diff --git a/internal/database/querier.go b/internal/database/querier.go index 5b58a21..0fe42e3 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -110,6 +110,8 @@ type Querier interface { DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error // Delete system config DeleteSystemConfig(ctx context.Context, key string) error + // Delete unlinked book + DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error DeleteUser(ctx context.Context, id pgtype.UUID) error GenerateKoboEntitlementId(ctx context.Context) (interface{}, error) // Get all system config @@ -191,6 +193,8 @@ type Querier interface { GetOpdsToken(ctx context.Context, token string) (GetOpdsTokenRow, error) // Get OPDS tokens by device GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error) + // Get most popular books for a user + GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error) // Get reading history for a user and book GetReadingHistory(ctx context.Context, arg GetReadingHistoryParams) ([]ReadingHistory, error) GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error) @@ -207,16 +211,23 @@ type Querier interface { GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error) // Get unlinked book by ContentId 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 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) GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) + // Get user device usage statistics + GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error) GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error) GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UUID) ([]GetUserMediaItemsForSyncRow, error) GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error) GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error) + // Analytics queries + // Get user reading history for analytics + GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error) // Check if book is in collection @@ -224,7 +235,6 @@ type Querier interface { IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfParams) (bool, error) // Link unlinked book to media item LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error) - ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error) ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error) @@ -236,6 +246,8 @@ type Querier interface { ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error) ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error) + // List unresolved unlinked books with pagination + ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error) // Query media items by multiple identifiers with confidence scoring QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index bcfc13a..c5e3538 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -1407,6 +1407,16 @@ func (q *Queries) DeleteSystemConfig(ctx context.Context, key string) error { return err } +const DeleteUnlinkedBook = `-- name: DeleteUnlinkedBook :exec +DELETE FROM unlinked_books WHERE id = $1 +` + +// Delete unlinked book +func (q *Queries) DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, DeleteUnlinkedBook, id) + return err +} + const DeleteUser = `-- name: DeleteUser :exec DELETE FROM users WHERE id = $1 ` @@ -3482,6 +3492,64 @@ func (q *Queries) GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUI return items, nil } +const GetPopularBooks = `-- name: GetPopularBooks :many +SELECT + mi.id, + mi.title, + mi.author, + COUNT(*) as read_count, + AVG(rh.progress_percentage) as avg_completion, + MAX(rh.created_at) as last_read + FROM media_items mi + JOIN reading_history rh ON rh.media_item_id = mi.id + WHERE rh.user_id = $1 + GROUP BY mi.id, mi.title, mi.author + ORDER BY read_count DESC + LIMIT $2 +` + +type GetPopularBooksParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + Limit int32 `db:"limit" json:"limit"` +} + +type GetPopularBooksRow struct { + ID pgtype.UUID `db:"id" json:"id"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + ReadCount int64 `db:"read_count" json:"read_count"` + AvgCompletion float64 `db:"avg_completion" json:"avg_completion"` + LastRead interface{} `db:"last_read" json:"last_read"` +} + +// Get most popular books for a user +func (q *Queries) GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error) { + rows, err := q.db.Query(ctx, GetPopularBooks, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetPopularBooksRow{} + for rows.Next() { + var i GetPopularBooksRow + if err := rows.Scan( + &i.ID, + &i.Title, + &i.Author, + &i.ReadCount, + &i.AvgCompletion, + &i.LastRead, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetReadingHistory = `-- name: GetReadingHistory :many SELECT id, user_id, media_item_id, device_id, progress_percentage, reading_session_start, reading_session_end, pages_read, time_spent_seconds, device_metadata, created_at FROM reading_history @@ -3899,6 +3967,32 @@ func (q *Queries) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinke 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 +` + +// Get unlinked book by ID +func (q *Queries) GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error) { + row := q.db.QueryRow(ctx, GetUnlinkedBookByID, id) + var i UnlinkedBooks + 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, + ) + 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 FROM unlinked_books ub @@ -4094,6 +4188,58 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs return i, err } +const GetUserDeviceUsage = `-- name: GetUserDeviceUsage :many +SELECT + d.id, + d.device_name, + d.device_type, + COUNT(*) as sync_count, + MAX(rh.created_at) as last_sync, + SUM(rh.time_spent_seconds) as total_time_seconds +FROM devices d +JOIN reading_history rh ON rh.device_id = d.id +WHERE d.user_id = $1 +GROUP BY d.id, d.device_name, d.device_type +ORDER BY sync_count DESC +` + +type GetUserDeviceUsageRow struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceName string `db:"device_name" json:"device_name"` + DeviceType string `db:"device_type" json:"device_type"` + SyncCount int64 `db:"sync_count" json:"sync_count"` + LastSync interface{} `db:"last_sync" json:"last_sync"` + TotalTimeSeconds int64 `db:"total_time_seconds" json:"total_time_seconds"` +} + +// Get user device usage statistics +func (q *Queries) GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error) { + rows, err := q.db.Query(ctx, GetUserDeviceUsage, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUserDeviceUsageRow{} + for rows.Next() { + var i GetUserDeviceUsageRow + if err := rows.Scan( + &i.ID, + &i.DeviceName, + &i.DeviceType, + &i.SyncCount, + &i.LastSync, + &i.TotalTimeSeconds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetUserForLogin = `-- name: GetUserForLogin :one SELECT id, email, username, password_hash, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1 OR username = $1 ` @@ -4296,6 +4442,95 @@ func (q *Queries) GetUserProgressForBooks(ctx context.Context, arg GetUserProgre return items, nil } +const GetUserReadingHistory = `-- name: GetUserReadingHistory :many + +SELECT + rh.id, + rh.user_id, + rh.media_item_id, + rh.device_id, + rh.progress_percentage, + rh.reading_session_start, + rh.reading_session_end, + rh.pages_read, + rh.time_spent_seconds, + rh.device_metadata, + rh.created_at, + mi.title, + mi.author, + d.device_name, + d.device_type +FROM reading_history rh +JOIN media_items mi ON rh.media_item_id = mi.id +LEFT JOIN devices d ON rh.device_id = d.id +WHERE rh.user_id = $1 + AND rh.created_at >= $2 + AND rh.created_at <= $3 +ORDER BY rh.created_at DESC +` + +type GetUserReadingHistoryParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + CreatedAt_2 pgtype.Timestamptz `db:"created_at_2" json:"created_at_2"` +} + +type GetUserReadingHistoryRow 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"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"` + ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"` + ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"` + PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"` + TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"` + DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + DeviceName pgtype.Text `db:"device_name" json:"device_name"` + DeviceType pgtype.Text `db:"device_type" json:"device_type"` +} + +// Analytics queries +// Get user reading history for analytics +func (q *Queries) GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error) { + rows, err := q.db.Query(ctx, GetUserReadingHistory, arg.UserID, arg.CreatedAt, arg.CreatedAt_2) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUserReadingHistoryRow{} + for rows.Next() { + var i GetUserReadingHistoryRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.MediaItemID, + &i.DeviceID, + &i.ProgressPercentage, + &i.ReadingSessionStart, + &i.ReadingSessionEnd, + &i.PagesRead, + &i.TimeSpentSeconds, + &i.DeviceMetadata, + &i.CreatedAt, + &i.Title, + &i.Author, + &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 GetUserVisibleLibraries = `-- name: GetUserVisibleLibraries :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, COALESCE(lv.is_visible, true) as is_visible @@ -4467,67 +4702,6 @@ func (q *Queries) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookPara return i, err } -const ListAllConflictsByUserAndStatus = `-- name: ListAllConflictsByUserAndStatus :many -SELECT sc.id, sc.media_item_id, sc.user_id, sc.conflict_type, sc.conflict_data, sc.resolution_status, sc.resolution_data, sc.resolved_by, sc.resolved_at, sc.created_at, mi.title, mi.author -FROM sync_conflicts sc -JOIN media_items mi ON sc.media_item_id = mi.id -WHERE sc.user_id = $1 AND sc.resolution_status = $2 -ORDER BY sc.created_at DESC -` - -type ListAllConflictsByUserAndStatusParams struct { - UserID pgtype.UUID `db:"user_id" json:"user_id"` - ResolutionStatus pgtype.Text `db:"resolution_status" json:"resolution_status"` -} - -type ListAllConflictsByUserAndStatusRow struct { - ID pgtype.UUID `db:"id" json:"id"` - MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` - UserID pgtype.UUID `db:"user_id" json:"user_id"` - ConflictType string `db:"conflict_type" json:"conflict_type"` - ConflictData []byte `db:"conflict_data" json:"conflict_data"` - ResolutionStatus pgtype.Text `db:"resolution_status" json:"resolution_status"` - ResolutionData []byte `db:"resolution_data" json:"resolution_data"` - ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"` - ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"` - CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` - Title string `db:"title" json:"title"` - Author pgtype.Text `db:"author" json:"author"` -} - -func (q *Queries) ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error) { - rows, err := q.db.Query(ctx, ListAllConflictsByUserAndStatus, arg.UserID, arg.ResolutionStatus) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListAllConflictsByUserAndStatusRow{} - for rows.Next() { - var i ListAllConflictsByUserAndStatusRow - if err := rows.Scan( - &i.ID, - &i.MediaItemID, - &i.UserID, - &i.ConflictType, - &i.ConflictData, - &i.ResolutionStatus, - &i.ResolutionData, - &i.ResolvedBy, - &i.ResolvedAt, - &i.CreatedAt, - &i.Title, - &i.Author, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const ListAllSyncQueueItems = `-- name: ListAllSyncQueueItems :many SELECT sq.id, sq.device_id, sq.media_item_id, sq.sync_type, sq.sync_data, sq.priority, sq.attempts, sq.max_attempts, sq.status, sq.error_message, sq.created_at, sq.processed_at, @@ -5503,6 +5677,75 @@ func (q *Queries) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUI return items, nil } +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 +FROM unlinked_books ub +JOIN devices d ON ub.device_id = d.id +WHERE ub.resolved = false +ORDER BY ub.last_seen_at DESC +LIMIT $1 OFFSET $2 +` + +type ListUnresolvedUnlinkedBooksParams struct { + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +type ListUnresolvedUnlinkedBooksRow 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"` + DeviceName string `db:"device_name" json:"device_name"` + DeviceType string `db:"device_type" json:"device_type"` +} + +// List unresolved unlinked books with pagination +func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) { + rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooks, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListUnresolvedUnlinkedBooksRow{} + for rows.Next() { + var i ListUnresolvedUnlinkedBooksRow + 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.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 ListUsers = `-- name: ListUsers :many SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users ORDER BY created_at DESC ` diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 16e1fb6..2d41b57 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -627,7 +627,66 @@ INSERT INTO reading_history ( VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; --- ============================================ +-- Analytics queries + +-- Get user reading history for analytics +-- name: GetUserReadingHistory :many +SELECT + rh.id, + rh.user_id, + rh.media_item_id, + rh.device_id, + rh.progress_percentage, + rh.reading_session_start, + rh.reading_session_end, + rh.pages_read, + rh.time_spent_seconds, + rh.device_metadata, + rh.created_at, + mi.title, + mi.author, + d.device_name, + d.device_type +FROM reading_history rh +JOIN media_items mi ON rh.media_item_id = mi.id +LEFT JOIN devices d ON rh.device_id = d.id +WHERE rh.user_id = $1 + AND rh.created_at >= $2 + AND rh.created_at <= $3 +ORDER BY rh.created_at DESC; + +-- Get user device usage statistics +-- name: GetUserDeviceUsage :many +SELECT + d.id, + d.device_name, + d.device_type, + COUNT(*) as sync_count, + MAX(rh.created_at) as last_sync, + SUM(rh.time_spent_seconds) as total_time_seconds +FROM devices d +JOIN reading_history rh ON rh.device_id = d.id +WHERE d.user_id = $1 +GROUP BY d.id, d.device_name, d.device_type +ORDER BY sync_count DESC; + +-- Get most popular books for a user +-- name: GetPopularBooks :many +SELECT + mi.id, + mi.title, + mi.author, + COUNT(*) as read_count, + AVG(rh.progress_percentage) as avg_completion, + MAX(rh.created_at) as last_read + FROM media_items mi + JOIN reading_history rh ON rh.media_item_id = mi.id + WHERE rh.user_id = $1 + GROUP BY mi.id, mi.title, mi.author + ORDER BY read_count DESC + LIMIT $2; + + -- ============================================ -- PHASE 2: DEVICE MANAGEMENT & AUTH (Weeks 5-6) -- ============================================ @@ -818,12 +877,19 @@ RETURNING *; -- name: DeleteSyncConflict :exec DELETE FROM sync_conflicts WHERE id = $1; --- name: ListAllConflictsByUserAndStatus :many -SELECT sc.*, mi.title, mi.author -FROM sync_conflicts sc -JOIN media_items mi ON sc.media_item_id = mi.id -WHERE sc.user_id = $1 AND sc.resolution_status = $2 -ORDER BY sc.created_at DESC; + -- name: ListAllConflictsByUserAndStatus :many + SELECT sc.*, mi.title, mi.author + FROM sync_conflicts sc + JOIN media_items mi ON sc.media_item_id = mi.id + WHERE sc.user_id = $1 AND sc.resolution_status = $2 + ORDER BY sc.created_at DESC; + + -- name: ListConflictsByUser :many + SELECT sc.*, mi.title, mi.author + FROM sync_conflicts sc + JOIN media_items mi ON sc.media_item_id = mi.id + WHERE sc.user_id = $1 + ORDER BY sc.created_at DESC; -- ============================================ -- PHASE 3: KOREADER SYNC PROTOCOL (Weeks 7-9) @@ -1467,4 +1533,21 @@ SET resolved_at = NOW(), resolution_method = 'manual_link' WHERE id = $1 -RETURNING *; \ No newline at end of file +RETURNING *; + +-- Get unlinked book by ID +-- name: GetUnlinkedBookByID :one +SELECT * FROM unlinked_books WHERE id = $1; + +-- Delete unlinked book +-- name: DeleteUnlinkedBook :exec +DELETE FROM unlinked_books WHERE id = $1; + +-- List unresolved unlinked books with pagination +-- name: ListUnresolvedUnlinkedBooks :many +SELECT ub.*, d.device_name, d.device_type +FROM unlinked_books ub +JOIN devices d ON ub.device_id = d.id +WHERE ub.resolved = false +ORDER BY ub.last_seen_at DESC +LIMIT $1 OFFSET $2; \ No newline at end of file