From abacad1cb7fb3d22c96e98041389f3364e20acf0 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 25 Sep 2026 08:09:05 +0200 Subject: [PATCH] feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 17 + CLAUDE.md | 16 +- README.md | 1 + docs/abnahme-mvp.md | 12 +- docs/architektur.md | 11 +- docs/devlog.md | 76 +- docs/issues/004-epic-phase-4.md | 2 +- docs/issues/005-geruest-prozess-design.md | 4 +- docs/issues/007-auth-rollen-benutzer.md | 2 +- docs/issues/008-betriebsmodus-kunden.md | 12 +- docs/issues/009-vorlagen-versionierung.md | 77 +- docs/issues/010-vorlagen-editor.md | 4 +- .../011-mitarbeiter-abteilungen-geraete.md | 4 +- docs/issues/012-platzhalter-namensregeln.md | 6 +- docs/issues/013-laeufe-abarbeitung.md | 8 +- docs/issues/014-qr-tags-geraeteseite.md | 16 +- docs/issues/016-verzeichnis-ldap.md | 6 +- docs/issues/017-seed-sieben-vorlagen.md | 22 +- docs/issues/018-e2e-anleitung-pdf.md | 6 +- docs/issues/README.md | 4 +- docs/vorlagen-format.md | 224 ++++++ eslint.config.mjs | 24 + .../migration.sql | 94 +++ prisma/schema.prisma | 27 +- src/lib/validierung.ts | 23 + src/lib/vorlagen-format.ts | 203 +++++ src/server/actions/anmeldung.ts | 2 +- src/server/actions/benutzer.ts | 2 +- src/server/actions/einstellungen.ts | 2 +- src/server/actions/kunden.ts | 2 +- src/server/actions/vorlagen.ts | 347 ++++++++ src/server/queries/vorlagen.ts | 244 ++++++ src/server/services/einstellungen.ts | 6 +- src/server/services/vorlagen-austausch.ts | 245 ++++++ src/server/services/vorlagen.ts | 727 +++++++++++++++++ tests/datenbankregeln.test.ts | 95 +++ tests/datenmodell.test.ts | 28 +- tests/validierung.test.ts | 34 + tests/vorlagen-austausch.test.ts | 578 ++++++++++++++ tests/vorlagen-sicht.test.ts | 156 ++++ tests/vorlagen.test.ts | 741 ++++++++++++++++++ 41 files changed, 4006 insertions(+), 104 deletions(-) create mode 100644 docs/vorlagen-format.md create mode 100644 prisma/migrations/20260925054517_vorlagen_unveraenderlich/migration.sql create mode 100644 src/lib/validierung.ts create mode 100644 src/lib/vorlagen-format.ts create mode 100644 src/server/actions/vorlagen.ts create mode 100644 src/server/queries/vorlagen.ts create mode 100644 src/server/services/vorlagen-austausch.ts create mode 100644 src/server/services/vorlagen.ts create mode 100644 tests/datenbankregeln.test.ts create mode 100644 tests/validierung.test.ts create mode 100644 tests/vorlagen-austausch.test.ts create mode 100644 tests/vorlagen-sicht.test.ts create mode 100644 tests/vorlagen.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ada3caf..28849a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/CLAUDE.md b/CLAUDE.md index ce21796..35ac254 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 8118a31..c826f6c 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/abnahme-mvp.md b/docs/abnahme-mvp.md index 9088e52..546ed8a 100644 --- a/docs/abnahme-mvp.md +++ b/docs/abnahme-mvp.md @@ -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) diff --git a/docs/architektur.md b/docs/architektur.md index ac35e95..8b06075 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -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 diff --git a/docs/devlog.md b/docs/devlog.md index 2659590..b6db790 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -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. diff --git a/docs/issues/004-epic-phase-4.md b/docs/issues/004-epic-phase-4.md index b746b7b..786dfa9 100644 --- a/docs/issues/004-epic-phase-4.md +++ b/docs/issues/004-epic-phase-4.md @@ -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) diff --git a/docs/issues/005-geruest-prozess-design.md b/docs/issues/005-geruest-prozess-design.md index 586ff11..62fa122 100644 --- a/docs/issues/005-geruest-prozess-design.md +++ b/docs/issues/005-geruest-prozess-design.md @@ -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“. diff --git a/docs/issues/007-auth-rollen-benutzer.md b/docs/issues/007-auth-rollen-benutzer.md index b7f151e..4e8c98f 100644 --- a/docs/issues/007-auth-rollen-benutzer.md +++ b/docs/issues/007-auth-rollen-benutzer.md @@ -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 diff --git a/docs/issues/008-betriebsmodus-kunden.md b/docs/issues/008-betriebsmodus-kunden.md index 1f6db60..feb77bd 100644 --- a/docs/issues/008-betriebsmodus-kunden.md +++ b/docs/issues/008-betriebsmodus-kunden.md @@ -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. diff --git a/docs/issues/009-vorlagen-versionierung.md b/docs/issues/009-vorlagen-versionierung.md index c2955b3..923a762 100644 --- a/docs/issues/009-vorlagen-versionierung.md +++ b/docs/issues/009-vorlagen-versionierung.md @@ -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. diff --git a/docs/issues/010-vorlagen-editor.md b/docs/issues/010-vorlagen-editor.md index 6f3a48c..aebce8b 100644 --- a/docs/issues/010-vorlagen-editor.md +++ b/docs/issues/010-vorlagen-editor.md @@ -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 diff --git a/docs/issues/011-mitarbeiter-abteilungen-geraete.md b/docs/issues/011-mitarbeiter-abteilungen-geraete.md index 278e441..5f943b7 100644 --- a/docs/issues/011-mitarbeiter-abteilungen-geraete.md +++ b/docs/issues/011-mitarbeiter-abteilungen-geraete.md @@ -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 diff --git a/docs/issues/012-platzhalter-namensregeln.md b/docs/issues/012-platzhalter-namensregeln.md index 5989b51..7776d10 100644 --- a/docs/issues/012-platzhalter-namensregeln.md +++ b/docs/issues/012-platzhalter-namensregeln.md @@ -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: ` davor, Zeilenumbruch passend zur Shell +- [ ] „Alle Befehle als Skript kopieren“ je Abschnitt: alle `BEFEHL`-Schritte in Reihenfolge, getrennt durch Leerzeilen, je mit `# Schritt: ` 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 diff --git a/docs/issues/013-laeufe-abarbeitung.md b/docs/issues/013-laeufe-abarbeitung.md index 99a524c..dcefb08 100644 --- a/docs/issues/013-laeufe-abarbeitung.md +++ b/docs/issues/013-laeufe-abarbeitung.md @@ -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/` diff --git a/docs/issues/014-qr-tags-geraeteseite.md b/docs/issues/014-qr-tags-geraeteseite.md index a5aaeb4..2fed549 100644 --- a/docs/issues/014-qr-tags-geraeteseite.md +++ b/docs/issues/014-qr-tags-geraeteseite.md @@ -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 diff --git a/docs/issues/016-verzeichnis-ldap.md b/docs/issues/016-verzeichnis-ldap.md index 307e5ee..9817d58 100644 --- a/docs/issues/016-verzeichnis-ldap.md +++ b/docs/issues/016-verzeichnis-ldap.md @@ -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 diff --git a/docs/issues/017-seed-sieben-vorlagen.md b/docs/issues/017-seed-sieben-vorlagen.md index 6ee3ce5..113b963 100644 --- a/docs/issues/017-seed-sieben-vorlagen.md +++ b/docs/issues/017-seed-sieben-vorlagen.md @@ -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 diff --git a/docs/issues/018-e2e-anleitung-pdf.md b/docs/issues/018-e2e-anleitung-pdf.md index 6a9f0a4..e8a878f 100644 --- a/docs/issues/018-e2e-anleitung-pdf.md +++ b/docs/issues/018-e2e-anleitung-pdf.md @@ -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/` diff --git a/docs/issues/README.md b/docs/issues/README.md index b603ce7..8ed8400 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -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** | diff --git a/docs/vorlagen-format.md b/docs/vorlagen-format.md new file mode 100644 index 0000000..4d794dc --- /dev/null +++ b/docs/vorlagen-format.md @@ -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. diff --git a/eslint.config.mjs b/eslint.config.mjs index 5523f00..662c31a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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/**", diff --git a/prisma/migrations/20260925054517_vorlagen_unveraenderlich/migration.sql b/prisma/migrations/20260925054517_vorlagen_unveraenderlich/migration.sql new file mode 100644 index 0000000..dabe09c --- /dev/null +++ b/prisma/migrations/20260925054517_vorlagen_unveraenderlich/migration.sql @@ -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(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3441e9a..9c2fc83 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/lib/validierung.ts b/src/lib/validierung.ts new file mode 100644 index 0000000..b743edc --- /dev/null +++ b/src/lib/validierung.ts @@ -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"; diff --git a/src/lib/vorlagen-format.ts b/src/lib/vorlagen-format.ts new file mode 100644 index 0000000..d6c16f7 --- /dev/null +++ b/src/lib/vorlagen-format.ts @@ -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(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; + +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 ``, 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; + +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; + +/** + * 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; +export type StrukturEingabe = z.input; + +/** 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; + +/** + * 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(); + 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(); + 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]; +} diff --git a/src/server/actions/anmeldung.ts b/src/server/actions/anmeldung.ts index fa9d490..7004d41 100644 --- a/src/server/actions/anmeldung.ts +++ b/src/server/actions/anmeldung.ts @@ -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"; diff --git a/src/server/actions/benutzer.ts b/src/server/actions/benutzer.ts index 5b47d83..a47b700 100644 --- a/src/server/actions/benutzer.ts +++ b/src/server/actions/benutzer.ts @@ -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, diff --git a/src/server/actions/einstellungen.ts b/src/server/actions/einstellungen.ts index 3440ff4..2be1c27 100644 --- a/src/server/actions/einstellungen.ts +++ b/src/server/actions/einstellungen.ts @@ -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, diff --git a/src/server/actions/kunden.ts b/src/server/actions/kunden.ts index aeb376a..1333c1e 100644 --- a/src/server/actions/kunden.ts +++ b/src/server/actions/kunden.ts @@ -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"; diff --git a/src/server/actions/vorlagen.ts b/src/server/actions/vorlagen.ts new file mode 100644 index 0000000..890a3f5 --- /dev/null +++ b/src/server/actions/vorlagen.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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."); + } +} diff --git a/src/server/queries/vorlagen.ts b/src/server/queries/vorlagen.ts new file mode 100644 index 0000000..7adb0da --- /dev/null +++ b/src/server/queries/vorlagen.ts @@ -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 { + 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(); + const schlagworte = new Set(); + 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 { + 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 { + 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, + ...(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), + }; +} diff --git a/src/server/services/einstellungen.ts b/src/server/services/einstellungen.ts index 5e7b585..d983561 100644 --- a/src/server/services/einstellungen.ts +++ b/src/server/services/einstellungen.ts @@ -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.", ], diff --git a/src/server/services/vorlagen-austausch.ts b/src/server/services/vorlagen-austausch.ts new file mode 100644 index 0000000..c5897d1 --- /dev/null +++ b/src/server/services/vorlagen-austausch.ts @@ -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 { + 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(); + 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); + } +} diff --git a/src/server/services/vorlagen.ts b/src/server/services/vorlagen.ts new file mode 100644 index 0000000..5ce0c89 --- /dev/null +++ b/src/server/services/vorlagen.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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.`); + } +} diff --git a/tests/datenbankregeln.test.ts b/tests/datenbankregeln.test.ts new file mode 100644 index 0000000..f98ca24 --- /dev/null +++ b/tests/datenbankregeln.test.ts @@ -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 { + 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); + }); +}); diff --git a/tests/datenmodell.test.ts b/tests/datenmodell.test.ts index c7bdb48..3ba1657 100644 --- a/tests/datenmodell.test.ts +++ b/tests/datenmodell.test.ts @@ -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, diff --git a/tests/validierung.test.ts b/tests/validierung.test.ts new file mode 100644 index 0000000..5ef0592 --- /dev/null +++ b/tests/validierung.test.ts @@ -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/); + }); +}); diff --git a/tests/vorlagen-austausch.test.ts b/tests/vorlagen-austausch.test.ts new file mode 100644 index 0000000..54ce389 --- /dev/null +++ b/tests/vorlagen-austausch.test.ts @@ -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 { + 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 = {}): Record { + 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 { + const kopie: Omit & { 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,", + "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 { + 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); + }); + }); +}); diff --git a/tests/vorlagen-sicht.test.ts b/tests/vorlagen-sicht.test.ts new file mode 100644 index 0000000..43f0108 --- /dev/null +++ b/tests/vorlagen-sicht.test.ts @@ -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); + }); + }); +}); diff --git a/tests/vorlagen.test.ts b/tests/vorlagen.test.ts new file mode 100644 index 0000000..7344a9e --- /dev/null +++ b/tests/vorlagen.test.ts @@ -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/); + }); + }); +}); -- 2.54.0