feat(server): system collections become permanent — soft-delete + in-place restore + query_type-keyed provisioning

The old model hard-deleted system collections and re-created them at
the tail, resurrecting stale dedups and breaking ordering. Redesign
(user-directed B-final):

- schema.sql: collections.deleted_at TIMESTAMPTZ (idempotent ALTER —
  the whole schema is re-applied on every boot via executeSchema).
- queries.sql: every collection list/read query filters
  deleted_at IS NULL; new GetSystemCollectionByQueryType :one
  (unfiltered, sees tombstones), TombstoneSystemCollection :exec,
  ClearCollectionItems :exec, RestoreSystemCollectionRow :one
  (in-place metadata reset + un-tombstone; collections have no
  updated_at column — ResetUpdatedAt was a 500 caught by tests).
  sqlc regenerated.
- collection_service.DeleteCollection: system collections are
  tombstoned (preserving position/ownership); user collections keep
  the hard delete. No ErrNoRows to the client (delete is idempotent).
- dashboard_service.RestoreSystemCollection: resolve by query_type;
  found → in-place restore (ClearCollectionItems + metadata reset,
  un-tombstoned); missing → provision fresh. "Want to Read"/etc.
  stay at their home positions forever.
- handlers/collections.CreateCollection: name-taken is 409, not 500.
- handlers/auth.CreateDefaultCollectionsForUser: idempotent by
  (user_id, query_type) — update-in-place when a row for that query
  type exists (heals renames/tombstones), insert when missing; this
  also fixes the duplicate-system-collections regression at source.
This commit is contained in:
John O'Keefe committed 2026-10-09 11:04:24 -04:00
1 parent 31f939002f
commit 7ce73213ae
9 files changed
+284 -39

No files matched your search

+6
View File
@@ -1135,9 +1135,15 @@ CREATE TABLE IF NOT EXISTS collections (
priority INT DEFAULT 100, priority INT DEFAULT 100,
is_system_collection BOOLEAN DEFAULT false, is_system_collection BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
UNIQUE(user_id, name) UNIQUE(user_id, name)
); );
-- System collections (the 5 dashboard sections) are permanent: "delete"
-- tombstones the row (deleted_at) and restore un-tombstones it in place —
-- the row id (and everything referencing it) never churn.
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id); CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
-- Index for dashboard queries -- Index for dashboard queries
+1
View File
@@ -31,6 +31,7 @@ type Collections struct {
Priority pgtype.Int4 `db:"priority" json:"priority"` Priority pgtype.Int4 `db:"priority" json:"priority"`
IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"` IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
} }
type DeviceCatalogs struct { type DeviceCatalogs struct {
+11
View File
@@ -30,6 +30,7 @@ type Querier interface {
// Cleanup expired OPDS tokens // Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error CleanupExpiredOpdsTokens(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
ClearCollectionItems(ctx context.Context, collectionID pgtype.UUID) error
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
@@ -305,6 +306,12 @@ type Querier interface {
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error) GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error) GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error)
GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (GetSyncQueueStatsRow, error) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (GetSyncQueueStatsRow, error)
// System collections are permanent: identity is (user_id, query_type).
// Restore/reset operate IN PLACE (the row id — and everything referencing
// it: shelf mappings, prefs order, links — never churns). Deleted sections
// are TOMBSTONED (deleted_at) and stay invisible until restored.
// Unfiltered by design: restore/seed must resolve tombstoned rows too.
GetSystemCollectionByQueryType(ctx context.Context, arg GetSystemCollectionByQueryTypeParams) (Collections, error)
// Dashboard collections queries // Dashboard collections queries
GetSystemCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error) GetSystemCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
// SYSTEM CONFIG QUERIES // SYSTEM CONFIG QUERIES
@@ -425,6 +432,9 @@ type Querier interface {
RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error) RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error)
RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error) RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error)
RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error) RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error)
// Full reset, in place: canonical metadata, back on the dashboard,
// un-tombstoned. Item assignments are cleared by the caller ("full").
RestoreSystemCollectionRow(ctx context.Context, arg RestoreSystemCollectionRowParams) (Collections, error)
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
RevokeDevice(ctx context.Context, id pgtype.UUID) error RevokeDevice(ctx context.Context, id pgtype.UUID) error
// Revoke OPDS token // Revoke OPDS token
@@ -449,6 +459,7 @@ type Querier interface {
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
// Update collection // Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error) UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
+133 -9
View File
@@ -206,6 +206,15 @@ func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 floa
return err return err
} }
const ClearCollectionItems = `-- name: ClearCollectionItems :exec
DELETE FROM collection_items WHERE collection_id = $1
`
func (q *Queries) ClearCollectionItems(ctx context.Context, collectionID pgtype.UUID) error {
_, err := q.db.Exec(ctx, ClearCollectionItems, collectionID)
return err
}
const ClearDeviceSyncQueue = `-- name: ClearDeviceSyncQueue :exec const ClearDeviceSyncQueue = `-- name: ClearDeviceSyncQueue :exec
DELETE FROM sync_queue WHERE device_id = $1 DELETE FROM sync_queue WHERE device_id = $1
` `
@@ -351,7 +360,7 @@ const CreateCollection = `-- name: CreateCollection :one
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings) INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
` `
type CreateCollectionParams struct { type CreateCollectionParams struct {
@@ -391,6 +400,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
&i.Priority, &i.Priority,
&i.IsSystemCollection, &i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
&i.DeletedAt,
) )
return i, err return i, err
} }
@@ -1724,7 +1734,7 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt
const CreateSystemCollection = `-- name: CreateSystemCollection :one const CreateSystemCollection = `-- name: CreateSystemCollection :one
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules) INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
` `
type CreateSystemCollectionParams struct { type CreateSystemCollectionParams struct {
@@ -1764,6 +1774,7 @@ func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCo
&i.Priority, &i.Priority,
&i.IsSystemCollection, &i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
&i.DeletedAt,
) )
return i, err return i, err
} }
@@ -2593,7 +2604,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
} }
const GetCollection = `-- name: GetCollection :one const GetCollection = `-- name: GetCollection :one
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at FROM collections WHERE id = $1 SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1
` `
// Get collection // Get collection
@@ -2614,6 +2625,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
&i.Priority, &i.Priority,
&i.IsSystemCollection, &i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
&i.DeletedAt,
) )
return i, err return i, err
} }
@@ -2846,7 +2858,7 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol
const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one
SELECT SELECT
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at,
COUNT(ci.id) as book_count COUNT(ci.id) as book_count
FROM collections c FROM collections c
LEFT JOIN collection_items ci ON c.id = ci.collection_id LEFT JOIN collection_items ci ON c.id = ci.collection_id
@@ -2868,6 +2880,7 @@ type GetCollectionWithBookCountRow struct {
Priority pgtype.Int4 `db:"priority" json:"priority"` Priority pgtype.Int4 `db:"priority" json:"priority"`
IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"` IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
BookCount int64 `db:"book_count" json:"book_count"` BookCount int64 `db:"book_count" json:"book_count"`
} }
@@ -2889,13 +2902,14 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
&i.Priority, &i.Priority,
&i.IsSystemCollection, &i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
&i.DeletedAt,
&i.BookCount, &i.BookCount,
) )
return i, err return i, err
} }
const GetCollectionsByUser = `-- name: GetCollectionsByUser :many const GetCollectionsByUser = `-- name: GetCollectionsByUser :many
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at FROM collections WHERE user_id = $1 ORDER BY created_at DESC SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
` `
// Get collections by user // Get collections by user
@@ -2922,6 +2936,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
&i.Priority, &i.Priority,
&i.IsSystemCollection, &i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
&i.DeletedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -2934,10 +2949,11 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
} }
const GetCollectionsForBook = `-- name: GetCollectionsForBook :many const GetCollectionsForBook = `-- name: GetCollectionsForBook :many
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at
FROM collections c FROM collections c
JOIN collection_items ci ON c.id = ci.collection_id JOIN collection_items ci ON c.id = ci.collection_id
WHERE ci.media_item_id = $1 WHERE ci.media_item_id = $1
AND c.deleted_at IS NULL
` `
// Get collections for book // Get collections for book
@@ -2964,6 +2980,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
&i.Priority, &i.Priority,
&i.IsSystemCollection, &i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
&i.DeletedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -7073,11 +7090,52 @@ func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (
return i, err return i, err
} }
const GetSystemCollectionByQueryType = `-- name: GetSystemCollectionByQueryType :one
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
WHERE user_id = $1
AND query_type = $2
AND is_system_collection = true
`
type GetSystemCollectionByQueryTypeParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
QueryType pgtype.Text `db:"query_type" json:"query_type"`
}
// System collections are permanent: identity is (user_id, query_type).
// Restore/reset operate IN PLACE (the row id — and everything referencing
// it: shelf mappings, prefs order, links — never churns). Deleted sections
// are TOMBSTONED (deleted_at) and stay invisible until restored.
// Unfiltered by design: restore/seed must resolve tombstoned rows too.
func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSystemCollectionByQueryTypeParams) (Collections, error) {
row := q.db.QueryRow(ctx, GetSystemCollectionByQueryType, arg.UserID, arg.QueryType)
var i Collections
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
const GetSystemCollectionsForDashboard = `-- name: GetSystemCollectionsForDashboard :many const GetSystemCollectionsForDashboard = `-- name: GetSystemCollectionsForDashboard :many
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at FROM collections SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
WHERE user_id = $1 WHERE user_id = $1
AND is_system_collection = true AND is_system_collection = true
AND show_on_dashboard = true AND show_on_dashboard = true
AND deleted_at IS NULL
ORDER BY priority ASC ORDER BY priority ASC
` `
@@ -7105,6 +7163,7 @@ func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID p
&i.Priority, &i.Priority,
&i.IsSystemCollection, &i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
&i.DeletedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -7657,10 +7716,11 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
} }
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at FROM collections c SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at FROM collections c
WHERE c.user_id = $1 WHERE c.user_id = $1
AND c.show_on_dashboard = true AND c.show_on_dashboard = true
AND c.is_system_collection = false AND c.is_system_collection = false
AND c.deleted_at IS NULL
ORDER BY priority ASC ORDER BY priority ASC
` `
@@ -7687,6 +7747,7 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt
&i.Priority, &i.Priority,
&i.IsSystemCollection, &i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
&i.DeletedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -10823,6 +10884,59 @@ func (q *Queries) RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNote
return result.RowsAffected(), nil return result.RowsAffected(), nil
} }
const RestoreSystemCollectionRow = `-- name: RestoreSystemCollectionRow :one
UPDATE collections
SET name = $2,
description = $3,
icon = $4,
color = $5,
priority = $6,
show_on_dashboard = true,
deleted_at = NULL
WHERE id = $1
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type RestoreSystemCollectionRowParams struct {
ID pgtype.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Description pgtype.Text `db:"description" json:"description"`
Icon pgtype.Text `db:"icon" json:"icon"`
Color pgtype.Text `db:"color" json:"color"`
Priority pgtype.Int4 `db:"priority" json:"priority"`
}
// Full reset, in place: canonical metadata, back on the dashboard,
// un-tombstoned. Item assignments are cleared by the caller ("full").
func (q *Queries) RestoreSystemCollectionRow(ctx context.Context, arg RestoreSystemCollectionRowParams) (Collections, error) {
row := q.db.QueryRow(ctx, RestoreSystemCollectionRow,
arg.ID,
arg.Name,
arg.Description,
arg.Icon,
arg.Color,
arg.Priority,
)
var i Collections
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL
` `
@@ -11869,6 +11983,15 @@ func (q *Queries) TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) er
return err return err
} }
const TombstoneSystemCollection = `-- name: TombstoneSystemCollection :exec
UPDATE collections SET deleted_at = NOW() WHERE id = $1
`
func (q *Queries) TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, TombstoneSystemCollection, id)
return err
}
const UpdateCollection = `-- name: UpdateCollection :one const UpdateCollection = `-- name: UpdateCollection :one
UPDATE collections UPDATE collections
SET SET
@@ -11879,7 +12002,7 @@ SET
auto_assign_rules = $6, auto_assign_rules = $6,
view_settings = $7 view_settings = $7
WHERE id = $1 WHERE id = $1
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
` `
type UpdateCollectionParams struct { type UpdateCollectionParams struct {
@@ -11918,6 +12041,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
&i.Priority, &i.Priority,
&i.IsSystemCollection, &i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
&i.DeletedAt,
) )
return i, err return i, err
} }
+37 -2
View File
@@ -2151,7 +2151,7 @@ SELECT * FROM collections WHERE id = $1;
-- Get collections by user -- Get collections by user
-- name: GetCollectionsByUser :many -- name: GetCollectionsByUser :many
SELECT * FROM collections WHERE user_id = $1 ORDER BY created_at DESC; SELECT * FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC;
-- Get collection with book count -- Get collection with book count
-- name: GetCollectionWithBookCount :one -- name: GetCollectionWithBookCount :one
@@ -2208,7 +2208,8 @@ ORDER BY ci.added_at DESC;
SELECT c.* SELECT c.*
FROM collections c FROM collections c
JOIN collection_items ci ON c.id = ci.collection_id JOIN collection_items ci ON c.id = ci.collection_id
WHERE ci.media_item_id = $1; WHERE ci.media_item_id = $1
AND c.deleted_at IS NULL;
-- Check if book is in collection -- Check if book is in collection
-- name: IsBookInCollection :one -- name: IsBookInCollection :one
@@ -2485,6 +2486,7 @@ SELECT * FROM collections
WHERE user_id = $1 WHERE user_id = $1
AND is_system_collection = true AND is_system_collection = true
AND show_on_dashboard = true AND show_on_dashboard = true
AND deleted_at IS NULL
ORDER BY priority ASC; ORDER BY priority ASC;
-- name: GetUserCollectionsForDashboard :many -- name: GetUserCollectionsForDashboard :many
@@ -2492,6 +2494,7 @@ SELECT c.* FROM collections c
WHERE c.user_id = $1 WHERE c.user_id = $1
AND c.show_on_dashboard = true AND c.show_on_dashboard = true
AND c.is_system_collection = false AND c.is_system_collection = false
AND c.deleted_at IS NULL
ORDER BY priority ASC; ORDER BY priority ASC;
-- name: DeleteUserSystemCollection :exec -- name: DeleteUserSystemCollection :exec
@@ -2500,6 +2503,38 @@ WHERE user_id = $1
AND name = $2 AND name = $2
AND is_system_collection = true; AND is_system_collection = true;
-- System collections are permanent: identity is (user_id, query_type).
-- Restore/reset operate IN PLACE (the row id — and everything referencing
-- it: shelf mappings, prefs order, links — never churns). Deleted sections
-- are TOMBSTONED (deleted_at) and stay invisible until restored.
-- name: GetSystemCollectionByQueryType :one
-- Unfiltered by design: restore/seed must resolve tombstoned rows too.
SELECT * FROM collections
WHERE user_id = $1
AND query_type = $2
AND is_system_collection = true;
-- name: TombstoneSystemCollection :exec
UPDATE collections SET deleted_at = NOW() WHERE id = $1;
-- name: RestoreSystemCollectionRow :one
-- Full reset, in place: canonical metadata, back on the dashboard,
-- un-tombstoned. Item assignments are cleared by the caller ("full").
UPDATE collections
SET name = $2,
description = $3,
icon = $4,
color = $5,
priority = $6,
show_on_dashboard = true,
deleted_at = NULL
WHERE id = $1
RETURNING *;
-- name: ClearCollectionItems :exec
DELETE FROM collection_items WHERE collection_id = $1;
-- name: CreateSystemCollection :one -- name: CreateSystemCollection :one
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules) INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
+21 -1
View File
@@ -1091,7 +1091,27 @@ func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userI
} }
for _, col := range defaultCollections { for _, col := range defaultCollections {
_, err := h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{ // Idempotent by the STABLE identity (user + query_type): a
// preserved or renamed row heals to canonical metadata instead of
// duplicating (the naming-drift bug). Insert only when absent.
existing, err := h.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
UserID: userID,
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
})
if err == nil {
if _, uerr := h.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
ID: existing.ID,
Name: col.Name,
Description: pgtype.Text{String: col.Description, Valid: true},
Icon: pgtype.Text{String: col.Icon, Valid: true},
Color: pgtype.Text{String: col.Color, Valid: true},
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
}); uerr != nil {
return uerr
}
continue
}
_, err = h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: userID, UserID: userID,
Name: col.Name, Name: col.Name,
Description: pgtype.Text{String: col.Description, Valid: true}, Description: pgtype.Text{String: col.Description, Valid: true},
+3
View File
@@ -111,6 +111,9 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
req.ViewSettings, req.ViewSettings,
) )
if err != nil { if err != nil {
if errors.Is(err, services.ErrCollectionNameTaken) {
return c.JSON(http.StatusConflict, map[string]string{"error": "a collection with this name already exists"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
+30 -3
View File
@@ -4,13 +4,19 @@ import (
"bookhoard/internal/database" "bookhoard/internal/database"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
// ErrCollectionNameTaken: the user already has a collection with this name.
var ErrCollectionNameTaken = errors.New("collection name already exists")
// Rule represents an auto-assign rule for collections // Rule represents an auto-assign rule for collections
type Rule struct { type Rule struct {
ID string `json:"id"` ID string `json:"id"`
@@ -72,7 +78,13 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
}) })
if err != nil { if err != nil {
return database.Collections{}, fmt.Errorf("failed to create collection: %v", err) // Duplicate name (UNIQUE(user_id, name)) is a client error, not a
// server failure: surface it as 409 so callers can recover.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return database.Collections{}, ErrCollectionNameTaken
}
return database.Collections{}, fmt.Errorf("failed to create collection: %w", err)
} }
return collection, nil return collection, nil
@@ -160,9 +172,24 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
// DeleteCollection deletes a collection // DeleteCollection deletes a collection
func (s *CollectionService) DeleteCollection(ctx context.Context, collectionID uuid.UUID) error { func (s *CollectionService) DeleteCollection(ctx context.Context, collectionID uuid.UUID) error {
err := s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}) // System collections (the 5 dashboard sections) are PERMANENT: a
// delete tombstones the row (invisible everywhere, restorable with
// the same id) instead of destroying it and every reference to it.
row, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil { if err != nil {
return fmt.Errorf("failed to delete collection: %v", err) if errors.Is(err, pgx.ErrNoRows) {
return nil // already gone: keep delete idempotent
}
return fmt.Errorf("failed to fetch collection: %w", err)
}
if row.IsSystemCollection.Bool {
return s.db.TombstoneSystemCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
}
err = s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
return fmt.Errorf("failed to delete collection: %w", err)
} }
return nil return nil
+42 -24
View File
@@ -5,9 +5,11 @@ import (
"bookhoard/internal/utils" "bookhoard/internal/utils"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
@@ -464,34 +466,50 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u
return fmt.Errorf("unknown collection: %s", collectionName) return fmt.Errorf("unknown collection: %s", collectionName)
} }
if resetType == "keep_books" { // System collections are PERMANENT rows identified by the stable
return s.db.ResetSystemCollectionMetadata(ctx, database.ResetSystemCollectionMetadataParams{ // (user_id, query_type) pair — restore never deletes or re-creates,
UserID: pgtype.UUID{Bytes: userID, Valid: true}, // so the row id (and every reference to it: device shelf mappings,
Name: collectionName, // dashboard order/hidden prefs, /collections/{id} links) never churns.
Description: pgtype.Text{String: meta.Description, Valid: true}, // Resolution includes tombstoned rows: restoring a deleted section
Icon: pgtype.Text{String: meta.Icon, Valid: true}, // un-deletes the exact same row.
Color: pgtype.Text{String: meta.Color, Valid: true}, row, err := s.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true}, UserID: pgtype.UUID{Bytes: userID, Valid: true},
}) QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
}
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
}) })
if err != nil { if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err return err
} }
_, err = s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{ if errors.Is(err, pgx.ErrNoRows) {
UserID: pgtype.UUID{Bytes: userID, Valid: true}, // Provision: the only path that mints a system-collection row.
Name: collectionName, _, cerr := s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
Description: pgtype.Text{String: meta.Description, Valid: true}, UserID: pgtype.UUID{Bytes: userID, Valid: true},
Icon: pgtype.Text{String: meta.Icon, Valid: true}, Name: collectionName,
Color: pgtype.Text{String: meta.Color, Valid: true}, Description: pgtype.Text{String: meta.Description, Valid: true},
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, Icon: pgtype.Text{String: meta.Icon, Valid: true},
QueryType: pgtype.Text{String: meta.QueryType, Valid: true}, Color: pgtype.Text{String: meta.Color, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true}, ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
})
return cerr
}
// "full": clear item assignments (a fresh section has none) and reset
// everything, IN PLACE. "keep_books": metadata-only, in place. Both
// un-tombstone, so a deleted section comes back as the same row.
if resetType != "keep_books" {
if err := s.db.ClearCollectionItems(ctx, row.ID); err != nil {
return err
}
}
_, err = s.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
ID: row.ID,
Name: collectionName,
Description: pgtype.Text{String: meta.Description, Valid: true},
Icon: pgtype.Text{String: meta.Icon, Valid: true},
Color: pgtype.Text{String: meta.Color, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
}) })
return err return err
} }