feat(dashboard): global (All Libraries) preferences scope

The dashboard's All Libraries view could render sections across every
library but could not persist its own preferences: the PUT required a
UUID library_id, and the prefs GET-without-library_id matched an
arbitrary library's row through a loose NULL OR-clause.

Storage: a dedicated user_dashboard_preferences_global table (one row
per user, user_id PK → users CASCADE) instead of a NULL/sentinel
library_id — chosen over both because (a) the column's FK to libraries
rules out a sentinel UUID without dropping the constraint or minting a
fake library row, and (b) intent becomes structural: only the explicit
global path writes that table, so a buggy or outside-the-API write can
never masquerade as global. System and user collections are
user-scoped, so the order/hidden arrays are library-independent and
valid in this scope.

API: PUT /api/dashboard/preferences binds library_id as *string —
JSON null/absent = global, empty string = 400 (a lost value must stay
loud, not silently clobber the global scope), valid UUID = per-library
(unchanged). GET without library_id now returns the global row instead
of an arbitrary one; the per-library query's dead NULL OR-arm is
removed. GET /sections keeps requiring library_id for per-library
views and aggregates across libraries when absent (unchanged).

Tests: global save/update-dedupe/read round-trip, empty-string 400,
global-vs-per-library independence. Two suite failures are the
handoff's documented pre-existing rot (duplicate system collections in
the dev DB; a sections test expecting a 400 the handler never
implemented) — reproduced and confirmed unrelated to this change.
Live-verified on the dev stack: null save 200 x2 with a single row
updated, empty string 400, global read carries the second save.
This commit is contained in:
John O'Keefe committed 2026-10-07 19:46:17 -04:00
1 parent b8db1d109c
commit 25d44452c0
8 files changed
+304 -7

No files matched your search

+61 -1
View File
@@ -3276,7 +3276,7 @@ func (q *Queries) GetContinueSeriesItems(ctx context.Context, arg GetContinueSer
const GetDashboardPreferences = `-- name: GetDashboardPreferences :one
SELECT id, user_id, library_id, hidden_collections, collection_order, items_per_section, created_at, updated_at FROM user_dashboard_preferences
WHERE user_id = $1 AND ($2::uuid IS NULL OR library_id = $2::uuid)
WHERE user_id = $1 AND library_id = $2::uuid
`
type GetDashboardPreferencesParams struct {
@@ -3288,6 +3288,10 @@ type GetDashboardPreferencesParams struct {
// CAROUSEL-STYLE DASHBOARD
// ============================================
// Dashboard preferences queries
// The handler routes by scope: a Valid library_id targets the per-library
// table below; an absent one targets user_dashboard_preferences_global
// (the "All Libraries" view). Never pass NULL here — the loose NULL match
// was removed because it returned an arbitrary library's row.
func (q *Queries) GetDashboardPreferences(ctx context.Context, arg GetDashboardPreferencesParams) (UserDashboardPreferences, error) {
row := q.db.QueryRow(ctx, GetDashboardPreferences, arg.UserID, arg.LibraryID)
var i UserDashboardPreferences
@@ -3846,6 +3850,25 @@ func (q *Queries) GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (Get
return i, err
}
const GetGlobalDashboardPreferences = `-- name: GetGlobalDashboardPreferences :one
SELECT user_id, hidden_collections, collection_order, items_per_section, updated_at FROM user_dashboard_preferences_global
WHERE user_id = $1
`
// Global (all-libraries) scope: exactly one row per user.
func (q *Queries) GetGlobalDashboardPreferences(ctx context.Context, userID pgtype.UUID) (UserDashboardPreferencesGlobal, error) {
row := q.db.QueryRow(ctx, GetGlobalDashboardPreferences, userID)
var i UserDashboardPreferencesGlobal
err := row.Scan(
&i.UserID,
&i.HiddenCollections,
&i.CollectionOrder,
&i.ItemsPerSection,
&i.UpdatedAt,
)
return i, err
}
const GetHashConflict = `-- name: GetHashConflict :one
SELECT id, library_id, file_sha256, status, resolution, resolved_by, created_at, resolved_at FROM hash_conflicts WHERE id = $1
`
@@ -13839,6 +13862,43 @@ func (q *Queries) UpsertDashboardPreferences(ctx context.Context, arg UpsertDash
return i, err
}
const UpsertGlobalDashboardPreferences = `-- name: UpsertGlobalDashboardPreferences :one
INSERT INTO user_dashboard_preferences_global (user_id, hidden_collections, collection_order, items_per_section)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id)
DO UPDATE SET
hidden_collections = EXCLUDED.hidden_collections,
collection_order = EXCLUDED.collection_order,
items_per_section = EXCLUDED.items_per_section,
updated_at = NOW()
RETURNING user_id, hidden_collections, collection_order, items_per_section, updated_at
`
type UpsertGlobalDashboardPreferencesParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
HiddenCollections []string `db:"hidden_collections" json:"hidden_collections"`
CollectionOrder []string `db:"collection_order" json:"collection_order"`
ItemsPerSection pgtype.Int4 `db:"items_per_section" json:"items_per_section"`
}
func (q *Queries) UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error) {
row := q.db.QueryRow(ctx, UpsertGlobalDashboardPreferences,
arg.UserID,
arg.HiddenCollections,
arg.CollectionOrder,
arg.ItemsPerSection,
)
var i UserDashboardPreferencesGlobal
err := row.Scan(
&i.UserID,
&i.HiddenCollections,
&i.CollectionOrder,
&i.ItemsPerSection,
&i.UpdatedAt,
)
return i, err
}
const UpsertPanelData = `-- name: UpsertPanelData :one
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
VALUES ($1, $2, $3, $4)