Datenmodell: Schema, tagCodes und Geheimnisse #20
+9
-2
@@ -59,8 +59,15 @@ SEED_ADMIN_PASSWORD=
|
|||||||
# INTERN (Standard) oder DIENSTLEISTUNG. SERVICE wird als Alias akzeptiert.
|
# INTERN (Standard) oder DIENSTLEISTUNG. SERVICE wird als Alias akzeptiert.
|
||||||
SEED_MODE=INTERN
|
SEED_MODE=INTERN
|
||||||
|
|
||||||
|
# Name der eigenen Organisation. Im Modus INTERN taucht er nirgends in der
|
||||||
|
# Oberflaeche auf, wird aber beim Wechsel zu DIENSTLEISTUNG zum ersten Kunden.
|
||||||
|
SEED_ORG_NAME=Eigene Organisation
|
||||||
|
# 2 bis 6 Grossbuchstaben oder Ziffern. Im Modus INTERN unsichtbar; im Modus
|
||||||
|
# DIENSTLEISTUNG steht das Kuerzel auf Etiketten und in der Hostname-Regel.
|
||||||
|
SEED_ORG_KUERZEL=EIGEN
|
||||||
|
|
||||||
# ---------- Tests ----------
|
# ---------- Tests ----------
|
||||||
|
|
||||||
# Datenbank fuer Vitest. Ohne diese Variable ueberspringen sich die Datenbanktests.
|
# Datenbank fuer Vitest. Ohne diese Variable ueberspringen sich die Datenbanktests.
|
||||||
# Passend zu docker-compose.dev.yml (Port 5433):
|
# Passend zu docker-compose.dev.yml (Port 5434, umstellbar ueber DEV_DB_PORT):
|
||||||
TEST_DATABASE_URL=postgresql://laufzettel:laufzettel@localhost:5433/laufzettel_test?schema=public
|
TEST_DATABASE_URL=postgresql://laufzettel:laufzettel@localhost:5434/laufzettel_test?schema=public
|
||||||
|
|||||||
@@ -24,8 +24,20 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
- `pnpm docs:screenshots` erzeugt Screenshots reproduzierbar und prüft dabei gleich, dass am
|
- `pnpm docs:screenshots` erzeugt Screenshots reproduzierbar und prüft dabei gleich, dass am
|
||||||
Handy nicht horizontal gescrollt wird (#005)
|
Handy nicht horizontal gescrollt wird (#005)
|
||||||
- Issues, Labels und Meilensteine für alle vier Phasen im Gitea-Tracker (#001–#019)
|
- Issues, Labels und Meilensteine für alle vier Phasen im Gitea-Tracker (#001–#019)
|
||||||
|
- Vollständiges Datenmodell in deutscher Benennung: 20 Tabellen, 9 Aufzählungen, erste
|
||||||
|
Migration, idempotenter Seed für Betriebsmodus, eigene Organisation und Administrator (#006)
|
||||||
|
- Erzeugung der `tagCode`s für die QR-Aufkleber, mit Vergabebuch, aus dem nie gelöscht wird —
|
||||||
|
ein Code kommt auch nach dem Löschen eines Geräts nicht zurück (#006)
|
||||||
|
- Verschlüsselung von Geheimnissen mit AES-256-GCM aus `APP_SECRET`, für das LDAP-Bind-Passwort
|
||||||
|
und später die Zugangsdaten der Connectoren (#006)
|
||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
- Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen,
|
- Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen,
|
||||||
statt mit Standardwerten zu starten (#005)
|
statt mit Standardwerten zu starten (#005)
|
||||||
|
- Die Mandantengrenze steht als zusammengesetzter Fremdschlüssel in der Datenbank: ein Lauf kann
|
||||||
|
strukturell kein Gerät, keine Person und keine Abteilung eines fremden Kunden wählen (#006)
|
||||||
|
- Ein Bind-Passwort ohne Versionspräfix wird von der Datenbank abgewiesen, ein versehentlich im
|
||||||
|
Klartext geschriebenes fällt damit sofort auf (#006)
|
||||||
|
- Ein Lauf nimmt seine Schrittergebnisse und Kommentare beim Löschen nicht mehr mit, und ein
|
||||||
|
Protokolleintrag kann seinen Urheber nicht mehr verlieren (#006)
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ Der Auftrag nennt englische Modelle. Verbindlich gilt:
|
|||||||
| Device | `Geraet` | StepResult | `Schrittergebnis` |
|
| Device | `Geraet` | StepResult | `Schrittergebnis` |
|
||||||
| DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` |
|
| DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` |
|
||||||
| — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` |
|
| — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` |
|
||||||
|
| — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` |
|
||||||
|
|
||||||
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
|
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
|
||||||
`LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` ·
|
`LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` ·
|
||||||
`SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` ·
|
`SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` ·
|
||||||
`SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) ·
|
`SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) ·
|
||||||
|
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
|
||||||
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
|
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
|
||||||
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}`.
|
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}`.
|
||||||
|
|
||||||
@@ -76,7 +78,14 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
|
|||||||
- **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in
|
- **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in
|
||||||
`Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht.
|
`Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht.
|
||||||
- **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt
|
- **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt
|
||||||
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json.
|
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und
|
||||||
|
`Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf
|
||||||
|
ist darstellbar.
|
||||||
|
- **Regeln stehen in der Datenbank, nicht nur im Service.** Zwölf CHECK-Bedingungen, zwei
|
||||||
|
partielle Eindeutigkeitsindizes und vier zusammengesetzte Fremdschlüssel halten fest, was auch
|
||||||
|
dann gelten muss, wenn jemand an der Service-Schicht vorbei schreibt. Die Mandantengrenze ist
|
||||||
|
einer davon: ein Lauf kann strukturell kein Gerät, keine Person und keine Abteilung eines
|
||||||
|
fremden Kunden wählen. Der Service prüft dasselbe noch einmal, nur mit besserer Meldung.
|
||||||
|
|
||||||
## Werkzeuge und Versionen
|
## Werkzeuge und Versionen
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
# Nur fuer die Entwicklung: Postgres auf Port 5433, damit ein lokales 5432 frei bleibt.
|
# Nur fuer die Entwicklung: PostgreSQL auf einem eigenen Port, damit ein lokales
|
||||||
|
# 5432 und die Datenbanken anderer Projekte frei bleiben. Den Port bei Bedarf
|
||||||
|
# ueber DEV_DB_PORT umstellen.
|
||||||
# Diese Datei niemals im Betrieb verwenden – dort ist die Datenbank absichtlich zu.
|
# Diese Datei niemals im Betrieb verwenden – dort ist die Datenbank absichtlich zu.
|
||||||
|
|
||||||
name: laufzettel-dev
|
name: laufzettel-dev
|
||||||
@@ -12,7 +14,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: laufzettel
|
POSTGRES_PASSWORD: laufzettel
|
||||||
POSTGRES_DB: laufzettel
|
POSTGRES_DB: laufzettel
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5433:5432"
|
- "127.0.0.1:${DEV_DB_PORT:-5434}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- datenbank-dev:/var/lib/postgresql/data
|
- datenbank-dev:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -80,6 +80,37 @@ Schritte**. Damit der Schritt dorthin keine Migration braucht:
|
|||||||
- kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt wird
|
- kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt wird
|
||||||
- das Krypto-Modul für das LDAP-Bind-Passwort ist schon der Platz für Connector-Geheimnisse
|
- das Krypto-Modul für das LDAP-Bind-Passwort ist schon der Platz für Connector-Geheimnisse
|
||||||
|
|
||||||
|
## 5a. Was in der Datenbank steht und warum
|
||||||
|
|
||||||
|
Die Service-Schicht ist der richtige Ort für Regeln mit verständlichen Meldungen. Sie ist aber
|
||||||
|
nicht der einzige Weg, auf dem Daten entstehen: Migrationen, `psql`, Skripte und ab Phase 4 die
|
||||||
|
Connectoren schreiben an ihr vorbei. Was stillschweigend falsch werden könnte, steht deshalb
|
||||||
|
zusätzlich in der Datenbank:
|
||||||
|
|
||||||
|
| Regel | Form |
|
||||||
|
|---|---|
|
||||||
|
| Höchstens eine eigene Organisation | partieller Eindeutigkeitsindex |
|
||||||
|
| Höchstens ein Entwurf je Vorlage | partieller Eindeutigkeitsindex |
|
||||||
|
| Kundenkürzel, `tagCode`-Form, verschlüsseltes Bind-Passwort | CHECK auf die Form |
|
||||||
|
| Registriertes Gerät braucht Hostname oder Bezeichnung | CHECK, greift erst ab `registriertAm` |
|
||||||
|
| Mitarbeiter aus dem Verzeichnis **oder** Freitextname | CHECK |
|
||||||
|
| Status und Zeitstempel laufen nicht auseinander | vier CHECKs |
|
||||||
|
| `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` brauchen eine Notiz | CHECK |
|
||||||
|
| Einstellung ist eine einzelne Zeile | CHECK auf die feste Kennung |
|
||||||
|
| **Mandantengrenze** | vier zusammengesetzte Fremdschlüssel |
|
||||||
|
|
||||||
|
Die Mandantengrenze verdient die Erklärung: `Lauf` verweist mit `("geraetId", "kundeId")` auf
|
||||||
|
`Geraet("id", "kundeId")`, analog für Person und Abteilung, und `Geraet` ebenso auf den Besitzer.
|
||||||
|
Damit ist ein Lauf, der ein Gerät eines fremden Kunden zieht, nicht mehr bloß ein Fehler im
|
||||||
|
Service, sondern strukturell unmöglich. `MATCH SIMPLE` sorgt dafür, dass ein Lauf ohne Gerät
|
||||||
|
weiterhin erlaubt ist.
|
||||||
|
|
||||||
|
Zwei Dinge sind bewusst **nicht** kaskadierend: ein `Lauf` nimmt seine Schrittergebnisse und
|
||||||
|
Kommentare nicht mit, und ein `Protokoll`-Eintrag verliert seinen Urheber nicht. Beides stünde
|
||||||
|
gegen den Zweck eines Archivs. Der `tagCode` ist zusätzlich in `TagCodeVergabe` festgehalten,
|
||||||
|
aus dem nie gelöscht wird — sonst würde ein Code nach dem Löschen eines Geräts wieder frei und
|
||||||
|
ein alter Aufkleber zeigte auf ein fremdes Objekt.
|
||||||
|
|
||||||
## 6. Betrieb: zwei Wege, eine Regel
|
## 6. Betrieb: zwei Wege, eine Regel
|
||||||
|
|
||||||
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
||||||
|
|||||||
+63
-2
@@ -8,11 +8,12 @@ als Unterpunkte mit Aufgabe und Dauer.
|
|||||||
| Issue | Titel | Tatsächlich |
|
| Issue | Titel | Tatsächlich |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| #005 | M0 – Gerüst, Prozess, Design-Token | 1 h 25 min |
|
| #005 | M0 – Gerüst, Prozess, Design-Token | 1 h 25 min |
|
||||||
| | **Summe** | **1 h 25 min** |
|
| #006 | M1 – Datenmodell | 1 h 10 min |
|
||||||
|
| | **Summe** | **2 h 35 min** |
|
||||||
|
|
||||||
| Meilenstein | Tatsächlich | Status |
|
| Meilenstein | Tatsächlich | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v0.1.0 | 1 h 25 min | in Arbeit |
|
| v0.1.0 | 2 h 35 min | in Arbeit |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,3 +105,63 @@ als Unterpunkte mit Aufgabe und Dauer.
|
|||||||
|
|
||||||
- #006 Datenmodell: das vollständige Schema in deutscher Benennung, `tagCode`-Generator,
|
- #006 Datenmodell: das vollständige Schema in deutscher Benennung, `tagCode`-Generator,
|
||||||
Krypto-Helfer, erste Migration, idempotenter Seed-Rumpf.
|
Krypto-Helfer, erste Migration, idempotenter Seed-Rumpf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-25 · Sitzung 2 · 00:05–01:15
|
||||||
|
|
||||||
|
**Issue:** #006 (M1)
|
||||||
|
**Version:** 0.1.0
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- Vollständiges Schema in deutscher Benennung: 20 Tabellen, 9 Aufzählungen, eine Migration
|
||||||
|
- `src/lib/tagcode.ts` und `src/lib/geheimnisse.ts` samt Tests, parallel von einem Sub-Agenten
|
||||||
|
gebaut, während ich das Schema schrieb (Dauer dort: rund 10 min)
|
||||||
|
- Idempotenter Seed für Betriebsmodus, eigene Organisation und Administrator — zweimal
|
||||||
|
hintereinander gefahren, keine Zeile geändert
|
||||||
|
- Unabhängiger Review-Agent gegen die Akzeptanzkriterien; alle Blocker und alle neun „sollte
|
||||||
|
behoben werden"-Punkte abgearbeitet
|
||||||
|
- **106 Tests** grün (vorher 11), davon 42 gegen die echten Datenbankregeln
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
- **Regeln gehören zusätzlich in die Datenbank.** Zwölf CHECK-Bedingungen, zwei partielle
|
||||||
|
Eindeutigkeitsindizes und vier zusammengesetzte Fremdschlüssel. Begründung: die Service-Schicht
|
||||||
|
ist nicht der einzige Weg, auf dem Daten entstehen — Migrationen, `psql`, Skripte und ab
|
||||||
|
Phase 4 die Connectoren schreiben an ihr vorbei.
|
||||||
|
- **Die Mandantengrenze wird strukturell erzwungen.** `Lauf` verweist mit
|
||||||
|
`("geraetId", "kundeId")` auf `Geraet("id", "kundeId")`, analog für Person und Abteilung.
|
||||||
|
`docs/architektur.md` nennt die Grenze eine Sicherheitsgrenze — dann soll sie nicht allein an
|
||||||
|
`kundeErmitteln()` hängen. `MATCH SIMPLE` lässt einen Lauf ohne Gerät weiter zu.
|
||||||
|
- **Kein Cascade dort, wo das Archiv hängt.** Ein `DELETE` auf einen Lauf hätte Schrittergebnisse
|
||||||
|
und Kommentare stillschweigend mitgenommen, ein Protokolleintrag hätte seinen Urheber verlieren
|
||||||
|
können. Beides jetzt `Restrict`.
|
||||||
|
- **`TagCodeVergabe` als Vergabebuch.** Die Eindeutigkeit allein aus `Geraet.tagCode` zu ziehen
|
||||||
|
reicht nicht: verschwindet die Zeile, wäre der Code wieder frei und ein alter Aufkleber im
|
||||||
|
Regal zeigte auf ein fremdes Objekt.
|
||||||
|
- **Widerspruch im Auftrag beim tagCode-Alphabet.** Das wörtliche Alphabet enthält ein `L`,
|
||||||
|
die Klammer daneben verbietet es. Aufgelöst zugunsten des wörtlichen Alphabets: die Klammer
|
||||||
|
benennt Verwechslungsgruppen, keine Ausschlussliste — aus `{1, I, L}` sind `1` und `I` weg,
|
||||||
|
dadurch ist `L` eindeutig. Das Akzeptanzkriterium in #006 ist richtiggestellt worden, statt
|
||||||
|
einen grünen Test darüber zu setzen.
|
||||||
|
- **Ein Systemlauf muss darstellbar sein.** `Lauf.erstelltVonId` ist optional, wie schon
|
||||||
|
`Schrittergebnis.aktualisiertVonId`. Wiederkehrende Läufe und der Start per Webhook (Phase 4)
|
||||||
|
haben keinen Menschen dahinter.
|
||||||
|
|
||||||
|
### Probleme
|
||||||
|
|
||||||
|
- **`prisma migrate reset --force` zeigte den Bestätigungsdialog trotzdem** und ließ einen
|
||||||
|
Schema-Engine-Prozess zurück, der die Advisory-Sperre hielt; der nächste `migrate dev` lief
|
||||||
|
daraufhin in einen Timeout. Verlässlicher: `DROP SCHEMA public CASCADE` per psql, dann
|
||||||
|
`migrate dev --create-only < /dev/null`.
|
||||||
|
- **Port 5433 ist von ProxLedger belegt.** Die Entwicklungsdatenbank liegt jetzt auf 5434,
|
||||||
|
umstellbar über `DEV_DB_PORT`.
|
||||||
|
- **Eine Regex-Ersetzung hat `quelle.length` auch aus der Längenprüfung gerissen** und den
|
||||||
|
Krypto-Helfer unübersetzbar gemacht. Die Testsuite hat es sofort gezeigt — ein Argument mehr
|
||||||
|
dafür, nach jeder Sammeländerung wirklich alle Schranken laufen zu lassen.
|
||||||
|
|
||||||
|
### Nächster Schritt
|
||||||
|
|
||||||
|
- #007 Auth, Rollen, Benutzer: `openid-client` mit Discovery, `jose`-Sitzungscookie, lokaler
|
||||||
|
Login mit argon2, `RECHTE`-Tabelle, Login-Seite mit Rücksprung auf die ursprüngliche URL.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# #006 M1 – Datenmodell
|
# #006 M1 – Datenmodell
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** phase-1, backend
|
**Labels:** phase-1, backend
|
||||||
**Geschätzt:** 5 h **Tatsächlich:** –
|
**Geschätzt:** 5 h **Tatsächlich:** 1 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -11,23 +11,49 @@ Das vollständige Schema in deutscher Benennung, sodass alle folgenden Meilenste
|
|||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] Prisma 7: Generator `prisma-client` mit `output = "../src/generated/prisma"`, `datasource` ohne `url`, `@prisma/adapter-pg`
|
- [x] Prisma 7: Generator `prisma-client` mit `output = "../src/generated/prisma"`, `datasource` ohne `url`, `@prisma/adapter-pg`
|
||||||
- [ ] `prisma.config.ts` mit `migrations.seed = "tsx prisma/seed.ts"`
|
- [x] `prisma.config.ts` mit `migrations.seed = "tsx prisma/seed.ts"`
|
||||||
- [ ] `src/lib/db.ts`: Lazy-Singleton-Proxy, damit `next build` ohne `DATABASE_URL` läuft, Dev-Instanz an `globalThis`
|
- [x] `src/lib/db.ts`: Lazy-Singleton-Proxy, damit `next build` ohne `DATABASE_URL` läuft, Dev-Instanz an `globalThis`
|
||||||
- [ ] Modelle: `Benutzer`, `Kunde`, `Einstellung`, `Verzeichnisquelle`, `Mitarbeiter`, `Abteilung`, `Geraet`, `GeraetetypVorlage`, `Vorlage`, `VorlagenVersion`, `VorlagenVariable`, `VorlagenAbschnitt`, `VorlagenSchritt`, `Lauf`, `Schrittergebnis`, `Kommentar`, `Protokoll`, `AbgleichSperre`
|
- [x] Modelle: `Benutzer`, `Kunde`, `Einstellung`, `Verzeichnisquelle`, `Mitarbeiter`, `Abteilung`, `Geraet`, `GeraetetypVorlage`, `Vorlage`, `VorlagenVersion`, `VorlagenVariable`, `VorlagenAbschnitt`, `VorlagenSchritt`, `Lauf`, `Schrittergebnis`, `Kommentar`, `Protokoll`, `AbgleichSperre`
|
||||||
- [ ] Enums: `Rolle`, `Betriebsmodus`, `LaufStatus`, `SchrittZustand`, `SchrittTyp`, `GeraeteTyp`, `VersionStatus`, `Herkunft`
|
- [x] Enums: `Rolle`, `Betriebsmodus`, `LaufStatus`, `SchrittZustand`, `SchrittTyp`, `GeraeteTyp`, `VersionStatus`, `Herkunft`
|
||||||
- [ ] Unique-Constraints: `Geraet.tagCode`, `(laufId, vorlagenSchrittId)` auf `Schrittergebnis`, `(quelleId, externeId)` auf `Mitarbeiter`, `(kundeId, name)` auf `Abteilung`, `(kundeId)` partiell für `isteigeneOrganisation`
|
- [x] Unique-Constraints: `Geraet.tagCode`, `(laufId, vorlagenSchrittId)` auf `Schrittergebnis`, `(quelleId, externeId)` auf `Mitarbeiter`, `(kundeId, name)` auf `Abteilung`, `(kundeId)` partiell für `isteigeneOrganisation`
|
||||||
- [ ] `pg_trgm`-Erweiterung und GIN-Indizes für die Archivsuche über `Lauf.titel`, `Schrittergebnis.notiz`, `Kommentar.rumpf`
|
- [x] `pg_trgm`-Erweiterung und GIN-Indizes für die Archivsuche über `Lauf.titel`, `Schrittergebnis.notiz`, `Kommentar.rumpf`
|
||||||
- [ ] Jede kundenbezogene Zeile trägt `kundeId` als Pflichtfeld: `Geraet`, `Mitarbeiter`, `Abteilung`, `Verzeichnisquelle`, `Lauf`
|
- [x] Jede kundenbezogene Zeile trägt `kundeId` als Pflichtfeld: `Geraet`, `Mitarbeiter`, `Abteilung`, `Verzeichnisquelle`, `Lauf`
|
||||||
- [ ] `src/lib/tagcode.ts`: Alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, Präfix `LZ-`, 6 Zeichen, Kollisionsprüfung serverseitig
|
- [x] `src/lib/tagcode.ts`: Alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, Präfix `LZ-`, 6 Zeichen, Kollisionsprüfung serverseitig
|
||||||
- [ ] `src/lib/geheimnisse.ts`: AES-256-GCM aus `APP_SECRET` (scrypt-abgeleitet), für `bindPasswortVerschluesselt`
|
- [x] `src/lib/geheimnisse.ts`: AES-256-GCM aus `APP_SECRET` (scrypt-abgeleitet), für `bindPasswortVerschluesselt`
|
||||||
- [ ] Erste Migration, `prisma/seed.ts` als idempotenter Rumpf: Admin aus `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` mit `@node-rs/argon2`, eigene Organisation, `Einstellung` mit `Betriebsmodus.INTERN`
|
- [x] Erste Migration, `prisma/seed.ts` als idempotenter Rumpf: Admin aus `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` mit `@node-rs/argon2`, eigene Organisation, `Einstellung` mit `Betriebsmodus.INTERN`
|
||||||
- [ ] `tests/setup-env.ts`, `tests/global-setup.ts`, `tests/helpers/db.ts` nach ProxLedger-Muster
|
- [x] `tests/setup-env.ts`, `tests/global-setup.ts`, `tests/helpers/db.ts` nach ProxLedger-Muster
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] `prisma migrate deploy` läuft auf leerer Datenbank durch
|
- [x] `prisma migrate deploy` läuft auf leerer Datenbank durch
|
||||||
- [ ] `pnpm db:seed` zweimal hintereinander ohne Fehler und ohne Datenänderung
|
- [x] `pnpm db:seed` zweimal hintereinander ohne Fehler und ohne Datenänderung
|
||||||
- [ ] Tests: `tagCode` benutzt nur das erlaubte Alphabet, enthält nie `0`, `O`, `1`, `I` oder `L`, ist eindeutig über 10 000 Ziehungen
|
- [x] Tests: `tagCode` benutzt nur das vorgegebene Alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` und enthält damit nie `0`, `O`, `1` oder `I`; eindeutig über 10 000 Ziehungen
|
||||||
- [ ] Tests: verschlüsseln und entschlüsseln ist verlustfrei, zwei Verschlüsselungen desselben Klartexts unterscheiden sich (eigener IV), ein manipulierter Geheimtext wirft
|
- [x] Tests: verschlüsseln und entschlüsseln ist verlustfrei, zwei Verschlüsselungen desselben Klartexts unterscheiden sich (eigener IV), ein manipulierter Geheimtext wirft
|
||||||
- [ ] `next build` gelingt ohne gesetzte `DATABASE_URL`
|
- [x] `next build` gelingt ohne gesetzte `DATABASE_URL`
|
||||||
|
|
||||||
|
## Beim Bauen aufgefallen
|
||||||
|
|
||||||
|
- **Widerspruch im Auftrag beim tagCode-Alphabet, aufgelöst.** Das wörtlich vorgegebene Alphabet
|
||||||
|
`ABCDEFGHJKLMNPQRSTUVWXYZ23456789` enthält ein `L`, obwohl die Klammer daneben „ohne 0/O,
|
||||||
|
1/I/L" sagt. Beides zugleich geht nicht: ohne `L` blieben 31 Zeichen. Wir folgen dem wörtlichen
|
||||||
|
Alphabet, weil die Klammer die *Verwechslungsgruppen* benennt und keine Ausschlussliste ist:
|
||||||
|
aus `{0, O}` sind beide weg, aus `{1, I, L}` sind `1` und `I` weg — dadurch ist `L` eindeutig.
|
||||||
|
Das Präfix `LZ-` enthält ohnehin in jedem Code ein `L`. Das Akzeptanzkriterium oben ist
|
||||||
|
entsprechend richtiggestellt; ein grüner Test über einem unerfüllten Kriterium wäre schlimmer
|
||||||
|
als ein korrigiertes Kriterium.
|
||||||
|
- **Der unabhängige Review hat drei Lücken gefunden, die alle behoben sind:** der geforderten
|
||||||
|
Suchspalte `Schrittergebnis.wert` fehlte der Trigramm-Index; die im Lauf gewählte Abteilung war
|
||||||
|
nur ein Wert in `variablen` statt ein Bezug, wodurch die Druckansicht eines archivierten Laufs
|
||||||
|
nicht reproduzierbar gewesen wäre; und für die Kundenbindung eines Benutzers (#008) gab es
|
||||||
|
keine Struktur, sie hätte also nur im Token stehen können — gegen die eigene Regel, Rechte pro
|
||||||
|
Anfrage aus der Datenbank zu lesen.
|
||||||
|
- **Ein `DELETE` auf einen Lauf hätte Schrittergebnisse und Kommentare stillschweigend
|
||||||
|
mitgenommen.** Jetzt `Restrict`; ein Lauf lässt sich nur noch bewusst und in der richtigen
|
||||||
|
Reihenfolge entfernen. Dasselbe für das `Protokoll`, das seinen Urheber nicht mehr verlieren
|
||||||
|
kann.
|
||||||
|
- **`tagCode` wäre nach dem Löschen eines Geräts wieder frei gewesen.** Deshalb `TagCodeVergabe`
|
||||||
|
als Vergabebuch, aus dem nie gelöscht wird.
|
||||||
|
- **`prisma migrate reset --force` hat den Bestätigungsdialog trotzdem gezeigt** und die
|
||||||
|
Advisory-Sperre gehalten. Schneller und verlässlicher: `DROP SCHEMA public CASCADE` per psql,
|
||||||
|
dann `migrate dev --create-only`.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
|||||||
| #003 | [Phase 3: Protokoll, bedingte Schritte, Freigabe](003-epic-phase-3.md) | offen | v0.3.0 | – | – |
|
| #003 | [Phase 3: Protokoll, bedingte Schritte, Freigabe](003-epic-phase-3.md) | offen | v0.3.0 | – | – |
|
||||||
| #004 | [Phase 4: Aktionen, Connectors, REST-API](004-epic-phase-4.md) | offen | v0.4.0 | – | – |
|
| #004 | [Phase 4: Aktionen, Connectors, REST-API](004-epic-phase-4.md) | offen | v0.4.0 | – | – |
|
||||||
| #005 | [M0 – Gerüst, Prozess, Design-Token](005-geruest-prozess-design.md) | erledigt | v0.1.0 | 6 h | 1 h 25 min |
|
| #005 | [M0 – Gerüst, Prozess, Design-Token](005-geruest-prozess-design.md) | erledigt | v0.1.0 | 6 h | 1 h 25 min |
|
||||||
| #006 | [M1 – Datenmodell](006-datenmodell.md) | offen | v0.1.0 | 5 h | – |
|
| #006 | [M1 – Datenmodell](006-datenmodell.md) | erledigt | v0.1.0 | 5 h | 1 h 10 min |
|
||||||
| #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | offen | v0.1.0 | 8 h | – |
|
| #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | offen | v0.1.0 | 8 h | – |
|
||||||
| #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | offen | v0.1.0 | 6 h | – |
|
| #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | offen | v0.1.0 | 6 h | – |
|
||||||
| #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | offen | v0.1.0 | 6 h | – |
|
| #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | offen | v0.1.0 | 6 h | – |
|
||||||
@@ -24,4 +24,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
|||||||
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
|
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
|
||||||
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
|
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
|
||||||
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
|
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
|
||||||
| | **Summe** | | | **136 h** | **1 h 25 min** |
|
| | **Summe** | | | **136 h** | **2 h 35 min** |
|
||||||
|
|||||||
@@ -0,0 +1,734 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Rolle" AS ENUM ('ADMIN', 'REDAKTEUR', 'TECHNIKER', 'LESER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Betriebsmodus" AS ENUM ('INTERN', 'DIENSTLEISTUNG');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LaufStatus" AS ENUM ('OFFEN', 'IN_ARBEIT', 'ABGESCHLOSSEN', 'ARCHIVIERT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SchrittZustand" AS ENUM ('OFFEN', 'ERLEDIGT', 'NICHT_ZUTREFFEND', 'FEHLGESCHLAGEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SchrittTyp" AS ENUM ('HAKEN', 'TEXT', 'ZAHL', 'AUSWAHL', 'DATUM', 'BEFEHL');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VariablenTyp" AS ENUM ('TEXT', 'ZAHL', 'AUSWAHL', 'DATUM');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "GeraeteTyp" AS ENUM ('PC', 'NOTEBOOK', 'SERVER', 'FIREWALL', 'SWITCH', 'DRUCKER', 'RAUM', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VersionStatus" AS ENUM ('ENTWURF', 'VEROEFFENTLICHT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Herkunft" AS ENUM ('ABGLEICH', 'MANUELL');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Einstellung" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'global',
|
||||||
|
"modus" "Betriebsmodus" NOT NULL DEFAULT 'INTERN',
|
||||||
|
"namensregeln" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"gesperrteOu" TEXT,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Einstellung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Kunde" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"kuerzel" TEXT NOT NULL,
|
||||||
|
"istEigeneOrganisation" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"namensregeln" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"notizen" TEXT,
|
||||||
|
"archiviert" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Kunde_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Benutzer" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"rolle" "Rolle" NOT NULL DEFAULT 'LESER',
|
||||||
|
"oidcSub" TEXT,
|
||||||
|
"passwortHash" TEXT,
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"sitzungenUngueltigAb" TIMESTAMP(3),
|
||||||
|
"letzteAnmeldungAm" TIMESTAMP(3),
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Benutzer_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BenutzerKunde" (
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"kundeId" TEXT NOT NULL,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "BenutzerKunde_pkey" PRIMARY KEY ("benutzerId","kundeId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Verzeichnisquelle" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kundeId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"bindDn" TEXT NOT NULL,
|
||||||
|
"bindPasswortVerschluesselt" TEXT NOT NULL,
|
||||||
|
"basisDn" TEXT NOT NULL,
|
||||||
|
"filter" TEXT NOT NULL,
|
||||||
|
"attributZuordnung" JSONB NOT NULL,
|
||||||
|
"caZertifikatPem" TEXT,
|
||||||
|
"abgleichIntervallMinuten" INTEGER NOT NULL DEFAULT 60,
|
||||||
|
"letzterAbgleichAm" TIMESTAMP(3),
|
||||||
|
"letzterAbgleichStatus" TEXT,
|
||||||
|
"letzterAbgleichFehler" TEXT,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Verzeichnisquelle_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Mitarbeiter" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kundeId" TEXT NOT NULL,
|
||||||
|
"quelleId" TEXT,
|
||||||
|
"externeId" TEXT,
|
||||||
|
"vorname" TEXT NOT NULL,
|
||||||
|
"nachname" TEXT NOT NULL,
|
||||||
|
"anzeigename" TEXT NOT NULL,
|
||||||
|
"mail" TEXT,
|
||||||
|
"upn" TEXT,
|
||||||
|
"sam" TEXT,
|
||||||
|
"dn" TEXT,
|
||||||
|
"abteilung" TEXT,
|
||||||
|
"titel" TEXT,
|
||||||
|
"vorgesetzterExterneId" TEXT,
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"herkunft" "Herkunft" NOT NULL DEFAULT 'MANUELL',
|
||||||
|
"zuletztGesehenAm" TIMESTAMP(3),
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Mitarbeiter_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Abteilung" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kundeId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"zielOu" TEXT,
|
||||||
|
"standardGruppen" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"mailDomain" TEXT,
|
||||||
|
"notizen" TEXT,
|
||||||
|
"archiviert" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Abteilung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Geraet" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kundeId" TEXT NOT NULL,
|
||||||
|
"mitarbeiterId" TEXT,
|
||||||
|
"tagCode" TEXT NOT NULL,
|
||||||
|
"hostname" TEXT,
|
||||||
|
"bezeichnung" TEXT,
|
||||||
|
"seriennummer" TEXT,
|
||||||
|
"inventarnummer" TEXT,
|
||||||
|
"typ" "GeraeteTyp" NOT NULL DEFAULT 'SONSTIGES',
|
||||||
|
"modell" TEXT,
|
||||||
|
"notizen" TEXT,
|
||||||
|
"archiviert" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"registriertAm" TIMESTAMP(3),
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Geraet_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TagCodeVergabe" (
|
||||||
|
"tagCode" TEXT NOT NULL,
|
||||||
|
"vergebenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TagCodeVergabe_pkey" PRIMARY KEY ("tagCode")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GeraetetypVorlage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"geraeteTyp" "GeraeteTyp" NOT NULL,
|
||||||
|
"vorlageId" TEXT NOT NULL,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "GeraetetypVorlage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Vorlage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"beschreibung" TEXT,
|
||||||
|
"kategorie" TEXT,
|
||||||
|
"schlagworte" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"kundeId" TEXT,
|
||||||
|
"archiviert" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Vorlage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VorlagenVersion" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"vorlageId" TEXT NOT NULL,
|
||||||
|
"version" INTEGER NOT NULL,
|
||||||
|
"status" "VersionStatus" NOT NULL DEFAULT 'ENTWURF',
|
||||||
|
"aenderungsnotiz" TEXT,
|
||||||
|
"veroeffentlichtAm" TIMESTAMP(3),
|
||||||
|
"veroeffentlichtVonId" TEXT,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "VorlagenVersion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VorlagenVariable" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"versionId" TEXT NOT NULL,
|
||||||
|
"schluessel" TEXT NOT NULL,
|
||||||
|
"beschriftung" TEXT NOT NULL,
|
||||||
|
"typ" "VariablenTyp" NOT NULL DEFAULT 'TEXT',
|
||||||
|
"optionen" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"pflicht" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "VorlagenVariable_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VorlagenAbschnitt" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"versionId" TEXT NOT NULL,
|
||||||
|
"titel" TEXT NOT NULL,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "VorlagenAbschnitt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VorlagenSchritt" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"abschnittId" TEXT NOT NULL,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"titel" TEXT NOT NULL,
|
||||||
|
"hilfetext" TEXT,
|
||||||
|
"typ" "SchrittTyp" NOT NULL DEFAULT 'HAKEN',
|
||||||
|
"pflicht" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"konfig" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"dokuUrl" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "VorlagenSchritt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Lauf" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"vorlagenVersionId" TEXT NOT NULL,
|
||||||
|
"titel" TEXT NOT NULL,
|
||||||
|
"kundeId" TEXT NOT NULL,
|
||||||
|
"mitarbeiterId" TEXT,
|
||||||
|
"mitarbeiterName" TEXT,
|
||||||
|
"geraetId" TEXT,
|
||||||
|
"abteilungId" TEXT,
|
||||||
|
"ticket" TEXT,
|
||||||
|
"variablen" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"status" "LaufStatus" NOT NULL DEFAULT 'OFFEN',
|
||||||
|
"zugewiesenAnId" TEXT,
|
||||||
|
"faelligAm" TIMESTAMP(3),
|
||||||
|
"erstelltVonId" TEXT,
|
||||||
|
"gestartetAm" TIMESTAMP(3),
|
||||||
|
"abgeschlossenAm" TIMESTAMP(3),
|
||||||
|
"abgeschlossenVonId" TEXT,
|
||||||
|
"archiviertAm" TIMESTAMP(3),
|
||||||
|
"archiviertVonId" TEXT,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Lauf_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Schrittergebnis" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"laufId" TEXT NOT NULL,
|
||||||
|
"vorlagenSchrittId" TEXT NOT NULL,
|
||||||
|
"zustand" "SchrittZustand" NOT NULL DEFAULT 'OFFEN',
|
||||||
|
"wert" JSONB,
|
||||||
|
"notiz" TEXT,
|
||||||
|
"aktualisiertVonId" TEXT,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Schrittergebnis_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Kommentar" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"laufId" TEXT NOT NULL,
|
||||||
|
"schrittergebnisId" TEXT,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"rumpf" TEXT NOT NULL,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Kommentar_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Protokoll" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"laufId" TEXT,
|
||||||
|
"entitaet" TEXT NOT NULL,
|
||||||
|
"entitaetId" TEXT NOT NULL,
|
||||||
|
"aktion" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT,
|
||||||
|
"vorher" JSONB,
|
||||||
|
"nachher" JSONB,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Protokoll_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AbgleichSperre" (
|
||||||
|
"schluessel" TEXT NOT NULL,
|
||||||
|
"gehaltenVon" TEXT NOT NULL,
|
||||||
|
"gehaltenSeit" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"laeuftAbAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AbgleichSperre_pkey" PRIMARY KEY ("schluessel")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Kunde_kuerzel_key" ON "Kunde"("kuerzel");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Kunde_archiviert_idx" ON "Kunde"("archiviert");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Benutzer_email_key" ON "Benutzer"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Benutzer_oidcSub_key" ON "Benutzer"("oidcSub");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Benutzer_aktiv_idx" ON "Benutzer"("aktiv");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Benutzer_rolle_idx" ON "Benutzer"("rolle");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BenutzerKunde_kundeId_idx" ON "BenutzerKunde"("kundeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Verzeichnisquelle_kundeId_idx" ON "Verzeichnisquelle"("kundeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Verzeichnisquelle_aktiv_idx" ON "Verzeichnisquelle"("aktiv");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Mitarbeiter_kundeId_aktiv_idx" ON "Mitarbeiter"("kundeId", "aktiv");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Mitarbeiter_abteilung_idx" ON "Mitarbeiter"("abteilung");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Mitarbeiter_upn_idx" ON "Mitarbeiter"("upn");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Mitarbeiter_sam_idx" ON "Mitarbeiter"("sam");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Mitarbeiter_quelleId_externeId_key" ON "Mitarbeiter"("quelleId", "externeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Mitarbeiter_id_kundeId_key" ON "Mitarbeiter"("id", "kundeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Abteilung_kundeId_archiviert_idx" ON "Abteilung"("kundeId", "archiviert");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Abteilung_kundeId_name_key" ON "Abteilung"("kundeId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Abteilung_id_kundeId_key" ON "Abteilung"("id", "kundeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Geraet_tagCode_key" ON "Geraet"("tagCode");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Geraet_kundeId_archiviert_idx" ON "Geraet"("kundeId", "archiviert");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Geraet_mitarbeiterId_idx" ON "Geraet"("mitarbeiterId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Geraet_typ_idx" ON "Geraet"("typ");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Geraet_registriertAm_idx" ON "Geraet"("registriertAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Geraet_id_kundeId_key" ON "Geraet"("id", "kundeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GeraetetypVorlage_geraeteTyp_position_idx" ON "GeraetetypVorlage"("geraeteTyp", "position");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GeraetetypVorlage_geraeteTyp_vorlageId_key" ON "GeraetetypVorlage"("geraeteTyp", "vorlageId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Vorlage_archiviert_idx" ON "Vorlage"("archiviert");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Vorlage_kategorie_idx" ON "Vorlage"("kategorie");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Vorlage_kundeId_idx" ON "Vorlage"("kundeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VorlagenVersion_status_idx" ON "VorlagenVersion"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VorlagenVersion_vorlageId_version_key" ON "VorlagenVersion"("vorlageId", "version");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VorlagenVariable_versionId_position_idx" ON "VorlagenVariable"("versionId", "position");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VorlagenVariable_versionId_schluessel_key" ON "VorlagenVariable"("versionId", "schluessel");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VorlagenAbschnitt_versionId_position_idx" ON "VorlagenAbschnitt"("versionId", "position");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VorlagenSchritt_abschnittId_position_idx" ON "VorlagenSchritt"("abschnittId", "position");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Lauf_kundeId_status_idx" ON "Lauf"("kundeId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Lauf_geraetId_status_idx" ON "Lauf"("geraetId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Lauf_mitarbeiterId_idx" ON "Lauf"("mitarbeiterId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Lauf_zugewiesenAnId_status_idx" ON "Lauf"("zugewiesenAnId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Lauf_status_faelligAm_idx" ON "Lauf"("status", "faelligAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Lauf_vorlagenVersionId_idx" ON "Lauf"("vorlagenVersionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Lauf_abteilungId_idx" ON "Lauf"("abteilungId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Schrittergebnis_laufId_zustand_idx" ON "Schrittergebnis"("laufId", "zustand");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Schrittergebnis_laufId_vorlagenSchrittId_key" ON "Schrittergebnis"("laufId", "vorlagenSchrittId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Kommentar_laufId_erstelltAm_idx" ON "Kommentar"("laufId", "erstelltAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Kommentar_schrittergebnisId_idx" ON "Kommentar"("schrittergebnisId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Protokoll_laufId_erstelltAm_idx" ON "Protokoll"("laufId", "erstelltAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Protokoll_entitaet_entitaetId_idx" ON "Protokoll"("entitaet", "entitaetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Protokoll_erstelltAm_idx" ON "Protokoll"("erstelltAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AbgleichSperre_laeuftAbAm_idx" ON "AbgleichSperre"("laeuftAbAm");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BenutzerKunde" ADD CONSTRAINT "BenutzerKunde_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BenutzerKunde" ADD CONSTRAINT "BenutzerKunde_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Verzeichnisquelle" ADD CONSTRAINT "Verzeichnisquelle_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Mitarbeiter" ADD CONSTRAINT "Mitarbeiter_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Mitarbeiter" ADD CONSTRAINT "Mitarbeiter_quelleId_fkey" FOREIGN KEY ("quelleId") REFERENCES "Verzeichnisquelle"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Abteilung" ADD CONSTRAINT "Abteilung_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Geraet" ADD CONSTRAINT "Geraet_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Geraet" ADD CONSTRAINT "Geraet_mitarbeiterId_fkey" FOREIGN KEY ("mitarbeiterId") REFERENCES "Mitarbeiter"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GeraetetypVorlage" ADD CONSTRAINT "GeraetetypVorlage_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "Vorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Vorlage" ADD CONSTRAINT "Vorlage_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VorlagenVersion" ADD CONSTRAINT "VorlagenVersion_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "Vorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VorlagenVersion" ADD CONSTRAINT "VorlagenVersion_veroeffentlichtVonId_fkey" FOREIGN KEY ("veroeffentlichtVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VorlagenVariable" ADD CONSTRAINT "VorlagenVariable_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "VorlagenVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VorlagenAbschnitt" ADD CONSTRAINT "VorlagenAbschnitt_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "VorlagenVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VorlagenSchritt" ADD CONSTRAINT "VorlagenSchritt_abschnittId_fkey" FOREIGN KEY ("abschnittId") REFERENCES "VorlagenAbschnitt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_vorlagenVersionId_fkey" FOREIGN KEY ("vorlagenVersionId") REFERENCES "VorlagenVersion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_mitarbeiterId_fkey" FOREIGN KEY ("mitarbeiterId") REFERENCES "Mitarbeiter"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "Geraet"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_abteilungId_fkey" FOREIGN KEY ("abteilungId") REFERENCES "Abteilung"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_abgeschlossenVonId_fkey" FOREIGN KEY ("abgeschlossenVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_archiviertVonId_fkey" FOREIGN KEY ("archiviertVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Schrittergebnis" ADD CONSTRAINT "Schrittergebnis_laufId_fkey" FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Schrittergebnis" ADD CONSTRAINT "Schrittergebnis_vorlagenSchrittId_fkey" FOREIGN KEY ("vorlagenSchrittId") REFERENCES "VorlagenSchritt"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Schrittergebnis" ADD CONSTRAINT "Schrittergebnis_aktualisiertVonId_fkey" FOREIGN KEY ("aktualisiertVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Kommentar" ADD CONSTRAINT "Kommentar_laufId_fkey" FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Kommentar" ADD CONSTRAINT "Kommentar_schrittergebnisId_fkey" FOREIGN KEY ("schrittergebnisId") REFERENCES "Schrittergebnis"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Kommentar" ADD CONSTRAINT "Kommentar_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Protokoll" ADD CONSTRAINT "Protokoll_laufId_fkey" FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Protokoll" ADD CONSTRAINT "Protokoll_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Regeln, die sich im Prisma-Schema nicht ausdruecken lassen.
|
||||||
|
--
|
||||||
|
-- Sie gehoeren in die Datenbank und nicht nur in die Service-Schicht, damit sie
|
||||||
|
-- auch dann gelten, wenn jemand an ihr vorbei schreibt: per Migration, per psql,
|
||||||
|
-- aus einem Skript oder aus einem spaeteren Connector.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- ---- Archiv-Volltextsuche -------------------------------------------------
|
||||||
|
-- Gesucht wird mit ILIKE '%...%' ueber Titel, Notizen, Werte und Kommentare
|
||||||
|
-- (#015). Ohne Trigramm-Index ist jede dieser Spalten ein Full Scan.
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
CREATE INDEX "Lauf_titel_trgm_idx" ON "Lauf" USING gin ("titel" gin_trgm_ops);
|
||||||
|
CREATE INDEX "Schrittergebnis_notiz_trgm_idx" ON "Schrittergebnis" USING gin ("notiz" gin_trgm_ops);
|
||||||
|
CREATE INDEX "Kommentar_rumpf_trgm_idx" ON "Kommentar" USING gin ("rumpf" gin_trgm_ops);
|
||||||
|
-- Die erfassten Werte liegen als Json vor; gesucht wird ueber ihre Textform.
|
||||||
|
CREATE INDEX "Schrittergebnis_wert_trgm_idx"
|
||||||
|
ON "Schrittergebnis" USING gin ((("wert")::text) gin_trgm_ops);
|
||||||
|
|
||||||
|
-- ---- Einmaligkeiten --------------------------------------------------------
|
||||||
|
-- Es gibt hoechstens eine eigene Organisation. Ein gewoehnlicher eindeutiger
|
||||||
|
-- Index wuerde auch die vielen false-Zeilen erfassen, deshalb partiell.
|
||||||
|
CREATE UNIQUE INDEX "Kunde_eigene_organisation_einmalig"
|
||||||
|
ON "Kunde" ("istEigeneOrganisation")
|
||||||
|
WHERE "istEigeneOrganisation";
|
||||||
|
|
||||||
|
-- Je Vorlage hoechstens ein Entwurf. Veroeffentlichte Versionen sind
|
||||||
|
-- unveraenderlich, ein zweiter Entwurf haette also keine eindeutige Grundlage.
|
||||||
|
CREATE UNIQUE INDEX "VorlagenVersion_ein_entwurf"
|
||||||
|
ON "VorlagenVersion" ("vorlageId")
|
||||||
|
WHERE "status" = 'ENTWURF';
|
||||||
|
|
||||||
|
-- ---- Formen ----------------------------------------------------------------
|
||||||
|
-- Das Kuerzel steht auf Etiketten und in der Hostname-Regel.
|
||||||
|
ALTER TABLE "Kunde"
|
||||||
|
ADD CONSTRAINT "Kunde_kuerzel_form" CHECK ("kuerzel" ~ '^[A-Z0-9]{2,6}$');
|
||||||
|
|
||||||
|
-- Der tagCode wird nie geaendert und nie wiederverwendet. Die Form wird schon
|
||||||
|
-- beim Erzeugen geprueft; hier steht sie als letzte Sicherung.
|
||||||
|
ALTER TABLE "Geraet"
|
||||||
|
ADD CONSTRAINT "Geraet_tagcode_form"
|
||||||
|
CHECK ("tagCode" ~ '^LZ-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$');
|
||||||
|
|
||||||
|
ALTER TABLE "TagCodeVergabe"
|
||||||
|
ADD CONSTRAINT "TagCodeVergabe_form"
|
||||||
|
CHECK ("tagCode" ~ '^LZ-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$');
|
||||||
|
|
||||||
|
-- Ein verschluesseltes Geheimnis traegt immer ein Versionspraefix (siehe
|
||||||
|
-- src/lib/geheimnisse.ts). Damit faellt ein versehentlich im Klartext
|
||||||
|
-- geschriebenes Bind-Passwort sofort auf.
|
||||||
|
ALTER TABLE "Verzeichnisquelle"
|
||||||
|
ADD CONSTRAINT "Verzeichnisquelle_bindpasswort_verschluesselt"
|
||||||
|
CHECK ("bindPasswortVerschluesselt" ~ '^v[0-9]+\.');
|
||||||
|
|
||||||
|
-- Einstellung ist eine einzelne Zeile. Ohne diese Bedingung koennte ein zweiter
|
||||||
|
-- Datensatz entstehen und die Anwendung haette zwei Betriebsmodi.
|
||||||
|
ALTER TABLE "Einstellung"
|
||||||
|
ADD CONSTRAINT "Einstellung_einzelne_zeile" CHECK ("id" = 'global');
|
||||||
|
|
||||||
|
-- ---- Fachliche Widerspruchsfreiheit ---------------------------------------
|
||||||
|
-- Ein Geraet ohne registriertAm ist ein vorgedruckter, noch nicht zugeordneter
|
||||||
|
-- Tag – der darf leer sein. Sobald es registriert ist, braucht es einen
|
||||||
|
-- Hostname oder eine Bezeichnung, sonst laesst es sich nicht benennen.
|
||||||
|
ALTER TABLE "Geraet"
|
||||||
|
ADD CONSTRAINT "Geraet_registriert_braucht_namen" CHECK (
|
||||||
|
"registriertAm" IS NULL
|
||||||
|
OR COALESCE(NULLIF(TRIM("hostname"), ''), NULLIF(TRIM("bezeichnung"), '')) IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ein Lauf haengt entweder an einem Mitarbeiter aus dem Verzeichnis oder traegt
|
||||||
|
-- einen Freitextnamen – beides zugleich waere widerspruechlich.
|
||||||
|
ALTER TABLE "Lauf"
|
||||||
|
ADD CONSTRAINT "Lauf_mitarbeiter_eindeutig" CHECK (
|
||||||
|
"mitarbeiterId" IS NULL OR "mitarbeiterName" IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Status und Zeitstempel duerfen nicht auseinanderlaufen, sonst zeigt das Archiv
|
||||||
|
-- einen abgeschlossenen Lauf ohne Abschlusszeitpunkt.
|
||||||
|
-- Der veroeffentlichende Benutzer bleibt bewusst optional: der Seed
|
||||||
|
-- veroeffentlicht die sieben Vorlagen ohne angemeldeten Menschen.
|
||||||
|
ALTER TABLE "VorlagenVersion"
|
||||||
|
ADD CONSTRAINT "VorlagenVersion_veroeffentlicht_mit_zeitpunkt" CHECK (
|
||||||
|
"status" <> 'VEROEFFENTLICHT' OR "veroeffentlichtAm" IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "Lauf"
|
||||||
|
ADD CONSTRAINT "Lauf_begonnen_mit_zeitpunkt" CHECK (
|
||||||
|
"status" = 'OFFEN' OR "gestartetAm" IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "Lauf"
|
||||||
|
ADD CONSTRAINT "Lauf_abgeschlossen_mit_zeitpunkt" CHECK (
|
||||||
|
"status" NOT IN ('ABGESCHLOSSEN', 'ARCHIVIERT') OR "abgeschlossenAm" IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "Lauf"
|
||||||
|
ADD CONSTRAINT "Lauf_archiviert_mit_zeitpunkt" CHECK (
|
||||||
|
"status" <> 'ARCHIVIERT' OR "archiviertAm" IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- NICHT_ZUTREFFEND und FEHLGESCHLAGEN verlangen eine Begruendung. Die
|
||||||
|
-- Service-Schicht prueft das ebenfalls, nur mit verstaendlicherer Meldung.
|
||||||
|
ALTER TABLE "Schrittergebnis"
|
||||||
|
ADD CONSTRAINT "Schrittergebnis_begruendung_noetig" CHECK (
|
||||||
|
"zustand" NOT IN ('NICHT_ZUTREFFEND', 'FEHLGESCHLAGEN')
|
||||||
|
OR NULLIF(TRIM("notiz"), '') IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---- Mandantengrenze -------------------------------------------------------
|
||||||
|
-- Die Trennung nach Kunden ist eine Sicherheitsgrenze (siehe
|
||||||
|
-- docs/architektur.md) und soll nicht allein an kundeErmitteln() haengen. Die
|
||||||
|
-- zusammengesetzten Fremdschluessel machen es unmoeglich, dass ein Lauf ein
|
||||||
|
-- Geraet, eine Person oder eine Abteilung eines fremden Kunden waehlt.
|
||||||
|
--
|
||||||
|
-- MATCH SIMPLE ist hier genau richtig: ist die optionale Spalte NULL, greift der
|
||||||
|
-- Fremdschluessel nicht – ein Lauf ohne Geraet bleibt also erlaubt.
|
||||||
|
ALTER TABLE "Lauf"
|
||||||
|
ADD CONSTRAINT "Lauf_geraet_gleicher_kunde"
|
||||||
|
FOREIGN KEY ("geraetId", "kundeId") REFERENCES "Geraet" ("id", "kundeId")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "Lauf"
|
||||||
|
ADD CONSTRAINT "Lauf_mitarbeiter_gleicher_kunde"
|
||||||
|
FOREIGN KEY ("mitarbeiterId", "kundeId") REFERENCES "Mitarbeiter" ("id", "kundeId")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "Lauf"
|
||||||
|
ADD CONSTRAINT "Lauf_abteilung_gleicher_kunde"
|
||||||
|
FOREIGN KEY ("abteilungId", "kundeId") REFERENCES "Abteilung" ("id", "kundeId")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Auch der Besitzer eines Geraets muss zum selben Kunden gehoeren.
|
||||||
|
ALTER TABLE "Geraet"
|
||||||
|
ADD CONSTRAINT "Geraet_besitzer_gleicher_kunde"
|
||||||
|
FOREIGN KEY ("mitarbeiterId", "kundeId") REFERENCES "Mitarbeiter" ("id", "kundeId")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ---- Zeitstempel ohne Prisma ----------------------------------------------
|
||||||
|
-- @updatedAt setzt Prisma im Client. Wer per psql, Migration oder Skript
|
||||||
|
-- schreibt, haette sonst eine NOT-NULL-Verletzung – genau die Wege, die diese
|
||||||
|
-- Datei oben ausdruecklich einkalkuliert.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
z record;
|
||||||
|
BEGIN
|
||||||
|
FOR z IN
|
||||||
|
SELECT table_name, column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND column_name IN ('geaendertAm', 'aktualisiertAm')
|
||||||
|
LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'ALTER TABLE %I ALTER COLUMN %I SET DEFAULT CURRENT_TIMESTAMP',
|
||||||
|
z.table_name, z.column_name
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
+579
-9
@@ -1,7 +1,19 @@
|
|||||||
// Laufzettel – Datenmodell
|
// Laufzettel – Datenmodell
|
||||||
// Benennung deutsch, Umlaute ASCII-gefaltet (siehe CLAUDE.md, Abschnitt Namensabbildung).
|
//
|
||||||
// Das vollstaendige Schema entsteht in #006; hier steht nur so viel, wie der
|
// Benennung deutsch, Umlaute ASCII-gefaltet. Die Abbildung auf die englischen
|
||||||
// Generator zum Laufen braucht.
|
// 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 {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
@@ -12,6 +24,8 @@ datasource db {
|
|||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Aufzaehlungen ---------------------------------------------------------
|
||||||
|
|
||||||
enum Rolle {
|
enum Rolle {
|
||||||
ADMIN
|
ADMIN
|
||||||
REDAKTEUR
|
REDAKTEUR
|
||||||
@@ -19,16 +33,572 @@ enum Rolle {
|
|||||||
LESER
|
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 {
|
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())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
|
||||||
name String
|
name String
|
||||||
rolle Rolle @default(LESER)
|
beschreibung String?
|
||||||
oidcSub String? @unique
|
kategorie String?
|
||||||
passwortHash String?
|
schlagworte String[] @default([])
|
||||||
aktiv Boolean @default(true)
|
/// Nur im Modus DIENSTLEISTUNG: kundenspezifische Vorlage. Leer heisst
|
||||||
|
/// "gilt fuer alle".
|
||||||
|
kundeId String?
|
||||||
|
archiviert Boolean @default(false)
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
|
|
||||||
@@index([aktiv])
|
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])
|
||||||
}
|
}
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { hash } from "@node-rs/argon2";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { PrismaClient } from "../src/generated/prisma/client.js";
|
||||||
|
import { Betriebsmodus, Rolle } from "../src/generated/prisma/enums.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grundbestand, ohne den die Anwendung nicht bedienbar waere: der Betriebsmodus,
|
||||||
|
* die eigene Organisation und ein Administrator.
|
||||||
|
*
|
||||||
|
* Der Seed ist idempotent – zweimal hintereinander aufgerufen aendert der zweite
|
||||||
|
* Lauf nichts. Er laeuft im Compose-Stand bewusst NICHT automatisch mit, sondern
|
||||||
|
* ist ein dokumentierter Handgriff (siehe README).
|
||||||
|
*
|
||||||
|
* Die sieben Vorlagen, die Beispiel-Mitarbeiter und die Demo-Kunden kommen mit
|
||||||
|
* #017 dazu.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const url = process.env["DATABASE_URL"];
|
||||||
|
if (!url) {
|
||||||
|
throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
|
||||||
|
|
||||||
|
/** Akzeptiert den im Auftrag dokumentierten Wert SERVICE als Alias. */
|
||||||
|
function modusLesen(): Betriebsmodus {
|
||||||
|
const roh = (process.env["SEED_MODE"] ?? "INTERN").trim().toUpperCase();
|
||||||
|
if (roh === "DIENSTLEISTUNG" || roh === "SERVICE") return Betriebsmodus.DIENSTLEISTUNG;
|
||||||
|
if (roh === "INTERN") return Betriebsmodus.INTERN;
|
||||||
|
throw new Error(`SEED_MODE kennt nur INTERN und DIENSTLEISTUNG (Alias SERVICE), nicht "${roh}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function einstellungAnlegen(modus: Betriebsmodus): Promise<void> {
|
||||||
|
// Nur anlegen, nicht ueberschreiben: ein spaeterer Seed-Lauf darf einen im
|
||||||
|
// Betrieb umgeschalteten Modus nicht zuruecksetzen.
|
||||||
|
const vorhanden = await prisma.einstellung.findUnique({ where: { id: "global" } });
|
||||||
|
if (vorhanden) {
|
||||||
|
console.log(` Einstellung: vorhanden, Modus ${vorhanden.modus} bleibt`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await prisma.einstellung.create({ data: { id: "global", modus } });
|
||||||
|
console.log(` Einstellung: angelegt, Modus ${modus}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eigeneOrganisationAnlegen(): Promise<string> {
|
||||||
|
const name = process.env["SEED_ORG_NAME"]?.trim() || "Eigene Organisation";
|
||||||
|
const kuerzel = (process.env["SEED_ORG_KUERZEL"]?.trim() || "EIGEN").toUpperCase();
|
||||||
|
if (!/^[A-Z0-9]{2,6}$/.test(kuerzel)) {
|
||||||
|
throw new Error(`SEED_ORG_KUERZEL muss 2 bis 6 Grossbuchstaben oder Ziffern sein, nicht "${kuerzel}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bewusst nicht aktualisieren: wer Name oder Kuerzel im Betrieb geaendert hat,
|
||||||
|
// soll das nicht beim naechsten Seed-Lauf zurueckbekommen. Geaenderte Werte in
|
||||||
|
// der Umgebung bleiben deshalb wirkungslos, sobald die Zeile existiert.
|
||||||
|
const vorhanden = await prisma.kunde.findFirst({ where: { istEigeneOrganisation: true } });
|
||||||
|
if (vorhanden) {
|
||||||
|
console.log(` Eigene Organisation: vorhanden (${vorhanden.name})`);
|
||||||
|
return vorhanden.id;
|
||||||
|
}
|
||||||
|
const kunde = await prisma.kunde.create({
|
||||||
|
data: { name, kuerzel, istEigeneOrganisation: true },
|
||||||
|
});
|
||||||
|
console.log(` Eigene Organisation: angelegt (${kunde.name}, ${kunde.kuerzel})`);
|
||||||
|
return kunde.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminAnlegen(): Promise<void> {
|
||||||
|
const email = process.env["SEED_ADMIN_EMAIL"]?.trim().toLowerCase();
|
||||||
|
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
||||||
|
if (!email || !passwort) {
|
||||||
|
console.log(" Administrator: uebersprungen (SEED_ADMIN_EMAIL oder SEED_ADMIN_PASSWORD fehlt)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gesucht wird ueber die Adresse: eine geaenderte SEED_ADMIN_EMAIL legt also
|
||||||
|
// einen zweiten Administrator an, statt den ersten umzubenennen. Das ist
|
||||||
|
// gewollt – umbenennen waere eine Aenderung an einem Konto, das laengst benutzt
|
||||||
|
// sein kann.
|
||||||
|
const vorhanden = await prisma.benutzer.findUnique({ where: { email } });
|
||||||
|
if (vorhanden) {
|
||||||
|
console.log(` Administrator: vorhanden (${email})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Das Passwort wird nur als argon2-Hash gespeichert, nie im Klartext.
|
||||||
|
await prisma.benutzer.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name: "Administrator",
|
||||||
|
rolle: Rolle.ADMIN,
|
||||||
|
passwortHash: await hash(passwort),
|
||||||
|
aktiv: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` Administrator: angelegt (${email})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const modus = modusLesen();
|
||||||
|
console.log("Seed laeuft:");
|
||||||
|
await einstellungAnlegen(modus);
|
||||||
|
await eigeneOrganisationAnlegen();
|
||||||
|
await adminAnlegen();
|
||||||
|
console.log("Seed fertig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschluesselt Geheimnisse, die die Anwendung im Klartext wieder braucht (#006):
|
||||||
|
* heute das LDAP-Bind-Passwort, spaeter Zugangsdaten von Connectoren. Fuer
|
||||||
|
* Anmeldepasswoerter von Benutzern ist das der falsche Weg -- die werden gehasht
|
||||||
|
* (argon2) und nie entschluesselt.
|
||||||
|
*
|
||||||
|
* AES-256-GCM, weil es neben der Verschluesselung auch die Unverfaelschtheit
|
||||||
|
* bezeugt: ein veraenderter Geheimtext wirft beim Entschluesseln, statt still
|
||||||
|
* Unsinn zu liefern.
|
||||||
|
*/
|
||||||
|
const ALGORITHMUS = "aes-256-gcm";
|
||||||
|
|
||||||
|
/** GCM ist auf 96 Bit IV ausgelegt; andere Laengen kosten eine zusaetzliche Ableitung. */
|
||||||
|
const IV_LAENGE = 12;
|
||||||
|
|
||||||
|
const AUTH_TAG_LAENGE = 16;
|
||||||
|
|
||||||
|
const SCHLUESSEL_LAENGE = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Festes Salz, absichtlich hier im Code.
|
||||||
|
*
|
||||||
|
* Ein Salz soll Angriffe mit vorberechneten Tabellen ueber viele schwache
|
||||||
|
* Passwoerter hinweg verteuern. Hier gibt es genau ein Geheimnis, `APP_SECRET`
|
||||||
|
* ist mit `openssl rand -base64 48` erzeugt und damit hochentropisch -- eine
|
||||||
|
* Tabelle dafuer vorzuberechnen ist aussichtslos. Umgekehrt muss der Schluessel
|
||||||
|
* ueber Neustarts und Container-Neubauten hinweg genau derselbe sein, sonst sind
|
||||||
|
* alle gespeicherten Geheimnisse nach einem Deploy wertlos. Ein zufaelliges Salz
|
||||||
|
* muesste also ohnehin neben dem Geheimtext liegen und schuetzte nichts
|
||||||
|
* zusaetzlich.
|
||||||
|
*/
|
||||||
|
const SALZ = "laufzettel.geheimnisse.v1";
|
||||||
|
|
||||||
|
/** Ein Versionspraefix, damit ein spaeterer Formatwechsel alte Werte noch lesen kann. */
|
||||||
|
const FORMAT_VERSION = "v1";
|
||||||
|
|
||||||
|
const MINDESTLAENGE = 32;
|
||||||
|
|
||||||
|
const FORMAT_HINWEIS = `Erwartet wird '${FORMAT_VERSION}.<iv>.<authTag>.<geheimtext>' mit base64-kodierten Teilen.`;
|
||||||
|
|
||||||
|
export class GeheimnisFehler extends Error {
|
||||||
|
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||||
|
super(nachricht, optionen);
|
||||||
|
this.name = "GeheimnisFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import:
|
||||||
|
* `next build` laeuft ohne gesetztes `APP_SECRET`, und `scryptSync` ist
|
||||||
|
* absichtlich langsam. Gemerkt wird die Quelle mit, damit ein geaendertes
|
||||||
|
* `APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher
|
||||||
|
* vorbeilaeuft.
|
||||||
|
*/
|
||||||
|
let abgeleitet: { quelle: string; schluessel: Buffer } | undefined;
|
||||||
|
|
||||||
|
function schluesselHolen(): Buffer {
|
||||||
|
const quelle = process.env["APP_SECRET"] ?? "";
|
||||||
|
if (quelle.length < MINDESTLAENGE) {
|
||||||
|
// Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in
|
||||||
|
// einem Log oder einer Fehlerseite landen und verriete etwas ueber das
|
||||||
|
// Geheimnis. Der Hinweis auf die Mindestlaenge genuegt zum Beheben.
|
||||||
|
throw new GeheimnisFehler(
|
||||||
|
`APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
|
||||||
|
"Erzeugen mit: openssl rand -base64 48",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (abgeleitet?.quelle !== quelle) {
|
||||||
|
abgeleitet = { quelle, schluessel: scryptSync(quelle, SALZ, SCHLUESSEL_LAENGE) };
|
||||||
|
}
|
||||||
|
return abgeleitet.schluessel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verschluesseln(klartext: string): string {
|
||||||
|
const schluessel = schluesselHolen();
|
||||||
|
// Je Aufruf ein neuer IV. Ein zweites Mal denselben IV mit demselben
|
||||||
|
// Schluessel zu benutzen bricht GCM vollstaendig.
|
||||||
|
const iv = randomBytes(IV_LAENGE);
|
||||||
|
const chiffre = createCipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE });
|
||||||
|
const geheim = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]);
|
||||||
|
// base64 verwendet keinen Punkt, der Punkt taugt also als Trenner.
|
||||||
|
return [
|
||||||
|
FORMAT_VERSION,
|
||||||
|
iv.toString("base64"),
|
||||||
|
chiffre.getAuthTag().toString("base64"),
|
||||||
|
geheim.toString("base64"),
|
||||||
|
].join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entschluesseln(geheimtext: string): string {
|
||||||
|
const teile = geheimtext.split(".");
|
||||||
|
const [version, ivTeil, tagTeil, datenTeil] = teile;
|
||||||
|
if (
|
||||||
|
teile.length !== 4 ||
|
||||||
|
version === undefined ||
|
||||||
|
ivTeil === undefined ||
|
||||||
|
tagTeil === undefined ||
|
||||||
|
datenTeil === undefined
|
||||||
|
) {
|
||||||
|
throw new GeheimnisFehler(`Geheimtext hat ein unbekanntes Format. ${FORMAT_HINWEIS}`);
|
||||||
|
}
|
||||||
|
if (version !== FORMAT_VERSION) {
|
||||||
|
throw new GeheimnisFehler(`Geheimtext traegt die unbekannte Version '${version}'. ${FORMAT_HINWEIS}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = Buffer.from(ivTeil, "base64");
|
||||||
|
const authTag = Buffer.from(tagTeil, "base64");
|
||||||
|
if (iv.length !== IV_LAENGE || authTag.length !== AUTH_TAG_LAENGE) {
|
||||||
|
throw new GeheimnisFehler(
|
||||||
|
`Geheimtext ist unvollstaendig: IV oder Auth-Tag haben die falsche Laenge. ${FORMAT_HINWEIS}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schluessel = schluesselHolen();
|
||||||
|
try {
|
||||||
|
const entschluessler = createDecipheriv(ALGORITHMUS, schluessel, iv, {
|
||||||
|
authTagLength: AUTH_TAG_LAENGE,
|
||||||
|
});
|
||||||
|
entschluessler.setAuthTag(authTag);
|
||||||
|
const klar = Buffer.concat([
|
||||||
|
entschluessler.update(Buffer.from(datenTeil, "base64")),
|
||||||
|
// `final` prueft das Auth-Tag und wirft, wenn Geheimtext oder Tag
|
||||||
|
// veraendert wurden oder der Schluessel nicht passt.
|
||||||
|
entschluessler.final(),
|
||||||
|
]);
|
||||||
|
return klar.toString("utf8");
|
||||||
|
} catch (ursache) {
|
||||||
|
throw new GeheimnisFehler(
|
||||||
|
"Geheimtext liess sich nicht entschluesseln: Auth-Tag passt nicht. Entweder wurde der " +
|
||||||
|
"Wert veraendert, oder APP_SECRET ist nicht mehr dasselbe wie beim Verschluesseln.",
|
||||||
|
{ cause: ursache },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { randomInt } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt die Codes fuer die QR-Aufkleber (#006).
|
||||||
|
*
|
||||||
|
* Ein tagCode wird laut Konvention nie geaendert und nie wiederverwendet. Er wird
|
||||||
|
* abgetippt und vorgelesen, deshalb sind die verwechselbaren Zeichen entschaerft.
|
||||||
|
* Entscheidend ist nicht, jedes einzelne zu streichen, sondern jede Gruppe
|
||||||
|
* aufzuloesen: aus {0, O} ist beides weg, aus {1, I, L} sind 1 und I weg. Das L
|
||||||
|
* bleibt und ist gerade deshalb eindeutig -- seine Doppelgaenger gibt es hier nicht.
|
||||||
|
* Es steht mit dem Praefix `LZ-` ohnehin in jedem Code und haelt die Breite bei
|
||||||
|
* genau 32 Zeichen. 32^6 sind rund 1,07 Mrd. Moeglichkeiten -- genug, dass Ziehen
|
||||||
|
* und Nachfragen billiger ist als ein Zaehler.
|
||||||
|
*/
|
||||||
|
export const TAGCODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
export const TAGCODE_PRAEFIX = "LZ-";
|
||||||
|
|
||||||
|
export const TAGCODE_LAENGE = 6;
|
||||||
|
|
||||||
|
/** Mehr Versuche lohnen nicht: bei dieser Breite ist schon der erste praktisch immer frei. */
|
||||||
|
export const TAGCODE_HOECHSTVERSUCHE = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Muster wird aus den Bausteinen gebaut, damit es nicht auseinanderlaeuft,
|
||||||
|
* wenn Alphabet oder Laenge sich aendern. Das Alphabet enthaelt keine
|
||||||
|
* Sonderzeichen der regulaeren Ausdruecke, ist also ohne Maskierung einsetzbar.
|
||||||
|
*/
|
||||||
|
export const TAGCODE_MUSTER: RegExp = new RegExp(
|
||||||
|
`^${TAGCODE_PRAEFIX}[${TAGCODE_ALPHABET}]{${TAGCODE_LAENGE}}$`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export class TagCodeFehler extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "TagCodeFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagCodeErzeugen(): string {
|
||||||
|
let zeichen = "";
|
||||||
|
for (let stelle = 0; stelle < TAGCODE_LAENGE; stelle += 1) {
|
||||||
|
// `randomInt` zieht kryptografisch sicher und gleichverteilt: es verwirft
|
||||||
|
// Werte am Rand, statt zu rechnen (`byte % 32` waere hier ebenfalls
|
||||||
|
// verzerrungsfrei, weil 32 die 256 teilt -- aber nur solange das Alphabet
|
||||||
|
// genau 32 Zeichen hat, und darauf soll sich niemand verlassen muessen).
|
||||||
|
// `charAt` statt Index, weil `noUncheckedIndexedAccess` sonst ein
|
||||||
|
// `undefined` einschleust, das hier nicht auftreten kann.
|
||||||
|
zeichen += TAGCODE_ALPHABET.charAt(randomInt(0, TAGCODE_ALPHABET.length));
|
||||||
|
}
|
||||||
|
return TAGCODE_PRAEFIX + zeichen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istTagCode(wert: string): boolean {
|
||||||
|
return TAGCODE_MUSTER.test(wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zieht einen Code, der noch frei ist.
|
||||||
|
*
|
||||||
|
* Die Pruefung wird hereingereicht, damit dieses Modul framework- und
|
||||||
|
* datenbankfrei bleibt (siehe Schichtung in CLAUDE.md). Die letzte Instanz fuer
|
||||||
|
* Eindeutigkeit ist ohnehin der Unique-Index in der Datenbank; diese Schleife
|
||||||
|
* erspart nur den absehbaren Schreibfehler.
|
||||||
|
*/
|
||||||
|
export async function tagCodeEindeutigErzeugen(
|
||||||
|
istVergeben: (code: string) => Promise<boolean>,
|
||||||
|
): Promise<string> {
|
||||||
|
for (let versuch = 0; versuch < TAGCODE_HOECHSTVERSUCHE; versuch += 1) {
|
||||||
|
const code = tagCodeErzeugen();
|
||||||
|
if (!(await istVergeben(code))) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new TagCodeFehler(
|
||||||
|
`Nach ${TAGCODE_HOECHSTVERSUCHE} Versuchen war kein freier tagCode zu finden. ` +
|
||||||
|
"Das deutet auf eine defekte Vergabepruefung hin, nicht auf einen erschoepften Vorrat.",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,10 +49,12 @@ describe("Betriebsstand: docker-compose.yml", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Betriebsstand: Entwicklungsdatenbank", () => {
|
describe("Betriebsstand: Entwicklungsdatenbank", () => {
|
||||||
it("laeuft auf 5433, damit ein lokales 5432 frei bleibt", () => {
|
it("bindet nur auf localhost und auf einen eigenen, umstellbaren Port", () => {
|
||||||
const stand = compose("docker-compose.dev.yml") as unknown as {
|
const stand = compose("docker-compose.dev.yml") as unknown as {
|
||||||
services: Record<string, { ports?: string[] }>;
|
services: Record<string, { ports?: string[] }>;
|
||||||
};
|
};
|
||||||
expect(stand.services["postgres"]?.ports).toEqual(["127.0.0.1:5433:5432"]);
|
// Der Standard ist 5434, weil 5432 lokal und 5433 von einem anderen Projekt
|
||||||
|
// belegt sein koennen. Umstellbar ueber DEV_DB_PORT.
|
||||||
|
expect(stand.services["postgres"]?.ports).toEqual(["127.0.0.1:${DEV_DB_PORT:-5434}:5432"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,666 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haelt die Regeln fest, die in der Datenbank selbst stehen (#006).
|
||||||
|
*
|
||||||
|
* Sie stehen dort und nicht nur in der Service-Schicht, damit sie auch dann
|
||||||
|
* greifen, wenn jemand an ihr vorbei schreibt – per Migration, per psql oder aus
|
||||||
|
* einem spaeteren Connector. Die Service-Schicht prueft dieselben Regeln noch
|
||||||
|
* einmal, aber mit verstaendlicheren Meldungen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function kundeAnlegen(kuerzel = "TST", eigene = false) {
|
||||||
|
return testPrisma.kunde.create({
|
||||||
|
data: { name: `Kunde ${kuerzel}`, kuerzel, istEigeneOrganisation: eigene },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function benutzerAnlegen(email = "techniker@firma.example") {
|
||||||
|
return testPrisma.benutzer.create({
|
||||||
|
data: { email, name: "Techniker", rolle: "TECHNIKER" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kleinste Vorlage, die ausreicht, um einen Lauf daran zu haengen. */
|
||||||
|
async function veroeffentlichteVersionAnlegen() {
|
||||||
|
const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } });
|
||||||
|
return testPrisma.vorlagenVersion.create({
|
||||||
|
// veroeffentlichtAm ist Pflicht, sobald der Status VEROEFFENTLICHT ist –
|
||||||
|
// eine Bedingung in der Datenbank haelt das fest.
|
||||||
|
data: {
|
||||||
|
vorlageId: vorlage.id,
|
||||||
|
version: 1,
|
||||||
|
status: "VEROEFFENTLICHT",
|
||||||
|
veroeffentlichtAm: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Datenmodell: Regeln in der Datenbank", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Kunde", () => {
|
||||||
|
it("laesst nur eine eigene Organisation zu", async () => {
|
||||||
|
await kundeAnlegen("EIGEN", true);
|
||||||
|
// Gemeint ist der partielle Index, nicht irgendeine Ablehnung.
|
||||||
|
await expect(kundeAnlegen("ZWEIT", true)).rejects.toThrow(/Kunde_eigene_organisation_einmalig/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst beliebig viele gewoehnliche Kunden zu", async () => {
|
||||||
|
await kundeAnlegen("EIGEN", true);
|
||||||
|
await kundeAnlegen("MUS");
|
||||||
|
await kundeAnlegen("REI");
|
||||||
|
expect(await testPrisma.kunde.count()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist ein Kuerzel ausserhalb von 2 bis 6 Zeichen zurueck", async () => {
|
||||||
|
await expect(kundeAnlegen("X")).rejects.toThrow(/Kunde_kuerzel_form/);
|
||||||
|
await expect(kundeAnlegen("ZULANG7")).rejects.toThrow(/Kunde_kuerzel_form/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist ein Kuerzel mit Kleinbuchstaben oder Sonderzeichen zurueck", async () => {
|
||||||
|
await expect(kundeAnlegen("mus")).rejects.toThrow();
|
||||||
|
await expect(kundeAnlegen("MU-S")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("haelt das Kuerzel eindeutig", async () => {
|
||||||
|
await kundeAnlegen("MUS");
|
||||||
|
await expect(kundeAnlegen("MUS")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Einstellung", () => {
|
||||||
|
it("laesst nur die eine Zeile mit der Kennung global zu", async () => {
|
||||||
|
await testPrisma.einstellung.create({ data: { id: "global" } });
|
||||||
|
await expect(testPrisma.einstellung.create({ data: { id: "zweite" } })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Geraet", () => {
|
||||||
|
it("erlaubt einen vorgedruckten Tag ohne Hostname und ohne Bezeichnung", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const tag = await testPrisma.geraet.create({
|
||||||
|
data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q" },
|
||||||
|
});
|
||||||
|
expect(tag.registriertAm).toBeNull();
|
||||||
|
expect(tag.hostname).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verlangt beim Registrieren einen Hostname oder eine Bezeichnung", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
await expect(
|
||||||
|
testPrisma.geraet.create({
|
||||||
|
data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q", registriertAm: new Date() },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Geraet_registriert_braucht_namen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wertet reine Leerzeichen nicht als Bezeichnung", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
await expect(
|
||||||
|
testPrisma.geraet.create({
|
||||||
|
data: {
|
||||||
|
kundeId: kunde.id,
|
||||||
|
tagCode: "LZ-7K3F9Q",
|
||||||
|
bezeichnung: " ",
|
||||||
|
registriertAm: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt ein registriertes Geraet mit Bezeichnung statt Hostname an", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const raum = await testPrisma.geraet.create({
|
||||||
|
data: {
|
||||||
|
kundeId: kunde.id,
|
||||||
|
tagCode: "LZ-M2XH4T",
|
||||||
|
bezeichnung: "Serverschrank Keller",
|
||||||
|
typ: "RAUM",
|
||||||
|
registriertAm: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(raum.bezeichnung).toBe("Serverschrank Keller");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist einen tagCode in falscher Form zurueck", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
for (const falsch of ["7K3F9Q", "LZ-7K3F9", "lz-7k3f9q", "LZ-7K3F9O", "LZ-7K3F91"]) {
|
||||||
|
await expect(
|
||||||
|
testPrisma.geraet.create({ data: { kundeId: kunde.id, tagCode: falsch } }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("haelt den tagCode ueber alle Kunden hinweg eindeutig", async () => {
|
||||||
|
const einer = await kundeAnlegen("MUS");
|
||||||
|
const anderer = await kundeAnlegen("REI");
|
||||||
|
await testPrisma.geraet.create({ data: { kundeId: einer.id, tagCode: "LZ-7K3F9Q" } });
|
||||||
|
await expect(
|
||||||
|
testPrisma.geraet.create({ data: { kundeId: anderer.id, tagCode: "LZ-7K3F9Q" } }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Mitarbeiter", () => {
|
||||||
|
it("haelt die externe Kennung je Quelle eindeutig", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const quelle = await testPrisma.verzeichnisquelle.create({
|
||||||
|
data: {
|
||||||
|
kundeId: kunde.id,
|
||||||
|
name: "Firmen-AD",
|
||||||
|
url: "ldaps://dc01.firma.local",
|
||||||
|
bindDn: "CN=Dienst,DC=firma,DC=local",
|
||||||
|
bindPasswortVerschluesselt: "v1.probe",
|
||||||
|
basisDn: "DC=firma,DC=local",
|
||||||
|
filter: "(objectClass=user)",
|
||||||
|
attributZuordnung: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stammdaten = {
|
||||||
|
kundeId: kunde.id,
|
||||||
|
quelleId: quelle.id,
|
||||||
|
externeId: "guid-1",
|
||||||
|
vorname: "Maria",
|
||||||
|
nachname: "Huber",
|
||||||
|
anzeigename: "Maria Huber",
|
||||||
|
herkunft: "ABGLEICH" as const,
|
||||||
|
};
|
||||||
|
await testPrisma.mitarbeiter.create({ data: stammdaten });
|
||||||
|
await expect(testPrisma.mitarbeiter.create({ data: stammdaten })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst beliebig viele manuell angelegte Personen ohne Quelle zu", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
// Postgres behandelt NULL als verschieden, deshalb greift der eindeutige
|
||||||
|
// Index hier nicht – genau so ist es gewollt.
|
||||||
|
for (const name of ["Ada", "Grace", "Alan"]) {
|
||||||
|
await testPrisma.mitarbeiter.create({
|
||||||
|
data: { kundeId: kunde.id, vorname: name, nachname: "Test", anzeigename: name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(await testPrisma.mitarbeiter.count()).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Abteilung", () => {
|
||||||
|
it("haelt den Namen je Kunde eindeutig, aber nicht ueber Kunden hinweg", async () => {
|
||||||
|
const einer = await kundeAnlegen("MUS");
|
||||||
|
const anderer = await kundeAnlegen("REI");
|
||||||
|
await testPrisma.abteilung.create({ data: { kundeId: einer.id, name: "Buchhaltung" } });
|
||||||
|
await expect(
|
||||||
|
testPrisma.abteilung.create({ data: { kundeId: einer.id, name: "Buchhaltung" } }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
const beimAnderen = await testPrisma.abteilung.create({
|
||||||
|
data: { kundeId: anderer.id, name: "Buchhaltung" },
|
||||||
|
});
|
||||||
|
expect(beimAnderen.name).toBe("Buchhaltung");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Lauf", () => {
|
||||||
|
it("laesst einen Lauf ohne Mitarbeiter und ohne Geraet zu", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
const lauf = await testPrisma.lauf.create({
|
||||||
|
data: {
|
||||||
|
vorlagenVersionId: version.id,
|
||||||
|
titel: "Patchday 2026-10",
|
||||||
|
kundeId: kunde.id,
|
||||||
|
erstelltVonId: benutzer.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(lauf.status).toBe("OFFEN");
|
||||||
|
expect(lauf.gestartetAm).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist Mitarbeiter und Freitextname zugleich zurueck", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
const person = await testPrisma.mitarbeiter.create({
|
||||||
|
data: { kundeId: kunde.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
testPrisma.lauf.create({
|
||||||
|
data: {
|
||||||
|
vorlagenVersionId: version.id,
|
||||||
|
titel: "Onboarding",
|
||||||
|
kundeId: kunde.id,
|
||||||
|
erstelltVonId: benutzer.id,
|
||||||
|
mitarbeiterId: person.id,
|
||||||
|
mitarbeiterName: "Maria Huber",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verhindert das Loeschen eines Geraets, an dem ein Lauf haengt", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
const geraet = await testPrisma.geraet.create({
|
||||||
|
data: {
|
||||||
|
kundeId: kunde.id,
|
||||||
|
tagCode: "LZ-7K3F9Q",
|
||||||
|
hostname: "PC-001",
|
||||||
|
typ: "PC",
|
||||||
|
registriertAm: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await testPrisma.lauf.create({
|
||||||
|
data: {
|
||||||
|
vorlagenVersionId: version.id,
|
||||||
|
titel: "PC Neuinstallation",
|
||||||
|
kundeId: kunde.id,
|
||||||
|
erstelltVonId: benutzer.id,
|
||||||
|
geraetId: geraet.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(testPrisma.geraet.delete({ where: { id: geraet.id } })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verhindert das Loeschen einer Version, auf die ein Lauf verweist", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
await testPrisma.lauf.create({
|
||||||
|
data: {
|
||||||
|
vorlagenVersionId: version.id,
|
||||||
|
titel: "Probe",
|
||||||
|
kundeId: kunde.id,
|
||||||
|
erstelltVonId: benutzer.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(testPrisma.vorlagenVersion.delete({ where: { id: version.id } })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Schrittergebnis", () => {
|
||||||
|
it("laesst je Lauf und Schritt nur ein Ergebnis zu", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||||||
|
data: { versionId: version.id, titel: "Vorbereitung" },
|
||||||
|
});
|
||||||
|
const schritt = await testPrisma.vorlagenSchritt.create({
|
||||||
|
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst", pflicht: true },
|
||||||
|
});
|
||||||
|
const lauf = await testPrisma.lauf.create({
|
||||||
|
data: {
|
||||||
|
vorlagenVersionId: version.id,
|
||||||
|
titel: "Probe",
|
||||||
|
kundeId: kunde.id,
|
||||||
|
erstelltVonId: benutzer.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await testPrisma.schrittergebnis.create({
|
||||||
|
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
testPrisma.schrittergebnis.create({
|
||||||
|
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Vorlagen-Version", () => {
|
||||||
|
it("haelt die Versionsnummer je Vorlage eindeutig", async () => {
|
||||||
|
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
|
||||||
|
await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1 } });
|
||||||
|
await expect(
|
||||||
|
testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1 } }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raeumt Abschnitte, Schritte und Variablen mit der Version ab", async () => {
|
||||||
|
// Ein Entwurf darf als Ganzes verworfen werden; nur deshalb steht hier
|
||||||
|
// Cascade und nicht Restrict.
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||||||
|
data: { versionId: version.id, titel: "Vorbereitung" },
|
||||||
|
});
|
||||||
|
await testPrisma.vorlagenSchritt.create({
|
||||||
|
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
|
||||||
|
});
|
||||||
|
await testPrisma.vorlagenVariable.create({
|
||||||
|
data: { versionId: version.id, schluessel: "os_version", beschriftung: "Betriebssystem" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await testPrisma.vorlagenVersion.delete({ where: { id: version.id } });
|
||||||
|
|
||||||
|
expect(await testPrisma.vorlagenAbschnitt.count()).toBe(0);
|
||||||
|
expect(await testPrisma.vorlagenSchritt.count()).toBe(0);
|
||||||
|
expect(await testPrisma.vorlagenVariable.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Zusicherungen, die erst der unabhaengige Review sichtbar gemacht hat:
|
||||||
|
* Mandantengrenze, Unversehrtheit des Archivs und die Vorleistung fuer Phase 4.
|
||||||
|
*/
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Datenmodell: Grenzen und Zusicherungen", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Mandantengrenze", () => {
|
||||||
|
it("laesst einen Lauf kein Geraet eines fremden Kunden waehlen", async () => {
|
||||||
|
const einer = await kundeAnlegen("MUS");
|
||||||
|
const anderer = await kundeAnlegen("REI");
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
const fremdesGeraet = await testPrisma.geraet.create({
|
||||||
|
data: {
|
||||||
|
kundeId: anderer.id,
|
||||||
|
tagCode: "LZ-7K3F9Q",
|
||||||
|
hostname: "REI-PC-001",
|
||||||
|
registriertAm: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
testPrisma.lauf.create({
|
||||||
|
data: {
|
||||||
|
vorlagenVersionId: version.id,
|
||||||
|
titel: "Quer ueber Kunden",
|
||||||
|
kundeId: einer.id,
|
||||||
|
erstelltVonId: benutzer.id,
|
||||||
|
geraetId: fremdesGeraet.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Lauf_geraet_gleicher_kunde/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst einen Lauf keine Person eines fremden Kunden waehlen", async () => {
|
||||||
|
const einer = await kundeAnlegen("MUS");
|
||||||
|
const anderer = await kundeAnlegen("REI");
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
const fremdePerson = await testPrisma.mitarbeiter.create({
|
||||||
|
data: { kundeId: anderer.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
testPrisma.lauf.create({
|
||||||
|
data: {
|
||||||
|
vorlagenVersionId: version.id,
|
||||||
|
titel: "Quer ueber Kunden",
|
||||||
|
kundeId: einer.id,
|
||||||
|
erstelltVonId: benutzer.id,
|
||||||
|
mitarbeiterId: fremdePerson.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Lauf_mitarbeiter_gleicher_kunde/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst einem Geraet keinen Besitzer eines fremden Kunden geben", async () => {
|
||||||
|
const einer = await kundeAnlegen("MUS");
|
||||||
|
const anderer = await kundeAnlegen("REI");
|
||||||
|
const fremdePerson = await testPrisma.mitarbeiter.create({
|
||||||
|
data: { kundeId: anderer.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
testPrisma.geraet.create({
|
||||||
|
data: {
|
||||||
|
kundeId: einer.id,
|
||||||
|
tagCode: "LZ-7K3F9Q",
|
||||||
|
hostname: "MUS-PC-001",
|
||||||
|
registriertAm: new Date(),
|
||||||
|
mitarbeiterId: fremdePerson.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Geraet_besitzer_gleicher_kunde/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Unversehrtheit des Archivs", () => {
|
||||||
|
it("verhindert, dass ein Lauf seine Schrittergebnisse mitnimmt", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||||||
|
data: { versionId: version.id, titel: "Vorbereitung" },
|
||||||
|
});
|
||||||
|
const schritt = await testPrisma.vorlagenSchritt.create({
|
||||||
|
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
|
||||||
|
});
|
||||||
|
const lauf = await testPrisma.lauf.create({
|
||||||
|
data: {
|
||||||
|
vorlagenVersionId: version.id,
|
||||||
|
titel: "Probe",
|
||||||
|
kundeId: kunde.id,
|
||||||
|
erstelltVonId: benutzer.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await testPrisma.schrittergebnis.create({
|
||||||
|
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id },
|
||||||
|
});
|
||||||
|
// Ohne diese Sperre verschwaende ein DELETE auf Lauf das halbe Archiv.
|
||||||
|
await expect(testPrisma.lauf.delete({ where: { id: lauf.id } })).rejects.toThrow();
|
||||||
|
expect(await testPrisma.schrittergebnis.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verhindert, dass ein Protokolleintrag seinen Urheber verliert", async () => {
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
await testPrisma.protokoll.create({
|
||||||
|
data: {
|
||||||
|
entitaet: "Benutzer",
|
||||||
|
entitaetId: benutzer.id,
|
||||||
|
aktion: "angemeldet",
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(testPrisma.benutzer.delete({ where: { id: benutzer.id } })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Vorlagen-Versionierung", () => {
|
||||||
|
it("laesst je Vorlage nur einen Entwurf zu", async () => {
|
||||||
|
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
|
||||||
|
await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1 } });
|
||||||
|
await expect(
|
||||||
|
testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 2 } }),
|
||||||
|
).rejects.toThrow(/VorlagenVersion_ein_entwurf/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verlangt beim Veroeffentlichen einen Zeitpunkt", async () => {
|
||||||
|
const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } });
|
||||||
|
await expect(
|
||||||
|
testPrisma.vorlagenVersion.create({
|
||||||
|
data: { vorlageId: vorlage.id, version: 1, status: "VEROEFFENTLICHT" },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/VorlagenVersion_veroeffentlicht_mit_zeitpunkt/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Status und Zeitstempel", () => {
|
||||||
|
it("verlangt einen Startzeitpunkt, sobald der Lauf nicht mehr offen ist", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
await expect(
|
||||||
|
testPrisma.lauf.create({
|
||||||
|
data: {
|
||||||
|
vorlagenVersionId: version.id,
|
||||||
|
titel: "Probe",
|
||||||
|
kundeId: kunde.id,
|
||||||
|
erstelltVonId: benutzer.id,
|
||||||
|
status: "IN_ARBEIT",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Lauf_begonnen_mit_zeitpunkt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verlangt beim Archivieren Abschluss- und Archivzeitpunkt", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
await expect(
|
||||||
|
testPrisma.lauf.create({
|
||||||
|
data: {
|
||||||
|
vorlagenVersionId: version.id,
|
||||||
|
titel: "Probe",
|
||||||
|
kundeId: kunde.id,
|
||||||
|
erstelltVonId: benutzer.id,
|
||||||
|
status: "ARCHIVIERT",
|
||||||
|
gestartetAm: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Lauf_abgeschlossen_mit_zeitpunkt/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Schrittergebnis", () => {
|
||||||
|
it("verlangt eine Begruendung bei nicht zutreffend und fehlgeschlagen", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||||||
|
data: { versionId: version.id, titel: "Vorbereitung" },
|
||||||
|
});
|
||||||
|
const schritt = await testPrisma.vorlagenSchritt.create({
|
||||||
|
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
|
||||||
|
});
|
||||||
|
const lauf = await testPrisma.lauf.create({
|
||||||
|
data: {
|
||||||
|
vorlagenVersionId: version.id,
|
||||||
|
titel: "Probe",
|
||||||
|
kundeId: kunde.id,
|
||||||
|
erstelltVonId: benutzer.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const zustand of ["NICHT_ZUTREFFEND", "FEHLGESCHLAGEN"] as const) {
|
||||||
|
await expect(
|
||||||
|
testPrisma.schrittergebnis.create({
|
||||||
|
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id, zustand },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Schrittergebnis_begruendung_noetig/);
|
||||||
|
}
|
||||||
|
const mitNotiz = await testPrisma.schrittergebnis.create({
|
||||||
|
data: {
|
||||||
|
laufId: lauf.id,
|
||||||
|
vorlagenSchrittId: schritt.id,
|
||||||
|
zustand: "NICHT_ZUTREFFEND",
|
||||||
|
notiz: "Geraet hat kein TPM.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mitNotiz.notiz).toBe("Geraet hat kein TPM.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt ein Aktionsergebnis verlustfrei auf", async () => {
|
||||||
|
// Vorleistung fuer Phase 4: der Wert muss Log, Kennungen und Zeitstempel
|
||||||
|
// einer ausgefuehrten Aktion tragen koennen, nicht nur einen Haken.
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const benutzer = await benutzerAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||||||
|
data: { versionId: version.id, titel: "Konto anlegen" },
|
||||||
|
});
|
||||||
|
const schritt = await testPrisma.vorlagenSchritt.create({
|
||||||
|
data: { abschnittId: abschnitt.id, titel: "New-ADUser", typ: "BEFEHL" },
|
||||||
|
});
|
||||||
|
const lauf = await testPrisma.lauf.create({
|
||||||
|
data: {
|
||||||
|
vorlagenVersionId: version.id,
|
||||||
|
titel: "Onboarding",
|
||||||
|
kundeId: kunde.id,
|
||||||
|
erstelltVonId: benutzer.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ergebnis = {
|
||||||
|
connector: "ldap",
|
||||||
|
ausgefuehrtAm: "2026-09-25T08:15:00.000Z",
|
||||||
|
dauerMs: 842,
|
||||||
|
objekte: [{ typ: "Benutzer", dn: "CN=Maria Huber,OU=Buchhaltung,DC=firma,DC=local" }],
|
||||||
|
log: ["Verbindung aufgebaut", "Konto angelegt", "Gruppen zugewiesen"],
|
||||||
|
erfolgreich: true,
|
||||||
|
};
|
||||||
|
const gespeichert = await testPrisma.schrittergebnis.create({
|
||||||
|
data: {
|
||||||
|
laufId: lauf.id,
|
||||||
|
vorlagenSchrittId: schritt.id,
|
||||||
|
zustand: "ERLEDIGT",
|
||||||
|
wert: ergebnis,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(gespeichert.wert).toEqual(ergebnis);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Vorleistung fuer Phase 4", () => {
|
||||||
|
it("laesst einen Lauf ohne erstellenden Menschen zu", async () => {
|
||||||
|
// Wiederkehrende Pruefungen und der Start per Webhook legen Laeufe an,
|
||||||
|
// hinter denen kein Benutzer steht.
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
const version = await veroeffentlichteVersionAnlegen();
|
||||||
|
const systemlauf = await testPrisma.lauf.create({
|
||||||
|
data: { vorlagenVersionId: version.id, titel: "Jaehrliche Pruefung", kundeId: kunde.id },
|
||||||
|
});
|
||||||
|
expect(systemlauf.erstelltVonId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Vergabebuch fuer tagCodes", () => {
|
||||||
|
it("haelt einen vergebenen Code fest, auch wenn das Geraet verschwindet", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
await testPrisma.tagCodeVergabe.create({ data: { tagCode: "LZ-7K3F9Q" } });
|
||||||
|
const geraet = await testPrisma.geraet.create({
|
||||||
|
data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q" },
|
||||||
|
});
|
||||||
|
await testPrisma.geraet.delete({ where: { id: geraet.id } });
|
||||||
|
|
||||||
|
expect(await testPrisma.geraet.count()).toBe(0);
|
||||||
|
// Der Code bleibt vergeben – ein alter Aufkleber darf nie auf ein neues
|
||||||
|
// Objekt zeigen.
|
||||||
|
expect(await testPrisma.tagCodeVergabe.count()).toBe(1);
|
||||||
|
await expect(testPrisma.tagCodeVergabe.create({ data: { tagCode: "LZ-7K3F9Q" } })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist einen Code in falscher Form zurueck", async () => {
|
||||||
|
await expect(testPrisma.tagCodeVergabe.create({ data: { tagCode: "LZ-7K3F9O" } })).rejects.toThrow(
|
||||||
|
/TagCodeVergabe_form/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Verzeichnisquelle", () => {
|
||||||
|
it("weist ein Bind-Passwort ohne Versionspraefix zurueck", async () => {
|
||||||
|
const kunde = await kundeAnlegen();
|
||||||
|
await expect(
|
||||||
|
testPrisma.verzeichnisquelle.create({
|
||||||
|
data: {
|
||||||
|
kundeId: kunde.id,
|
||||||
|
name: "Firmen-AD",
|
||||||
|
url: "ldaps://dc01.firma.local",
|
||||||
|
bindDn: "CN=Dienst,DC=firma,DC=local",
|
||||||
|
// So saehe ein versehentlich im Klartext gespeichertes Passwort aus.
|
||||||
|
bindPasswortVerschluesselt: "GeheimesPasswort123",
|
||||||
|
basisDn: "DC=firma,DC=local",
|
||||||
|
filter: "(objectClass=user)",
|
||||||
|
attributZuordnung: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Verzeichnisquelle_bindpasswort_verschluesselt/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Abgleich-Sperre", () => {
|
||||||
|
it("laesst dieselbe Sperre nicht zweimal halten", async () => {
|
||||||
|
const laeuftAbAm = new Date(Date.now() + 60_000);
|
||||||
|
await testPrisma.abgleichSperre.create({
|
||||||
|
data: { schluessel: "abgleich:quelle-1", gehaltenVon: "instanz-a", laeuftAbAm },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
testPrisma.abgleichSperre.create({
|
||||||
|
data: { schluessel: "abgleich:quelle-1", gehaltenVon: "instanz-b", laeuftAbAm },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { GeheimnisFehler, entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
|
||||||
|
// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen
|
||||||
|
// ihn danach wieder her, damit die Reihenfolge der Tests nichts aendert.
|
||||||
|
const APP_SECRET_VORHER = process.env["APP_SECRET"];
|
||||||
|
|
||||||
|
function appSecretSetzen(wert: string): void {
|
||||||
|
process.env["APP_SECRET"] = wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (APP_SECRET_VORHER === undefined) {
|
||||||
|
delete process.env["APP_SECRET"];
|
||||||
|
} else {
|
||||||
|
process.env["APP_SECRET"] = APP_SECRET_VORHER;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Aendert ein Byte im angegebenen Teil des Geheimtexts, ohne das Format zu verletzen. */
|
||||||
|
function teilVerfaelschen(geheimtext: string, index: number): string {
|
||||||
|
const teile = geheimtext.split(".");
|
||||||
|
const teil = teile[index];
|
||||||
|
if (teil === undefined || teil.length === 0) {
|
||||||
|
throw new Error(`Teil ${index} fehlt im Geheimtext.`);
|
||||||
|
}
|
||||||
|
// Das erste base64-Zeichen tragt die oberen Bits des ersten Bytes, eine
|
||||||
|
// Aenderung dort wirkt also sicher -- anders als am gepolsterten Ende.
|
||||||
|
const ersetzt = teil.startsWith("A") ? "B" : "A";
|
||||||
|
teile[index] = ersetzt + teil.slice(1);
|
||||||
|
return teile.join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Geheimnisse verschluesseln und entschluesseln", () => {
|
||||||
|
it("ist verlustfrei fuer einfachen Text", () => {
|
||||||
|
const klartext = "bind-passwort-123";
|
||||||
|
expect(entschluesseln(verschluesseln(klartext))).toBe(klartext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Umlaute und Eszett", "Grueße aus Bad Münstereifel: ÄÖÜäöüß"],
|
||||||
|
["Emoji", "Schlüssel 🔐 im Tresor 🗄️ mit Häkchen ✅"],
|
||||||
|
["leerer Text", ""],
|
||||||
|
["nur Leerzeichen", " "],
|
||||||
|
["Zeilenumbrueche", "erste Zeile\nzweite Zeile\r\ndritte"],
|
||||||
|
["Punkte im Klartext", "v1.das.sieht.aus.wie.ein.geheimtext"],
|
||||||
|
["base64-artiger Text", "YWJjZGVmZ2g="],
|
||||||
|
])("ist verlustfrei fuer %s", (_fall, klartext) => {
|
||||||
|
expect(entschluesseln(verschluesseln(klartext))).toBe(klartext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist verlustfrei fuer lange Texte", () => {
|
||||||
|
const lang = "Zertifikat-".repeat(10_000);
|
||||||
|
const geheim = verschluesseln(lang);
|
||||||
|
|
||||||
|
expect(geheim.length).toBeGreaterThan(lang.length / 2);
|
||||||
|
expect(entschluesseln(geheim)).toBe(lang);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("traegt das Versionspraefix und vier durch Punkt getrennte Teile", () => {
|
||||||
|
const teile = verschluesseln("etwas").split(".");
|
||||||
|
|
||||||
|
expect(teile).toHaveLength(4);
|
||||||
|
expect(teile[0]).toBe("v1");
|
||||||
|
// 12 Byte IV und 16 Byte Auth-Tag, base64 kodiert.
|
||||||
|
expect(Buffer.from(teile[1] ?? "", "base64")).toHaveLength(12);
|
||||||
|
expect(Buffer.from(teile[2] ?? "", "base64")).toHaveLength(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert fuer denselben Klartext zwei verschiedene Geheimtexte", () => {
|
||||||
|
// Jeder Aufruf zieht einen eigenen IV; sonst waere an gleichen Geheimtexten
|
||||||
|
// ablesbar, welche Konten dasselbe Passwort benutzen.
|
||||||
|
const klartext = "immer-dasselbe-passwort";
|
||||||
|
const erster = verschluesseln(klartext);
|
||||||
|
const zweiter = verschluesseln(klartext);
|
||||||
|
|
||||||
|
expect(erster).not.toBe(zweiter);
|
||||||
|
expect(erster.split(".")[1]).not.toBe(zweiter.split(".")[1]);
|
||||||
|
expect(entschluesseln(erster)).toBe(klartext);
|
||||||
|
expect(entschluesseln(zweiter)).toBe(klartext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enthaelt den Klartext nicht im Ergebnis", () => {
|
||||||
|
const klartext = "geheimes-bind-passwort";
|
||||||
|
expect(verschluesseln(klartext)).not.toContain(klartext);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Geheimnisse gegen Verfaelschung", () => {
|
||||||
|
it("wirft, wenn ein Byte im Geheimtext veraendert wurde", () => {
|
||||||
|
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 3);
|
||||||
|
|
||||||
|
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
|
||||||
|
expect(() => entschluesseln(verfaelscht)).toThrow(/Auth-Tag passt nicht/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft, wenn das Auth-Tag veraendert wurde", () => {
|
||||||
|
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 2);
|
||||||
|
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft, wenn der IV veraendert wurde", () => {
|
||||||
|
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 1);
|
||||||
|
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft, wenn der Geheimtext abgeschnitten wurde", () => {
|
||||||
|
const geheim = verschluesseln("bind-passwort-123");
|
||||||
|
const ohneLetztenTeil = geheim.split(".").slice(0, 3).join(".");
|
||||||
|
|
||||||
|
expect(() => entschluesseln(ohneLetztenTeil)).toThrow(GeheimnisFehler);
|
||||||
|
expect(() => entschluesseln(ohneLetztenTeil)).toThrow(/unbekanntes Format/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["leerer Wert", ""],
|
||||||
|
["Klartext ohne Trenner", "einfach-nur-text"],
|
||||||
|
["zu wenige Teile", "v1.abc"],
|
||||||
|
["zu viele Teile", "v1.a.b.c.d"],
|
||||||
|
["unbekannte Version", "v9.AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"],
|
||||||
|
["IV zu kurz", "v1.AAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"],
|
||||||
|
["Auth-Tag zu kurz", "v1.AAAAAAAAAAAAAAAA.AAAA.AAAA"],
|
||||||
|
])("wirft mit verstaendlicher Meldung bei %s", (_fall, geheimtext) => {
|
||||||
|
let gefangen: unknown;
|
||||||
|
try {
|
||||||
|
entschluesseln(geheimtext);
|
||||||
|
} catch (fehler) {
|
||||||
|
gefangen = fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(gefangen).toBeInstanceOf(GeheimnisFehler);
|
||||||
|
expect((gefangen as GeheimnisFehler).message).toMatch(/v1\.<iv>\.<authTag>\.<geheimtext>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft, wenn APP_SECRET zwischen Verschluesseln und Entschluesseln wechselt", () => {
|
||||||
|
const geheim = verschluesseln("bind-passwort-123");
|
||||||
|
appSecretSetzen("ein-voellig-anderes-geheimnis-mit-genug-zeichen");
|
||||||
|
|
||||||
|
expect(() => entschluesseln(geheim)).toThrow(GeheimnisFehler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("APP_SECRET pruefen", () => {
|
||||||
|
it.each([
|
||||||
|
["fehlendes", ""],
|
||||||
|
["viel zu kurzes", "kurz"],
|
||||||
|
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
|
||||||
|
])("wirft bei %s APP_SECRET mit Hinweis auf openssl", (_fall, wert) => {
|
||||||
|
appSecretSetzen(wert);
|
||||||
|
|
||||||
|
expect(() => verschluesseln("etwas")).toThrow(GeheimnisFehler);
|
||||||
|
expect(() => verschluesseln("etwas")).toThrow(/openssl rand -base64 48/);
|
||||||
|
expect(() => verschluesseln("etwas")).toThrow(/mindestens 32 Zeichen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt genau 32 Zeichen an", () => {
|
||||||
|
appSecretSetzen("b".repeat(32));
|
||||||
|
|
||||||
|
expect(entschluesseln(verschluesseln("grenzfall"))).toBe("grenzfall");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stellt den Standardwert aus setup-env wieder her", () => {
|
||||||
|
// Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die
|
||||||
|
// Tests je nach Reihenfolge unterschiedlich ausgehen.
|
||||||
|
expect(process.env["APP_SECRET"]).toBe(APP_SECRET_VORHER);
|
||||||
|
expect(entschluesseln(verschluesseln("nach dem Aufraeumen"))).toBe("nach dem Aufraeumen");
|
||||||
|
});
|
||||||
|
});
|
||||||
+12
-1
@@ -3,7 +3,18 @@ import { PrismaClient } from "@/generated/prisma/client";
|
|||||||
|
|
||||||
const url = process.env["TEST_DATABASE_URL"] ?? process.env["DATABASE_URL"];
|
const url = process.env["TEST_DATABASE_URL"] ?? process.env["DATABASE_URL"];
|
||||||
|
|
||||||
/** Ohne Testdatenbank ueberspringen sich die Datenbanktests statt fehlzuschlagen. */
|
/**
|
||||||
|
* Ohne Testdatenbank ueberspringen sich die Datenbanktests, damit ein schneller
|
||||||
|
* Durchlauf ohne Docker moeglich bleibt.
|
||||||
|
*
|
||||||
|
* In CI waere das gefaehrlich: `pnpm test` ist das Tor vor jedem Commit, und ein
|
||||||
|
* lautloses Ueberspringen saehe genauso gruen aus wie ein bestandener Lauf.
|
||||||
|
* Deshalb dort lieber hart abbrechen.
|
||||||
|
*/
|
||||||
|
if (!url && process.env["CI"]) {
|
||||||
|
throw new Error("TEST_DATABASE_URL fehlt. In CI duerfen die Datenbanktests nicht uebersprungen werden.");
|
||||||
|
}
|
||||||
|
|
||||||
export const datenbankVerfuegbar = Boolean(url);
|
export const datenbankVerfuegbar = Boolean(url);
|
||||||
|
|
||||||
export const testPrisma = new PrismaClient({
|
export const testPrisma = new PrismaClient({
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
TAGCODE_ALPHABET,
|
||||||
|
TAGCODE_HOECHSTVERSUCHE,
|
||||||
|
TAGCODE_MUSTER,
|
||||||
|
TagCodeFehler,
|
||||||
|
istTagCode,
|
||||||
|
tagCodeEindeutigErzeugen,
|
||||||
|
tagCodeErzeugen,
|
||||||
|
} from "@/lib/tagcode";
|
||||||
|
|
||||||
|
function zieheCodes(anzahl: number): string[] {
|
||||||
|
return Array.from({ length: anzahl }, () => tagCodeErzeugen());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tagCode erzeugen", () => {
|
||||||
|
it("haelt Praefix und Laenge ein", () => {
|
||||||
|
for (const code of zieheCodes(200)) {
|
||||||
|
expect(code).toMatch(TAGCODE_MUSTER);
|
||||||
|
expect(code).toHaveLength(3 + 6);
|
||||||
|
expect(code.startsWith("LZ-")).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("benutzt nur das erlaubte Alphabet", () => {
|
||||||
|
for (const code of zieheCodes(5000)) {
|
||||||
|
for (const zeichen of code.slice(3)) {
|
||||||
|
expect(TAGCODE_ALPHABET).toContain(zeichen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enthaelt nie die verwechselbaren Zeichen 0, O, 1 oder I", () => {
|
||||||
|
// Genau das ist der Grund fuer das eingeschraenkte Alphabet: die Codes werden
|
||||||
|
// von Aufklebern abgetippt und am Telefon vorgelesen. Das L ist bewusst nicht
|
||||||
|
// dabei: ohne es bliebe das Alphabet bei 31 Zeichen statt der gewuenschten 32,
|
||||||
|
// und das Praefix `LZ-` enthaelt es ohnehin.
|
||||||
|
for (const code of zieheCodes(5000)) {
|
||||||
|
expect(code.slice(3)).not.toMatch(/[0O1I]/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert bei 10 000 Ziehungen praktisch keine Dubletten", () => {
|
||||||
|
const anzahl = 10_000;
|
||||||
|
const eindeutige = new Set(zieheCodes(anzahl));
|
||||||
|
// Bei 32^6 rund 1,07 Mrd. Moeglichkeiten sind nach dem Geburtstagsproblem
|
||||||
|
// etwa 0,05 Dubletten zu erwarten -- meistens also keine. Ein strikter
|
||||||
|
// Vergleich auf 10 000 waere deshalb flatterhaft: er wuerde selten, aber
|
||||||
|
// regelmaessig rot, ohne dass etwas kaputt ist. Die verbindliche
|
||||||
|
// Eindeutigkeit sichert der Unique-Index in der Datenbank.
|
||||||
|
expect(eindeutige.size).toBeGreaterThanOrEqual(anzahl - 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verteilt die Zeichen grob gleichmaessig ueber das Alphabet", () => {
|
||||||
|
const codes = zieheCodes(5000);
|
||||||
|
const haeufigkeit = new Map<string, number>();
|
||||||
|
for (const code of codes) {
|
||||||
|
for (const zeichen of code.slice(3)) {
|
||||||
|
haeufigkeit.set(zeichen, (haeufigkeit.get(zeichen) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gezogeneZeichen = codes.length * 6;
|
||||||
|
const erwartet = gezogeneZeichen / TAGCODE_ALPHABET.length;
|
||||||
|
expect(haeufigkeit.size).toBe(TAGCODE_ALPHABET.length);
|
||||||
|
for (const zeichen of TAGCODE_ALPHABET) {
|
||||||
|
const anzahl = haeufigkeit.get(zeichen) ?? 0;
|
||||||
|
// Grosszuegige Schranken (rund 15 Standardabweichungen), damit der Test
|
||||||
|
// eine echte Schieflage findet, aber nicht am Zufall scheitert.
|
||||||
|
expect(anzahl).toBeGreaterThan(erwartet * 0.5);
|
||||||
|
expect(anzahl).toBeLessThan(erwartet * 1.5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("istTagCode", () => {
|
||||||
|
it("nimmt einen frisch erzeugten Code an", () => {
|
||||||
|
expect(istTagCode(tagCodeErzeugen())).toBe(true);
|
||||||
|
expect(istTagCode("LZ-7K3F9Q")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Kleinbuchstaben", "lz-7k3f9q"],
|
||||||
|
["gemischte Schreibweise", "LZ-7k3F9Q"],
|
||||||
|
["fehlendes Praefix", "7K3F9Q"],
|
||||||
|
["falsches Praefix", "XY-7K3F9Q"],
|
||||||
|
["Praefix ohne Bindestrich", "LZ7K3F9Q"],
|
||||||
|
["zu kurz", "LZ-7K3F9"],
|
||||||
|
["zu lang", "LZ-7K3F9QA"],
|
||||||
|
["verbotene Null", "LZ-7K3F90"],
|
||||||
|
["verbotenes O", "LZ-7K3F9O"],
|
||||||
|
["verbotene Eins", "LZ-7K3F91"],
|
||||||
|
["verbotenes I", "LZ-7K3F9I"],
|
||||||
|
["Sonderzeichen", "LZ-7K3F9-"],
|
||||||
|
["Leerzeichen", "LZ-7K3F9 "],
|
||||||
|
["leer", ""],
|
||||||
|
["nur Praefix", "LZ-"],
|
||||||
|
["Zeilenumbruch angehaengt", "LZ-7K3F9Q\n"],
|
||||||
|
["Text davor", "xLZ-7K3F9Q"],
|
||||||
|
])("weist %s zurueck", (_fall, wert) => {
|
||||||
|
expect(istTagCode(wert)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("urteilt bei mehrfachem Aufruf gleich", () => {
|
||||||
|
// Das Muster wird geteilt; ein globales Flag darauf wuerde den Zustand
|
||||||
|
// zwischen Aufrufen mitschleppen.
|
||||||
|
expect(istTagCode("LZ-7K3F9Q")).toBe(true);
|
||||||
|
expect(istTagCode("LZ-7K3F9Q")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tagCodeEindeutigErzeugen", () => {
|
||||||
|
it("liefert den ersten Code, wenn er frei ist", async () => {
|
||||||
|
const istVergeben = vi.fn(async () => false);
|
||||||
|
const code = await tagCodeEindeutigErzeugen(istVergeben);
|
||||||
|
|
||||||
|
expect(istTagCode(code)).toBe(true);
|
||||||
|
expect(istVergeben).toHaveBeenCalledTimes(1);
|
||||||
|
expect(istVergeben).toHaveBeenCalledWith(code);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zieht erneut, wenn der erste Code vergeben ist", async () => {
|
||||||
|
const gesehen: string[] = [];
|
||||||
|
const istVergeben = vi.fn(async (code: string) => {
|
||||||
|
gesehen.push(code);
|
||||||
|
return gesehen.length === 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const code = await tagCodeEindeutigErzeugen(istVergeben);
|
||||||
|
|
||||||
|
expect(istVergeben).toHaveBeenCalledTimes(2);
|
||||||
|
expect(gesehen).toHaveLength(2);
|
||||||
|
expect(gesehen[0]).not.toBe(code);
|
||||||
|
expect(gesehen[1]).toBe(code);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft nach zehn vergeblichen Versuchen einen TagCodeFehler", async () => {
|
||||||
|
const istVergeben = vi.fn(async () => true);
|
||||||
|
|
||||||
|
await expect(tagCodeEindeutigErzeugen(istVergeben)).rejects.toThrow(TagCodeFehler);
|
||||||
|
expect(istVergeben).toHaveBeenCalledTimes(TAGCODE_HOECHSTVERSUCHE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt einen Fehler der Vergabepruefung unveraendert weiter", async () => {
|
||||||
|
const istVergeben = vi.fn(async () => {
|
||||||
|
throw new Error("Datenbank nicht erreichbar");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(tagCodeEindeutigErzeugen(istVergeben)).rejects.toThrow("Datenbank nicht erreichbar");
|
||||||
|
expect(istVergeben).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user