Datenmodell: Schema, tagCodes und Geheimnisse #20
+9
-2
@@ -59,8 +59,15 @@ SEED_ADMIN_PASSWORD=
|
||||
# INTERN (Standard) oder DIENSTLEISTUNG. SERVICE wird als Alias akzeptiert.
|
||||
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 ----------
|
||||
|
||||
# Datenbank fuer Vitest. Ohne diese Variable ueberspringen sich die Datenbanktests.
|
||||
# Passend zu docker-compose.dev.yml (Port 5433):
|
||||
TEST_DATABASE_URL=postgresql://laufzettel:laufzettel@localhost:5433/laufzettel_test?schema=public
|
||||
# Passend zu docker-compose.dev.yml (Port 5434, umstellbar ueber DEV_DB_PORT):
|
||||
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
|
||||
Handy nicht horizontal gescrollt wird (#005)
|
||||
- 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
|
||||
|
||||
- Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen,
|
||||
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` |
|
||||
| DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` |
|
||||
| — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` |
|
||||
| — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` |
|
||||
|
||||
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
|
||||
`LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` ·
|
||||
`SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` ·
|
||||
`SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) ·
|
||||
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
|
||||
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
|
||||
`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
|
||||
`Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
name: laufzettel-dev
|
||||
@@ -12,7 +14,7 @@ services:
|
||||
POSTGRES_PASSWORD: laufzettel
|
||||
POSTGRES_DB: laufzettel
|
||||
ports:
|
||||
- "127.0.0.1:5433:5432"
|
||||
- "127.0.0.1:${DEV_DB_PORT:-5434}:5432"
|
||||
volumes:
|
||||
- datenbank-dev:/var/lib/postgresql/data
|
||||
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
|
||||
- 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
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| #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 |
|
||||
|---|---|---|
|
||||
| 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,
|
||||
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
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** phase-1, backend
|
||||
**Geschätzt:** 5 h **Tatsächlich:** –
|
||||
**Geschätzt:** 5 h **Tatsächlich:** 1 h 10 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -11,23 +11,49 @@ Das vollständige Schema in deutscher Benennung, sodass alle folgenden Meilenste
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] 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"`
|
||||
- [ ] `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`
|
||||
- [ ] 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`
|
||||
- [ ] `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`
|
||||
- [ ] `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`
|
||||
- [ ] 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] Prisma 7: Generator `prisma-client` mit `output = "../src/generated/prisma"`, `datasource` ohne `url`, `@prisma/adapter-pg`
|
||||
- [x] `prisma.config.ts` mit `migrations.seed = "tsx prisma/seed.ts"`
|
||||
- [x] `src/lib/db.ts`: Lazy-Singleton-Proxy, damit `next build` ohne `DATABASE_URL` läuft, Dev-Instanz an `globalThis`
|
||||
- [x] Modelle: `Benutzer`, `Kunde`, `Einstellung`, `Verzeichnisquelle`, `Mitarbeiter`, `Abteilung`, `Geraet`, `GeraetetypVorlage`, `Vorlage`, `VorlagenVersion`, `VorlagenVariable`, `VorlagenAbschnitt`, `VorlagenSchritt`, `Lauf`, `Schrittergebnis`, `Kommentar`, `Protokoll`, `AbgleichSperre`
|
||||
- [x] Enums: `Rolle`, `Betriebsmodus`, `LaufStatus`, `SchrittZustand`, `SchrittTyp`, `GeraeteTyp`, `VersionStatus`, `Herkunft`
|
||||
- [x] Unique-Constraints: `Geraet.tagCode`, `(laufId, vorlagenSchrittId)` auf `Schrittergebnis`, `(quelleId, externeId)` auf `Mitarbeiter`, `(kundeId, name)` auf `Abteilung`, `(kundeId)` partiell für `isteigeneOrganisation`
|
||||
- [x] `pg_trgm`-Erweiterung und GIN-Indizes für die Archivsuche über `Lauf.titel`, `Schrittergebnis.notiz`, `Kommentar.rumpf`
|
||||
- [x] Jede kundenbezogene Zeile trägt `kundeId` als Pflichtfeld: `Geraet`, `Mitarbeiter`, `Abteilung`, `Verzeichnisquelle`, `Lauf`
|
||||
- [x] `src/lib/tagcode.ts`: Alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, Präfix `LZ-`, 6 Zeichen, Kollisionsprüfung serverseitig
|
||||
- [x] `src/lib/geheimnisse.ts`: AES-256-GCM aus `APP_SECRET` (scrypt-abgeleitet), für `bindPasswortVerschluesselt`
|
||||
- [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`
|
||||
- [x] `tests/setup-env.ts`, `tests/global-setup.ts`, `tests/helpers/db.ts` nach ProxLedger-Muster
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] `prisma migrate deploy` läuft auf leerer Datenbank durch
|
||||
- [ ] `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
|
||||
- [ ] 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] `prisma migrate deploy` läuft auf leerer Datenbank durch
|
||||
- [x] `pnpm db:seed` zweimal hintereinander ohne Fehler und ohne Datenänderung
|
||||
- [x] Tests: `tagCode` benutzt nur das vorgegebene Alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` und enthält damit nie `0`, `O`, `1` oder `I`; eindeutig über 10 000 Ziehungen
|
||||
- [x] Tests: verschlüsseln und entschlüsseln ist verlustfrei, zwei Verschlüsselungen desselben Klartexts unterscheiden sich (eigener IV), ein manipulierter Geheimtext wirft
|
||||
- [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 | – | – |
|
||||
| #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 |
|
||||
| #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 | – |
|
||||
| #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 | – |
|
||||
@@ -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 | – |
|
||||
| #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 | – |
|
||||
| | **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
|
||||
// Benennung deutsch, Umlaute ASCII-gefaltet (siehe CLAUDE.md, Abschnitt Namensabbildung).
|
||||
// Das vollstaendige Schema entsteht in #006; hier steht nur so viel, wie der
|
||||
// Generator zum Laufen braucht.
|
||||
//
|
||||
// Benennung deutsch, Umlaute ASCII-gefaltet. Die Abbildung auf die englischen
|
||||
// Modellnamen der Auftragsvorgabe steht in CLAUDE.md.
|
||||
//
|
||||
// Zwei Entwurfsentscheidungen tragen das Ganze, siehe docs/architektur.md:
|
||||
//
|
||||
// 1. Jede kundenbezogene Zeile traegt IMMER eine kundeId – auch im Modus
|
||||
// INTERN, wo es die eine Organisation mit istEigeneOrganisation = true ist.
|
||||
// Dadurch ist der Wechsel in den Modus DIENSTLEISTUNG eine Datenaenderung
|
||||
// und keine Migration.
|
||||
//
|
||||
// 2. Nichts wird geloescht, woran ein Lauf haengt. Geraete, Abteilungen und
|
||||
// Vorlagen werden archiviert, Mitarbeiter nur auf aktiv = false gesetzt.
|
||||
// Deshalb stehen die Fremdschluessel auf Restrict; Cascade gibt es nur
|
||||
// innerhalb einer Vorlagen-Version, die als Ganzes verworfen werden darf.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
@@ -12,6 +24,8 @@ datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
// ---- Aufzaehlungen ---------------------------------------------------------
|
||||
|
||||
enum Rolle {
|
||||
ADMIN
|
||||
REDAKTEUR
|
||||
@@ -19,16 +33,572 @@ enum Rolle {
|
||||
LESER
|
||||
}
|
||||
|
||||
enum Betriebsmodus {
|
||||
INTERN
|
||||
DIENSTLEISTUNG
|
||||
}
|
||||
|
||||
enum LaufStatus {
|
||||
OFFEN
|
||||
IN_ARBEIT
|
||||
ABGESCHLOSSEN
|
||||
ARCHIVIERT
|
||||
}
|
||||
|
||||
enum SchrittZustand {
|
||||
OFFEN
|
||||
ERLEDIGT
|
||||
NICHT_ZUTREFFEND
|
||||
FEHLGESCHLAGEN
|
||||
}
|
||||
|
||||
// AKTION kommt in Phase 4 dazu. Weil der Typ ein Enum ist und die Einzelheiten
|
||||
// in VorlagenSchritt.konfig liegen, braucht das keine Migration der bestehenden
|
||||
// Schritte.
|
||||
enum SchrittTyp {
|
||||
HAKEN
|
||||
TEXT
|
||||
ZAHL
|
||||
AUSWAHL
|
||||
DATUM
|
||||
BEFEHL
|
||||
}
|
||||
|
||||
enum VariablenTyp {
|
||||
TEXT
|
||||
ZAHL
|
||||
AUSWAHL
|
||||
DATUM
|
||||
}
|
||||
|
||||
enum GeraeteTyp {
|
||||
PC
|
||||
NOTEBOOK
|
||||
SERVER
|
||||
FIREWALL
|
||||
SWITCH
|
||||
DRUCKER
|
||||
RAUM
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
enum VersionStatus {
|
||||
ENTWURF
|
||||
VEROEFFENTLICHT
|
||||
}
|
||||
|
||||
enum Herkunft {
|
||||
ABGLEICH
|
||||
MANUELL
|
||||
}
|
||||
|
||||
// ---- Einstellungen und Mandanten ------------------------------------------
|
||||
|
||||
/// Einzelne Zeile mit fester id "global". Der Betriebsmodus liegt bewusst in
|
||||
/// der Datenbank und nicht in der Umgebung, damit der Admin ohne Neustart
|
||||
/// umschalten kann.
|
||||
model Einstellung {
|
||||
id String @id @default("global")
|
||||
modus Betriebsmodus @default(INTERN)
|
||||
/// Globale Namensregeln fuer upn, sam, mail und hostname. Im Modus
|
||||
/// DIENSTLEISTUNG koennen Kunden eigene Regeln haben, die diese ueberlagern.
|
||||
namensregeln Json @default("{}")
|
||||
/// Ziel-OU fuer deaktivierte Konten, Vorbelegung im Offboarding.
|
||||
gesperrteOu String?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Kunde {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
/// 2 bis 6 Zeichen, erscheint auf Etiketten und in der Hostname-Regel.
|
||||
kuerzel String @unique
|
||||
/// Genau eine Zeile traegt true. Erzwungen durch einen partiellen eindeutigen
|
||||
/// Index in der Migration, den Prisma selbst nicht ausdruecken kann.
|
||||
istEigeneOrganisation Boolean @default(false)
|
||||
/// Namensregeln dieses Kunden; fehlende Schluessel fallen auf die globalen
|
||||
/// aus Einstellung zurueck.
|
||||
namensregeln Json @default("{}")
|
||||
notizen String?
|
||||
archiviert Boolean @default(false)
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
|
||||
abteilungen Abteilung[]
|
||||
benutzerbindungen BenutzerKunde[]
|
||||
geraete Geraet[]
|
||||
laeufe Lauf[]
|
||||
mitarbeiter Mitarbeiter[]
|
||||
verzeichnisquellen Verzeichnisquelle[]
|
||||
vorlagen Vorlage[]
|
||||
|
||||
@@index([archiviert])
|
||||
}
|
||||
|
||||
// ---- Benutzer der Anwendung ------------------------------------------------
|
||||
|
||||
/// Die Menschen, die Laufzettel bedienen – nicht zu verwechseln mit Mitarbeiter,
|
||||
/// den Personen der Firma aus dem Verzeichnis.
|
||||
model Benutzer {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String
|
||||
rolle Rolle @default(LESER)
|
||||
/// Subject-Claim des OIDC-Anbieters. Leer bei rein lokalen Konten.
|
||||
oidcSub String? @unique
|
||||
/// Nur gesetzt, wenn der lokale Login benutzt wird. argon2, nie Klartext.
|
||||
passwortHash String?
|
||||
aktiv Boolean @default(true)
|
||||
/// Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten nicht mehr.
|
||||
/// So laesst sich ein Konto sperren, ohne auf den Ablauf des Cookies zu warten.
|
||||
sitzungenUngueltigAb DateTime?
|
||||
letzteAnmeldungAm DateTime?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
|
||||
abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon")
|
||||
archivierteLaeufe Lauf[] @relation("LaufArchiviertVon")
|
||||
erstellteLaeufe Lauf[] @relation("LaufErstelltVon")
|
||||
zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn")
|
||||
kommentare Kommentar[]
|
||||
kundenbindungen BenutzerKunde[]
|
||||
protokolle Protokoll[]
|
||||
schrittergebnisse Schrittergebnis[]
|
||||
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
|
||||
|
||||
@@index([aktiv])
|
||||
@@index([rolle])
|
||||
}
|
||||
|
||||
/// Bindet einen Benutzer an Kunden. Nur im Modus DIENSTLEISTUNG und nur, wenn
|
||||
/// OIDC_CUSTOMER_GROUP_PREFIX gesetzt ist: TECHNIKER und LESER sehen dann allein
|
||||
/// ihre Kunden, ADMIN und REDAKTEUR alles.
|
||||
///
|
||||
/// Die Bindung liegt in der Datenbank und nicht im Token, damit sie – wie Rolle
|
||||
/// und Aktiv-Status – pro Anfrage frisch gelesen wird und auch fuer lokale Konten
|
||||
/// gilt. Beim OIDC-Login wird sie neu geschrieben.
|
||||
model BenutzerKunde {
|
||||
benutzerId String
|
||||
kundeId String
|
||||
erstelltAm DateTime @default(now())
|
||||
|
||||
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([benutzerId, kundeId])
|
||||
@@index([kundeId])
|
||||
}
|
||||
|
||||
// ---- Verzeichnis und Personen ---------------------------------------------
|
||||
|
||||
/// LDAP-Quelle fuer den Abgleich des Mitarbeiterverzeichnisses. In der Regel
|
||||
/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene.
|
||||
/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen
|
||||
/// Abgleich zu Kunden-ADs.
|
||||
model Verzeichnisquelle {
|
||||
id String @id @default(cuid())
|
||||
kundeId String
|
||||
name String
|
||||
aktiv Boolean @default(false)
|
||||
url String
|
||||
bindDn String
|
||||
/// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt
|
||||
/// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test.
|
||||
bindPasswortVerschluesselt String
|
||||
basisDn String
|
||||
filter String
|
||||
/// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
|
||||
attributZuordnung Json
|
||||
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
|
||||
caZertifikatPem String?
|
||||
abgleichIntervallMinuten Int @default(60)
|
||||
letzterAbgleichAm DateTime?
|
||||
letzterAbgleichStatus String?
|
||||
/// Nimmt die Meldung der LDAP-Bibliothek auf. #016 muss sie filtern, bevor sie
|
||||
/// hier landet – rohe Bind-Fehler koennen das Passwort enthalten.
|
||||
letzterAbgleichFehler String?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
|
||||
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
||||
mitarbeiter Mitarbeiter[]
|
||||
|
||||
@@index([kundeId])
|
||||
@@index([aktiv])
|
||||
}
|
||||
|
||||
/// Die Personen der Firma bzw. des Kunden. Werden nie geloescht, nur auf
|
||||
/// aktiv = false gesetzt, damit archivierte Laeufe weiter auf sie zeigen.
|
||||
model Mitarbeiter {
|
||||
id String @id @default(cuid())
|
||||
kundeId String
|
||||
/// Nur bei Herkunft ABGLEICH gesetzt.
|
||||
quelleId String?
|
||||
/// objectGUID des Verzeichnisses, je Quelle eindeutig.
|
||||
externeId String?
|
||||
vorname String
|
||||
nachname String
|
||||
anzeigename String
|
||||
mail String?
|
||||
upn String?
|
||||
sam String?
|
||||
dn String?
|
||||
/// Der rohe department-Wert aus dem Verzeichnis. Die gepflegten Abteilungen
|
||||
/// stehen in Abteilung und werden ueber den Namen zugeordnet; ein Wert ohne
|
||||
/// passende Abteilung bleibt trotzdem erhalten.
|
||||
abteilung String?
|
||||
titel String?
|
||||
vorgesetzterExterneId String?
|
||||
aktiv Boolean @default(true)
|
||||
herkunft Herkunft @default(MANUELL)
|
||||
zuletztGesehenAm DateTime?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
|
||||
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
||||
quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict)
|
||||
geraete Geraet[]
|
||||
laeufe Lauf[]
|
||||
|
||||
/// Postgres behandelt NULL als verschieden, deshalb stoeren mehrere manuell
|
||||
/// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht.
|
||||
@@unique([quelleId, externeId])
|
||||
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel.
|
||||
@@unique([id, kundeId])
|
||||
@@index([kundeId, aktiv])
|
||||
@@index([abteilung])
|
||||
@@index([upn])
|
||||
@@index([sam])
|
||||
}
|
||||
|
||||
/// Vom Admin gepflegt, Vorschlaege entstehen aus den department-Werten des
|
||||
/// Abgleichs. Liefert beim Onboarding die Ziel-OU und die Standardgruppen.
|
||||
model Abteilung {
|
||||
id String @id @default(cuid())
|
||||
kundeId String
|
||||
name String
|
||||
zielOu String?
|
||||
standardGruppen String[] @default([])
|
||||
mailDomain String?
|
||||
notizen String?
|
||||
archiviert Boolean @default(false)
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
|
||||
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
||||
laeufe Lauf[]
|
||||
|
||||
@@unique([kundeId, name])
|
||||
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel aus der
|
||||
/// Migration, der eine Abteilung eines fremden Kunden ausschliesst.
|
||||
@@unique([id, kundeId])
|
||||
@@index([kundeId, archiviert])
|
||||
}
|
||||
|
||||
// ---- Geraete und Tags ------------------------------------------------------
|
||||
|
||||
/// Nicht nur PCs: ein Serverschrank, ein Raum oder ein Auftrag bekommen
|
||||
/// genauso einen Aufkleber.
|
||||
///
|
||||
/// Ein Geraet ohne registriertAm ist ein vorgedruckter, noch nicht zugeordneter
|
||||
/// Tag. Erst beim ersten Scannen wird es zu einem echten Objekt – deshalb greift
|
||||
/// die Pflicht zu hostname oder bezeichnung erst ab dann (Check-Constraint in
|
||||
/// der Migration).
|
||||
model Geraet {
|
||||
id String @id @default(cuid())
|
||||
kundeId String
|
||||
/// Besitzer bzw. Hauptnutzer. Server, Raeume und Poolgeraete haben keinen.
|
||||
mitarbeiterId String?
|
||||
/// Wird nie geaendert und nie wiederverwendet, auch nicht nach Archivierung.
|
||||
/// Unabhaengig vom Hostname, damit ein Geraet umbenannt werden kann, ohne dass
|
||||
/// der Aufkleber ungueltig wird.
|
||||
tagCode String @unique
|
||||
hostname String?
|
||||
/// Anzeigename fuer Objekte ohne Hostname, etwa "Serverschrank Keller".
|
||||
bezeichnung String?
|
||||
seriennummer String?
|
||||
inventarnummer String?
|
||||
typ GeraeteTyp @default(SONSTIGES)
|
||||
modell String?
|
||||
notizen String?
|
||||
archiviert Boolean @default(false)
|
||||
registriertAm DateTime?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
|
||||
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
||||
mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: Restrict)
|
||||
laeufe Lauf[]
|
||||
|
||||
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel.
|
||||
@@unique([id, kundeId])
|
||||
@@index([kundeId, archiviert])
|
||||
@@index([mitarbeiterId])
|
||||
@@index([typ])
|
||||
@@index([registriertAm])
|
||||
}
|
||||
|
||||
/// Jeder je vergebene tagCode, unabhaengig davon, ob das Geraet noch existiert.
|
||||
///
|
||||
/// Die Eindeutigkeit allein aus Geraet.tagCode zu ziehen reicht nicht: verschwindet
|
||||
/// die Zeile, waere der Code wieder frei und ein alter Aufkleber im Regal zeigte
|
||||
/// auf ein fremdes Objekt. Aus diesem Buch wird nie geloescht.
|
||||
model TagCodeVergabe {
|
||||
tagCode String @id
|
||||
vergebenAm DateTime @default(now())
|
||||
}
|
||||
|
||||
/// Vorlagen, die auf der Geraeteseite fuer diesen Geraetetyp als Schnellstart
|
||||
/// vorgeschlagen werden. Bewusst ohne kundeId: welcher Schnellstart zu einem
|
||||
/// Geraetetyp passt, haengt am Typ und nicht am Kunden. Kundenspezifische
|
||||
/// Vorlagen filtert die Geraeteseite ueber Vorlage.kundeId heraus.
|
||||
model GeraetetypVorlage {
|
||||
id String @id @default(cuid())
|
||||
geraeteTyp GeraeteTyp
|
||||
vorlageId String
|
||||
position Int @default(0)
|
||||
|
||||
vorlage Vorlage @relation(fields: [vorlageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([geraeteTyp, vorlageId])
|
||||
@@index([geraeteTyp, position])
|
||||
}
|
||||
|
||||
// ---- Vorlagen --------------------------------------------------------------
|
||||
|
||||
model Vorlage {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String
|
||||
rolle Rolle @default(LESER)
|
||||
oidcSub String? @unique
|
||||
passwortHash String?
|
||||
aktiv Boolean @default(true)
|
||||
beschreibung String?
|
||||
kategorie String?
|
||||
schlagworte String[] @default([])
|
||||
/// Nur im Modus DIENSTLEISTUNG: kundenspezifische Vorlage. Leer heisst
|
||||
/// "gilt fuer alle".
|
||||
kundeId String?
|
||||
archiviert Boolean @default(false)
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
|
||||
@@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", () => {
|
||||
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 {
|
||||
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"];
|
||||
|
||||
/** 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 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