feat(datenmodell): Schema, tagCodes und Geheimnisse (#20)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Vollständiges Datenmodell in deutscher Benennung: 20 Tabellen, 9 Aufzählungen,
eine Migration, dazu die framework-freien Helfer für tagCodes und Geheimnisse.

Regeln stehen zusätzlich in der Datenbank, nicht nur im Service: 12 CHECKs,
2 partielle Eindeutigkeitsindizes, 4 zusammengesetzte Fremdschlüssel. Die
Mandantengrenze ist damit strukturell erzwungen. Schrittergebnisse, Kommentare
und Protokolleinträge kaskadieren bewusst nicht — das Archiv ist das Gedächtnis
der Anwendung.

106 Tests, davon 42 gegen die echten Datenbankregeln.

Closes #6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #20.
This commit is contained in:
2026-09-24 23:11:58 +00:00
co-authored by Claude Opus 5
parent 49ce06f206
commit 7a0f875f55
19 changed files with 2820 additions and 40 deletions
@@ -0,0 +1,734 @@
-- CreateEnum
CREATE TYPE "Rolle" AS ENUM ('ADMIN', 'REDAKTEUR', 'TECHNIKER', 'LESER');
-- CreateEnum
CREATE TYPE "Betriebsmodus" AS ENUM ('INTERN', 'DIENSTLEISTUNG');
-- CreateEnum
CREATE TYPE "LaufStatus" AS ENUM ('OFFEN', 'IN_ARBEIT', 'ABGESCHLOSSEN', 'ARCHIVIERT');
-- CreateEnum
CREATE TYPE "SchrittZustand" AS ENUM ('OFFEN', 'ERLEDIGT', 'NICHT_ZUTREFFEND', 'FEHLGESCHLAGEN');
-- CreateEnum
CREATE TYPE "SchrittTyp" AS ENUM ('HAKEN', 'TEXT', 'ZAHL', 'AUSWAHL', 'DATUM', 'BEFEHL');
-- CreateEnum
CREATE TYPE "VariablenTyp" AS ENUM ('TEXT', 'ZAHL', 'AUSWAHL', 'DATUM');
-- CreateEnum
CREATE TYPE "GeraeteTyp" AS ENUM ('PC', 'NOTEBOOK', 'SERVER', 'FIREWALL', 'SWITCH', 'DRUCKER', 'RAUM', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "VersionStatus" AS ENUM ('ENTWURF', 'VEROEFFENTLICHT');
-- CreateEnum
CREATE TYPE "Herkunft" AS ENUM ('ABGLEICH', 'MANUELL');
-- CreateTable
CREATE TABLE "Einstellung" (
"id" TEXT NOT NULL DEFAULT 'global',
"modus" "Betriebsmodus" NOT NULL DEFAULT 'INTERN',
"namensregeln" JSONB NOT NULL DEFAULT '{}',
"gesperrteOu" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Einstellung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Kunde" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kuerzel" TEXT NOT NULL,
"istEigeneOrganisation" BOOLEAN NOT NULL DEFAULT false,
"namensregeln" JSONB NOT NULL DEFAULT '{}',
"notizen" TEXT,
"archiviert" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Kunde_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Benutzer" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"rolle" "Rolle" NOT NULL DEFAULT 'LESER',
"oidcSub" TEXT,
"passwortHash" TEXT,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"sitzungenUngueltigAb" TIMESTAMP(3),
"letzteAnmeldungAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Benutzer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BenutzerKunde" (
"benutzerId" TEXT NOT NULL,
"kundeId" TEXT NOT NULL,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BenutzerKunde_pkey" PRIMARY KEY ("benutzerId","kundeId")
);
-- CreateTable
CREATE TABLE "Verzeichnisquelle" (
"id" TEXT NOT NULL,
"kundeId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"aktiv" BOOLEAN NOT NULL DEFAULT false,
"url" TEXT NOT NULL,
"bindDn" TEXT NOT NULL,
"bindPasswortVerschluesselt" TEXT NOT NULL,
"basisDn" TEXT NOT NULL,
"filter" TEXT NOT NULL,
"attributZuordnung" JSONB NOT NULL,
"caZertifikatPem" TEXT,
"abgleichIntervallMinuten" INTEGER NOT NULL DEFAULT 60,
"letzterAbgleichAm" TIMESTAMP(3),
"letzterAbgleichStatus" TEXT,
"letzterAbgleichFehler" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Verzeichnisquelle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Mitarbeiter" (
"id" TEXT NOT NULL,
"kundeId" TEXT NOT NULL,
"quelleId" TEXT,
"externeId" TEXT,
"vorname" TEXT NOT NULL,
"nachname" TEXT NOT NULL,
"anzeigename" TEXT NOT NULL,
"mail" TEXT,
"upn" TEXT,
"sam" TEXT,
"dn" TEXT,
"abteilung" TEXT,
"titel" TEXT,
"vorgesetzterExterneId" TEXT,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"herkunft" "Herkunft" NOT NULL DEFAULT 'MANUELL',
"zuletztGesehenAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Mitarbeiter_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Abteilung" (
"id" TEXT NOT NULL,
"kundeId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"zielOu" TEXT,
"standardGruppen" TEXT[] DEFAULT ARRAY[]::TEXT[],
"mailDomain" TEXT,
"notizen" TEXT,
"archiviert" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Abteilung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Geraet" (
"id" TEXT NOT NULL,
"kundeId" TEXT NOT NULL,
"mitarbeiterId" TEXT,
"tagCode" TEXT NOT NULL,
"hostname" TEXT,
"bezeichnung" TEXT,
"seriennummer" TEXT,
"inventarnummer" TEXT,
"typ" "GeraeteTyp" NOT NULL DEFAULT 'SONSTIGES',
"modell" TEXT,
"notizen" TEXT,
"archiviert" BOOLEAN NOT NULL DEFAULT false,
"registriertAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Geraet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TagCodeVergabe" (
"tagCode" TEXT NOT NULL,
"vergebenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TagCodeVergabe_pkey" PRIMARY KEY ("tagCode")
);
-- CreateTable
CREATE TABLE "GeraetetypVorlage" (
"id" TEXT NOT NULL,
"geraeteTyp" "GeraeteTyp" NOT NULL,
"vorlageId" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "GeraetetypVorlage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Vorlage" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"beschreibung" TEXT,
"kategorie" TEXT,
"schlagworte" TEXT[] DEFAULT ARRAY[]::TEXT[],
"kundeId" TEXT,
"archiviert" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Vorlage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VorlagenVersion" (
"id" TEXT NOT NULL,
"vorlageId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"status" "VersionStatus" NOT NULL DEFAULT 'ENTWURF',
"aenderungsnotiz" TEXT,
"veroeffentlichtAm" TIMESTAMP(3),
"veroeffentlichtVonId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VorlagenVersion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VorlagenVariable" (
"id" TEXT NOT NULL,
"versionId" TEXT NOT NULL,
"schluessel" TEXT NOT NULL,
"beschriftung" TEXT NOT NULL,
"typ" "VariablenTyp" NOT NULL DEFAULT 'TEXT',
"optionen" TEXT[] DEFAULT ARRAY[]::TEXT[],
"pflicht" BOOLEAN NOT NULL DEFAULT false,
"position" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "VorlagenVariable_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VorlagenAbschnitt" (
"id" TEXT NOT NULL,
"versionId" TEXT NOT NULL,
"titel" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "VorlagenAbschnitt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VorlagenSchritt" (
"id" TEXT NOT NULL,
"abschnittId" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"titel" TEXT NOT NULL,
"hilfetext" TEXT,
"typ" "SchrittTyp" NOT NULL DEFAULT 'HAKEN',
"pflicht" BOOLEAN NOT NULL DEFAULT false,
"konfig" JSONB NOT NULL DEFAULT '{}',
"dokuUrl" TEXT,
CONSTRAINT "VorlagenSchritt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Lauf" (
"id" TEXT NOT NULL,
"vorlagenVersionId" TEXT NOT NULL,
"titel" TEXT NOT NULL,
"kundeId" TEXT NOT NULL,
"mitarbeiterId" TEXT,
"mitarbeiterName" TEXT,
"geraetId" TEXT,
"abteilungId" TEXT,
"ticket" TEXT,
"variablen" JSONB NOT NULL DEFAULT '{}',
"status" "LaufStatus" NOT NULL DEFAULT 'OFFEN',
"zugewiesenAnId" TEXT,
"faelligAm" TIMESTAMP(3),
"erstelltVonId" TEXT,
"gestartetAm" TIMESTAMP(3),
"abgeschlossenAm" TIMESTAMP(3),
"abgeschlossenVonId" TEXT,
"archiviertAm" TIMESTAMP(3),
"archiviertVonId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Lauf_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Schrittergebnis" (
"id" TEXT NOT NULL,
"laufId" TEXT NOT NULL,
"vorlagenSchrittId" TEXT NOT NULL,
"zustand" "SchrittZustand" NOT NULL DEFAULT 'OFFEN',
"wert" JSONB,
"notiz" TEXT,
"aktualisiertVonId" TEXT,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Schrittergebnis_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Kommentar" (
"id" TEXT NOT NULL,
"laufId" TEXT NOT NULL,
"schrittergebnisId" TEXT,
"benutzerId" TEXT NOT NULL,
"rumpf" TEXT NOT NULL,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Kommentar_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Protokoll" (
"id" TEXT NOT NULL,
"laufId" TEXT,
"entitaet" TEXT NOT NULL,
"entitaetId" TEXT NOT NULL,
"aktion" TEXT NOT NULL,
"benutzerId" TEXT,
"vorher" JSONB,
"nachher" JSONB,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Protokoll_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AbgleichSperre" (
"schluessel" TEXT NOT NULL,
"gehaltenVon" TEXT NOT NULL,
"gehaltenSeit" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"laeuftAbAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AbgleichSperre_pkey" PRIMARY KEY ("schluessel")
);
-- CreateIndex
CREATE UNIQUE INDEX "Kunde_kuerzel_key" ON "Kunde"("kuerzel");
-- CreateIndex
CREATE INDEX "Kunde_archiviert_idx" ON "Kunde"("archiviert");
-- CreateIndex
CREATE UNIQUE INDEX "Benutzer_email_key" ON "Benutzer"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Benutzer_oidcSub_key" ON "Benutzer"("oidcSub");
-- CreateIndex
CREATE INDEX "Benutzer_aktiv_idx" ON "Benutzer"("aktiv");
-- CreateIndex
CREATE INDEX "Benutzer_rolle_idx" ON "Benutzer"("rolle");
-- CreateIndex
CREATE INDEX "BenutzerKunde_kundeId_idx" ON "BenutzerKunde"("kundeId");
-- CreateIndex
CREATE INDEX "Verzeichnisquelle_kundeId_idx" ON "Verzeichnisquelle"("kundeId");
-- CreateIndex
CREATE INDEX "Verzeichnisquelle_aktiv_idx" ON "Verzeichnisquelle"("aktiv");
-- CreateIndex
CREATE INDEX "Mitarbeiter_kundeId_aktiv_idx" ON "Mitarbeiter"("kundeId", "aktiv");
-- CreateIndex
CREATE INDEX "Mitarbeiter_abteilung_idx" ON "Mitarbeiter"("abteilung");
-- CreateIndex
CREATE INDEX "Mitarbeiter_upn_idx" ON "Mitarbeiter"("upn");
-- CreateIndex
CREATE INDEX "Mitarbeiter_sam_idx" ON "Mitarbeiter"("sam");
-- CreateIndex
CREATE UNIQUE INDEX "Mitarbeiter_quelleId_externeId_key" ON "Mitarbeiter"("quelleId", "externeId");
-- CreateIndex
CREATE UNIQUE INDEX "Mitarbeiter_id_kundeId_key" ON "Mitarbeiter"("id", "kundeId");
-- CreateIndex
CREATE INDEX "Abteilung_kundeId_archiviert_idx" ON "Abteilung"("kundeId", "archiviert");
-- CreateIndex
CREATE UNIQUE INDEX "Abteilung_kundeId_name_key" ON "Abteilung"("kundeId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "Abteilung_id_kundeId_key" ON "Abteilung"("id", "kundeId");
-- CreateIndex
CREATE UNIQUE INDEX "Geraet_tagCode_key" ON "Geraet"("tagCode");
-- CreateIndex
CREATE INDEX "Geraet_kundeId_archiviert_idx" ON "Geraet"("kundeId", "archiviert");
-- CreateIndex
CREATE INDEX "Geraet_mitarbeiterId_idx" ON "Geraet"("mitarbeiterId");
-- CreateIndex
CREATE INDEX "Geraet_typ_idx" ON "Geraet"("typ");
-- CreateIndex
CREATE INDEX "Geraet_registriertAm_idx" ON "Geraet"("registriertAm");
-- CreateIndex
CREATE UNIQUE INDEX "Geraet_id_kundeId_key" ON "Geraet"("id", "kundeId");
-- CreateIndex
CREATE INDEX "GeraetetypVorlage_geraeteTyp_position_idx" ON "GeraetetypVorlage"("geraeteTyp", "position");
-- CreateIndex
CREATE UNIQUE INDEX "GeraetetypVorlage_geraeteTyp_vorlageId_key" ON "GeraetetypVorlage"("geraeteTyp", "vorlageId");
-- CreateIndex
CREATE INDEX "Vorlage_archiviert_idx" ON "Vorlage"("archiviert");
-- CreateIndex
CREATE INDEX "Vorlage_kategorie_idx" ON "Vorlage"("kategorie");
-- CreateIndex
CREATE INDEX "Vorlage_kundeId_idx" ON "Vorlage"("kundeId");
-- CreateIndex
CREATE INDEX "VorlagenVersion_status_idx" ON "VorlagenVersion"("status");
-- CreateIndex
CREATE UNIQUE INDEX "VorlagenVersion_vorlageId_version_key" ON "VorlagenVersion"("vorlageId", "version");
-- CreateIndex
CREATE INDEX "VorlagenVariable_versionId_position_idx" ON "VorlagenVariable"("versionId", "position");
-- CreateIndex
CREATE UNIQUE INDEX "VorlagenVariable_versionId_schluessel_key" ON "VorlagenVariable"("versionId", "schluessel");
-- CreateIndex
CREATE INDEX "VorlagenAbschnitt_versionId_position_idx" ON "VorlagenAbschnitt"("versionId", "position");
-- CreateIndex
CREATE INDEX "VorlagenSchritt_abschnittId_position_idx" ON "VorlagenSchritt"("abschnittId", "position");
-- CreateIndex
CREATE INDEX "Lauf_kundeId_status_idx" ON "Lauf"("kundeId", "status");
-- CreateIndex
CREATE INDEX "Lauf_geraetId_status_idx" ON "Lauf"("geraetId", "status");
-- CreateIndex
CREATE INDEX "Lauf_mitarbeiterId_idx" ON "Lauf"("mitarbeiterId");
-- CreateIndex
CREATE INDEX "Lauf_zugewiesenAnId_status_idx" ON "Lauf"("zugewiesenAnId", "status");
-- CreateIndex
CREATE INDEX "Lauf_status_faelligAm_idx" ON "Lauf"("status", "faelligAm");
-- CreateIndex
CREATE INDEX "Lauf_vorlagenVersionId_idx" ON "Lauf"("vorlagenVersionId");
-- CreateIndex
CREATE INDEX "Lauf_abteilungId_idx" ON "Lauf"("abteilungId");
-- CreateIndex
CREATE INDEX "Schrittergebnis_laufId_zustand_idx" ON "Schrittergebnis"("laufId", "zustand");
-- CreateIndex
CREATE UNIQUE INDEX "Schrittergebnis_laufId_vorlagenSchrittId_key" ON "Schrittergebnis"("laufId", "vorlagenSchrittId");
-- CreateIndex
CREATE INDEX "Kommentar_laufId_erstelltAm_idx" ON "Kommentar"("laufId", "erstelltAm");
-- CreateIndex
CREATE INDEX "Kommentar_schrittergebnisId_idx" ON "Kommentar"("schrittergebnisId");
-- CreateIndex
CREATE INDEX "Protokoll_laufId_erstelltAm_idx" ON "Protokoll"("laufId", "erstelltAm");
-- CreateIndex
CREATE INDEX "Protokoll_entitaet_entitaetId_idx" ON "Protokoll"("entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "Protokoll_erstelltAm_idx" ON "Protokoll"("erstelltAm");
-- CreateIndex
CREATE INDEX "AbgleichSperre_laeuftAbAm_idx" ON "AbgleichSperre"("laeuftAbAm");
-- AddForeignKey
ALTER TABLE "BenutzerKunde" ADD CONSTRAINT "BenutzerKunde_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BenutzerKunde" ADD CONSTRAINT "BenutzerKunde_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Verzeichnisquelle" ADD CONSTRAINT "Verzeichnisquelle_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Mitarbeiter" ADD CONSTRAINT "Mitarbeiter_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Mitarbeiter" ADD CONSTRAINT "Mitarbeiter_quelleId_fkey" FOREIGN KEY ("quelleId") REFERENCES "Verzeichnisquelle"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Abteilung" ADD CONSTRAINT "Abteilung_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Geraet" ADD CONSTRAINT "Geraet_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Geraet" ADD CONSTRAINT "Geraet_mitarbeiterId_fkey" FOREIGN KEY ("mitarbeiterId") REFERENCES "Mitarbeiter"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GeraetetypVorlage" ADD CONSTRAINT "GeraetetypVorlage_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "Vorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Vorlage" ADD CONSTRAINT "Vorlage_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VorlagenVersion" ADD CONSTRAINT "VorlagenVersion_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "Vorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VorlagenVersion" ADD CONSTRAINT "VorlagenVersion_veroeffentlichtVonId_fkey" FOREIGN KEY ("veroeffentlichtVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VorlagenVariable" ADD CONSTRAINT "VorlagenVariable_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "VorlagenVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VorlagenAbschnitt" ADD CONSTRAINT "VorlagenAbschnitt_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "VorlagenVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VorlagenSchritt" ADD CONSTRAINT "VorlagenSchritt_abschnittId_fkey" FOREIGN KEY ("abschnittId") REFERENCES "VorlagenAbschnitt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_vorlagenVersionId_fkey" FOREIGN KEY ("vorlagenVersionId") REFERENCES "VorlagenVersion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_mitarbeiterId_fkey" FOREIGN KEY ("mitarbeiterId") REFERENCES "Mitarbeiter"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "Geraet"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_abteilungId_fkey" FOREIGN KEY ("abteilungId") REFERENCES "Abteilung"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_abgeschlossenVonId_fkey" FOREIGN KEY ("abgeschlossenVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_archiviertVonId_fkey" FOREIGN KEY ("archiviertVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Schrittergebnis" ADD CONSTRAINT "Schrittergebnis_laufId_fkey" FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Schrittergebnis" ADD CONSTRAINT "Schrittergebnis_vorlagenSchrittId_fkey" FOREIGN KEY ("vorlagenSchrittId") REFERENCES "VorlagenSchritt"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Schrittergebnis" ADD CONSTRAINT "Schrittergebnis_aktualisiertVonId_fkey" FOREIGN KEY ("aktualisiertVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Kommentar" ADD CONSTRAINT "Kommentar_laufId_fkey" FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Kommentar" ADD CONSTRAINT "Kommentar_schrittergebnisId_fkey" FOREIGN KEY ("schrittergebnisId") REFERENCES "Schrittergebnis"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Kommentar" ADD CONSTRAINT "Kommentar_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Protokoll" ADD CONSTRAINT "Protokoll_laufId_fkey" FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Protokoll" ADD CONSTRAINT "Protokoll_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- ---------------------------------------------------------------------------
-- Regeln, die sich im Prisma-Schema nicht ausdruecken lassen.
--
-- Sie gehoeren in die Datenbank und nicht nur in die Service-Schicht, damit sie
-- auch dann gelten, wenn jemand an ihr vorbei schreibt: per Migration, per psql,
-- aus einem Skript oder aus einem spaeteren Connector.
-- ---------------------------------------------------------------------------
-- ---- Archiv-Volltextsuche -------------------------------------------------
-- Gesucht wird mit ILIKE '%...%' ueber Titel, Notizen, Werte und Kommentare
-- (#015). Ohne Trigramm-Index ist jede dieser Spalten ein Full Scan.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX "Lauf_titel_trgm_idx" ON "Lauf" USING gin ("titel" gin_trgm_ops);
CREATE INDEX "Schrittergebnis_notiz_trgm_idx" ON "Schrittergebnis" USING gin ("notiz" gin_trgm_ops);
CREATE INDEX "Kommentar_rumpf_trgm_idx" ON "Kommentar" USING gin ("rumpf" gin_trgm_ops);
-- Die erfassten Werte liegen als Json vor; gesucht wird ueber ihre Textform.
CREATE INDEX "Schrittergebnis_wert_trgm_idx"
ON "Schrittergebnis" USING gin ((("wert")::text) gin_trgm_ops);
-- ---- Einmaligkeiten --------------------------------------------------------
-- Es gibt hoechstens eine eigene Organisation. Ein gewoehnlicher eindeutiger
-- Index wuerde auch die vielen false-Zeilen erfassen, deshalb partiell.
CREATE UNIQUE INDEX "Kunde_eigene_organisation_einmalig"
ON "Kunde" ("istEigeneOrganisation")
WHERE "istEigeneOrganisation";
-- Je Vorlage hoechstens ein Entwurf. Veroeffentlichte Versionen sind
-- unveraenderlich, ein zweiter Entwurf haette also keine eindeutige Grundlage.
CREATE UNIQUE INDEX "VorlagenVersion_ein_entwurf"
ON "VorlagenVersion" ("vorlageId")
WHERE "status" = 'ENTWURF';
-- ---- Formen ----------------------------------------------------------------
-- Das Kuerzel steht auf Etiketten und in der Hostname-Regel.
ALTER TABLE "Kunde"
ADD CONSTRAINT "Kunde_kuerzel_form" CHECK ("kuerzel" ~ '^[A-Z0-9]{2,6}$');
-- Der tagCode wird nie geaendert und nie wiederverwendet. Die Form wird schon
-- beim Erzeugen geprueft; hier steht sie als letzte Sicherung.
ALTER TABLE "Geraet"
ADD CONSTRAINT "Geraet_tagcode_form"
CHECK ("tagCode" ~ '^LZ-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$');
ALTER TABLE "TagCodeVergabe"
ADD CONSTRAINT "TagCodeVergabe_form"
CHECK ("tagCode" ~ '^LZ-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$');
-- Ein verschluesseltes Geheimnis traegt immer ein Versionspraefix (siehe
-- src/lib/geheimnisse.ts). Damit faellt ein versehentlich im Klartext
-- geschriebenes Bind-Passwort sofort auf.
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_bindpasswort_verschluesselt"
CHECK ("bindPasswortVerschluesselt" ~ '^v[0-9]+\.');
-- Einstellung ist eine einzelne Zeile. Ohne diese Bedingung koennte ein zweiter
-- Datensatz entstehen und die Anwendung haette zwei Betriebsmodi.
ALTER TABLE "Einstellung"
ADD CONSTRAINT "Einstellung_einzelne_zeile" CHECK ("id" = 'global');
-- ---- Fachliche Widerspruchsfreiheit ---------------------------------------
-- Ein Geraet ohne registriertAm ist ein vorgedruckter, noch nicht zugeordneter
-- Tag – der darf leer sein. Sobald es registriert ist, braucht es einen
-- Hostname oder eine Bezeichnung, sonst laesst es sich nicht benennen.
ALTER TABLE "Geraet"
ADD CONSTRAINT "Geraet_registriert_braucht_namen" CHECK (
"registriertAm" IS NULL
OR COALESCE(NULLIF(TRIM("hostname"), ''), NULLIF(TRIM("bezeichnung"), '')) IS NOT NULL
);
-- Ein Lauf haengt entweder an einem Mitarbeiter aus dem Verzeichnis oder traegt
-- einen Freitextnamen – beides zugleich waere widerspruechlich.
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_mitarbeiter_eindeutig" CHECK (
"mitarbeiterId" IS NULL OR "mitarbeiterName" IS NULL
);
-- Status und Zeitstempel duerfen nicht auseinanderlaufen, sonst zeigt das Archiv
-- einen abgeschlossenen Lauf ohne Abschlusszeitpunkt.
-- Der veroeffentlichende Benutzer bleibt bewusst optional: der Seed
-- veroeffentlicht die sieben Vorlagen ohne angemeldeten Menschen.
ALTER TABLE "VorlagenVersion"
ADD CONSTRAINT "VorlagenVersion_veroeffentlicht_mit_zeitpunkt" CHECK (
"status" <> 'VEROEFFENTLICHT' OR "veroeffentlichtAm" IS NOT NULL
);
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_begonnen_mit_zeitpunkt" CHECK (
"status" = 'OFFEN' OR "gestartetAm" IS NOT NULL
);
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_abgeschlossen_mit_zeitpunkt" CHECK (
"status" NOT IN ('ABGESCHLOSSEN', 'ARCHIVIERT') OR "abgeschlossenAm" IS NOT NULL
);
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_archiviert_mit_zeitpunkt" CHECK (
"status" <> 'ARCHIVIERT' OR "archiviertAm" IS NOT NULL
);
-- NICHT_ZUTREFFEND und FEHLGESCHLAGEN verlangen eine Begruendung. Die
-- Service-Schicht prueft das ebenfalls, nur mit verstaendlicherer Meldung.
ALTER TABLE "Schrittergebnis"
ADD CONSTRAINT "Schrittergebnis_begruendung_noetig" CHECK (
"zustand" NOT IN ('NICHT_ZUTREFFEND', 'FEHLGESCHLAGEN')
OR NULLIF(TRIM("notiz"), '') IS NOT NULL
);
-- ---- Mandantengrenze -------------------------------------------------------
-- Die Trennung nach Kunden ist eine Sicherheitsgrenze (siehe
-- docs/architektur.md) und soll nicht allein an kundeErmitteln() haengen. Die
-- zusammengesetzten Fremdschluessel machen es unmoeglich, dass ein Lauf ein
-- Geraet, eine Person oder eine Abteilung eines fremden Kunden waehlt.
--
-- MATCH SIMPLE ist hier genau richtig: ist die optionale Spalte NULL, greift der
-- Fremdschluessel nicht – ein Lauf ohne Geraet bleibt also erlaubt.
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_geraet_gleicher_kunde"
FOREIGN KEY ("geraetId", "kundeId") REFERENCES "Geraet" ("id", "kundeId")
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_mitarbeiter_gleicher_kunde"
FOREIGN KEY ("mitarbeiterId", "kundeId") REFERENCES "Mitarbeiter" ("id", "kundeId")
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_abteilung_gleicher_kunde"
FOREIGN KEY ("abteilungId", "kundeId") REFERENCES "Abteilung" ("id", "kundeId")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- Auch der Besitzer eines Geraets muss zum selben Kunden gehoeren.
ALTER TABLE "Geraet"
ADD CONSTRAINT "Geraet_besitzer_gleicher_kunde"
FOREIGN KEY ("mitarbeiterId", "kundeId") REFERENCES "Mitarbeiter" ("id", "kundeId")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- ---- Zeitstempel ohne Prisma ----------------------------------------------
-- @updatedAt setzt Prisma im Client. Wer per psql, Migration oder Skript
-- schreibt, haette sonst eine NOT-NULL-Verletzung – genau die Wege, die diese
-- Datei oben ausdruecklich einkalkuliert.
DO $$
DECLARE
z record;
BEGIN
FOR z IN
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND column_name IN ('geaendertAm', 'aktualisiertAm')
LOOP
EXECUTE format(
'ALTER TABLE %I ALTER COLUMN %I SET DEFAULT CURRENT_TIMESTAMP',
z.table_name, z.column_name
);
END LOOP;
END $$;
+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"
+579 -9
View File
@@ -1,7 +1,19 @@
// Laufzettel – Datenmodell
// Benennung deutsch, Umlaute ASCII-gefaltet (siehe CLAUDE.md, Abschnitt Namensabbildung).
// Das vollstaendige Schema entsteht in #006; hier steht nur so viel, wie der
// Generator zum Laufen braucht.
//
// Benennung deutsch, Umlaute ASCII-gefaltet. Die Abbildung auf die englischen
// Modellnamen der Auftragsvorgabe steht in CLAUDE.md.
//
// Zwei Entwurfsentscheidungen tragen das Ganze, siehe docs/architektur.md:
//
// 1. Jede kundenbezogene Zeile traegt IMMER eine kundeId – auch im Modus
// INTERN, wo es die eine Organisation mit istEigeneOrganisation = true ist.
// Dadurch ist der Wechsel in den Modus DIENSTLEISTUNG eine Datenaenderung
// und keine Migration.
//
// 2. Nichts wird geloescht, woran ein Lauf haengt. Geraete, Abteilungen und
// Vorlagen werden archiviert, Mitarbeiter nur auf aktiv = false gesetzt.
// Deshalb stehen die Fremdschluessel auf Restrict; Cascade gibt es nur
// innerhalb einer Vorlagen-Version, die als Ganzes verworfen werden darf.
generator client {
provider = "prisma-client"
@@ -12,6 +24,8 @@ datasource db {
provider = "postgresql"
}
// ---- Aufzaehlungen ---------------------------------------------------------
enum Rolle {
ADMIN
REDAKTEUR
@@ -19,16 +33,572 @@ enum Rolle {
LESER
}
enum Betriebsmodus {
INTERN
DIENSTLEISTUNG
}
enum LaufStatus {
OFFEN
IN_ARBEIT
ABGESCHLOSSEN
ARCHIVIERT
}
enum SchrittZustand {
OFFEN
ERLEDIGT
NICHT_ZUTREFFEND
FEHLGESCHLAGEN
}
// AKTION kommt in Phase 4 dazu. Weil der Typ ein Enum ist und die Einzelheiten
// in VorlagenSchritt.konfig liegen, braucht das keine Migration der bestehenden
// Schritte.
enum SchrittTyp {
HAKEN
TEXT
ZAHL
AUSWAHL
DATUM
BEFEHL
}
enum VariablenTyp {
TEXT
ZAHL
AUSWAHL
DATUM
}
enum GeraeteTyp {
PC
NOTEBOOK
SERVER
FIREWALL
SWITCH
DRUCKER
RAUM
SONSTIGES
}
enum VersionStatus {
ENTWURF
VEROEFFENTLICHT
}
enum Herkunft {
ABGLEICH
MANUELL
}
// ---- Einstellungen und Mandanten ------------------------------------------
/// Einzelne Zeile mit fester id "global". Der Betriebsmodus liegt bewusst in
/// der Datenbank und nicht in der Umgebung, damit der Admin ohne Neustart
/// umschalten kann.
model Einstellung {
id String @id @default("global")
modus Betriebsmodus @default(INTERN)
/// Globale Namensregeln fuer upn, sam, mail und hostname. Im Modus
/// DIENSTLEISTUNG koennen Kunden eigene Regeln haben, die diese ueberlagern.
namensregeln Json @default("{}")
/// Ziel-OU fuer deaktivierte Konten, Vorbelegung im Offboarding.
gesperrteOu String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
}
model Kunde {
id String @id @default(cuid())
name String
/// 2 bis 6 Zeichen, erscheint auf Etiketten und in der Hostname-Regel.
kuerzel String @unique
/// Genau eine Zeile traegt true. Erzwungen durch einen partiellen eindeutigen
/// Index in der Migration, den Prisma selbst nicht ausdruecken kann.
istEigeneOrganisation Boolean @default(false)
/// Namensregeln dieses Kunden; fehlende Schluessel fallen auf die globalen
/// aus Einstellung zurueck.
namensregeln Json @default("{}")
notizen String?
archiviert Boolean @default(false)
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
abteilungen Abteilung[]
benutzerbindungen BenutzerKunde[]
geraete Geraet[]
laeufe Lauf[]
mitarbeiter Mitarbeiter[]
verzeichnisquellen Verzeichnisquelle[]
vorlagen Vorlage[]
@@index([archiviert])
}
// ---- Benutzer der Anwendung ------------------------------------------------
/// Die Menschen, die Laufzettel bedienen – nicht zu verwechseln mit Mitarbeiter,
/// den Personen der Firma aus dem Verzeichnis.
model Benutzer {
id String @id @default(cuid())
email String @unique
name String
rolle Rolle @default(LESER)
/// Subject-Claim des OIDC-Anbieters. Leer bei rein lokalen Konten.
oidcSub String? @unique
/// Nur gesetzt, wenn der lokale Login benutzt wird. argon2, nie Klartext.
passwortHash String?
aktiv Boolean @default(true)
/// Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten nicht mehr.
/// So laesst sich ein Konto sperren, ohne auf den Ablauf des Cookies zu warten.
sitzungenUngueltigAb DateTime?
letzteAnmeldungAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon")
archivierteLaeufe Lauf[] @relation("LaufArchiviertVon")
erstellteLaeufe Lauf[] @relation("LaufErstelltVon")
zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn")
kommentare Kommentar[]
kundenbindungen BenutzerKunde[]
protokolle Protokoll[]
schrittergebnisse Schrittergebnis[]
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
@@index([aktiv])
@@index([rolle])
}
/// Bindet einen Benutzer an Kunden. Nur im Modus DIENSTLEISTUNG und nur, wenn
/// OIDC_CUSTOMER_GROUP_PREFIX gesetzt ist: TECHNIKER und LESER sehen dann allein
/// ihre Kunden, ADMIN und REDAKTEUR alles.
///
/// Die Bindung liegt in der Datenbank und nicht im Token, damit sie – wie Rolle
/// und Aktiv-Status – pro Anfrage frisch gelesen wird und auch fuer lokale Konten
/// gilt. Beim OIDC-Login wird sie neu geschrieben.
model BenutzerKunde {
benutzerId String
kundeId String
erstelltAm DateTime @default(now())
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Cascade)
@@id([benutzerId, kundeId])
@@index([kundeId])
}
// ---- Verzeichnis und Personen ---------------------------------------------
/// LDAP-Quelle fuer den Abgleich des Mitarbeiterverzeichnisses. In der Regel
/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene.
/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen
/// Abgleich zu Kunden-ADs.
model Verzeichnisquelle {
id String @id @default(cuid())
kundeId String
name String
aktiv Boolean @default(false)
url String
bindDn String
/// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt
/// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test.
bindPasswortVerschluesselt String
basisDn String
filter String
/// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
attributZuordnung Json
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
caZertifikatPem String?
abgleichIntervallMinuten Int @default(60)
letzterAbgleichAm DateTime?
letzterAbgleichStatus String?
/// Nimmt die Meldung der LDAP-Bibliothek auf. #016 muss sie filtern, bevor sie
/// hier landet – rohe Bind-Fehler koennen das Passwort enthalten.
letzterAbgleichFehler String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
mitarbeiter Mitarbeiter[]
@@index([kundeId])
@@index([aktiv])
}
/// Die Personen der Firma bzw. des Kunden. Werden nie geloescht, nur auf
/// aktiv = false gesetzt, damit archivierte Laeufe weiter auf sie zeigen.
model Mitarbeiter {
id String @id @default(cuid())
kundeId String
/// Nur bei Herkunft ABGLEICH gesetzt.
quelleId String?
/// objectGUID des Verzeichnisses, je Quelle eindeutig.
externeId String?
vorname String
nachname String
anzeigename String
mail String?
upn String?
sam String?
dn String?
/// Der rohe department-Wert aus dem Verzeichnis. Die gepflegten Abteilungen
/// stehen in Abteilung und werden ueber den Namen zugeordnet; ein Wert ohne
/// passende Abteilung bleibt trotzdem erhalten.
abteilung String?
titel String?
vorgesetzterExterneId String?
aktiv Boolean @default(true)
herkunft Herkunft @default(MANUELL)
zuletztGesehenAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict)
geraete Geraet[]
laeufe Lauf[]
/// Postgres behandelt NULL als verschieden, deshalb stoeren mehrere manuell
/// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht.
@@unique([quelleId, externeId])
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel.
@@unique([id, kundeId])
@@index([kundeId, aktiv])
@@index([abteilung])
@@index([upn])
@@index([sam])
}
/// Vom Admin gepflegt, Vorschlaege entstehen aus den department-Werten des
/// Abgleichs. Liefert beim Onboarding die Ziel-OU und die Standardgruppen.
model Abteilung {
id String @id @default(cuid())
kundeId String
name String
zielOu String?
standardGruppen String[] @default([])
mailDomain String?
notizen String?
archiviert Boolean @default(false)
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
laeufe Lauf[]
@@unique([kundeId, name])
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel aus der
/// Migration, der eine Abteilung eines fremden Kunden ausschliesst.
@@unique([id, kundeId])
@@index([kundeId, archiviert])
}
// ---- Geraete und Tags ------------------------------------------------------
/// Nicht nur PCs: ein Serverschrank, ein Raum oder ein Auftrag bekommen
/// genauso einen Aufkleber.
///
/// Ein Geraet ohne registriertAm ist ein vorgedruckter, noch nicht zugeordneter
/// Tag. Erst beim ersten Scannen wird es zu einem echten Objekt – deshalb greift
/// die Pflicht zu hostname oder bezeichnung erst ab dann (Check-Constraint in
/// der Migration).
model Geraet {
id String @id @default(cuid())
kundeId String
/// Besitzer bzw. Hauptnutzer. Server, Raeume und Poolgeraete haben keinen.
mitarbeiterId String?
/// Wird nie geaendert und nie wiederverwendet, auch nicht nach Archivierung.
/// Unabhaengig vom Hostname, damit ein Geraet umbenannt werden kann, ohne dass
/// der Aufkleber ungueltig wird.
tagCode String @unique
hostname String?
/// Anzeigename fuer Objekte ohne Hostname, etwa "Serverschrank Keller".
bezeichnung String?
seriennummer String?
inventarnummer String?
typ GeraeteTyp @default(SONSTIGES)
modell String?
notizen String?
archiviert Boolean @default(false)
registriertAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: Restrict)
laeufe Lauf[]
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel.
@@unique([id, kundeId])
@@index([kundeId, archiviert])
@@index([mitarbeiterId])
@@index([typ])
@@index([registriertAm])
}
/// Jeder je vergebene tagCode, unabhaengig davon, ob das Geraet noch existiert.
///
/// Die Eindeutigkeit allein aus Geraet.tagCode zu ziehen reicht nicht: verschwindet
/// die Zeile, waere der Code wieder frei und ein alter Aufkleber im Regal zeigte
/// auf ein fremdes Objekt. Aus diesem Buch wird nie geloescht.
model TagCodeVergabe {
tagCode String @id
vergebenAm DateTime @default(now())
}
/// Vorlagen, die auf der Geraeteseite fuer diesen Geraetetyp als Schnellstart
/// vorgeschlagen werden. Bewusst ohne kundeId: welcher Schnellstart zu einem
/// Geraetetyp passt, haengt am Typ und nicht am Kunden. Kundenspezifische
/// Vorlagen filtert die Geraeteseite ueber Vorlage.kundeId heraus.
model GeraetetypVorlage {
id String @id @default(cuid())
geraeteTyp GeraeteTyp
vorlageId String
position Int @default(0)
vorlage Vorlage @relation(fields: [vorlageId], references: [id], onDelete: Cascade)
@@unique([geraeteTyp, vorlageId])
@@index([geraeteTyp, position])
}
// ---- Vorlagen --------------------------------------------------------------
model Vorlage {
id String @id @default(cuid())
email String @unique
name String
rolle Rolle @default(LESER)
oidcSub String? @unique
passwortHash String?
aktiv Boolean @default(true)
beschreibung String?
kategorie String?
schlagworte String[] @default([])
/// Nur im Modus DIENSTLEISTUNG: kundenspezifische Vorlage. Leer heisst
/// "gilt fuer alle".
kundeId String?
archiviert Boolean @default(false)
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
@@index([aktiv])
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: Restrict)
versionen VorlagenVersion[]
typVorgaben GeraetetypVorlage[]
@@index([archiviert])
@@index([kategorie])
@@index([kundeId])
}
/// Eine veroeffentlichte Version wird nie wieder veraendert. "Bearbeiten"
/// erzeugt eine tiefe Kopie als neuen Entwurf; je Vorlage gibt es hoechstens
/// einen Entwurf. Laeufe verweisen immer auf eine veroeffentlichte Version.
model VorlagenVersion {
id String @id @default(cuid())
vorlageId String
version Int
status VersionStatus @default(ENTWURF)
aenderungsnotiz String?
veroeffentlichtAm DateTime?
veroeffentlichtVonId String?
erstelltAm DateTime @default(now())
vorlage Vorlage @relation(fields: [vorlageId], references: [id], onDelete: Cascade)
veroeffentlichtVon Benutzer? @relation("VersionVeroeffentlichtVon", fields: [veroeffentlichtVonId], references: [id], onDelete: Restrict)
abschnitte VorlagenAbschnitt[]
variablen VorlagenVariable[]
laeufe Lauf[]
@@unique([vorlageId, version])
@@index([status])
}
/// Wird beim Start eines Laufs abgefragt und fuellt die Platzhalter.
model VorlagenVariable {
id String @id @default(cuid())
versionId String
schluessel String
beschriftung String
typ VariablenTyp @default(TEXT)
optionen String[] @default([])
pflicht Boolean @default(false)
position Int @default(0)
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
@@unique([versionId, schluessel])
@@index([versionId, position])
}
model VorlagenAbschnitt {
id String @id @default(cuid())
versionId String
titel String
position Int @default(0)
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
schritte VorlagenSchritt[]
@@index([versionId, position])
}
model VorlagenSchritt {
id String @id @default(cuid())
abschnittId String
position Int @default(0)
titel String
/// Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML.
hilfetext String?
typ SchrittTyp @default(HAKEN)
pflicht Boolean @default(false)
/// Typabhaengige Einzelheiten: Auswahloptionen, Min/Max/Einheit, bei BEFEHL
/// shell, multiline und der Befehlstext. Ab Phase 4 ausserdem Connector und
/// Parameter fuer Schritte vom Typ AKTION.
konfig Json @default("{}")
dokuUrl String?
abschnitt VorlagenAbschnitt @relation(fields: [abschnittId], references: [id], onDelete: Cascade)
ergebnisse Schrittergebnis[]
@@index([abschnittId, position])
}
// ---- Laeufe ----------------------------------------------------------------
model Lauf {
id String @id @default(cuid())
vorlagenVersionId String
titel String
kundeId String
/// Mitarbeiter aus dem Verzeichnis. Ein Lauf ohne ist gueltig – Server,
/// Firewall, Patchday.
mitarbeiterId String?
/// Freitext, wenn die Person noch nicht im Verzeichnis existiert, etwa beim
/// Onboarding. Kann spaeter ueber den UPN mit einem Mitarbeiter verknuepft
/// werden; beim Verknuepfen wird dieses Feld geleert, weil eine Bedingung in
/// der Datenbank beides zugleich ausschliesst. Der urspruengliche Name bleibt
/// im Protokoll erhalten.
mitarbeiterName String?
geraetId String?
/// Die beim Start gewaehlte Abteilung. Speist {{ou}} und {{gruppen}}. Steht als
/// Bezug und nicht nur als Wert in variablen, damit die Druckansicht eines
/// archivierten Laufs reproduzierbar bleibt, auch wenn die Abteilung spaeter
/// umbenannt wird.
abteilungId String?
ticket String?
/// Werte der VorlagenVariablen, beim Start erfasst.
variablen Json @default("{}")
status LaufStatus @default(OFFEN)
zugewiesenAnId String?
faelligAm DateTime?
/// Leer bei einem Lauf, den die Anwendung selbst angelegt hat – wiederkehrende
/// Pruefungen und der Start per Webhook kommen in Phase 4. Kein Codepfad darf
/// annehmen, dass hier immer ein Mensch steht.
erstelltVonId String?
/// Bleibt leer, solange der Lauf nur zugewiesen ist. Wird beim ersten Oeffnen
/// zur Bearbeitung gesetzt, zusammen mit dem Wechsel auf IN_ARBEIT.
gestartetAm DateTime?
abgeschlossenAm DateTime?
abgeschlossenVonId String?
archiviertAm DateTime?
archiviertVonId String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
vorlagenVersion VorlagenVersion @relation(fields: [vorlagenVersionId], references: [id], onDelete: Restrict)
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: Restrict)
geraet Geraet? @relation(fields: [geraetId], references: [id], onDelete: Restrict)
abteilung Abteilung? @relation(fields: [abteilungId], references: [id], onDelete: Restrict)
erstelltVon Benutzer? @relation("LaufErstelltVon", fields: [erstelltVonId], references: [id], onDelete: Restrict)
zugewiesenAn Benutzer? @relation("LaufZugewiesenAn", fields: [zugewiesenAnId], references: [id], onDelete: Restrict)
abgeschlossenVon Benutzer? @relation("LaufAbgeschlossenVon", fields: [abgeschlossenVonId], references: [id], onDelete: Restrict)
archiviertVon Benutzer? @relation("LaufArchiviertVon", fields: [archiviertVonId], references: [id], onDelete: Restrict)
ergebnisse Schrittergebnis[]
kommentare Kommentar[]
protokolle Protokoll[]
@@index([kundeId, status])
@@index([geraetId, status])
@@index([mitarbeiterId])
@@index([zugewiesenAnId, status])
@@index([status, faelligAm])
@@index([vorlagenVersionId])
@@index([abteilungId])
}
model Schrittergebnis {
id String @id @default(cuid())
laufId String
vorlagenSchrittId String
zustand SchrittZustand @default(OFFEN)
/// Der erfasste Wert, je nach Schritt-Typ. Nimmt ab Phase 4 auch das Ergebnis
/// einer Aktion auf: Log, IDs, Zeitstempel.
wert Json?
/// Pflicht bei NICHT_ZUTREFFEND und FEHLGESCHLAGEN, erzwungen im Service.
notiz String?
aktualisiertVonId String?
aktualisiertAm DateTime @updatedAt
erstelltAm DateTime @default(now())
lauf Lauf @relation(fields: [laufId], references: [id], onDelete: Restrict)
vorlagenSchritt VorlagenSchritt @relation(fields: [vorlagenSchrittId], references: [id], onDelete: Restrict)
/// Leer, wenn der Schritt von der Anwendung selbst gesetzt wurde. Kein
/// Codepfad darf annehmen, dass immer ein Mensch dahintersteht.
aktualisiertVon Benutzer? @relation(fields: [aktualisiertVonId], references: [id], onDelete: Restrict)
kommentare Kommentar[]
@@unique([laufId, vorlagenSchrittId])
@@index([laufId, zustand])
}
model Kommentar {
id String @id @default(cuid())
laufId String
schrittergebnisId String?
benutzerId String
rumpf String
erstelltAm DateTime @default(now())
lauf Lauf @relation(fields: [laufId], references: [id], onDelete: Restrict)
schrittergebnis Schrittergebnis? @relation(fields: [schrittergebnisId], references: [id], onDelete: Restrict)
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Restrict)
@@index([laufId, erstelltAm])
@@index([schrittergebnisId])
}
// ---- Protokoll und Sperren -------------------------------------------------
/// Jede Aenderung an einem Schrittergebnis, jeder Statuswechsel eines Laufs und
/// jedes Veroeffentlichen einer Vorlage landet hier. Wird nie geloescht.
model Protokoll {
id String @id @default(cuid())
laufId String?
entitaet String
entitaetId String
aktion String
benutzerId String?
vorher Json?
nachher Json?
erstelltAm DateTime @default(now())
lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Restrict)
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Restrict)
@@index([laufId, erstelltAm])
@@index([entitaet, entitaetId])
@@index([erstelltAm])
}
/// Sperre fuer Aufgaben, die nur einmal gleichzeitig laufen duerfen – zuerst der
/// LDAP-Abgleich. Liegt in der Datenbank, damit auch mehrere Instanzen der
/// Anwendung sich einig sind.
model AbgleichSperre {
schluessel String @id
gehaltenVon String
gehaltenSeit DateTime @default(now())
laeuftAbAm DateTime
@@index([laeuftAbAm])
}
+111
View File
@@ -0,0 +1,111 @@
import "dotenv/config";
import { hash } from "@node-rs/argon2";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../src/generated/prisma/client.js";
import { Betriebsmodus, Rolle } from "../src/generated/prisma/enums.js";
/**
* Grundbestand, ohne den die Anwendung nicht bedienbar waere: der Betriebsmodus,
* die eigene Organisation und ein Administrator.
*
* Der Seed ist idempotent – zweimal hintereinander aufgerufen aendert der zweite
* Lauf nichts. Er laeuft im Compose-Stand bewusst NICHT automatisch mit, sondern
* ist ein dokumentierter Handgriff (siehe README).
*
* Die sieben Vorlagen, die Beispiel-Mitarbeiter und die Demo-Kunden kommen mit
* #017 dazu.
*/
const url = process.env["DATABASE_URL"];
if (!url) {
throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example.");
}
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
/** Akzeptiert den im Auftrag dokumentierten Wert SERVICE als Alias. */
function modusLesen(): Betriebsmodus {
const roh = (process.env["SEED_MODE"] ?? "INTERN").trim().toUpperCase();
if (roh === "DIENSTLEISTUNG" || roh === "SERVICE") return Betriebsmodus.DIENSTLEISTUNG;
if (roh === "INTERN") return Betriebsmodus.INTERN;
throw new Error(`SEED_MODE kennt nur INTERN und DIENSTLEISTUNG (Alias SERVICE), nicht "${roh}".`);
}
async function einstellungAnlegen(modus: Betriebsmodus): Promise<void> {
// Nur anlegen, nicht ueberschreiben: ein spaeterer Seed-Lauf darf einen im
// Betrieb umgeschalteten Modus nicht zuruecksetzen.
const vorhanden = await prisma.einstellung.findUnique({ where: { id: "global" } });
if (vorhanden) {
console.log(` Einstellung: vorhanden, Modus ${vorhanden.modus} bleibt`);
return;
}
await prisma.einstellung.create({ data: { id: "global", modus } });
console.log(` Einstellung: angelegt, Modus ${modus}`);
}
async function eigeneOrganisationAnlegen(): Promise<string> {
const name = process.env["SEED_ORG_NAME"]?.trim() || "Eigene Organisation";
const kuerzel = (process.env["SEED_ORG_KUERZEL"]?.trim() || "EIGEN").toUpperCase();
if (!/^[A-Z0-9]{2,6}$/.test(kuerzel)) {
throw new Error(`SEED_ORG_KUERZEL muss 2 bis 6 Grossbuchstaben oder Ziffern sein, nicht "${kuerzel}".`);
}
// Bewusst nicht aktualisieren: wer Name oder Kuerzel im Betrieb geaendert hat,
// soll das nicht beim naechsten Seed-Lauf zurueckbekommen. Geaenderte Werte in
// der Umgebung bleiben deshalb wirkungslos, sobald die Zeile existiert.
const vorhanden = await prisma.kunde.findFirst({ where: { istEigeneOrganisation: true } });
if (vorhanden) {
console.log(` Eigene Organisation: vorhanden (${vorhanden.name})`);
return vorhanden.id;
}
const kunde = await prisma.kunde.create({
data: { name, kuerzel, istEigeneOrganisation: true },
});
console.log(` Eigene Organisation: angelegt (${kunde.name}, ${kunde.kuerzel})`);
return kunde.id;
}
async function adminAnlegen(): Promise<void> {
const email = process.env["SEED_ADMIN_EMAIL"]?.trim().toLowerCase();
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) {
console.log(" Administrator: uebersprungen (SEED_ADMIN_EMAIL oder SEED_ADMIN_PASSWORD fehlt)");
return;
}
// Gesucht wird ueber die Adresse: eine geaenderte SEED_ADMIN_EMAIL legt also
// einen zweiten Administrator an, statt den ersten umzubenennen. Das ist
// gewollt – umbenennen waere eine Aenderung an einem Konto, das laengst benutzt
// sein kann.
const vorhanden = await prisma.benutzer.findUnique({ where: { email } });
if (vorhanden) {
console.log(` Administrator: vorhanden (${email})`);
return;
}
// Das Passwort wird nur als argon2-Hash gespeichert, nie im Klartext.
await prisma.benutzer.create({
data: {
email,
name: "Administrator",
rolle: Rolle.ADMIN,
passwortHash: await hash(passwort),
aktiv: true,
},
});
console.log(` Administrator: angelegt (${email})`);
}
async function main(): Promise<void> {
const modus = modusLesen();
console.log("Seed laeuft:");
await einstellungAnlegen(modus);
await eigeneOrganisationAnlegen();
await adminAnlegen();
console.log("Seed fertig.");
}
try {
await main();
} finally {
await prisma.$disconnect();
}