From 3e11e4ffb115081026d47809dd7afae00607fa3c Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 18:46:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Datenmodell=20f=C3=BCr=20Katalog,=20Tra?= =?UTF-8?q?cking=20und=20Universen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 78 +++ .gitignore | 1 + docker-compose.dev.yml | 23 + package-lock.json | 200 ++++++- package.json | 4 + prisma.config.mjs | 26 + .../20260901162941_init/migration.sql | 553 ++++++++++++++++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 510 ++++++++++++++++ src/lib/db.ts | 26 + src/lib/env.ts | 91 +++ 11 files changed, 1512 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100644 docker-compose.dev.yml create mode 100644 prisma.config.mjs create mode 100644 prisma/migrations/20260901162941_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 src/lib/db.ts create mode 100644 src/lib/env.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..72268b1 --- /dev/null +++ b/.env.example @@ -0,0 +1,78 @@ +# ============================================================================= +# Flimmer — Beispielkonfiguration +# +# cp .env.example .env +# +# Alles, was Flimmer über die Außenwelt wissen muss, steht hier. Die Datei .env +# gehört NICHT ins Repository (siehe .gitignore). +# ============================================================================= + +# --- Datenbank --------------------------------------------------------------- +# Im Docker-Compose-Betrieb zeigt der Host auf den Dienst "db". +POSTGRES_USER=flimmer +POSTGRES_PASSWORD=bitte-aendern +POSTGRES_DB=flimmer +DATABASE_URL=postgresql://flimmer:bitte-aendern@db:5432/flimmer?schema=public + +# --- Grundeinstellungen ------------------------------------------------------ +# Öffentlich erreichbare Adresse der Instanz (ohne abschließenden Schrägstrich). +# Wird für Links in Benachrichtigungen und für OIDC-Rücksprünge gebraucht. +APP_URL=http://localhost:3000 +APP_NAME=Flimmer + +# Zufälliger Wert, mindestens 32 Zeichen. Erzeugen z. B. mit: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +AUTH_SECRET=bitte-durch-zufallswert-ersetzen + +# Ablage für Bild-Cache und sonstige Laufzeitdaten. +DATA_DIR=/app/data + +# Zeitzone für Kalender, Statistiken und geplante Läufe +TZ=Europe/Berlin + +# --- Registrierung ----------------------------------------------------------- +# Der erste registrierte Nutzer wird automatisch Admin. Danach lässt sich die +# Registrierung im Adminbereich abschalten; dieser Wert ist nur die Vorgabe +# für die allererste Inbetriebnahme. +ALLOW_REGISTRATION=true + +# --- TMDB -------------------------------------------------------------------- +# Kostenloser Schlüssel: https://www.themoviedb.org/settings/api +# Entweder der klassische API-Key (v3) … +TMDB_API_KEY= +# … oder das Bearer-Token (v4). Eines von beidem genügt. +TMDB_ACCESS_TOKEN= +# Sprache der Metadaten und Region für die Streaming-Verfügbarkeit +TMDB_LANGUAGE=de-DE +TMDB_REGION=DE + +# --- Hintergrundläufe -------------------------------------------------------- +# Eingebauter Scheduler (prüft laufende Serien auf neue Episoden, räumt den +# Papierkorb, verschickt Benachrichtigungen). Bei mehreren App-Instanzen nur +# in einer aktivieren. +ENABLE_SCHEDULER=true +# Uhrzeit des täglichen Laufs (24-Stunden-Format) +SYNC_HOUR=4 +# Alternativ von außen anstoßen: curl -H "Authorization: Bearer $CRON_SECRET" \ +# https://flimmer.example/api/cron/sync +CRON_SECRET= + +# --- E-Mail (optional) ------------------------------------------------------- +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_SECURE=false +SMTP_FROM=Flimmer + +# --- OIDC / SSO (optional) --------------------------------------------------- +# Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert. +# Redirect-URI beim Anbieter eintragen: /api/auth/oidc/callback +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_SCOPES=openid profile email +# Beschriftung des Login-Knopfes +OIDC_BUTTON_LABEL=Mit SSO anmelden +# Neue Nutzer per OIDC automatisch anlegen +OIDC_AUTO_REGISTER=true diff --git a/.gitignore b/.gitignore index 95b1d22..aec44d0 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ Thumbs.db /playwright-report/ /blob-report/ .playwright/ +src/generated/ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..cc52a56 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,23 @@ +# Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`. +# docker compose -f docker-compose.dev.yml up -d +services: + db: + image: postgres:17-alpine + container_name: flimmer-dev-db + restart: unless-stopped + environment: + POSTGRES_USER: flimmer + POSTGRES_PASSWORD: flimmer-dev + POSTGRES_DB: flimmer + ports: + - "127.0.0.1:5439:5432" + volumes: + - flimmer-dev-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U flimmer -d flimmer"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + flimmer-dev-db: diff --git a/package-lock.json b/package-lock.json index 15f0e7c..11ba573 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "7.10.0", "bcryptjs": "3.0.3", "clsx": "2.1.1", @@ -16,8 +17,10 @@ "jose": "6.2.10", "next": "16.3.4", "nodemailer": "9.1.0", + "pg": "^8.23.0", "react": "19.2.8", "react-dom": "19.2.8", + "server-only": "^0.0.1", "zod": "4.5.4" }, "devDependencies": { @@ -25,6 +28,7 @@ "@types/bcryptjs": "3.0.0", "@types/node": "24.10.1", "@types/nodemailer": "8.0.1", + "@types/pg": "^8.23.1", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", "playwright": "1.62.1", @@ -1278,6 +1282,18 @@ "node": ">= 10" } }, + "node_modules/@prisma/adapter-pg": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz", + "integrity": "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.10.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz", @@ -1325,7 +1341,6 @@ "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz", "integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/dev": { @@ -1352,6 +1367,15 @@ "zeptomatch": "2.1.0" } }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.0.tgz", + "integrity": "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.0" + } + }, "node_modules/@prisma/engines": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz", @@ -2039,7 +2063,6 @@ "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -2055,6 +2078,17 @@ "@types/node": "*" } }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -3748,6 +3782,104 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3841,6 +3973,45 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prisma": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.0.tgz", @@ -4059,6 +4230,12 @@ "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", "devOptional": true }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", @@ -4154,6 +4331,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -4294,7 +4480,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/valibot": { @@ -4328,6 +4513,15 @@ "node": ">= 8" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zeptomatch": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", diff --git a/package.json b/package.json index 0c855b3..d237808 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "screenshots": "tsx scripts/screenshots.ts" }, "dependencies": { + "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "7.10.0", "bcryptjs": "3.0.3", "clsx": "2.1.1", @@ -25,8 +26,10 @@ "jose": "6.2.10", "next": "16.3.4", "nodemailer": "9.1.0", + "pg": "^8.23.0", "react": "19.2.8", "react-dom": "19.2.8", + "server-only": "^0.0.1", "zod": "4.5.4" }, "devDependencies": { @@ -34,6 +37,7 @@ "@types/bcryptjs": "3.0.0", "@types/node": "24.10.1", "@types/nodemailer": "8.0.1", + "@types/pg": "^8.23.1", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", "playwright": "1.62.1", diff --git a/prisma.config.mjs b/prisma.config.mjs new file mode 100644 index 0000000..8717a14 --- /dev/null +++ b/prisma.config.mjs @@ -0,0 +1,26 @@ +import { defineConfig } from "prisma/config"; + +/** + * Prisma-Konfiguration. + * + * Bewusst als .mjs und nicht als .ts: die Prisma-CLI läuft auch im schlanken + * Produktionsimage (Migrationen beim Containerstart), und dort ist kein + * TypeScript-Loader vorhanden. + * + * Prisma 7 lädt .env nicht mehr von selbst — Node bringt das aber mit. + */ +try { + process.loadEnvFile(".env"); +} catch { + // Keine .env vorhanden (z. B. im Container): Werte kommen von außen. +} + +export default defineConfig({ + schema: "prisma/schema.prisma", + datasource: { + url: process.env.DATABASE_URL, + }, + migrations: { + seed: "tsx prisma/seed.ts", + }, +}); diff --git a/prisma/migrations/20260901162941_init/migration.sql b/prisma/migrations/20260901162941_init/migration.sql new file mode 100644 index 0000000..593fb82 --- /dev/null +++ b/prisma/migrations/20260901162941_init/migration.sql @@ -0,0 +1,553 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER'); + +-- CreateEnum +CREATE TYPE "TitleType" AS ENUM ('SERIES', 'MOVIE'); + +-- CreateEnum +CREATE TYPE "ProviderKind" AS ENUM ('FLATRATE', 'RENT', 'BUY', 'ADS', 'FREE'); + +-- CreateEnum +CREATE TYPE "EntryStatus" AS ENUM ('WATCHING', 'PAUSED', 'COMPLETED', 'DROPPED', 'WATCHLIST'); + +-- CreateEnum +CREATE TYPE "SharePermission" AS ENUM ('READ', 'WRITE'); + +-- CreateEnum +CREATE TYPE "OrderKind" AS ENUM ('RELEASE', 'CUSTOM'); + +-- CreateEnum +CREATE TYPE "ChannelKind" AS ENUM ('EMAIL', 'NTFY', 'GOTIFY', 'WEBHOOK'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "passwordHash" TEXT, + "role" "Role" NOT NULL DEFAULT 'USER', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "region" TEXT NOT NULL DEFAULT 'DE', + "locale" TEXT NOT NULL DEFAULT 'de', + "publicProfile" BOOLEAN NOT NULL DEFAULT false, + "profileSlug" TEXT, + "notifyNewEpisode" BOOLEAN NOT NULL DEFAULT true, + "notifyRelease" BOOLEAN NOT NULL DEFAULT true, + "notifyAvailability" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "userAgent" TEXT, + "ip" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeen" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "prefix" TEXT NOT NULL, + "keyHash" TEXT NOT NULL, + "lastUsedAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OidcAccount" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OidcAccount_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AppSetting" ( + "key" TEXT NOT NULL, + "value" JSONB NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "Title" ( + "id" TEXT NOT NULL, + "type" "TitleType" NOT NULL, + "tmdbId" INTEGER, + "imdbId" TEXT, + "title" TEXT NOT NULL, + "originalTitle" TEXT, + "overview" TEXT, + "tagline" TEXT, + "posterPath" TEXT, + "backdropPath" TEXT, + "releaseDate" TIMESTAMP(3), + "lastAirDate" TIMESTAMP(3), + "runtime" INTEGER, + "status" TEXT, + "inProduction" BOOLEAN NOT NULL DEFAULT false, + "genres" TEXT[], + "originalLang" TEXT, + "homepage" TEXT, + "voteAverage" DOUBLE PRECISION, + "seasonCount" INTEGER NOT NULL DEFAULT 0, + "episodeCount" INTEGER NOT NULL DEFAULT 0, + "tmdbCollectionId" INTEGER, + "tmdbCollectionName" TEXT, + "syncedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Title_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Season" ( + "id" TEXT NOT NULL, + "titleId" TEXT NOT NULL, + "tmdbId" INTEGER, + "seasonNumber" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "overview" TEXT, + "posterPath" TEXT, + "airDate" TIMESTAMP(3), + "episodeCount" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "Season_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Episode" ( + "id" TEXT NOT NULL, + "titleId" TEXT NOT NULL, + "seasonId" TEXT NOT NULL, + "tmdbId" INTEGER, + "seasonNumber" INTEGER NOT NULL, + "episodeNumber" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "overview" TEXT, + "stillPath" TEXT, + "airDate" TIMESTAMP(3), + "runtime" INTEGER, + + CONSTRAINT "Episode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WatchProvider" ( + "id" TEXT NOT NULL, + "tmdbId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "logoPath" TEXT, + "color" TEXT, + + CONSTRAINT "WatchProvider_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TitleProvider" ( + "id" TEXT NOT NULL, + "titleId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "region" TEXT NOT NULL, + "kind" "ProviderKind" NOT NULL, + "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TitleProvider_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LibraryEntry" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "titleId" TEXT NOT NULL, + "status" "EntryStatus" NOT NULL DEFAULT 'WATCHLIST', + "rating" INTEGER, + "notes" TEXT, + "whereToWatch" TEXT, + "favorite" BOOLEAN NOT NULL DEFAULT false, + "currentRun" INTEGER NOT NULL DEFAULT 0, + "startedAt" TIMESTAMP(3), + "finishedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LibraryEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Tag" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "color" TEXT, + + CONSTRAINT "Tag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TagOnEntry" ( + "tagId" TEXT NOT NULL, + "entryId" TEXT NOT NULL, + + CONSTRAINT "TagOnEntry_pkey" PRIMARY KEY ("tagId","entryId") +); + +-- CreateTable +CREATE TABLE "Watch" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "entryId" TEXT, + "titleId" TEXT NOT NULL, + "episodeId" TEXT, + "run" INTEGER NOT NULL DEFAULT 0, + "watchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "minutes" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Watch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Universe" ( + "id" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "coverPath" TEXT, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Universe_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UniverseMember" ( + "id" TEXT NOT NULL, + "universeId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "permission" "SharePermission" NOT NULL DEFAULT 'READ', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UniverseMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UniverseItem" ( + "id" TEXT NOT NULL, + "universeId" TEXT NOT NULL, + "titleId" TEXT NOT NULL, + "note" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UniverseItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UniverseOrder" ( + "id" TEXT NOT NULL, + "universeId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "kind" "OrderKind" NOT NULL DEFAULT 'CUSTOM', + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UniverseOrder_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UniverseOrderItem" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "position" INTEGER NOT NULL, + + CONSTRAINT "UniverseOrderItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NotificationChannel" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "kind" "ChannelKind" NOT NULL, + "name" TEXT NOT NULL, + "config" JSONB NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "NotificationChannel_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NotificationLog" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "dedupeKey" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "body" TEXT NOT NULL, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ok" BOOLEAN NOT NULL DEFAULT true, + "error" TEXT, + + CONSTRAINT "NotificationLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SyncRun" ( + "id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finishedAt" TIMESTAMP(3), + "ok" BOOLEAN NOT NULL DEFAULT false, + "message" TEXT, + "stats" JSONB, + + CONSTRAINT "SyncRun_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_profileSlug_key" ON "User"("profileSlug"); + +-- CreateIndex +CREATE INDEX "User_createdAt_idx" ON "User"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash"); + +-- CreateIndex +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); + +-- CreateIndex +CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_prefix_key" ON "ApiKey"("prefix"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash"); + +-- CreateIndex +CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId"); + +-- CreateIndex +CREATE INDEX "OidcAccount_userId_idx" ON "OidcAccount"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OidcAccount_issuer_subject_key" ON "OidcAccount"("issuer", "subject"); + +-- CreateIndex +CREATE INDEX "Title_type_idx" ON "Title"("type"); + +-- CreateIndex +CREATE INDEX "Title_releaseDate_idx" ON "Title"("releaseDate"); + +-- CreateIndex +CREATE INDEX "Title_title_idx" ON "Title"("title"); + +-- CreateIndex +CREATE INDEX "Title_tmdbCollectionId_idx" ON "Title"("tmdbCollectionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Title_type_tmdbId_key" ON "Title"("type", "tmdbId"); + +-- CreateIndex +CREATE INDEX "Season_titleId_idx" ON "Season"("titleId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Season_titleId_seasonNumber_key" ON "Season"("titleId", "seasonNumber"); + +-- CreateIndex +CREATE INDEX "Episode_titleId_idx" ON "Episode"("titleId"); + +-- CreateIndex +CREATE INDEX "Episode_seasonId_idx" ON "Episode"("seasonId"); + +-- CreateIndex +CREATE INDEX "Episode_airDate_idx" ON "Episode"("airDate"); + +-- CreateIndex +CREATE UNIQUE INDEX "Episode_titleId_seasonNumber_episodeNumber_key" ON "Episode"("titleId", "seasonNumber", "episodeNumber"); + +-- CreateIndex +CREATE UNIQUE INDEX "WatchProvider_tmdbId_key" ON "WatchProvider"("tmdbId"); + +-- CreateIndex +CREATE INDEX "TitleProvider_titleId_region_idx" ON "TitleProvider"("titleId", "region"); + +-- CreateIndex +CREATE UNIQUE INDEX "TitleProvider_titleId_providerId_region_kind_key" ON "TitleProvider"("titleId", "providerId", "region", "kind"); + +-- CreateIndex +CREATE INDEX "LibraryEntry_userId_status_idx" ON "LibraryEntry"("userId", "status"); + +-- CreateIndex +CREATE INDEX "LibraryEntry_userId_updatedAt_idx" ON "LibraryEntry"("userId", "updatedAt"); + +-- CreateIndex +CREATE INDEX "LibraryEntry_deletedAt_idx" ON "LibraryEntry"("deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "LibraryEntry_userId_titleId_key" ON "LibraryEntry"("userId", "titleId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Tag_userId_name_key" ON "Tag"("userId", "name"); + +-- CreateIndex +CREATE INDEX "TagOnEntry_entryId_idx" ON "TagOnEntry"("entryId"); + +-- CreateIndex +CREATE INDEX "Watch_userId_watchedAt_idx" ON "Watch"("userId", "watchedAt"); + +-- CreateIndex +CREATE INDEX "Watch_userId_titleId_idx" ON "Watch"("userId", "titleId"); + +-- CreateIndex +CREATE INDEX "Watch_entryId_idx" ON "Watch"("entryId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Watch_userId_episodeId_run_key" ON "Watch"("userId", "episodeId", "run"); + +-- CreateIndex +CREATE INDEX "Universe_ownerId_idx" ON "Universe"("ownerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Universe_ownerId_slug_key" ON "Universe"("ownerId", "slug"); + +-- CreateIndex +CREATE INDEX "UniverseMember_userId_idx" ON "UniverseMember"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UniverseMember_universeId_userId_key" ON "UniverseMember"("universeId", "userId"); + +-- CreateIndex +CREATE INDEX "UniverseItem_universeId_idx" ON "UniverseItem"("universeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UniverseItem_universeId_titleId_key" ON "UniverseItem"("universeId", "titleId"); + +-- CreateIndex +CREATE INDEX "UniverseOrder_universeId_idx" ON "UniverseOrder"("universeId"); + +-- CreateIndex +CREATE INDEX "UniverseOrderItem_orderId_position_idx" ON "UniverseOrderItem"("orderId", "position"); + +-- CreateIndex +CREATE UNIQUE INDEX "UniverseOrderItem_orderId_itemId_key" ON "UniverseOrderItem"("orderId", "itemId"); + +-- CreateIndex +CREATE INDEX "NotificationChannel_userId_idx" ON "NotificationChannel"("userId"); + +-- CreateIndex +CREATE INDEX "NotificationLog_userId_sentAt_idx" ON "NotificationLog"("userId", "sentAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "NotificationLog_userId_dedupeKey_key" ON "NotificationLog"("userId", "dedupeKey"); + +-- CreateIndex +CREATE INDEX "SyncRun_kind_startedAt_idx" ON "SyncRun"("kind", "startedAt"); + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OidcAccount" ADD CONSTRAINT "OidcAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Season" ADD CONSTRAINT "Season_titleId_fkey" FOREIGN KEY ("titleId") REFERENCES "Title"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Episode" ADD CONSTRAINT "Episode_titleId_fkey" FOREIGN KEY ("titleId") REFERENCES "Title"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Episode" ADD CONSTRAINT "Episode_seasonId_fkey" FOREIGN KEY ("seasonId") REFERENCES "Season"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TitleProvider" ADD CONSTRAINT "TitleProvider_titleId_fkey" FOREIGN KEY ("titleId") REFERENCES "Title"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TitleProvider" ADD CONSTRAINT "TitleProvider_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "WatchProvider"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LibraryEntry" ADD CONSTRAINT "LibraryEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LibraryEntry" ADD CONSTRAINT "LibraryEntry_titleId_fkey" FOREIGN KEY ("titleId") REFERENCES "Title"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Tag" ADD CONSTRAINT "Tag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TagOnEntry" ADD CONSTRAINT "TagOnEntry_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TagOnEntry" ADD CONSTRAINT "TagOnEntry_entryId_fkey" FOREIGN KEY ("entryId") REFERENCES "LibraryEntry"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Watch" ADD CONSTRAINT "Watch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Watch" ADD CONSTRAINT "Watch_entryId_fkey" FOREIGN KEY ("entryId") REFERENCES "LibraryEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Watch" ADD CONSTRAINT "Watch_titleId_fkey" FOREIGN KEY ("titleId") REFERENCES "Title"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Watch" ADD CONSTRAINT "Watch_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "Episode"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Universe" ADD CONSTRAINT "Universe_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UniverseMember" ADD CONSTRAINT "UniverseMember_universeId_fkey" FOREIGN KEY ("universeId") REFERENCES "Universe"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UniverseMember" ADD CONSTRAINT "UniverseMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UniverseItem" ADD CONSTRAINT "UniverseItem_universeId_fkey" FOREIGN KEY ("universeId") REFERENCES "Universe"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UniverseItem" ADD CONSTRAINT "UniverseItem_titleId_fkey" FOREIGN KEY ("titleId") REFERENCES "Title"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UniverseOrder" ADD CONSTRAINT "UniverseOrder_universeId_fkey" FOREIGN KEY ("universeId") REFERENCES "Universe"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UniverseOrderItem" ADD CONSTRAINT "UniverseOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "UniverseOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UniverseOrderItem" ADD CONSTRAINT "UniverseOrderItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "UniverseItem"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NotificationChannel" ADD CONSTRAINT "NotificationChannel_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..e6098e7 --- /dev/null +++ b/prisma/schema.prisma @@ -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/ + 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]) +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..ee14cb2 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,26 @@ +import "server-only"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "@/generated/prisma/client"; +import { env } from "./env"; + +/** + * Prisma-Client als Singleton. + * + * Im Entwicklungsmodus lädt Next Module bei jedem Hot-Reload neu; ohne das + * globale Zwischenspeichern entstünde pro Reload ein neuer Verbindungspool. + */ +const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; + +function createClient() { + const adapter = new PrismaPg({ connectionString: env.databaseUrl }); + return new PrismaClient({ + adapter, + log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"], + }); +} + +export const prisma = globalForPrisma.prisma ?? createClient(); + +if (process.env.NODE_ENV !== "production") { + globalForPrisma.prisma = prisma; +} diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..6232633 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,91 @@ +import "server-only"; + +/** + * Zentraler, geprüfter Zugriff auf die Umgebungsvariablen. + * + * Absicht: Tippfehler und fehlende Pflichtwerte fallen beim Start auf, nicht + * erst irgendwo tief in einem Request. Optionale Bausteine (TMDB, SMTP, OIDC) + * melden sich stattdessen über `isConfigured`-Flags ab. + */ + +function required(name: string, fallback?: string): string { + const value = process.env[name] ?? fallback; + if (!value) { + throw new Error( + `Umgebungsvariable ${name} fehlt. Bitte in der .env setzen — siehe .env.example.`, + ); + } + return value; +} + +function optional(name: string, fallback = ""): string { + return process.env[name]?.trim() ?? fallback; +} + +function bool(name: string, fallback: boolean): boolean { + const raw = process.env[name]?.trim().toLowerCase(); + if (raw === undefined || raw === "") return fallback; + return raw === "1" || raw === "true" || raw === "yes" || raw === "ja"; +} + +function int(name: string, fallback: number): number { + const raw = Number.parseInt(process.env[name] ?? "", 10); + return Number.isFinite(raw) ? raw : fallback; +} + +const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build"; + +export const env = { + appName: optional("APP_NAME", "Flimmer"), + appUrl: optional("APP_URL", "http://localhost:3000").replace(/\/+$/, ""), + databaseUrl: isBuildPhase + ? optional("DATABASE_URL", "postgresql://build:build@localhost:5432/build") + : required("DATABASE_URL"), + authSecret: isBuildPhase ? optional("AUTH_SECRET", "build-time-placeholder") : required("AUTH_SECRET"), + dataDir: optional("DATA_DIR", "./data"), + timezone: optional("TZ", "Europe/Berlin"), + + allowRegistrationDefault: bool("ALLOW_REGISTRATION", true), + + tmdb: { + apiKey: optional("TMDB_API_KEY"), + accessToken: optional("TMDB_ACCESS_TOKEN"), + language: optional("TMDB_LANGUAGE", "de-DE"), + region: optional("TMDB_REGION", "DE"), + get isConfigured() { + return Boolean(this.apiKey || this.accessToken); + }, + }, + + scheduler: { + enabled: bool("ENABLE_SCHEDULER", false), + hour: Math.min(23, Math.max(0, int("SYNC_HOUR", 4))), + cronSecret: optional("CRON_SECRET"), + }, + + smtp: { + host: optional("SMTP_HOST"), + port: int("SMTP_PORT", 587), + user: optional("SMTP_USER"), + password: optional("SMTP_PASSWORD"), + secure: bool("SMTP_SECURE", false), + from: optional("SMTP_FROM", "Flimmer "), + get isConfigured() { + return Boolean(this.host); + }, + }, + + oidc: { + issuer: optional("OIDC_ISSUER"), + clientId: optional("OIDC_CLIENT_ID"), + clientSecret: optional("OIDC_CLIENT_SECRET"), + scopes: optional("OIDC_SCOPES", "openid profile email"), + buttonLabel: optional("OIDC_BUTTON_LABEL", "Mit SSO anmelden"), + autoRegister: bool("OIDC_AUTO_REGISTER", true), + get isConfigured() { + return Boolean(this.issuer && this.clientId && this.clientSecret); + }, + }, +} as const; + +export const isProduction = process.env.NODE_ENV === "production";