feat(db): queries for the deleted-annotation history

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.
This commit is contained in:
2026-08-22 13:16:31 -04:00
parent 91c8be8562
commit acbb6c7981
3 changed files with 314 additions and 0 deletions
+217
View File
@@ -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
`