diff --git a/database/schema/schema.sql b/database/schema/schema.sql index b27cd7dd..04484657 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -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 diff --git a/internal/database/models.go b/internal/database/models.go index 4b524bf7..8ce89d31 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -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 { diff --git a/internal/database/querier.go b/internal/database/querier.go index 984cfcab..a75521e8 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -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) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 556b67b5..423fa070 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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 } diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index c93feeb6..502bd976 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index fea13bb6..1142294d 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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}, diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index a0fb9398..47aa892a 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -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()}) } diff --git a/internal/services/collection_service.go b/internal/services/collection_service.go index 6878c08c..f7252dfe 100644 --- a/internal/services/collection_service.go +++ b/internal/services/collection_service.go @@ -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 diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index 6972e436..911debe0 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -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 }