Files
bookhoard/internal/database/queries/queries.sql
T
john-okeefe 516cec5a7f feat: migrate tags and contributors from TEXT to TEXT[] arrays
Convert tags and contributors columns from comma-separated strings to PostgreSQL
TEXT[] arrays for better data normalization and query performance.

Database Changes:
- schema.sql: Change tags/contributors from TEXT to TEXT[]
- schema.sql: Add GIN indexes for fast array searches
- queries.sql: Update search queries to use ANY() operator
- queries.sql: Update fuzzy search with unnest() for arrays

Generated Code (sqlc):
- models.go: Auto-generated with []string types for tags/contributors
- queries.sql.go: Auto-generated with proper array handling

Handler Changes:
- media.go: Update request structs to use []string for tags/contributors
- media.go: Remove pgtype.Text wrapping, use direct array assignment
- media.go: Add tag normalization in CreateMediaItemHandler
- collections.go: Update tags evaluation to join arrays for comparison
- collections.go: Add strings import for Join() function

Service Changes:
- ebook_scanner.go: Update EbookMetadata struct to use []string
- ebook_scanner.go: Remove string Join(), assign arrays directly
- collection_service.go: Update tags rule evaluation to join arrays
- collection_service.go: Add strings import

New Utilities:
- internal/utils/tags.go: Create NormalizeTags(), JoinTags(), SplitTags()
- Normalizes tags by trimming, lowercasing, removing duplicates/empties

API Documentation:
- bruno/media-items/Create Media Item.bru: Update examples to use arrays
- bruno/media-items/Update Media Item.bru: Update examples to use arrays
- Update docs: tags/contributors now array of string

Breaking Change:
- JSON format changes from "tags": "tag1,tag2" to "tags": ["tag1", "tag2"]
- Tests already use array format (no changes needed)

Benefits:
- GIN indexes enable faster array searches
- Normalization prevents data quality issues (case, duplicates)
- Array operations use PostgreSQL native operators (ANY, &&, unnest)
- Better separation of concerns (no string parsing in application)
2026-02-07 22:53:12 -05:00

1569 lines
45 KiB
SQL

-- name: CreateUser :one
INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email, username, theme, first_name, last_name, role, created_at, updated_at;
-- name: GetUserByEmail :one
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1;
-- name: GetUserByUsername :one
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE username = $1;
-- name: GetUserByEmailOrUsername :one
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1 OR username = $1;
-- 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;
-- name: GetUser :one
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE id = $1;
-- name: GetUserPasswordHash :one
SELECT password_hash FROM users WHERE id = $1;
-- name: ListUsers :many
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users ORDER BY created_at DESC;
-- Library Types queries
-- name: GetLibraryTypes :many
SELECT * FROM library_types ORDER BY name;
-- name: GetLibraryType :one
SELECT * FROM library_types WHERE id = $1;
-- name: GetLibraryTypeByName :one
SELECT * FROM library_types WHERE name = $1;
-- Libraries queries
-- name: CreateLibrary :one
INSERT INTO libraries (name, description, library_type_id, created_by_admin_id)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetLibrary :one
SELECT l.*, lt.name as type_name, lt.description as type_description
FROM libraries l
JOIN library_types lt ON l.library_type_id = lt.id
WHERE l.id = $1;
-- name: ListLibraries :many
SELECT l.*, lt.name as type_name, lt.description as type_description
FROM libraries l
JOIN library_types lt ON l.library_type_id = lt.id
ORDER BY l.created_at DESC;
-- name: UpdateLibrary :one
UPDATE libraries SET
name = $2,
description = $3,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: DeleteLibrary :exec
DELETE FROM libraries WHERE id = $1;
-- Library Folders queries
-- name: AddLibraryFolder :one
INSERT INTO library_folders (library_id, folder_path) VALUES ($1, $2) RETURNING *;
-- name: GetLibraryFolders :many
SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at;
-- name: DeleteLibraryFolder :one
DELETE FROM library_folders WHERE library_id = $1 AND folder_path = $2 RETURNING *;
-- name: GetLibraryByFolder :one
SELECT lf.library_id, l.* FROM library_folders lf
JOIN libraries l ON lf.library_id = l.id
WHERE lf.folder_path = $1;
-- Library Visibility queries
-- name: SetLibraryVisibility :one
INSERT INTO library_visibility (user_id, library_id, is_visible)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, library_id)
DO UPDATE SET
is_visible = EXCLUDED.is_visible,
updated_at = NOW()
RETURNING *;
-- name: GetLibraryVisibility :one
SELECT * FROM library_visibility WHERE user_id = $1 AND library_id = $2;
-- name: GetUserVisibleLibraries :many
SELECT l.*, lt.name as type_name, lt.description as type_description,
COALESCE(lv.is_visible, true) as is_visible
FROM libraries l
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
WHERE COALESCE(lv.is_visible, true) = true
ORDER BY l.created_at DESC;
-- Media Items queries
-- name: CreateMediaItem :one
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
RETURNING *;
-- name: GetMediaItem :one
SELECT * FROM media_items WHERE id = $1;
-- name: ListMediaItems :many
SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
-- name: ListMediaItemsByLibrary :many
SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC;
-- name: ListMediaItemsSorted :many
SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.library_id = sqlc.narg('library_id')
ORDER BY
CASE
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
ELSE ''
END ASC,
CASE
WHEN sqlc.narg('sort') = 'title DESC' THEN mi.title
ELSE ''
END DESC,
CASE
WHEN sqlc.narg('sort') = 'author ASC' THEN COALESCE(mi.author, '')
ELSE ''
END ASC,
CASE
WHEN sqlc.narg('sort') = 'author DESC' THEN COALESCE(mi.author, '')
ELSE ''
END DESC,
CASE
WHEN sqlc.narg('sort') = 'created_at ASC' THEN mi.created_at
ELSE '1970-01-01'::timestamp
END ASC,
CASE
WHEN sqlc.narg('sort') = 'created_at DESC' THEN mi.created_at
ELSE '1970-01-01'::timestamp
END DESC,
CASE
WHEN sqlc.narg('sort') = 'series ASC' THEN COALESCE(mi.series, '')
ELSE ''
END ASC,
CASE
WHEN sqlc.narg('sort') = 'series DESC' THEN COALESCE(mi.series, '')
ELSE ''
END DESC,
CASE
WHEN sqlc.narg('sort') = 'date_published ASC' THEN COALESCE(mi.date_published::text, '')
ELSE ''
END ASC,
CASE
WHEN sqlc.narg('sort') = 'date_published DESC' THEN COALESCE(mi.date_published::text, '')
ELSE ''
END DESC,
CASE
WHEN sqlc.narg('sort') = 'copyright_year ASC' THEN COALESCE(mi.copyright_year::text, '0')
ELSE ''
END ASC,
CASE
WHEN sqlc.narg('sort') = 'copyright_year DESC' THEN COALESCE(mi.copyright_year::text, '0')
ELSE ''
END DESC,
CASE
WHEN sqlc.narg('sort') = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
ELSE ''
END ASC,
CASE
WHEN sqlc.narg('sort') = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
ELSE ''
END DESC,
CASE
WHEN sqlc.narg('sort') = 'genre ASC' THEN COALESCE(mi.genre, '')
ELSE ''
END ASC,
CASE
WHEN sqlc.narg('sort') = 'genre DESC' THEN COALESCE(mi.genre, '')
ELSE ''
END DESC,
mi.created_at DESC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: ListMediaItemsFiltered :many
SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE mi.library_id = sqlc.narg('library_id')
AND COALESCE(lv.is_visible, true) = true
AND (sqlc.narg('author_filter') = '' OR mi.author ILIKE sqlc.narg('author_filter'))
AND (sqlc.narg('series_filter') = '' OR mi.series ILIKE sqlc.narg('series_filter'))
AND (sqlc.narg('genre_filter') = '' OR mi.genre = sqlc.narg('genre_filter'))
AND (sqlc.narg('language_filter') = '' OR mi.language = sqlc.narg('language_filter'))
AND (sqlc.narg('year_min') = 0 OR mi.copyright_year >= sqlc.narg('year_min'))
AND (sqlc.narg('year_max') = 0 OR mi.copyright_year <= sqlc.narg('year_max'))
AND (sqlc.narg('has_cover') = false OR mi.cover_image_path IS NOT NULL)
ORDER BY
CASE
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
ELSE ''
END ASC,
CASE
WHEN sqlc.narg('sort') = 'title DESC' THEN mi.title
ELSE ''
END DESC,
CASE
WHEN sqlc.narg('sort') = 'author ASC' THEN COALESCE(mi.author, '')
ELSE ''
END ASC,
CASE
WHEN sqlc.narg('sort') = 'author DESC' THEN COALESCE(mi.author, '')
ELSE ''
END DESC,
CASE
WHEN sqlc.narg('sort') = 'created_at ASC' THEN mi.created_at
ELSE '1970-01-01'::timestamp
END ASC,
CASE
WHEN sqlc.narg('sort') = 'created_at DESC' THEN mi.created_at
ELSE '1970-01-01'::timestamp
END DESC,
mi.created_at DESC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: UpdateMediaItem :one
UPDATE media_items SET
title = $2,
author = $3,
isbn = $4,
description = $5,
cover_image_path = $6,
series = $7,
series_number = $8,
tags = $9,
asin = $10,
date_published = $11,
publisher = $12,
contributors = $13,
language = $14,
edition = $15,
page_count = $16,
genre = $17,
copyright_year = $18,
goodreads_id = $19,
openlibrary_id = $20,
google_books_id = $21,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: DeleteMediaItem :exec
DELETE FROM media_items WHERE id = $1;
-- name: GetMediaItemByFilePath :one
SELECT * FROM media_items WHERE file_path = $1;
-- name: GetReadingProgress :one
SELECT * FROM reading_progress WHERE media_item_id = $1 AND user_id = $2;
-- name: UpdateReadingProgress :one
INSERT INTO reading_progress (media_item_id, user_id, current_page, total_pages, last_read_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
current_page = EXCLUDED.current_page,
total_pages = EXCLUDED.total_pages,
last_read_at = NOW()
RETURNING *;
-- name: DeleteReadingProgress :exec
DELETE FROM reading_progress WHERE media_item_id = $1 AND user_id = $2;
-- name: UpdateUserTheme :exec
UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserProfile :exec
UPDATE users SET first_name = $2, last_name = $3, updated_at = NOW() WHERE id = $1;
-- name: UpdateUsername :exec
UPDATE users SET username = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateEmail :exec
UPDATE users SET email = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdatePassword :exec
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserMaxDevices :exec
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1;
-- name: CountUserDevices :one
SELECT COUNT(*) FROM devices WHERE user_id = $1;
-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;
-- name: UpdateScanSettings :exec
UPDATE users SET scan_frequency_minutes = $2, auto_scan_enabled = $3, updated_at = NOW() WHERE id = $1;
-- name: GetScanSettings :one
SELECT scan_frequency_minutes, auto_scan_enabled FROM users WHERE id = $1;
-- name: CreateMediaRating :one
INSERT INTO media_ratings (media_item_id, user_id, rating)
VALUES ($1, $2, $3)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
rating = EXCLUDED.rating,
updated_at = NOW()
RETURNING *;
-- name: GetMediaRating :one
SELECT * FROM media_ratings WHERE media_item_id = $1 AND user_id = $2;
-- name: GetMediaRatings :many
SELECT mr.*, u.username
FROM media_ratings mr
JOIN users u ON mr.user_id = u.id
WHERE mr.media_item_id = $1
ORDER BY mr.created_at DESC;
-- name: UpdateMediaRating :one
UPDATE media_ratings SET
rating = $3,
updated_at = NOW()
WHERE media_item_id = $1 AND user_id = $2
RETURNING *;
-- name: DeleteMediaRating :exec
DELETE FROM media_ratings WHERE media_item_id = $1 AND user_id = $2;
-- Note: User ebook folders replaced by library folders system
-- Legacy folder management is now handled through libraries
-- Search Media Items queries
-- name: SearchMediaItems :many
SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (
mi.title ILIKE sqlc.narg('search_pattern') OR
mi.author ILIKE sqlc.narg('search_pattern') OR
mi.series ILIKE sqlc.narg('search_pattern') OR
sqlc.narg('search_pattern') = ANY(mi.tags) OR
sqlc.narg('search_pattern') = ANY(mi.contributors)
)
ORDER BY
CASE
WHEN mi.title ILIKE sqlc.narg('search_pattern') THEN 1
WHEN mi.author ILIKE sqlc.narg('search_pattern') THEN 2
WHEN mi.series ILIKE sqlc.narg('search_pattern') THEN 3
WHEN sqlc.narg('search_pattern') = ANY(mi.tags) THEN 4
ELSE 5
END,
mi.title ASC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: SearchMediaItemsFuzzy :many
SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (
word_similarity(sqlc.narg('search_query'), mi.title) > 0.3 OR
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 OR
word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 OR
EXISTS (
SELECT 1 FROM unnest(mi.tags) AS tag
WHERE word_similarity(sqlc.narg('search_query'), tag) > 0.3
LIMIT 1
) OR
EXISTS (
SELECT 1 FROM unnest(mi.contributors) AS contributor
WHERE word_similarity(sqlc.narg('search_query'), contributor) > 0.3
LIMIT 1
)
)
ORDER BY
GREATEST(
word_similarity(sqlc.narg('search_query'), mi.title),
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')),
word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')),
COALESCE(
(SELECT MAX(word_similarity(sqlc.narg('search_query'), tag))
FROM unnest(mi.tags) AS tag),
0
),
COALESCE(
(SELECT MAX(word_similarity(sqlc.narg('search_query'), contributor))
FROM unnest(mi.contributors) AS contributor),
0
)
) DESC,
mi.title ASC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- Media Notes queries
-- name: CreateMediaNote :one
INSERT INTO media_notes (media_item_id, user_id, content, position)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetMediaNote :one
SELECT * FROM media_notes WHERE id = $1;
-- name: GetMediaNotes :many
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
-- name: UpdateMediaNote :one
UPDATE media_notes SET
content = $2,
position = $3,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: DeleteMediaNote :exec
DELETE FROM media_notes WHERE id = $1;
-- Media Highlights queries
-- name: CreateMediaHighlight :one
INSERT INTO media_highlights (media_item_id, user_id, selection_text, start_position, end_position, color, note_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: GetMediaHighlight :one
SELECT * FROM media_highlights WHERE id = $1;
-- name: GetMediaHighlights :many
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
-- name: UpdateMediaHighlight :one
UPDATE media_highlights SET
selection_text = $2,
start_position = $3,
end_position = $4,
color = $5,
note_id = $6,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: DeleteMediaHighlight :exec
DELETE FROM media_highlights WHERE id = $1;
-- Backward compatibility - Ebook Notes queries (using views)
-- name: CreateEbookNote :one
INSERT INTO media_notes (media_item_id, user_id, content, position)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: UpdateEbookNote :one
UPDATE media_notes SET
content = $2,
position = $3,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: DeleteEbookNote :exec
DELETE FROM media_notes WHERE id = $1;
-- Refresh Tokens queries
-- name: CreateRefreshToken :one
INSERT INTO refresh_tokens (user_id, token, expires_at)
VALUES ($1, $2, $3)
RETURNING *;
-- name: GetRefreshToken :one
SELECT rt.*, u.email, u.username, u.role
FROM refresh_tokens rt
JOIN users u ON rt.user_id = u.id
WHERE rt.token = $1 AND rt.revoked_at IS NULL AND rt.expires_at > NOW();
-- name: RevokeRefreshToken :exec
UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1;
-- name: RevokeAllUserRefreshTokens :exec
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
-- name: CleanupExpiredRefreshTokens :exec
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days');
-- ============================================
-- PHASE 1: FORMAT DETECTION & PROGRESS (Week 2)
-- ============================================
-- Update media item format group information
-- name: UpdateMediaItemFormatGroup :exec
UPDATE media_items
SET
format_group = $2,
format_mimetype = $3,
is_reflowable = $4,
has_fixed_layout = $5,
total_characters = $6,
chapter_count = $7,
updated_at = NOW()
WHERE id = $1;
-- Bulk update format group for all media items
-- name: BulkUpdateFormatGroups :exec
UPDATE media_items m
SET
format_group = detect_format_group(m.mime_type, m.file_path),
format_mimetype = m.mime_type,
is_reflowable = (detect_format_group(m.mime_type, m.file_path) = 'reflowable'),
has_fixed_layout = (detect_format_group(m.mime_type, m.file_path) IN ('fixed_layout', 'comic_archive')),
updated_at = NOW()
WHERE m.format_group IS NULL OR m.format_group = 'unknown';
-- Get universal progress for a book
-- name: GetUniversalProgress :one
SELECT
rp.id,
rp.media_item_id,
rp.user_id,
rp.current_page,
rp.total_pages,
rp.last_read_at,
rp.percentage,
rp.character_offset,
rp.epubcfi,
rp.chapter,
rp.chapter_progress,
rp.viewport_x,
rp.viewport_y,
rp.zoom_level,
rp.scroll_position_x,
rp.scroll_position_y,
rp.panel_number,
rp.reading_mode,
rp.last_sync_device,
rp.last_sync_source,
rp.last_sync_timestamp,
rp.conflict_detected,
rp.conflict_resolved,
mi.format_group,
mi.format_mimetype,
mi.is_reflowable,
mi.has_fixed_layout,
mi.total_characters,
mi.chapter_count
FROM reading_progress rp
JOIN media_items mi ON rp.media_item_id = mi.id
WHERE rp.media_item_id = $1 AND rp.user_id = $2;
-- Update universal progress
-- name: UpdateUniversalProgress :one
INSERT INTO reading_progress (
media_item_id,
user_id,
percentage,
character_offset,
epubcfi,
chapter,
chapter_progress,
viewport_x,
viewport_y,
zoom_level,
scroll_position_x,
scroll_position_y,
panel_number,
reading_mode,
last_sync_device,
last_sync_source,
last_sync_timestamp,
current_page,
total_pages,
last_read_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), $17, $18, NOW()
)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
percentage = EXCLUDED.percentage,
character_offset = EXCLUDED.character_offset,
epubcfi = EXCLUDED.epubcfi,
chapter = EXCLUDED.chapter,
chapter_progress = EXCLUDED.chapter_progress,
viewport_x = EXCLUDED.viewport_x,
viewport_y = EXCLUDED.viewport_y,
zoom_level = EXCLUDED.zoom_level,
scroll_position_x = EXCLUDED.scroll_position_x,
scroll_position_y = EXCLUDED.scroll_position_y,
panel_number = EXCLUDED.panel_number,
reading_mode = EXCLUDED.reading_mode,
last_sync_device = EXCLUDED.last_sync_device,
last_sync_source = EXCLUDED.last_sync_source,
last_sync_timestamp = EXCLUDED.last_sync_timestamp,
current_page = EXCLUDED.current_page,
total_pages = EXCLUDED.total_pages,
last_read_at = NOW()
RETURNING *;
-- Get reading history for a user and book
-- name: GetReadingHistory :many
SELECT *
FROM reading_history
WHERE user_id = $1 AND media_item_id = $2
ORDER BY created_at DESC
LIMIT $3;
-- Create reading history entry
-- name: CreateReadingHistory :one
INSERT INTO reading_history (
user_id,
media_item_id,
device_id,
progress_percentage,
reading_session_start,
reading_session_end,
pages_read,
time_spent_seconds,
device_metadata
)
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)
-- ============================================
-- Device Registration & Management
-- name: CreateDevice :one
INSERT INTO devices (user_id, device_name, device_type, device_identifier, auth_token, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *;
-- name: GetDevice :one
SELECT * FROM devices WHERE id = $1;
-- name: GetDeviceByIdentifier :one
SELECT * FROM devices WHERE device_identifier = $1;
-- name: GetDeviceByAuthToken :one
SELECT * FROM devices WHERE auth_token = $1;
-- name: ListDevicesByUser :many
SELECT * FROM devices WHERE user_id = $1 ORDER BY created_at DESC;
-- name: ListDevicesByType :many
SELECT * FROM devices WHERE device_type = $1 ORDER BY created_at DESC;
-- name: UpdateDevice :one
UPDATE devices
SET
device_name = $2,
sync_enabled = $3,
auto_sync = $4,
sync_frequency_minutes = $5,
device_metadata = $6,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: UpdateDeviceLastSync :one
UPDATE devices
SET
last_sync = NOW(),
last_seen = NOW(),
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: UpdateDeviceLastSeen :one
UPDATE devices
SET
last_seen = NOW(),
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: RevokeDevice :exec
UPDATE devices
SET
auth_token = NULL,
sync_enabled = false,
updated_at = NOW()
WHERE id = $1;
-- name: DeleteDevice :exec
DELETE FROM devices WHERE id = $1;
-- name: DeleteDeviceByToken :exec
DELETE FROM devices WHERE auth_token = $1;
-- Sync Queue Management
-- name: CreateSyncQueueItem :one
INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, max_attempts, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: GetSyncQueueItem :one
SELECT * FROM sync_queue WHERE id = $1;
-- name: ListPendingSyncQueueItems :many
SELECT * FROM sync_queue
WHERE device_id = $1 AND status = 'pending'
ORDER BY priority ASC, created_at ASC
LIMIT $2;
-- name: UpdateSyncQueueItemStatus :one
UPDATE sync_queue
SET
status = $2,
attempts = attempts + 1,
error_message = $3,
processed_at = CASE WHEN $2 = 'completed' THEN NOW() ELSE NULL END
WHERE id = $1
RETURNING *;
-- name: DeleteSyncQueueItem :exec
DELETE FROM sync_queue WHERE id = $1;
-- name: ClearDeviceSyncQueue :exec
DELETE FROM sync_queue WHERE device_id = $1;
-- name: GetFailedSyncQueueItems :many
SELECT * FROM sync_queue
WHERE status = 'failed' AND attempts < max_attempts
ORDER BY priority ASC, created_at ASC
LIMIT $1;
-- name: GetStuckSyncQueueItems :many
SELECT * FROM sync_queue
WHERE status = 'processing' AND created_at < NOW() - INTERVAL '1 hour'
ORDER BY priority ASC, created_at ASC;
-- name: IncrementSyncQueueAttempts :one
UPDATE sync_queue
SET
attempts = attempts + 1,
status = CASE
WHEN attempts + 1 >= max_attempts THEN 'failed'
ELSE 'pending'
END,
error_message = $2
WHERE id = $1
RETURNING *;
-- name: GetSyncQueueStats :one
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
COUNT(*) FILTER (WHERE status = 'processing') as processing_count,
COUNT(*) FILTER (WHERE status = 'failed') as failed_count,
COUNT(*) FILTER (WHERE status = 'completed') as completed_count,
COUNT(*) as total_count
FROM sync_queue
WHERE device_id = $1;
-- name: ListAllSyncQueueItems :many
SELECT
sq.*,
d.device_name,
d.device_type,
u.email as user_email,
mi.title as media_title
FROM sync_queue sq
JOIN devices d ON sq.device_id = d.id
JOIN users u ON d.user_id = u.id
LEFT JOIN media_items mi ON sq.media_item_id = mi.id
ORDER BY sq.priority ASC, sq.created_at DESC
LIMIT $1 OFFSET $2;
-- name: GetNextRetryTime :one
SELECT
CASE
WHEN attempts = 0 THEN NOW()
WHEN attempts = 1 THEN NOW() + INTERVAL '1 minute'
WHEN attempts = 2 THEN NOW() + INTERVAL '5 minutes'
WHEN attempts = 3 THEN NOW() + INTERVAL '15 minutes'
WHEN attempts = 4 THEN NOW() + INTERVAL '1 hour'
ELSE NOW() + INTERVAL '24 hours'
END as next_retry_time;
-- Conflict Resolution
-- name: CreateSyncConflict :one
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetSyncConflict :one
SELECT * FROM sync_conflicts WHERE id = $1;
-- name: ListSyncConflictsByUser :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 = 'unresolved'
ORDER BY sc.created_at DESC;
-- name: ListSyncConflictsByMediaItem :many
SELECT * FROM sync_conflicts
WHERE media_item_id = $1 AND user_id = $2
ORDER BY created_at DESC;
-- name: ResolveSyncConflict :one
UPDATE sync_conflicts
SET
resolution_status = $2,
resolution_data = $3,
resolved_by = $4,
resolved_at = NOW()
WHERE id = $1
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: 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)
-- ============================================
-- name: GetMediaItemByFilePathForSync :one
SELECT * FROM media_items WHERE file_path = $1;
-- name: GetUserProgressForBooks :many
SELECT
rp.media_item_id,
rp.user_id,
rp.percentage,
rp.character_offset,
rp.epubcfi,
rp.chapter,
rp.chapter_progress,
rp.current_page,
rp.total_pages,
rp.last_read_at,
rp.last_sync_device,
rp.last_sync_source,
mi.title,
mi.author,
mi.file_path
FROM reading_progress rp
JOIN media_items mi ON rp.media_item_id = mi.id
WHERE rp.user_id = $1
AND rp.media_item_id = ANY($2::uuid[])
ORDER BY rp.last_read_at DESC;
-- name: BulkUpdateProgressFromSync :many
SELECT * FROM bulk_update_progress_from_koreader($1::uuid, $2::jsonb);
-- name: GetAnnotationsForBook :many
SELECT
mh.id,
mh.selection_text,
mh.start_position,
mh.end_position,
mh.color,
mh.created_at,
mh.updated_at,
'highlight' as annotation_type,
mh.percentage_start,
mh.percentage_end,
mh.epubcfi_start,
mh.epubcfi_end
FROM media_highlights mh
WHERE mh.media_item_id = $1 AND mh.user_id = $2
UNION ALL
SELECT
mn.id,
mn.content,
mn.position,
NULL as end_position,
NULL as color,
mn.created_at,
mn.updated_at,
'note' as annotation_type,
mn.percentage_location as percentage_start,
NULL as percentage_end,
mn.epubcfi_location as epubcfi_start,
NULL as epubcfi_end
FROM media_notes mn
WHERE mn.media_item_id = $1 AND mn.user_id = $2
ORDER BY created_at DESC;
-- name: UpdateDeviceSyncTimestamp :one
UPDATE devices
SET
last_sync = NOW(),
last_seen = NOW(),
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: CreateSyncHistoryEntry :one
INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, status)
VALUES ($1, $2, 'koreader_progress', $3, 5, 'completed')
RETURNING *;
-- name: GetUserMediaItemsForSync :many
SELECT
mi.id,
mi.title,
mi.author,
mi.file_path,
mi.mime_type,
mi.page_count,
mi.format_group,
mi.total_characters,
mi.chapter_count,
mi.entitlement_id,
mi.revision_number,
mi.file_size,
mi.file_sha256
FROM media_items mi
JOIN library_visibility lv ON mi.library_id = lv.library_id
WHERE lv.user_id = $1
AND lv.is_visible = true
ORDER BY mi.title ASC
LIMIT 1000;
-- name: CheckForProgressConflicts :one
SELECT COUNT(*) as conflict_count
FROM reading_progress
WHERE media_item_id = $1
AND user_id = $2
AND last_sync_timestamp > NOW() - INTERVAL '5 minutes'
AND last_sync_source != $3;
-- ============================================
-- KOBO SHELF MANAGEMENT QUERIES (Phase 4)
-- ============================================
-- name: AddBookToKoboShelf :one
INSERT INTO kobo_shelves (device_id, media_item_id, shelf_name, shelf_position)
VALUES ($1, $2, $3, $4)
ON CONFLICT (device_id, media_item_id)
DO UPDATE SET
shelf_name = EXCLUDED.shelf_name,
shelf_position = EXCLUDED.shelf_position,
last_synced_at = NOW()
RETURNING *;
-- name: RemoveBookFromKoboShelf :exec
DELETE FROM kobo_shelves WHERE device_id = $1 AND media_item_id = $2;
-- name: GetKoboShelfBooks :many
SELECT ks.*, mi.title, mi.author, mi.file_path, mi.mime_type, mi.entitlement_id, mi.kobo_content_id, mi.revision_number
FROM kobo_shelves ks
JOIN media_items mi ON ks.media_item_id = mi.id
WHERE ks.device_id = $1
ORDER BY ks.shelf_position ASC, ks.added_at ASC;
-- name: GetKoboShelfBooksByShelfName :many
SELECT ks.*, mi.title, mi.author, mi.file_path, mi.mime_type, mi.entitlement_id, mi.kobo_content_id, mi.revision_number
FROM kobo_shelves ks
JOIN media_items mi ON ks.media_item_id = mi.id
WHERE ks.device_id = $1 AND ks.shelf_name = $2
ORDER BY ks.shelf_position ASC, ks.added_at ASC;
-- name: UpdateKoboShelfBookPosition :exec
UPDATE kobo_shelves
SET shelf_position = $3, last_synced_at = NOW()
WHERE device_id = $1 AND media_item_id = $2;
-- name: ClearKoboShelf :exec
DELETE FROM kobo_shelves WHERE device_id = $1;
-- name: ClearKoboShelfByName :exec
DELETE FROM kobo_shelves WHERE device_id = $1 AND shelf_name = $2;
-- name: IsBookOnKoboShelf :one
SELECT EXISTS(
SELECT 1 FROM kobo_shelves
WHERE device_id = $1 AND media_item_id = $2
) as on_shelf;
-- name: GetKoboShelfBookCount :one
SELECT COUNT(*) as book_count
FROM kobo_shelves
WHERE device_id = $1;
-- ============================================
-- KOBO ENTITLEMENT QUERIES (Phase 4)
-- ============================================
-- name: CreateOrUpdateKoboEntitlement :one
INSERT INTO kobo_entitlements (device_id, media_item_id, entitlement_id, content_id, revision_number, purchase_date, kobo_metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (device_id, entitlement_id)
DO UPDATE SET
content_id = EXCLUDED.content_id,
revision_number = EXCLUDED.revision_number,
purchase_date = COALESCE(EXCLUDED.purchase_date, kobo_entitlements.purchase_date),
book_status = 'installed',
sync_status = 'synced',
kobo_metadata = EXCLUDED.kobo_metadata,
updated_at = NOW()
RETURNING *;
-- name: GetKoboEntitlementsForDevice :many
SELECT ke.*, mi.title, mi.author, mi.file_path
FROM kobo_entitlements ke
JOIN media_items mi ON ke.media_item_id = mi.id
WHERE ke.device_id = $1
ORDER BY ke.accession_date DESC;
-- name: GetKoboEntitlementByContentId :one
SELECT ke.*, mi.title, mi.author, mi.file_path
FROM kobo_entitlements ke
JOIN media_items mi ON ke.media_item_id = mi.id
WHERE ke.device_id = $1 AND ke.content_id = $2;
-- name: GetKoboEntitlementByEntitlementId :one
SELECT ke.*, mi.title, mi.author, mi.file_path
FROM kobo_entitlements ke
JOIN media_items mi ON ke.media_item_id = mi.id
WHERE ke.device_id = $1 AND ke.entitlement_id = $2;
-- name: UpdateKoboEntitlementStatus :exec
UPDATE kobo_entitlements
SET book_status = $3, sync_status = 'synced', updated_at = NOW()
WHERE device_id = $1 AND entitlement_id = $2;
-- name: UpdateKoboEntitlementRevision :exec
UPDATE kobo_entitlements
SET revision_number = $3, updated_at = NOW()
WHERE device_id = $1 AND entitlement_id = $2;
-- name: DeleteKoboEntitlement :exec
DELETE FROM kobo_entitlements WHERE device_id = $1 AND entitlement_id = $2;
-- name: GenerateKoboEntitlementId :one
SELECT 'kobo_' || uuid_generate_v4()::TEXT as entitlement_id;
-- name: UpdateMediaItemKoboMetadata :one
UPDATE media_items
SET entitlement_id = $2,
kobo_content_id = $3,
revision_number = COALESCE($4, revision_number) + 1,
kobo_metadata = $5,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: GetMediaItemByKoboContentId :one
SELECT * FROM media_items WHERE kobo_content_id = $1;
-- Media Items Admin Operations
-- ============================================
-- PHASE 1: UNIVERSAL BOOK IDENTIFIERS (Week 1)
-- ============================================
-- Update media item with universal identifiers
-- name: UpdateMediaItemIdentifiers :one
UPDATE media_items
SET
file_sha256 = $2,
opf_identifier = $3,
opf_uuid = $4,
hash_confidence = $5,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- Get media item by SHA-256 hash
-- name: GetMediaItemBySHA256 :one
SELECT * FROM media_items WHERE file_sha256 = $1;
-- Get media item by OPF identifier
-- name: GetMediaItemByOPFIdentifier :one
SELECT * FROM media_items WHERE opf_identifier = $1;
-- Get media item by OPF UUID
-- name: GetMediaItemByOPFUUID :one
SELECT * FROM media_items WHERE opf_uuid = $1;
-- Query media items by multiple identifiers with confidence scoring
-- name: QueryMediaItemsByIdentifiers :many
SELECT
mi.id,
mi.file_sha256,
mi.opf_identifier,
mi.opf_uuid,
mi.isbn,
mi.asin,
mi.title,
mi.author,
mi.file_size,
mi.hash_confidence,
CASE
WHEN $1::uuid IS NOT NULL AND mi.id = $1::uuid THEN 1.0
WHEN mi.opf_uuid IS NOT NULL AND mi.opf_uuid = $2::text THEN 0.95
WHEN mi.file_sha256 IS NOT NULL AND mi.file_sha256 = $3::text THEN 0.9
WHEN mi.opf_identifier IS NOT NULL AND mi.opf_identifier = $4::text THEN 0.85
WHEN mi.isbn IS NOT NULL AND mi.isbn = $5::text THEN 0.8
WHEN mi.asin IS NOT NULL AND mi.asin = $6::text THEN 0.8
ELSE 0.5
END as confidence_score
FROM media_items mi
WHERE
($1::uuid IS NULL OR mi.id = $1::uuid)
OR ($2::text IS NULL OR mi.opf_uuid = $2::text)
OR ($3::text IS NULL OR mi.file_sha256 = $3::text)
OR ($4::text IS NULL OR mi.opf_identifier = $4::text)
OR ($5::text IS NULL OR mi.isbn = $5::text)
OR ($6::text IS NULL OR mi.asin = $6::text)
OR ($7::text IS NULL OR mi.title ILIKE '%' || $7::text || '%')
ORDER BY confidence_score DESC;
-- MEDIA ITEM FORMATS QUERIES
-- Create media item format
-- name: CreateMediaItemFormat :one
INSERT INTO media_item_formats (media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, converted_from_format_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- Get media item formats
-- name: GetMediaItemFormats :many
SELECT * FROM media_item_formats WHERE media_item_id = $1;
-- Get media item format by type
-- name: GetMediaItemFormatByType :one
SELECT * FROM media_item_formats WHERE media_item_id = $1 AND format_type = $2;
-- Get media item format by SHA-256
-- name: GetMediaItemFormatBySHA256 :one
SELECT * FROM media_item_formats WHERE file_sha256 = $1;
-- Update media item format
-- name: UpdateMediaItemFormat :one
UPDATE media_item_formats
SET
file_path = $2,
file_sha256 = $3,
file_size_bytes = $4,
mime_type = $5
WHERE id = $1
RETURNING *;
-- Delete media item format
-- name: DeleteMediaItemFormat :exec
DELETE FROM media_item_formats WHERE id = $1;
-- DEVICE FILE ALIASES QUERIES
-- Create device file alias
-- name: CreateDeviceFileAlias :one
INSERT INTO device_file_aliases (media_item_id, device_id, file_path, file_sha256, confidence_score)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- Get device file alias
-- name: GetDeviceFileAlias :one
SELECT * FROM device_file_aliases WHERE device_id = $1 AND file_path = $2;
-- Get device file aliases by device
-- name: GetDeviceFileAliasesByDevice :many
SELECT dfa.*, mi.title, mi.author
FROM device_file_aliases dfa
JOIN media_items mi ON dfa.media_item_id = mi.id
WHERE dfa.device_id = $1
ORDER BY dfa.last_seen_at DESC;
-- Get device file alias by SHA-256
-- name: GetDeviceFileAliasBySHA256 :one
SELECT dfa.*, mi.title, mi.author
FROM device_file_aliases dfa
JOIN media_items mi ON dfa.media_item_id = mi.id
WHERE dfa.file_sha256 = $1;
-- Update device file alias
-- name: UpdateDeviceFileAlias :one
UPDATE device_file_aliases
SET
media_item_id = $2,
file_sha256 = $3,
confidence_score = $4,
last_seen_at = NOW()
WHERE id = $1
RETURNING *;
-- Delete device file alias
-- name: DeleteDeviceFileAlias :exec
DELETE FROM device_file_aliases WHERE id = $1;
-- COLLECTIONS QUERIES
-- Create collection
-- name: CreateCollection :one
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- Get collection
-- name: GetCollection :one
SELECT * FROM collections WHERE id = $1;
-- Get collections by user
-- name: GetCollectionsByUser :many
SELECT * FROM collections WHERE user_id = $1 ORDER BY created_at DESC;
-- Get collection with book count
-- name: GetCollectionWithBookCount :one
SELECT
c.*,
COUNT(ci.id) as book_count
FROM collections c
LEFT JOIN collection_items ci ON c.id = ci.collection_id
WHERE c.id = $1
GROUP BY c.id;
-- Update collection
-- name: UpdateCollection :one
UPDATE collections
SET
name = $2,
description = $3,
color = $4,
icon = $5,
auto_assign_rules = $6,
view_settings = $7
WHERE id = $1
RETURNING *;
-- Delete collection
-- name: DeleteCollection :exec
DELETE FROM collections WHERE id = $1;
-- COLLECTION ITEMS QUERIES
-- Add book to collection
-- name: AddBookToCollection :one
INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id)
VALUES ($1, $2, $3)
ON CONFLICT (collection_id, media_item_id) DO NOTHING
RETURNING *;
-- Remove book from collection
-- name: RemoveBookFromCollection :exec
DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2;
-- Get collection items
-- name: GetCollectionItems :many
SELECT ci.*, mi.title, mi.author, mi.cover_image_path
FROM collection_items ci
JOIN media_items mi ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1
ORDER BY ci.added_at DESC;
-- Get collections for book
-- name: GetCollectionsForBook :many
SELECT c.*
FROM collections c
JOIN collection_items ci ON c.id = ci.collection_id
WHERE ci.media_item_id = $1;
-- Check if book is in collection
-- name: IsBookInCollection :one
SELECT EXISTS(
SELECT 1 FROM collection_items
WHERE collection_id = $1 AND media_item_id = $2
) as in_collection;
-- DEVICE SHELF MAPPINGS QUERIES
-- Create device shelf mapping
-- name: CreateDeviceShelfMapping :one
INSERT INTO device_shelf_mappings (collection_id, device_id, device_shelf_name, sync_direction)
VALUES ($1, $2, $3, $4)
ON CONFLICT (collection_id, device_id)
DO UPDATE SET
device_shelf_name = EXCLUDED.device_shelf_name,
sync_direction = EXCLUDED.sync_direction
RETURNING *;
-- Get device shelf mappings
-- name: GetDeviceShelfMappings :many
SELECT dsm.*, c.name as collection_name, c.icon as collection_icon
FROM device_shelf_mappings dsm
JOIN collections c ON dsm.collection_id = c.id
WHERE dsm.device_id = $1;
-- Get device shelf mapping
-- name: GetDeviceShelfMapping :one
SELECT * FROM device_shelf_mappings WHERE device_id = $1 AND collection_id = $2;
-- Update device shelf mapping
-- name: UpdateDeviceShelfMapping :one
UPDATE device_shelf_mappings
SET
device_shelf_name = $2,
sync_direction = $3
WHERE id = $1
RETURNING *;
-- Delete device shelf mapping
-- name: DeleteDeviceShelfMapping :exec
DELETE FROM device_shelf_mappings WHERE id = $1;
-- DEVICE CATALOGS QUERIES
-- Create device catalog entry
-- name: CreateDeviceCatalog :one
INSERT INTO device_catalogs (device_id, media_item_id, bookhoard_uuid, kobo_content_id, content_id_type, available, delivery_date, delivery_method)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (device_id, kobo_content_id)
DO UPDATE SET
available = EXCLUDED.available,
delivery_date = COALESCE(EXCLUDED.delivery_date, device_catalogs.delivery_date),
delivery_method = EXCLUDED.delivery_method
RETURNING *;
-- Get device catalog by Bookhoard UUID
-- name: GetDeviceCatalogByBookhoardUUID :one
SELECT * FROM device_catalogs WHERE device_id = $1 AND bookhoard_uuid = $2;
-- Get device catalog by Kobo ContentId
-- name: GetDeviceCatalogByKoboContentId :one
SELECT * FROM device_catalogs WHERE kobo_content_id = $1;
-- Get device catalog entries
-- name: GetDeviceCatalogEntries :many
SELECT dc.*, mi.title, mi.author
FROM device_catalogs dc
JOIN media_items mi ON dc.media_item_id = mi.id
WHERE dc.device_id = $1 AND dc.available = true
ORDER BY dc.delivery_date DESC;
-- Update device catalog availability
-- name: UpdateDeviceCatalogAvailability :exec
UPDATE device_catalogs
SET available = $2
WHERE id = $1;
-- Delete device catalog entry
-- name: DeleteDeviceCatalog :exec
DELETE FROM device_catalogs WHERE id = $1;
-- SYSTEM CONFIG QUERIES
-- Get system config
-- name: GetSystemConfig :one
SELECT * FROM system_config WHERE key = $1;
-- Get all system config
-- name: GetAllSystemConfig :many
SELECT * FROM system_config ORDER BY key;
-- Set system config
-- name: SetSystemConfig :one
INSERT INTO system_config (key, value, updated_by)
VALUES ($1, $2, $3)
ON CONFLICT (key)
DO UPDATE SET
value = EXCLUDED.value,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING *;
-- Delete system config
-- name: DeleteSystemConfig :exec
DELETE FROM system_config WHERE key = $1;
-- OPDS TOKENS QUERIES
-- Create OPDS token
-- name: CreateOpdsToken :one
INSERT INTO opds_tokens (device_id, token, token_type, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- Get OPDS token
-- name: GetOpdsToken :one
SELECT ot.*, d.device_name, d.device_type
FROM opds_tokens ot
JOIN devices d ON ot.device_id = d.id
WHERE ot.token = $1 AND ot.expires_at > NOW();
-- Get OPDS tokens by device
-- name: GetOpdsTokensByDevice :many
SELECT * FROM opds_tokens WHERE device_id = $1 AND expires_at > NOW();
-- Revoke OPDS token
-- name: RevokeOpdsToken :exec
DELETE FROM opds_tokens WHERE token = $1;
-- Cleanup expired OPDS tokens
-- name: CleanupExpiredOpdsTokens :exec
DELETE FROM opds_tokens WHERE expires_at < NOW();
-- Update Kobo shelves to support collections
-- name: UpdateKoboShelfCollection :one
UPDATE kobo_shelves
SET
collection_id = $2,
position_in_collection = $3,
last_synced_at = NOW()
WHERE id = $1
RETURNING *;
-- Get Kobo shelves by collection
-- name: GetKoboShelvesByCollection :many
SELECT ks.*, mi.title, mi.author
FROM kobo_shelves ks
JOIN media_items mi ON ks.media_item_id = mi.id
WHERE ks.device_id = $1 AND ks.collection_id = $2
ORDER BY ks.position_in_collection ASC, ks.shelf_position ASC;
-- ============================================
-- PHASE 6: ENHANCED KOBO SYNC (Week 3-4)
-- ============================================
-- Create unlinked book entry
-- 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 *;
-- Get unlinked books for a device
-- 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
ORDER BY ub.last_seen_at DESC;
-- Get unlinked book by ContentId
-- name: GetUnlinkedBookByContentId :one
SELECT * FROM unlinked_books WHERE device_id = $1 AND content_id = $2;
-- Resolve unlinked book
-- name: ResolveUnlinkedBook :one
UPDATE unlinked_books
SET
resolved = true,
media_item_id = $2,
resolved_at = NOW(),
resolution_method = $3
WHERE id = $1
RETURNING *;
-- Count unlinked books for a device
-- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count
FROM unlinked_books
WHERE device_id = $1 AND resolved = false;
-- Link unlinked book to media item
-- name: LinkUnlinkedBook :one
UPDATE unlinked_books
SET
media_item_id = $2,
confidence_score = $3,
resolved = true,
resolved_at = NOW(),
resolution_method = 'manual_link'
WHERE id = $1
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;