„Was ist eigentlich mit meinem Notebook passiert?“ musste bisher ein Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen** Geraeten jetzt selbst ansehen — mit der fuenften Rolle MITARBEITER, zugeordnet beim Anmelden ueber den UPN. Was er sieht, sind Titel, Zeitraum und Status. Keine Schritte, keine Notizen, keine Kommentare, keine Anhaenge: in einem Lauf stehen Arbeitsnotizen ueber Menschen und Befehle mit Platzhaltern fuer Zugangsdaten. Eine Selbstauskunft ist keine Akteneinsicht, und das haelt nicht die Seite fest, sondern das Lesemodell — eine Seite, die filtert, ist eine Seite, die es vergessen kann. `selbstauskunft` ist ein **Ersatz** fuer `lesen` und kein Zusatz; auch ein ADMIN hat es nicht. Ein zweiter Weg zu denselben Daten waere eine zweite Stelle, an der jemand die Einschraenkung auf die eigene Person vergisst. Die Datenbank verlangt das sogar: ein CHECK haelt fest, dass Rolle und Zuordnung zusammengehoeren, in beide Richtungen. Ein mehrdeutiger UPN wird abgewiesen und protokolliert. Zwei Kunden koennen dieselbe Person fuehren; ein findFirst entschiede nach Sortierung darueber, wessen Geraete jemand sieht. **Verhaltensaenderung:** wer sich anmeldet und in keiner Rollengruppe steht, bekommt nicht mehr automatisch LESER — also nicht mehr Lesezugriff auf alle Laeufe des Hauses. Das greift nur mit konfigurierten OIDC_GROUP_*; ohne sie bleibt alles wie vorher, denn ohne abgebildete Gruppen laesst sich Techniker nicht von Mitarbeiter unterscheiden. Nebenbei behoben: `staerksteRolle` benutzte LESER als Startwert und damit als Untergrenze — mit einer Rolle darunter lieferte sie mehr Rechte als uebergeben. Gefunden von einem Test, der seit #007 unveraendert dasteht. Closes #55 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
893 lines
35 KiB
Plaintext
893 lines
35 KiB
Plaintext
// 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
|
||
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")
|
||
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.
|
||
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[]
|
||
/// 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)
|
||
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])
|
||
}
|