feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Eine veroeffentlichte Vorlagen-Version ist fuer immer unveraenderlich — daran
haengt, dass ein archivierter Lauf in zehn Jahren noch genau die Schritte zeigt,
mit denen er gestartet wurde. Die Regel steht deshalb zweimal: im Service, der
die verstaendliche Meldung gibt, und in der Datenbank, die die Garantie gibt.
Vier Trigger fangen Schreibzugriffe ab, die nicht durch den Service kommen —
Migrationen, psql, der Seed und ab Phase 4 die Connectoren — und versperren den
Rueckweg nach ENTWURF, der alles andere ausgehebelt haette.

- Service: anlegen, Kopfdaten bearbeiten, Entwurf als tiefe Kopie der letzten
  veroeffentlichten Version, speichern, veroeffentlichen, verwerfen,
  duplizieren, archivieren
- Hoechstens ein Entwurf je Vorlage, durchgesetzt vom partiellen Index; zwei
  gleichzeitige Klicks auf „Bearbeiten“ bekommen denselben Entwurf statt eines
  Fehlers
- Optimistisches Sperren ueber geaendertAm: wer auf einem ueberholten Stand
  speichert, wird abgewiesen statt die Arbeit des anderen spurlos zu ersetzen
- JSON-Export und -Import samt Format-Dokumentation; der Export ist nachsichtig,
  der Import streng
- src/lib/validierung.ts als einzige Zod-Quelle mit deutschen Meldungen, per
  ESLint-Regel erzwungen

Der unabhaengige Review fand drei Blocker, alle an der Mandantengrenze: der
Suchfilter brachte ein zweites OR mit und ueberschrieb die Kundenbedingung,
Historie und Aufbau einer Version filterten gar nicht. Zu queries/vorlagen.ts
gab es keinen Test — jetzt gibt es tests/vorlagen-sicht.test.ts.

428 Tests gruen (vorher 336).

Schliesst #009

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 08:09:21 +02:00
co-authored by Claude Opus 5
parent ecc3c579df
commit abacad1cb7
41 changed files with 4006 additions and 104 deletions
+17
View File
@@ -50,6 +50,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
Sperr-OU und die Anzeige der Adresse, die in den QR-Codes steckt (#008)
- Navigation und Beschriftungen folgen dem Betriebsmodus — ein Codepfad, nur ein- und
ausgeblendete Einträge (#008)
- Versionierte Vorlagen: „Bearbeiten“ erzeugt eine tiefe Kopie der zuletzt veröffentlichten
Version als Entwurf, je Vorlage gibt es höchstens einen. Veröffentlichen hält Zeitpunkt,
Urheber und Änderungsnotiz fest (#009)
- Vorlagen duplizieren und archivieren statt löschen (#009)
- JSON-Export und -Import für Vorlagen, Format dokumentiert in `docs/vorlagen-format.md` (#009)
- Zwei Redakteure am selben Entwurf treten sich nicht mehr auf die Füße: wer auf einem überholten
Stand speichert, wird abgewiesen statt die Arbeit des anderen spurlos zu überschreiben (#009)
### Sicherheit
@@ -77,3 +84,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Der Rückweg nach Intern wird abgelehnt, solange mehr als ein nicht archivierter Kunde
existiert (#008)
- Die eigene Organisation lässt sich nicht archivieren (#008)
- Eine veröffentlichte Vorlagen-Version ist unveränderlich: jeder Schreibversuch wird abgewiesen,
und ein bestehender Lauf zeigt auch nach neuen Veröffentlichungen genau die Schritte, mit denen
er gestartet wurde (#009)
- Die Unveränderlichkeit setzt zusätzlich die Datenbank durch, damit auch Migrationen, `psql` und
spätere Connectoren nicht daran vorbeischreiben können; der Rückweg nach Entwurf ist versperrt
(#009)
- Vorlagen eines fremden Kunden bleiben auch bei Suche und Filtern verborgen, und Historie wie
Aufbau einer Version geben nichts heraus, was der Benutzer nicht sehen darf (#009)
- Der Doku-Link eines Schritts nimmt nur `http` und `https` an; über eine importierte Datei wäre
ein `javascript:`-Link sonst fremder Code im Browser des Technikers (#009)
+14 -2
View File
@@ -11,7 +11,7 @@ ASCII-gefaltet**: `Geraet`, `geaendertAm`, `laeufe`, `veroeffentlichtAm`.
**Gefaltet werden Bezeichner, nicht Fließtext.** Alles, was ein Mensch zu lesen bekommt, schreibt
sich richtig: Oberflächentexte, Fehlermeldungen, Protokoll-Aktionen (`"Rolle geändert"`),
Überschriften. „Kuerzel" in einer Fehlermeldung ist ein Fehler, `kuerzel` als Feldname nicht.
Überschriften. „Kuerzel“ in einer Fehlermeldung ist ein Fehler, `kuerzel` als Feldname nicht.
Englisch bleiben nur:
@@ -65,7 +65,7 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
## Feste Regeln, die in Tests festgehalten sind
- **Versionierung:** Eine veröffentlichte `VorlagenVersion` wird nie verändert. „Bearbeiten"
- **Versionierung:** Eine veröffentlichte `VorlagenVersion` wird nie verändert. „Bearbeiten“
erzeugt eine tiefe Kopie als Entwurf. Höchstens ein Entwurf je Vorlage. Läufe verweisen nur auf
veröffentlichte Versionen.
- **Archiv:** `laufMussVeraenderbarSein(lauf)` sitzt an genau einer Stelle und wird vor jeder
@@ -137,6 +137,18 @@ nicht die Modelle selbst — ein `import` von dort schlägt fehl. Ein Adapter is
`adapter` wirft. Seed steht in `prisma.config.ts` unter `migrations.seed`. Der Client wird über
den Lazy-Proxy in `src/lib/db.ts` geholt, damit `next build` ohne `DATABASE_URL` läuft.
## Tests brauchen die Testdatenbank für sich allein
`tests/helpers/db.ts` leert vor jedem Test alle Tabellen. Zwei gleichzeitige Läufe gegen dieselbe
Datenbank räumen sich deshalb gegenseitig die Daten weg, und das sieht aus wie ein echter Fehler
(typisch: `Foreign key constraint violated on Protokoll_benutzerId_fkey`). Wer parallel prüfen
lässt — etwa einen Review-Agenten —, gibt ihm eine **eigene** Datenbank:
```bash
createdb -h 127.0.0.1 -p 5434 -U laufzettel laufzettel_review
TEST_DATABASE_URL=postgresql://laufzettel:laufzettel@localhost:5434/laufzettel_review pnpm test
```
## Vor jedem Commit
```bash
+1
View File
@@ -140,6 +140,7 @@ Standardwerten zu starten.
- Anleitung für Techniker und Admins — entsteht mit #018, inkl. Bildern und PDF
- [Architektur und Entscheidungen](docs/architektur.md)
- [Austauschformat für Vorlagen](docs/vorlagen-format.md) — wie eine Vorlage als JSON aussieht
- [Anbindung an authentik](docs/betrieb/authentik.md) — Inbetriebnahme, Proxmox und Reverse Proxy folgen mit #019
- [Konventionen](CLAUDE.md) — Sprache, Schichtung, feste Regeln
- [Änderungen](CHANGELOG.md) · [Entwicklungstagebuch](docs/devlog.md)
+6 -6
View File
@@ -33,7 +33,7 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw
## Vorlagen
- [ ] Veröffentlichte Version ist unveränderlich; „Bearbeiten" erzeugt eine tiefe Kopie als Entwurf
- [ ] Veröffentlichte Version ist unveränderlich; „Bearbeiten“ erzeugt eine tiefe Kopie als Entwurf
- [ ] Höchstens ein Entwurf je Vorlage
- [ ] Veröffentlichen einer neuen Version verändert bestehende Läufe nicht
- [ ] Export und anschließender Import ergeben eine inhaltsgleiche Vorlage
@@ -44,7 +44,7 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw
- [ ] Lauf ohne Mitarbeiter und ohne Gerät lässt sich starten, abschließen und archivieren
- [ ] Abschließen scheitert, solange ein Pflichtschritt offen ist
- [ ] `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz
- [ ] „Abschnitt als nicht zutreffend markieren" setzt alle Schritte mit einer Notiz
- [ ] „Abschnitt als nicht zutreffend markieren“ setzt alle Schritte mit einer Notiz
- [ ] Archiv-Schreibschutz greift für Kopfdaten, Schrittergebnisse und Kommentare
- [ ] Nur `ADMIN` holt einen archivierten Lauf zurück, mit `Protokoll`-Eintrag
- [ ] Im Archiv wird über Titel, Notizen, Werte und Kommentare gefunden
@@ -55,7 +55,7 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw
- [ ] Mitarbeitersuche liefert standardmäßig nur aktive, Umschalter zeigt auch deaktivierte
- [ ] Ein von der Geräteseite gestarteter Lauf hat den Besitzer vorbelegt
- [ ] `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login auf die Geräteseite
- [ ] „Vorlage zuweisen" erzeugt einen `OFFEN`-Lauf, der unter „Zugewiesen" erscheint; „Starten" setzt `IN_ARBEIT`
- [ ] „Vorlage zuweisen“ erzeugt einen `OFFEN`-Lauf, der unter „Zugewiesen“ erscheint; „Starten“ setzt `IN_ARBEIT`
- [ ] Unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis
- [ ] `tagCode`: richtiges Alphabet, eindeutig, unveränderlich, nie wiederverwendet
- [ ] Etikettendruck in beiden Layouts sauber; QR maschinell zurücklesbar
@@ -63,12 +63,12 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw
## Platzhalter und Befehle
- [ ] Onboarding: Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen
- [ ] Abteilung „Buchhaltung" wählen → `-Path "OU=Buchhaltung,…"` und Gruppen `G-Buchhaltung, G-Alle`
- [ ] Abteilung „Buchhaltung“ wählen → `-Path "OU=Buchhaltung,…"` und Gruppen `G-Buchhaltung, G-Alle`
- [ ] Kopieren liefert exakt diesen Text, zeichengenau
- [ ] „Jörg Müller-Lüdenscheid" ergibt gültige Vorschläge (`joerg.mueller-luedenscheid@…`, `jmuellerluedensch`)
- [ ] „Jörg Müller-Lüdenscheid“ ergibt gültige Vorschläge (`joerg.mueller-luedenscheid@…`, `jmuellerluedensch`)
- [ ] `{{generiertes_passwort}}` erscheint im Befehl, steht aber nicht in Datenbank, Druckansicht oder `Protokoll`
- [ ] Werte mit Anführungszeichen werden für die jeweilige Shell korrekt maskiert
- [ ] „Alle Befehle als Skript kopieren" liefert die Schritte in richtiger Reihenfolge
- [ ] „Alle Befehle als Skript kopieren“ liefert die Schritte in richtiger Reihenfolge
- [ ] Unbekannte Platzhalter bleiben stehen; ersetzte Werte sind HTML-escaped
## Verzeichnis (LDAP)
+9 -2
View File
@@ -42,7 +42,7 @@ Der Auftrag nannte Auth.js. Beim Prüfen der Lage am 2026-09-24:
- `next-auth` v5 steht auf `5.0.0-beta.32`, seit Jahren ohne GA
- authjs.dev führt das Projekt im Wartungsmodus („The Auth.js project is now part of Better
Auth"), die Maintainer schreiben „no immediate plans for v5"
Auth"), die Maintainer schreiben „no immediate plans for v5“
- `@auth/prisma-adapter` deklariert `@prisma/client` nur bis `>=6` — wir fahren Prisma 7
Entschieden wurde deshalb der Eigenbau mit stabilen Bausteinen, wie in ProxLedger:
@@ -59,7 +59,7 @@ Cookie abgewiesen.
Zwei Regeln tragen die Nachvollziehbarkeit:
1. **Eine veröffentlichte `VorlagenVersion` wird nie verändert.** „Bearbeiten" erzeugt eine tiefe
1. **Eine veröffentlichte `VorlagenVersion` wird nie verändert.** „Bearbeiten“ erzeugt eine tiefe
Kopie als Entwurf. Ein Lauf auf Version 1 zeigt für immer die Schritte aus Version 1, auch
wenn längst Version 5 veröffentlicht ist.
2. **Ein archivierter Lauf ist schreibgeschützt.** Die Sperre sitzt in genau einer Funktion,
@@ -68,6 +68,13 @@ Zwei Regeln tragen die Nachvollziehbarkeit:
Beides ist durch Tests abgedeckt, nicht durch Disziplin.
Der Aufbau einer Version wird **als Ganzes** gespeichert, nicht Schritt für Schritt: der Editor
hält den Baum im Speicher und sortiert per Drag & Drop, ein Teil-Update wäre dort ständig mit dem
Sortieren im Streit. Dasselbe Zod-Schema (`src/lib/vorlagen-format.ts`) beschreibt den Entwurf im
Editor, den JSON-Export und den Import — drei getrennte Schemata würden auseinanderlaufen, und der
Import wäre der Ort, an dem das erst auffiele. Das Format ist in
[`docs/vorlagen-format.md`](vorlagen-format.md) beschrieben.
## 5. Der Weg zu Phase 4 (Aktionen)
Die in den Vorlagen vorbereiteten PowerShell-Befehle sind bewusst die **Vorstufe ausführbarer
+72 -4
View File
@@ -11,11 +11,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #006 | M1 – Datenmodell | 1 h 10 min |
| #007 | M2 – Auth, Rollen, Benutzer | 1 h 35 min |
| #008 | M3 – Betriebsmodus und Kunden | 1 h 20 min |
| | **Summe** | **5 h 30 min** |
| #009 | M4 – Vorlagen-Versionierung im Service | 1 h 20 min |
| | **Summe** | **6 h 50 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
| v0.1.0 | 5 h 30 min | in Arbeit |
| v0.1.0 | 6 h 50 min | in Arbeit |
---
@@ -43,7 +44,7 @@ als Unterpunkte mit Aufgabe und Dauer.
- **Deutsch im Code, auch für Bezeichner.** Der Auftragstext verlangte Englisch; Florian hat sich
für Einheitlichkeit mit ProxLedger entschieden. Verbindliche Namensabbildung in `CLAUDE.md`.
- **Kein Auth.js.** v5 steht seit Jahren auf Beta (`5.0.0-beta.32`), authjs.dev führt das Projekt
im Wartungsmodus („now part of Better Auth"), die Maintainer nennen „no immediate plans for v5",
im Wartungsmodus („now part of Better Auth“), die Maintainer nennen „no immediate plans for v5“,
und `@auth/prisma-adapter` deklariert Prisma nur bis 6 — wir fahren Prisma 7. Stattdessen
`openid-client` + `jose` wie in ProxLedger. Dieselbe Überlegung wie dort, nur ein Jahr später
noch deutlicher.
@@ -246,7 +247,7 @@ als Unterpunkte mit Aufgabe und Dauer.
- **Die Sichtbarkeit gehört in `kundeErmitteln`,** nicht in eine zweite Funktion daneben. Sonst
müsste jeder künftige Service daran denken — und #009 ff. übernehmen diese Signatur.
- **Lesemodelle filtern selbst.** Sie verlangen den Benutzer, statt sich auf den Aufrufer zu
verlassen. `undefined` heißt „keine Einschränkung", `[]` heißt „sieht nichts"; die Umsetzung
verlassen. `undefined` heißt „keine Einschränkung“, `[]` heißt „sieht nichts“; die Umsetzung
steht an einer Stelle, und beide Fälle haben einen Test.
- **`pg_advisory_xact_lock` statt Serializable.** Der Wechsel ist selten und kurz; ein Schloss auf
Transaktionsebene ist verständlicher als eine Isolationsstufe, die man beim Lesen übersieht, und
@@ -271,3 +272,70 @@ als Unterpunkte mit Aufgabe und Dauer.
- #009 Vorlagen-Versionierung: Veröffentlichen, tiefe Kopie als Entwurf, höchstens ein Entwurf,
Unveränderlichkeit, JSON-Export und -Import.
---
## 2026-09-25 · Sitzung 5 · 07:00–08:20
**Issue:** #009 (M4)
**Version:** 0.1.0
### Erledigt
- `src/server/services/vorlagen.ts`: Anlegen, Kopfdaten bearbeiten, Entwurf holen (tiefe Kopie),
Speichern, Veröffentlichen, Verwerfen, Duplizieren, Archivieren
- `src/server/services/vorlagen-austausch.ts` mit `docs/vorlagen-format.md`: ein Zod-Schema für
Editor, Export und Import — eine Vorlage zieht als Datei zwischen zwei Installationen um
- `src/lib/validierung.ts` als einzige Quelle für Zod samt deutscher Meldungen, mit ESLint-Regel
- Migration `vorlagen_unveraenderlich`: vier Trigger, die die Unveränderlichkeit in der Datenbank
durchsetzen — nicht nur im Service
- Unabhängiger Review: drei Blocker (alle an der Mandantengrenze), acht weitere Befunde, alle
abgearbeitet
- **428 Tests** grün (vorher 336), davon neu: Mandantensicht, Datenbankregeln, Gleichzeitigkeit,
Austausch mit unsauberen Daten
### Entscheidungen
- **Die Regel steht zweimal: im Service und in der Datenbank.** Der Service gibt die verständliche
Meldung, die Datenbank die Garantie. Alles, was nicht durch den Service geht — Migrationen,
`psql`, der Seed, ab Phase 4 die Connectoren —, läuft sonst an der einen Regel vorbei, die das
Archiv überhaupt erst etwas wert macht. Die doppelte Haltung kostet einen Trigger und spart die
Annahme, dass jeder künftige Schreibweg daran denkt.
- **Ein Entwurf je Vorlage kommt vom partiellen Index**, nicht von einer Prüfung davor. Zwei
gleichzeitige Klicks auf „Bearbeiten“ laufen damit unvermeidlich in `P2002` — und genau dort
wiederholt der Service und gibt beiden denselben Entwurf zurück, statt einen Fehler zu zeigen,
den niemand versteht.
- **Optimistisches Sperren über `geaendertAm`,** nicht über eine Zählerspalte. Der Zeitstempel ist
ohnehin da, und `updateMany` mit Bedingung erledigt den Vergleich in derselben Anweisung.
- **Unbekannte `konfig`-Schlüssel überleben, unbekannte Werte bekannter Schlüssel nicht.** Ein
neues Feld aus einer späteren Fassung darf einen Umzug nicht verhindern; eine unbekannte `shell`
aber entscheidet darüber, wie ein Wert maskiert wird — sie stillschweigend zu verwerfen hieße,
einen bash-Befehl nach PowerShell-Regeln zu maskieren, und das kann auf einem Server Daten
kosten. Also laut abweisen, mit Pfad und erlaubten Werten.
- **Der Export ist nachsichtig, der Import streng.** Herausgeben muss man auch, was nicht ins
Schema passt, sonst verliert die Datei Inhalt. Annehmen darf man es nicht.
- **`tests/datenbankregeln.test.ts` als Wächter.** Prisma erzeugt für jede künftige Migration
Löschbefehle gegen die handgeschriebenen Objekte. Der Test zählt sie namentlich auf, damit ein
übersehener `DROP` die Suite rot macht statt still die Mandantengrenze zu entfernen.
### Probleme
- **Die drei Blocker lagen alle an derselben Stelle: den Lesemodellen.** Der Suchfilter brachte
einen zweiten `OR`-Schlüssel mit und überschrieb die Kundenbedingung — ein Buchstabe im Suchfeld
genügte, um einem gebundenen Techniker alle Vorlagen aller Kunden zu zeigen. Historie und Aufbau
einer Version filterten gar nicht. Zu `queries/vorlagen.ts` gab es keinen Test; die Sicht in #008
abzusichern und in #009 nur den Service zu testen war der Fehler.
- **`Prisma.validator`-Objekte mit zwei gleichen Schlüsseln sind kein Typfehler.** Ein
zusammengebautes `where` mit `OR` aus zwei Quellen fällt weder ESLint noch `tsc` auf. Deshalb
liegt die Mandantenbedingung jetzt in `AND`, wo ein zweiter Filter sie nicht erreichen kann.
- **Prisma wollte sieben handgeschriebene Objekte löschen.** Die Migration musste von Hand
geschrieben werden; zusätzlich fehlten `@default(now())` bei den `@updatedAt`-Feldern, weshalb
Prisma bei jedem Lauf `DROP DEFAULT` vorschlug.
- **Zwei gleichzeitige Testläufe gegen `laufzettel_test`** ergaben 62 falsche Fehlschläge. Der
Review-Agent und ich haben uns die Daten weggetruncated; im Auftrag an den Agenten hatte ich die
eigene Datenbank nicht verlangt.
### Nächster Schritt
- #010 Vorlagen-Editor: Liste mit Suche und Filtern, Abschnitte und Schritte per Drag & Drop,
Vorschau, Veröffentlichen, Versionshistorie, Warnung bei unbekannten Platzhaltern.
+1 -1
View File
@@ -15,7 +15,7 @@ Nicht im MVP umsetzen. Der Weg dorthin wird im MVP aber bewusst freigehalten: Di
- [ ] Connectors: LDAP/AD schreibend über eigenes Dienstkonto, Microsoft Graph (Entra ID, Exchange Online, Intune), authentik-API, später Ticketsystem und RMM
- [ ] Onboarding als Aktion: Konto anlegen, Initialpasswort erzeugen und sicher übergeben, Gruppen laut Abteilung, Postfach und Lizenz
- [ ] Offboarding als Aktion: Konto sperren, Sitzungen widerrufen, Postfach in Shared Mailbox wandeln
- [ ] Vorschau „das wird passieren", ausdrückliche Bestätigung, Ergebnis-Log am Schritt, Rollback-Hinweis
- [ ] Vorschau „das wird passieren“, ausdrückliche Bestätigung, Ergebnis-Log am Schritt, Rollback-Hinweis
- [ ] Fehler setzen den Schritt auf `FEHLGESCHLAGEN` mit Meldung
- [ ] Berechtigung je Connector und Rolle, Geheimnisse verschlüsselt, alle Aufrufe im `Protokoll`
- [ ] Wiederkehrende Läufe (v0.5.0)
+2 -2
View File
@@ -31,7 +31,7 @@ Ein lauffähiges, leeres Projekt mit allen Qualitätsschranken, dem gehärteten
- [x] Design-Token als CSS-Variablen (Grund `#0B0D10`, Flächen `#14171C`/`#1B1F26`, Linien `#262B33`, Text `#F3F5F7`/`#98A2B3`, Akzent `#C6F135`, Status `#3DDC97`/`#F5B84A`/`#F87171`), heller Modus optional
- [x] Schriften selbst gehostet: `@fontsource-variable/space-grotesk`, `-manrope`, `-jetbrains-mono`
- [x] Wortmarke `laufzettel` (Space Grotesk 700, Laufweite −0.04em, Akzentpunkt) und Bildmarke `[ ✓ ]` als SVG, Favicon, PWA-Icon, `manifest.ts`
- [x] App-Shell: dunkle Sidebar mit „Scannen"-Knopf direkt unter dem Logo, Pillen-Buttons (40 px, am Handy 44–46 px), Karten mit Radius 16–18 px und 1-px-Linie ohne Schatten
- [x] App-Shell: dunkle Sidebar mit „Scannen“-Knopf direkt unter dem Logo, Pillen-Buttons (40 px, am Handy 44–46 px), Karten mit Radius 16–18 px und 1-px-Linie ohne Schatten
- [x] Versionsanzeige unten links aus `NEXT_PUBLIC_APP_VERSION` und `NEXT_PUBLIC_GIT_SHA`
- [x] `README.md`, `CHANGELOG.md` (Keep a Changelog, deutsch), `docs/devlog.md`, `CLAUDE.md` mit Konventionen und Namensabbildung, `docs/architektur.md`, `docs/abnahme-mvp.md`
- [x] Gitea: Labels, Milestones `v0.1.0`…`v0.5.0`, Epic-Issues #001–#004 und Meilenstein-Issues #005–#019
@@ -65,4 +65,4 @@ Ein lauffähiges, leeres Projekt mit allen Qualitätsschranken, dem gehärteten
Paket `radix-ui`.
- **Port 3000 war von ProxLedger belegt**, die Abnahme lief deshalb auf 3100 bzw. 3200.
- Die Migrationen laufen im Compose-Stand nachweislich automatisch; eingespielt wird ab #006
etwas, vorher meldet der Dienst korrekt „No pending migrations to apply".
etwas, vorher meldet der Dienst korrekt „No pending migrations to apply“.
+1 -1
View File
@@ -51,7 +51,7 @@ Anmeldung per generischem OIDC (Ziel authentik) und als Notfallzugang lokal per
gewesen. Jetzt ein echter argon2-Hash, einmal lazy erzeugt. Nachgemessen: rund 1 ms Unterschied
über je 60 Anfragen, über ein Netz nicht auswertbar.
- **`overflow-x: hidden` auf `body` hat meinen eigenen Mobil-Test blind gemacht.** Auf der
Benutzerseite war bei 390 px der Knopf „Setzen" abgeschnitten, die Prüfung
Benutzerseite war bei 390 px der Knopf „Setzen“ abgeschnitten, die Prüfung
`scrollWidth > clientWidth` konnte das aber nicht sehen. Das `hidden` ist raus, die Liste sind
Karten statt einer Tabelle.
- **Jede OIDC-Anmeldung hätte die Rolle überschrieben**, auch ohne konfigurierte
+6 -6
View File
@@ -17,7 +17,7 @@ Ein Codepfad für beide Betriebsmodi. Im Modus `INTERN` ist die Kunden-Ebene vol
- [x] Moduswechsel-Service: `DIENSTLEISTUNG → INTERN` nur bei genau einem nicht archivierten Kunden, sonst Ablehnung mit Hinweis
- [x] Beide Richtungen mit Bestätigungsdialog und `Protokoll`-Eintrag, nur `ADMIN`
- [x] Kundenverwaltung im Modus `DIENSTLEISTUNG`: anlegen, `kuerzel` eindeutig 2–6 Zeichen, archivieren statt löschen, Notizen
- [x] Navigation und Beschriftungen je Modus: `INTERN` = Übersicht, Läufe, Vorlagen, Mitarbeiter & Geräte, Archiv, Admin; `DIENSTLEISTUNG` = Übersicht, Läufe, Vorlagen, Kunden, Archiv, Admin und „Mitarbeiter" heißt „Personen"
- [x] Navigation und Beschriftungen je Modus: `INTERN` = Übersicht, Läufe, Vorlagen, Mitarbeiter & Geräte, Archiv, Admin; `DIENSTLEISTUNG` = Übersicht, Läufe, Vorlagen, Kunden, Archiv, Admin und „Mitarbeiter“ heißt „Personen“
- [x] Optionale Kundenbindung über `OIDC_CUSTOMER_GROUP_PREFIX` (z. B. `lz-kunde-MUS`): `TECHNIKER`/`LESER` sehen nur ihre Kunden, `ADMIN`/`REDAKTEUR` alles; ohne die Variable keine Einschränkung
- [x] Admin → Einstellungen: Modus mit Wechsel-Dialog, Name der eigenen Organisation, globale Namensregeln, Sperr-OU, Anzeige von `APP_URL` für die QR-Codes
- [x] Screenshots Einstellungen, Wechsel-Dialog, Kundenliste in `docs/screenshots/m3/`
@@ -38,8 +38,8 @@ Ein Codepfad für beide Betriebsmodi. Im Modus `INTERN` ist die Kunden-Ebene vol
Kunden zurück, `sichtbareKundenIds` hatte in der Produktion **keinen einzigen Aufrufer**. Ein
gebundener Techniker hätte Namen, Notizen und Bestandszahlen fremder Kunden gesehen. Die
Funktionen verlangen jetzt den Benutzer und filtern selbst — `CLAUDE.md` verlangt genau das,
„damit kein Aufrufer sie vergessen kann".
- **Mein Kommentar „Alles in einer Transaktion" war falsch.** Der Protokolleintrag stand hinter
„damit kein Aufrufer sie vergessen kann“.
- **Mein Kommentar „Alles in einer Transaktion“ war falsch.** Der Protokolleintrag stand hinter
der Transaktion: schlug er fehl, war der Modus schon gewechselt und der Vorgang stand nirgends.
Der Review hat es reproduziert. Jetzt läuft alles in einer Transaktion.
- **Wettlauf beim Moduswechsel.** Prüfung und Vollzug lagen auseinander: zwei gleichzeitige
@@ -49,11 +49,11 @@ Ein Codepfad für beide Betriebsmodi. Im Modus `INTERN` ist die Kunden-Ebene vol
- **Die Sichtbarkeit steckte in einer zweiten Funktion**, an die jeder künftige Aufrufer selbst
hätte denken müssen. Sie ist jetzt Teil von `kundeErmitteln` — der einen Stelle, an der die
Mandantenregel steht. Die Signatur ändert sich damit vor #009, das sie übernimmt.
- **ASCII-Faltung im falschen Text.** „Kuerzel", „gehoert", „Grossbuchstaben" standen in
Fehlermeldungen, die der Admin liest, während die Oberfläche sonst „Kürzel" schreibt. Die Regel
- **ASCII-Faltung im falschen Text.** „Kuerzel“, „gehoert“, „Grossbuchstaben“ standen in
Fehlermeldungen, die der Admin liest, während die Oberfläche sonst „Kürzel“ schreibt. Die Regel
in `CLAUDE.md` gilt für Bezeichner, nicht für Fließtext — steht jetzt ausdrücklich dort.
- **Die Erfolgsmeldung nach dem Wechsel war unsichtbar.** Sie wurde im Formularzweig gerendert,
den der gelungene Wechsel gerade durch die „Rückweg gesperrt"-Box ersetzt.
den der gelungene Wechsel gerade durch die „Rückweg gesperrt“-Box ersetzt.
- **Der Dialog versprach etwas, das der Code nicht durchsetzt** („Das Verzeichnis bleibt der
eigenen Organisation vorbehalten"). Eine Zusage auf Vorrat in einem Dialog, dessen ganzer Wert
darin liegt, nur zu zeigen was wirklich passiert — Satz entfernt.
+59 -18
View File
@@ -1,9 +1,9 @@
# #009 M4 – Vorlagen-Versionierung im Service
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** phase-1, backend
**Geschätzt:** 6 h **Tatsächlich:** –
**Geschätzt:** 6 h **Tatsächlich:** 1 h 20 min
## Ziel
@@ -11,22 +11,63 @@ Die harte Regel der Anwendung: eine veröffentlichte Version ist für immer unve
## Aufgaben
- [ ] Services für `Vorlage`, `VorlagenVersion`, `VorlagenAbschnitt`, `VorlagenSchritt`, `VorlagenVariable`
- [ ] `vorlageVeroeffentlichenService`: setzt `status = VEROEFFENTLICHT`, `veroeffentlichtAm`, `veroeffentlichtVonId`, Änderungsnotiz, `Protokoll`-Eintrag
- [ ] `vorlageBearbeitenService`: tiefe Kopie der letzten veröffentlichten Version als neuer Entwurf mit fortlaufender `version`; kopiert Variablen, Abschnitte und Schritte samt `konfig`
- [ ] Höchstens ein Entwurf je Vorlage — zweiter Versuch wirft `VorlagenFehler` mit Verweis auf den bestehenden Entwurf
- [ ] Jede Schreiboperation auf einer veröffentlichten Version wirft; zentral in `versionMussEntwurfSein()`
- [ ] Läufe verweisen ausschließlich auf veröffentlichte Versionen — beim Anlegen geprüft
- [ ] `vorlageDuplizierenService`: neue Vorlage mit Entwurf als Kopie
- [ ] JSON-Export und -Import mit Zod-Schema, Format in `docs/vorlagen-format.md` dokumentiert (Version des Formats, Abschnitte, Schritte, Variablen, `konfig`)
- [ ] Rechte: `REDAKTEUR` und `ADMIN` dürfen anlegen, bearbeiten, veröffentlichen; `TECHNIKER` und `LESER` nicht
- [x] Services für `Vorlage`, `VorlagenVersion`, `VorlagenAbschnitt`, `VorlagenSchritt`, `VorlagenVariable`
- [x] `versionVeroeffentlichen`: setzt `status = VEROEFFENTLICHT`, `veroeffentlichtAm`, `veroeffentlichtVonId`, Änderungsnotiz, `Protokoll`-Eintrag
- [x] `entwurfHolenOderErzeugen`: tiefe Kopie der letzten veröffentlichten Version als neuer Entwurf mit fortlaufender `version`; kopiert Variablen, Abschnitte und Schritte samt `konfig`
- [x] Höchstens ein Entwurf je Vorlage — durchgesetzt vom partiellen Eindeutigkeitsindex `VorlagenVersion_ein_entwurf`, der zweite gleichzeitige Aufruf bekommt denselben Entwurf
- [x] Jede Schreiboperation auf einer veröffentlichten Version wirft; zentral in `versionMussEntwurfSein()` — **und** zusätzlich in der Datenbank per Trigger
- [x] Läufe verweisen ausschließlich auf veröffentlichte Versionen — `veroeffentlichteVersionPruefen()` für #013
- [x] `vorlageDuplizieren`: neue Vorlage mit Entwurf als Kopie
- [x] JSON-Export und -Import mit Zod-Schema, Format in `docs/vorlagen-format.md` dokumentiert (Version des Formats, Abschnitte, Schritte, Variablen, `konfig`)
- [x] Rechte: `REDAKTEUR` und `ADMIN` dürfen anlegen, bearbeiten, veröffentlichen; `TECHNIKER` und `LESER` nicht
## Akzeptanzkriterien
- [ ] Test: nach dem Veröffentlichen schlägt jede Änderung an Version, Abschnitten, Schritten und Variablen fehl
- [ ] Test: „Bearbeiten" erzeugt eine tiefe Kopie — Änderungen am Entwurf lassen die veröffentlichte Version unberührt, auch in verschachtelten `konfig`-Feldern
- [ ] Test: ein zweiter Entwurf wird abgelehnt
- [ ] Test: ein Lauf auf Version 1 bleibt nach Veröffentlichen von Version 2 unverändert und zeigt weiter die Schritte aus Version 1
- [ ] Test: Export und anschließender Import ergeben eine inhaltsgleiche Vorlage
- [ ] Test: `TECHNIKER` darf nicht veröffentlichen
- [ ] Test: Veröffentlichen schreibt einen `Protokoll`-Eintrag
- [x] Test: nach dem Veröffentlichen schlägt jede Änderung an Version, Abschnitten, Schritten und Variablen fehl
- [x] Test: „Bearbeiten“ erzeugt eine tiefe Kopie — Änderungen am Entwurf lassen die veröffentlichte Version unberührt, auch in verschachtelten `konfig`-Feldern
- [x] Test: ein zweiter Entwurf wird abgelehnt
- [x] Test: ein Lauf auf Version 1 bleibt nach Veröffentlichen von Version 2 unverändert und zeigt weiter die Schritte aus Version 1
- [x] Test: Export und anschließender Import ergeben eine inhaltsgleiche Vorlage
- [x] Test: `TECHNIKER` darf nicht veröffentlichen
- [x] Test: Veröffentlichen schreibt einen `Protokoll`-Eintrag
## Beim Bauen aufgefallen
- **Die Lesemodelle haben die Mandantengrenze verloren, sobald jemand suchte.** Der Suchfilter
brachte einen zweiten `OR`-Schlüssel in dasselbe Objekt; in JavaScript gewinnt der letzte, also
ersetzte er die Kundenbedingung. Ein einzelner Buchstabe im Suchfeld gab einem gebundenen
Techniker alle Vorlagen aller Kunden. `versionsHistorie` und `versionMitAufbau` haben überhaupt
nicht gefiltert — damit lagen Abschnittstitel, Schritte und PowerShell-Befehle fremder Kunden
offen. Ursache ist banal: zu `src/server/queries/vorlagen.ts` gab es keinen einzigen Test. Es
gibt jetzt `tests/vorlagen-sicht.test.ts`, und die Kundenbedingung steht in einem `AND`-Eintrag,
wo kein weiterer Filter sie überschreiben kann.
- **Die Unveränderlichkeit hing allein am Service.** Migrationen, `psql`, ein Seed-Skript und ab
Phase 4 die Connectoren schreiben daran vorbei — und genau diese Regel ist der Grund, warum ein
Archiv-Lauf in zehn Jahren noch etwas wert ist. Vier Trigger setzen sie jetzt in der Datenbank
durch, einer davon verbietet den Rückweg `VEROEFFENTLICHT → ENTWURF`, der sonst alles andere
ausgehebelt hätte.
- **Prisma will handgeschriebene Datenbankobjekte löschen.** `prisma migrate dev` erzeugte für die
neue Migration sieben `DROP`-Anweisungen gegen Trigger, zusammengesetzte Fremdschlüssel und
Trigramm-Indizes — aus Prismas Sicht sind sie Abweichungen von der Vorgabe. Wer sie einmal
übersieht, entfernt stillschweigend die Mandantengrenze. `tests/datenbankregeln.test.ts` zählt
diese Objekte jetzt namentlich auf und macht jeden Verlust laut.
- **Die Trigger haben schlechte Test-Fixtures entlarvt.** Mehrere legten eine Version als
`VEROEFFENTLICHT` an und füllten sie *danach* — also genau das, was die Regel verbietet. Der
Service hatte das nie gesehen, weil die Fixtures direkt über Prisma schrieben.
- **Zwei Redakteure am selben Entwurf überschrieben sich spurlos.** Entwürfe werden bewusst nicht
protokolliert, die Arbeit wäre also auch nicht wiederherstellbar gewesen. `entwurfSpeichern`
nimmt jetzt den erwarteten Stand (`geaendertAm`) und weist ein Speichern auf einem überholten
Stand ab.
- **`Zod` war achtmal einzeln eingebunden**, die deutschen Meldungen hingen davon ab, dass jede
Datei daran denkt. Jetzt gibt es `src/lib/validierung.ts` als einzige Quelle, und eine
ESLint-Regel weist einen direkten `zod`-Import ab.
- **Der Doku-Link eines Schritts nahm jedes Schema an**, auch `javascript:alert(1)`. Er landet in
der Abarbeitungsansicht als `href` — über eine importierte Datei wäre das fremder Code im
Browser des Technikers. Erlaubt sind jetzt nur `http` und `https`.
- **Der Export verwarf bei jeder Beanstandung das ganze `konfig`.** Ein `shell: "zsh"` aus einer
späteren Fassung kostete lautlos auch den `befehl` — in genau der Datei, die jemand zum Umzug
braucht. Exportiert wird jetzt der rohe Inhalt; abgewiesen wird beim Import, laut und mit Pfad.
- **Zwei gleichzeitige Testläufe gegen dieselbe Datenbank** (mein Lauf und der des Review-Agenten)
ergaben 62 falsche Fehlschläge, weil `TRUNCATE` dem anderen die Daten unter den Füßen wegzieht.
Das war mein Fehler im Auftrag an den Agenten; `CLAUDE.md` nennt jetzt den Befehl für eine
eigene Review-Datenbank.
+2 -2
View File
@@ -11,7 +11,7 @@ Der Editor, in dem ein Redakteur eine Vorlage baut, ohne den Kopf über Versione
## Aufgaben
- [ ] Vorlagenliste mit Suche, Filter nach Kategorie und Tag, Kennzeichnung „Entwurf vorhanden"
- [ ] Vorlagenliste mit Suche, Filter nach Kategorie und Tag, Kennzeichnung „Entwurf vorhanden“
- [ ] Editor: Abschnitte und Schritte anlegen, bearbeiten, löschen; Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`)
- [ ] Sortieren per Drag & Drop mit `@dnd-kit`, auch am Handy mit dem Finger bedienbar
- [ ] Je Schritt: Titel, Typ (`HAKEN`, `TEXT`, `ZAHL`, `AUSWAHL`, `DATUM`, `BEFEHL`), Pflicht (roter Stern), Hilfetext als Markdown, Doku-Link
@@ -25,7 +25,7 @@ Der Editor, in dem ein Redakteur eine Vorlage baut, ohne den Kopf über Versione
## Akzeptanzkriterien
- [ ] Eine Vorlage lässt sich vollständig ohne Umweg über die Datenbank anlegen, sortieren, veröffentlichen
- [ ] Eine veröffentlichte Version ist im Editor nur lesend; „Bearbeiten" erzeugt sichtbar einen Entwurf
- [ ] Eine veröffentlichte Version ist im Editor nur lesend; „Bearbeiten“ erzeugt sichtbar einen Entwurf
- [ ] Unbekannte Platzhalter werden als Warnung angezeigt, blockieren aber das Speichern nicht
- [ ] Sortieren funktioniert am Handy bei 390 px Breite ohne horizontales Scrollen
- [ ] Der Markdown-Hilfetext rendert ohne rohes HTML
@@ -12,12 +12,12 @@ Die Stammdaten, an denen die Historie hängt. Mitarbeiter und Geräte werden nie
## Aufgaben
- [ ] Mitarbeiterliste: Suche über Anzeigename, Mail, UPN und `sam`, Filter nach Abteilung und Aktiv-Status
- [ ] Mitarbeitersuche als wiederverwendbare Komponente: standardmäßig nur aktive, Umschalter „auch deaktivierte" fürs Offboarding
- [ ] Mitarbeitersuche als wiederverwendbare Komponente: standardmäßig nur aktive, Umschalter „auch deaktivierte“ fürs Offboarding
- [ ] Mitarbeiterseite: Stammdaten, Abteilung, Vorgesetzter, seine Geräte, alle Läufe chronologisch (Onboarding, Übergaben, Offboarding)
- [ ] Mitarbeiter manuell anlegen und bearbeiten (`herkunft = MANUELL`); synchronisierte Felder sind bei `herkunft = ABGLEICH` gesperrt
- [ ] Deaktivieren statt löschen (`aktiv = false`)
- [ ] Abteilungen: Name (eindeutig je Kunde), Ziel-OU, Standardgruppen (`String[]`), Mail-Domain, Notizen
- [ ] Geräte: Stammdaten nach Vorgabe, Pflichtregel „`hostname` oder `bezeichnung` muss gesetzt sein", Besitzer optional
- [ ] Geräte: Stammdaten nach Vorgabe, Pflichtregel „`hostname` oder `bezeichnung` muss gesetzt sein“, Besitzer optional
- [ ] Geräteliste mit Suche über Hostname, Bezeichnung, Seriennummer und `tagCode`, Filter nach Typ
- [ ] CSV-Import für Geräte (`hostname`, `label`, `serialNumber`, `inventoryNumber`, `type`, `model`), `tagCode` wird beim Import erzeugt; eigener `src/lib/csv.ts`
- [ ] Archivieren statt Löschen, sobald Läufe an einem Gerät oder einer Abteilung hängen
+3 -3
View File
@@ -17,16 +17,16 @@ Reine, framework-freie Service-Schicht mit dichter Testabdeckung. Hier entstehen
- [ ] Aus der gewählten Abteilung: `{{ou}}` falls nicht vom Mitarbeiter, `{{gruppen}}` kommagetrennt
- [ ] `{{ou}}` eines synchronisierten Mitarbeiters ist der Eltern-Container seines `dn` (alles nach dem ersten Komma)
- [ ] Dazu die in der Vorlage definierten Variablen; unbekannte Platzhalter bleiben sichtbar stehen
- [ ] `{{generiertes_passwort}}`: 20 Zeichen aus Groß, Klein, Ziffern und Sonderzeichen ohne verwechselbare Zeichen, je Anzeige neu erzeugt, **nie** gespeichert; Hinweis „Passwort sofort in den Passwortmanager übernehmen"; in der Druckansicht `[Passwort – nicht gespeichert]`
- [ ] `{{generiertes_passwort}}`: 20 Zeichen aus Groß, Klein, Ziffern und Sonderzeichen ohne verwechselbare Zeichen, je Anzeige neu erzeugt, **nie** gespeichert; Hinweis „Passwort sofort in den Passwortmanager übernehmen“; in der Druckansicht `[Passwort – nicht gespeichert]`
- [ ] Namensregeln (`Einstellung.namensregeln` global, im Modus `DIENSTLEISTUNG` zusätzlich `Kunde.namensregeln` mit Fallback): `upn`, `sam` (höchstens 20 Zeichen), `mail`, `hostname`
- [ ] Transliteration (ä→ae, ö→oe, ü→ue, ß→ss, Akzente entfernt), Kleinschreibung, Leerzeichen entfernt, `{nnn}`-Zähler, `{v}` für den ersten Buchstaben des Vornamens, `{kundenkuerzel}`
- [ ] Shell-Escaping je Shell: PowerShell (doppelte Anführungszeichen verdoppeln, Backtick maskieren), Bash, Cmd
- [ ] HTML-Escaping ersetzter Werte; Markdown im Hilfetext sicher gerendert, kein rohes HTML
- [ ] „Alle Befehle als Skript kopieren" je Abschnitt: alle `BEFEHL`-Schritte in Reihenfolge, getrennt durch Leerzeilen, je mit `# Schritt: <Titel>` davor, Zeilenumbruch passend zur Shell
- [ ] „Alle Befehle als Skript kopieren“ je Abschnitt: alle `BEFEHL`-Schritte in Reihenfolge, getrennt durch Leerzeilen, je mit `# Schritt: <Titel>` davor, Zeilenumbruch passend zur Shell
## Akzeptanzkriterien
- [ ] Test: „Jörg Müller-Lüdenscheid" ergibt `joerg.mueller-luedenscheid@firma.de` und `jmuellerluedensch` (auf 20 Zeichen gekürzt)
- [ ] Test: „Jörg Müller-Lüdenscheid“ ergibt `joerg.mueller-luedenscheid@firma.de` und `jmuellerluedensch` (auf 20 Zeichen gekürzt)
- [ ] Test: `{{generiertes_passwort}}` erscheint im angezeigten Befehl, steht aber nicht in `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht; zwei Aufrufe liefern verschiedene Passwörter
- [ ] Test: ein Wert mit `"` und `` ` `` wird für PowerShell korrekt maskiert und bricht den Befehl nicht
- [ ] Test: unbekannte Platzhalter bleiben als `{{key}}` stehen
+4 -4
View File
@@ -22,14 +22,14 @@ Das Herz der Anwendung: ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen u
- [ ] `laufArchivierenService` und `laufZurueckholenService` (nur `ADMIN`), beide mit `Protokoll`-Eintrag
- [ ] `laufMussVeraenderbarSein(lauf)` zentral, aufgerufen vor jeder Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar`
- [ ] Kommentare je Lauf und je Schrittergebnis
- [ ] „Mit AD-Konto verknüpfen": ein Lauf mit `mitarbeiterName` wird nachträglich über UPN oder Name mit einem `Mitarbeiter` verbunden
- [ ] „Mit AD-Konto verknüpfen“: ein Lauf mit `mitarbeiterName` wird nachträglich über UPN oder Name mit einem `Mitarbeiter` verbunden
- [ ] Start-Dialog: Vorlage → Mitarbeiter → Gerät (wählen, neu anlegen oder scannen) → Ticket, Variablen, Zuweisung, Fälligkeit; Vorbelegung je Aufrufort, Besitzer des Geräts als Mitarbeiter vorgeschlagen; Vorschläge aus den Namensregeln für `upn`, `sam`, `mail`
- [ ] Abarbeitungsansicht: einklappbare Abschnitte mit `01 ·`-Nummerierung, Fortschrittsbalken in Lime, je Schritt Zustand, Wert, Notiz, Kommentare und „wer wann"
- [ ] Abarbeitungsansicht: einklappbare Abschnitte mit `01 ·`-Nummerierung, Fortschrittsbalken in Lime, je Schritt Zustand, Wert, Notiz, Kommentare und „wer wann“
- [ ] Erledigte Schritte durchgestrichen in Nebentext-Farbe, Checkbox erledigt als Lime-Kachel mit dunklem Haken, Pflichtschritte mit rotem Stern
- [ ] `BEFEHL`-Schritte als Codeblock auf `#0B0D10` mit Lime-Schrift, Kopier-Knopf, Warnung wenn ein Platzhalter unaufgelöst ist; je Abschnitt „Alle Befehle als Skript kopieren"
- [ ] `BEFEHL`-Schritte als Codeblock auf `#0B0D10` mit Lime-Schrift, Kopier-Knopf, Warnung wenn ein Platzhalter unaufgelöst ist; je Abschnitt „Alle Befehle als Skript kopieren“
- [ ] `ZAHL` außerhalb von Min/Max wird markiert, aber nicht blockiert
- [ ] Route Handler `GET /api/laeufe/[id]/zustand`, Client pollt alle 10 s und pausiert bei verstecktem Tab
- [ ] Umschalter „Kompakt / Ausführlich", ausführlich klappt alle Hilfetexte auf
- [ ] Umschalter „Kompakt / Ausführlich“, ausführlich klappt alle Hilfetexte auf
- [ ] Abschließen und Archivieren als getrennte Aktionen
- [ ] Screenshots Start-Dialog, Lauf Desktop, Lauf Handy, Befehlsschritt in `docs/screenshots/m8/`
+8 -8
View File
@@ -12,24 +12,24 @@ Der Einstieg für den Techniker, der am Gerät steht: scannen, sehen was zu tun
## Aufgaben
- [ ] Stapel-Erzeugung von N unregistrierten Tags (Standard 50) für vorgedruckte Aufkleber
- [ ] Route `/t/[tagCode]` mit Anmeldepflicht und Rücksprung; drei Fälle: registriert → Geräteseite; existiert unregistriert → Registrierformular; unbekannt → Hinweis „Kein Laufzettel-Tag" mit Link zur Suche
- [ ] Route `/t/[tagCode]` mit Anmeldepflicht und Rücksprung; drei Fälle: registriert → Geräteseite; existiert unregistriert → Registrierformular; unbekannt → Hinweis „Kein Laufzettel-Tag“ mit Link zur Suche
- [ ] Registrierformular: Typ, Hostname oder Bezeichnung, Seriennummer, Inventarnummer, optional Besitzer per Mitarbeitersuche; danach Geräteseite
- [ ] Geräteseite `/geraete/[id]`, handyoptimiert: Kopf mit Hostname bzw. Bezeichnung, Typ, Besitzer mit Abteilung, Seriennummer, `tagCode` in JetBrains Mono
- [ ] Abschnitt „Zugewiesen": alle Läufe zu diesem Gerät in `OFFEN` und `IN_ARBEIT` mit Fortschritt, Zuweisung und Fälligkeit, Knopf „Starten" bzw. „Weiter"
- [ ] Abschnitt „Schnellstart": Vorlagen aus `GeraetetypVorlage` für diesen Gerätetyp, ein Tipp startet den Lauf mit vorbelegtem Gerät und Besitzer
- [ ] Abschnitt „Vorlage zuweisen": Vorlage wählen, optional Techniker und Fälligkeit, erzeugt einen Lauf in `OFFEN`
- [ ] Abschnitt „Historie": abgeschlossene und archivierte Läufe chronologisch
- [ ] Abschnitt „Zugewiesen“: alle Läufe zu diesem Gerät in `OFFEN` und `IN_ARBEIT` mit Fortschritt, Zuweisung und Fälligkeit, Knopf „Starten“ bzw. „Weiter“
- [ ] Abschnitt „Schnellstart“: Vorlagen aus `GeraetetypVorlage` für diesen Gerätetyp, ein Tipp startet den Lauf mit vorbelegtem Gerät und Besitzer
- [ ] Abschnitt „Vorlage zuweisen“: Vorlage wählen, optional Techniker und Fälligkeit, erzeugt einen Lauf in `OFFEN`
- [ ] Abschnitt „Historie“: abgeschlossene und archivierte Läufe chronologisch
- [ ] QR-Erzeugung serverseitig als SVG mit `qrcode`, Fehlerkorrektur M, Inhalt ausschließlich `{APP_URL}/t/{tagCode}`, keine Personendaten, keine externe API
- [ ] Etikettendruck mit Print-CSS in zwei Layouts: Etikettendrucker 62 × 29 mm (ein Etikett pro Seite) und A4-Bogen 3 × 8 à 70 × 37 mm; je Etikett QR, `tagCode` lesbar darunter, optional Hostname und Besitzer, kleines Laufzettel-Logo; immer hell
- [ ] Im Modus `DIENSTLEISTUNG` zeigt das Etikett zusätzlich das Kundenkürzel, im Modus `INTERN` nie
- [ ] Knopf „Etikett drucken" auf der Geräteseite, Stapeldruck für unregistrierte Tags
- [ ] „Scannen" in der App: `barcode-detector` (native BarcodeDetector-API mit Polyfill-Fallback), Kamera im Browser, direkter Sprung zur Geräteseite
- [ ] Knopf „Etikett drucken“ auf der Geräteseite, Stapeldruck für unregistrierte Tags
- [ ] „Scannen“ in der App: `barcode-detector` (native BarcodeDetector-API mit Polyfill-Fallback), Kamera im Browser, direkter Sprung zur Geräteseite
- [ ] Screenshots Geräteseite Handy, Registrierformular, Etikett 62 × 29, A4-Bogen, Scanner in `docs/screenshots/m9/`
## Akzeptanzkriterien
- [ ] Test: `/t/LZ-7K3F9Q` ohne Anmeldung leitet zum Login und danach auf die Geräteseite von `PC-001`
- [ ] Test: „Vorlage zuweisen" erzeugt einen Lauf in `OFFEN`, der unter „Zugewiesen" erscheint; „Starten" öffnet ihn und setzt `IN_ARBEIT`
- [ ] Test: „Vorlage zuweisen“ erzeugt einen Lauf in `OFFEN`, der unter „Zugewiesen“ erscheint; „Starten“ öffnet ihn und setzt `IN_ARBEIT`
- [ ] Test: unregistrierter Tag führt zum Registrierformular, danach zur Geräteseite; unbekannter Tag zeigt den Hinweis
- [ ] Test: mehrere Zuweisungen je Gerät sind gleichzeitig möglich
- [ ] Test: der QR-Inhalt ist genau `{APP_URL}/t/{tagCode}` und enthält keine Personendaten
+3 -3
View File
@@ -16,11 +16,11 @@ Die Mitarbeiter der eigenen Firma kommen aus dem Active Directory, damit beim St
- [ ] `explicitBufferAttributes: ['objectGUID']` und korrekte Umsortierung der gemischt-endianen Bytes zur GUID-Zeichenkette als `externeId`
- [ ] Standardfilter `(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))`
- [ ] Attribut-Mapping mit AD-Vorgaben: `givenName`, `sn`, `displayName`, `mail`, `userPrincipalName` → `upn`, `sAMAccountName` → `sam`, `department`, `title`, `manager` → `vorgesetzterExterneId`, `distinguishedName` → `dn`
- [ ] „Verbindung testen": zeigt die ersten 10 Treffer mit gemappten Feldern, ohne etwas zu schreiben
- [ ] „Verbindung testen“: zeigt die ersten 10 Treffer mit gemappten Feldern, ohne etwas zu schreiben
- [ ] `abgleichService`: anlegen, aktualisieren, verschwundene oder deaktivierte Konten auf `aktiv = false` — **nie löschen**; Ergebnis (angelegt/aktualisiert/deaktiviert/Fehler) in `letzterAbgleichStatus`, `letzterAbgleichFehler`, `letzterAbgleichAm` und im `Protokoll`
- [ ] Zeitplan mit `node-cron` im selben Prozess, Intervall je Quelle (Standard 60 min), Sperre über `AbgleichSperre` in der Datenbank, damit nur eine Instanz läuft; zusätzlich „Jetzt abgleichen"
- [ ] Zeitplan mit `node-cron` im selben Prozess, Intervall je Quelle (Standard 60 min), Sperre über `AbgleichSperre` in der Datenbank, damit nur eine Instanz läuft; zusätzlich „Jetzt abgleichen“
- [ ] Bind-Passwort verschlüsselt über `src/lib/geheimnisse.ts`, Serializer entfernt `bindPasswortVerschluesselt` aus jeder Antwort
- [ ] Admin → Verzeichnis: Quelle anlegen und bearbeiten, Verbindung testen, Abgleich-Status und -Log, „Jetzt abgleichen"
- [ ] Admin → Verzeichnis: Quelle anlegen und bearbeiten, Verbindung testen, Abgleich-Status und -Log, „Jetzt abgleichen“
- [ ] Manuelle Mitarbeiter und CSV-Import (`givenName`, `sn`, `displayName`, `mail`, `upn`, `sam`, `department`, `title`)
- [ ] Abteilungsvorschläge aus den `department`-Werten des Abgleichs, vom Admin in `Abteilung`-Einträge übernehmbar
- [ ] Nur die gemappten Attribute speichern: keine Passwörter, keine Gruppenmitgliedschaften des Mitarbeiters
+11 -11
View File
@@ -11,27 +11,27 @@ Die App ist nach dem Seed fachlich sofort brauchbar: sieben vollständige, fachl
## Aufgaben
- [ ] Vorlage 1 „PC Neuinstallation" (Client), Variablen `os_version`, `join_typ`; Abschnitte Vorbereitung, Installation, Einbindung, Software, Sicherheit, Benutzer, Abschluss
- [ ] Vorlage 2 „Mitarbeiter-Onboarding" (Benutzer), Variablen `konto_typ`, `vorname`, `nachname`, `upn`, `sam`, `mail`, `abteilung`, `ou`, `gruppen`, `eintrittsdatum`, `vorgesetzter`; **beide** Konto-Abschnitte (AD und Entra ID), weil bedingte Abschnitte erst Phase 3 sind
- [ ] Vorlage 3 „Mitarbeiter-Offboarding" (Benutzer), Variablen `austrittsdatum`, `ou_gesperrt`, `postfach_verbleib`, `vertreter`
- [ ] Vorlage 4 „Server-Inbetriebnahme Windows-VM" (Server), Variablen `vmid`, `ip_adresse`, `vlan`, `rolle`, `os_version`
- [ ] Vorlage 5 „Firewall-Rollout OPNsense" (Netzwerk), Variablen `wan_typ`, `lan_netz`, `vpn_typ`
- [ ] Vorlage 6 „Monatlicher Patchday" (Wartung), Variable `monat`
- [ ] Vorlage 7 „Backup-Restore-Test" (Wartung), Variablen `system`, `restore_typ`
- [ ] Vorlage 1 „PC Neuinstallation“ (Client), Variablen `os_version`, `join_typ`; Abschnitte Vorbereitung, Installation, Einbindung, Software, Sicherheit, Benutzer, Abschluss
- [ ] Vorlage 2 „Mitarbeiter-Onboarding“ (Benutzer), Variablen `konto_typ`, `vorname`, `nachname`, `upn`, `sam`, `mail`, `abteilung`, `ou`, `gruppen`, `eintrittsdatum`, `vorgesetzter`; **beide** Konto-Abschnitte (AD und Entra ID), weil bedingte Abschnitte erst Phase 3 sind
- [ ] Vorlage 3 „Mitarbeiter-Offboarding“ (Benutzer), Variablen `austrittsdatum`, `ou_gesperrt`, `postfach_verbleib`, `vertreter`
- [ ] Vorlage 4 „Server-Inbetriebnahme Windows-VM“ (Server), Variablen `vmid`, `ip_adresse`, `vlan`, `rolle`, `os_version`
- [ ] Vorlage 5 „Firewall-Rollout OPNsense“ (Netzwerk), Variablen `wan_typ`, `lan_netz`, `vpn_typ`
- [ ] Vorlage 6 „Monatlicher Patchday“ (Wartung), Variable `monat`
- [ ] Vorlage 7 „Backup-Restore-Test“ (Wartung), Variablen `system`, `restore_typ`
- [ ] Alle Schritte wortgetreu nach Auftrag mit Typ, Pflichtkennzeichen und `konfig`; die im Auftrag genannten Hilfetexte übernehmen, die übrigen fachlich korrekt und kurz ergänzen
- [ ] 5 Mitarbeiter, darunter „Maria Huber", `m.huber@firma.example`, `sam` `mhuber`, Abteilung Buchhaltung, Vorgesetzte „Petra Klein"
- [ ] 5 Mitarbeiter, darunter „Maria Huber“, `m.huber@firma.example`, `sam` `mhuber`, Abteilung Buchhaltung, Vorgesetzte „Petra Klein“
- [ ] 3 Abteilungen: Buchhaltung → `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` mit Gruppen `G-Buchhaltung, G-Alle`; Vertrieb; IT
- [ ] Eine **deaktivierte** `Verzeichnisquelle` als Beispielkonfiguration
- [ ] Geräte `PC-001` (PC, `LZ-7K3F9Q`, Besitzerin Maria Huber) und `NB-001` (Notebook, `LZ-M2XH4T`), dazu 5 unregistrierte Tags
- [ ] `GeraetetypVorlage`: PC und NOTEBOOK → „PC Neuinstallation"; SERVER → „Server-Inbetriebnahme Windows-VM" und „Backup-Restore-Test"; FIREWALL → „Firewall-Rollout OPNsense"
- [ ] `SEED_MODE=DIENSTLEISTUNG` (Alias `SERVICE`) erzeugt stattdessen „Musterfirma GmbH" (`MUS`) und „Bäckerei Reiter" (`REI`) mit je einer Person, einer Abteilung und einem Gerät
- [ ] `GeraetetypVorlage`: PC und NOTEBOOK → „PC Neuinstallation“; SERVER → „Server-Inbetriebnahme Windows-VM“ und „Backup-Restore-Test“; FIREWALL → „Firewall-Rollout OPNsense“
- [ ] `SEED_MODE=DIENSTLEISTUNG` (Alias `SERVICE`) erzeugt stattdessen „Musterfirma GmbH“ (`MUS`) und „Bäckerei Reiter“ (`REI`) mit je einer Person, einer Abteilung und einem Gerät
- [ ] Alles idempotent über `upsert` mit stabilen Schlüsseln
## Akzeptanzkriterien
- [ ] `pnpm db:seed` zweimal hintereinander: keine Fehler, keine Duplikate, keine geänderten Zeilen
- [ ] Alle sieben Vorlagen liegen als `VEROEFFENTLICHT`, Version 1 vor
- [ ] Im Onboarding-Lauf steht im Schritt „Konto anlegen" der vollständige `New-ADUser`-Befehl mit Name, UPN, `-Path "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local"` und der Gruppen-Befehl mit `G-Buchhaltung, G-Alle`
- [ ] Im Onboarding-Lauf steht im Schritt „Konto anlegen“ der vollständige `New-ADUser`-Befehl mit Name, UPN, `-Path "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local"` und der Gruppen-Befehl mit `G-Buchhaltung, G-Alle`
- [ ] `SEED_MODE=SERVICE` erzeugt `MUS-PC-001` gemäß Hostname-Regel `{kundenkuerzel}-PC-{nnn}`
- [ ] Die Beispiel-Verzeichnisquelle ist deaktiviert und löst keinen Abgleich aus
- [ ] Kein Seed-Datensatz enthält ein Klartext-Passwort außer dem Admin-Passwort aus der Umgebung, und das nur als argon2-Hash
+3 -3
View File
@@ -11,10 +11,10 @@ Nachweis, dass die Abläufe wirklich durchlaufen, und eine Anleitung, mit der ei
## Aufgaben
- [ ] Playwright-Hauptablauf: anmelden → „PC Neuinstallation" für `PC-001` starten → Schritte abhaken → Wert eintragen → einen Schritt „nicht zutreffend" mit Notiz → abschließen → archivieren → im Archiv finden → Schreibschutz greift
- [ ] Playwright-Hauptablauf: anmelden → „PC Neuinstallation“ für `PC-001` starten → Schritte abhaken → Wert eintragen → einen Schritt „nicht zutreffend“ mit Notiz → abschließen → archivieren → im Archiv finden → Schreibschutz greift
- [ ] Derselbe Hauptablauf mit `SEED_MODE=SERVICE` gegen `MUS-PC-001`, mit Kundenwahl im Start-Dialog
- [ ] Playwright-QR-Ablauf: `/t/LZ-7K3F9Q` ohne Anmeldung → Login → Geräteseite → „Vorlage zuweisen" → Lauf erscheint unter „Zugewiesen" → „Starten" setzt `IN_ARBEIT`; dazu unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis
- [ ] Playwright-Onboarding-Ablauf: Vorlage starten → Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen → Abteilung „Buchhaltung" wählen → im Schritt „Konto anlegen" steht der fertige `New-ADUser`-Befehl → Kopieren liefert exakt diesen Text (Clipboard auslesen)
- [ ] Playwright-QR-Ablauf: `/t/LZ-7K3F9Q` ohne Anmeldung → Login → Geräteseite → „Vorlage zuweisen“ → Lauf erscheint unter „Zugewiesen“ → „Starten“ setzt `IN_ARBEIT`; dazu unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis
- [ ] Playwright-Onboarding-Ablauf: Vorlage starten → Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen → Abteilung „Buchhaltung“ wählen → im Schritt „Konto anlegen“ steht der fertige `New-ADUser`-Befehl → Kopieren liefert exakt diesen Text (Clipboard auslesen)
- [ ] Playwright-Mobilprüfung: auf allen Hauptseiten bei 390 × 844 ist `scrollWidth <= clientWidth`
- [ ] Playwright-Rollenprüfung: `LESER` sieht keine Schreib-Knöpfe und wird serverseitig abgewiesen
- [ ] `pnpm docs:screenshots`: Playwright-Skript, Desktop 1440 × 900 und Handy 390 × 844, Seed-Daten, dunkles Design, mit Markierungen (Rahmen, Nummern) für die beschriebenen Stellen; Ablage `docs/anleitung/bilder/`
+2 -2
View File
@@ -13,7 +13,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #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) | erledigt | v0.1.0 | 8 h | 1 h 35 min |
| #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | erledigt | v0.1.0 | 6 h | 1 h 20 min |
| #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | offen | v0.1.0 | 6 h | – |
| #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | erledigt | v0.1.0 | 6 h | 1 h 20 min |
| #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | offen | v0.1.0 | 10 h | – |
| #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | offen | v0.1.0 | 9 h | – |
| #012 | [M7 – Platzhalter, Namensregeln, Escaping](012-platzhalter-namensregeln.md) | offen | v0.1.0 | 8 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** | **5 h 30 min** |
| | **Summe** | | | **136 h** | **6 h 50 min** |
+224
View File
@@ -0,0 +1,224 @@
# Austauschformat für Vorlagen
Eine Vorlage lässt sich als JSON-Datei exportieren und wieder einspielen. Diese Datei beschreibt
das Format — für jemanden, der eine Vorlage von Hand schreiben oder ein Skript dafür bauen will.
Die Prüfung liegt in [`src/lib/vorlagen-format.ts`](../src/lib/vorlagen-format.ts), Export und
Import in [`src/server/services/vorlagen-austausch.ts`](../src/server/services/vorlagen-austausch.ts).
Beide benutzen dasselbe Schema: was hier steht, gilt für den Export, den Import und den Editor.
## Wozu das Format da ist
Eine exportierte Vorlage soll sich in eine **andere** Laufzettel-Installation einspielen lassen.
Deshalb enthält das Dokument nur das, was eine Vorlage inhaltlich ausmacht: Kopfdaten, Variablen,
Abschnitte, Schritte. Alles, was nur in der Quellinstallation eine Bedeutung hat, bleibt draußen.
**Nicht im Dokument enthalten:**
- **Kennungen** (`id`, `versionId`, `abschnittId`) — sie zeigen in der Zielinstallation ins Leere
- **Zeitstempel** (`erstelltAm`, `geaendertAm`, `veroeffentlichtAm`) und der veröffentlichende
Benutzer
- **Kundenbezug** (`kundeId`) — eine eingespielte Vorlage gilt zunächst für alle Kunden; wer sie
einem Kunden zuordnen will, tut das danach in der Oberfläche
- **`position`** — die Reihenfolge steckt in der Reihenfolge der Listen. Zwei Wahrheiten über
dieselbe Reihenfolge laufen früher oder später auseinander.
- **Läufe**, Schrittergebnisse, Kommentare und das Protokoll — das ist Betriebsgeschehen, keine
Vorlage
- der Status der Version und das Archiv-Kennzeichen
## Beispiel
Das folgende Dokument ist vollständig: zwei Abschnitte, zwei Variablen und ein `BEFEHL`-Schritt mit
Platzhaltern. **JSON kennt keine Kommentare** — die Zeilen mit `//` müssen vor dem Import raus.
```jsonc
{
// Fassung des Austauschformats, derzeit immer 1.
"format": 1,
"name": "Onboarding Notebook",
"beschreibung": "Neue Person, neues Gerät.",
"kategorie": "Onboarding",
"schlagworte": ["notebook", "eintritt"],
// Nur zur Information: welche Version der Quelle exportiert wurde. Beim
// Import wird der Wert gelesen und nicht übernommen.
"version": 3,
"struktur": {
// Wird beim Start eines Laufs abgefragt und füllt die Platzhalter.
"variablen": [
{ "schluessel": "vorname", "beschriftung": "Vorname", "typ": "TEXT", "pflicht": true },
{
"schluessel": "abteilung",
"beschriftung": "Abteilung",
"typ": "AUSWAHL",
"optionen": ["IT", "Vertrieb", "Buchhaltung"]
}
],
// Die Reihenfolge dieser Liste ist die Reihenfolge in der Ansicht.
"abschnitte": [
{
"titel": "01 · Vorbereitung",
"schritte": [
{ "titel": "Gerät auspacken", "pflicht": true },
{
"titel": "Seriennummer erfassen",
"hilfetext": "Aufkleber auf der Unterseite. **Nicht** die Artikelnummer.",
"typ": "TEXT",
"pflicht": true,
"konfig": { "mehrzeilig": false },
"dokuUrl": "https://intern.example/seriennummern"
},
{
"titel": "Arbeitsspeicher prüfen",
"typ": "ZAHL",
"konfig": { "min": 8, "max": 64, "einheit": "GB" }
}
]
},
{
"titel": "02 · Konto",
"schritte": [
{
"titel": "Benutzerkonto anlegen",
"typ": "BEFEHL",
"pflicht": true,
// Die Platzhalter werden erst beim Abarbeiten ersetzt; hier stehen
// sie wörtlich.
"konfig": {
"shell": "powershell",
"befehl": "New-ADUser -Name \"{{vorname}}\" -Department \"{{abteilung}}\""
}
},
{ "titel": "Eintrittsdatum hinterlegen", "typ": "DATUM" }
]
}
]
}
}
```
## Felder
### Kopf
| Feld | Pflicht | Bedeutung |
| -------------- | -------- | ------------------------------------------------------------------------------- |
| `format` | Pflicht | Fassung des Austauschformats. Derzeit `1`; ein anderer Wert wird abgewiesen. |
| `name` | Pflicht | Name der Vorlage, nicht leer. Namen sind **nicht** eindeutig. |
| `beschreibung` | optional | Ein bis zwei Sätze, was die Vorlage abdeckt. |
| `kategorie` | optional | Freitext, gruppiert die Vorlagenliste (etwa `Onboarding`). |
| `schlagworte` | optional | Liste nicht leerer Texte; Standard `[]`. |
| `version` | optional | Versionsnummer der exportierten Quelle. Reine Information, siehe unten. |
| `struktur` | Pflicht | Objekt mit `variablen` und `abschnitte`. |
### `struktur`
| Feld | Pflicht | Bedeutung |
| ------------ | -------- | ---------------------------------------------------------- |
| `variablen` | optional | Liste von Variablen in Abfragereihenfolge; Standard `[]`. |
| `abschnitte` | optional | Liste von Abschnitten in Anzeigereihenfolge; Standard `[]`. |
### Variable
| Feld | Pflicht | Bedeutung |
| -------------- | -------- | ------------------------------------------------------------------------- |
| `schluessel` | Pflicht | Der Name im Platzhalter `{{schluessel}}`. Regeln siehe unten. |
| `beschriftung` | Pflicht | Was im Startformular über dem Feld steht, nicht leer. |
| `typ` | optional | `TEXT`, `ZAHL`, `AUSWAHL` oder `DATUM`; Standard `TEXT`. |
| `optionen` | optional | Nur bei `AUSWAHL`: die Auswahlwerte in Anzeigereihenfolge; Standard `[]`. |
| `pflicht` | optional | `true` verlangt eine Eingabe beim Start; Standard `false`. |
Ein Schlüssel darf in einem Dokument nur **einmal** vorkommen. Ein zweiter Eintrag mit demselben
Schlüssel ist ein Fehler und kein stilles Überschreiben — sonst verschwände eine Variable, ohne
dass es jemand merkt.
### Abschnitt
| Feld | Pflicht | Bedeutung |
| ---------- | -------- | ----------------------------------------------------------------------------- |
| `titel` | Pflicht | Überschrift, nicht leer. Üblich mit zweistelliger Nummer: `01 · Vorbereitung`. |
| `schritte` | optional | Liste von Schritten in Abarbeitungsreihenfolge; Standard `[]`. |
### Schritt
| Feld | Pflicht | Bedeutung |
| ----------- | -------- | -------------------------------------------------------------------------------- |
| `titel` | Pflicht | Was zu tun ist, nicht leer. Darf Platzhalter enthalten. |
| `hilfetext` | optional | Markdown, in der Abarbeitungsansicht ausklappbar. **Kein rohes HTML.** |
| `typ` | optional | Einer der Schritt-Typen unten; Standard `HAKEN`. |
| `pflicht` | optional | Pflichtschritte müssen vor dem Abschluss erledigt oder ausdrücklich als nicht zutreffend markiert sein; Standard `false`. |
| `konfig` | optional | Typabhängige Einzelheiten; Standard `{}`. Siehe unten. |
| `dokuUrl` | optional | Link auf eine Anleitung. Muss eine gültige Adresse sein. |
## Schritt-Typen und ihre `konfig`
| Typ | Was der Techniker tut | Schlüssel in `konfig` |
| --------- | ------------------------------ | -------------------------------------------------------------- |
| `HAKEN` | abhaken | keine |
| `TEXT` | Text eintragen | `mehrzeilig` (`true`/`false`) |
| `ZAHL` | Zahl eintragen | `min`, `max` (Zahlen), `einheit` (Text, höchstens 40 Zeichen) |
| `AUSWAHL` | aus einer Liste wählen | `optionen` (Liste nicht leerer Texte, in Anzeigereihenfolge) |
| `DATUM` | Datum wählen | keine |
| `BEFEHL` | Befehl kopieren und ausführen | `shell` (`powershell`, `bash` oder `cmd`), `befehl` (Text) |
`min` und `max` sind Hinweise: ein Wert außerhalb wird markiert, nicht blockiert.
`konfig` wird **nachsichtig** geprüft. Unbekannte Schlüssel bleiben erhalten — eine Datei aus einer
späteren Fassung verliert hier nichts, auch wenn diese Fassung die Schlüssel nicht kennt. Was die
Anwendung kennt, wird geprüft: `shell` etwa akzeptiert nur die drei genannten Werte.
Ein Schlüssel, der nicht zum Typ passt, ist kein Fehler; er wird schlicht nicht beachtet. Wer den
Typ eines Schritts ändert, räumt die `konfig` also besser selbst auf.
## Platzhalter und Variablenschlüssel
Ein Variablenschlüssel muss der Form `^[a-z][a-z0-9_]{0,39}$` entsprechen: Beginn mit einem
Kleinbuchstaben, danach Kleinbuchstaben, Ziffern und Unterstrich, höchstens 40 Zeichen.
`vorname`, `ad_gruppe` und `raum2` sind in Ordnung; `Vorname`, `mit-strich`, `2te_wahl` und ein
leerer Schlüssel nicht.
Die Strenge hat einen Grund: der Schlüssel steht als `{{schluessel}}` in Titeln, Hilfetexten und
Befehlen. Ein Platzhalter wird genau mit diesem Muster wiedererkannt. Wäre `Vor-Name` erlaubt,
müsste der Suchausdruck Groß- und Kleinschreibung, Striche und Leerzeichen auseinanderhalten — und
`{{Vor-Name}}` in einem PowerShell-Befehl wäre nicht mehr von dessen eigener Syntax zu trennen.
Deshalb: ein Zeichensatz, eine Regel, keine Ausnahmen.
Geschweifte Klammern dürfen Leerzeichen enthalten (`{{ vorname }}` wird erkannt). Neben den
Variablen der Vorlage gibt es eingebaute Platzhalter — `{{generiertes_passwort}}` etwa —, die nicht
im Austauschdokument stehen und auch nirgends gespeichert werden.
## Was beim Import passiert
1. Das Dokument wird gegen das Schema geprüft. Schlägt das fehl, wird **nichts** geschrieben.
2. Es entsteht eine **neue Vorlage** — der Import schreibt nie in eine bestehende hinein.
3. Die Vorlage bekommt genau eine Version: `version = 1`, Status **`ENTWURF`**. Eine eingespielte
Vorlage wird bewusst nicht sofort veröffentlicht; der Redakteur sieht sie sich erst an,
ergänzt vielleicht etwas und veröffentlicht dann. Erst danach lässt sich ein Lauf daran starten.
4. `position` wird aus der Reihenfolge der Listen abgeleitet (0, 1, 2 …).
5. Das mitgeschickte `version`-Feld wird nicht übernommen. Eine Vorlage, die in der Quelle auf
Version 7 stand, fängt hier wieder bei 1 an — die Versionsgeschichte der Quelle wandert nicht mit.
6. Der Vorlagenname kommt so an, wie er im Dokument steht. Gibt es ihn schon, ist das kein Fehler
und es wird nichts angehängt: Vorlagennamen sind nicht eindeutig.
7. Alles geschieht in einer Transaktion. Ein Fehler auf halbem Weg hinterlässt keine halbe Vorlage.
8. Ein Protokolleintrag mit der Aktion `Vorlage importiert` hält fest, wer die Vorlage eingespielt hat.
Wird das Dokument abgewiesen, nennt die Meldung die **erste** Beanstandung samt Pfad, etwa:
```
struktur.abschnitte.1.schritte.2.titel: Ein Schritt braucht einen Titel.
```
## Wie sich das Format ändern darf
Das Feld `format` ist die Fassung des Formats, nicht die der Anwendung. Es gilt:
- **Neue optionale Felder** und neue `konfig`-Schlüssel erhöhen `format` **nicht**. Alte Dateien
bleiben gültig, und weil `konfig` nachsichtig geprüft wird, überleben neue Schlüssel auch einen
Rundlauf durch eine ältere Fassung.
- **Neue Schritt- und Variablentypen** erhöhen `format` ebenfalls nicht. Eine Fassung, die den Typ
nicht kennt, weist die Datei allerdings ab, weil der Typ eine geprüfte Aufzählung ist.
- Erst eine Änderung, die alte Dateien **unbrauchbar** macht — ein umbenanntes Pflichtfeld, eine
andere Verschachtelung —, erhöht `format` auf `2`.
- Ein unbekannter `format`-Wert wird mit einer klaren Meldung abgewiesen, nie geraten. Ein Import,
der die Hälfte versteht und die andere Hälfte still verwirft, wäre schlimmer als ein Fehlschlag.
Die Zahl steht als `FORMAT_VERSION` in `src/lib/vorlagen-format.ts` an genau einer Stelle.
+24
View File
@@ -7,6 +7,30 @@ export default defineConfig([
...nextVitals,
...nextTs,
prettier,
{
rules: {
// Zod wird in src/lib/validierung.ts auf deutsche Meldungen eingestellt.
// Ein direkter Import aus "zod" umginge das, und die englischen
// Standardsaetze landeten in der Oberflaeche.
"no-restricted-imports": [
"error",
{
paths: [
{
name: "zod",
message:
'Bitte `import { z } from "@/lib/validierung"` benutzen – dort sind die deutschen Meldungen eingestellt.',
},
],
},
],
},
},
{
// Die Datei, die Zod einstellt, muss es selbst importieren duerfen.
files: ["src/lib/validierung.ts"],
rules: { "no-restricted-imports": "off" },
},
globalIgnores([
".next/**",
"out/**",
@@ -0,0 +1,94 @@
-- ---------------------------------------------------------------------------
-- HANDGESCHRIEBEN. Prisma hatte hier zusaetzlich sieben Loeschbefehle erzeugt:
-- vier zusammengesetzte Fremdschluessel (Mandantengrenze) und drei
-- Trigramm-Indizes (Archivsuche) aus der ersten Migration. Prisma kennt sie
-- nicht, weil sie sich im Schema nicht ausdruecken lassen, und will sie deshalb
-- bei jeder Migration entfernen. Die Loeschbefehle sind bewusst gestrichen.
--
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
-- ---------------------------------------------------------------------------
-- Stand des Entwurfs, gegen den der Editor beim Speichern prueft.
ALTER TABLE "VorlagenVersion"
ADD COLUMN "geaendertAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- ---------------------------------------------------------------------------
-- Eine veroeffentlichte Version ist unveraenderlich.
--
-- Diese Regel traegt die Nachvollziehbarkeit des ganzen Archivs: ein Lauf zeigt
-- auch in zehn Jahren genau die Schritte, die damals abgearbeitet wurden.
-- Bisher stand sie nur in der Service-Schicht — Migrationen, psql, Skripte und
-- ab Phase 4 die Connectoren schreiben daran vorbei. Deshalb steht sie jetzt
-- auch hier.
--
-- TRUNCATE loest Zeilen-Trigger nicht aus; das Leeren der Testdatenbank bleibt
-- also moeglich.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION laufzettel_version_unveraenderlich() RETURNS trigger AS $$
DECLARE
betroffene_version TEXT;
aktueller_status TEXT;
BEGIN
betroffene_version := COALESCE(NEW."versionId", OLD."versionId");
SELECT status::TEXT INTO aktueller_status
FROM "VorlagenVersion" WHERE id = betroffene_version;
IF aktueller_status = 'VEROEFFENTLICHT' THEN
RAISE EXCEPTION
'Version % ist veroeffentlicht und unveraenderlich (%).',
betroffene_version, TG_TABLE_NAME
USING ERRCODE = 'integrity_constraint_violation';
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "VorlagenAbschnitt_unveraenderlich"
BEFORE INSERT OR UPDATE OR DELETE ON "VorlagenAbschnitt"
FOR EACH ROW EXECUTE FUNCTION laufzettel_version_unveraenderlich();
CREATE TRIGGER "VorlagenVariable_unveraenderlich"
BEFORE INSERT OR UPDATE OR DELETE ON "VorlagenVariable"
FOR EACH ROW EXECUTE FUNCTION laufzettel_version_unveraenderlich();
-- Schritte haengen ueber den Abschnitt an der Version, brauchen also den Umweg.
CREATE OR REPLACE FUNCTION laufzettel_schritt_unveraenderlich() RETURNS trigger AS $$
DECLARE
aktueller_status TEXT;
BEGIN
SELECT v.status::TEXT INTO aktueller_status
FROM "VorlagenAbschnitt" a
JOIN "VorlagenVersion" v ON v.id = a."versionId"
WHERE a.id = COALESCE(NEW."abschnittId", OLD."abschnittId");
IF aktueller_status = 'VEROEFFENTLICHT' THEN
RAISE EXCEPTION
'Der Abschnitt gehoert zu einer veroeffentlichten Version und ist unveraenderlich.'
USING ERRCODE = 'integrity_constraint_violation';
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "VorlagenSchritt_unveraenderlich"
BEFORE INSERT OR UPDATE OR DELETE ON "VorlagenSchritt"
FOR EACH ROW EXECUTE FUNCTION laufzettel_schritt_unveraenderlich();
-- Der Weg zurueck von VEROEFFENTLICHT nach ENTWURF waere die Hintertuer, durch
-- die sich alles Obige aushebeln liesse.
CREATE OR REPLACE FUNCTION laufzettel_version_status_einbahn() RETURNS trigger AS $$
BEGIN
IF OLD.status = 'VEROEFFENTLICHT' AND NEW.status <> 'VEROEFFENTLICHT' THEN
RAISE EXCEPTION
'Eine veroeffentlichte Version laesst sich nicht wieder zum Entwurf machen.'
USING ERRCODE = 'integrity_constraint_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "VorlagenVersion_status_einbahn"
BEFORE UPDATE ON "VorlagenVersion"
FOR EACH ROW EXECUTE FUNCTION laufzettel_version_status_einbahn();
+17 -10
View File
@@ -106,7 +106,7 @@ model Einstellung {
/// Ziel-OU fuer deaktivierte Konten, Vorbelegung im Offboarding.
gesperrteOu String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
geaendertAm DateTime @default(now()) @updatedAt
}
model Kunde {
@@ -123,7 +123,7 @@ model Kunde {
notizen String?
archiviert Boolean @default(false)
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
geaendertAm DateTime @default(now()) @updatedAt
abteilungen Abteilung[]
benutzerbindungen BenutzerKunde[]
@@ -155,7 +155,7 @@ model Benutzer {
sitzungenUngueltigAb DateTime?
letzteAnmeldungAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
geaendertAm DateTime @default(now()) @updatedAt
abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon")
archivierteLaeufe Lauf[] @relation("LaufArchiviertVon")
@@ -219,7 +219,7 @@ model Verzeichnisquelle {
/// hier landet – rohe Bind-Fehler koennen das Passwort enthalten.
letzterAbgleichFehler String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
geaendertAm DateTime @default(now()) @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
mitarbeiter Mitarbeiter[]
@@ -254,7 +254,7 @@ model Mitarbeiter {
herkunft Herkunft @default(MANUELL)
zuletztGesehenAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
geaendertAm DateTime @default(now()) @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict)
@@ -284,7 +284,7 @@ model Abteilung {
notizen String?
archiviert Boolean @default(false)
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
geaendertAm DateTime @default(now()) @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
laeufe Lauf[]
@@ -325,7 +325,7 @@ model Geraet {
archiviert Boolean @default(false)
registriertAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
geaendertAm DateTime @default(now()) @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: Restrict)
@@ -378,7 +378,7 @@ model Vorlage {
kundeId String?
archiviert Boolean @default(false)
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
geaendertAm DateTime @default(now()) @updatedAt
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: Restrict)
versionen VorlagenVersion[]
@@ -401,6 +401,13 @@ model VorlagenVersion {
veroeffentlichtAm DateTime?
veroeffentlichtVonId String?
erstelltAm DateTime @default(now())
/// Wird bei jeder Aenderung am Entwurf neu gesetzt und dient als Stand, den
/// der Editor beim Laden mitbekommt und beim Speichern zurueckschickt. Weicht
/// er ab, hat jemand anderes zwischenzeitlich gespeichert — ohne diese Pruefung
/// ueberschriebe der zweite Redakteur die Arbeit des ersten spurlos, und weil
/// Entwuerfe bewusst nicht protokolliert werden, waere sie auch nicht
/// wiederherstellbar.
geaendertAm DateTime @default(now()) @updatedAt
vorlage Vorlage @relation(fields: [vorlageId], references: [id], onDelete: Cascade)
veroeffentlichtVon Benutzer? @relation("VersionVeroeffentlichtVon", fields: [veroeffentlichtVonId], references: [id], onDelete: Restrict)
@@ -502,7 +509,7 @@ model Lauf {
archiviertAm DateTime?
archiviertVonId String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
geaendertAm DateTime @default(now()) @updatedAt
vorlagenVersion VorlagenVersion @relation(fields: [vorlagenVersionId], references: [id], onDelete: Restrict)
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
@@ -538,7 +545,7 @@ model Schrittergebnis {
/// Pflicht bei NICHT_ZUTREFFEND und FEHLGESCHLAGEN, erzwungen im Service.
notiz String?
aktualisiertVonId String?
aktualisiertAm DateTime @updatedAt
aktualisiertAm DateTime @default(now()) @updatedAt
erstelltAm DateTime @default(now())
lauf Lauf @relation(fields: [laufId], references: [id], onDelete: Restrict)
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod";
/**
* Zod mit deutschen Standardmeldungen (#009).
*
* Ohne diese Einstellung kommen bei fehlenden oder falsch typisierten Feldern
* englische Saetze heraus („Invalid input: expected string“), und die landen
* ueber die Server Actions unmittelbar in der Oberflaeche. Eigene `message`-Texte
* helfen nur dort, wo man an sie gedacht hat — die Lokalisierung deckt auch alles
* ab, woran niemand gedacht hat.
*
* Deshalb wird `z` **hier** eingestellt und von hier weitergereicht: ein Import
* direkt aus "zod" umginge die Einstellung. Die ESLint-Regel
* `no-restricted-imports` sorgt dafuer, dass das auffaellt.
*/
z.config(z.locales.de());
export { z };
// Was sonst noch aus Zod gebraucht wird, laeuft ebenfalls ueber diesen Pfad –
// sonst muesste doch wieder jemand direkt aus "zod" importieren.
export { ZodError } from "zod";
export type { ZodIssue, ZodType } from "zod";
+203
View File
@@ -0,0 +1,203 @@
import { z } from "@/lib/validierung";
/**
* Aufbau einer Vorlagen-Version (#009).
*
* Ein Schema fuer drei Zwecke: der Editor schickt den Entwurf in dieser Form,
* der JSON-Export schreibt sie, der Import liest sie. Gaebe es drei Schemata,
* liefen sie frueher oder spaeter auseinander — und der Import waere der Ort, an
* dem das erst auffiele.
*
* Framework-frei und ohne Datenbank, damit es sich ohne Next testen laesst.
*/
/**
* Ein leeres Formularfeld kommt als `""` an, nicht als `undefined`.
*
* Ohne diese Vorstufe scheiterte ein nicht ausgefuelltes optionales Feld an
* seiner eigenen Pruefung — beim Doku-Link etwa mit „keine gültige Adresse“,
* obwohl gar keine angegeben werden sollte.
*/
function leerAlsUndefiniert<T extends z.ZodType>(schema: T) {
return z.preprocess(
(wert) => (typeof wert === "string" && wert.trim() === "" ? undefined : wert),
schema.optional(),
);
}
/** Die Schritt-Typen des MVP. `AKTION` kommt in Phase 4 dazu. */
export const SCHRITT_TYPEN = ["HAKEN", "TEXT", "ZAHL", "AUSWAHL", "DATUM", "BEFEHL"] as const;
export const VARIABLEN_TYPEN = ["TEXT", "ZAHL", "AUSWAHL", "DATUM"] as const;
export const SHELLS = ["powershell", "bash", "cmd"] as const;
/**
* Obergrenzen fuer ein Austauschdokument.
*
* Ohne sie nimmt der Import beliebig grosse Listen an, laeuft in die
* Transaktions-Zeitgrenze von Postgres und meldet einen Fehler, der nichts ueber
* die Ursache sagt. Die Werte sind grosszuegig gewaehlt: die groesste Vorlage im
* Seed hat sieben Abschnitte und rund dreissig Schritte.
*/
export const GRENZEN = {
abschnitte: 200,
schritteJeAbschnitt: 500,
variablen: 200,
text: 20_000,
optionen: 200,
} as const;
/**
* Typabhaengige Einzelheiten eines Schritts.
*
* Bewusst nachsichtig: unbekannte Schluessel bleiben erhalten, damit ein
* Export aus einer spaeteren Fassung hier nicht stillschweigend Angaben
* verliert. Was die Anwendung kennt, wird geprueft.
*/
export const SchrittKonfigSchema = z.looseObject({
/** AUSWAHL: die Optionen in der angezeigten Reihenfolge. */
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).optional(),
/** ZAHL: Grenzen und Einheit. Ein Wert ausserhalb wird markiert, nicht blockiert. */
min: z.number().optional(),
max: z.number().optional(),
einheit: z.string().trim().max(40).optional(),
/** TEXT: mehrzeilig statt einzeilig. */
mehrzeilig: z.boolean().optional(),
/** BEFEHL: Shell, Mehrzeiligkeit und der Befehlstext mit Platzhaltern. */
shell: z.enum(SHELLS).optional(),
befehl: z.string().max(GRENZEN.text).optional(),
});
export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>;
export const SchrittSchema = z.object({
titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text),
/** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */
hilfetext: leerAlsUndefiniert(z.string().trim().max(GRENZEN.text)),
typ: z.enum(SCHRITT_TYPEN).default("HAKEN"),
pflicht: z.boolean().default(false),
konfig: SchrittKonfigSchema.default({}),
/**
* Nur http und https. `z.url()` allein liesse `javascript:` und `data:` durch —
* und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein
* gespeicherter Angriff, eingeschleust ueber eine importierte Datei.
*/
dokuUrl: leerAlsUndefiniert(
z.url({
protocol: /^https?$/,
message: "Der Doku-Link muss eine http- oder https-Adresse sein.",
}),
),
});
export type SchrittEingabe = z.input<typeof SchrittSchema>;
export const AbschnittSchema = z.object({
titel: z.string().trim().min(1, "Ein Abschnitt braucht einen Titel.").max(GRENZEN.text),
schritte: z
.array(SchrittSchema)
.max(
GRENZEN.schritteJeAbschnitt,
`Ein Abschnitt fasst höchstens ${GRENZEN.schritteJeAbschnitt} Schritte.`,
)
.default([]),
});
export const VariableSchema = z.object({
/**
* Der Schluessel steht als `{{schluessel}}` in Titeln, Hilfetexten und
* Befehlen. Deshalb nur Kleinbuchstaben, Ziffern und Unterstrich — alles
* andere liesse sich im Platzhalter nicht sauber wiedererkennen.
*/
schluessel: z
.string()
.trim()
.regex(
/^[a-z][a-z0-9_]{0,39}$/,
"Ein Variablenschlüssel beginnt mit einem Kleinbuchstaben und besteht aus Kleinbuchstaben, Ziffern und Unterstrich.",
),
beschriftung: z.string().trim().min(1, "Eine Variable braucht eine Beschriftung.").max(GRENZEN.text),
typ: z.enum(VARIABLEN_TYPEN).default("TEXT"),
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).default([]),
pflicht: z.boolean().default(false),
});
export type VariableEingabe = z.input<typeof VariableSchema>;
/**
* Der gesamte Aufbau einer Version. Die Reihenfolge steckt in der Reihenfolge
* der Listen; `position` wird daraus abgeleitet und nicht mitgeschickt — sonst
* gaebe es zwei Wahrheiten, die auseinanderlaufen koennen.
*/
export const StrukturSchema = z.object({
variablen: z
.array(VariableSchema)
.max(GRENZEN.variablen, `Eine Vorlage fasst höchstens ${GRENZEN.variablen} Variablen.`)
.default([]),
abschnitte: z
.array(AbschnittSchema)
.max(GRENZEN.abschnitte, `Eine Vorlage fasst höchstens ${GRENZEN.abschnitte} Abschnitte.`)
.default([]),
});
export type Struktur = z.infer<typeof StrukturSchema>;
export type StrukturEingabe = z.input<typeof StrukturSchema>;
/** Fassung des Austauschformats. Wird beim Import geprueft. */
export const FORMAT_VERSION = 1;
/**
* Was eine exportierte Datei enthaelt.
*
* Bewusst ohne Kennungen, Zeitstempel und Kundenbezug: eine exportierte Vorlage
* soll sich in eine andere Laufzettel-Installation einspielen lassen, ohne
* Verweise mitzubringen, die dort nichts bedeuten.
*/
export const AustauschSchema = z.object({
format: z.literal(FORMAT_VERSION, {
message: `Unbekanntes Format. Diese Fassung liest Format ${FORMAT_VERSION}.`,
}),
name: z.string().trim().min(1, "Die Vorlage braucht einen Namen.").max(200),
beschreibung: leerAlsUndefiniert(z.string().trim().max(GRENZEN.text)),
kategorie: leerAlsUndefiniert(z.string().trim().max(100)),
schlagworte: z.array(z.string().trim().min(1).max(100)).max(50).default([]),
/** Nur zur Information, welche Version der Quelle exportiert wurde. */
version: z.number().int().positive().optional(),
struktur: StrukturSchema,
});
export type Austausch = z.infer<typeof AustauschSchema>;
/**
* Alle Platzhalter `{{key}}` aus einem Text.
*
* Steht hier und nicht in #012, weil der Editor schon beim Bearbeiten warnen
* soll, wenn ein Platzhalter zu keiner Variablen und zu keinem eingebauten Wert
* gehoert. Das Ersetzen selbst kommt in #012.
*/
export function platzhalterFinden(text: string): readonly string[] {
const gefunden = new Set<string>();
for (const treffer of text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)) {
if (treffer[1]) gefunden.add(treffer[1]);
}
return [...gefunden];
}
/** Alle Platzhalter, die irgendwo in einer Struktur vorkommen. */
export function platzhalterDerStruktur(struktur: Struktur): readonly string[] {
const gefunden = new Set<string>();
const sammeln = (text: string | undefined) => {
if (text) for (const name of platzhalterFinden(text)) gefunden.add(name);
};
for (const abschnitt of struktur.abschnitte) {
sammeln(abschnitt.titel);
for (const schritt of abschnitt.schritte) {
sammeln(schritt.titel);
sammeln(schritt.hilfetext);
const befehl = schritt.konfig["befehl"];
if (typeof befehl === "string") sammeln(befehl);
}
}
return [...gefunden];
}
+1 -1
View File
@@ -1,7 +1,7 @@
"use server";
import { redirect } from "next/navigation";
import { z } from "zod";
import { z } from "@/lib/validierung";
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
import { AnmeldeFehler, lokalAnmelden } from "@/server/auth/benutzer";
+1 -1
View File
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { z } from "@/lib/validierung";
import { Rolle } from "@/generated/prisma/enums";
import {
AnmeldeFehler,
+1 -1
View File
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { z } from "@/lib/validierung";
import { Betriebsmodus } from "@/generated/prisma/enums";
import {
EinstellungsFehler,
+1 -1
View File
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { z } from "@/lib/validierung";
import { KundenFehler, kundeAnlegen, kundeArchivieren, kundeBearbeiten } from "@/server/services/kunden";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
+347
View File
@@ -0,0 +1,347 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { StrukturSchema } from "@/lib/vorlagen-format";
import { kundeZugriffPruefen, kundenEbeneSichtbar } from "@/server/mandant";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
VorlagenFehler,
entwurfHolenOderErzeugen,
entwurfSpeichern,
entwurfVerwerfen,
versionVeroeffentlichen,
vorlageAnlegen,
vorlageArchivieren,
vorlageBearbeiten,
vorlageDuplizieren,
} from "@/server/services/vorlagen";
import {
AustauschFehler,
vorlageExportieren,
vorlageImportieren,
} from "@/server/services/vorlagen-austausch";
/**
* Server Actions der Vorlagen (#009).
*
* Jede beginnt mit `rechtErforderlich("vorlagenBearbeiten")` – das haben nur
* ADMIN und REDAKTEUR. Techniker starten Laeufe, sie bauen keine Vorlagen.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
/** Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine neutrale. */
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof VorlagenFehler || fehler instanceof AustauschFehler) {
return { fehler: fehler.message };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const AnlegenSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
beschreibung: z.string().trim().optional(),
kategorie: z.string().trim().optional(),
schlagworte: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
export async function vorlageAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = AnlegenSchema.safeParse({
name: formular.get("name"),
beschreibung: formular.get("beschreibung") ?? undefined,
kategorie: formular.get("kategorie") ?? undefined,
schlagworte: formular.get("schlagworte") ?? undefined,
kundeId: formular.get("kundeId") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
// Eine kundenspezifische Vorlage gibt es nur im Modus Dienstleistung – und nur
// fuer einen Kunden, den dieser Benutzer sehen darf.
const kundeId = eingabe.data.kundeId || null;
if (kundeId) {
if (!(await kundenEbeneSichtbar())) {
return { fehler: "Kundenspezifische Vorlagen gibt es nur im Modus Dienstleistung." };
}
await kundeZugriffPruefen(kundeId, benutzer);
}
try {
const { vorlage } = await vorlageAnlegen(
{
name: eingabe.data.name,
...(eingabe.data.beschreibung ? { beschreibung: eingabe.data.beschreibung } : {}),
...(eingabe.data.kategorie ? { kategorie: eingabe.data.kategorie } : {}),
schlagworte: (eingabe.data.schlagworte ?? "")
.split(",")
.map((wort) => wort.trim())
.filter(Boolean),
kundeId,
},
benutzer.id,
);
revalidatePath("/vorlagen");
return { erfolg: `„${vorlage.name}“ wurde angelegt.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Vorlage konnte nicht angelegt werden.");
}
}
export async function entwurfOeffnenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis & { readonly versionId?: string }> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const vorlageId = z.string().min(1).safeParse(formular.get("vorlageId"));
if (!vorlageId.success) return { fehler: "Es fehlt die Vorlage." };
try {
const entwurf = await entwurfHolenOderErzeugen(vorlageId.data, benutzer.id);
revalidatePath("/vorlagen");
return { versionId: entwurf.id };
} catch (fehler) {
return alsErgebnis(fehler, "Der Entwurf konnte nicht geöffnet werden.");
}
}
const SpeichernSchema = z.object({
versionId: z.string().min(1),
struktur: StrukturSchema,
/**
* Stand, den der Editor beim Laden bekommen hat. Weicht er ab, hat jemand
* anderes zwischenzeitlich gespeichert – und dessen Arbeit waere sonst weg.
*/
stand: z.coerce.date().optional(),
});
export async function entwurfSpeichernAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis & { readonly stand?: string }> {
await rechtErforderlich("vorlagenBearbeiten");
const roh = formular.get("struktur");
let geparst: unknown;
try {
geparst = typeof roh === "string" ? JSON.parse(roh) : undefined;
} catch {
return { fehler: "Der Aufbau kam nicht als gültiges JSON an." };
}
const eingabe = SpeichernSchema.safeParse({
versionId: formular.get("versionId"),
struktur: geparst,
stand: formular.get("stand") ?? undefined,
});
if (!eingabe.success) {
const erster = eingabe.error.issues[0];
return {
fehler: erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
};
}
try {
const { stand } = await entwurfSpeichern(
eingabe.data.versionId,
eingabe.data.struktur,
eingabe.data.stand,
);
revalidatePath("/vorlagen");
return { erfolg: "Entwurf gespeichert.", stand: stand.toISOString() };
} catch (fehler) {
return alsErgebnis(fehler, "Der Entwurf konnte nicht gespeichert werden.");
}
}
const BearbeitenSchema = z.object({
vorlageId: z.string().min(1),
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
beschreibung: z.string().trim().optional(),
kategorie: z.string().trim().optional(),
schlagworte: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
export async function vorlageBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = BearbeitenSchema.safeParse({
vorlageId: formular.get("vorlageId"),
name: formular.get("name"),
beschreibung: formular.get("beschreibung") ?? undefined,
kategorie: formular.get("kategorie") ?? undefined,
schlagworte: formular.get("schlagworte") ?? undefined,
kundeId: formular.get("kundeId") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
const kundeId = eingabe.data.kundeId || null;
if (kundeId) {
if (!(await kundenEbeneSichtbar())) {
return { fehler: "Kundenspezifische Vorlagen gibt es nur im Modus Dienstleistung." };
}
await kundeZugriffPruefen(kundeId, benutzer);
}
try {
await vorlageBearbeiten(
eingabe.data.vorlageId,
{
name: eingabe.data.name,
beschreibung: eingabe.data.beschreibung ?? null,
kategorie: eingabe.data.kategorie ?? null,
schlagworte: (eingabe.data.schlagworte ?? "")
.split(",")
.map((wort) => wort.trim())
.filter(Boolean),
kundeId,
},
benutzer.id,
);
revalidatePath("/vorlagen");
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
export async function veroeffentlichenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = z
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
.safeParse({
versionId: formular.get("versionId"),
aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined,
});
if (!eingabe.success) return { fehler: "Es fehlt die Version." };
try {
const version = await versionVeroeffentlichen(
eingabe.data.versionId,
eingabe.data.aenderungsnotiz ?? null,
benutzer.id,
);
revalidatePath("/vorlagen");
return { erfolg: `Version ${version.version} ist veröffentlicht.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Version konnte nicht veröffentlicht werden.");
}
}
export async function entwurfVerwerfenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const versionId = z.string().min(1).safeParse(formular.get("versionId"));
if (!versionId.success) return { fehler: "Es fehlt die Version." };
try {
await entwurfVerwerfen(versionId.data, benutzer.id);
revalidatePath("/vorlagen");
return { erfolg: "Entwurf verworfen." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Entwurf konnte nicht verworfen werden.");
}
}
export async function vorlageDuplizierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = z
.object({ vorlageId: z.string().min(1), name: z.string().trim().min(1, "Bitte einen Namen angeben.") })
.safeParse({ vorlageId: formular.get("vorlageId"), name: formular.get("name") });
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const kopie = await vorlageDuplizieren(eingabe.data.vorlageId, eingabe.data.name, benutzer.id);
revalidatePath("/vorlagen");
return { erfolg: `„${kopie.name}“ wurde als Entwurf angelegt.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Vorlage konnte nicht dupliziert werden.");
}
}
export async function vorlageArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = z
.object({ vorlageId: z.string().min(1), archiviert: z.enum(["true", "false"]) })
.safeParse({ vorlageId: formular.get("vorlageId"), archiviert: formular.get("archiviert") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
await vorlageArchivieren(eingabe.data.vorlageId, eingabe.data.archiviert === "true", benutzer.id);
revalidatePath("/vorlagen");
return {};
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
/** Gibt das Austauschdokument als JSON-Text zurueck – der Browser bietet es zum Sichern an. */
export async function vorlageExportierenAktion(
versionId: string,
): Promise<{ readonly dateiname: string; readonly inhalt: string } | AktionsErgebnis> {
await rechtErforderlich("vorlagenBearbeiten");
try {
const dokument = await vorlageExportieren(versionId);
const sauber = dokument.name.replace(/[^\p{L}\p{N}]+/gu, "-").toLowerCase();
return {
dateiname: `vorlage-${sauber}-v${dokument.version ?? 1}.json`,
inhalt: JSON.stringify(dokument, null, 2),
};
} catch (fehler) {
return alsErgebnis(fehler, "Die Vorlage konnte nicht exportiert werden.");
}
}
export async function vorlageImportierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const roh = formular.get("dokument");
if (typeof roh !== "string" || !roh.trim()) {
return { fehler: "Bitte eine Vorlagendatei auswählen oder den Inhalt einfügen." };
}
let geparst: unknown;
try {
geparst = JSON.parse(roh);
} catch {
return { fehler: "Die Datei enthält kein gültiges JSON." };
}
try {
await vorlageImportieren(geparst, benutzer.id);
revalidatePath("/vorlagen");
return { erfolg: "Die Vorlage wurde als Entwurf importiert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Vorlage konnte nicht importiert werden.");
}
}
+244
View File
@@ -0,0 +1,244 @@
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Vorlagen (#009).
*
* Wie bei den Kunden wenden sie die Mandantensicht selbst an: eine Vorlage kann
* kundenspezifisch sein, und wer den Kunden nicht sehen darf, soll auch seine
* Vorlagen nicht sehen. Vorlagen ohne Kundenbezug gelten fuer alle.
*/
export type VorlagenZeile = {
readonly id: string;
readonly name: string;
readonly beschreibung: string | null;
readonly kategorie: string | null;
readonly schlagworte: readonly string[];
readonly archiviert: boolean;
readonly kundeId: string | null;
/** Nummer der zuletzt veroeffentlichten Version, falls es eine gibt. */
readonly veroeffentlichteVersion: number | null;
readonly veroeffentlichtAm: Date | null;
/** Wird gerade an einem Entwurf gearbeitet? */
readonly hatEntwurf: boolean;
readonly anzahlLaeufe: number;
};
export type VorlagenFilter = {
readonly suche?: string;
readonly kategorie?: string;
readonly schlagwort?: string;
readonly auchArchivierte?: boolean;
};
/**
* Die Mandantenbedingung als **eigener** Ausdruck, nicht als Objekt zum
* Hineinspreizen.
*
* Vorher wurde `{ OR: [...] }` in dasselbe Literal gespreizt wie die Suche, die
* ebenfalls ein `OR` mitbringt — der zweite Schluessel gewann, und die
* Mandantengrenze war durch einen Buchstaben im Suchfeld umgangen. Alle
* Teilbedingungen liegen deshalb in einem `AND`, wo keine die andere
* ueberschreiben kann.
*/
async function kundenBedingung(benutzer: HandelnderBenutzer) {
const sichtbar = await sichtbareKundenIds(benutzer);
if (sichtbar === undefined) return undefined;
// Vorlagen ohne Kundenbezug gelten fuer alle.
return { OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] };
}
export async function vorlagenListe(
benutzer: HandelnderBenutzer,
filter: VorlagenFilter = {},
): Promise<readonly VorlagenZeile[]> {
const suche = filter.suche?.trim();
const bedingungen = [
await kundenBedingung(benutzer),
filter.auchArchivierte ? undefined : { archiviert: false },
filter.kategorie ? { kategorie: filter.kategorie } : undefined,
filter.schlagwort ? { schlagworte: { has: filter.schlagwort } } : undefined,
suche
? {
OR: [
{ name: { contains: suche, mode: "insensitive" as const } },
{ beschreibung: { contains: suche, mode: "insensitive" as const } },
],
}
: undefined,
].filter((bedingung) => bedingung !== undefined);
const zeilen = await prisma.vorlage.findMany({
where: { AND: bedingungen },
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
beschreibung: true,
kategorie: true,
schlagworte: true,
archiviert: true,
kundeId: true,
versionen: {
select: {
version: true,
status: true,
veroeffentlichtAm: true,
_count: { select: { laeufe: true } },
},
},
},
});
return zeilen.map((zeile) => {
const veroeffentlichte = zeile.versionen
.filter((v) => v.status === VersionStatus.VEROEFFENTLICHT)
.sort((a, b) => b.version - a.version);
const neueste = veroeffentlichte[0];
return {
id: zeile.id,
name: zeile.name,
beschreibung: zeile.beschreibung,
kategorie: zeile.kategorie,
schlagworte: zeile.schlagworte,
archiviert: zeile.archiviert,
kundeId: zeile.kundeId,
veroeffentlichteVersion: neueste?.version ?? null,
veroeffentlichtAm: neueste?.veroeffentlichtAm ?? null,
hatEntwurf: zeile.versionen.some((v) => v.status === VersionStatus.ENTWURF),
anzahlLaeufe: zeile.versionen.reduce((summe, v) => summe + v._count.laeufe, 0),
};
});
}
/** Die Kategorien und Schlagworte, die tatsaechlich vorkommen – fuer die Filter. */
export async function vorlagenFilterwerte(
benutzer: HandelnderBenutzer,
): Promise<{ readonly kategorien: readonly string[]; readonly schlagworte: readonly string[] }> {
const mandant = await kundenBedingung(benutzer);
const zeilen = await prisma.vorlage.findMany({
where: { AND: [{ archiviert: false }, ...(mandant ? [mandant] : [])] },
select: { kategorie: true, schlagworte: true },
});
const kategorien = new Set<string>();
const schlagworte = new Set<string>();
for (const zeile of zeilen) {
if (zeile.kategorie) kategorien.add(zeile.kategorie);
for (const wort of zeile.schlagworte) schlagworte.add(wort);
}
return {
kategorien: [...kategorien].sort((a, b) => a.localeCompare(b, "de")),
schlagworte: [...schlagworte].sort((a, b) => a.localeCompare(b, "de")),
};
}
export type VersionsZeile = {
readonly id: string;
readonly version: number;
readonly status: VersionStatus;
readonly aenderungsnotiz: string | null;
readonly veroeffentlichtAm: Date | null;
readonly veroeffentlichtVon: string | null;
readonly anzahlLaeufe: number;
};
/**
* Versionshistorie einer Vorlage, neueste zuerst.
*
* Wendet die Mandantensicht selbst an: wer die Vorlage nicht sehen darf, sieht
* auch ihre Historie nicht. Eine leere Liste ist hier die richtige Antwort – wer
* die Vorlage nicht kennt, soll nicht einmal erfahren, dass es sie gibt.
*/
export async function versionsHistorie(
vorlageId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly VersionsZeile[]> {
const mandant = await kundenBedingung(benutzer);
const versionen = await prisma.vorlagenVersion.findMany({
where: { vorlageId, ...(mandant ? { vorlage: mandant } : {}) },
orderBy: { version: "desc" },
select: {
id: true,
version: true,
status: true,
aenderungsnotiz: true,
veroeffentlichtAm: true,
veroeffentlichtVon: { select: { name: true } },
_count: { select: { laeufe: true } },
},
});
return versionen.map((version) => ({
id: version.id,
version: version.version,
status: version.status,
aenderungsnotiz: version.aenderungsnotiz,
veroeffentlichtAm: version.veroeffentlichtAm,
veroeffentlichtVon: version.veroeffentlichtVon?.name ?? null,
anzahlLaeufe: version._count.laeufe,
}));
}
export type VersionMitAufbau = {
readonly id: string;
readonly vorlageId: string;
readonly vorlageName: string;
readonly version: number;
readonly status: VersionStatus;
readonly struktur: Struktur;
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
readonly platzhalter: readonly string[];
};
export async function versionMitAufbau(
versionId: string,
benutzer: HandelnderBenutzer,
): Promise<VersionMitAufbau | undefined> {
const mandant = await kundenBedingung(benutzer);
const version = await prisma.vorlagenVersion.findFirst({
where: { id: versionId, ...(mandant ? { vorlage: mandant } : {}) },
include: {
vorlage: { select: { id: true, name: true } },
variablen: { orderBy: { position: "asc" } },
abschnitte: {
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
},
},
});
if (!version) return undefined;
const struktur: Struktur = {
variablen: version.variablen.map((variable) => ({
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: variable.optionen,
pflicht: variable.pflicht,
})),
abschnitte: version.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel,
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: (schritt.konfig ?? {}) as Record<string, unknown>,
...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }),
})),
})),
};
return {
id: version.id,
vorlageId: version.vorlage.id,
vorlageName: version.vorlage.name,
version: version.version,
status: version.status,
struktur,
platzhalter: platzhalterDerStruktur(struktur),
};
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "@/lib/validierung";
import type { Prisma } from "@/generated/prisma/client";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
@@ -180,7 +180,7 @@ async function pruefungMit(
moeglich: true,
brauchtAngaben: true,
folgen: [
`Die bisherige eigene Organisation „${organisation.name}" wird zum ersten Kunden.`,
`Die bisherige eigene Organisation „${organisation.name}“ wird zum ersten Kunden.`,
"Alle Geräte, Personen, Abteilungen und Läufe bleiben daran hängen.",
"Kunden werden zu einer sichtbaren Ebene: Kundenliste, Kundenwahl beim Start eines Laufs, Kundenkürzel auf den Etiketten.",
],
@@ -204,7 +204,7 @@ async function pruefungMit(
moeglich: true,
brauchtAngaben: false,
folgen: [
`„${einziger.name}" wird zur eigenen Organisation und verschwindet aus der Oberfläche.`,
`„${einziger.name}“ wird zur eigenen Organisation und verschwindet aus der Oberfläche.`,
"Alle Daten bleiben erhalten, nur die Kundenebene wird ausgeblendet.",
"Archivierte Kunden bleiben archiviert und tauchen nicht wieder auf.",
],
+245
View File
@@ -0,0 +1,245 @@
import type { ZodError } from "@/lib/validierung";
import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { AustauschSchema, FORMAT_VERSION, type Austausch } from "@/lib/vorlagen-format";
/**
* JSON-Austausch fuer Vorlagen (#009).
*
* Export und Import sind die Bruecke zwischen zwei Installationen: eine Vorlage
* verlaesst die eine als Datei und kommt in der anderen als Entwurf an. Deshalb
* traegt das Dokument keine Kennungen, keine Zeitstempel und keinen Kundenbezug
* — Verweise, die in der Zielinstallation nichts bedeuten wuerden. Das Format
* selbst steht in `docs/vorlagen-format.md`, die Schemata in
* `src/lib/vorlagen-format.ts`.
*
* Rechte werden hier nicht geprueft, das tut die Server Action darueber.
*/
export class AustauschFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "AustauschFehler";
}
}
/** Ein Schritt so, wie er im Austauschdokument steht — ohne Kennung und Position. */
type AustauschSchritt = Austausch["struktur"]["abschnitte"][number]["schritte"][number];
/**
* Baut das Austauschdokument einer Version. Liest nur.
*
* Sortiert wird nach `position`, bei Gleichstand nach `id`: zwei Zeilen mit
* derselben Position duerfen die Reihenfolge nicht dem Zufall der Datenbank
* ueberlassen, sonst waere ein Export nicht wiederholbar.
*/
export async function vorlageExportieren(versionId: string): Promise<Austausch> {
const version = await prisma.vorlagenVersion.findUnique({
where: { id: versionId },
include: {
vorlage: true,
variablen: { orderBy: [{ position: "asc" }, { id: "asc" }] },
abschnitte: {
orderBy: [{ position: "asc" }, { id: "asc" }],
include: { schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] } },
},
},
});
if (!version) {
throw new AustauschFehler("Diese Vorlagen-Version gibt es nicht (mehr).");
}
const { vorlage } = version;
return {
format: FORMAT_VERSION,
name: vorlage.name,
// Leere Felder werden weggelassen statt als null geschrieben: eine von Hand
// geschriebene Datei sieht genauso aus wie eine exportierte.
...(vorlage.beschreibung ? { beschreibung: vorlage.beschreibung } : {}),
...(vorlage.kategorie ? { kategorie: vorlage.kategorie } : {}),
schlagworte: [...vorlage.schlagworte],
version: version.version,
struktur: {
variablen: version.variablen.map((variable) => ({
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: [...variable.optionen],
pflicht: variable.pflicht,
})),
abschnitte: version.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
schritte: abschnitt.schritte.map(schrittAbbilden),
})),
},
};
}
/**
* Bildet eine Schritt-Zeile auf das Austauschformat ab.
*
* `konfig` ist in der Datenbank ein Json-Feld und kann alles enthalten.
*
* Frueher lief es durch `SchrittKonfigSchema.safeParse` und wurde bei **jeder**
* Beanstandung komplett verworfen — ein `shell: "zsh"` aus einer spaeteren
* Fassung kostete lautlos auch den `befehl`. Genau das Gegenteil dessen, was das
* nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
* kein Objekt ist, gilt als leer.
*/
function schrittAbbilden(schritt: VorlagenSchritt): AustauschSchritt {
const roh = schritt.konfig;
const konfig =
typeof roh === "object" && roh !== null && !Array.isArray(roh) ? (roh as AustauschSchritt["konfig"]) : {};
return {
titel: schritt.titel,
...(schritt.hilfetext ? { hilfetext: schritt.hilfetext } : {}),
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig,
...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}),
};
}
/**
* Legt aus einem Austauschdokument eine neue Vorlage mit einem Entwurf an.
* Nimmt unbekannte Eingaben entgegen und prueft sie mit AustauschSchema.
*
* Immer eine **neue** Vorlage, nie in eine bestehende hinein, und die erste
* Version ist `ENTWURF`: eine eingespielte Vorlage wird bewusst nicht sofort
* veroeffentlicht, der Redakteur sieht sie sich erst an. Ein Name, den es schon
* gibt, ist kein Fehler — Vorlagennamen sind nicht eindeutig, und ein
* angehaengtes „(2)“ waere eine Aenderung, um die niemand gebeten hat.
*/
export async function vorlageImportieren(
dokument: unknown,
durchBenutzerId: string,
): Promise<{ readonly vorlageId: string; readonly versionId: string }> {
const austausch = dokumentPruefen(dokument);
variablenschluesselPruefen(austausch.struktur.variablen);
// Eine Vorlage ist erst mit Variablen, Abschnitten und Schritten sinnvoll.
// Deshalb alles in einer Transaktion: ein Fehler auf halbem Weg darf keine
// halbe Vorlage hinterlassen, die jemand in der Liste findet.
return prisma.$transaction(async (tx) => {
const vorlage = await tx.vorlage.create({
data: {
name: austausch.name,
beschreibung: austausch.beschreibung ?? null,
kategorie: austausch.kategorie ?? null,
schlagworte: [...austausch.schlagworte],
},
});
const version = await tx.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
// `position` kommt aus der Reihenfolge der Listen und wird nicht
// mitgeschickt — sonst gaebe es zwei Wahrheiten.
if (austausch.struktur.variablen.length > 0) {
await tx.vorlagenVariable.createMany({
data: austausch.struktur.variablen.map((variable, position) => ({
versionId: version.id,
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: [...variable.optionen],
pflicht: variable.pflicht,
position,
})),
});
}
let schritteGesamt = 0;
for (const [position, abschnitt] of austausch.struktur.abschnitte.entries()) {
const zeile = await tx.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: abschnitt.titel, position },
});
if (abschnitt.schritte.length === 0) continue;
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittPosition) => ({
abschnittId: zeile.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Das nachsichtige Schema traegt eine Index-Signatur auf `unknown`,
// die Prisma nicht als Json-Wert erkennt. Der Inhalt kommt aus
// geparstem JSON, ist also einer.
konfig: schritt.konfig as Prisma.InputJsonObject,
dokuUrl: schritt.dokuUrl ?? null,
position: schrittPosition,
})),
});
schritteGesamt += abschnitt.schritte.length;
}
await tx.protokoll.create({
data: {
entitaet: "Vorlage",
entitaetId: vorlage.id,
aktion: "Vorlage importiert",
benutzerId: durchBenutzerId,
nachher: {
name: vorlage.name,
version: version.version,
abschnitte: austausch.struktur.abschnitte.length,
schritte: schritteGesamt,
variablen: austausch.struktur.variablen.length,
},
},
});
return { vorlageId: vorlage.id, versionId: version.id };
});
}
/** Prueft das Dokument gegen das Schema und uebersetzt Zod-Fehler in eine lesbare Meldung. */
function dokumentPruefen(dokument: unknown): Austausch {
// Eigene Meldung fuer den haeufigsten Fehlgriff – eine Datei, die gar kein
// JSON-Objekt enthaelt. Zod sagt dazu nur einen englischen Satz.
if (typeof dokument !== "object" || dokument === null || Array.isArray(dokument)) {
throw new AustauschFehler("Das Austauschdokument ist kein JSON-Objekt.");
}
const ergebnis = AustauschSchema.safeParse(dokument);
if (!ergebnis.success) {
throw new AustauschFehler(ersteBeanstandung(ergebnis.error));
}
return ergebnis.data;
}
/**
* Nennt die erste Beanstandung samt Pfad.
*
* Eine rohe Zod-Ausgabe hilft niemandem: wer eine Datei von Hand schreibt,
* braucht die Stelle („abschnitte.0.schritte.2.titel“) und einen Satz dazu, und
* zwar den ersten — eine Liste von zwanzig Folgefehlern liest niemand.
*/
function ersteBeanstandung(fehler: ZodError): string {
const problem = fehler.issues[0];
if (!problem) return "Das Austauschdokument passt nicht zum Format.";
const pfad = problem.path.map(String).join(".");
return pfad.length === 0 ? problem.message : `${pfad}: ${problem.message}`;
}
/**
* Weist doppelte Variablenschluessel ab.
*
* Die Datenbank haelt dasselbe ueber `@@unique([versionId, schluessel])` fest;
* diese Pruefung existiert fuer die Meldung, denn der Fehler des Index nennt
* einen Spaltennamen und keinen Schluessel. Ausserdem faellt er erst mitten in
* der Transaktion auf, also nach halber Arbeit.
*/
function variablenschluesselPruefen(variablen: readonly { readonly schluessel: string }[]): void {
const gesehen = new Set<string>();
for (const variable of variablen) {
if (gesehen.has(variable.schluessel)) {
throw new AustauschFehler(
`Den Variablenschlüssel „${variable.schluessel}“ gibt es zweimal. ` +
"Jeder Schlüssel darf in einer Vorlage nur einmal stehen.",
);
}
gesehen.add(variable.schluessel);
}
}
+727
View File
@@ -0,0 +1,727 @@
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
/**
* Vorlagen und ihre Versionierung (#009).
*
* Die eine Regel, die alles traegt: **eine veroeffentlichte Version wird nie
* wieder veraendert.** Ein Lauf verweist auf eine bestimmte Version und zeigt
* deshalb auch in zehn Jahren noch genau die Schritte, die damals abgearbeitet
* wurden. „Bearbeiten“ heisst deswegen nicht "aendern", sondern "tiefe Kopie als
* neuer Entwurf".
*
* Je Vorlage gibt es hoechstens einen Entwurf. Das steht zusaetzlich als
* partieller Eindeutigkeitsindex in der Datenbank (`VorlagenVersion_ein_entwurf`);
* die Pruefung hier existiert fuer die bessere Meldung.
*/
export class VorlagenFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "VorlagenFehler";
}
}
export type NeueVorlage = {
readonly name: string;
readonly beschreibung?: string;
readonly kategorie?: string;
readonly schlagworte?: readonly string[];
/** Nur im Modus DIENSTLEISTUNG: kundenspezifische Vorlage. Leer gilt fuer alle. */
readonly kundeId?: string | null;
};
function namePruefen(roh: string): string {
const name = roh.trim();
if (!name) throw new VorlagenFehler("Eine Vorlage braucht einen Namen.");
return name;
}
/** Legt eine Vorlage mit einem leeren Entwurf als Version 1 an. */
export async function vorlageAnlegen(
eingabe: NeueVorlage,
durchBenutzerId: string,
): Promise<{ readonly vorlage: Vorlage; readonly version: VorlagenVersion }> {
const name = namePruefen(eingabe.name);
return prisma.$transaction(async (tx) => {
const vorlage = await tx.vorlage.create({
data: {
name,
beschreibung: eingabe.beschreibung?.trim() || null,
kategorie: eingabe.kategorie?.trim() || null,
schlagworte: [...(eingabe.schlagworte ?? [])],
kundeId: eingabe.kundeId ?? null,
},
});
const version = await tx.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
await tx.protokoll.create({
data: {
entitaet: "Vorlage",
entitaetId: vorlage.id,
aktion: "Vorlage angelegt",
benutzerId: durchBenutzerId,
nachher: { name: vorlage.name, version: 1 },
},
});
return { vorlage, version };
});
}
export type VorlagenAenderung = {
readonly name?: string;
readonly beschreibung?: string | null;
readonly kategorie?: string | null;
readonly schlagworte?: readonly string[];
/** `null` macht aus einer kundenspezifischen Vorlage eine fuer alle. */
readonly kundeId?: string | null;
};
/**
* Aendert die Kopfdaten einer Vorlage.
*
* Betrifft die Vorlage, nicht ihre Versionen: Name, Kategorie und Schlagworte
* sind kein Teil des abgearbeiteten Inhalts und aendern deshalb nichts an der
* Nachvollziehbarkeit eines Laufs.
*/
export async function vorlageBearbeiten(
vorlageId: string,
aenderung: VorlagenAenderung,
durchBenutzerId: string,
): Promise<Vorlage> {
const vorher = await prisma.vorlage.findUnique({ where: { id: vorlageId } });
if (!vorher) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
const daten: {
name?: string;
beschreibung?: string | null;
kategorie?: string | null;
schlagworte?: string[];
kundeId?: string | null;
} = {};
const geaendert: string[] = [];
if (aenderung.name !== undefined) {
const name = namePruefen(aenderung.name);
if (name !== vorher.name) {
daten.name = name;
geaendert.push("Name");
}
}
if (aenderung.beschreibung !== undefined) {
const wert = aenderung.beschreibung?.trim() || null;
if (wert !== vorher.beschreibung) {
daten.beschreibung = wert;
geaendert.push("Beschreibung");
}
}
if (aenderung.kategorie !== undefined) {
const wert = aenderung.kategorie?.trim() || null;
if (wert !== vorher.kategorie) {
daten.kategorie = wert;
geaendert.push("Kategorie");
}
}
if (aenderung.schlagworte !== undefined) {
const wert = [...aenderung.schlagworte];
if (JSON.stringify(wert) !== JSON.stringify(vorher.schlagworte)) {
daten.schlagworte = wert;
geaendert.push("Schlagworte");
}
}
if (aenderung.kundeId !== undefined) {
const wert = aenderung.kundeId || null;
if (wert !== vorher.kundeId) {
daten.kundeId = wert;
geaendert.push("Kundenzuordnung");
}
}
if (geaendert.length === 0) return vorher;
return prisma.$transaction(async (tx) => {
const nachher = await tx.vorlage.update({ where: { id: vorlageId }, data: daten });
await tx.protokoll.create({
data: {
entitaet: "Vorlage",
entitaetId: vorlageId,
aktion: `${geaendert.join(" und ")} geändert`,
benutzerId: durchBenutzerId,
vorher: {
name: vorher.name,
kategorie: vorher.kategorie,
schlagworte: vorher.schlagworte,
kundeId: vorher.kundeId,
},
nachher: {
name: nachher.name,
kategorie: nachher.kategorie,
schlagworte: nachher.schlagworte,
kundeId: nachher.kundeId,
},
},
});
return nachher;
});
}
/**
* Die Version, an der gerade gearbeitet wird.
*
* Gibt es schon einen Entwurf, wird der zurueckgegeben — je Vorlage darf es nur
* einen geben. Sonst entsteht eine **tiefe Kopie** der zuletzt veroeffentlichten
* Version: Variablen, Abschnitte und Schritte samt `konfig` werden mitkopiert,
* nicht verlinkt. Wuerden sie geteilt, aenderte eine Bearbeitung rueckwirkend
* die veroeffentlichte Version und damit alle Laeufe, die daran haengen.
*/
export async function entwurfHolenOderErzeugen(
vorlageId: string,
durchBenutzerId: string,
): Promise<VorlagenVersion> {
const vorlage = await prisma.vorlage.findUnique({
where: { id: vorlageId },
select: { id: true, archiviert: true, name: true },
});
if (!vorlage) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
if (vorlage.archiviert) {
throw new VorlagenFehler(`„${vorlage.name}“ ist archiviert. Erst wieder freigeben, dann bearbeiten.`);
}
const vorhandener = await prisma.vorlagenVersion.findFirst({
where: { vorlageId, status: VersionStatus.ENTWURF },
});
if (vorhandener) return vorhandener;
const letzte = await prisma.vorlagenVersion.findFirst({
where: { vorlageId, status: VersionStatus.VEROEFFENTLICHT },
orderBy: { version: "desc" },
include: {
variablen: { orderBy: { position: "asc" } },
abschnitte: {
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
},
},
});
if (!letzte) {
throw new VorlagenFehler("Diese Vorlage hat weder einen Entwurf noch eine veröffentlichte Version.");
}
const hoechste = await prisma.vorlagenVersion.aggregate({
where: { vorlageId },
_max: { version: true },
});
const naechste = (hoechste._max.version ?? 0) + 1;
try {
return await entwurfKopieren(vorlageId, naechste, letzte, durchBenutzerId);
} catch (fehler) {
// Zwei gleichzeitige Aufrufe: der zweite laeuft in den eindeutigen Index –
// mal in den auf (vorlageId, version), mal in den auf "ein Entwurf". Beides
// heisst dasselbe: jemand anderes war schneller, und der Entwurf liegt
// bereits vor. Ihn zurueckzugeben ist richtiger als eine Fehlermeldung.
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
const inzwischen = await prisma.vorlagenVersion.findFirst({
where: { vorlageId, status: VersionStatus.ENTWURF },
});
if (inzwischen) return inzwischen;
}
throw fehler;
}
}
type VersionMitInhalt = {
readonly version: number;
readonly variablen: readonly {
schluessel: string;
beschriftung: string;
typ: "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM";
optionen: string[];
pflicht: boolean;
}[];
readonly abschnitte: readonly {
titel: string;
schritte: readonly {
titel: string;
hilfetext: string | null;
typ: "HAKEN" | "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM" | "BEFEHL";
pflicht: boolean;
konfig: unknown;
dokuUrl: string | null;
}[];
}[];
};
async function entwurfKopieren(
vorlageId: string,
naechste: number,
letzte: VersionMitInhalt,
durchBenutzerId: string,
): Promise<VorlagenVersion> {
return prisma.$transaction(async (tx) => {
const entwurf = await tx.vorlagenVersion.create({
data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF },
});
if (letzte.variablen.length > 0) {
await tx.vorlagenVariable.createMany({
data: letzte.variablen.map((variable, index) => ({
versionId: entwurf.id,
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: variable.optionen,
pflicht: variable.pflicht,
position: index,
})),
});
}
for (const [index, abschnitt] of letzte.abschnitte.entries()) {
const kopie = await tx.vorlagenAbschnitt.create({
data: { versionId: entwurf.id, titel: abschnitt.titel, position: index },
});
if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
abschnittId: kopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Json wird als Wert kopiert, nicht als Verweis geteilt.
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
})),
});
}
}
await tx.protokoll.create({
data: {
entitaet: "VorlagenVersion",
entitaetId: entwurf.id,
aktion: "Entwurf aus veröffentlichter Version erstellt",
benutzerId: durchBenutzerId,
vorher: { version: letzte.version },
nachher: { version: naechste },
},
});
return entwurf;
});
}
/**
* Sperrt die Versionszeile und gibt ihren Stand zurueck.
*
* Ohne die Sperre lief folgendes durch: Speichern liest den Status, findet
* ENTWURF, und waehrend es schreibt, veroeffentlicht ein zweiter Aufruf die
* Version. Danach trug eine veroeffentlichte Version Inhalt, der nach dem
* Veroeffentlichen geschrieben wurde — genau die Regel, die das Archiv traegt,
* durchbrochen an einem Doppelklick.
*/
async function versionSperren(
tx: Prisma.TransactionClient,
versionId: string,
): Promise<{ status: VersionStatus; version: number; geaendertAm: Date }> {
const zeilen = await tx.$queryRaw<{ status: VersionStatus; version: number; geaendertAm: Date }[]>`
SELECT status, version, "geaendertAm" FROM "VorlagenVersion" WHERE id = ${versionId} FOR UPDATE
`;
const zeile = zeilen[0];
if (!zeile) throw new VorlagenFehler("Diese Version gibt es nicht.");
return zeile;
}
/**
* Wirft, wenn an dieser Version nicht mehr geschrieben werden darf.
*
* Eine einzige Stelle, wie `laufMussVeraenderbarSein` beim Archiv: verstreute
* Pruefungen werden irgendwann an einer Stelle vergessen.
*/
export function versionMussEntwurfSein(version: {
readonly status: VersionStatus;
readonly version: number;
}): void {
if (version.status !== VersionStatus.ENTWURF) {
throw new VorlagenFehler(
`Version ${version.version} ist veröffentlicht und damit unveränderlich. ` +
"„Bearbeiten“ erzeugt daraus einen neuen Entwurf.",
);
}
}
/**
* Ersetzt den Aufbau eines Entwurfs vollstaendig.
*
* Bewusst als Ganzes statt Schritt fuer Schritt: der Editor haelt den ganzen
* Baum im Speicher und sortiert per Drag & Drop, ein Teil-Update waere dort
* staendig mit dem Sortieren im Streit. Alles in einer Transaktion — ein halb
* gespeicherter Entwurf waere schlimmer als gar keiner.
*
* Kein Protokolleintrag: ein Entwurf wird beim Bauen dutzendfach gespeichert,
* das Protokoll wuerde davon unlesbar. Festgehalten wird das Veroeffentlichen —
* der Punkt, ab dem die Version unveraenderlich ist.
*/
export async function entwurfSpeichern(
versionId: string,
eingabe: StrukturEingabe,
erwarteterStand?: Date,
): Promise<{ readonly struktur: Struktur; readonly stand: Date }> {
const gelesen = StrukturSchema.safeParse(eingabe);
if (!gelesen.success) {
const erster = gelesen.error.issues[0];
throw new VorlagenFehler(
erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
);
}
const struktur = gelesen.data;
const schluessel = struktur.variablen.map((variable) => variable.schluessel);
const doppelt = schluessel.find((wert, index) => schluessel.indexOf(wert) !== index);
if (doppelt) {
throw new VorlagenFehler(`Den Variablenschlüssel „${doppelt}“ gibt es zweimal.`);
}
const stand = await prisma.$transaction(async (tx) => {
// Erst sperren, dann pruefen: sonst kann zwischen Pruefung und Schreiben
// veroeffentlicht werden.
const zeile = await versionSperren(tx, versionId);
versionMussEntwurfSein(zeile);
if (erwarteterStand && zeile.geaendertAm.getTime() !== erwarteterStand.getTime()) {
throw new VorlagenFehler(
"Der Entwurf wurde zwischenzeitlich von jemand anderem gespeichert. " +
"Bitte neu laden, sonst geht dessen Arbeit verloren.",
);
}
// Abschnitte raeumen ihre Schritte per Cascade mit ab. Haengt an einem
// Schritt ein Schrittergebnis, verhindert der Fremdschluessel das Loeschen.
// Das darf bei einem Entwurf nicht vorkommen – Laeufe haengen nur an
// veroeffentlichten Versionen, siehe veroeffentlichteVersionPruefen (#013).
// Sollte es doch passieren, wird der rohe Fremdschluesselfehler unten in eine
// verstaendliche Meldung uebersetzt.
try {
await tx.vorlagenAbschnitt.deleteMany({ where: { versionId } });
await tx.vorlagenVariable.deleteMany({ where: { versionId } });
} catch (fehler) {
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2003") {
throw new VorlagenFehler(
"An diesem Entwurf hängen bereits Schrittergebnisse. Er lässt sich nicht mehr umbauen.",
);
}
throw fehler;
}
if (struktur.variablen.length > 0) {
await tx.vorlagenVariable.createMany({
data: struktur.variablen.map((variable, index) => ({
versionId,
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: [...variable.optionen],
pflicht: variable.pflicht,
position: index,
})),
});
}
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
const angelegt = await tx.vorlagenAbschnitt.create({
data: { versionId, titel: abschnitt.titel, position: index },
});
if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
abschnittId: angelegt.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl ?? null,
position: schrittIndex,
})),
});
}
}
// Der Stand wandert weiter, damit ein zweiter Editor beim Speichern merkt,
// dass hier inzwischen etwas passiert ist.
const frisch = await tx.vorlagenVersion.update({
where: { id: versionId },
data: { geaendertAm: new Date() },
select: { geaendertAm: true },
});
return frisch.geaendertAm;
});
return { struktur, stand };
}
/**
* Veroeffentlicht einen Entwurf.
*
* Ab hier ist die Version unveraenderlich. Bestehende Laeufe bleiben davon
* unberuehrt: sie verweisen auf die Version, mit der sie gestartet wurden.
*/
export async function versionVeroeffentlichen(
versionId: string,
aenderungsnotiz: string | null,
durchBenutzerId: string,
): Promise<VorlagenVersion> {
const notiz = aenderungsnotiz?.trim() || null;
return prisma.$transaction(async (tx) => {
const zeile = await versionSperren(tx, versionId);
versionMussEntwurfSein(zeile);
const kopf = await tx.vorlagenVersion.findUniqueOrThrow({
where: { id: versionId },
select: {
vorlage: { select: { archiviert: true, name: true } },
_count: { select: { abschnitte: true } },
},
});
if (kopf.vorlage.archiviert) {
throw new VorlagenFehler(
`„${kopf.vorlage.name}“ ist archiviert. Erst wieder freigeben, dann veröffentlichen.`,
);
}
if (kopf._count.abschnitte === 0) {
throw new VorlagenFehler(
"Eine Vorlage ohne Abschnitte lässt sich nicht veröffentlichen — es gäbe nichts abzuarbeiten.",
);
}
// Bedingtes Update: trifft es keine Zeile, hat jemand anderes in derselben
// Sekunde veroeffentlicht. Ohne diese Bedingung entstuenden zwei
// Protokolleintraege fuer denselben Vorgang.
const getroffen = await tx.vorlagenVersion.updateMany({
where: { id: versionId, status: VersionStatus.ENTWURF },
data: {
status: VersionStatus.VEROEFFENTLICHT,
aenderungsnotiz: notiz,
veroeffentlichtAm: new Date(),
veroeffentlichtVonId: durchBenutzerId,
},
});
if (getroffen.count === 0) {
throw new VorlagenFehler("Diese Version wurde bereits veröffentlicht.");
}
await tx.protokoll.create({
data: {
entitaet: "VorlagenVersion",
entitaetId: versionId,
aktion: "Version veröffentlicht",
benutzerId: durchBenutzerId,
vorher: { status: VersionStatus.ENTWURF },
nachher: {
status: VersionStatus.VEROEFFENTLICHT,
version: zeile.version,
aenderungsnotiz: notiz,
},
},
});
return tx.vorlagenVersion.findUniqueOrThrow({ where: { id: versionId } });
});
}
/**
* Verwirft einen Entwurf.
*
* Nur ein Entwurf laesst sich verwerfen, und nur wenn die Vorlage noch eine
* veroeffentlichte Version hat — sonst bliebe eine Vorlage ohne jeden Inhalt
* zurueck, die niemand mehr bearbeiten kann.
*/
export async function entwurfVerwerfen(versionId: string, durchBenutzerId: string): Promise<void> {
const version = await prisma.vorlagenVersion.findUnique({
where: { id: versionId },
select: { id: true, status: true, version: true, vorlageId: true },
});
if (!version) throw new VorlagenFehler("Diese Version gibt es nicht.");
versionMussEntwurfSein(version);
const veroeffentlichte = await prisma.vorlagenVersion.count({
where: { vorlageId: version.vorlageId, status: VersionStatus.VEROEFFENTLICHT },
});
if (veroeffentlichte === 0) {
throw new VorlagenFehler(
"Das ist die einzige Version dieser Vorlage. Statt sie zu verwerfen, archiviere die Vorlage.",
);
}
await prisma.$transaction(async (tx) => {
await tx.vorlagenVersion.delete({ where: { id: versionId } });
await tx.protokoll.create({
data: {
entitaet: "VorlagenVersion",
entitaetId: versionId,
aktion: "Entwurf verworfen",
benutzerId: durchBenutzerId,
vorher: { version: version.version },
},
});
});
}
/**
* Dupliziert eine Vorlage samt ihrer zuletzt veroeffentlichten Version.
*
* Die Kopie beginnt als Entwurf: eine duplizierte Vorlage ist fast nie
* unveraendert brauchbar, und sofort zu veroeffentlichen wuerde sie in der
* Auswahl erscheinen lassen, bevor jemand sie angesehen hat.
*/
export async function vorlageDuplizieren(
vorlageId: string,
neuerName: string,
durchBenutzerId: string,
): Promise<Vorlage> {
const name = namePruefen(neuerName);
const quelle = await prisma.vorlage.findUnique({
where: { id: vorlageId },
include: {
versionen: {
where: { status: VersionStatus.VEROEFFENTLICHT },
orderBy: { version: "desc" },
take: 1,
include: {
variablen: { orderBy: { position: "asc" } },
abschnitte: {
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
},
},
},
},
});
if (!quelle) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
const letzte = quelle.versionen[0];
if (!letzte) {
throw new VorlagenFehler(
"Diese Vorlage hat noch keine veröffentlichte Version, es gibt also nichts zu duplizieren.",
);
}
return prisma.$transaction(async (tx) => {
const kopie = await tx.vorlage.create({
data: {
name,
beschreibung: quelle.beschreibung,
kategorie: quelle.kategorie,
schlagworte: quelle.schlagworte,
kundeId: quelle.kundeId,
},
});
const version = await tx.vorlagenVersion.create({
data: { vorlageId: kopie.id, version: 1, status: VersionStatus.ENTWURF },
});
if (letzte.variablen.length > 0) {
await tx.vorlagenVariable.createMany({
data: letzte.variablen.map((variable, index) => ({
versionId: version.id,
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: variable.optionen,
pflicht: variable.pflicht,
position: index,
})),
});
}
for (const [index, abschnitt] of letzte.abschnitte.entries()) {
const abschnittKopie = await tx.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: abschnitt.titel, position: index },
});
if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
abschnittId: abschnittKopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
})),
});
}
}
await tx.protokoll.create({
data: {
entitaet: "Vorlage",
entitaetId: kopie.id,
aktion: "Vorlage dupliziert",
benutzerId: durchBenutzerId,
vorher: { ausVorlage: quelle.name, version: letzte.version },
nachher: { name },
},
});
return kopie;
});
}
/**
* Archivieren statt Loeschen.
*
* An einer Vorlage haengen Laeufe, und die muessen fuer immer nachvollziehbar
* bleiben. Eine archivierte Vorlage erscheint nicht mehr in der Auswahl, ihre
* Laeufe aber weiter im Archiv.
*/
export async function vorlageArchivieren(
vorlageId: string,
archiviert: boolean,
durchBenutzerId: string,
): Promise<void> {
const vorlage = await prisma.vorlage.findUnique({ where: { id: vorlageId } });
if (!vorlage) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
if (vorlage.archiviert === archiviert) return;
await prisma.$transaction(async (tx) => {
await tx.vorlage.update({ where: { id: vorlageId }, data: { archiviert } });
await tx.protokoll.create({
data: {
entitaet: "Vorlage",
entitaetId: vorlageId,
aktion: archiviert ? "Vorlage archiviert" : "Vorlage wieder freigegeben",
benutzerId: durchBenutzerId,
vorher: { archiviert: vorlage.archiviert },
nachher: { archiviert },
},
});
});
}
/**
* Prueft, dass ein Lauf an dieser Version haengen darf.
*
* Steht hier, damit #013 die Regel „Laeufe verweisen immer auf eine
* veroeffentlichte Version" nicht neu erfinden muss.
*/
export async function veroeffentlichteVersionPruefen(versionId: string): Promise<void> {
const version = await prisma.vorlagenVersion.findUnique({
where: { id: versionId },
select: { status: true, version: true, vorlage: { select: { archiviert: true, name: true } } },
});
if (!version) throw new VorlagenFehler("Diese Version gibt es nicht.");
if (version.status !== VersionStatus.VEROEFFENTLICHT) {
throw new VorlagenFehler(
"Ein Lauf braucht eine veröffentlichte Version. Version " + `${version.version} ist noch ein Entwurf.`,
);
}
if (version.vorlage.archiviert) {
throw new VorlagenFehler(`„${version.vorlage.name}“ ist archiviert und startet keine Läufe mehr.`);
}
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Wächter über die Datenbankobjekte, die Prisma nicht kennt (#009).
*
* Zusammengesetzte Fremdschlüssel, Trigramm-Indizes und Trigger lassen sich im
* Prisma-Schema nicht ausdrücken. Deshalb erzeugt `prisma migrate dev` bei
* **jeder** künftigen Migration Löschbefehle für sie — sie sind aus Prismas
* Sicht Abweichungen von der Vorgabe. Wer die Löschbefehle einmal übersieht,
* entfernt stillschweigend die Mandantengrenze, die Archivsuche oder den Schutz
* der Unveränderlichkeit.
*
* Dieser Test macht das laut. Er prüft nicht, dass die Regeln *wirken* — das tun
* `tests/datenmodell.test.ts` und `tests/vorlagen.test.ts` —, sondern nur, dass
* die Objekte überhaupt noch da sind.
*/
async function namen(abfrage: string): Promise<string[]> {
const zeilen = await testPrisma.$queryRawUnsafe<{ name: string }[]>(abfrage);
return zeilen.map((zeile) => zeile.name).sort();
}
describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
it("hat die zusammengesetzten Fremdschlüssel der Mandantengrenze", async () => {
// Ohne sie kann ein Lauf ein Gerät eines fremden Kunden ziehen.
expect(
await namen(`SELECT conname AS name FROM pg_constraint WHERE conname LIKE '%gleicher_kunde'`),
).toEqual([
"Geraet_besitzer_gleicher_kunde",
"Lauf_abteilung_gleicher_kunde",
"Lauf_geraet_gleicher_kunde",
"Lauf_mitarbeiter_gleicher_kunde",
]);
});
it("hat die Trigramm-Indizes für die Archivsuche", async () => {
// Ohne sie wird jede Archivsuche zum vollständigen Durchlauf der Tabelle.
expect(
await namen(
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%gin_trgm_ops%'`,
),
).toEqual([
"Kommentar_rumpf_trgm_idx",
"Lauf_titel_trgm_idx",
"Schrittergebnis_notiz_trgm_idx",
"Schrittergebnis_wert_trgm_idx",
]);
});
it("hat die Trigger, die eine veröffentlichte Version schützen", async () => {
expect(await namen(`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal`)).toEqual([
"VorlagenAbschnitt_unveraenderlich",
"VorlagenSchritt_unveraenderlich",
"VorlagenVariable_unveraenderlich",
"VorlagenVersion_status_einbahn",
]);
});
it("hat die partiellen Eindeutigkeitsindizes", async () => {
expect(
await namen(
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%WHERE%'`,
),
).toEqual(["Kunde_eigene_organisation_einmalig", "VorlagenVersion_ein_entwurf"]);
});
it("hat alle Prüfbedingungen", async () => {
const vorhanden = await namen(
`SELECT conname AS name FROM pg_constraint WHERE contype='c' AND connamespace='public'::regnamespace`,
);
// Namentlich aufgezählt, damit auch das Verschwinden einer einzelnen auffällt.
expect(vorhanden).toEqual([
"Einstellung_einzelne_zeile",
"Geraet_registriert_braucht_namen",
"Geraet_tagcode_form",
"Kunde_kuerzel_form",
"Lauf_abgeschlossen_mit_zeitpunkt",
"Lauf_archiviert_mit_zeitpunkt",
"Lauf_begonnen_mit_zeitpunkt",
"Lauf_mitarbeiter_eindeutig",
"Schrittergebnis_begruendung_noetig",
"TagCodeVergabe_form",
"Verzeichnisquelle_bindpasswort_verschluesselt",
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
]);
});
it("hat die Erweiterung für die Trigramm-Suche", async () => {
const zeilen = await testPrisma.$queryRaw<{ extname: string }[]>`
SELECT extname FROM pg_extension WHERE extname = 'pg_trgm'
`;
expect(zeilen).toHaveLength(1);
});
});
+21 -7
View File
@@ -22,18 +22,28 @@ async function benutzerAnlegen(email = "techniker@firma.example") {
});
}
/** Kleinste Vorlage, die ausreicht, um einen Lauf daran zu haengen. */
/**
* Kleinste Vorlage, die ausreicht, um einen Lauf daran zu haengen.
*
* Erst als Entwurf anlegen, dann veroeffentlichen – an einer veroeffentlichten
* Version laesst sich nichts mehr anhaengen, dafuer sorgen Trigger in der
* Datenbank. Wer die Fixture umdreht, baut genau den Zustand, den die Anwendung
* nie herstellen darf.
*/
async function veroeffentlichteVersionAnlegen() {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } });
return testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
}
/** Schliesst eine Fixture-Version ab, nachdem ihr Aufbau steht. */
async function versionVeroeffentlichen(versionId: string) {
return testPrisma.vorlagenVersion.update({
where: { id: versionId },
// 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(),
},
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
}
@@ -291,6 +301,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell: Regeln in der Datenbank", () =
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst", pflicht: true },
});
await versionVeroeffentlichen(version.id);
const lauf = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
@@ -430,6 +441,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell: Grenzen und Zusicherungen", ()
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
});
await versionVeroeffentlichen(version.id);
const lauf = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
@@ -527,6 +539,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell: Grenzen und Zusicherungen", ()
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
});
await versionVeroeffentlichen(version.id);
const lauf = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
@@ -565,6 +578,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell: Grenzen und Zusicherungen", ()
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "New-ADUser", typ: "BEFEHL" },
});
await versionVeroeffentlichen(version.id);
const lauf = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { z } from "@/lib/validierung";
import { AustauschSchema, StrukturSchema } from "@/lib/vorlagen-format";
/**
* Zod ist in src/lib/validierung.ts auf Deutsch eingestellt (#009).
*
* Ohne diese Einstellung landen englische Standardsaetze ueber die Server
* Actions in der Oberflaeche — und zwar genau dort, wo niemand an eine eigene
* Meldung gedacht hat.
*/
describe("Deutsche Meldungen", () => {
it("meldet einen falschen Typ auf Deutsch", () => {
const ergebnis = z.object({ name: z.string() }).safeParse({ name: 42 });
expect(ergebnis.error?.issues[0]?.message).toMatch(/Ungültige Eingabe/);
});
it("meldet ein fehlendes Pflichtfeld auf Deutsch", () => {
const ergebnis = z.object({ name: z.string() }).safeParse({});
expect(ergebnis.error?.issues[0]?.message).not.toMatch(/Invalid input/);
});
it("laesst eigene Meldungen unangetastet", () => {
const ergebnis = StrukturSchema.safeParse({
abschnitte: [{ titel: "A", schritte: [{ titel: "" }] }],
});
expect(ergebnis.error?.issues[0]?.message).toBe("Ein Schritt braucht einen Titel.");
});
it("meldet ein unbekanntes Austauschformat auf Deutsch", () => {
const ergebnis = AustauschSchema.safeParse({ format: 99, name: "X", struktur: {} });
expect(ergebnis.error?.issues[0]?.message).toMatch(/Unbekanntes Format/);
});
});
+578
View File
@@ -0,0 +1,578 @@
import { beforeEach, describe, expect, it } from "vitest";
import { GRENZEN, type Austausch } from "@/lib/vorlagen-format";
import {
AustauschFehler,
vorlageExportieren,
vorlageImportieren,
} from "@/server/services/vorlagen-austausch";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* JSON-Austausch fuer Vorlagen (#009).
*
* Der Rundlauf ist der eigentliche Prueffall: was exportiert und wieder
* eingespielt wird, muss dasselbe sein. Alles andere hier haelt fest, was dabei
* bewusst *nicht* mitwandert (Kennungen, Positionen, Kundenbezug) und was ein
* Import mit einer krummen Datei tun soll.
*/
/** Ein Befehl mit Platzhaltern – er darf den Rundlauf Zeichen fuer Zeichen ueberleben. */
const BEFEHL = 'New-ADUser -Name "{{vorname}} {{nachname}}" -Department "{{abteilung}}"';
/** Urheber der Protokolleintraege; ohne echten Benutzer greift der Fremdschluessel. */
async function redakteurAnlegen() {
return testPrisma.benutzer.create({
data: { email: "redaktion@firma.example", name: "Redaktion", rolle: "REDAKTEUR" },
});
}
/**
* Eine veroeffentlichte Version mit zwei Abschnitten, Schritten verschiedener
* Typen und zwei Variablen.
*
* Die Zeilen werden absichtlich in verkehrter Reihenfolge angelegt: dann zeigt
* der Export, dass er nach `position` sortiert und nicht danach, wie die
* Datenbank die Zeilen gerade zurueckgibt.
*/
async function beispielVersionAnlegen(): Promise<string> {
const vorlage = await testPrisma.vorlage.create({
data: {
name: "Onboarding Notebook",
beschreibung: "Neue Person, neues Gerät.",
kategorie: "Onboarding",
schlagworte: ["notebook", "eintritt"],
},
});
// Erst als Entwurf anlegen und befuellen, dann veroeffentlichen: an einer
// veroeffentlichten Version laesst sich nichts mehr anhaengen, dafuer sorgen
// Trigger in der Datenbank.
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 3, status: "ENTWURF" },
});
await testPrisma.vorlagenVariable.create({
data: {
versionId: version.id,
schluessel: "abteilung",
beschriftung: "Abteilung",
typ: "AUSWAHL",
optionen: ["IT", "Vertrieb"],
pflicht: false,
position: 1,
},
});
await testPrisma.vorlagenVariable.create({
data: {
versionId: version.id,
schluessel: "vorname",
beschriftung: "Vorname",
typ: "TEXT",
pflicht: true,
position: 0,
},
});
const zweiter = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "02 · Konto", position: 1 },
});
const erster = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "01 · Vorbereitung", position: 0 },
});
await testPrisma.vorlagenSchritt.createMany({
data: [
{
abschnittId: erster.id,
titel: "Gerät auspacken",
typ: "HAKEN",
pflicht: true,
position: 0,
},
{
abschnittId: erster.id,
titel: "Seriennummer erfassen",
hilfetext: "Aufkleber auf der Unterseite.",
typ: "TEXT",
pflicht: true,
konfig: { mehrzeilig: false },
dokuUrl: "https://intern.example/seriennummern",
position: 1,
},
{
abschnittId: erster.id,
titel: "Arbeitsspeicher prüfen",
typ: "ZAHL",
konfig: { min: 8, max: 64, einheit: "GB" },
position: 2,
},
],
});
await testPrisma.vorlagenSchritt.createMany({
data: [
{
abschnittId: zweiter.id,
titel: "Benutzerkonto anlegen",
typ: "BEFEHL",
pflicht: true,
// Ein unbekannter Schluessel: das Schema ist nachsichtig, damit ein
// Export aus einer spaeteren Fassung hier nichts verliert.
konfig: { shell: "powershell", befehl: BEFEHL, kuenftigesFeld: { tief: [1, 2] } },
position: 0,
},
{
abschnittId: zweiter.id,
titel: "Eintrittsdatum hinterlegen",
typ: "DATUM",
position: 1,
},
],
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
return version.id;
}
/** Das kleinste Dokument, das durchgeht – Ausgangspunkt fuer die Fehlerfaelle. */
function dokument(aenderung: Record<string, unknown> = {}): Record<string, unknown> {
return {
format: 1,
name: "Kleine Vorlage",
struktur: {
variablen: [{ schluessel: "vorname", beschriftung: "Vorname" }],
abschnitte: [{ titel: "01 · Start", schritte: [{ titel: "Anfangen" }] }],
},
...aenderung,
};
}
/** Der Rundlauf darf sich in `version` unterscheiden – alles andere nicht. */
function ohneVersion(austausch: Austausch): Omit<Austausch, "version"> {
const kopie: Omit<Austausch, "version"> & { version?: number } = { ...austausch };
delete kopie.version;
return kopie;
}
/**
* Diese Faelle scheitern am Schema, also noch vor dem ersten Datenbankzugriff –
* deshalb laufen sie auch ohne Testdatenbank.
*/
describe("Import: Pruefung des Dokuments", () => {
it("weist ein fehlendes oder falsches Format ab", async () => {
const ohneFormat = dokument();
delete ohneFormat["format"];
await expect(vorlageImportieren(ohneFormat, "wer-auch-immer")).rejects.toThrow(AustauschFehler);
await expect(vorlageImportieren(dokument({ format: 2 }), "wer")).rejects.toThrow(/Format 1/);
await expect(vorlageImportieren("kein Objekt", "wer")).rejects.toThrow(AustauschFehler);
});
it("nennt bei einem leeren Schritt-Titel den Pfad", async () => {
const kaputt = dokument({
struktur: {
variablen: [],
abschnitte: [
{ titel: "01 · Start", schritte: [{ titel: "Anfangen" }] },
{
titel: "02 · Konto",
schritte: [{ titel: "Erledigen" }, { titel: "Prüfen" }, { titel: " " }],
},
],
},
});
await expect(vorlageImportieren(kaputt, "wer")).rejects.toThrow(
"struktur.abschnitte.1.schritte.2.titel: Ein Schritt braucht einen Titel.",
);
});
it("weist doppelte Variablenschluessel ab und nennt den Schluessel", async () => {
const doppelt = dokument({
struktur: {
variablen: [
{ schluessel: "vorname", beschriftung: "Vorname" },
{ schluessel: "vorname", beschriftung: "Rufname" },
],
abschnitte: [],
},
});
await expect(vorlageImportieren(doppelt, "wer")).rejects.toThrow(/„vorname“ gibt es zweimal/);
});
it("weist ungueltige Variablenschluessel ab", async () => {
// Der Schluessel steht als {{schluessel}} in Texten; ein Grossbuchstabe oder
// ein Strich liesse sich dort nicht sauber wiedererkennen.
for (const schluessel of ["Vorname", "mit-strich", "", "1vorname", "mit punkt"]) {
const falsch = dokument({
struktur: { variablen: [{ schluessel, beschriftung: "Egal" }], abschnitte: [] },
});
await expect(vorlageImportieren(falsch, "wer")).rejects.toThrow(AustauschFehler);
}
});
it("weist einen Doku-Link ab, der kein http ist", async () => {
// Der Doku-Link landet in der Abarbeitungsansicht als `href`. Ein
// `javascript:`-Link darin waere fremder Code im Browser des Technikers –
// eingespielt ueber eine Datei, die ein Kunde geschickt hat.
for (const link of [
"javascript:alert(1)",
"JavaScript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"intern.example/doku",
]) {
const kaputt = dokument({
struktur: {
variablen: [],
abschnitte: [{ titel: "Start", schritte: [{ titel: "Anfangen", dokuUrl: link }] }],
},
});
await expect(vorlageImportieren(kaputt, "wer"), link).rejects.toThrow(/http/);
}
});
it("nimmt http und https an", async () => {
for (const link of ["http://intern.example/doku", "https://intern.example/doku"]) {
const gut = dokument({
struktur: {
variablen: [],
abschnitte: [{ titel: "Start", schritte: [{ titel: "Anfangen", dokuUrl: link }] }],
},
});
// Ohne Datenbank kommt es bis zum Schreiben; abgewiesen wird es nicht.
await expect(vorlageImportieren(gut, "wer")).rejects.not.toThrow(/http/);
}
});
it("weist ein Dokument ab, das die Mengengrenzen sprengt", async () => {
// Eine Datei mit 100 000 Schritten ist kein Import, sondern ein
// Erschoepfungsangriff auf die Datenbank.
const zuViele = dokument({
struktur: {
variablen: [],
abschnitte: Array.from({ length: GRENZEN.abschnitte + 1 }, (_, i) => ({
titel: `Abschnitt ${i}`,
schritte: [],
})),
},
});
await expect(vorlageImportieren(zuViele, "wer")).rejects.toThrow(
new RegExp(`${GRENZEN.abschnitte} Abschnitte`),
);
const zuLang = dokument({
struktur: {
variablen: [],
abschnitte: [{ titel: "Start", schritte: [{ titel: "x".repeat(GRENZEN.text + 1) }] }],
},
});
await expect(vorlageImportieren(zuLang, "wer")).rejects.toThrow(AustauschFehler);
});
it("weist einen unbekannten Schritt-Typ ab", async () => {
// Ein unbekannter Typ wuerde in der Abarbeitung zu einem Schritt ohne
// Eingabefeld – der Techniker koennte ihn nie erledigen.
const kaputt = dokument({
struktur: {
variablen: [],
abschnitte: [{ titel: "Start", schritte: [{ titel: "Anfangen", typ: "AKTION" }] }],
},
});
await expect(vorlageImportieren(kaputt, "wer")).rejects.toThrow(AustauschFehler);
});
it("nennt bei einem ungueltigen Schluessel den Pfad und die Regel", async () => {
const falsch = dokument({
struktur: { variablen: [{ schluessel: "Vorname", beschriftung: "Egal" }], abschnitte: [] },
});
await expect(vorlageImportieren(falsch, "wer")).rejects.toThrow(
/^struktur\.variablen\.0\.schluessel: Ein Variablenschlüssel beginnt/,
);
});
});
describe.runIf(datenbankVerfuegbar)("Vorlagen-Austausch", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Export", () => {
it("liefert Abschnitte, Schritte und Variablen in der Reihenfolge der Position", async () => {
const versionId = await beispielVersionAnlegen();
const austausch = await vorlageExportieren(versionId);
expect(austausch.format).toBe(1);
expect(austausch.name).toBe("Onboarding Notebook");
expect(austausch.kategorie).toBe("Onboarding");
expect(austausch.schlagworte).toEqual(["notebook", "eintritt"]);
expect(austausch.version).toBe(3);
expect(austausch.struktur.variablen.map((v) => v.schluessel)).toEqual(["vorname", "abteilung"]);
expect(austausch.struktur.abschnitte.map((a) => a.titel)).toEqual(["01 · Vorbereitung", "02 · Konto"]);
expect(austausch.struktur.abschnitte[0]?.schritte.map((s) => s.titel)).toEqual([
"Gerät auspacken",
"Seriennummer erfassen",
"Arbeitsspeicher prüfen",
]);
expect(austausch.struktur.abschnitte[1]?.schritte.map((s) => s.typ)).toEqual(["BEFEHL", "DATUM"]);
});
it("enthaelt keine Kennungen, Positionen und keinen Kundenbezug", async () => {
const versionId = await beispielVersionAnlegen();
const austausch = await vorlageExportieren(versionId);
const rohtext = JSON.stringify(austausch);
expect(rohtext).not.toContain(versionId);
for (const feld of ['"id"', "versionId", "abschnittId", "position", "kundeId", "erstelltAm"]) {
expect(rohtext).not.toContain(feld);
}
});
it("wirft bei einer unbekannten Version", async () => {
await expect(vorlageExportieren("gibt-es-nicht")).rejects.toThrow(AustauschFehler);
});
});
describe("Import", () => {
it("legt eine neue Vorlage mit Version 1 im Status ENTWURF an", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const austausch = await vorlageExportieren(quelle);
const { vorlageId, versionId } = await vorlageImportieren(austausch, redakteur.id);
// Die Quelle bleibt unberuehrt, es gibt jetzt zwei Vorlagen.
expect(await testPrisma.vorlage.count()).toBe(2);
const version = await testPrisma.vorlagenVersion.findUniqueOrThrow({
where: { id: versionId },
});
expect(version.vorlageId).toBe(vorlageId);
expect(version.version).toBe(1);
expect(version.status).toBe("ENTWURF");
expect(version.veroeffentlichtAm).toBeNull();
expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId } })).toBe(1);
});
it("leitet die Positionen aus der Reihenfolge der Listen ab", async () => {
const redakteur = await redakteurAnlegen();
const { versionId } = await vorlageImportieren(
dokument({
struktur: {
variablen: [
{ schluessel: "vorname", beschriftung: "Vorname" },
{ schluessel: "nachname", beschriftung: "Nachname" },
],
abschnitte: [
{ titel: "01 · Start", schritte: [{ titel: "Eins" }, { titel: "Zwei" }] },
{ titel: "02 · Ende", schritte: [{ titel: "Drei" }] },
],
},
}),
redakteur.id,
);
const variablen = await testPrisma.vorlagenVariable.findMany({
where: { versionId },
orderBy: { position: "asc" },
});
expect(variablen.map((v) => [v.schluessel, v.position])).toEqual([
["vorname", 0],
["nachname", 1],
]);
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId },
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
});
expect(abschnitte.map((a) => a.position)).toEqual([0, 1]);
expect(abschnitte[0]?.schritte.map((s) => [s.titel, s.position])).toEqual([
["Eins", 0],
["Zwei", 1],
]);
expect(abschnitte[1]?.schritte.map((s) => s.position)).toEqual([0]);
});
it("nimmt einen Namen, den es schon gibt, ohne Murren an", async () => {
const redakteur = await redakteurAnlegen();
await vorlageImportieren(dokument(), redakteur.id);
await vorlageImportieren(dokument(), redakteur.id);
const gleichnamige = await testPrisma.vorlage.findMany({ where: { name: "Kleine Vorlage" } });
expect(gleichnamige).toHaveLength(2);
// Kein angehaengtes "(2)": der Name kommt so an, wie er im Dokument steht.
expect(gleichnamige.map((v) => v.name)).toEqual(["Kleine Vorlage", "Kleine Vorlage"]);
});
it("schreibt einen Protokolleintrag", async () => {
const redakteur = await redakteurAnlegen();
const { vorlageId } = await vorlageImportieren(dokument(), redakteur.id);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "Vorlage", entitaetId: vorlageId },
});
expect(eintraege).toHaveLength(1);
expect(eintraege[0]?.aktion).toBe("Vorlage importiert");
expect(eintraege[0]?.benutzerId).toBe(redakteur.id);
});
it("hinterlaesst nach einem Fehlschlag keine halbe Vorlage", async () => {
// Ein unbekannter Benutzer laesst den Protokolleintrag am Fremdschluessel
// scheitern – also erst, nachdem Vorlage, Version und Schritte geschrieben
// sind. Genau dieser Fall zeigt, dass die Transaktion greift.
await expect(vorlageImportieren(dokument(), "gibt-es-nicht")).rejects.toThrow();
expect(await testPrisma.vorlage.count()).toBe(0);
expect(await testPrisma.vorlagenVersion.count()).toBe(0);
expect(await testPrisma.vorlagenAbschnitt.count()).toBe(0);
expect(await testPrisma.vorlagenSchritt.count()).toBe(0);
expect(await testPrisma.vorlagenVariable.count()).toBe(0);
expect(await testPrisma.protokoll.count()).toBe(0);
});
it("schreibt nichts, wenn das Dokument abgewiesen wird", async () => {
await expect(vorlageImportieren(dokument({ format: 99 }), "wer")).rejects.toThrow(AustauschFehler);
expect(await testPrisma.vorlage.count()).toBe(0);
});
});
describe("Rundlauf", () => {
it("ergibt ein inhaltsgleiches Dokument – bis auf die Version", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const erst = await vorlageExportieren(quelle);
const { versionId } = await vorlageImportieren(erst, redakteur.id);
const wieder = await vorlageExportieren(versionId);
expect(ohneVersion(wieder)).toEqual(ohneVersion(erst));
// Die Quelle stand auf Version 3, die eingespielte Vorlage faengt bei 1 an.
expect(erst.version).toBe(3);
expect(wieder.version).toBe(1);
});
it("laesst einen BEFEHL-Schritt samt Platzhaltern unveraendert", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[1]?.schritte[0];
expect(schritt?.typ).toBe("BEFEHL");
expect(schritt?.konfig["shell"]).toBe("powershell");
expect(schritt?.konfig["befehl"]).toBe(BEFEHL);
});
it("bewahrt unbekannte konfig-Schluessel", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[1]?.schritte[0];
expect(schritt?.konfig["kuenftigesFeld"]).toEqual({ tief: [1, 2] });
});
it("bewahrt Hilfetext, Doku-Link und Zahlengrenzen", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
const schritte = (await vorlageExportieren(versionId)).struktur.abschnitte[0]?.schritte;
expect(schritte?.[1]?.hilfetext).toBe("Aufkleber auf der Unterseite.");
expect(schritte?.[1]?.dokuUrl).toBe("https://intern.example/seriennummern");
expect(schritte?.[2]?.konfig).toEqual({ min: 8, max: 64, einheit: "GB" });
});
});
describe("Export unsauberer Daten", () => {
/** Legt einen einzelnen Schritt mit frei gewaehltem `konfig` an. */
async function schrittMitKonfig(konfig: unknown): Promise<string> {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Krumm" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Abschnitt" },
});
await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Schritt",
typ: "BEFEHL",
konfig: konfig as never,
},
});
return version.id;
}
it("verliert bei einer unbekannten Shell nicht den Befehl", async () => {
// Das war ein echter Befund: `safeParse` verwarf bei jeder Beanstandung
// das ganze `konfig` – eine Shell aus einer spaeteren Fassung kostete
// lautlos auch den Befehl. Genau die Datei, die jemand zum Umzug braucht.
const versionId = await schrittMitKonfig({ shell: "zsh", befehl: "echo hallo" });
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[0]?.schritte[0];
expect(schritt?.konfig["befehl"]).toBe("echo hallo");
expect(schritt?.konfig["shell"]).toBe("zsh");
});
it("liefert für konfig-Werte, die gar kein Objekt sind, ein leeres Objekt", async () => {
for (const krumm of ["kaputt", 42, [1, 2], true]) {
const versionId = await schrittMitKonfig(krumm);
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[0]?.schritte[0];
expect(schritt?.konfig, JSON.stringify(krumm)).toEqual({});
await datenbankLeeren();
}
});
it("lässt sich mit unbekanntem konfig-Schlüssel wieder einspielen", async () => {
// Der Export muss nicht nur gelingen, er muss auch durch die Pruefung des
// Imports kommen – sonst waere er eine Datei, die niemand nutzen kann.
const redakteur = await redakteurAnlegen();
const versionId = await schrittMitKonfig({ shell: "bash", befehl: "echo", tief: null });
const { versionId: neu } = await vorlageImportieren(await vorlageExportieren(versionId), redakteur.id);
const schritt = (await vorlageExportieren(neu)).struktur.abschnitte[0]?.schritte[0];
expect(schritt?.konfig["befehl"]).toBe("echo");
expect(schritt?.konfig["tief"]).toBeNull();
});
it("weist eine unbekannte Shell beim Einspielen laut ab", async () => {
// Hier laufen zwei Regeln absichtlich auseinander, und das ist die
// Entscheidung: der **Export** gibt eine unbekannte Shell heraus, damit die
// Datei nichts verliert; der **Import** weist sie ab, weil `shell` darueber
// entscheidet, wie ein Wert maskiert wird. Eine stillschweigend verworfene
// Shell hiesse, einen bash-Befehl nach PowerShell-Regeln zu maskieren – das
// kann auf einem Server Daten kosten. Die Meldung nennt Pfad und erlaubte
// Werte, also kann der Redakteur die Datei richtigstellen.
const redakteur = await redakteurAnlegen();
const versionId = await schrittMitKonfig({ shell: "zsh", befehl: "echo hallo" });
const dokument = await vorlageExportieren(versionId);
await expect(vorlageImportieren(dokument, redakteur.id)).rejects.toThrow(/schritte\.0\.konfig\.shell/);
await expect(vorlageImportieren(dokument, redakteur.id)).rejects.toThrow(/powershell/);
// Nur die eine Quellvorlage steht da, keine halb eingespielte zweite.
expect(await testPrisma.vorlage.count()).toBe(1);
});
it("weist einen bereits gespeicherten javascript-Link beim Wiedereinspielen ab", async () => {
// Aeltere Zeilen koennten so einen Link tragen, denn die Pruefung kam erst
// mit #009. Der Export gibt ihn heraus, der Import faengt ihn – die Datei
// ist dann ein sichtbarer Fehlerfall und kein stiller Durchlaeufer.
const redakteur = await redakteurAnlegen();
const vorlage = await testPrisma.vorlage.create({ data: { name: "Alt" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Abschnitt" },
});
await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Schritt", dokuUrl: "javascript:alert(1)" },
});
const dokument = await vorlageExportieren(version.id);
expect(dokument.struktur.abschnitte[0]?.schritte[0]?.dokuUrl).toBe("javascript:alert(1)");
await expect(vorlageImportieren(dokument, redakteur.id)).rejects.toThrow(/http/);
expect(await testPrisma.vorlage.count()).toBe(1);
});
});
});
+156
View File
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus } from "@/generated/prisma/enums";
import {
versionMitAufbau,
versionsHistorie,
vorlagenFilterwerte,
vorlagenListe,
} from "@/server/queries/vorlagen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Mandantensicht auf die Vorlagen (#009).
*
* Diese Datei gibt es, weil der unabhängige Review zwei Lücken gefunden hat, die
* kein Test sehen konnte: der Suchfilter hat die Kundenbedingung überschrieben
* (zwei `OR`-Schlüssel im selben Objekt, der zweite gewinnt), und Historie wie
* Aufbau einer Version haben überhaupt nicht gefiltert. Beides fiel nicht auf,
* weil es zu `src/server/queries/vorlagen.ts` keinen einzigen Test gab.
*/
async function aufbau() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await testPrisma.einstellung.create({
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
});
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: meiner.id },
});
async function vorlageMit(name: string, kundeId: string | null) {
const vorlage = await testPrisma.vorlage.create({
data: { name, kategorie: "Geheim", schlagworte: ["intern"], kundeId },
});
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: `Abschnitt von ${name}` },
});
await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Ein Schritt" },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
return { vorlage, versionId: version.id };
}
return {
techniker,
admin: { id: "admin", rolle: "ADMIN" as const },
meine: await vorlageMit("Geheimprojekt Meiner", meiner.id),
fremde: await vorlageMit("Geheimprojekt Fremder", fremder.id),
globale: await vorlageMit("Geheimprojekt Global", null),
eigene,
};
}
describe.runIf(datenbankVerfuegbar)("Mandantensicht auf Vorlagen", () => {
beforeEach(async () => {
await datenbankLeeren();
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
});
describe("Liste", () => {
it("zeigt einem gebundenen Techniker nur seine und die globalen Vorlagen", async () => {
const { techniker } = await aufbau();
const namen = (await vorlagenListe(techniker)).map((z) => z.name).sort();
expect(namen).toEqual(["Geheimprojekt Global", "Geheimprojekt Meiner"]);
});
it("lässt die Mandantengrenze auch beim Suchen stehen", async () => {
// Genau der Blocker: der Suchfilter brachte ein zweites `OR` mit und
// überschrieb die Kundenbedingung — ein Buchstabe im Suchfeld genügte.
const { techniker } = await aufbau();
const namen = (await vorlagenListe(techniker, { suche: "Geheim" })).map((z) => z.name).sort();
expect(namen).toEqual(["Geheimprojekt Global", "Geheimprojekt Meiner"]);
expect(namen).not.toContain("Geheimprojekt Fremder");
});
it("lässt sie auch bei Kategorie- und Schlagwortfilter stehen", async () => {
const { techniker } = await aufbau();
for (const filter of [{ kategorie: "Geheim" }, { schlagwort: "intern" }]) {
const namen = (await vorlagenListe(techniker, filter)).map((z) => z.name);
expect(namen, JSON.stringify(filter)).not.toContain("Geheimprojekt Fremder");
}
});
it("lässt sie auch bei allen Filtern gleichzeitig stehen", async () => {
const { techniker } = await aufbau();
const namen = (
await vorlagenListe(techniker, {
suche: "Geheim",
kategorie: "Geheim",
schlagwort: "intern",
auchArchivierte: true,
})
).map((z) => z.name);
expect(namen).not.toContain("Geheimprojekt Fremder");
});
it("zeigt einem Administrator alles", async () => {
const { admin } = await aufbau();
expect(await vorlagenListe(admin, { suche: "Geheim" })).toHaveLength(3);
});
it("blendet fremde Kategorien und Schlagworte aus den Filterwerten aus", async () => {
const { techniker, fremde } = await aufbau();
await testPrisma.vorlage.update({
where: { id: fremde.vorlage.id },
data: { kategorie: "Nur-fuer-Fremde", schlagworte: ["geheimwort"] },
});
const werte = await vorlagenFilterwerte(techniker);
expect(werte.kategorien).not.toContain("Nur-fuer-Fremde");
expect(werte.schlagworte).not.toContain("geheimwort");
});
});
describe("Historie und Aufbau", () => {
it("zeigt die Historie einer fremden Vorlage nicht", async () => {
const { techniker, fremde, meine } = await aufbau();
expect(await versionsHistorie(fremde.vorlage.id, techniker)).toEqual([]);
expect(await versionsHistorie(meine.vorlage.id, techniker)).toHaveLength(1);
});
it("gibt den Aufbau einer fremden Version nicht heraus", async () => {
// Sonst stünden Abschnittstitel, Schritte und Befehle eines fremden Kunden
// offen — die Vorlage ist der innere Ablauf seines Betriebs.
const { techniker, fremde, meine, globale } = await aufbau();
expect(await versionMitAufbau(fremde.versionId, techniker)).toBeUndefined();
expect(await versionMitAufbau(meine.versionId, techniker)).toBeDefined();
expect(await versionMitAufbau(globale.versionId, techniker)).toBeDefined();
});
it("gibt einem Administrator alles heraus", async () => {
const { admin, fremde } = await aufbau();
expect(await versionMitAufbau(fremde.versionId, admin)).toBeDefined();
});
it("schränkt ohne gesetztes Präfix niemanden ein", async () => {
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
const { techniker, fremde } = await aufbau();
expect(await versionMitAufbau(fremde.versionId, techniker)).toBeDefined();
expect(await vorlagenListe(techniker)).toHaveLength(3);
});
});
});
+741
View File
@@ -0,0 +1,741 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
VorlagenFehler,
entwurfHolenOderErzeugen,
entwurfSpeichern,
entwurfVerwerfen,
veroeffentlichteVersionPruefen,
versionMussEntwurfSein,
versionVeroeffentlichen,
vorlageAnlegen,
vorlageArchivieren,
vorlageBearbeiten,
vorlageDuplizieren,
} from "@/server/services/vorlagen";
import type { StrukturEingabe } from "@/lib/vorlagen-format";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Versionierung der Vorlagen (#009).
*
* Die tragende Regel: eine veroeffentlichte Version wird nie wieder veraendert.
* Ein Lauf zeigt deshalb auch in zehn Jahren genau die Schritte, die damals
* abgearbeitet wurden. Alles hier dreht sich darum, dass diese Regel haelt.
*/
const STRUKTUR: StrukturEingabe = {
variablen: [
{
schluessel: "os_version",
beschriftung: "Betriebssystem",
typ: "AUSWAHL",
optionen: ["Windows 11 Pro", "Windows 11 Enterprise"],
pflicht: true,
},
{
schluessel: "join_typ",
beschriftung: "Einbindung",
typ: "AUSWAHL",
optionen: ["Active Directory", "Entra ID"],
pflicht: true,
},
],
abschnitte: [
{
titel: "Vorbereitung",
schritte: [
{ titel: "Ticket und Endbenutzer im Lauf erfasst", pflicht: true },
{
titel: "Datensicherung geprüft",
hilfetext: "Auch nach lokal gespeicherten Daten außerhalb des Profils fragen.",
pflicht: true,
},
],
},
{
titel: "Installation",
schritte: [
{
titel: "Hostname gesetzt",
typ: "BEFEHL",
pflicht: true,
konfig: { shell: "powershell", befehl: 'Rename-Computer -NewName "{{hostname}}" -Restart' },
},
{ titel: "Build-Nummer", typ: "TEXT", pflicht: true, konfig: { mehrzeilig: false } },
],
},
],
};
async function benutzerAnlegen(email = "redakteur@firma.example") {
return testPrisma.benutzer.create({
data: { email, name: "Redakteur", rolle: "REDAKTEUR" },
});
}
/** Eine Vorlage mit einer veroeffentlichten Version 1. */
async function veroeffentlichteVorlage(benutzerId: string) {
const { vorlage, version } = await vorlageAnlegen(
{ name: "PC Neuinstallation", kategorie: "Client" },
benutzerId,
);
await entwurfSpeichern(version.id, STRUKTUR);
await versionVeroeffentlichen(version.id, "Erste Fassung", benutzerId);
return { vorlage, versionId: version.id };
}
async function strukturLesen(versionId: string) {
const version = await testPrisma.vorlagenVersion.findUniqueOrThrow({
where: { id: versionId },
include: {
variablen: { orderBy: { position: "asc" } },
abschnitte: {
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
},
},
});
return version;
}
describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Anlegen", () => {
it("legt eine Vorlage mit einem leeren Entwurf als Version 1 an", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage, version } = await vorlageAnlegen(
{ name: " Mitarbeiter-Onboarding ", kategorie: "Benutzer", schlagworte: ["ad", "entra"] },
benutzer.id,
);
expect(vorlage.name).toBe("Mitarbeiter-Onboarding");
expect(vorlage.schlagworte).toEqual(["ad", "entra"]);
expect(version.version).toBe(1);
expect(version.status).toBe("ENTWURF");
expect(version.veroeffentlichtAm).toBeNull();
});
it("weist einen leeren Namen ab", async () => {
const benutzer = await benutzerAnlegen();
await expect(vorlageAnlegen({ name: " " }, benutzer.id)).rejects.toBeInstanceOf(VorlagenFehler);
expect(await testPrisma.vorlage.count()).toBe(0);
});
});
describe("Entwurf speichern", () => {
it("legt Variablen, Abschnitte und Schritte in der übergebenen Reihenfolge an", async () => {
const benutzer = await benutzerAnlegen();
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
await entwurfSpeichern(version.id, STRUKTUR);
const gelesen = await strukturLesen(version.id);
expect(gelesen.variablen.map((v) => v.schluessel)).toEqual(["os_version", "join_typ"]);
expect(gelesen.variablen.map((v) => v.position)).toEqual([0, 1]);
expect(gelesen.abschnitte.map((a) => a.titel)).toEqual(["Vorbereitung", "Installation"]);
expect(gelesen.abschnitte[1]?.schritte.map((s) => s.titel)).toEqual([
"Hostname gesetzt",
"Build-Nummer",
]);
expect(gelesen.abschnitte[1]?.schritte[0]?.konfig).toMatchObject({
shell: "powershell",
befehl: 'Rename-Computer -NewName "{{hostname}}" -Restart',
});
});
it("ersetzt den Aufbau vollständig, statt ihn zu ergänzen", async () => {
const benutzer = await benutzerAnlegen();
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
await entwurfSpeichern(version.id, STRUKTUR);
await entwurfSpeichern(version.id, {
variablen: [],
abschnitte: [{ titel: "Nur noch das hier", schritte: [{ titel: "Ein Schritt" }] }],
});
const gelesen = await strukturLesen(version.id);
expect(gelesen.variablen).toHaveLength(0);
expect(gelesen.abschnitte).toHaveLength(1);
expect(await testPrisma.vorlagenSchritt.count()).toBe(1);
});
it("weist einen doppelten Variablenschlüssel mit verständlicher Meldung ab", async () => {
const benutzer = await benutzerAnlegen();
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
await expect(
entwurfSpeichern(version.id, {
variablen: [
{ schluessel: "upn", beschriftung: "UPN" },
{ schluessel: "upn", beschriftung: "Nochmal UPN" },
],
abschnitte: [],
}),
).rejects.toThrow(/„upn“ gibt es zweimal/);
});
it("nennt bei einem Fehler im Aufbau den Pfad", async () => {
const benutzer = await benutzerAnlegen();
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
await expect(
entwurfSpeichern(version.id, {
variablen: [],
abschnitte: [{ titel: "Abschnitt", schritte: [{ titel: "gut" }, { titel: " " }] }],
}),
).rejects.toThrow(/abschnitte\.0\.schritte\.1\.titel/);
});
it("hinterlässt bei einem Fehler keinen halben Entwurf", async () => {
const benutzer = await benutzerAnlegen();
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
await entwurfSpeichern(version.id, STRUKTUR);
await expect(
entwurfSpeichern(version.id, {
variablen: [],
abschnitte: [{ titel: "", schritte: [] }],
}),
).rejects.toThrow();
// Der alte Aufbau steht unveraendert.
const gelesen = await strukturLesen(version.id);
expect(gelesen.abschnitte).toHaveLength(2);
});
});
describe("Unveränderlichkeit", () => {
it("lässt eine veröffentlichte Version nicht mehr bearbeiten", async () => {
const benutzer = await benutzerAnlegen();
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
await expect(entwurfSpeichern(versionId, { variablen: [], abschnitte: [] })).rejects.toThrow(
/unveränderlich/,
);
});
it("lässt eine veröffentlichte Version nicht erneut veröffentlichen", async () => {
const benutzer = await benutzerAnlegen();
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
await expect(versionVeroeffentlichen(versionId, null, benutzer.id)).rejects.toThrow(/unveränderlich/);
});
it("lässt eine veröffentlichte Version nicht verwerfen", async () => {
const benutzer = await benutzerAnlegen();
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
await expect(entwurfVerwerfen(versionId, benutzer.id)).rejects.toThrow(/unveränderlich/);
});
it("prüft den Zustand an einer Stelle", () => {
expect(() => versionMussEntwurfSein({ status: "ENTWURF", version: 1 })).not.toThrow();
expect(() => versionMussEntwurfSein({ status: "VEROEFFENTLICHT", version: 3 })).toThrow(/Version 3/);
});
});
describe("Bearbeiten erzeugt eine tiefe Kopie", () => {
it("kopiert Variablen, Abschnitte und Schritte, statt sie zu teilen", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
expect(entwurf.version).toBe(2);
expect(entwurf.status).toBe("ENTWURF");
const veroeffentlicht = await strukturLesen(versionId);
const kopie = await strukturLesen(entwurf.id);
// Gleicher Inhalt …
expect(kopie.variablen.map((v) => v.schluessel)).toEqual(
veroeffentlicht.variablen.map((v) => v.schluessel),
);
expect(kopie.abschnitte.map((a) => a.titel)).toEqual(veroeffentlicht.abschnitte.map((a) => a.titel));
// … aber eigene Zeilen.
const alteIds = veroeffentlicht.abschnitte.map((a) => a.id);
for (const abschnitt of kopie.abschnitte) {
expect(alteIds).not.toContain(abschnitt.id);
}
});
it("lässt die veröffentlichte Version unberührt, wenn der Entwurf geändert wird", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
await entwurfSpeichern(entwurf.id, {
variablen: [],
abschnitte: [{ titel: "Komplett anders", schritte: [{ titel: "Neu" }] }],
});
const veroeffentlicht = await strukturLesen(versionId);
expect(veroeffentlicht.abschnitte.map((a) => a.titel)).toEqual(["Vorbereitung", "Installation"]);
expect(veroeffentlicht.variablen).toHaveLength(2);
});
it("kopiert auch die konfig als Wert, nicht als geteilten Verweis", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
const kopie = await strukturLesen(entwurf.id);
const befehlsschritt = kopie.abschnitte[1]?.schritte[0];
expect(befehlsschritt?.konfig).toMatchObject({ shell: "powershell" });
await testPrisma.vorlagenSchritt.update({
where: { id: befehlsschritt!.id },
data: { konfig: { shell: "bash", befehl: "echo neu" } },
});
const original = await strukturLesen(versionId);
expect(original.abschnitte[1]?.schritte[0]?.konfig).toMatchObject({ shell: "powershell" });
});
it("gibt einen vorhandenen Entwurf zurück, statt einen zweiten anzulegen", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const erst = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
const nochmal = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
expect(nochmal.id).toBe(erst.id);
expect(
await testPrisma.vorlagenVersion.count({
where: { vorlageId: vorlage.id, status: "ENTWURF" },
}),
).toBe(1);
});
it("lässt eine archivierte Vorlage nicht bearbeiten", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
await vorlageArchivieren(vorlage.id, true, benutzer.id);
await expect(entwurfHolenOderErzeugen(vorlage.id, benutzer.id)).rejects.toThrow(/archiviert/);
});
});
describe("Veröffentlichen", () => {
it("setzt Status, Zeitpunkt, Änderungsnotiz und Urheber", async () => {
const benutzer = await benutzerAnlegen();
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
await entwurfSpeichern(version.id, STRUKTUR);
const veroeffentlicht = await versionVeroeffentlichen(
version.id,
" Secure Boot ergänzt ",
benutzer.id,
);
expect(veroeffentlicht.status).toBe("VEROEFFENTLICHT");
expect(veroeffentlicht.veroeffentlichtAm).not.toBeNull();
expect(veroeffentlicht.veroeffentlichtVonId).toBe(benutzer.id);
expect(veroeffentlicht.aenderungsnotiz).toBe("Secure Boot ergänzt");
});
it("hält das Veröffentlichen im Protokoll fest", async () => {
const benutzer = await benutzerAnlegen();
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: versionId, aktion: "Version veröffentlicht" },
});
expect(eintrag.benutzerId).toBe(benutzer.id);
expect(eintrag.nachher).toMatchObject({ status: "VEROEFFENTLICHT" });
});
it("weist eine Vorlage ohne Abschnitte ab", async () => {
const benutzer = await benutzerAnlegen();
const { version } = await vorlageAnlegen({ name: "Leer" }, benutzer.id);
await expect(versionVeroeffentlichen(version.id, null, benutzer.id)).rejects.toThrow(/ohne Abschnitte/);
});
it("verändert bestehende Läufe nicht", async () => {
// Das ist der Grund für die ganze Versionierung.
const benutzer = await benutzerAnlegen();
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
const lauf = await testPrisma.lauf.create({
data: {
vorlagenVersionId: versionId,
titel: "PC-001 neu aufsetzen",
kundeId: kunde.id,
erstelltVonId: benutzer.id,
},
});
// Zweite Version mit ganz anderem Inhalt veroeffentlichen.
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
await entwurfSpeichern(entwurf.id, {
variablen: [],
abschnitte: [{ titel: "Alles neu", schritte: [{ titel: "Ein einziger Schritt" }] }],
});
await versionVeroeffentlichen(entwurf.id, "Umgebaut", benutzer.id);
const frisch = await testPrisma.lauf.findUniqueOrThrow({
where: { id: lauf.id },
include: {
vorlagenVersion: {
include: { abschnitte: { orderBy: { position: "asc" } } },
},
},
});
expect(frisch.vorlagenVersionId).toBe(versionId);
expect(frisch.vorlagenVersion.version).toBe(1);
expect(frisch.vorlagenVersion.abschnitte.map((a) => a.titel)).toEqual(["Vorbereitung", "Installation"]);
});
});
describe("Höchstens ein Entwurf", () => {
it("lehnt einen zweiten Entwurf auch an der Datenbank ab", async () => {
// Die Regel steht zusaetzlich als partieller Index in der Datenbank – der
// Dienst prueft nur fuer die bessere Meldung.
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
await expect(
testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 99, status: "ENTWURF" },
}),
).rejects.toThrow(/VorlagenVersion_ein_entwurf/);
});
});
describe("Entwurf verwerfen", () => {
it("entfernt den Entwurf samt Aufbau", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
await entwurfVerwerfen(entwurf.id, benutzer.id);
expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId: vorlage.id } })).toBe(1);
// Die veroeffentlichte Version behaelt ihren Aufbau.
expect(await testPrisma.vorlagenAbschnitt.count()).toBe(2);
});
it("verwirft nicht die einzige Version", async () => {
const benutzer = await benutzerAnlegen();
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
await expect(entwurfVerwerfen(version.id, benutzer.id)).rejects.toThrow(/einzige Version/);
});
});
describe("Duplizieren", () => {
it("legt eine eigenständige Kopie als Entwurf an", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const kopie = await vorlageDuplizieren(vorlage.id, "PC Neuinstallation (Außendienst)", benutzer.id);
expect(kopie.id).not.toBe(vorlage.id);
expect(kopie.name).toBe("PC Neuinstallation (Außendienst)");
expect(kopie.kategorie).toBe("Client");
const versionen = await testPrisma.vorlagenVersion.findMany({ where: { vorlageId: kopie.id } });
expect(versionen).toHaveLength(1);
expect(versionen[0]?.status).toBe("ENTWURF");
expect(versionen[0]?.version).toBe(1);
const aufbau = await strukturLesen(versionen[0]!.id);
expect(aufbau.abschnitte.map((a) => a.titel)).toEqual(["Vorbereitung", "Installation"]);
expect(aufbau.variablen).toHaveLength(2);
});
it("lässt eine Änderung an der Kopie das Original unberührt", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
const kopie = await vorlageDuplizieren(vorlage.id, "Kopie", benutzer.id);
const kopieVersion = await testPrisma.vorlagenVersion.findFirstOrThrow({
where: { vorlageId: kopie.id },
});
await entwurfSpeichern(kopieVersion.id, { variablen: [], abschnitte: [{ titel: "X", schritte: [] }] });
const original = await strukturLesen(versionId);
expect(original.abschnitte).toHaveLength(2);
});
it("weist eine Vorlage ohne veröffentlichte Version ab", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await vorlageAnlegen({ name: "Nur Entwurf" }, benutzer.id);
await expect(vorlageDuplizieren(vorlage.id, "Kopie", benutzer.id)).rejects.toThrow(
/keine veröffentlichte Version/,
);
});
});
describe("Archivieren statt Löschen", () => {
it("blendet die Vorlage aus, behält aber ihre Läufe", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.lauf.create({
data: {
vorlagenVersionId: versionId,
titel: "Ein Lauf",
kundeId: kunde.id,
erstelltVonId: benutzer.id,
},
});
await vorlageArchivieren(vorlage.id, true, benutzer.id);
const frisch = await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } });
expect(frisch.archiviert).toBe(true);
expect(await testPrisma.lauf.count()).toBe(1);
expect(await testPrisma.protokoll.count({ where: { aktion: "Vorlage archiviert" } })).toBe(1);
});
it("schreibt nichts, wenn sich nichts ändert", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const vorher = await testPrisma.protokoll.count();
await vorlageArchivieren(vorlage.id, false, benutzer.id);
expect(await testPrisma.protokoll.count()).toBe(vorher);
});
});
describe("Läufe hängen nur an veröffentlichten Versionen", () => {
it("lässt eine veröffentlichte Version zu", async () => {
const benutzer = await benutzerAnlegen();
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
await expect(veroeffentlichteVersionPruefen(versionId)).resolves.toBeUndefined();
});
it("weist einen Entwurf ab", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
await expect(veroeffentlichteVersionPruefen(entwurf.id)).rejects.toThrow(/noch ein Entwurf/);
});
it("weist eine archivierte Vorlage ab", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
await vorlageArchivieren(vorlage.id, true, benutzer.id);
await expect(veroeffentlichteVersionPruefen(versionId)).rejects.toThrow(/startet keine Läufe/);
});
});
describe("Gleichzeitigkeit", () => {
it("gibt bei zwei gleichzeitigen Bearbeitungen denselben Entwurf zurück", async () => {
// Ohne Wiederholung lief der zweite Aufruf in den eindeutigen Index und der
// Benutzer sah „Der Entwurf konnte nicht geöffnet werden“ – obwohl er in
// derselben Sekunde angelegt wurde.
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const ergebnisse = await Promise.allSettled([
entwurfHolenOderErzeugen(vorlage.id, benutzer.id),
entwurfHolenOderErzeugen(vorlage.id, benutzer.id),
]);
const erfuellt = ergebnisse.filter((e) => e.status === "fulfilled");
expect(erfuellt).toHaveLength(2);
const ids = new Set(erfuellt.map((e) => e.value.id));
expect(ids.size).toBe(1);
expect(
await testPrisma.vorlagenVersion.count({
where: { vorlageId: vorlage.id, status: "ENTWURF" },
}),
).toBe(1);
});
it("lässt Speichern nicht in eine gerade veröffentlichte Version schreiben", async () => {
// Der wichtigste Test dieses Meilensteins: ohne Zeilensperre meldeten beide
// Aufrufe Erfolg, und die veröffentlichte Version trug hinterher Inhalt,
// der nach dem Veröffentlichen geschrieben wurde.
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
await Promise.allSettled([
versionVeroeffentlichen(entwurf.id, "Veröffentlicht", benutzer.id),
entwurfSpeichern(entwurf.id, {
variablen: [],
abschnitte: [{ titel: "NACHTRAEGLICH", schritte: [] }],
}),
]);
const danach = await strukturLesen(entwurf.id);
if (danach.status === "VEROEFFENTLICHT") {
// Wurde veroeffentlicht, darf der nachträgliche Aufbau nicht drinstehen.
expect(danach.abschnitte.map((a) => a.titel)).not.toContain("NACHTRAEGLICH");
}
});
it("veröffentlicht bei zwei gleichzeitigen Aufrufen nur einmal", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
await entwurfSpeichern(entwurf.id, STRUKTUR);
const ergebnisse = await Promise.allSettled([
versionVeroeffentlichen(entwurf.id, "A", benutzer.id),
versionVeroeffentlichen(entwurf.id, "B", benutzer.id),
]);
expect(ergebnisse.filter((e) => e.status === "fulfilled")).toHaveLength(1);
expect(
await testPrisma.protokoll.count({
where: { entitaetId: entwurf.id, aktion: "Version veröffentlicht" },
}),
).toBe(1);
});
});
describe("Zwei Redakteure am selben Entwurf", () => {
it("weist ein Speichern auf einem überholten Stand ab", async () => {
// Ohne diese Prüfung überschriebe der zweite die Arbeit des ersten
// spurlos – und weil Entwürfe bewusst nicht protokolliert werden, wäre sie
// auch nicht wiederherstellbar.
const benutzer = await benutzerAnlegen();
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
const geladen = await testPrisma.vorlagenVersion.findUniqueOrThrow({
where: { id: version.id },
});
// Der erste speichert.
const { stand } = await entwurfSpeichern(version.id, STRUKTUR, geladen.geaendertAm);
// Der zweite hat noch den alten Stand in der Hand.
await expect(
entwurfSpeichern(
version.id,
{ variablen: [], abschnitte: [{ titel: "Meine Fassung", schritte: [] }] },
geladen.geaendertAm,
),
).rejects.toThrow(/zwischenzeitlich/);
// Mit dem frischen Stand geht es.
await expect(
entwurfSpeichern(
version.id,
{ variablen: [], abschnitte: [{ titel: "Meine Fassung", schritte: [] }] },
stand,
),
).resolves.toBeTruthy();
});
it("speichert ohne mitgegebenen Stand weiterhin", async () => {
// Ein Aufrufer, der den Stand nicht kennt (Import, Seed), soll nicht
// blockiert sein.
const benutzer = await benutzerAnlegen();
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
await expect(entwurfSpeichern(version.id, STRUKTUR)).resolves.toBeTruthy();
});
});
describe("Die Datenbank schützt die Unveränderlichkeit mit", () => {
it("lässt einen Schritt einer veröffentlichten Version nicht ändern", async () => {
// Bisher hing die Regel allein am Dienst. Migrationen, psql und ab Phase 4
// die Connectoren schreiben daran vorbei.
const benutzer = await benutzerAnlegen();
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
const gelesen = await strukturLesen(versionId);
const schritt = gelesen.abschnitte[0]?.schritte[0];
await expect(
testPrisma.vorlagenSchritt.update({
where: { id: schritt!.id },
data: { titel: "Heimlich geändert" },
}),
).rejects.toThrow(/unveraenderlich/i);
});
it("lässt Abschnitte einer veröffentlichten Version nicht löschen", async () => {
const benutzer = await benutzerAnlegen();
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
await expect(testPrisma.vorlagenAbschnitt.deleteMany({ where: { versionId } })).rejects.toThrow(
/unveraenderlich/i,
);
});
it("lässt keine Variable an eine veröffentlichte Version hängen", async () => {
const benutzer = await benutzerAnlegen();
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
await expect(
testPrisma.vorlagenVariable.create({
data: { versionId, schluessel: "heimlich", beschriftung: "Heimlich" },
}),
).rejects.toThrow(/unveraenderlich/i);
});
it("lässt eine veröffentlichte Version nicht zum Entwurf zurückdrehen", async () => {
// Das wäre die Hintertür, durch die sich alles Obige aushebeln ließe.
const benutzer = await benutzerAnlegen();
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
await expect(
testPrisma.vorlagenVersion.update({
where: { id: versionId },
data: { status: "ENTWURF" },
}),
).rejects.toThrow(/nicht wieder zum Entwurf/i);
});
it("lässt einen Entwurf weiterhin frei bearbeiten", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
const gelesen = await strukturLesen(entwurf.id);
await expect(
testPrisma.vorlagenAbschnitt.update({
where: { id: gelesen.abschnitte[0]!.id },
data: { titel: "Umbenannt" },
}),
).resolves.toBeTruthy();
});
});
describe("Kopfdaten bearbeiten", () => {
it("ändert Name, Kategorie und Schlagworte und protokolliert es", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
await vorlageBearbeiten(
vorlage.id,
{ name: "PC Neuinstallation (Standard)", kategorie: "Client", schlagworte: ["windows"] },
benutzer.id,
);
const frisch = await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } });
expect(frisch.name).toBe("PC Neuinstallation (Standard)");
expect(frisch.schlagworte).toEqual(["windows"]);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: vorlage.id, aktion: { contains: "geändert" } },
});
expect(eintrag.aktion).toContain("Name");
});
it("lässt die Versionen unberührt", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
await vorlageBearbeiten(vorlage.id, { name: "Anders" }, benutzer.id);
const gelesen = await strukturLesen(versionId);
expect(gelesen.abschnitte).toHaveLength(2);
expect(gelesen.status).toBe("VEROEFFENTLICHT");
});
it("schreibt nichts, wenn sich nichts ändert", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const vorher = await testPrisma.protokoll.count();
await vorlageBearbeiten(vorlage.id, { name: "PC Neuinstallation" }, benutzer.id);
expect(await testPrisma.protokoll.count()).toBe(vorher);
});
});
describe("Archivierte Vorlage", () => {
it("lässt sich nicht veröffentlichen", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
await vorlageArchivieren(vorlage.id, true, benutzer.id);
await expect(versionVeroeffentlichen(entwurf.id, null, benutzer.id)).rejects.toThrow(/archiviert/);
});
});
});