Files
ReadMeABook/prisma/schema.prisma
T
H0tChicken 37f063229c fix: use BigInt for PlexLibrary.duration to prevent INT4 overflow
The duration column (Int/int4, max ~2.15B) overflows when storing
millisecond values for items with large durations from Audiobookshelf
or Plex backends. Change to BigInt (int8) and wrap duration calculations
in BigInt() at the Prisma write boundary.

Changes:
- prisma/schema.prisma: PlexLibrary.duration Int? → BigInt?
- plex-recently-added.processor.ts: BigInt(Math.round(...)) wrapping
- scan-plex.processor.ts: same BigInt wrapping
- documentation/backend/database.md: updated duration type notation

Fixes #193

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-05-04 00:32:09 +00:00

768 lines
31 KiB
Plaintext

/**
* ReadMeABook Database Schema
* Documentation: documentation/backend/database.md
* ARCHITECTURE:
* - audible_cache: Pure Audible metadata (popular/new releases from Audible.com)
* - plex_library: Pure Plex library content (what's in your Plex server)
* - audiobooks: User-requested audiobooks only (created on request)
* - Matching happens at QUERY TIME by comparing audible_cache against plex_library
*/
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================================================
// MODELS
// ============================================================================
model User {
id String @id @default(uuid())
plexId String @unique @map("plex_id")
plexUsername String @map("plex_username")
plexEmail String? @map("plex_email")
role String @default("user") // 'user' or 'admin'
isSetupAdmin Boolean @default(false) @map("is_setup_admin") // First admin created during setup, cannot be demoted
avatarUrl String? @map("avatar_url")
authToken String? @map("auth_token") // Encrypted
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
// Plex Home profile tracking
plexHomeUserId String? @map("plex_home_user_id") // Profile ID from Plex Home (null = main account, set = home profile)
// Multi-auth support (for Audiobookshelf integration)
authProvider String? @map("auth_provider") // 'plex' | 'oidc' | 'local'
oidcSubject String? @map("oidc_subject") // OIDC subject ID (unique per provider)
oidcProvider String? @map("oidc_provider") // OIDC provider name (e.g., 'authentik', 'keycloak')
registrationStatus String? @default("approved") @map("registration_status") // 'pending_approval' | 'approved' | 'rejected'
// BookDate per-user preferences
bookDateLibraryScope String? @default("full") @map("bookdate_library_scope") // 'full' | 'rated' | 'favorites'
bookDateFavoriteBookIds String? @map("bookdate_favorite_book_ids") @db.Text // JSON array of PlexLibrary IDs (max 25)
bookDateCustomPrompt String? @map("bookdate_custom_prompt") @db.Text
bookDateOnboardingComplete Boolean @default(false) @map("bookdate_onboarding_complete")
// Request approval preferences
autoApproveRequests Boolean? @map("auto_approve_requests") // null = use global setting, true = auto-approve, false = require approval
// Fine-grained permissions
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
// Login token (admin-generated, for direct URL login)
loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext)
// Session invalidation (set when login token is revoked to force-logout active sessions)
sessionsInvalidatedAt DateTime? @map("sessions_invalidated_at")
// Soft delete support
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
// Relations
requests Request[]
bookDateRecommendations BookDateRecommendation[]
bookDateSwipes BookDateSwipe[]
goodreadsShelves GoodreadsShelf[]
hardcoverShelves HardcoverShelf[]
reportedIssues ReportedIssue[] @relation("Reporter")
resolvedIssues ReportedIssue[] @relation("Resolver")
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
apiTokens ApiToken[] @relation("UserApiTokens")
watchedSeries WatchedSeries[]
watchedAuthors WatchedAuthor[]
homeSections UserHomeSection[]
ignoredAudiobooks IgnoredAudiobook[]
@@index([plexId])
@@index([role])
@@index([deletedAt])
@@map("users")
}
// ============================================================================
// AUDIBLE CACHE TABLE
// Pure Audible metadata - Popular/New Releases cached from Audible.com
// No Plex data, no availability status - just Audible metadata
// ============================================================================
model AudibleCache {
id String @id @default(uuid())
asin String @unique // Audible Standard Identification Number
title String
author String
narrator String?
description String? @db.Text
coverArtUrl String? @map("cover_art_url") @db.Text
cachedCoverPath String? @map("cached_cover_path") @db.Text // Local path to cached cover image
durationMinutes Int? @map("duration_minutes")
releaseDate DateTime? @map("release_date") @db.Date
rating Decimal? @db.Decimal(3, 2)
genres Json @default("[]")
lastSyncedAt DateTime @default(now()) @map("last_synced_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([asin])
@@index([title])
@@index([author])
@@map("audible_cache")
}
// ============================================================================
// LIBRARY CACHE TABLE (plex_library for backward compatibility)
// Universal library content - Works with Plex or Audiobookshelf backends
// Stores complete metadata including ASIN/ISBN for accurate matching
// No Audible data - just library backend metadata and file info
// ============================================================================
model PlexLibrary {
id String @id @default(uuid())
plexGuid String @unique @map("plex_guid") // Plex's unique identifier
plexRatingKey String? @map("plex_rating_key") // Plex's rating key
title String
author String
narrator String?
summary String? @db.Text
duration BigInt? // Duration in milliseconds (Plex format)
year Int?
userRating Decimal? @map("user_rating") @db.Decimal(3, 1) // User's rating (0-10 scale from Plex)
// Universal identifiers (works for both Plex and Audiobookshelf)
asin String? // Audible ASIN - extracted from Plex GUID or stored directly from ABS
isbn String? // ISBN (10 or 13) - for additional matching capability
// File information
filePath String? @map("file_path") @db.Text
thumbUrl String? @map("thumb_url") @db.Text // Plex thumbnail URL
cachedLibraryCoverPath String? @map("cached_library_cover_path") @db.Text // Local path to cached library cover image
// Plex metadata
plexLibraryId String @map("plex_library_id") // Which Plex library contains this
addedAt DateTime? @map("added_at") // When added to Plex
lastScannedAt DateTime @default(now()) @map("last_scanned_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([plexGuid])
@@index([title])
@@index([author])
@@index([plexLibraryId])
@@index([asin])
@@index([isbn])
@@map("plex_library")
}
// ============================================================================
// AUDIOBOOK TABLE (Simplified)
// Only created when user requests an audiobook
// Links to AudibleCache for metadata (optional - search results may not be cached)
// ============================================================================
model Audiobook {
id String @id @default(uuid())
// Core metadata (may come from Audible search, not necessarily cached)
audibleAsin String? @map("audible_asin") // ASIN if from Audible
title String
author String
narrator String?
description String? @db.Text
coverArtUrl String? @map("cover_art_url") @db.Text
year Int? // Release year extracted from releaseDate
series String? // Book series name (e.g., "The Mistborn Saga")
seriesPart String? @map("series_part") // Series position (e.g., "1", "1.5", "Book 1")
seriesAsin String? @map("series_asin") // Audible series ASIN for linking to series detail page
// Request tracking
status String @default("requested") // requested, downloading, processing, completed, failed
// File information (populated after download/organization)
filePath String? @map("file_path") @db.Text
fileFormat String? @map("file_format") // m4b, m4a, mp3
fileSizeBytes BigInt? @map("file_size_bytes")
// Plex integration (populated after successful import)
plexGuid String? @map("plex_guid") // Set when imported into Plex
plexLibraryId String? @map("plex_library_id")
// Audiobookshelf integration (alternative to Plex)
absItemId String? @map("abs_item_id") // Audiobookshelf item ID
// File hash for accurate library matching (SHA256 of sorted audio filenames)
filesHash String? @map("files_hash") @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
// Relations
requests Request[]
reportedIssues ReportedIssue[]
@@index([audibleAsin])
@@index([plexGuid])
@@index([absItemId])
@@index([title])
@@index([author])
@@index([status])
@@index([filesHash])
@@map("audiobooks")
}
model Request {
id String @id @default(uuid())
userId String @map("user_id")
audiobookId String @map("audiobook_id")
status String @default("pending")
// Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, warn
// Flow (audiobook): pending → searching → downloading → processing → downloaded → available (when matched in Plex)
// Flow (ebook): pending → searching → downloading → processing → downloaded (terminal - no available state)
progress Int @default(0) // 0-100
priority Int @default(0)
errorMessage String? @map("error_message") @db.Text
selectedTorrent Json? @map("selected_torrent") // Pre-selected torrent from interactive search (stored when awaiting approval)
searchAttempts Int @default(0) @map("search_attempts")
downloadAttempts Int @default(0) @map("download_attempts")
importAttempts Int @default(0) @map("import_attempts")
maxImportRetries Int @default(5) @map("max_import_retries")
lastSearchAt DateTime? @map("last_search_at")
customSearchTerms String? @map("custom_search_terms") @db.Text
lastImportAt DateTime? @map("last_import_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
// Request type: 'audiobook' (default) or 'ebook'
// Ebook requests are created automatically when an audiobook is organized (if ebook downloads enabled)
type String @default("audiobook") // 'audiobook' | 'ebook'
parentRequestId String? @map("parent_request_id") // Links ebook request to originating audiobook request
// Soft delete support
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by") // Admin user ID
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
audiobook Audiobook @relation(fields: [audiobookId], references: [id], onDelete: Cascade)
downloadHistory DownloadHistory[]
jobs Job[]
parentRequest Request? @relation("EbookParent", fields: [parentRequestId], references: [id], onDelete: SetNull)
childRequests Request[] @relation("EbookParent")
@@index([userId])
@@index([audiobookId])
@@index([status])
@@index([createdAt(sort: Desc)])
@@index([deletedAt])
@@index([type])
@@index([parentRequestId])
@@map("requests")
}
model DownloadHistory {
id String @id @default(uuid())
requestId String @map("request_id")
indexerName String @map("indexer_name")
indexerId Int? @map("indexer_id") // Prowlarr indexer ID for configuration lookup
torrentName String? @map("torrent_name")
torrentHash String? @map("torrent_hash")
nzbId String? @map("nzb_id") // SABnzbd NZB ID (mutually exclusive with torrentHash)
torrentSizeBytes BigInt? @map("torrent_size_bytes")
magnetLink String? @map("magnet_link") @db.Text
torrentUrl String? @map("torrent_url") @db.Text
seeders Int?
leechers Int?
qualityScore Int? @map("quality_score")
selected Boolean @default(false)
downloadClient String? @map("download_client") // qbittorrent, sabnzbd, direct (HTTP download for ebooks)
downloadClientId String? @map("download_client_id")
downloadStatus String? @map("download_status")
// Status values: queued, downloading, completed, failed, stalled
downloadError String? @map("download_error") @db.Text
downloadPath String? @map("download_path") @db.Text
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
// Relations
request Request @relation(fields: [requestId], references: [id], onDelete: Cascade)
@@index([requestId])
@@index([selected])
@@index([indexerId])
@@index([torrentHash])
@@index([nzbId])
@@index([createdAt(sort: Desc)])
@@map("download_history")
}
model Configuration {
id String @id @default(uuid())
key String @unique
value String? @db.Text
encrypted Boolean @default(false)
category String? // plex, indexer, download_client, system, automation
description String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([key])
@@index([category])
@@map("configuration")
}
model Job {
id String @id @default(uuid())
bullJobId String? @map("bull_job_id")
requestId String? @map("request_id")
type String
// Job types: search_indexers, monitor_download, organize_files, scan_plex, plex_recently_added_check, match_plex
// Ebook job types: search_ebook, start_direct_download, monitor_direct_download
status String @default("pending")
// Status values: pending, active, completed, failed, delayed, stuck
priority Int @default(0)
attempts Int @default(0)
maxAttempts Int @default(3) @map("max_attempts")
payload Json?
result Json?
errorMessage String? @map("error_message") @db.Text
stackTrace String? @map("stack_trace") @db.Text
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
request Request? @relation(fields: [requestId], references: [id], onDelete: SetNull)
events JobEvent[]
@@index([requestId])
@@index([type])
@@index([status])
@@index([createdAt(sort: Desc)])
@@map("jobs")
}
model JobEvent {
id String @id @default(uuid())
jobId String @map("job_id")
level String // info, warn, error
context String // e.g., OrganizeFiles, FileOrganizer, MonitorDownload
message String @db.Text
metadata Json? // Additional structured data
createdAt DateTime @default(now()) @map("created_at")
// Relations
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
@@index([jobId])
@@index([createdAt])
@@map("job_events")
}
model ScheduledJob {
id String @id @default(uuid())
name String
type String // 'plex_library_scan', 'plex_recently_added_check', 'audible_refresh', 'retry_missing_torrents', 'retry_failed_imports', 'cleanup_seeded_torrents', 'monitor_rss_feeds'
schedule String // Cron expression
enabled Boolean @default(true)
payload Json @default("{}")
lastRun DateTime? @map("last_run")
lastRunJobId String? @map("last_run_job_id") // Bull queue job ID of most recent execution
nextRun DateTime? @map("next_run")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([type])
@@index([enabled])
@@map("scheduled_jobs")
}
// ============================================================================
// BOOKDATE TABLES
// AI-powered audiobook recommendation system
// Documentation: documentation/features/bookdate-prd.md
// ============================================================================
model BookDateConfig {
id String @id @default(uuid())
provider String // 'openai' | 'claude' | 'gemini' | 'custom'
apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256)
model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929'
baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints)
isVerified Boolean @default(false) @map("is_verified")
isEnabled Boolean @default(true) @map("is_enabled") // Admin toggle (global feature)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("bookdate_config")
}
// Note: BookDateConfig is now a singleton - only ONE record exists globally.
// Admin configures this in settings, and all users share the same API key.
// Individual users still have their own recommendations and swipe history.
model BookDateRecommendation {
id String @id @default(uuid())
userId String @map("user_id")
batchId String @map("batch_id") // Group recommendations from same AI call
title String
author String
narrator String?
rating Decimal? @db.Decimal(3, 2)
description String? @db.Text
coverUrl String? @map("cover_url") @db.Text
audnexusAsin String? @map("audnexus_asin") // For matching
aiReason String @map("ai_reason") @db.Text // Why AI recommended this
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
swipes BookDateSwipe[]
@@index([userId, batchId])
@@index([userId, createdAt])
@@map("bookdate_recommendations")
}
model BookDateSwipe {
id String @id @default(uuid())
userId String @map("user_id")
recommendationId String? @map("recommendation_id") // NULL if book not from BookDate
bookTitle String @map("book_title")
bookAuthor String @map("book_author")
action String // 'left' | 'right' | 'up'
markedAsKnown Boolean @default(false) @map("marked_as_known") // True if "Mark as Known"
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
recommendation BookDateRecommendation? @relation(fields: [recommendationId], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([recommendationId])
@@map("bookdate_swipes")
}
model NotificationBackend {
id String @id @default(uuid())
type String // 'discord' | 'pushover' | 'email' | 'slack' | 'telegram' | 'webhook'
name String // User-friendly label
config Json // Type-specific config (encrypted sensitive values)
events Json @default("[]") // Array of event strings
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([enabled])
@@map("notification_backends")
}
// ============================================================================
// REPORTED ISSUES TABLE
// User-reported problems with available audiobooks (corrupted, wrong book, etc.)
// ============================================================================
model ReportedIssue {
id String @id @default(uuid())
audiobookId String @map("audiobook_id")
reporterId String @map("reporter_id")
reason String @db.VarChar(250)
status String @default("open") // open, dismissed, replaced
resolvedAt DateTime? @map("resolved_at")
resolvedById String? @map("resolved_by_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
audiobook Audiobook @relation(fields: [audiobookId], references: [id], onDelete: Cascade)
reporter User @relation("Reporter", fields: [reporterId], references: [id], onDelete: Cascade)
resolvedBy User? @relation("Resolver", fields: [resolvedById], references: [id], onDelete: SetNull)
@@index([audiobookId])
@@index([reporterId])
@@index([status])
@@map("reported_issues")
}
// ============================================================================
// GOODREADS SYNC TABLES
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
// ============================================================================
// ============================================================================
// API TOKEN TABLE
// Static API tokens for programmatic access (alternative to JWT)
// Documentation: documentation/backend/services/api-tokens.md
// ============================================================================
model ApiToken {
id String @id @default(uuid())
name String // User-friendly label (e.g., "Home Assistant", "Webhook")
tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext)
tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2")
role String @default("user") // Token role: 'admin' or 'user'
createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens)
userId String @map("user_id") // The user identity this token acts as
lastUsedAt DateTime? @map("last_used_at")
expiresAt DateTime? @map("expires_at") // null = never expires
createdAt DateTime @default(now()) @map("created_at")
// Relations
createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade)
tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade)
@@index([tokenHash])
@@index([createdById])
@@index([userId])
@@map("api_tokens")
}
model GoodreadsShelf {
id String @id @default(uuid())
userId String @map("user_id")
name String // Extracted from RSS <title>
rssUrl String @map("rss_url") @db.Text
lastSyncAt DateTime? @map("last_sync_at")
bookCount Int? @map("book_count")
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, rssUrl])
@@index([userId])
@@map("goodreads_shelves")
}
// ============================================================================
// UNIFIED BOOK MAPPING TABLE
// Global book-to-ASIN mapping cache shared across all shelf providers.
// Uses provider + externalBookId composite key for cross-provider dedup.
// ============================================================================
model BookMapping {
id String @id @default(uuid())
provider String // "goodreads", "hardcover", etc.
externalBookId String @map("external_book_id")
title String
author String
audibleAsin String? @map("audible_asin")
coverUrl String? @map("cover_url") @db.Text
noMatch Boolean @default(false) @map("no_match")
lastSearchAt DateTime? @map("last_search_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([provider, externalBookId])
@@index([provider, externalBookId])
@@index([audibleAsin])
@@map("book_mappings")
}
// ============================================================================
// HARDCOVER SYNC TABLES
// Per-user Hardcover list subscriptions
// ============================================================================
model HardcoverShelf {
id String @id @default(uuid())
userId String @map("user_id")
name String // Extracted from Hardcover API list name or status
listId String @map("list_id") // Hardcover List ID or Status ID
apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api
lastSyncAt DateTime? @map("last_sync_at")
bookCount Int? @map("book_count")
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, listId])
@@index([userId])
@@map("hardcover_shelves")
}
// ============================================================================
// WORKS TABLE
// Cross-ASIN audiobook identity mapping — links multiple Audible ASINs
// to a single logical work for library matching across editions.
// Documentation: documentation/integrations/audible.md
// ============================================================================
model Work {
id String @id @default(uuid())
title String
author String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
asins WorkAsin[]
@@index([title])
@@index([author])
@@map("works")
}
model WorkAsin {
id String @id @default(uuid())
workId String @map("work_id")
asin String @unique
narrator String?
durationMinutes Int? @map("duration_minutes")
isCanonical Boolean @default(false) @map("is_canonical")
source String // 'dedup_auto' | 'admin_manual'
createdAt DateTime @default(now()) @map("created_at")
// Relations
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
@@index([workId])
@@index([asin])
@@map("work_asins")
}
// ============================================================================
// WATCHED LISTS TABLES
// Per-user series and author subscriptions for automatic new-release requests.
// Documentation: documentation/features/watched-lists.md
// ============================================================================
model WatchedSeries {
id String @id @default(uuid())
userId String @map("user_id")
seriesAsin String @map("series_asin")
seriesTitle String @map("series_title")
coverArtUrl String? @map("cover_art_url") @db.Text
lastCheckedAt DateTime? @map("last_checked_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, seriesAsin])
@@index([userId])
@@index([seriesAsin])
@@map("watched_series")
}
model WatchedAuthor {
id String @id @default(uuid())
userId String @map("user_id")
authorAsin String @map("author_asin")
authorName String @map("author_name")
coverArtUrl String? @map("cover_art_url") @db.Text
lastCheckedAt DateTime? @map("last_checked_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, authorAsin])
@@index([userId])
@@index([authorAsin])
@@map("watched_authors")
}
// ============================================================================
// IGNORED AUDIOBOOK TABLE
// Per-user ignore list for auto-request suppression.
// Stores the ASIN the user clicked ignore on; works-system expansion
// happens at check-time in request-creator.service.ts.
// Documentation: documentation/features/ignored-audiobooks.md
// ============================================================================
model IgnoredAudiobook {
id String @id @default(uuid())
userId String @map("user_id")
asin String // Audible ASIN that was explicitly ignored
title String // Display only — snapshot at ignore time
author String // Display only — snapshot at ignore time
coverArtUrl String? @map("cover_art_url") @db.Text
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, asin])
@@index([userId])
@@index([asin])
@@map("ignored_audiobooks")
}
// ============================================================================
// USER HOME SECTION TABLE
// Per-user configurable home page sections (popular, new_releases, category)
// Documentation: documentation/features/home-sections.md
// ============================================================================
model UserHomeSection {
id String @id @default(uuid())
userId String @map("user_id")
sectionType String @map("section_type") // 'popular' | 'new_releases' | 'category'
categoryId String? @map("category_id") // Audible category node ID (only for type 'category')
categoryName String? @map("category_name") // Display name (only for type 'category')
sortOrder Int @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, sectionType, categoryId])
@@index([userId])
@@index([sortOrder])
@@map("user_home_sections")
}
// ============================================================================
// AUDIBLE CACHE CATEGORY TABLE
// Join table linking AudibleCache entries to Audible categories with ranking
// Documentation: documentation/features/home-sections.md
// ============================================================================
model AudibleCacheCategory {
id String @id @default(uuid())
asin String
categoryId String @map("category_id")
rank Int
lastSyncedAt DateTime @default(now()) @map("last_synced_at")
createdAt DateTime @default(now()) @map("created_at")
@@unique([asin, categoryId])
@@index([categoryId])
@@index([asin])
@@index([categoryId, rank])
@@map("audible_cache_categories")
}
// ============================================================================
// DATA MIGRATION TRACKING
// Tracks which data migration SQL scripts have been executed (run-once).
// ============================================================================
model DataMigration {
name String @id
executedAt DateTime @default(now()) @map("executed_at")
@@map("_data_migrations")
}