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,
|
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user