feat: Datenmodell für Katalog, Tracking und Universen
Prisma-Schema in vier Schichten: Konten und Zugang, gemeinsamer Katalog (über die TMDB-ID dedupliziert), persönliches Tracking je Nutzer sowie Universen mit mehreren Reihenfolgen. Dazu Benachrichtigungskanäle und Protokoll der Hintergrundläufe. Prisma 7 verlangt einen Treiber-Adapter statt der URL im Schema; die Konfiguration liegt deshalb in prisma.config.mjs — bewusst als .mjs, damit die CLI auch im schlanken Produktionsimage ohne TypeScript-Loader läuft.
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
// Flimmer — Datenmodell
|
||||
//
|
||||
// Aufbau in vier Schichten:
|
||||
// 1. Konten & Zugang (User, Session, ApiKey, OidcAccount, AppSetting)
|
||||
// 2. Katalog (Title, Season, Episode, WatchProvider …)
|
||||
// — geteilt zwischen allen Nutzern, dedupliziert über die TMDB-ID
|
||||
// 3. Persönliches Tracking (LibraryEntry, Watch, Tag …)
|
||||
// 4. Universen (Universe, UniverseItem, UniverseOrder …)
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
runtime = "nodejs"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Konten & Zugang
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String
|
||||
passwordHash String?
|
||||
role Role @default(USER)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
/// Region für die Streaming-Verfügbarkeit (ISO-3166-1, z. B. "DE")
|
||||
region String @default("DE")
|
||||
locale String @default("de")
|
||||
|
||||
/// Öffentliche Profilseite unter /u/<profileSlug>
|
||||
publicProfile Boolean @default(false)
|
||||
profileSlug String? @unique
|
||||
|
||||
// Benachrichtigungswünsche
|
||||
notifyNewEpisode Boolean @default(true)
|
||||
notifyRelease Boolean @default(true)
|
||||
notifyAvailability Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sessions Session[]
|
||||
apiKeys ApiKey[]
|
||||
oidcAccounts OidcAccount[]
|
||||
entries LibraryEntry[]
|
||||
watches Watch[]
|
||||
tags Tag[]
|
||||
universes Universe[] @relation("UniverseOwner")
|
||||
memberships UniverseMember[]
|
||||
channels NotificationChannel[]
|
||||
notifications NotificationLog[]
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
|
||||
/// SHA-256 des Cookie-Tokens — das Klartext-Token verlässt den Server nie erneut
|
||||
tokenHash String @unique
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime
|
||||
userAgent String?
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
lastSeen DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
/// Sichtbares Präfix zur Wiedererkennung, z. B. "flm_a1b2c3d4"
|
||||
prefix String @unique
|
||||
keyHash String @unique
|
||||
|
||||
lastUsedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model OidcAccount {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
issuer String
|
||||
subject String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([issuer, subject])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// Instanzweite Einstellungen (Registrierung offen/zu, Instanzname …)
|
||||
model AppSetting {
|
||||
key String @id
|
||||
value Json
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Katalog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum TitleType {
|
||||
SERIES
|
||||
MOVIE
|
||||
}
|
||||
|
||||
model Title {
|
||||
id String @id @default(cuid())
|
||||
type TitleType
|
||||
|
||||
/// TMDB-ID; null bei manuell angelegten Titeln ohne TMDB-Treffer
|
||||
tmdbId Int?
|
||||
imdbId String?
|
||||
|
||||
title String
|
||||
originalTitle String?
|
||||
overview String?
|
||||
tagline String?
|
||||
posterPath String?
|
||||
backdropPath String?
|
||||
|
||||
/// Erstausstrahlung (Serie) bzw. Kinostart (Film)
|
||||
releaseDate DateTime?
|
||||
lastAirDate DateTime?
|
||||
|
||||
/// Laufzeit in Minuten: bei Filmen die Filmlänge, bei Serien die
|
||||
/// durchschnittliche Episodenlänge (Rückfallwert für Episoden ohne Angabe)
|
||||
runtime Int?
|
||||
|
||||
/// TMDB-Status: "Returning Series", "Ended", "Released", "Planned" …
|
||||
status String?
|
||||
inProduction Boolean @default(false)
|
||||
|
||||
genres String[]
|
||||
originalLang String?
|
||||
homepage String?
|
||||
voteAverage Float?
|
||||
|
||||
seasonCount Int @default(0)
|
||||
episodeCount Int @default(0)
|
||||
|
||||
/// Offizielle TMDB-Collection (z. B. „Spider-Man Collection")
|
||||
tmdbCollectionId Int?
|
||||
tmdbCollectionName String?
|
||||
|
||||
/// Zeitpunkt des letzten TMDB-Abgleichs
|
||||
syncedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
seasons Season[]
|
||||
episodes Episode[]
|
||||
entries LibraryEntry[]
|
||||
watches Watch[]
|
||||
providers TitleProvider[]
|
||||
universeItems UniverseItem[]
|
||||
|
||||
@@unique([type, tmdbId])
|
||||
@@index([type])
|
||||
@@index([releaseDate])
|
||||
@@index([title])
|
||||
@@index([tmdbCollectionId])
|
||||
}
|
||||
|
||||
model Season {
|
||||
id String @id @default(cuid())
|
||||
titleId String
|
||||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||||
|
||||
tmdbId Int?
|
||||
seasonNumber Int
|
||||
name String
|
||||
overview String?
|
||||
posterPath String?
|
||||
airDate DateTime?
|
||||
episodeCount Int @default(0)
|
||||
|
||||
episodes Episode[]
|
||||
|
||||
@@unique([titleId, seasonNumber])
|
||||
@@index([titleId])
|
||||
}
|
||||
|
||||
model Episode {
|
||||
id String @id @default(cuid())
|
||||
titleId String
|
||||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||||
seasonId String
|
||||
season Season @relation(fields: [seasonId], references: [id], onDelete: Cascade)
|
||||
|
||||
tmdbId Int?
|
||||
seasonNumber Int
|
||||
episodeNumber Int
|
||||
name String
|
||||
overview String?
|
||||
stillPath String?
|
||||
airDate DateTime?
|
||||
runtime Int?
|
||||
|
||||
watches Watch[]
|
||||
|
||||
@@unique([titleId, seasonNumber, episodeNumber])
|
||||
@@index([titleId])
|
||||
@@index([seasonId])
|
||||
@@index([airDate])
|
||||
}
|
||||
|
||||
/// Streamingdienst laut TMDB/JustWatch
|
||||
model WatchProvider {
|
||||
id String @id @default(cuid())
|
||||
tmdbId Int @unique
|
||||
name String
|
||||
logoPath String?
|
||||
/// Akzentfarbe für Badges im UI (Hex); fällt sonst auf einen Standardton zurück
|
||||
color String?
|
||||
|
||||
titles TitleProvider[]
|
||||
}
|
||||
|
||||
enum ProviderKind {
|
||||
FLATRATE
|
||||
RENT
|
||||
BUY
|
||||
ADS
|
||||
FREE
|
||||
}
|
||||
|
||||
model TitleProvider {
|
||||
id String @id @default(cuid())
|
||||
titleId String
|
||||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||||
providerId String
|
||||
provider WatchProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
||||
region String
|
||||
kind ProviderKind
|
||||
/// Für die Benachrichtigung „ist jetzt verfügbar"
|
||||
firstSeenAt DateTime @default(now())
|
||||
|
||||
@@unique([titleId, providerId, region, kind])
|
||||
@@index([titleId, region])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Persönliches Tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum EntryStatus {
|
||||
WATCHING
|
||||
PAUSED
|
||||
COMPLETED
|
||||
DROPPED
|
||||
WATCHLIST
|
||||
}
|
||||
|
||||
model LibraryEntry {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
titleId String
|
||||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||||
|
||||
status EntryStatus @default(WATCHLIST)
|
||||
|
||||
/// Eigene Bewertung 1–10
|
||||
rating Int?
|
||||
notes String?
|
||||
|
||||
/// „Wo schaue ich das" — Vorschlag aus der Verfügbarkeit, überschreibbar
|
||||
whereToWatch String?
|
||||
|
||||
favorite Boolean @default(false)
|
||||
|
||||
/// Aktueller Durchlauf: 0 = Erstsichtung, 1 = erster Rewatch …
|
||||
currentRun Int @default(0)
|
||||
|
||||
startedAt DateTime?
|
||||
finishedAt DateTime?
|
||||
|
||||
/// Papierkorb: 30 Tage aufbewahren statt hart löschen
|
||||
deletedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tags TagOnEntry[]
|
||||
watches Watch[]
|
||||
|
||||
@@unique([userId, titleId])
|
||||
@@index([userId, status])
|
||||
@@index([userId, updatedAt])
|
||||
@@index([deletedAt])
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
color String?
|
||||
|
||||
entries TagOnEntry[]
|
||||
|
||||
@@unique([userId, name])
|
||||
}
|
||||
|
||||
model TagOnEntry {
|
||||
tagId String
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
entryId String
|
||||
entry LibraryEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([tagId, entryId])
|
||||
@@index([entryId])
|
||||
}
|
||||
|
||||
/// Ein Sichtungsvorgang: entweder eine Episode oder ein Film.
|
||||
/// Rewatches werden über `run` gezählt (0 = Erstsichtung).
|
||||
model Watch {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
entryId String?
|
||||
entry LibraryEntry? @relation(fields: [entryId], references: [id], onDelete: SetNull)
|
||||
|
||||
titleId String
|
||||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||||
episodeId String?
|
||||
episode Episode? @relation(fields: [episodeId], references: [id], onDelete: Cascade)
|
||||
|
||||
run Int @default(0)
|
||||
watchedAt DateTime @default(now())
|
||||
|
||||
/// Tatsächlich angerechnete Minuten — friert die Laufzeit zum Zeitpunkt der
|
||||
/// Sichtung ein, damit spätere TMDB-Korrekturen die Statistik nicht verbiegen.
|
||||
minutes Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, episodeId, run])
|
||||
@@index([userId, watchedAt])
|
||||
@@index([userId, titleId])
|
||||
@@index([entryId])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Universen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model Universe {
|
||||
id String @id @default(cuid())
|
||||
ownerId String
|
||||
owner User @relation("UniverseOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
slug String
|
||||
description String?
|
||||
coverPath String?
|
||||
|
||||
deletedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
items UniverseItem[]
|
||||
orders UniverseOrder[]
|
||||
members UniverseMember[]
|
||||
|
||||
@@unique([ownerId, slug])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
enum SharePermission {
|
||||
READ
|
||||
WRITE
|
||||
}
|
||||
|
||||
model UniverseMember {
|
||||
id String @id @default(cuid())
|
||||
universeId String
|
||||
universe Universe @relation(fields: [universeId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
permission SharePermission @default(READ)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([universeId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model UniverseItem {
|
||||
id String @id @default(cuid())
|
||||
universeId String
|
||||
universe Universe @relation(fields: [universeId], references: [id], onDelete: Cascade)
|
||||
titleId String
|
||||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
positions UniverseOrderItem[]
|
||||
|
||||
@@unique([universeId, titleId])
|
||||
@@index([universeId])
|
||||
}
|
||||
|
||||
enum OrderKind {
|
||||
/// Automatisch aus den Erscheinungsdaten berechnet
|
||||
RELEASE
|
||||
/// Frei sortierbar (Story-Chronologie, Einsteiger-Reihenfolge …)
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
model UniverseOrder {
|
||||
id String @id @default(cuid())
|
||||
universeId String
|
||||
universe Universe @relation(fields: [universeId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
kind OrderKind @default(CUSTOM)
|
||||
isDefault Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
items UniverseOrderItem[]
|
||||
|
||||
@@index([universeId])
|
||||
}
|
||||
|
||||
model UniverseOrderItem {
|
||||
id String @id @default(cuid())
|
||||
orderId String
|
||||
order UniverseOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
itemId String
|
||||
item UniverseItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
position Int
|
||||
|
||||
@@unique([orderId, itemId])
|
||||
@@index([orderId, position])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benachrichtigungen & Hintergrundläufe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum ChannelKind {
|
||||
EMAIL
|
||||
NTFY
|
||||
GOTIFY
|
||||
WEBHOOK
|
||||
}
|
||||
|
||||
model NotificationChannel {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
kind ChannelKind
|
||||
name String
|
||||
/// Kanalspezifische Einstellungen (URL, Topic, Token …)
|
||||
config Json
|
||||
enabled Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model NotificationLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Verhindert Doppelversand, z. B. "episode:<episodeId>"
|
||||
dedupeKey String
|
||||
subject String
|
||||
body String
|
||||
sentAt DateTime @default(now())
|
||||
ok Boolean @default(true)
|
||||
error String?
|
||||
|
||||
@@unique([userId, dedupeKey])
|
||||
@@index([userId, sentAt])
|
||||
}
|
||||
|
||||
model SyncRun {
|
||||
id String @id @default(cuid())
|
||||
kind String
|
||||
startedAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
ok Boolean @default(false)
|
||||
message String?
|
||||
stats Json?
|
||||
|
||||
@@index([kind, startedAt])
|
||||
}
|
||||
Reference in New Issue
Block a user