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:
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) {
|
func TestDashboardIntegrationTestSuite(t *testing.T) {
|
||||||
suite.Run(t, new(DashboardIntegrationTestSuite))
|
suite.Run(t, new(DashboardIntegrationTestSuite))
|
||||||
}
|
}
|
||||||
@@ -1160,6 +1160,20 @@ CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
|
|||||||
-- Index for fast lookups
|
-- Index for fast lookups
|
||||||
CREATE INDEX IF NOT EXISTS idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
|
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 collection_items table (which books belong to each collection)
|
||||||
CREATE TABLE IF NOT EXISTS collection_items (
|
CREATE TABLE IF NOT EXISTS collection_items (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
@@ -538,6 +538,14 @@ type UserDashboardPreferences struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
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 {
|
type Users struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
Email string `db:"email" json:"email"`
|
Email string `db:"email" json:"email"`
|
||||||
|
|||||||
@@ -174,6 +174,10 @@ type Querier interface {
|
|||||||
// CAROUSEL-STYLE DASHBOARD
|
// CAROUSEL-STYLE DASHBOARD
|
||||||
// ============================================
|
// ============================================
|
||||||
// Dashboard preferences queries
|
// 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)
|
GetDashboardPreferences(ctx context.Context, arg GetDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error)
|
GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error)
|
||||||
GetDeviceByAuthToken(ctx context.Context, authToken string) (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)
|
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||||
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
||||||
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, 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)
|
GetHashConflict(ctx context.Context, id pgtype.UUID) (HashConflicts, error)
|
||||||
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
|
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
|
||||||
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
|
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
|
||||||
@@ -503,6 +509,7 @@ type Querier interface {
|
|||||||
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
||||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, 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)
|
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||||
|
|||||||
@@ -3276,7 +3276,7 @@ func (q *Queries) GetContinueSeriesItems(ctx context.Context, arg GetContinueSer
|
|||||||
const GetDashboardPreferences = `-- name: GetDashboardPreferences :one
|
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
|
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 {
|
type GetDashboardPreferencesParams struct {
|
||||||
@@ -3288,6 +3288,10 @@ type GetDashboardPreferencesParams struct {
|
|||||||
// CAROUSEL-STYLE DASHBOARD
|
// CAROUSEL-STYLE DASHBOARD
|
||||||
// ============================================
|
// ============================================
|
||||||
// Dashboard preferences queries
|
// 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) {
|
func (q *Queries) GetDashboardPreferences(ctx context.Context, arg GetDashboardPreferencesParams) (UserDashboardPreferences, error) {
|
||||||
row := q.db.QueryRow(ctx, GetDashboardPreferences, arg.UserID, arg.LibraryID)
|
row := q.db.QueryRow(ctx, GetDashboardPreferences, arg.UserID, arg.LibraryID)
|
||||||
var i UserDashboardPreferences
|
var i UserDashboardPreferences
|
||||||
@@ -3846,6 +3850,25 @@ func (q *Queries) GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (Get
|
|||||||
return i, err
|
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
|
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
|
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
|
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
|
const UpsertPanelData = `-- name: UpsertPanelData :one
|
||||||
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
|
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
|
|||||||
@@ -2435,9 +2435,13 @@ LIMIT $1 OFFSET $2;
|
|||||||
-- ============================================
|
-- ============================================
|
||||||
|
|
||||||
-- Dashboard preferences queries
|
-- 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
|
-- name: GetDashboardPreferences :one
|
||||||
SELECT * FROM user_dashboard_preferences
|
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
|
-- name: UpsertDashboardPreferences :one
|
||||||
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
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
|
WHERE user_id = $1 AND library_id = $5
|
||||||
RETURNING *;
|
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
|
-- Dashboard collections queries
|
||||||
-- name: GetSystemCollectionsForDashboard :many
|
-- name: GetSystemCollectionsForDashboard :many
|
||||||
SELECT * FROM collections
|
SELECT * FROM collections
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
|
|||||||
userUUID := uuid.UUID(user.ID.Bytes)
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
|
||||||
var req struct {
|
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"`
|
HiddenCollections []string `json:"hidden_collections"`
|
||||||
CollectionOrder []string `json:"collection_order"`
|
CollectionOrder []string `json:"collection_order"`
|
||||||
ItemsPerSection int `json:"items_per_section"`
|
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"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||||||
}
|
}
|
||||||
|
|
||||||
libUUID, err := uuid.Parse(req.LibraryID)
|
var libUUID pgtype.UUID
|
||||||
if err != nil {
|
if req.LibraryID != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
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)
|
// 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{
|
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
LibraryID: libUUID,
|
||||||
HiddenCollections: req.HiddenCollections,
|
HiddenCollections: req.HiddenCollections,
|
||||||
CollectionOrder: req.CollectionOrder,
|
CollectionOrder: req.CollectionOrder,
|
||||||
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
|
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
|
||||||
|
|||||||
@@ -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) {
|
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{
|
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
LibraryID: libraryID,
|
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) {
|
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)
|
return s.db.UpsertDashboardPreferences(ctx, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user