From 748d032f70e63b7b1a141fa514b24a75e91e0a5d Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 12:39:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(datenbank):=20Datenmodell=20f=C3=BCr=20Anm?= =?UTF-8?q?eldung,=20Bereiche=20und=20Audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benutzer mit Rolle, OIDC-Konten, Sitzungen, Passkeys samt kurzlebiger WebAuthn-Herausforderung, Bereiche mit Tageskapazität und Arbeitsfenster sowie ein unveränderlicher Audit-Eintrag je schreibender Aktion. Aufgabe und Zeitblock kommen mit M2 und M4, Kalenderkonto mit M3 — dann mit den Feldern, die der jeweilige Provider wirklich braucht. Prisma 7 nimmt die Verbindung nicht mehr aus dem Schema entgegen: die URL steht in prisma.config.ts, der Client bekommt den pg-Treiberadapter. Co-Authored-By: Claude Opus 5 (1M context) --- prisma.config.ts | 13 ++ .../20260910103056_fundament/migration.sql | 179 ++++++++++++++++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 170 +++++++++++++++++ src/server/db.ts | 31 +++ 5 files changed, 396 insertions(+) create mode 100644 prisma.config.ts create mode 100644 prisma/migrations/20260910103056_fundament/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 src/server/db.ts diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..2774414 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,13 @@ +import 'dotenv/config' +import { defineConfig, env } from 'prisma/config' + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + seed: 'tsx prisma/seed.ts', + }, + datasource: { + url: env('DATABASE_URL'), + }, +}) diff --git a/prisma/migrations/20260910103056_fundament/migration.sql b/prisma/migrations/20260910103056_fundament/migration.sql new file mode 100644 index 0000000..59096f8 --- /dev/null +++ b/prisma/migrations/20260910103056_fundament/migration.sql @@ -0,0 +1,179 @@ +-- CreateEnum +CREATE TYPE "Rolle" AS ENUM ('ADMIN', 'BENUTZER'); + +-- CreateTable +CREATE TABLE "benutzer" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + "passwortHash" TEXT, + "rolle" "Rolle" NOT NULL DEFAULT 'BENUTZER', + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "bildUrl" TEXT, + "emailGeprueft" TIMESTAMP(3), + "zuletztAktiv" TIMESTAMP(3), + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "benutzer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "konto" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + + CONSTRAINT "konto_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sitzung" ( + "id" TEXT NOT NULL, + "sessionToken" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sitzung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verifizierungs_token" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL +); + +-- CreateTable +CREATE TABLE "passkey" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "credentialId" TEXT NOT NULL, + "publicKey" BYTEA NOT NULL, + "counter" BIGINT NOT NULL DEFAULT 0, + "transports" TEXT, + "geraetetyp" TEXT, + "gesichert" BOOLEAN NOT NULL DEFAULT false, + "bezeichnung" TEXT NOT NULL, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "zuletztBenutzt" TIMESTAMP(3), + + CONSTRAINT "passkey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "webauthn_challenge" ( + "id" TEXT NOT NULL, + "kennung" TEXT NOT NULL, + "challenge" TEXT NOT NULL, + "zweck" TEXT NOT NULL, + "gueltigBis" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "webauthn_challenge_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "bereich" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "farbe" TEXT NOT NULL, + "reihenfolge" INTEGER NOT NULL DEFAULT 0, + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "kapazitaetMo" INTEGER NOT NULL DEFAULT 0, + "kapazitaetDi" INTEGER NOT NULL DEFAULT 0, + "kapazitaetMi" INTEGER NOT NULL DEFAULT 0, + "kapazitaetDo" INTEGER NOT NULL DEFAULT 0, + "kapazitaetFr" INTEGER NOT NULL DEFAULT 0, + "kapazitaetSa" INTEGER NOT NULL DEFAULT 0, + "kapazitaetSo" INTEGER NOT NULL DEFAULT 0, + "arbeitsfensterVon" INTEGER NOT NULL DEFAULT 480, + "arbeitsfensterBis" INTEGER NOT NULL DEFAULT 1020, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "bereich_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_eintrag" ( + "id" TEXT NOT NULL, + "zeitpunkt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "benutzerId" TEXT, + "benutzerEmail" TEXT NOT NULL, + "aktion" TEXT NOT NULL, + "objektTyp" TEXT NOT NULL, + "objektId" TEXT, + "details" JSONB, + "instanz" TEXT NOT NULL, + + CONSTRAINT "audit_eintrag_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "benutzer_email_key" ON "benutzer"("email"); + +-- CreateIndex +CREATE INDEX "benutzer_aktiv_idx" ON "benutzer"("aktiv"); + +-- CreateIndex +CREATE INDEX "konto_benutzerId_idx" ON "konto"("benutzerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "konto_provider_providerAccountId_key" ON "konto"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "sitzung_sessionToken_key" ON "sitzung"("sessionToken"); + +-- CreateIndex +CREATE INDEX "sitzung_benutzerId_idx" ON "sitzung"("benutzerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "verifizierungs_token_token_key" ON "verifizierungs_token"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "verifizierungs_token_identifier_token_key" ON "verifizierungs_token"("identifier", "token"); + +-- CreateIndex +CREATE UNIQUE INDEX "passkey_credentialId_key" ON "passkey"("credentialId"); + +-- CreateIndex +CREATE INDEX "passkey_benutzerId_idx" ON "passkey"("benutzerId"); + +-- CreateIndex +CREATE INDEX "webauthn_challenge_kennung_zweck_idx" ON "webauthn_challenge"("kennung", "zweck"); + +-- CreateIndex +CREATE UNIQUE INDEX "bereich_name_key" ON "bereich"("name"); + +-- CreateIndex +CREATE INDEX "bereich_aktiv_reihenfolge_idx" ON "bereich"("aktiv", "reihenfolge"); + +-- CreateIndex +CREATE INDEX "audit_eintrag_zeitpunkt_idx" ON "audit_eintrag"("zeitpunkt"); + +-- CreateIndex +CREATE INDEX "audit_eintrag_objektTyp_objektId_idx" ON "audit_eintrag"("objektTyp", "objektId"); + +-- CreateIndex +CREATE INDEX "audit_eintrag_benutzerId_idx" ON "audit_eintrag"("benutzerId"); + +-- AddForeignKey +ALTER TABLE "konto" ADD CONSTRAINT "konto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sitzung" ADD CONSTRAINT "sitzung_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "passkey" ADD CONSTRAINT "passkey_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "audit_eintrag" ADD CONSTRAINT "audit_eintrag_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE SET NULL 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..313b933 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,170 @@ +// Datenmodell des Planers. +// Fachbegriffe auf Deutsch, technische Felder auf Englisch. + +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" + moduleFormat = "esm" +} + +datasource db { + provider = "postgresql" +} + +enum Rolle { + ADMIN + BENUTZER +} + +/// Ein Mensch, der sich anmelden darf. +model Benutzer { + id String @id @default(cuid()) + email String @unique + name String? + /// Nur gesetzt, wenn die Anmeldung per Passwort erlaubt ist. + passwortHash String? + rolle Rolle @default(BENUTZER) + aktiv Boolean @default(true) + bildUrl String? + emailGeprueft DateTime? + zuletztAktiv DateTime? + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + konten Konto[] + sitzungen Sitzung[] + passkeys Passkey[] + audit AuditEintrag[] + + @@index([aktiv]) + @@map("benutzer") +} + +/// Verknuepfung zu einem OIDC-Anbieter (Entra ID, authentik). +model Konto { + id String @id @default(cuid()) + benutzerId String + type String + provider String + providerAccountId String + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + session_state String? + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@index([benutzerId]) + @@map("konto") +} + +model Sitzung { + id String @id @default(cuid()) + sessionToken String @unique + benutzerId String + expires DateTime + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + + @@index([benutzerId]) + @@map("sitzung") +} + +model VerifizierungsToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) + @@map("verifizierungs_token") +} + +/// Ein registrierter Passkey (WebAuthn) eines Benutzers. +model Passkey { + id String @id @default(cuid()) + benutzerId String + /// Base64URL der Credential-ID. + credentialId String @unique + publicKey Bytes + counter BigInt @default(0) + /// Kommagetrennt, z. B. "internal,hybrid". + transports String? + geraetetyp String? + gesichert Boolean @default(false) + bezeichnung String + erstelltAm DateTime @default(now()) + zuletztBenutzt DateTime? + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + + @@index([benutzerId]) + @@map("passkey") +} + +/// Laufende WebAuthn-Herausforderung; kurzlebig, wird nach Gebrauch geloescht. +model WebauthnChallenge { + id String @id @default(cuid()) + /// E-Mail bei der Anmeldung, Benutzer-ID bei der Registrierung. + kennung String + challenge String + zweck String + gueltigBis DateTime + + @@index([kennung, zweck]) + @@map("webauthn_challenge") +} + +/// Lebensbereich mit Farbe, Tageskapazitaet und Arbeitsfenster. +model Bereich { + id String @id @default(cuid()) + name String @unique + /// Hex-Farbe inklusive Raute, z. B. "#2563eb". + farbe String + reihenfolge Int @default(0) + aktiv Boolean @default(true) + + /// Tageskapazitaet in Minuten, Montag bis Sonntag. + kapazitaetMo Int @default(0) + kapazitaetDi Int @default(0) + kapazitaetMi Int @default(0) + kapazitaetDo Int @default(0) + kapazitaetFr Int @default(0) + kapazitaetSa Int @default(0) + kapazitaetSo Int @default(0) + + /// Arbeitsfenster als Minuten seit Mitternacht, Europe/Berlin. + arbeitsfensterVon Int @default(480) + arbeitsfensterBis Int @default(1020) + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + @@index([aktiv, reihenfolge]) + @@map("bereich") +} + +/// Unveraenderlicher Nachweis jeder schreibenden Aktion. +model AuditEintrag { + id String @id @default(cuid()) + zeitpunkt DateTime @default(now()) + benutzerId String? + /// Bleibt erhalten, auch wenn der Benutzer spaeter geloescht wird. + benutzerEmail String + aktion String + objektTyp String + objektId String? + /// Nur fachliche Felder, niemals Tokens, Mailinhalte oder Termin-Betreffs. + details Json? + instanz String + + benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull) + + @@index([zeitpunkt]) + @@index([objektTyp, objektId]) + @@index([benutzerId]) + @@map("audit_eintrag") +} diff --git a/src/server/db.ts b/src/server/db.ts new file mode 100644 index 0000000..b5170ec --- /dev/null +++ b/src/server/db.ts @@ -0,0 +1,31 @@ +import 'server-only' +import { PrismaPg } from '@prisma/adapter-pg' +import { PrismaClient } from '@/generated/prisma/client' + +/** + * Ein einziger Prisma-Client je Prozess. Im Entwicklungsbetrieb ueberlebt er + * das Neuladen der Module, damit nicht bei jedem Speichern ein neuer + * Verbindungspool entsteht. + */ + +const globalerBereich = globalThis as unknown as { + prisma: PrismaClient | undefined +} + +function erzeugen(): PrismaClient { + const verbindung = process.env.DATABASE_URL + if (!verbindung) { + throw new Error('DATABASE_URL ist nicht gesetzt.') + } + const adapter = new PrismaPg({ connectionString: verbindung }) + return new PrismaClient({ + adapter, + log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], + }) +} + +export const db: PrismaClient = globalerBereich.prisma ?? erzeugen() + +if (process.env.NODE_ENV !== 'production') { + globalerBereich.prisma = db +}