feat(datenmodell): Schema, tagCodes und Geheimnisse (#20)
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:
@@ -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 $$;
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user