Datenmodell: Schema, tagCodes und Geheimnisse #20

Merged
florian.wilde merged 1 commits from feat/6-datenmodell into main 2026-09-24 23:11:59 +00:00
19 changed files with 2820 additions and 40 deletions
+9 -2
View File
@@ -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
+12
View File
@@ -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)
+10 -1
View File
@@ -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
+4 -2
View File
@@ -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:
+31
View File
@@ -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
View File
@@ -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.
+45 -19
View File
@@ -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`.
+2 -2
View File
@@ -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 $$;
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
+136
View File
@@ -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 },
);
}
}
+79
View File
@@ -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.",
);
}
+4 -2
View File
@@ -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"]);
});
});
+666
View File
@@ -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();
});
});
});
+168
View File
@@ -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
View File
@@ -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({
+152
View File
@@ -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);
});
});