Files
bookhoard/database/schema/schema.sql
T
john-okeefe 7e7945fbb7 fix: make trigger creation idempotent
- Add DROP TRIGGER IF EXISTS before CREATE TRIGGER
- Fixes 'trigger already exists' error during schema initialization
- Allows schema to run multiple times safely
2026-02-10 16:54:01 -05:00

963 lines
40 KiB
PL/PgSQL

-- Consolidated Bookhoard Database Schema
-- This file contains the complete current schema for the Bookhoard media library management system
-- Create library types table
CREATE TABLE IF NOT EXISTS library_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
allowed_extensions TEXT[] NOT NULL, -- Array of allowed file extensions for this type
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Insert default library types
INSERT INTO library_types (name, description, allowed_extensions) VALUES
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.pdf']),
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'])
ON CONFLICT (name) DO NOTHING;
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(255),
last_name VARCHAR(255),
role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
theme VARCHAR(50) DEFAULT 'tokyo-night',
max_devices INTEGER DEFAULT 10,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create system_settings table
CREATE TABLE IF NOT EXISTS system_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
setting_key VARCHAR(100) UNIQUE NOT NULL,
setting_value TEXT NOT NULL,
description TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Insert default system settings
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
('scan_frequency_minutes', '60', 'How often to scan all libraries in minutes'),
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide')
ON CONFLICT (setting_key) DO NOTHING;
-- Create refresh_tokens table
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
revoked_at TIMESTAMP WITH TIME ZONE
);
-- Create libraries table
CREATE TABLE IF NOT EXISTS libraries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
library_type_id UUID NOT NULL REFERENCES library_types(id) ON DELETE RESTRICT,
created_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create library_folders table for multiple folders per library
CREATE TABLE IF NOT EXISTS library_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
folder_path VARCHAR(500) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(library_id, folder_path)
);
-- Create library_visibility table for user-specific library visibility
CREATE TABLE IF NOT EXISTS library_visibility (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
is_visible BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, library_id)
);
-- Create media_items table
CREATE TABLE IF NOT EXISTS media_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
author VARCHAR(255),
isbn VARCHAR(13), -- Still relevant for ebooks
description TEXT,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT,
mime_type VARCHAR(100),
cover_image_path VARCHAR(500),
series VARCHAR(255),
series_number INTEGER,
tags TEXT[],
asin VARCHAR(20), -- Still relevant for ebooks
date_published DATE,
publisher VARCHAR(255),
contributors TEXT[],
-- Enhanced fields for better metadata and functionality
language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1)
edition VARCHAR(100), -- Edition information
page_count INTEGER, -- Total page count
genre VARCHAR(100), -- Genre classification
copyright_year INTEGER, -- Copyright/publication year
goodreads_id VARCHAR(50), -- Goodreads identifier
openlibrary_id VARCHAR(50), -- Open Library identifier
google_books_id VARCHAR(100), -- Google Books identifier
added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Universal Sync Format Detection (Phase 1)
format_group VARCHAR(20) NOT NULL DEFAULT 'reflowable',
format_mimetype VARCHAR(100),
is_reflowable BOOLEAN DEFAULT TRUE,
has_fixed_layout BOOLEAN DEFAULT FALSE,
total_characters BIGINT,
chapter_count INTEGER,
-- Kobo-Specific Metadata (Phase 4)
entitlement_id VARCHAR(255) UNIQUE,
revision_number INTEGER DEFAULT 1,
kobo_content_id VARCHAR(255),
kobo_metadata JSONB
);
-- Add GIN indexes for fast array searches
CREATE INDEX IF NOT EXISTS idx_media_items_tags_gin ON media_items USING GIN (tags);
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING GIN (contributors);
-- Add search fields for case-insensitive, punctuation-free searching
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
-- Create GIN indexes for fast search field searches
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);
-- Create reading_progress table
CREATE TABLE IF NOT EXISTS reading_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
current_page INTEGER DEFAULT 0,
total_pages INTEGER,
last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(media_item_id, user_id),
-- Universal Progress Tracking (Phase 1)
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
character_offset BIGINT,
epubcfi TEXT,
chapter INTEGER,
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
viewport_x FLOAT DEFAULT 0,
viewport_y FLOAT DEFAULT 0,
zoom_level FLOAT DEFAULT 1.0,
scroll_position_x FLOAT DEFAULT 0,
scroll_position_y FLOAT DEFAULT 0,
panel_number INTEGER,
reading_mode VARCHAR(20),
-- Device Sync Metadata (Phase 1)
last_sync_device VARCHAR(50),
last_sync_source VARCHAR(20),
last_sync_timestamp TIMESTAMP WITH TIME ZONE,
conflict_detected BOOLEAN DEFAULT FALSE,
conflict_resolved BOOLEAN DEFAULT TRUE
);
-- Create media_ratings table
CREATE TABLE IF NOT EXISTS media_ratings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 10), -- 10-point scale for half-star precision
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(media_item_id, user_id)
);
-- Create media_notes table for user notes on media items
CREATE TABLE IF NOT EXISTS media_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
position VARCHAR(100), -- optional position (page:offset or CFI) for standalone notes
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Location Enhancements (Phase 1)
percentage_location FLOAT,
character_start INTEGER,
character_end INTEGER,
epubcfi_location TEXT,
chapter_reference INTEGER,
paragraph_reference INTEGER,
device_sync_data JSONB
);
-- Create media_highlights table for user highlights on media items
CREATE TABLE IF NOT EXISTS media_highlights (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
selection_text TEXT NOT NULL,
start_position VARCHAR(100), -- position (page:offset or CFI) where highlight starts
end_position VARCHAR(100), -- position (page:offset or CFI) where highlight ends
color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight
note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Location Enhancements (Phase 1)
percentage_start FLOAT,
percentage_end FLOAT,
character_start INTEGER,
character_end INTEGER,
epubcfi_start TEXT,
epubcfi_end TEXT,
chapter_reference INTEGER,
paragraph_start INTEGER,
paragraph_end INTEGER,
panel_number INTEGER,
device_sync_data JSONB
);
-- ============================================
-- DEVICE REGISTRY (Phase 1)
-- ============================================
CREATE TABLE IF NOT EXISTS devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_name VARCHAR(100) NOT NULL,
device_type VARCHAR(20) NOT NULL,
device_identifier VARCHAR(255) UNIQUE NOT NULL,
auth_token VARCHAR(500) UNIQUE NOT NULL,
last_sync TIMESTAMP WITH TIME ZONE,
last_seen TIMESTAMP WITH TIME ZONE,
sync_enabled BOOLEAN DEFAULT TRUE,
auto_sync BOOLEAN DEFAULT TRUE,
sync_frequency_minutes INTEGER DEFAULT 5,
device_metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- ============================================
-- SYNC QUEUE FOR OFFLINE SUPPORT (Phase 1)
-- ============================================
CREATE TABLE IF NOT EXISTS sync_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
media_item_id UUID REFERENCES media_items(id) ON DELETE CASCADE,
sync_type VARCHAR(20) NOT NULL,
sync_data JSONB NOT NULL,
priority INTEGER DEFAULT 5,
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 3,
status VARCHAR(20) DEFAULT 'pending',
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
processed_at TIMESTAMP WITH TIME ZONE
);
-- ============================================
-- CONFLICT RESOLUTION (Phase 1)
-- ============================================
CREATE TABLE IF NOT EXISTS sync_conflicts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
conflict_type VARCHAR(20) NOT NULL,
conflict_data JSONB NOT NULL,
resolution_status VARCHAR(20) DEFAULT 'unresolved',
resolution_data JSONB,
resolved_by UUID REFERENCES users(id),
resolved_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- ============================================
-- KOBO SHELF MANAGEMENT (Phase 4)
-- ============================================
CREATE TABLE IF NOT EXISTS kobo_shelves (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
shelf_name VARCHAR(100) DEFAULT 'Default',
shelf_position INTEGER DEFAULT 0,
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_synced_at TIMESTAMP WITH TIME ZONE,
UNIQUE(device_id, media_item_id)
);
CREATE INDEX IF NOT EXISTS idx_kobo_shelves_device_id ON kobo_shelves(device_id);
CREATE INDEX IF NOT EXISTS idx_kobo_shelves_media_item_id ON kobo_shelves(media_item_id);
CREATE INDEX IF NOT EXISTS idx_kobo_shelves_shelf_name ON kobo_shelves(shelf_name);
-- ============================================
-- KOBO ENTITLEMENTS TRACKING (Phase 4)
-- ============================================
CREATE TABLE IF NOT EXISTS kobo_entitlements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
entitlement_id VARCHAR(255) NOT NULL,
content_id VARCHAR(255) NOT NULL,
revision_number INTEGER DEFAULT 1,
purchase_date TIMESTAMP WITH TIME ZONE,
accession_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
book_status VARCHAR(20) DEFAULT 'installed',
sync_status VARCHAR(20) DEFAULT 'synced',
kobo_metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(device_id, entitlement_id)
);
CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_device_id ON kobo_entitlements(device_id);
CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_media_item_id ON kobo_entitlements(media_item_id);
CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_entitlement_id ON kobo_entitlements(entitlement_id);
CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_content_id ON kobo_entitlements(content_id);
-- ============================================
-- READING HISTORY (Phase 1)
-- ============================================
CREATE TABLE IF NOT EXISTS reading_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
device_id UUID REFERENCES devices(id),
progress_percentage FLOAT,
reading_session_start TIMESTAMP WITH TIME ZONE,
reading_session_end TIMESTAMP WITH TIME ZONE,
pages_read INTEGER,
time_spent_seconds INTEGER,
device_metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_library_types_name ON library_types(name);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
-- Library indexes
CREATE INDEX IF NOT EXISTS idx_libraries_name ON libraries(name);
CREATE INDEX IF NOT EXISTS idx_libraries_library_type_id ON libraries(library_type_id);
CREATE INDEX IF NOT EXISTS idx_libraries_created_by_admin_id ON libraries(created_by_admin_id);
CREATE INDEX IF NOT EXISTS idx_library_folders_library_id ON library_folders(library_id);
CREATE INDEX IF NOT EXISTS idx_library_visibility_user_id ON library_visibility(user_id);
CREATE INDEX IF NOT EXISTS idx_library_visibility_library_id ON library_visibility(library_id);
-- Media items indexes
CREATE INDEX IF NOT EXISTS idx_media_items_title ON media_items(title);
CREATE INDEX IF NOT EXISTS idx_media_items_author ON media_items(author);
CREATE INDEX IF NOT EXISTS idx_media_items_library_id ON media_items(library_id);
CREATE INDEX IF NOT EXISTS idx_media_items_added_by_admin_id ON media_items(added_by_admin_id);
CREATE INDEX IF NOT EXISTS idx_media_items_genre ON media_items(genre);
CREATE INDEX IF NOT EXISTS idx_media_items_language ON media_items(language);
CREATE INDEX IF NOT EXISTS idx_media_items_copyright_year ON media_items(copyright_year);
CREATE INDEX IF NOT EXISTS idx_media_items_page_count ON media_items(page_count);
CREATE INDEX IF NOT EXISTS idx_media_items_series ON media_items(series);
-- Progress and ratings indexes
CREATE INDEX IF NOT EXISTS idx_reading_progress_media_item_id ON reading_progress(media_item_id);
CREATE INDEX IF NOT EXISTS idx_reading_progress_user_id ON reading_progress(user_id);
CREATE INDEX IF NOT EXISTS idx_media_ratings_media_item_id ON media_ratings(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_ratings_user_id ON media_ratings(user_id);
-- Notes and highlights indexes
CREATE INDEX IF NOT EXISTS idx_media_notes_media_item_id ON media_notes(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_notes_user_id ON media_notes(user_id);
CREATE INDEX IF NOT EXISTS idx_media_highlights_media_item_id ON media_highlights(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_highlights_user_id ON media_highlights(user_id);
CREATE INDEX IF NOT EXISTS idx_media_highlights_note_id ON media_highlights(note_id);
-- ============================================
-- NEW TABLE INDEXES (Phase 1)
-- ============================================
-- Device registry indexes
CREATE INDEX IF NOT EXISTS idx_devices_user_id ON devices(user_id);
CREATE INDEX IF NOT EXISTS idx_devices_device_type ON devices(device_type);
CREATE INDEX IF NOT EXISTS idx_devices_device_identifier ON devices(device_identifier);
-- Sync queue indexes
CREATE INDEX IF NOT EXISTS idx_sync_queue_device_id ON sync_queue(device_id);
CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status);
CREATE INDEX IF NOT EXISTS idx_sync_queue_priority ON sync_queue(priority);
-- Sync conflicts indexes
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_media_item_id ON sync_conflicts(media_item_id);
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_user_id ON sync_conflicts(user_id);
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_status ON sync_conflicts(resolution_status);
-- Composite indexes for sync queue performance (Phase 7)
CREATE INDEX IF NOT EXISTS idx_sync_queue_device_status_priority ON sync_queue(device_id, status, priority ASC, created_at ASC);
CREATE INDEX IF NOT EXISTS idx_sync_queue_status_priority_created ON sync_queue(status, priority ASC, created_at ASC);
CREATE INDEX IF NOT EXISTS idx_sync_queue_device_created_at ON sync_queue(device_id, created_at DESC);
-- Composite indexes for reading progress performance (Phase 7)
CREATE INDEX IF NOT EXISTS idx_reading_progress_user_last_sync ON reading_progress(user_id, last_sync_timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_reading_progress_media_last_sync ON reading_progress(media_item_id, last_sync_timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_reading_progress_user_sync_source ON reading_progress(user_id, last_sync_source, last_sync_timestamp DESC);
-- Composite indexes for devices (Phase 7)
CREATE INDEX IF NOT EXISTS idx_devices_user_sync_enabled ON devices(user_id, sync_enabled, auto_sync);
CREATE INDEX IF NOT EXISTS idx_devices_user_type ON devices(user_id, device_type);
CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen DESC) WHERE sync_enabled = true;
-- Composite indexes for annotations (Phase 7)
CREATE INDEX IF NOT EXISTS idx_media_notes_user_media ON media_notes(user_id, media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_highlights_user_media ON media_highlights(user_id, media_item_id);
-- Reading history indexes
CREATE INDEX IF NOT EXISTS idx_reading_history_user_id ON reading_history(user_id);
CREATE INDEX IF NOT EXISTS idx_reading_history_media_item_id ON reading_history(media_item_id);
CREATE INDEX IF NOT EXISTS idx_reading_history_created_at ON reading_history(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_reading_history_user_device_created ON reading_history(user_id, device_id, created_at DESC);
-- ============================================
-- TRIGGER FUNCTIONS (Phase 1)
-- ============================================
-- Update updated_at timestamp for devices
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger for devices table
DROP TRIGGER IF EXISTS update_devices_updated_at ON devices;
CREATE TRIGGER update_devices_updated_at
BEFORE UPDATE ON devices
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Add comment explaining the rating system
COMMENT ON COLUMN media_ratings.rating IS 'Rating scale 1-10 (odd numbers = half-stars: 1,3,5,7,9 = 0.5,1.5,2.5,3.5,4.5 stars)';
-- Enhanced metadata comments
COMMENT ON COLUMN media_items.language IS 'ISO 639-1 language code (e.g., en, es, fr)';
COMMENT ON COLUMN media_items.copyright_year IS 'Copyright or publication year for filtering/sorting';
COMMENT ON COLUMN media_items.page_count IS 'Total page count for progress tracking and sorting';
COMMENT ON COLUMN media_items.genre IS 'Genre classification for filtering and discovery';
COMMENT ON COLUMN media_items.edition IS 'Edition information (e.g., "First Edition", "Revised Edition")';
COMMENT ON COLUMN media_items.goodreads_id IS 'Goodreads book identifier for integration';
COMMENT ON COLUMN media_items.openlibrary_id IS 'Open Library identifier for integration';
COMMENT ON COLUMN media_items.google_books_id IS 'Google Books identifier for integration';
-- Library Type File Extensions Notes:
-- - Ebooks: .epub, .pdf, .mobi, .azw, .azw3, .txt, .rtf, .doc, .docx, .lit, .fb2, .pdb
-- - Comics: .cbz, .cbr, .cb7, .cbt, .pdf
-- - Manga: .cbz, .cbr, .png, .jpg, .jpeg, .gif, .bmp, .webp (note: manga includes image folders)
-- Role System Notes:
-- - All users default to 'user' role
-- - Admin users can: create/manage libraries and folders, scan media, modify media metadata, delete media, control library visibility
-- - Regular users can: view visible libraries, rate media, track reading progress, create notes and highlights, manage their profile
-- - Library visibility is controlled through library_visibility table - admins can hide/show libraries per user
-- - Notes and highlights support position data (page:offset or CFI format) for precise location tracking
-- - Highlights can have associated notes for detailed annotations
-- ============================================
-- PERFORMANCE OPTIMIZATION (Phase 7)
-- ============================================
-- NOTE: ALTER SYSTEM commands are commented out for sqlc compatibility.
-- These should be run manually by DBA or via init script:
--
-- Concurrency settings for high-throughput sync operations
-- ALTER SYSTEM SET max_connections = 200;
-- ALTER SYSTEM SET shared_buffers = '256MB';
-- ALTER SYSTEM SET effective_cache_size = '1GB';
-- ALTER SYSTEM SET maintenance_work_mem = '64MB';
-- ALTER SYSTEM SET checkpoint_completion_target = 0.9;
-- ALTER SYSTEM SET wal_buffers = '16MB';
-- ALTER SYSTEM SET default_statistics_target = 100;
--
-- Enable parallel query processing for sync operations
-- ALTER SYSTEM SET max_parallel_workers_per_gather = 4;
-- ALTER SYSTEM SET max_parallel_workers = 8;
-- ALTER SYSTEM SET parallel_setup_cost = 100;
-- ALTER SYSTEM SET parallel_tuple_cost = 0.1;
--
-- Work memory for complex sync queries
-- ALTER SYSTEM SET work_mem = '16MB';
--
-- Background writer optimization for sync queue
-- ALTER SYSTEM SET bgwriter_delay = 200ms;
-- ALTER SYSTEM SET bgwriter_lru_maxpages = 200;
-- ALTER SYSTEM SET bgwriter_lru_multiplier = 2.0;
-- Vacuum and analyze schedule for sync tables (run via cron)
-- VACUUM ANALYZE sync_queue;
-- VACUUM ANALYZE reading_progress;
-- VACUUM ANALYZE sync_conflicts;
-- VACUUM ANALYZE devices;
-- REINDEX TABLE CONCURRENTLY sync_queue;
-- REINDEX TABLE CONCURRENTLY reading_progress;
-- ============================================
-- SYNC HELPER FUNCTIONS (Phase 1)
-- ============================================
-- Detect format group based on mimetype and file path
CREATE OR REPLACE FUNCTION detect_format_group(p_mimetype VARCHAR, p_file_path VARCHAR)
RETURNS VARCHAR AS $$
BEGIN
CASE
-- Reflowable formats
WHEN p_mimetype = 'application/epub+zip' THEN
RETURN 'reflowable';
WHEN p_mimetype = 'application/x-mobipocket-ebook' THEN
RETURN 'reflowable';
WHEN p_mimetype = 'application/vnd.amazon.mobi8-ebook' THEN
RETURN 'reflowable';
WHEN p_file_path LIKE '%.epub' THEN
RETURN 'reflowable';
WHEN p_file_path LIKE '%.mobi' THEN
RETURN 'reflowable';
WHEN p_file_path LIKE '%.azw3' THEN
RETURN 'reflowable';
WHEN p_file_path LIKE '%.fb2' THEN
RETURN 'reflowable';
WHEN p_file_path LIKE '%.txt' THEN
RETURN 'reflowable';
-- Fixed layout formats
WHEN p_mimetype = 'application/pdf' THEN
RETURN 'fixed_layout';
WHEN p_file_path LIKE '%.pdf' THEN
RETURN 'fixed_layout';
WHEN p_file_path LIKE '%.djvu' THEN
RETURN 'fixed_layout';
-- Comic archive formats
WHEN p_mimetype = 'application/x-cbr' THEN
RETURN 'comic_archive';
WHEN p_mimetype = 'application/x-cbz' THEN
RETURN 'comic_archive';
WHEN p_file_path LIKE '%.cbz' THEN
RETURN 'comic_archive';
WHEN p_file_path LIKE '%.cbr' THEN
RETURN 'comic_archive';
WHEN p_file_path LIKE '%.cbt' THEN
RETURN 'comic_archive';
WHEN p_file_path LIKE '%.cb7' THEN
RETURN 'comic_archive';
ELSE
RETURN 'unknown';
END CASE;
END;
$$ LANGUAGE plpgsql;
-- Convert progress between format groups
CREATE OR REPLACE FUNCTION convert_progress(
source_format VARCHAR,
target_format VARCHAR,
source_progress JSONB
) RETURNS JSONB AS $$
DECLARE
result JSONB;
percentage FLOAT;
BEGIN
-- Extract percentage from source
CASE source_format
WHEN 'reflowable' THEN
percentage := (source_progress->>'percentage')::FLOAT;
WHEN 'fixed_layout' THEN
percentage := ((source_progress->>'page')::FLOAT /
(source_progress->>'total_pages')::FLOAT);
WHEN 'comic_archive' THEN
percentage := ((source_progress->>'page')::FLOAT /
(source_progress->>'total_pages')::FLOAT);
ELSE
percentage := 0.0;
END CASE;
-- Build target format progress
CASE target_format
WHEN 'reflowable' THEN
result := jsonb_build_object(
'percentage', percentage,
'epubcfi', 'epubcfi(/6/4/2:' || CAST(percentage * 100 AS INTEGER) || ')'
);
WHEN 'fixed_layout' THEN
result := jsonb_build_object(
'page', CAST(percentage * 200 AS INTEGER),
'total_pages', 200,
'percentage', percentage
);
WHEN 'comic_archive' THEN
result := jsonb_build_object(
'page', CAST(percentage * 32 AS INTEGER),
'total_pages', 32,
'percentage', percentage
);
ELSE
result := '{}'::jsonb;
END CASE;
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- Detect conflicts in sync progress
CREATE OR REPLACE FUNCTION detect_conflict(
p_media_item_id UUID,
new_progress JSONB,
device_type VARCHAR
) RETURNS BOOLEAN AS $$
DECLARE
existing_progress JSONB;
time_diff INTERVAL;
BEGIN
-- Get most recent progress from different device
SELECT jsonb_build_object(
'percentage', percentage,
'timestamp', last_sync_timestamp
) INTO existing_progress
FROM reading_progress
WHERE media_item_id = p_media_item_id
AND last_sync_source != device_type
ORDER BY last_sync_timestamp DESC
LIMIT 1;
-- If no existing progress, no conflict
IF existing_progress IS NULL THEN
RETURN FALSE;
END IF;
-- Check time difference (within 5 minutes = potential conflict)
time_diff := NOW() - (existing_progress->>'timestamp')::TIMESTAMP WITH TIME ZONE;
IF time_diff < INTERVAL '5 minutes' THEN
-- Check if progress significantly different
IF ABS((new_progress->>'percentage')::FLOAT -
(existing_progress->>'percentage')::FLOAT) > 0.01 THEN
RETURN TRUE;
END IF;
END IF;
RETURN FALSE;
END;
$$ LANGUAGE plpgsql;
-- Merge progress from two sources
CREATE OR REPLACE FUNCTION merge_progress(
progress_a JSONB,
progress_b JSONB
) RETURNS JSONB AS $$
BEGIN
RETURN jsonb_build_object(
'percentage', GREATEST(
(progress_a->>'percentage')::FLOAT,
(progress_b->>'percentage')::FLOAT
),
'merged_from', ARRAY[
(progress_a->>'source'),
(progress_b->>'source')
],
'merge_timestamp', NOW()
);
END;
$$ LANGUAGE plpgsql;
-- ============================================
-- KOREADER SYNC FUNCTIONS (Phase 3)
-- ============================================
-- Bulk update progress from KOReader sync data
CREATE OR REPLACE FUNCTION bulk_update_progress_from_koreader(
p_user_id UUID,
p_sync_data JSONB
) RETURNS TABLE (
media_item_id UUID,
success BOOLEAN,
message TEXT
) AS $$
DECLARE
book_record JSONB;
media_uuid UUID;
existing_progress reading_progress%ROWTYPE;
conflict_detected BOOLEAN;
BEGIN
FOR book_record IN SELECT * FROM jsonb_array_elements(p_sync_data->'books')
LOOP
-- Try to find media item by UUID
media_uuid := NULL;
SELECT id INTO media_uuid FROM media_items WHERE id = (book_record->>'uuid')::uuid;
-- If not found by UUID, try to match by file path
IF media_uuid IS NULL AND book_record ? 'file_path' THEN
SELECT id INTO media_uuid FROM media_items WHERE file_path = book_record->>'file_path';
END IF;
-- If still not found, try to match by title + author
IF media_uuid IS NULL THEN
SELECT id INTO media_uuid FROM media_items
WHERE title = book_record->>'title'
AND author = book_record->>'author'
LIMIT 1;
END IF;
-- If no match found, return failure
IF media_uuid IS NULL THEN
RETURN QUERY SELECT NULL::uuid, FALSE, 'media item not found';
CONTINUE;
END IF;
-- Check for conflicts
conflict_detected := detect_conflict(media_uuid, book_record, 'koreader');
-- Check if progress exists
SELECT * INTO existing_progress FROM reading_progress
WHERE media_item_id = media_uuid AND user_id = p_user_id;
-- Update or insert progress
IF existing_progress.media_item_id IS NOT NULL THEN
UPDATE reading_progress SET
percentage = (book_record->>'percentage')::FLOAT,
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
chapter_progress = (book_record->>'percentage')::FLOAT,
last_sync_device = 'koreader',
last_sync_source = 'koreader',
last_sync_timestamp = NOW(),
conflict_detected = conflict_detected,
conflict_resolved = NOT conflict_detected,
last_read_at = CASE WHEN book_record ? 'last_read' THEN (book_record->>'last_read')::TIMESTAMP WITH TIME ZONE ELSE NOW() END,
current_page = CASE WHEN book_record ? 'page' THEN (book_record->>'page')::INTEGER ELSE existing_progress.current_page END,
total_pages = CASE WHEN book_record ? 'total_pages' THEN (book_record->>'total_pages')::INTEGER ELSE existing_progress.total_pages END
WHERE media_item_id = media_uuid AND user_id = p_user_id;
ELSE
INSERT INTO reading_progress (
media_item_id,
user_id,
percentage,
character_offset,
epubcfi,
chapter,
chapter_progress,
last_sync_device,
last_sync_source,
last_sync_timestamp,
conflict_detected,
conflict_resolved,
last_read_at,
current_page,
total_pages
) VALUES (
media_uuid,
p_user_id,
(book_record->>'percentage')::FLOAT,
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
(book_record->>'percentage')::FLOAT,
'koreader',
'koreader',
NOW(),
conflict_detected,
NOT conflict_detected,
CASE WHEN book_record ? 'last_read' THEN (book_record->>'last_read')::TIMESTAMP WITH TIME ZONE ELSE NOW() END,
CASE WHEN book_record ? 'page' THEN (book_record->>'page')::INTEGER ELSE NULL END,
CASE WHEN book_record ? 'total_pages' THEN (book_record->>'total_pages')::INTEGER ELSE NULL END
);
END IF;
-- Create conflict record if detected
IF conflict_detected THEN
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data)
VALUES (media_uuid, p_user_id, 'progress', jsonb_build_object(
'koreader', book_record,
'timestamp', NOW()
));
END IF;
RETURN QUERY SELECT media_uuid, TRUE,
CASE WHEN conflict_detected THEN 'conflict detected' ELSE 'success' END;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- ============================================
-- PHASE 1: UNIVERSAL BOOK IDENTIFIERS (Week 1)
-- ============================================
-- Add universal identifier columns to media_items table
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS file_sha256 CHAR(64);
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS opf_identifier VARCHAR(255);
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS opf_uuid VARCHAR(255);
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS hash_confidence VARCHAR(20);
-- Create indexes for fast lookup
CREATE INDEX IF NOT EXISTS idx_media_items_sha256 ON media_items(file_sha256);
CREATE INDEX IF NOT EXISTS idx_media_items_opf_identifier ON media_items(opf_identifier);
-- Create media_item_formats table (track all format versions with their hashes)
CREATE TABLE IF NOT EXISTS media_item_formats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
format_type VARCHAR(10) NOT NULL,
file_path VARCHAR(500),
file_sha256 CHAR(64),
file_size_bytes BIGINT,
mime_type VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
converted_from_format_id UUID REFERENCES media_item_formats(id),
UNIQUE(media_item_id, format_type)
);
CREATE INDEX IF NOT EXISTS idx_media_item_formats_media ON media_item_formats(media_item_id, format_type);
CREATE INDEX IF NOT EXISTS idx_media_item_formats_sha256 ON media_item_formats(file_sha256);
-- Create device_file_aliases table (track file paths per device for cross-device matching)
CREATE TABLE IF NOT EXISTS device_file_aliases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
file_path VARCHAR(500) NOT NULL,
file_sha256 CHAR(64),
confidence_score FLOAT DEFAULT 1.0,
last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(device_id, file_path)
);
CREATE INDEX IF NOT EXISTS idx_device_file_aliases_media_device ON device_file_aliases(media_item_id, device_id);
CREATE INDEX IF NOT EXISTS idx_device_file_aliases_sha256 ON device_file_aliases(file_sha256);
-- Create collections table (device-neutral collections)
CREATE TABLE IF NOT EXISTS collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(7),
icon VARCHAR(50),
auto_assign_rules JSONB,
view_settings JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
-- Create collection_items table (which books belong to each collection)
CREATE TABLE IF NOT EXISTS collection_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
UNIQUE(collection_id, media_item_id)
);
CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_id);
CREATE INDEX IF NOT EXISTS idx_collection_items_media ON collection_items(media_item_id);
-- Create device_shelf_mappings table (map Bookhoard collections to device-specific shelf names)
CREATE TABLE IF NOT EXISTS device_shelf_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
device_shelf_name VARCHAR(100),
sync_direction VARCHAR(20),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(collection_id, device_id)
);
CREATE INDEX IF NOT EXISTS idx_device_shelf_mappings_collection ON device_shelf_mappings(collection_id);
CREATE INDEX IF NOT EXISTS idx_device_shelf_mappings_device ON device_shelf_mappings(device_id);
-- Create device_catalogs table (track OPDS downloads and map Bookhoard UUIDs to device ContentIds)
CREATE TABLE IF NOT EXISTS device_catalogs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
bookhoard_uuid UUID NOT NULL,
kobo_content_id VARCHAR(255) NOT NULL,
content_id_type VARCHAR(20),
available BOOLEAN DEFAULT TRUE,
delivery_date TIMESTAMP WITH TIME ZONE,
delivery_method VARCHAR(20),
UNIQUE(device_id, kobo_content_id)
);
CREATE INDEX IF NOT EXISTS idx_device_catalogs_bookhoard ON device_catalogs(bookhoard_uuid);
CREATE INDEX IF NOT EXISTS idx_device_catalogs_kobo ON device_catalogs(kobo_content_id);
-- Create system_config table (system-wide configuration)
CREATE TABLE IF NOT EXISTS system_config (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_by UUID REFERENCES users(id)
);
-- Pre-seeded values
INSERT INTO system_config (key, value) VALUES
('base_url', 'https://bookhoard.example.com'),
('opds_base_url', 'https://bookhoard.example.com/opds'),
('api_base_url', 'https://bookhoard.example.com/api')
ON CONFLICT (key) DO NOTHING;
-- Create opds_tokens table (device-specific OPDS access tokens)
CREATE TABLE IF NOT EXISTS opds_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
token VARCHAR(64) UNIQUE NOT NULL,
token_type VARCHAR(20),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_opds_tokens_device ON opds_tokens(device_id);
CREATE INDEX IF NOT EXISTS idx_opds_tokens_token ON opds_tokens(token);
-- Modify kobo_shelves table (reference collections instead of media_items directly)
ALTER TABLE kobo_shelves ADD COLUMN IF NOT EXISTS collection_id UUID REFERENCES collections(id);
ALTER TABLE kobo_shelves ADD COLUMN IF NOT EXISTS position_in_collection INTEGER;
-- ============================================
-- PHASE 6: UNLINKED BOOKS TRACKING (Week 3-4)
-- ============================================
-- Create unlinked_books table to track books that couldn't be auto-matched
CREATE TABLE IF NOT EXISTS unlinked_books (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
content_id VARCHAR(255) NOT NULL,
file_path VARCHAR(500),
title VARCHAR(255),
author VARCHAR(255),
confidence_score FLOAT DEFAULT 0.5,
resolved BOOLEAN DEFAULT FALSE,
media_item_id UUID REFERENCES media_items(id) ON DELETE SET NULL,
resolved_at TIMESTAMP WITH TIME ZONE,
resolution_method VARCHAR(50),
last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_resolved ON unlinked_books(resolved);