From acbb6c7981e72151d191c8e7e1e17c065521b102 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 22 Aug 2026 13:16:31 -0400 Subject: [PATCH] feat(db): queries for the deleted-annotation history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListDeletedAnnotationsForBook unions tombstoned highlights, notes, and bookmarks for a user+book regardless of the sync TTL cutoff (the history must show everything still restorable, not just recent deletes), with display text, secondary text, color, and both timestamps. Restore queries clear deleted/deleted_at (lossless — the row was soft- deleted, never removed) and are scoped to the owning user and media item so a restore can never touch another user's annotation. Purge queries hard-delete an already-tombstoned row: the user-driven counterpart of the TTL maintenance sweep, for explicit 'delete permanently' actions from the history. All six write queries are :execrows so callers can distinguish 'restored' from 'nothing matched' without a follow-up read. --- internal/database/querier.go | 16 ++ internal/database/queries.sql.go | 217 ++++++++++++++++++++++++++ internal/database/queries/queries.sql | 81 ++++++++++ 3 files changed, 314 insertions(+) diff --git a/internal/database/querier.go b/internal/database/querier.go index da151e5..2a54c98 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -327,6 +327,14 @@ type Querier interface { ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error) ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error) ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error) + // ============================================ + // ANNOTATION HISTORY (deleted-annotation archive) + // ============================================ + // Lists every currently-tombstoned annotation for a book regardless of the + // tombstone TTL: this backs the book page's "recently deleted" history where + // users can restore or permanently remove entries. Rows whose tombstones have + // been purged by the daily maintenance sweep no longer exist at all. + ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) @@ -348,6 +356,11 @@ type Querier interface { PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error + PurgeMediaBookmarkByID(ctx context.Context, arg PurgeMediaBookmarkByIDParams) (int64, error) + // Permanent removal from the history (distinct from the TTL-driven purge, + // which is maintenance). Scoped to the owning user and book. + PurgeMediaHighlightByID(ctx context.Context, arg PurgeMediaHighlightByIDParams) (int64, error) + PurgeMediaNoteByID(ctx context.Context, arg PurgeMediaNoteByIDParams) (int64, error) // Query media items by multiple identifiers with confidence scoring QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error) ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error @@ -363,6 +376,9 @@ type Querier interface { ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error) // Resolve unlinked book ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBookParams) (UnlinkedBooks, error) + RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error) + RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error) + RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error) RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error RevokeDevice(ctx context.Context, id pgtype.UUID) error // Revoke OPDS token diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 2300065..1541229 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -8127,6 +8127,98 @@ func (q *Queries) ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ( return items, nil } +const ListDeletedAnnotationsForBook = `-- name: ListDeletedAnnotationsForBook :many + +SELECT + mh.id, + mh.dedup_key, + 'highlight' as annotation_type, + mh.selection_text as display_text, + mh.note_text as secondary_text, + mh.color, + mh.deleted_at, + mh.created_at +FROM media_highlights mh +WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE +UNION ALL +SELECT + mn.id, + mn.dedup_key, + 'note' as annotation_type, + mn.content as display_text, + NULL::text as secondary_text, + NULL::text as color, + mn.deleted_at, + mn.created_at +FROM media_notes mn +WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE +UNION ALL +SELECT + mb.id, + mb.dedup_key, + 'bookmark' as annotation_type, + mb.title as display_text, + mb.notes as secondary_text, + NULL::text as color, + mb.deleted_at, + mb.created_at +FROM media_bookmarks mb +WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE +ORDER BY deleted_at DESC +` + +type ListDeletedAnnotationsForBookParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` +} + +type ListDeletedAnnotationsForBookRow struct { + ID pgtype.UUID `db:"id" json:"id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` + AnnotationType string `db:"annotation_type" json:"annotation_type"` + DisplayText string `db:"display_text" json:"display_text"` + SecondaryText pgtype.Text `db:"secondary_text" json:"secondary_text"` + Color pgtype.Text `db:"color" json:"color"` + DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` +} + +// ============================================ +// ANNOTATION HISTORY (deleted-annotation archive) +// ============================================ +// Lists every currently-tombstoned annotation for a book regardless of the +// tombstone TTL: this backs the book page's "recently deleted" history where +// users can restore or permanently remove entries. Rows whose tombstones have +// been purged by the daily maintenance sweep no longer exist at all. +func (q *Queries) ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error) { + rows, err := q.db.Query(ctx, ListDeletedAnnotationsForBook, arg.MediaItemID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListDeletedAnnotationsForBookRow{} + for rows.Next() { + var i ListDeletedAnnotationsForBookRow + if err := rows.Scan( + &i.ID, + &i.DedupKey, + &i.AnnotationType, + &i.DisplayText, + &i.SecondaryText, + &i.Color, + &i.DeletedAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListDevicesByType = `-- name: ListDevicesByType :many SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE device_type = $1 ORDER BY created_at DESC ` @@ -9419,6 +9511,65 @@ func (q *Queries) PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgty return err } +const PurgeMediaBookmarkByID = `-- name: PurgeMediaBookmarkByID :execrows +DELETE FROM media_bookmarks +WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE +` + +type PurgeMediaBookmarkByIDParams struct { + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` +} + +func (q *Queries) PurgeMediaBookmarkByID(ctx context.Context, arg PurgeMediaBookmarkByIDParams) (int64, error) { + result, err := q.db.Exec(ctx, PurgeMediaBookmarkByID, arg.ID, arg.UserID, arg.MediaItemID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const PurgeMediaHighlightByID = `-- name: PurgeMediaHighlightByID :execrows +DELETE FROM media_highlights +WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE +` + +type PurgeMediaHighlightByIDParams struct { + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` +} + +// Permanent removal from the history (distinct from the TTL-driven purge, +// which is maintenance). Scoped to the owning user and book. +func (q *Queries) PurgeMediaHighlightByID(ctx context.Context, arg PurgeMediaHighlightByIDParams) (int64, error) { + result, err := q.db.Exec(ctx, PurgeMediaHighlightByID, arg.ID, arg.UserID, arg.MediaItemID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const PurgeMediaNoteByID = `-- name: PurgeMediaNoteByID :execrows +DELETE FROM media_notes +WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE +` + +type PurgeMediaNoteByIDParams struct { + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` +} + +func (q *Queries) PurgeMediaNoteByID(ctx context.Context, arg PurgeMediaNoteByIDParams) (int64, error) { + result, err := q.db.Exec(ctx, PurgeMediaNoteByID, arg.ID, arg.UserID, arg.MediaItemID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const QueryMediaItemsByIdentifiers = `-- name: QueryMediaItemsByIdentifiers :many SELECT mi.id, @@ -9752,6 +9903,72 @@ func (q *Queries) ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBo return i, err } +const RestoreMediaBookmarkByID = `-- name: RestoreMediaBookmarkByID :execrows +UPDATE media_bookmarks SET + deleted = FALSE, + deleted_at = NULL, + last_modified_at = NOW() +WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE +` + +type RestoreMediaBookmarkByIDParams struct { + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` +} + +func (q *Queries) RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error) { + result, err := q.db.Exec(ctx, RestoreMediaBookmarkByID, arg.ID, arg.UserID, arg.MediaItemID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const RestoreMediaHighlightByID = `-- name: RestoreMediaHighlightByID :execrows +UPDATE media_highlights SET + deleted = FALSE, + deleted_at = NULL, + last_modified_at = NOW() +WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE +` + +type RestoreMediaHighlightByIDParams struct { + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` +} + +func (q *Queries) RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error) { + result, err := q.db.Exec(ctx, RestoreMediaHighlightByID, arg.ID, arg.UserID, arg.MediaItemID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const RestoreMediaNoteByID = `-- name: RestoreMediaNoteByID :execrows +UPDATE media_notes SET + deleted = FALSE, + deleted_at = NULL, + last_modified_at = NOW() +WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE +` + +type RestoreMediaNoteByIDParams struct { + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` +} + +func (q *Queries) RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error) { + result, err := q.db.Exec(ctx, RestoreMediaNoteByID, arg.ID, arg.UserID, arg.MediaItemID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL ` diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 9430858..8681a0b 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -1027,6 +1027,87 @@ FROM media_bookmarks mb WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3 ORDER BY deleted_at DESC; +-- ============================================ +-- ANNOTATION HISTORY (deleted-annotation archive) +-- ============================================ + +-- Lists every currently-tombstoned annotation for a book regardless of the +-- tombstone TTL: this backs the book page's "recently deleted" history where +-- users can restore or permanently remove entries. Rows whose tombstones have +-- been purged by the daily maintenance sweep no longer exist at all. +-- name: ListDeletedAnnotationsForBook :many +SELECT + mh.id, + mh.dedup_key, + 'highlight' as annotation_type, + mh.selection_text as display_text, + mh.note_text as secondary_text, + mh.color, + mh.deleted_at, + mh.created_at +FROM media_highlights mh +WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE +UNION ALL +SELECT + mn.id, + mn.dedup_key, + 'note' as annotation_type, + mn.content as display_text, + NULL::text as secondary_text, + NULL::text as color, + mn.deleted_at, + mn.created_at +FROM media_notes mn +WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE +UNION ALL +SELECT + mb.id, + mb.dedup_key, + 'bookmark' as annotation_type, + mb.title as display_text, + mb.notes as secondary_text, + NULL::text as color, + mb.deleted_at, + mb.created_at +FROM media_bookmarks mb +WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE +ORDER BY deleted_at DESC; + +-- name: RestoreMediaHighlightByID :execrows +UPDATE media_highlights SET + deleted = FALSE, + deleted_at = NULL, + last_modified_at = NOW() +WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE; + +-- name: RestoreMediaNoteByID :execrows +UPDATE media_notes SET + deleted = FALSE, + deleted_at = NULL, + last_modified_at = NOW() +WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE; + +-- name: RestoreMediaBookmarkByID :execrows +UPDATE media_bookmarks SET + deleted = FALSE, + deleted_at = NULL, + last_modified_at = NOW() +WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE; + +-- Permanent removal from the history (distinct from the TTL-driven purge, +-- which is maintenance). Scoped to the owning user and book. +-- name: PurgeMediaHighlightByID :execrows +DELETE FROM media_highlights +WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE; + +-- name: PurgeMediaNoteByID :execrows +DELETE FROM media_notes +WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE; + +-- name: PurgeMediaBookmarkByID :execrows +DELETE FROM media_bookmarks +WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE; + -- Refresh Tokens queries -- name: CreateRefreshToken :one INSERT INTO refresh_tokens (user_id, token, expires_at)