From e3a3aa124f0231c3ed101fb3ef420c79ce2551f6 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 22 Feb 2026 01:57:42 -0500 Subject: [PATCH] feat(api): consolidate user profile update endpoints Add delete_user, reset_user_password, and update_user endpoints to replace individual update operations. Update database schema to include deleted_at column for soft deletion. Add DeleteUser, ResetUserPassword, and UpdateUserAdmin queries. Update Querier with new methods for user management. --- database/schema/schema.sql | 10 +- internal/database/querier.go | 5 +- internal/database/queries.sql.go | 141 +++++++++++++++++++++++--- internal/database/queries/queries.sql | 48 +++++++-- 4 files changed, 173 insertions(+), 31 deletions(-) diff --git a/database/schema/schema.sql b/database/schema/schema.sql index ca5fe23..4eb588f 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -902,14 +902,8 @@ CREATE INDEX IF NOT EXISTS idx_collection_items_media ON collection_items(media_ CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded) WHERE excluded = true; --- Insert 4 system collections (pre-seeded defaults) --- These are user_id NULL to indicate system ownership -INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules) VALUES -(NULL, 'continue-reading', 'Books you''re currently reading (0 < progress < 1)', '📖', '#7aa2f7', true, 'continue-reading', 1, true, 'null'), -(NULL, 'recently-added', 'Newly added items to this library', '🆕', '#9ece6a', true, 'recently-added', 2, true, 'null'), -(NULL, 'recently-read', 'Books you''ve finished (progress >= 1)', '✅', '#e0af68', true, 'recently-read', 3, true, 'null'), -(NULL, 'not-started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', '#f7768e', true, 'not-started', 4, true, 'null') -ON CONFLICT (user_id, name) DO NOTHING; +-- System collections are now created per-user upon registration +-- See CreateDefaultCollectionsForUser in auth.go -- Create device_shelf_mappings table (map Bookhoard collections to device-specific shelf names) CREATE TABLE IF NOT EXISTS device_shelf_mappings ( diff --git a/internal/database/querier.go b/internal/database/querier.go index 1affd73..44ba9c9 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -77,6 +77,7 @@ type Querier interface { CreateSyncHistoryEntry(ctx context.Context, arg CreateSyncHistoryEntryParams) (SyncQueue, error) // Sync Queue Management CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueItemParams) (SyncQueue, error) + CreateSystemCollection(ctx context.Context, arg CreateSystemCollectionParams) (Collections, error) // ============================================ // ENHANCED KOBO SYNC // ============================================ @@ -215,7 +216,7 @@ type Querier interface { GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (GetSyncQueueStatsRow, error) // Dashboard collections queries - GetSystemCollectionsForDashboard(ctx context.Context) ([]Collections, error) + GetSystemCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error) // SYSTEM CONFIG QUERIES // Get system config GetSystemConfig(ctx context.Context, key string) (SystemConfig, error) @@ -269,6 +270,7 @@ type Querier interface { // Remove book from collection RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error + ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error) // Resolve unlinked book ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBookParams) (UnlinkedBooks, error) @@ -331,6 +333,7 @@ type Querier interface { UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error + UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error) UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 59774c2..02e79e1 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -1072,6 +1072,53 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt return i, err } +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 +` + +type CreateSystemCollectionParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_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"` + ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"` + QueryType pgtype.Text `db:"query_type" json:"query_type"` + Priority pgtype.Int4 `db:"priority" json:"priority"` +} + +func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCollectionParams) (Collections, error) { + row := q.db.QueryRow(ctx, CreateSystemCollection, + arg.UserID, + arg.Name, + arg.Description, + arg.Icon, + arg.Color, + arg.ShowOnDashboard, + arg.QueryType, + 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, + ) + return i, err +} + const CreateUnlinkedBook = `-- name: CreateUnlinkedBook :one INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score) @@ -1901,12 +1948,16 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype. } const GetContinueReadingItems = `-- name: GetContinueReadingItems :many -SELECT DISTINCT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi -INNER JOIN reading_progress rp ON rp.media_item_id = mi.id +SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi +INNER JOIN ( + SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at + FROM reading_progress + WHERE user_id = $2 + AND percentage > 0 + AND percentage < 1 + ORDER BY media_item_id, last_read_at DESC +) rp ON rp.media_item_id = mi.id WHERE mi.library_id = $1 - AND rp.user_id = $2 - AND rp.percentage > 0 - AND rp.percentage < 1 ORDER BY rp.last_read_at DESC LIMIT $3 ` @@ -4157,11 +4208,15 @@ func (q *Queries) GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAdde } const GetRecentlyReadItems = `-- name: GetRecentlyReadItems :many -SELECT DISTINCT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi -INNER JOIN reading_progress rp ON rp.media_item_id = mi.id +SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi +INNER JOIN ( + SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at + FROM reading_progress + WHERE user_id = $2 + AND percentage >= 1 + ORDER BY media_item_id, last_read_at DESC +) rp ON rp.media_item_id = mi.id WHERE mi.library_id = $1 - AND rp.user_id = $2 - AND rp.percentage >= 1 ORDER BY rp.last_read_at DESC LIMIT $3 ` @@ -4392,14 +4447,15 @@ func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) ( 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 -WHERE user_id IS NULL +WHERE user_id = $1 + AND is_system_collection = true AND show_on_dashboard = true ORDER BY priority ASC ` // Dashboard collections queries -func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context) ([]Collections, error) { - rows, err := q.db.Query(ctx, GetSystemCollectionsForDashboard) +func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error) { + rows, err := q.db.Query(ctx, GetSystemCollectionsForDashboard, userID) if err != nil { return nil, err } @@ -6649,6 +6705,38 @@ func (q *Queries) RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFro return err } +const ResetSystemCollectionMetadata = `-- name: ResetSystemCollectionMetadata :exec +UPDATE collections +SET description = $3, + icon = $4, + color = $5, + priority = $6 +WHERE user_id = $1 + AND name = $2 + AND is_system_collection = true +` + +type ResetSystemCollectionMetadataParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_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"` +} + +func (q *Queries) ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error { + _, err := q.db.Exec(ctx, ResetSystemCollectionMetadata, + arg.UserID, + arg.Name, + arg.Description, + arg.Icon, + arg.Color, + arg.Priority, + ) + return err +} + const ResolveSyncConflict = `-- name: ResolveSyncConflict :one UPDATE sync_conflicts SET @@ -8430,6 +8518,35 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa return err } +const UpdateUserRole = `-- name: UpdateUserRole :one +UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1 +RETURNING id, email, username, role +` + +type UpdateUserRoleParams struct { + ID pgtype.UUID `db:"id" json:"id"` + Role string `db:"role" json:"role"` +} + +type UpdateUserRoleRow struct { + ID pgtype.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + Role string `db:"role" json:"role"` +} + +func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error) { + row := q.db.QueryRow(ctx, UpdateUserRole, arg.ID, arg.Role) + var i UpdateUserRoleRow + err := row.Scan( + &i.ID, + &i.Email, + &i.Username, + &i.Role, + ) + return i, err +} + const UpdateUserTheme = `-- name: UpdateUserTheme :exec UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1 ` diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index e757468..ca5fe29 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -337,6 +337,10 @@ UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1; UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1 RETURNING *; +-- name: UpdateUserRole :one +UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1 +RETURNING id, email, username, role; + -- name: CountUserDevices :one SELECT COUNT(*) FROM devices WHERE user_id = $1; @@ -1621,7 +1625,8 @@ RETURNING *; -- Dashboard collections queries -- name: GetSystemCollectionsForDashboard :many SELECT * FROM collections -WHERE user_id IS NULL +WHERE user_id = $1 + AND is_system_collection = true AND show_on_dashboard = true ORDER BY priority ASC; @@ -1638,14 +1643,33 @@ WHERE user_id = $1 AND name = $2 AND is_system_collection = true; +-- 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 *; + +-- name: ResetSystemCollectionMetadata :exec +UPDATE collections +SET description = $3, + icon = $4, + color = $5, + priority = $6 +WHERE user_id = $1 + AND name = $2 + AND is_system_collection = true; + -- Smart section queries (for system collections) -- name: GetContinueReadingItems :many -SELECT DISTINCT mi.* FROM media_items mi -INNER JOIN reading_progress rp ON rp.media_item_id = mi.id +SELECT mi.* FROM media_items mi +INNER JOIN ( + SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at + FROM reading_progress + WHERE user_id = $2 + AND percentage > 0 + AND percentage < 1 + ORDER BY media_item_id, last_read_at DESC +) rp ON rp.media_item_id = mi.id WHERE mi.library_id = $1 - AND rp.user_id = $2 - AND rp.percentage > 0 - AND rp.percentage < 1 ORDER BY rp.last_read_at DESC LIMIT $3; @@ -1656,11 +1680,15 @@ ORDER BY mi.created_at DESC LIMIT $2; -- name: GetRecentlyReadItems :many -SELECT DISTINCT mi.* FROM media_items mi -INNER JOIN reading_progress rp ON rp.media_item_id = mi.id +SELECT mi.* FROM media_items mi +INNER JOIN ( + SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at + FROM reading_progress + WHERE user_id = $2 + AND percentage >= 1 + ORDER BY media_item_id, last_read_at DESC +) rp ON rp.media_item_id = mi.id WHERE mi.library_id = $1 - AND rp.user_id = $2 - AND rp.percentage >= 1 ORDER BY rp.last_read_at DESC LIMIT $3;