Files
Laufzettel/prisma/schema.prisma
T
Florian WildeandClaude Opus 5 a417426f43
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand die Uebersicht
oeffnete; ein ueberfaelliger ueberhaupt nicht. Vier Anlaesse gehen jetzt
hinaus — zugewiesen, ueberfaellig, abgeschlossen und Verzeichnisabgleich
fehlgeschlagen —, je Person abschaltbar. Niemand bekommt Post ueber die
eigene Tat: das ist der haeufigste Grund, Benachrichtigungen ganz
abzuschalten, und deshalb faellt der Handelnde immer aus der
Empfaengerliste.

Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht. Ein haengender
Mailserver darf kein Abhaken blockieren, also schreibt der Dienst eine
Zeile in `Zustellung` und ein eigener Zeitplan schickt sie. Betreff und
Rumpf stehen fertig darin: eine Meldung sagt, was *damals* war.

In keiner Nachricht steht ein Schrittinhalt, und damit auch kein
erzeugtes Passwort. Darueber wacht src/lib/meldungen.ts — die Funktionen
dort nehmen nur Kopfdaten, der Typ laesst nichts anderes zu. Geprueft
wird es zusaetzlich ueber alle Rumpftexte, die wirklich verschickt
wurden, so wie #012 es fuer die Druckansicht tat.

Zwei Dinge haetten ohne Datenbank nicht gehalten. Die Einmaligkeit der
Ueberfaelligkeitsmeldung liegt in einem partiellen eindeutigen Index:
der Zeitplan sieht jede Minute nach, und ohne ihn bekaeme der
Zustaendige fuer einen Lauf, der ueber das Wochenende steht, 2880
Mails — jede fuer sich korrekt, kein Test rot. Und die Sperre der
Warteschlange liegt in derselben Tabelle wie die des Abgleichs, denn
hinter einem Lastverteiler laufen zwei Instanzen.

Eine stille Warteschlange ist schlimmer als keine: Verwaltung →
Zustellungen zeigt mit Grund, was hinausging und was nicht.

Geprueft gegen einen echten SMTP-Faenger (mailpit) statt gegen eine
abgefangene Funktion — und `mailpit` gehoert damit ins Tor vor dem
Commit, sonst ueberspringt die Suite 3 Tests und sieht gruen aus.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 22:52:36 +02:00

874 lines
34 KiB
Plaintext
Raw 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
}
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
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)
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @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")
anhaenge Anhang[]
zustellungen Zustellung[]
@@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 @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[]
/// 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)
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?
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?
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?
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)
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([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])
}