diff --git a/cmd/server/tests/dashboard_integration_test.go b/cmd/server/tests/dashboard_integration_test.go index 9235a7a7..b656b020 100644 --- a/cmd/server/tests/dashboard_integration_test.go +++ b/cmd/server/tests/dashboard_integration_test.go @@ -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)) } diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 0dda786e..b27cd7dd 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -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(), diff --git a/internal/database/models.go b/internal/database/models.go index 480f944a..4b524bf7 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -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"` diff --git a/internal/database/querier.go b/internal/database/querier.go index 5b2575da..984cfcab 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -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) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 45c312a8..556b67b5 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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) diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index e2529cf5..c93feeb6 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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 diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index c163d339..00242ad0 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -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}, diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index 193b5452..6972e436 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -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) }