Skip to content

Commit

Permalink
feat: layer 1 desident dashboard, organize favorites by content type
Browse files Browse the repository at this point in the history
  • Loading branch information
carddev81 authored Jan 1, 2025
1 parent 8263af4 commit 30f12f2
Show file tree
Hide file tree
Showing 13 changed files with 369 additions and 41 deletions.
11 changes: 11 additions & 0 deletions backend/migrations/00029_alter_open_content_favorites_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE public.open_content_favorites DROP COLUMN updated_at;
ALTER TABLE public.open_content_favorites DROP COLUMN deleted_at;
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
ALTER TABLE public.open_content_favorites ADD COLUMN updated_at timestamp with time zone;
ALTER TABLE public.open_content_favorites ADD COLUMN deleted_at timestamp with time zone;
-- +goose StatementEnd
2 changes: 1 addition & 1 deletion backend/src/database/helpful_links.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func (db *DB) GetHelpfulLinks(page, perPage int, search, orderBy string, onlyVis

subQuery := db.Table("open_content_favorites f").
Select("1").
Where("f.content_id = helpful_links.id AND f.user_id = ? AND f.deleted_at IS NULL", userID)
Where("f.content_id = helpful_links.id AND f.user_id = ?", userID)
tx := db.Model(&models.HelpfulLink{}).Select("helpful_links.*, EXISTS(?) as is_favorited", subQuery)
var total int64

Expand Down
3 changes: 0 additions & 3 deletions backend/src/database/libraries.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ func (db *DB) GetAllLibraries(page, perPage int, userId, facilityId uint, visibi
WHERE f.content_id = libraries.id
AND f.open_content_provider_id = libraries.open_content_provider_id
AND f.user_id = ?
AND f.deleted_at IS NULL
) AS is_favorited`, userId)

visibility = strings.ToLower(visibility)
Expand Down Expand Up @@ -65,7 +64,6 @@ func (db *DB) GetAllLibraries(page, perPage int, userId, facilityId uint, visibi
WHERE f.content_id = libraries.id
AND f.open_content_provider_id = libraries.open_content_provider_id
AND f.user_id = ?
AND f.deleted_at IS NULL
) AS is_favorited`, userId)
if !isFeatured {
tx = tx.Joins(`LEFT JOIN open_content_favorites f
Expand All @@ -84,7 +82,6 @@ func (db *DB) GetAllLibraries(page, perPage int, userId, facilityId uint, visibi
WHERE f.content_id = libraries.id
AND f.open_content_provider_id = libraries.open_content_provider_id
AND f.user_id = ?
AND f.deleted_at IS NULL
) AS is_favorited`, userId)
if !isFeatured {
tx = tx.Joins(`LEFT JOIN open_content_favorites f
Expand Down
131 changes: 116 additions & 15 deletions backend/src/database/open_content.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,24 +43,120 @@ func (db *DB) CreateContentActivity(urlString string, activity *models.OpenConte
}
}

// This method will at most ever return the most recent 30 favorites (10 Libraries, 10 Videos, 10 Helpful Links)
func (db *DB) GetUserFavoriteGroupings(userID uint) ([]models.OpenContentItem, error) {
favorites := make([]models.OpenContentItem, 0, 30)
favoritesQuery := `WITH ordered_libraries AS (
SELECT
'library' AS content_type,
f.content_id,
lib.title,
lib.url,
lib.thumbnail_url,
ocp.description,
lib.visibility_status,
lib.open_content_provider_id,
ocp.title AS provider_name,
NULL AS channel_title,
f.created_at,
ROW_NUMBER() OVER (ORDER BY f.created_at DESC) AS row_num
FROM open_content_favorites f
JOIN open_content_providers ocp ON ocp.id = f.open_content_provider_id
AND ocp.currently_enabled = TRUE
AND ocp.deleted_at IS NULL
JOIN libraries lib ON lib.open_content_provider_id = ocp.id
AND lib.id = f.content_id
WHERE f.user_id = ?
AND f.content_id IN (SELECT id FROM libraries where visibility_status = true)
),
ordered_videos AS (
SELECT
'video' AS content_type,
f.content_id,
videos.title,
videos.url,
videos.thumbnail_url,
videos.description,
videos.visibility_status,
videos.open_content_provider_id,
NULL AS provider_name,
videos.channel_title,
f.created_at,
ROW_NUMBER() OVER (ORDER BY f.created_at DESC) AS row_num
FROM open_content_favorites f
JOIN open_content_providers ocp ON ocp.id = f.open_content_provider_id
AND ocp.currently_enabled = TRUE
AND ocp.deleted_at IS NULL
JOIN videos ON videos.open_content_provider_id = ocp.id
AND videos.id = f.content_id
WHERE f.user_id = ?
AND f.content_id IN (SELECT id FROM videos where visibility_status = true AND availability = 'available')
),
ordered_helpful_links AS (
SELECT
'helpful_link' AS content_type,
f.content_id AS content_id,
hl.title,
hl.url,
hl.thumbnail_url,
hl.description,
hl.visibility_status,
hl.open_content_provider_id AS open_content_provider_id,
NULL AS provider_name,
NULL AS channel_title,
f.created_at,
ROW_NUMBER() OVER (ORDER BY f.created_at DESC) AS row_num
FROM open_content_favorites f
JOIN open_content_providers ocp ON ocp.id = f.open_content_provider_id
AND ocp.currently_enabled = TRUE
AND ocp.deleted_at IS NULL
JOIN helpful_links hl ON hl.open_content_provider_id = ocp.id
AND hl.id = f.content_id
WHERE f.user_id = ?
AND f.content_id IN (SELECT id from helpful_links WHERE visibility_status = true)
)
SELECT
content_type,
content_id,
title,
url,
thumbnail_url,
description,
visibility_status,
open_content_provider_id,
provider_name,
channel_title,
created_at
FROM (
SELECT * FROM ordered_libraries WHERE row_num <= 10
UNION ALL
SELECT * FROM ordered_videos WHERE row_num <= 10
UNION ALL
SELECT * FROM ordered_helpful_links WHERE row_num <= 10
)`
if err := db.Raw(favoritesQuery, userID, userID, userID).Scan(&favorites).Error; err != nil {
return nil, err
}
return favorites, nil
}

func (db *DB) GetUserFavorites(userID uint, page, perPage int) (int64, []models.OpenContentItem, error) {
var total int64
countQuery := `SELECT COUNT(*) FROM (
SELECT fav.content_id
FROM open_content_favorites fav
JOIN libraries lib ON lib.id = fav.content_id
JOIN open_content_providers ocp ON ocp.id = lib.open_content_provider_id
AND ocp.currently_enabled = true
AND ocp.currently_enabled = true
AND ocp.deleted_at IS NULL
WHERE fav.user_id = ? AND fav.deleted_at IS NULL
WHERE fav.user_id = ?
) AS total_favorites
`
if err := db.Raw(countQuery, userID).Scan(&total).Error; err != nil {
return 0, nil, err
}

favorites := make([]models.OpenContentItem, 0, perPage)
favoritesQuery := `SELECT
favoritesQuery := `SELECT
content_type,
content_id,
title,
Expand All @@ -86,11 +182,12 @@ func (db *DB) GetUserFavorites(userID uint, page, perPage int) (int64, []models.
NULL AS channel_title,
f.created_at
FROM open_content_favorites f
JOIN libraries lib ON lib.id = f.content_id
JOIN open_content_providers ocp ON ocp.id = lib.open_content_provider_id
JOIN open_content_providers ocp ON ocp.id = f.open_content_provider_id
AND ocp.currently_enabled = TRUE
AND ocp.deleted_at IS NULL
WHERE f.user_id = ? AND f.deleted_at IS NULL
JOIN libraries lib ON lib.open_content_provider_id = ocp.id
AND lib.id = f.content_id
WHERE f.user_id = ?
AND f.content_id IN (SELECT id FROM libraries where visibility_status = true)
UNION ALL
Expand All @@ -107,13 +204,14 @@ func (db *DB) GetUserFavorites(userID uint, page, perPage int) (int64, []models.
videos.channel_title,
f.created_at
FROM open_content_favorites f
JOIN videos ON videos.id = f.content_id
JOIN open_content_providers ocp ON ocp.id = videos.open_content_provider_id
JOIN open_content_providers ocp ON ocp.id = f.open_content_provider_id
AND ocp.currently_enabled = TRUE
AND ocp.deleted_at IS NULL
WHERE f.user_id = ? AND f.deleted_at IS NULL
JOIN videos ON videos.open_content_provider_id = ocp.id
AND videos.id = f.content_id
WHERE f.user_id = ?
AND f.content_id IN (SELECT id FROM videos where visibility_status = true AND availability = 'available')
UNION ALL
UNION ALL
SELECT
'helpful_link' AS content_type,
Expand All @@ -128,16 +226,19 @@ func (db *DB) GetUserFavorites(userID uint, page, perPage int) (int64, []models.
NULL AS channel_title,
f.created_at
FROM open_content_favorites f
JOIN helpful_links hl ON hl.id = f.content_id
WHERE f.user_id = ? AND f.deleted_at IS NULL
AND f.content_id IN (SELECT id from helpful_links WHERE visibility_status = true)
JOIN open_content_providers ocp ON ocp.id = f.open_content_provider_id
AND ocp.currently_enabled = TRUE
AND ocp.deleted_at IS NULL
JOIN helpful_links hl ON hl.open_content_provider_id = ocp.id
AND hl.id = f.content_id
WHERE f.user_id = ?
AND f.content_id IN (SELECT id from helpful_links WHERE visibility_status = true)
) AS all_favorites
ORDER BY created_at DESC
LIMIT ? OFFSET ?`
if err := db.Raw(favoritesQuery, userID, userID, userID, perPage, calcOffset(page, perPage)).Scan(&favorites).Error; err != nil {
return 0, nil, err
}

return total, favorites, nil
}

Expand Down
21 changes: 17 additions & 4 deletions backend/src/database/videos.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func (db *DB) FavoriteOpenContent(contentID int, ocpID uint, userID uint, facili
UserID: userID,
FacilityID: facilityID,
}

//use Unscoped method to ignore soft deletions
tx := db.Where("content_id = ? AND open_content_provider_id = ?", contentID, ocpID)
if facilityID != nil {
tx = tx.Where("facility_id = ?", facilityID)
Expand All @@ -49,9 +49,22 @@ func (db *DB) FavoriteOpenContent(contentID int, ocpID uint, userID uint, facili
}
}

func (db *DB) GetAllVideos(onlyVisible bool, page, perPage int, search, orderBy string, userID uint) (int64, []models.Video, error) {
var videos []models.Video
tx := db.Model(&models.Video{}).Preload("Attempts")
type VideoResponse struct {
models.Video
IsFavorited bool `json:"is_favorited"`
}

func (db *DB) GetAllVideos(onlyVisible bool, page, perPage int, search, orderBy string, userID uint) (int64, []VideoResponse, error) {
var videos []VideoResponse
tx := db.Model(&models.Video{}).Preload("Attempts").Select(`
videos.*,
EXISTS (
SELECT 1
FROM open_content_favorites f
WHERE f.content_id = videos.id
AND f.open_content_provider_id = videos.open_content_provider_id
AND f.user_id = ?
) AS is_favorited`, userID)
var total int64
validOrder := map[string]bool{
"title ASC": true,
Expand Down
11 changes: 10 additions & 1 deletion backend/src/handlers/open_content_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import (
func (srv *Server) registerOpenContentRoutes() []routeDef {
axx := models.Feature(models.OpenContentAccess)
return []routeDef{
{"GET /api/open-content/favorites", srv.handleGetUserFavoriteOpenContent, false, axx},
{"GET /api/open-content", srv.handleIndexOpenContent, false, axx},
{"GET /api/open-content/favorites", srv.handleGetUserFavoriteOpenContent, false, axx},
{"GET /api/open-content/favorite-groupings", srv.handleGetUserFavoriteOpenContentGroupings, false, axx},
}
}

Expand All @@ -36,3 +37,11 @@ func (srv *Server) handleGetUserFavoriteOpenContent(w http.ResponseWriter, r *ht
meta := models.NewPaginationInfo(page, perPage, total)
return writePaginatedResponse(w, http.StatusOK, favorites, meta)
}

func (srv *Server) handleGetUserFavoriteOpenContentGroupings(w http.ResponseWriter, r *http.Request, log sLog) error {
favorites, err := srv.Db.GetUserFavoriteGroupings(srv.getUserID(r))
if err != nil {
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, favorites)
}
110 changes: 110 additions & 0 deletions backend/src/handlers/open_content_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package handlers

import (
"UnlockEdv2/src/models"
"encoding/json"
"net/http"
"slices"
"testing"
)

func TestHandleIndexOpenContent(t *testing.T) {
httpTests := []httpTest{
{"TestIndexOpenContentAsAdmin", "admin", nil, http.StatusOK, ""},
{"TestIndexOpenContentAsUser", "student", nil, http.StatusOK, ""},
}
for _, test := range httpTests {
t.Run(test.testName, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/api/open-content", nil)
if err != nil {
t.Fatalf("unable to create new request, error is %v", err)
}
handler := getHandlerByRole(server.handleIndexOpenContent, test.role)
rr := executeRequest(t, req, handler, test)
var all bool
if test.role == "admin" {
all = true
}
openContentProviders, err := server.Db.GetOpenContent(all)
if err != nil {
t.Fatalf("unable to get open content from db, error is %v", err)
}
data := models.Resource[[]models.OpenContentProvider]{}
received := rr.Body.String()
if err = json.Unmarshal([]byte(received), &data); err != nil {
t.Errorf("failed to unmarshal resource, error is %v", err)
}
for _, provider := range openContentProviders {
if !slices.ContainsFunc(data.Data, func(ocProvider models.OpenContentProvider) bool {
return ocProvider.ID == provider.ID
}) {
t.Error("providers not found, out of sync")
}
}
})
}
}

func TestHandleGetUserFavoriteOpenContentGroupings(t *testing.T) {
httpTests := []httpTest{
{"TestGetUserFavoriteOpenContentGroupingsAsUser", "student", map[string]any{"user_id": uint(4)}, http.StatusOK, ""},
}
for _, test := range httpTests {
t.Run(test.testName, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/api/open-content/favorite-groupings", nil)
if err != nil {
t.Fatalf("unable to create new request, error is %v", err)
}
handler := getHandlerByRole(server.handleGetUserFavoriteOpenContentGroupings, test.role)
rr := executeRequest(t, req, handler, test)
contentItems, err := server.Db.GetUserFavoriteGroupings(test.mapKeyValues["user_id"].(uint))
if err != nil {
t.Fatalf("unable to get open content items from db, error is %v", err)
}
data := models.Resource[[]models.OpenContentItem]{}
received := rr.Body.String()
if err = json.Unmarshal([]byte(received), &data); err != nil {
t.Errorf("failed to unmarshal resource, error is %v", err)
}
for _, contentItem := range contentItems {
if !slices.ContainsFunc(data.Data, func(item models.OpenContentItem) bool {
return item.ContentId == contentItem.ContentId
}) {
t.Error("open content favorites not found, out of sync")
}
}
})
}
}

func TestHandleGetUserFavoriteOpenContent(t *testing.T) {
httpTests := []httpTest{
{"TestGetUserFavoriteOpenContentUser", "student", map[string]any{"user_id": uint(4), "page": 1, "per_page": 10}, http.StatusOK, ""},
}
for _, test := range httpTests {
t.Run(test.testName, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/api/open-content/favorites", nil)
if err != nil {
t.Fatalf("unable to create new request, error is %v", err)
}
handler := getHandlerByRole(server.handleGetUserFavoriteOpenContent, test.role)
rr := executeRequest(t, req, handler, test)
_, favorites, err := server.Db.GetUserFavorites(test.mapKeyValues["user_id"].(uint), test.mapKeyValues["page"].(int), test.mapKeyValues["per_page"].(int))
if err != nil {
t.Fatalf("unable to get user favorites from db, error is %v", err)
}
data := models.PaginatedResource[models.OpenContentItem]{}
received := rr.Body.String()
if err = json.Unmarshal([]byte(received), &data); err != nil {
t.Errorf("failed to unmarshal resource, error is %v", err)
}
for _, favorite := range favorites {
if !slices.ContainsFunc(data.Data, func(item models.OpenContentItem) bool {
return favorite.ContentId == item.ContentId
}) {
t.Error("favorites not found, out of sync")
}
}
})
}
}
Loading

0 comments on commit 30f12f2

Please sign in to comment.