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
+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])
}