mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
37f063229c
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>
768 lines
31 KiB
Plaintext
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")
|
|
}
|