mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: harden database schema
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
CREATE EXTENSION IF NOT EXISTS vector;--> statement-breakpoint
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;--> statement-breakpoint
|
||||
CREATE TYPE "public"."library_access_level" AS ENUM('viewer', 'editor', 'owner');--> statement-breakpoint
|
||||
CREATE TYPE "public"."user_avatar_source" AS ENUM('none', 'external', 'uploaded');--> statement-breakpoint
|
||||
CREATE TYPE "public"."opds_sort_order" AS ENUM('recent', 'title_asc', 'title_desc', 'author_asc', 'author_desc', 'series_asc', 'series_desc');--> statement-breakpoint
|
||||
@@ -11,14 +13,14 @@ CREATE TABLE "audit_log" (
|
||||
"description" text NOT NULL,
|
||||
"ip" varchar(45),
|
||||
"meta" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "app_settings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar(100) NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "app_settings_key_unique" UNIQUE("key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -26,20 +28,22 @@ CREATE TABLE "password_reset_tokens" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"token_hash" varchar(255) NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"used_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "password_reset_tokens_token_hash_unique" UNIQUE("token_hash")
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "password_reset_tokens_token_hash_unique" UNIQUE("token_hash"),
|
||||
CONSTRAINT "password_reset_tokens_expires_after_created_chk" CHECK ("password_reset_tokens"."expires_at" > "password_reset_tokens"."created_at")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "refresh_tokens" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"token_hash" varchar(255) NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"revoked_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "refresh_tokens_token_hash_unique" UNIQUE("token_hash")
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "refresh_tokens_token_hash_unique" UNIQUE("token_hash"),
|
||||
CONSTRAINT "refresh_tokens_expires_after_created_chk" CHECK ("refresh_tokens"."expires_at" > "refresh_tokens"."created_at")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_library_access" (
|
||||
@@ -72,10 +76,13 @@ CREATE TABLE "users" (
|
||||
"avatar_source" "user_avatar_source" DEFAULT 'none' NOT NULL,
|
||||
"avatar_version" integer DEFAULT 0 NOT NULL,
|
||||
"provisioning_method" varchar(20) DEFAULT 'local' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_username_unique" UNIQUE("username"),
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email"),
|
||||
CONSTRAINT "users_provisioning_method_chk" CHECK ("users"."provisioning_method" in ('local', 'manual', 'oidc')),
|
||||
CONSTRAINT "users_token_version_nonnegative_chk" CHECK ("users"."token_version" >= 0),
|
||||
CONSTRAINT "users_avatar_version_nonnegative_chk" CHECK ("users"."avatar_version" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "comic_metadata" (
|
||||
@@ -92,8 +99,8 @@ CREATE TABLE "comic_metadata" (
|
||||
"teams" text[],
|
||||
"locations" text[],
|
||||
"story_arcs" text[],
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "comic_metadata_book_id_unique" UNIQUE("book_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -108,8 +115,8 @@ CREATE TABLE "migration_plan_artifacts" (
|
||||
"source_data" jsonb,
|
||||
"summary" jsonb NOT NULL,
|
||||
"created_by_user_id" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "migration_profiles" (
|
||||
@@ -121,8 +128,9 @@ CREATE TABLE "migration_profiles" (
|
||||
"path_mappings" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"scope" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_by_user_id" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "migration_profiles_version_positive_chk" CHECK ("migration_profiles"."version" >= 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "migration_run_metrics" (
|
||||
@@ -135,8 +143,13 @@ CREATE TABLE "migration_run_metrics" (
|
||||
"skipped" integer DEFAULT 0 NOT NULL,
|
||||
"unresolved" integer DEFAULT 0 NOT NULL,
|
||||
"failed" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "migration_run_metrics_processed_nonnegative_chk" CHECK ("migration_run_metrics"."processed" >= 0),
|
||||
CONSTRAINT "migration_run_metrics_imported_nonnegative_chk" CHECK ("migration_run_metrics"."imported" >= 0),
|
||||
CONSTRAINT "migration_run_metrics_skipped_nonnegative_chk" CHECK ("migration_run_metrics"."skipped" >= 0),
|
||||
CONSTRAINT "migration_run_metrics_unresolved_nonnegative_chk" CHECK ("migration_run_metrics"."unresolved" >= 0),
|
||||
CONSTRAINT "migration_run_metrics_failed_nonnegative_chk" CHECK ("migration_run_metrics"."failed" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "migration_runs" (
|
||||
@@ -148,12 +161,13 @@ CREATE TABLE "migration_runs" (
|
||||
"state" varchar(32) DEFAULT 'draft' NOT NULL,
|
||||
"current_stage" varchar(64),
|
||||
"triggered_by_user_id" integer,
|
||||
"started_at" timestamp,
|
||||
"ended_at" timestamp,
|
||||
"started_at" timestamp with time zone,
|
||||
"ended_at" timestamp with time zone,
|
||||
"error_message" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "migration_runs_state_chk" CHECK ("state" IN ('draft', 'preflight_failed', 'dry_run_ready', 'running', 'failed', 'completed'))
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "migration_runs_state_chk" CHECK ("state" IN ('draft', 'preflight_failed', 'dry_run_ready', 'running', 'failed', 'completed')),
|
||||
CONSTRAINT "migration_runs_started_before_ended_chk" CHECK ("migration_runs"."ended_at" is null or "migration_runs"."started_at" is null or "migration_runs"."ended_at" >= "migration_runs"."started_at")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "migration_sources" (
|
||||
@@ -162,10 +176,10 @@ CREATE TABLE "migration_sources" (
|
||||
"name" varchar(255) NOT NULL,
|
||||
"connection_config" jsonb NOT NULL,
|
||||
"capabilities" jsonb,
|
||||
"last_validated_at" timestamp,
|
||||
"last_validated_at" timestamp with time zone,
|
||||
"created_by_user_id" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "libraries" (
|
||||
@@ -186,19 +200,25 @@ CREATE TABLE "libraries" (
|
||||
"file_naming_pattern" varchar(500),
|
||||
"metadata_fetch_preferences" jsonb,
|
||||
"book_metadata_fetch_config" jsonb,
|
||||
"book_metadata_fetch_last_run_at" timestamp,
|
||||
"book_metadata_fetch_last_run_at" timestamp with time zone,
|
||||
"book_metadata_fetch_last_queued_count" integer,
|
||||
"scan_mode" varchar(20) DEFAULT 'auto' NOT NULL,
|
||||
"poll_interval_seconds" integer DEFAULT 300,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "libraries_display_order_nonnegative_chk" CHECK ("libraries"."display_order" >= 0),
|
||||
CONSTRAINT "libraries_organization_mode_chk" CHECK ("libraries"."organization_mode" in ('book_per_folder', 'file_per_book')),
|
||||
CONSTRAINT "libraries_reading_threshold_range_chk" CHECK ("libraries"."reading_threshold" >= 0 and "libraries"."reading_threshold" <= 1),
|
||||
CONSTRAINT "libraries_mark_finished_percent_range_chk" CHECK ("libraries"."mark_as_finished_percent_complete" >= 0 and "libraries"."mark_as_finished_percent_complete" <= 100),
|
||||
CONSTRAINT "libraries_scan_mode_chk" CHECK ("libraries"."scan_mode" in ('auto', 'manual')),
|
||||
CONSTRAINT "libraries_poll_interval_nonnegative_chk" CHECK ("libraries"."poll_interval_seconds" is null or "libraries"."poll_interval_seconds" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "library_folders" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"library_id" integer NOT NULL,
|
||||
"path" varchar(4096) NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "book_files" (
|
||||
@@ -209,14 +229,17 @@ CREATE TABLE "book_files" (
|
||||
"rel_path" varchar(4096),
|
||||
"ino" bigint NOT NULL,
|
||||
"size_bytes" bigint,
|
||||
"mtime" timestamp,
|
||||
"mtime" timestamp with time zone,
|
||||
"hash" varchar(64),
|
||||
"format" varchar(20),
|
||||
"role" varchar(20) DEFAULT 'content' NOT NULL,
|
||||
"sort_order" integer,
|
||||
"duration_seconds" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "book_files_role_chk" CHECK ("book_files"."role" in ('content', 'cover', 'supplement')),
|
||||
CONSTRAINT "book_files_size_bytes_nonnegative_chk" CHECK ("book_files"."size_bytes" is null or "book_files"."size_bytes" >= 0),
|
||||
CONSTRAINT "book_files_duration_seconds_nonnegative_chk" CHECK ("book_files"."duration_seconds" is null or "book_files"."duration_seconds" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books" (
|
||||
@@ -226,14 +249,15 @@ CREATE TABLE "books" (
|
||||
"primary_file_id" integer,
|
||||
"folder_path" varchar(4096) NOT NULL,
|
||||
"status" varchar(20) DEFAULT 'present' NOT NULL,
|
||||
"added_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "books_status_chk" CHECK ("books"."status" in ('present', 'missing'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "collection_books" (
|
||||
"collection_id" integer NOT NULL,
|
||||
"book_id" integer NOT NULL,
|
||||
"added_at" timestamp DEFAULT now() NOT NULL,
|
||||
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "collection_books_collection_id_book_id_pk" PRIMARY KEY("collection_id","book_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -245,8 +269,8 @@ CREATE TABLE "collections" (
|
||||
"description" text,
|
||||
"sync_to_kobo" boolean DEFAULT false NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "lenses" (
|
||||
@@ -258,8 +282,8 @@ CREATE TABLE "lenses" (
|
||||
"default_sort" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"is_public" boolean DEFAULT false NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "lenses_user_id_name_unique" UNIQUE("user_id","name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -268,13 +292,16 @@ CREATE TABLE "author_enrichment_queue" (
|
||||
"status" varchar(20) DEFAULT 'queued' NOT NULL,
|
||||
"reason" varchar(50) DEFAULT 'unknown' NOT NULL,
|
||||
"attempt_count" integer DEFAULT 0 NOT NULL,
|
||||
"next_attempt_at" timestamp DEFAULT now() NOT NULL,
|
||||
"last_attempt_at" timestamp,
|
||||
"last_success_at" timestamp,
|
||||
"next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_attempt_at" timestamp with time zone,
|
||||
"last_success_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"last_http_status" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "author_enrichment_queue_status_chk" CHECK ("author_enrichment_queue"."status" in ('queued', 'processing', 'rate_limited', 'failed', 'done')),
|
||||
CONSTRAINT "author_enrichment_queue_reason_chk" CHECK ("author_enrichment_queue"."reason" in ('unknown', 'metadata_replace', 'manual_backfill', 'manual_backfill_all', 'author_rename', 'author_merge_target')),
|
||||
CONSTRAINT "author_enrichment_queue_attempt_count_nonnegative_chk" CHECK ("author_enrichment_queue"."attempt_count" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "authors" (
|
||||
@@ -283,7 +310,7 @@ CREATE TABLE "authors" (
|
||||
"sort_name" varchar(500),
|
||||
"description" text,
|
||||
"has_photo" boolean DEFAULT false NOT NULL,
|
||||
"last_enriched_at" timestamp,
|
||||
"last_enriched_at" timestamp with time zone,
|
||||
CONSTRAINT "authors_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -322,16 +349,21 @@ CREATE TABLE "book_metadata" (
|
||||
"open_library_id" varchar(50),
|
||||
"itunes_id" varchar(50),
|
||||
"metadata_score" integer,
|
||||
"last_metadata_fetch_at" timestamp,
|
||||
"last_metadata_fetch_at" timestamp with time zone,
|
||||
"embedding" vector(256),
|
||||
"last_written_at" timestamp,
|
||||
"last_written_at" timestamp with time zone,
|
||||
"duration_seconds" integer,
|
||||
"abridged" boolean DEFAULT false NOT NULL,
|
||||
"audible_id" varchar(20),
|
||||
"comicvine_id" varchar(50),
|
||||
"chapters" jsonb,
|
||||
"locked_fields" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "book_metadata_rating_range_chk" CHECK ("book_metadata"."rating" is null or ("book_metadata"."rating" >= 1 and "book_metadata"."rating" <= 10)),
|
||||
CONSTRAINT "book_metadata_published_year_range_chk" CHECK ("book_metadata"."published_year" is null or ("book_metadata"."published_year" >= 1000 and "book_metadata"."published_year" <= 2200)),
|
||||
CONSTRAINT "book_metadata_page_count_nonnegative_chk" CHECK ("book_metadata"."page_count" is null or "book_metadata"."page_count" >= 0),
|
||||
CONSTRAINT "book_metadata_duration_seconds_nonnegative_chk" CHECK ("book_metadata"."duration_seconds" is null or "book_metadata"."duration_seconds" >= 0),
|
||||
CONSTRAINT "book_metadata_cover_source_chk" CHECK ("book_metadata"."cover_source" is null or "book_metadata"."cover_source" in ('extracted', 'custom'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "book_metadata_fetch_queue" (
|
||||
@@ -339,11 +371,14 @@ CREATE TABLE "book_metadata_fetch_queue" (
|
||||
"status" varchar(20) DEFAULT 'queued' NOT NULL,
|
||||
"reason" varchar(50) DEFAULT 'manual_trigger' NOT NULL,
|
||||
"attempt_count" integer DEFAULT 0 NOT NULL,
|
||||
"last_attempt_at" timestamp,
|
||||
"last_attempt_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"last_http_status" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "book_metadata_fetch_queue_status_chk" CHECK ("book_metadata_fetch_queue"."status" in ('queued', 'processing', 'failed')),
|
||||
CONSTRAINT "book_metadata_fetch_queue_reason_chk" CHECK ("book_metadata_fetch_queue"."reason" in ('event_import', 'manual_trigger', 'manual_retry')),
|
||||
CONSTRAINT "book_metadata_fetch_queue_attempt_count_nonnegative_chk" CHECK ("book_metadata_fetch_queue"."attempt_count" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "book_tags" (
|
||||
@@ -375,7 +410,7 @@ CREATE TABLE "narrators" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(500) NOT NULL,
|
||||
"sort_name" varchar(500),
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "narrators_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -388,8 +423,13 @@ CREATE TABLE "scan_jobs" (
|
||||
"updated_count" integer DEFAULT 0 NOT NULL,
|
||||
"missing_count" integer DEFAULT 0 NOT NULL,
|
||||
"error_message" text,
|
||||
"started_at" timestamp DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone,
|
||||
CONSTRAINT "scan_jobs_status_chk" CHECK ("scan_jobs"."status" in ('running', 'completed', 'failed')),
|
||||
CONSTRAINT "scan_jobs_triggered_by_chk" CHECK ("scan_jobs"."triggered_by" in ('manual', 'watcher', 'schedule')),
|
||||
CONSTRAINT "scan_jobs_added_count_nonnegative_chk" CHECK ("scan_jobs"."added_count" >= 0),
|
||||
CONSTRAINT "scan_jobs_updated_count_nonnegative_chk" CHECK ("scan_jobs"."updated_count" >= 0),
|
||||
CONSTRAINT "scan_jobs_missing_count_nonnegative_chk" CHECK ("scan_jobs"."missing_count" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "annotations" (
|
||||
@@ -402,8 +442,9 @@ CREATE TABLE "annotations" (
|
||||
"style" varchar(20) DEFAULT 'highlight' NOT NULL,
|
||||
"note" text,
|
||||
"chapter_title" varchar(500),
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "annotations_style_chk" CHECK ("annotations"."style" in ('highlight', 'underline', 'strikethrough', 'squiggly'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "audiobook_progress" (
|
||||
@@ -412,8 +453,10 @@ CREATE TABLE "audiobook_progress" (
|
||||
"percentage" real DEFAULT 0 NOT NULL,
|
||||
"current_file_id" integer NOT NULL,
|
||||
"position_seconds" real DEFAULT 0 NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "audiobook_progress_user_id_book_id_pk" PRIMARY KEY("user_id","book_id")
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "audiobook_progress_user_id_book_id_pk" PRIMARY KEY("user_id","book_id"),
|
||||
CONSTRAINT "audiobook_progress_percentage_range_chk" CHECK ("audiobook_progress"."percentage" >= 0 and "audiobook_progress"."percentage" <= 100),
|
||||
CONSTRAINT "audiobook_progress_position_seconds_nonnegative_chk" CHECK ("audiobook_progress"."position_seconds" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "bookmarks" (
|
||||
@@ -423,7 +466,7 @@ CREATE TABLE "bookmarks" (
|
||||
"cfi" varchar(2000),
|
||||
"title" varchar(500) NOT NULL,
|
||||
"position_seconds" real,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "reader_default_preferences" (
|
||||
@@ -431,7 +474,8 @@ CREATE TABLE "reader_default_preferences" (
|
||||
"user_id" integer NOT NULL,
|
||||
"format_group" varchar(10) NOT NULL,
|
||||
"settings" jsonb NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "reader_default_preferences_format_group_chk" CHECK ("reader_default_preferences"."format_group" in ('epub', 'pdf', 'cbx', 'audio'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "reader_preferences" (
|
||||
@@ -439,7 +483,7 @@ CREATE TABLE "reader_preferences" (
|
||||
"user_id" integer NOT NULL,
|
||||
"book_file_id" integer NOT NULL,
|
||||
"settings" jsonb NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "reading_progress" (
|
||||
@@ -449,8 +493,11 @@ CREATE TABLE "reading_progress" (
|
||||
"cfi" varchar(2000),
|
||||
"page_number" integer,
|
||||
"position_seconds" real,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "reading_progress_book_file_id_user_id_pk" PRIMARY KEY("book_file_id","user_id")
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "reading_progress_book_file_id_user_id_pk" PRIMARY KEY("book_file_id","user_id"),
|
||||
CONSTRAINT "reading_progress_percentage_range_chk" CHECK ("reading_progress"."percentage" >= 0 and "reading_progress"."percentage" <= 100),
|
||||
CONSTRAINT "reading_progress_page_number_nonnegative_chk" CHECK ("reading_progress"."page_number" is null or "reading_progress"."page_number" >= 0),
|
||||
CONSTRAINT "reading_progress_position_seconds_nonnegative_chk" CHECK ("reading_progress"."position_seconds" is null or "reading_progress"."position_seconds" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "reading_sessions" (
|
||||
@@ -458,11 +505,14 @@ CREATE TABLE "reading_sessions" (
|
||||
"user_id" integer NOT NULL,
|
||||
"book_file_id" integer NOT NULL,
|
||||
"session_id" varchar(64) NOT NULL,
|
||||
"started_at" timestamp NOT NULL,
|
||||
"ended_at" timestamp NOT NULL,
|
||||
"started_at" timestamp with time zone NOT NULL,
|
||||
"ended_at" timestamp with time zone NOT NULL,
|
||||
"duration_seconds" integer NOT NULL,
|
||||
"progress_delta" real,
|
||||
"end_progress" real
|
||||
"end_progress" real,
|
||||
CONSTRAINT "reading_sessions_duration_seconds_nonnegative_chk" CHECK ("reading_sessions"."duration_seconds" >= 0),
|
||||
CONSTRAINT "reading_sessions_end_progress_range_chk" CHECK ("reading_sessions"."end_progress" is null or ("reading_sessions"."end_progress" >= 0 and "reading_sessions"."end_progress" <= 100)),
|
||||
CONSTRAINT "reading_sessions_ended_after_started_chk" CHECK ("reading_sessions"."ended_at" >= "reading_sessions"."started_at")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_book_status" (
|
||||
@@ -470,10 +520,13 @@ CREATE TABLE "user_book_status" (
|
||||
"book_id" integer NOT NULL,
|
||||
"status" varchar(20) DEFAULT 'unread' NOT NULL,
|
||||
"source" varchar(10) DEFAULT 'auto' NOT NULL,
|
||||
"started_at" timestamp,
|
||||
"finished_at" timestamp,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_book_status_user_id_book_id_pk" PRIMARY KEY("user_id","book_id")
|
||||
"started_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_book_status_user_id_book_id_pk" PRIMARY KEY("user_id","book_id"),
|
||||
CONSTRAINT "user_book_status_status_chk" CHECK ("user_book_status"."status" in ('unread', 'want_to_read', 'reading', 'on_hold', 'rereading', 'read', 'skimmed', 'abandoned')),
|
||||
CONSTRAINT "user_book_status_source_chk" CHECK ("user_book_status"."source" in ('auto', 'manual')),
|
||||
CONSTRAINT "user_book_status_finished_after_started_chk" CHECK ("user_book_status"."finished_at" is null or "user_book_status"."started_at" is null or "user_book_status"."finished_at" >= "user_book_status"."started_at")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_reading_daily_stats" (
|
||||
@@ -483,15 +536,17 @@ CREATE TABLE "user_reading_daily_stats" (
|
||||
"reading_seconds" integer DEFAULT 0 NOT NULL,
|
||||
"progress_delta" real DEFAULT 0 NOT NULL,
|
||||
"sessions_count" integer DEFAULT 0 NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_reading_daily_stats_user_id_library_id_day_pk" PRIMARY KEY("user_id","library_id","day")
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_reading_daily_stats_user_id_library_id_day_pk" PRIMARY KEY("user_id","library_id","day"),
|
||||
CONSTRAINT "user_reading_daily_stats_reading_seconds_nonnegative_chk" CHECK ("user_reading_daily_stats"."reading_seconds" >= 0),
|
||||
CONSTRAINT "user_reading_daily_stats_sessions_count_nonnegative_chk" CHECK ("user_reading_daily_stats"."sessions_count" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oidc_group_mappings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"oidc_group_claim" text NOT NULL,
|
||||
"permission_name" varchar(100),
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "oidc_group_mappings_oidc_group_claim_unique" UNIQUE("oidc_group_claim")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -503,7 +558,8 @@ CREATE TABLE "oidc_sessions" (
|
||||
"oidc_session_id" text,
|
||||
"id_token_hint" text,
|
||||
"revoked" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "opds_users" (
|
||||
@@ -512,8 +568,8 @@ CREATE TABLE "opds_users" (
|
||||
"username" varchar(100) NOT NULL,
|
||||
"password_hash" varchar(255) NOT NULL,
|
||||
"sort_order" "opds_sort_order" DEFAULT 'recent' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "opds_users_username_unique" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -522,16 +578,16 @@ CREATE TABLE "kobo_devices" (
|
||||
"user_id" integer NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"token" varchar(64) NOT NULL,
|
||||
"last_seen_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"last_seen_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "kobo_devices_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kobo_library_snapshots" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "kobo_library_snapshots_user_id_unique" UNIQUE("user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -546,8 +602,8 @@ CREATE TABLE "kobo_reading_states" (
|
||||
"current_bookmark" jsonb,
|
||||
"statistics" jsonb,
|
||||
"status_info" jsonb,
|
||||
"progress_synced_at" timestamp,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"progress_synced_at" timestamp with time zone,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "kobo_reading_states_user_id_book_id_unique" UNIQUE("user_id","book_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -572,9 +628,12 @@ CREATE TABLE "kobo_sync_settings" (
|
||||
"two_way_progress_sync" boolean DEFAULT false NOT NULL,
|
||||
"force_enable_hyphenation" boolean DEFAULT false NOT NULL,
|
||||
"kepub_conversion_limit_mb" integer DEFAULT 100 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "kobo_sync_settings_user_id_unique" UNIQUE("user_id")
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "kobo_sync_settings_user_id_unique" UNIQUE("user_id"),
|
||||
CONSTRAINT "kobo_sync_settings_reading_threshold_range_chk" CHECK ("kobo_sync_settings"."reading_threshold" >= 0 and "kobo_sync_settings"."reading_threshold" <= 100),
|
||||
CONSTRAINT "kobo_sync_settings_finished_threshold_range_chk" CHECK ("kobo_sync_settings"."finished_threshold" >= 0 and "kobo_sync_settings"."finished_threshold" <= 100),
|
||||
CONSTRAINT "kobo_sync_settings_conversion_limit_nonnegative_chk" CHECK ("kobo_sync_settings"."kepub_conversion_limit_mb" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "book_bucket_files" (
|
||||
@@ -593,10 +652,12 @@ CREATE TABLE "book_bucket_files" (
|
||||
"confidence" integer,
|
||||
"fetched_metadata_sources" jsonb,
|
||||
"error_message" text,
|
||||
"metadata_edited_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "book_bucket_files_absolute_path_unique" UNIQUE("absolute_path")
|
||||
"metadata_edited_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "book_bucket_files_absolute_path_unique" UNIQUE("absolute_path"),
|
||||
CONSTRAINT "book_bucket_files_status_chk" CHECK ("book_bucket_files"."status" in ('pending', 'extracting', 'fetching', 'ready', 'error')),
|
||||
CONSTRAINT "book_bucket_files_confidence_range_chk" CHECK ("book_bucket_files"."confidence" is null or ("book_bucket_files"."confidence" >= 0 and "book_bucket_files"."confidence" <= 100))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "file_write_log" (
|
||||
@@ -610,7 +671,10 @@ CREATE TABLE "file_write_log" (
|
||||
"error_message" text,
|
||||
"duration_ms" integer,
|
||||
"triggered_by" varchar(10) NOT NULL,
|
||||
"written_at" timestamp DEFAULT now() NOT NULL
|
||||
"written_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "file_write_log_status_chk" CHECK ("file_write_log"."status" in ('success', 'skipped', 'failed')),
|
||||
CONSTRAINT "file_write_log_triggered_by_chk" CHECK ("file_write_log"."triggered_by" in ('auto', 'sync')),
|
||||
CONSTRAINT "file_write_log_duration_ms_nonnegative_chk" CHECK ("file_write_log"."duration_ms" is null or "file_write_log"."duration_ms" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "email_templates" (
|
||||
@@ -621,8 +685,8 @@ CREATE TABLE "email_templates" (
|
||||
"body_text" text NOT NULL,
|
||||
"is_default" boolean DEFAULT false NOT NULL,
|
||||
"is_system" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_templates_user_id_name_unique" UNIQUE("user_id","name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -642,15 +706,17 @@ CREATE TABLE "email_providers" (
|
||||
"is_default" boolean DEFAULT false NOT NULL,
|
||||
"is_shared" boolean DEFAULT false NOT NULL,
|
||||
"is_system_provider" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_providers_user_id_name_unique" UNIQUE("user_id","name")
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_providers_user_id_name_unique" UNIQUE("user_id","name"),
|
||||
CONSTRAINT "email_providers_port_range_chk" CHECK ("email_providers"."port" >= 1 and "email_providers"."port" <= 65535)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "email_recipient_group_members" (
|
||||
"user_id" integer NOT NULL,
|
||||
"group_id" integer NOT NULL,
|
||||
"recipient_id" integer NOT NULL,
|
||||
CONSTRAINT "email_recipient_group_members_group_id_recipient_id_unique" UNIQUE("group_id","recipient_id")
|
||||
CONSTRAINT "email_recipient_group_members_group_id_recipient_id_pk" PRIMARY KEY("group_id","recipient_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "email_recipient_groups" (
|
||||
@@ -658,8 +724,8 @@ CREATE TABLE "email_recipient_groups" (
|
||||
"user_id" integer NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"default_template_id" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_recipient_groups_user_id_name_unique" UNIQUE("user_id","name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -672,9 +738,11 @@ CREATE TABLE "email_recipients" (
|
||||
"device_type" varchar(20),
|
||||
"preferred_format" varchar(20),
|
||||
"default_template_id" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_recipients_user_id_email_unique" UNIQUE("user_id","email")
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_recipients_user_id_email_unique" UNIQUE("user_id","email"),
|
||||
CONSTRAINT "email_recipients_device_type_chk" CHECK ("email_recipients"."device_type" is null or "email_recipients"."device_type" in ('kindle', 'kobo', 'other')),
|
||||
CONSTRAINT "email_recipients_preferred_format_chk" CHECK ("email_recipients"."preferred_format" is null or "email_recipients"."preferred_format" in ('epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "email_preferences" (
|
||||
@@ -683,8 +751,8 @@ CREATE TABLE "email_preferences" (
|
||||
"default_provider_id" integer,
|
||||
"default_recipient_id" integer,
|
||||
"default_template_id" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_preferences_user_id_unique" UNIQUE("user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -700,11 +768,14 @@ CREATE TABLE "email_send_log" (
|
||||
"subject" text,
|
||||
"status" varchar(20) DEFAULT 'pending' NOT NULL,
|
||||
"attempt_count" integer DEFAULT 0 NOT NULL,
|
||||
"next_retry_at" timestamp,
|
||||
"next_retry_at" timestamp with time zone,
|
||||
"error_message" text,
|
||||
"sent_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
"sent_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_send_log_status_chk" CHECK ("email_send_log"."status" in ('pending', 'sent', 'failed')),
|
||||
CONSTRAINT "email_send_log_attempt_count_nonnegative_chk" CHECK ("email_send_log"."attempt_count" >= 0),
|
||||
CONSTRAINT "email_send_log_sent_after_created_chk" CHECK ("email_send_log"."sent_at" is null or "email_send_log"."sent_at" >= "email_send_log"."created_at")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
@@ -717,6 +788,7 @@ ALTER TABLE "comic_metadata" ADD CONSTRAINT "comic_metadata_book_id_books_id_fk"
|
||||
ALTER TABLE "migration_plan_artifacts" ADD CONSTRAINT "migration_plan_artifacts_source_id_migration_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."migration_sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "migration_plan_artifacts" ADD CONSTRAINT "migration_plan_artifacts_profile_id_migration_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."migration_profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "migration_plan_artifacts" ADD CONSTRAINT "migration_plan_artifacts_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "migration_plan_artifacts" ADD CONSTRAINT "migration_plan_artifacts_profile_source_fk" FOREIGN KEY ("profile_id","source_id") REFERENCES "public"."migration_profiles"("id","source_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "migration_profiles" ADD CONSTRAINT "migration_profiles_source_id_migration_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."migration_sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "migration_profiles" ADD CONSTRAINT "migration_profiles_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "migration_run_metrics" ADD CONSTRAINT "migration_run_metrics_run_id_migration_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."migration_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
@@ -724,13 +796,17 @@ ALTER TABLE "migration_runs" ADD CONSTRAINT "migration_runs_source_id_migration_
|
||||
ALTER TABLE "migration_runs" ADD CONSTRAINT "migration_runs_profile_id_migration_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."migration_profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "migration_runs" ADD CONSTRAINT "migration_runs_plan_artifact_id_migration_plan_artifacts_id_fk" FOREIGN KEY ("plan_artifact_id") REFERENCES "public"."migration_plan_artifacts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "migration_runs" ADD CONSTRAINT "migration_runs_triggered_by_user_id_users_id_fk" FOREIGN KEY ("triggered_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "migration_runs" ADD CONSTRAINT "migration_runs_profile_source_fk" FOREIGN KEY ("profile_id","source_id") REFERENCES "public"."migration_profiles"("id","source_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "migration_runs" ADD CONSTRAINT "migration_runs_plan_artifact_source_profile_fk" FOREIGN KEY ("plan_artifact_id","source_id","profile_id") REFERENCES "public"."migration_plan_artifacts"("id","source_id","profile_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "migration_sources" ADD CONSTRAINT "migration_sources_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "library_folders" ADD CONSTRAINT "library_folders_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "book_files" ADD CONSTRAINT "book_files_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "book_files" ADD CONSTRAINT "book_files_library_folder_id_library_folders_id_fk" FOREIGN KEY ("library_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "book_files" ADD CONSTRAINT "book_files_book_folder_consistency_fk" FOREIGN KEY ("book_id","library_folder_id") REFERENCES "public"."books"("id","library_folder_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "books" ADD CONSTRAINT "books_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "books" ADD CONSTRAINT "books_library_folder_id_library_folders_id_fk" FOREIGN KEY ("library_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "books" ADD CONSTRAINT "books_primary_file_id_book_files_id_fk" FOREIGN KEY ("primary_file_id") REFERENCES "public"."book_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "books" ADD CONSTRAINT "books_library_folder_library_fk" FOREIGN KEY ("library_folder_id","library_id") REFERENCES "public"."library_folders"("id","library_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collection_books" ADD CONSTRAINT "collection_books_collection_id_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collection_books" ADD CONSTRAINT "collection_books_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collections" ADD CONSTRAINT "collections_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
@@ -781,8 +857,11 @@ ALTER TABLE "file_write_log" ADD CONSTRAINT "file_write_log_book_file_id_book_fi
|
||||
ALTER TABLE "file_write_log" ADD CONSTRAINT "file_write_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_templates" ADD CONSTRAINT "email_templates_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_providers" ADD CONSTRAINT "email_providers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_recipient_group_members" ADD CONSTRAINT "email_recipient_group_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_recipient_group_members" ADD CONSTRAINT "email_recipient_group_members_group_id_email_recipient_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."email_recipient_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_recipient_group_members" ADD CONSTRAINT "email_recipient_group_members_recipient_id_email_recipients_id_fk" FOREIGN KEY ("recipient_id") REFERENCES "public"."email_recipients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_recipient_group_members" ADD CONSTRAINT "email_recipient_group_members_group_user_fk" FOREIGN KEY ("group_id","user_id") REFERENCES "public"."email_recipient_groups"("id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_recipient_group_members" ADD CONSTRAINT "email_recipient_group_members_recipient_user_fk" FOREIGN KEY ("recipient_id","user_id") REFERENCES "public"."email_recipients"("id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_recipient_groups" ADD CONSTRAINT "email_recipient_groups_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_recipient_groups" ADD CONSTRAINT "email_recipient_groups_default_template_id_email_templates_id_fk" FOREIGN KEY ("default_template_id") REFERENCES "public"."email_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_recipients" ADD CONSTRAINT "email_recipients_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
@@ -791,6 +870,7 @@ ALTER TABLE "email_preferences" ADD CONSTRAINT "email_preferences_user_id_users_
|
||||
ALTER TABLE "email_preferences" ADD CONSTRAINT "email_preferences_default_provider_id_email_providers_id_fk" FOREIGN KEY ("default_provider_id") REFERENCES "public"."email_providers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_preferences" ADD CONSTRAINT "email_preferences_default_recipient_id_email_recipients_id_fk" FOREIGN KEY ("default_recipient_id") REFERENCES "public"."email_recipients"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_preferences" ADD CONSTRAINT "email_preferences_default_template_id_email_templates_id_fk" FOREIGN KEY ("default_template_id") REFERENCES "public"."email_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_preferences" ADD CONSTRAINT "email_preferences_default_recipient_owner_fk" FOREIGN KEY ("default_recipient_id","user_id") REFERENCES "public"."email_recipients"("id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
@@ -802,11 +882,18 @@ CREATE INDEX "idx_audit_action" ON "audit_log" USING btree ("action");--> statem
|
||||
CREATE INDEX "idx_audit_ip" ON "audit_log" USING btree ("ip");--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_created_at" ON "audit_log" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "password_reset_tokens_expires_at_idx" ON "password_reset_tokens" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "refresh_tokens_expires_at_idx" ON "refresh_tokens" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_username_lower_uidx" ON "users" USING btree (lower("username"));--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_email_lower_uidx" ON "users" USING btree (lower("email")) WHERE "users"."email" is not null;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_oidc_subject_issuer_uidx" ON "users" USING btree ("oidc_subject","oidc_issuer") WHERE "users"."oidc_subject" is not null and "users"."oidc_issuer" is not null;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "migration_plan_artifacts_plan_hash_uidx" ON "migration_plan_artifacts" USING btree ("plan_hash");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "migration_plan_artifacts_id_source_profile_uidx" ON "migration_plan_artifacts" USING btree ("id","source_id","profile_id");--> statement-breakpoint
|
||||
CREATE INDEX "migration_plan_artifacts_source_id_idx" ON "migration_plan_artifacts" USING btree ("source_id");--> statement-breakpoint
|
||||
CREATE INDEX "migration_plan_artifacts_profile_id_idx" ON "migration_plan_artifacts" USING btree ("profile_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "migration_profiles_source_name_version_uidx" ON "migration_profiles" USING btree ("source_id","name","version");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "migration_profiles_id_source_id_uidx" ON "migration_profiles" USING btree ("id","source_id");--> statement-breakpoint
|
||||
CREATE INDEX "migration_profiles_source_id_idx" ON "migration_profiles" USING btree ("source_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "migration_run_metrics_run_stage_entity_uidx" ON "migration_run_metrics" USING btree ("run_id","stage","entity_type");--> statement-breakpoint
|
||||
CREATE INDEX "migration_run_metrics_run_id_idx" ON "migration_run_metrics" USING btree ("run_id");--> statement-breakpoint
|
||||
@@ -814,8 +901,26 @@ CREATE INDEX "migration_runs_source_target_state_idx" ON "migration_runs" USING
|
||||
CREATE INDEX "migration_runs_state_idx" ON "migration_runs" USING btree ("state");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "migration_sources_type_name_uidx" ON "migration_sources" USING btree ("type","name");--> statement-breakpoint
|
||||
CREATE INDEX "migration_sources_created_by_user_id_idx" ON "migration_sources" USING btree ("created_by_user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "libraries_name_lower_uidx" ON "libraries" USING btree (lower("name"));--> statement-breakpoint
|
||||
CREATE INDEX "library_folders_library_id_idx" ON "library_folders" USING btree ("library_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "library_folders_library_path_uidx" ON "library_folders" USING btree ("library_id","path");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "library_folders_id_library_id_uidx" ON "library_folders" USING btree ("id","library_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "book_files_absolute_path_uidx" ON "book_files" USING btree ("absolute_path");--> statement-breakpoint
|
||||
CREATE INDEX "book_files_book_id_idx" ON "book_files" USING btree ("book_id");--> statement-breakpoint
|
||||
CREATE INDEX "book_files_library_folder_id_idx" ON "book_files" USING btree ("library_folder_id");--> statement-breakpoint
|
||||
CREATE INDEX "book_files_hash_idx" ON "book_files" USING btree ("hash");--> statement-breakpoint
|
||||
CREATE INDEX "book_files_ino_idx" ON "book_files" USING btree ("ino");--> statement-breakpoint
|
||||
CREATE INDEX "book_files_format_idx" ON "book_files" USING btree ("format");--> statement-breakpoint
|
||||
CREATE INDEX "book_files_library_folder_hash_idx" ON "book_files" USING btree ("library_folder_id","hash");--> statement-breakpoint
|
||||
CREATE INDEX "book_files_library_folder_ino_idx" ON "book_files" USING btree ("library_folder_id","ino");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "books_library_id_folder_path_idx" ON "books" USING btree ("library_id","folder_path");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "books_id_library_folder_id_uidx" ON "books" USING btree ("id","library_folder_id");--> statement-breakpoint
|
||||
CREATE INDEX "books_primary_file_id_idx" ON "books" USING btree ("primary_file_id");--> statement-breakpoint
|
||||
CREATE INDEX "books_library_status_idx" ON "books" USING btree ("library_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "books_library_added_at_idx" ON "books" USING btree ("library_id","added_at" desc);--> statement-breakpoint
|
||||
CREATE INDEX "collection_books_book_id_idx" ON "collection_books" USING btree ("book_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "collections_user_name_uidx" ON "collections" USING btree ("user_id","name");--> statement-breakpoint
|
||||
CREATE INDEX "collections_user_display_name_idx" ON "collections" USING btree ("user_id","display_order","name");--> statement-breakpoint
|
||||
CREATE INDEX "author_enrichment_queue_status_next_attempt_idx" ON "author_enrichment_queue" USING btree ("status","next_attempt_at");--> statement-breakpoint
|
||||
CREATE INDEX "author_enrichment_queue_next_attempt_idx" ON "author_enrichment_queue" USING btree ("next_attempt_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "author_enrichment_queue_single_processing_idx" ON "author_enrichment_queue" USING btree ("status") WHERE "author_enrichment_queue"."status" = 'processing';--> statement-breakpoint
|
||||
@@ -825,14 +930,19 @@ CREATE INDEX "bm_title_trgm_idx" ON "book_metadata" USING gin ("title" gin_trgm_
|
||||
CREATE INDEX "bm_series_trgm_idx" ON "book_metadata" USING gin ("series_name" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "bm_publisher_trgm_idx" ON "book_metadata" USING gin ("publisher" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "bm_language_idx" ON "book_metadata" USING btree ("language");--> statement-breakpoint
|
||||
CREATE INDEX "bm_published_year_idx" ON "book_metadata" USING btree ("published_year");--> statement-breakpoint
|
||||
CREATE INDEX "bm_series_name_index_idx" ON "book_metadata" USING btree ("series_name","series_index");--> statement-breakpoint
|
||||
CREATE INDEX "bmfq_status_idx" ON "book_metadata_fetch_queue" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "bmfq_created_at_idx" ON "book_metadata_fetch_queue" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "book_narrators_narrator_id_idx" ON "book_narrators" USING btree ("narrator_id");--> statement-breakpoint
|
||||
CREATE INDEX "narrators_name_trgm_idx" ON "narrators" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "scan_jobs_library_status_idx" ON "scan_jobs" USING btree ("library_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "annotations_user_id_idx" ON "annotations" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "annotations_user_book_idx" ON "annotations" USING btree ("user_id","book_id");--> statement-breakpoint
|
||||
CREATE INDEX "abp_user_id_idx" ON "audiobook_progress" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "bookmarks_user_id_idx" ON "bookmarks" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "bookmarks_user_book_idx" ON "bookmarks" USING btree ("user_id","book_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "bookmarks_user_book_cfi_uidx" ON "bookmarks" USING btree ("user_id","book_id","cfi") WHERE "bookmarks"."cfi" is not null;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "bookmarks_user_book_pos_uidx" ON "bookmarks" USING btree ("user_id","book_id","position_seconds") WHERE "bookmarks"."position_seconds" is not null and "bookmarks"."cfi" is null;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "rdp_user_format_idx" ON "reader_default_preferences" USING btree ("user_id","format_group");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "rp_user_file_idx" ON "reader_preferences" USING btree ("user_id","book_file_id");--> statement-breakpoint
|
||||
CREATE INDEX "reading_progress_user_id_idx" ON "reading_progress" USING btree ("user_id");--> statement-breakpoint
|
||||
@@ -843,10 +953,23 @@ CREATE INDEX "rs_user_book_file_idx" ON "reading_sessions" USING btree ("user_id
|
||||
CREATE INDEX "ubs_user_id_idx" ON "user_book_status" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "ubs_user_status_idx" ON "user_book_status" USING btree ("user_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "urds_user_day_idx" ON "user_reading_daily_stats" USING btree ("user_id","day");--> statement-breakpoint
|
||||
CREATE INDEX "urds_user_library_day_idx" ON "user_reading_daily_stats" USING btree ("user_id","library_id","day");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_sessions_user_id_idx" ON "oidc_sessions" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_sessions_user_active_idx" ON "oidc_sessions" USING btree ("user_id") WHERE "oidc_sessions"."revoked" = false;--> statement-breakpoint
|
||||
CREATE INDEX "oidc_sessions_subject_issuer_idx" ON "oidc_sessions" USING btree ("oidc_subject","oidc_issuer");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_sessions_sid_idx" ON "oidc_sessions" USING btree ("oidc_session_id");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_sessions_expires_at_idx" ON "oidc_sessions" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "opds_users_username_lower_uidx" ON "opds_users" USING btree (lower("username"));--> statement-breakpoint
|
||||
CREATE INDEX "kobo_devices_user_id_idx" ON "kobo_devices" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "book_bucket_files_status_idx" ON "book_bucket_files" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "fwl_book_id_idx" ON "file_write_log" USING btree ("book_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null;
|
||||
CREATE INDEX "fwl_book_id_written_at_idx" ON "file_write_log" USING btree ("book_id","written_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "email_templates_one_default_per_user_uidx" ON "email_templates" USING btree ("user_id") WHERE "email_templates"."is_default" = true and "email_templates"."user_id" is not null;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "email_providers_id_user_id_uidx" ON "email_providers" USING btree ("id","user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "email_providers_one_default_per_user_uidx" ON "email_providers" USING btree ("user_id") WHERE "email_providers"."is_default" = true and "email_providers"."user_id" is not null;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "email_providers_one_system_provider_uidx" ON "email_providers" USING btree ("is_system_provider") WHERE "email_providers"."is_system_provider" = true;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "email_recipient_groups_id_user_id_uidx" ON "email_recipient_groups" USING btree ("id","user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "email_recipients_user_email_lower_uidx" ON "email_recipients" USING btree ("user_id",lower("email"));--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "email_recipients_one_default_per_user_uidx" ON "email_recipients" USING btree ("user_id") WHERE "email_recipients"."is_default" = true;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "email_recipients_id_user_id_uidx" ON "email_recipients" USING btree ("id","user_id");--> statement-breakpoint
|
||||
CREATE INDEX "email_send_log_user_created_at_idx" ON "email_send_log" USING btree ("user_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "email_send_log_created_at_idx" ON "email_send_log" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "email_send_log_status_next_retry_idx" ON "email_send_log" USING btree ("status","next_retry_at");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1775440071752,
|
||||
"when": 1775479477769,
|
||||
"tag": "0000_initial",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const auditLog = pgTable(
|
||||
description: text('description').notNull(),
|
||||
ip: varchar('ip', { length: 45 }),
|
||||
meta: jsonb('meta'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_audit_user_id').on(t.userId),
|
||||
|
||||
@@ -1,32 +1,62 @@
|
||||
import { boolean, index, integer, jsonb, pgEnum, pgTable, primaryKey, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
serial,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
import { libraries } from './libraries';
|
||||
|
||||
export const userAvatarSourceEnum = pgEnum('user_avatar_source', ['none', 'external', 'uploaded']);
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: serial('id').primaryKey(),
|
||||
username: varchar('username', { length: 100 }).notNull().unique(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
email: varchar('email', { length: 255 }).unique(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
active: boolean('active').notNull().default(true),
|
||||
isSuperuser: boolean('is_superuser').notNull().default(false),
|
||||
isDefaultPassword: boolean('is_default_password').notNull().default(false),
|
||||
tokenVersion: integer('token_version').notNull().default(1),
|
||||
settings: jsonb('settings').notNull().default({}),
|
||||
oidcSubject: text('oidc_subject'),
|
||||
oidcIssuer: text('oidc_issuer'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
avatarSource: userAvatarSourceEnum('avatar_source').notNull().default('none'),
|
||||
avatarVersion: integer('avatar_version').notNull().default(0),
|
||||
provisioningMethod: varchar('provisioning_method', { length: 20 }).notNull().default('local'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
});
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
username: varchar('username', { length: 100 }).notNull().unique(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
email: varchar('email', { length: 255 }).unique(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
active: boolean('active').notNull().default(true),
|
||||
isSuperuser: boolean('is_superuser').notNull().default(false),
|
||||
isDefaultPassword: boolean('is_default_password').notNull().default(false),
|
||||
tokenVersion: integer('token_version').notNull().default(1),
|
||||
settings: jsonb('settings').notNull().default({}),
|
||||
oidcSubject: text('oidc_subject'),
|
||||
oidcIssuer: text('oidc_issuer'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
avatarSource: userAvatarSourceEnum('avatar_source').notNull().default('none'),
|
||||
avatarVersion: integer('avatar_version').notNull().default(0),
|
||||
provisioningMethod: varchar('provisioning_method', { length: 20 }).notNull().default('local'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('users_username_lower_uidx').on(sql`lower(${t.username})`),
|
||||
uniqueIndex('users_email_lower_uidx')
|
||||
.on(sql`lower(${t.email})`)
|
||||
.where(sql`${t.email} is not null`),
|
||||
uniqueIndex('users_oidc_subject_issuer_uidx')
|
||||
.on(t.oidcSubject, t.oidcIssuer)
|
||||
.where(sql`${t.oidcSubject} is not null and ${t.oidcIssuer} is not null`),
|
||||
check('users_provisioning_method_chk', sql`${t.provisioningMethod} in ('local', 'manual', 'oidc')`),
|
||||
check('users_token_version_nonnegative_chk', sql`${t.tokenVersion} >= 0`),
|
||||
check('users_avatar_version_nonnegative_chk', sql`${t.avatarVersion} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export const userPermissions = pgTable(
|
||||
'user_permissions',
|
||||
@@ -63,11 +93,15 @@ export const refreshTokens = pgTable(
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
revokedAt: timestamp('revoked_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('refresh_tokens_user_id_idx').on(t.userId)],
|
||||
(t) => [
|
||||
index('refresh_tokens_user_id_idx').on(t.userId),
|
||||
index('refresh_tokens_expires_at_idx').on(t.expiresAt),
|
||||
check('refresh_tokens_expires_after_created_chk', sql`${t.expiresAt} > ${t.createdAt}`),
|
||||
],
|
||||
);
|
||||
|
||||
export const passwordResetTokens = pgTable(
|
||||
@@ -78,18 +112,22 @@ export const passwordResetTokens = pgTable(
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
usedAt: timestamp('used_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('password_reset_tokens_user_id_idx').on(t.userId)],
|
||||
(t) => [
|
||||
index('password_reset_tokens_user_id_idx').on(t.userId),
|
||||
index('password_reset_tokens_expires_at_idx').on(t.expiresAt),
|
||||
check('password_reset_tokens_expires_after_created_chk', sql`${t.expiresAt} > ${t.createdAt}`),
|
||||
],
|
||||
);
|
||||
|
||||
export const appSettings = pgTable('app_settings', {
|
||||
id: serial('id').primaryKey(),
|
||||
key: varchar('key', { length: 100 }).notNull().unique(),
|
||||
value: text('value').notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bigint, index, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { bigint, check, index, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
import type { BookBucketMetadata } from '@projectx/types';
|
||||
|
||||
import { libraries, libraryFolders } from './libraries';
|
||||
@@ -21,14 +22,18 @@ export const bookBucketFiles = pgTable(
|
||||
confidence: integer('confidence'),
|
||||
fetchedMetadataSources: jsonb('fetched_metadata_sources').$type<Partial<Record<keyof BookBucketMetadata, string>>>(),
|
||||
errorMessage: text('error_message'),
|
||||
metadataEditedAt: timestamp('metadata_edited_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
metadataEditedAt: timestamp('metadata_edited_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [index('book_bucket_files_status_idx').on(t.status)],
|
||||
(t) => [
|
||||
index('book_bucket_files_status_idx').on(t.status),
|
||||
check('book_bucket_files_status_chk', sql`${t.status} in ('pending', 'extracting', 'fetching', 'ready', 'error')`),
|
||||
check('book_bucket_files_confidence_range_chk', sql`${t.confidence} is null or (${t.confidence} >= 0 and ${t.confidence} <= 100)`),
|
||||
],
|
||||
);
|
||||
|
||||
export type BookBucketFileRow = typeof bookBucketFiles.$inferSelect;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bigint, index, integer, pgTable, serial, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { bigint, check, foreignKey, index, integer, pgTable, serial, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { libraryFolders, libraries } from './libraries';
|
||||
|
||||
@@ -15,39 +16,72 @@ export const books = pgTable(
|
||||
primaryFileId: integer('primary_file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
|
||||
folderPath: varchar('folder_path', { length: 4096 }).notNull(),
|
||||
status: varchar('status', { length: 20 }).notNull().default('present'),
|
||||
addedAt: timestamp('added_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [uniqueIndex('books_library_id_folder_path_idx').on(t.libraryId, t.folderPath), index('books_primary_file_id_idx').on(t.primaryFileId)],
|
||||
(t) => [
|
||||
uniqueIndex('books_library_id_folder_path_idx').on(t.libraryId, t.folderPath),
|
||||
uniqueIndex('books_id_library_folder_id_uidx').on(t.id, t.libraryFolderId),
|
||||
index('books_primary_file_id_idx').on(t.primaryFileId),
|
||||
index('books_library_status_idx').on(t.libraryId, t.status),
|
||||
index('books_library_added_at_idx').on(t.libraryId, sql`${t.addedAt} desc`),
|
||||
foreignKey({
|
||||
columns: [t.libraryFolderId, t.libraryId],
|
||||
foreignColumns: [libraryFolders.id, libraryFolders.libraryId],
|
||||
name: 'books_library_folder_library_fk',
|
||||
}),
|
||||
check('books_status_chk', sql`${t.status} in ('present', 'missing')`),
|
||||
],
|
||||
);
|
||||
|
||||
export const bookFiles = pgTable('book_files', {
|
||||
id: serial('id').primaryKey(),
|
||||
bookId: integer('book_id')
|
||||
.notNull()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
libraryFolderId: integer('library_folder_id')
|
||||
.notNull()
|
||||
.references(() => libraryFolders.id, { onDelete: 'cascade' }),
|
||||
absolutePath: varchar('absolute_path', { length: 4096 }).notNull(),
|
||||
relPath: varchar('rel_path', { length: 4096 }),
|
||||
ino: bigint('ino', { mode: 'number' }).notNull(),
|
||||
sizeBytes: bigint('size_bytes', { mode: 'number' }),
|
||||
mtime: timestamp('mtime'),
|
||||
hash: varchar('hash', { length: 64 }),
|
||||
format: varchar('format', { length: 20 }),
|
||||
role: varchar('role', { length: 20 }).notNull().default('content'),
|
||||
sortOrder: integer('sort_order'),
|
||||
durationSeconds: integer('duration_seconds'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
});
|
||||
export const bookFiles = pgTable(
|
||||
'book_files',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
bookId: integer('book_id')
|
||||
.notNull()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
libraryFolderId: integer('library_folder_id')
|
||||
.notNull()
|
||||
.references(() => libraryFolders.id, { onDelete: 'cascade' }),
|
||||
absolutePath: varchar('absolute_path', { length: 4096 }).notNull(),
|
||||
relPath: varchar('rel_path', { length: 4096 }),
|
||||
ino: bigint('ino', { mode: 'number' }).notNull(),
|
||||
sizeBytes: bigint('size_bytes', { mode: 'number' }),
|
||||
mtime: timestamp('mtime', { withTimezone: true }),
|
||||
hash: varchar('hash', { length: 64 }),
|
||||
format: varchar('format', { length: 20 }),
|
||||
role: varchar('role', { length: 20 }).notNull().default('content'),
|
||||
sortOrder: integer('sort_order'),
|
||||
durationSeconds: integer('duration_seconds'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('book_files_absolute_path_uidx').on(t.absolutePath),
|
||||
index('book_files_book_id_idx').on(t.bookId),
|
||||
index('book_files_library_folder_id_idx').on(t.libraryFolderId),
|
||||
index('book_files_hash_idx').on(t.hash),
|
||||
index('book_files_ino_idx').on(t.ino),
|
||||
index('book_files_format_idx').on(t.format),
|
||||
index('book_files_library_folder_hash_idx').on(t.libraryFolderId, t.hash),
|
||||
index('book_files_library_folder_ino_idx').on(t.libraryFolderId, t.ino),
|
||||
foreignKey({
|
||||
columns: [t.bookId, t.libraryFolderId],
|
||||
foreignColumns: [books.id, books.libraryFolderId],
|
||||
name: 'book_files_book_folder_consistency_fk',
|
||||
}),
|
||||
check('book_files_role_chk', sql`${t.role} in ('content', 'cover', 'supplement')`),
|
||||
check('book_files_size_bytes_nonnegative_chk', sql`${t.sizeBytes} is null or ${t.sizeBytes} >= 0`),
|
||||
check('book_files_duration_seconds_nonnegative_chk', sql`${t.durationSeconds} is null or ${t.durationSeconds} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export type Book = typeof books.$inferSelect;
|
||||
export type NewBook = typeof books.$inferInsert;
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { boolean, integer, pgTable, primaryKey, serial, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { index, boolean, integer, pgTable, primaryKey, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { books } from './books';
|
||||
import { users } from './auth';
|
||||
|
||||
export const collections = pgTable('collections', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
icon: text('icon'),
|
||||
description: text('description'),
|
||||
syncToKobo: boolean('sync_to_kobo').notNull().default(false),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
});
|
||||
export const collections = pgTable(
|
||||
'collections',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
icon: text('icon'),
|
||||
description: text('description'),
|
||||
syncToKobo: boolean('sync_to_kobo').notNull().default(false),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('collections_user_name_uidx').on(t.userId, t.name),
|
||||
index('collections_user_display_name_idx').on(t.userId, t.displayOrder, t.name),
|
||||
],
|
||||
);
|
||||
|
||||
export const collectionBooks = pgTable(
|
||||
'collection_books',
|
||||
@@ -29,7 +36,7 @@ export const collectionBooks = pgTable(
|
||||
bookId: integer('book_id')
|
||||
.notNull()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
addedAt: timestamp('added_at').defaultNow().notNull(),
|
||||
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.collectionId, table.bookId] })],
|
||||
(table) => [primaryKey({ columns: [table.collectionId, table.bookId] }), index('collection_books_book_id_idx').on(table.bookId)],
|
||||
);
|
||||
|
||||
@@ -20,8 +20,8 @@ export const comicMetadata = pgTable(
|
||||
teams: text('teams').array(),
|
||||
locations: text('locations').array(),
|
||||
storyArcs: text('story_arcs').array(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import { integer, pgTable, serial, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { foreignKey, integer, pgTable, serial, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { users } from './auth';
|
||||
import { emailProviders } from './email-providers';
|
||||
import { emailRecipients } from './email-recipients';
|
||||
import { emailTemplates } from './email-templates';
|
||||
|
||||
export const emailPreferences = pgTable('email_preferences', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
defaultProviderId: integer('default_provider_id').references(() => emailProviders.id, { onDelete: 'set null' }),
|
||||
defaultRecipientId: integer('default_recipient_id').references(() => emailRecipients.id, { onDelete: 'set null' }),
|
||||
defaultTemplateId: integer('default_template_id').references(() => emailTemplates.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
});
|
||||
export const emailPreferences = pgTable(
|
||||
'email_preferences',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
defaultProviderId: integer('default_provider_id').references(() => emailProviders.id, { onDelete: 'set null' }),
|
||||
defaultRecipientId: integer('default_recipient_id').references(() => emailRecipients.id, { onDelete: 'set null' }),
|
||||
defaultTemplateId: integer('default_template_id').references(() => emailTemplates.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
foreignKey({
|
||||
columns: [t.defaultRecipientId, t.userId],
|
||||
foreignColumns: [emailRecipients.id, emailRecipients.userId],
|
||||
name: 'email_preferences_default_recipient_owner_fk',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
export type EmailPreferences = typeof emailPreferences.$inferSelect;
|
||||
export type NewEmailPreferences = typeof emailPreferences.$inferInsert;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { boolean, integer, pgTable, serial, text, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { boolean, check, integer, pgTable, serial, text, timestamp, unique, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { users } from './auth';
|
||||
|
||||
@@ -20,13 +21,23 @@ export const emailProviders = pgTable(
|
||||
isDefault: boolean('is_default').notNull().default(false),
|
||||
isShared: boolean('is_shared').notNull().default(false),
|
||||
isSystemProvider: boolean('is_system_provider').notNull().default(false),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.name)],
|
||||
(t) => [
|
||||
unique().on(t.userId, t.name),
|
||||
uniqueIndex('email_providers_id_user_id_uidx').on(t.id, t.userId),
|
||||
uniqueIndex('email_providers_one_default_per_user_uidx')
|
||||
.on(t.userId)
|
||||
.where(sql`${t.isDefault} = true and ${t.userId} is not null`),
|
||||
uniqueIndex('email_providers_one_system_provider_uidx')
|
||||
.on(t.isSystemProvider)
|
||||
.where(sql`${t.isSystemProvider} = true`),
|
||||
check('email_providers_port_range_chk', sql`${t.port} >= 1 and ${t.port} <= 65535`),
|
||||
],
|
||||
);
|
||||
|
||||
export type EmailProvider = typeof emailProviders.$inferSelect;
|
||||
|
||||
@@ -44,12 +44,15 @@ describe('email-recipients schema', () => {
|
||||
|
||||
it('protects group-membership integrity with composite uniqueness and cascade deletes', () => {
|
||||
const config = getTableConfig(emailRecipientGroupMembers);
|
||||
const unique = config.uniqueConstraints.map((constraint) => constraint.columns.map((col) => col.name));
|
||||
const primaryKeys = config.primaryKeys.map((constraint) => constraint.columns.map((col) => col.name));
|
||||
|
||||
expect(unique).toContainEqual(['group_id', 'recipient_id']);
|
||||
expect(primaryKeys).toContainEqual(['group_id', 'recipient_id']);
|
||||
|
||||
const foreignKeys = fkByColumn(emailRecipientGroupMembers);
|
||||
expect(foreignKeys.get('user_id')?.onDelete).toBe('cascade');
|
||||
expect(foreignKeys.get('group_id')?.onDelete).toBe('cascade');
|
||||
expect(foreignKeys.get('recipient_id')?.onDelete).toBe('cascade');
|
||||
expect(foreignKeys.get('group_id,user_id')).toBeDefined();
|
||||
expect(foreignKeys.get('recipient_id,user_id')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { boolean, integer, pgTable, serial, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { boolean, check, foreignKey, integer, pgTable, primaryKey, serial, timestamp, unique, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { users } from './auth';
|
||||
import { emailTemplates } from './email-templates';
|
||||
@@ -16,13 +17,25 @@ export const emailRecipients = pgTable(
|
||||
deviceType: varchar('device_type', { length: 20 }),
|
||||
preferredFormat: varchar('preferred_format', { length: 20 }),
|
||||
defaultTemplateId: integer('default_template_id').references(() => emailTemplates.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.email)],
|
||||
(t) => [
|
||||
unique().on(t.userId, t.email),
|
||||
uniqueIndex('email_recipients_user_email_lower_uidx').on(t.userId, sql`lower(${t.email})`),
|
||||
uniqueIndex('email_recipients_one_default_per_user_uidx')
|
||||
.on(t.userId)
|
||||
.where(sql`${t.isDefault} = true`),
|
||||
uniqueIndex('email_recipients_id_user_id_uidx').on(t.id, t.userId),
|
||||
check('email_recipients_device_type_chk', sql`${t.deviceType} is null or ${t.deviceType} in ('kindle', 'kobo', 'other')`),
|
||||
check(
|
||||
'email_recipients_preferred_format_chk',
|
||||
sql`${t.preferredFormat} is null or ${t.preferredFormat} in ('epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const emailRecipientGroups = pgTable(
|
||||
@@ -34,18 +47,21 @@ export const emailRecipientGroups = pgTable(
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
defaultTemplateId: integer('default_template_id').references(() => emailTemplates.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.name)],
|
||||
(t) => [unique().on(t.userId, t.name), uniqueIndex('email_recipient_groups_id_user_id_uidx').on(t.id, t.userId)],
|
||||
);
|
||||
|
||||
export const emailRecipientGroupMembers = pgTable(
|
||||
'email_recipient_group_members',
|
||||
{
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
groupId: integer('group_id')
|
||||
.notNull()
|
||||
.references(() => emailRecipientGroups.id, { onDelete: 'cascade' }),
|
||||
@@ -53,7 +69,19 @@ export const emailRecipientGroupMembers = pgTable(
|
||||
.notNull()
|
||||
.references(() => emailRecipients.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(t) => [unique().on(t.groupId, t.recipientId)],
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.groupId, t.recipientId] }),
|
||||
foreignKey({
|
||||
columns: [t.groupId, t.userId],
|
||||
foreignColumns: [emailRecipientGroups.id, emailRecipientGroups.userId],
|
||||
name: 'email_recipient_group_members_group_user_fk',
|
||||
}),
|
||||
foreignKey({
|
||||
columns: [t.recipientId, t.userId],
|
||||
foreignColumns: [emailRecipients.id, emailRecipients.userId],
|
||||
name: 'email_recipient_group_members_recipient_user_fk',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
export type EmailRecipient = typeof emailRecipients.$inferSelect;
|
||||
|
||||
@@ -1,33 +1,45 @@
|
||||
import { integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, index, integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { users } from './auth';
|
||||
import { books, bookFiles } from './books';
|
||||
import { emailProviders } from './email-providers';
|
||||
import { emailTemplates } from './email-templates';
|
||||
|
||||
export const emailSendLog = pgTable('email_send_log', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
bookId: integer('book_id').references(() => books.id, { onDelete: 'set null' }),
|
||||
bookFileId: integer('book_file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
|
||||
providerId: integer('provider_id').references(() => emailProviders.id, { onDelete: 'set null' }),
|
||||
templateId: integer('template_id').references(() => emailTemplates.id, { onDelete: 'set null' }),
|
||||
toEmail: varchar('to_email', { length: 255 }).notNull(),
|
||||
toName: varchar('to_name', { length: 255 }),
|
||||
subject: text('subject'),
|
||||
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||
attemptCount: integer('attempt_count').notNull().default(0),
|
||||
nextRetryAt: timestamp('next_retry_at'),
|
||||
errorMessage: text('error_message'),
|
||||
sentAt: timestamp('sent_at'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
});
|
||||
export const emailSendLog = pgTable(
|
||||
'email_send_log',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
bookId: integer('book_id').references(() => books.id, { onDelete: 'set null' }),
|
||||
bookFileId: integer('book_file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
|
||||
providerId: integer('provider_id').references(() => emailProviders.id, { onDelete: 'set null' }),
|
||||
templateId: integer('template_id').references(() => emailTemplates.id, { onDelete: 'set null' }),
|
||||
toEmail: varchar('to_email', { length: 255 }).notNull(),
|
||||
toName: varchar('to_name', { length: 255 }),
|
||||
subject: text('subject'),
|
||||
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||
attemptCount: integer('attempt_count').notNull().default(0),
|
||||
nextRetryAt: timestamp('next_retry_at', { withTimezone: true }),
|
||||
errorMessage: text('error_message'),
|
||||
sentAt: timestamp('sent_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
index('email_send_log_user_created_at_idx').on(t.userId, t.createdAt),
|
||||
index('email_send_log_created_at_idx').on(t.createdAt),
|
||||
index('email_send_log_status_next_retry_idx').on(t.status, t.nextRetryAt),
|
||||
check('email_send_log_status_chk', sql`${t.status} in ('pending', 'sent', 'failed')`),
|
||||
check('email_send_log_attempt_count_nonnegative_chk', sql`${t.attemptCount} >= 0`),
|
||||
check('email_send_log_sent_after_created_chk', sql`${t.sentAt} is null or ${t.sentAt} >= ${t.createdAt}`),
|
||||
],
|
||||
);
|
||||
|
||||
export type EmailSendLog = typeof emailSendLog.$inferSelect;
|
||||
export type NewEmailSendLog = typeof emailSendLog.$inferInsert;
|
||||
|
||||
@@ -13,14 +13,17 @@ export const emailTemplates = pgTable(
|
||||
bodyText: text('body_text').notNull(),
|
||||
isDefault: boolean('is_default').notNull().default(false),
|
||||
isSystem: boolean('is_system').notNull().default(false),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
unique().on(t.userId, t.name),
|
||||
uniqueIndex('email_templates_one_default_per_user_uidx')
|
||||
.on(t.userId)
|
||||
.where(sql`${t.isDefault} = true and ${t.userId} is not null`),
|
||||
uniqueIndex('email_templates_system_name_unique')
|
||||
.on(t.name)
|
||||
.where(sql`${t.userId} is null`),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { index, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, index, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { bookFiles, books } from './books';
|
||||
import { users } from './auth';
|
||||
@@ -18,9 +19,14 @@ export const fileWriteLog = pgTable(
|
||||
errorMessage: text('error_message'),
|
||||
durationMs: integer('duration_ms'),
|
||||
triggeredBy: varchar('triggered_by', { length: 10 }).notNull(),
|
||||
writtenAt: timestamp('written_at').defaultNow().notNull(),
|
||||
writtenAt: timestamp('written_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [index('fwl_book_id_idx').on(t.bookId)],
|
||||
(t) => [
|
||||
index('fwl_book_id_written_at_idx').on(t.bookId, t.writtenAt),
|
||||
check('file_write_log_status_chk', sql`${t.status} in ('success', 'skipped', 'failed')`),
|
||||
check('file_write_log_triggered_by_chk', sql`${t.triggeredBy} in ('auto', 'sync')`),
|
||||
check('file_write_log_duration_ms_nonnegative_chk', sql`${t.durationMs} is null or ${t.durationMs} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export type FileWriteLog = typeof fileWriteLog.$inferSelect;
|
||||
|
||||
@@ -1,37 +1,50 @@
|
||||
import { boolean, integer, jsonb, pgTable, primaryKey, real, serial, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { boolean, check, index, integer, jsonb, pgTable, primaryKey, real, serial, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { books } from './books';
|
||||
import { users } from './auth';
|
||||
|
||||
export const koboDevices = pgTable('kobo_devices', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
token: varchar('token', { length: 64 }).notNull().unique(),
|
||||
lastSeenAt: timestamp('last_seen_at'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
export const koboDevices = pgTable(
|
||||
'kobo_devices',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
token: varchar('token', { length: 64 }).notNull().unique(),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [index('kobo_devices_user_id_idx').on(t.userId)],
|
||||
);
|
||||
|
||||
export const koboSyncSettings = pgTable('kobo_sync_settings', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.unique(),
|
||||
readingThreshold: real('reading_threshold').notNull().default(1),
|
||||
finishedThreshold: real('finished_threshold').notNull().default(99),
|
||||
convertToKepub: boolean('convert_to_kepub').notNull().default(false),
|
||||
twoWayProgressSync: boolean('two_way_progress_sync').notNull().default(false),
|
||||
forceEnableHyphenation: boolean('force_enable_hyphenation').notNull().default(false),
|
||||
kepubConversionLimitMb: integer('kepub_conversion_limit_mb').notNull().default(100),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
});
|
||||
export const koboSyncSettings = pgTable(
|
||||
'kobo_sync_settings',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.unique(),
|
||||
readingThreshold: real('reading_threshold').notNull().default(1),
|
||||
finishedThreshold: real('finished_threshold').notNull().default(99),
|
||||
convertToKepub: boolean('convert_to_kepub').notNull().default(false),
|
||||
twoWayProgressSync: boolean('two_way_progress_sync').notNull().default(false),
|
||||
forceEnableHyphenation: boolean('force_enable_hyphenation').notNull().default(false),
|
||||
kepubConversionLimitMb: integer('kepub_conversion_limit_mb').notNull().default(100),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
check('kobo_sync_settings_reading_threshold_range_chk', sql`${t.readingThreshold} >= 0 and ${t.readingThreshold} <= 100`),
|
||||
check('kobo_sync_settings_finished_threshold_range_chk', sql`${t.finishedThreshold} >= 0 and ${t.finishedThreshold} <= 100`),
|
||||
check('kobo_sync_settings_conversion_limit_nonnegative_chk', sql`${t.kepubConversionLimitMb} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export const koboLibrarySnapshots = pgTable('kobo_library_snapshots', {
|
||||
id: serial('id').primaryKey(),
|
||||
@@ -39,8 +52,8 @@ export const koboLibrarySnapshots = pgTable('kobo_library_snapshots', {
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.unique(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
@@ -82,8 +95,8 @@ export const koboReadingStates = pgTable(
|
||||
currentBookmark: jsonb('current_bookmark'),
|
||||
statistics: jsonb('statistics'),
|
||||
statusInfo: jsonb('status_info'),
|
||||
progressSyncedAt: timestamp('progress_synced_at'),
|
||||
updatedAt: timestamp('updated_at')
|
||||
progressSyncedAt: timestamp('progress_synced_at', { withTimezone: true }),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
|
||||
@@ -16,8 +16,8 @@ export const lenses = pgTable(
|
||||
defaultSort: jsonb('default_sort').$type<SortSpec[]>().notNull().default([]),
|
||||
isPublic: boolean('is_public').notNull().default(false),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
|
||||
@@ -1,66 +1,87 @@
|
||||
import { boolean, doublePrecision, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { boolean, check, doublePrecision, index, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
import { FieldPreferenceOverrides, BookMetadataFetchConfigOverride } from '@projectx/types';
|
||||
|
||||
export const libraries = pgTable('libraries', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
icon: varchar('icon', { length: 100 }),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
coverAspectRatio: varchar('cover_aspect_ratio', { length: 10 }).notNull().default('2/3'),
|
||||
export const libraries = pgTable(
|
||||
'libraries',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
icon: varchar('icon', { length: 100 }),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
coverAspectRatio: varchar('cover_aspect_ratio', { length: 10 }).notNull().default('2/3'),
|
||||
|
||||
// File watching & scheduling
|
||||
watch: boolean('watch').notNull().default(false),
|
||||
autoScanCronExpression: text('auto_scan_cron_expression'),
|
||||
// File watching & scheduling
|
||||
watch: boolean('watch').notNull().default(false),
|
||||
autoScanCronExpression: text('auto_scan_cron_expression'),
|
||||
|
||||
// Scanner behaviour
|
||||
metadataPrecedence: jsonb('metadata_precedence')
|
||||
.$type<string[]>()
|
||||
.notNull()
|
||||
.default(['folderStructure', 'embedded', 'nfoFile', 'opfFile', 'sidecar']),
|
||||
formatPriority: jsonb('format_priority')
|
||||
.$type<string[]>()
|
||||
.notNull()
|
||||
.default(['epub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac']),
|
||||
allowedFormats: jsonb('allowed_formats').$type<string[]>().notNull().default([]),
|
||||
organizationMode: varchar('organization_mode', { length: 20 }).notNull().default('book_per_folder'),
|
||||
excludePatterns: jsonb('exclude_patterns').$type<string[]>().notNull().default([]),
|
||||
// Scanner behaviour
|
||||
metadataPrecedence: jsonb('metadata_precedence')
|
||||
.$type<string[]>()
|
||||
.notNull()
|
||||
.default(['folderStructure', 'embedded', 'nfoFile', 'opfFile', 'sidecar']),
|
||||
formatPriority: jsonb('format_priority')
|
||||
.$type<string[]>()
|
||||
.notNull()
|
||||
.default(['epub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac']),
|
||||
allowedFormats: jsonb('allowed_formats').$type<string[]>().notNull().default([]),
|
||||
organizationMode: varchar('organization_mode', { length: 20 }).notNull().default('book_per_folder'),
|
||||
excludePatterns: jsonb('exclude_patterns').$type<string[]>().notNull().default([]),
|
||||
|
||||
// Reading progress thresholds
|
||||
readingThreshold: doublePrecision('reading_threshold').notNull().default(0.25),
|
||||
markAsFinishedPercentComplete: integer('mark_as_finished_percent_complete').notNull().default(98),
|
||||
// Reading progress thresholds
|
||||
readingThreshold: doublePrecision('reading_threshold').notNull().default(0.25),
|
||||
markAsFinishedPercentComplete: integer('mark_as_finished_percent_complete').notNull().default(98),
|
||||
|
||||
// File naming pattern for uploads (null = use global default)
|
||||
fileNamingPattern: varchar('file_naming_pattern', { length: 500 }),
|
||||
// File naming pattern for uploads (null = use global default)
|
||||
fileNamingPattern: varchar('file_naming_pattern', { length: 500 }),
|
||||
|
||||
// Metadata fetch preferences override (null = inherit global defaults)
|
||||
metadataFetchPreferences: jsonb('metadata_fetch_preferences').$type<FieldPreferenceOverrides>(),
|
||||
// Metadata fetch preferences override (null = inherit global defaults)
|
||||
metadataFetchPreferences: jsonb('metadata_fetch_preferences').$type<FieldPreferenceOverrides>(),
|
||||
|
||||
// Book metadata auto-fetch config override (null = inherit global defaults)
|
||||
bookMetadataFetchConfig: jsonb('book_metadata_fetch_config').$type<BookMetadataFetchConfigOverride>(),
|
||||
// Book metadata auto-fetch config override (null = inherit global defaults)
|
||||
bookMetadataFetchConfig: jsonb('book_metadata_fetch_config').$type<BookMetadataFetchConfigOverride>(),
|
||||
|
||||
// Last manual/scheduled run info for this library (null = never run)
|
||||
bookMetadataFetchLastRunAt: timestamp('book_metadata_fetch_last_run_at'),
|
||||
bookMetadataFetchLastQueuedCount: integer('book_metadata_fetch_last_queued_count'),
|
||||
// Last manual/scheduled run info for this library (null = never run)
|
||||
bookMetadataFetchLastRunAt: timestamp('book_metadata_fetch_last_run_at', { withTimezone: true }),
|
||||
bookMetadataFetchLastQueuedCount: integer('book_metadata_fetch_last_queued_count'),
|
||||
|
||||
// Legacy — kept for scanner compatibility
|
||||
scanMode: varchar('scan_mode', { length: 20 }).notNull().default('auto'),
|
||||
pollInterval: integer('poll_interval_seconds').default(300),
|
||||
// Legacy - kept for scanner compatibility
|
||||
scanMode: varchar('scan_mode', { length: 20 }).notNull().default('auto'),
|
||||
pollInterval: integer('poll_interval_seconds').default(300),
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
});
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('libraries_name_lower_uidx').on(sql`lower(${t.name})`),
|
||||
check('libraries_display_order_nonnegative_chk', sql`${t.displayOrder} >= 0`),
|
||||
check('libraries_organization_mode_chk', sql`${t.organizationMode} in ('book_per_folder', 'file_per_book')`),
|
||||
check('libraries_reading_threshold_range_chk', sql`${t.readingThreshold} >= 0 and ${t.readingThreshold} <= 1`),
|
||||
check('libraries_mark_finished_percent_range_chk', sql`${t.markAsFinishedPercentComplete} >= 0 and ${t.markAsFinishedPercentComplete} <= 100`),
|
||||
check('libraries_scan_mode_chk', sql`${t.scanMode} in ('auto', 'manual')`),
|
||||
check('libraries_poll_interval_nonnegative_chk', sql`${t.pollInterval} is null or ${t.pollInterval} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export const libraryFolders = pgTable('library_folders', {
|
||||
id: serial('id').primaryKey(),
|
||||
libraryId: integer('library_id')
|
||||
.notNull()
|
||||
.references(() => libraries.id, { onDelete: 'cascade' }),
|
||||
path: varchar('path', { length: 4096 }).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
export const libraryFolders = pgTable(
|
||||
'library_folders',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
libraryId: integer('library_id')
|
||||
.notNull()
|
||||
.references(() => libraries.id, { onDelete: 'cascade' }),
|
||||
path: varchar('path', { length: 4096 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('library_folders_library_id_idx').on(t.libraryId),
|
||||
uniqueIndex('library_folders_library_path_uidx').on(t.libraryId, t.path),
|
||||
uniqueIndex('library_folders_id_library_id_uidx').on(t.id, t.libraryId),
|
||||
],
|
||||
);
|
||||
|
||||
export type Library = typeof libraries.$inferSelect;
|
||||
export type NewLibrary = typeof libraries.$inferInsert;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
customType,
|
||||
index,
|
||||
integer,
|
||||
@@ -56,9 +57,9 @@ export const bookMetadata = pgTable(
|
||||
openLibraryId: varchar('open_library_id', { length: 50 }),
|
||||
itunesId: varchar('itunes_id', { length: 50 }),
|
||||
metadataScore: integer('metadata_score'),
|
||||
lastMetadataFetchAt: timestamp('last_metadata_fetch_at'),
|
||||
lastMetadataFetchAt: timestamp('last_metadata_fetch_at', { withTimezone: true }),
|
||||
embedding: embedding256('embedding'),
|
||||
lastWrittenAt: timestamp('last_written_at'),
|
||||
lastWrittenAt: timestamp('last_written_at', { withTimezone: true }),
|
||||
durationSeconds: integer('duration_seconds'),
|
||||
abridged: boolean('abridged').notNull().default(false),
|
||||
audibleId: varchar('audible_id', { length: 20 }),
|
||||
@@ -68,7 +69,7 @@ export const bookMetadata = pgTable(
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{}'::text[]`),
|
||||
updatedAt: timestamp('updated_at')
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
@@ -78,6 +79,13 @@ export const bookMetadata = pgTable(
|
||||
index('bm_series_trgm_idx').using('gin', t.seriesName.op('gin_trgm_ops')),
|
||||
index('bm_publisher_trgm_idx').using('gin', t.publisher.op('gin_trgm_ops')),
|
||||
index('bm_language_idx').on(t.language),
|
||||
index('bm_published_year_idx').on(t.publishedYear),
|
||||
index('bm_series_name_index_idx').on(t.seriesName, t.seriesIndex),
|
||||
check('book_metadata_rating_range_chk', sql`${t.rating} is null or (${t.rating} >= 1 and ${t.rating} <= 10)`),
|
||||
check('book_metadata_published_year_range_chk', sql`${t.publishedYear} is null or (${t.publishedYear} >= 1000 and ${t.publishedYear} <= 2200)`),
|
||||
check('book_metadata_page_count_nonnegative_chk', sql`${t.pageCount} is null or ${t.pageCount} >= 0`),
|
||||
check('book_metadata_duration_seconds_nonnegative_chk', sql`${t.durationSeconds} is null or ${t.durationSeconds} >= 0`),
|
||||
check('book_metadata_cover_source_chk', sql`${t.coverSource} is null or ${t.coverSource} in ('extracted', 'custom')`),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -89,7 +97,7 @@ export const authors = pgTable(
|
||||
sortName: varchar('sort_name', { length: 500 }),
|
||||
description: text('description'),
|
||||
hasPhoto: boolean('has_photo').notNull().default(false),
|
||||
lastEnrichedAt: timestamp('last_enriched_at'),
|
||||
lastEnrichedAt: timestamp('last_enriched_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => [unique('authors_name_unique').on(t.name), index('authors_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops'))],
|
||||
);
|
||||
@@ -117,13 +125,13 @@ export const authorEnrichmentQueue = pgTable(
|
||||
status: varchar('status', { length: 20 }).notNull().default('queued'),
|
||||
reason: varchar('reason', { length: 50 }).notNull().default('unknown'),
|
||||
attemptCount: integer('attempt_count').notNull().default(0),
|
||||
nextAttemptAt: timestamp('next_attempt_at').notNull().defaultNow(),
|
||||
lastAttemptAt: timestamp('last_attempt_at'),
|
||||
lastSuccessAt: timestamp('last_success_at'),
|
||||
nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }),
|
||||
lastSuccessAt: timestamp('last_success_at', { withTimezone: true }),
|
||||
lastError: text('last_error'),
|
||||
lastHttpStatus: integer('last_http_status'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
@@ -134,6 +142,12 @@ export const authorEnrichmentQueue = pgTable(
|
||||
uniqueIndex('author_enrichment_queue_single_processing_idx')
|
||||
.on(t.status)
|
||||
.where(sql`${t.status} = 'processing'`),
|
||||
check('author_enrichment_queue_status_chk', sql`${t.status} in ('queued', 'processing', 'rate_limited', 'failed', 'done')`),
|
||||
check(
|
||||
'author_enrichment_queue_reason_chk',
|
||||
sql`${t.reason} in ('unknown', 'metadata_replace', 'manual_backfill', 'manual_backfill_all', 'author_rename', 'author_merge_target')`,
|
||||
),
|
||||
check('author_enrichment_queue_attempt_count_nonnegative_chk', sql`${t.attemptCount} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -197,16 +211,22 @@ export const bookMetadataFetchQueue = pgTable(
|
||||
status: varchar('status', { length: 20 }).notNull().default('queued'),
|
||||
reason: varchar('reason', { length: 50 }).notNull().default('manual_trigger'),
|
||||
attemptCount: integer('attempt_count').notNull().default(0),
|
||||
lastAttemptAt: timestamp('last_attempt_at'),
|
||||
lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }),
|
||||
lastError: text('last_error'),
|
||||
lastHttpStatus: integer('last_http_status'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [index('bmfq_status_idx').on(t.status), index('bmfq_created_at_idx').on(t.createdAt)],
|
||||
(t) => [
|
||||
index('bmfq_status_idx').on(t.status),
|
||||
index('bmfq_created_at_idx').on(t.createdAt),
|
||||
check('book_metadata_fetch_queue_status_chk', sql`${t.status} in ('queued', 'processing', 'failed')`),
|
||||
check('book_metadata_fetch_queue_reason_chk', sql`${t.reason} in ('event_import', 'manual_trigger', 'manual_retry')`),
|
||||
check('book_metadata_fetch_queue_attempt_count_nonnegative_chk', sql`${t.attemptCount} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export type BookMetadataFetchQueue = typeof bookMetadataFetchQueue.$inferSelect;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { check, index, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
import { check, foreignKey, index, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import { users } from './auth';
|
||||
@@ -11,10 +11,10 @@ export const migrationSources = pgTable(
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
connectionConfig: jsonb('connection_config').notNull(),
|
||||
capabilities: jsonb('capabilities'),
|
||||
lastValidatedAt: timestamp('last_validated_at'),
|
||||
lastValidatedAt: timestamp('last_validated_at', { withTimezone: true }),
|
||||
createdByUserId: integer('created_by_user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
@@ -42,15 +42,17 @@ export const migrationProfiles = pgTable(
|
||||
.notNull()
|
||||
.default(sql`'{}'::jsonb`),
|
||||
createdByUserId: integer('created_by_user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('migration_profiles_source_name_version_uidx').on(t.sourceId, t.name, t.version),
|
||||
uniqueIndex('migration_profiles_id_source_id_uidx').on(t.id, t.sourceId),
|
||||
index('migration_profiles_source_id_idx').on(t.sourceId),
|
||||
check('migration_profiles_version_positive_chk', sql`${t.version} >= 1`),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -71,16 +73,22 @@ export const migrationPlanArtifacts = pgTable(
|
||||
sourceData: jsonb('source_data'),
|
||||
summary: jsonb('summary').notNull(),
|
||||
createdByUserId: integer('created_by_user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('migration_plan_artifacts_plan_hash_uidx').on(t.planHash),
|
||||
uniqueIndex('migration_plan_artifacts_id_source_profile_uidx').on(t.id, t.sourceId, t.profileId),
|
||||
index('migration_plan_artifacts_source_id_idx').on(t.sourceId),
|
||||
index('migration_plan_artifacts_profile_id_idx').on(t.profileId),
|
||||
foreignKey({
|
||||
columns: [t.profileId, t.sourceId],
|
||||
foreignColumns: [migrationProfiles.id, migrationProfiles.sourceId],
|
||||
name: 'migration_plan_artifacts_profile_source_fk',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -99,11 +107,11 @@ export const migrationRuns = pgTable(
|
||||
state: varchar('state', { length: 32 }).notNull().default('draft'),
|
||||
currentStage: varchar('current_stage', { length: 64 }),
|
||||
triggeredByUserId: integer('triggered_by_user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
startedAt: timestamp('started_at'),
|
||||
endedAt: timestamp('ended_at'),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||
endedAt: timestamp('ended_at', { withTimezone: true }),
|
||||
errorMessage: text('error_message'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
@@ -111,7 +119,18 @@ export const migrationRuns = pgTable(
|
||||
(t) => [
|
||||
index('migration_runs_source_target_state_idx').on(t.sourceId, t.targetKey, t.state),
|
||||
index('migration_runs_state_idx').on(t.state),
|
||||
foreignKey({
|
||||
columns: [t.profileId, t.sourceId],
|
||||
foreignColumns: [migrationProfiles.id, migrationProfiles.sourceId],
|
||||
name: 'migration_runs_profile_source_fk',
|
||||
}),
|
||||
foreignKey({
|
||||
columns: [t.planArtifactId, t.sourceId, t.profileId],
|
||||
foreignColumns: [migrationPlanArtifacts.id, migrationPlanArtifacts.sourceId, migrationPlanArtifacts.profileId],
|
||||
name: 'migration_runs_plan_artifact_source_profile_fk',
|
||||
}),
|
||||
check('migration_runs_state_chk', sql`"state" IN ('draft', 'preflight_failed', 'dry_run_ready', 'running', 'failed', 'completed')`),
|
||||
check('migration_runs_started_before_ended_chk', sql`${t.endedAt} is null or ${t.startedAt} is null or ${t.endedAt} >= ${t.startedAt}`),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -129,8 +148,8 @@ export const migrationRunMetrics = pgTable(
|
||||
skipped: integer('skipped').notNull().default(0),
|
||||
unresolved: integer('unresolved').notNull().default(0),
|
||||
failed: integer('failed').notNull().default(0),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
@@ -138,6 +157,11 @@ export const migrationRunMetrics = pgTable(
|
||||
(t) => [
|
||||
uniqueIndex('migration_run_metrics_run_stage_entity_uidx').on(t.runId, t.stage, t.entityType),
|
||||
index('migration_run_metrics_run_id_idx').on(t.runId),
|
||||
check('migration_run_metrics_processed_nonnegative_chk', sql`${t.processed} >= 0`),
|
||||
check('migration_run_metrics_imported_nonnegative_chk', sql`${t.imported} >= 0`),
|
||||
check('migration_run_metrics_skipped_nonnegative_chk', sql`${t.skipped} >= 0`),
|
||||
check('migration_run_metrics_unresolved_nonnegative_chk', sql`${t.unresolved} >= 0`),
|
||||
check('migration_run_metrics_failed_nonnegative_chk', sql`${t.failed} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export const narrators = pgTable(
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 500 }).notNull().unique(),
|
||||
sortName: varchar('sort_name', { length: 500 }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [index('narrators_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops'))],
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { boolean, index, integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { users } from './auth';
|
||||
@@ -14,12 +15,16 @@ export const oidcSessions = pgTable(
|
||||
oidcSessionId: text('oidc_session_id'),
|
||||
idTokenHint: text('id_token_hint'),
|
||||
revoked: boolean('revoked').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('oidc_sessions_user_id_idx').on(t.userId),
|
||||
index('oidc_sessions_user_active_idx')
|
||||
.on(t.userId)
|
||||
.where(sql`${t.revoked} = false`),
|
||||
index('oidc_sessions_subject_issuer_idx').on(t.oidcSubject, t.oidcIssuer),
|
||||
index('oidc_sessions_sid_idx').on(t.oidcSessionId),
|
||||
index('oidc_sessions_expires_at_idx').on(t.expiresAt),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -27,5 +32,5 @@ export const oidcGroupMappings = pgTable('oidc_group_mappings', {
|
||||
id: serial('id').primaryKey(),
|
||||
oidcGroupClaim: text('oidc_group_claim').notNull().unique(),
|
||||
permissionName: varchar('permission_name', { length: 100 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { integer, pgEnum, pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { integer, pgEnum, pgTable, serial, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { users } from './auth';
|
||||
|
||||
@@ -12,17 +13,21 @@ export const opdsSortOrderEnum = pgEnum('opds_sort_order', [
|
||||
'series_desc',
|
||||
]);
|
||||
|
||||
export const opdsUsers = pgTable('opds_users', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
username: varchar('username', { length: 100 }).notNull().unique(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
sortOrder: opdsSortOrderEnum('sort_order').notNull().default('recent'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
});
|
||||
export const opdsUsers = pgTable(
|
||||
'opds_users',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
username: varchar('username', { length: 100 }).notNull().unique(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
sortOrder: opdsSortOrderEnum('sort_order').notNull().default('recent'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [uniqueIndex('opds_users_username_lower_uidx').on(sql`lower(${t.username})`)],
|
||||
);
|
||||
|
||||
@@ -57,18 +57,23 @@ describe('reader schema', () => {
|
||||
|
||||
expect(pkColumns).toContainEqual(['user_id', 'library_id', 'day']);
|
||||
expect(indexNames).toContain('urds_user_day_idx');
|
||||
expect(indexNames).toContain('urds_user_library_day_idx');
|
||||
expect(indexNames).not.toContain('urds_user_library_day_idx');
|
||||
expect(fkMap.get('user_id')?.onDelete).toBe('cascade');
|
||||
expect(fkMap.get('library_id')?.onDelete).toBe('cascade');
|
||||
expect(userReadingDailyStats.updatedAt.onUpdateFn?.()).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('enforces cascade deletes for bookmarks and annotations ownership', () => {
|
||||
it('enforces cascade deletes and bookmark uniqueness by logical location', () => {
|
||||
const bookmarkFks = fkByColumn(bookmarks);
|
||||
const annotationFks = fkByColumn(annotations);
|
||||
const bookmarkConfig = getTableConfig(bookmarks);
|
||||
const bookmarkIndexNames = bookmarkConfig.indexes.map((idx) => idx.config.name);
|
||||
|
||||
expect(bookmarkFks.get('user_id')?.onDelete).toBe('cascade');
|
||||
expect(bookmarkFks.get('book_id')?.onDelete).toBe('cascade');
|
||||
expect(bookmarkIndexNames).toContain('bookmarks_user_book_idx');
|
||||
expect(bookmarkIndexNames).toContain('bookmarks_user_book_cfi_uidx');
|
||||
expect(bookmarkIndexNames).toContain('bookmarks_user_book_pos_uidx');
|
||||
expect(annotationFks.get('user_id')?.onDelete).toBe('cascade');
|
||||
expect(annotationFks.get('book_id')?.onDelete).toBe('cascade');
|
||||
expect(annotations.color.default).toBe('yellow');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { date, index, integer, jsonb, pgTable, primaryKey, real, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, date, index, integer, jsonb, pgTable, primaryKey, real, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
import type { ReadStatus, ReadStatusSource } from '@projectx/types';
|
||||
|
||||
import { bookFiles, books } from './books';
|
||||
@@ -18,14 +19,24 @@ export const userBookStatus = pgTable(
|
||||
status: varchar('status', { length: 20 }).$type<ReadStatus>().notNull().default('unread'),
|
||||
// 'auto' (derived from progress) | 'manual' (user-set; never auto-overridden)
|
||||
source: varchar('source', { length: 10 }).$type<ReadStatusSource>().notNull().default('auto'),
|
||||
startedAt: timestamp('started_at'),
|
||||
finishedAt: timestamp('finished_at'),
|
||||
updatedAt: timestamp('updated_at')
|
||||
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.bookId] }), index('ubs_user_id_idx').on(t.userId), index('ubs_user_status_idx').on(t.userId, t.status)],
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.userId, t.bookId] }),
|
||||
index('ubs_user_id_idx').on(t.userId),
|
||||
index('ubs_user_status_idx').on(t.userId, t.status),
|
||||
check(
|
||||
'user_book_status_status_chk',
|
||||
sql`${t.status} in ('unread', 'want_to_read', 'reading', 'on_hold', 'rereading', 'read', 'skimmed', 'abandoned')`,
|
||||
),
|
||||
check('user_book_status_source_chk', sql`${t.source} in ('auto', 'manual')`),
|
||||
check('user_book_status_finished_after_started_chk', sql`${t.finishedAt} is null or ${t.startedAt} is null or ${t.finishedAt} >= ${t.startedAt}`),
|
||||
],
|
||||
);
|
||||
|
||||
export type UserBookStatusRow = typeof userBookStatus.$inferSelect;
|
||||
@@ -47,12 +58,18 @@ export const readingProgress = pgTable(
|
||||
pageNumber: integer('page_number'),
|
||||
// Audio: playback position in seconds
|
||||
positionSeconds: real('position_seconds'),
|
||||
updatedAt: timestamp('updated_at')
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.bookFileId, t.userId] }), index('reading_progress_user_id_idx').on(t.userId)],
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.bookFileId, t.userId] }),
|
||||
index('reading_progress_user_id_idx').on(t.userId),
|
||||
check('reading_progress_percentage_range_chk', sql`${t.percentage} >= 0 and ${t.percentage} <= 100`),
|
||||
check('reading_progress_page_number_nonnegative_chk', sql`${t.pageNumber} is null or ${t.pageNumber} >= 0`),
|
||||
check('reading_progress_position_seconds_nonnegative_chk', sql`${t.positionSeconds} is null or ${t.positionSeconds} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export type ReadingProgress = typeof readingProgress.$inferSelect;
|
||||
@@ -70,8 +87,8 @@ export const readingSessions = pgTable(
|
||||
.references(() => bookFiles.id, { onDelete: 'cascade' }),
|
||||
// Client-generated UUID; used for idempotent retries.
|
||||
sessionId: varchar('session_id', { length: 64 }).notNull(),
|
||||
startedAt: timestamp('started_at').notNull(),
|
||||
endedAt: timestamp('ended_at').notNull(),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }).notNull(),
|
||||
endedAt: timestamp('ended_at', { withTimezone: true }).notNull(),
|
||||
// Server-computed from endedAt - startedAt; client-provided timestamps are untrusted for duration.
|
||||
durationSeconds: integer('duration_seconds').notNull(),
|
||||
// Nullable: CBX with no percentage tracking may omit these.
|
||||
@@ -83,6 +100,9 @@ export const readingSessions = pgTable(
|
||||
index('rs_user_started_at_idx').on(t.userId, t.startedAt),
|
||||
index('rs_book_file_started_at_idx').on(t.bookFileId, t.startedAt),
|
||||
index('rs_user_book_file_idx').on(t.userId, t.bookFileId),
|
||||
check('reading_sessions_duration_seconds_nonnegative_chk', sql`${t.durationSeconds} >= 0`),
|
||||
check('reading_sessions_end_progress_range_chk', sql`${t.endProgress} is null or (${t.endProgress} >= 0 and ${t.endProgress} <= 100)`),
|
||||
check('reading_sessions_ended_after_started_chk', sql`${t.endedAt} >= ${t.startedAt}`),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -102,7 +122,7 @@ export const userReadingDailyStats = pgTable(
|
||||
readingSeconds: integer('reading_seconds').notNull().default(0),
|
||||
progressDelta: real('progress_delta').notNull().default(0),
|
||||
sessionsCount: integer('sessions_count').notNull().default(0),
|
||||
updatedAt: timestamp('updated_at')
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
@@ -110,7 +130,8 @@ export const userReadingDailyStats = pgTable(
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.userId, t.libraryId, t.day] }),
|
||||
index('urds_user_day_idx').on(t.userId, t.day),
|
||||
index('urds_user_library_day_idx').on(t.userId, t.libraryId, t.day),
|
||||
check('user_reading_daily_stats_reading_seconds_nonnegative_chk', sql`${t.readingSeconds} >= 0`),
|
||||
check('user_reading_daily_stats_sessions_count_nonnegative_chk', sql`${t.sessionsCount} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -131,12 +152,17 @@ export const audiobookProgress = pgTable(
|
||||
.notNull()
|
||||
.references(() => bookFiles.id, { onDelete: 'cascade' }),
|
||||
positionSeconds: real('position_seconds').notNull().default(0),
|
||||
updatedAt: timestamp('updated_at')
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.bookId] }), index('abp_user_id_idx').on(t.userId)],
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.userId, t.bookId] }),
|
||||
index('abp_user_id_idx').on(t.userId),
|
||||
check('audiobook_progress_percentage_range_chk', sql`${t.percentage} >= 0 and ${t.percentage} <= 100`),
|
||||
check('audiobook_progress_position_seconds_nonnegative_chk', sql`${t.positionSeconds} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export type AudiobookProgress = typeof audiobookProgress.$inferSelect;
|
||||
@@ -157,9 +183,17 @@ export const bookmarks = pgTable(
|
||||
title: varchar('title', { length: 500 }).notNull(),
|
||||
// Audio: absolute book position in seconds (sum of preceding file durations + offset).
|
||||
positionSeconds: real('position_seconds'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [index('bookmarks_user_id_idx').on(t.userId)],
|
||||
(t) => [
|
||||
index('bookmarks_user_book_idx').on(t.userId, t.bookId),
|
||||
uniqueIndex('bookmarks_user_book_cfi_uidx')
|
||||
.on(t.userId, t.bookId, t.cfi)
|
||||
.where(sql`${t.cfi} is not null`),
|
||||
uniqueIndex('bookmarks_user_book_pos_uidx')
|
||||
.on(t.userId, t.bookId, t.positionSeconds)
|
||||
.where(sql`${t.positionSeconds} is not null and ${t.cfi} is null`),
|
||||
],
|
||||
);
|
||||
|
||||
export type BookmarkRow = typeof bookmarks.$inferSelect;
|
||||
@@ -181,13 +215,17 @@ export const annotations = pgTable(
|
||||
style: varchar('style', { length: 20 }).notNull().default('highlight'),
|
||||
note: text('note'),
|
||||
chapterTitle: varchar('chapter_title', { length: 500 }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [index('annotations_user_id_idx').on(t.userId), index('annotations_user_book_idx').on(t.userId, t.bookId)],
|
||||
(t) => [
|
||||
index('annotations_user_id_idx').on(t.userId),
|
||||
index('annotations_user_book_idx').on(t.userId, t.bookId),
|
||||
check('annotations_style_chk', sql`${t.style} in ('highlight', 'underline', 'strikethrough', 'squiggly')`),
|
||||
],
|
||||
);
|
||||
|
||||
export type AnnotationRow = typeof annotations.$inferSelect;
|
||||
@@ -202,12 +240,15 @@ export const readerDefaultPreferences = pgTable(
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
formatGroup: varchar('format_group', { length: 10 }).notNull(),
|
||||
settings: jsonb('settings').notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [uniqueIndex('rdp_user_format_idx').on(t.userId, t.formatGroup)],
|
||||
(t) => [
|
||||
uniqueIndex('rdp_user_format_idx').on(t.userId, t.formatGroup),
|
||||
check('reader_default_preferences_format_group_chk', sql`${t.formatGroup} in ('epub', 'pdf', 'cbx', 'audio')`),
|
||||
],
|
||||
);
|
||||
|
||||
export type ReaderDefaultPreference = typeof readerDefaultPreferences.$inferSelect;
|
||||
@@ -224,7 +265,7 @@ export const readerPreferences = pgTable(
|
||||
.notNull()
|
||||
.references(() => bookFiles.id, { onDelete: 'cascade' }),
|
||||
settings: jsonb('settings').notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, index, integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { libraries } from './libraries';
|
||||
|
||||
export const scanJobs = pgTable('scan_jobs', {
|
||||
id: serial('id').primaryKey(),
|
||||
libraryId: integer('library_id')
|
||||
.notNull()
|
||||
.references(() => libraries.id, { onDelete: 'cascade' }),
|
||||
status: varchar('status', { length: 20 }).notNull().default('running'),
|
||||
triggeredBy: varchar('triggered_by', { length: 20 }).notNull(),
|
||||
addedCount: integer('added_count').notNull().default(0),
|
||||
updatedCount: integer('updated_count').notNull().default(0),
|
||||
missingCount: integer('missing_count').notNull().default(0),
|
||||
errorMessage: text('error_message'),
|
||||
startedAt: timestamp('started_at').defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at'),
|
||||
});
|
||||
export const scanJobs = pgTable(
|
||||
'scan_jobs',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
libraryId: integer('library_id')
|
||||
.notNull()
|
||||
.references(() => libraries.id, { onDelete: 'cascade' }),
|
||||
status: varchar('status', { length: 20 }).notNull().default('running'),
|
||||
triggeredBy: varchar('triggered_by', { length: 20 }).notNull(),
|
||||
addedCount: integer('added_count').notNull().default(0),
|
||||
updatedCount: integer('updated_count').notNull().default(0),
|
||||
missingCount: integer('missing_count').notNull().default(0),
|
||||
errorMessage: text('error_message'),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => [
|
||||
index('scan_jobs_library_status_idx').on(t.libraryId, t.status),
|
||||
check('scan_jobs_status_chk', sql`${t.status} in ('running', 'completed', 'failed')`),
|
||||
check('scan_jobs_triggered_by_chk', sql`${t.triggeredBy} in ('manual', 'watcher', 'schedule')`),
|
||||
check('scan_jobs_added_count_nonnegative_chk', sql`${t.addedCount} >= 0`),
|
||||
check('scan_jobs_updated_count_nonnegative_chk', sql`${t.updatedCount} >= 0`),
|
||||
check('scan_jobs_missing_count_nonnegative_chk', sql`${t.missingCount} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export type ScanJob = typeof scanJobs.$inferSelect;
|
||||
export type NewScanJob = typeof scanJobs.$inferInsert;
|
||||
|
||||
@@ -91,6 +91,7 @@ function makeService(dbOverrides?: Record<string, unknown>) {
|
||||
const oidcSessionRepo = {
|
||||
findActiveByUserId: vi.fn().mockResolvedValue(null),
|
||||
revokeByUserId: vi.fn().mockResolvedValue(undefined),
|
||||
touchActiveByUserId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const oidcDiscovery = {
|
||||
getDiscoveryDoc: vi.fn(),
|
||||
@@ -345,7 +346,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('rotates token and sets cookies when refresh succeeds', async () => {
|
||||
const { service, db } = makeService();
|
||||
const { service, db, oidcSessionRepo } = makeService();
|
||||
const reply = makeReply();
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({
|
||||
id: 11,
|
||||
@@ -362,6 +363,7 @@ describe('AuthService', () => {
|
||||
const result = await service.refresh(makeRequest({ refresh_token: 'ok-token' }), reply);
|
||||
expect(result).toEqual({ accessToken: 'signed-jwt' });
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
expect(oidcSessionRepo.touchActiveByUserId).toHaveBeenCalledWith(5, expect.any(Date));
|
||||
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,11 +79,15 @@ export class AuthService {
|
||||
|
||||
const passwordHash = await hash(dto.password, 12);
|
||||
return this.db.transaction(async (tx) => {
|
||||
const existingUsername = await tx.query.users.findFirst({ where: eq(schema.users.username, dto.username) });
|
||||
const existingUsername = await tx.query.users.findFirst({
|
||||
where: eq(sql`lower(${schema.users.username})`, dto.username.toLowerCase()),
|
||||
});
|
||||
if (existingUsername) throw new ConflictException('Username already taken');
|
||||
|
||||
if (dto.email) {
|
||||
const existingEmail = await tx.query.users.findFirst({ where: eq(schema.users.email, dto.email) });
|
||||
const existingEmail = await tx.query.users.findFirst({
|
||||
where: eq(sql`lower(${schema.users.email})`, dto.email.toLowerCase()),
|
||||
});
|
||||
if (existingEmail) throw new ConflictException('Email already in use');
|
||||
}
|
||||
|
||||
@@ -135,12 +139,16 @@ export class AuthService {
|
||||
throw new ConflictException('Setup already completed');
|
||||
}
|
||||
|
||||
const existingUsername = await tx.query.users.findFirst({ where: eq(schema.users.username, dto.username) });
|
||||
const existingUsername = await tx.query.users.findFirst({
|
||||
where: eq(sql`lower(${schema.users.username})`, dto.username.toLowerCase()),
|
||||
});
|
||||
if (existingUsername) {
|
||||
throw new ConflictException('Username already taken');
|
||||
}
|
||||
|
||||
const existingEmail = await tx.query.users.findFirst({ where: eq(schema.users.email, dto.email) });
|
||||
const existingEmail = await tx.query.users.findFirst({
|
||||
where: eq(sql`lower(${schema.users.email})`, dto.email.toLowerCase()),
|
||||
});
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('Email already in use');
|
||||
}
|
||||
@@ -222,7 +230,8 @@ export class AuthService {
|
||||
async issueTokensForUser(userId: number, reply: FastifyReply) {
|
||||
const user = await this.userService.findByIdWithPermissions(userId);
|
||||
if (!user || !user.active) throw new UnauthorizedException();
|
||||
const { accessToken, rawRefreshToken } = await this.issueTokenPair(userId, user.tokenVersion);
|
||||
const { accessToken, rawRefreshToken, refreshExpiresAt } = await this.issueTokenPair(userId, user.tokenVersion);
|
||||
await this.oidcSessionRepo.touchActiveByUserId(userId, refreshExpiresAt);
|
||||
this.setRefreshCookie(reply, rawRefreshToken);
|
||||
this.setAccessCookie(reply, accessToken);
|
||||
return { accessToken, user: this.buildUserResponse(user) };
|
||||
@@ -278,7 +287,8 @@ export class AuthService {
|
||||
// Rotate: revoke old, issue new
|
||||
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, row.id));
|
||||
|
||||
const { accessToken, rawRefreshToken } = await this.issueTokenPair(row.userId, userForToken.tokenVersion);
|
||||
const { accessToken, rawRefreshToken, refreshExpiresAt } = await this.issueTokenPair(row.userId, userForToken.tokenVersion);
|
||||
await this.oidcSessionRepo.touchActiveByUserId(row.userId, refreshExpiresAt);
|
||||
this.setRefreshCookie(reply, rawRefreshToken);
|
||||
this.setAccessCookie(reply, accessToken);
|
||||
|
||||
@@ -491,17 +501,20 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
getRefreshTokenExpiryDate(baseDate = new Date()) {
|
||||
return new Date(baseDate.getTime() + parseDurationMs(this.config.get<string>('auth.jwtRefreshExpiresIn') ?? '7d'));
|
||||
}
|
||||
|
||||
private async issueTokenPair(userId: number, tokenVersion: number) {
|
||||
const accessToken = this.jwtService.sign({ sub: userId, ver: tokenVersion });
|
||||
|
||||
const rawRefreshToken = randomBytes(32).toString('hex');
|
||||
const tokenHash = sha256(rawRefreshToken);
|
||||
const refreshTtlMs = parseDurationMs(this.config.get<string>('auth.jwtRefreshExpiresIn') ?? '7d');
|
||||
const expiresAt = new Date(Date.now() + refreshTtlMs);
|
||||
const expiresAt = this.getRefreshTokenExpiryDate();
|
||||
|
||||
await this.db.insert(schema.refreshTokens).values({ userId, tokenHash, expiresAt });
|
||||
|
||||
return { accessToken, rawRefreshToken };
|
||||
return { accessToken, rawRefreshToken, refreshExpiresAt: expiresAt };
|
||||
}
|
||||
|
||||
private assertSetupToken(setupToken: string | undefined) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
|
||||
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
|
||||
gt: vi.fn((left: unknown, right: unknown) => ({ op: 'gt', left, right })),
|
||||
sql: vi.fn((parts: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', parts, values })),
|
||||
}));
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, gt } from 'drizzle-orm';
|
||||
|
||||
import * as schema from '../../../db/schema';
|
||||
import { OidcSessionRepository } from './oidc-session.repository';
|
||||
@@ -51,7 +52,7 @@ describe('OidcSessionRepository', () => {
|
||||
it('creates an OIDC session and returns the inserted row', async () => {
|
||||
const { db, insert, insertValues, insertReturning } = makeDb();
|
||||
const repository = new OidcSessionRepository(db as never);
|
||||
const payload = { userId: 2, oidcSubject: 'sub', oidcIssuer: 'issuer', oidcSessionId: 'sid-1' };
|
||||
const payload = { userId: 2, oidcSubject: 'sub', oidcIssuer: 'issuer', oidcSessionId: 'sid-1', expiresAt: new Date('2026-01-08T00:00:00Z') };
|
||||
const created = { id: 7, ...payload };
|
||||
|
||||
insertReturning.mockResolvedValue([created]);
|
||||
@@ -62,7 +63,7 @@ describe('OidcSessionRepository', () => {
|
||||
expect(insertReturning).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('finds an active session by sid and always filters out revoked rows', async () => {
|
||||
it('finds an active session by sid and always filters out revoked and expired rows', async () => {
|
||||
const { db, findFirst } = makeDb();
|
||||
const repository = new OidcSessionRepository(db as never);
|
||||
|
||||
@@ -70,9 +71,11 @@ describe('OidcSessionRepository', () => {
|
||||
|
||||
expect(eq).toHaveBeenCalledWith(schema.oidcSessions.oidcSessionId, 'sid-123');
|
||||
expect(eq).toHaveBeenCalledWith(schema.oidcSessions.revoked, false);
|
||||
expect(gt).toHaveBeenCalledWith(schema.oidcSessions.expiresAt, expect.any(Date));
|
||||
expect(and).toHaveBeenCalledWith(
|
||||
{ op: 'eq', left: schema.oidcSessions.oidcSessionId, right: 'sid-123' },
|
||||
{ op: 'eq', left: schema.oidcSessions.revoked, right: false },
|
||||
expect.objectContaining({ op: 'gt', left: schema.oidcSessions.expiresAt, right: expect.any(Date) }),
|
||||
);
|
||||
expect(findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
@@ -80,6 +83,7 @@ describe('OidcSessionRepository', () => {
|
||||
clauses: [
|
||||
{ op: 'eq', left: schema.oidcSessions.oidcSessionId, right: 'sid-123' },
|
||||
{ op: 'eq', left: schema.oidcSessions.revoked, right: false },
|
||||
expect.objectContaining({ op: 'gt', left: schema.oidcSessions.expiresAt, right: expect.any(Date) }),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -98,6 +102,7 @@ describe('OidcSessionRepository', () => {
|
||||
{ op: 'eq', left: schema.oidcSessions.oidcSubject, right: 'subject-a' },
|
||||
{ op: 'eq', left: schema.oidcSessions.oidcIssuer, right: 'https://issuer.example' },
|
||||
{ op: 'eq', left: schema.oidcSessions.revoked, right: false },
|
||||
expect.objectContaining({ op: 'gt', left: schema.oidcSessions.expiresAt, right: expect.any(Date) }),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -115,6 +120,7 @@ describe('OidcSessionRepository', () => {
|
||||
clauses: [
|
||||
{ op: 'eq', left: schema.oidcSessions.userId, right: 42 },
|
||||
{ op: 'eq', left: schema.oidcSessions.revoked, right: false },
|
||||
expect.objectContaining({ op: 'gt', left: schema.oidcSessions.expiresAt, right: expect.any(Date) }),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -148,4 +154,22 @@ describe('OidcSessionRepository', () => {
|
||||
});
|
||||
expect(updateWhere).toHaveBeenNthCalledWith(3, { op: 'eq', left: schema.oidcSessions.userId, right: 91 });
|
||||
});
|
||||
|
||||
it('extends active sessions for a user without reviving expired ones', async () => {
|
||||
const { db, updateSet, updateWhere } = makeDb();
|
||||
const repository = new OidcSessionRepository(db as never);
|
||||
const expiresAt = new Date('2026-01-09T00:00:00Z');
|
||||
|
||||
await repository.touchActiveByUserId(42, expiresAt);
|
||||
|
||||
expect(updateSet).toHaveBeenCalledWith({ expiresAt });
|
||||
expect(updateWhere).toHaveBeenCalledWith({
|
||||
op: 'and',
|
||||
clauses: [
|
||||
{ op: 'eq', left: schema.oidcSessions.userId, right: 42 },
|
||||
{ op: 'eq', left: schema.oidcSessions.revoked, right: false },
|
||||
expect.objectContaining({ op: 'gt', left: schema.oidcSessions.expiresAt, right: expect.any(Date) }),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, gt } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
@@ -18,13 +18,18 @@ export class OidcSessionRepository {
|
||||
|
||||
async findActiveBySid(sid: string) {
|
||||
return this.db.query.oidcSessions.findFirst({
|
||||
where: and(eq(schema.oidcSessions.oidcSessionId, sid), eq(schema.oidcSessions.revoked, false)),
|
||||
where: and(eq(schema.oidcSessions.oidcSessionId, sid), eq(schema.oidcSessions.revoked, false), gt(schema.oidcSessions.expiresAt, new Date())),
|
||||
});
|
||||
}
|
||||
|
||||
async findActiveBySubjectAndIssuer(subject: string, issuer: string) {
|
||||
return this.db.query.oidcSessions.findMany({
|
||||
where: and(eq(schema.oidcSessions.oidcSubject, subject), eq(schema.oidcSessions.oidcIssuer, issuer), eq(schema.oidcSessions.revoked, false)),
|
||||
where: and(
|
||||
eq(schema.oidcSessions.oidcSubject, subject),
|
||||
eq(schema.oidcSessions.oidcIssuer, issuer),
|
||||
eq(schema.oidcSessions.revoked, false),
|
||||
gt(schema.oidcSessions.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,11 +46,18 @@ export class OidcSessionRepository {
|
||||
|
||||
async findActiveByUserId(userId: number) {
|
||||
return this.db.query.oidcSessions.findFirst({
|
||||
where: and(eq(schema.oidcSessions.userId, userId), eq(schema.oidcSessions.revoked, false)),
|
||||
where: and(eq(schema.oidcSessions.userId, userId), eq(schema.oidcSessions.revoked, false), gt(schema.oidcSessions.expiresAt, new Date())),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
async touchActiveByUserId(userId: number, expiresAt: Date) {
|
||||
await this.db
|
||||
.update(schema.oidcSessions)
|
||||
.set({ expiresAt })
|
||||
.where(and(eq(schema.oidcSessions.userId, userId), eq(schema.oidcSessions.revoked, false), gt(schema.oidcSessions.expiresAt, new Date())));
|
||||
}
|
||||
|
||||
async revokeByUserId(userId: number) {
|
||||
await this.db.update(schema.oidcSessions).set({ revoked: true }).where(eq(schema.oidcSessions.userId, userId));
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ function makeService() {
|
||||
setPermissionsDirectly: vi.fn(),
|
||||
};
|
||||
const authService = {
|
||||
getRefreshTokenExpiryDate: vi.fn().mockReturnValue(new Date('2026-01-08T00:00:00Z')),
|
||||
issueTokensForUser: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -69,7 +70,7 @@ function makeService() {
|
||||
authService as never,
|
||||
);
|
||||
|
||||
return { service, claimExtractor };
|
||||
return { service, claimExtractor, userService, appSettings, authService, sessionRepo };
|
||||
}
|
||||
|
||||
describe('OidcService', () => {
|
||||
@@ -96,4 +97,50 @@ describe('OidcService', () => {
|
||||
),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('reuses an OIDC user when auto-provision races on the new unique constraint', async () => {
|
||||
const { service, claimExtractor, userService, appSettings, authService, sessionRepo } = makeService();
|
||||
const existingUser = { id: 7, active: true };
|
||||
appSettings.getOidcConfig.mockResolvedValue({
|
||||
enabled: true,
|
||||
issuerUri: 'https://issuer.example',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
|
||||
autoProvision: { enabled: true, allowLocalLinking: false, defaultPermissionNames: ['library_download'] },
|
||||
});
|
||||
claimExtractor.extract.mockReturnValue({
|
||||
subject: 'sub-1',
|
||||
username: 'u1',
|
||||
name: 'User One',
|
||||
email: 'u1@example.com',
|
||||
groups: [],
|
||||
});
|
||||
userService.findByOidcSubject.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser);
|
||||
userService.createOidcUser.mockRejectedValueOnce({ code: '23505' });
|
||||
authService.issueTokensForUser.mockResolvedValue({ accessToken: 'token' });
|
||||
|
||||
await expect(
|
||||
service.handleCallback(
|
||||
{
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: 'https://app.example/callback',
|
||||
nonce: 'nonce',
|
||||
state: 'state',
|
||||
},
|
||||
{ setCookie: vi.fn() } as never,
|
||||
),
|
||||
).resolves.toEqual({ accessToken: 'token' });
|
||||
|
||||
expect(userService.setPermissionsDirectly).not.toHaveBeenCalled();
|
||||
expect(sessionRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
oidcSubject: 'sub-1',
|
||||
oidcIssuer: 'https://issuer.example',
|
||||
expiresAt: new Date('2026-01-08T00:00:00Z'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,21 @@ import { OidcStateService } from './oidc-state.service';
|
||||
import { OidcTokenClientService } from './oidc-token-client.service';
|
||||
import { OidcTokenValidatorService } from './oidc-token-validator.service';
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
|
||||
const directCode = (error as { code?: unknown }).code;
|
||||
if (directCode === '23505') return true;
|
||||
|
||||
if (!(error instanceof Error)) return false;
|
||||
const causeCode = (error.cause as { code?: unknown } | undefined)?.code;
|
||||
return causeCode === '23505';
|
||||
}
|
||||
|
||||
function toThrowable(error: unknown, fallbackMessage: string): Error {
|
||||
return error instanceof Error ? error : new UnauthorizedException(fallbackMessage);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
private readonly logger = new Logger(OidcService.name);
|
||||
@@ -87,6 +102,7 @@ export class OidcService {
|
||||
oidcIssuer: disc.issuer,
|
||||
oidcSessionId: sid,
|
||||
idTokenHint: tokens.idToken,
|
||||
expiresAt: this.authService.getRefreshTokenExpiryDate(),
|
||||
});
|
||||
|
||||
return this.authService.issueTokensForUser(user.id, reply);
|
||||
@@ -104,23 +120,40 @@ export class OidcService {
|
||||
if (!user && config.autoProvision.allowLocalLinking) {
|
||||
const byUsername = await this.userService.findByUsername(claims.username);
|
||||
if (byUsername) {
|
||||
await this.userService.linkOidcIdentity(byUsername.id, claims.subject, issuer, claims.avatarUrl);
|
||||
let linkConflict: unknown;
|
||||
try {
|
||||
await this.userService.linkOidcIdentity(byUsername.id, claims.subject, issuer, claims.avatarUrl);
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
linkConflict = error;
|
||||
}
|
||||
user = await this.userService.findByOidcSubject(claims.subject, issuer);
|
||||
if (!user && linkConflict) {
|
||||
throw toThrowable(linkConflict, 'OIDC identity conflict');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Auto-provision new user
|
||||
let createdUser = false;
|
||||
if (!user && config.autoProvision.enabled) {
|
||||
user = await this.userService.createOidcUser({
|
||||
username: claims.username,
|
||||
name: claims.name,
|
||||
email: claims.email,
|
||||
oidcSubject: claims.subject,
|
||||
oidcIssuer: issuer,
|
||||
avatarUrl: claims.avatarUrl,
|
||||
});
|
||||
try {
|
||||
user = await this.userService.createOidcUser({
|
||||
username: claims.username,
|
||||
name: claims.name,
|
||||
email: claims.email,
|
||||
oidcSubject: claims.subject,
|
||||
oidcIssuer: issuer,
|
||||
avatarUrl: claims.avatarUrl,
|
||||
});
|
||||
createdUser = true;
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
user = await this.userService.findByOidcSubject(claims.subject, issuer);
|
||||
if (!user) throw toThrowable(error, 'OIDC user provisioning conflict');
|
||||
}
|
||||
|
||||
if (config.autoProvision.defaultPermissionNames?.length) {
|
||||
if (createdUser && config.autoProvision.defaultPermissionNames?.length) {
|
||||
await this.userService.setPermissionsDirectly(user.id, config.autoProvision.defaultPermissionNames as Permission[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ describe('Book DTO validation', () => {
|
||||
expect((await errorsFor(UpdateBookMetadataDto, { publishedYear: 0 })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(UpdateBookMetadataDto, { authors: ['ok', 1] })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(UpdateBookMetadataDto, { language: 'english-too-long' })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(UpdateBookMetadataDto, { isbn10: '12345678901' })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects legacy top-level audiobook fields and accepts nested audioMetadata', async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export class UpdateBookMetadataDto {
|
||||
@IsOptional() @IsInt() @Min(1) pageCount?: number | null;
|
||||
@IsOptional() @IsString() @MaxLength(500) seriesName?: string | null;
|
||||
@IsOptional() @IsNumber() seriesIndex?: number | null;
|
||||
@IsOptional() @IsString() @MaxLength(13) isbn10?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(10) isbn10?: string | null;
|
||||
@IsOptional() @IsString() @MaxLength(13) isbn13?: string | null;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(5) rating?: number | null;
|
||||
@IsOptional() @IsArray() @IsString({ each: true }) authors?: string[];
|
||||
|
||||
@@ -25,9 +25,11 @@ function makeDb() {
|
||||
orderByResult.limit.mockResolvedValue([]);
|
||||
|
||||
const insertResult = { values: vi.fn() };
|
||||
const valuesResult = { returning: vi.fn() };
|
||||
const valuesResult = { onConflictDoNothing: vi.fn() };
|
||||
const conflictResult = { returning: vi.fn() };
|
||||
insertResult.values.mockReturnValue(valuesResult);
|
||||
valuesResult.returning.mockResolvedValue([]);
|
||||
valuesResult.onConflictDoNothing.mockReturnValue(conflictResult);
|
||||
conflictResult.returning.mockResolvedValue([]);
|
||||
|
||||
const deleteResult = { where: vi.fn() };
|
||||
const deleteWhereResult = { returning: vi.fn() };
|
||||
@@ -42,6 +44,7 @@ function makeDb() {
|
||||
_orderBy: orderByResult,
|
||||
_insert: insertResult,
|
||||
_values: valuesResult,
|
||||
_conflict: conflictResult,
|
||||
_deleteWhere: deleteWhereResult,
|
||||
};
|
||||
return db;
|
||||
@@ -116,13 +119,23 @@ describe('BookmarkRepository', () => {
|
||||
it('inserts and returns created row', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
const row = makeRow({ id: 8, cfi: null, positionSeconds: 15, title: '00:00:15' });
|
||||
db._values.returning.mockResolvedValue([row]);
|
||||
db._conflict.returning.mockResolvedValue([row]);
|
||||
|
||||
const result = await repo.create(10, 5, { cfi: null, title: '00:00:15', positionSeconds: 15 });
|
||||
|
||||
expect(db._insert.values).toHaveBeenCalledWith({ userId: 10, bookId: 5, cfi: null, title: '00:00:15', positionSeconds: 15 });
|
||||
expect(db._values.onConflictDoNothing).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(row);
|
||||
});
|
||||
|
||||
it('returns null when a duplicate insert is ignored by the database', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
db._conflict.returning.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.create(10, 5, { cfi: 'epubcfi(/6/2)', title: 'Chapter 1', positionSeconds: null });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
|
||||
@@ -50,8 +50,9 @@ export class BookmarkRepository {
|
||||
const [row] = await this.db
|
||||
.insert(bookmarks)
|
||||
.values({ userId, bookId, cfi: data.cfi ?? null, title: data.title, positionSeconds: data.positionSeconds ?? null })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
return row;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async delete(bookId: number, bookmarkId: number, userId: number) {
|
||||
|
||||
@@ -122,6 +122,18 @@ describe('BookmarkService', () => {
|
||||
expect(result.positionSeconds).toBe(42);
|
||||
});
|
||||
|
||||
it('re-reads and returns the existing bookmark when a concurrent duplicate insert is ignored', async () => {
|
||||
const { service, bookmarkRepo } = makeService();
|
||||
const existing = makeBookmarkRow({ id: 13, cfi: null, positionSeconds: 42, title: '00:00:42' });
|
||||
bookmarkRepo.findExistingByLocation.mockResolvedValueOnce(null).mockResolvedValueOnce(existing);
|
||||
bookmarkRepo.create.mockResolvedValue(null);
|
||||
|
||||
const result = await service.createBookmark(5, makeUser(), { title: '00:00:42', positionSeconds: 42 });
|
||||
|
||||
expect(bookmarkRepo.findExistingByLocation).toHaveBeenNthCalledWith(2, 1, 5, { cfi: null, positionSeconds: 42 });
|
||||
expect(result.id).toBe(13);
|
||||
});
|
||||
|
||||
it('propagates access errors and does not query repository', async () => {
|
||||
const { service, bookService, bookmarkRepo } = makeService();
|
||||
bookService.verifyBookAccess.mockRejectedValue(new NotFoundException('Book 5 not found'));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import type { NewBookmark } from '../../db/schema';
|
||||
@@ -7,6 +7,8 @@ import { BookmarkRepository } from './bookmark.repository';
|
||||
import { BookmarkResponseDto } from './dto/bookmark-response.dto';
|
||||
import { CreateBookmarkDto } from './dto/create-bookmark.dto';
|
||||
|
||||
const BOOKMARK_CONFLICT_MESSAGE = 'Bookmark already exists';
|
||||
|
||||
@Injectable()
|
||||
export class BookmarkService {
|
||||
constructor(
|
||||
@@ -30,6 +32,14 @@ export class BookmarkService {
|
||||
if (existing) return BookmarkResponseDto.from(existing);
|
||||
|
||||
const row = await this.bookmarkRepo.create(user.id, bookId, createData);
|
||||
if (!row) {
|
||||
const concurrent = await this.bookmarkRepo.findExistingByLocation(user.id, bookId, {
|
||||
cfi: createData.cfi,
|
||||
positionSeconds: createData.positionSeconds,
|
||||
});
|
||||
if (concurrent) return BookmarkResponseDto.from(concurrent);
|
||||
throw new ConflictException(BOOKMARK_CONFLICT_MESSAGE);
|
||||
}
|
||||
return BookmarkResponseDto.from(row);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type { BooksPage } from '@projectx/types';
|
||||
import { assembleBookCards } from '../book/utils/assemble-book-cards';
|
||||
@@ -15,6 +15,17 @@ const COLLECTION_NOT_FOUND_MESSAGE = 'Collection not found';
|
||||
const COLLECTION_ACCESS_DENIED_MESSAGE = 'No access to this collection';
|
||||
const BOOKS_NOT_FOUND_MESSAGE = 'One or more books were not found';
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
|
||||
const directCode = (error as { code?: unknown }).code;
|
||||
if (directCode === '23505') return true;
|
||||
|
||||
if (!(error instanceof Error)) return false;
|
||||
const causeCode = (error.cause as { code?: unknown } | undefined)?.code;
|
||||
return causeCode === '23505';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CollectionService {
|
||||
private readonly logger = new Logger(CollectionService.name);
|
||||
@@ -71,26 +82,40 @@ export class CollectionService {
|
||||
}
|
||||
|
||||
async create(dto: CreateCollectionDto, user: RequestUser) {
|
||||
const [inserted] = await this.collectionRepo.insert({
|
||||
userId: user.id,
|
||||
name: dto.name,
|
||||
icon: dto.icon.trim(),
|
||||
description: dto.description ?? null,
|
||||
syncToKobo: dto.syncToKobo ?? false,
|
||||
});
|
||||
const [collection] = await this.collectionRepo.findById(inserted.id);
|
||||
return collection;
|
||||
try {
|
||||
const [inserted] = await this.collectionRepo.insert({
|
||||
userId: user.id,
|
||||
name: dto.name,
|
||||
icon: dto.icon.trim(),
|
||||
description: dto.description ?? null,
|
||||
syncToKobo: dto.syncToKobo ?? false,
|
||||
});
|
||||
const [collection] = await this.collectionRepo.findById(inserted.id);
|
||||
return collection;
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException('A collection with this name already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateCollectionDto, user: RequestUser) {
|
||||
const existing = await this.findCollectionForUserOrThrow(id, user);
|
||||
|
||||
await this.collectionRepo.update(id, existing.userId, {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.icon !== undefined && { icon: dto.icon.trim() || null }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.syncToKobo !== undefined && { syncToKobo: dto.syncToKobo }),
|
||||
});
|
||||
try {
|
||||
await this.collectionRepo.update(id, existing.userId, {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.icon !== undefined && { icon: dto.icon.trim() || null }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.syncToKobo !== undefined && { syncToKobo: dto.syncToKobo }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException('A collection with this name already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [updated] = await this.collectionRepo.findById(id);
|
||||
return updated;
|
||||
|
||||
@@ -144,9 +144,9 @@ describe('EmailRecipientGroupRepository', () => {
|
||||
const { db, insertBuilder, deleteBuilder } = makeDb();
|
||||
const repo = new EmailRecipientGroupRepository(db as never);
|
||||
|
||||
void repo.addMember(3, 4);
|
||||
void repo.addMember(3, 4, 8);
|
||||
expect(db.insert).toHaveBeenCalledWith(emailRecipientGroupMembers);
|
||||
expect(insertBuilder.values).toHaveBeenCalledWith({ groupId: 3, recipientId: 4 });
|
||||
expect(insertBuilder.values).toHaveBeenCalledWith({ groupId: 3, recipientId: 4, userId: 8 });
|
||||
expect(insertBuilder.onConflictDoNothing).toHaveBeenCalled();
|
||||
expect(insertBuilder.returning).toHaveBeenCalled();
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ export class EmailRecipientGroupRepository {
|
||||
.returning();
|
||||
}
|
||||
|
||||
addMember(groupId: number, recipientId: number) {
|
||||
return this.db.insert(emailRecipientGroupMembers).values({ groupId, recipientId }).onConflictDoNothing().returning();
|
||||
addMember(groupId: number, recipientId: number, userId: number) {
|
||||
return this.db.insert(emailRecipientGroupMembers).values({ groupId, recipientId, userId }).onConflictDoNothing().returning();
|
||||
}
|
||||
|
||||
removeMember(groupId: number, recipientId: number) {
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('EmailRecipientGroupService', () => {
|
||||
describe('addMember', () => {
|
||||
it('should add member and return updated group', async () => {
|
||||
await service.addMember(10, 100, mockUser);
|
||||
expect(repo.addMember).toHaveBeenCalledWith(10, 100);
|
||||
expect(repo.addMember).toHaveBeenCalledWith(10, 100, 1);
|
||||
expect(repo.findMembers).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export class EmailRecipientGroupService {
|
||||
const [recipient] = await this.recipientRepo.findById(recipientId);
|
||||
if (!recipient) throw new NotFoundException('Recipient not found');
|
||||
if (recipient.userId !== user.id) throw new ForbiddenException('Cannot add a recipient you do not own');
|
||||
await this.repo.addMember(groupId, recipientId);
|
||||
await this.repo.addMember(groupId, recipientId, user.id);
|
||||
return this.findOne(groupId, user);
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ export class MigrationImportRepository {
|
||||
}
|
||||
|
||||
async insertBookmark(values: typeof schema.bookmarks.$inferInsert): Promise<void> {
|
||||
await this.db.insert(schema.bookmarks).values(values);
|
||||
await this.db.insert(schema.bookmarks).values(values).onConflictDoNothing();
|
||||
}
|
||||
|
||||
// --- Annotations ---
|
||||
@@ -472,7 +472,7 @@ export class MigrationImportRepository {
|
||||
async batchInsertBookmarks(items: Array<typeof schema.bookmarks.$inferInsert>): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
for (const batch of chunk(items, BATCH_CHUNK_SIZE)) {
|
||||
await this.db.insert(schema.bookmarks).values(batch);
|
||||
await this.db.insert(schema.bookmarks).values(batch).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,26 @@ import { Logger } from '@nestjs/common';
|
||||
import * as schema from '../../db/schema';
|
||||
import { CleanupService } from './cleanup.service';
|
||||
|
||||
function makeDb(options?: { refreshDeleteResult?: { rowCount?: number }; resetDeleteResult?: { rowCount?: number } }) {
|
||||
function makeDb(options?: {
|
||||
refreshDeleteResult?: { rowCount?: number };
|
||||
resetDeleteResult?: { rowCount?: number };
|
||||
oidcDeleteResult?: { rowCount?: number };
|
||||
}) {
|
||||
const refreshDeleteBuilder = {
|
||||
where: vi.fn().mockResolvedValue(options?.refreshDeleteResult ?? { rowCount: 2 }),
|
||||
};
|
||||
const resetDeleteBuilder = {
|
||||
where: vi.fn().mockResolvedValue(options?.resetDeleteResult ?? { rowCount: 1 }),
|
||||
};
|
||||
const oidcDeleteBuilder = {
|
||||
where: vi.fn().mockResolvedValue(options?.oidcDeleteResult ?? { rowCount: 3 }),
|
||||
};
|
||||
|
||||
return {
|
||||
delete: vi.fn().mockReturnValueOnce(refreshDeleteBuilder).mockReturnValueOnce(resetDeleteBuilder),
|
||||
delete: vi.fn().mockReturnValueOnce(refreshDeleteBuilder).mockReturnValueOnce(resetDeleteBuilder).mockReturnValueOnce(oidcDeleteBuilder),
|
||||
__refreshDeleteBuilder: refreshDeleteBuilder,
|
||||
__resetDeleteBuilder: resetDeleteBuilder,
|
||||
__oidcDeleteBuilder: oidcDeleteBuilder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,7 +41,7 @@ describe('CleanupService', () => {
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deletes expired or invalid tokens from both tables and logs completion', async () => {
|
||||
it('deletes expired or invalid auth state from all three tables and logs completion', async () => {
|
||||
const db = makeDb();
|
||||
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
const service = new CleanupService(db as never);
|
||||
@@ -42,10 +50,12 @@ describe('CleanupService', () => {
|
||||
|
||||
expect(db.delete).toHaveBeenNthCalledWith(1, schema.refreshTokens);
|
||||
expect(db.delete).toHaveBeenNthCalledWith(2, schema.passwordResetTokens);
|
||||
expect(db.delete).toHaveBeenNthCalledWith(3, schema.oidcSessions);
|
||||
expect(db.__refreshDeleteBuilder.where).toHaveBeenCalledTimes(1);
|
||||
expect(db.__resetDeleteBuilder.where).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_tokens] [start]'));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_tokens] [end] refreshDeleted=2 resetDeleted=1'));
|
||||
expect(db.__oidcDeleteBuilder.where).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_auth_state] [start]'));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_auth_state] [end] refreshDeleted=2 resetDeleted=1 oidcDeleted=3'));
|
||||
});
|
||||
|
||||
it('logs failure and rethrows on cleanup errors', async () => {
|
||||
@@ -56,6 +66,6 @@ describe('CleanupService', () => {
|
||||
const service = new CleanupService(db as never);
|
||||
|
||||
await expect(service.cleanup()).rejects.toThrow('delete failed');
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_tokens] [fail]'));
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_auth_state] [fail]'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { isNotNull, lt, or } from 'drizzle-orm';
|
||||
import { eq, isNotNull, lt, or } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
@@ -20,8 +20,8 @@ export class CleanupService implements OnApplicationBootstrap {
|
||||
@Cron('0 3 * * *')
|
||||
async cleanup() {
|
||||
const startedAt = Date.now();
|
||||
const event = 'seed.cleanup_tokens';
|
||||
this.logger.log(`[${event}] [start] - token cleanup started`);
|
||||
const event = 'seed.cleanup_auth_state';
|
||||
this.logger.log(`[${event}] [start] - auth state cleanup started`);
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
@@ -32,13 +32,18 @@ export class CleanupService implements OnApplicationBootstrap {
|
||||
const { rowCount: resetCount } = await this.db
|
||||
.delete(schema.passwordResetTokens)
|
||||
.where(or(lt(schema.passwordResetTokens.expiresAt, now), isNotNull(schema.passwordResetTokens.usedAt)));
|
||||
|
||||
const { rowCount: oidcCount } = await this.db
|
||||
.delete(schema.oidcSessions)
|
||||
.where(or(lt(schema.oidcSessions.expiresAt, now), eq(schema.oidcSessions.revoked, true)));
|
||||
|
||||
this.logger.log(
|
||||
`[${event}] [end] refreshDeleted=${refreshCount ?? 0} resetDeleted=${resetCount ?? 0} durationMs=${Date.now() - startedAt} - token cleanup completed`,
|
||||
`[${event}] [end] refreshDeleted=${refreshCount ?? 0} resetDeleted=${resetCount ?? 0} oidcDeleted=${oidcCount ?? 0} durationMs=${Date.now() - startedAt} - auth state cleanup completed`,
|
||||
);
|
||||
} catch (error) {
|
||||
const { errorClass, errorMessage } = getSanitizedErrorInfo(error);
|
||||
this.logger.error(
|
||||
`[${event}] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - token cleanup failed`,
|
||||
`[${event}] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - auth state cleanup failed`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user