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