feat(calendar): add per-profile followed/favorites/watchlist/watched resolvers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Quick
2026-05-29 13:50:48 -04:00
co-authored by Claude Opus 4.8
parent 62b3861da0
commit 1ba76af353
2 changed files with 131 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
package catalog
import (
"context"
"fmt"
)
// Per-profile id-set queries backing the calendar presets. All run on the same
// pool as the base calendar query. The watched-series rollup intentionally
// mirrors recommendations.GetPopularItems (COALESCE(e.series_id, wp.media_item_id))
// so "engaged with a series" means the same thing everywhere; it is a one-line
// SQL expression, not worth a cross-package extraction.
const (
followedItemIDsQuery = `
SELECT media_item_id FROM user_favorites WHERE user_id = $1 AND profile_id = $2
UNION
SELECT media_item_id FROM user_watchlist WHERE user_id = $1 AND profile_id = $2
UNION
SELECT DISTINCT COALESCE(e.series_id, wp.media_item_id)
FROM user_watch_progress wp
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
WHERE wp.user_id = $1 AND wp.profile_id = $2`
favoriteItemIDsQuery = `SELECT media_item_id FROM user_favorites WHERE user_id = $1 AND profile_id = $2`
watchlistItemIDsQuery = `SELECT media_item_id FROM user_watchlist WHERE user_id = $1 AND profile_id = $2`
watchedItemIDsQuery = `
SELECT media_item_id
FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND completed = true AND media_item_id = ANY($3)`
)
// ListFollowedItemIDs returns the profile's followed set: favorited ∪ watchlisted ∪
// any series/movie they have watch progress on.
func (r *CalendarRepository) ListFollowedItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) {
return r.queryIDs(ctx, followedItemIDsQuery, userID, profileID)
}
// ListFavoriteItemIDs returns the profile's favorited content ids.
func (r *CalendarRepository) ListFavoriteItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) {
return r.queryIDs(ctx, favoriteItemIDsQuery, userID, profileID)
}
// ListWatchlistItemIDs returns the profile's watchlisted content ids.
func (r *CalendarRepository) ListWatchlistItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) {
return r.queryIDs(ctx, watchlistItemIDsQuery, userID, profileID)
}
func (r *CalendarRepository) queryIDs(ctx context.Context, query string, userID int, profileID string) ([]string, error) {
rows, err := r.pool.Query(ctx, query, userID, profileID)
if err != nil {
return nil, fmt.Errorf("calendar personal query: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scanning personal id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ListWatchedItemIDs returns the subset of contentIDs the profile has completed,
// as a set. An empty input returns an empty set without querying.
func (r *CalendarRepository) ListWatchedItemIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]bool, error) {
watched := make(map[string]bool, len(contentIDs))
if len(contentIDs) == 0 {
return watched, nil
}
rows, err := r.pool.Query(ctx, watchedItemIDsQuery, userID, profileID, contentIDs)
if err != nil {
return nil, fmt.Errorf("calendar watched query: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scanning watched id: %w", err)
}
watched[id] = true
}
return watched, rows.Err()
}
@@ -0,0 +1,44 @@
package catalog
import (
"strings"
"testing"
)
func TestFollowedItemIDsQuery_UnionsAllSignals(t *testing.T) {
for _, fragment := range []string{
"FROM user_favorites",
"FROM user_watchlist",
"user_watch_progress wp",
"LEFT JOIN episodes e ON e.content_id = wp.media_item_id",
"COALESCE(e.series_id, wp.media_item_id)",
"UNION",
} {
if !strings.Contains(followedItemIDsQuery, fragment) {
t.Fatalf("followedItemIDsQuery missing %q:\n%s", fragment, followedItemIDsQuery)
}
}
}
func TestWatchedItemIDsQuery_FiltersCompletedWithinSet(t *testing.T) {
for _, fragment := range []string{
"FROM user_watch_progress",
"completed = true",
"media_item_id = ANY($3)",
} {
if !strings.Contains(watchedItemIDsQuery, fragment) {
t.Fatalf("watchedItemIDsQuery missing %q:\n%s", fragment, watchedItemIDsQuery)
}
}
}
func TestFavoriteAndWatchlistQueries_ScopeToProfile(t *testing.T) {
if !strings.Contains(favoriteItemIDsQuery, "FROM user_favorites") ||
!strings.Contains(favoriteItemIDsQuery, "profile_id = $2") {
t.Fatalf("favoriteItemIDsQuery wrong:\n%s", favoriteItemIDsQuery)
}
if !strings.Contains(watchlistItemIDsQuery, "FROM user_watchlist") ||
!strings.Contains(watchlistItemIDsQuery, "profile_id = $2") {
t.Fatalf("watchlistItemIDsQuery wrong:\n%s", watchlistItemIDsQuery)
}
}