feat(unlinked-books): dismiss (ignore) or delete queue entries
Release / build-and-push (push) Successful in 3m15s
Release / build-and-push (push) Successful in 3m15s
Unidentified sync files used to be stuck: every KOReader re-sync re-queues them, and there was no way to say "this isn't a book" or "I don't want this one on the server". Ignore is the durable dismiss: a new ignored_at column hides the row from the page list, dashboard badge, and per-device API while the sync keeps refreshing its metadata (the upsert never clears the column). A collapsible "Ignored" section lists dismissed rows with Restore and Delete so a misclick is recoverable. Delete is the hard exit — the row is gone, and honestly re-queued if the device syncs that file again (the confirm dialog says so). Actions are page-scoped SSR routes: htmx swaps in the re-rendered section so counts and buckets can never drift from the DB; ownership is row → device → user (admins may act on any row). Integration tests pin the lifecycle (hide everywhere, survive the sync upsert, restore, delete), foreign-user 403s, and the render surface.
This commit is contained in:
1 parent
afbf1029a9
commit
f6a73ab5aa
10 files changed
+1241
-375
No files matched your search
@@ -562,6 +562,7 @@ type UnlinkedBooks struct {
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||
}
|
||||
|
||||
type UserDashboardPreferences struct {
|
||||
|
||||
@@ -53,9 +53,10 @@ type Querier interface {
|
||||
// collections page); same live filter as GetCollectionItems, plus the
|
||||
// future-hybrid excluded exclusion and an optional library scope.
|
||||
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
||||
// Count unlinked books for a device
|
||||
// Count unlinked books for a device (ignored rows stay hidden)
|
||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||
// Banner count: unresolved queue rows across the caller's own devices.
|
||||
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
||||
// devices. Ignored rows must not nag from the dashboard badge.
|
||||
CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
||||
@@ -456,7 +457,9 @@ type Querier interface {
|
||||
GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error)
|
||||
// Get unlinked book by ID
|
||||
GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error)
|
||||
// Get unlinked books for a device
|
||||
// Fetch one row together with its device's owner for authorization.
|
||||
GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error)
|
||||
// Get unlinked books for a device (ignored rows stay hidden)
|
||||
GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error)
|
||||
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
||||
@@ -504,6 +507,9 @@ type Querier interface {
|
||||
// static collection paths apply.
|
||||
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
|
||||
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||
// Soft-dismiss one row (ownership is checked by the caller against the
|
||||
// device's user_id before calling this).
|
||||
IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
||||
// Check if book is in collection
|
||||
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
||||
@@ -530,6 +536,9 @@ type Querier interface {
|
||||
// archived row's reading data into its active twin instead of waiting for a
|
||||
// file that will never return.
|
||||
ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error)
|
||||
// Page list, ignored bucket: rows the user dismissed, kept for the
|
||||
// collapsible "Ignored" section so a misclick is recoverable.
|
||||
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
|
||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||
@@ -553,7 +562,8 @@ type Querier interface {
|
||||
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
||||
// List unresolved unlinked books with pagination
|
||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||
// Page list: unresolved queue rows for the caller's own devices.
|
||||
// Page list: unresolved queue rows for the caller's own devices (ignored
|
||||
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
|
||||
ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error)
|
||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||
// First consecutive scan that cannot find the file on disk.
|
||||
@@ -637,6 +647,8 @@ type Querier interface {
|
||||
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
|
||||
TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error
|
||||
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
|
||||
// Undo a soft-dismiss.
|
||||
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||
// Update collection
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
||||
|
||||
@@ -372,10 +372,10 @@ func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollect
|
||||
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
|
||||
SELECT COUNT(*) as count
|
||||
FROM unlinked_books
|
||||
WHERE device_id = $1 AND resolved = false
|
||||
WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL
|
||||
`
|
||||
|
||||
// Count unlinked books for a device
|
||||
// Count unlinked books for a device (ignored rows stay hidden)
|
||||
func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, CountUnlinkedBooks, deviceID)
|
||||
var count int64
|
||||
@@ -387,10 +387,11 @@ const CountUnresolvedUnlinkedBooksForUser = `-- name: CountUnresolvedUnlinkedBoo
|
||||
SELECT COUNT(*) as count
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false
|
||||
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
||||
`
|
||||
|
||||
// Banner count: unresolved queue rows across the caller's own devices.
|
||||
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
||||
// devices. Ignored rows must not nag from the dashboard badge.
|
||||
func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID)
|
||||
var count int64
|
||||
@@ -1956,7 +1957,7 @@ const CreateUnlinkedBook = `-- name: CreateUnlinkedBook :one
|
||||
|
||||
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
|
||||
`
|
||||
|
||||
type CreateUnlinkedBookParams struct {
|
||||
@@ -1996,6 +1997,7 @@ func (q *Queries) CreateUnlinkedBook(ctx context.Context, arg CreateUnlinkedBook
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -9809,7 +9811,7 @@ func (q *Queries) GetUniversalProgress(ctx context.Context, arg GetUniversalProg
|
||||
}
|
||||
|
||||
const GetUnlinkedBookByContentId = `-- name: GetUnlinkedBookByContentId :one
|
||||
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2
|
||||
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2
|
||||
`
|
||||
|
||||
type GetUnlinkedBookByContentIdParams struct {
|
||||
@@ -9835,12 +9837,13 @@ func (q *Queries) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinke
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUnlinkedBookByID = `-- name: GetUnlinkedBookByID :one
|
||||
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE id = $1
|
||||
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE id = $1
|
||||
`
|
||||
|
||||
// Get unlinked book by ID
|
||||
@@ -9861,15 +9864,69 @@ func (q *Queries) GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (Unli
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUnlinkedBookWithOwner = `-- name: GetUnlinkedBookWithOwner :one
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.user_id, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE ub.id = $1
|
||||
`
|
||||
|
||||
type GetUnlinkedBookWithOwnerRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||
ContentID string `db:"content_id" json:"content_id"`
|
||||
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
||||
Title pgtype.Text `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
||||
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
DeviceName string `db:"device_name" json:"device_name"`
|
||||
DeviceType string `db:"device_type" json:"device_type"`
|
||||
}
|
||||
|
||||
// Fetch one row together with its device's owner for authorization.
|
||||
func (q *Queries) GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error) {
|
||||
row := q.db.QueryRow(ctx, GetUnlinkedBookWithOwner, id)
|
||||
var i GetUnlinkedBookWithOwnerRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.DeviceID,
|
||||
&i.ContentID,
|
||||
&i.FilePath,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.ConfidenceScore,
|
||||
&i.Resolved,
|
||||
&i.MediaItemID,
|
||||
&i.ResolvedAt,
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
&i.UserID,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUnlinkedBooksByDevice = `-- name: GetUnlinkedBooksByDevice :many
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE ub.device_id = $1 AND ub.resolved = false
|
||||
WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
||||
ORDER BY ub.last_seen_at DESC
|
||||
`
|
||||
|
||||
@@ -9887,11 +9944,12 @@ type GetUnlinkedBooksByDeviceRow struct {
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||
DeviceName string `db:"device_name" json:"device_name"`
|
||||
DeviceType string `db:"device_type" json:"device_type"`
|
||||
}
|
||||
|
||||
// Get unlinked books for a device
|
||||
// Get unlinked books for a device (ignored rows stay hidden)
|
||||
func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetUnlinkedBooksByDevice, deviceID)
|
||||
if err != nil {
|
||||
@@ -9915,6 +9973,7 @@ func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
); err != nil {
|
||||
@@ -10985,6 +11044,17 @@ func (q *Queries) HasRecentConflictResolution(ctx context.Context, arg HasRecent
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const IgnoreUnlinkedBook = `-- name: IgnoreUnlinkedBook :exec
|
||||
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
// Soft-dismiss one row (ownership is checked by the caller against the
|
||||
// device's user_id before calling this).
|
||||
func (q *Queries) IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, IgnoreUnlinkedBook, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const IncrementSyncQueueAttempts = `-- name: IncrementSyncQueueAttempts :one
|
||||
UPDATE sync_queue
|
||||
SET
|
||||
@@ -11071,7 +11141,7 @@ SET
|
||||
resolved_at = NOW(),
|
||||
resolution_method = 'manual_link'
|
||||
WHERE id = $1
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
|
||||
`
|
||||
|
||||
type LinkUnlinkedBookParams struct {
|
||||
@@ -11098,6 +11168,7 @@ func (q *Queries) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookPara
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -11680,6 +11751,72 @@ func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaIt
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListIgnoredUnlinkedBooksForUser = `-- name: ListIgnoredUnlinkedBooksForUser :many
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
|
||||
ORDER BY ub.ignored_at DESC
|
||||
`
|
||||
|
||||
type ListIgnoredUnlinkedBooksForUserRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||
ContentID string `db:"content_id" json:"content_id"`
|
||||
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
||||
Title pgtype.Text `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
||||
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||
DeviceName string `db:"device_name" json:"device_name"`
|
||||
DeviceType string `db:"device_type" json:"device_type"`
|
||||
}
|
||||
|
||||
// Page list, ignored bucket: rows the user dismissed, kept for the
|
||||
// collapsible "Ignored" section so a misclick is recoverable.
|
||||
func (q *Queries) ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListIgnoredUnlinkedBooksForUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListIgnoredUnlinkedBooksForUserRow{}
|
||||
for rows.Next() {
|
||||
var i ListIgnoredUnlinkedBooksForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.DeviceID,
|
||||
&i.ContentID,
|
||||
&i.FilePath,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.ConfidenceScore,
|
||||
&i.Resolved,
|
||||
&i.MediaItemID,
|
||||
&i.ResolvedAt,
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListLibraries = `-- name: ListLibraries :many
|
||||
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description
|
||||
FROM libraries l
|
||||
@@ -13040,7 +13177,7 @@ func (q *Queries) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUI
|
||||
}
|
||||
|
||||
const ListUnresolvedUnlinkedBooks = `-- name: ListUnresolvedUnlinkedBooks :many
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE ub.resolved = false
|
||||
@@ -13067,6 +13204,7 @@ type ListUnresolvedUnlinkedBooksRow struct {
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||
DeviceName string `db:"device_name" json:"device_name"`
|
||||
DeviceType string `db:"device_type" json:"device_type"`
|
||||
}
|
||||
@@ -13095,6 +13233,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
); err != nil {
|
||||
@@ -13109,10 +13248,10 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
|
||||
}
|
||||
|
||||
const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false
|
||||
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
||||
ORDER BY ub.last_seen_at DESC
|
||||
`
|
||||
|
||||
@@ -13130,11 +13269,13 @@ type ListUnresolvedUnlinkedBooksForUserRow struct {
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||
DeviceName string `db:"device_name" json:"device_name"`
|
||||
DeviceType string `db:"device_type" json:"device_type"`
|
||||
}
|
||||
|
||||
// Page list: unresolved queue rows for the caller's own devices.
|
||||
// Page list: unresolved queue rows for the caller's own devices (ignored
|
||||
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
|
||||
func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID)
|
||||
if err != nil {
|
||||
@@ -13158,6 +13299,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
); err != nil {
|
||||
@@ -13814,7 +13956,7 @@ SET
|
||||
resolved_at = NOW(),
|
||||
resolution_method = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
|
||||
`
|
||||
|
||||
type ResolveUnlinkedBookParams struct {
|
||||
@@ -13841,6 +13983,7 @@ func (q *Queries) ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBo
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -15246,6 +15389,16 @@ func (q *Queries) TombstoneSystemCollection(ctx context.Context, id pgtype.UUID)
|
||||
return err
|
||||
}
|
||||
|
||||
const UnignoreUnlinkedBook = `-- name: UnignoreUnlinkedBook :exec
|
||||
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1
|
||||
`
|
||||
|
||||
// Undo a soft-dismiss.
|
||||
func (q *Queries) UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, UnignoreUnlinkedBook, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const UpdateCollection = `-- name: UpdateCollection :one
|
||||
UPDATE collections
|
||||
SET
|
||||
@@ -17649,7 +17802,7 @@ SET file_path = EXCLUDED.file_path,
|
||||
author = EXCLUDED.author,
|
||||
confidence_score = EXCLUDED.confidence_score,
|
||||
last_seen_at = NOW()
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
|
||||
`
|
||||
|
||||
type UpsertUnlinkedBookParams struct {
|
||||
@@ -17687,6 +17840,7 @@ func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBook
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -3701,12 +3701,12 @@ INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, con
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *;
|
||||
|
||||
-- Get unlinked books for a device
|
||||
-- Get unlinked books for a device (ignored rows stay hidden)
|
||||
-- name: GetUnlinkedBooksByDevice :many
|
||||
SELECT ub.*, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE ub.device_id = $1 AND ub.resolved = false
|
||||
WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
||||
ORDER BY ub.last_seen_at DESC;
|
||||
|
||||
-- Get unlinked book by ContentId
|
||||
@@ -3724,11 +3724,11 @@ SET
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- Count unlinked books for a device
|
||||
-- Count unlinked books for a device (ignored rows stay hidden)
|
||||
-- name: CountUnlinkedBooks :one
|
||||
SELECT COUNT(*) as count
|
||||
FROM unlinked_books
|
||||
WHERE device_id = $1 AND resolved = false;
|
||||
WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL;
|
||||
|
||||
-- Link unlinked book to media item
|
||||
-- name: LinkUnlinkedBook :one
|
||||
@@ -3772,21 +3772,48 @@ SET file_path = EXCLUDED.file_path,
|
||||
last_seen_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- Banner count: unresolved queue rows across the caller's own devices.
|
||||
-- Banner count: unresolved, non-ignored queue rows across the caller's own
|
||||
-- devices. Ignored rows must not nag from the dashboard badge.
|
||||
-- name: CountUnresolvedUnlinkedBooksForUser :one
|
||||
SELECT COUNT(*) as count
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false;
|
||||
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL;
|
||||
|
||||
-- Page list: unresolved queue rows for the caller's own devices.
|
||||
-- Page list: unresolved queue rows for the caller's own devices (ignored
|
||||
-- rows are listed by ListIgnoredUnlinkedBooksForUser instead).
|
||||
-- name: ListUnresolvedUnlinkedBooksForUser :many
|
||||
SELECT ub.*, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false
|
||||
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
||||
ORDER BY ub.last_seen_at DESC;
|
||||
|
||||
-- Page list, ignored bucket: rows the user dismissed, kept for the
|
||||
-- collapsible "Ignored" section so a misclick is recoverable.
|
||||
-- name: ListIgnoredUnlinkedBooksForUser :many
|
||||
SELECT ub.*, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
|
||||
ORDER BY ub.ignored_at DESC;
|
||||
|
||||
-- Soft-dismiss one row (ownership is checked by the caller against the
|
||||
-- device's user_id before calling this).
|
||||
-- name: IgnoreUnlinkedBook :exec
|
||||
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1;
|
||||
|
||||
-- Undo a soft-dismiss.
|
||||
-- name: UnignoreUnlinkedBook :exec
|
||||
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1;
|
||||
|
||||
-- Fetch one row together with its device's owner for authorization.
|
||||
-- name: GetUnlinkedBookWithOwner :one
|
||||
SELECT ub.*, d.user_id, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE ub.id = $1;
|
||||
|
||||
-- ============================================
|
||||
-- CAROUSEL-STYLE DASHBOARD
|
||||
-- ============================================
|
||||
|
||||
+127
-1
@@ -767,6 +767,8 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// resolution rules as the dashboard page (family-lens safe).
|
||||
// Unlinked-books page: the identification queue for device files the
|
||||
// sync could not match (personal data — the caller's own devices only).
|
||||
// Unlinked-book queue page: unresolved rows plus the ignored bucket
|
||||
// (soft-dismissed rows, kept for the collapsible recovery section).
|
||||
frontendProtected.GET("/unlinked-books", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
@@ -781,6 +783,11 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr)
|
||||
rows = []database.ListUnresolvedUnlinkedBooksForUserRow{}
|
||||
}
|
||||
ignoredRows, ignoredErr := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if ignoredErr != nil {
|
||||
log.Printf("ListIgnoredUnlinkedBooksForUser failed: %v", ignoredErr)
|
||||
ignoredRows = []database.ListIgnoredUnlinkedBooksForUserRow{}
|
||||
}
|
||||
|
||||
books := make([]templates.UnlinkedBookData, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
@@ -796,14 +803,133 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
ConfidenceScore: r.ConfidenceScore.Float64,
|
||||
})
|
||||
}
|
||||
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
|
||||
for _, r := range ignoredRows {
|
||||
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
|
||||
ProgressID: uuid.UUID(r.ID.Bytes).String(),
|
||||
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
|
||||
DeviceType: r.DeviceType,
|
||||
DeviceName: r.DeviceName,
|
||||
FilePath: utils.PgTextString(r.FilePath),
|
||||
SHA256: r.ContentID,
|
||||
TitleFromDevice: utils.PgTextString(r.Title),
|
||||
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, user.Timezone),
|
||||
ConfidenceScore: r.ConfidenceScore.Float64,
|
||||
})
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil {
|
||||
if renderErr := templates.UnlinkedBooks(user, books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Unlinked-book dismiss/delete actions (htmx: the buttons post/delete
|
||||
// and swap in the re-rendered section). Authorization is row → device
|
||||
// → requesting user (admin may act on anyone's rows); a non-htmx hit
|
||||
// just bounces back to the page. These are page-scoped SSR routes, so
|
||||
// they are intentionally NOT under /api and not in the OpenAPI docs.
|
||||
unlinkedSectionHTML := func(c *echo.Context, dbUser database.Users) error {
|
||||
userUUID, err := uuid.Parse(dbUser.ID.String())
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
|
||||
}
|
||||
rows, err := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
|
||||
}
|
||||
ignoredRows, err := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
|
||||
}
|
||||
books := make([]templates.UnlinkedBookData, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
books = append(books, templates.UnlinkedBookData{
|
||||
ProgressID: uuid.UUID(r.ID.Bytes).String(),
|
||||
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
|
||||
DeviceType: r.DeviceType,
|
||||
DeviceName: r.DeviceName,
|
||||
FilePath: utils.PgTextString(r.FilePath),
|
||||
SHA256: r.ContentID,
|
||||
TitleFromDevice: utils.PgTextString(r.Title),
|
||||
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
|
||||
ConfidenceScore: r.ConfidenceScore.Float64,
|
||||
})
|
||||
}
|
||||
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
|
||||
for _, r := range ignoredRows {
|
||||
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
|
||||
ProgressID: uuid.UUID(r.ID.Bytes).String(),
|
||||
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
|
||||
DeviceType: r.DeviceType,
|
||||
DeviceName: r.DeviceName,
|
||||
FilePath: utils.PgTextString(r.FilePath),
|
||||
SHA256: r.ContentID,
|
||||
TitleFromDevice: utils.PgTextString(r.Title),
|
||||
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
|
||||
ConfidenceScore: r.ConfidenceScore.Float64,
|
||||
})
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if renderErr := templates.UnlinkedBooksSection(books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}
|
||||
// authorizeUnlinkedAction resolves the :id row with its device owner
|
||||
// and gates the action: nil row + status/message on failure.
|
||||
authorizeUnlinkedAction := func(c *echo.Context, dbUser database.Users) (*database.GetUnlinkedBookWithOwnerRow, int, string) {
|
||||
rowID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return nil, http.StatusBadRequest, "<div>Invalid entry id</div>"
|
||||
}
|
||||
row, err := cfg.Queries.GetUnlinkedBookWithOwner(c.Request().Context(), pgtype.UUID{Bytes: rowID, Valid: true})
|
||||
if err != nil {
|
||||
return nil, http.StatusNotFound, "<div>Entry not found</div>"
|
||||
}
|
||||
ownerID := uuid.UUID(row.UserID.Bytes).String()
|
||||
if ownerID != dbUser.ID.String() && dbUser.Role != "admin" {
|
||||
return nil, http.StatusForbidden, "<div>Not allowed</div>"
|
||||
}
|
||||
return &row, 0, ""
|
||||
}
|
||||
for _, action := range []struct {
|
||||
method string
|
||||
path string
|
||||
run func(ctx context.Context, rowID pgtype.UUID) error
|
||||
}{
|
||||
{"POST", "/unlinked-books/:id/ignore", func(ctx context.Context, rowID pgtype.UUID) error {
|
||||
return cfg.Queries.IgnoreUnlinkedBook(ctx, rowID)
|
||||
}},
|
||||
{"POST", "/unlinked-books/:id/unignore", func(ctx context.Context, rowID pgtype.UUID) error {
|
||||
return cfg.Queries.UnignoreUnlinkedBook(ctx, rowID)
|
||||
}},
|
||||
{"DELETE", "/unlinked-books/:id", func(ctx context.Context, rowID pgtype.UUID) error {
|
||||
return cfg.Queries.DeleteUnlinkedBook(ctx, rowID)
|
||||
}},
|
||||
} {
|
||||
method, path, run := action.method, action.path, action.run
|
||||
frontendProtected.Add(method, path, func(c *echo.Context) error {
|
||||
dbUser, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
|
||||
}
|
||||
row, status, msg := authorizeUnlinkedAction(c, dbUser)
|
||||
if row == nil {
|
||||
return c.HTML(status, msg)
|
||||
}
|
||||
if err := run(c.Request().Context(), row.ID); err != nil {
|
||||
log.Printf("unlinked-book action %s %s failed: %v", method, path, err)
|
||||
return c.HTML(http.StatusInternalServerError, "<div>Action failed</div>")
|
||||
}
|
||||
if c.Request().Header.Get("HX-Request") != "true" {
|
||||
return c.Redirect(http.StatusSeeOther, "/unlinked-books")
|
||||
}
|
||||
return unlinkedSectionHTML(c, dbUser)
|
||||
})
|
||||
}
|
||||
|
||||
// Unlinked-book match results partial: the match cascade for one device
|
||||
// file, rendered server-side and scoped to the requester's visible
|
||||
// libraries (absent-from-results) — rendering-ownership: templ renders,
|
||||
|
||||
Reference in new issue
Block a user