feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch (#23)
This commit was merged in pull request #23.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -140,6 +140,7 @@ Standardwerten zu starten.
|
||||
|
||||
- Anleitung für Techniker und Admins — entsteht mit #018, inkl. Bildern und PDF
|
||||
- [Architektur und Entscheidungen](docs/architektur.md)
|
||||
- [Austauschformat für Vorlagen](docs/vorlagen-format.md) — wie eine Vorlage als JSON aussieht
|
||||
- [Anbindung an authentik](docs/betrieb/authentik.md) — Inbetriebnahme, Proxmox und Reverse Proxy folgen mit #019
|
||||
- [Konventionen](CLAUDE.md) — Sprache, Schichtung, feste Regeln
|
||||
- [Änderungen](CHANGELOG.md) · [Entwicklungstagebuch](docs/devlog.md)
|
||||
|
||||
+6
-6
@@ -33,7 +33,7 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw
|
||||
|
||||
## Vorlagen
|
||||
|
||||
- [ ] Veröffentlichte Version ist unveränderlich; „Bearbeiten" erzeugt eine tiefe Kopie als Entwurf
|
||||
- [ ] Veröffentlichte Version ist unveränderlich; „Bearbeiten“ erzeugt eine tiefe Kopie als Entwurf
|
||||
- [ ] Höchstens ein Entwurf je Vorlage
|
||||
- [ ] Veröffentlichen einer neuen Version verändert bestehende Läufe nicht
|
||||
- [ ] Export und anschließender Import ergeben eine inhaltsgleiche Vorlage
|
||||
@@ -44,7 +44,7 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw
|
||||
- [ ] Lauf ohne Mitarbeiter und ohne Gerät lässt sich starten, abschließen und archivieren
|
||||
- [ ] Abschließen scheitert, solange ein Pflichtschritt offen ist
|
||||
- [ ] `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz
|
||||
- [ ] „Abschnitt als nicht zutreffend markieren" setzt alle Schritte mit einer Notiz
|
||||
- [ ] „Abschnitt als nicht zutreffend markieren“ setzt alle Schritte mit einer Notiz
|
||||
- [ ] Archiv-Schreibschutz greift für Kopfdaten, Schrittergebnisse und Kommentare
|
||||
- [ ] Nur `ADMIN` holt einen archivierten Lauf zurück, mit `Protokoll`-Eintrag
|
||||
- [ ] Im Archiv wird über Titel, Notizen, Werte und Kommentare gefunden
|
||||
@@ -55,7 +55,7 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw
|
||||
- [ ] Mitarbeitersuche liefert standardmäßig nur aktive, Umschalter zeigt auch deaktivierte
|
||||
- [ ] Ein von der Geräteseite gestarteter Lauf hat den Besitzer vorbelegt
|
||||
- [ ] `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login auf die Geräteseite
|
||||
- [ ] „Vorlage zuweisen" erzeugt einen `OFFEN`-Lauf, der unter „Zugewiesen" erscheint; „Starten" setzt `IN_ARBEIT`
|
||||
- [ ] „Vorlage zuweisen“ erzeugt einen `OFFEN`-Lauf, der unter „Zugewiesen“ erscheint; „Starten“ setzt `IN_ARBEIT`
|
||||
- [ ] Unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis
|
||||
- [ ] `tagCode`: richtiges Alphabet, eindeutig, unveränderlich, nie wiederverwendet
|
||||
- [ ] Etikettendruck in beiden Layouts sauber; QR maschinell zurücklesbar
|
||||
@@ -63,12 +63,12 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw
|
||||
## Platzhalter und Befehle
|
||||
|
||||
- [ ] Onboarding: Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen
|
||||
- [ ] Abteilung „Buchhaltung" wählen → `-Path "OU=Buchhaltung,…"` und Gruppen `G-Buchhaltung, G-Alle`
|
||||
- [ ] Abteilung „Buchhaltung“ wählen → `-Path "OU=Buchhaltung,…"` und Gruppen `G-Buchhaltung, G-Alle`
|
||||
- [ ] Kopieren liefert exakt diesen Text, zeichengenau
|
||||
- [ ] „Jörg Müller-Lüdenscheid" ergibt gültige Vorschläge (`joerg.mueller-luedenscheid@…`, `jmuellerluedensch`)
|
||||
- [ ] „Jörg Müller-Lüdenscheid“ ergibt gültige Vorschläge (`joerg.mueller-luedenscheid@…`, `jmuellerluedensch`)
|
||||
- [ ] `{{generiertes_passwort}}` erscheint im Befehl, steht aber nicht in Datenbank, Druckansicht oder `Protokoll`
|
||||
- [ ] Werte mit Anführungszeichen werden für die jeweilige Shell korrekt maskiert
|
||||
- [ ] „Alle Befehle als Skript kopieren" liefert die Schritte in richtiger Reihenfolge
|
||||
- [ ] „Alle Befehle als Skript kopieren“ liefert die Schritte in richtiger Reihenfolge
|
||||
- [ ] Unbekannte Platzhalter bleiben stehen; ersetzte Werte sind HTML-escaped
|
||||
|
||||
## Verzeichnis (LDAP)
|
||||
|
||||
+9
-2
@@ -42,7 +42,7 @@ Der Auftrag nannte Auth.js. Beim Prüfen der Lage am 2026-09-24:
|
||||
|
||||
- `next-auth` v5 steht auf `5.0.0-beta.32`, seit Jahren ohne GA
|
||||
- authjs.dev führt das Projekt im Wartungsmodus („The Auth.js project is now part of Better
|
||||
Auth"), die Maintainer schreiben „no immediate plans for v5"
|
||||
Auth"), die Maintainer schreiben „no immediate plans for v5“
|
||||
- `@auth/prisma-adapter` deklariert `@prisma/client` nur bis `>=6` — wir fahren Prisma 7
|
||||
|
||||
Entschieden wurde deshalb der Eigenbau mit stabilen Bausteinen, wie in ProxLedger:
|
||||
@@ -59,7 +59,7 @@ Cookie abgewiesen.
|
||||
|
||||
Zwei Regeln tragen die Nachvollziehbarkeit:
|
||||
|
||||
1. **Eine veröffentlichte `VorlagenVersion` wird nie verändert.** „Bearbeiten" erzeugt eine tiefe
|
||||
1. **Eine veröffentlichte `VorlagenVersion` wird nie verändert.** „Bearbeiten“ erzeugt eine tiefe
|
||||
Kopie als Entwurf. Ein Lauf auf Version 1 zeigt für immer die Schritte aus Version 1, auch
|
||||
wenn längst Version 5 veröffentlicht ist.
|
||||
2. **Ein archivierter Lauf ist schreibgeschützt.** Die Sperre sitzt in genau einer Funktion,
|
||||
@@ -68,6 +68,13 @@ Zwei Regeln tragen die Nachvollziehbarkeit:
|
||||
|
||||
Beides ist durch Tests abgedeckt, nicht durch Disziplin.
|
||||
|
||||
Der Aufbau einer Version wird **als Ganzes** gespeichert, nicht Schritt für Schritt: der Editor
|
||||
hält den Baum im Speicher und sortiert per Drag & Drop, ein Teil-Update wäre dort ständig mit dem
|
||||
Sortieren im Streit. Dasselbe Zod-Schema (`src/lib/vorlagen-format.ts`) beschreibt den Entwurf im
|
||||
Editor, den JSON-Export und den Import — drei getrennte Schemata würden auseinanderlaufen, und der
|
||||
Import wäre der Ort, an dem das erst auffiele. Das Format ist in
|
||||
[`docs/vorlagen-format.md`](vorlagen-format.md) beschrieben.
|
||||
|
||||
## 5. Der Weg zu Phase 4 (Aktionen)
|
||||
|
||||
Die in den Vorlagen vorbereiteten PowerShell-Befehle sind bewusst die **Vorstufe ausführbarer
|
||||
|
||||
+72
-4
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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“.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -11,7 +11,7 @@ Der Editor, in dem ein Redakteur eine Vorlage baut, ohne den Kopf über Versione
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Vorlagenliste mit Suche, Filter nach Kategorie und Tag, Kennzeichnung „Entwurf vorhanden"
|
||||
- [ ] Vorlagenliste mit Suche, Filter nach Kategorie und Tag, Kennzeichnung „Entwurf vorhanden“
|
||||
- [ ] Editor: Abschnitte und Schritte anlegen, bearbeiten, löschen; Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`)
|
||||
- [ ] Sortieren per Drag & Drop mit `@dnd-kit`, auch am Handy mit dem Finger bedienbar
|
||||
- [ ] Je Schritt: Titel, Typ (`HAKEN`, `TEXT`, `ZAHL`, `AUSWAHL`, `DATUM`, `BEFEHL`), Pflicht (roter Stern), Hilfetext als Markdown, Doku-Link
|
||||
@@ -25,7 +25,7 @@ Der Editor, in dem ein Redakteur eine Vorlage baut, ohne den Kopf über Versione
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Eine Vorlage lässt sich vollständig ohne Umweg über die Datenbank anlegen, sortieren, veröffentlichen
|
||||
- [ ] Eine veröffentlichte Version ist im Editor nur lesend; „Bearbeiten" erzeugt sichtbar einen Entwurf
|
||||
- [ ] Eine veröffentlichte Version ist im Editor nur lesend; „Bearbeiten“ erzeugt sichtbar einen Entwurf
|
||||
- [ ] Unbekannte Platzhalter werden als Warnung angezeigt, blockieren aber das Speichern nicht
|
||||
- [ ] Sortieren funktioniert am Handy bei 390 px Breite ohne horizontales Scrollen
|
||||
- [ ] Der Markdown-Hilfetext rendert ohne rohes HTML
|
||||
|
||||
@@ -12,12 +12,12 @@ Die Stammdaten, an denen die Historie hängt. Mitarbeiter und Geräte werden nie
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Mitarbeiterliste: Suche über Anzeigename, Mail, UPN und `sam`, Filter nach Abteilung und Aktiv-Status
|
||||
- [ ] Mitarbeitersuche als wiederverwendbare Komponente: standardmäßig nur aktive, Umschalter „auch deaktivierte" fürs Offboarding
|
||||
- [ ] Mitarbeitersuche als wiederverwendbare Komponente: standardmäßig nur aktive, Umschalter „auch deaktivierte“ fürs Offboarding
|
||||
- [ ] Mitarbeiterseite: Stammdaten, Abteilung, Vorgesetzter, seine Geräte, alle Läufe chronologisch (Onboarding, Übergaben, Offboarding)
|
||||
- [ ] Mitarbeiter manuell anlegen und bearbeiten (`herkunft = MANUELL`); synchronisierte Felder sind bei `herkunft = ABGLEICH` gesperrt
|
||||
- [ ] Deaktivieren statt löschen (`aktiv = false`)
|
||||
- [ ] Abteilungen: Name (eindeutig je Kunde), Ziel-OU, Standardgruppen (`String[]`), Mail-Domain, Notizen
|
||||
- [ ] Geräte: Stammdaten nach Vorgabe, Pflichtregel „`hostname` oder `bezeichnung` muss gesetzt sein", Besitzer optional
|
||||
- [ ] Geräte: Stammdaten nach Vorgabe, Pflichtregel „`hostname` oder `bezeichnung` muss gesetzt sein“, Besitzer optional
|
||||
- [ ] Geräteliste mit Suche über Hostname, Bezeichnung, Seriennummer und `tagCode`, Filter nach Typ
|
||||
- [ ] CSV-Import für Geräte (`hostname`, `label`, `serialNumber`, `inventoryNumber`, `type`, `model`), `tagCode` wird beim Import erzeugt; eigener `src/lib/csv.ts`
|
||||
- [ ] Archivieren statt Löschen, sobald Läufe an einem Gerät oder einer Abteilung hängen
|
||||
|
||||
@@ -17,16 +17,16 @@ Reine, framework-freie Service-Schicht mit dichter Testabdeckung. Hier entstehen
|
||||
- [ ] Aus der gewählten Abteilung: `{{ou}}` falls nicht vom Mitarbeiter, `{{gruppen}}` kommagetrennt
|
||||
- [ ] `{{ou}}` eines synchronisierten Mitarbeiters ist der Eltern-Container seines `dn` (alles nach dem ersten Komma)
|
||||
- [ ] Dazu die in der Vorlage definierten Variablen; unbekannte Platzhalter bleiben sichtbar stehen
|
||||
- [ ] `{{generiertes_passwort}}`: 20 Zeichen aus Groß, Klein, Ziffern und Sonderzeichen ohne verwechselbare Zeichen, je Anzeige neu erzeugt, **nie** gespeichert; Hinweis „Passwort sofort in den Passwortmanager übernehmen"; in der Druckansicht `[Passwort – nicht gespeichert]`
|
||||
- [ ] `{{generiertes_passwort}}`: 20 Zeichen aus Groß, Klein, Ziffern und Sonderzeichen ohne verwechselbare Zeichen, je Anzeige neu erzeugt, **nie** gespeichert; Hinweis „Passwort sofort in den Passwortmanager übernehmen“; in der Druckansicht `[Passwort – nicht gespeichert]`
|
||||
- [ ] Namensregeln (`Einstellung.namensregeln` global, im Modus `DIENSTLEISTUNG` zusätzlich `Kunde.namensregeln` mit Fallback): `upn`, `sam` (höchstens 20 Zeichen), `mail`, `hostname`
|
||||
- [ ] Transliteration (ä→ae, ö→oe, ü→ue, ß→ss, Akzente entfernt), Kleinschreibung, Leerzeichen entfernt, `{nnn}`-Zähler, `{v}` für den ersten Buchstaben des Vornamens, `{kundenkuerzel}`
|
||||
- [ ] Shell-Escaping je Shell: PowerShell (doppelte Anführungszeichen verdoppeln, Backtick maskieren), Bash, Cmd
|
||||
- [ ] HTML-Escaping ersetzter Werte; Markdown im Hilfetext sicher gerendert, kein rohes HTML
|
||||
- [ ] „Alle Befehle als Skript kopieren" je Abschnitt: alle `BEFEHL`-Schritte in Reihenfolge, getrennt durch Leerzeilen, je mit `# Schritt: <Titel>` davor, Zeilenumbruch passend zur Shell
|
||||
- [ ] „Alle Befehle als Skript kopieren“ je Abschnitt: alle `BEFEHL`-Schritte in Reihenfolge, getrennt durch Leerzeilen, je mit `# Schritt: <Titel>` davor, Zeilenumbruch passend zur Shell
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: „Jörg Müller-Lüdenscheid" ergibt `joerg.mueller-luedenscheid@firma.de` und `jmuellerluedensch` (auf 20 Zeichen gekürzt)
|
||||
- [ ] Test: „Jörg Müller-Lüdenscheid“ ergibt `joerg.mueller-luedenscheid@firma.de` und `jmuellerluedensch` (auf 20 Zeichen gekürzt)
|
||||
- [ ] Test: `{{generiertes_passwort}}` erscheint im angezeigten Befehl, steht aber nicht in `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht; zwei Aufrufe liefern verschiedene Passwörter
|
||||
- [ ] Test: ein Wert mit `"` und `` ` `` wird für PowerShell korrekt maskiert und bricht den Befehl nicht
|
||||
- [ ] Test: unbekannte Platzhalter bleiben als `{{key}}` stehen
|
||||
|
||||
@@ -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/`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,11 +16,11 @@ Die Mitarbeiter der eigenen Firma kommen aus dem Active Directory, damit beim St
|
||||
- [ ] `explicitBufferAttributes: ['objectGUID']` und korrekte Umsortierung der gemischt-endianen Bytes zur GUID-Zeichenkette als `externeId`
|
||||
- [ ] Standardfilter `(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))`
|
||||
- [ ] Attribut-Mapping mit AD-Vorgaben: `givenName`, `sn`, `displayName`, `mail`, `userPrincipalName` → `upn`, `sAMAccountName` → `sam`, `department`, `title`, `manager` → `vorgesetzterExterneId`, `distinguishedName` → `dn`
|
||||
- [ ] „Verbindung testen": zeigt die ersten 10 Treffer mit gemappten Feldern, ohne etwas zu schreiben
|
||||
- [ ] „Verbindung testen“: zeigt die ersten 10 Treffer mit gemappten Feldern, ohne etwas zu schreiben
|
||||
- [ ] `abgleichService`: anlegen, aktualisieren, verschwundene oder deaktivierte Konten auf `aktiv = false` — **nie löschen**; Ergebnis (angelegt/aktualisiert/deaktiviert/Fehler) in `letzterAbgleichStatus`, `letzterAbgleichFehler`, `letzterAbgleichAm` und im `Protokoll`
|
||||
- [ ] Zeitplan mit `node-cron` im selben Prozess, Intervall je Quelle (Standard 60 min), Sperre über `AbgleichSperre` in der Datenbank, damit nur eine Instanz läuft; zusätzlich „Jetzt abgleichen"
|
||||
- [ ] Zeitplan mit `node-cron` im selben Prozess, Intervall je Quelle (Standard 60 min), Sperre über `AbgleichSperre` in der Datenbank, damit nur eine Instanz läuft; zusätzlich „Jetzt abgleichen“
|
||||
- [ ] Bind-Passwort verschlüsselt über `src/lib/geheimnisse.ts`, Serializer entfernt `bindPasswortVerschluesselt` aus jeder Antwort
|
||||
- [ ] Admin → Verzeichnis: Quelle anlegen und bearbeiten, Verbindung testen, Abgleich-Status und -Log, „Jetzt abgleichen"
|
||||
- [ ] Admin → Verzeichnis: Quelle anlegen und bearbeiten, Verbindung testen, Abgleich-Status und -Log, „Jetzt abgleichen“
|
||||
- [ ] Manuelle Mitarbeiter und CSV-Import (`givenName`, `sn`, `displayName`, `mail`, `upn`, `sam`, `department`, `title`)
|
||||
- [ ] Abteilungsvorschläge aus den `department`-Werten des Abgleichs, vom Admin in `Abteilung`-Einträge übernehmbar
|
||||
- [ ] Nur die gemappten Attribute speichern: keine Passwörter, keine Gruppenmitgliedschaften des Mitarbeiters
|
||||
|
||||
@@ -11,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
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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** |
|
||||
|
||||
@@ -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.
|
||||
@@ -7,6 +7,30 @@ export default defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
prettier,
|
||||
{
|
||||
rules: {
|
||||
// Zod wird in src/lib/validierung.ts auf deutsche Meldungen eingestellt.
|
||||
// Ein direkter Import aus "zod" umginge das, und die englischen
|
||||
// Standardsaetze landeten in der Oberflaeche.
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "zod",
|
||||
message:
|
||||
'Bitte `import { z } from "@/lib/validierung"` benutzen – dort sind die deutschen Meldungen eingestellt.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Die Datei, die Zod einstellt, muss es selbst importieren duerfen.
|
||||
files: ["src/lib/validierung.ts"],
|
||||
rules: { "no-restricted-imports": "off" },
|
||||
},
|
||||
globalIgnores([
|
||||
".next/**",
|
||||
"out/**",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- HANDGESCHRIEBEN. Prisma hatte hier zusaetzlich sieben Loeschbefehle erzeugt:
|
||||
-- vier zusammengesetzte Fremdschluessel (Mandantengrenze) und drei
|
||||
-- Trigramm-Indizes (Archivsuche) aus der ersten Migration. Prisma kennt sie
|
||||
-- nicht, weil sie sich im Schema nicht ausdruecken lassen, und will sie deshalb
|
||||
-- bei jeder Migration entfernen. Die Loeschbefehle sind bewusst gestrichen.
|
||||
--
|
||||
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Stand des Entwurfs, gegen den der Editor beim Speichern prueft.
|
||||
ALTER TABLE "VorlagenVersion"
|
||||
ADD COLUMN "geaendertAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Eine veroeffentlichte Version ist unveraenderlich.
|
||||
--
|
||||
-- Diese Regel traegt die Nachvollziehbarkeit des ganzen Archivs: ein Lauf zeigt
|
||||
-- auch in zehn Jahren genau die Schritte, die damals abgearbeitet wurden.
|
||||
-- Bisher stand sie nur in der Service-Schicht — Migrationen, psql, Skripte und
|
||||
-- ab Phase 4 die Connectoren schreiben daran vorbei. Deshalb steht sie jetzt
|
||||
-- auch hier.
|
||||
--
|
||||
-- TRUNCATE loest Zeilen-Trigger nicht aus; das Leeren der Testdatenbank bleibt
|
||||
-- also moeglich.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION laufzettel_version_unveraenderlich() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
betroffene_version TEXT;
|
||||
aktueller_status TEXT;
|
||||
BEGIN
|
||||
betroffene_version := COALESCE(NEW."versionId", OLD."versionId");
|
||||
SELECT status::TEXT INTO aktueller_status
|
||||
FROM "VorlagenVersion" WHERE id = betroffene_version;
|
||||
|
||||
IF aktueller_status = 'VEROEFFENTLICHT' THEN
|
||||
RAISE EXCEPTION
|
||||
'Version % ist veroeffentlicht und unveraenderlich (%).',
|
||||
betroffene_version, TG_TABLE_NAME
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "VorlagenAbschnitt_unveraenderlich"
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "VorlagenAbschnitt"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_version_unveraenderlich();
|
||||
|
||||
CREATE TRIGGER "VorlagenVariable_unveraenderlich"
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "VorlagenVariable"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_version_unveraenderlich();
|
||||
|
||||
-- Schritte haengen ueber den Abschnitt an der Version, brauchen also den Umweg.
|
||||
CREATE OR REPLACE FUNCTION laufzettel_schritt_unveraenderlich() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
aktueller_status TEXT;
|
||||
BEGIN
|
||||
SELECT v.status::TEXT INTO aktueller_status
|
||||
FROM "VorlagenAbschnitt" a
|
||||
JOIN "VorlagenVersion" v ON v.id = a."versionId"
|
||||
WHERE a.id = COALESCE(NEW."abschnittId", OLD."abschnittId");
|
||||
|
||||
IF aktueller_status = 'VEROEFFENTLICHT' THEN
|
||||
RAISE EXCEPTION
|
||||
'Der Abschnitt gehoert zu einer veroeffentlichten Version und ist unveraenderlich.'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "VorlagenSchritt_unveraenderlich"
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "VorlagenSchritt"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_schritt_unveraenderlich();
|
||||
|
||||
-- Der Weg zurueck von VEROEFFENTLICHT nach ENTWURF waere die Hintertuer, durch
|
||||
-- die sich alles Obige aushebeln liesse.
|
||||
CREATE OR REPLACE FUNCTION laufzettel_version_status_einbahn() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF OLD.status = 'VEROEFFENTLICHT' AND NEW.status <> 'VEROEFFENTLICHT' THEN
|
||||
RAISE EXCEPTION
|
||||
'Eine veroeffentlichte Version laesst sich nicht wieder zum Entwurf machen.'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "VorlagenVersion_status_einbahn"
|
||||
BEFORE UPDATE ON "VorlagenVersion"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_version_status_einbahn();
|
||||
+17
-10
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,203 @@
|
||||
import { z } from "@/lib/validierung";
|
||||
|
||||
/**
|
||||
* Aufbau einer Vorlagen-Version (#009).
|
||||
*
|
||||
* Ein Schema fuer drei Zwecke: der Editor schickt den Entwurf in dieser Form,
|
||||
* der JSON-Export schreibt sie, der Import liest sie. Gaebe es drei Schemata,
|
||||
* liefen sie frueher oder spaeter auseinander — und der Import waere der Ort, an
|
||||
* dem das erst auffiele.
|
||||
*
|
||||
* Framework-frei und ohne Datenbank, damit es sich ohne Next testen laesst.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ein leeres Formularfeld kommt als `""` an, nicht als `undefined`.
|
||||
*
|
||||
* Ohne diese Vorstufe scheiterte ein nicht ausgefuelltes optionales Feld an
|
||||
* seiner eigenen Pruefung — beim Doku-Link etwa mit „keine gültige Adresse“,
|
||||
* obwohl gar keine angegeben werden sollte.
|
||||
*/
|
||||
function leerAlsUndefiniert<T extends z.ZodType>(schema: T) {
|
||||
return z.preprocess(
|
||||
(wert) => (typeof wert === "string" && wert.trim() === "" ? undefined : wert),
|
||||
schema.optional(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Die Schritt-Typen des MVP. `AKTION` kommt in Phase 4 dazu. */
|
||||
export const SCHRITT_TYPEN = ["HAKEN", "TEXT", "ZAHL", "AUSWAHL", "DATUM", "BEFEHL"] as const;
|
||||
|
||||
export const VARIABLEN_TYPEN = ["TEXT", "ZAHL", "AUSWAHL", "DATUM"] as const;
|
||||
|
||||
export const SHELLS = ["powershell", "bash", "cmd"] as const;
|
||||
|
||||
/**
|
||||
* Obergrenzen fuer ein Austauschdokument.
|
||||
*
|
||||
* Ohne sie nimmt der Import beliebig grosse Listen an, laeuft in die
|
||||
* Transaktions-Zeitgrenze von Postgres und meldet einen Fehler, der nichts ueber
|
||||
* die Ursache sagt. Die Werte sind grosszuegig gewaehlt: die groesste Vorlage im
|
||||
* Seed hat sieben Abschnitte und rund dreissig Schritte.
|
||||
*/
|
||||
export const GRENZEN = {
|
||||
abschnitte: 200,
|
||||
schritteJeAbschnitt: 500,
|
||||
variablen: 200,
|
||||
text: 20_000,
|
||||
optionen: 200,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Typabhaengige Einzelheiten eines Schritts.
|
||||
*
|
||||
* Bewusst nachsichtig: unbekannte Schluessel bleiben erhalten, damit ein
|
||||
* Export aus einer spaeteren Fassung hier nicht stillschweigend Angaben
|
||||
* verliert. Was die Anwendung kennt, wird geprueft.
|
||||
*/
|
||||
export const SchrittKonfigSchema = z.looseObject({
|
||||
/** AUSWAHL: die Optionen in der angezeigten Reihenfolge. */
|
||||
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).optional(),
|
||||
/** ZAHL: Grenzen und Einheit. Ein Wert ausserhalb wird markiert, nicht blockiert. */
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
einheit: z.string().trim().max(40).optional(),
|
||||
/** TEXT: mehrzeilig statt einzeilig. */
|
||||
mehrzeilig: z.boolean().optional(),
|
||||
/** BEFEHL: Shell, Mehrzeiligkeit und der Befehlstext mit Platzhaltern. */
|
||||
shell: z.enum(SHELLS).optional(),
|
||||
befehl: z.string().max(GRENZEN.text).optional(),
|
||||
});
|
||||
|
||||
export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>;
|
||||
|
||||
export const SchrittSchema = z.object({
|
||||
titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text),
|
||||
/** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */
|
||||
hilfetext: leerAlsUndefiniert(z.string().trim().max(GRENZEN.text)),
|
||||
typ: z.enum(SCHRITT_TYPEN).default("HAKEN"),
|
||||
pflicht: z.boolean().default(false),
|
||||
konfig: SchrittKonfigSchema.default({}),
|
||||
/**
|
||||
* Nur http und https. `z.url()` allein liesse `javascript:` und `data:` durch —
|
||||
* und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein
|
||||
* gespeicherter Angriff, eingeschleust ueber eine importierte Datei.
|
||||
*/
|
||||
dokuUrl: leerAlsUndefiniert(
|
||||
z.url({
|
||||
protocol: /^https?$/,
|
||||
message: "Der Doku-Link muss eine http- oder https-Adresse sein.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type SchrittEingabe = z.input<typeof SchrittSchema>;
|
||||
|
||||
export const AbschnittSchema = z.object({
|
||||
titel: z.string().trim().min(1, "Ein Abschnitt braucht einen Titel.").max(GRENZEN.text),
|
||||
schritte: z
|
||||
.array(SchrittSchema)
|
||||
.max(
|
||||
GRENZEN.schritteJeAbschnitt,
|
||||
`Ein Abschnitt fasst höchstens ${GRENZEN.schritteJeAbschnitt} Schritte.`,
|
||||
)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export const VariableSchema = z.object({
|
||||
/**
|
||||
* Der Schluessel steht als `{{schluessel}}` in Titeln, Hilfetexten und
|
||||
* Befehlen. Deshalb nur Kleinbuchstaben, Ziffern und Unterstrich — alles
|
||||
* andere liesse sich im Platzhalter nicht sauber wiedererkennen.
|
||||
*/
|
||||
schluessel: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(
|
||||
/^[a-z][a-z0-9_]{0,39}$/,
|
||||
"Ein Variablenschlüssel beginnt mit einem Kleinbuchstaben und besteht aus Kleinbuchstaben, Ziffern und Unterstrich.",
|
||||
),
|
||||
beschriftung: z.string().trim().min(1, "Eine Variable braucht eine Beschriftung.").max(GRENZEN.text),
|
||||
typ: z.enum(VARIABLEN_TYPEN).default("TEXT"),
|
||||
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).default([]),
|
||||
pflicht: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type VariableEingabe = z.input<typeof VariableSchema>;
|
||||
|
||||
/**
|
||||
* Der gesamte Aufbau einer Version. Die Reihenfolge steckt in der Reihenfolge
|
||||
* der Listen; `position` wird daraus abgeleitet und nicht mitgeschickt — sonst
|
||||
* gaebe es zwei Wahrheiten, die auseinanderlaufen koennen.
|
||||
*/
|
||||
export const StrukturSchema = z.object({
|
||||
variablen: z
|
||||
.array(VariableSchema)
|
||||
.max(GRENZEN.variablen, `Eine Vorlage fasst höchstens ${GRENZEN.variablen} Variablen.`)
|
||||
.default([]),
|
||||
abschnitte: z
|
||||
.array(AbschnittSchema)
|
||||
.max(GRENZEN.abschnitte, `Eine Vorlage fasst höchstens ${GRENZEN.abschnitte} Abschnitte.`)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export type Struktur = z.infer<typeof StrukturSchema>;
|
||||
export type StrukturEingabe = z.input<typeof StrukturSchema>;
|
||||
|
||||
/** Fassung des Austauschformats. Wird beim Import geprueft. */
|
||||
export const FORMAT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Was eine exportierte Datei enthaelt.
|
||||
*
|
||||
* Bewusst ohne Kennungen, Zeitstempel und Kundenbezug: eine exportierte Vorlage
|
||||
* soll sich in eine andere Laufzettel-Installation einspielen lassen, ohne
|
||||
* Verweise mitzubringen, die dort nichts bedeuten.
|
||||
*/
|
||||
export const AustauschSchema = z.object({
|
||||
format: z.literal(FORMAT_VERSION, {
|
||||
message: `Unbekanntes Format. Diese Fassung liest Format ${FORMAT_VERSION}.`,
|
||||
}),
|
||||
name: z.string().trim().min(1, "Die Vorlage braucht einen Namen.").max(200),
|
||||
beschreibung: leerAlsUndefiniert(z.string().trim().max(GRENZEN.text)),
|
||||
kategorie: leerAlsUndefiniert(z.string().trim().max(100)),
|
||||
schlagworte: z.array(z.string().trim().min(1).max(100)).max(50).default([]),
|
||||
/** Nur zur Information, welche Version der Quelle exportiert wurde. */
|
||||
version: z.number().int().positive().optional(),
|
||||
struktur: StrukturSchema,
|
||||
});
|
||||
|
||||
export type Austausch = z.infer<typeof AustauschSchema>;
|
||||
|
||||
/**
|
||||
* Alle Platzhalter `{{key}}` aus einem Text.
|
||||
*
|
||||
* Steht hier und nicht in #012, weil der Editor schon beim Bearbeiten warnen
|
||||
* soll, wenn ein Platzhalter zu keiner Variablen und zu keinem eingebauten Wert
|
||||
* gehoert. Das Ersetzen selbst kommt in #012.
|
||||
*/
|
||||
export function platzhalterFinden(text: string): readonly string[] {
|
||||
const gefunden = new Set<string>();
|
||||
for (const treffer of text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)) {
|
||||
if (treffer[1]) gefunden.add(treffer[1]);
|
||||
}
|
||||
return [...gefunden];
|
||||
}
|
||||
|
||||
/** Alle Platzhalter, die irgendwo in einer Struktur vorkommen. */
|
||||
export function platzhalterDerStruktur(struktur: Struktur): readonly string[] {
|
||||
const gefunden = new Set<string>();
|
||||
const sammeln = (text: string | undefined) => {
|
||||
if (text) for (const name of platzhalterFinden(text)) gefunden.add(name);
|
||||
};
|
||||
for (const abschnitt of struktur.abschnitte) {
|
||||
sammeln(abschnitt.titel);
|
||||
for (const schritt of abschnitt.schritte) {
|
||||
sammeln(schritt.titel);
|
||||
sammeln(schritt.hilfetext);
|
||||
const befehl = schritt.konfig["befehl"];
|
||||
if (typeof befehl === "string") sammeln(befehl);
|
||||
}
|
||||
}
|
||||
return [...gefunden];
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
|
||||
import { AnmeldeFehler, lokalAnmelden } from "@/server/auth/benutzer";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { Rolle } from "@/generated/prisma/enums";
|
||||
import {
|
||||
AnmeldeFehler,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import {
|
||||
EinstellungsFehler,
|
||||
|
||||
@@ -1,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";
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { StrukturSchema } from "@/lib/vorlagen-format";
|
||||
import { kundeZugriffPruefen, kundenEbeneSichtbar } from "@/server/mandant";
|
||||
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import {
|
||||
VorlagenFehler,
|
||||
entwurfHolenOderErzeugen,
|
||||
entwurfSpeichern,
|
||||
entwurfVerwerfen,
|
||||
versionVeroeffentlichen,
|
||||
vorlageAnlegen,
|
||||
vorlageArchivieren,
|
||||
vorlageBearbeiten,
|
||||
vorlageDuplizieren,
|
||||
} from "@/server/services/vorlagen";
|
||||
import {
|
||||
AustauschFehler,
|
||||
vorlageExportieren,
|
||||
vorlageImportieren,
|
||||
} from "@/server/services/vorlagen-austausch";
|
||||
|
||||
/**
|
||||
* Server Actions der Vorlagen (#009).
|
||||
*
|
||||
* Jede beginnt mit `rechtErforderlich("vorlagenBearbeiten")` – das haben nur
|
||||
* ADMIN und REDAKTEUR. Techniker starten Laeufe, sie bauen keine Vorlagen.
|
||||
*/
|
||||
|
||||
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
|
||||
|
||||
/** Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine neutrale. */
|
||||
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
|
||||
if (fehler instanceof VorlagenFehler || fehler instanceof AustauschFehler) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
|
||||
return { fehler: neutral };
|
||||
}
|
||||
|
||||
const AnlegenSchema = z.object({
|
||||
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
||||
beschreibung: z.string().trim().optional(),
|
||||
kategorie: z.string().trim().optional(),
|
||||
schlagworte: z.string().trim().optional(),
|
||||
kundeId: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export async function vorlageAnlegenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
|
||||
const eingabe = AnlegenSchema.safeParse({
|
||||
name: formular.get("name"),
|
||||
beschreibung: formular.get("beschreibung") ?? undefined,
|
||||
kategorie: formular.get("kategorie") ?? undefined,
|
||||
schlagworte: formular.get("schlagworte") ?? undefined,
|
||||
kundeId: formular.get("kundeId") ?? undefined,
|
||||
});
|
||||
if (!eingabe.success) {
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
// Eine kundenspezifische Vorlage gibt es nur im Modus Dienstleistung – und nur
|
||||
// fuer einen Kunden, den dieser Benutzer sehen darf.
|
||||
const kundeId = eingabe.data.kundeId || null;
|
||||
if (kundeId) {
|
||||
if (!(await kundenEbeneSichtbar())) {
|
||||
return { fehler: "Kundenspezifische Vorlagen gibt es nur im Modus Dienstleistung." };
|
||||
}
|
||||
await kundeZugriffPruefen(kundeId, benutzer);
|
||||
}
|
||||
|
||||
try {
|
||||
const { vorlage } = await vorlageAnlegen(
|
||||
{
|
||||
name: eingabe.data.name,
|
||||
...(eingabe.data.beschreibung ? { beschreibung: eingabe.data.beschreibung } : {}),
|
||||
...(eingabe.data.kategorie ? { kategorie: eingabe.data.kategorie } : {}),
|
||||
schlagworte: (eingabe.data.schlagworte ?? "")
|
||||
.split(",")
|
||||
.map((wort) => wort.trim())
|
||||
.filter(Boolean),
|
||||
kundeId,
|
||||
},
|
||||
benutzer.id,
|
||||
);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: `„${vorlage.name}“ wurde angelegt.` };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Vorlage konnte nicht angelegt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function entwurfOeffnenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis & { readonly versionId?: string }> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
const vorlageId = z.string().min(1).safeParse(formular.get("vorlageId"));
|
||||
if (!vorlageId.success) return { fehler: "Es fehlt die Vorlage." };
|
||||
|
||||
try {
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlageId.data, benutzer.id);
|
||||
revalidatePath("/vorlagen");
|
||||
return { versionId: entwurf.id };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Der Entwurf konnte nicht geöffnet werden.");
|
||||
}
|
||||
}
|
||||
|
||||
const SpeichernSchema = z.object({
|
||||
versionId: z.string().min(1),
|
||||
struktur: StrukturSchema,
|
||||
/**
|
||||
* Stand, den der Editor beim Laden bekommen hat. Weicht er ab, hat jemand
|
||||
* anderes zwischenzeitlich gespeichert – und dessen Arbeit waere sonst weg.
|
||||
*/
|
||||
stand: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export async function entwurfSpeichernAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis & { readonly stand?: string }> {
|
||||
await rechtErforderlich("vorlagenBearbeiten");
|
||||
|
||||
const roh = formular.get("struktur");
|
||||
let geparst: unknown;
|
||||
try {
|
||||
geparst = typeof roh === "string" ? JSON.parse(roh) : undefined;
|
||||
} catch {
|
||||
return { fehler: "Der Aufbau kam nicht als gültiges JSON an." };
|
||||
}
|
||||
|
||||
const eingabe = SpeichernSchema.safeParse({
|
||||
versionId: formular.get("versionId"),
|
||||
struktur: geparst,
|
||||
stand: formular.get("stand") ?? undefined,
|
||||
});
|
||||
if (!eingabe.success) {
|
||||
const erster = eingabe.error.issues[0];
|
||||
return {
|
||||
fehler: erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { stand } = await entwurfSpeichern(
|
||||
eingabe.data.versionId,
|
||||
eingabe.data.struktur,
|
||||
eingabe.data.stand,
|
||||
);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: "Entwurf gespeichert.", stand: stand.toISOString() };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Der Entwurf konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
const BearbeitenSchema = z.object({
|
||||
vorlageId: z.string().min(1),
|
||||
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
||||
beschreibung: z.string().trim().optional(),
|
||||
kategorie: z.string().trim().optional(),
|
||||
schlagworte: z.string().trim().optional(),
|
||||
kundeId: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export async function vorlageBearbeitenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
|
||||
const eingabe = BearbeitenSchema.safeParse({
|
||||
vorlageId: formular.get("vorlageId"),
|
||||
name: formular.get("name"),
|
||||
beschreibung: formular.get("beschreibung") ?? undefined,
|
||||
kategorie: formular.get("kategorie") ?? undefined,
|
||||
schlagworte: formular.get("schlagworte") ?? undefined,
|
||||
kundeId: formular.get("kundeId") ?? undefined,
|
||||
});
|
||||
if (!eingabe.success) {
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
const kundeId = eingabe.data.kundeId || null;
|
||||
if (kundeId) {
|
||||
if (!(await kundenEbeneSichtbar())) {
|
||||
return { fehler: "Kundenspezifische Vorlagen gibt es nur im Modus Dienstleistung." };
|
||||
}
|
||||
await kundeZugriffPruefen(kundeId, benutzer);
|
||||
}
|
||||
|
||||
try {
|
||||
await vorlageBearbeiten(
|
||||
eingabe.data.vorlageId,
|
||||
{
|
||||
name: eingabe.data.name,
|
||||
beschreibung: eingabe.data.beschreibung ?? null,
|
||||
kategorie: eingabe.data.kategorie ?? null,
|
||||
schlagworte: (eingabe.data.schlagworte ?? "")
|
||||
.split(",")
|
||||
.map((wort) => wort.trim())
|
||||
.filter(Boolean),
|
||||
kundeId,
|
||||
},
|
||||
benutzer.id,
|
||||
);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: "Gespeichert." };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function veroeffentlichenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
const eingabe = z
|
||||
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
|
||||
.safeParse({
|
||||
versionId: formular.get("versionId"),
|
||||
aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined,
|
||||
});
|
||||
if (!eingabe.success) return { fehler: "Es fehlt die Version." };
|
||||
|
||||
try {
|
||||
const version = await versionVeroeffentlichen(
|
||||
eingabe.data.versionId,
|
||||
eingabe.data.aenderungsnotiz ?? null,
|
||||
benutzer.id,
|
||||
);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: `Version ${version.version} ist veröffentlicht.` };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Version konnte nicht veröffentlicht werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function entwurfVerwerfenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
const versionId = z.string().min(1).safeParse(formular.get("versionId"));
|
||||
if (!versionId.success) return { fehler: "Es fehlt die Version." };
|
||||
|
||||
try {
|
||||
await entwurfVerwerfen(versionId.data, benutzer.id);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: "Entwurf verworfen." };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Der Entwurf konnte nicht verworfen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function vorlageDuplizierenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
const eingabe = z
|
||||
.object({ vorlageId: z.string().min(1), name: z.string().trim().min(1, "Bitte einen Namen angeben.") })
|
||||
.safeParse({ vorlageId: formular.get("vorlageId"), name: formular.get("name") });
|
||||
if (!eingabe.success) {
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
try {
|
||||
const kopie = await vorlageDuplizieren(eingabe.data.vorlageId, eingabe.data.name, benutzer.id);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: `„${kopie.name}“ wurde als Entwurf angelegt.` };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Vorlage konnte nicht dupliziert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function vorlageArchivierenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
const eingabe = z
|
||||
.object({ vorlageId: z.string().min(1), archiviert: z.enum(["true", "false"]) })
|
||||
.safeParse({ vorlageId: formular.get("vorlageId"), archiviert: formular.get("archiviert") });
|
||||
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
|
||||
|
||||
try {
|
||||
await vorlageArchivieren(eingabe.data.vorlageId, eingabe.data.archiviert === "true", benutzer.id);
|
||||
revalidatePath("/vorlagen");
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Gibt das Austauschdokument als JSON-Text zurueck – der Browser bietet es zum Sichern an. */
|
||||
export async function vorlageExportierenAktion(
|
||||
versionId: string,
|
||||
): Promise<{ readonly dateiname: string; readonly inhalt: string } | AktionsErgebnis> {
|
||||
await rechtErforderlich("vorlagenBearbeiten");
|
||||
try {
|
||||
const dokument = await vorlageExportieren(versionId);
|
||||
const sauber = dokument.name.replace(/[^\p{L}\p{N}]+/gu, "-").toLowerCase();
|
||||
return {
|
||||
dateiname: `vorlage-${sauber}-v${dokument.version ?? 1}.json`,
|
||||
inhalt: JSON.stringify(dokument, null, 2),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Vorlage konnte nicht exportiert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function vorlageImportierenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
|
||||
const roh = formular.get("dokument");
|
||||
if (typeof roh !== "string" || !roh.trim()) {
|
||||
return { fehler: "Bitte eine Vorlagendatei auswählen oder den Inhalt einfügen." };
|
||||
}
|
||||
|
||||
let geparst: unknown;
|
||||
try {
|
||||
geparst = JSON.parse(roh);
|
||||
} catch {
|
||||
return { fehler: "Die Datei enthält kein gültiges JSON." };
|
||||
}
|
||||
|
||||
try {
|
||||
await vorlageImportieren(geparst, benutzer.id);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: "Die Vorlage wurde als Entwurf importiert." };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Vorlage konnte nicht importiert werden.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
|
||||
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
/**
|
||||
* Lesemodelle der Vorlagen (#009).
|
||||
*
|
||||
* Wie bei den Kunden wenden sie die Mandantensicht selbst an: eine Vorlage kann
|
||||
* kundenspezifisch sein, und wer den Kunden nicht sehen darf, soll auch seine
|
||||
* Vorlagen nicht sehen. Vorlagen ohne Kundenbezug gelten fuer alle.
|
||||
*/
|
||||
|
||||
export type VorlagenZeile = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly beschreibung: string | null;
|
||||
readonly kategorie: string | null;
|
||||
readonly schlagworte: readonly string[];
|
||||
readonly archiviert: boolean;
|
||||
readonly kundeId: string | null;
|
||||
/** Nummer der zuletzt veroeffentlichten Version, falls es eine gibt. */
|
||||
readonly veroeffentlichteVersion: number | null;
|
||||
readonly veroeffentlichtAm: Date | null;
|
||||
/** Wird gerade an einem Entwurf gearbeitet? */
|
||||
readonly hatEntwurf: boolean;
|
||||
readonly anzahlLaeufe: number;
|
||||
};
|
||||
|
||||
export type VorlagenFilter = {
|
||||
readonly suche?: string;
|
||||
readonly kategorie?: string;
|
||||
readonly schlagwort?: string;
|
||||
readonly auchArchivierte?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Mandantenbedingung als **eigener** Ausdruck, nicht als Objekt zum
|
||||
* Hineinspreizen.
|
||||
*
|
||||
* Vorher wurde `{ OR: [...] }` in dasselbe Literal gespreizt wie die Suche, die
|
||||
* ebenfalls ein `OR` mitbringt — der zweite Schluessel gewann, und die
|
||||
* Mandantengrenze war durch einen Buchstaben im Suchfeld umgangen. Alle
|
||||
* Teilbedingungen liegen deshalb in einem `AND`, wo keine die andere
|
||||
* ueberschreiben kann.
|
||||
*/
|
||||
async function kundenBedingung(benutzer: HandelnderBenutzer) {
|
||||
const sichtbar = await sichtbareKundenIds(benutzer);
|
||||
if (sichtbar === undefined) return undefined;
|
||||
// Vorlagen ohne Kundenbezug gelten fuer alle.
|
||||
return { OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] };
|
||||
}
|
||||
|
||||
export async function vorlagenListe(
|
||||
benutzer: HandelnderBenutzer,
|
||||
filter: VorlagenFilter = {},
|
||||
): Promise<readonly VorlagenZeile[]> {
|
||||
const suche = filter.suche?.trim();
|
||||
|
||||
const bedingungen = [
|
||||
await kundenBedingung(benutzer),
|
||||
filter.auchArchivierte ? undefined : { archiviert: false },
|
||||
filter.kategorie ? { kategorie: filter.kategorie } : undefined,
|
||||
filter.schlagwort ? { schlagworte: { has: filter.schlagwort } } : undefined,
|
||||
suche
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: suche, mode: "insensitive" as const } },
|
||||
{ beschreibung: { contains: suche, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
].filter((bedingung) => bedingung !== undefined);
|
||||
|
||||
const zeilen = await prisma.vorlage.findMany({
|
||||
where: { AND: bedingungen },
|
||||
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
beschreibung: true,
|
||||
kategorie: true,
|
||||
schlagworte: true,
|
||||
archiviert: true,
|
||||
kundeId: true,
|
||||
versionen: {
|
||||
select: {
|
||||
version: true,
|
||||
status: true,
|
||||
veroeffentlichtAm: true,
|
||||
_count: { select: { laeufe: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return zeilen.map((zeile) => {
|
||||
const veroeffentlichte = zeile.versionen
|
||||
.filter((v) => v.status === VersionStatus.VEROEFFENTLICHT)
|
||||
.sort((a, b) => b.version - a.version);
|
||||
const neueste = veroeffentlichte[0];
|
||||
return {
|
||||
id: zeile.id,
|
||||
name: zeile.name,
|
||||
beschreibung: zeile.beschreibung,
|
||||
kategorie: zeile.kategorie,
|
||||
schlagworte: zeile.schlagworte,
|
||||
archiviert: zeile.archiviert,
|
||||
kundeId: zeile.kundeId,
|
||||
veroeffentlichteVersion: neueste?.version ?? null,
|
||||
veroeffentlichtAm: neueste?.veroeffentlichtAm ?? null,
|
||||
hatEntwurf: zeile.versionen.some((v) => v.status === VersionStatus.ENTWURF),
|
||||
anzahlLaeufe: zeile.versionen.reduce((summe, v) => summe + v._count.laeufe, 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Die Kategorien und Schlagworte, die tatsaechlich vorkommen – fuer die Filter. */
|
||||
export async function vorlagenFilterwerte(
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<{ readonly kategorien: readonly string[]; readonly schlagworte: readonly string[] }> {
|
||||
const mandant = await kundenBedingung(benutzer);
|
||||
const zeilen = await prisma.vorlage.findMany({
|
||||
where: { AND: [{ archiviert: false }, ...(mandant ? [mandant] : [])] },
|
||||
select: { kategorie: true, schlagworte: true },
|
||||
});
|
||||
const kategorien = new Set<string>();
|
||||
const schlagworte = new Set<string>();
|
||||
for (const zeile of zeilen) {
|
||||
if (zeile.kategorie) kategorien.add(zeile.kategorie);
|
||||
for (const wort of zeile.schlagworte) schlagworte.add(wort);
|
||||
}
|
||||
return {
|
||||
kategorien: [...kategorien].sort((a, b) => a.localeCompare(b, "de")),
|
||||
schlagworte: [...schlagworte].sort((a, b) => a.localeCompare(b, "de")),
|
||||
};
|
||||
}
|
||||
|
||||
export type VersionsZeile = {
|
||||
readonly id: string;
|
||||
readonly version: number;
|
||||
readonly status: VersionStatus;
|
||||
readonly aenderungsnotiz: string | null;
|
||||
readonly veroeffentlichtAm: Date | null;
|
||||
readonly veroeffentlichtVon: string | null;
|
||||
readonly anzahlLaeufe: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Versionshistorie einer Vorlage, neueste zuerst.
|
||||
*
|
||||
* Wendet die Mandantensicht selbst an: wer die Vorlage nicht sehen darf, sieht
|
||||
* auch ihre Historie nicht. Eine leere Liste ist hier die richtige Antwort – wer
|
||||
* die Vorlage nicht kennt, soll nicht einmal erfahren, dass es sie gibt.
|
||||
*/
|
||||
export async function versionsHistorie(
|
||||
vorlageId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<readonly VersionsZeile[]> {
|
||||
const mandant = await kundenBedingung(benutzer);
|
||||
const versionen = await prisma.vorlagenVersion.findMany({
|
||||
where: { vorlageId, ...(mandant ? { vorlage: mandant } : {}) },
|
||||
orderBy: { version: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
status: true,
|
||||
aenderungsnotiz: true,
|
||||
veroeffentlichtAm: true,
|
||||
veroeffentlichtVon: { select: { name: true } },
|
||||
_count: { select: { laeufe: true } },
|
||||
},
|
||||
});
|
||||
return versionen.map((version) => ({
|
||||
id: version.id,
|
||||
version: version.version,
|
||||
status: version.status,
|
||||
aenderungsnotiz: version.aenderungsnotiz,
|
||||
veroeffentlichtAm: version.veroeffentlichtAm,
|
||||
veroeffentlichtVon: version.veroeffentlichtVon?.name ?? null,
|
||||
anzahlLaeufe: version._count.laeufe,
|
||||
}));
|
||||
}
|
||||
|
||||
export type VersionMitAufbau = {
|
||||
readonly id: string;
|
||||
readonly vorlageId: string;
|
||||
readonly vorlageName: string;
|
||||
readonly version: number;
|
||||
readonly status: VersionStatus;
|
||||
readonly struktur: Struktur;
|
||||
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
|
||||
readonly platzhalter: readonly string[];
|
||||
};
|
||||
|
||||
export async function versionMitAufbau(
|
||||
versionId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<VersionMitAufbau | undefined> {
|
||||
const mandant = await kundenBedingung(benutzer);
|
||||
const version = await prisma.vorlagenVersion.findFirst({
|
||||
where: { id: versionId, ...(mandant ? { vorlage: mandant } : {}) },
|
||||
include: {
|
||||
vorlage: { select: { id: true, name: true } },
|
||||
variablen: { orderBy: { position: "asc" } },
|
||||
abschnitte: {
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: { orderBy: { position: "asc" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!version) return undefined;
|
||||
|
||||
const struktur: Struktur = {
|
||||
variablen: version.variablen.map((variable) => ({
|
||||
schluessel: variable.schluessel,
|
||||
beschriftung: variable.beschriftung,
|
||||
typ: variable.typ,
|
||||
optionen: variable.optionen,
|
||||
pflicht: variable.pflicht,
|
||||
})),
|
||||
abschnitte: version.abschnitte.map((abschnitt) => ({
|
||||
titel: abschnitt.titel,
|
||||
schritte: abschnitt.schritte.map((schritt) => ({
|
||||
titel: schritt.titel,
|
||||
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
konfig: (schritt.konfig ?? {}) as Record<string, unknown>,
|
||||
...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
return {
|
||||
id: version.id,
|
||||
vorlageId: version.vorlage.id,
|
||||
vorlageName: version.vorlage.name,
|
||||
version: version.version,
|
||||
status: version.status,
|
||||
struktur,
|
||||
platzhalter: platzhalterDerStruktur(struktur),
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { ZodError } from "@/lib/validierung";
|
||||
import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client";
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { AustauschSchema, FORMAT_VERSION, type Austausch } from "@/lib/vorlagen-format";
|
||||
|
||||
/**
|
||||
* JSON-Austausch fuer Vorlagen (#009).
|
||||
*
|
||||
* Export und Import sind die Bruecke zwischen zwei Installationen: eine Vorlage
|
||||
* verlaesst die eine als Datei und kommt in der anderen als Entwurf an. Deshalb
|
||||
* traegt das Dokument keine Kennungen, keine Zeitstempel und keinen Kundenbezug
|
||||
* — Verweise, die in der Zielinstallation nichts bedeuten wuerden. Das Format
|
||||
* selbst steht in `docs/vorlagen-format.md`, die Schemata in
|
||||
* `src/lib/vorlagen-format.ts`.
|
||||
*
|
||||
* Rechte werden hier nicht geprueft, das tut die Server Action darueber.
|
||||
*/
|
||||
|
||||
export class AustauschFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "AustauschFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Schritt so, wie er im Austauschdokument steht — ohne Kennung und Position. */
|
||||
type AustauschSchritt = Austausch["struktur"]["abschnitte"][number]["schritte"][number];
|
||||
|
||||
/**
|
||||
* Baut das Austauschdokument einer Version. Liest nur.
|
||||
*
|
||||
* Sortiert wird nach `position`, bei Gleichstand nach `id`: zwei Zeilen mit
|
||||
* derselben Position duerfen die Reihenfolge nicht dem Zufall der Datenbank
|
||||
* ueberlassen, sonst waere ein Export nicht wiederholbar.
|
||||
*/
|
||||
export async function vorlageExportieren(versionId: string): Promise<Austausch> {
|
||||
const version = await prisma.vorlagenVersion.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
vorlage: true,
|
||||
variablen: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
abschnitte: {
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
include: { schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!version) {
|
||||
throw new AustauschFehler("Diese Vorlagen-Version gibt es nicht (mehr).");
|
||||
}
|
||||
|
||||
const { vorlage } = version;
|
||||
return {
|
||||
format: FORMAT_VERSION,
|
||||
name: vorlage.name,
|
||||
// Leere Felder werden weggelassen statt als null geschrieben: eine von Hand
|
||||
// geschriebene Datei sieht genauso aus wie eine exportierte.
|
||||
...(vorlage.beschreibung ? { beschreibung: vorlage.beschreibung } : {}),
|
||||
...(vorlage.kategorie ? { kategorie: vorlage.kategorie } : {}),
|
||||
schlagworte: [...vorlage.schlagworte],
|
||||
version: version.version,
|
||||
struktur: {
|
||||
variablen: version.variablen.map((variable) => ({
|
||||
schluessel: variable.schluessel,
|
||||
beschriftung: variable.beschriftung,
|
||||
typ: variable.typ,
|
||||
optionen: [...variable.optionen],
|
||||
pflicht: variable.pflicht,
|
||||
})),
|
||||
abschnitte: version.abschnitte.map((abschnitt) => ({
|
||||
titel: abschnitt.titel,
|
||||
schritte: abschnitt.schritte.map(schrittAbbilden),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet eine Schritt-Zeile auf das Austauschformat ab.
|
||||
*
|
||||
* `konfig` ist in der Datenbank ein Json-Feld und kann alles enthalten.
|
||||
*
|
||||
* Frueher lief es durch `SchrittKonfigSchema.safeParse` und wurde bei **jeder**
|
||||
* Beanstandung komplett verworfen — ein `shell: "zsh"` aus einer spaeteren
|
||||
* Fassung kostete lautlos auch den `befehl`. Genau das Gegenteil dessen, was das
|
||||
* nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
|
||||
* kein Objekt ist, gilt als leer.
|
||||
*/
|
||||
function schrittAbbilden(schritt: VorlagenSchritt): AustauschSchritt {
|
||||
const roh = schritt.konfig;
|
||||
const konfig =
|
||||
typeof roh === "object" && roh !== null && !Array.isArray(roh) ? (roh as AustauschSchritt["konfig"]) : {};
|
||||
return {
|
||||
titel: schritt.titel,
|
||||
...(schritt.hilfetext ? { hilfetext: schritt.hilfetext } : {}),
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
konfig,
|
||||
...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt aus einem Austauschdokument eine neue Vorlage mit einem Entwurf an.
|
||||
* Nimmt unbekannte Eingaben entgegen und prueft sie mit AustauschSchema.
|
||||
*
|
||||
* Immer eine **neue** Vorlage, nie in eine bestehende hinein, und die erste
|
||||
* Version ist `ENTWURF`: eine eingespielte Vorlage wird bewusst nicht sofort
|
||||
* veroeffentlicht, der Redakteur sieht sie sich erst an. Ein Name, den es schon
|
||||
* gibt, ist kein Fehler — Vorlagennamen sind nicht eindeutig, und ein
|
||||
* angehaengtes „(2)“ waere eine Aenderung, um die niemand gebeten hat.
|
||||
*/
|
||||
export async function vorlageImportieren(
|
||||
dokument: unknown,
|
||||
durchBenutzerId: string,
|
||||
): Promise<{ readonly vorlageId: string; readonly versionId: string }> {
|
||||
const austausch = dokumentPruefen(dokument);
|
||||
variablenschluesselPruefen(austausch.struktur.variablen);
|
||||
|
||||
// Eine Vorlage ist erst mit Variablen, Abschnitten und Schritten sinnvoll.
|
||||
// Deshalb alles in einer Transaktion: ein Fehler auf halbem Weg darf keine
|
||||
// halbe Vorlage hinterlassen, die jemand in der Liste findet.
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const vorlage = await tx.vorlage.create({
|
||||
data: {
|
||||
name: austausch.name,
|
||||
beschreibung: austausch.beschreibung ?? null,
|
||||
kategorie: austausch.kategorie ?? null,
|
||||
schlagworte: [...austausch.schlagworte],
|
||||
},
|
||||
});
|
||||
|
||||
const version = await tx.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
|
||||
// `position` kommt aus der Reihenfolge der Listen und wird nicht
|
||||
// mitgeschickt — sonst gaebe es zwei Wahrheiten.
|
||||
if (austausch.struktur.variablen.length > 0) {
|
||||
await tx.vorlagenVariable.createMany({
|
||||
data: austausch.struktur.variablen.map((variable, position) => ({
|
||||
versionId: version.id,
|
||||
schluessel: variable.schluessel,
|
||||
beschriftung: variable.beschriftung,
|
||||
typ: variable.typ,
|
||||
optionen: [...variable.optionen],
|
||||
pflicht: variable.pflicht,
|
||||
position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
let schritteGesamt = 0;
|
||||
for (const [position, abschnitt] of austausch.struktur.abschnitte.entries()) {
|
||||
const zeile = await tx.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: abschnitt.titel, position },
|
||||
});
|
||||
if (abschnitt.schritte.length === 0) continue;
|
||||
await tx.vorlagenSchritt.createMany({
|
||||
data: abschnitt.schritte.map((schritt, schrittPosition) => ({
|
||||
abschnittId: zeile.id,
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext ?? null,
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
// Das nachsichtige Schema traegt eine Index-Signatur auf `unknown`,
|
||||
// die Prisma nicht als Json-Wert erkennt. Der Inhalt kommt aus
|
||||
// geparstem JSON, ist also einer.
|
||||
konfig: schritt.konfig as Prisma.InputJsonObject,
|
||||
dokuUrl: schritt.dokuUrl ?? null,
|
||||
position: schrittPosition,
|
||||
})),
|
||||
});
|
||||
schritteGesamt += abschnitt.schritte.length;
|
||||
}
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: vorlage.id,
|
||||
aktion: "Vorlage importiert",
|
||||
benutzerId: durchBenutzerId,
|
||||
nachher: {
|
||||
name: vorlage.name,
|
||||
version: version.version,
|
||||
abschnitte: austausch.struktur.abschnitte.length,
|
||||
schritte: schritteGesamt,
|
||||
variablen: austausch.struktur.variablen.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { vorlageId: vorlage.id, versionId: version.id };
|
||||
});
|
||||
}
|
||||
|
||||
/** Prueft das Dokument gegen das Schema und uebersetzt Zod-Fehler in eine lesbare Meldung. */
|
||||
function dokumentPruefen(dokument: unknown): Austausch {
|
||||
// Eigene Meldung fuer den haeufigsten Fehlgriff – eine Datei, die gar kein
|
||||
// JSON-Objekt enthaelt. Zod sagt dazu nur einen englischen Satz.
|
||||
if (typeof dokument !== "object" || dokument === null || Array.isArray(dokument)) {
|
||||
throw new AustauschFehler("Das Austauschdokument ist kein JSON-Objekt.");
|
||||
}
|
||||
const ergebnis = AustauschSchema.safeParse(dokument);
|
||||
if (!ergebnis.success) {
|
||||
throw new AustauschFehler(ersteBeanstandung(ergebnis.error));
|
||||
}
|
||||
return ergebnis.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nennt die erste Beanstandung samt Pfad.
|
||||
*
|
||||
* Eine rohe Zod-Ausgabe hilft niemandem: wer eine Datei von Hand schreibt,
|
||||
* braucht die Stelle („abschnitte.0.schritte.2.titel“) und einen Satz dazu, und
|
||||
* zwar den ersten — eine Liste von zwanzig Folgefehlern liest niemand.
|
||||
*/
|
||||
function ersteBeanstandung(fehler: ZodError): string {
|
||||
const problem = fehler.issues[0];
|
||||
if (!problem) return "Das Austauschdokument passt nicht zum Format.";
|
||||
const pfad = problem.path.map(String).join(".");
|
||||
return pfad.length === 0 ? problem.message : `${pfad}: ${problem.message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weist doppelte Variablenschluessel ab.
|
||||
*
|
||||
* Die Datenbank haelt dasselbe ueber `@@unique([versionId, schluessel])` fest;
|
||||
* diese Pruefung existiert fuer die Meldung, denn der Fehler des Index nennt
|
||||
* einen Spaltennamen und keinen Schluessel. Ausserdem faellt er erst mitten in
|
||||
* der Transaktion auf, also nach halber Arbeit.
|
||||
*/
|
||||
function variablenschluesselPruefen(variablen: readonly { readonly schluessel: string }[]): void {
|
||||
const gesehen = new Set<string>();
|
||||
for (const variable of variablen) {
|
||||
if (gesehen.has(variable.schluessel)) {
|
||||
throw new AustauschFehler(
|
||||
`Den Variablenschlüssel „${variable.schluessel}“ gibt es zweimal. ` +
|
||||
"Jeder Schlüssel darf in einer Vorlage nur einmal stehen.",
|
||||
);
|
||||
}
|
||||
gesehen.add(variable.schluessel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
|
||||
|
||||
/**
|
||||
* Vorlagen und ihre Versionierung (#009).
|
||||
*
|
||||
* Die eine Regel, die alles traegt: **eine veroeffentlichte Version wird nie
|
||||
* wieder veraendert.** Ein Lauf verweist auf eine bestimmte Version und zeigt
|
||||
* deshalb auch in zehn Jahren noch genau die Schritte, die damals abgearbeitet
|
||||
* wurden. „Bearbeiten“ heisst deswegen nicht "aendern", sondern "tiefe Kopie als
|
||||
* neuer Entwurf".
|
||||
*
|
||||
* Je Vorlage gibt es hoechstens einen Entwurf. Das steht zusaetzlich als
|
||||
* partieller Eindeutigkeitsindex in der Datenbank (`VorlagenVersion_ein_entwurf`);
|
||||
* die Pruefung hier existiert fuer die bessere Meldung.
|
||||
*/
|
||||
|
||||
export class VorlagenFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "VorlagenFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export type NeueVorlage = {
|
||||
readonly name: string;
|
||||
readonly beschreibung?: string;
|
||||
readonly kategorie?: string;
|
||||
readonly schlagworte?: readonly string[];
|
||||
/** Nur im Modus DIENSTLEISTUNG: kundenspezifische Vorlage. Leer gilt fuer alle. */
|
||||
readonly kundeId?: string | null;
|
||||
};
|
||||
|
||||
function namePruefen(roh: string): string {
|
||||
const name = roh.trim();
|
||||
if (!name) throw new VorlagenFehler("Eine Vorlage braucht einen Namen.");
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Legt eine Vorlage mit einem leeren Entwurf als Version 1 an. */
|
||||
export async function vorlageAnlegen(
|
||||
eingabe: NeueVorlage,
|
||||
durchBenutzerId: string,
|
||||
): Promise<{ readonly vorlage: Vorlage; readonly version: VorlagenVersion }> {
|
||||
const name = namePruefen(eingabe.name);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const vorlage = await tx.vorlage.create({
|
||||
data: {
|
||||
name,
|
||||
beschreibung: eingabe.beschreibung?.trim() || null,
|
||||
kategorie: eingabe.kategorie?.trim() || null,
|
||||
schlagworte: [...(eingabe.schlagworte ?? [])],
|
||||
kundeId: eingabe.kundeId ?? null,
|
||||
},
|
||||
});
|
||||
const version = await tx.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: vorlage.id,
|
||||
aktion: "Vorlage angelegt",
|
||||
benutzerId: durchBenutzerId,
|
||||
nachher: { name: vorlage.name, version: 1 },
|
||||
},
|
||||
});
|
||||
return { vorlage, version };
|
||||
});
|
||||
}
|
||||
|
||||
export type VorlagenAenderung = {
|
||||
readonly name?: string;
|
||||
readonly beschreibung?: string | null;
|
||||
readonly kategorie?: string | null;
|
||||
readonly schlagworte?: readonly string[];
|
||||
/** `null` macht aus einer kundenspezifischen Vorlage eine fuer alle. */
|
||||
readonly kundeId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Aendert die Kopfdaten einer Vorlage.
|
||||
*
|
||||
* Betrifft die Vorlage, nicht ihre Versionen: Name, Kategorie und Schlagworte
|
||||
* sind kein Teil des abgearbeiteten Inhalts und aendern deshalb nichts an der
|
||||
* Nachvollziehbarkeit eines Laufs.
|
||||
*/
|
||||
export async function vorlageBearbeiten(
|
||||
vorlageId: string,
|
||||
aenderung: VorlagenAenderung,
|
||||
durchBenutzerId: string,
|
||||
): Promise<Vorlage> {
|
||||
const vorher = await prisma.vorlage.findUnique({ where: { id: vorlageId } });
|
||||
if (!vorher) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
|
||||
|
||||
const daten: {
|
||||
name?: string;
|
||||
beschreibung?: string | null;
|
||||
kategorie?: string | null;
|
||||
schlagworte?: string[];
|
||||
kundeId?: string | null;
|
||||
} = {};
|
||||
const geaendert: string[] = [];
|
||||
|
||||
if (aenderung.name !== undefined) {
|
||||
const name = namePruefen(aenderung.name);
|
||||
if (name !== vorher.name) {
|
||||
daten.name = name;
|
||||
geaendert.push("Name");
|
||||
}
|
||||
}
|
||||
if (aenderung.beschreibung !== undefined) {
|
||||
const wert = aenderung.beschreibung?.trim() || null;
|
||||
if (wert !== vorher.beschreibung) {
|
||||
daten.beschreibung = wert;
|
||||
geaendert.push("Beschreibung");
|
||||
}
|
||||
}
|
||||
if (aenderung.kategorie !== undefined) {
|
||||
const wert = aenderung.kategorie?.trim() || null;
|
||||
if (wert !== vorher.kategorie) {
|
||||
daten.kategorie = wert;
|
||||
geaendert.push("Kategorie");
|
||||
}
|
||||
}
|
||||
if (aenderung.schlagworte !== undefined) {
|
||||
const wert = [...aenderung.schlagworte];
|
||||
if (JSON.stringify(wert) !== JSON.stringify(vorher.schlagworte)) {
|
||||
daten.schlagworte = wert;
|
||||
geaendert.push("Schlagworte");
|
||||
}
|
||||
}
|
||||
if (aenderung.kundeId !== undefined) {
|
||||
const wert = aenderung.kundeId || null;
|
||||
if (wert !== vorher.kundeId) {
|
||||
daten.kundeId = wert;
|
||||
geaendert.push("Kundenzuordnung");
|
||||
}
|
||||
}
|
||||
|
||||
if (geaendert.length === 0) return vorher;
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const nachher = await tx.vorlage.update({ where: { id: vorlageId }, data: daten });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: vorlageId,
|
||||
aktion: `${geaendert.join(" und ")} geändert`,
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: {
|
||||
name: vorher.name,
|
||||
kategorie: vorher.kategorie,
|
||||
schlagworte: vorher.schlagworte,
|
||||
kundeId: vorher.kundeId,
|
||||
},
|
||||
nachher: {
|
||||
name: nachher.name,
|
||||
kategorie: nachher.kategorie,
|
||||
schlagworte: nachher.schlagworte,
|
||||
kundeId: nachher.kundeId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return nachher;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Version, an der gerade gearbeitet wird.
|
||||
*
|
||||
* Gibt es schon einen Entwurf, wird der zurueckgegeben — je Vorlage darf es nur
|
||||
* einen geben. Sonst entsteht eine **tiefe Kopie** der zuletzt veroeffentlichten
|
||||
* Version: Variablen, Abschnitte und Schritte samt `konfig` werden mitkopiert,
|
||||
* nicht verlinkt. Wuerden sie geteilt, aenderte eine Bearbeitung rueckwirkend
|
||||
* die veroeffentlichte Version und damit alle Laeufe, die daran haengen.
|
||||
*/
|
||||
export async function entwurfHolenOderErzeugen(
|
||||
vorlageId: string,
|
||||
durchBenutzerId: string,
|
||||
): Promise<VorlagenVersion> {
|
||||
const vorlage = await prisma.vorlage.findUnique({
|
||||
where: { id: vorlageId },
|
||||
select: { id: true, archiviert: true, name: true },
|
||||
});
|
||||
if (!vorlage) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
|
||||
if (vorlage.archiviert) {
|
||||
throw new VorlagenFehler(`„${vorlage.name}“ ist archiviert. Erst wieder freigeben, dann bearbeiten.`);
|
||||
}
|
||||
|
||||
const vorhandener = await prisma.vorlagenVersion.findFirst({
|
||||
where: { vorlageId, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
if (vorhandener) return vorhandener;
|
||||
|
||||
const letzte = await prisma.vorlagenVersion.findFirst({
|
||||
where: { vorlageId, status: VersionStatus.VEROEFFENTLICHT },
|
||||
orderBy: { version: "desc" },
|
||||
include: {
|
||||
variablen: { orderBy: { position: "asc" } },
|
||||
abschnitte: {
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: { orderBy: { position: "asc" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!letzte) {
|
||||
throw new VorlagenFehler("Diese Vorlage hat weder einen Entwurf noch eine veröffentlichte Version.");
|
||||
}
|
||||
|
||||
const hoechste = await prisma.vorlagenVersion.aggregate({
|
||||
where: { vorlageId },
|
||||
_max: { version: true },
|
||||
});
|
||||
const naechste = (hoechste._max.version ?? 0) + 1;
|
||||
|
||||
try {
|
||||
return await entwurfKopieren(vorlageId, naechste, letzte, durchBenutzerId);
|
||||
} catch (fehler) {
|
||||
// Zwei gleichzeitige Aufrufe: der zweite laeuft in den eindeutigen Index –
|
||||
// mal in den auf (vorlageId, version), mal in den auf "ein Entwurf". Beides
|
||||
// heisst dasselbe: jemand anderes war schneller, und der Entwurf liegt
|
||||
// bereits vor. Ihn zurueckzugeben ist richtiger als eine Fehlermeldung.
|
||||
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
|
||||
const inzwischen = await prisma.vorlagenVersion.findFirst({
|
||||
where: { vorlageId, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
if (inzwischen) return inzwischen;
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
type VersionMitInhalt = {
|
||||
readonly version: number;
|
||||
readonly variablen: readonly {
|
||||
schluessel: string;
|
||||
beschriftung: string;
|
||||
typ: "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM";
|
||||
optionen: string[];
|
||||
pflicht: boolean;
|
||||
}[];
|
||||
readonly abschnitte: readonly {
|
||||
titel: string;
|
||||
schritte: readonly {
|
||||
titel: string;
|
||||
hilfetext: string | null;
|
||||
typ: "HAKEN" | "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM" | "BEFEHL";
|
||||
pflicht: boolean;
|
||||
konfig: unknown;
|
||||
dokuUrl: string | null;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
async function entwurfKopieren(
|
||||
vorlageId: string,
|
||||
naechste: number,
|
||||
letzte: VersionMitInhalt,
|
||||
durchBenutzerId: string,
|
||||
): Promise<VorlagenVersion> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const entwurf = await tx.vorlagenVersion.create({
|
||||
data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
|
||||
if (letzte.variablen.length > 0) {
|
||||
await tx.vorlagenVariable.createMany({
|
||||
data: letzte.variablen.map((variable, index) => ({
|
||||
versionId: entwurf.id,
|
||||
schluessel: variable.schluessel,
|
||||
beschriftung: variable.beschriftung,
|
||||
typ: variable.typ,
|
||||
optionen: variable.optionen,
|
||||
pflicht: variable.pflicht,
|
||||
position: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, abschnitt] of letzte.abschnitte.entries()) {
|
||||
const kopie = await tx.vorlagenAbschnitt.create({
|
||||
data: { versionId: entwurf.id, titel: abschnitt.titel, position: index },
|
||||
});
|
||||
if (abschnitt.schritte.length > 0) {
|
||||
await tx.vorlagenSchritt.createMany({
|
||||
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
|
||||
abschnittId: kopie.id,
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext,
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
// Json wird als Wert kopiert, nicht als Verweis geteilt.
|
||||
konfig: schritt.konfig as Prisma.InputJsonValue,
|
||||
dokuUrl: schritt.dokuUrl,
|
||||
position: schrittIndex,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "VorlagenVersion",
|
||||
entitaetId: entwurf.id,
|
||||
aktion: "Entwurf aus veröffentlichter Version erstellt",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: { version: letzte.version },
|
||||
nachher: { version: naechste },
|
||||
},
|
||||
});
|
||||
return entwurf;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sperrt die Versionszeile und gibt ihren Stand zurueck.
|
||||
*
|
||||
* Ohne die Sperre lief folgendes durch: Speichern liest den Status, findet
|
||||
* ENTWURF, und waehrend es schreibt, veroeffentlicht ein zweiter Aufruf die
|
||||
* Version. Danach trug eine veroeffentlichte Version Inhalt, der nach dem
|
||||
* Veroeffentlichen geschrieben wurde — genau die Regel, die das Archiv traegt,
|
||||
* durchbrochen an einem Doppelklick.
|
||||
*/
|
||||
async function versionSperren(
|
||||
tx: Prisma.TransactionClient,
|
||||
versionId: string,
|
||||
): Promise<{ status: VersionStatus; version: number; geaendertAm: Date }> {
|
||||
const zeilen = await tx.$queryRaw<{ status: VersionStatus; version: number; geaendertAm: Date }[]>`
|
||||
SELECT status, version, "geaendertAm" FROM "VorlagenVersion" WHERE id = ${versionId} FOR UPDATE
|
||||
`;
|
||||
const zeile = zeilen[0];
|
||||
if (!zeile) throw new VorlagenFehler("Diese Version gibt es nicht.");
|
||||
return zeile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wirft, wenn an dieser Version nicht mehr geschrieben werden darf.
|
||||
*
|
||||
* Eine einzige Stelle, wie `laufMussVeraenderbarSein` beim Archiv: verstreute
|
||||
* Pruefungen werden irgendwann an einer Stelle vergessen.
|
||||
*/
|
||||
export function versionMussEntwurfSein(version: {
|
||||
readonly status: VersionStatus;
|
||||
readonly version: number;
|
||||
}): void {
|
||||
if (version.status !== VersionStatus.ENTWURF) {
|
||||
throw new VorlagenFehler(
|
||||
`Version ${version.version} ist veröffentlicht und damit unveränderlich. ` +
|
||||
"„Bearbeiten“ erzeugt daraus einen neuen Entwurf.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt den Aufbau eines Entwurfs vollstaendig.
|
||||
*
|
||||
* Bewusst als Ganzes statt Schritt fuer Schritt: der Editor haelt den ganzen
|
||||
* Baum im Speicher und sortiert per Drag & Drop, ein Teil-Update waere dort
|
||||
* staendig mit dem Sortieren im Streit. Alles in einer Transaktion — ein halb
|
||||
* gespeicherter Entwurf waere schlimmer als gar keiner.
|
||||
*
|
||||
* Kein Protokolleintrag: ein Entwurf wird beim Bauen dutzendfach gespeichert,
|
||||
* das Protokoll wuerde davon unlesbar. Festgehalten wird das Veroeffentlichen —
|
||||
* der Punkt, ab dem die Version unveraenderlich ist.
|
||||
*/
|
||||
export async function entwurfSpeichern(
|
||||
versionId: string,
|
||||
eingabe: StrukturEingabe,
|
||||
erwarteterStand?: Date,
|
||||
): Promise<{ readonly struktur: Struktur; readonly stand: Date }> {
|
||||
const gelesen = StrukturSchema.safeParse(eingabe);
|
||||
if (!gelesen.success) {
|
||||
const erster = gelesen.error.issues[0];
|
||||
throw new VorlagenFehler(
|
||||
erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
|
||||
);
|
||||
}
|
||||
const struktur = gelesen.data;
|
||||
|
||||
const schluessel = struktur.variablen.map((variable) => variable.schluessel);
|
||||
const doppelt = schluessel.find((wert, index) => schluessel.indexOf(wert) !== index);
|
||||
if (doppelt) {
|
||||
throw new VorlagenFehler(`Den Variablenschlüssel „${doppelt}“ gibt es zweimal.`);
|
||||
}
|
||||
|
||||
const stand = await prisma.$transaction(async (tx) => {
|
||||
// Erst sperren, dann pruefen: sonst kann zwischen Pruefung und Schreiben
|
||||
// veroeffentlicht werden.
|
||||
const zeile = await versionSperren(tx, versionId);
|
||||
versionMussEntwurfSein(zeile);
|
||||
|
||||
if (erwarteterStand && zeile.geaendertAm.getTime() !== erwarteterStand.getTime()) {
|
||||
throw new VorlagenFehler(
|
||||
"Der Entwurf wurde zwischenzeitlich von jemand anderem gespeichert. " +
|
||||
"Bitte neu laden, sonst geht dessen Arbeit verloren.",
|
||||
);
|
||||
}
|
||||
|
||||
// Abschnitte raeumen ihre Schritte per Cascade mit ab. Haengt an einem
|
||||
// Schritt ein Schrittergebnis, verhindert der Fremdschluessel das Loeschen.
|
||||
// Das darf bei einem Entwurf nicht vorkommen – Laeufe haengen nur an
|
||||
// veroeffentlichten Versionen, siehe veroeffentlichteVersionPruefen (#013).
|
||||
// Sollte es doch passieren, wird der rohe Fremdschluesselfehler unten in eine
|
||||
// verstaendliche Meldung uebersetzt.
|
||||
try {
|
||||
await tx.vorlagenAbschnitt.deleteMany({ where: { versionId } });
|
||||
await tx.vorlagenVariable.deleteMany({ where: { versionId } });
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2003") {
|
||||
throw new VorlagenFehler(
|
||||
"An diesem Entwurf hängen bereits Schrittergebnisse. Er lässt sich nicht mehr umbauen.",
|
||||
);
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
if (struktur.variablen.length > 0) {
|
||||
await tx.vorlagenVariable.createMany({
|
||||
data: struktur.variablen.map((variable, index) => ({
|
||||
versionId,
|
||||
schluessel: variable.schluessel,
|
||||
beschriftung: variable.beschriftung,
|
||||
typ: variable.typ,
|
||||
optionen: [...variable.optionen],
|
||||
pflicht: variable.pflicht,
|
||||
position: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
|
||||
const angelegt = await tx.vorlagenAbschnitt.create({
|
||||
data: { versionId, titel: abschnitt.titel, position: index },
|
||||
});
|
||||
if (abschnitt.schritte.length > 0) {
|
||||
await tx.vorlagenSchritt.createMany({
|
||||
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
|
||||
abschnittId: angelegt.id,
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext ?? null,
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
konfig: schritt.konfig as Prisma.InputJsonValue,
|
||||
dokuUrl: schritt.dokuUrl ?? null,
|
||||
position: schrittIndex,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Der Stand wandert weiter, damit ein zweiter Editor beim Speichern merkt,
|
||||
// dass hier inzwischen etwas passiert ist.
|
||||
const frisch = await tx.vorlagenVersion.update({
|
||||
where: { id: versionId },
|
||||
data: { geaendertAm: new Date() },
|
||||
select: { geaendertAm: true },
|
||||
});
|
||||
return frisch.geaendertAm;
|
||||
});
|
||||
|
||||
return { struktur, stand };
|
||||
}
|
||||
|
||||
/**
|
||||
* Veroeffentlicht einen Entwurf.
|
||||
*
|
||||
* Ab hier ist die Version unveraenderlich. Bestehende Laeufe bleiben davon
|
||||
* unberuehrt: sie verweisen auf die Version, mit der sie gestartet wurden.
|
||||
*/
|
||||
export async function versionVeroeffentlichen(
|
||||
versionId: string,
|
||||
aenderungsnotiz: string | null,
|
||||
durchBenutzerId: string,
|
||||
): Promise<VorlagenVersion> {
|
||||
const notiz = aenderungsnotiz?.trim() || null;
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const zeile = await versionSperren(tx, versionId);
|
||||
versionMussEntwurfSein(zeile);
|
||||
|
||||
const kopf = await tx.vorlagenVersion.findUniqueOrThrow({
|
||||
where: { id: versionId },
|
||||
select: {
|
||||
vorlage: { select: { archiviert: true, name: true } },
|
||||
_count: { select: { abschnitte: true } },
|
||||
},
|
||||
});
|
||||
if (kopf.vorlage.archiviert) {
|
||||
throw new VorlagenFehler(
|
||||
`„${kopf.vorlage.name}“ ist archiviert. Erst wieder freigeben, dann veröffentlichen.`,
|
||||
);
|
||||
}
|
||||
if (kopf._count.abschnitte === 0) {
|
||||
throw new VorlagenFehler(
|
||||
"Eine Vorlage ohne Abschnitte lässt sich nicht veröffentlichen — es gäbe nichts abzuarbeiten.",
|
||||
);
|
||||
}
|
||||
|
||||
// Bedingtes Update: trifft es keine Zeile, hat jemand anderes in derselben
|
||||
// Sekunde veroeffentlicht. Ohne diese Bedingung entstuenden zwei
|
||||
// Protokolleintraege fuer denselben Vorgang.
|
||||
const getroffen = await tx.vorlagenVersion.updateMany({
|
||||
where: { id: versionId, status: VersionStatus.ENTWURF },
|
||||
data: {
|
||||
status: VersionStatus.VEROEFFENTLICHT,
|
||||
aenderungsnotiz: notiz,
|
||||
veroeffentlichtAm: new Date(),
|
||||
veroeffentlichtVonId: durchBenutzerId,
|
||||
},
|
||||
});
|
||||
if (getroffen.count === 0) {
|
||||
throw new VorlagenFehler("Diese Version wurde bereits veröffentlicht.");
|
||||
}
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "VorlagenVersion",
|
||||
entitaetId: versionId,
|
||||
aktion: "Version veröffentlicht",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: { status: VersionStatus.ENTWURF },
|
||||
nachher: {
|
||||
status: VersionStatus.VEROEFFENTLICHT,
|
||||
version: zeile.version,
|
||||
aenderungsnotiz: notiz,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return tx.vorlagenVersion.findUniqueOrThrow({ where: { id: versionId } });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwirft einen Entwurf.
|
||||
*
|
||||
* Nur ein Entwurf laesst sich verwerfen, und nur wenn die Vorlage noch eine
|
||||
* veroeffentlichte Version hat — sonst bliebe eine Vorlage ohne jeden Inhalt
|
||||
* zurueck, die niemand mehr bearbeiten kann.
|
||||
*/
|
||||
export async function entwurfVerwerfen(versionId: string, durchBenutzerId: string): Promise<void> {
|
||||
const version = await prisma.vorlagenVersion.findUnique({
|
||||
where: { id: versionId },
|
||||
select: { id: true, status: true, version: true, vorlageId: true },
|
||||
});
|
||||
if (!version) throw new VorlagenFehler("Diese Version gibt es nicht.");
|
||||
versionMussEntwurfSein(version);
|
||||
|
||||
const veroeffentlichte = await prisma.vorlagenVersion.count({
|
||||
where: { vorlageId: version.vorlageId, status: VersionStatus.VEROEFFENTLICHT },
|
||||
});
|
||||
if (veroeffentlichte === 0) {
|
||||
throw new VorlagenFehler(
|
||||
"Das ist die einzige Version dieser Vorlage. Statt sie zu verwerfen, archiviere die Vorlage.",
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.vorlagenVersion.delete({ where: { id: versionId } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "VorlagenVersion",
|
||||
entitaetId: versionId,
|
||||
aktion: "Entwurf verworfen",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: { version: version.version },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dupliziert eine Vorlage samt ihrer zuletzt veroeffentlichten Version.
|
||||
*
|
||||
* Die Kopie beginnt als Entwurf: eine duplizierte Vorlage ist fast nie
|
||||
* unveraendert brauchbar, und sofort zu veroeffentlichen wuerde sie in der
|
||||
* Auswahl erscheinen lassen, bevor jemand sie angesehen hat.
|
||||
*/
|
||||
export async function vorlageDuplizieren(
|
||||
vorlageId: string,
|
||||
neuerName: string,
|
||||
durchBenutzerId: string,
|
||||
): Promise<Vorlage> {
|
||||
const name = namePruefen(neuerName);
|
||||
const quelle = await prisma.vorlage.findUnique({
|
||||
where: { id: vorlageId },
|
||||
include: {
|
||||
versionen: {
|
||||
where: { status: VersionStatus.VEROEFFENTLICHT },
|
||||
orderBy: { version: "desc" },
|
||||
take: 1,
|
||||
include: {
|
||||
variablen: { orderBy: { position: "asc" } },
|
||||
abschnitte: {
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: { orderBy: { position: "asc" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!quelle) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
|
||||
|
||||
const letzte = quelle.versionen[0];
|
||||
if (!letzte) {
|
||||
throw new VorlagenFehler(
|
||||
"Diese Vorlage hat noch keine veröffentlichte Version, es gibt also nichts zu duplizieren.",
|
||||
);
|
||||
}
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const kopie = await tx.vorlage.create({
|
||||
data: {
|
||||
name,
|
||||
beschreibung: quelle.beschreibung,
|
||||
kategorie: quelle.kategorie,
|
||||
schlagworte: quelle.schlagworte,
|
||||
kundeId: quelle.kundeId,
|
||||
},
|
||||
});
|
||||
const version = await tx.vorlagenVersion.create({
|
||||
data: { vorlageId: kopie.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
|
||||
if (letzte.variablen.length > 0) {
|
||||
await tx.vorlagenVariable.createMany({
|
||||
data: letzte.variablen.map((variable, index) => ({
|
||||
versionId: version.id,
|
||||
schluessel: variable.schluessel,
|
||||
beschriftung: variable.beschriftung,
|
||||
typ: variable.typ,
|
||||
optionen: variable.optionen,
|
||||
pflicht: variable.pflicht,
|
||||
position: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
for (const [index, abschnitt] of letzte.abschnitte.entries()) {
|
||||
const abschnittKopie = await tx.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: abschnitt.titel, position: index },
|
||||
});
|
||||
if (abschnitt.schritte.length > 0) {
|
||||
await tx.vorlagenSchritt.createMany({
|
||||
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
|
||||
abschnittId: abschnittKopie.id,
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext,
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
konfig: schritt.konfig as Prisma.InputJsonValue,
|
||||
dokuUrl: schritt.dokuUrl,
|
||||
position: schrittIndex,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: kopie.id,
|
||||
aktion: "Vorlage dupliziert",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: { ausVorlage: quelle.name, version: letzte.version },
|
||||
nachher: { name },
|
||||
},
|
||||
});
|
||||
return kopie;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archivieren statt Loeschen.
|
||||
*
|
||||
* An einer Vorlage haengen Laeufe, und die muessen fuer immer nachvollziehbar
|
||||
* bleiben. Eine archivierte Vorlage erscheint nicht mehr in der Auswahl, ihre
|
||||
* Laeufe aber weiter im Archiv.
|
||||
*/
|
||||
export async function vorlageArchivieren(
|
||||
vorlageId: string,
|
||||
archiviert: boolean,
|
||||
durchBenutzerId: string,
|
||||
): Promise<void> {
|
||||
const vorlage = await prisma.vorlage.findUnique({ where: { id: vorlageId } });
|
||||
if (!vorlage) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
|
||||
if (vorlage.archiviert === archiviert) return;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.vorlage.update({ where: { id: vorlageId }, data: { archiviert } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: vorlageId,
|
||||
aktion: archiviert ? "Vorlage archiviert" : "Vorlage wieder freigegeben",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: { archiviert: vorlage.archiviert },
|
||||
nachher: { archiviert },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft, dass ein Lauf an dieser Version haengen darf.
|
||||
*
|
||||
* Steht hier, damit #013 die Regel „Laeufe verweisen immer auf eine
|
||||
* veroeffentlichte Version" nicht neu erfinden muss.
|
||||
*/
|
||||
export async function veroeffentlichteVersionPruefen(versionId: string): Promise<void> {
|
||||
const version = await prisma.vorlagenVersion.findUnique({
|
||||
where: { id: versionId },
|
||||
select: { status: true, version: true, vorlage: { select: { archiviert: true, name: true } } },
|
||||
});
|
||||
if (!version) throw new VorlagenFehler("Diese Version gibt es nicht.");
|
||||
if (version.status !== VersionStatus.VEROEFFENTLICHT) {
|
||||
throw new VorlagenFehler(
|
||||
"Ein Lauf braucht eine veröffentlichte Version. Version " + `${version.version} ist noch ein Entwurf.`,
|
||||
);
|
||||
}
|
||||
if (version.vorlage.archiviert) {
|
||||
throw new VorlagenFehler(`„${version.vorlage.name}“ ist archiviert und startet keine Läufe mehr.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Wächter über die Datenbankobjekte, die Prisma nicht kennt (#009).
|
||||
*
|
||||
* Zusammengesetzte Fremdschlüssel, Trigramm-Indizes und Trigger lassen sich im
|
||||
* Prisma-Schema nicht ausdrücken. Deshalb erzeugt `prisma migrate dev` bei
|
||||
* **jeder** künftigen Migration Löschbefehle für sie — sie sind aus Prismas
|
||||
* Sicht Abweichungen von der Vorgabe. Wer die Löschbefehle einmal übersieht,
|
||||
* entfernt stillschweigend die Mandantengrenze, die Archivsuche oder den Schutz
|
||||
* der Unveränderlichkeit.
|
||||
*
|
||||
* Dieser Test macht das laut. Er prüft nicht, dass die Regeln *wirken* — das tun
|
||||
* `tests/datenmodell.test.ts` und `tests/vorlagen.test.ts` —, sondern nur, dass
|
||||
* die Objekte überhaupt noch da sind.
|
||||
*/
|
||||
|
||||
async function namen(abfrage: string): Promise<string[]> {
|
||||
const zeilen = await testPrisma.$queryRawUnsafe<{ name: string }[]>(abfrage);
|
||||
return zeilen.map((zeile) => zeile.name).sort();
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
||||
it("hat die zusammengesetzten Fremdschlüssel der Mandantengrenze", async () => {
|
||||
// Ohne sie kann ein Lauf ein Gerät eines fremden Kunden ziehen.
|
||||
expect(
|
||||
await namen(`SELECT conname AS name FROM pg_constraint WHERE conname LIKE '%gleicher_kunde'`),
|
||||
).toEqual([
|
||||
"Geraet_besitzer_gleicher_kunde",
|
||||
"Lauf_abteilung_gleicher_kunde",
|
||||
"Lauf_geraet_gleicher_kunde",
|
||||
"Lauf_mitarbeiter_gleicher_kunde",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hat die Trigramm-Indizes für die Archivsuche", async () => {
|
||||
// Ohne sie wird jede Archivsuche zum vollständigen Durchlauf der Tabelle.
|
||||
expect(
|
||||
await namen(
|
||||
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%gin_trgm_ops%'`,
|
||||
),
|
||||
).toEqual([
|
||||
"Kommentar_rumpf_trgm_idx",
|
||||
"Lauf_titel_trgm_idx",
|
||||
"Schrittergebnis_notiz_trgm_idx",
|
||||
"Schrittergebnis_wert_trgm_idx",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hat die Trigger, die eine veröffentlichte Version schützen", async () => {
|
||||
expect(await namen(`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal`)).toEqual([
|
||||
"VorlagenAbschnitt_unveraenderlich",
|
||||
"VorlagenSchritt_unveraenderlich",
|
||||
"VorlagenVariable_unveraenderlich",
|
||||
"VorlagenVersion_status_einbahn",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hat die partiellen Eindeutigkeitsindizes", async () => {
|
||||
expect(
|
||||
await namen(
|
||||
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%WHERE%'`,
|
||||
),
|
||||
).toEqual(["Kunde_eigene_organisation_einmalig", "VorlagenVersion_ein_entwurf"]);
|
||||
});
|
||||
|
||||
it("hat alle Prüfbedingungen", async () => {
|
||||
const vorhanden = await namen(
|
||||
`SELECT conname AS name FROM pg_constraint WHERE contype='c' AND connamespace='public'::regnamespace`,
|
||||
);
|
||||
// Namentlich aufgezählt, damit auch das Verschwinden einer einzelnen auffällt.
|
||||
expect(vorhanden).toEqual([
|
||||
"Einstellung_einzelne_zeile",
|
||||
"Geraet_registriert_braucht_namen",
|
||||
"Geraet_tagcode_form",
|
||||
"Kunde_kuerzel_form",
|
||||
"Lauf_abgeschlossen_mit_zeitpunkt",
|
||||
"Lauf_archiviert_mit_zeitpunkt",
|
||||
"Lauf_begonnen_mit_zeitpunkt",
|
||||
"Lauf_mitarbeiter_eindeutig",
|
||||
"Schrittergebnis_begruendung_noetig",
|
||||
"TagCodeVergabe_form",
|
||||
"Verzeichnisquelle_bindpasswort_verschluesselt",
|
||||
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hat die Erweiterung für die Trigramm-Suche", async () => {
|
||||
const zeilen = await testPrisma.$queryRaw<{ extname: string }[]>`
|
||||
SELECT extname FROM pg_extension WHERE extname = 'pg_trgm'
|
||||
`;
|
||||
expect(zeilen).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,578 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { GRENZEN, type Austausch } from "@/lib/vorlagen-format";
|
||||
import {
|
||||
AustauschFehler,
|
||||
vorlageExportieren,
|
||||
vorlageImportieren,
|
||||
} from "@/server/services/vorlagen-austausch";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* JSON-Austausch fuer Vorlagen (#009).
|
||||
*
|
||||
* Der Rundlauf ist der eigentliche Prueffall: was exportiert und wieder
|
||||
* eingespielt wird, muss dasselbe sein. Alles andere hier haelt fest, was dabei
|
||||
* bewusst *nicht* mitwandert (Kennungen, Positionen, Kundenbezug) und was ein
|
||||
* Import mit einer krummen Datei tun soll.
|
||||
*/
|
||||
|
||||
/** Ein Befehl mit Platzhaltern – er darf den Rundlauf Zeichen fuer Zeichen ueberleben. */
|
||||
const BEFEHL = 'New-ADUser -Name "{{vorname}} {{nachname}}" -Department "{{abteilung}}"';
|
||||
|
||||
/** Urheber der Protokolleintraege; ohne echten Benutzer greift der Fremdschluessel. */
|
||||
async function redakteurAnlegen() {
|
||||
return testPrisma.benutzer.create({
|
||||
data: { email: "redaktion@firma.example", name: "Redaktion", rolle: "REDAKTEUR" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine veroeffentlichte Version mit zwei Abschnitten, Schritten verschiedener
|
||||
* Typen und zwei Variablen.
|
||||
*
|
||||
* Die Zeilen werden absichtlich in verkehrter Reihenfolge angelegt: dann zeigt
|
||||
* der Export, dass er nach `position` sortiert und nicht danach, wie die
|
||||
* Datenbank die Zeilen gerade zurueckgibt.
|
||||
*/
|
||||
async function beispielVersionAnlegen(): Promise<string> {
|
||||
const vorlage = await testPrisma.vorlage.create({
|
||||
data: {
|
||||
name: "Onboarding Notebook",
|
||||
beschreibung: "Neue Person, neues Gerät.",
|
||||
kategorie: "Onboarding",
|
||||
schlagworte: ["notebook", "eintritt"],
|
||||
},
|
||||
});
|
||||
// Erst als Entwurf anlegen und befuellen, dann veroeffentlichen: an einer
|
||||
// veroeffentlichten Version laesst sich nichts mehr anhaengen, dafuer sorgen
|
||||
// Trigger in der Datenbank.
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 3, status: "ENTWURF" },
|
||||
});
|
||||
|
||||
await testPrisma.vorlagenVariable.create({
|
||||
data: {
|
||||
versionId: version.id,
|
||||
schluessel: "abteilung",
|
||||
beschriftung: "Abteilung",
|
||||
typ: "AUSWAHL",
|
||||
optionen: ["IT", "Vertrieb"],
|
||||
pflicht: false,
|
||||
position: 1,
|
||||
},
|
||||
});
|
||||
await testPrisma.vorlagenVariable.create({
|
||||
data: {
|
||||
versionId: version.id,
|
||||
schluessel: "vorname",
|
||||
beschriftung: "Vorname",
|
||||
typ: "TEXT",
|
||||
pflicht: true,
|
||||
position: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const zweiter = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "02 · Konto", position: 1 },
|
||||
});
|
||||
const erster = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "01 · Vorbereitung", position: 0 },
|
||||
});
|
||||
|
||||
await testPrisma.vorlagenSchritt.createMany({
|
||||
data: [
|
||||
{
|
||||
abschnittId: erster.id,
|
||||
titel: "Gerät auspacken",
|
||||
typ: "HAKEN",
|
||||
pflicht: true,
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
abschnittId: erster.id,
|
||||
titel: "Seriennummer erfassen",
|
||||
hilfetext: "Aufkleber auf der Unterseite.",
|
||||
typ: "TEXT",
|
||||
pflicht: true,
|
||||
konfig: { mehrzeilig: false },
|
||||
dokuUrl: "https://intern.example/seriennummern",
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
abschnittId: erster.id,
|
||||
titel: "Arbeitsspeicher prüfen",
|
||||
typ: "ZAHL",
|
||||
konfig: { min: 8, max: 64, einheit: "GB" },
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
await testPrisma.vorlagenSchritt.createMany({
|
||||
data: [
|
||||
{
|
||||
abschnittId: zweiter.id,
|
||||
titel: "Benutzerkonto anlegen",
|
||||
typ: "BEFEHL",
|
||||
pflicht: true,
|
||||
// Ein unbekannter Schluessel: das Schema ist nachsichtig, damit ein
|
||||
// Export aus einer spaeteren Fassung hier nichts verliert.
|
||||
konfig: { shell: "powershell", befehl: BEFEHL, kuenftigesFeld: { tief: [1, 2] } },
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
abschnittId: zweiter.id,
|
||||
titel: "Eintrittsdatum hinterlegen",
|
||||
typ: "DATUM",
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
return version.id;
|
||||
}
|
||||
|
||||
/** Das kleinste Dokument, das durchgeht – Ausgangspunkt fuer die Fehlerfaelle. */
|
||||
function dokument(aenderung: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
format: 1,
|
||||
name: "Kleine Vorlage",
|
||||
struktur: {
|
||||
variablen: [{ schluessel: "vorname", beschriftung: "Vorname" }],
|
||||
abschnitte: [{ titel: "01 · Start", schritte: [{ titel: "Anfangen" }] }],
|
||||
},
|
||||
...aenderung,
|
||||
};
|
||||
}
|
||||
|
||||
/** Der Rundlauf darf sich in `version` unterscheiden – alles andere nicht. */
|
||||
function ohneVersion(austausch: Austausch): Omit<Austausch, "version"> {
|
||||
const kopie: Omit<Austausch, "version"> & { version?: number } = { ...austausch };
|
||||
delete kopie.version;
|
||||
return kopie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diese Faelle scheitern am Schema, also noch vor dem ersten Datenbankzugriff –
|
||||
* deshalb laufen sie auch ohne Testdatenbank.
|
||||
*/
|
||||
describe("Import: Pruefung des Dokuments", () => {
|
||||
it("weist ein fehlendes oder falsches Format ab", async () => {
|
||||
const ohneFormat = dokument();
|
||||
delete ohneFormat["format"];
|
||||
await expect(vorlageImportieren(ohneFormat, "wer-auch-immer")).rejects.toThrow(AustauschFehler);
|
||||
await expect(vorlageImportieren(dokument({ format: 2 }), "wer")).rejects.toThrow(/Format 1/);
|
||||
await expect(vorlageImportieren("kein Objekt", "wer")).rejects.toThrow(AustauschFehler);
|
||||
});
|
||||
|
||||
it("nennt bei einem leeren Schritt-Titel den Pfad", async () => {
|
||||
const kaputt = dokument({
|
||||
struktur: {
|
||||
variablen: [],
|
||||
abschnitte: [
|
||||
{ titel: "01 · Start", schritte: [{ titel: "Anfangen" }] },
|
||||
{
|
||||
titel: "02 · Konto",
|
||||
schritte: [{ titel: "Erledigen" }, { titel: "Prüfen" }, { titel: " " }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(vorlageImportieren(kaputt, "wer")).rejects.toThrow(
|
||||
"struktur.abschnitte.1.schritte.2.titel: Ein Schritt braucht einen Titel.",
|
||||
);
|
||||
});
|
||||
|
||||
it("weist doppelte Variablenschluessel ab und nennt den Schluessel", async () => {
|
||||
const doppelt = dokument({
|
||||
struktur: {
|
||||
variablen: [
|
||||
{ schluessel: "vorname", beschriftung: "Vorname" },
|
||||
{ schluessel: "vorname", beschriftung: "Rufname" },
|
||||
],
|
||||
abschnitte: [],
|
||||
},
|
||||
});
|
||||
await expect(vorlageImportieren(doppelt, "wer")).rejects.toThrow(/„vorname“ gibt es zweimal/);
|
||||
});
|
||||
|
||||
it("weist ungueltige Variablenschluessel ab", async () => {
|
||||
// Der Schluessel steht als {{schluessel}} in Texten; ein Grossbuchstabe oder
|
||||
// ein Strich liesse sich dort nicht sauber wiedererkennen.
|
||||
for (const schluessel of ["Vorname", "mit-strich", "", "1vorname", "mit punkt"]) {
|
||||
const falsch = dokument({
|
||||
struktur: { variablen: [{ schluessel, beschriftung: "Egal" }], abschnitte: [] },
|
||||
});
|
||||
await expect(vorlageImportieren(falsch, "wer")).rejects.toThrow(AustauschFehler);
|
||||
}
|
||||
});
|
||||
|
||||
it("weist einen Doku-Link ab, der kein http ist", async () => {
|
||||
// Der Doku-Link landet in der Abarbeitungsansicht als `href`. Ein
|
||||
// `javascript:`-Link darin waere fremder Code im Browser des Technikers –
|
||||
// eingespielt ueber eine Datei, die ein Kunde geschickt hat.
|
||||
for (const link of [
|
||||
"javascript:alert(1)",
|
||||
"JavaScript:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"file:///etc/passwd",
|
||||
"intern.example/doku",
|
||||
]) {
|
||||
const kaputt = dokument({
|
||||
struktur: {
|
||||
variablen: [],
|
||||
abschnitte: [{ titel: "Start", schritte: [{ titel: "Anfangen", dokuUrl: link }] }],
|
||||
},
|
||||
});
|
||||
await expect(vorlageImportieren(kaputt, "wer"), link).rejects.toThrow(/http/);
|
||||
}
|
||||
});
|
||||
|
||||
it("nimmt http und https an", async () => {
|
||||
for (const link of ["http://intern.example/doku", "https://intern.example/doku"]) {
|
||||
const gut = dokument({
|
||||
struktur: {
|
||||
variablen: [],
|
||||
abschnitte: [{ titel: "Start", schritte: [{ titel: "Anfangen", dokuUrl: link }] }],
|
||||
},
|
||||
});
|
||||
// Ohne Datenbank kommt es bis zum Schreiben; abgewiesen wird es nicht.
|
||||
await expect(vorlageImportieren(gut, "wer")).rejects.not.toThrow(/http/);
|
||||
}
|
||||
});
|
||||
|
||||
it("weist ein Dokument ab, das die Mengengrenzen sprengt", async () => {
|
||||
// Eine Datei mit 100 000 Schritten ist kein Import, sondern ein
|
||||
// Erschoepfungsangriff auf die Datenbank.
|
||||
const zuViele = dokument({
|
||||
struktur: {
|
||||
variablen: [],
|
||||
abschnitte: Array.from({ length: GRENZEN.abschnitte + 1 }, (_, i) => ({
|
||||
titel: `Abschnitt ${i}`,
|
||||
schritte: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
await expect(vorlageImportieren(zuViele, "wer")).rejects.toThrow(
|
||||
new RegExp(`${GRENZEN.abschnitte} Abschnitte`),
|
||||
);
|
||||
|
||||
const zuLang = dokument({
|
||||
struktur: {
|
||||
variablen: [],
|
||||
abschnitte: [{ titel: "Start", schritte: [{ titel: "x".repeat(GRENZEN.text + 1) }] }],
|
||||
},
|
||||
});
|
||||
await expect(vorlageImportieren(zuLang, "wer")).rejects.toThrow(AustauschFehler);
|
||||
});
|
||||
|
||||
it("weist einen unbekannten Schritt-Typ ab", async () => {
|
||||
// Ein unbekannter Typ wuerde in der Abarbeitung zu einem Schritt ohne
|
||||
// Eingabefeld – der Techniker koennte ihn nie erledigen.
|
||||
const kaputt = dokument({
|
||||
struktur: {
|
||||
variablen: [],
|
||||
abschnitte: [{ titel: "Start", schritte: [{ titel: "Anfangen", typ: "AKTION" }] }],
|
||||
},
|
||||
});
|
||||
await expect(vorlageImportieren(kaputt, "wer")).rejects.toThrow(AustauschFehler);
|
||||
});
|
||||
|
||||
it("nennt bei einem ungueltigen Schluessel den Pfad und die Regel", async () => {
|
||||
const falsch = dokument({
|
||||
struktur: { variablen: [{ schluessel: "Vorname", beschriftung: "Egal" }], abschnitte: [] },
|
||||
});
|
||||
await expect(vorlageImportieren(falsch, "wer")).rejects.toThrow(
|
||||
/^struktur\.variablen\.0\.schluessel: Ein Variablenschlüssel beginnt/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Vorlagen-Austausch", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
describe("Export", () => {
|
||||
it("liefert Abschnitte, Schritte und Variablen in der Reihenfolge der Position", async () => {
|
||||
const versionId = await beispielVersionAnlegen();
|
||||
const austausch = await vorlageExportieren(versionId);
|
||||
|
||||
expect(austausch.format).toBe(1);
|
||||
expect(austausch.name).toBe("Onboarding Notebook");
|
||||
expect(austausch.kategorie).toBe("Onboarding");
|
||||
expect(austausch.schlagworte).toEqual(["notebook", "eintritt"]);
|
||||
expect(austausch.version).toBe(3);
|
||||
|
||||
expect(austausch.struktur.variablen.map((v) => v.schluessel)).toEqual(["vorname", "abteilung"]);
|
||||
expect(austausch.struktur.abschnitte.map((a) => a.titel)).toEqual(["01 · Vorbereitung", "02 · Konto"]);
|
||||
expect(austausch.struktur.abschnitte[0]?.schritte.map((s) => s.titel)).toEqual([
|
||||
"Gerät auspacken",
|
||||
"Seriennummer erfassen",
|
||||
"Arbeitsspeicher prüfen",
|
||||
]);
|
||||
expect(austausch.struktur.abschnitte[1]?.schritte.map((s) => s.typ)).toEqual(["BEFEHL", "DATUM"]);
|
||||
});
|
||||
|
||||
it("enthaelt keine Kennungen, Positionen und keinen Kundenbezug", async () => {
|
||||
const versionId = await beispielVersionAnlegen();
|
||||
const austausch = await vorlageExportieren(versionId);
|
||||
const rohtext = JSON.stringify(austausch);
|
||||
|
||||
expect(rohtext).not.toContain(versionId);
|
||||
for (const feld of ['"id"', "versionId", "abschnittId", "position", "kundeId", "erstelltAm"]) {
|
||||
expect(rohtext).not.toContain(feld);
|
||||
}
|
||||
});
|
||||
|
||||
it("wirft bei einer unbekannten Version", async () => {
|
||||
await expect(vorlageExportieren("gibt-es-nicht")).rejects.toThrow(AustauschFehler);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Import", () => {
|
||||
it("legt eine neue Vorlage mit Version 1 im Status ENTWURF an", async () => {
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const quelle = await beispielVersionAnlegen();
|
||||
const austausch = await vorlageExportieren(quelle);
|
||||
|
||||
const { vorlageId, versionId } = await vorlageImportieren(austausch, redakteur.id);
|
||||
|
||||
// Die Quelle bleibt unberuehrt, es gibt jetzt zwei Vorlagen.
|
||||
expect(await testPrisma.vorlage.count()).toBe(2);
|
||||
const version = await testPrisma.vorlagenVersion.findUniqueOrThrow({
|
||||
where: { id: versionId },
|
||||
});
|
||||
expect(version.vorlageId).toBe(vorlageId);
|
||||
expect(version.version).toBe(1);
|
||||
expect(version.status).toBe("ENTWURF");
|
||||
expect(version.veroeffentlichtAm).toBeNull();
|
||||
expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId } })).toBe(1);
|
||||
});
|
||||
|
||||
it("leitet die Positionen aus der Reihenfolge der Listen ab", async () => {
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const { versionId } = await vorlageImportieren(
|
||||
dokument({
|
||||
struktur: {
|
||||
variablen: [
|
||||
{ schluessel: "vorname", beschriftung: "Vorname" },
|
||||
{ schluessel: "nachname", beschriftung: "Nachname" },
|
||||
],
|
||||
abschnitte: [
|
||||
{ titel: "01 · Start", schritte: [{ titel: "Eins" }, { titel: "Zwei" }] },
|
||||
{ titel: "02 · Ende", schritte: [{ titel: "Drei" }] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
redakteur.id,
|
||||
);
|
||||
|
||||
const variablen = await testPrisma.vorlagenVariable.findMany({
|
||||
where: { versionId },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
expect(variablen.map((v) => [v.schluessel, v.position])).toEqual([
|
||||
["vorname", 0],
|
||||
["nachname", 1],
|
||||
]);
|
||||
|
||||
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
|
||||
where: { versionId },
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: { orderBy: { position: "asc" } } },
|
||||
});
|
||||
expect(abschnitte.map((a) => a.position)).toEqual([0, 1]);
|
||||
expect(abschnitte[0]?.schritte.map((s) => [s.titel, s.position])).toEqual([
|
||||
["Eins", 0],
|
||||
["Zwei", 1],
|
||||
]);
|
||||
expect(abschnitte[1]?.schritte.map((s) => s.position)).toEqual([0]);
|
||||
});
|
||||
|
||||
it("nimmt einen Namen, den es schon gibt, ohne Murren an", async () => {
|
||||
const redakteur = await redakteurAnlegen();
|
||||
await vorlageImportieren(dokument(), redakteur.id);
|
||||
await vorlageImportieren(dokument(), redakteur.id);
|
||||
|
||||
const gleichnamige = await testPrisma.vorlage.findMany({ where: { name: "Kleine Vorlage" } });
|
||||
expect(gleichnamige).toHaveLength(2);
|
||||
// Kein angehaengtes "(2)": der Name kommt so an, wie er im Dokument steht.
|
||||
expect(gleichnamige.map((v) => v.name)).toEqual(["Kleine Vorlage", "Kleine Vorlage"]);
|
||||
});
|
||||
|
||||
it("schreibt einen Protokolleintrag", async () => {
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const { vorlageId } = await vorlageImportieren(dokument(), redakteur.id);
|
||||
|
||||
const eintraege = await testPrisma.protokoll.findMany({
|
||||
where: { entitaet: "Vorlage", entitaetId: vorlageId },
|
||||
});
|
||||
expect(eintraege).toHaveLength(1);
|
||||
expect(eintraege[0]?.aktion).toBe("Vorlage importiert");
|
||||
expect(eintraege[0]?.benutzerId).toBe(redakteur.id);
|
||||
});
|
||||
|
||||
it("hinterlaesst nach einem Fehlschlag keine halbe Vorlage", async () => {
|
||||
// Ein unbekannter Benutzer laesst den Protokolleintrag am Fremdschluessel
|
||||
// scheitern – also erst, nachdem Vorlage, Version und Schritte geschrieben
|
||||
// sind. Genau dieser Fall zeigt, dass die Transaktion greift.
|
||||
await expect(vorlageImportieren(dokument(), "gibt-es-nicht")).rejects.toThrow();
|
||||
|
||||
expect(await testPrisma.vorlage.count()).toBe(0);
|
||||
expect(await testPrisma.vorlagenVersion.count()).toBe(0);
|
||||
expect(await testPrisma.vorlagenAbschnitt.count()).toBe(0);
|
||||
expect(await testPrisma.vorlagenSchritt.count()).toBe(0);
|
||||
expect(await testPrisma.vorlagenVariable.count()).toBe(0);
|
||||
expect(await testPrisma.protokoll.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("schreibt nichts, wenn das Dokument abgewiesen wird", async () => {
|
||||
await expect(vorlageImportieren(dokument({ format: 99 }), "wer")).rejects.toThrow(AustauschFehler);
|
||||
expect(await testPrisma.vorlage.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rundlauf", () => {
|
||||
it("ergibt ein inhaltsgleiches Dokument – bis auf die Version", async () => {
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const quelle = await beispielVersionAnlegen();
|
||||
|
||||
const erst = await vorlageExportieren(quelle);
|
||||
const { versionId } = await vorlageImportieren(erst, redakteur.id);
|
||||
const wieder = await vorlageExportieren(versionId);
|
||||
|
||||
expect(ohneVersion(wieder)).toEqual(ohneVersion(erst));
|
||||
// Die Quelle stand auf Version 3, die eingespielte Vorlage faengt bei 1 an.
|
||||
expect(erst.version).toBe(3);
|
||||
expect(wieder.version).toBe(1);
|
||||
});
|
||||
|
||||
it("laesst einen BEFEHL-Schritt samt Platzhaltern unveraendert", async () => {
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const quelle = await beispielVersionAnlegen();
|
||||
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
|
||||
|
||||
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[1]?.schritte[0];
|
||||
expect(schritt?.typ).toBe("BEFEHL");
|
||||
expect(schritt?.konfig["shell"]).toBe("powershell");
|
||||
expect(schritt?.konfig["befehl"]).toBe(BEFEHL);
|
||||
});
|
||||
|
||||
it("bewahrt unbekannte konfig-Schluessel", async () => {
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const quelle = await beispielVersionAnlegen();
|
||||
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
|
||||
|
||||
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[1]?.schritte[0];
|
||||
expect(schritt?.konfig["kuenftigesFeld"]).toEqual({ tief: [1, 2] });
|
||||
});
|
||||
|
||||
it("bewahrt Hilfetext, Doku-Link und Zahlengrenzen", async () => {
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const quelle = await beispielVersionAnlegen();
|
||||
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
|
||||
|
||||
const schritte = (await vorlageExportieren(versionId)).struktur.abschnitte[0]?.schritte;
|
||||
expect(schritte?.[1]?.hilfetext).toBe("Aufkleber auf der Unterseite.");
|
||||
expect(schritte?.[1]?.dokuUrl).toBe("https://intern.example/seriennummern");
|
||||
expect(schritte?.[2]?.konfig).toEqual({ min: 8, max: 64, einheit: "GB" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Export unsauberer Daten", () => {
|
||||
/** Legt einen einzelnen Schritt mit frei gewaehltem `konfig` an. */
|
||||
async function schrittMitKonfig(konfig: unknown): Promise<string> {
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Krumm" } });
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
|
||||
});
|
||||
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Abschnitt" },
|
||||
});
|
||||
await testPrisma.vorlagenSchritt.create({
|
||||
data: {
|
||||
abschnittId: abschnitt.id,
|
||||
titel: "Schritt",
|
||||
typ: "BEFEHL",
|
||||
konfig: konfig as never,
|
||||
},
|
||||
});
|
||||
return version.id;
|
||||
}
|
||||
|
||||
it("verliert bei einer unbekannten Shell nicht den Befehl", async () => {
|
||||
// Das war ein echter Befund: `safeParse` verwarf bei jeder Beanstandung
|
||||
// das ganze `konfig` – eine Shell aus einer spaeteren Fassung kostete
|
||||
// lautlos auch den Befehl. Genau die Datei, die jemand zum Umzug braucht.
|
||||
const versionId = await schrittMitKonfig({ shell: "zsh", befehl: "echo hallo" });
|
||||
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[0]?.schritte[0];
|
||||
expect(schritt?.konfig["befehl"]).toBe("echo hallo");
|
||||
expect(schritt?.konfig["shell"]).toBe("zsh");
|
||||
});
|
||||
|
||||
it("liefert für konfig-Werte, die gar kein Objekt sind, ein leeres Objekt", async () => {
|
||||
for (const krumm of ["kaputt", 42, [1, 2], true]) {
|
||||
const versionId = await schrittMitKonfig(krumm);
|
||||
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[0]?.schritte[0];
|
||||
expect(schritt?.konfig, JSON.stringify(krumm)).toEqual({});
|
||||
await datenbankLeeren();
|
||||
}
|
||||
});
|
||||
|
||||
it("lässt sich mit unbekanntem konfig-Schlüssel wieder einspielen", async () => {
|
||||
// Der Export muss nicht nur gelingen, er muss auch durch die Pruefung des
|
||||
// Imports kommen – sonst waere er eine Datei, die niemand nutzen kann.
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const versionId = await schrittMitKonfig({ shell: "bash", befehl: "echo", tief: null });
|
||||
const { versionId: neu } = await vorlageImportieren(await vorlageExportieren(versionId), redakteur.id);
|
||||
const schritt = (await vorlageExportieren(neu)).struktur.abschnitte[0]?.schritte[0];
|
||||
expect(schritt?.konfig["befehl"]).toBe("echo");
|
||||
expect(schritt?.konfig["tief"]).toBeNull();
|
||||
});
|
||||
|
||||
it("weist eine unbekannte Shell beim Einspielen laut ab", async () => {
|
||||
// Hier laufen zwei Regeln absichtlich auseinander, und das ist die
|
||||
// Entscheidung: der **Export** gibt eine unbekannte Shell heraus, damit die
|
||||
// Datei nichts verliert; der **Import** weist sie ab, weil `shell` darueber
|
||||
// entscheidet, wie ein Wert maskiert wird. Eine stillschweigend verworfene
|
||||
// Shell hiesse, einen bash-Befehl nach PowerShell-Regeln zu maskieren – das
|
||||
// kann auf einem Server Daten kosten. Die Meldung nennt Pfad und erlaubte
|
||||
// Werte, also kann der Redakteur die Datei richtigstellen.
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const versionId = await schrittMitKonfig({ shell: "zsh", befehl: "echo hallo" });
|
||||
const dokument = await vorlageExportieren(versionId);
|
||||
|
||||
await expect(vorlageImportieren(dokument, redakteur.id)).rejects.toThrow(/schritte\.0\.konfig\.shell/);
|
||||
await expect(vorlageImportieren(dokument, redakteur.id)).rejects.toThrow(/powershell/);
|
||||
// Nur die eine Quellvorlage steht da, keine halb eingespielte zweite.
|
||||
expect(await testPrisma.vorlage.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("weist einen bereits gespeicherten javascript-Link beim Wiedereinspielen ab", async () => {
|
||||
// Aeltere Zeilen koennten so einen Link tragen, denn die Pruefung kam erst
|
||||
// mit #009. Der Export gibt ihn heraus, der Import faengt ihn – die Datei
|
||||
// ist dann ein sichtbarer Fehlerfall und kein stiller Durchlaeufer.
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Alt" } });
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
|
||||
});
|
||||
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Abschnitt" },
|
||||
});
|
||||
await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Schritt", dokuUrl: "javascript:alert(1)" },
|
||||
});
|
||||
|
||||
const dokument = await vorlageExportieren(version.id);
|
||||
expect(dokument.struktur.abschnitte[0]?.schritte[0]?.dokuUrl).toBe("javascript:alert(1)");
|
||||
await expect(vorlageImportieren(dokument, redakteur.id)).rejects.toThrow(/http/);
|
||||
expect(await testPrisma.vorlage.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user