// 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/ 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:" 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]) }