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)