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
+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";