Files
Florian WildeandClaude Opus 5 d30bb6d235
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions
feat(laeufe): Vier-Augen-Freigabe (#040)
Verlangt eine Vorlagen-Version eine Freigabe, laesst sich ein Lauf erst
abschliessen, wenn ein Zweiter ihn freigegeben hat. Das braucht zweierlei, und
beides zusammen: das neue Recht `freigeben` (ADMIN und REDAKTEUR, nicht
TECHNIKER) und dass man nicht an dem Lauf gearbeitet hat.

Als gearbeitet zaehlt: angelegt, einen Schritt gesetzt oder abgeschlossen. Eine
Zuweisung allein nicht — waere die Regel hier strenger, blockierte sie kleine
Teams ohne Gewinn.

Auch ein Administrator kann sich nicht selbst freigeben, und das ist der ganze
Sinn. Die Pruefung sitzt in `hatDaranGearbeitet` an einer Stelle, wie
`laufMussVeraenderbarSein`; die wichtigste Haelfte — der Ersteller — haelt
zusaetzlich ein CHECK in der Datenbank.

Zurueckweisen verlangt eine Begruendung, setzt den Lauf auf IN_ARBEIT und nimmt
eine erteilte Freigabe zurueck. Kein fuenfter LaufStatus: ein neuer Enum-Wert
haette jeden Codepfad betroffen, der heute vier Faelle kennt.

Sobald jemand abzuschliessen versucht und die Freigabe fehlt, bekommen die
Freigabeberechtigten eine Mail — ein anderer Empfaengerkreis als bei jeder
anderen Meldung, und ohne die, die gearbeitet haben. Die Aufforderung entsteht
ausserhalb der Transaktion: innerhalb wuerde sie mit dem Fehler zurueckgerollt,
und dann wartet ein Lauf auf etwas, von dem niemand weiss. Ein partieller
Unique-Index haelt sie bei mehreren Versuchen auf einmal.

Die Pflicht haengt an der Version, nicht an der Vorlage: eine veroeffentlichte
Version ist unveraenderlich, ein laufender Lauf behaelt also seine Regel. Beim
Bearbeiten und Duplizieren wandert sie mit — das war die unmittelbare Lehre aus
#039, wo die tiefe Kopie zwei Felder stillschweigend verworfen hatte.

1526 Vitest-Tests (vorher 1496), 115 E2E-Ablaeufe gruen. Klickprobe am gebauten
Stand mit elf Pruefungen; drei rote Punkte darin waren meine eigenen
Testfehler, einer davon lehrreich: ein reload() direkt nach einer Server Action
trifft den Router-Cache von Next und zeigt kurz den alten Stand.

Closes #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-24 01:14:04 +02:00

971 lines
39 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Laufzettel – Datenmodell
//
// 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"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
// ---- Aufzaehlungen ---------------------------------------------------------
enum Rolle {
ADMIN
REDAKTEUR
TECHNIKER
LESER
/// Selbstauskunft (#035): sieht die Laeufe zu **seinen** Geraeten und zu
/// seiner Person, und nichts sonst. Keine Schritte, keine Notizen, keine
/// Kommentare, keine Anhaenge — eine Selbstauskunft ist keine Akteneinsicht.
/// Haengt an `Benutzer.mitarbeiterId`; ohne Zuordnung gibt es die Rolle nicht
/// (CHECK `Benutzer_mitarbeiter_nur_bei_rolle`).
MITARBEITER
}
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?
/// Sobald jemand den Schnellstart in der Oberflaeche gespeichert hat, laesst
/// der Seed `GeraetetypVorlage` in Ruhe (#022). Ohne diese Marke kaeme eine
/// entfernte Zuordnung beim naechsten Seed-Lauf zurueck: eine fehlende Zeile
/// sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde.
schnellstartGepflegt Boolean @default(false)
/// SMTP fuer die Benachrichtigungen (#034). Leerer Host heisst: keine Mail.
/// Kein Dienstanbieter, sondern SMTP — eine selbst gehostete Anwendung soll an
/// den Mailserver des Hauses koennen.
smtpHost String?
smtpPort Int?
/// `true` = StartTLS oder implizites TLS (Port 465). Klartext nur, wenn jemand
/// das ausdruecklich abschaltet.
smtpTls Boolean @default(true)
smtpBenutzer String?
/// AES-256-GCM aus APP_SECRET, wie `Verzeichnisquelle.bindPasswortVerschluesselt`.
/// Erreicht **nie** eine Antwort.
smtpPasswortVerschluesselt String?
smtpAbsender String?
/// Ein Webhook-Ziel fuer dieselben vier Anlaesse. Leere URL heisst: keiner.
webhookUrl String?
/// Geheimnis fuer die HMAC-SHA256-Signatur, verschluesselt wie oben.
webhookGeheimnisVerschluesselt String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
}
/// Wie eine Meldung hinausgeht (#034).
enum Zustellweg {
MAIL
WEBHOOK
}
/// Wo eine Zustellung steht (#034).
enum Zustellzustand {
OFFEN
GESENDET
/// Fehlgeschlagen, wird aber noch einmal versucht.
FEHLGESCHLAGEN
/// Endgueltig aufgegeben. Bleibt sichtbar — eine stille Warteschlange ist
/// schlimmer als keine.
AUFGEGEBEN
}
/// Was gemeldet wird. Bewusst genau vier Anlaesse (#034).
enum Meldeanlass {
LAUF_ZUGEWIESEN
LAUF_UEBERFAELLIG
LAUF_ABGESCHLOSSEN
/// Ein Lauf wartet auf eine Freigabe durch einen Zweiten (#040).
LAUF_FREIGABE_OFFEN
ABGLEICH_FEHLGESCHLAGEN
}
/// Eine einzelne Meldung in der Warteschlange (#034).
///
/// **In der Datenbank und nicht im Anfrage-Pfad.** Ein haengender Mailserver
/// darf kein Abhaken blockieren: der Dienst reiht ein, der Zeitplan schickt.
///
/// Betreff und Rumpf stehen fertig in der Zeile. Das kostet Platz und ist es
/// wert: eine Meldung sagt, was **damals** war. Baute der Zeitplan den Text erst
/// beim Senden, stuende in der Mail der Stand von jetzt — und in einer
/// aufgegebenen Zustellung von naechster Woche etwas voellig anderes.
model Zustellung {
id String @id @default(cuid())
anlass Meldeanlass
weg Zustellweg
/// Mailadresse oder Webhook-URL. Als Text, damit die Zeile auch dann noch
/// sagt, wohin sie ging, wenn das Konto spaeter deaktiviert wurde.
empfaenger String
betreff String
rumpf String
zustand Zustellzustand @default(OFFEN)
versuche Int @default(0)
/// Vor diesem Zeitpunkt holt der Zeitplan die Zeile nicht ab. Traegt das
/// wachsende Warten zwischen zwei Versuchen.
naechsterVersuchAm DateTime @default(now())
letzterFehler String?
gesendetAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
/// Wer gemeint ist. Leer bei einem Webhook — der gehoert keiner Person.
benutzerId String?
laufId String?
verzeichnisquelleId String?
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Cascade)
verzeichnisquelle Verzeichnisquelle? @relation(fields: [verzeichnisquelleId], references: [id], onDelete: Cascade)
/// Der Index, auf dem der Zeitplan arbeitet.
@@index([zustand, naechsterVersuchAm])
@@index([erstelltAm])
@@index([laufId])
@@index([benutzerId])
}
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 @default(now()) @updatedAt
abteilungen Abteilung[]
benutzerbindungen BenutzerKunde[]
geraete Geraet[]
laeufe Lauf[]
anhaenge Anhang[]
mitarbeiter Mitarbeiter[]
protokolle Protokoll[]
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?
/// Bekommt diese Person Benachrichtigungen (#034)? Je Empfaenger abschaltbar.
/// Ein Konto ohne Mailadresse gibt es nicht — `email` ist Pflicht —, aber ein
/// Dienstkonto will keine Post.
meldungenAktiv Boolean @default(true)
/// Die Person im Verzeichnis, als die dieses Konto sich selbst sehen darf
/// (#035). Gesetzt **genau dann**, wenn die Rolle `MITARBEITER` ist.
///
/// Hier steht **kein** zusammengesetzter Fremdschluessel, anders als sonst bei
/// jedem kundenbezogenen Verweis: ein `Benutzer` traegt keine `kundeId`, gegen
/// die man zusammensetzen koennte — ein Administrator sieht alle Kunden. Die
/// Zusage „sieht nur seine eigenen Laeufe“ haengt an dieser einen Kennung, und
/// staerker als ein Fremdschluessel darauf wird sie nicht.
mitarbeiterId String? @unique
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon")
freigegebeneLaeufe Lauf[] @relation("LaufFreigegebenVon")
archivierteLaeufe Lauf[] @relation("LaufArchiviertVon")
erstellteLaeufe Lauf[] @relation("LaufErstelltVon")
zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn")
kommentare Kommentar[]
kundenbindungen BenutzerKunde[]
protokolle Protokoll[]
schrittergebnisse Schrittergebnis[]
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
anhaenge Anhang[]
zustellungen Zustellung[]
mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: SetNull)
@@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.
/// Woher eine Verzeichnisquelle liest (#028).
///
/// Bis #028 war jede Quelle implizit LDAP. `SCIM` steht hier bereits, weil #029
/// dieselbe Weiche braucht — behandelt wird es noch nicht, und der Dienst weist
/// es mit einer Meldung ab, statt stillschweigend nichts zu tun.
enum QuellenArt {
LDAP
GRAPH
SCIM
}
model Verzeichnisquelle {
id String @id @default(cuid())
kundeId String
name String
aktiv Boolean @default(false)
/// Bestehende Zeilen sind LDAP – das war vor #028 die einzige Art.
art QuellenArt @default(LDAP)
/// Ab hier: **nur bei `art = LDAP`** gesetzt. Welche Felder eine Art braucht,
/// haelt der CHECK `Verzeichnisquelle_felder_je_art` fest — eine Graph-Quelle
/// mit einem `basisDn` waere eine Zeile, die zwei Dinge gleichzeitig behauptet.
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?
/// Ab hier: **nur bei `art = GRAPH`**. Der Mandant in Entra ID und die
/// App-Registrierung, mit der gelesen wird (Client-Credentials-Flow).
tenantId String?
clientId String?
/// Verschluesselt wie das Bind-Passwort und mit demselben Vorsatz: es darf
/// keine Antwort und keinen Protokolleintrag erreichen.
clientSecretVerschluesselt String?
/// Ab hier: **nur bei `art = SCIM`** (#029).
///
/// Das Token als **argon2-Hash**, nicht verschluesselt — anders als die
/// Geheimnisse darueber. Der Unterschied ist der Zweck: ein Bind-Passwort
/// muss Laufzettel **benutzen**, also wieder lesbar machen. Ein SCIM-Token
/// muss es nur **wiedererkennen**, und was man nie lesen muss, speichert man
/// so, dass es niemand lesen kann. Gezeigt wird es genau einmal.
scimTokenHash String?
/// Wann das Token erzeugt wurde — fuer „seit wann gilt das“ in der Oberflaeche.
scimTokenGesetztAm DateTime?
/// Wann zuletzt etwas geschickt wurde. Das Gegenstueck zu `letzterAbgleichAm`:
/// bei SCIM holt Laufzettel nicht, es bekommt.
letzteScimZustellungAm DateTime?
/// 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 @default(now()) @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
mitarbeiter Mitarbeiter[]
zustellungen Zustellung[]
@@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?
/// Der rohe `manager`-Wert aus dem Verzeichnis, aufgeloest auf die `externeId`
/// des Vorgesetzten — je Quelle eindeutig. Wie `abteilung` ist das der Wert
/// des Verzeichnisses, nicht die gepflegte Zuordnung; die steht in
/// `vorgesetzterId`. Leer bei einer von Hand angelegten Person.
vorgesetzterExterneId String?
/// Die **gepflegte** Zuordnung, und der einzige Leseweg (#020). Der Abgleich
/// schreibt sie aus `vorgesetzterExterneId` mit, von Hand gesetzt geht sie
/// auch ohne Verzeichnis. Darf nicht auf die Person selbst zeigen (CHECK) und
/// nicht auf jemanden eines anderen Kunden (zusammengesetzter Fremdschluessel
/// `Mitarbeiter_vorgesetzter_gleicher_kunde`). Laengere Schleifen haelt der
/// Dienst auf.
vorgesetzterId String?
aktiv Boolean @default(true)
herkunft Herkunft @default(MANUELL)
zuletztGesehenAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict)
vorgesetzter Mitarbeiter? @relation("Vorgesetzter", fields: [vorgesetzterId], references: [id], onDelete: Restrict)
untergebene Mitarbeiter[] @relation("Vorgesetzter")
geraete Geraet[]
laeufe Lauf[]
/// Das Konto, mit dem diese Person sich selbst ansehen darf (#035).
/// Hoechstens eines — `Benutzer.mitarbeiterId` ist eindeutig.
konto Benutzer?
/// 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])
@@index([vorgesetzterId])
}
/// 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 @default(now()) @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 @default(now()) @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())
name String
beschreibung String?
kategorie String?
schlagworte String[] @default([])
/// Nur im Modus DIENSTLEISTUNG: kundenspezifische Vorlage. Leer heisst
/// "gilt fuer alle".
kundeId String?
archiviert Boolean @default(false)
/// Kennung einer **mitgelieferten** Vorlage. Der Seed setzt sie und erkennt
/// seine sieben Vorlagen daran statt am Namen, der sich aendern darf (#021).
/// Eindeutig, wo gesetzt — erzwungen durch den partiellen eindeutigen Index
/// `Vorlage_seedSchluessel_einmalig`, den Prisma nicht ausdruecken kann.
/// Der Import setzt sie absichtlich **nicht**: eine eingespielte Kopie soll
/// nicht den Platz des Originals einnehmen.
seedSchluessel String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
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)
/// Verlangt dieser Lauf eine Freigabe durch einen Zweiten (#040)?
///
/// An der **Version** und nicht an der Vorlage: eine veroeffentlichte Version
/// ist unveraenderlich, also kann ein laufender Lauf seine Regel nicht unter
/// den Fuessen verlieren. Wer die Pflicht aendert, veroeffentlicht eine neue
/// Fassung — bestehende Laeufe behalten ihre.
freigabePflicht Boolean @default(false)
aenderungsnotiz String?
veroeffentlichtAm DateTime?
veroeffentlichtVonId String?
erstelltAm DateTime @default(now())
/// Wird bei jeder Aenderung am Entwurf neu gesetzt und dient als Stand, den
/// der Editor beim Laden mitbekommt und beim Speichern zurueckschickt. Weicht
/// er ab, hat jemand anderes zwischenzeitlich gespeichert — ohne diese Pruefung
/// ueberschriebe der zweite Redakteur die Arbeit des ersten spurlos, und weil
/// Entwuerfe bewusst nicht protokolliert werden, waere sie auch nicht
/// wiederherstellbar.
geaendertAm DateTime @default(now()) @updatedAt
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)
/// Nur fuer diese Abteilungen (#031). Leer heisst: fuer alle.
///
/// **Namen und nicht Kennungen.** Der Abschnitt gehoert zur Version und muss
/// ein Umbenennen oder Archivieren einer Abteilung ueberleben, ohne ins Leere
/// zu zeigen — dieselbe Entscheidung wie bei `Mitarbeiter.abteilung`.
nurFuerAbteilungen String[] @default([])
/// Verweis auf einen wiederverwendbaren Baustein (#030).
///
/// In einem **Entwurf** heisst das: beim Veroeffentlichen hier die Schritte des
/// Bausteins einsetzen. In einer **veroeffentlichten** Version heisst dasselbe
/// Feld: diese Schritte stammen von dort — es ist dann nur noch Herkunft.
/// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites
/// Feld dafuer waere eine zweite Wahrheit.
bausteinVersionId String?
/// Wann dieser Abschnitt gilt (#039). Leer heisst: immer.
///
/// Form und Auswertung stehen in `src/lib/bedingungen.ts`; ein CHECK haelt die
/// Form auch gegen Schreiben an der Dienstschicht vorbei fest, und ein Trigger
/// haelt fest, dass eine Schritt-Bedingung auf **dieselbe Version** zeigt —
/// nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr
/// entscheidbar, und der Abschnitt erschiene stillschweigend immer.
///
/// **Nicht entscheidbar heisst: gilt.** Ein Abschnitt zu viel ist sichtbar und
/// korrigierbar; ein fehlender faellt niemandem auf.
bedingung Json?
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict)
schritte VorlagenSchritt[]
@@index([versionId, position])
@@index([bausteinVersionId])
}
// ---- Bausteine -------------------------------------------------------------
/// Ein Abschnitt, den mehrere Vorlagen teilen (#030).
///
/// Versioniert nach denselben Regeln wie eine Vorlage: hoechstens ein Entwurf,
/// eine veroeffentlichte Version wird nie veraendert. Beim Veroeffentlichen
/// einer Vorlage wird der Baustein **tief kopiert** — ein Lauf sieht damit
/// weiterhin konkrete Schritte, und Schrittergebnis, Druckansicht und Archiv
/// muessen nichts lernen.
model Baustein {
id String @id @default(cuid())
/// Eindeutig: der Name ist, wonach ein Redakteur ihn sucht.
name String @unique
beschreibung String?
archiviert Boolean @default(false)
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
versionen BausteinVersion[]
@@index([archiviert])
}
model BausteinVersion {
id String @id @default(cuid())
bausteinId String
version Int
status VersionStatus @default(ENTWURF)
/// Titel des Abschnitts, der daraus entsteht. Steht an der Version und nicht
/// am Baustein, damit er sich aendern laesst, ohne alte Vorlagen umzuschreiben.
titel String
aenderungsnotiz String?
veroeffentlichtAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
baustein Baustein @relation(fields: [bausteinId], references: [id], onDelete: Cascade)
schritte BausteinSchritt[]
verwendungen VorlagenAbschnitt[]
@@unique([bausteinId, version])
@@index([bausteinId, status])
}
model BausteinSchritt {
id String @id @default(cuid())
versionId String
position Int @default(0)
titel String
hilfetext String?
typ SchrittTyp @default(HAKEN)
pflicht Boolean @default(false)
konfig Json @default("{}")
dokuUrl String?
version BausteinVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
@@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?
/// Wann dieser Schritt gilt (#039). Leer heisst: immer. Siehe
/// `VorlagenAbschnitt.bedingung` — dieselbe Form, dieselben Regeln.
bedingung Json?
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("{}")
/// Welche Abschnitte fuer diesen Lauf gelten, beim Start festgeschrieben (#031).
///
/// **Leer heisst: alle.** So bleiben Laeufe von vor dieser Fassung unberuehrt.
/// Festgeschrieben und nicht bei jedem Aufruf aus `abteilungId` berechnet: ein
/// archivierter Lauf muss genau die Schritte zeigen, die damals abgearbeitet
/// wurden, und ein spaeterer Abteilungswechsel darf keinen Schritt entfernen,
/// an dem schon eine Notiz haengt.
geltendeAbschnitte String[] @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?
/// Wer diesen Lauf freigegeben hat (#040), und wann.
///
/// **Nicht der Ersteller** — das haelt ein CHECK fest
/// (`Lauf_freigabe_nicht_selbst`). Der Dienst prueft strenger: er sieht auch
/// nach, wer an den Schrittergebnissen gearbeitet hat; dafuer braeuchte ein
/// CHECK eine Unterabfrage.
///
/// `freigabeNotiz` traegt die Begruendung — bei einer Freigabe freiwillig, bei
/// einer Zurueckweisung Pflicht. Sie bleibt stehen, wenn der Lauf danach
/// wieder freigegeben wird: das Protokoll haelt beide Vorgaenge fest, und die
/// letzte Notiz ist die, die jemand an der Abarbeitung lesen soll.
freigegebenVonId String?
freigegebenAm DateTime?
freigabeNotiz String?
archiviertAm DateTime?
archiviertVonId String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @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)
freigegebenVon Benutzer? @relation("LaufFreigegebenVon", fields: [freigegebenVonId], references: [id], onDelete: Restrict)
archiviertVon Benutzer? @relation("LaufArchiviertVon", fields: [archiviertVonId], references: [id], onDelete: Restrict)
ergebnisse Schrittergebnis[]
kommentare Kommentar[]
protokolle Protokoll[]
anhaenge Anhang[]
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
zustellungen Zustellung[]
@@unique([id, kundeId])
@@index([kundeId, status])
@@index([geraetId, status])
@@index([mitarbeiterId])
@@index([freigegebenVonId])
@@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 @default(now()) @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[]
anhaenge Anhang[]
@@unique([laufId, vorlagenSchrittId])
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
@@unique([id, laufId])
@@index([laufId, zustand])
}
/// Eine Datei oder ein Foto an einem Schritt (#032).
///
/// Die Datei selbst liegt im Dateisystem unter `ANHAENGE_PFAD`; hier stehen nur
/// die Metadaten. Gegen `bytea` spricht die Sicherung: ein `pg_dump` mit Fotos
/// ist nach einem Jahr kein `pg_dump` mehr, das jemand einspielt. **Folge:** die
/// Sicherung muss Datenbank **und** Verzeichnis mitnehmen, siehe
/// `docs/betrieb/sicherung.md`.
model Anhang {
id String @id @default(cuid())
schrittergebnisId String
laufId String
/// Jede kundenbezogene Zeile traegt kundeId (CLAUDE.md). Die Mandantengrenze
/// steht zusaetzlich als zusammengesetzter Fremdschluessel in der Migration.
kundeId String
/// Der Name, den der Browser mitgeschickt hat – nur zur Anzeige. Der Pfad auf
/// der Platte entsteht aus `id`, damit kein Dateiname von aussen einen Pfad
/// bestimmt.
dateiname String
medientyp String
groesse Int
/// SHA-256 der Datei. Beantwortet nach einer Ruecksicherung die Frage, ob die
/// Datei noch die ist, die hier beschrieben steht.
pruefsumme String
erstelltVonId String?
erstelltAm DateTime @default(now())
schrittergebnis Schrittergebnis @relation(fields: [schrittergebnisId], references: [id], onDelete: Cascade)
lauf Lauf @relation(fields: [laufId], references: [id], onDelete: Restrict)
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
/// Leer bei einem Systemlauf – kein Codepfad nimmt an, dass ein Mensch
/// dahintersteht.
erstelltVon Benutzer? @relation(fields: [erstelltVonId], references: [id], onDelete: Restrict)
@@index([schrittergebnisId])
@@index([laufId])
@@index([kundeId])
}
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?
/// Der Kunde, um den es geht — **leer heisst global** (Einstellungen,
/// Benutzer, kundenfreie Vorlagen, Schnellstart). CLAUDE.md verlangt die
/// Spalte fuer jede kundenbezogene Zeile; ohne sie liess sich das Protokoll
/// nicht nach Mandant filtern, ohne je Eintrag die Entitaet nachzuschlagen
/// (#023).
kundeId 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)
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: Restrict)
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Restrict)
@@index([laufId, erstelltAm])
@@index([entitaet, entitaetId])
@@index([erstelltAm])
@@index([kundeId, 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])
}