// 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? erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt } model Kunde { id String @id @default(cuid()) name String /// 2 bis 6 Zeichen, erscheint auf Etiketten und in der Hostname-Regel. kuerzel String @unique /// Genau eine Zeile traegt true. Erzwungen durch einen partiellen eindeutigen /// Index in der Migration, den Prisma selbst nicht ausdruecken kann. istEigeneOrganisation Boolean @default(false) /// Namensregeln dieses Kunden; fehlende Schluessel fallen auf die globalen /// aus Einstellung zurueck. namensregeln Json @default("{}") notizen String? archiviert Boolean @default(false) erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt abteilungen Abteilung[] benutzerbindungen BenutzerKunde[] geraete Geraet[] laeufe Lauf[] mitarbeiter Mitarbeiter[] verzeichnisquellen Verzeichnisquelle[] vorlagen Vorlage[] @@index([archiviert]) } // ---- Benutzer der Anwendung ------------------------------------------------ /// Die Menschen, die Laufzettel bedienen – nicht zu verwechseln mit Mitarbeiter, /// den Personen der Firma aus dem Verzeichnis. model Benutzer { id String @id @default(cuid()) email String @unique name String rolle Rolle @default(LESER) /// Subject-Claim des OIDC-Anbieters. Leer bei rein lokalen Konten. oidcSub String? @unique /// Nur gesetzt, wenn der lokale Login benutzt wird. argon2, nie Klartext. passwortHash String? aktiv Boolean @default(true) /// Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten nicht mehr. /// So laesst sich ein Konto sperren, ohne auf den Ablauf des Cookies zu warten. sitzungenUngueltigAb DateTime? letzteAnmeldungAm DateTime? erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon") archivierteLaeufe Lauf[] @relation("LaufArchiviertVon") erstellteLaeufe Lauf[] @relation("LaufErstelltVon") zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn") kommentare Kommentar[] kundenbindungen BenutzerKunde[] protokolle Protokoll[] schrittergebnisse Schrittergebnis[] veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon") @@index([aktiv]) @@index([rolle]) } /// Bindet einen Benutzer an Kunden. Nur im Modus DIENSTLEISTUNG und nur, wenn /// OIDC_CUSTOMER_GROUP_PREFIX gesetzt ist: TECHNIKER und LESER sehen dann allein /// ihre Kunden, ADMIN und REDAKTEUR alles. /// /// Die Bindung liegt in der Datenbank und nicht im Token, damit sie – wie Rolle /// und Aktiv-Status – pro Anfrage frisch gelesen wird und auch fuer lokale Konten /// gilt. Beim OIDC-Login wird sie neu geschrieben. model BenutzerKunde { benutzerId String kundeId String erstelltAm DateTime @default(now()) benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Cascade) @@id([benutzerId, kundeId]) @@index([kundeId]) } // ---- Verzeichnis und Personen --------------------------------------------- /// LDAP-Quelle fuer den Abgleich des Mitarbeiterverzeichnisses. In der Regel /// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene. /// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen /// Abgleich zu Kunden-ADs. model Verzeichnisquelle { id String @id @default(cuid()) kundeId String name String aktiv Boolean @default(false) url String bindDn String /// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt /// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test. bindPasswortVerschluesselt String basisDn String filter String /// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben. attributZuordnung Json /// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft. caZertifikatPem String? abgleichIntervallMinuten Int @default(60) letzterAbgleichAm DateTime? letzterAbgleichStatus String? /// Nimmt die Meldung der LDAP-Bibliothek auf. #016 muss sie filtern, bevor sie /// hier landet – rohe Bind-Fehler koennen das Passwort enthalten. letzterAbgleichFehler String? erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) mitarbeiter Mitarbeiter[] @@index([kundeId]) @@index([aktiv]) } /// Die Personen der Firma bzw. des Kunden. Werden nie geloescht, nur auf /// aktiv = false gesetzt, damit archivierte Laeufe weiter auf sie zeigen. model Mitarbeiter { id String @id @default(cuid()) kundeId String /// Nur bei Herkunft ABGLEICH gesetzt. quelleId String? /// objectGUID des Verzeichnisses, je Quelle eindeutig. externeId String? vorname String nachname String anzeigename String mail String? upn String? sam String? dn String? /// Der rohe department-Wert aus dem Verzeichnis. Die gepflegten Abteilungen /// stehen in Abteilung und werden ueber den Namen zugeordnet; ein Wert ohne /// passende Abteilung bleibt trotzdem erhalten. abteilung String? titel String? vorgesetzterExterneId String? aktiv Boolean @default(true) herkunft Herkunft @default(MANUELL) zuletztGesehenAm DateTime? erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict) geraete Geraet[] laeufe Lauf[] /// Postgres behandelt NULL als verschieden, deshalb stoeren mehrere manuell /// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht. @@unique([quelleId, externeId]) /// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel. @@unique([id, kundeId]) @@index([kundeId, aktiv]) @@index([abteilung]) @@index([upn]) @@index([sam]) } /// Vom Admin gepflegt, Vorschlaege entstehen aus den department-Werten des /// Abgleichs. Liefert beim Onboarding die Ziel-OU und die Standardgruppen. model Abteilung { id String @id @default(cuid()) kundeId String name String zielOu String? standardGruppen String[] @default([]) mailDomain String? notizen String? archiviert Boolean @default(false) erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) laeufe Lauf[] @@unique([kundeId, name]) /// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel aus der /// Migration, der eine Abteilung eines fremden Kunden ausschliesst. @@unique([id, kundeId]) @@index([kundeId, archiviert]) } // ---- Geraete und Tags ------------------------------------------------------ /// Nicht nur PCs: ein Serverschrank, ein Raum oder ein Auftrag bekommen /// genauso einen Aufkleber. /// /// Ein Geraet ohne registriertAm ist ein vorgedruckter, noch nicht zugeordneter /// Tag. Erst beim ersten Scannen wird es zu einem echten Objekt – deshalb greift /// die Pflicht zu hostname oder bezeichnung erst ab dann (Check-Constraint in /// der Migration). model Geraet { id String @id @default(cuid()) kundeId String /// Besitzer bzw. Hauptnutzer. Server, Raeume und Poolgeraete haben keinen. mitarbeiterId String? /// Wird nie geaendert und nie wiederverwendet, auch nicht nach Archivierung. /// Unabhaengig vom Hostname, damit ein Geraet umbenannt werden kann, ohne dass /// der Aufkleber ungueltig wird. tagCode String @unique hostname String? /// Anzeigename fuer Objekte ohne Hostname, etwa "Serverschrank Keller". bezeichnung String? seriennummer String? inventarnummer String? typ GeraeteTyp @default(SONSTIGES) modell String? notizen String? archiviert Boolean @default(false) registriertAm DateTime? erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: Restrict) laeufe Lauf[] /// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel. @@unique([id, kundeId]) @@index([kundeId, archiviert]) @@index([mitarbeiterId]) @@index([typ]) @@index([registriertAm]) } /// Jeder je vergebene tagCode, unabhaengig davon, ob das Geraet noch existiert. /// /// Die Eindeutigkeit allein aus Geraet.tagCode zu ziehen reicht nicht: verschwindet /// die Zeile, waere der Code wieder frei und ein alter Aufkleber im Regal zeigte /// auf ein fremdes Objekt. Aus diesem Buch wird nie geloescht. model TagCodeVergabe { tagCode String @id vergebenAm DateTime @default(now()) } /// Vorlagen, die auf der Geraeteseite fuer diesen Geraetetyp als Schnellstart /// vorgeschlagen werden. Bewusst ohne kundeId: welcher Schnellstart zu einem /// Geraetetyp passt, haengt am Typ und nicht am Kunden. Kundenspezifische /// Vorlagen filtert die Geraeteseite ueber Vorlage.kundeId heraus. model GeraetetypVorlage { id String @id @default(cuid()) geraeteTyp GeraeteTyp vorlageId String position Int @default(0) vorlage Vorlage @relation(fields: [vorlageId], references: [id], onDelete: Cascade) @@unique([geraeteTyp, vorlageId]) @@index([geraeteTyp, position]) } // ---- Vorlagen -------------------------------------------------------------- model Vorlage { id String @id @default(cuid()) 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) erstelltAm DateTime @default(now()) geaendertAm DateTime @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()) vorlage Vorlage @relation(fields: [vorlageId], references: [id], onDelete: Cascade) veroeffentlichtVon Benutzer? @relation("VersionVeroeffentlichtVon", fields: [veroeffentlichtVonId], references: [id], onDelete: Restrict) abschnitte VorlagenAbschnitt[] variablen VorlagenVariable[] laeufe Lauf[] @@unique([vorlageId, version]) @@index([status]) } /// Wird beim Start eines Laufs abgefragt und fuellt die Platzhalter. model VorlagenVariable { id String @id @default(cuid()) versionId String schluessel String beschriftung String typ VariablenTyp @default(TEXT) optionen String[] @default([]) pflicht Boolean @default(false) position Int @default(0) version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) @@unique([versionId, schluessel]) @@index([versionId, position]) } model VorlagenAbschnitt { id String @id @default(cuid()) versionId String titel String position Int @default(0) version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) schritte VorlagenSchritt[] @@index([versionId, position]) } model VorlagenSchritt { id String @id @default(cuid()) abschnittId String position Int @default(0) titel String /// Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. hilfetext String? typ SchrittTyp @default(HAKEN) pflicht Boolean @default(false) /// Typabhaengige Einzelheiten: Auswahloptionen, Min/Max/Einheit, bei BEFEHL /// shell, multiline und der Befehlstext. Ab Phase 4 ausserdem Connector und /// Parameter fuer Schritte vom Typ AKTION. konfig Json @default("{}") dokuUrl String? abschnitt VorlagenAbschnitt @relation(fields: [abschnittId], references: [id], onDelete: Cascade) ergebnisse Schrittergebnis[] @@index([abschnittId, position]) } // ---- Laeufe ---------------------------------------------------------------- model Lauf { id String @id @default(cuid()) vorlagenVersionId String titel String kundeId String /// Mitarbeiter aus dem Verzeichnis. Ein Lauf ohne ist gueltig – Server, /// Firewall, Patchday. mitarbeiterId String? /// Freitext, wenn die Person noch nicht im Verzeichnis existiert, etwa beim /// Onboarding. Kann spaeter ueber den UPN mit einem Mitarbeiter verknuepft /// werden; beim Verknuepfen wird dieses Feld geleert, weil eine Bedingung in /// der Datenbank beides zugleich ausschliesst. Der urspruengliche Name bleibt /// im Protokoll erhalten. mitarbeiterName String? geraetId String? /// Die beim Start gewaehlte Abteilung. Speist {{ou}} und {{gruppen}}. Steht als /// Bezug und nicht nur als Wert in variablen, damit die Druckansicht eines /// archivierten Laufs reproduzierbar bleibt, auch wenn die Abteilung spaeter /// umbenannt wird. abteilungId String? ticket String? /// Werte der VorlagenVariablen, beim Start erfasst. variablen Json @default("{}") status LaufStatus @default(OFFEN) zugewiesenAnId String? faelligAm DateTime? /// Leer bei einem Lauf, den die Anwendung selbst angelegt hat – wiederkehrende /// Pruefungen und der Start per Webhook kommen in Phase 4. Kein Codepfad darf /// annehmen, dass hier immer ein Mensch steht. erstelltVonId String? /// Bleibt leer, solange der Lauf nur zugewiesen ist. Wird beim ersten Oeffnen /// zur Bearbeitung gesetzt, zusammen mit dem Wechsel auf IN_ARBEIT. gestartetAm DateTime? abgeschlossenAm DateTime? abgeschlossenVonId String? archiviertAm DateTime? archiviertVonId String? erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt vorlagenVersion VorlagenVersion @relation(fields: [vorlagenVersionId], references: [id], onDelete: Restrict) kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: Restrict) geraet Geraet? @relation(fields: [geraetId], references: [id], onDelete: Restrict) abteilung Abteilung? @relation(fields: [abteilungId], references: [id], onDelete: Restrict) erstelltVon Benutzer? @relation("LaufErstelltVon", fields: [erstelltVonId], references: [id], onDelete: Restrict) zugewiesenAn Benutzer? @relation("LaufZugewiesenAn", fields: [zugewiesenAnId], references: [id], onDelete: Restrict) abgeschlossenVon Benutzer? @relation("LaufAbgeschlossenVon", fields: [abgeschlossenVonId], references: [id], onDelete: Restrict) archiviertVon Benutzer? @relation("LaufArchiviertVon", fields: [archiviertVonId], references: [id], onDelete: Restrict) ergebnisse Schrittergebnis[] kommentare Kommentar[] protokolle Protokoll[] @@index([kundeId, status]) @@index([geraetId, status]) @@index([mitarbeiterId]) @@index([zugewiesenAnId, status]) @@index([status, faelligAm]) @@index([vorlagenVersionId]) @@index([abteilungId]) } model Schrittergebnis { id String @id @default(cuid()) laufId String vorlagenSchrittId String zustand SchrittZustand @default(OFFEN) /// Der erfasste Wert, je nach Schritt-Typ. Nimmt ab Phase 4 auch das Ergebnis /// einer Aktion auf: Log, IDs, Zeitstempel. wert Json? /// Pflicht bei NICHT_ZUTREFFEND und FEHLGESCHLAGEN, erzwungen im Service. notiz String? aktualisiertVonId String? aktualisiertAm DateTime @updatedAt erstelltAm DateTime @default(now()) lauf Lauf @relation(fields: [laufId], references: [id], onDelete: Restrict) vorlagenSchritt VorlagenSchritt @relation(fields: [vorlagenSchrittId], references: [id], onDelete: Restrict) /// Leer, wenn der Schritt von der Anwendung selbst gesetzt wurde. Kein /// Codepfad darf annehmen, dass immer ein Mensch dahintersteht. aktualisiertVon Benutzer? @relation(fields: [aktualisiertVonId], references: [id], onDelete: Restrict) kommentare Kommentar[] @@unique([laufId, vorlagenSchrittId]) @@index([laufId, zustand]) } model Kommentar { id String @id @default(cuid()) laufId String schrittergebnisId String? benutzerId String rumpf String erstelltAm DateTime @default(now()) lauf Lauf @relation(fields: [laufId], references: [id], onDelete: Restrict) schrittergebnis Schrittergebnis? @relation(fields: [schrittergebnisId], references: [id], onDelete: Restrict) benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Restrict) @@index([laufId, erstelltAm]) @@index([schrittergebnisId]) } // ---- Protokoll und Sperren ------------------------------------------------- /// Jede Aenderung an einem Schrittergebnis, jeder Statuswechsel eines Laufs und /// jedes Veroeffentlichen einer Vorlage landet hier. Wird nie geloescht. model Protokoll { id String @id @default(cuid()) laufId String? entitaet String entitaetId String aktion String benutzerId String? vorher Json? nachher Json? erstelltAm DateTime @default(now()) lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Restrict) benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Restrict) @@index([laufId, erstelltAm]) @@index([entitaet, entitaetId]) @@index([erstelltAm]) } /// Sperre fuer Aufgaben, die nur einmal gleichzeitig laufen duerfen – zuerst der /// LDAP-Abgleich. Liegt in der Datenbank, damit auch mehrere Instanzen der /// Anwendung sich einig sind. model AbgleichSperre { schluessel String @id gehaltenVon String gehaltenSeit DateTime @default(now()) laeuftAbAm DateTime @@index([laeuftAbAm]) }