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:
Florian Wilde
2026-09-01 18:46:27 +02:00
parent 488b99f7a1
commit 3e11e4ffb1
11 changed files with 1512 additions and 3 deletions
+510
View File
@@ -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])
}