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

@@ -258,6 +258,151 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames()
}
}
func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_GlobalScope() {
token := s.setup.Token
// Absent library_id (JSON null after omit) = the global All Libraries
// scope: stored in user_dashboard_preferences_global, one row per user.
reqBody := map[string]interface{}{
"hidden_collections": []string{"not-started"},
"collection_order": []string{"continue-reading", "recently-added"},
"items_per_section": 15,
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Second global save with different values: must UPDATE the same row
// (the old ON CONFLICT (user_id, library_id) never fired for NULLs and
// would have minted a duplicate per save).
reqBody["items_per_section"] = 25
body, _ = json.Marshal(reqBody)
req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Reading prefs without library_id returns the global row (not some
// arbitrary library's).
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var prefs map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&prefs)
require.NoError(s.T(), err)
assert.Equal(s.T(), float64(25), prefs["items_per_section"], "global row should carry the second save")
}
func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_EmptyLibraryIDRejected() {
token := s.setup.Token
// Empty string is a LOST value (a client bug), never a scope — must
// stay a loud 400 rather than silently writing the global scope.
reqBody := map[string]interface{}{
"library_id": "",
"hidden_collections": []string{},
"collection_order": []string{},
"items_per_section": 20,
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
}
func (s *DashboardIntegrationTestSuite) TestGetPreferences_GlobalAndPerLibraryIndependent() {
token := s.setup.Token
libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Prefs Scope Library", false)
// Global scope: 15.
globalBody, _ := json.Marshal(map[string]interface{}{
"hidden_collections": []string{},
"collection_order": []string{},
"items_per_section": 15,
})
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(globalBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Per-library scope: 30. Must not bleed into global or vice versa.
libBody, _ := json.Marshal(map[string]interface{}{
"library_id": libraryID,
"hidden_collections": []string{},
"collection_order": []string{},
"items_per_section": 30,
})
req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(libBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Per-library read.
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
var libPrefs map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&libPrefs)
require.NoError(s.T(), err)
assert.Equal(s.T(), float64(30), libPrefs["items_per_section"], "per-library scope should read its own row")
// Global read.
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
var globalPrefs map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&globalPrefs)
require.NoError(s.T(), err)
assert.Equal(s.T(), float64(15), globalPrefs["items_per_section"], "global scope should stay independent")
}
func TestDashboardIntegrationTestSuite(t *testing.T) {
suite.Run(t, new(DashboardIntegrationTestSuite))
}
+14
View File
@@ -1160,6 +1160,20 @@ CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
-- Index for fast lookups
CREATE INDEX IF NOT EXISTS idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
-- Global (all-libraries) dashboard preferences: exactly one row per user.
-- Backs the dashboard's "All Libraries" view (library_id NULL convention
-- rejected: the upsert's ON CONFLICT cannot dedupe NULLs and intent would
-- be invisible — a dedicated table makes the scope structural). System
-- and user collections are user-scoped, so collection_order/
-- hidden_collections IDs are library-independent and valid in this scope.
CREATE TABLE IF NOT EXISTS user_dashboard_preferences_global (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
hidden_collections TEXT[] DEFAULT '{}',
collection_order TEXT[] DEFAULT '{}',
items_per_section INT DEFAULT 20,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create collection_items table (which books belong to each collection)
CREATE TABLE IF NOT EXISTS collection_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+8
View File
@@ -538,6 +538,14 @@ type UserDashboardPreferences struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type UserDashboardPreferencesGlobal 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"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type Users struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
+7
View File
@@ -174,6 +174,10 @@ type Querier interface {
// 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.
GetDashboardPreferences(ctx context.Context, arg GetDashboardPreferencesParams) (UserDashboardPreferences, error)
GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error)
GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error)
@@ -200,6 +204,8 @@ type Querier interface {
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
// Global (all-libraries) scope: exactly one row per user.
GetGlobalDashboardPreferences(ctx context.Context, userID pgtype.UUID) (UserDashboardPreferencesGlobal, error)
GetHashConflict(ctx context.Context, id pgtype.UUID) (HashConflicts, error)
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
@@ -503,6 +509,7 @@ type Querier interface {
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
+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)
+21 -1
View File
@@ -2435,9 +2435,13 @@ LIMIT $1 OFFSET $2;
-- ============================================
-- 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.
-- name: GetDashboardPreferences :one
SELECT * FROM user_dashboard_preferences
WHERE user_id = sqlc.narg('user_id') AND (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid);
WHERE user_id = sqlc.narg('user_id') AND library_id = sqlc.narg('library_id')::uuid;
-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
@@ -2459,6 +2463,22 @@ SET hidden_collections = $2,
WHERE user_id = $1 AND library_id = $5
RETURNING *;
-- Global (all-libraries) scope: exactly one row per user.
-- name: GetGlobalDashboardPreferences :one
SELECT user_id, hidden_collections, collection_order, items_per_section, updated_at FROM user_dashboard_preferences_global
WHERE user_id = $1;
-- 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;
-- Dashboard collections queries
-- name: GetSystemCollectionsForDashboard :many
SELECT * FROM collections
+14 -5
View File
@@ -75,7 +75,9 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
LibraryID string `json:"library_id"`
// nil (JSON null/absent) = the global All Libraries scope;
// empty string is a LOST value, not a scope — rejected loudly.
LibraryID *string `json:"library_id"`
HiddenCollections []string `json:"hidden_collections"`
CollectionOrder []string `json:"collection_order"`
ItemsPerSection int `json:"items_per_section"`
@@ -85,9 +87,16 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
libUUID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
var libUUID pgtype.UUID
if req.LibraryID != nil {
if *req.LibraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
parsed, err := uuid.Parse(*req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
// Sanitize preferences (remove duplicates)
@@ -97,7 +106,7 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
LibraryID: libUUID,
HiddenCollections: req.HiddenCollections,
CollectionOrder: req.CollectionOrder,
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
+34
View File
@@ -376,6 +376,22 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
}
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
// Absent library = the global (All Libraries) scope: a separate
// one-row-per-user table, so an accidental per-library write can never
// masquerade as global.
if !libraryID.Valid {
g, err := s.db.GetGlobalDashboardPreferences(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return database.UserDashboardPreferences{}, err
}
return database.UserDashboardPreferences{
UserID: g.UserID,
HiddenCollections: g.HiddenCollections,
CollectionOrder: g.CollectionOrder,
ItemsPerSection: g.ItemsPerSection,
UpdatedAt: g.UpdatedAt,
}, nil
}
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: libraryID,
@@ -383,6 +399,24 @@ func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID u
}
func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) {
if !params.LibraryID.Valid {
g, err := s.db.UpsertGlobalDashboardPreferences(ctx, database.UpsertGlobalDashboardPreferencesParams{
UserID: params.UserID,
HiddenCollections: params.HiddenCollections,
CollectionOrder: params.CollectionOrder,
ItemsPerSection: params.ItemsPerSection,
})
if err != nil {
return database.UserDashboardPreferences{}, err
}
return database.UserDashboardPreferences{
UserID: g.UserID,
HiddenCollections: g.HiddenCollections,
CollectionOrder: g.CollectionOrder,
ItemsPerSection: g.ItemsPerSection,
UpdatedAt: g.UpdatedAt,
}, nil
}
return s.db.UpsertDashboardPreferences(ctx, params)
}