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
+78
View File
@@ -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 <flimmer@example.com>
# --- OIDC / SSO (optional) ---------------------------------------------------
# Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert.
# Redirect-URI beim Anbieter eintragen: <APP_URL>/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
+1
View File
@@ -41,3 +41,4 @@ Thumbs.db
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
.playwright/ .playwright/
src/generated/
+23
View File
@@ -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:
+197 -3
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"bcryptjs": "3.0.3", "bcryptjs": "3.0.3",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -16,8 +17,10 @@
"jose": "6.2.10", "jose": "6.2.10",
"next": "16.3.4", "next": "16.3.4",
"nodemailer": "9.1.0", "nodemailer": "9.1.0",
"pg": "^8.23.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"server-only": "^0.0.1",
"zod": "4.5.4" "zod": "4.5.4"
}, },
"devDependencies": { "devDependencies": {
@@ -25,6 +28,7 @@
"@types/bcryptjs": "3.0.0", "@types/bcryptjs": "3.0.0",
"@types/node": "24.10.1", "@types/node": "24.10.1",
"@types/nodemailer": "8.0.1", "@types/nodemailer": "8.0.1",
"@types/pg": "^8.23.1",
"@types/react": "19.2.18", "@types/react": "19.2.18",
"@types/react-dom": "19.2.5", "@types/react-dom": "19.2.5",
"playwright": "1.62.1", "playwright": "1.62.1",
@@ -1278,6 +1282,18 @@
"node": ">= 10" "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": { "node_modules/@prisma/client": {
"version": "7.10.0", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz",
@@ -1325,7 +1341,6 @@
"version": "7.10.0", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz",
"integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==", "integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==",
"devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/dev": { "node_modules/@prisma/dev": {
@@ -1352,6 +1367,15 @@
"zeptomatch": "2.1.0" "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": { "node_modules/@prisma/engines": {
"version": "7.10.0", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz",
@@ -2039,7 +2063,6 @@
"version": "24.10.1", "version": "24.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
@@ -2055,6 +2078,17 @@
"@types/node": "*" "@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": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -3748,6 +3782,104 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3841,6 +3973,45 @@
"url": "https://github.com/sponsors/porsager" "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": { "node_modules/prisma": {
"version": "7.10.0", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.0.tgz",
@@ -4059,6 +4230,12 @@
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==",
"devOptional": true "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": { "node_modules/sharp": {
"version": "0.35.4", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
@@ -4154,6 +4331,15 @@
"node": ">=0.10.0" "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": { "node_modules/sqlstring": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@@ -4294,7 +4480,6 @@
"version": "7.16.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/valibot": { "node_modules/valibot": {
@@ -4328,6 +4513,15 @@
"node": ">= 8" "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": { "node_modules/zeptomatch": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
+4
View File
@@ -18,6 +18,7 @@
"screenshots": "tsx scripts/screenshots.ts" "screenshots": "tsx scripts/screenshots.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"bcryptjs": "3.0.3", "bcryptjs": "3.0.3",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -25,8 +26,10 @@
"jose": "6.2.10", "jose": "6.2.10",
"next": "16.3.4", "next": "16.3.4",
"nodemailer": "9.1.0", "nodemailer": "9.1.0",
"pg": "^8.23.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"server-only": "^0.0.1",
"zod": "4.5.4" "zod": "4.5.4"
}, },
"devDependencies": { "devDependencies": {
@@ -34,6 +37,7 @@
"@types/bcryptjs": "3.0.0", "@types/bcryptjs": "3.0.0",
"@types/node": "24.10.1", "@types/node": "24.10.1",
"@types/nodemailer": "8.0.1", "@types/nodemailer": "8.0.1",
"@types/pg": "^8.23.1",
"@types/react": "19.2.18", "@types/react": "19.2.18",
"@types/react-dom": "19.2.5", "@types/react-dom": "19.2.5",
"playwright": "1.62.1", "playwright": "1.62.1",
+26
View File
@@ -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",
},
});
@@ -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;
+3
View File
@@ -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"
+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])
}
+26
View File
@@ -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;
}
+91
View File
@@ -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 <flimmer@localhost>"),
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";