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:
1 parent
31f939002f
commit
7ce73213ae
9 files changed
+284
-39
No files matched your search
@@ -1135,9 +1135,15 @@ CREATE TABLE IF NOT EXISTS collections (
|
||||
priority INT DEFAULT 100,
|
||||
is_system_collection BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
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);
|
||||
|
||||
-- Index for dashboard queries
|
||||
|
||||
@@ -31,6 +31,7 @@ type Collections struct {
|
||||
Priority pgtype.Int4 `db:"priority" json:"priority"`
|
||||
IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
type DeviceCatalogs struct {
|
||||
|
||||
@@ -30,6 +30,7 @@ type Querier interface {
|
||||
// Cleanup expired OPDS tokens
|
||||
CleanupExpiredOpdsTokens(ctx context.Context) 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
|
||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||
@@ -305,6 +306,12 @@ type Querier interface {
|
||||
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
|
||||
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, 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
|
||||
GetSystemCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
|
||||
// SYSTEM CONFIG QUERIES
|
||||
@@ -425,6 +432,9 @@ type Querier interface {
|
||||
RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error)
|
||||
RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (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
|
||||
RevokeDevice(ctx context.Context, id pgtype.UUID) error
|
||||
// Revoke OPDS token
|
||||
@@ -449,6 +459,7 @@ type Querier interface {
|
||||
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
|
||||
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
|
||||
// Update collection
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
|
||||
@@ -206,6 +206,15 @@ func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 floa
|
||||
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
|
||||
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)
|
||||
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 {
|
||||
@@ -391,6 +400,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
|
||||
&i.Priority,
|
||||
&i.IsSystemCollection,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1724,7 +1734,7 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt
|
||||
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)
|
||||
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 {
|
||||
@@ -1764,6 +1774,7 @@ func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCo
|
||||
&i.Priority,
|
||||
&i.IsSystemCollection,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2593,7 +2604,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
|
||||
}
|
||||
|
||||
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
|
||||
@@ -2614,6 +2625,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
|
||||
&i.Priority,
|
||||
&i.IsSystemCollection,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2846,7 +2858,7 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol
|
||||
|
||||
const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one
|
||||
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
|
||||
FROM collections c
|
||||
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"`
|
||||
IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -2889,13 +2902,14 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
|
||||
&i.Priority,
|
||||
&i.IsSystemCollection,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
&i.BookCount,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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
|
||||
@@ -2922,6 +2936,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
|
||||
&i.Priority,
|
||||
&i.IsSystemCollection,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2934,10 +2949,11 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
|
||||
}
|
||||
|
||||
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
|
||||
JOIN collection_items ci ON c.id = ci.collection_id
|
||||
WHERE ci.media_item_id = $1
|
||||
AND c.deleted_at IS NULL
|
||||
`
|
||||
|
||||
// Get collections for book
|
||||
@@ -2964,6 +2980,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
|
||||
&i.Priority,
|
||||
&i.IsSystemCollection,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -7073,11 +7090,52 @@ func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (
|
||||
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
|
||||
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
|
||||
AND is_system_collection = true
|
||||
AND show_on_dashboard = true
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY priority ASC
|
||||
`
|
||||
|
||||
@@ -7105,6 +7163,7 @@ func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID p
|
||||
&i.Priority,
|
||||
&i.IsSystemCollection,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -7657,10 +7716,11 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
|
||||
}
|
||||
|
||||
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
|
||||
AND c.show_on_dashboard = true
|
||||
AND c.is_system_collection = false
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY priority ASC
|
||||
`
|
||||
|
||||
@@ -7687,6 +7747,7 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt
|
||||
&i.Priority,
|
||||
&i.IsSystemCollection,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -10823,6 +10884,59 @@ func (q *Queries) RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNote
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
UPDATE collections
|
||||
SET
|
||||
@@ -11879,7 +12002,7 @@ SET
|
||||
auto_assign_rules = $6,
|
||||
view_settings = $7
|
||||
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 {
|
||||
@@ -11918,6 +12041,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
|
||||
&i.Priority,
|
||||
&i.IsSystemCollection,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -2151,7 +2151,7 @@ SELECT * FROM collections WHERE id = $1;
|
||||
|
||||
-- Get collections by user
|
||||
-- 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
|
||||
-- name: GetCollectionWithBookCount :one
|
||||
@@ -2208,7 +2208,8 @@ ORDER BY ci.added_at DESC;
|
||||
SELECT c.*
|
||||
FROM collections c
|
||||
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
|
||||
-- name: IsBookInCollection :one
|
||||
@@ -2485,6 +2486,7 @@ SELECT * FROM collections
|
||||
WHERE user_id = $1
|
||||
AND is_system_collection = true
|
||||
AND show_on_dashboard = true
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY priority ASC;
|
||||
|
||||
-- name: GetUserCollectionsForDashboard :many
|
||||
@@ -2492,6 +2494,7 @@ SELECT c.* FROM collections c
|
||||
WHERE c.user_id = $1
|
||||
AND c.show_on_dashboard = true
|
||||
AND c.is_system_collection = false
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY priority ASC;
|
||||
|
||||
-- name: DeleteUserSystemCollection :exec
|
||||
@@ -2500,6 +2503,38 @@ WHERE user_id = $1
|
||||
AND name = $2
|
||||
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
|
||||
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)
|
||||
|
||||
@@ -1091,7 +1091,27 @@ func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userI
|
||||
}
|
||||
|
||||
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,
|
||||
Name: col.Name,
|
||||
Description: pgtype.Text{String: col.Description, Valid: true},
|
||||
|
||||
@@ -111,6 +111,9 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||
req.ViewSettings,
|
||||
)
|
||||
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()})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,19 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"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
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
@@ -72,7 +78,13 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
|
||||
})
|
||||
|
||||
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
|
||||
@@ -160,9 +172,24 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
|
||||
|
||||
// DeleteCollection deletes a collection
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"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)
|
||||
}
|
||||
|
||||
if resetType == "keep_books" {
|
||||
return s.db.ResetSystemCollectionMetadata(ctx, database.ResetSystemCollectionMetadataParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
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},
|
||||
})
|
||||
}
|
||||
|
||||
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: collectionName,
|
||||
// System collections are PERMANENT rows identified by the stable
|
||||
// (user_id, query_type) pair — restore never deletes or re-creates,
|
||||
// so the row id (and every reference to it: device shelf mappings,
|
||||
// dashboard order/hidden prefs, /collections/{id} links) never churns.
|
||||
// Resolution includes tombstoned rows: restoring a deleted section
|
||||
// un-deletes the exact same row.
|
||||
row, err := s.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
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},
|
||||
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
|
||||
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Provision: the only path that mints a system-collection row.
|
||||
_, cerr := s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
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},
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user