feat(datenbank): Datenmodell für Anmeldung, Bereiche und Audit

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) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 12:41:29 +02:00
co-authored by Claude Opus 5
parent de1f923767
commit 748d032f70
5 changed files with 396 additions and 0 deletions
+13
View File
@@ -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'),
},
})
@@ -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;
+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"
+170
View File
@@ -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")
}
+31
View File
@@ -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
}