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:
co-authored by
Claude Opus 5
parent
de1f923767
commit
748d032f70
@@ -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;
|
||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user