Compare commits
13
Commits
v0.1.0
...
2d3325ef20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d3325ef20 | ||
|
|
4aa79c93ff | ||
|
|
3cbd5026f8 | ||
|
|
855164066c | ||
|
|
80870133b8 | ||
|
|
b688e2ebc8 | ||
|
|
7c9fc4eb21 | ||
|
|
e1f244e4e8 | ||
|
|
f5936327a0 | ||
|
|
0060a55387 | ||
|
|
0fa41b9e08 | ||
|
|
88d7db8c3d | ||
|
|
9027229b55 |
@@ -19,6 +19,34 @@ APP_SECRET=
|
||||
#
|
||||
# DATABASE_URL gilt fuer alles, was ausserhalb der Container laeuft: `pnpm dev`,
|
||||
# `pnpm prisma …`, `pnpm db:seed` von der Werkbank aus. Standard ist die
|
||||
# ---- Anhaenge (#032) -------------------------------------------------------
|
||||
# Wo die Dateien der Anhaenge liegen. Im Compose-Stand setzt docker-compose.yml
|
||||
# das selbst auf /daten/anhaenge im Volume `anhaenge`; hier gilt es fuer die
|
||||
# Entwicklung und die native Installation.
|
||||
#
|
||||
# **Gehoert in jede Sicherung.** In der Datenbank stehen nur die Metadaten.
|
||||
ANHAENGE_PFAD=./daten/anhaenge
|
||||
# Hoechstgroesse je Datei in MB und Hoechstzahl Anhaenge je Lauf.
|
||||
ANHANG_MAX_MB=10
|
||||
ANHAENGE_JE_LAUF=20
|
||||
#
|
||||
# **Wer das anhebt, hebt `client_max_body_size` im Reverse Proxy mit an.** Sonst
|
||||
# weist nginx die Datei mit 413 ab, bevor die Anwendung ihre eigene, freundliche
|
||||
# Meldung zeigen kann — siehe docs/betrieb/reverse-proxy.md.
|
||||
|
||||
# ---- Live-Aktualisierung (#033) --------------------------------------------
|
||||
# Die Abarbeitungsansicht haelt einen Ereignisstrom offen, statt alle zehn
|
||||
# Sekunden zu fragen. Beide Werte brauchen im Normalfall niemand anzufassen.
|
||||
#
|
||||
# Abstand der Herzschlaege in Millisekunden. Muss **unter** der Leerlaufzeit des
|
||||
# Reverse Proxy liegen (nginx und Caddy: 60 s), sonst schliesst der die stille
|
||||
# Verbindung.
|
||||
SSE_HERZSCHLAG_MS=25000
|
||||
# Hoechstzahl gleichzeitig offener Stroeme je Instanz. Ist sie erreicht, fallen
|
||||
# weitere Laschen von sich aus auf das Polling zurueck — die Seite bleibt
|
||||
# bedienbar, nur nicht mehr sofort aktuell.
|
||||
SSE_MAX_STROEME=200
|
||||
|
||||
# Entwicklungsdatenbank aus docker-compose.dev.yml (Port 5434, umstellbar ueber DEV_DB_PORT).
|
||||
POSTGRES_USER=laufzettel
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
@@ -28,6 +28,10 @@ blob-report/
|
||||
# Release liegt es bei, so steht es im Auftrag.
|
||||
/docs/anleitung/*.pdf
|
||||
|
||||
# Hochgeladene Anhaenge (#032). Nutzdaten, nie Quellcode – in der Entwicklung
|
||||
# landen sie unter `daten/anhaenge`, im Betrieb ausserhalb des Arbeitsbaums.
|
||||
/daten/
|
||||
|
||||
# Werkzeuge
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
|
||||
+123
@@ -5,6 +5,125 @@ Alle nennenswerten Änderungen an Laufzettel werden in dieser Datei dokumentiert
|
||||
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
|
||||
und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Die Abarbeitungsansicht aktualisiert sich jetzt sofort statt alle zehn Sekunden.** Hakt ein
|
||||
Kollege einen Schritt ab, schreibt einen Kommentar, hängt ein Foto an oder ändert den Titel,
|
||||
erscheint das beim anderen in weniger als einer halben Sekunde — gemessen 485 ms, vorher bis zu
|
||||
zehn Sekunden. Dahinter stehen vier Datenbank-Trigger mit `NOTIFY` und ein Ereignisstrom; das
|
||||
funktioniert auch mit mehreren Instanzen der Anwendung, ohne einen zweiten Dienst. Zwanzig offene
|
||||
Laschen kosten jetzt nichts mehr, statt zwanzig Abfragen je zehn Sekunden.
|
||||
**Das Polling bleibt als Rückfall** an derselben Adresse, mit derselben Rechteprüfung: wer kein
|
||||
`EventSource` hat oder hinter einem Proxy sitzt, der Ströme puffert, bekommt den alten Weg. Neu in
|
||||
der Umgebung: `SSE_HERZSCHLAG_MS` (25000) und `SSE_MAX_STROEME` (200) (#033)
|
||||
- Der Zustand eines Laufs trägt jetzt auch die Zahl der **Anhänge**. Ohne sie wäre ein angehängtes
|
||||
Foto für die zweite Sitzung unsichtbar geblieben — die Anhänge kamen in #032 dazu, der Zustand
|
||||
kannte sie nicht (#033)
|
||||
- **`docs/betrieb/sicherung.md` ist neu geschrieben.** Dort stand „Kein Upload-Verzeichnis, keine
|
||||
erzeugten Dateien“ — mit den Anhängen ist das falsch, und eine Sicherung nach der alten Anleitung
|
||||
hätte ab #032 still Daten verloren: in der Datenbank stehen nur die Metadaten. Jetzt sind es drei
|
||||
Dinge statt zwei, mit `tar` für beide Betriebswege, dem `chown`-Fallstrick nach einer
|
||||
Rücksicherung als `root`, einem Sicherungsskript, das Datenbank und Anhänge im **selben** Lauf
|
||||
mitnimmt, und einer Gegenprobe über die Zahlen. Sicherung und Rückspielung sind einmal wirklich
|
||||
gefahren (#032)
|
||||
- Der Seed macht Petra Klein zur Vorgesetzten von Maria Huber — die Zuordnung aus dem Auftrag, die
|
||||
sich vorher nirgends speichern ließ (#020)
|
||||
- Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne
|
||||
Anmeldung erreichbar, und eine Meldung von Prisma oder `pg` nennt Host, Port, Benutzernamen und
|
||||
Datenbanknamen. Nach außen steht jetzt nur noch `{"status":"fehler"}` mit Status 503; die
|
||||
Begründung geht ins Protokoll des Prozesses (#039)
|
||||
- Die Konvention „nie auf rohe Hexwerte“ nennt ihre sechs begründeten Ausnahmen beim Namen —
|
||||
Druckansichten, QR-Code und `themeColor` entscheiden nicht nach unserem Farbschema. Die Regel war
|
||||
strenger formuliert als gemeint (#040)
|
||||
- `CLAUDE.md` und `README.md` nennen den LDAP-Testcontainer als Teil des Tors vor dem Commit. Ohne
|
||||
ihn überspringt die Suite **22 Tests** — den ganzen Verzeichnis-Abgleich —, sagt das auf stderr,
|
||||
und der Lauf sieht trotzdem grün aus (#040)
|
||||
- Im README fehlte `format:pruefen` in den Qualitätsschranken, obwohl `CLAUDE.md` es seit #010
|
||||
verlangt (#040)
|
||||
|
||||
### Behoben
|
||||
|
||||
- **nginx hätte ein 10-MB-Foto abgewiesen.** `docs/betrieb/reverse-proxy.md` empfahl
|
||||
`client_max_body_size 8m`, während `ANHANG_MAX_MB` 10 MB erlaubt: ein Handyfoto am oberen Rand
|
||||
wäre mit „413 Request Entity Too Large“ an der Wand gelandet, und die freundliche Meldung der
|
||||
Anwendung wäre nie zum Zuge gekommen. Jetzt 12m, mit dem Hinweis, dass beide Werte zusammengehören
|
||||
(#033)
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Benachrichtigungen per Mail und Webhook.** Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand
|
||||
die Übersicht öffnete; ein überfälliger überhaupt nicht. Vier Anlässe gehen jetzt hinaus —
|
||||
**zugewiesen**, **überfällig**, **abgeschlossen** und **Verzeichnisabgleich fehlgeschlagen** —, je
|
||||
Person abschaltbar, und **niemand bekommt Post über die eigene Tat**. Mail über SMTP (Zugangsdaten
|
||||
verschlüsselt wie das LDAP-Bind-Passwort, mit „Testmail schicken“), dazu optional ein Webhook mit
|
||||
HMAC-SHA256-Signatur im Kopf `X-Laufzettel-Signatur`.
|
||||
Zugestellt wird aus einer **Warteschlange in der Datenbank**, nicht im Anfrage-Pfad: ein hängender
|
||||
Mailserver darf kein Abhaken blockieren. Fehlschläge werden nach 1, 5, 30 und 120 Minuten
|
||||
wiederholt und bleiben danach als **aufgegeben** sichtbar — unter Verwaltung → Zustellungen, denn
|
||||
eine stille Warteschlange ist schlimmer als keine.
|
||||
**In keiner Nachricht steht ein Schrittinhalt**, und damit auch kein erzeugtes Passwort: geprüft
|
||||
über alle Rumpftexte, die wirklich verschickt wurden (#034)
|
||||
- **Fotos und Dateien an einem Schritt.** „Seriennummer fotografiert“, „Typenschild“, „Zustand bei
|
||||
Übergabe“ — manches lässt sich nicht abhaken, es muss gezeigt werden. Ein Schritt nimmt jetzt
|
||||
Bilder (JPEG, PNG, WebP, HEIC) und PDF auf, am Handy direkt aus der Kamera. Die Dateien liegen im
|
||||
Dateisystem unter `ANHAENGE_PFAD`, nach Kunde und Lauf gegliedert; in der Datenbank stehen nur
|
||||
Name, Typ, Größe und Prüfsumme. Jeder Abruf prüft Recht und Mandantengrenze — auch der des Bildes
|
||||
selbst. Grenzen über `ANHANG_MAX_MB` (10) und `ANHAENGE_JE_LAUF` (20), und eine abgewiesene Datei
|
||||
nennt den Grund. Kein SVG: das ist ein Dokument, das Skripte ausführt. Der Archiv-Schreibschutz
|
||||
gilt auch hier, und die Druckansicht zeigt Bilder als Miniatur, ein PDF als Zeile (#032)
|
||||
- **Abschnitte nur für bestimmte Abteilungen.** Beim Onboarding in der Buchhaltung gehören Schritte
|
||||
dazu, die sonst niemand braucht. Bisher hieß die Wahl: eine Vorlage je Abteilung pflegen, oder
|
||||
Schritte, die für die meisten „nicht zutreffend“ sind. Jeder Abschnitt kann jetzt sagen, für welche
|
||||
Abteilungen er gilt; gespeichert werden **Namen**, damit ein Umbenennen die Vorlage nicht
|
||||
beschädigt. Welche Abschnitte ein Lauf hat, wird **beim Start festgeschrieben** — ein späterer
|
||||
Abteilungswechsel ändert ihn nicht, und ein ausgefilterter Pflichtschritt blockiert den Abschluss
|
||||
nicht. Der Start-Dialog sagt vorher, was die gewählte Abteilung hinzunimmt (#031)
|
||||
- **Wiederverwendbare Bausteine.** Ein Abschnitt, den mehrere Vorlagen teilen und der an einer
|
||||
Stelle gepflegt wird — „Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen,
|
||||
wortgleich. Versioniert nach denselben Regeln wie eine Vorlage: höchstens ein Entwurf, eine
|
||||
veröffentlichte Fassung wird nie verändert. Eingesetzt wird ein Baustein im Editor; beim
|
||||
**Veröffentlichen** einer Vorlage setzt der Dienst seine Schritte als tiefe Kopie ein. Damit
|
||||
ändert eine neue Baustein-Fassung **keine** veröffentlichte Vorlagenversion und **keinen**
|
||||
laufenden Lauf — sie wirkt erst in der nächsten veröffentlichten Version. Auf der Seite eines
|
||||
Bausteins steht, in welchen Vorlagen er vorkommt und welche Fassung dort eingesetzt ist (#030)
|
||||
- **Das Protokoll ist ansehbar**, unter Verwaltung → Protokoll. Geschrieben wurde es seit dem
|
||||
Datenmodell, ansehen konnte es niemand: wer wissen wollte, wer wann eine Rolle geändert hat,
|
||||
brauchte `psql`. Jetzt mit Volltext über Aktion und Lauftitel, Filter nach Urheber und Zeitraum,
|
||||
fünfzig Einträge je Seite. Ein Eintrag ohne Urheber wird als **Systemlauf** gezeigt und nicht als
|
||||
Lücke — der Zeitplan des Verzeichnisabgleichs hat keinen Menschen hinter sich. Am Lauf selbst
|
||||
stehen seine Einträge eingeklappt in der Abarbeitungsansicht; wer den Lauf sehen darf, sieht auch
|
||||
dessen Geschichte (#023)
|
||||
- **`Protokoll` trägt eine `kundeId`**, wie CLAUDE.md es für jede kundenbezogene Zeile verlangt. Ohne
|
||||
sie ließ sich die Mandantensicht nicht anwenden, ohne je Eintrag die Entität nachzuschlagen. Leer
|
||||
heißt global; bestehende Einträge nimmt die Migration mit. Ein Eintrag, der sich **keinem** Kunden
|
||||
zuordnen lässt — etwa zu einem gelöschten Gerät —, wird einem auf Kunden beschränkten Benutzer
|
||||
nicht gezeigt (#023)
|
||||
- **Schnellstart je Gerätetyp pflegen**, unter Verwaltung → Schnellstart. Welche Vorlagen auf der
|
||||
Geräteseite als Kachel erscheinen, stand bisher nur im Seed: wer eine eigene Vorlage dort haben
|
||||
wollte, kam nicht heran, und wer eine mitgelieferte loswerden wollte, auch nicht. Zur Auswahl
|
||||
stehen nur veröffentlichte, nicht archivierte Vorlagen; die Reihenfolge der Liste ist die
|
||||
Reihenfolge der Kacheln. Sobald jemand hier gespeichert hat, legt der Seed keine eigenen Vorgaben
|
||||
mehr nach — eine entfernte Kachel soll nicht beim nächsten Einrichtungslauf zurückkommen (#022)
|
||||
- **Vorgesetzter auch für von Hand angelegte Personen.** Die Zuordnung lief allein über die Kennung
|
||||
des Verzeichnisses, und die ist nur innerhalb einer Verzeichnisquelle eindeutig: wer eine Person
|
||||
selbst eingetragen hatte, konnte ihr keinen Vorgesetzten geben, und `{{vorgesetzter_sam}}` blieb im
|
||||
Onboarding-Befehl leer — genau dort, wo er gebraucht wird. Jetzt trägt jede Person eine gepflegte
|
||||
Zuordnung, die sich auf ihrer Seite setzen und entfernen lässt; der Abgleich schreibt sie aus dem
|
||||
Verzeichnis mit, und liefert das Verzeichnis keinen Vorgesetzten, bleibt eine von Hand gesetzte
|
||||
Zuordnung stehen. Bestehende Zuordnungen nimmt die Migration mit (#020)
|
||||
- Mitgelieferte Vorlagen tragen einen **stabilen Schlüssel** (`seedSchluessel`). Der Seed erkennt
|
||||
seine sieben Vorlagen daran statt am Namen — wer eine umbenannte, bekam vorher beim nächsten Lauf
|
||||
eine zweite, samt doppeltem Schnellstart auf der Geräteseite. Eine bestehende Installation bekommt
|
||||
den Schlüssel beim ersten Lauf danach nachgetragen, ohne Dubletten. Eine über den Import
|
||||
eingespielte Kopie bekommt **keinen** Schlüssel: sie soll nicht den Platz des Originals einnehmen
|
||||
(#021)
|
||||
- `pnpm pruefe:doku` vergleicht die Schranken-Befehle in `CLAUDE.md` und `README.md` und verlangt von
|
||||
beiden den LDAP-Container. Genau diese Abweichung war unbemerkt entstanden (#040)
|
||||
- Fünf Tests für den Healthcheck, darunter einer, der belegt, dass die Begründung ins Protokoll
|
||||
geschrieben und nicht bloß verschluckt wird (#039)
|
||||
|
||||
## [0.1.0] – 2026-09-26
|
||||
|
||||
### Hinzugefügt
|
||||
@@ -190,6 +309,10 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Behoben
|
||||
|
||||
- Die Umsortierung im Schnellstart wurde nicht gespeichert, wenn mehrere Zeilen desselben
|
||||
Gerätetyps dieselbe `position` trugen — bei gleicher Position ist die Reihenfolge in Postgres nicht
|
||||
festgelegt, und Lesemodell und Dienst gaben die Zeilen unterschiedlich zurück. Gefunden beim
|
||||
Durchklicken des gebauten Stands, nicht von der Testsuite (#022)
|
||||
- Der Compose-Stand las `DATABASE_URL` aus der `.env` — dort steht die Adresse für die Entwicklung
|
||||
(`localhost` und ein offener Port), und im Container zeigt sie ins Leere. Wer den dokumentierten
|
||||
Weg ging, bekam einen Stand, der nicht startet. Die Adresse baut sich jetzt immer aus den
|
||||
|
||||
@@ -38,6 +38,7 @@ Der Auftrag nennt englische Modelle. Verbindlich gilt:
|
||||
| DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` |
|
||||
| — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` |
|
||||
| — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` |
|
||||
| — (ergänzt) | `Zustellung` | | |
|
||||
|
||||
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
|
||||
`LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` ·
|
||||
@@ -45,7 +46,9 @@ Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENS
|
||||
`SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) ·
|
||||
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
|
||||
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
|
||||
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}`.
|
||||
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
|
||||
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
|
||||
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`.
|
||||
|
||||
`SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias.
|
||||
|
||||
@@ -56,7 +59,8 @@ src/server/services/* Regeln und Schreiboperationen. Werfen eigene …Fehler-K
|
||||
src/server/queries/* Lesemodelle. Wenden Rollen- und Mandantensicht selbst an.
|
||||
src/server/actions/* Dünne "use server"-Einstiege. Prüfen genau ein Recht, rufen den Service.
|
||||
src/lib/* Framework-freie Helfer. Kein Next, kein React, gut testbar.
|
||||
src/app/api/* Route Handler. Nur Polling, QR-SVG, CSV, Auth, Healthcheck. Minimal.
|
||||
src/app/api/* Route Handler. Nur Zustandsstrom, QR-SVG, CSV, Anhaenge, Auth,
|
||||
Healthcheck. Minimal.
|
||||
src/components/* Darstellung. Keine Geschäftslogik.
|
||||
```
|
||||
|
||||
@@ -97,9 +101,11 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
|
||||
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und
|
||||
`Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf
|
||||
ist darstellbar.
|
||||
- **Regeln stehen in der Datenbank, nicht nur im Service.** Zwölf CHECK-Bedingungen, zwei
|
||||
partielle Eindeutigkeitsindizes und vier zusammengesetzte Fremdschlüssel halten fest, was auch
|
||||
dann gelten muss, wenn jemand an der Service-Schicht vorbei schreibt. Die Mandantengrenze ist
|
||||
- **Regeln stehen in der Datenbank, nicht nur im Service.** CHECK-Bedingungen, partielle
|
||||
Eindeutigkeitsindizes, zusammengesetzte Fremdschlüssel und Trigger halten fest, was auch
|
||||
dann gelten muss, wenn jemand an der Service-Schicht vorbei schreibt.
|
||||
`tests/datenbankregeln.test.ts` zählt sie **namentlich** auf — dort steht die jeweils aktuelle
|
||||
Liste, und genau deshalb steht hier keine Zahl mehr: sie lief dreimal der Wirklichkeit nach. Die Mandantengrenze ist
|
||||
einer davon: ein Lauf kann strukturell kein Gerät, keine Person und keine Abteilung eines
|
||||
fremden Kunden wählen. Der Service prüft dasselbe noch einmal, nur mit besserer Meldung.
|
||||
|
||||
@@ -121,6 +127,12 @@ Zwei Obergrenzen, die nicht angehoben werden dürfen:
|
||||
Build-Skripte von Abhängigkeiten sind in `pnpm-workspace.yaml` unter `allowBuilds` freigegeben
|
||||
(pnpm 12 liest das `pnpm`-Feld in `package.json` nicht mehr).
|
||||
|
||||
**`minimumReleaseAgeExclude` bleibt leer.** pnpm 12 nimmt Pakete erst nach einer Wartezeit an — ein
|
||||
Schutz gegen ein kompromittiertes Release, das Stunden später zurückgezogen wird. Trägt `pnpm add`
|
||||
dort von sich aus eine Ausnahme ein, ist das kein Formfehler, sondern die Meldung „diese Version ist
|
||||
frisch von heute“. Dann gehört **eine abgelagerte Version** gepinnt, nicht die Ausnahme committet.
|
||||
`nodemailer` steht deshalb exakt auf `10.0.10` und nicht auf der neuesten Fassung (#034).
|
||||
|
||||
## Next 16
|
||||
|
||||
`middleware.ts` heißt **`proxy.ts`** und exportiert `proxy()`, Node-Laufzeit. `cookies()`,
|
||||
@@ -176,9 +188,22 @@ speichern, neu laden, und nachsehen, ob es wirklich in der Datenbank steht.
|
||||
## Vor jedem Commit
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yml up -d --wait ldap mailpit
|
||||
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
|
||||
```
|
||||
|
||||
**Die beiden Container gehören in die erste Zeile.** Ohne sie überspringt die Suite Tests, sagt das
|
||||
auf stderr — und sieht trotzdem grün aus. Wer nur diesem Abschnitt folgte, hätte das Betroffene nie
|
||||
geprüft. In CI bricht sie stattdessen ab, absichtlich.
|
||||
|
||||
| Container | Fehlt er, fehlen | Betroffen |
|
||||
|---|---|---|
|
||||
| `ldap` | **22 Tests** (nachgezählt: 81 statt 59) | der ganze Verzeichnis-Abgleich, `tests/abgleich.test.ts` und `tests/verzeichnis.test.ts` |
|
||||
| `mailpit` | **3 Tests** (nachgezählt: 36 statt 33) | der echte SMTP-Versand in `tests/benachrichtigungen.test.ts` — die vier Anlässe, die Passwort-Prüfung über alle Rumpftexte und „nichts zweimal“ |
|
||||
|
||||
`ldap` fiel bei der Abnahme zu #019 auf, `mailpit` kam mit #034 dazu. Beide Zahlen sind **gemessen**,
|
||||
nicht geschätzt — in #040 hätte beinahe eine geratene in dieser Datei gestanden.
|
||||
|
||||
`format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert
|
||||
beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
|
||||
hat. Lieber vorher sehen, was sich ändert.
|
||||
@@ -198,6 +223,17 @@ Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`), die aus der Position
|
||||
kommt und nie gespeichert wird. Druckansichten und Etiketten immer hell.
|
||||
**Am Handy nie horizontales Scrollen** — bei 390 px geprüft.
|
||||
|
||||
**„Nie auf rohe Hexwerte" meint die Oberfläche.** Es gibt genau sechs begründete Ausnahmen, und sie
|
||||
haben alle denselben Grund: dort entscheidet nicht unser Farbschema, sondern etwas außerhalb davon.
|
||||
|
||||
| Stelle | Wert | Warum kein Token |
|
||||
|---|---|---|
|
||||
| `src/app/drucken/lauf/[id]/page.tsx`, `src/app/drucken/etiketten/page.tsx` | `#ffffff` | Druck ist immer hell, unabhängig vom Erscheinungsbild der Anwendung |
|
||||
| `src/lib/qr.ts` | `#000000ff` / `#ffffffff` | Ein QR-Code muss reines Schwarz auf reinem Weiß sein, sonst liest ihn kein Decoder |
|
||||
| `src/app/layout.tsx` (`themeColor`), `src/app/manifest.ts` (zweimal) | `#0B0D10` | `themeColor` und das Web-App-Manifest vertragen keine CSS-Variablen |
|
||||
|
||||
Wer eine siebte hinzufügt, trägt sie hier ein — oder lässt es.
|
||||
|
||||
> Der folgende Block ist **nicht von uns**: `next dev` schreibt ihn bei jedem Start selbst in diese
|
||||
> Datei und stellt ihn wieder her, wenn man ihn löscht. Er steht deshalb hier, auf Englisch, und
|
||||
> wird mitcommittet, damit der Arbeitsbaum sauber bleibt.
|
||||
|
||||
@@ -45,11 +45,17 @@ openssl rand -base64 48
|
||||
### Qualitätsschranken
|
||||
|
||||
```bash
|
||||
pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku
|
||||
docker compose -f docker-compose.test.yml up -d --wait ldap mailpit # sonst fehlen 25 Tests
|
||||
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
|
||||
```
|
||||
|
||||
Die beiden Container sind keine Zierde: ohne `ldap` überspringt die Suite den ganzen
|
||||
Verzeichnis-Abgleich (22 Tests), ohne `mailpit` den echten Mailversand (3 Tests) — sie sagt das auf
|
||||
stderr, aber der Lauf sieht grün aus. In CI bricht sie stattdessen ab.
|
||||
|
||||
Ein Pre-Commit-Hook erzwingt Lint und Format, ein Commit-Msg-Hook Conventional Commits.
|
||||
Ende-zu-Ende-Tests: `pnpm test:e2e`.
|
||||
Ende-zu-Ende-Tests: `pnpm test:e2e` (verlangt einen gebauten, laufenden Stand — siehe
|
||||
`docs/anleitung/`).
|
||||
|
||||
## Betrieb mit Docker Compose
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ readonly STANDALONE="$ZIEL/.next/standalone"
|
||||
# Zwischenspeicher von pnpm und corepack. So bleibt der Checkout unter /opt
|
||||
# sauber und `git pull` findet nichts Fremdes vor.
|
||||
readonly HEIM="/var/cache/laufzettel"
|
||||
# Die Dateien der Anhaenge (#032). Unter /var/lib und nicht unter /opt: /opt ist
|
||||
# der Quellcode und wird bei jedem Aktualisieren angefasst, /var/lib ist
|
||||
# veraenderlicher Zustand, der bleibt. **Gehoert in jede Sicherung** -- in der
|
||||
# Datenbank stehen nur die Metadaten.
|
||||
readonly ANHAENGE="/var/lib/laufzettel/anhaenge"
|
||||
readonly ETC_VERZEICHNIS="/etc/laufzettel"
|
||||
readonly UMGEBUNGSDATEI="$ETC_VERZEICHNIS/laufzettel.env"
|
||||
readonly UNIT_DATEI="/etc/systemd/system/laufzettel.service"
|
||||
@@ -330,6 +335,9 @@ nutzerAnlegen() {
|
||||
install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0755 "$ZIEL"
|
||||
install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0750 "$HEIM"
|
||||
install -d -o root -g root -m 0750 "$ETC_VERZEICHNIS"
|
||||
# 0750 und nicht 0755: ein Anhang kann ein Foto eines Typenschilds oder eine
|
||||
# Rechnung sein. Nur der Dienstnutzer liest hier.
|
||||
install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0750 "$ANHAENGE"
|
||||
}
|
||||
|
||||
# ---------- Schritt 4: Geheimnisse und Umgebungsdatei -----------------------
|
||||
@@ -365,6 +373,11 @@ DATABASE_URL="postgresql://$DB_ROLLE:$dbPasswort@localhost/$DATENBANK?host=$SOCK
|
||||
HOSTNAME="$bindAdresse"
|
||||
PORT="$port"
|
||||
|
||||
# Wo die Dateien der Anhaenge liegen (#032). In der Datenbank stehen nur ihre
|
||||
# Metadaten -- dieses Verzeichnis gehoert deshalb in jede Sicherung, sonst
|
||||
# zeigen die Anhaenge nach einer Ruecksicherung ins Leere.
|
||||
ANHAENGE_PFAD="$ANHAENGE"
|
||||
|
||||
# Lokaler Login mit E-Mail und Passwort – der Notfallzugang, mit dem diese
|
||||
# Installation bedienbar ist. Sobald OIDC eingerichtet ist: auf false setzen.
|
||||
AUTH_LOCAL_ENABLED="true"
|
||||
|
||||
@@ -17,6 +17,30 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# Der Mailfaenger fuer die Benachrichtigungen (#034). Mailpit nimmt SMTP auf
|
||||
# 1025 an und zeigt die Nachrichten ueber eine HTTP-Schnittstelle auf 8025 —
|
||||
# darueber liest der Test, was wirklich ankam.
|
||||
#
|
||||
# **Ein echter SMTP-Dialog, keine Attrappe.** Eine abgefangene
|
||||
# `sendMail`-Funktion haette in #034 nichts bewiesen: dass Betreff und
|
||||
# Empfaenger stimmen, zeigt erst eine Nachricht, die durch Verbindungsaufbau,
|
||||
# EHLO und DATA gegangen ist. Ohne Anmeldung und ohne TLS, weil der Test den
|
||||
# Dialog prueft und nicht die Verschluesselung.
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
environment:
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: "true"
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: "true"
|
||||
ports:
|
||||
- "127.0.0.1:1025:1025"
|
||||
- "127.0.0.1:8025:8025"
|
||||
healthcheck:
|
||||
# Mailpit bringt kein curl mit; `/livez` ueber den eigenen Client.
|
||||
test: ["CMD", "/mailpit", "readyz"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
# Das Verzeichnis fuer den Abgleich (#016). OpenLDAP mit einem AD-aehnlichen
|
||||
# Schema: sAMAccountName, userPrincipalName, binaeres objectGUID,
|
||||
# userAccountControl, objectCategory und department bringt OpenLDAP nicht mit,
|
||||
|
||||
@@ -99,9 +99,19 @@ services:
|
||||
# Der Verzeichnis-Abgleich laeuft im selben Prozess (#016). Hier steht er
|
||||
# an, weil dieser Dienst der einzige ist, der dauerhaft laeuft.
|
||||
ZEITPLAN_AKTIV: ${ZEITPLAN_AKTIV:-true}
|
||||
# Anhaenge und Fotos liegen im Dateisystem, nicht in der Datenbank (#032).
|
||||
# Der Pfad zeigt in das Volume `anhaenge` unten.
|
||||
ANHAENGE_PFAD: /daten/anhaenge
|
||||
ANHANG_MAX_MB: ${ANHANG_MAX_MB:-10}
|
||||
ANHAENGE_JE_LAUF: ${ANHAENGE_JE_LAUF:-20}
|
||||
# Standardmaessig nur auf localhost; davor gehoert ein Reverse Proxy.
|
||||
ports:
|
||||
- "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000"
|
||||
volumes:
|
||||
# **Gehoert in jede Sicherung.** Eine Sicherung, die nur `pg_dump` macht,
|
||||
# verliert ab hier Daten: in der Datenbank stehen nur die Metadaten der
|
||||
# Anhaenge, die Dateien liegen hier. Siehe docs/betrieb/sicherung.md.
|
||||
- anhaenge:/daten/anhaenge
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -130,3 +140,6 @@ networks:
|
||||
|
||||
volumes:
|
||||
datenbank:
|
||||
# Die Dateien der Anhaenge (#032). Zusammen mit der Datenbank zu sichern –
|
||||
# getrennt gesichert ergeben beide Haelften kein vollstaendiges Bild.
|
||||
anhaenge:
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw
|
||||
|
||||
- [x] Issues für alle Phasen und MVP-Meilensteine existieren
|
||||
- [x] `CHANGELOG.md` und `docs/devlog.md` sind gepflegt
|
||||
- [ ] `v0.1.0` ist getaggt, Gitea-Release mit Changelog-Auszug
|
||||
- [x] `v0.1.0` ist getaggt, Gitea-Release mit Changelog-Auszug
|
||||
- [x] `docs/anleitung/` vollständig mit Screenshots und als PDF
|
||||
- [x] `docs/screenshots/` enthält die Meilenstein-Bilder
|
||||
- [x] `pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku` grün
|
||||
|
||||
@@ -40,6 +40,11 @@ Es gibt drei Wege, und der schnellste ist meist nicht dieser:
|
||||
entsteht und beginnt sofort, mit Gerät und Besitzer, ohne eine einzige Eingabe. Siehe
|
||||
[Kapitel 03](03-qr-tags.md).
|
||||
- **Aus der Übersicht**: der Block **Schnellstart** führt mit vorgewählter Vorlage in den Dialog.
|
||||
|
||||
Wählst du im Start-Dialog eine **Abteilung**, steht darunter, welche Abschnitte sie hinzunimmt — oder
|
||||
dass Abschnitte nur für andere Abteilungen gelten und wegbleiben. Welche Abschnitte ein Lauf hat,
|
||||
wird beim Start festgeschrieben und ändert sich danach nicht mehr, auch nicht, wenn jemand die
|
||||
Abteilung des Laufs korrigiert.
|
||||
- **Über den Knopf Lauf starten** in der Laufliste — der vollständige Weg, hier beschrieben.
|
||||
|
||||

|
||||
@@ -176,8 +181,18 @@ werden mit Name und Zeitpunkt festgehalten und lassen sich nicht löschen.
|
||||
|
||||
### Zwei Techniker am selben Lauf
|
||||
|
||||
Die Seite fragt alle zehn Sekunden nach Änderungen und lädt sich neu, wenn sich wirklich etwas
|
||||
geändert hat. Bei verstecktem Tab pausiert die Abfrage.
|
||||
Hakt der Kollege einen Schritt ab, erscheint das bei dir **sofort** — ohne dass du etwas tun musst.
|
||||
Dasselbe gilt für Kommentare, angehängte Fotos und einen geänderten Titel. Die Seite lädt sich nur
|
||||
dann neu, wenn sich wirklich etwas geändert hat; an einem Feld, in das du gerade tippst, geht dabei
|
||||
nichts verloren.
|
||||
|
||||
Ist die Lasche im Hintergrund, pausiert das. Holst du sie zurück, steht gleich der aktuelle Stand
|
||||
da.
|
||||
|
||||
Bleibt die Seite doch einmal stehen, obwohl jemand anders arbeitet, aktualisiert sie sich spätestens
|
||||
nach zehn Sekunden von selbst — das ist der eingebaute Rückfall. Dauert es dauerhaft so lange, liegt
|
||||
es meist am Reverse Proxy; der Abschnitt „Die Seite aktualisiert sich nur langsam“ in
|
||||
[Fehlerbehebung](08-fehlerbehebung.md) sagt, was zu tun ist.
|
||||
|
||||
Steht ein erzeugtes Passwort auf dem Bildschirm, lädt sie **nicht** von selbst neu — stattdessen
|
||||
erscheint oben ein Streifen: „Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen
|
||||
@@ -190,6 +205,33 @@ Zwei Wege: die Person aus der Liste wählen, oder — wenn das Konto gerade erst
|
||||
entstanden und noch nicht abgeglichen ist — ihren **UPN** eintragen. Danach **Verknüpfen**. Ohne
|
||||
das findet die Person in ihrer Historie später nichts wieder.
|
||||
|
||||
## Fotos und Dateien an einem Schritt
|
||||
|
||||
Manches lässt sich nicht abhaken, es muss gezeigt werden: das Typenschild, der Zustand bei der
|
||||
Übergabe, die Rechnung. An jedem Schritt stehen dafür zwei Knöpfe:
|
||||
|
||||
- **Foto** öffnet am Handy direkt die Kamera. Am Rechner zeigt der Browser den Dateiwähler — dort
|
||||
gibt es keine Kamera-App.
|
||||
- **Datei** öffnet den Dateiwähler.
|
||||
|
||||
Abgeschickt wird **sofort nach der Wahl**, ohne zweiten Knopf. Ein Foto, das du gerade gemacht hast,
|
||||
willst du nicht noch bestätigen.
|
||||
|
||||
Erlaubt sind **Bilder** (JPEG, PNG, WebP, HEIC) und **PDF**, höchstens 10 MB je Datei und 20 Dateien
|
||||
je Lauf. Beide Grenzen lassen sich beim Betrieb ändern (`ANHANG_MAX_MB`, `ANHAENGE_JE_LAUF`). Eine zu
|
||||
große Datei wird schon im Browser abgewiesen — am Handy im Mobilfunk ist das der Unterschied zwischen
|
||||
einer Meldung und einer Minute Warten.
|
||||
|
||||
Bilder erscheinen als Miniatur, ein PDF als Zeile mit Namen. Ein Klick öffnet die Datei in einem
|
||||
neuen Tab; ein PDF wird dabei heruntergeladen statt im Browser angezeigt.
|
||||
|
||||
**An einem archivierten Lauf kommt kein Anhang mehr dazu und keiner weg** — dieselbe Regel wie für
|
||||
Schritte und Kommentare. Ansehen geht weiter: ein Foto vom Typenschild ist Teil des Belegs.
|
||||
|
||||
> **Für den Betrieb wichtig:** Die Dateien liegen im Dateisystem, in der Datenbank stehen nur ihre
|
||||
> Namen und Größen. Eine Sicherung, die nur die Datenbank mitnimmt, verliert sie — siehe
|
||||
> `docs/betrieb/sicherung.md`.
|
||||
|
||||
## Abschließen
|
||||
|
||||
**Lauf abschließen** geht nur, wenn kein Pflichtschritt mehr offen ist. Erledigt und „nicht
|
||||
|
||||
@@ -136,8 +136,9 @@ sofort anfangen? Was war schon?
|
||||
den Fall, dass der Aufkleber abgerissen ist.
|
||||
- **Zugewiesen** — was an diesem Gerät wartet. Ein offener Lauf hat den Knopf **Starten**: er setzt
|
||||
ihn auf „in Arbeit“ und führt hinein. Ein laufender hat **Weiter**, ein fertiger **Ansehen**.
|
||||
- **Schnellstart** — eine Kachel je Vorlage, die für diesen Gerätetyp hinterlegt ist. Ein Tipp legt
|
||||
den Lauf mit diesem Gerät und seinem Besitzer an und beginnt ihn, ohne eine einzige Eingabe.
|
||||
- **Schnellstart** — eine Kachel je Vorlage, die für diesen Gerätetyp hinterlegt ist (gepflegt unter
|
||||
**Verwaltung → Schnellstart**, siehe Kapitel 07). Ein Tipp legt den Lauf mit diesem Gerät und
|
||||
seinem Besitzer an und beginnt ihn, ohne eine einzige Eingabe.
|
||||
Steht auf der Kachel „fragt noch nach Angaben“, führt sie stattdessen in den Start-Dialog — dort
|
||||
sind Gerät und Person schon vorbelegt.
|
||||
- **Vorlage zuweisen** — für später oder für jemand anderen: Vorlage, **Techniker (optional)**,
|
||||
|
||||
@@ -190,6 +190,84 @@ und für `{{generiertes_passwort}}` entsteht **kein** Passwort — dort steht
|
||||
Nutz sie vor dem Veröffentlichen, besonders bei Befehlen: erst hier sieht man, ob der fertige Befehl
|
||||
so aussieht, wie er aussehen soll.
|
||||
|
||||
## Abschnitte nur für bestimmte Abteilungen
|
||||
|
||||
Beim Onboarding in der Buchhaltung gehören Schritte dazu, die sonst niemand braucht: DATEV
|
||||
einrichten, Drucker für Formulare, Zugang zum Bankportal. Bisher hieß die Wahl: eine Vorlage je
|
||||
Abteilung pflegen, oder Schritte, die für die meisten „nicht zutreffend“ sind.
|
||||
|
||||
In jedem Abschnitt steht dafür **Nur für diese Abteilungen**: ein Kästchen je gepflegter Abteilung,
|
||||
darunter ein Feld für eine, die es noch nicht gibt. Bleibt alles leer, gilt der Abschnitt für jeden
|
||||
Lauf — das ist der Normalfall.
|
||||
|
||||
Drei Dinge, die man dazu wissen muss:
|
||||
|
||||
- **Ein Lauf ohne Abteilung bekommt nur die uneingeschränkten Abschnitte.** Ein Firewall-Rollout hat
|
||||
keine Abteilung, und das ist kein Mangel.
|
||||
- **Die Auswahl wird beim Start festgeschrieben.** Wer später die Abteilung eines laufenden Laufs
|
||||
ändert, ändert seine Abschnitte **nicht**. Sonst verschwände ein Schritt, an dem schon eine Notiz
|
||||
hängt — und ein archivierter Lauf muss genau das zeigen, was damals abgearbeitet wurde.
|
||||
- **Ein ausgefilterter Pflichtschritt blockiert den Abschluss nicht.** Er steht nicht in der
|
||||
Abarbeitung, also lässt er sich auch nicht abhaken; würde er zählen, wäre der Abschluss
|
||||
unerreichbar.
|
||||
|
||||
Gespeichert werden **Namen**, nicht Kennungen. Ein Abschnitt gehört zur Version und muss ein
|
||||
Umbenennen oder Archivieren einer Abteilung überleben, ohne ins Leere zu zeigen — und beim Vergleich
|
||||
ist Groß- und Kleinschreibung gleichgültig. Deshalb wandert die Einschränkung auch durch Export und
|
||||
Import: ein Abteilungsname bedeutet in einer anderen Installation dasselbe.
|
||||
|
||||
**Lässt für eine Abteilung kein Abschnitt übrig, wird der Lauf abgewiesen** — es gäbe nichts
|
||||
abzuarbeiten. Das passiert nur, wenn jeder Abschnitt einer Vorlage eingeschränkt ist.
|
||||
|
||||
## Bausteine: ein Abschnitt für mehrere Vorlagen
|
||||
|
||||
„Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf
|
||||
ändert, ändert ihn viermal — oder dreimal und vergisst den vierten. Ein **Baustein** ist ein
|
||||
Abschnitt, den mehrere Vorlagen teilen und der an einer Stelle gepflegt wird.
|
||||
|
||||
Die Liste steht hinter dem Knopf **Bausteine** oben auf der Vorlagenseite. Ein Baustein wird dort
|
||||
angelegt (nur ein Name), bekommt dann einen Titel und seine Schritte, und wird **veröffentlicht** —
|
||||
genau wie eine Vorlage, mit denselben Regeln: höchstens ein Entwurf, und eine veröffentlichte
|
||||
Fassung wird nie wieder verändert.
|
||||
|
||||
### Einsetzen
|
||||
|
||||
Im Editor einer Vorlage steht unten neben **Abschnitt hinzufügen** das Feld **Baustein einsetzen**.
|
||||
Angeboten werden nur veröffentlichte Fassungen nicht archivierter Bausteine. Der Abschnitt erscheint
|
||||
dann als Einheit: mit dem Kennzeichen „Baustein“, dem Namen der Quelle und der Zahl seiner Schritte.
|
||||
|
||||
**Titel und Schritte lassen sich dort nicht ändern.** Beides kommt beim Veröffentlichen aus dem
|
||||
Baustein — ein Feld, das man ausfüllen kann und das danach überschrieben wird, wäre eine Falle.
|
||||
Geändert wird am Baustein.
|
||||
|
||||
### Was eine Änderung am Baustein bewirkt — und was nicht
|
||||
|
||||
Das ist der Punkt, den man einmal verstanden haben muss:
|
||||
|
||||
- **Schon veröffentlichte Vorlagenversionen ändern sich nicht.** Sie tragen beim Veröffentlichen
|
||||
eingesetzte **Kopien** der Schritte. Ein Lauf von vor zwei Jahren zeigt genau das, was damals
|
||||
abgearbeitet wurde — daran ändert kein Baustein etwas.
|
||||
- **Laufende Arbeiten ändern sich nicht.** Aus demselben Grund.
|
||||
- **Die neue Fassung wirkt in der nächsten veröffentlichten Version** einer Vorlage. Wer sie dort
|
||||
haben will, legt einen neuen Entwurf an und veröffentlicht ihn.
|
||||
|
||||
Auf der Seite eines Bausteins steht unter **Wo er steckt**, in welchen Vorlagen er vorkommt und
|
||||
welche **Fassung** dort eingesetzt ist. Vor einer Änderung ist das die Liste, die man lesen will.
|
||||
|
||||
### Archivieren
|
||||
|
||||
Gelöscht wird ein Baustein nie: veröffentlichte Vorlagenversionen tragen seine Herkunft. Archiviert
|
||||
lässt er sich nicht mehr neu einsetzen, bleibt aber in jeder Vorlage lesbar, die ihn schon benutzt.
|
||||
Steht er noch in einem **Entwurf**, weist Laufzettel das Archivieren ab — diese Vorlage ließe sich
|
||||
danach nicht mehr veröffentlichen.
|
||||
|
||||
### In einer Austauschdatei steht kein Baustein
|
||||
|
||||
Ein Export trägt die **aufgelösten** Schritte, keinen Verweis. Eine Kennung aus dieser Installation
|
||||
bedeutet in einer anderen nichts, und ein Verweis auf einen Baustein, den es dort nicht gibt, wäre
|
||||
eine Vorlage, die sich nicht veröffentlichen lässt. Wer einen Baustein mitgeben will, exportiert die
|
||||
Vorlage und legt den Baustein in der anderen Installation neu an.
|
||||
|
||||
## Veröffentlichen
|
||||
|
||||
Im Abschnitt **Veröffentlichen** trägst du eine **Änderungsnotiz (optional)** ein — ein Satz, was
|
||||
@@ -231,9 +309,9 @@ den Fall, dass jemand eine Vorlage von Hand schreiben oder ein Skript dafür bau
|
||||
## Vorlagen einem Gerätetyp zuordnen
|
||||
|
||||
Damit auf der Geräteseite ein **Schnellstart** erscheint, muss für den Gerätetyp eine Vorlage
|
||||
hinterlegt sein — etwa „PC Neuinstallation“ für den Typ PC. Diese Zuordnung wird beim Einrichten der
|
||||
Anwendung gesetzt und lässt sich in der Oberfläche noch nicht ändern; wende dich dafür an die
|
||||
Person, die Laufzettel betreibt.
|
||||
hinterlegt sein — etwa „PC Neuinstallation“ für den Typ PC. Gepflegt wird das unter
|
||||
**Verwaltung → Schnellstart**; das verlangt die Rolle Administrator und ist in Kapitel 07
|
||||
beschrieben.
|
||||
|
||||
Ohne Zuordnung bleibt der Schnellstart leer, und das ist kein Mangel: unter **Vorlage zuweisen**
|
||||
steht auf der Geräteseite jede Vorlage, die zu diesem Gerät passt, und der Start-Dialog kennt sie
|
||||
|
||||
@@ -88,6 +88,33 @@ Aufkleber sonst ins Leere zeigen würden. Steht dort „APP_URL ist nicht gesetz
|
||||
noch nicht gedruckt werden“, muss das erst beim Betrieb der Anwendung eingetragen werden. **Drucke
|
||||
vorher keine Etiketten.**
|
||||
|
||||
## Schnellstart je Gerätetyp
|
||||
|
||||
Auf der Seite eines Geräts stehen oben die Vorlagen, die dort am häufigsten gebraucht werden — ein
|
||||
Klick, und der Lauf steht. Welche das sind, wird unter **Verwaltung → Schnellstart** festgelegt, je
|
||||
Gerätetyp getrennt.
|
||||
|
||||
Zu jedem Gerätetyp steht dort die Liste in der Reihenfolge, in der die Kacheln erscheinen. Mit den
|
||||
Pfeilen wird umsortiert, mit dem **×** entfernt, und über **Vorlage hinzufügen** kommt eine dazu.
|
||||
Nach jeder Änderung steht „nicht gespeichert“ daneben, bis **Speichern** gedrückt ist — die
|
||||
Reihenfolge wandert als Ganzes in die Datenbank, nicht Zeile für Zeile.
|
||||
|
||||
Drei Dinge, die hier anders sind als erwartet:
|
||||
|
||||
- **Zur Auswahl stehen nur veröffentlichte, nicht archivierte Vorlagen.** Ein Entwurf taugt nicht:
|
||||
aus ihm lässt sich kein Lauf starten, die Kachel täte also nichts.
|
||||
- **Eine archivierte Vorlage verschwindet von selbst** aus dem Schnellstart. Niemand muss hier
|
||||
aufräumen, nachdem eine Vorlage weggelegt wurde.
|
||||
- **Eine Vorlage, die nur für einen Kunden gilt**, lässt sich zuweisen und wird mit dem Kundennamen
|
||||
gezeigt. Sie erscheint trotzdem nur bei den Geräten dieses Kunden.
|
||||
|
||||
**Ein leerer Schnellstart ist kein Mangel.** Unter **Vorlage zuweisen** steht auf der Geräteseite
|
||||
jede Vorlage, die zu diesem Gerät passt, und der Start-Dialog kennt sie ohnehin alle.
|
||||
|
||||
Sobald hier einmal gespeichert wurde, hält sich der Seed heraus: die mitgelieferten Vorgaben werden
|
||||
danach nicht mehr nachgelegt. Das ist gewollt — eine entfernte Kachel soll nicht beim nächsten
|
||||
Einrichtungslauf zurückkommen.
|
||||
|
||||
## Betriebsmodus umstellen
|
||||
|
||||

|
||||
@@ -142,13 +169,79 @@ nicht archivieren.
|
||||
Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten
|
||||
lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden.
|
||||
|
||||
## Was protokolliert wird
|
||||
## Benachrichtigungen
|
||||
|
||||
Ein zugewiesener Lauf fiel früher nur auf, wenn jemand die Übersicht öffnete. Ein überfälliger
|
||||
überhaupt nicht. Laufzettel meldet deshalb vier Dinge — und nur diese vier:
|
||||
|
||||
| Anlass | Wer bekommt es |
|
||||
|---|---|
|
||||
| Ein Lauf wird jemandem **zugewiesen** | der Zugewiesene |
|
||||
| Ein Lauf ist **überfällig** | der Zugewiesene, einmal je Lauf |
|
||||
| Ein Lauf wird **abgeschlossen** | der Zugewiesene und wer ihn angelegt hat |
|
||||
| Ein **Verzeichnisabgleich schlägt fehl** | alle Administratoren |
|
||||
|
||||
**Über die eigene Tat bekommt niemand Post.** Wer selbst zuweist, abschließt oder abhakt, erfährt es
|
||||
nicht per Mail — das ist der häufigste Grund, Benachrichtigungen ganz abzuschalten.
|
||||
|
||||
**In der Nachricht stehen Titel, Status, Gerät, Person, Frist und ein Link.** Keine Schrittinhalte,
|
||||
und damit auch kein erzeugtes Passwort. Das Passwort entsteht bei jeder Anzeige neu; eine Mail
|
||||
dagegen bleibt für immer in einem Postfach.
|
||||
|
||||
### Einrichten
|
||||
|
||||
Unter **Verwaltung → Einstellungen**, Abschnitt **Benachrichtigungen**: Host, Port, Benutzer,
|
||||
Passwort und Absender des Mailservers. Dann **Speichern**, dann **Testmail schicken** — die geht
|
||||
sofort hinaus, nicht über die Warteschlange, und ein Fehler erscheint im Klartext.
|
||||
|
||||
Daneben steht ein **Webhook**: dieselben vier Anlässe als JSON an eine Adresse, mit Signatur. Das ist
|
||||
für Ticketsysteme und Chat-Räume gedacht; die Einzelheiten stehen in
|
||||
[`docs/betrieb/benachrichtigungen.md`](../betrieb/benachrichtigungen.md).
|
||||
|
||||
**Das Passwortfeld ist leer, obwohl ein Passwort gesetzt ist.** Leer heißt „unverändert lassen“ —
|
||||
sonst löschte jedes Speichern der übrigen Felder das Passwort mit.
|
||||
|
||||
### Abschalten
|
||||
|
||||
Unter **Verwaltung → Benutzer** schaltet der **Mail**-Knopf an einer Person die Benachrichtigungen
|
||||
ab. Das geht auch für das eigene Konto. Wer abgeschaltet hat, bekommt nichts und löst auch keinen
|
||||
Fehler aus.
|
||||
|
||||
### Zustellungen
|
||||
|
||||
Unter **Verwaltung → Zustellungen** steht, was hinausgegangen ist und was nicht. Schlägt eine
|
||||
Zustellung fehl, versucht Laufzettel es nach 1, 5, 30 und 120 Minuten erneut; nach dem fünften
|
||||
Fehlschlag bleibt sie als
|
||||
**aufgegeben** stehen.
|
||||
|
||||
**Da hinzusehen lohnt, wenn jemand sagt, er bekomme keine Mails.** Fast immer ist das SMTP-Passwort
|
||||
abgelaufen — und ohne diese Seite merkt es wochenlang niemand, weil die Läufe weiter funktionieren.
|
||||
Ganz oben steht ein Hinweis, sobald etwas aufgegeben wurde.
|
||||
|
||||
## Protokoll
|
||||
|
||||
Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen,
|
||||
veröffentlichte Vorlagen, eingespielte Vorlagen, archivierte und zurückgeholte Läufe, angelegte und
|
||||
gelöschte Verzeichnisquellen, den Moduswechsel. Ein Protokolleintrag verliert seinen Urheber nie. Eine
|
||||
eigene Ansicht dafür gibt es in der Oberfläche noch nicht; was am Lauf selbst geschehen ist, steht in
|
||||
seiner Abarbeitungsansicht und im Ausdruck.
|
||||
gelöschte Verzeichnisquellen, den Moduswechsel. Ein Protokolleintrag verliert seinen Urheber nie, und
|
||||
geändert oder gelöscht wird keiner.
|
||||
|
||||
Zu sehen ist das unter **Verwaltung → Protokoll**, neueste Einträge zuerst, fünfzig je Seite. Vier
|
||||
Filter stehen darüber:
|
||||
|
||||
- **Aktion oder Lauf** — Volltext über die Aktion, die Art des Eintrags und den Titel des Laufs.
|
||||
„Rolle“ findet jede Rollenänderung, „archiviert“ jede Archivierung.
|
||||
- **Urheber** — zur Auswahl stehen nur die, die wirklich Einträge haben.
|
||||
- **von** und **bis** — der *bis*-Tag zählt mit; „bis heute“ enthält also, was heute passiert ist.
|
||||
|
||||
Hinter **Werte** stehen die Felder vor und nach der Änderung, soweit sie festgehalten wurden.
|
||||
Eingeklappt, weil sie lang werden können.
|
||||
|
||||
**Ein Eintrag ohne Urheber ist keine Lücke.** Dort steht „Systemlauf“: der Zeitplan des
|
||||
Verzeichnisabgleichs läuft ohne Menschen dahinter, und das soll man sehen können.
|
||||
|
||||
Was an einem **einzelnen Lauf** geschehen ist, steht außerdem unten in seiner Abarbeitungsansicht
|
||||
unter **Protokoll dieses Laufs**, eingeklappt. Wer den Lauf sehen darf, sieht auch dessen Einträge —
|
||||
dafür braucht es keine Administratorrolle.
|
||||
|
||||
Ein **erzeugtes Passwort steht nie im Protokoll** — es wird nirgends gespeichert. Gäbe es ein Feld
|
||||
dafür, wäre die Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
|
||||
|
||||
@@ -77,6 +77,58 @@ Zurückholen wird protokolliert.
|
||||
**Vorbeugen:** archiviere erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile — ein
|
||||
abgeschlossener Lauf steht auch schon im Archiv und ist durchsuchbar.
|
||||
|
||||
## Die Seite aktualisiert sich nur langsam
|
||||
|
||||
**Was du siehst:** Der Kollege hakt einen Schritt ab, und bei dir erscheint es erst nach rund zehn
|
||||
Sekunden statt sofort. Alles funktioniert, es ist nur träge.
|
||||
|
||||
**Ursache:** Die Seite hält normalerweise eine offene Verbindung zum Server, über die Änderungen
|
||||
sofort ankommen. Kommt die nicht zustande, schaltet sie von selbst auf Nachfragen im
|
||||
Zehn-Sekunden-Takt um — die Arbeit geht also weiter, nur nicht in Echtzeit. Fast immer liegt das am
|
||||
Reverse Proxy, der die Verbindung sammelt statt sie durchzulassen.
|
||||
|
||||
**Was zu tun ist** (für Administratoren): In `docs/betrieb/reverse-proxy.md` stehen die beiden
|
||||
nötigen Zeilen — `proxy_buffering off` für nginx, `flush_interval -1` für Caddy — und ein
|
||||
Mindestwert für `proxy_read_timeout`. Fällt der unter 30 Sekunden, reißt die Verbindung zwischen
|
||||
zwei Herzschlägen immer wieder ab.
|
||||
|
||||
**Zweitursache:** Sehr viele gleichzeitig offene Laschen. Ab einer Obergrenze (`SSE_MAX_STROEME`,
|
||||
voreingestellt 200 je Instanz) bekommen weitere Laschen bewusst den langsamen Weg, damit der Server
|
||||
erreichbar bleibt. Dann hilft, nicht mehr gebrauchte Laschen zu schließen.
|
||||
|
||||
**Was es nicht ist:** ein Datenverlust. Gespeichert wird beim Klick, nicht beim Aktualisieren. Was du
|
||||
siehst, kann veraltet sein; was in der Datenbank steht, ist es nie.
|
||||
|
||||
## „Die Datei zu … fehlt im Dateisystem“
|
||||
|
||||
**Ursache:** In der Datenbank steht die Zeile eines Anhangs, die Datei selbst ist nicht da. Fast
|
||||
immer heißt das: es wurde eine Sicherung eingespielt, die nur die Datenbank enthielt. Die Dateien der
|
||||
Anhänge liegen im Dateisystem, nicht in der Datenbank.
|
||||
|
||||
**Was tun:** Das Verzeichnis aus derselben Sicherungsrunde zurückspielen —
|
||||
`docs/betrieb/sicherung.md` beschreibt beide Wege. Gibt es kein Archiv der Dateien, sind sie weg; die
|
||||
Zeilen lassen sich dann nur entfernen, indem jemand den Anhang in der Oberfläche löscht.
|
||||
|
||||
**Zweitursache, seltener:** nach einer Rücksicherung gehören die Dateien `root` statt dem
|
||||
Dienstnutzer. Der Dienst kommt dann nicht an sie heran, obwohl sie daliegen:
|
||||
|
||||
```bash
|
||||
sudo ls -l /var/lib/laufzettel/anhaenge
|
||||
sudo chown -R laufzettel:laufzettel /var/lib/laufzettel/anhaenge
|
||||
```
|
||||
|
||||
**Vorbeugen:** Datenbank und Anhänge im **selben** Skript sichern, direkt hintereinander. Getrennt
|
||||
gesichert passen die beiden Hälften irgendwann nicht mehr zusammen.
|
||||
|
||||
## „Dateien vom Typ … lassen sich nicht anhängen“
|
||||
|
||||
**Ursache:** Erlaubt sind Bilder (JPEG, PNG, WebP, HEIC) und PDF. Alles andere wird abgewiesen —
|
||||
auch SVG, und das ist Absicht: ein SVG ist ein Dokument, das Skripte ausführen kann, und ein Anhang,
|
||||
den jemand öffnet, soll kein Angriffsweg sein.
|
||||
|
||||
**Was tun:** Ein Office-Dokument oder eine Textdatei als PDF ausgeben. Ein Screenshot wird zu PNG,
|
||||
sobald du ihn speicherst statt ihn aus der Zwischenablage zu ziehen.
|
||||
|
||||
## „Kein Laufzettel-Tag“ nach dem Scannen
|
||||
|
||||
**Ursache:** einer von drei Fällen, und Laufzettel unterscheidet sie absichtlich nicht — die
|
||||
|
||||
+75
-10
@@ -185,21 +185,86 @@ Das **generierte Passwort** entsteht erst beim Ersetzen und wird nirgends zurüc
|
||||
keinen Weg, es außerhalb des fertigen Texts zu bekommen. Gäbe es ein Feld dafür, wäre die
|
||||
Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
|
||||
|
||||
## 5d. Warum die Aktualisierung keine Befehle mitliefert
|
||||
## 5d. Wie die Aktualisierung funktioniert – und warum sie keine Befehle mitliefert
|
||||
|
||||
Die Abarbeitungsansicht fragt alle zehn Sekunden `GET /api/laeufe/[id]/zustand` — zwei Techniker
|
||||
teilen sich ein Onboarding und sollen die Arbeit des anderen sehen, ohne die Seite neu zu laden.
|
||||
Diese Antwort trägt **nur** Status und Schrittzustände, ausdrücklich keine fertigen Befehle.
|
||||
Die Abarbeitungsansicht hält `GET /api/laeufe/[id]/zustand` als **Ereignisstrom** offen — zwei
|
||||
Techniker teilen sich ein Onboarding und sollen die Arbeit des anderen sehen, ohne die Seite neu zu
|
||||
laden. Diese Nutzlast trägt **nur** Status, Schrittzustände und die Zahl der Kommentare und
|
||||
Anhänge, ausdrücklich keine fertigen Befehle.
|
||||
|
||||
Der Grund ist `{{generiertes_passwort}}`: es entsteht bei jedem Auflösen neu. Lieferte das Polling
|
||||
Befehle mit, stünde alle zehn Sekunden ein anderes Passwort auf dem Bildschirm als das, das der
|
||||
Techniker gerade in die Konsole geklebt hat. Die Befehle entstehen deshalb genau einmal je
|
||||
Seitenaufbau, und der Client lädt die Seite erst neu, wenn sich am Zustand wirklich etwas geändert
|
||||
hat. Bei verstecktem Tab pausiert die Abfrage.
|
||||
Der Grund ist `{{generiertes_passwort}}`: es entsteht bei jedem Auflösen neu. Lieferte die
|
||||
Aktualisierung Befehle mit, stünde nach jeder Änderung ein anderes Passwort auf dem Bildschirm als
|
||||
das, das der Techniker gerade in die Konsole geklebt hat. Die Befehle entstehen deshalb genau einmal
|
||||
je Seitenaufbau, und der Client lädt die Seite erst neu, wenn sich am Zustand wirklich etwas
|
||||
geändert hat. Bei verstecktem Tab wird der Strom geschlossen.
|
||||
|
||||
Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück.
|
||||
Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück —
|
||||
auch und gerade für den Strom, **bevor** das erste Ereignis fließt.
|
||||
|
||||
### Warum die Datenbank das Ereignis schickt, nicht der Dienst
|
||||
|
||||
Bis #033 fragte die Seite alle zehn Sekunden. Das war richtig, aber zehn Sekunden sind lang, wenn
|
||||
zwei Leute dieselbe Liste abhaken, und zwanzig offene Laschen bedeuteten zwanzig Abfragen je zehn
|
||||
Sekunden. Seit #033 melden vier Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine LISTEN-Verbindung
|
||||
je Prozess hört zu, und der Route Handler schiebt weiter.
|
||||
|
||||
**In der Datenbank und nicht im Service**, aus demselben Grund, aus dem auch die Regeln dort stehen:
|
||||
ein Ereignis, das der Service verschickt, entsteht nur auf dem Weg durch den Service. Ein Trigger
|
||||
sieht auch den Seed, eine Migration und die Reparatur per `psql`. Und `NOTIFY` geht an **jede**
|
||||
Verbindung mit `LISTEN` — also an jede Instanz der Anwendung, ohne einen zweiten Dienst.
|
||||
|
||||
**Welche Tabellen melden, hängt an dem, was das Lesemodell liefert.** Das Polling verglich den
|
||||
ganzen Stand und sah damit auch, was kein Trigger meldet; ein Strom sieht nur, was gemeldet wird.
|
||||
Fehlte `Kommentar` oder `Anhang`, wäre die Live-Aktualisierung gegenüber dem Polling ein
|
||||
**Rückschritt** — der Kommentar des Kollegen käme nie an. `tests/datenbankregeln.test.ts` zählt die
|
||||
vier Trigger namentlich auf, `tests/zustand-route.test.ts` die Schlüssel des Zustands.
|
||||
|
||||
**Das Polling bleibt, als Rückfall an derselben Adresse.** Wer kein `EventSource` hat oder hinter
|
||||
einem Proxy sitzt, der Ströme puffert, bekommt den alten Weg — dieselbe Rechteprüfung, dasselbe
|
||||
Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt wäre eine zweite Stelle, an der jemand das Recht
|
||||
vergessen kann. Unterschieden wird allein am `Accept`-Kopf, den `EventSource` von sich aus schickt.
|
||||
Dieser Rückfall wird gefahren, nicht behauptet: `tests/e2e/live.spec.ts` nimmt dem Browser
|
||||
`EventSource` weg.
|
||||
|
||||
Eine Obergrenze (`SSE_MAX_STROEME`, Vorgabe 200) begrenzt die offenen Ströme je Instanz. Ist sie
|
||||
erreicht, antwortet der Handler gewöhnlich mit JSON; `EventSource` meldet darauf einen Fehler und
|
||||
versucht es nicht wieder, und der Client fällt von sich aus auf Polling zurück. Das braucht keinen
|
||||
Sonderfall und keinen eigenen Fehlercode.
|
||||
Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab.
|
||||
|
||||
### Warum die Benachrichtigungen in einer Warteschlange liegen
|
||||
|
||||
Vier Anlässe gehen seit #034 hinaus: zugewiesen, überfällig, abgeschlossen, Abgleich fehlgeschlagen.
|
||||
Drei Entscheidungen prägen das.
|
||||
|
||||
**Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht.** Ein hängender Mailserver darf kein
|
||||
Abhaken blockieren. Der Dienst schreibt eine Zeile in `Zustellung` — eine Einfügung in dieselbe
|
||||
Datenbank, die der Vorgang ohnehin anfasst —, und ein Zeitplan im selben Prozess schickt sie. Das
|
||||
Einreihen passiert **nach** der Transaktion und mit abgefangenem Fehler: ein Abhaken darf nicht
|
||||
daran scheitern, dass eine Benachrichtigung nicht eingereiht werden konnte.
|
||||
|
||||
**Betreff und Rumpf stehen fertig in der Zeile.** Das kostet Platz und ist es wert: eine Meldung
|
||||
sagt, was *damals* war. Baute der Zeitplan den Text erst beim Senden, stünde in einer Mail von heute
|
||||
der Stand von jetzt — und in einer aufgegebenen Zustellung von nächster Woche etwas völlig anderes.
|
||||
|
||||
**In keiner Meldung steht ein Schrittinhalt.** Darüber wacht `src/lib/meldungen.ts`: die Funktionen
|
||||
dort nehmen **nur** Kopfdaten, der Typ lässt nichts anderes zu. Eine Mail mit dem Lauf-Inhalt wäre
|
||||
genau die Lücke, die dieses Projekt an jeder anderen Stelle geschlossen hat — das erzeugte Passwort
|
||||
wird nirgends gespeichert, aber eine Mail liegt für immer in einem Postfach, oft auf einem Server,
|
||||
der nicht im Haus steht. `tests/benachrichtigungen.test.ts` prüft es zusätzlich über **alle**
|
||||
Rumpftexte, die wirklich verschickt wurden, so wie #012 es für die Druckansicht tat.
|
||||
|
||||
Zwei Dinge, die ohne Datenbank nicht gehalten hätten. Die **Einmaligkeit der
|
||||
Überfälligkeitsmeldung** liegt in einem partiellen eindeutigen Index: der Zeitplan sieht jede Minute
|
||||
nach, und ohne ihn bekäme der Zuständige für einen Lauf, der über das Wochenende steht, 2880 Mails —
|
||||
jede davon für sich korrekt, kein Test rot. Und die **Sperre** der Warteschlange liegt in derselben
|
||||
Tabelle wie die des Abgleichs: hinter einem Lastverteiler laufen zwei Instanzen, und ein `let laeuft
|
||||
= false` im Modul wüsste nichts von der zweiten.
|
||||
|
||||
**Das Polling-Prinzip gilt auch hier nicht umgekehrt:** ein Kommentar und ein angehängtes Foto lösen
|
||||
*keine* Mail aus. Beides sieht die Live-Aktualisierung sofort (Abschnitt 5d); eine Mail dafür wäre
|
||||
Lärm. Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich.
|
||||
|
||||
## 5e. Der Aufkleber ist die Schnittstelle
|
||||
|
||||
Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert
|
||||
|
||||
@@ -56,6 +56,8 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter
|
||||
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang
|
||||
- **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und
|
||||
was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert
|
||||
- **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
|
||||
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
|
||||
|
||||
## Wo sonst noch etwas steht
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Benachrichtigungen einrichten
|
||||
|
||||
Laufzettel meldet vier Dinge, und nur diese vier:
|
||||
|
||||
| Anlass | Wer bekommt es |
|
||||
|---|---|
|
||||
| Ein Lauf wird jemandem **zugewiesen** | der Zugewiesene |
|
||||
| Ein Lauf ist **überfällig** | der Zugewiesene, **einmal** je Lauf |
|
||||
| Ein Lauf wird **abgeschlossen** | der Zugewiesene und wer ihn angelegt hat |
|
||||
| Ein **Verzeichnisabgleich schlägt fehl** | alle aktiven Administratoren |
|
||||
|
||||
**Niemand bekommt Post über das, was er selbst gerade getan hat.** Das ist der häufigste Grund,
|
||||
Benachrichtigungen ganz abzuschalten, und deshalb fällt der Handelnde immer aus der Empfängerliste.
|
||||
|
||||
> **In keiner Nachricht steht ein Schrittinhalt.** Titel, Status, Gerät, Person, Frist und ein Link —
|
||||
> mehr nicht. Kein Wert, keine Notiz, kein Befehl, und damit auch kein `{{generiertes_passwort}}`.
|
||||
> Das Passwort entsteht bei jeder Anzeige neu und wird nirgends gespeichert; eine Mail dagegen liegt
|
||||
> für immer in einem Postfach, oft auf einem Server, der nicht im Haus steht.
|
||||
|
||||
## Was gebraucht wird
|
||||
|
||||
- **`APP_URL`**, damit der Link in der Mail irgendwohin führt. Fehlt sie, gehen die Meldungen
|
||||
trotzdem hinaus — die Einstellungsseite sagt es dann deutlich.
|
||||
- **Ein SMTP-Server** für Mail, **oder** eine Webhook-Adresse, **oder** beides. Ohne eines von beiden
|
||||
wird nichts eingereiht; es entstehen also keine Zeilen, die nie ankommen.
|
||||
|
||||
## Mail über SMTP
|
||||
|
||||
Unter **Admin → Einstellungen → Benachrichtigungen**:
|
||||
|
||||
| Feld | Was hineingehört |
|
||||
|---|---|
|
||||
| Host | `mail.firma.de` — der Mailserver des Hauses |
|
||||
| Port | `587` für StartTLS, `465` für TLS von Anfang an, `25` nur im eigenen Netz |
|
||||
| Benutzer | leer lassen, wenn der Server dem Rechner ohne Anmeldung traut |
|
||||
| Passwort | wird verschlüsselt gespeichert und **nie wieder angezeigt** |
|
||||
| Absender | `laufzettel@firma.de` — muss der Server akzeptieren |
|
||||
| TLS verlangen | an, außer der Server kann es wirklich nicht |
|
||||
|
||||
Dann **Speichern**, dann **Testmail schicken**. Die Testmail geht **direkt** hinaus und nicht über
|
||||
die Warteschlange: ein Fehler erscheint sofort und im Klartext, statt eine Minute später in einer
|
||||
Liste.
|
||||
|
||||
**Das Passwortfeld ist leer, obwohl ein Passwort gesetzt ist** — das ist Absicht. Leer heißt
|
||||
„unverändert lassen“. Sonst löschte jedes Speichern der übrigen Felder das Passwort mit.
|
||||
|
||||
### Wenn die Testmail nicht ankommt
|
||||
|
||||
| Meldung | Ursache |
|
||||
|---|---|
|
||||
| `greeting never received` | `secure` passt nicht zum Port. Auf 587 gehört StartTLS, nicht TLS von Anfang an — Laufzettel entscheidet das selbst am Port, aber ein Server, der auf 465 StartTLS erwartet, ist falsch konfiguriert. |
|
||||
| `535 Authentication failed` | Benutzer oder Passwort stimmen nicht. Manche Server wollen die vollständige Mailadresse als Benutzernamen. |
|
||||
| `550 relay not permitted` | Der Server nimmt von dieser Maschine keine Mail für fremde Domains an. Entweder den Rechner freischalten oder sich anmelden. |
|
||||
| `self signed certificate` | Der Mailserver hat ein eigenes Zertifikat. Entweder ein richtiges einrichten oder TLS für diesen Server abschalten — im eigenen Netz ist das zweite vertretbar. |
|
||||
| `ECONNREFUSED` | Port falsch, oder eine Firewall dazwischen. |
|
||||
|
||||
## Webhook
|
||||
|
||||
Dieselben vier Anlässe als `POST` mit JSON-Rumpf:
|
||||
|
||||
```json
|
||||
{
|
||||
"anlass": "LAUF_ABGESCHLOSSEN",
|
||||
"betreff": "Laufzettel abgeschlossen: „PC Neuinstallation – PC-001“",
|
||||
"link": "https://laufzettel.firma.de/laeufe/cmu…",
|
||||
"laufId": "cmu…",
|
||||
"verzeichnisquelleId": null,
|
||||
"zeitpunkt": "2026-10-05T08:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
`anlass` steht vorn, damit sich ohne Auspacken filtern lässt. Auch hier: **keine Schrittinhalte.**
|
||||
|
||||
Als erfolgreich gilt nur eine Antwort mit `2xx`. Alles andere ist ein Fehlschlag und wird wiederholt.
|
||||
|
||||
### Die Signatur nachrechnen
|
||||
|
||||
Ist ein Geheimnis gesetzt, trägt jede Anfrage den Kopf:
|
||||
|
||||
```
|
||||
X-Laufzettel-Signatur: t=1762334400,v1=9f86d08188...
|
||||
```
|
||||
|
||||
Signiert wird `<t>.<rumpf>` mit HMAC-SHA256. **Der Zeitstempel ist Teil der signierten Nachricht**,
|
||||
nicht Beiwerk — stünde er nur daneben, könnte ihn jeder ändern, ohne die Signatur zu brechen, und
|
||||
eine abgefangene Nachricht ließe sich in einem Jahr noch einspielen.
|
||||
|
||||
So prüft die Gegenseite (Node, aber in jeder Sprache dasselbe):
|
||||
|
||||
```js
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
function pruefen(geheimnis, rumpf, kopf) {
|
||||
const teile = Object.fromEntries(kopf.split(",").map((s) => s.split("=")));
|
||||
const alter = Math.abs(Math.floor(Date.now() / 1000) - Number(teile.t));
|
||||
if (alter > 300) return false; // älter als fünf Minuten: ablehnen
|
||||
|
||||
const erwartet = createHmac("sha256", geheimnis).update(`${teile.t}.${rumpf}`).digest("hex");
|
||||
// Zeitkonstant vergleichen, nicht mit ===
|
||||
return timingSafeEqual(Buffer.from(erwartet), Buffer.from(teile.v1));
|
||||
}
|
||||
```
|
||||
|
||||
Drei Dinge, die dabei schiefgehen:
|
||||
|
||||
1. **Den Rumpf vorher geparst und wieder serialisiert.** Dann stimmt die Signatur nicht mehr —
|
||||
`JSON.stringify` setzt Leerzeichen und Reihenfolge anders. Signiert wird der **rohe** Rumpf, also
|
||||
vor jeder Verarbeitung lesen.
|
||||
2. **Mit `===` verglichen.** Das bricht beim ersten Unterschied ab; aus der Dauer ließe sich die
|
||||
richtige Signatur Zeichen für Zeichen erraten. `timingSafeEqual` oder das Äquivalent benutzen.
|
||||
3. **Nur „zu alt“ geprüft.** Eine Nachricht mit Zeitstempel im Jahr 2040 wäre dann für immer gültig.
|
||||
Der Abstand gehört in **beide** Richtungen geprüft.
|
||||
|
||||
Ohne Geheimnis fehlt die Kopfzeile ganz, statt dass eine leere Signatur Sicherheit vortäuscht.
|
||||
|
||||
## Die Warteschlange
|
||||
|
||||
**Eingereiht wird sofort, gesendet nicht.** Ein hängender Mailserver darf kein Abhaken blockieren:
|
||||
der Dienst schreibt eine Zeile, und ein Zeitplan im selben Prozess schickt sie — jede Minute, kein
|
||||
zweiter Dienst, kein Systemd-Timer.
|
||||
|
||||
Ein Fehlschlag wird wiederholt, mit wachsendem Abstand: nach **1, 5, 30 und 120 Minuten**. Beim
|
||||
fünften Fehlschlag gibt Laufzettel auf und lässt die Zeile als **aufgegeben** stehen.
|
||||
|
||||
> **Eine stille Warteschlange ist schlimmer als keine.** Unter **Admin → Zustellungen** steht, was
|
||||
> hinausgegangen ist und was nicht — mit Grund. Sieh dort hin, wenn jemand sagt, er bekomme keine
|
||||
> Mails: fast immer ist das SMTP-Passwort abgelaufen, und ohne diese Seite merkt es wochenlang
|
||||
> niemand, weil jeder Lauf weiter funktioniert.
|
||||
|
||||
Betreff und Rumpf stehen **fertig** in der Zeile. Eine Meldung sagt, was damals war; würde der Text
|
||||
erst beim Senden gebaut, stünde in einer Mail von heute der Stand von jetzt.
|
||||
|
||||
### Mehrere Instanzen
|
||||
|
||||
Läuft Laufzettel hinter einem Lastverteiler mehrfach, nimmt genau eine Instanz je Durchgang die
|
||||
Warteschlange — über eine Sperre in der Datenbank, dieselbe Mechanik wie beim Verzeichnisabgleich.
|
||||
Ohne sie bekäme jeder Empfänger jede Mail doppelt. Dafür ist nichts einzurichten.
|
||||
|
||||
## Je Person abschalten
|
||||
|
||||
Unter **Admin → Benutzer** schaltet der **Mail**-Knopf die Benachrichtigungen einer Person ab. Das
|
||||
gilt auch für das eigene Konto — sich selbst zu deaktivieren wäre ein Eigentor, seine eigene Post
|
||||
abzustellen ist der häufigste Wunsch überhaupt.
|
||||
|
||||
Wer abgeschaltet hat, bekommt keine Zeile und keinen Fehler: es wird gar nichts eingereiht. Ein
|
||||
deaktiviertes Konto ebenso.
|
||||
|
||||
## Was nicht gemeldet wird
|
||||
|
||||
- **Ein Kommentar oder ein angehängtes Foto.** Das sieht die Live-Aktualisierung in der
|
||||
Abarbeitungsansicht sofort; eine Mail dafür wäre Lärm.
|
||||
- **Ein einzelner abgehakter Schritt.** Dasselbe.
|
||||
- **Ein archivierter Lauf.** Archivieren ist eine Aufräumarbeit, keine Nachricht.
|
||||
|
||||
Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich. Diese vier decken
|
||||
ab, was im Betrieb wirklich fehlt.
|
||||
|
||||
## Prüfen, dass es läuft
|
||||
|
||||
```bash
|
||||
# Steht etwas in der Warteschlange, das nicht hinausgeht?
|
||||
docker compose exec -T postgres psql -U laufzettel laufzettel -tAc \
|
||||
"SELECT zustand, count(*) FROM \"Zustellung\" GROUP BY zustand"
|
||||
```
|
||||
|
||||
Nativ dasselbe über `sudo -u postgres psql -d laufzettel -tAc "…"`.
|
||||
|
||||
Steht dort etwas unter `AUFGEGEBEN`, schau unter **Admin → Zustellungen** nach dem Grund. Alles unter
|
||||
`OFFEN` oder `FEHLGESCHLAGEN` regelt der Zeitplan von selbst — es sei denn, es steht dort seit
|
||||
Stunden, dann läuft der Prozess nicht oder `ZEITPLAN_AKTIV` ist auf `false`.
|
||||
@@ -249,7 +249,7 @@ Datei nicht neu. Änderungen an `APP_VERSION` oder `GIT_SHA` sind Build-Argument
|
||||
|---|---|
|
||||
| `APP_SECRET fehlt in der .env` beim `up` | Genau das. Der Stand bricht absichtlich ab, statt mit einem Standardwert zu starten. Gilt ebenso für `APP_URL` und `POSTGRES_PASSWORD`. |
|
||||
| `web` startet nicht, `migrate` steht auf `exited (1)` | `docker compose logs migrate`. Meist erreicht die Migration die Datenbank nicht — oder du hast `POSTGRES_PASSWORD` nach dem ersten Start geändert. Das Passwort steckt dann noch im Volume. |
|
||||
| `/api/gesundheit` antwortet mit 503 | Die Anwendung läuft, die Datenbank antwortet nicht. `docker compose ps` — ist `postgres` gesund? |
|
||||
| `/api/gesundheit` antwortet mit 503 | Die Anwendung läuft, die Datenbank antwortet nicht. `docker compose ps` — ist `postgres` gesund? Die Antwort nennt absichtlich keinen Grund, weil der Pfad ohne Anmeldung erreichbar ist; der Grund steht in `docker compose logs web`. |
|
||||
| Der Seed bricht mittendrin ab | Bis zur Abnahme von #019 fehlte dem Einmal-Dienst `APP_SECRET`, und das Bind-Passwort der Beispiel-Verzeichnisquelle ließ sich nicht verschlüsseln — abgebrochen wurde erst, nachdem die Vorlagen schon standen. Steht dein `docker-compose.yml` auf dem aktuellen Stand, ist das behoben; ein erneuter Lauf holt den Rest nach. |
|
||||
| Nach dem Login wieder auf der Anmeldeseite | Das Cookie ist `Secure`, die Verbindung aber `http`. `APP_URL` auf `https://…` setzen und HTTPS über den Proxy anbieten. |
|
||||
| Die QR-Codes zeigen auf `localhost:3000` | `APP_URL` ist nicht oder falsch gesetzt. Die Oberfläche warnt unter **Admin** und in der Etikettenansicht davor. Aufkleber, die so gedruckt wurden, sind wertlos — bevor du weiterdruckst, `APP_URL` richtigstellen und `docker compose up -d`. |
|
||||
|
||||
@@ -58,6 +58,14 @@ laufzettel.firma.de {
|
||||
# gerade beim Neustart. Der Pfad braucht keine Anmeldung.
|
||||
health_uri /api/gesundheit
|
||||
health_interval 30s
|
||||
|
||||
# Die Live-Aktualisierung ist ein Ereignisstrom (#033). Caddy erkennt das
|
||||
# heute an `Content-Type: text/event-stream` und reicht von sich aus
|
||||
# sofort durch – ausdruecklich gesetzt bleibt es auch dann richtig, wenn
|
||||
# eine Fassung das einmal anders handhabt. Ohne sofortiges Durchreichen
|
||||
# sammelt der Proxy den Strom, und die Seite sieht aus, als sei sie
|
||||
# haengen geblieben.
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -102,9 +110,16 @@ server {
|
||||
ssl_certificate /etc/letsencrypt/live/laufzettel.firma.de/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/laufzettel.firma.de/privkey.pem;
|
||||
|
||||
# Der CSV-Import schickt die Datei als Text durch eine Server Action. nginx
|
||||
# laesst von sich aus nur 1 MB durch und waere damit die erste Wand.
|
||||
client_max_body_size 8m;
|
||||
# Der CSV-Import schickt die Datei als Text durch eine Server Action, und
|
||||
# seit #032 haengt jemand Fotos an Schritte. nginx laesst von sich aus nur
|
||||
# 1 MB durch und waere damit die erste Wand.
|
||||
#
|
||||
# **Muss ueber `ANHANG_MAX_MB` liegen.** Steht dort die Vorgabe 10, dann
|
||||
# reichen 8m nicht: ein 10-MB-Foto scheitert an nginx mit „413 Request
|
||||
# Entity Too Large“, und die Meldung der Anwendung („Erlaubt sind hoechstens
|
||||
# 10,0 MB“) kommt nie zum Zuge. Wer die Grenze in der Anwendung anhebt,
|
||||
# hebt sie hier mit an — mit etwas Luft fuer den Rahmen des Formulars.
|
||||
client_max_body_size 12m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
@@ -117,12 +132,22 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# Der Lauf fragt seinen Zustand alle zehn Sekunden nach; 90 s sind
|
||||
# reichlich und fangen einen langsamen Verzeichnis-Abgleich mit ab.
|
||||
# Die Abarbeitungsansicht haelt seit #033 einen **offenen** Strom auf
|
||||
# /api/laeufe/<id>/zustand. Der Server schickt alle 25 s einen
|
||||
# Herzschlag, damit nginx die stille Verbindung nicht schliesst; 90 s
|
||||
# sind reichlich dafuer und fangen einen langsamen Verzeichnis-Abgleich
|
||||
# mit ab. **Unter 30 s darf dieser Wert nicht fallen**, sonst reisst der
|
||||
# Strom zwischen zwei Herzschlaegen immer wieder ab.
|
||||
proxy_read_timeout 90s;
|
||||
|
||||
# Next liefert Seiten stroemend aus. Ohne das haelt nginx die Antwort
|
||||
# zusammen, und die Seite erscheint spuerbar spaeter auf einmal.
|
||||
# Next liefert Seiten stroemend aus, und die Live-Aktualisierung ist ein
|
||||
# Ereignisstrom. Ohne das haelt nginx die Antwort zusammen: die Seite
|
||||
# erscheint spuerbar spaeter auf einmal, und vom Strom kommt gar nichts
|
||||
# an — sie sieht dann aus, als sei sie haengen geblieben.
|
||||
#
|
||||
# Die Anwendung schickt zusaetzlich `X-Accel-Buffering: no` auf der
|
||||
# Antwort des Stroms. Das wirkt auch, wenn hier `on` stuende, und deckt
|
||||
# genau diesen einen Pfad ab; die Zeile hier gilt fuer alles andere.
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
@@ -203,6 +228,11 @@ schlimmsten Fall, macht den Unterpfad aber nicht betriebsfähig.
|
||||
antwortet `{"status":"ok"}` oder mit Status 503. Das ist der richtige Pfad für die Überwachung des
|
||||
Proxys; `/` leitet ohne Cookie auf die Anmeldeseite um und sagt nichts über den Zustand.
|
||||
|
||||
Im Fehlerfall sagt er nur `{"status":"fehler"}` und **nicht, warum** — der Pfad ist ohne Anmeldung
|
||||
erreichbar, und eine Datenbankmeldung nennt Host, Port, Benutzernamen und Datenbanknamen. Die
|
||||
Begründung steht im Protokoll des Prozesses: `docker compose logs web` beziehungsweise
|
||||
`journalctl -u laufzettel -n 50`.
|
||||
|
||||
**Die Kamera braucht HTTPS.** Der QR-Scanner in der Anwendung benutzt die Kamera des Geräts, und
|
||||
Browser geben sie nur über eine gesicherte Verbindung frei (`localhost` ausgenommen). Ohne TLS
|
||||
scannt am Handy niemand — das ist der praktische Grund, warum der Proxy nicht optional ist.
|
||||
|
||||
+123
-23
@@ -11,12 +11,19 @@ ebenfalls einfach, aber nur, wenn man es einmal geübt hat.
|
||||
|
||||
## Was gesichert werden muss
|
||||
|
||||
Zwei Dinge, und nur diese zwei:
|
||||
Drei Dinge, und nur diese drei:
|
||||
|
||||
| | Was | Wo |
|
||||
|---|---|---|
|
||||
| **1** | **Die Datenbank.** Alles Veränderliche steckt darin: Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten |
|
||||
| **1** | **Die Datenbank.** Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten |
|
||||
| **2** | **Die Geheimnisse.** `APP_SECRET` und das Datenbank-Passwort. | Compose: die `.env` · Nativ: `/etc/laufzettel/laufzettel.env` |
|
||||
| **3** | **Die Anhänge.** Die Dateien und Fotos an den Schritten. | Volume `laufzettel_anhaenge` · Nativ: `ANHAENGE_PFAD` |
|
||||
|
||||
> **Die Anhänge sind seit #032 dabei, und sie sind die Falle.** In der Datenbank stehen nur ihre
|
||||
> **Metadaten** — Name, Typ, Größe, Prüfsumme. Die Dateien selbst liegen im Dateisystem. Eine
|
||||
> Sicherung, die nur `pg_dump` macht, sieht vollständig aus und ist es nicht: nach dem Zurückspielen
|
||||
> stehen die Anhänge in der Oberfläche, und jeder Klick darauf meldet, dass die Datei fehlt.
|
||||
> Datenbank und Verzeichnis gehören **zum selben Zeitpunkt** gesichert.
|
||||
|
||||
**Warum die Geheimnisse dazugehören:** `APP_SECRET` verschlüsselt nicht nur die Sitzungs-Cookies,
|
||||
sondern auch die gespeicherten LDAP-Bind-Passwörter. Spielst du eine Datenbank in eine Installation
|
||||
@@ -29,9 +36,8 @@ Sicher die beiden Dinge getrennt und leg das Geheimnis dahin, wo auch die restli
|
||||
Hauses liegen — in den Passwortmanager. Eine Datei mit `APP_SECRET` neben dem Datenbank-Abzug auf
|
||||
derselben Netzwerkfreigabe hebt den Zweck der Verschlüsselung auf.
|
||||
|
||||
**Was nicht gesichert werden muss:** Die Anwendung hält keinen veränderlichen Zustand auf der Platte.
|
||||
Kein Upload-Verzeichnis, keine erzeugten Dateien. Der Quellcode kommt aus Git, `node_modules` und
|
||||
`.next` entstehen beim Bauen neu, das PDF der Anleitung liegt am Release.
|
||||
**Was nicht gesichert werden muss:** Der Quellcode kommt aus Git, `node_modules` und `.next`
|
||||
entstehen beim Bauen neu, das PDF der Anleitung liegt am Release.
|
||||
|
||||
**Was der Abzug bewusst nicht enthält:** ein `{{generiertes_passwort}}`. Es wird bei jeder Anzeige neu
|
||||
erzeugt und nie gespeichert — nicht im Schrittergebnis, nicht im Kommentar, nicht im Protokoll. Wer
|
||||
@@ -73,6 +79,28 @@ Das Volume heißt `laufzettel_datenbank`. Es mit `docker run --volumes-from` weg
|
||||
**keine** gleichwertige Sicherung: ein Dateiabzug eines laufenden PostgreSQL ist im besten Fall
|
||||
brauchbar und im schlechteren still beschädigt. Nimm `pg_dump`.
|
||||
|
||||
#### Und die Anhänge
|
||||
|
||||
Hier gilt das Umgekehrte: ein Dateiabzug ist genau richtig, denn es sind gewöhnliche Dateien.
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v laufzettel_anhaenge:/daten:ro \
|
||||
-v "$PWD":/ziel \
|
||||
debian:13 tar czf /ziel/anhaenge-$(date +%F).tar.gz -C /daten .
|
||||
```
|
||||
|
||||
Kurz nachsehen, dass etwas drin ist:
|
||||
|
||||
```bash
|
||||
tar tzf anhaenge-$(date +%F).tar.gz | head -5
|
||||
tar tzf anhaenge-$(date +%F).tar.gz | wc -l
|
||||
```
|
||||
|
||||
Ist die Datei leer und es gibt Anhänge in der Oberfläche, stimmt der Volume-Name nicht — `docker
|
||||
volume ls | grep anhaenge` nennt den richtigen. Er trägt den Projektnamen als Präfix, und der kommt
|
||||
aus dem Verzeichnisnamen.
|
||||
|
||||
### Zurückspielen
|
||||
|
||||
Die Datenbank muss dafür leer sein — ein Abzug in eine gefüllte Datenbank gespielt bringt lauter
|
||||
@@ -91,10 +119,18 @@ docker compose exec -T postgres createdb -U laufzettel -O laufzettel laufzettel
|
||||
# 3. Abzug einspielen
|
||||
gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel
|
||||
|
||||
# 4. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt
|
||||
# 4. Die Anhänge zurück ins Volume – **vorher leeren**, sonst bleiben Dateien
|
||||
# liegen, auf die keine Zeile mehr zeigt
|
||||
docker run --rm -v laufzettel_anhaenge:/daten debian:13 sh -c 'rm -rf /daten/* /daten/.[!.]*' || true
|
||||
docker run --rm \
|
||||
-v laufzettel_anhaenge:/daten \
|
||||
-v "$PWD":/quelle:ro \
|
||||
debian:13 tar xzf /quelle/anhaenge-2026-09-24.tar.gz -C /daten
|
||||
|
||||
# 5. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt
|
||||
docker compose run --rm migrate pnpm exec prisma migrate deploy
|
||||
|
||||
# 5. Wieder anfahren
|
||||
# 6. Wieder anfahren
|
||||
docker compose start web
|
||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||
```
|
||||
@@ -118,6 +154,14 @@ auf —, aber alles, was er anlegen würde, steht schon im Abzug.
|
||||
sudo -u postgres pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%F).dump
|
||||
```
|
||||
|
||||
Und die Anhänge dazu — gewöhnliche Dateien, also `tar`:
|
||||
|
||||
```bash
|
||||
# Der Pfad steht in ANHAENGE_PFAD; ohne Angabe liegt er unter /opt/laufzettel/daten/anhaenge.
|
||||
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
|
||||
sudo tar czf /var/backups/laufzettel-anhaenge-$(date +%F).tar.gz -C "$ANHAENGE" .
|
||||
```
|
||||
|
||||
Und das Geheimnis, einmalig und in den Passwortmanager:
|
||||
|
||||
```bash
|
||||
@@ -141,16 +185,29 @@ sudo -u postgres createdb -O laufzettel laufzettel
|
||||
# 3. Abzug einspielen
|
||||
sudo -u postgres pg_restore -d laufzettel --no-owner /var/backups/laufzettel-2026-09-24.dump
|
||||
|
||||
# 4. Migrationen nachziehen, wenn der Abzug älter ist als der Code
|
||||
# 4. Die Anhänge zurück – **vorher leeren**, sonst bleiben Dateien liegen, auf
|
||||
# die keine Zeile mehr zeigt
|
||||
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
|
||||
sudo rm -rf "$ANHAENGE"
|
||||
sudo install -d -o laufzettel -g laufzettel -m 0750 "$ANHAENGE"
|
||||
sudo tar xzf /var/backups/laufzettel-anhaenge-2026-09-24.tar.gz -C "$ANHAENGE"
|
||||
sudo chown -R laufzettel:laufzettel "$ANHAENGE"
|
||||
|
||||
# 5. Migrationen nachziehen, wenn der Abzug älter ist als der Code
|
||||
sudo bash /opt/laufzettel/deploy/install.sh
|
||||
|
||||
# 5. Wieder anfahren und nachsehen
|
||||
# 6. Wieder anfahren und nachsehen
|
||||
sudo systemctl start laufzettel
|
||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||
```
|
||||
|
||||
Schritt 4 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich
|
||||
neu startet — Schritt 5 ist dann nur noch die Gegenprobe. Willst du nur die Migrationen:
|
||||
Schritt 5 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich
|
||||
neu startet — Schritt 6 ist dann nur noch die Gegenprobe.
|
||||
|
||||
**Der `chown` in Schritt 4 ist nicht Beiwerk.** `tar` legt die Dateien mit den Besitzern aus dem
|
||||
Archiv an; läuft die Rücksicherung als `root`, gehören sie danach `root`, und der Dienstnutzer
|
||||
`laufzettel` kommt nicht mehr an sie heran. Jeder Anhang meldet dann „Datei fehlt“, obwohl sie
|
||||
daliegt. Willst du nur die Migrationen:
|
||||
|
||||
```bash
|
||||
cd /opt/laufzettel
|
||||
@@ -170,11 +227,30 @@ After=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=postgres
|
||||
# %i-freies Einzeiler-Skript: pg_dump in eine Datei mit Datum, dann alles
|
||||
# aelter als 30 Tage weg.
|
||||
ExecStart=/bin/bash -c 'pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%%F).dump'
|
||||
ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*.dump' -mtime +30 -delete
|
||||
# `root`, nicht `postgres`: der Timer sichert jetzt auch das Anhang-Verzeichnis,
|
||||
# und das gehoert dem Dienstnutzer `laufzettel`. Der Datenbankteil laeuft
|
||||
# weiterhin als `postgres`, ueber `runuser`.
|
||||
User=root
|
||||
ExecStart=/usr/local/sbin/laufzettel-sichern
|
||||
ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*' -mtime +30 -delete
|
||||
```
|
||||
|
||||
`/usr/local/sbin/laufzettel-sichern`, Modus 0750:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Datenbank und Anhaenge zum **selben** Zeitpunkt. Getrennt gesichert ergeben
|
||||
# beide Haelften kein vollstaendiges Bild (#032).
|
||||
set -Eeuo pipefail
|
||||
tag=$(date +%F)
|
||||
runuser -u postgres -- pg_dump -Fc laufzettel > "/var/backups/laufzettel-$tag.dump"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. /etc/laufzettel/laufzettel.env
|
||||
anhaenge="${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"
|
||||
if [ -d "$anhaenge" ]; then
|
||||
tar czf "/var/backups/laufzettel-anhaenge-$tag.tar.gz" -C "$anhaenge" .
|
||||
fi
|
||||
```
|
||||
|
||||
`/etc/systemd/system/laufzettel-sicherung.timer`:
|
||||
@@ -193,19 +269,22 @@ RandomizedDelaySec=15m
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
Einrichten, einschalten, und **einmal von Hand auslösen** — eine Sicherung, die
|
||||
nur im Timer steht, hat noch nie eine Datei erzeugt:
|
||||
|
||||
```bash
|
||||
sudo install -m 0750 laufzettel-sichern /usr/local/sbin/laufzettel-sichern
|
||||
sudo install -d -o postgres -g postgres -m 0700 /var/backups
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now laufzettel-sicherung.timer
|
||||
sudo systemctl start laufzettel-sicherung.service
|
||||
ls -l /var/backups/laufzettel-*
|
||||
sudo systemctl list-timers laufzettel-sicherung.timer
|
||||
```
|
||||
|
||||
Einmal von Hand auslösen und nachsehen, ob wirklich eine Datei entstanden ist:
|
||||
|
||||
```bash
|
||||
sudo systemctl start laufzettel-sicherung.service
|
||||
ls -l /var/backups/
|
||||
```
|
||||
Nach dem Lauf müssen **zwei** Dateien daliegen: `laufzettel-<Datum>.dump` und
|
||||
`laufzettel-anhaenge-<Datum>.tar.gz`. Fehlt die zweite, gibt es entweder keine Anhänge, oder
|
||||
`ANHAENGE_PFAD` zeigt woandershin als gedacht.
|
||||
|
||||
### Compose, als Cron-Eintrag
|
||||
|
||||
@@ -218,6 +297,12 @@ In der Crontab von root (`sudo crontab -e`):
|
||||
Das `%` muss in einer Crontab als `\%` geschrieben werden, sonst schneidet cron die Zeile ab — der
|
||||
häufigste Fehler an dieser Stelle, und er fällt erst auf, wenn die Datei fehlt.
|
||||
|
||||
Und die Anhänge direkt danach, damit beide Hälften vom selben Zeitpunkt stammen:
|
||||
|
||||
```cron
|
||||
20 3 * * * docker run --rm -v laufzettel_anhaenge:/daten:ro -v /var/backups:/ziel debian:13 tar czf /ziel/laufzettel-anhaenge-$(date +\%F).tar.gz -C /daten . && find /var/backups -name 'laufzettel-anhaenge-*' -mtime +30 -delete
|
||||
```
|
||||
|
||||
### Und dann weg vom Rechner
|
||||
|
||||
Eine Sicherung, die auf derselben Platte liegt wie die Datenbank, hilft gegen einen Tippfehler und
|
||||
@@ -258,6 +343,21 @@ sudo -u postgres dropdb probe
|
||||
Kommt bei `count(*)` eine plausible Zahl heraus, ist die Sicherung brauchbar. Kommt eine Fehlermeldung
|
||||
oder eine Null, hast du es jetzt erfahren und nicht im Ernstfall.
|
||||
|
||||
### Und die Anhänge mitprüfen
|
||||
|
||||
Die Zahl in der Datenbank gegen die Zahl der Dateien halten — passt sie nicht, fehlt eine der beiden
|
||||
Hälften:
|
||||
|
||||
```bash
|
||||
# Compose: Zeilen in der Datenbank …
|
||||
docker compose exec -T postgres psql -U laufzettel laufzettel -tAc 'SELECT count(*) FROM "Anhang"'
|
||||
# … gegen Dateien im Archiv
|
||||
tar tzf anhaenge-2026-09-24.tar.gz | grep -c '\.' || true
|
||||
```
|
||||
|
||||
Stimmen die Zahlen nicht überein, ist entweder der Abzug älter als das Archiv oder umgekehrt. Beides
|
||||
zum **selben Zeitpunkt** sichern — am einfachsten im selben Skript, direkt hintereinander.
|
||||
|
||||
## Umzug auf einen neuen Server
|
||||
|
||||
Derselbe Weg, nur mit einer Zwischenstation:
|
||||
@@ -284,6 +384,6 @@ Die PostgreSQL-Hauptfassung sollte auf dem Zielsystem dieselbe oder eine neuere
|
||||
| `ERROR: relation "Benutzer" already exists` | Du spielst in eine gefüllte Datenbank. Erst `dropdb`/`createdb`, siehe oben. |
|
||||
| `role "laufzettel" does not exist` beim Zurückspielen auf einem anderen System | Der Abzug nennt Eigentümer, die dort fehlen. `pg_restore --no-owner` beziehungsweise `pg_dump --no-owner` beim Erzeugen. |
|
||||
| Nach dem Zurückspielen schlägt der Verzeichnis-Abgleich fehl | `APP_SECRET` ist nicht mehr dasselbe, und das Bind-Passwort ist unlesbar. Entweder den alten Wert eintragen und neu starten, oder unter **Admin → Verzeichnis** das Bind-Passwort neu setzen. |
|
||||
| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 4 oben. |
|
||||
| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 5 oben. |
|
||||
| Die Anwendung startet nach dem Zurückspielen nicht | `docker compose logs web` beziehungsweise `journalctl -u laufzettel -n 50 --no-pager`. Meist läuft noch die Migration, oder sie ist gescheitert. |
|
||||
| Ein Tag-Code taucht doppelt auf | Kann nach einem Zurückspielen nicht passieren; ein partieller Eindeutigkeitsindex in der Datenbank verhindert es. Siehst du es doch, ist der Abzug nicht sauber eingespielt — Datenbank leeren und noch einmal von vorn. |
|
||||
|
||||
+263
-1
@@ -22,11 +22,23 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #017 | M12 – Seed der sieben Vorlagen | 1 h 30 min |
|
||||
| #018 | M13 – E2E-Tests, Anleitung, PDF | 2 h 15 min |
|
||||
| #019 | M14 – Betriebswege und Release v0.1.0 | 5 h 25 min |
|
||||
| | **Summe** | **35 h 20 min** |
|
||||
| #025 | Healthcheck gibt die rohe Datenbankfehlermeldung heraus | 25 min |
|
||||
| #026 | Zwei Konventionen präzisieren | 20 min |
|
||||
| #020 | Vorgesetzter auch für von Hand angelegte Personen | 1 h 20 min |
|
||||
| #021 | Stabiler Schlüssel für Seed-Vorlagen | 55 min |
|
||||
| #022 | Schnellstart je Gerätetyp pflegen | 1 h 10 min |
|
||||
| #023 | Protokoll ansehen | 1 h 50 min |
|
||||
| #030 | M17 – Wiederverwendbare Bausteine | 2 h 25 min |
|
||||
| #031 | M18 – Abteilungsspezifische Zusatzabschnitte | 1 h 35 min |
|
||||
| #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min |
|
||||
| #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min |
|
||||
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
|
||||
| | **Summe** | **53 h 35 min** |
|
||||
|
||||
| Meilenstein | Tatsächlich | Status |
|
||||
|---|---|---|
|
||||
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
||||
| v0.2.0 | 18 h 15 min | in Arbeit |
|
||||
|
||||
---
|
||||
|
||||
@@ -977,6 +989,52 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-26 · Sitzung 16 · 16:00–16:45
|
||||
|
||||
**Issues:** #025, #026 (beide aus der Abnahme zu #019)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **`/api/gesundheit` verrät nichts mehr.** Der Pfad ist ohne Anmeldung erreichbar; eine Meldung von
|
||||
Prisma oder `pg` nennt Host, Port, Benutzernamen und Datenbanknamen. Nach außen bleibt
|
||||
`{"status":"fehler"}` mit 503, die Begründung geht ins Protokoll — dasselbe Muster, das
|
||||
`src/server/actions/verzeichnis.ts` schon für unbekannte Ausnahmen benutzt
|
||||
- **Fünf Tests** dafür, einer davon für die andere Hälfte der Zusage: dass die Begründung wirklich
|
||||
**geschrieben** und nicht bloß verschluckt wird
|
||||
- **Die Hexwert-Regel nennt ihre Ausnahmen.** Sechs Stellen, als Tabelle mit Begründung je Zeile
|
||||
- **Der LDAP-Container steht jetzt im Tor**, in `CLAUDE.md` und im README — mit der gemessenen Zahl
|
||||
- **`pnpm pruefe:doku` vergleicht die beiden Schranken-Blöcke** und verlangt von beiden den Container
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Die Regel wurde ergänzt, nicht der Code geändert.** Alle sechs Hexwerte haben denselben Grund:
|
||||
dort entscheidet nicht unser Farbschema, sondern etwas außerhalb davon — Papier, ein QR-Decoder,
|
||||
das Web-App-Manifest. Sie in eine Konstante zu ziehen hätte eine Abstraktion über drei
|
||||
unverwandte Dinge gelegt und die Begründung versteckt. Die Tabelle in `CLAUDE.md` zeigt sie.
|
||||
- **Der Hinweis auf den Container reicht nicht, er muss geprüft werden.** Zwei Dateien beschreiben
|
||||
dasselbe Tor und waren auseinandergelaufen. Ein Satz in einer Konvention hält nur, solange ihn
|
||||
jemand liest; die sechste Prüfung in `pruefe:doku` hält ihn, auch wenn niemand hinsieht.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Ich hätte „rund zwanzig Tests" in eine Konvention geschrieben.** Die Zahl stammte aus dem
|
||||
Abnahmebericht (dort: 18), nicht aus einer Messung. Nachgezählt sind es **22** — mit Container 81,
|
||||
ohne 59. Eine ungefähre Zahl in einer verbindlichen Datei ist derselbe Fehlertyp, den dieses
|
||||
Projekt vierzehn Meilensteine lang gesammelt hat, nur kleiner.
|
||||
- **Der Review hat eine Ausnahme übersehen.** Er nannte vier rohe Hexwerte, es sind sechs:
|
||||
`src/lib/qr.ts` fehlte. Das Issue #026 hat die falsche Zahl von ihm übernommen; beim Abhaken wäre
|
||||
ein richtiger Haken über falschem Text entstanden. Issue-Text korrigiert, dann abgehakt.
|
||||
- **`docs/architektur.md` trägt die Gestaltungsregel nicht**, obwohl `CLAUDE.md` verlangt, dass
|
||||
Änderungen dort mitgehen und das Issue es behauptete. Geprüft statt geglaubt.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- Phase 2 (#002) oder die vier verbliebenen v0.2.0-Issues: #020 Vorgesetzter, #021 stabiler
|
||||
Schlüssel, #022 Schnellstart pflegen, #023 Protokoll-Ansicht. #024 wartet auf einen PVE-Host.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 abgeschlossen: was aus dem Plan wurde
|
||||
|
||||
**35 h 20 min** über vierzehn Meilensteine, geschätzt waren **148 h**. Der Faktor ist kein Verdienst,
|
||||
@@ -1040,3 +1098,207 @@ Sechs Folge-Issues für `v0.2.0`: Vorgesetzter für von Hand angelegte Personen
|
||||
Schlüssel für Seed-Vorlagen (#021), Schnellstart je Gerätetyp pflegen (#022), Protokollansicht
|
||||
(#023). Dazu die Punkte, die der Abnahme-Review aufwirft. Nichts davon blockiert den Betrieb; alles
|
||||
davon steht geschrieben, statt verschwiegen zu werden.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-04 · Sitzung 17 · der ganze Tag
|
||||
|
||||
**Issues:** #021, #020, #022, #023, #046/#047, Phase-2-Plan, #030 (M17), #031 (M18), #032 (M19)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
Eine lange Sitzung mit acht zusammengeführten Änderungen. Der Devlog-Eintrag kommt am Ende des Tages
|
||||
und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal trägt.
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **Der Rückstand für `v0.2.0` ist leer.** Stabiler Seed-Schlüssel (#021), Vorgesetzter für von Hand
|
||||
angelegte Personen (#020), Schnellstart je Gerätetyp in der Oberfläche pflegen (#022), und das
|
||||
Protokoll ist ansehbar geworden (#023) — geschrieben wurde es seit dem Datenmodell, ansehen konnte
|
||||
es niemand.
|
||||
- **Phase 2 in acht Meilensteine geschnitten** (#028–#035), mit drei Entscheidungen, die vor dem
|
||||
ersten Codepfad fallen mussten.
|
||||
- **M17 Bausteine** (#030): ein Abschnitt, den mehrere Vorlagen teilen, versioniert nach denselben
|
||||
Regeln wie eine Vorlage. Eingesetzt wird er als tiefe Kopie beim **Veröffentlichen** — damit ändert
|
||||
eine neue Baustein-Fassung keine veröffentlichte Vorlagenversion und keinen laufenden Lauf.
|
||||
- **M18 Abteilungsabschnitte** (#031): ein Abschnitt kann sagen, für welche Abteilungen er gilt.
|
||||
Welche Abschnitte ein Lauf hat, wird beim Start festgeschrieben.
|
||||
- **M19 Anhänge** (#032): Fotos und Dateien an einem Schritt, am Handy direkt aus der Kamera. Dateien
|
||||
im Dateisystem, Metadaten in der Datenbank, jeder Abruf prüft Recht und Mandant.
|
||||
- **Sicherung und Rücksicherung der Anhänge wirklich gefahren** — Abzug gezogen, Datenbank verworfen,
|
||||
Verzeichnis gelöscht, zurückgespielt, und die ausgelieferten Bytes gegen die gespeicherte
|
||||
`pruefsumme` gehalten.
|
||||
- 1246 Vitest-Tests (von 1130 am Morgen), alle Schranken grün.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Ein Baustein wird beim Veröffentlichen aufgelöst, nicht beim Lesen.** Eine Auflösung zur Laufzeit
|
||||
wäre weniger Code gewesen und hätte die Zusage „eine veröffentlichte Version wird nie verändert“
|
||||
gebrochen — über eine Hintertür, die niemand so nennt.
|
||||
- **`Lauf.geltendeAbschnitte` ist leer heißt „alle“.** Damit verhalten sich Läufe von vor #031
|
||||
unverändert, ohne Migration von Daten.
|
||||
- **Anhänge ins Dateisystem, nicht als `bytea`.** Ein `pg_dump` mit Fotos ist nach einem Jahr kein
|
||||
Abzug mehr, den jemand einspielt. Der Preis ist die zweite Hälfte der Sicherung, und der steht
|
||||
jetzt in `docs/betrieb/sicherung.md`.
|
||||
- **Hochgeladen wird über eine Server Action, nicht über einen zweiten Endpunkt.** Das Issue hatte
|
||||
einen Route Handler vorgesehen; der wäre eine zweite Tür mit eigener Rechteprüfung. Der Route
|
||||
Handler bleibt für das Ausliefern, und er ruft `rechtErforderlich` selbst — die Hülle unter `(app)`
|
||||
deckt Route Handler nicht ab.
|
||||
- **Die Regel „geltende Abschnitte“ liegt in `src/lib`**, nicht in einem Lesemodell. Drei Schichten
|
||||
brauchen sie; lebte sie in einem Lesemodell, importierte ein Dienst daraus.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Ein Fehler, den neunzehn grüne Tests nicht sehen konnten.** Der Schritt hat zwei Felder `datei`
|
||||
in einem Formular — „Foto“ und „Datei“ —, abgeschickt wird immer beides, und `FormData.get` nahm
|
||||
das erste, also das leere. Wer „Datei“ wählte, bekam „Keine Datei gewählt“, während „Foto“ ging.
|
||||
Kein Dienst-Test schickt zwei Felder; gefunden hat es erst die Klickprobe am gebauten Stand. Die
|
||||
Lehre ist dieselbe wie in #019: **Fehler im dokumentierten Weg eines Dritten sind für eine grüne
|
||||
Suite unsichtbar.** Seitdem gibt es `tests/e2e/anhaenge.spec.ts`, der bewusst über den zweiten
|
||||
Knopf hochlädt, und die Gegenprobe wurde gefahren: mit `get` statt `getAll` wird er rot.
|
||||
- **`docs/betrieb/sicherung.md` enthielt einen falschen Satz**, nicht bloß eine Lücke: „Kein
|
||||
Upload-Verzeichnis, keine erzeugten Dateien.“ Mit den Anhängen hätte eine Sicherung nach dieser
|
||||
Anleitung still Daten verloren. Die Datei ist neu geschrieben.
|
||||
- **Zweimal habe ich dem Produkt angelastet, was mein Prüfskript falsch machte** — ein zu weiter
|
||||
`role="status"`-Selektor und eine Prüfung direkt nach `reload()`, als `networkidle` zu früh
|
||||
zurückkam. Die Datenbank hat beide Male entschieden, und sie hat dem Produkt recht gegeben.
|
||||
- **Einmal die Ursache falsch benannt.** Ich hielt eine `positionenSauber`-Prüfung für die Behebung
|
||||
des Schnellstart-Fehlers; die Gegenprobe zeigte, dass die Tests ohne sie grün bleiben. Die
|
||||
Behebung ist der deterministische Stichentscheid beim Sortieren.
|
||||
- **Ein Numerusfehler in einem eigenen Hinweistext** („Ein Abschnitt gilt … und *bleiben* weg“),
|
||||
gefunden beim Durchklicken. Alles, was ein Mensch liest, schreibt sich richtig.
|
||||
- **`tests/datenbankregeln.test.ts` wurde rot** — genau wie gewollt: er zählt die handgeschriebenen
|
||||
Datenbankobjekte namentlich auf, und die zwei neuen CHECK-Bedingungen und zwei zusammengesetzten
|
||||
Fremdschlüssel standen nicht drin.
|
||||
- **`/daten/` fehlte in `.gitignore`.** Ohne das läge das erste hochgeladene Foto im nächsten Commit.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- **M20 (#033)** Live-Aktualisierung per SSE statt Polling, dann **M21 (#034)** Benachrichtigungen,
|
||||
**M22 (#035)** Selbstauskunft. **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden
|
||||
großen verbliebenen.
|
||||
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||
- **#027** Die Editor-Abläufe lassen ihre Vorlagen in der Entwicklungsdatenbank stehen.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-05 · Sitzung 18 · 09:00–10:40
|
||||
|
||||
**Issue:** #033 (M20)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **Die Live-Aktualisierung läuft über einen Ereignisstrom.** Vier Trigger schicken
|
||||
`NOTIFY lauf_geaendert, '<laufId>'`, eine LISTEN-Verbindung je Prozess hört zu, der Route Handler
|
||||
schiebt weiter. Gemessen: der Haken ist nach **485 ms** beim Zuschauer (Handy: 487 ms), vorher
|
||||
bis zu zehn Sekunden.
|
||||
- **Das Polling bleibt als Rückfall** — an derselben Adresse, unterschieden nur am `Accept`-Kopf.
|
||||
- 1266 Vitest-Tests (vorher 1247), dazu `tests/e2e/live.spec.ts` mit zwei Browser-Kontexten.
|
||||
- Der Herzschlag ist **echt über 70 Sekunden** gefahren: `readyState` blieb `OPEN`, ein einziges
|
||||
Ereignis, kein Neuaufbau.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Das Ereignis kommt aus der Datenbank, nicht aus dem Dienst.** Ein Ereignis, das der Service
|
||||
verschickt, entsteht nur auf dem Weg durch den Service — ein Trigger sieht auch den Seed, eine
|
||||
Migration und die Reparatur per `psql`. Und `NOTIFY` erreicht jede Instanz, ohne einen zweiten
|
||||
Dienst wie Redis.
|
||||
- **Ein Handler für beide Wege.** Damit ist „das Polling bleibt als Rückfall, nicht als zweite
|
||||
Wahrheit“ keine Absichtserklärung, sondern Bauart: eine Rechteprüfung, ein Lesemodell, eine
|
||||
Nutzlast.
|
||||
- **Die Obergrenze braucht keinen Sonderfall.** Ist sie erreicht, antwortet der Handler gewöhnlich
|
||||
mit JSON; `EventSource` meldet darauf einen Fehler und versucht es nicht wieder, und der Client
|
||||
fällt von sich aus zurück. Kein eigener Fehlercode.
|
||||
- **Keine Verbindung aus dem Prisma-Pool.** Ein Pool gibt Verbindungen zurück und verteilt sie neu;
|
||||
ein `LISTEN` darauf gilt danach für eine fremde Anfrage oder für niemanden.
|
||||
- **Bei versteckter Lasche wird der Strom geschlossen**, nicht bloß ignoriert — sonst belegte jede
|
||||
Hintergrundlasche genau die Verbindung, die die Obergrenze schützen soll.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Das Issue hätte einen Rückschritt gebaut.** Es nannte Trigger auf `Schrittergebnis` und `Lauf`.
|
||||
`laufZustand` trägt aber auch die Zahl der Kommentare, und seit #032 gibt es Anhänge: der Strom
|
||||
sieht **nur**, was gemeldet wird, während das Polling den ganzen Stand verglich. Mit den zwei
|
||||
Triggern aus dem Issue wäre der Kommentar des Kollegen nie angekommen — und die Seite hätte
|
||||
ausgesehen wie immer. Das ist derselbe Fehlertyp wie in #032: eine Zusage, die niemand prüft.
|
||||
Jetzt melden vier Tabellen, `laufZustand` trägt `anzahlAnhaenge`, und beide Tests zählen namentlich
|
||||
auf, was dazugehört.
|
||||
- **Zwei Fehler in `reverse-proxy.md`, beide von vorgestern.** `client_max_body_size 8m` bei
|
||||
`ANHANG_MAX_MB=10`: ein 10-MB-Foto wäre an nginx mit 413 gescheitert, und die eigene Meldung
|
||||
(„Erlaubt sind höchstens 10,0 MB“) wäre nie erschienen. Dazu ein Kommentar, der noch das alte
|
||||
Polling beschrieb. **Gefunden beim Schreiben der Doku, nicht von einem Test** — Dateien, die ein
|
||||
Dritter liest, prüft hier niemand.
|
||||
- **`## 5e` war in `docs/architektur.md` schon belegt.** Statt alles umzunummerieren und Verweise zu
|
||||
riskieren, hängt der neue Text als `###` unter 5d.
|
||||
- **Zwei eigene Testfehler, keine Produktfehler:** `schritte` zählte die „erledigt“-Knöpfe der
|
||||
ganzen Seite (34) statt die des ersten Abschnitts (5), und ein `const` nach einem `?.`-Guard
|
||||
verlor in der Closure seine Verengung. Beide Male war die Meldung eindeutig.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- **M21 (#034)** Benachrichtigungen per Mail und Webhook — baut direkt auf den Ereignissen auf, die
|
||||
jetzt stehen. Danach **M22 (#035)** Selbstauskunft.
|
||||
- **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden großen verbliebenen.
|
||||
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-05 · Sitzung 19 · 10:45–14:15
|
||||
|
||||
**Issue:** #034 (M21)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **Vier Anlässe gehen hinaus**, per Mail über SMTP und optional per Webhook mit
|
||||
HMAC-SHA256-Signatur. Je Person abschaltbar, und niemand bekommt Post über die eigene Tat.
|
||||
- **Eine Warteschlange in der Datenbank**, abgearbeitet von einem eigenen Zeitplan im selben
|
||||
Prozess. Fehlschläge nach 1, 5, 30 und 120 Minuten wiederholt, danach sichtbar aufgegeben.
|
||||
- **Verwaltung → Zustellungen** zeigt, was hinausging und was nicht, mit Grund.
|
||||
- 1302 Vitest-Tests (vorher 1266), davon 36 neue — **gegen einen echten SMTP-Fänger** (mailpit),
|
||||
nicht gegen eine abgefangene Funktion.
|
||||
- Im gebauten Stand gefahren: SMTP eingetragen, Testmail angekommen, ein überfälliger Lauf angelegt,
|
||||
und der Zeitplan hat die Meldung **von selbst** verschickt.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Eingereiht im Anfrage-Pfad, gesendet vom Zeitplan.** Ein hängender Mailserver darf kein Abhaken
|
||||
blockieren. Das Einreihen passiert nach der Transaktion und mit abgefangenem Fehler.
|
||||
- **Betreff und Rumpf stehen fertig in der Zeile.** Eine Meldung sagt, was *damals* war; würde der
|
||||
Text erst beim Senden gebaut, stünde in einer Mail von heute der Stand von jetzt.
|
||||
- **Die Texte liegen in `src/lib/meldungen.ts` und nehmen nur Kopfdaten.** Der Typ lässt keinen
|
||||
Schrittinhalt zu — das ist die Absicht, und es ist die einzige Stelle, die man dafür ändern müsste.
|
||||
- **Die Sperre wurde aus dem Abgleich herausgezogen** nach `services/sperre.ts`. Zwei Fassungen
|
||||
derselben Mechanik wären die schlechteste aller Lösungen.
|
||||
- **Die Mandantengrenze gilt auch für Empfänger.** Ohne diese Prüfung wäre die Benachrichtigung der
|
||||
eine Weg nach draußen, den `sichtbareKundenIds` nicht abdeckt.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **pnpm wollte eine Sicherheitsregel umgehen, und ich hätte es fast committet.** `pnpm add
|
||||
nodemailer` trug von sich aus `minimumReleaseAgeExclude: nodemailer@10.0.15` in
|
||||
`pnpm-workspace.yaml` ein — die Version war **vom selben Tag**. Das ist kein Formfehler, sondern
|
||||
genau die Warnung, vor der die Regel schützt. Gepinnt ist jetzt `10.0.10` vom 14. September, die
|
||||
Ausnahme ist weg, und die Konvention steht in `CLAUDE.md`. Aufgefallen beim Durchsehen des Diffs,
|
||||
nicht durch einen Test — eine Zeile in einer Datei, die man leicht übersieht.
|
||||
- **Drei Tests waren stillschweigend übersprungen.** `it.runIf(mailpitDa)` wird beim **Einlesen** der
|
||||
Datei ausgewertet; mein Flag stand in `beforeAll` und kam zu spät. Der erste Lauf meldete
|
||||
„3 skipped“, war grün — und hatte den echten SMTP-Dialog nie geprüft. Genau das Muster, das
|
||||
`ldap` in #019 schon einmal gekostet hat. `mailpit` steht jetzt im Tor vor dem Commit, und
|
||||
`pruefe:doku` erzwingt es; gegengeprobt.
|
||||
- **Die Wartezeiten waren um eins verschoben.** Nach dem ersten Fehlschlag wartete meine erste
|
||||
Fassung fünf Minuten statt einer. Der Test hat es sofort gezeigt — und die Umbenennung des
|
||||
Parameters auf `versucheGesamt` macht die Semantik jetzt lesbar.
|
||||
- **Zwei Knöpfe „Speichern“ auf einer Seite**, beim Durchklicken gefunden. Der neue heißt jetzt
|
||||
„Benachrichtigungen speichern“.
|
||||
- **Drei eigene Testfehler**, keine Produktfehler: die `Verzeichnisquelle` hat `url`, nicht
|
||||
`host`/`port`; `Lauf` mit Status `ABGESCHLOSSEN` braucht `abgeschlossenAm` (die Datenbank verlangt
|
||||
es); und ein fester Testzeitpunkt lag **vor** der echten Einreihung, sodass der Durchgang die
|
||||
Zeile gar nicht erst anfasste.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- **M22 (#035)** Mitarbeiter-Selbstauskunft — dort kommt auch die Profilseite, auf der jeder seine
|
||||
eigenen Benachrichtigungen abschalten kann.
|
||||
- **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden großen verbliebenen.
|
||||
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||
|
||||
@@ -1,24 +1,54 @@
|
||||
# #002 Phase 2: Verzeichnis-Alternativen, Bausteine, Anhänge
|
||||
|
||||
**Status:** offen
|
||||
**Status:** in Arbeit
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2
|
||||
**Geschätzt:** – **Tatsächlich:** –
|
||||
**Geschätzt:** 76 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
|
||||
Nicht im MVP umsetzen. Hier gesammelt, damit das Datenmodell nichts verbaut.
|
||||
Was der MVP bewusst offen gelassen hat, in acht Meilensteinen. Die Reihenfolge ist nicht beliebig:
|
||||
M15 legt die Weiche, die M16 braucht, und M17 die Zusammensetzung, auf der M18 aufsitzt.
|
||||
|
||||
## Meilensteine
|
||||
|
||||
| # | Meilenstein | Geschätzt | Hängt an |
|
||||
|---|---|---|---|
|
||||
| M15 | [#028 Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | 10 h | – |
|
||||
| M16 | [#029 SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | 12 h | M15 |
|
||||
| M17 | [#030 Wiederverwendbare Bausteine](030-bausteine.md) | 12 h | – |
|
||||
| M18 | [#031 Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | 8 h | M17 |
|
||||
| M19 | [#032 Anhänge und Fotos an Schritten](032-anhaenge.md) | 10 h | – |
|
||||
| M20 | [#033 Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | 6 h | – |
|
||||
| M21 | [#034 Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | 10 h | – |
|
||||
| M22 | [#035 Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | 8 h | – |
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] SCIM-Endpunkt als Alternative zum Pull-Abgleich
|
||||
- [ ] Microsoft-Graph-Quelle für reine Entra-ID-Umgebungen ohne LDAP
|
||||
- [ ] Wiederverwendbare Bausteine: Abschnitte, die mehrere Vorlagen teilen
|
||||
- [ ] Abteilungsspezifische Zusatzabschnitte (Fachsoftware je Abteilung)
|
||||
- [ ] Anhänge und Fotos an Schritten
|
||||
- [ ] Live-Aktualisierung per SSE statt Polling
|
||||
- [ ] Benachrichtigungen per Mail und Webhook
|
||||
- [ ] Mitarbeiter-Selbstauskunft: ein Mitarbeiter sieht die Läufe zu seinen Geräten, nur lesend
|
||||
- [x] SCIM-Endpunkt als Alternative zum Pull-Abgleich → M16
|
||||
- [x] Microsoft-Graph-Quelle für reine Entra-ID-Umgebungen ohne LDAP → M15
|
||||
- [x] Wiederverwendbare Bausteine: Abschnitte, die mehrere Vorlagen teilen → M17
|
||||
- [x] Abteilungsspezifische Zusatzabschnitte (Fachsoftware je Abteilung) → M18
|
||||
- [x] Anhänge und Fotos an Schritten → M19
|
||||
- [x] Live-Aktualisierung per SSE statt Polling → M20
|
||||
- [x] Benachrichtigungen per Mail und Webhook → M21
|
||||
- [x] Mitarbeiter-Selbstauskunft: ein Mitarbeiter sieht die Läufe zu seinen Geräten, nur lesend → M22
|
||||
|
||||
Die Haken bedeuten **in einen Meilenstein geschnitten**, nicht umgesetzt. Der Stand je Meilenstein
|
||||
steht in seiner eigenen Datei.
|
||||
|
||||
## Drei Entscheidungen, die vor dem Code fallen müssen
|
||||
|
||||
Sie stehen je Meilenstein als erste Aufgabe, mit Empfehlung. Zwei davon greifen weit:
|
||||
|
||||
1. **Wie ein Baustein die Unveränderlichkeit überlebt** (M17). Empfehlung: Verweis im Entwurf, tiefe
|
||||
Kopie beim Veröffentlichen. Ein Lauf sieht weiterhin konkrete Schritte und muss nichts lernen.
|
||||
2. **Wo Anhänge liegen** (M19). Empfehlung: Dateisystem, Metadaten in der Datenbank — mit der Folge,
|
||||
dass Compose-Stand, native Installation und `sicherung.md` ein Volume brauchen. Eine Sicherung,
|
||||
die nur die Datenbank mitnimmt, verliert ab dort Daten.
|
||||
3. **Was eine Selbstauskunft zeigt** (M22). Empfehlung: Titel, Status, Zeitraum, Gerät — **keine**
|
||||
Schritte, Notizen, Kommentare oder Anhänge. In einem Lauf stehen Arbeitsnotizen über Menschen;
|
||||
eine Selbstauskunft ist keine Akteneinsicht.
|
||||
|
||||
## Vorleistung im MVP
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
|
||||
Zwei erprobte Wege in den Betrieb und ein sauberes erstes Release.
|
||||
|
||||
**Abgeschlossen am 2026-09-26.** Veröffentlicht als
|
||||
[v0.1.0](https://git.wilde-server.de/florian.wilde/Laufzettel/releases/tag/v0.1.0), Squash-Commit
|
||||
`d97aebe`, Anleitung als PDF beigelegt. Von den 61 Abnahmekriterien sind 60 abgehakt; offen bleibt
|
||||
allein der Lauf des Proxmox-Einzeilers auf einem echten PVE-Host, siehe
|
||||
[#024](024-proxmox-echtlauf.md).
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [x] `deploy/install.sh`: native Installation auf Debian 13 — PostgreSQL 17 mit `listen_addresses = ''` (nur Unix-Socket), Node 24 aus dem NodeSource-Repo, corepack/pnpm, Dienstnutzer `laufzettel` ohne Login, Quellcode nach `/opt/laufzettel`, `pnpm install`, `pnpm build`, `prisma migrate deploy`, `prisma db seed`
|
||||
@@ -24,9 +30,9 @@ Zwei erprobte Wege in den Betrieb und ein sauberes erstes Release.
|
||||
- [x] Prüfliste für den Lauf auf echtem Proxmox, da ich den `pct`-Teil hier nicht ausführen kann
|
||||
- [x] `docs/abnahme-mvp.md` abschließend durchgehen, Review-Agent gegen alle Kriterien, Befunde als Issues
|
||||
- [x] Devlog-Phasenzusammenfassung: Gesamtstunden, Stunden je Meilenstein, größte Zeitfresser, Abweichung vom Plan
|
||||
- [ ] `CHANGELOG.md` für `v0.1.0` mit Datum, Version in `package.json`, Tag `v0.1.0`, Gitea-Release mit Changelog-Auszug, Anleitung als PDF beigelegt
|
||||
- [x] `CHANGELOG.md` für `v0.1.0` mit Datum, Version in `package.json`, Tag `v0.1.0`, Gitea-Release mit Changelog-Auszug, Anleitung als PDF beigelegt
|
||||
|
||||
- [ ] Die Anleitung als PDF (`pnpm docs:pdf`) dem Gitea-Release als Datei beilegen — im Repo liegt sie nicht, sie entsteht aus den Kapiteln und Bildern
|
||||
- [x] Die Anleitung als PDF (`pnpm docs:pdf`) dem Gitea-Release als Datei beilegen — im Repo liegt sie nicht, sie entsteht aus den Kapiteln und Bildern
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
@@ -36,5 +42,5 @@ Zwei erprobte Wege in den Betrieb und ein sauberes erstes Release.
|
||||
- [x] `bash deploy/proxmox/laufzettel.sh --dry-run` gibt einen plausiblen, vollständigen `pct create`-Aufruf aus
|
||||
- [x] `shellcheck --severity=warning` über alle Skripte ohne Befund
|
||||
- [x] Im Compose-Weg ist die Datenbank vom Host nicht erreichbar, aus `web` schon
|
||||
- [ ] `pnpm pruefe:doku` grün, `v0.1.0` getaggt und als Gitea-Release veröffentlicht
|
||||
- [x] `pnpm pruefe:doku` grün, `v0.1.0` getaggt und als Gitea-Release veröffentlicht
|
||||
- [x] Alle Kriterien aus `docs/abnahme-mvp.md` abgehakt oder als Folge-Issue erfasst
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #020 Vorgesetzter auch für von Hand angelegte Personen
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#31](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/31) — die Zählung dort schließt Pull Requests ein und läuft deshalb auseinander
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 1 h 20 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -26,17 +26,34 @@ Feld — `{{vorgesetzter_sam}}` bleibt dort immer leer.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Entscheiden: eigene Selbstbeziehung `vorgesetzterId` (sauber, aber zwei Wege zur selben
|
||||
Auskunft) **oder** `vorgesetzterExterneId` auch im Bereich „ohne Quelle“ auflösen (`quelleId
|
||||
IS NULL`), mit stabilen Kennungen für manuelle Personen
|
||||
- [ ] Beide Lesepfade und `{{vorgesetzter}}` / `{{vorgesetzter_sam}}` entsprechend erweitern
|
||||
- [ ] Oberfläche: Vorgesetzten auf der Mitarbeiterseite setzen und entfernen
|
||||
- [ ] Der Abgleich darf die Zuordnung nicht überschreiben, wenn das Verzeichnis keinen `manager`
|
||||
- [x] **Entschieden: eigene Selbstbeziehung `vorgesetzterId`** — und die befürchteten „zwei Wege zur
|
||||
selben Auskunft“ gibt es nicht, weil der Abgleich sie mitschreibt. `vorgesetzterExterneId`
|
||||
bleibt damit wie `abteilung` nur der rohe Wert des Verzeichnisses; gelesen wird allein
|
||||
`vorgesetzterId`. Die Alternative hätte manuellen Personen eine erfundene `externeId` geben
|
||||
müssen, und `externeId` heißt „objectGUID aus dem Verzeichnis“ — das wäre eine Lüge im
|
||||
Datenmodell gewesen
|
||||
- [x] Beide Lesepfade und `{{vorgesetzter}}` / `{{vorgesetzter_sam}}` entsprechend erweitern
|
||||
- [x] Oberfläche: Vorgesetzten auf der Mitarbeiterseite setzen und entfernen
|
||||
- [x] Der Abgleich darf die Zuordnung nicht überschreiben, wenn das Verzeichnis keinen `manager`
|
||||
liefert — und keine Schleife zulassen (A ist Vorgesetzter von B ist Vorgesetzter von A)
|
||||
- [ ] Seed: Petra Klein als Vorgesetzte von Maria Huber eintragen
|
||||
- [x] Seed: Petra Klein als Vorgesetzte von Maria Huber eintragen
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: eine von Hand angelegte Person bekommt einen Vorgesetzten, `{{vorgesetzter_sam}}` löst auf
|
||||
- [ ] Test: die Zuordnung gilt nur innerhalb desselben Kunden
|
||||
- [ ] Test: ein Abgleich ohne `manager` löscht eine von Hand gesetzte Zuordnung nicht
|
||||
- [x] Test: eine von Hand angelegte Person bekommt einen Vorgesetzten, `{{vorgesetzter_sam}}` löst auf
|
||||
- [x] Test: die Zuordnung gilt nur innerhalb desselben Kunden
|
||||
- [x] Test: ein Abgleich ohne `manager` löscht eine von Hand gesetzte Zuordnung nicht
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Die Migration führt bestehende Daten nach.** Ohne das wäre `{{vorgesetzter_sam}}` nach dem
|
||||
Einspielen bei **jeder** Person aus dem Verzeichnis leer gewesen, bis der nächste Abgleich läuft —
|
||||
und zwar still. Dieselbe Falle wie der Seed-Schlüssel in
|
||||
[#021](021-stabiler-schluessel-vorlagen.md): ein Umstieg, der die vorhandenen Daten nicht mitnimmt.
|
||||
An einem nachgebauten Vor-Zustand in der Entwicklungsdatenbank gefahren.
|
||||
- **Der Seed setzt die Zuordnung nur für Personen, die derselbe Lauf angelegt hat.** Eine leere
|
||||
Zuordnung sagt nicht, ob sie noch nie gesetzt oder im Betrieb entfernt wurde; ein zweiter Lauf
|
||||
darf nichts zurückdrehen.
|
||||
- **Zwei Schleifen-Prüfungen, nicht eine.** Die Datenbank fängt die kürzeste ab
|
||||
(`vorgesetzterId <> id`), längere brauchen den Gang nach oben im Dienst — mit Obergrenze, damit
|
||||
eine schon beschädigte Kette die Suche nicht ewig laufen lässt.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# #021 Stabiler Schlüssel für Seed-Vorlagen
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#32](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/32) — die Zählung dort schließt Pull Requests ein und läuft deshalb auseinander
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend
|
||||
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 55 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -15,17 +15,19 @@ Geräteseite doppelt. Aufgefallen im Review zu [#017](017-seed-sieben-vorlagen.m
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Ein optionales Feld `schluessel` im [Austauschformat](../vorlagen-format.md) — gedacht für
|
||||
- [x] Ein optionales Feld `seedSchluessel` im [Austauschformat](../vorlagen-format.md) — **nicht**
|
||||
`schluessel` wie hier geplant: so heißt im Format schon das Feld einer Variablen, und zwei
|
||||
Dinge gleichen Namens in derselben Datei wären eine Lesefalle — gedacht für
|
||||
mitgelieferte Vorlagen, nicht für die Oberfläche; beim Import **nicht** übernommen, damit eine
|
||||
eingespielte Kopie nicht den Platz des Originals einnimmt
|
||||
- [ ] `Vorlage.seedSchluessel` mit partiellem eindeutigen Index (nur wo gesetzt)
|
||||
- [ ] Der Seed sucht über den Schlüssel und fällt auf den Namen zurück, solange noch keiner gesetzt
|
||||
- [x] `Vorlage.seedSchluessel` mit partiellem eindeutigen Index (nur wo gesetzt)
|
||||
- [x] Der Seed sucht über den Schlüssel und fällt auf den Namen zurück, solange noch keiner gesetzt
|
||||
ist — eine bestehende Installation darf beim ersten Lauf danach keine Dubletten bekommen
|
||||
- [ ] Dokumentieren, was der Schlüssel **nicht** tut: er macht eine Vorlage nicht unveränderlich und
|
||||
- [x] Dokumentieren, was der Schlüssel **nicht** tut: er macht eine Vorlage nicht unveränderlich und
|
||||
erlaubt dem Seed nicht, eine bearbeitete Vorlage zu überschreiben
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: umbenannte Seed-Vorlage, zweiter Seed-Lauf legt keine zweite an
|
||||
- [ ] Test: zweiter Lauf erzeugt keine zweite `GeraetetypVorlage`-Zeile
|
||||
- [ ] Test: eine über den Import eingespielte Kopie bekommt **keinen** Schlüssel
|
||||
- [x] Test: umbenannte Seed-Vorlage, zweiter Seed-Lauf legt keine zweite an
|
||||
- [x] Test: zweiter Lauf erzeugt keine zweite `GeraetetypVorlage`-Zeile
|
||||
- [x] Test: eine über den Import eingespielte Kopie bekommt **keinen** Schlüssel
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #022 Schnellstart je Gerätetyp pflegen
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#34](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/34)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, frontend, backend
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 1 h 10 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -18,17 +18,44 @@ den Schnellstart, konnte aber nicht sagen, wer ihn einrichtet.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Admin-Seite (oder ein Abschnitt unter Admin → Einstellungen): je Gerätetyp eine sortierbare
|
||||
Liste der Vorlagen, die dort erscheinen sollen
|
||||
- [ ] Nur **veröffentlichte**, nicht archivierte Vorlagen zur Auswahl; die Reihenfolge ist die
|
||||
- [x] Eigene Seite **Verwaltung → Schnellstart** (kein Abschnitt unter Einstellungen: acht Gerätetypen
|
||||
mit je einer Liste sind zu viel für eine Seite, die schon den Moduswechsel trägt)
|
||||
- [x] Nur **veröffentlichte**, nicht archivierte Vorlagen zur Auswahl; die Reihenfolge ist die
|
||||
Reihenfolge der Kacheln (`position`)
|
||||
- [ ] Recht `verwaltung`, Änderung ins `Protokoll`
|
||||
- [ ] Der Seed legt die Vorgaben weiter an, überschreibt aber nie eine im Betrieb geänderte
|
||||
- [x] Recht `verwaltung`, Änderung ins `Protokoll`
|
||||
- [x] Der Seed legt die Vorgaben weiter an, überschreibt aber nie eine im Betrieb geänderte
|
||||
Zuordnung — dieselbe Regel wie für alles andere im Seed
|
||||
- [ ] Anleitung Kapitel 03 und 07 nachziehen
|
||||
- [x] Anleitung Kapitel 03 und 07 nachziehen
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: eine zugewiesene Vorlage erscheint als Kachel auf der Geräteseite des passenden Typs
|
||||
- [ ] Test: eine archivierte Vorlage verschwindet aus dem Schnellstart, ohne dass jemand aufräumt
|
||||
- [ ] Test: `LESER` und `TECHNIKER` dürfen nicht ändern
|
||||
- [x] Test: eine zugewiesene Vorlage erscheint als Kachel auf der Geräteseite des passenden Typs
|
||||
- [x] Test: eine archivierte Vorlage verschwindet aus dem Schnellstart, ohne dass jemand aufräumt
|
||||
- [x] Test: `LESER` und `TECHNIKER` dürfen nicht ändern
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Die Reihenfolge läuft über Hoch und Runter, nicht über Drag & Drop.** Der Vorlagen-Editor
|
||||
benutzt dnd-kit, weil dort hundert Schritte in Abschnitten liegen; hier sind es zwei bis vier
|
||||
Kacheln, und zwei Knöpfe sind am Handy verlässlicher als eine Ziehgeste neben einer scrollenden
|
||||
Seite.
|
||||
- **`Einstellung.schnellstartGepflegt` als Marke.** Eine fehlende Zeile in `GeraetetypVorlage` sagt
|
||||
nicht, ob sie noch nie da war oder bewusst entfernt wurde — ohne Marke käme eine entfernte Kachel
|
||||
beim nächsten Seed-Lauf zurück. Gesetzt wird sie **auch bei unverändert gespeicherter Liste**: das
|
||||
Speichern selbst ist die Aussage „ich pflege das jetzt“.
|
||||
- **Gespeichert wird die ganze Liste, nicht Zeile für Zeile.** `position` kommt aus der Reihenfolge
|
||||
des Formulars; zwei Wahrheiten über die Reihenfolge gäbe es sonst.
|
||||
- **Das Durchklicken des gebauten Stands hat einen echten Fehler gefunden**, den die Suite nicht
|
||||
gesehen hätte: die Umsortierung wurde nicht gespeichert. Altdaten tragen mehrere Zeilen auf
|
||||
`position` 0 — der Seed zählt je Vorgabe von 0, und ein Beispielskript legte eine zweite dazu. Bei
|
||||
gleicher Position ist die Reihenfolge in Postgres nicht festgelegt, und das Lesemodell gab die
|
||||
Zeilen anders zurück als der Dienst. Der Vergleich „unverändert" hielt die Umsortierung deshalb
|
||||
für einen Leerlauf: die Marke wurde gesetzt, geschrieben wurde nichts. Behoben durch einen
|
||||
eindeutigen Tiebreak (`position`, dann `vorlageId`) in **beiden** Abfragen.
|
||||
- Dabei fiel auf, dass ich die Ursache zuerst falsch benannt hatte: eine zusätzliche Prüfung auf
|
||||
aufeinanderfolgende Positionen behebt den Fehler **nicht** (Gegenprobe gefahren, Tests blieben
|
||||
grün). Sie zieht nur alte Positionen gerade; der Kommentar sagt das jetzt so, und ein eigener Test
|
||||
deckt genau das ab.
|
||||
- Zwei der drei Akzeptanzkriterien waren schon durch `tests/geraeteseite.test.ts` gedeckt — die
|
||||
Kachel am passenden Typ und das Verschwinden einer archivierten Vorlage. Das stand bei
|
||||
[#014](014-qr-tags-geraeteseite.md) schon richtig im Leseweg; gefehlt hat nur die Pflege.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #023 Protokoll ansehen
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#35](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/35)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, frontend
|
||||
@@ -14,22 +14,42 @@ geändert hat, braucht `psql`.
|
||||
|
||||
Aufgefallen beim Schreiben der Anleitung in [#018](018-e2e-anleitung-pdf.md).
|
||||
|
||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
||||
**Geschätzt:** 4 h **Tatsächlich:** 1 h 50 min
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Admin → Protokoll: Liste mit Zeitpunkt, Urheber, Aktion, betroffener Zeile, umblätterbar
|
||||
- [ ] Filter nach Zeitraum, Aktion und Urheber; Volltext über die Aktion
|
||||
- [ ] Die Mandantensicht gilt auch hier: ein Eintrag zu einem fremden Kunden ist nicht sichtbar
|
||||
- [ ] Recht `verwaltung` — das Protokoll sagt, wer was getan hat, und ist damit selbst eine Auskunft
|
||||
- [x] Admin → Protokoll: Liste mit Zeitpunkt, Urheber, Aktion, betroffener Zeile, umblätterbar
|
||||
- [x] Filter nach Zeitraum, Aktion und Urheber; Volltext über die Aktion
|
||||
- [x] Die Mandantensicht gilt auch hier: ein Eintrag zu einem fremden Kunden ist nicht sichtbar
|
||||
- [x] Recht `verwaltung` — das Protokoll sagt, wer was getan hat, und ist damit selbst eine Auskunft
|
||||
über Menschen
|
||||
- [ ] Ein Eintrag ohne Urheber (Systemlauf, etwa der Zeitplan) wird als solcher gezeigt, nicht als
|
||||
- [x] Ein Eintrag ohne Urheber (Systemlauf, etwa der Zeitplan) wird als solcher gezeigt, nicht als
|
||||
Lücke
|
||||
- [ ] Am Lauf selbst: die Einträge dieses Laufs in seiner Abarbeitungsansicht, eingeklappt
|
||||
- [x] Am Lauf selbst: die Einträge dieses Laufs in seiner Abarbeitungsansicht, eingeklappt
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: ein Eintrag eines fremden Kunden erscheint nicht
|
||||
- [ ] Test: `TECHNIKER` kommt nicht auf die Seite
|
||||
- [ ] Test: ein erzeugtes Passwort taucht in keinem Eintrag auf
|
||||
- [ ] Am Handy kein horizontales Scrollen
|
||||
- [x] Test: ein Eintrag eines fremden Kunden erscheint nicht
|
||||
- [x] Test: `TECHNIKER` kommt nicht auf die Seite
|
||||
- [x] Test: ein erzeugtes Passwort taucht in keinem Eintrag auf
|
||||
- [x] Am Handy kein horizontales Scrollen
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **`Protokoll` hat jetzt eine `kundeId`.** CLAUDE.md verlangt sie für jede kundenbezogene Zeile; das
|
||||
Protokoll hatte sie nicht, und damit ließ sich die Mandantensicht nicht anwenden, ohne je Eintrag
|
||||
die Entität nachzuschlagen. Leer heißt **global** (Einstellungen, Benutzer, kundenfreie Vorlagen,
|
||||
Schnellstart). Zwanzig Schreibstellen setzen sie jetzt; laufbezogene Einträge bleiben ohne, weil
|
||||
der Lauf die bessere Quelle ist — er kann seine Zuordnung nicht verlieren.
|
||||
- **Die Migration führt bestehende Einträge nach**, über den Lauf und über die fünf
|
||||
kundentragenden Entitäten. An echten Daten gefahren: 59 von 733 Einträgen bekamen einen Kunden.
|
||||
- **Nicht zuordenbare Einträge werden verborgen, nicht gezeigt.** 414 Einträge in der
|
||||
Entwicklungsdatenbank zeigen auf längst gelöschte Testgeräte und lassen sich keinem Kunden
|
||||
zuordnen. `vorher`/`nachher` können einen Hostnamen enthalten — bei einer Mandantengrenze wird im
|
||||
Zweifel zugemacht, nicht aufgemacht.
|
||||
- **Keine Tabelle, sondern Karten.** Sieben Spalten sind bei 390 px nicht darstellbar, ohne
|
||||
horizontal zu scrollen, und genau das ist ausgeschlossen.
|
||||
- Das Mandanten-Kriterium ist auf der Seite heute **strukturell** erfüllt, weil sie `verwaltung`
|
||||
verlangt und ein `ADMIN` ohnehin alle Kunden sieht. Der Filter sitzt trotzdem im Lesemodell und ist
|
||||
dort geprüft — ein Lesemodell trägt seine Sicht selbst, und wenn das Recht je weiter gefasst wird,
|
||||
hält die Grenze.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #025 Healthcheck gibt die rohe Datenbankfehlermeldung heraus
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#39](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/39)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend
|
||||
**Geschätzt:** 1 h **Tatsächlich:** –
|
||||
**Geschätzt:** 1 h **Tatsächlich:** 25 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -23,13 +23,13 @@ Aufgefallen beim Abnahme-Review zu [#019](019-betrieb-release.md).
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Nach außen nur `{ "status": "fehler" }` mit Status 503
|
||||
- [ ] Die Begründung serverseitig protokollieren, damit sie beim Suchen nicht fehlt
|
||||
- [ ] Prüfen, ob `docs/betrieb/` irgendwo die Meldung als Diagnosehilfe nennt — dann auf das Log
|
||||
- [x] Nach außen nur `{ "status": "fehler" }` mit Status 503
|
||||
- [x] Die Begründung serverseitig protokollieren, damit sie beim Suchen nicht fehlt
|
||||
- [x] Prüfen, ob `docs/betrieb/` irgendwo die Meldung als Diagnosehilfe nennt — dann auf das Log
|
||||
verweisen
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: bei nicht erreichbarer Datenbank enthält die Antwort weder Host noch Port noch
|
||||
- [x] Test: bei nicht erreichbarer Datenbank enthält die Antwort weder Host noch Port noch
|
||||
Benutzernamen
|
||||
- [ ] Test: der Statuscode bleibt 503, damit Docker und der Reverse Proxy weiter richtig entscheiden
|
||||
- [x] Test: der Statuscode bleibt 503, damit Docker und der Reverse Proxy weiter richtig entscheiden
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
# #026 Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#40](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/40)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, docs
|
||||
**Geschätzt:** 1 h **Tatsächlich:** –
|
||||
**Geschätzt:** 1 h **Tatsächlich:** 20 min
|
||||
|
||||
## Ziel
|
||||
|
||||
Zwei Sätze in `CLAUDE.md` (und gleichlautend in `docs/architektur.md`) beschreiben den Code nicht
|
||||
genau. Beide fielen beim Abnahme-Review zu [#019](019-betrieb-release.md) auf.
|
||||
Zwei Sätze in `CLAUDE.md` beschreiben den Code nicht genau. Beide fielen beim Abnahme-Review zu
|
||||
[#019](019-betrieb-release.md) auf.
|
||||
|
||||
**Nachtrag aus der Umsetzung:** `docs/architektur.md` trägt die Gestaltungsregel gar nicht — sie
|
||||
steht nur in `CLAUDE.md`. Und es sind **sechs** Ausnahmen, nicht vier: der Review hat `src/lib/qr.ts`
|
||||
übersehen, wo der QR-Code reines Schwarz auf reinem Weiß braucht, damit ein Decoder ihn liest.
|
||||
|
||||
**1. Rohe Hexwerte.** Die Regel sagt „Komponenten greifen nur darauf zu, nie auf rohe Hexwerte“.
|
||||
Tatsächlich stehen vier begründete Ausnahmen im Code: `#ffffff` in `drucken/lauf/[id]/page.tsx` und
|
||||
`drucken/etiketten/page.tsx` (Druck ist immer hell, unabhängig vom Farbschema der Anwendung) sowie
|
||||
`#0B0D10` in `layout.tsx` und `manifest.ts` (`themeColor` und das Manifest vertragen keine
|
||||
CSS-Variablen). Die Regel ist strenger formuliert als gemeint — sie soll um diese Ausnahmen ergänzt
|
||||
werden, nicht der Code geändert.
|
||||
Tatsächlich stehen sechs begründete Ausnahmen im Code: `#ffffff` in `drucken/lauf/[id]/page.tsx` und
|
||||
`drucken/etiketten/page.tsx` (Druck ist immer hell, unabhängig vom Farbschema der Anwendung),
|
||||
`#000000ff`/`#ffffffff` in `src/lib/qr.ts` (ein QR-Code muss reines Schwarz auf reinem Weiß sein)
|
||||
sowie `#0B0D10` in `layout.tsx` und zweimal in `manifest.ts` (`themeColor` und das Manifest
|
||||
vertragen keine CSS-Variablen). Die Regel ist strenger formuliert als gemeint — sie soll um diese
|
||||
Ausnahmen ergänzt werden, nicht der Code geändert.
|
||||
|
||||
**2. `pnpm test` ohne LDAP-Container.** „Vor jedem Commit“ in `CLAUDE.md` und „Qualitätsschranken“ im
|
||||
README nennen `pnpm test`, aber nicht
|
||||
@@ -24,14 +29,17 @@ README nennen `pnpm test`, aber nicht
|
||||
Tests gegen das echte Verzeichnis — sie sagt das auf stderr, aber der Lauf sieht grün aus. Wer nur
|
||||
dem dokumentierten Weg folgt, prüft den Verzeichnis-Abgleich nie. Ein Satz in beiden Dateien.
|
||||
|
||||
Nachgezählt sind es **22 Tests**: `tests/abgleich.test.ts` und `tests/verzeichnis.test.ts` ergeben
|
||||
mit Container 81, ohne 59. Dabei fiel auf, dass im README zusätzlich `format:pruefen` fehlte.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Ausnahme für rohe Hexwerte in `CLAUDE.md` und `docs/architektur.md` benennen, mit Begründung
|
||||
- [ ] Den LDAP-Container in `CLAUDE.md` („Vor jedem Commit“) und `README.md` aufnehmen
|
||||
- [ ] Prüfen, ob sich der Hinweis maschinell absichern lässt — `pruefe:doku` könnte verlangen, dass
|
||||
- [x] Ausnahme für rohe Hexwerte in `CLAUDE.md` benennen, mit Begründung (in `docs/architektur.md` steht die Regel nicht)
|
||||
- [x] Den LDAP-Container in `CLAUDE.md` („Vor jedem Commit“) und `README.md` aufnehmen
|
||||
- [x] Prüfen, ob sich der Hinweis maschinell absichern lässt — `pruefe:doku` könnte verlangen, dass
|
||||
jede in `CLAUDE.md` genannte Schranke auch den nötigen Vorlauf nennt
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Die vier Hexwerte im Code stehen in Einklang mit der Regel, ohne den Code zu ändern
|
||||
- [ ] Wer `CLAUDE.md` von oben nach unten befolgt, fährt die Suite mit LDAP-Container
|
||||
- [x] Die sechs Hexwerte im Code stehen in Einklang mit der Regel, ohne den Code zu ändern
|
||||
- [x] Wer `CLAUDE.md` von oben nach unten befolgt, fährt die Suite mit LDAP-Container
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# #027 Die Editor-Abläufe lassen ihre Vorlagen stehen
|
||||
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#46](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/46)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, test
|
||||
**Geschätzt:** 1 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
|
||||
In der Entwicklungsdatenbank stehen über hundert Vorlagen mit Namen wie
|
||||
`ZZ E2E Editor 1790324192596 C`. Die Ende-zu-Ende-Abläufe legen sie an und räumen sie nicht weg; bei
|
||||
jedem Lauf kommen drei dazu.
|
||||
|
||||
Aufgefallen beim Nachsehen zu [#021](021-stabiler-schluessel-vorlagen.md), als eine Abfrage über
|
||||
`Vorlage` statt sieben Zeilen über hundert lieferte.
|
||||
|
||||
Das ist kein Schaden an den Tests — das `ZZ`-Präfix sortiert sie ans Ende, und jeder Ablauf legt
|
||||
seine eigenen an. Es ist ein Schaden an allem, was **danach** in dieselbe Datenbank sieht: die
|
||||
Vorlagenliste in der Oberfläche, Screenshots, und jede Abfrage von Hand. Bei einem Fehler, der
|
||||
„0 Treffer“ oder „zu viele Treffer“ heißt, kostet das Zeit.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Die Abläufe unter `tests/e2e/`, die Vorlagen anlegen, räumen sie in einem Nachlauf wieder weg —
|
||||
über ihr Präfix, nicht über gemerkte Kennungen (ein abgebrochener Lauf merkt sich nichts)
|
||||
- [ ] Dasselbe für Geräte, Personen und Läufe prüfen, die die Abläufe anlegen
|
||||
- [ ] Einmalig aufräumen: die vorhandenen Reste aus der Entwicklungsdatenbank entfernen
|
||||
- [ ] Prüfen, ob ein Nachlauf das überhaupt leisten kann — ein Ablauf, der mitten im Lauf scheitert,
|
||||
darf den Nachlauf nicht überspringen
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Nach `pnpm test:e2e` steht in `Vorlage` keine Zeile mit `ZZ E2E`
|
||||
- [ ] Ein abgebrochener Lauf lässt ebenfalls nichts stehen
|
||||
@@ -0,0 +1,55 @@
|
||||
# #028 M15 – Quellenarten und Microsoft-Graph-Quelle
|
||||
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend
|
||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein Haus ohne lokales AD hat kein LDAP, auf das sich zeigen ließe. Für reine Entra-ID-Umgebungen
|
||||
braucht es eine zweite Art von Verzeichnisquelle, die über Microsoft Graph liest — mit denselben
|
||||
Regeln wie der LDAP-Abgleich: anlegen, aktualisieren, deaktivieren, **nie löschen**.
|
||||
|
||||
Dieser Meilenstein legt dazu die Weiche, die auch [#029](029-scim-endpunkt.md) braucht:
|
||||
`Verzeichnisquelle` bekommt eine **Art**. Heute ist jede Quelle implizit LDAP.
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [ ] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder
|
||||
`url`, `bindDn` und `basisDn` sind dann je Art unterschiedlich belegt; was eine Art braucht,
|
||||
prüft der Service, nicht die Datenbank. Die Alternative wäre eine Tabelle je Art mit
|
||||
gemeinsamer Oberklasse — mehr Struktur, als drei Arten rechtfertigen.
|
||||
- [ ] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den
|
||||
Platzhalter bei einer Graph-Quelle **leer** zu lassen, statt etwas hineinzudeuten — und das
|
||||
in der Anleitung zu sagen. Wer OU-abhängige Befehle fährt, braucht ein lokales AD.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
|
||||
- [ ] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie
|
||||
in einer Antwort), Token über Client-Credentials-Flow, Zwischenspeicher bis kurz vor Ablauf
|
||||
- [ ] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der
|
||||
Vorgesetzte über `$expand=manager($select=id)`
|
||||
- [ ] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`,
|
||||
`userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`)
|
||||
- [ ] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
|
||||
- [ ] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
|
||||
- [ ] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
|
||||
unterscheidet sich. Sonst driften die Regeln auseinander
|
||||
- [ ] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
|
||||
- [ ] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
|
||||
(App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster
|
||||
Abgleich legt an, zweiter aktualisiert ohne Dubletten, ein `accountEnabled: false` setzt
|
||||
`aktiv = false`
|
||||
- [ ] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
|
||||
- [ ] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
|
||||
- [ ] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden
|
||||
- [ ] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
|
||||
- [ ] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die
|
||||
Gegenprobe gehört in eine Prüfliste wie bei [#024](024-proxmox-echtlauf.md)
|
||||
@@ -0,0 +1,56 @@
|
||||
# #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich
|
||||
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#49](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/49)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend
|
||||
**Geschätzt:** 12 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
|
||||
Der Abgleich holt heute. Ein Identitätsanbieter kann auch **schicken** — authentik, Entra ID und
|
||||
Okta sprechen dafür SCIM 2.0. Das ist der Weg für Häuser, die Laufzettel keinen Lesezugriff auf ihr
|
||||
Verzeichnis geben wollen oder aus einem Netz heraus betreiben, das das Verzeichnis nicht erreicht.
|
||||
|
||||
Setzt [#028](028-graph-quelle.md) voraus: die Quellenart `SCIM` braucht die Weiche.
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [ ] **Ein Token je Quelle, als Hash gespeichert** — Empfehlung, dasselbe Verfahren wie beim
|
||||
lokalen Passwort (argon2). Es wird **einmal** angezeigt und ist danach nicht mehr lesbar. Das
|
||||
Token identifiziert die Quelle und damit den Kunden; es ist der einzige Mandantenbeweis, den
|
||||
ein Push mitbringt.
|
||||
- [ ] **Was bei `DELETE /Users/{id}` geschieht:** `aktiv = false`, nicht löschen. Das ist die Regel
|
||||
des ganzen Projekts, und ein Identitätsanbieter, der löschen will, bekommt `204` — aber die
|
||||
Zeile bleibt, weil Läufe an ihr hängen. Das muss in der Anleitung stehen, sonst wirkt es wie
|
||||
ein Fehler.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Route Handler unter `src/app/api/scim/v2/`: `Users` (GET mit Filter `userName eq`, POST),
|
||||
`Users/{id}` (GET, PUT, PATCH, DELETE), dazu `ServiceProviderConfig`, `Schemas`,
|
||||
`ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen
|
||||
- [ ] **Jeder dieser Handler prüft selbst**: das Token, und daraus die Quelle und den Kunden. Die
|
||||
Hülle unter `(app)` liegt hier nicht darüber (CLAUDE.md)
|
||||
- [ ] Zuordnung: `externalId` → `externeId`, `userName` → `upn`, `name.givenName`/`familyName`,
|
||||
`emails[primary]` → `mail`, `active` → `aktiv`, `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User`
|
||||
für `department` und `manager`
|
||||
- [ ] Antwortform nach RFC 7644: `schemas`, `id`, `meta.resourceType`, `meta.location`, bei Listen
|
||||
`totalResults`/`itemsPerPage`/`startIndex`; Fehler als `urn:ietf:params:scim:api:messages:2.0:Error`
|
||||
- [ ] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken —
|
||||
nicht der ganze Standard
|
||||
- [ ] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person
|
||||
- [ ] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer)
|
||||
- [ ] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar
|
||||
- [ ] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden):
|
||||
anlegen, aktualisieren, deaktivieren, wieder aktivieren
|
||||
- [ ] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle
|
||||
- [ ] Test: `DELETE` setzt `aktiv = false` und löscht nichts
|
||||
- [ ] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile
|
||||
- [ ] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort
|
||||
- [ ] Test: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt
|
||||
geschickt
|
||||
@@ -0,0 +1,82 @@
|
||||
# #030 M17 – Wiederverwendbare Bausteine
|
||||
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#50](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/50)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend
|
||||
**Geschätzt:** 12 h **Tatsächlich:** 2 h 25 min
|
||||
|
||||
## Ziel
|
||||
|
||||
„Datensicherung prüfen“ steht in vier der sieben Vorlagen, wortgleich. Wer den Ablauf ändert, ändert
|
||||
ihn heute viermal — oder dreimal und vergisst den vierten. Ein **Baustein** ist ein Abschnitt, den
|
||||
mehrere Vorlagen teilen.
|
||||
|
||||
## Woran es hängt
|
||||
|
||||
Eine veröffentlichte `VorlagenVersion` ist **unveränderlich**. Das trägt die Nachvollziehbarkeit des
|
||||
ganzen Archivs: ein Lauf zeigt auch in zehn Jahren genau die Schritte, die damals abgearbeitet
|
||||
wurden. Ein Baustein, der sich ändert, darf das nicht antasten.
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [x] **Baustein mit eigener Version, beim Veröffentlichen tief kopiert** — Empfehlung. Ein
|
||||
Entwurf **verweist** auf einen Baustein; beim Veröffentlichen löst der Dienst den Verweis in
|
||||
echte Abschnitte und Schritte auf. Damit bleibt alles, was danach kommt, unberührt: `Lauf`,
|
||||
`Schrittergebnis`, Druckansicht und Archiv sehen weiterhin konkrete Schritte und müssen nichts
|
||||
lernen.
|
||||
Die Alternative — Läufe verweisen zur Laufzeit auf den Baustein — hieße, dass die Unveränder-
|
||||
lichkeit an einer zweiten Stelle durchgesetzt werden muss, und dass jede Abfrage über Schritte
|
||||
zwei Wege kennen muss. Das ist der Preis für eine Bequemlichkeit, die niemand verlangt hat.
|
||||
- [x] **Was „Baustein aktualisieren“ für laufende Arbeiten bedeutet:** nichts. Eine neue
|
||||
Baustein-Version wirkt erst in der **nächsten** veröffentlichten Vorlagenversion. Das muss die
|
||||
Oberfläche sagen, sonst erwartet man Durchschlag.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [x] Modelle `Baustein` und `BausteinVersion` mit denselben Regeln wie bei Vorlagen: höchstens ein
|
||||
Entwurf, veröffentlichte Version unveränderlich (Trigger, nicht nur Dienst)
|
||||
- [x] `VorlagenAbschnitt` kann statt eigener Schritte einen Baustein nennen — nur im **Entwurf**
|
||||
- [x] Veröffentlichen löst auf: tiefe Kopie der Baustein-Abschnitte und -Schritte in die Version,
|
||||
mit Vermerk, aus welchem Baustein und welcher Version sie stammen
|
||||
- [x] Editor: Baustein einfügen, als Einheit anzeigen, nicht Schritt für Schritt bearbeitbar; ein
|
||||
Hinweis nennt Baustein und Version
|
||||
- [x] Eigene Verwaltung für Bausteine, Recht `vorlagenBearbeiten` (wer Vorlagen pflegt, pflegt
|
||||
Bausteine)
|
||||
- [x] „Wo wird dieser Baustein benutzt“ — vor dem Ändern will man das wissen
|
||||
- [x] Anleitung Kapitel 04
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [x] Test: eine neue Baustein-Version ändert **keine** veröffentlichte Vorlagenversion und **keinen**
|
||||
laufenden Lauf
|
||||
- [x] Test: die Druckansicht eines archivierten Laufs ist nach einer Baustein-Änderung Zeichen für
|
||||
Zeichen dieselbe
|
||||
- [x] Test: die nächste veröffentlichte Vorlagenversion enthält die neue Baustein-Version
|
||||
- [x] Test: ein Baustein lässt sich nicht löschen, solange eine Vorlage ihn im Entwurf nennt
|
||||
- [x] Test: Export und Import einer Vorlage mit Baustein ergeben eine inhaltsgleiche Vorlage — die
|
||||
Datei trägt die aufgelösten Schritte, keinen Verweis auf einen Baustein, den die andere
|
||||
Installation nicht hat
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Ein Baustein ist genau ein Abschnitt**, nicht eine Gruppe davon. Das stand so im Auftrag
|
||||
(„Abschnitte, die mehrere Vorlagen teilen“) und spart eine Verschachtelungsebene, die niemand
|
||||
gebraucht hätte.
|
||||
- **Der Verweis bleibt nach dem Veröffentlichen stehen** und ist dann nur noch Herkunft. Welche der
|
||||
beiden Lesarten gilt, sagt der Status der Version — ein zweites Feld dafür wäre eine zweite
|
||||
Wahrheit. Die Seite eines Bausteins liest daraus „Wo er steckt“, mit der Fassung je Vorlage.
|
||||
- **Der Titel kommt aus dem Baustein**, nicht aus dem Abschnitt der Vorlage. Sonst müsste man ihn in
|
||||
jeder Vorlage einzeln pflegen, und zwei Vorlagen zeigten denselben Baustein unter verschiedenen
|
||||
Namen.
|
||||
- **Vier Garantien liegen in der Datenbank**, nicht nur im Dienst: höchstens ein Entwurf (partieller
|
||||
Index), eine veröffentlichte Fassung ist unveränderlich (Trigger), der Weg zurück zum Entwurf ist
|
||||
versperrt (Trigger), und ein Abschnitt kann nur auf eine **veröffentlichte** Fassung verweisen
|
||||
(Trigger). Beim Aufräumen der Testdaten hat der zweite sofort zugeschlagen — gut so.
|
||||
- **Das Durchklicken des gebauten Stands fand einen Fehler**, den die Suite nicht gesehen hätte:
|
||||
`neuerBausteinAbschnitt` legt Platzhalter-Schritte **ohne Titel** an, damit die Anzahl in der Karte
|
||||
stimmt. `alsStruktur` schickte sie mit, und `SchrittSchema` wies den ganzen Entwurf ab — das
|
||||
Speichern scheiterte. Behoben, mit Test und Gegenprobe.
|
||||
- Dabei habe ich **zweimal mein eigenes Prüfskript** für den Fehler gehalten: einmal griff ein zu
|
||||
weiter `role=status`-Selektor den falschen Text, einmal eine Prüfung nach dem Neuladen. Die
|
||||
Datenbank hat beides geklärt — sie ist der einzige Zeuge, der nicht lügt.
|
||||
@@ -0,0 +1,69 @@
|
||||
# #031 M18 – Abteilungsspezifische Zusatzabschnitte
|
||||
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#51](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/51)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend
|
||||
**Geschätzt:** 8 h **Tatsächlich:** 1 h 35 min
|
||||
|
||||
## Ziel
|
||||
|
||||
Beim Onboarding in der Buchhaltung gehören drei Schritte dazu, die sonst niemand braucht: DATEV
|
||||
einrichten, Drucker für Formulare, Zugang zum Bankportal. Heute heißt die Wahl: drei Vorlagen je
|
||||
Abteilung pflegen, oder Schritte, die für die meisten „nicht zutreffend“ sind.
|
||||
|
||||
Ein Abschnitt soll sagen können: **nur für diese Abteilungen.**
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [x] **Die Auswahl wird beim Start festgeschrieben** — Empfehlung. Der Lauf merkt sich, welche
|
||||
Abschnitte für ihn gelten, statt sie bei jedem Aufruf aus `Lauf.abteilungId` neu zu berechnen.
|
||||
Grund: ein archivierter Lauf muss genau die Schritte zeigen, die damals abgearbeitet wurden.
|
||||
Wechselt jemand später die Abteilung des Laufs, dürfen Schritte nicht verschwinden, an denen
|
||||
schon eine Notiz hängt.
|
||||
- [x] **Was bei einem Lauf ohne Abteilung gilt:** nur die Abschnitte ohne Einschränkung. Ein
|
||||
Firewall-Rollout hat keine Abteilung, und das ist kein Mangel.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [x] `VorlagenAbschnitt.nurFuerAbteilungen` — Liste von Abteilungsnamen, nicht von Kennungen: der
|
||||
Abschnitt gehört zur Version und muss ein Umbenennen oder Archivieren einer Abteilung
|
||||
überleben, ohne ins Leere zu zeigen. Dieselbe Entscheidung wie bei `Mitarbeiter.abteilung`
|
||||
- [x] Beim Start: die geltenden Abschnitte ermitteln und am Lauf festhalten
|
||||
- [x] Die Abarbeitungsansicht zeigt nur geltende Abschnitte; die Nummerierung (`01 ·`) zählt nur sie
|
||||
- [x] **Die Abschlussregel zählt nur geltende Pflichtschritte.** Ein ausgefilterter Pflichtschritt
|
||||
darf einen Lauf nicht blockieren — sonst ist der Abschluss unerreichbar
|
||||
- [x] Druckansicht und Archiv zeigen dieselbe Auswahl wie die Abarbeitung
|
||||
- [x] Editor: je Abschnitt eine Mehrfachauswahl über die gepflegten Abteilungen, mit Freitext für
|
||||
eine, die es noch nicht gibt
|
||||
- [x] Der Start-Dialog sagt vorher, wie viele Abschnitte die gewählte Abteilung hinzunimmt
|
||||
- [x] Anleitung Kapitel 04 und 02
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [x] Test: ein Lauf für „Buchhaltung“ enthält den Buchhaltungs-Abschnitt, einer für „Vertrieb“ nicht
|
||||
- [x] Test: ein Lauf ohne Abteilung enthält nur die uneingeschränkten Abschnitte
|
||||
- [x] Test: die Abteilung eines laufenden Laufs ändern ändert **nicht** seine Abschnitte
|
||||
- [x] Test: ein ausgefilterter Pflichtschritt blockiert den Abschluss nicht
|
||||
- [x] Test: die Druckansicht zeigt genau die Abschnitte der Abarbeitung — nicht mehr und nicht weniger
|
||||
- [x] Am Handy kein horizontales Scrollen
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Festgeschrieben wird über `Lauf.geltendeAbschnitte`, und leer heißt „alle".** Damit verhalten
|
||||
sich Läufe von vor dieser Fassung unverändert, ohne dass eine Migration Daten nachführen müsste.
|
||||
Ein neuer Lauf trägt immer alle geltenden Kennungen — auch wenn nichts eingeschränkt ist, denn
|
||||
sonst wäre „leer" zweideutig.
|
||||
- **Ein Lauf, für den kein Abschnitt übrig bleibt, wird abgewiesen.** Das war nötig, damit „leer
|
||||
heißt alle" eindeutig bleibt — und es ist ohnehin richtig: es gäbe nichts abzuarbeiten.
|
||||
- **Die Abschlussregel musste nachziehen.** `offenePflichtschritte` zählte den ausgefilterten
|
||||
Pflichtschritt weiter mit; der Abschluss wäre unerreichbar gewesen, weil sich nur abhaken lässt,
|
||||
was in der Abarbeitung steht. Der Test hat das sofort gezeigt.
|
||||
- **Die Regel liegt in `src/lib/geltende-abschnitte.ts`**, nicht in einem der Lesemodelle: drei
|
||||
Schichten brauchen sie — der Dienst beim Start und beim Abschluss, und die Lesemodelle der
|
||||
Abarbeitung und der Druckansicht. Lebte sie in einem Lesemodell, importierte ein Dienst daraus, und
|
||||
das wäre eine Abhängigkeit in die falsche Richtung.
|
||||
- **Ein Baustein-Abschnitt lässt sich genauso einschränken** wie jeder andere: die Einschränkung
|
||||
gehört zur Vorlage, nicht zum Baustein.
|
||||
- **Eigener Numerusfehler beim Durchklicken gefunden:** „Ein Abschnitt gilt … und *bleiben* weg."
|
||||
Korrigiert — alles, was ein Mensch liest, schreibt sich richtig (CLAUDE.md).
|
||||
@@ -0,0 +1,92 @@
|
||||
# #032 M19 – Anhänge und Fotos an Schritten
|
||||
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#52](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/52)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend, infra
|
||||
**Geschätzt:** 10 h **Tatsächlich:** 3 h 05 min
|
||||
|
||||
## Ziel
|
||||
|
||||
„Seriennummer fotografiert“, „Typenschild“, „Zustand bei Übergabe“, „Rechnung abgelegt“ — manches
|
||||
lässt sich nicht abhaken, es muss gezeigt werden. Ein Schritt soll Dateien und Fotos aufnehmen
|
||||
können, am Handy direkt aus der Kamera.
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [x] **Dateien im Dateisystem, Metadaten in der Datenbank** — Empfehlung. Ein Pfad aus der
|
||||
Umgebung (`ANHAENGE_PFAD`), darunter nach Kunde und Lauf gegliedert. Gegen `bytea` in Postgres
|
||||
spricht die Sicherung: ein `pg_dump` mit Fotos ist nach einem Jahr kein `pg_dump` mehr, das
|
||||
jemand einspielt. Gegen S3 spricht, dass diese Anwendung selbst gehostet läuft und keinen
|
||||
Objektspeicher voraussetzen soll.
|
||||
**Folge, die mitgeplant werden muss:** der Compose-Stand und die native Installation brauchen
|
||||
ein Volume, und `docs/betrieb/sicherung.md` muss beides nennen. Eine Sicherung, die nur die
|
||||
Datenbank mitnimmt, verliert ab hier Daten — das ist genau die Art Zusage, die dieses Projekt
|
||||
sonst teuer bezahlt hat.
|
||||
- [x] **Keine Verkleinerung auf dem Server im ersten Schritt.** Ein Handyfoto hat heute 3–6 MB; eine
|
||||
Obergrenze (etwa 10 MB je Datei, 20 Dateien je Lauf) ist ehrlicher als eine Umwandlung, die
|
||||
Metadaten verliert und eine Bildbibliothek ins Projekt zieht.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [x] Modell `Anhang`: `schrittergebnisId`, `kundeId`, Dateiname, Medientyp, Größe, Prüfsumme,
|
||||
`erstelltVonId`, `erstelltAm`; Datei daneben im Dateisystem
|
||||
- [x] Hochladen über einen Route Handler mit `rechtErforderlich` — kein Weg daran vorbei, und
|
||||
**jeder** Abruf prüft Recht und Mandant, auch der des Bildes selbst
|
||||
- [x] Grenzen: Medientypen auf Bilder und PDF, Größe je Datei und Anzahl je Lauf, beides in der
|
||||
Umgebung einstellbar; eine abgewiesene Datei nennt den Grund
|
||||
- [x] Am Handy direkt aus der Kamera (`capture="environment"`), am Rechner aus dem Dateiwähler
|
||||
- [x] **Der Archiv-Schreibschutz gilt auch hier**: an einem archivierten Lauf kommt kein Anhang mehr
|
||||
dazu und keiner weg. `laufMussVeraenderbarSein` sitzt an genau einer Stelle — dort anschließen
|
||||
- [x] Druckansicht: Bilder als Miniaturen mit Dateinamen, PDF nur als Zeile
|
||||
- [x] `docs/betrieb/sicherung.md`, `docker-compose.yml`, `deploy/install.sh`: Volume, Rechte,
|
||||
Sicherung **und** Rückspielung
|
||||
- [x] Anleitung Kapitel 02 und 08
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [x] Test: Anhang an einem Schritt hochladen, in der Abarbeitung und im Druck sichtbar
|
||||
- [x] Test: an einem archivierten Lauf wird das Hochladen abgewiesen
|
||||
- [x] Test: ein Anhang eines fremden Kunden ist über seine Adresse **nicht** abrufbar
|
||||
- [x] Test: eine zu große Datei und ein falscher Medientyp werden mit Grund abgewiesen
|
||||
- [x] Test: `docker-compose.yml` hat ein Volume für die Anhänge, und `sicherung.md` nennt es (wie
|
||||
`tests/betriebsstand.test.ts` es für die Datenbank schon prüft)
|
||||
- [x] Sicherung und **Rückspielung** einmal wirklich gefahren, nicht nur beschrieben
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Hochgeladen wird über eine Server Action, nicht über einen eigenen Route Handler.** Die Aufgabe
|
||||
oben nannte einen Route Handler; der wäre eine zweite Tür mit eigener Rechteprüfung. Eine Server
|
||||
Action prüft dasselbe Recht an derselben Stelle wie jede andere Schreiboperation am Lauf. Ein
|
||||
Route Handler bleibt für das **Ausliefern** — dort braucht es eine Adresse, die ein `<img>` holen
|
||||
kann, und `src/app/api/anhaenge/[id]/route.ts` ruft selbst `rechtErforderlich("lesen")`, weil die
|
||||
Hülle unter `(app)` Route Handler nicht abdeckt.
|
||||
- **Der Pfad entsteht aus der Kennung, nie aus dem Dateinamen.** `<ANHAENGE_PFAD>/<kundeId>/<laufId>/<id>.<endung>`.
|
||||
Der Name vom Browser wird trotzdem gesäubert, weil er in einer Antwortkopfzeile landet.
|
||||
- **Kein SVG**, obwohl es ein Bild ist: ein SVG ist ein Dokument, das Skripte ausführt. Darum eine
|
||||
Liste erlaubter Medientypen und kein `image/*`.
|
||||
- **Reihenfolge beim Anlegen:** prüfen, Datei schreiben (`flag: "wx"`), dann die Zeile — und bei
|
||||
einem Fehler die Datei wieder weg. Eine Datei ohne Zeile findet niemand; eine Zeile ohne Datei ist
|
||||
ein Anhang, der beim Öffnen fehlschlägt. Beim Entfernen umgekehrt: erst die Zeile, dann die Datei.
|
||||
- **Zwei Felder `datei` in einem Formular — und `FormData.get` nahm das falsche.** „Foto“ und
|
||||
„Datei“ stehen im selben Formular, abgeschickt wird immer beides, und das leere Feld kam zuerst:
|
||||
wer „Datei“ wählte, bekam „Keine Datei gewählt“, während „Foto“ ging. Neunzehn grüne Dienst-Tests
|
||||
sahen davon nichts, weil kein Dienst-Test zwei Felder schickt. Gefunden hat es erst die Klickprobe
|
||||
am gebauten Stand. Die Regel liegt jetzt als reine Funktion `gewaehlteDatei()` in `src/lib`, und
|
||||
`tests/e2e/anhaenge.spec.ts` lädt bewusst **über den zweiten Knopf** hoch. Gegenprobe gefahren:
|
||||
mit `get` statt `getAll` wird der Ablauf rot.
|
||||
- **`docs/betrieb/sicherung.md` war nicht nur unvollständig, sondern an einer Stelle falsch** — dort
|
||||
stand „Kein Upload-Verzeichnis, keine erzeugten Dateien“. Die Datei ist neu geschrieben: drei
|
||||
Dinge statt zwei, `tar` für beide Betriebswege, der `chown`-Fallstrick nach einer Rücksicherung als
|
||||
`root`, ein Sicherungsskript für den systemd-Timer, das Datenbank und Anhänge im **selben** Lauf
|
||||
mitnimmt, und die Gegenprobe über die Zahlen.
|
||||
- **Sicherung und Rücksicherung wirklich gefahren**, nicht beschrieben: Abzug gezogen, Datenbank
|
||||
verworfen und neu angelegt, Verzeichnis gelöscht, erst nur die Datenbank zurückgespielt — und da
|
||||
meldete der Abruf genau den dokumentierten Satz „Die Datei zu … fehlt im Dateisystem“ —, dann die
|
||||
Anhänge dazu, und die ausgelieferten Bytes gegen die gespeicherte `pruefsumme` geprüft. Beide
|
||||
stimmten.
|
||||
- **`tests/datenbankregeln.test.ts` musste nachziehen.** Er zählt die handgeschriebenen
|
||||
Datenbankobjekte namentlich auf; die zwei neuen CHECK-Bedingungen und die beiden zusammengesetzten
|
||||
Fremdschlüssel standen nicht drin, und der Test wurde dadurch rot — genau wie gewollt.
|
||||
- **`/daten/` ist jetzt in `.gitignore`.** Ohne das läge das erste hochgeladene Foto im nächsten
|
||||
Commit.
|
||||
@@ -0,0 +1,94 @@
|
||||
# #033 M20 – Live-Aktualisierung per SSE statt Polling
|
||||
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#53](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/53)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend
|
||||
**Geschätzt:** 6 h **Tatsächlich:** 1 h 40 min
|
||||
|
||||
## Ziel
|
||||
|
||||
Die Abarbeitungsansicht fragt alle zehn Sekunden nach dem Zustand. Das reicht für zwei Techniker an
|
||||
einem Lauf, kostet aber bei zwanzig offenen Tabs zwanzig Abfragen je zehn Sekunden — und zehn
|
||||
Sekunden sind lang, wenn zwei Leute dieselbe Liste abhaken.
|
||||
|
||||
Die Vorleistung steht: das Polling liegt in genau einem Route Handler
|
||||
(`/api/laeufe/[id]/zustand`) und lässt sich tauschen.
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [x] **`LISTEN`/`NOTIFY` in Postgres als Signal** — Empfehlung. Ein Trigger auf `Schrittergebnis`
|
||||
und `Lauf` schickt `NOTIFY lauf_geaendert, '<laufId>'`; der Route Handler hält eine Verbindung
|
||||
und schiebt weiter. Damit funktioniert es auch mit mehreren Instanzen der Anwendung, ohne
|
||||
einen zweiten Dienst. Die Alternative — der Server fragt selbst alle zwei Sekunden — wäre
|
||||
dasselbe Polling, nur an anderer Stelle.
|
||||
- [x] **Polling bleibt als Rückfall**, nicht als zweite Wahrheit: derselbe Handler antwortet weiter
|
||||
auf eine gewöhnliche Anfrage. Wer kein `EventSource` hat oder hinter einem Proxy sitzt, der
|
||||
Ströme puffert, bekommt den alten Weg. Ein Rückfall, der nie geprüft wird, ist keiner —
|
||||
deshalb ein Test je Weg.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [x] Trigger und `NOTIFY` in einer handgeschriebenen Migration, mit dem üblichen Vermerk (Prisma
|
||||
kennt Trigger nicht und will sie löschen)
|
||||
- [x] Route Handler liefert `text/event-stream`, schickt alle 25 s einen Kommentar als Herzschlag
|
||||
(sonst schließen Proxys die Verbindung) und beendet sauber bei `abort`
|
||||
- [x] **Der Handler prüft Recht und Mandant selbst** — ein offener Strom ist ein Leseweg wie jeder
|
||||
andere (CLAUDE.md: Route Handler deckt die Hülle nicht ab)
|
||||
- [x] Client: `EventSource` mit Wiederverbindung; bei versteckter Lasche pausieren wie heute; bei
|
||||
`error` auf Polling zurückfallen und das **einmal** melden, nicht bei jedem Versuch
|
||||
- [x] `docs/betrieb/reverse-proxy.md`: `proxy_buffering off` für nginx, `flush_interval -1` für
|
||||
Caddy — ohne das kommt kein Ereignis an, und die Seite sieht aus, als sei sie hängen geblieben
|
||||
- [x] Obergrenze für gleichzeitige Ströme je Instanz, damit ein Browser mit vierzig Tabs die
|
||||
Verbindungen nicht aufbraucht
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [x] Test: zwei Sitzungen am selben Lauf — hakt die eine ab, sieht die andere es in unter einer
|
||||
Sekunde, ohne neu zu laden
|
||||
- [x] Test: ohne Recht wird der Strom abgewiesen, bevor das erste Ereignis fließt
|
||||
- [x] Test: ein Lauf eines fremden Kunden liefert keinen Strom
|
||||
- [x] Test: der Rückfall auf Polling funktioniert — gefahren mit abgeschaltetem `EventSource`
|
||||
- [x] Der Herzschlag kommt an: eine Verbindung überlebt 60 s ohne Änderung
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Es wäre ein Rückschritt geworden.** Das Issue nannte Trigger auf `Schrittergebnis` und `Lauf`.
|
||||
`laufZustand` trägt aber auch die Zahl der **Kommentare** — das Polling verglich alle zehn
|
||||
Sekunden den ganzen Stand und sah sie damit, ein Strom sieht nur, was gemeldet wird. Mit den zwei
|
||||
Triggern aus dem Issue wäre der Kommentar des Kollegen **nie** angekommen, und niemandem wäre
|
||||
etwas aufgefallen: die Seite sieht aus wie immer. Jetzt melden vier Tabellen, und
|
||||
`tests/datenbankregeln.test.ts` zählt sie namentlich auf. Gegenprobe gefahren: ohne die Trigger auf
|
||||
`Kommentar` und `Anhang` wird `tests/lauf-ereignisse.test.ts` rot.
|
||||
- **`laufZustand` hat `anzahlAnhaenge` bekommen.** Anhänge gibt es seit #032, im Zustand standen sie
|
||||
nicht. Ein Ereignis ohne Entsprechung im Zustand löst nichts aus, weil der Client vergleicht,
|
||||
bevor er neu lädt — ein angehängtes Foto wäre also auch mit Trigger unsichtbar geblieben.
|
||||
- **Ein Handler für beide Wege, unterschieden am `Accept`-Kopf.** So steht „Polling bleibt als
|
||||
Rückfall, nicht als zweite Wahrheit“ nicht nur im Issue: es ist dieselbe Rechteprüfung, dasselbe
|
||||
Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt wäre eine zweite Stelle, an der jemand
|
||||
`rechtErforderlich` vergessen kann.
|
||||
- **Die Obergrenze braucht keinen Sonderfall.** Ist sie erreicht, antwortet der Handler gewöhnlich
|
||||
mit JSON. `EventSource` verlangt `text/event-stream`, meldet einen Fehler und versucht es **nicht
|
||||
wieder** — der Client fällt damit von sich aus auf Polling zurück. Kein eigener Fehlercode, kein
|
||||
zweiter Pfad.
|
||||
- **Eine LISTEN-Verbindung je Prozess, nicht je Strom** — und ausdrücklich **nicht** aus dem
|
||||
Prisma-Pool: ein Pool gibt Verbindungen zurück und verteilt sie neu, ein `LISTEN` darauf gilt
|
||||
danach für eine fremde Anfrage oder für niemanden.
|
||||
- **Nach einem Verbindungsabbruch werden alle Zuhörer geweckt.** `NOTIFY` hat keinen
|
||||
Zwischenspeicher; was während des Abbruchs passierte, ist verloren. Lieber ein Abruf zu viel als
|
||||
ein stiller Stand, der falsch ist.
|
||||
- **Bei versteckter Lasche wird der Strom geschlossen**, nicht bloß ignoriert. Eine offene
|
||||
Verbindung je Hintergrundlasche ist genau das, was die Obergrenze verhindern soll.
|
||||
- **Zwei Fehler in `docs/betrieb/reverse-proxy.md` gefunden, beide aus #032:**
|
||||
`client_max_body_size` stand auf **8m**, während `ANHANG_MAX_MB` 10 erlaubt — ein 10-MB-Foto wäre
|
||||
an nginx mit „413“ gescheitert, und die freundliche Meldung der Anwendung wäre nie zum Zuge
|
||||
gekommen. Jetzt 12m, mit dem Hinweis, dass beide Werte zusammengehören. Dazu beschrieb der
|
||||
Kommentar am `proxy_read_timeout` noch das alte Polling.
|
||||
- **Der Herzschlag ist echt über 70 Sekunden gefahren**, nicht nur im Test mit verkürztem Intervall:
|
||||
`readyState` blieb `OPEN`, ein einziges Zustands-Ereignis, kein Fehler, kein Neuaufbau.
|
||||
- **Gemessen statt behauptet:** der Haken war nach **485 ms** beim Zuschauer (Desktop) und nach
|
||||
**487 ms** am Handy. Mit abgeschaltetem `EventSource` dauert derselbe Ablauf rund 13 Sekunden —
|
||||
das ist zugleich der Nachweis, dass der Strom wirklich benutzt wird und nicht heimlich gepollt
|
||||
wird.
|
||||
- **Der Testname versprach `tests/e2e/live.spec.ts`**, also gibt es die Datei. Zwei Browser-Kontexte,
|
||||
und für den Rückfall wird dem Zuschauer `EventSource` vor dem Laden weggenommen.
|
||||
@@ -0,0 +1,106 @@
|
||||
# #034 M21 – Benachrichtigungen per Mail und Webhook
|
||||
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#54](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/54)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend, infra
|
||||
**Geschätzt:** 10 h **Tatsächlich:** 3 h 30 min
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein zugewiesener Lauf fällt heute nur auf, wenn jemand die Übersicht öffnet. Ein überfälliger
|
||||
überhaupt nicht. Vier Ereignisse sind es wert, gemeldet zu werden: **zugewiesen**, **überfällig**,
|
||||
**abgeschlossen**, **Verzeichnisabgleich fehlgeschlagen**.
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [x] **Nur diese vier Ereignisse, je Empfänger abschaltbar** — Empfehlung. Ein allgemeiner
|
||||
Benachrichtigungsbaukasten ist eine Phase für sich; vier Ereignisse decken ab, was im Betrieb
|
||||
wirklich fehlt.
|
||||
- [x] **Mail über SMTP, keine Dienstanbindung.** Eine selbst gehostete Anwendung soll an den
|
||||
Mailserver des Hauses können, und das ist SMTP. Zugangsdaten verschlüsselt wie das
|
||||
LDAP-Bind-Passwort.
|
||||
- [x] **Zustellung in einer Warteschlange in der Datenbank**, nicht im Anfrage-Pfad. Ein hängender
|
||||
Mailserver darf kein Abhaken blockieren. Dieselbe Sperre wie beim Abgleich
|
||||
(`AbgleichSperre`) verhindert, dass zwei Instanzen dasselbe zweimal schicken.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [x] Modell `Zustellung`: Ereignis, Empfänger, Weg (MAIL | WEBHOOK), Versuche, letzter Fehler,
|
||||
Zustand; mit Index auf das, was der Zeitplan abholt
|
||||
- [x] SMTP-Einstellungen unter Admin → Einstellungen: Host, Port, TLS, Benutzer, Passwort
|
||||
(verschlüsselt, nie in einer Antwort), Absender, „Testmail schicken“
|
||||
- [x] Webhook: Ziel-URL, Geheimnis, **HMAC-SHA256 über den Rumpf** im Kopf `X-Laufzettel-Signatur`,
|
||||
dazu ein Zeitstempel gegen Wiedereinspielen
|
||||
- [x] Wiederholung mit wachsendem Abstand (1, 5, 30, 120 Minuten), dann aufgeben und sichtbar
|
||||
machen. Eine stille Warteschlange ist schlimmer als keine
|
||||
- [x] Überfälligkeit erkennt der Zeitplan, der schon läuft (`node-cron` im Prozess) — kein zweiter
|
||||
Dienst
|
||||
- [x] Je Benutzer abschaltbar; ein Benutzer ohne Mailadresse bekommt keine Mail und keinen Fehler
|
||||
- [x] Admin: die letzten Zustellungen mit Zustand und Grund
|
||||
- [x] **`{{generiertes_passwort}}` kommt in keine Nachricht.** Eine Mail mit dem Lauf-Inhalt wäre
|
||||
genau die Lücke, die das Projekt an jeder anderen Stelle geschlossen hat. Die Nachricht
|
||||
enthält Titel, Status und einen Link — **keine Schrittinhalte**
|
||||
- [x] `docs/betrieb/`: SMTP einrichten, Webhook prüfen, Signatur nachrechnen
|
||||
- [x] Anleitung Kapitel 07
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [x] Test gegen einen echten SMTP-Fänger im Container (mailpit): zugewiesen, überfällig,
|
||||
abgeschlossen, Abgleich fehlgeschlagen — vier Mails, richtige Empfänger
|
||||
- [x] Test gegen einen lokalen Empfänger: die Signatur lässt sich mit dem Geheimnis nachrechnen, mit
|
||||
einem falschen nicht
|
||||
- [x] Test: eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar
|
||||
- [x] Test: zwei gleichzeitige Durchgänge schicken nichts zweimal
|
||||
- [x] Test: in **keiner** Nachricht steht ein erzeugtes Passwort — über alle Rumpftexte geprüft, wie
|
||||
in #012
|
||||
- [x] Test: ein Benutzer mit abgeschalteter Benachrichtigung bekommt nichts
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Die Sperre wurde aus dem Abgleich herausgezogen.** `mitAbgleichSperre` lag in
|
||||
`services/abgleich.ts`; die Warteschlange braucht dieselbe Mechanik, und zwei Fassungen davon
|
||||
wären die schlechteste aller Lösungen. Sie heißt jetzt `mitSperre` in `services/sperre.ts`. Die
|
||||
Tabelle behält den Namen `AbgleichSperre` — ein Umbenennen wäre eine Migration, die nichts
|
||||
verbessert, und der Schlüssel sagt ohnehin, wer sperrt.
|
||||
- **Niemand bekommt Post über die eigene Tat.** Steht nicht im Issue, ist aber der häufigste Grund,
|
||||
Benachrichtigungen ganz abzuschalten. Der Handelnde fällt immer aus der Empfängerliste.
|
||||
- **Die Mandantengrenze gilt auch für Empfänger.** Eine Meldung trägt Lauftitel und Gerätenamen;
|
||||
ohne diese Prüfung wäre die Benachrichtigung der eine Weg nach draußen, den `sichtbareKundenIds`
|
||||
nicht abdeckt. Ein Benutzer, der den Kunden des Laufs nicht sehen darf, bekommt keine Mail — mit
|
||||
eigenem Test.
|
||||
- **Die Einmaligkeit der Überfälligkeitsmeldung liegt in der Datenbank**, als partieller eindeutiger
|
||||
Index. Der Zeitplan sieht jede Minute nach; ohne ihn bekäme der Zuständige für einen Lauf, der
|
||||
über das Wochenende steht, **2880 Mails** — und jede einzelne davon wäre für sich korrekt, kein
|
||||
Test rot. Gegenprobe gefahren: ohne den Index werden zwei Tests rot.
|
||||
- **Fünf Versuche, nicht vier.** Die vier Wartezeiten aus dem Issue (1, 5, 30, 120 Minuten) liegen
|
||||
*zwischen* den Versuchen — der fünfte Fehlschlag gibt auf. Meine erste Fassung indizierte um eins
|
||||
verschoben und wartete nach dem ersten Fehlschlag fünf Minuten; der Test hat es sofort gezeigt.
|
||||
- **Ein eigener Zeitplan, nicht derselbe wie der Abgleich.** Der Mailversand darf nicht warten, weil
|
||||
gerade ein großes Verzeichnis gelesen wird. Zwei Takte sind billig.
|
||||
- **`mailpit` gehört ins Tor vor dem Commit.** Ohne den Container überspringt die Suite **3 Tests**
|
||||
(gemessen: 36 statt 33) — genau die, die den echten SMTP-Dialog prüfen. `CLAUDE.md`, das README
|
||||
und `scripts/pruefe-doku.mjs` nennen ihn jetzt, und die Prüfung ist gegengeprobt: ohne ihn im
|
||||
Block wird `pnpm pruefe:doku` rot.
|
||||
- **`it.runIf(mailpitDa)` wird beim Einlesen der Datei ausgewertet.** Das Flag stand in `beforeAll`
|
||||
und kam zu spät: der erste Lauf meldete „3 skipped“, war grün und hatte den Mailversand nie
|
||||
geprüft. Jetzt wird auf Modulebene geprüft, wie bei `datenbankVerfuegbar`.
|
||||
- **pnpm wollte eine Mindestalter-Sperre umgehen.** `pnpm add nodemailer` trug von sich aus
|
||||
`minimumReleaseAgeExclude: nodemailer@10.0.15` ein — die Version war **vom selben Tag**. Das ist
|
||||
kein Formfehler, sondern genau die Warnung, vor der die Regel schützt: ein frisches Release eines
|
||||
Mailpakets ungeprüft einzubauen. Gepinnt ist jetzt `10.0.10` vom 14. September, die Ausnahme ist
|
||||
weg, und die Konvention steht in `CLAUDE.md`.
|
||||
- **Zwei Knöpfe „Speichern“ auf einer Seite.** Beim Durchklicken aufgefallen: die Einstellungsseite
|
||||
hatte zwei gleich benannte. Der neue heißt jetzt „Benachrichtigungen speichern“ — für einen
|
||||
Menschen genauso verwirrend wie für einen Test.
|
||||
- **Die Testmail geht direkt hinaus, nicht über die Warteschlange.** Ein Knopf, der „eingereiht“
|
||||
meldet und den Fehler eine Minute später woanders zeigt, prüft nichts.
|
||||
- **Der Mail-Schalter gilt auch für das eigene Konto**, anders als „aktiv“: sich selbst zu
|
||||
deaktivieren wäre ein Eigentor, seine eigene Post abzustellen der häufigste Wunsch überhaupt. Eine
|
||||
**Profilseite**, auf der das jeder selbst könnte, fehlt noch — sie kommt mit der Selbstauskunft
|
||||
(#035). Bis dahin stellt ein Administrator es um, und so steht es in der Anleitung.
|
||||
- **Im gebauten Stand wirklich gefahren:** SMTP eingetragen, Testmail bei mailpit angekommen, ein
|
||||
überfälliger Lauf angelegt — und der Zeitplan hat die Meldung **von selbst** verschickt, ohne
|
||||
Zutun. Dazu geprüft: das Passwort steht nicht im Quelltext der Seite, Speichern ohne Passwortfeld
|
||||
behält es, die Zustellungsliste zeigt keinen Rumpf, und 390 px ohne waagerechtes Scrollen auf allen
|
||||
drei Seiten.
|
||||
@@ -0,0 +1,61 @@
|
||||
# #035 M22 – Mitarbeiter-Selbstauskunft
|
||||
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#55](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/55)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend
|
||||
**Geschätzt:** 8 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
|
||||
„Was ist eigentlich mit meinem Notebook passiert?“ fragt heute jemand im Ticket, und ein Techniker
|
||||
sieht nach. Ein Mitarbeiter soll die Läufe zu **seinen** Geräten und zu **seiner** Person selbst
|
||||
ansehen können — nur lesend.
|
||||
|
||||
## Woran es hängt
|
||||
|
||||
Ein `Mitarbeiter` ist kein `Benutzer`. Die Anwendung kennt vier Rollen für Leute, die **arbeiten**;
|
||||
ein Mitarbeiter ist Gegenstand der Arbeit, nicht ihr Träger. Es gibt heute keinen Weg, von einer
|
||||
Anmeldung zu einer `Mitarbeiter`-Zeile zu kommen.
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [ ] **Anmeldung über OIDC, Zuordnung über den UPN, neue Rolle `MITARBEITER`** — Empfehlung. Das
|
||||
Haus hat seine Leute ohnehin im Identitätsanbieter; wer sich anmeldet und keiner
|
||||
Rollengruppe angehört, wird über `upn` einer `Mitarbeiter`-Zeile zugeordnet und bekommt genau
|
||||
ein Recht: `selbstauskunft`. `Benutzer.mitarbeiterId` hält die Zuordnung fest.
|
||||
Die Alternative — ein Link mit Token je Person — braucht keine Anmeldung, aber jeder
|
||||
weitergeleitete Link ist dann ein Datenleck, und widerrufen müsste man je Person.
|
||||
- [ ] **Was der Mitarbeiter sieht:** Titel, Status, Zeitraum und Gerät eines Laufs. **Nicht** die
|
||||
Schritte, nicht die Notizen, nicht die Kommentare, nicht die Anhänge. In einem Lauf stehen
|
||||
Arbeitsnotizen über Menschen und Zugangsdaten-Platzhalter; eine Selbstauskunft ist keine
|
||||
Akteneinsicht. Das ist die wichtigste Entscheidung dieses Meilensteins und gehört in die
|
||||
Anleitung, nicht nur in den Code.
|
||||
- [ ] **Kein Zugang ohne Zuordnung.** Wer sich anmeldet und zu keiner `Mitarbeiter`-Zeile passt,
|
||||
bekommt `/kein-zugriff` mit Begründung — nicht eine leere Seite, die wie ein Fehler aussieht.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] `Rolle` um `MITARBEITER` erweitern; `RECHTE` bekommt `selbstauskunft: ["MITARBEITER", …]`.
|
||||
**Jede** Rollenprüfung in `tests/rollen.test.ts` wächst um eine Spalte — die Matrix ist
|
||||
ausgeschrieben und nicht berechnet, genau damit das auffällt
|
||||
- [ ] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel
|
||||
- [ ] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein
|
||||
Fehler und kein Zufallstreffer
|
||||
- [ ] Eigene Seite `/meine-geraete`: meine Geräte, je Gerät die Läufe chronologisch, Titel und Status
|
||||
- [ ] **Jede Abfrage schränkt auf die eigene Person ein** — nicht die Seite. Ein Lesemodell trägt
|
||||
seine Sicht selbst (CLAUDE.md)
|
||||
- [ ] Die Navigation zeigt einem `MITARBEITER` nur diese Seite; alles andere ist weg, nicht grau
|
||||
- [ ] Anleitung: ein eigenes kurzes Kapitel, an Mitarbeiter gerichtet und nicht an Techniker
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: ein `MITARBEITER` sieht die Läufe seiner Geräte und keinen anderen
|
||||
- [ ] Test: er sieht **keine** Schritte, Notizen, Kommentare oder Anhänge — geprüft über die Antwort,
|
||||
nicht über die Seite
|
||||
- [ ] Test: er kann nichts schreiben — jede Server Action weist ihn ab (`tests/zugang.test.ts`
|
||||
erkennt eine ungeprüfte Aktion ohnehin)
|
||||
- [ ] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung
|
||||
- [ ] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert
|
||||
- [ ] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht
|
||||
- [ ] Am Handy kein horizontales Scrollen
|
||||
+17
-8
@@ -6,7 +6,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| Nr. | Titel | Status | Meilenstein | Geschätzt | Tatsächlich |
|
||||
|---|---|---|---|---|---|
|
||||
| #001 | [Phase 1: MVP](001-epic-phase-1-mvp.md) | in Arbeit | v0.1.0 | – | – |
|
||||
| #002 | [Phase 2: Verzeichnis-Alternativen, Bausteine, Anhänge](002-epic-phase-2.md) | offen | v0.2.0 | – | – |
|
||||
| #002 | [Phase 2: Verzeichnis-Alternativen, Bausteine, Anhänge](002-epic-phase-2.md) | in Arbeit | v0.2.0 | 76 h | – |
|
||||
| #003 | [Phase 3: Protokoll, bedingte Schritte, Freigabe](003-epic-phase-3.md) | offen | v0.3.0 | – | – |
|
||||
| #004 | [Phase 4: Aktionen, Connectors, REST-API](004-epic-phase-4.md) | offen | v0.4.0 | – | – |
|
||||
| #005 | [M0 – Gerüst, Prozess, Design-Token](005-geruest-prozess-design.md) | erledigt | v0.1.0 | 6 h | 1 h 25 min |
|
||||
@@ -24,11 +24,20 @@ 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) | erledigt | v0.1.0 | 8 h | 1 h 30 min |
|
||||
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | erledigt | v0.1.0 | 14 h | 2 h 15 min |
|
||||
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | erledigt | v0.1.0 | 10 h | 5 h 25 min |
|
||||
| #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | offen | v0.2.0 | 3 h | – |
|
||||
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | offen | v0.2.0 | 2 h | – |
|
||||
| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | offen | v0.2.0 | 3 h | – |
|
||||
| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – |
|
||||
| #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | erledigt | v0.2.0 | 3 h | 1 h 20 min |
|
||||
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | erledigt | v0.2.0 | 2 h | 55 min |
|
||||
| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | erledigt | v0.2.0 | 3 h | 1 h 10 min |
|
||||
| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | erledigt | v0.2.0 | 4 h | 1 h 50 min |
|
||||
| #024 | [Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen](024-proxmox-echtlauf.md) | offen | v0.2.0 | 2 h | – |
|
||||
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | offen | v0.2.0 | 1 h | – |
|
||||
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | offen | v0.2.0 | 1 h | – |
|
||||
| | **Summe** | | | **152 h** | **35 h 20 min** |
|
||||
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
|
||||
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
|
||||
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
||||
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – |
|
||||
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
|
||||
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
|
||||
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
|
||||
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min |
|
||||
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min |
|
||||
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min |
|
||||
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
|
||||
| | **Summe** | | | **229 h** | **53 h 35 min** |
|
||||
|
||||
@@ -104,6 +104,7 @@ Platzhaltern. **JSON kennt keine Kommentare** — die Zeilen mit `//` müssen vo
|
||||
| -------------- | -------- | ------------------------------------------------------------------------------- |
|
||||
| `format` | Pflicht | Fassung des Austauschformats. Derzeit `1`; ein anderer Wert wird abgewiesen. |
|
||||
| `name` | Pflicht | Name der Vorlage, nicht leer. Namen sind **nicht** eindeutig. |
|
||||
| `seedSchluessel` | optional | **Nur für mitgelieferte Vorlagen.** Siehe unten — beim Import verfällt das Feld. |
|
||||
| `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 `[]`. |
|
||||
@@ -230,3 +231,33 @@ Das Feld `format` ist die Fassung des Formats, nicht die der Anwendung. Es gilt:
|
||||
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.
|
||||
|
||||
## `seedSchluessel` — nur für mitgelieferte Vorlagen
|
||||
|
||||
Die sieben Vorlagen, die Laufzettel mitbringt, tragen in ihrer Datei ein zusätzliches Feld:
|
||||
|
||||
```json
|
||||
{ "format": 1, "name": "PC Neuinstallation", "seedSchluessel": "pc-neuinstallation" }
|
||||
```
|
||||
|
||||
Daran erkennt der Seed sie wieder. Vorher ging das über den **Namen** — und wer eine mitgelieferte
|
||||
Vorlage umbenannte, bekam beim nächsten Seed-Lauf eine zweite, samt doppeltem Schnellstart auf der
|
||||
Geräteseite (#021).
|
||||
|
||||
**Was der Schlüssel nicht tut:**
|
||||
|
||||
- Er macht eine Vorlage **nicht unveränderlich.** Umbenennen, umbauen, archivieren — alles bleibt
|
||||
erlaubt. Der Seed erkennt sie danach wieder und lässt sie in Ruhe.
|
||||
- Er erlaubt dem Seed **nicht**, eine bearbeitete Vorlage zu überschreiben. Gibt es die Vorlage, legt
|
||||
er nichts an und ändert nichts. Das war vorher so und bleibt so.
|
||||
- Er ist **keine Kennung über Installationen hinweg.** Zwei Installationen haben für dieselbe
|
||||
mitgelieferte Vorlage denselben Schlüssel, aber verschiedene `id`s — der Schlüssel sagt nur
|
||||
„diese Zeile stammt aus dem Seed“.
|
||||
|
||||
**Beim Import verfällt das Feld.** Wer eine mitgelieferte Vorlage exportiert und wieder einspielt,
|
||||
bekommt eine gewöhnliche Vorlage ohne Schlüssel. Das ist Absicht: die Kopie soll nicht den Platz des
|
||||
Originals einnehmen, sonst hielte der Seed sie dafür und würde das Original ein zweites Mal anlegen.
|
||||
Der Export schreibt das Feld deshalb auch nicht — ein Feld, das beim Einlesen verfällt, verwirrt nur.
|
||||
|
||||
Eigene Vorlagen brauchen den Schlüssel nicht und sollten ihn nicht bekommen. Er ist eindeutig, wo er
|
||||
gesetzt ist; eine zweite Vorlage mit demselben Schlüssel weist die Datenbank ab.
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"lucide-react": "1.47.0",
|
||||
"next": "16.3.6",
|
||||
"node-cron": "^4.6.0",
|
||||
"nodemailer": "10.0.10",
|
||||
"openid-client": "^6.8.8",
|
||||
"pg": "^8.23.0",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -72,6 +73,7 @@
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.3.0",
|
||||
|
||||
Generated
+19
@@ -220,6 +220,9 @@ importers:
|
||||
node-cron:
|
||||
specifier: ^4.6.0
|
||||
version: 4.6.0
|
||||
nodemailer:
|
||||
specifier: 10.0.10
|
||||
version: 10.0.10
|
||||
openid-client:
|
||||
specifier: ^6.8.8
|
||||
version: 6.8.8
|
||||
@@ -269,6 +272,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^24.13.6
|
||||
version: 24.13.6
|
||||
'@types/nodemailer':
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
'@types/pg':
|
||||
specifier: ^8.23.1
|
||||
version: 8.23.1
|
||||
@@ -2321,6 +2327,9 @@ packages:
|
||||
'@types/node@24.13.6':
|
||||
resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==}
|
||||
|
||||
'@types/nodemailer@8.0.2':
|
||||
resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==}
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||
|
||||
@@ -4113,6 +4122,10 @@ packages:
|
||||
resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
nodemailer@10.0.10:
|
||||
resolution: {integrity: sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
oauth4webapi@3.8.8:
|
||||
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
|
||||
|
||||
@@ -6880,6 +6893,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/nodemailer@8.0.2':
|
||||
dependencies:
|
||||
'@types/node': 24.13.6
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
dependencies:
|
||||
'@types/node': 24.13.6
|
||||
@@ -9011,6 +9028,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.57: {}
|
||||
|
||||
nodemailer@10.0.10: {}
|
||||
|
||||
oauth4webapi@3.8.8: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
|
||||
-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
|
||||
-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
|
||||
--
|
||||
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Stabiler Schluessel fuer mitgelieferte Vorlagen (#021).
|
||||
--
|
||||
-- Bisher erkannte der Seed seine sieben Vorlagen am Namen. Wurde eine
|
||||
-- umbenannt, legte der naechste Lauf sie erneut an, und der Schnellstart stand
|
||||
-- auf der Geraeteseite doppelt. Der Schluessel ueberlebt das Umbenennen.
|
||||
ALTER TABLE "Vorlage"
|
||||
ADD COLUMN "seedSchluessel" TEXT;
|
||||
|
||||
-- Eindeutig nur dort, wo gesetzt: eigene Vorlagen tragen keinen Schluessel, und
|
||||
-- ein gewoehnlicher eindeutiger Index wuerde an der zweiten davon scheitern.
|
||||
CREATE UNIQUE INDEX "Vorlage_seedSchluessel_einmalig"
|
||||
ON "Vorlage" ("seedSchluessel")
|
||||
WHERE "seedSchluessel" IS NOT NULL;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
|
||||
-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
|
||||
-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
|
||||
--
|
||||
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Vorgesetzter auch fuer von Hand angelegte Personen (#020).
|
||||
--
|
||||
-- Bisher lief die Auskunft allein ueber `vorgesetzterExterneId`, und die ist nur
|
||||
-- innerhalb einer Verzeichnisquelle eindeutig: eine Person ohne Quelle konnte
|
||||
-- keinen Vorgesetzten haben, {{vorgesetzter_sam}} blieb dort immer leer.
|
||||
ALTER TABLE "Mitarbeiter"
|
||||
ADD COLUMN "vorgesetzterId" TEXT;
|
||||
|
||||
CREATE INDEX "Mitarbeiter_vorgesetzterId_idx" ON "Mitarbeiter"("vorgesetzterId");
|
||||
|
||||
ALTER TABLE "Mitarbeiter"
|
||||
ADD CONSTRAINT "Mitarbeiter_vorgesetzterId_fkey"
|
||||
FOREIGN KEY ("vorgesetzterId") REFERENCES "Mitarbeiter" ("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Die Mandantengrenze, wie bei Lauf und Geraet: der Vorgesetzte gehoert zum
|
||||
-- selben Kunden. MATCH SIMPLE ist richtig — ist die Spalte NULL, greift der
|
||||
-- Fremdschluessel nicht, eine Person ohne Vorgesetzten bleibt also erlaubt.
|
||||
ALTER TABLE "Mitarbeiter"
|
||||
ADD CONSTRAINT "Mitarbeiter_vorgesetzter_gleicher_kunde"
|
||||
FOREIGN KEY ("vorgesetzterId", "kundeId") REFERENCES "Mitarbeiter" ("id", "kundeId")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Niemand ist sein eigener Vorgesetzter. Das ist die kuerzeste Schleife und die
|
||||
-- einzige, die sich ohne Rekursion abfangen laesst; laengere haelt der Dienst
|
||||
-- auf (siehe mitarbeiter.ts, vorgesetztenSetzen).
|
||||
ALTER TABLE "Mitarbeiter"
|
||||
ADD CONSTRAINT "Mitarbeiter_vorgesetzter_nicht_selbst"
|
||||
CHECK ("vorgesetzterId" IS NULL OR "vorgesetzterId" <> "id");
|
||||
|
||||
-- Nachfuehrung fuer bestehende Installationen.
|
||||
--
|
||||
-- Ohne sie waere `{{vorgesetzter_sam}}` nach dieser Migration bei **jeder**
|
||||
-- Person aus dem Verzeichnis leer, bis der naechste Abgleich laeuft -- und zwar
|
||||
-- still: die Auskunft lief bisher allein ueber `vorgesetzterExterneId`, der neue
|
||||
-- Leseweg nimmt nur `vorgesetzterId`. Dieselbe Falle wie beim Seed-Schluessel
|
||||
-- in #021: ein Umstieg, der die vorhandenen Daten nicht mitnimmt.
|
||||
--
|
||||
-- Die Zuordnung laeuft ueber die `externeId` **derselben** Quelle, genau wie der
|
||||
-- alte Leseweg: die Kennung ist nur dort eindeutig.
|
||||
UPDATE "Mitarbeiter" AS person
|
||||
SET "vorgesetzterId" = chef."id"
|
||||
FROM "Mitarbeiter" AS chef
|
||||
WHERE person."vorgesetzterExterneId" IS NOT NULL
|
||||
AND person."quelleId" IS NOT NULL
|
||||
AND chef."quelleId" = person."quelleId"
|
||||
AND chef."externeId" = person."vorgesetzterExterneId"
|
||||
-- Die Mandantengrenze gilt auch hier; stand je eine Zuordnung ueber Kunden
|
||||
-- hinweg in den Daten, bliebe sie leer statt den Fremdschluessel zu brechen.
|
||||
AND chef."kundeId" = person."kundeId"
|
||||
AND chef."id" <> person."id";
|
||||
@@ -0,0 +1,21 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
|
||||
-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
|
||||
-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
|
||||
--
|
||||
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Marke fuer den Schnellstart (#022).
|
||||
--
|
||||
-- Der Seed legt Vorgaben je Geraetetyp an. Ohne diese Marke kaeme eine in der
|
||||
-- Oberflaeche entfernte Zuordnung beim naechsten Lauf zurueck: eine fehlende
|
||||
-- Zeile sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde.
|
||||
--
|
||||
-- Bestehende Installationen bekommen `false`. Das ist richtig: dort hat noch
|
||||
-- niemand den Schnellstart in der Oberflaeche gepflegt, weil es sie nicht gab.
|
||||
ALTER TABLE "Einstellung"
|
||||
ADD COLUMN "schnellstartGepflegt" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,78 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
|
||||
-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
|
||||
-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
|
||||
--
|
||||
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Mandantenspalte fuer das Protokoll (#023).
|
||||
--
|
||||
-- CLAUDE.md verlangt sie fuer jede kundenbezogene Zeile. Das Protokoll hatte sie
|
||||
-- nicht, und damit liess sich die Protokoll-Ansicht nicht nach Mandant filtern,
|
||||
-- ohne je Eintrag die Entitaet nachzuschlagen. Leer heisst **global**:
|
||||
-- Einstellungen, Benutzer, kundenfreie Vorlagen, Schnellstart.
|
||||
ALTER TABLE "Protokoll"
|
||||
ADD COLUMN "kundeId" TEXT;
|
||||
|
||||
CREATE INDEX "Protokoll_kundeId_erstelltAm_idx" ON "Protokoll"("kundeId", "erstelltAm");
|
||||
|
||||
ALTER TABLE "Protokoll"
|
||||
ADD CONSTRAINT "Protokoll_kundeId_fkey"
|
||||
FOREIGN KEY ("kundeId") REFERENCES "Kunde" ("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- ---- Nachfuehrung ---------------------------------------------------------
|
||||
-- Vorhandene Eintraege bekommen ihren Kunden, soweit er sich ableiten laesst.
|
||||
-- Ohne das traegt jeder alte Eintrag "global" und waere fuer einen auf Kunden
|
||||
-- beschraenkten Benutzer sichtbar -- dieselbe Falle wie bei den Umstiegen in
|
||||
-- #021 und #020, nur hier mit Folgen fuer die Sichtbarkeit.
|
||||
|
||||
-- 1. Alles, was an einem Lauf haengt: der Lauf kennt seinen Kunden.
|
||||
UPDATE "Protokoll" AS p
|
||||
SET "kundeId" = l."kundeId"
|
||||
FROM "Lauf" AS l
|
||||
WHERE p."laufId" = l."id" AND p."kundeId" IS NULL;
|
||||
|
||||
-- 2. Die kundentragenden Entitaeten, je ueber ihre eigene Tabelle.
|
||||
UPDATE "Protokoll" AS p SET "kundeId" = m."kundeId"
|
||||
FROM "Mitarbeiter" AS m
|
||||
WHERE p."entitaet" = 'Mitarbeiter' AND p."entitaetId" = m."id" AND p."kundeId" IS NULL;
|
||||
|
||||
UPDATE "Protokoll" AS p SET "kundeId" = g."kundeId"
|
||||
FROM "Geraet" AS g
|
||||
WHERE p."entitaet" = 'Geraet' AND p."entitaetId" = g."id" AND p."kundeId" IS NULL;
|
||||
|
||||
UPDATE "Protokoll" AS p SET "kundeId" = a."kundeId"
|
||||
FROM "Abteilung" AS a
|
||||
WHERE p."entitaet" = 'Abteilung' AND p."entitaetId" = a."id" AND p."kundeId" IS NULL;
|
||||
|
||||
UPDATE "Protokoll" AS p SET "kundeId" = q."kundeId"
|
||||
FROM "Verzeichnisquelle" AS q
|
||||
WHERE p."entitaet" = 'Verzeichnisquelle' AND p."entitaetId" = q."id" AND p."kundeId" IS NULL;
|
||||
|
||||
-- 3. Ein Eintrag **ueber** einen Kunden gehoert zu diesem Kunden.
|
||||
UPDATE "Protokoll" AS p SET "kundeId" = k."id"
|
||||
FROM "Kunde" AS k
|
||||
WHERE p."entitaet" = 'Kunde' AND p."entitaetId" = k."id" AND p."kundeId" IS NULL;
|
||||
|
||||
-- 4. Eine Vorlage traegt nur im Modus DIENSTLEISTUNG einen Kunden; ist sie
|
||||
-- kundenfrei, bleibt der Eintrag global. Genau richtig: sie gilt fuer alle.
|
||||
UPDATE "Protokoll" AS p SET "kundeId" = v."kundeId"
|
||||
FROM "Vorlage" AS v
|
||||
WHERE p."entitaet" = 'Vorlage' AND p."entitaetId" = v."id" AND p."kundeId" IS NULL;
|
||||
|
||||
-- 5. Eine Version gehoert zum Kunden ihrer Vorlage.
|
||||
UPDATE "Protokoll" AS p SET "kundeId" = v."kundeId"
|
||||
FROM "VorlagenVersion" AS vv
|
||||
JOIN "Vorlage" AS v ON v."id" = vv."vorlageId"
|
||||
WHERE p."entitaet" = 'VorlagenVersion' AND p."entitaetId" = vv."id" AND p."kundeId" IS NULL;
|
||||
|
||||
-- Alles Uebrige bleibt leer: Einstellung, Benutzer, GeraetetypVorlage und
|
||||
-- kundenfreie Vorlagen sind wirklich global. Eintraege zu **geloeschten** Zeilen
|
||||
-- bleiben ebenfalls leer und sind damit nicht zuordenbar — die Abfrage zeigt sie
|
||||
-- einem auf Kunden beschraenkten Benutzer deshalb **nicht** (fail closed, siehe
|
||||
-- src/server/queries/protokoll.ts).
|
||||
@@ -0,0 +1,160 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
|
||||
-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
|
||||
-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
|
||||
--
|
||||
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Wiederverwendbare Bausteine (#030).
|
||||
|
||||
CREATE TABLE "Baustein" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"beschreibung" TEXT,
|
||||
"archiviert" BOOLEAN NOT NULL DEFAULT false,
|
||||
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Baustein_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Baustein_name_key" ON "Baustein"("name");
|
||||
CREATE INDEX "Baustein_archiviert_idx" ON "Baustein"("archiviert");
|
||||
|
||||
CREATE TABLE "BausteinVersion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bausteinId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"status" "VersionStatus" NOT NULL DEFAULT 'ENTWURF',
|
||||
"titel" TEXT NOT NULL,
|
||||
"aenderungsnotiz" TEXT,
|
||||
"veroeffentlichtAm" TIMESTAMP(3),
|
||||
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "BausteinVersion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "BausteinVersion_bausteinId_version_key"
|
||||
ON "BausteinVersion"("bausteinId", "version");
|
||||
CREATE INDEX "BausteinVersion_bausteinId_status_idx"
|
||||
ON "BausteinVersion"("bausteinId", "status");
|
||||
|
||||
CREATE TABLE "BausteinSchritt" (
|
||||
"id" TEXT NOT NULL,
|
||||
"versionId" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"titel" TEXT NOT NULL,
|
||||
"hilfetext" TEXT,
|
||||
"typ" "SchrittTyp" NOT NULL DEFAULT 'HAKEN',
|
||||
"pflicht" BOOLEAN NOT NULL DEFAULT false,
|
||||
"konfig" JSONB NOT NULL DEFAULT '{}',
|
||||
"dokuUrl" TEXT,
|
||||
CONSTRAINT "BausteinSchritt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "BausteinSchritt_versionId_position_idx"
|
||||
ON "BausteinSchritt"("versionId", "position");
|
||||
|
||||
ALTER TABLE "BausteinVersion"
|
||||
ADD CONSTRAINT "BausteinVersion_bausteinId_fkey"
|
||||
FOREIGN KEY ("bausteinId") REFERENCES "Baustein"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "BausteinSchritt"
|
||||
ADD CONSTRAINT "BausteinSchritt_versionId_fkey"
|
||||
FOREIGN KEY ("versionId") REFERENCES "BausteinVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Der Verweis am Abschnitt. RESTRICT, nicht CASCADE: eine Baustein-Version, auf
|
||||
-- die eine Vorlage zeigt, darf nicht unter ihr wegbrechen.
|
||||
ALTER TABLE "VorlagenAbschnitt"
|
||||
ADD COLUMN "bausteinVersionId" TEXT;
|
||||
|
||||
CREATE INDEX "VorlagenAbschnitt_bausteinVersionId_idx"
|
||||
ON "VorlagenAbschnitt"("bausteinVersionId");
|
||||
|
||||
ALTER TABLE "VorlagenAbschnitt"
|
||||
ADD CONSTRAINT "VorlagenAbschnitt_bausteinVersionId_fkey"
|
||||
FOREIGN KEY ("bausteinVersionId") REFERENCES "BausteinVersion"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Dieselben Garantien wie bei Vorlagen, und zwar in der Datenbank.
|
||||
--
|
||||
-- Ein Baustein ist eine Quelle fuer Schritte, die in Laeufe wandern. Stuende
|
||||
-- seine Unveraenderlichkeit nur im Dienst, liesse sich die Nachvollziehbarkeit
|
||||
-- des Archivs ueber eine Baustein-Aenderung aushebeln.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Je Baustein hoechstens ein Entwurf.
|
||||
CREATE UNIQUE INDEX "BausteinVersion_ein_entwurf"
|
||||
ON "BausteinVersion" ("bausteinId")
|
||||
WHERE "status" = 'ENTWURF';
|
||||
|
||||
-- Eine veroeffentlichte Baustein-Version ist unveraenderlich.
|
||||
CREATE OR REPLACE FUNCTION laufzettel_baustein_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 "BausteinVersion" WHERE id = betroffene_version;
|
||||
|
||||
IF aktueller_status = 'VEROEFFENTLICHT' THEN
|
||||
RAISE EXCEPTION
|
||||
'Baustein-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 "BausteinSchritt_unveraenderlich"
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "BausteinSchritt"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_baustein_unveraenderlich();
|
||||
|
||||
-- Der Weg zurueck waere die Hintertuer, durch die sich das Obige aushebeln
|
||||
-- liesse. Dieselbe Regel wie bei VorlagenVersion.
|
||||
CREATE OR REPLACE FUNCTION laufzettel_baustein_status_einbahn() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF OLD.status = 'VEROEFFENTLICHT' AND NEW.status <> 'VEROEFFENTLICHT' THEN
|
||||
RAISE EXCEPTION
|
||||
'Eine veroeffentlichte Baustein-Version laesst sich nicht wieder zum Entwurf machen.'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "BausteinVersion_status_einbahn"
|
||||
BEFORE UPDATE ON "BausteinVersion"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_baustein_status_einbahn();
|
||||
|
||||
-- Ein Verweis zeigt nur auf eine **veroeffentlichte** Baustein-Version. Einen
|
||||
-- Entwurf einzusetzen hiesse, beim Veroeffentlichen etwas zu kopieren, das sich
|
||||
-- danach noch aendert.
|
||||
CREATE OR REPLACE FUNCTION laufzettel_baustein_verweis_veroeffentlicht() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
verwiesener_status TEXT;
|
||||
BEGIN
|
||||
IF NEW."bausteinVersionId" IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
SELECT status::TEXT INTO verwiesener_status
|
||||
FROM "BausteinVersion" WHERE id = NEW."bausteinVersionId";
|
||||
|
||||
IF verwiesener_status <> 'VEROEFFENTLICHT' THEN
|
||||
RAISE EXCEPTION
|
||||
'Ein Abschnitt kann nur auf eine veroeffentlichte Baustein-Version verweisen.'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "VorlagenAbschnitt_baustein_veroeffentlicht"
|
||||
BEFORE INSERT OR UPDATE ON "VorlagenAbschnitt"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_baustein_verweis_veroeffentlicht();
|
||||
@@ -0,0 +1,30 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
|
||||
-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
|
||||
-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
|
||||
--
|
||||
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Abteilungsspezifische Zusatzabschnitte (#031).
|
||||
--
|
||||
-- Beim Onboarding in der Buchhaltung gehoeren Schritte dazu, die sonst niemand
|
||||
-- braucht. Bisher hiess die Wahl: eine Vorlage je Abteilung pflegen, oder
|
||||
-- Schritte, die fuer die meisten "nicht zutreffend" sind.
|
||||
--
|
||||
-- Namen und nicht Kennungen: der Abschnitt gehoert zur Version und muss ein
|
||||
-- Umbenennen oder Archivieren einer Abteilung ueberleben.
|
||||
ALTER TABLE "VorlagenAbschnitt"
|
||||
ADD COLUMN "nurFuerAbteilungen" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- Welche Abschnitte fuer einen Lauf gelten, beim Start festgeschrieben.
|
||||
--
|
||||
-- Leer heisst **alle**. Bestehende Laeufe bekommen damit genau ihr bisheriges
|
||||
-- Verhalten, ohne Nachfuehrung: sie zeigen weiter jeden Abschnitt ihrer Version.
|
||||
-- Festgeschrieben und nicht berechnet, weil ein archivierter Lauf genau die
|
||||
-- Schritte zeigen muss, die damals abgearbeitet wurden.
|
||||
ALTER TABLE "Lauf"
|
||||
ADD COLUMN "geltendeAbschnitte" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,81 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
|
||||
-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
|
||||
-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
|
||||
--
|
||||
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Anhaenge und Fotos an Schritten (#032).
|
||||
--
|
||||
-- Die Datei liegt im Dateisystem unter ANHAENGE_PFAD, hier stehen die
|
||||
-- Metadaten. Gegen bytea spricht die Sicherung: ein pg_dump mit Fotos ist nach
|
||||
-- einem Jahr keines, das jemand einspielt.
|
||||
|
||||
CREATE TABLE "Anhang" (
|
||||
"id" TEXT NOT NULL,
|
||||
"schrittergebnisId" TEXT NOT NULL,
|
||||
"laufId" TEXT NOT NULL,
|
||||
"kundeId" TEXT NOT NULL,
|
||||
"dateiname" TEXT NOT NULL,
|
||||
"medientyp" TEXT NOT NULL,
|
||||
"groesse" INTEGER NOT NULL,
|
||||
"pruefsumme" TEXT NOT NULL,
|
||||
"erstelltVonId" TEXT,
|
||||
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Anhang_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "Anhang_schrittergebnisId_idx" ON "Anhang"("schrittergebnisId");
|
||||
CREATE INDEX "Anhang_laufId_idx" ON "Anhang"("laufId");
|
||||
CREATE INDEX "Anhang_kundeId_idx" ON "Anhang"("kundeId");
|
||||
|
||||
-- Schluesselkandidaten fuer die Mandantengrenze unten. Lauf hatte bisher keinen –
|
||||
-- die vier bestehenden zusammengesetzten Fremdschluessel zeigen auf Geraet,
|
||||
-- Mitarbeiter und Abteilung, nicht auf Lauf.
|
||||
CREATE UNIQUE INDEX "Schrittergebnis_id_laufId_key" ON "Schrittergebnis"("id", "laufId");
|
||||
CREATE UNIQUE INDEX "Lauf_id_kundeId_key" ON "Lauf"("id", "kundeId");
|
||||
|
||||
ALTER TABLE "Anhang"
|
||||
ADD CONSTRAINT "Anhang_schrittergebnisId_fkey"
|
||||
FOREIGN KEY ("schrittergebnisId") REFERENCES "Schrittergebnis"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Anhang"
|
||||
ADD CONSTRAINT "Anhang_laufId_fkey"
|
||||
FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Anhang"
|
||||
ADD CONSTRAINT "Anhang_kundeId_fkey"
|
||||
FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Anhang"
|
||||
ADD CONSTRAINT "Anhang_erstelltVonId_fkey"
|
||||
FOREIGN KEY ("erstelltVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- ---- Mandantengrenze und Zusammenhalt --------------------------------------
|
||||
--
|
||||
-- Dieselbe Bauart wie bei Lauf und Geraet: ein Anhang kann strukturell nicht zu
|
||||
-- einem Schrittergebnis eines anderen Laufs und nicht zu einem anderen Kunden
|
||||
-- als sein Lauf gehoeren.
|
||||
ALTER TABLE "Anhang"
|
||||
ADD CONSTRAINT "Anhang_ergebnis_gleicher_lauf"
|
||||
FOREIGN KEY ("schrittergebnisId", "laufId") REFERENCES "Schrittergebnis" ("id", "laufId")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Anhang"
|
||||
ADD CONSTRAINT "Anhang_lauf_gleicher_kunde"
|
||||
FOREIGN KEY ("laufId", "kundeId") REFERENCES "Lauf" ("id", "kundeId")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Eine Groesse von 0 waere eine leere Datei, und eine negative gibt es nicht.
|
||||
ALTER TABLE "Anhang"
|
||||
ADD CONSTRAINT "Anhang_groesse_positiv" CHECK ("groesse" > 0);
|
||||
|
||||
-- SHA-256 als Hex: 64 Zeichen, nur Ziffern und a-f. Eine Pruefsumme, die keine
|
||||
-- ist, wuerde nach einer Ruecksicherung nichts beantworten.
|
||||
ALTER TABLE "Anhang"
|
||||
ADD CONSTRAINT "Anhang_pruefsumme_form" CHECK ("pruefsumme" ~ '^[0-9a-f]{64}$');
|
||||
@@ -0,0 +1,84 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||
-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt
|
||||
-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese
|
||||
-- Loeschbefehle sind bewusst gestrichen.
|
||||
--
|
||||
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Live-Aktualisierung per SSE statt Polling (#033).
|
||||
--
|
||||
-- Die Datenbank sagt selbst, wenn sich an einem Lauf etwas geaendert hat:
|
||||
-- `NOTIFY lauf_geaendert, '<laufId>'`. Der Route Handler haelt eine
|
||||
-- LISTEN-Verbindung und schiebt weiter.
|
||||
--
|
||||
-- **Warum in der Datenbank und nicht im Dienst.** Ein Ereignis, das der Service
|
||||
-- verschickt, entsteht nur auf dem Weg durch den Service. Die Regeln dieses
|
||||
-- Projekts stehen aber absichtlich auch in der Datenbank, weil jemand an der
|
||||
-- Service-Schicht vorbei schreiben kann — der Seed, eine Migration, eine
|
||||
-- Reparatur per psql. Ein Trigger sieht all das.
|
||||
--
|
||||
-- **Warum es mit mehreren Instanzen funktioniert.** NOTIFY geht an alle
|
||||
-- Verbindungen, die LISTEN auf dem Kanal haben, also an jede Instanz der
|
||||
-- Anwendung. Ein zweiter Dienst (Redis, ein Nachrichtenbus) waere dafuer nicht
|
||||
-- noetig.
|
||||
--
|
||||
-- **Zugestellt wird erst beim COMMIT.** Das ist hier genau richtig: ein
|
||||
-- Abonnent, der eine noch nicht festgeschriebene Aenderung meldet, wuerde die
|
||||
-- Seite mit Daten neu bauen lassen, die es gleich wieder nicht gibt.
|
||||
|
||||
CREATE OR REPLACE FUNCTION laufzettel_lauf_geaendert() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
betroffener TEXT;
|
||||
BEGIN
|
||||
/*
|
||||
* Bei DELETE gibt es kein NEW. Der Kanalname ist fest, die Nutzlast nicht –
|
||||
* deshalb pg_notify() und nicht NOTIFY, das keine Variablen nimmt.
|
||||
*/
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
betroffener := OLD."laufId";
|
||||
ELSE
|
||||
betroffener := NEW."laufId";
|
||||
END IF;
|
||||
|
||||
IF betroffener IS NOT NULL THEN
|
||||
PERFORM pg_notify('lauf_geaendert', betroffener);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Derselbe Rumpf fuer die Laufzeile selbst: dort heisst der Schluessel "id".
|
||||
CREATE OR REPLACE FUNCTION laufzettel_lauf_geaendert_selbst() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('lauf_geaendert', NEW."id");
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
/*
|
||||
* **Welche Tabellen melden, haengt daran, was `laufZustand` liefert** — nicht
|
||||
* daran, was sich "irgendwie" aendert. Das Lesemodell traegt Status,
|
||||
* Schrittzustaende, die Zahl der Kommentare und (seit #033) die Zahl der
|
||||
* Anhaenge. Wuerde eine davon nicht melden, waere der Strom gegenueber dem
|
||||
* Polling, das alle zehn Sekunden vollstaendig vergleicht, ein **Rueckschritt**:
|
||||
* ein neuer Kommentar kaeme nie an.
|
||||
*/
|
||||
CREATE TRIGGER "Lauf_geaendert_melden"
|
||||
AFTER UPDATE ON "Lauf"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert_selbst();
|
||||
|
||||
CREATE TRIGGER "Schrittergebnis_geaendert_melden"
|
||||
AFTER INSERT OR UPDATE OR DELETE ON "Schrittergebnis"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert();
|
||||
|
||||
CREATE TRIGGER "Kommentar_geaendert_melden"
|
||||
AFTER INSERT OR UPDATE OR DELETE ON "Kommentar"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert();
|
||||
|
||||
CREATE TRIGGER "Anhang_geaendert_melden"
|
||||
AFTER INSERT OR DELETE ON "Anhang"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert();
|
||||
@@ -0,0 +1,143 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||
-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt
|
||||
-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese
|
||||
-- Loeschbefehle sind bewusst gestrichen.
|
||||
--
|
||||
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Benachrichtigungen per Mail und Webhook (#034).
|
||||
|
||||
CREATE TYPE "Zustellweg" AS ENUM ('MAIL', 'WEBHOOK');
|
||||
CREATE TYPE "Zustellzustand" AS ENUM ('OFFEN', 'GESENDET', 'FEHLGESCHLAGEN', 'AUFGEGEBEN');
|
||||
CREATE TYPE "Meldeanlass" AS ENUM (
|
||||
'LAUF_ZUGEWIESEN',
|
||||
'LAUF_UEBERFAELLIG',
|
||||
'LAUF_ABGESCHLOSSEN',
|
||||
'ABGLEICH_FEHLGESCHLAGEN'
|
||||
);
|
||||
|
||||
-- Je Empfaenger abschaltbar.
|
||||
ALTER TABLE "Benutzer" ADD COLUMN "meldungenAktiv" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- SMTP und Webhook gehoeren zur einen Einstellungszeile.
|
||||
ALTER TABLE "Einstellung"
|
||||
ADD COLUMN "smtpHost" TEXT,
|
||||
ADD COLUMN "smtpPort" INTEGER,
|
||||
ADD COLUMN "smtpTls" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "smtpBenutzer" TEXT,
|
||||
ADD COLUMN "smtpPasswortVerschluesselt" TEXT,
|
||||
ADD COLUMN "smtpAbsender" TEXT,
|
||||
ADD COLUMN "webhookUrl" TEXT,
|
||||
ADD COLUMN "webhookGeheimnisVerschluesselt" TEXT;
|
||||
|
||||
CREATE TABLE "Zustellung" (
|
||||
"id" TEXT NOT NULL,
|
||||
"anlass" "Meldeanlass" NOT NULL,
|
||||
"weg" "Zustellweg" NOT NULL,
|
||||
"empfaenger" TEXT NOT NULL,
|
||||
"betreff" TEXT NOT NULL,
|
||||
"rumpf" TEXT NOT NULL,
|
||||
"zustand" "Zustellzustand" NOT NULL DEFAULT 'OFFEN',
|
||||
"versuche" INTEGER NOT NULL DEFAULT 0,
|
||||
"naechsterVersuchAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"letzterFehler" TEXT,
|
||||
"gesendetAm" TIMESTAMP(3),
|
||||
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"benutzerId" TEXT,
|
||||
"laufId" TEXT,
|
||||
"verzeichnisquelleId" TEXT,
|
||||
CONSTRAINT "Zustellung_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Der Index, auf dem der Zeitplan arbeitet: er fragt jede Minute nach offenen
|
||||
-- und faelligen Zeilen. Ohne ihn waere das ein vollstaendiger Durchlauf der
|
||||
-- Tabelle, die mit der Zeit nur waechst.
|
||||
CREATE INDEX "Zustellung_zustand_naechsterVersuchAm_idx"
|
||||
ON "Zustellung"("zustand", "naechsterVersuchAm");
|
||||
CREATE INDEX "Zustellung_erstelltAm_idx" ON "Zustellung"("erstelltAm");
|
||||
CREATE INDEX "Zustellung_laufId_idx" ON "Zustellung"("laufId");
|
||||
CREATE INDEX "Zustellung_benutzerId_idx" ON "Zustellung"("benutzerId");
|
||||
|
||||
ALTER TABLE "Zustellung"
|
||||
ADD CONSTRAINT "Zustellung_benutzerId_fkey" FOREIGN KEY ("benutzerId")
|
||||
REFERENCES "Benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT "Zustellung_laufId_fkey" FOREIGN KEY ("laufId")
|
||||
REFERENCES "Lauf"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT "Zustellung_verzeichnisquelleId_fkey" FOREIGN KEY ("verzeichnisquelleId")
|
||||
REFERENCES "Verzeichnisquelle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Regeln, die auch dann gelten, wenn jemand an der Service-Schicht vorbei
|
||||
-- schreibt.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Ein Empfaenger ohne Inhalt ist eine Zeile, die nie ankommen kann.
|
||||
ALTER TABLE "Zustellung"
|
||||
ADD CONSTRAINT "Zustellung_empfaenger_nicht_leer"
|
||||
CHECK (length(btrim("empfaenger")) > 0);
|
||||
|
||||
ALTER TABLE "Zustellung"
|
||||
ADD CONSTRAINT "Zustellung_versuche_nicht_negativ" CHECK ("versuche" >= 0);
|
||||
|
||||
-- „Gesendet“ und „wann“ gehoeren zusammen, in beide Richtungen. Dieselbe Form
|
||||
-- wie bei `Lauf_abgeschlossen_mit_zeitpunkt`: ein Zustand ohne Zeitpunkt macht
|
||||
-- jede spaetere Auswertung zur Schaetzung.
|
||||
ALTER TABLE "Zustellung"
|
||||
ADD CONSTRAINT "Zustellung_gesendet_mit_zeitpunkt"
|
||||
CHECK (("zustand" = 'GESENDET') = ("gesendetAm" IS NOT NULL));
|
||||
|
||||
-- Eine Mail geht an eine Person, ein Webhook nicht. Ohne das koennte eine
|
||||
-- Webhook-Zeile an einem Benutzer haengen und in dessen Mailverlauf auftauchen.
|
||||
ALTER TABLE "Zustellung"
|
||||
ADD CONSTRAINT "Zustellung_webhook_ohne_benutzer"
|
||||
CHECK ("weg" <> 'WEBHOOK' OR "benutzerId" IS NULL);
|
||||
|
||||
-- Jeder Anlass haengt an dem, worum es geht: die drei Lauf-Anlaesse an einem
|
||||
-- Lauf, der Abgleich an einer Quelle. Eine Meldung ohne Bezug waere ein Satz
|
||||
-- ohne Gegenstand — und der Link in der Mail zeigte ins Leere.
|
||||
ALTER TABLE "Zustellung"
|
||||
ADD CONSTRAINT "Zustellung_bezug_passt_zum_anlass"
|
||||
CHECK (
|
||||
("anlass" = 'ABGLEICH_FEHLGESCHLAGEN' AND "verzeichnisquelleId" IS NOT NULL AND "laufId" IS NULL)
|
||||
OR ("anlass" <> 'ABGLEICH_FEHLGESCHLAGEN' AND "laufId" IS NOT NULL AND "verzeichnisquelleId" IS NULL)
|
||||
);
|
||||
|
||||
-- Verschluesselt, nicht im Klartext – dieselbe Pruefung wie beim
|
||||
-- LDAP-Bind-Passwort. `v<n>.` ist das Praefix aus `src/lib/geheimnisse.ts`.
|
||||
ALTER TABLE "Einstellung"
|
||||
ADD CONSTRAINT "Einstellung_smtp_passwort_verschluesselt"
|
||||
CHECK ("smtpPasswortVerschluesselt" ~ '^v[0-9]+\.');
|
||||
|
||||
ALTER TABLE "Einstellung"
|
||||
ADD CONSTRAINT "Einstellung_webhook_geheimnis_verschluesselt"
|
||||
CHECK ("webhookGeheimnisVerschluesselt" ~ '^v[0-9]+\.');
|
||||
|
||||
ALTER TABLE "Einstellung"
|
||||
ADD CONSTRAINT "Einstellung_smtp_port_bereich"
|
||||
CHECK ("smtpPort" IS NULL OR ("smtpPort" BETWEEN 1 AND 65535));
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Und die Regel, die der Zeitplan braucht.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
/*
|
||||
* **Ueberfaellig wird je Lauf und Empfaenger genau einmal gemeldet.**
|
||||
*
|
||||
* Der Zeitplan sieht jede Minute nach. Ohne diesen Index bekaeme der
|
||||
* Zustaendige fuer einen Lauf, der ueber das Wochenende ueberfaellig steht,
|
||||
* 2880 Mails — und zwar ohne dass irgendein Test rot waere, denn jede einzelne
|
||||
* davon ist korrekt. Der Dienst prueft dasselbe noch einmal, nur mit besserer
|
||||
* Meldung.
|
||||
*
|
||||
* Partiell, weil es nur fuer diesen einen Anlass gilt: „abgeschlossen“ darf nach
|
||||
* einem Zurueckholen und erneuten Abschliessen ein zweites Mal hinausgehen, und
|
||||
* „zugewiesen“ bei jeder neuen Zuweisung.
|
||||
*/
|
||||
CREATE UNIQUE INDEX "Zustellung_ueberfaellig_einmal"
|
||||
ON "Zustellung"("laufId", "empfaenger")
|
||||
WHERE "anlass" = 'LAUF_UEBERFAELLIG';
|
||||
@@ -105,10 +105,104 @@ model Einstellung {
|
||||
namensregeln Json @default("{}")
|
||||
/// Ziel-OU fuer deaktivierte Konten, Vorbelegung im Offboarding.
|
||||
gesperrteOu String?
|
||||
/// Sobald jemand den Schnellstart in der Oberflaeche gespeichert hat, laesst
|
||||
/// der Seed `GeraetetypVorlage` in Ruhe (#022). Ohne diese Marke kaeme eine
|
||||
/// entfernte Zuordnung beim naechsten Seed-Lauf zurueck: eine fehlende Zeile
|
||||
/// sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde.
|
||||
schnellstartGepflegt Boolean @default(false)
|
||||
|
||||
/// SMTP fuer die Benachrichtigungen (#034). Leerer Host heisst: keine Mail.
|
||||
/// Kein Dienstanbieter, sondern SMTP — eine selbst gehostete Anwendung soll an
|
||||
/// den Mailserver des Hauses koennen.
|
||||
smtpHost String?
|
||||
smtpPort Int?
|
||||
/// `true` = StartTLS oder implizites TLS (Port 465). Klartext nur, wenn jemand
|
||||
/// das ausdruecklich abschaltet.
|
||||
smtpTls Boolean @default(true)
|
||||
smtpBenutzer String?
|
||||
/// AES-256-GCM aus APP_SECRET, wie `Verzeichnisquelle.bindPasswortVerschluesselt`.
|
||||
/// Erreicht **nie** eine Antwort.
|
||||
smtpPasswortVerschluesselt String?
|
||||
smtpAbsender String?
|
||||
|
||||
/// Ein Webhook-Ziel fuer dieselben vier Anlaesse. Leere URL heisst: keiner.
|
||||
webhookUrl String?
|
||||
/// Geheimnis fuer die HMAC-SHA256-Signatur, verschluesselt wie oben.
|
||||
webhookGeheimnisVerschluesselt String?
|
||||
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @default(now()) @updatedAt
|
||||
}
|
||||
|
||||
/// Wie eine Meldung hinausgeht (#034).
|
||||
enum Zustellweg {
|
||||
MAIL
|
||||
WEBHOOK
|
||||
}
|
||||
|
||||
/// Wo eine Zustellung steht (#034).
|
||||
enum Zustellzustand {
|
||||
OFFEN
|
||||
GESENDET
|
||||
/// Fehlgeschlagen, wird aber noch einmal versucht.
|
||||
FEHLGESCHLAGEN
|
||||
/// Endgueltig aufgegeben. Bleibt sichtbar — eine stille Warteschlange ist
|
||||
/// schlimmer als keine.
|
||||
AUFGEGEBEN
|
||||
}
|
||||
|
||||
/// Was gemeldet wird. Bewusst genau vier Anlaesse (#034).
|
||||
enum Meldeanlass {
|
||||
LAUF_ZUGEWIESEN
|
||||
LAUF_UEBERFAELLIG
|
||||
LAUF_ABGESCHLOSSEN
|
||||
ABGLEICH_FEHLGESCHLAGEN
|
||||
}
|
||||
|
||||
/// Eine einzelne Meldung in der Warteschlange (#034).
|
||||
///
|
||||
/// **In der Datenbank und nicht im Anfrage-Pfad.** Ein haengender Mailserver
|
||||
/// darf kein Abhaken blockieren: der Dienst reiht ein, der Zeitplan schickt.
|
||||
///
|
||||
/// Betreff und Rumpf stehen fertig in der Zeile. Das kostet Platz und ist es
|
||||
/// wert: eine Meldung sagt, was **damals** war. Baute der Zeitplan den Text erst
|
||||
/// beim Senden, stuende in der Mail der Stand von jetzt — und in einer
|
||||
/// aufgegebenen Zustellung von naechster Woche etwas voellig anderes.
|
||||
model Zustellung {
|
||||
id String @id @default(cuid())
|
||||
anlass Meldeanlass
|
||||
weg Zustellweg
|
||||
/// Mailadresse oder Webhook-URL. Als Text, damit die Zeile auch dann noch
|
||||
/// sagt, wohin sie ging, wenn das Konto spaeter deaktiviert wurde.
|
||||
empfaenger String
|
||||
betreff String
|
||||
rumpf String
|
||||
zustand Zustellzustand @default(OFFEN)
|
||||
versuche Int @default(0)
|
||||
/// Vor diesem Zeitpunkt holt der Zeitplan die Zeile nicht ab. Traegt das
|
||||
/// wachsende Warten zwischen zwei Versuchen.
|
||||
naechsterVersuchAm DateTime @default(now())
|
||||
letzterFehler String?
|
||||
gesendetAm DateTime?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @default(now()) @updatedAt
|
||||
|
||||
/// Wer gemeint ist. Leer bei einem Webhook — der gehoert keiner Person.
|
||||
benutzerId String?
|
||||
laufId String?
|
||||
verzeichnisquelleId String?
|
||||
|
||||
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
|
||||
lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Cascade)
|
||||
verzeichnisquelle Verzeichnisquelle? @relation(fields: [verzeichnisquelleId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Der Index, auf dem der Zeitplan arbeitet.
|
||||
@@index([zustand, naechsterVersuchAm])
|
||||
@@index([erstelltAm])
|
||||
@@index([laufId])
|
||||
@@index([benutzerId])
|
||||
}
|
||||
|
||||
model Kunde {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
@@ -129,7 +223,9 @@ model Kunde {
|
||||
benutzerbindungen BenutzerKunde[]
|
||||
geraete Geraet[]
|
||||
laeufe Lauf[]
|
||||
anhaenge Anhang[]
|
||||
mitarbeiter Mitarbeiter[]
|
||||
protokolle Protokoll[]
|
||||
verzeichnisquellen Verzeichnisquelle[]
|
||||
vorlagen Vorlage[]
|
||||
|
||||
@@ -154,6 +250,10 @@ model Benutzer {
|
||||
/// So laesst sich ein Konto sperren, ohne auf den Ablauf des Cookies zu warten.
|
||||
sitzungenUngueltigAb DateTime?
|
||||
letzteAnmeldungAm DateTime?
|
||||
/// Bekommt diese Person Benachrichtigungen (#034)? Je Empfaenger abschaltbar.
|
||||
/// Ein Konto ohne Mailadresse gibt es nicht — `email` ist Pflicht —, aber ein
|
||||
/// Dienstkonto will keine Post.
|
||||
meldungenAktiv Boolean @default(true)
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @default(now()) @updatedAt
|
||||
|
||||
@@ -166,6 +266,8 @@ model Benutzer {
|
||||
protokolle Protokoll[]
|
||||
schrittergebnisse Schrittergebnis[]
|
||||
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
|
||||
anhaenge Anhang[]
|
||||
zustellungen Zustellung[]
|
||||
|
||||
@@index([aktiv])
|
||||
@@index([rolle])
|
||||
@@ -223,6 +325,7 @@ model Verzeichnisquelle {
|
||||
|
||||
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
||||
mitarbeiter Mitarbeiter[]
|
||||
zustellungen Zustellung[]
|
||||
|
||||
@@index([kundeId])
|
||||
@@index([aktiv])
|
||||
@@ -249,7 +352,18 @@ model Mitarbeiter {
|
||||
/// passende Abteilung bleibt trotzdem erhalten.
|
||||
abteilung String?
|
||||
titel String?
|
||||
/// Der rohe `manager`-Wert aus dem Verzeichnis, aufgeloest auf die `externeId`
|
||||
/// des Vorgesetzten — je Quelle eindeutig. Wie `abteilung` ist das der Wert
|
||||
/// des Verzeichnisses, nicht die gepflegte Zuordnung; die steht in
|
||||
/// `vorgesetzterId`. Leer bei einer von Hand angelegten Person.
|
||||
vorgesetzterExterneId String?
|
||||
/// Die **gepflegte** Zuordnung, und der einzige Leseweg (#020). Der Abgleich
|
||||
/// schreibt sie aus `vorgesetzterExterneId` mit, von Hand gesetzt geht sie
|
||||
/// auch ohne Verzeichnis. Darf nicht auf die Person selbst zeigen (CHECK) und
|
||||
/// nicht auf jemanden eines anderen Kunden (zusammengesetzter Fremdschluessel
|
||||
/// `Mitarbeiter_vorgesetzter_gleicher_kunde`). Laengere Schleifen haelt der
|
||||
/// Dienst auf.
|
||||
vorgesetzterId String?
|
||||
aktiv Boolean @default(true)
|
||||
herkunft Herkunft @default(MANUELL)
|
||||
zuletztGesehenAm DateTime?
|
||||
@@ -258,6 +372,8 @@ model Mitarbeiter {
|
||||
|
||||
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
||||
quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict)
|
||||
vorgesetzter Mitarbeiter? @relation("Vorgesetzter", fields: [vorgesetzterId], references: [id], onDelete: Restrict)
|
||||
untergebene Mitarbeiter[] @relation("Vorgesetzter")
|
||||
geraete Geraet[]
|
||||
laeufe Lauf[]
|
||||
|
||||
@@ -270,6 +386,7 @@ model Mitarbeiter {
|
||||
@@index([abteilung])
|
||||
@@index([upn])
|
||||
@@index([sam])
|
||||
@@index([vorgesetzterId])
|
||||
}
|
||||
|
||||
/// Vom Admin gepflegt, Vorschlaege entstehen aus den department-Werten des
|
||||
@@ -377,6 +494,13 @@ model Vorlage {
|
||||
/// "gilt fuer alle".
|
||||
kundeId String?
|
||||
archiviert Boolean @default(false)
|
||||
/// Kennung einer **mitgelieferten** Vorlage. Der Seed setzt sie und erkennt
|
||||
/// seine sieben Vorlagen daran statt am Namen, der sich aendern darf (#021).
|
||||
/// Eindeutig, wo gesetzt — erzwungen durch den partiellen eindeutigen Index
|
||||
/// `Vorlage_seedSchluessel_einmalig`, den Prisma nicht ausdruecken kann.
|
||||
/// Der Import setzt sie absichtlich **nicht**: eine eingespielte Kopie soll
|
||||
/// nicht den Platz des Originals einnehmen.
|
||||
seedSchluessel String?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @default(now()) @updatedAt
|
||||
|
||||
@@ -441,10 +565,86 @@ model VorlagenAbschnitt {
|
||||
versionId String
|
||||
titel String
|
||||
position Int @default(0)
|
||||
/// Nur fuer diese Abteilungen (#031). Leer heisst: fuer alle.
|
||||
///
|
||||
/// **Namen und nicht Kennungen.** Der Abschnitt gehoert zur Version und muss
|
||||
/// ein Umbenennen oder Archivieren einer Abteilung ueberleben, ohne ins Leere
|
||||
/// zu zeigen — dieselbe Entscheidung wie bei `Mitarbeiter.abteilung`.
|
||||
nurFuerAbteilungen String[] @default([])
|
||||
/// Verweis auf einen wiederverwendbaren Baustein (#030).
|
||||
///
|
||||
/// In einem **Entwurf** heisst das: beim Veroeffentlichen hier die Schritte des
|
||||
/// Bausteins einsetzen. In einer **veroeffentlichten** Version heisst dasselbe
|
||||
/// Feld: diese Schritte stammen von dort — es ist dann nur noch Herkunft.
|
||||
/// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites
|
||||
/// Feld dafuer waere eine zweite Wahrheit.
|
||||
bausteinVersionId String?
|
||||
|
||||
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
|
||||
bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict)
|
||||
schritte VorlagenSchritt[]
|
||||
|
||||
@@index([versionId, position])
|
||||
@@index([bausteinVersionId])
|
||||
}
|
||||
|
||||
// ---- Bausteine -------------------------------------------------------------
|
||||
|
||||
/// Ein Abschnitt, den mehrere Vorlagen teilen (#030).
|
||||
///
|
||||
/// Versioniert nach denselben Regeln wie eine Vorlage: hoechstens ein Entwurf,
|
||||
/// eine veroeffentlichte Version wird nie veraendert. Beim Veroeffentlichen
|
||||
/// einer Vorlage wird der Baustein **tief kopiert** — ein Lauf sieht damit
|
||||
/// weiterhin konkrete Schritte, und Schrittergebnis, Druckansicht und Archiv
|
||||
/// muessen nichts lernen.
|
||||
model Baustein {
|
||||
id String @id @default(cuid())
|
||||
/// Eindeutig: der Name ist, wonach ein Redakteur ihn sucht.
|
||||
name String @unique
|
||||
beschreibung String?
|
||||
archiviert Boolean @default(false)
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @default(now()) @updatedAt
|
||||
|
||||
versionen BausteinVersion[]
|
||||
|
||||
@@index([archiviert])
|
||||
}
|
||||
|
||||
model BausteinVersion {
|
||||
id String @id @default(cuid())
|
||||
bausteinId String
|
||||
version Int
|
||||
status VersionStatus @default(ENTWURF)
|
||||
/// Titel des Abschnitts, der daraus entsteht. Steht an der Version und nicht
|
||||
/// am Baustein, damit er sich aendern laesst, ohne alte Vorlagen umzuschreiben.
|
||||
titel String
|
||||
aenderungsnotiz String?
|
||||
veroeffentlichtAm DateTime?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @default(now()) @updatedAt
|
||||
|
||||
baustein Baustein @relation(fields: [bausteinId], references: [id], onDelete: Cascade)
|
||||
schritte BausteinSchritt[]
|
||||
verwendungen VorlagenAbschnitt[]
|
||||
|
||||
@@unique([bausteinId, version])
|
||||
@@index([bausteinId, status])
|
||||
}
|
||||
|
||||
model BausteinSchritt {
|
||||
id String @id @default(cuid())
|
||||
versionId String
|
||||
position Int @default(0)
|
||||
titel String
|
||||
hilfetext String?
|
||||
typ SchrittTyp @default(HAKEN)
|
||||
pflicht Boolean @default(false)
|
||||
konfig Json @default("{}")
|
||||
dokuUrl String?
|
||||
|
||||
version BausteinVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([versionId, position])
|
||||
}
|
||||
|
||||
@@ -494,6 +694,14 @@ model Lauf {
|
||||
ticket String?
|
||||
/// Werte der VorlagenVariablen, beim Start erfasst.
|
||||
variablen Json @default("{}")
|
||||
/// Welche Abschnitte fuer diesen Lauf gelten, beim Start festgeschrieben (#031).
|
||||
///
|
||||
/// **Leer heisst: alle.** So bleiben Laeufe von vor dieser Fassung unberuehrt.
|
||||
/// Festgeschrieben und nicht bei jedem Aufruf aus `abteilungId` berechnet: ein
|
||||
/// archivierter Lauf muss genau die Schritte zeigen, die damals abgearbeitet
|
||||
/// wurden, und ein spaeterer Abteilungswechsel darf keinen Schritt entfernen,
|
||||
/// an dem schon eine Notiz haengt.
|
||||
geltendeAbschnitte String[] @default([])
|
||||
status LaufStatus @default(OFFEN)
|
||||
zugewiesenAnId String?
|
||||
faelligAm DateTime?
|
||||
@@ -524,7 +732,12 @@ model Lauf {
|
||||
ergebnisse Schrittergebnis[]
|
||||
kommentare Kommentar[]
|
||||
protokolle Protokoll[]
|
||||
anhaenge Anhang[]
|
||||
|
||||
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
|
||||
zustellungen Zustellung[]
|
||||
|
||||
@@unique([id, kundeId])
|
||||
@@index([kundeId, status])
|
||||
@@index([geraetId, status])
|
||||
@@index([mitarbeiterId])
|
||||
@@ -554,11 +767,52 @@ model Schrittergebnis {
|
||||
/// Codepfad darf annehmen, dass immer ein Mensch dahintersteht.
|
||||
aktualisiertVon Benutzer? @relation(fields: [aktualisiertVonId], references: [id], onDelete: Restrict)
|
||||
kommentare Kommentar[]
|
||||
anhaenge Anhang[]
|
||||
|
||||
@@unique([laufId, vorlagenSchrittId])
|
||||
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
|
||||
@@unique([id, laufId])
|
||||
@@index([laufId, zustand])
|
||||
}
|
||||
|
||||
/// Eine Datei oder ein Foto an einem Schritt (#032).
|
||||
///
|
||||
/// Die Datei selbst liegt im Dateisystem unter `ANHAENGE_PFAD`; hier stehen nur
|
||||
/// die Metadaten. Gegen `bytea` spricht die Sicherung: ein `pg_dump` mit Fotos
|
||||
/// ist nach einem Jahr kein `pg_dump` mehr, das jemand einspielt. **Folge:** die
|
||||
/// Sicherung muss Datenbank **und** Verzeichnis mitnehmen, siehe
|
||||
/// `docs/betrieb/sicherung.md`.
|
||||
model Anhang {
|
||||
id String @id @default(cuid())
|
||||
schrittergebnisId String
|
||||
laufId String
|
||||
/// Jede kundenbezogene Zeile traegt kundeId (CLAUDE.md). Die Mandantengrenze
|
||||
/// steht zusaetzlich als zusammengesetzter Fremdschluessel in der Migration.
|
||||
kundeId String
|
||||
/// Der Name, den der Browser mitgeschickt hat – nur zur Anzeige. Der Pfad auf
|
||||
/// der Platte entsteht aus `id`, damit kein Dateiname von aussen einen Pfad
|
||||
/// bestimmt.
|
||||
dateiname String
|
||||
medientyp String
|
||||
groesse Int
|
||||
/// SHA-256 der Datei. Beantwortet nach einer Ruecksicherung die Frage, ob die
|
||||
/// Datei noch die ist, die hier beschrieben steht.
|
||||
pruefsumme String
|
||||
erstelltVonId String?
|
||||
erstelltAm DateTime @default(now())
|
||||
|
||||
schrittergebnis Schrittergebnis @relation(fields: [schrittergebnisId], references: [id], onDelete: Cascade)
|
||||
lauf Lauf @relation(fields: [laufId], references: [id], onDelete: Restrict)
|
||||
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
||||
/// Leer bei einem Systemlauf – kein Codepfad nimmt an, dass ein Mensch
|
||||
/// dahintersteht.
|
||||
erstelltVon Benutzer? @relation(fields: [erstelltVonId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([schrittergebnisId])
|
||||
@@index([laufId])
|
||||
@@index([kundeId])
|
||||
}
|
||||
|
||||
model Kommentar {
|
||||
id String @id @default(cuid())
|
||||
laufId String
|
||||
@@ -582,6 +836,12 @@ model Kommentar {
|
||||
model Protokoll {
|
||||
id String @id @default(cuid())
|
||||
laufId String?
|
||||
/// Der Kunde, um den es geht — **leer heisst global** (Einstellungen,
|
||||
/// Benutzer, kundenfreie Vorlagen, Schnellstart). CLAUDE.md verlangt die
|
||||
/// Spalte fuer jede kundenbezogene Zeile; ohne sie liess sich das Protokoll
|
||||
/// nicht nach Mandant filtern, ohne je Eintrag die Entitaet nachzuschlagen
|
||||
/// (#023).
|
||||
kundeId String?
|
||||
entitaet String
|
||||
entitaetId String
|
||||
aktion String
|
||||
@@ -591,11 +851,13 @@ model Protokoll {
|
||||
erstelltAm DateTime @default(now())
|
||||
|
||||
lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Restrict)
|
||||
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
||||
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([laufId, erstelltAm])
|
||||
@@index([entitaet, entitaetId])
|
||||
@@index([erstelltAm])
|
||||
@@index([kundeId, erstelltAm])
|
||||
}
|
||||
|
||||
/// Sperre fuer Aufgaben, die nur einmal gleichzeitig laufen duerfen – zuerst der
|
||||
|
||||
@@ -57,6 +57,8 @@ export type PersonenVorgabe = {
|
||||
readonly upn: string;
|
||||
readonly abteilung: string;
|
||||
readonly titel: string;
|
||||
/** UPN des Vorgesetzten – aufgeloest, nachdem alle Personen stehen. */
|
||||
readonly vorgesetzterUpn?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -64,10 +66,11 @@ export type PersonenVorgabe = {
|
||||
* Haus ohne Verzeichnis entstehen.
|
||||
*
|
||||
* Maria Huber steht so im Auftrag und taucht in den Abnahmekriterien wieder auf.
|
||||
* Ihre Vorgesetzte Petra Klein ist deshalb ebenfalls dabei; **gespeichert wird
|
||||
* die Zuordnung nicht**: `vorgesetzterExterneId` zeigt auf die `externeId`
|
||||
* derselben Verzeichnisquelle, und eine von Hand angelegte Person hat beides
|
||||
* nicht. Im Onboarding-Lauf wird der Vorgesetzte ohnehin beim Start gewaehlt.
|
||||
* Ihre Vorgesetzte Petra Klein ist deshalb ebenfalls dabei — **und die Zuordnung
|
||||
* wird gespeichert**, seit `vorgesetzterId` es erlaubt (#020). Vorher ging das
|
||||
* nicht: die Auskunft lief allein ueber `vorgesetzterExterneId`, und die zeigt
|
||||
* auf die `externeId` derselben Verzeichnisquelle, die eine von Hand angelegte
|
||||
* Person nicht hat.
|
||||
*/
|
||||
export const PERSONEN: readonly PersonenVorgabe[] = [
|
||||
{
|
||||
@@ -77,6 +80,7 @@ export const PERSONEN: readonly PersonenVorgabe[] = [
|
||||
upn: "m.huber@firma.example",
|
||||
abteilung: "Buchhaltung",
|
||||
titel: "Sachbearbeiterin Rechnungsprüfung",
|
||||
vorgesetzterUpn: "p.klein@firma.example",
|
||||
},
|
||||
{
|
||||
vorname: "Petra",
|
||||
@@ -230,6 +234,8 @@ export async function stammdatenSaeen(
|
||||
|
||||
let personen = 0;
|
||||
const nachUpn = new Map<string, string>();
|
||||
/** Nur diese bekommen eine Vorgesetzten-Zuordnung – siehe unten. */
|
||||
const neuAngelegt = new Set<string>();
|
||||
for (const vorgabe of PERSONEN) {
|
||||
const vorhanden = await db.mitarbeiter.findFirst({ where: { kundeId, upn: vorgabe.upn } });
|
||||
if (vorhanden) {
|
||||
@@ -252,9 +258,27 @@ export async function stammdatenSaeen(
|
||||
},
|
||||
});
|
||||
nachUpn.set(vorgabe.upn, angelegt.id);
|
||||
neuAngelegt.add(vorgabe.upn);
|
||||
personen += 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Die Vorgesetzten erst jetzt, wenn alle Personen stehen: eine Zuordnung
|
||||
* zeigt auf eine Zeile, die beim Anlegen der ersten noch nicht da war.
|
||||
*
|
||||
* Nur fuer Personen, die **dieser Lauf** angelegt hat. Eine leere Zuordnung
|
||||
* sagt nicht, ob sie noch nie gesetzt oder im Betrieb entfernt wurde — und ein
|
||||
* zweiter Seed-Lauf darf nichts zurueckdrehen. Dieselbe Regel wie fuer alles
|
||||
* andere hier.
|
||||
*/
|
||||
for (const vorgabe of PERSONEN) {
|
||||
if (!vorgabe.vorgesetzterUpn || !neuAngelegt.has(vorgabe.upn)) continue;
|
||||
const personId = nachUpn.get(vorgabe.upn);
|
||||
const vorgesetzterId = nachUpn.get(vorgabe.vorgesetzterUpn);
|
||||
if (!personId || !vorgesetzterId) continue;
|
||||
await db.mitarbeiter.update({ where: { id: personId }, data: { vorgesetzterId } });
|
||||
}
|
||||
|
||||
// Im Modus DIENSTLEISTUNG traegt der Hostname das Kundenkuerzel – dieselbe
|
||||
// Regel, die auch die Namensvorschlaege benutzen (`{kundenkuerzel}-PC-{nnn}`).
|
||||
const praefix = optionen.hostnamePraefix ? `${optionen.hostnamePraefix}-` : "";
|
||||
|
||||
@@ -68,11 +68,61 @@ export type GesaeteVorlage = {
|
||||
readonly neu: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sucht die mitgelieferte Vorlage – zuerst am Schluessel, dann am Namen.
|
||||
*
|
||||
* Der Schluessel ueberlebt ein Umbenennen, der Name nicht: genau daran legte der
|
||||
* Seed eine umbenannte Vorlage ein zweites Mal an, und der Schnellstart stand
|
||||
* auf der Geraeteseite doppelt (#021). Der Rueckfall auf den Namen bleibt, weil
|
||||
* eine Installation von vor diesem Feld noch keinen Schluessel hat — ohne ihn
|
||||
* bekaeme sie beim ersten Lauf danach genau die Dubletten, die das Feld
|
||||
* verhindern soll. Wird sie so gefunden, traegt der Seed den Schluessel nach.
|
||||
*
|
||||
* Auch eine **archivierte** Vorlage gilt als vorhanden: eine zweite gleichen
|
||||
* Namens danebenzustellen waere keine Hilfe, sondern eine Dublette in jeder
|
||||
* Liste. Wer die Beispielvorlage zurueck will, holt sie aus dem Archiv.
|
||||
*/
|
||||
async function mitgelieferteFinden(db: PrismaClient, dokument: Austausch) {
|
||||
const mitVersion = {
|
||||
versionen: {
|
||||
where: { status: VersionStatus.VEROEFFENTLICHT },
|
||||
orderBy: { version: "desc" as const },
|
||||
take: 1,
|
||||
},
|
||||
};
|
||||
|
||||
if (dokument.seedSchluessel !== undefined) {
|
||||
const amSchluessel = await db.vorlage.findFirst({
|
||||
where: { seedSchluessel: dokument.seedSchluessel },
|
||||
include: mitVersion,
|
||||
});
|
||||
if (amSchluessel) return amSchluessel;
|
||||
}
|
||||
|
||||
const amNamen = await db.vorlage.findFirst({
|
||||
// Nur eine Zeile **ohne** Schluessel darf hier zugeordnet werden. Sonst
|
||||
// koennte eine fremde Vorlage, die zufaellig so heisst wie die mitgelieferte,
|
||||
// deren Schluessel bekommen - und der naechste Lauf wuerde sie fuer das
|
||||
// Original halten.
|
||||
where: { name: dokument.name, kundeId: null, seedSchluessel: null },
|
||||
include: mitVersion,
|
||||
});
|
||||
if (amNamen && dokument.seedSchluessel !== undefined) {
|
||||
await db.vorlage.update({
|
||||
where: { id: amNamen.id },
|
||||
data: { seedSchluessel: dokument.seedSchluessel },
|
||||
});
|
||||
console.log(` Vorlage „${amNamen.name}“: Schlüssel ${dokument.seedSchluessel} nachgetragen`);
|
||||
}
|
||||
return amNamen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt die Vorlagen als veroeffentlichte Version 1 an.
|
||||
*
|
||||
* Idempotent ueber den **Namen**: gibt es die Vorlage schon, bleibt sie
|
||||
* unangetastet — auch dann, wenn jemand sie inzwischen bearbeitet hat. Ein
|
||||
* Idempotent ueber den **Schluessel** (Rueckfall: Name, siehe
|
||||
* `mitgelieferteFinden`): gibt es die Vorlage schon, bleibt sie unangetastet —
|
||||
* auch dann, wenn jemand sie inzwischen bearbeitet oder umbenannt hat. Ein
|
||||
* zweiter Seed-Lauf darf die Arbeit eines Redakteurs nicht zurueckdrehen, und
|
||||
* eine veroeffentlichte Version wird ohnehin nie veraendert.
|
||||
*/
|
||||
@@ -82,19 +132,7 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
|
||||
let uebersprungen = 0;
|
||||
|
||||
for (const { dokument } of vorlagenLesen()) {
|
||||
const vorhanden = await db.vorlage.findFirst({
|
||||
// Auch eine **archivierte** Vorlage gilt als vorhanden: eine zweite gleichen
|
||||
// Namens danebenzustellen waere keine Hilfe, sondern eine Dublette in jeder
|
||||
// Liste. Wer die Beispielvorlage zurueck will, holt sie aus dem Archiv.
|
||||
where: { name: dokument.name, kundeId: null },
|
||||
include: {
|
||||
versionen: {
|
||||
where: { status: VersionStatus.VEROEFFENTLICHT },
|
||||
orderBy: { version: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const vorhanden = await mitgelieferteFinden(db, dokument);
|
||||
const veroeffentlichte = vorhanden?.versionen[0];
|
||||
if (vorhanden && veroeffentlichte) {
|
||||
ergebnis.push({
|
||||
@@ -131,6 +169,7 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
|
||||
beschreibung: dokument.beschreibung ?? null,
|
||||
kategorie: dokument.kategorie ?? null,
|
||||
schlagworte: [...dokument.schlagworte],
|
||||
seedSchluessel: dokument.seedSchluessel ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -234,6 +273,21 @@ export async function typVorgabenSaeen(
|
||||
db: PrismaClient,
|
||||
vorlagen: readonly GesaeteVorlage[],
|
||||
): Promise<number> {
|
||||
/*
|
||||
* Hat jemand den Schnellstart in der Oberflaeche gespeichert, haelt der Seed
|
||||
* sich heraus — auch bei einer Vorgabe, die dort fehlt (#022). Eine fehlende
|
||||
* Zeile sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde; ohne
|
||||
* diese Marke kaeme eine entfernte Kachel beim naechsten Lauf zurueck.
|
||||
*/
|
||||
const einstellung = await db.einstellung.findUnique({
|
||||
where: { id: "global" },
|
||||
select: { schnellstartGepflegt: true },
|
||||
});
|
||||
if (einstellung?.schnellstartGepflegt) {
|
||||
console.log(" Schnellstart je Geraetetyp: in der Oberflaeche gepflegt, bleibt unberührt");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const nachName = new Map(vorlagen.map((eintrag) => [eintrag.name, eintrag.vorlageId]));
|
||||
let angelegt = 0;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "PC Neuinstallation",
|
||||
"seedSchluessel": "pc-neuinstallation",
|
||||
"beschreibung": "Vollständige Neuinstallation eines Arbeitsplatzrechners: von der Datensicherung des alten Systems über Installation, Einbindung und Verschlüsselung bis zur Übergabe an den Benutzer.",
|
||||
"kategorie": "Client",
|
||||
"schlagworte": ["windows", "client", "neuinstallation", "bitlocker"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Mitarbeiter-Onboarding",
|
||||
"seedSchluessel": "mitarbeiter-onboarding",
|
||||
"beschreibung": "Neues Benutzerkonto im Active Directory oder in Entra ID anlegen, Hardware bereitstellen und den ersten Arbeitstag begleiten.",
|
||||
"kategorie": "Benutzer",
|
||||
"schlagworte": ["onboarding", "eintritt", "benutzerkonto", "active directory", "entra id"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Mitarbeiter-Offboarding",
|
||||
"seedSchluessel": "mitarbeiter-offboarding",
|
||||
"beschreibung": "Konto sperren, Postfach und Daten übergeben, Hardware und Zugänge einsammeln, wenn jemand das Haus verlässt.",
|
||||
"kategorie": "Benutzer",
|
||||
"schlagworte": ["offboarding", "austritt", "benutzerkonto", "sperrung"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Server-Inbetriebnahme Windows-VM",
|
||||
"seedSchluessel": "server-inbetriebnahme",
|
||||
"beschreibung": "Inbetriebnahme einer neuen Windows-Server-VM: Planung und Adressvergabe, Installation, Domänenbeitritt, Absicherung sowie Aufnahme in Backup und Monitoring.",
|
||||
"kategorie": "Server",
|
||||
"schlagworte": ["windows", "server", "vm", "inbetriebnahme"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Firewall-Rollout OPNsense",
|
||||
"seedSchluessel": "firewall-rollout-opnsense",
|
||||
"beschreibung": "Austausch oder Neuaufbau einer OPNsense-Firewall: von den Providerdaten über Regelwerk und VPN bis zur Abnahme und zur außer Betrieb genommenen Altfirewall.",
|
||||
"kategorie": "Netzwerk",
|
||||
"schlagworte": ["firewall", "opnsense", "netzwerk", "vpn", "rollout"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Monatlicher Patchday",
|
||||
"seedSchluessel": "monatlicher-patchday",
|
||||
"beschreibung": "Der wiederkehrende Ablauf für Updates an Servern, Hypervisor, Netzgeräten und Clients: ankündigen, einspielen, kontrollieren und berichten.",
|
||||
"kategorie": "Wartung",
|
||||
"schlagworte": ["patchday", "updates", "wartung", "monatlich"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Backup-Restore-Test",
|
||||
"seedSchluessel": "backup-restore-test",
|
||||
"beschreibung": "Der regelmäßige Nachweis, dass sich eine Sicherung wirklich zurückholen lässt: Wiederherstellungspunkt prüfen, in einer isolierten Umgebung zurückspielen, Funktion und Dauer festhalten.",
|
||||
"kategorie": "Wartung",
|
||||
"schlagworte": ["backup", "restore", "wiederherstellung", "test", "wartung"],
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* 3. Geschaetzte und tatsaechliche Zeiten stimmen zwischen Datei und Uebersicht.
|
||||
* 4. Alle in der Anleitung verlinkten Bilder sind vorhanden.
|
||||
* 5. Kein Markdown verweist auf eine Datei, die es nicht gibt.
|
||||
* 6. Die Schranken-Befehle in CLAUDE.md und README.md stimmen ueberein.
|
||||
*/
|
||||
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
@@ -134,6 +135,68 @@ function feld(text, name) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 6. Die Schranken in CLAUDE.md und README.md -------------------------
|
||||
//
|
||||
// Zwei Dateien beschreiben dasselbe Tor, und sie sind auseinandergelaufen: im
|
||||
// README fehlte `format:pruefen`, und beide nannten den LDAP-Container nicht, ohne
|
||||
// den die Suite 22 Tests still ueberspringt. Beides fiel erst bei der Abnahme zu
|
||||
// #019 auf -- ein gruener Lauf sieht genauso aus. Geprueft wird deshalb der
|
||||
// Befehlsblock selbst, nicht seine Umgebung (#040).
|
||||
{
|
||||
/** Der erste eingezaeunte Block nach einer Ueberschrift. */
|
||||
const blockNach = (text, ueberschrift) => {
|
||||
const ab = text.indexOf(ueberschrift);
|
||||
if (ab < 0) return null;
|
||||
const treffer = text.slice(ab).match(/```(?:bash|sh)?\n([\s\S]*?)```/);
|
||||
return treffer?.[1] ?? null;
|
||||
};
|
||||
|
||||
const skripte = (block) => [...block.matchAll(/pnpm ([a-z:]+)/g)].map((m) => m[1]).sort();
|
||||
|
||||
const quellen = [
|
||||
["CLAUDE.md", "## Vor jedem Commit"],
|
||||
["README.md", "### Qualitätsschranken"],
|
||||
];
|
||||
|
||||
const bloecke = quellen.map(([datei, ueberschrift]) => {
|
||||
const block = blockNach(lies(datei), ueberschrift);
|
||||
if (block === null) melde(`${datei}: kein Befehlsblock unter "${ueberschrift}" gefunden.`);
|
||||
return [datei, block];
|
||||
});
|
||||
|
||||
if (bloecke.every(([, block]) => block !== null)) {
|
||||
const [[dateiA, blockA], [dateiB, blockB]] = bloecke;
|
||||
const a = skripte(blockA);
|
||||
const b = skripte(blockB);
|
||||
if (a.join(" ") !== b.join(" ")) {
|
||||
melde(
|
||||
`Die Schranken laufen auseinander: ${dateiA} nennt [${a.join(", ")}], ` +
|
||||
`${dateiB} nennt [${b.join(", ")}].`,
|
||||
);
|
||||
}
|
||||
for (const [datei, block] of bloecke) {
|
||||
/*
|
||||
* Beide Container namentlich. Ohne sie ueberspringt die Suite Tests und
|
||||
* sieht gruen aus — genau die Art Luecke, die dieses Skript finden soll.
|
||||
*/
|
||||
for (const [dienst, was] of [
|
||||
["ldap", "den Verzeichnis-Abgleich"],
|
||||
["mailpit", "den echten Mailversand"],
|
||||
]) {
|
||||
const gefunden = new RegExp(`docker compose -f docker-compose\\.test\\.yml up [^\n]*${dienst}`).test(
|
||||
block,
|
||||
);
|
||||
if (!gefunden) {
|
||||
melde(
|
||||
`${datei}: der Schranken-Block nennt den Container "${dienst}" nicht. ` +
|
||||
`Ohne ihn ueberspringt die Suite ${was}, und der Lauf sieht gruen aus.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Ergebnis --------------------------------------------------------------
|
||||
if (befunde.length > 0) {
|
||||
console.error(`\nDokumentation und Code laufen auseinander (${befunde.length}):\n`);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Rolle } from "@/generated/prisma/enums";
|
||||
import { Bell, BellOff } from "lucide-react";
|
||||
import { BenutzerAnlegen } from "@/components/benutzer-anlegen";
|
||||
import { aktivSetzenAktion, rolleSetzenAktion } from "@/server/actions/benutzer";
|
||||
import { meldungenSchalterAktion } from "@/server/actions/benachrichtigungen";
|
||||
import { lokalerLoginAktiv } from "@/server/auth/benutzer";
|
||||
import { ROLLEN_TEXT } from "@/lib/rollen-text";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
@@ -59,6 +61,36 @@ function BenutzerKarte({ zeile, selbst }: { zeile: BenutzerZeile; selbst: boolea
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/*
|
||||
* Benachrichtigungen auch **fuer sich selbst** umschaltbar, anders als
|
||||
* „aktiv“: sich selbst zu deaktivieren waere ein Eigentor, seine
|
||||
* eigene Post abzustellen ist der haeufigste Wunsch ueberhaupt. Eine
|
||||
* Profilseite, auf der das jeder selbst koennte, kommt mit der
|
||||
* Selbstauskunft (#035).
|
||||
*/}
|
||||
<form action={meldungenSchalterAktion}>
|
||||
<input type="hidden" name="benutzerId" value={zeile.id} />
|
||||
<input type="hidden" name="aktiv" value={zeile.meldungenAktiv ? "aus" : "an"} />
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={`Benachrichtigungen für ${zeile.name} ${
|
||||
zeile.meldungenAktiv ? "abschalten" : "einschalten"
|
||||
}`}
|
||||
className={`flex min-h-11 items-center gap-1.5 rounded-pille px-3 text-sm md:min-h-9 ${
|
||||
zeile.meldungenAktiv
|
||||
? "bg-flaeche-hoch text-nebentext hover:text-text"
|
||||
: "bg-flaeche-hoch text-nebentext line-through hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{zeile.meldungenAktiv ? (
|
||||
<Bell className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
) : (
|
||||
<BellOff className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
)}
|
||||
Mail
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{selbst ? (
|
||||
<span className="rounded-pille bg-akzent-flaeche px-3 py-2 text-xs text-akzent">das bist du</span>
|
||||
) : (
|
||||
|
||||
@@ -13,6 +13,9 @@ const REITER = [
|
||||
{ pfad: "/admin", beschriftung: "Einstellungen" },
|
||||
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
|
||||
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
|
||||
{ pfad: "/admin/schnellstart", beschriftung: "Schnellstart" },
|
||||
{ pfad: "/admin/protokoll", beschriftung: "Protokoll" },
|
||||
{ pfad: "/admin/zustellungen", beschriftung: "Zustellungen" },
|
||||
/*
|
||||
* Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD,
|
||||
* aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu
|
||||
|
||||
@@ -3,6 +3,8 @@ import { EinstellungsFormular } from "@/components/einstellungen-formular";
|
||||
import { ModusWechsel } from "@/components/modus-wechsel";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { einstellungenHolen, modusWechselPruefen } from "@/server/services/einstellungen";
|
||||
import { meldungseinstellungenHolen } from "@/server/services/benachrichtigungen";
|
||||
import { BenachrichtigungenFormular } from "@/components/benachrichtigungen-formular";
|
||||
|
||||
const MODUS_NAME: Record<Betriebsmodus, string> = {
|
||||
INTERN: "Intern",
|
||||
@@ -17,8 +19,8 @@ const MODUS_ERKLAERUNG: Record<Betriebsmodus, string> = {
|
||||
};
|
||||
|
||||
export default async function EinstellungenSeite() {
|
||||
await sitzungErforderlich("verwaltung", "/admin");
|
||||
const einstellungen = await einstellungenHolen();
|
||||
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin");
|
||||
const [einstellungen, meldungen] = await Promise.all([einstellungenHolen(), meldungseinstellungenHolen()]);
|
||||
|
||||
const gegenteil =
|
||||
einstellungen.modus === Betriebsmodus.INTERN ? Betriebsmodus.DIENSTLEISTUNG : Betriebsmodus.INTERN;
|
||||
@@ -68,6 +70,23 @@ export default async function EinstellungenSeite() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
|
||||
<BenachrichtigungenFormular
|
||||
werte={{
|
||||
smtpHost: meldungen.smtp.host ?? "",
|
||||
smtpPort: meldungen.smtp.port === null ? "" : String(meldungen.smtp.port),
|
||||
smtpTls: meldungen.smtp.tls,
|
||||
smtpBenutzer: meldungen.smtp.benutzer ?? "",
|
||||
smtpAbsender: meldungen.smtp.absender ?? "",
|
||||
smtpPasswortGesetzt: meldungen.smtp.passwortGesetzt,
|
||||
webhookUrl: meldungen.webhook.url ?? "",
|
||||
webhookGeheimnisGesetzt: meldungen.webhook.geheimnisGesetzt,
|
||||
appUrlFehlt: meldungen.appUrl === null,
|
||||
eigeneMail: benutzer.email,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h2 className="text-lg font-semibold">Adresse für die QR-Codes</h2>
|
||||
<p className="mt-1 mb-3 max-w-prose text-sm text-nebentext">
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import Link from "next/link";
|
||||
import { ScrollText } from "lucide-react";
|
||||
import { ProtokollFilter, type ProtokollFilterfelder } from "@/components/protokoll/protokoll-filter";
|
||||
import { ProtokollListe } from "@/components/protokoll/protokoll-liste";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { kundenEbeneSichtbar } from "@/server/mandant";
|
||||
import { PRO_SEITE, protokollSeite, protokollUrheber } from "@/server/queries/protokoll";
|
||||
|
||||
/**
|
||||
* Das Protokoll ansehen (#023).
|
||||
*
|
||||
* Geschrieben wurde es seit #006 — Rollenänderungen, veröffentlichte Vorlagen,
|
||||
* archivierte und zurückgeholte Läufe, der Moduswechsel. Ansehen konnte es
|
||||
* niemand: wer wissen wollte, wer wann eine Rolle geändert hat, brauchte `psql`.
|
||||
*
|
||||
* `verwaltung` und nicht `lesen`: das Protokoll sagt, wer was getan hat, und ist
|
||||
* damit selbst eine Auskunft über Menschen.
|
||||
*
|
||||
* **Die Seite prüft selbst.** Das Admin-Layout darüber ist das zweite Netz und
|
||||
* nicht das erste (siehe CLAUDE.md).
|
||||
*/
|
||||
|
||||
export default async function ProtokollSeiteAnsicht({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin/protokoll");
|
||||
const parameter = await searchParams;
|
||||
const einzeln = (name: string): string =>
|
||||
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
|
||||
|
||||
const felder: ProtokollFilterfelder = {
|
||||
suche: einzeln("suche"),
|
||||
urheber: einzeln("urheber"),
|
||||
von: einzeln("von"),
|
||||
bis: einzeln("bis"),
|
||||
};
|
||||
const gewuenschteSeite = Number.parseInt(einzeln("seite"), 10);
|
||||
|
||||
const [ergebnis, urheber, kundenEbene] = await Promise.all([
|
||||
protokollSeite(
|
||||
{
|
||||
suche: felder.suche,
|
||||
...(felder.urheber ? { urheberId: felder.urheber } : {}),
|
||||
...(felder.von ? { vonDatum: felder.von } : {}),
|
||||
...(felder.bis ? { bisDatum: felder.bis } : {}),
|
||||
seite: Number.isNaN(gewuenschteSeite) ? 1 : gewuenschteSeite,
|
||||
},
|
||||
benutzer,
|
||||
),
|
||||
protokollUrheber(benutzer),
|
||||
kundenEbeneSichtbar(),
|
||||
]);
|
||||
|
||||
/** Dieselben Filter mitnehmen, nur die Seite wechseln. */
|
||||
function blaetternZu(seite: number): string {
|
||||
const suchteil = new URLSearchParams();
|
||||
if (felder.suche) suchteil.set("suche", felder.suche);
|
||||
if (felder.urheber) suchteil.set("urheber", felder.urheber);
|
||||
if (felder.von) suchteil.set("von", felder.von);
|
||||
if (felder.bis) suchteil.set("bis", felder.bis);
|
||||
suchteil.set("seite", String(seite));
|
||||
return `/admin/protokoll?${suchteil.toString()}`;
|
||||
}
|
||||
|
||||
const erster = (ergebnis.seite - 1) * PRO_SEITE + 1;
|
||||
const letzter = Math.min(ergebnis.seite * PRO_SEITE, ergebnis.gesamt);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||
<ScrollText className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
Protokoll
|
||||
</h1>
|
||||
<p className="mt-2 max-w-prose text-nebentext">
|
||||
Wer hat was wann getan. Festgehalten wird, was nachvollziehbar bleiben muss — Rollenänderungen,
|
||||
veröffentlichte Vorlagen, archivierte und zurückgeholte Läufe, Verzeichnisquellen, der Moduswechsel.
|
||||
Einträge werden nie geändert und nie gelöscht.
|
||||
</p>
|
||||
|
||||
<ProtokollFilter felder={felder} urheber={urheber} />
|
||||
|
||||
<p className="mt-4 text-sm text-nebentext">
|
||||
{ergebnis.gesamt === 0 ? "Keine Einträge." : `${erster}–${letzter} von ${ergebnis.gesamt} Einträgen`}
|
||||
</p>
|
||||
|
||||
<ProtokollListe zeilen={ergebnis.zeilen} kundenEbene={kundenEbene} />
|
||||
|
||||
{ergebnis.seiten > 1 ? (
|
||||
<nav aria-label="Seiten" className="mt-6 flex flex-wrap items-center gap-3">
|
||||
{ergebnis.seite > 1 ? (
|
||||
<Link
|
||||
href={blaetternZu(ergebnis.seite - 1)}
|
||||
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||
>
|
||||
zurück
|
||||
</Link>
|
||||
) : null}
|
||||
<span className="text-sm text-nebentext">
|
||||
Seite {ergebnis.seite} von {ergebnis.seiten}
|
||||
</span>
|
||||
{ergebnis.seite < ergebnis.seiten ? (
|
||||
<Link
|
||||
href={blaetternZu(ergebnis.seite + 1)}
|
||||
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||
>
|
||||
weiter
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Rocket } from "lucide-react";
|
||||
import { SchnellstartPflege } from "@/components/schnellstart-pflege";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { schnellstartPflege } from "@/server/queries/schnellstart";
|
||||
|
||||
/**
|
||||
* Schnellstart je Gerätetyp (#022).
|
||||
*
|
||||
* Welche Vorlagen auf der Geräteseite als Kachel erscheinen, stand bisher nur im
|
||||
* Seed. Wer eine eigene Vorlage dort haben wollte, kam nicht heran.
|
||||
*
|
||||
* `verwaltung`, wie bei den Abteilungen: die Zuordnung gilt global und bestimmt,
|
||||
* was jeder Techniker an jedem Gerät als erstes sieht.
|
||||
*/
|
||||
|
||||
export default async function SchnellstartSeite() {
|
||||
// Jede Seite prüft selbst – das Admin-Layout darüber ist das zweite Netz,
|
||||
// nicht das erste (siehe CLAUDE.md).
|
||||
await sitzungErforderlich("verwaltung", "/admin/schnellstart");
|
||||
const { typen, auswahl } = await schnellstartPflege();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||
<Rocket className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
Schnellstart
|
||||
</h1>
|
||||
<p className="mt-2 max-w-prose text-nebentext">
|
||||
Auf der Seite eines Geräts stehen oben die Vorlagen, die dort am häufigsten gebraucht werden — ein
|
||||
Klick, und der Lauf steht. Hier wird festgelegt, welche das je Gerätetyp sind und in welcher
|
||||
Reihenfolge sie erscheinen.
|
||||
</p>
|
||||
<p className="mt-2 max-w-prose text-sm text-nebentext">
|
||||
Zur Auswahl stehen nur veröffentlichte, nicht archivierte Vorlagen. Wird eine Vorlage später
|
||||
archiviert, verschwindet sie von selbst aus dem Schnellstart — niemand muss hier aufräumen. Eine
|
||||
Vorlage, die nur für einen Kunden gilt, erscheint auch nur bei dessen Geräten.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
{typen.map((eintrag) => (
|
||||
<SchnellstartPflege
|
||||
key={eintrag.typ}
|
||||
typ={eintrag.typ}
|
||||
zugewiesen={eintrag.zugewiesen}
|
||||
auswahl={auswahl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, Check, Clock, RotateCcw } from "lucide-react";
|
||||
import { Zustellweg, Zustellzustand, type Meldeanlass } from "@/generated/prisma/enums";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { zustellungenHolen, zustellungenZaehlen } from "@/server/queries/zustellungen";
|
||||
|
||||
/**
|
||||
* Admin → Zustellungen (#034).
|
||||
*
|
||||
* **Warum es diese Seite gibt:** eine stille Warteschlange ist schlimmer als
|
||||
* keine. Ohne sie merkt niemand, dass seit drei Wochen keine Mail mehr
|
||||
* hinausgeht, weil das SMTP-Passwort abgelaufen ist — jeder Lauf funktioniert
|
||||
* weiter, nur erfaehrt niemand davon.
|
||||
*
|
||||
* Gezeigt wird der **Betreff**, nicht der Rumpf: fuer „laeuft der Versand?“
|
||||
* braucht es ihn nicht, und er traegt Lauftitel und Geraetenamen.
|
||||
*/
|
||||
|
||||
const ANLASS_TEXT: Record<Meldeanlass, string> = {
|
||||
LAUF_ZUGEWIESEN: "zugewiesen",
|
||||
LAUF_UEBERFAELLIG: "überfällig",
|
||||
LAUF_ABGESCHLOSSEN: "abgeschlossen",
|
||||
ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen",
|
||||
};
|
||||
|
||||
const ZUSTAND_TEXT: Record<Zustellzustand, string> = {
|
||||
OFFEN: "offen",
|
||||
GESENDET: "gesendet",
|
||||
FEHLGESCHLAGEN: "wird wiederholt",
|
||||
AUFGEGEBEN: "aufgegeben",
|
||||
};
|
||||
|
||||
const ZUSTAND_FARBE: Record<Zustellzustand, string> = {
|
||||
OFFEN: "text-nebentext",
|
||||
GESENDET: "text-erledigt",
|
||||
FEHLGESCHLAGEN: "text-faellig",
|
||||
AUFGEGEBEN: "text-fehler",
|
||||
};
|
||||
|
||||
function zeitpunkt(wert: Date): string {
|
||||
return wert.toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ZustellungenSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ zustand?: string }>;
|
||||
}) {
|
||||
// Jede Seite prüft selbst – die Hülle ist das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich("verwaltung", "/admin/zustellungen");
|
||||
|
||||
const { zustand: roherZustand } = await searchParams;
|
||||
const gefiltert =
|
||||
roherZustand && roherZustand in Zustellzustand ? (roherZustand as Zustellzustand) : undefined;
|
||||
|
||||
const [zeilen, zaehler] = await Promise.all([
|
||||
zustellungenHolen(gefiltert ? { zustand: gefiltert } : {}),
|
||||
zustellungenZaehlen(),
|
||||
]);
|
||||
|
||||
const aufgegeben = zaehler[Zustellzustand.AUFGEGEBEN];
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold">Zustellungen</h1>
|
||||
<p className="mt-2 max-w-prose text-sm text-nebentext">
|
||||
Was Laufzettel per Mail und Webhook hinausgeschickt hat. Eine fehlgeschlagene Zustellung wird
|
||||
wiederholt — nach 1, 5, 30 und 120 Minuten — und bleibt nach dem fünften Fehlschlag als{" "}
|
||||
<strong>aufgegeben</strong> stehen, damit es jemand sieht.
|
||||
</p>
|
||||
|
||||
{aufgegeben > 0 ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-4 flex max-w-prose items-start gap-2 rounded-karte border border-fehler/40 bg-flaeche p-3 text-sm"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-fehler" strokeWidth={2} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{aufgegeben}</strong> {aufgegeben === 1 ? "Zustellung wurde" : "Zustellungen wurden"}{" "}
|
||||
aufgegeben. Der Grund steht jeweils unter der Zeile; meist stimmt der SMTP-Zugang nicht mehr.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Nach Zustand filtern">
|
||||
<Link
|
||||
href="/admin/zustellungen"
|
||||
className={`flex min-h-11 items-center gap-2 rounded-pille border px-4 text-sm md:min-h-9 ${
|
||||
gefiltert === undefined
|
||||
? "border-akzent bg-akzent-flaeche text-akzent"
|
||||
: "border-linie text-nebentext hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Alle
|
||||
</Link>
|
||||
{Object.values(Zustellzustand).map((wert) => (
|
||||
<Link
|
||||
key={wert}
|
||||
href={`/admin/zustellungen?zustand=${wert}`}
|
||||
className={`flex min-h-11 items-center gap-2 rounded-pille border px-4 text-sm md:min-h-9 ${
|
||||
gefiltert === wert
|
||||
? "border-akzent bg-akzent-flaeche text-akzent"
|
||||
: "border-linie text-nebentext hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{ZUSTAND_TEXT[wert]}
|
||||
<span className="text-xs">{zaehler[wert]}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{zeilen.length === 0 ? (
|
||||
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
|
||||
{gefiltert === undefined
|
||||
? "Noch nichts verschickt. Sobald ein Lauf jemandem zugewiesen, überfällig oder " +
|
||||
"abgeschlossen wird, steht es hier."
|
||||
: "In diesem Zustand steht gerade nichts."}
|
||||
</p>
|
||||
) : (
|
||||
/* Am Handy nie waagerecht scrollen: dort Karten statt einer Tabelle. */
|
||||
<ul className="mt-6 flex flex-col gap-3">
|
||||
{zeilen.map((zeile) => (
|
||||
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche p-4 text-sm">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className={`flex items-center gap-1.5 font-semibold ${ZUSTAND_FARBE[zeile.zustand]}`}>
|
||||
{zeile.zustand === Zustellzustand.GESENDET ? (
|
||||
<Check className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||
) : zeile.zustand === Zustellzustand.AUFGEGEBEN ? (
|
||||
<AlertTriangle className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||
) : zeile.zustand === Zustellzustand.FEHLGESCHLAGEN ? (
|
||||
<RotateCcw className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||
) : (
|
||||
<Clock className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
{ZUSTAND_TEXT[zeile.zustand]}
|
||||
</span>
|
||||
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
|
||||
{ANLASS_TEXT[zeile.anlass]}
|
||||
</span>
|
||||
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
|
||||
{zeile.weg === Zustellweg.MAIL ? "Mail" : "Webhook"}
|
||||
</span>
|
||||
{zeile.versuche > 1 ? (
|
||||
<span className="text-xs text-nebentext">{zeile.versuche} Versuche</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 break-words">{zeile.betreff}</p>
|
||||
<p className="mt-1 break-all text-xs text-nebentext">{zeile.empfaenger}</p>
|
||||
|
||||
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs text-nebentext">
|
||||
<div>
|
||||
<dt className="inline">Eingereiht: </dt>
|
||||
<dd className="inline">{zeitpunkt(zeile.erstelltAm)}</dd>
|
||||
</div>
|
||||
{zeile.gesendetAm ? (
|
||||
<div>
|
||||
<dt className="inline">Gesendet: </dt>
|
||||
<dd className="inline">{zeitpunkt(zeile.gesendetAm)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{zeile.zustand === Zustellzustand.FEHLGESCHLAGEN ? (
|
||||
<div>
|
||||
<dt className="inline">Nächster Versuch: </dt>
|
||||
<dd className="inline">{zeitpunkt(zeile.naechsterVersuchAm)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{zeile.laufId ? (
|
||||
<div>
|
||||
<Link href={`/laeufe/${zeile.laufId}`} className="underline hover:text-text">
|
||||
Zum Lauf
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
{zeile.letzterFehler ? (
|
||||
<p className="mono mt-3 rounded-karte border border-linie bg-grund px-3 py-2 text-xs break-words text-fehler">
|
||||
{zeile.letzterFehler}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, MessageSquare, Printer, Star } from "lucide-react";
|
||||
import { ArrowLeft, MessageSquare, Printer, ScrollText, Star } from "lucide-react";
|
||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle";
|
||||
@@ -15,10 +15,13 @@ import {
|
||||
LaufMitAdKontoVerknuepfen,
|
||||
LaufZurueckholen,
|
||||
} from "@/components/laeufe/lauf-aktionen";
|
||||
import { SchrittAnhaenge, type SchrittAnhang } from "@/components/laeufe/schritt-anhaenge";
|
||||
import { SchrittWert } from "@/components/laeufe/schritt-wert";
|
||||
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { anhaengeDesLaufs } from "@/server/services/anhaenge";
|
||||
import { protokollDesLaufs } from "@/server/queries/protokoll";
|
||||
import {
|
||||
kommentareDesLaufs,
|
||||
laufZumAbarbeiten,
|
||||
@@ -81,12 +84,14 @@ function Schritt({
|
||||
lesend,
|
||||
ausfuehrlich,
|
||||
kommentare,
|
||||
anhaenge,
|
||||
}: {
|
||||
schritt: AbarbeitungsSchritt;
|
||||
laufId: string;
|
||||
lesend: boolean;
|
||||
ausfuehrlich: boolean;
|
||||
kommentare: readonly KommentarZeile[];
|
||||
anhaenge: readonly SchrittAnhang[];
|
||||
}) {
|
||||
const vomTisch =
|
||||
schritt.zustand === SchrittZustand.ERLEDIGT || schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND;
|
||||
@@ -196,6 +201,22 @@ function Schritt({
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
* Anhaenge (#032). Immer sichtbar, wenn es welche gibt — auch an einem
|
||||
* archivierten Lauf, nur dann ohne Knoepfe. Ein Foto vom Typenschild
|
||||
* ist Teil des Belegs.
|
||||
*/}
|
||||
{anhaenge.length > 0 || (!lesend && schritt.ergebnisId) ? (
|
||||
<div className="mt-3">
|
||||
<SchrittAnhaenge
|
||||
laufId={laufId}
|
||||
vorlagenSchrittId={schritt.schrittId}
|
||||
anhaenge={anhaenge}
|
||||
lesend={lesend || !schritt.ergebnisId}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{lesend || !schritt.ergebnisId ? null : (
|
||||
<details className="mt-2">
|
||||
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-xs text-nebentext hover:text-text md:min-h-9">
|
||||
@@ -219,12 +240,14 @@ function Abschnitt({
|
||||
lesend,
|
||||
ausfuehrlich,
|
||||
kommentare,
|
||||
anhaenge,
|
||||
}: {
|
||||
abschnitt: AbarbeitungsAbschnitt;
|
||||
laufId: string;
|
||||
lesend: boolean;
|
||||
ausfuehrlich: boolean;
|
||||
kommentare: readonly KommentarZeile[];
|
||||
anhaenge: readonly (SchrittAnhang & { readonly schrittergebnisId: string })[];
|
||||
}) {
|
||||
const erledigt = abschnitt.schritte.filter(
|
||||
(schritt) =>
|
||||
@@ -262,6 +285,7 @@ function Abschnitt({
|
||||
lesend={lesend}
|
||||
ausfuehrlich={ausfuehrlich}
|
||||
kommentare={kommentare.filter((kommentar) => kommentar.schrittergebnisId === schritt.ergebnisId)}
|
||||
anhaenge={anhaenge.filter((anhang) => anhang.schrittergebnisId === schritt.ergebnisId)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -293,6 +317,12 @@ export default async function LaufSeite({
|
||||
if (!daten) notFound();
|
||||
|
||||
const kommentare = await kommentareDesLaufs(id, benutzer);
|
||||
// Die Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die
|
||||
// Einträge dieses Laufs (#023).
|
||||
const laufProtokoll = await protokollDesLaufs(id);
|
||||
// Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
|
||||
// Anhaenge dieses Laufs (#032).
|
||||
const anhaenge = await anhaengeDesLaufs(id);
|
||||
/*
|
||||
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
|
||||
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
|
||||
@@ -390,6 +420,7 @@ export default async function LaufSeite({
|
||||
lesend={lesend}
|
||||
ausfuehrlich={ausfuehrlich}
|
||||
kommentare={kommentare}
|
||||
anhaenge={anhaenge}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -416,6 +447,41 @@ export default async function LaufSeite({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/*
|
||||
* Die Geschichte dieses Laufs, eingeklappt (#023). Sie steht hier und
|
||||
* nicht nur unter Verwaltung → Protokoll, weil die Frage „wer hat das
|
||||
* zurückgeholt“ am Lauf aufkommt und nicht in der Verwaltung. Eingeklappt,
|
||||
* weil ein abgearbeiteter Lauf Dutzende Einträge hat und sie die Schritte
|
||||
* sonst aus dem Bild schieben.
|
||||
*
|
||||
* Kein eigenes Recht: wer den Lauf sehen darf, darf auch sehen, was an ihm
|
||||
* geschehen ist. Die Mandantensicht hat `laufHolen` oben schon angewandt.
|
||||
*/}
|
||||
{laufProtokoll.length > 0 ? (
|
||||
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-4 md:p-5">
|
||||
<details>
|
||||
<summary className="flex min-h-11 cursor-pointer items-center gap-2 text-lg font-semibold md:min-h-0">
|
||||
<ScrollText className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
Protokoll dieses Laufs
|
||||
<span className="text-sm font-normal text-nebentext">({laufProtokoll.length})</span>
|
||||
</summary>
|
||||
<ol className="mt-3 flex flex-col gap-2">
|
||||
{laufProtokoll.map((eintrag) => (
|
||||
<li key={eintrag.id} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
|
||||
<span className="mono shrink-0 text-xs text-nebentext">
|
||||
{ZEITPUNKT.format(eintrag.erstelltAm)}
|
||||
</span>
|
||||
<span className="min-w-0 break-words">{eintrag.aktion}</span>
|
||||
<span className="text-xs text-nebentext">
|
||||
{eintrag.urheber ? eintrag.urheber.name : "Systemlauf"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,17 @@ import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Laptop } from "lucide-react";
|
||||
import { MitarbeiterAktiv } from "@/components/mitarbeiter-aktiv";
|
||||
import { MitarbeiterStammdaten } from "@/components/mitarbeiter-stammdaten";
|
||||
import { MitarbeiterVorgesetzter } from "@/components/mitarbeiter-vorgesetzter";
|
||||
import { Herkunft } from "@/generated/prisma/enums";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { kundenEbeneSichtbar } from "@/server/mandant";
|
||||
import { geraeteDesMitarbeiters, mitarbeiterHolen, vorgesetzterVon } from "@/server/queries/mitarbeiter";
|
||||
import {
|
||||
geraeteDesMitarbeiters,
|
||||
mitarbeiterHolen,
|
||||
moeglicheVorgesetzte,
|
||||
vorgesetzterVon,
|
||||
} from "@/server/queries/mitarbeiter";
|
||||
|
||||
/**
|
||||
* Die Seite einer Person (#011).
|
||||
@@ -26,10 +32,13 @@ export default async function MitarbeiterSeite({ params }: { params: Promise<{ i
|
||||
if (!person) notFound();
|
||||
|
||||
const darfBearbeiten = darf(benutzer.rolle, "mitarbeiterBearbeiten");
|
||||
const [geraete, kundenEbene, vorgesetzter] = await Promise.all([
|
||||
const [geraete, kundenEbene, vorgesetzter, auswahl] = await Promise.all([
|
||||
geraeteDesMitarbeiters(person.id, benutzer),
|
||||
kundenEbeneSichtbar(),
|
||||
vorgesetzterVon(person.id, benutzer),
|
||||
// Nur holen, wer sie auch braucht: fuer einen Leser ist die Liste aller
|
||||
// Kolleginnen und Kollegen eine Abfrage ohne Zweck.
|
||||
darfBearbeiten ? moeglicheVorgesetzte(person.id, benutzer) : [],
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -60,8 +69,14 @@ export default async function MitarbeiterSeite({ params }: { params: Promise<{ i
|
||||
) : null}
|
||||
|
||||
{darfBearbeiten ? (
|
||||
<div className="mt-6">
|
||||
<div className="mt-6 flex flex-col gap-6">
|
||||
<MitarbeiterAktiv mitarbeiterId={person.id} aktiv={person.aktiv} />
|
||||
<MitarbeiterVorgesetzter
|
||||
mitarbeiterId={person.id}
|
||||
vorgesetzterId={vorgesetzter?.id ?? null}
|
||||
auswahl={auswahl}
|
||||
ausDemVerzeichnis={person.herkunft === Herkunft.ABGLEICH}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Blocks } from "lucide-react";
|
||||
import { BausteinEditor } from "@/components/bausteine/baustein-editor";
|
||||
import { BausteinKopf } from "@/components/bausteine/baustein-kopf";
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { bausteinHolen, bausteinVerwendungen } from "@/server/queries/bausteine";
|
||||
|
||||
/**
|
||||
* Ein Baustein (#030).
|
||||
*
|
||||
* Oben die Kopfdaten, dann der Entwurf, dann die Fassungen und zuletzt die
|
||||
* Verwendungen. Die Reihenfolge ist die der Fragen: woran arbeite ich, was gab
|
||||
* es vorher, und wen trifft eine Änderung.
|
||||
*/
|
||||
|
||||
const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
export default async function BausteinSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { benutzer } = await sitzungErforderlich("lesen", `/vorlagen/bausteine/${id}`);
|
||||
const baustein = await bausteinHolen(id);
|
||||
if (!baustein) notFound();
|
||||
|
||||
const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
|
||||
const verwendungen = await bausteinVerwendungen(id);
|
||||
const entwurf = baustein.versionen.find((version) => version.status === VersionStatus.ENTWURF);
|
||||
const veroeffentlichte = baustein.versionen.filter(
|
||||
(version) => version.status === VersionStatus.VEROEFFENTLICHT,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href="/vorlagen/bausteine"
|
||||
className="inline-flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text"
|
||||
>
|
||||
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Alle Bausteine
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-2 flex items-center gap-2 text-2xl font-bold">
|
||||
<Blocks className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
<span className={baustein.archiviert ? "line-through" : ""}>{baustein.name}</span>
|
||||
</h1>
|
||||
{baustein.archiviert ? (
|
||||
<p className="mt-2 max-w-prose text-sm text-faellig">
|
||||
Archiviert. Er lässt sich nicht mehr in einen Entwurf einsetzen, bleibt aber in jeder Vorlage
|
||||
lesbar, die ihn schon benutzt.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{darfBearbeiten ? (
|
||||
<div className="mt-6">
|
||||
<BausteinKopf
|
||||
baustein={{
|
||||
id: baustein.id,
|
||||
name: baustein.name,
|
||||
beschreibung: baustein.beschreibung ?? "",
|
||||
archiviert: baustein.archiviert,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : baustein.beschreibung ? (
|
||||
<p className="mt-4 max-w-prose text-nebentext">{baustein.beschreibung}</p>
|
||||
) : null}
|
||||
|
||||
{darfBearbeiten && !baustein.archiviert ? (
|
||||
<section className="mt-8">
|
||||
<h2 className="sr-only">Entwurf</h2>
|
||||
{entwurf ? (
|
||||
<BausteinEditor
|
||||
entwurf={{
|
||||
bausteinId: baustein.id,
|
||||
versionId: entwurf.id,
|
||||
version: entwurf.version,
|
||||
titel: entwurf.titel,
|
||||
schritte: entwurf.schritte.map((schritt) => ({
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext,
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
konfig: schritt.konfig,
|
||||
dokuUrl: schritt.dokuUrl,
|
||||
})),
|
||||
darfVerwerfen: veroeffentlichte.length > 0,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
/* Kein Entwurf: der Knopf erzeugt ihn als tiefe Kopie der letzten
|
||||
veröffentlichten Fassung — dieselbe Regel wie bei Vorlagen. */
|
||||
<BausteinKopf
|
||||
baustein={{
|
||||
id: baustein.id,
|
||||
name: baustein.name,
|
||||
beschreibung: baustein.beschreibung ?? "",
|
||||
archiviert: baustein.archiviert,
|
||||
}}
|
||||
nurEntwurfKnopf
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h2 className="text-lg font-semibold">Fassungen</h2>
|
||||
{veroeffentlichte.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-nebentext">Noch keine veröffentlichte Fassung.</p>
|
||||
) : (
|
||||
<ul className="mt-3 flex flex-col gap-2">
|
||||
{veroeffentlichte.map((version) => (
|
||||
<li
|
||||
key={version.id}
|
||||
className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-t border-linie py-2 text-sm first:border-t-0"
|
||||
>
|
||||
<span className="zahl shrink-0 text-akzent">{String(version.version).padStart(2, "0")}</span>
|
||||
<span className="min-w-0 break-words">{version.titel}</span>
|
||||
<span className="text-nebentext">
|
||||
{version.schritte.length} {version.schritte.length === 1 ? "Schritt" : "Schritte"}
|
||||
</span>
|
||||
{version.veroeffentlichtAm ? (
|
||||
<span className="mono text-xs text-nebentext">
|
||||
{ZEITPUNKT.format(version.veroeffentlichtAm)}
|
||||
</span>
|
||||
) : null}
|
||||
{version.aenderungsnotiz ? (
|
||||
<span className="min-w-0 break-words text-nebentext">· {version.aenderungsnotiz}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h2 className="text-lg font-semibold">Wo er steckt</h2>
|
||||
{verwendungen.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-nebentext">
|
||||
In keiner Vorlagenversion. Eine Änderung trifft damit nichts Bestehendes.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 max-w-prose text-sm text-nebentext">
|
||||
Jede Zeile nennt die Fassung, die dort <strong>eingesetzt</strong> ist. Eine neue Fassung ändert
|
||||
davon nichts.
|
||||
</p>
|
||||
<ul className="mt-3 flex flex-col gap-2">
|
||||
{verwendungen.map((eintrag, stelle) => (
|
||||
<li
|
||||
key={`${eintrag.vorlageId}-${eintrag.vorlagenVersion}-${stelle}`}
|
||||
className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-t border-linie py-2 text-sm first:border-t-0"
|
||||
>
|
||||
<Link
|
||||
href={`/vorlagen/${eintrag.vorlageId}`}
|
||||
className={`min-w-0 break-words text-akzent underline underline-offset-2 ${
|
||||
eintrag.archiviert ? "line-through" : ""
|
||||
}`}
|
||||
>
|
||||
{eintrag.vorlageName}
|
||||
</Link>
|
||||
<span className="text-nebentext">Version {eintrag.vorlagenVersion}</span>
|
||||
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs text-nebentext">
|
||||
Fassung {eintrag.bausteinVersion}
|
||||
</span>
|
||||
{eintrag.status === VersionStatus.ENTWURF ? (
|
||||
<span className="text-xs text-faellig">Entwurf</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Link from "next/link";
|
||||
import { Blocks, Plus } from "lucide-react";
|
||||
import { BausteinAnlegen } from "@/components/bausteine/baustein-anlegen";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { bausteinListe } from "@/server/queries/bausteine";
|
||||
|
||||
/**
|
||||
* Die Bausteine (#030).
|
||||
*
|
||||
* Ein Baustein ist ein Abschnitt, den mehrere Vorlagen teilen. Er gehört zu den
|
||||
* Vorlagen und nicht in die Verwaltung: wer Vorlagen pflegt, pflegt Bausteine.
|
||||
*
|
||||
* **Die Seite prüft selbst** — die Hülle darunter ist das zweite Netz, nicht das
|
||||
* erste (siehe CLAUDE.md).
|
||||
*/
|
||||
|
||||
export default async function BausteineSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen/bausteine");
|
||||
const parameter = await searchParams;
|
||||
const mitArchivierten =
|
||||
(Array.isArray(parameter["archiv"]) ? parameter["archiv"][0] : parameter["archiv"]) === "1";
|
||||
|
||||
const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
|
||||
const zeilen = await bausteinListe({ mitArchivierten });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href="/vorlagen"
|
||||
className="inline-flex min-h-11 items-center text-sm text-nebentext hover:text-text"
|
||||
>
|
||||
Alle Vorlagen
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-2 flex items-center gap-2 text-2xl font-bold">
|
||||
<Blocks className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
Bausteine
|
||||
</h1>
|
||||
<p className="mt-2 max-w-prose text-nebentext">
|
||||
Ein Abschnitt, den mehrere Vorlagen teilen. „Datensicherung prüfen“ steht in vier der sieben
|
||||
mitgelieferten Vorlagen — als Baustein wird er an einer Stelle gepflegt.
|
||||
</p>
|
||||
<p className="mt-2 max-w-prose text-sm text-nebentext">
|
||||
Eine neue Fassung wirkt erst in der <strong>nächsten</strong> veröffentlichten Version einer Vorlage.
|
||||
Schon veröffentlichte Versionen und laufende Arbeiten bleiben unberührt — sie tragen Kopien, und das
|
||||
ist Absicht.
|
||||
</p>
|
||||
|
||||
{darfBearbeiten ? (
|
||||
<div className="mt-6">
|
||||
<BausteinAnlegen />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href={mitArchivierten ? "/vorlagen/bausteine" : "/vorlagen/bausteine?archiv=1"}
|
||||
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||
>
|
||||
{mitArchivierten ? "Archivierte ausblenden" : "Archivierte mitzeigen"}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{zeilen.length === 0 ? (
|
||||
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
|
||||
Noch kein Baustein.{" "}
|
||||
{darfBearbeiten
|
||||
? "Lege einen an — zum Beispiel für die Schritte, die in mehreren Vorlagen gleich sind."
|
||||
: ""}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-4 flex flex-col gap-2">
|
||||
{zeilen.map((zeile) => (
|
||||
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche p-4">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<Link
|
||||
href={`/vorlagen/bausteine/${zeile.id}`}
|
||||
className={`min-w-0 font-semibold break-words text-akzent underline underline-offset-2 ${
|
||||
zeile.archiviert ? "line-through" : ""
|
||||
}`}
|
||||
>
|
||||
{zeile.name}
|
||||
</Link>
|
||||
{zeile.veroeffentlichteVersion === null ? (
|
||||
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs text-nebentext">
|
||||
noch nicht veröffentlicht
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-nebentext">Fassung {zeile.veroeffentlichteVersion}</span>
|
||||
)}
|
||||
{zeile.hatEntwurf ? (
|
||||
<span className="rounded-pille bg-akzent-flaeche px-2.5 py-0.5 text-xs font-semibold text-akzent">
|
||||
Entwurf
|
||||
</span>
|
||||
) : null}
|
||||
{zeile.archiviert ? <span className="text-xs text-nebentext">archiviert</span> : null}
|
||||
</div>
|
||||
{zeile.beschreibung ? (
|
||||
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.beschreibung}</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-sm text-nebentext">
|
||||
{zeile.anzahlSchritte} {zeile.anzahlSchritte === 1 ? "Schritt" : "Schritte"} ·{" "}
|
||||
{zeile.verwendungen === 0
|
||||
? "in keiner Vorlage"
|
||||
: `in ${zeile.verwendungen} ${zeile.verwendungen === 1 ? "Vorlagenversion" : "Vorlagenversionen"}`}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{darfBearbeiten ? null : (
|
||||
<p className="mt-6 flex items-center gap-2 text-sm text-nebentext">
|
||||
<Plus className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Anlegen und ändern dürfen Redakteure und Administratoren.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { FileText, Pencil, Plus } from "lucide-react";
|
||||
import { Blocks, FileText, Pencil, Plus } from "lucide-react";
|
||||
import { VorlageAnlegen } from "@/components/vorlage-anlegen";
|
||||
import { VorlageImportieren } from "@/components/vorlage-importieren";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
@@ -129,7 +129,16 @@ export default async function VorlagenSeite({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold">Vorlagen</h1>
|
||||
<Link
|
||||
href="/vorlagen/bausteine"
|
||||
className="flex min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||
>
|
||||
<Blocks className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Bausteine
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-2 max-w-prose text-nebentext">
|
||||
Eine Vorlage wird veröffentlicht und ist danach unveränderlich. Ein Lauf hängt immer an der Version,
|
||||
mit der er gestartet wurde — deshalb ändert eine neue Veröffentlichung nie einen laufenden oder
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Editor } from "@/components/vorlagen/editor";
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { abteilungsListe } from "@/server/queries/abteilungen";
|
||||
import { einsetzbareBausteine } from "@/server/queries/bausteine";
|
||||
import { versionMitAufbau } from "@/server/queries/vorlagen";
|
||||
|
||||
/**
|
||||
@@ -26,6 +28,24 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
|
||||
|
||||
const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
|
||||
const veroeffentlicht = version.status === VersionStatus.VEROEFFENTLICHT;
|
||||
/*
|
||||
* Die einsetzbaren Bausteine braucht nur ein Entwurf, den jemand aendern darf
|
||||
* (#030). Eine veroeffentlichte Version bekommt sie trotzdem — sonst stuenden
|
||||
* bei ihren Baustein-Abschnitten statt des Namens nur „nicht mehr verfuegbar“,
|
||||
* und das waere eine Falschaussage ueber die Herkunft.
|
||||
*/
|
||||
const bausteine = await einsetzbareBausteine();
|
||||
/*
|
||||
* Die gepflegten Abteilungen fuer die Einschraenkung je Abschnitt (#031).
|
||||
* Namen und nicht Kennungen: der Abschnitt gehoert zur Version und muss ein
|
||||
* Umbenennen ueberleben. Dubletten ueber Kunden hinweg fallen weg — die
|
||||
* Einschraenkung gilt ueber den Namen.
|
||||
*/
|
||||
const abteilungen = [
|
||||
...new Set(
|
||||
(await abteilungsListe(benutzer)).filter((zeile) => !zeile.archiviert).map((zeile) => zeile.name),
|
||||
),
|
||||
].sort((links, rechts) => links.localeCompare(rechts, "de"));
|
||||
|
||||
return (
|
||||
<Editor
|
||||
@@ -41,6 +61,8 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
|
||||
}}
|
||||
lesend={veroeffentlicht || !darfBearbeiten}
|
||||
darfBearbeiten={darfBearbeiten}
|
||||
bausteine={bausteine}
|
||||
abteilungen={abteilungen}
|
||||
// Auf dem Server formatiert: im Browser waere es eine zweite Uhrzeit und
|
||||
// damit ein Unterschied zwischen Vorrendern und Hydrieren.
|
||||
beispielDatum={new Date().toLocaleDateString("de-DE", {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AnhangFehler, anhangLesen } from "@/server/services/anhaenge";
|
||||
import { MandantFehler } from "@/server/mandant";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { istBild } from "@/lib/anhaenge";
|
||||
|
||||
/**
|
||||
* Einen Anhang ausliefern (#032).
|
||||
*
|
||||
* **Route Handler prüfen selbst.** Die Hülle unter `src/app/(app)/layout.tsx`
|
||||
* deckt sie nicht ab, und der Proxy sieht nur, **ob** ein Cookie da ist, nie was
|
||||
* drinsteht (CLAUDE.md). Hier zählt das doppelt: eine Adresse, die eine Datei
|
||||
* ohne Prüfung ausliefert, umgeht alles andere.
|
||||
*
|
||||
* `Content-Disposition: inline` nur für Bilder. Ein PDF wird **heruntergeladen**
|
||||
* statt im Browser gerendert: ein PDF kann Skripte enthalten, und ein Anhang,
|
||||
* den jemand aus einem fremden Lauf öffnet, soll nichts ausführen.
|
||||
*/
|
||||
export async function GET(
|
||||
_anfrage: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
let benutzer: { id: string; rolle: Parameters<typeof anhangLesen>[1]["rolle"] };
|
||||
try {
|
||||
const zugang = await rechtErforderlich("lesen");
|
||||
benutzer = { id: zugang.benutzer.id, rolle: zugang.benutzer.rolle };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) {
|
||||
return Response.json({ fehler: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
try {
|
||||
const datei = await anhangLesen(id, benutzer);
|
||||
// Der Dateiname steckt in einer Kopfzeile. `dateinameSaeubern` hat
|
||||
// Steuerzeichen und Anführungszeichen schon beim Anlegen entfernt.
|
||||
const anzeige = istBild(datei.medientyp) ? "inline" : "attachment";
|
||||
return new Response(new Uint8Array(datei.inhalt), {
|
||||
headers: {
|
||||
"Content-Type": datei.medientyp,
|
||||
"Content-Length": String(datei.groesse),
|
||||
"Content-Disposition": `${anzeige}; filename="${datei.dateiname}"`,
|
||||
// Privat: ein Anhang gehört einem Kunden, kein Zwischenspeicher darf ihn
|
||||
// an den nächsten Betrachter weitergeben.
|
||||
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof MandantFehler) {
|
||||
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
|
||||
// Unterscheidung wäre schon eine Auskunft über fremde Kunden.
|
||||
return Response.json({ fehler: "Diesen Anhang gibt es nicht." }, { status: 404 });
|
||||
}
|
||||
if (fehler instanceof AnhangFehler) {
|
||||
return Response.json({ fehler: fehler.message }, { status: 404 });
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Healthcheck fuer Docker und den Reverse Proxy. Prueft, ob die Datenbank antwortet. */
|
||||
/**
|
||||
* Healthcheck fuer Docker und den Reverse Proxy. Prueft, ob die Datenbank antwortet.
|
||||
*
|
||||
* Dieser Pfad ist absichtlich ohne Anmeldung erreichbar — er steht in der
|
||||
* Allowlist von `src/proxy.ts`, damit ein Healthcheck kein Cookie braucht.
|
||||
* Deshalb geht die **Begruendung** eines Fehlers nicht hinaus: eine Meldung von
|
||||
* Prisma oder `pg` nennt typischerweise Host, Port, Benutzernamen und
|
||||
* Datenbanknamen. Nach aussen genuegt, **ob** es geht; warum nicht, steht im
|
||||
* Protokoll des Prozesses (`docker compose logs web`,
|
||||
* `journalctl -u laufzettel`). Dasselbe Muster wie in
|
||||
* `src/server/actions/verzeichnis.ts` (#039).
|
||||
*/
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return Response.json({ status: "ok" });
|
||||
} catch (fehler) {
|
||||
const meldung = fehler instanceof Error ? fehler.message : "unbekannter Fehler";
|
||||
return Response.json({ status: "fehler", meldung }, { status: 503 });
|
||||
console.error(
|
||||
"Healthcheck fehlgeschlagen:",
|
||||
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : "unbekannter Fehler",
|
||||
);
|
||||
return Response.json({ status: "fehler" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { laufZustand } from "@/server/queries/laeufe";
|
||||
import { HERZSCHLAG, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse";
|
||||
import { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse";
|
||||
|
||||
/**
|
||||
* Der Zustand eines Laufs fuer die Aktualisierung alle zehn Sekunden (#013).
|
||||
* Der Zustand eines Laufs – als Strom oder als einzelne Antwort (#013, #033).
|
||||
*
|
||||
* **Route Handler pruefen selbst.** Die Huelle unter `src/app/(app)/layout.tsx`
|
||||
* deckt sie nicht ab — sie liegt in einem anderen Zweig des Baums, und der Proxy
|
||||
* sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md).
|
||||
* sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md). Ein
|
||||
* offener Strom ist ein Leseweg wie jeder andere; geprueft wird **bevor** das
|
||||
* erste Ereignis fliesst.
|
||||
*
|
||||
* **Eine Adresse fuer beide Wege.** `EventSource` schickt
|
||||
* `Accept: text/event-stream`, sonst kommt eine gewoehnliche Anfrage — dieselbe
|
||||
* Rechtepruefung, dasselbe Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt
|
||||
* waere eine zweite Stelle, an der jemand das Recht vergessen kann, und zwei
|
||||
* Wahrheiten, die auseinanderlaufen. Das Polling bleibt damit ein echter
|
||||
* Rueckfall und nicht ein zweiter Codepfad, den niemand mehr faehrt.
|
||||
*
|
||||
* Die Antwort enthaelt **keine Befehle**. Ein `{{generiertes_passwort}}`
|
||||
* entsteht bei jeder Aufloesung neu; alle zehn Sekunden mitgeliefert, tauschte
|
||||
* es dem Techniker das Passwort unter den Haenden weg, waehrend er es abtippt.
|
||||
* Zurueck kommen nur Status und Schrittzustaende — genau das, was zwei Browser
|
||||
* am selben Lauf voneinander wissen muessen.
|
||||
* entsteht bei jeder Aufloesung neu; mitgeliefert, tauschte es dem Techniker das
|
||||
* Passwort unter den Haenden weg, waehrend er es abtippt. Zurueck kommen nur
|
||||
* Status, Schrittzustaende und die Zahl der Kommentare und Anhaenge — genau das,
|
||||
* was zwei Browser am selben Lauf voneinander wissen muessen.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Sammelfenster. Wer einen Abschnitt abschnittsweise abhakt, loest ein Dutzend
|
||||
* NOTIFY in einer Sekunde aus; ohne Fenster waere das ein Dutzend Abfragen des
|
||||
* Lesemodells je Zuschauer. 150 ms sind unter der Wahrnehmungsschwelle und
|
||||
* fassen einen Schwung zusammen.
|
||||
*/
|
||||
const SAMMELFENSTER_MS = 150;
|
||||
|
||||
export async function GET(
|
||||
_anfrage: Request,
|
||||
anfrage: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
let benutzerId: string;
|
||||
@@ -34,16 +54,127 @@ export async function GET(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const zustand = await laufZustand(id, { id: benutzerId, rolle });
|
||||
const benutzer = { id: benutzerId, rolle };
|
||||
const zustand = await laufZustand(id, benutzer);
|
||||
if (!zustand) {
|
||||
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
|
||||
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden.
|
||||
return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 });
|
||||
}
|
||||
|
||||
/*
|
||||
* Kein Strom gewuenscht – oder keiner mehr frei. Bei erreichter Obergrenze
|
||||
* antwortet der Handler **gewoehnlich**: `EventSource` verlangt
|
||||
* `text/event-stream`, bekommt JSON, meldet `error` und versucht es nicht
|
||||
* wieder. Der Client fuellt damit von sich aus auf Polling zurueck, ohne dass
|
||||
* es dafuer einen Sonderfall oder einen Fehlercode braucht.
|
||||
*/
|
||||
if (!willStrom(anfrage.headers.get("accept")) || stroemeOffen() >= maxStroeme()) {
|
||||
return Response.json(zustand, {
|
||||
// Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie
|
||||
// aus einem Zwischenspeicher kommt.
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
const kodierer = new TextEncoder();
|
||||
let abmelden: (() => void) | undefined;
|
||||
let herzschlag: ReturnType<typeof setInterval> | undefined;
|
||||
let sammler: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const strom = new ReadableStream<Uint8Array>({
|
||||
async start(steuerung) {
|
||||
let offen = true;
|
||||
|
||||
function schicken(text: string): void {
|
||||
if (!offen) return;
|
||||
try {
|
||||
steuerung.enqueue(kodierer.encode(text));
|
||||
} catch {
|
||||
// Gegenseite ist weg, bevor `abort` ankam.
|
||||
offen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function aufraeumen(): void {
|
||||
if (!offen) return;
|
||||
offen = false;
|
||||
abmelden?.();
|
||||
if (herzschlag) clearInterval(herzschlag);
|
||||
if (sammler) clearTimeout(sammler);
|
||||
try {
|
||||
steuerung.close();
|
||||
} catch {
|
||||
// Schon geschlossen.
|
||||
}
|
||||
}
|
||||
|
||||
async function standSchicken(): Promise<void> {
|
||||
if (!offen) return;
|
||||
try {
|
||||
const jetzt = await laufZustand(id, benutzer);
|
||||
if (!jetzt) {
|
||||
/*
|
||||
* Lauf weg oder nicht mehr sichtbar — etwa weil jemandem der Kunde
|
||||
* entzogen wurde. Der Strom endet; der Client holt sich an der
|
||||
* gewoehnlichen Adresse die 404 und stellt ein.
|
||||
*/
|
||||
schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-sichtbar" })));
|
||||
aufraeumen();
|
||||
return;
|
||||
}
|
||||
schicken(sseEreignis("zustand", JSON.stringify(jetzt)));
|
||||
} catch (fehler) {
|
||||
console.error("Zustand fuer den Strom konnte nicht gelesen werden:", fehler);
|
||||
}
|
||||
}
|
||||
|
||||
// Der erste Stand sofort, damit der Client einen Vergleichspunkt hat,
|
||||
// ohne zusaetzlich zu pollen.
|
||||
schicken(sseEreignis("zustand", JSON.stringify(zustand)));
|
||||
|
||||
try {
|
||||
abmelden = await aufLaufHoeren(id, () => {
|
||||
if (sammler) return;
|
||||
sammler = setTimeout(() => {
|
||||
sammler = undefined;
|
||||
void standSchicken();
|
||||
}, SAMMELFENSTER_MS);
|
||||
});
|
||||
} catch (fehler) {
|
||||
/*
|
||||
* Keine LISTEN-Verbindung zu bekommen ist kein Grund, die Seite blind
|
||||
* zu lassen: Strom beenden, der Client fuellt auf Polling zurueck.
|
||||
*/
|
||||
console.error("LISTEN fuer den Strom nicht moeglich:", fehler);
|
||||
aufraeumen();
|
||||
return;
|
||||
}
|
||||
|
||||
herzschlag = setInterval(() => schicken(HERZSCHLAG), herzschlagMs());
|
||||
anfrage.signal.addEventListener("abort", aufraeumen);
|
||||
},
|
||||
|
||||
cancel() {
|
||||
abmelden?.();
|
||||
if (herzschlag) clearInterval(herzschlag);
|
||||
if (sammler) clearTimeout(sammler);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(strom, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-store, no-transform",
|
||||
Connection: "keep-alive",
|
||||
/*
|
||||
* Fuer nginx: schaltet die Pufferung fuer **diese** Antwort ab, auch wenn
|
||||
* in der Konfiguration `proxy_buffering on` steht. Ohne das sammelt nginx
|
||||
* den Strom und gibt ihn erst am Ende heraus — die Seite sieht dann aus,
|
||||
* als sei sie hangen geblieben. Die Einstellung steht trotzdem in
|
||||
* `docs/betrieb/reverse-proxy.md`: diese Kopfzeile versteht nur nginx.
|
||||
*/
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { bausteinAnlegenAktion, type AktionsErgebnis } from "@/server/actions/bausteine";
|
||||
|
||||
/**
|
||||
* Einen Baustein anlegen (#030).
|
||||
*
|
||||
* Nur der Name. Titel und Schritte kommen danach im Entwurf — ein Formular, das
|
||||
* alles auf einmal fragt, stünde dem Anlegen im Weg.
|
||||
*/
|
||||
export function BausteinAnlegen() {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(bausteinAnlegenAktion, {});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form action={aktion} className="flex flex-wrap items-end gap-3">
|
||||
<label className="flex min-w-0 flex-1 basis-64 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Name des Bausteins</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
placeholder="Datensicherung prüfen"
|
||||
className="min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm outline-none focus-visible:border-akzent md:min-h-10"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||
>
|
||||
Anlegen
|
||||
</button>
|
||||
</form>
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
|
||||
import { SortierBereich, SortierZeile } from "@/components/vorlagen/editor-sortieren";
|
||||
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
|
||||
import {
|
||||
alsStruktur,
|
||||
alsEditorAufbau,
|
||||
neuerSchritt,
|
||||
umsortieren,
|
||||
type EditorSchritt,
|
||||
} from "@/lib/editor-aufbau";
|
||||
import { GRENZEN } from "@/lib/vorlagen-format";
|
||||
import {
|
||||
bausteinEntwurfSpeichernAktion,
|
||||
bausteinEntwurfVerwerfenAktion,
|
||||
bausteinVeroeffentlichenAktion,
|
||||
type AktionsErgebnis,
|
||||
} from "@/server/actions/bausteine";
|
||||
|
||||
/**
|
||||
* Titel und Schritte eines Baustein-Entwurfs (#030).
|
||||
*
|
||||
* Bewusst **kein** zweiter grosser Editor: ein Baustein ist ein Abschnitt, also
|
||||
* ein Titel und eine Liste Schritte. Die Schritt-Karten und das Sortieren kommen
|
||||
* aus dem Vorlagen-Editor — dieselben Bauteile, damit sich beides gleich anfuehlt
|
||||
* und ein Feinschliff nur an einer Stelle geschieht.
|
||||
*
|
||||
* Variablen hat ein Baustein nicht. Platzhalter darf er benutzen; sie werden aus
|
||||
* der **Vorlage** gefuellt, in der er landet. Das steht als Hinweis an der Seite,
|
||||
* weil es sonst niemand erwartet.
|
||||
*/
|
||||
|
||||
export type BausteinEntwurf = {
|
||||
readonly bausteinId: string;
|
||||
readonly versionId: string;
|
||||
readonly version: number;
|
||||
readonly titel: string;
|
||||
readonly schritte: readonly {
|
||||
readonly titel: string;
|
||||
readonly hilfetext: string | null;
|
||||
readonly typ: EditorSchritt["typ"];
|
||||
readonly pflicht: boolean;
|
||||
readonly konfig: unknown;
|
||||
readonly dokuUrl: string | null;
|
||||
}[];
|
||||
/** Gibt es schon eine veröffentlichte Fassung? Dann darf ein Entwurf verworfen werden. */
|
||||
readonly darfVerwerfen: boolean;
|
||||
};
|
||||
|
||||
export function BausteinEditor({ entwurf }: { entwurf: BausteinEntwurf }) {
|
||||
// Über `alsEditorAufbau` statt von Hand: so bekommen die Schritte dieselbe
|
||||
// Aufbereitung wie im Vorlagen-Editor (leere Texte statt null, Konfig je Typ).
|
||||
const anfang = useMemo(
|
||||
() =>
|
||||
alsEditorAufbau({
|
||||
variablen: [],
|
||||
abschnitte: [
|
||||
{
|
||||
titel: entwurf.titel,
|
||||
// Ein Baustein kennt keine Abteilungseinschränkung: die gehört zur
|
||||
// Vorlage, in der er landet (#031).
|
||||
nurFuerAbteilungen: [],
|
||||
schritte: entwurf.schritte.map((schritt) => ({
|
||||
titel: schritt.titel,
|
||||
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
konfig: schritt.konfig as never,
|
||||
...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
}).abschnitte[0],
|
||||
[entwurf],
|
||||
);
|
||||
|
||||
const [titel, setzeTitel] = useState(anfang?.titel ?? "");
|
||||
const [schritte, setzeSchritte] = useState<readonly EditorSchritt[]>(anfang?.schritte ?? []);
|
||||
const [zuletzt, setzeZuletzt] = useState<string | null>(null);
|
||||
|
||||
const [speichern, speichernAktion, speichertGerade] = useActionState<AktionsErgebnis, FormData>(
|
||||
bausteinEntwurfSpeichernAktion,
|
||||
{},
|
||||
);
|
||||
const [veroeffentlichen, veroeffentlichenAktion, veroeffentlichtGerade] = useActionState<
|
||||
AktionsErgebnis,
|
||||
FormData
|
||||
>(bausteinVeroeffentlichenAktion, {});
|
||||
const [verwerfen, verwerfenAktion, verwirftGerade] = useActionState<AktionsErgebnis, FormData>(
|
||||
bausteinEntwurfVerwerfenAktion,
|
||||
{},
|
||||
);
|
||||
|
||||
const jetzt = JSON.stringify(
|
||||
alsStruktur({
|
||||
variablen: [],
|
||||
abschnitte: [{ kennung: "x", titel, schritte, nurFuerAbteilungen: [] }],
|
||||
}).abschnitte[0],
|
||||
);
|
||||
const vorher = JSON.stringify(
|
||||
alsStruktur({
|
||||
variablen: [],
|
||||
abschnitte: [anfang ?? { kennung: "x", titel: "", schritte: [], nurFuerAbteilungen: [] }],
|
||||
}).abschnitte[0],
|
||||
);
|
||||
const geaendert = jetzt !== vorher;
|
||||
const voll = schritte.length >= GRENZEN.schritteJeAbschnitt;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="rounded-karte border border-linie bg-flaeche p-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">Entwurf, Fassung {entwurf.version}</h2>
|
||||
{geaendert ? <span className="text-sm text-faellig">nicht gespeichert</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<Textfeld
|
||||
beschriftung="Titel des Abschnitts"
|
||||
wert={titel}
|
||||
setzen={setzeTitel}
|
||||
lesend={false}
|
||||
vorschlag="Datensicherung"
|
||||
/>
|
||||
<p className="max-w-prose text-sm text-nebentext">
|
||||
So heißt der Abschnitt später in jeder Vorlage, die diesen Baustein einsetzt. Platzhalter wie{" "}
|
||||
<code className="mono">{"{{mitarbeiter}}"}</code> darfst du benutzen — sie werden aus der Vorlage
|
||||
gefüllt, in der der Baustein landet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h2 className="text-lg font-semibold">Schritte</h2>
|
||||
{schritte.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-nebentext">
|
||||
Noch kein Schritt. Ein Baustein ohne Schritte lässt sich nicht veröffentlichen.
|
||||
</p>
|
||||
) : (
|
||||
<SortierBereich
|
||||
kennungen={schritte.map((schritt) => schritt.kennung)}
|
||||
art="Schritt"
|
||||
lesend={false}
|
||||
aufSortiert={(von, nach) => setzeSchritte(umsortieren(schritte, von, nach))}
|
||||
>
|
||||
<ul className="mt-4 flex flex-col gap-3">
|
||||
{schritte.map((schritt, stelle) => (
|
||||
<SortierZeile key={schritt.kennung} kennung={schritt.kennung} lesend={false}>
|
||||
{(anfasser) => (
|
||||
<SchrittKarte
|
||||
schritt={schritt}
|
||||
index={stelle}
|
||||
anzahl={schritte.length}
|
||||
lesend={false}
|
||||
neu={schritt.kennung === zuletzt}
|
||||
anfasser={anfasser}
|
||||
aendern={(teil) =>
|
||||
setzeSchritte(
|
||||
schritte.map((eintrag) =>
|
||||
eintrag.kennung === schritt.kennung ? { ...eintrag, ...teil } : eintrag,
|
||||
),
|
||||
)
|
||||
}
|
||||
loeschen={() =>
|
||||
setzeSchritte(schritte.filter((eintrag) => eintrag.kennung !== schritt.kennung))
|
||||
}
|
||||
aufSortiert={(von, nach) => setzeSchritte(umsortieren(schritte, von, nach))}
|
||||
/>
|
||||
)}
|
||||
</SortierZeile>
|
||||
))}
|
||||
</ul>
|
||||
</SortierBereich>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<TextKnopf
|
||||
beschriftung={voll ? `Höchstens ${GRENZEN.schritteJeAbschnitt} Schritte` : "Schritt hinzufügen"}
|
||||
aus={voll}
|
||||
aufKlick={() => {
|
||||
const schritt = neuerSchritt();
|
||||
setzeZuletzt(schritt.kennung);
|
||||
setzeSchritte([...schritte, schritt]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-5">
|
||||
<form action={speichernAktion} className="flex flex-wrap items-center gap-3">
|
||||
<input type="hidden" name="bausteinId" value={entwurf.bausteinId} />
|
||||
<input type="hidden" name="abschnitt" value={jetzt} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={speichertGerade}
|
||||
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||
>
|
||||
Entwurf speichern
|
||||
</button>
|
||||
{speichern.erfolg && !geaendert ? (
|
||||
<span role="status" className="text-sm text-erledigt">
|
||||
{speichern.erfolg}
|
||||
</span>
|
||||
) : null}
|
||||
{speichern.fehler ? (
|
||||
<span role="alert" className="text-sm text-fehler">
|
||||
{speichern.fehler}
|
||||
</span>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<form action={veroeffentlichenAktion} className="flex flex-wrap items-end gap-3">
|
||||
<input type="hidden" name="bausteinId" value={entwurf.bausteinId} />
|
||||
<input type="hidden" name="versionId" value={entwurf.versionId} />
|
||||
<label className="flex min-w-0 flex-1 basis-64 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Änderungsnotiz (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
name="aenderungsnotiz"
|
||||
placeholder="Was hat sich geändert?"
|
||||
className="min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm outline-none focus-visible:border-akzent md:min-h-10"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={veroeffentlichtGerade || geaendert}
|
||||
title={geaendert ? "Erst den Entwurf speichern" : undefined}
|
||||
className="min-h-11 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:opacity-40 md:min-h-10"
|
||||
>
|
||||
Veröffentlichen
|
||||
</button>
|
||||
{veroeffentlichen.fehler ? (
|
||||
<span role="alert" className="text-sm text-fehler">
|
||||
{veroeffentlichen.fehler}
|
||||
</span>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
{entwurf.darfVerwerfen ? (
|
||||
<div>
|
||||
{/* Kein Formular drumherum: der Knopf fragt erst nach und ruft die
|
||||
Action dann selbst — ein zweiter Absendeweg wäre einer zu viel. */}
|
||||
<LoeschKnopf
|
||||
beschriftung={verwirftGerade ? "wird verworfen …" : "Entwurf verwerfen"}
|
||||
frage="Entwurf verwerfen? Die veröffentlichte Fassung bleibt."
|
||||
aufLoeschen={() => {
|
||||
const formular = new FormData();
|
||||
formular.set("bausteinId", entwurf.bausteinId);
|
||||
formular.set("versionId", entwurf.versionId);
|
||||
verwerfenAktion(formular);
|
||||
}}
|
||||
/>
|
||||
{verwerfen.fehler ? (
|
||||
<p role="alert" className="mt-2 text-sm text-fehler">
|
||||
{verwerfen.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import {
|
||||
bausteinArchivierenAktion,
|
||||
bausteinBearbeitenAktion,
|
||||
bausteinEntwurfSpeichernAktion,
|
||||
type AktionsErgebnis,
|
||||
} from "@/server/actions/bausteine";
|
||||
|
||||
/**
|
||||
* Kopfdaten eines Bausteins und die beiden Wege daneben (#030).
|
||||
*
|
||||
* `nurEntwurfKnopf` ist der Fall „es gibt keinen Entwurf“: dann steht hier nur
|
||||
* der Knopf, der einen erzeugt. Er geht ueber dieselbe Action wie das Speichern
|
||||
* und schickt den aktuellen Stand der letzten Fassung mit — `bausteinEntwurf-
|
||||
* HolenOderErzeugen` legt den Entwurf als tiefe Kopie an, und ein zweiter Weg
|
||||
* dafuer waere eine zweite Regel.
|
||||
*/
|
||||
export function BausteinKopf({
|
||||
baustein,
|
||||
nurEntwurfKnopf = false,
|
||||
}: {
|
||||
baustein: {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly beschreibung: string;
|
||||
readonly archiviert: boolean;
|
||||
};
|
||||
nurEntwurfKnopf?: boolean;
|
||||
}) {
|
||||
const [kopf, kopfAktion, kopfLaeuft] = useActionState<AktionsErgebnis, FormData>(
|
||||
bausteinBearbeitenAktion,
|
||||
{},
|
||||
);
|
||||
const [archiv, archivAktion, archivLaeuft] = useActionState<AktionsErgebnis, FormData>(
|
||||
bausteinArchivierenAktion,
|
||||
{},
|
||||
);
|
||||
const [entwurf, entwurfAktion, entwurfLaeuft] = useActionState<AktionsErgebnis, FormData>(
|
||||
bausteinEntwurfSpeichernAktion,
|
||||
{},
|
||||
);
|
||||
|
||||
if (nurEntwurfKnopf) {
|
||||
return (
|
||||
<div className="rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h2 className="text-lg font-semibold">Ändern</h2>
|
||||
<p className="mt-1 max-w-prose text-sm text-nebentext">
|
||||
Eine veröffentlichte Fassung wird nie verändert. „Bearbeiten“ erzeugt daraus einen neuen Entwurf —
|
||||
eine tiefe Kopie, an der du arbeitest, bis du sie veröffentlichst.
|
||||
</p>
|
||||
<form action={entwurfAktion} className="mt-4">
|
||||
<input type="hidden" name="bausteinId" value={baustein.id} />
|
||||
{/* Leerer Aufbau: der Dienst legt den Entwurf als Kopie der letzten
|
||||
Fassung an, bevor er speichert. Das Schema verlangt einen Titel. */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="abschnitt"
|
||||
value={JSON.stringify({ titel: baustein.name, schritte: [] })}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={entwurfLaeuft}
|
||||
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||
>
|
||||
Entwurf anlegen
|
||||
</button>
|
||||
</form>
|
||||
{entwurf.fehler ? (
|
||||
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
|
||||
{entwurf.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h2 className="text-lg font-semibold">Kopfdaten</h2>
|
||||
<form action={kopfAktion} className="mt-4 flex flex-col gap-4">
|
||||
<input type="hidden" name="bausteinId" value={baustein.id} />
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
defaultValue={baustein.name}
|
||||
required
|
||||
className="min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm outline-none focus-visible:border-akzent md:min-h-10"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Beschreibung (optional)</span>
|
||||
<textarea
|
||||
name="beschreibung"
|
||||
defaultValue={baustein.beschreibung}
|
||||
rows={2}
|
||||
placeholder="Wofür ist dieser Baustein da?"
|
||||
className="rounded-karte border border-linie bg-grund px-3 py-2 text-sm outline-none focus-visible:border-akzent"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={kopfLaeuft}
|
||||
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
{kopf.erfolg ? (
|
||||
<span role="status" className="text-sm text-erledigt">
|
||||
{kopf.erfolg}
|
||||
</span>
|
||||
) : null}
|
||||
{kopf.fehler ? (
|
||||
<span role="alert" className="text-sm text-fehler">
|
||||
{kopf.fehler}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
action={archivAktion}
|
||||
className="mt-5 flex flex-wrap items-center gap-3 border-t border-linie pt-4"
|
||||
>
|
||||
<input type="hidden" name="bausteinId" value={baustein.id} />
|
||||
<input type="hidden" name="archiviert" value={baustein.archiviert ? "false" : "true"} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={archivLaeuft}
|
||||
className="min-h-11 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||
>
|
||||
{baustein.archiviert ? "wieder freigeben" : "archivieren"}
|
||||
</button>
|
||||
<span className="max-w-prose text-sm text-nebentext">
|
||||
{baustein.archiviert
|
||||
? "Danach lässt er sich wieder in Entwürfe einsetzen."
|
||||
: "Gelöscht wird nie: veröffentlichte Vorlagenversionen tragen seine Herkunft."}
|
||||
</span>
|
||||
{archiv.fehler ? (
|
||||
<span role="alert" className="max-w-prose text-sm text-fehler">
|
||||
{archiv.fehler}
|
||||
</span>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Mail, Send, Webhook } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FELD } from "@/lib/formular";
|
||||
import {
|
||||
meldungenSpeichernAktion,
|
||||
testmailAktion,
|
||||
type AktionsErgebnis,
|
||||
} from "@/server/actions/benachrichtigungen";
|
||||
|
||||
/**
|
||||
* SMTP und Webhook einrichten (#034).
|
||||
*
|
||||
* Zwei Dinge sind hier bewusst:
|
||||
*
|
||||
* 1. **Ein gesetztes Passwort wird nie angezeigt**, nur dass es eines gibt. Das
|
||||
* Feld bleibt leer, und leer heisst „unverändert lassen“ — sonst loeschte
|
||||
* jedes Speichern der uebrigen Felder das Passwort mit.
|
||||
* 2. **Die Testmail ist ein eigenes Formular** und geht direkt hinaus, nicht
|
||||
* ueber die Warteschlange. Ein Knopf, der „eingereiht“ meldet und den Fehler
|
||||
* eine Minute spaeter woanders zeigt, prueft nichts.
|
||||
*/
|
||||
|
||||
export type MeldungsWerte = {
|
||||
readonly smtpHost: string;
|
||||
readonly smtpPort: string;
|
||||
readonly smtpTls: boolean;
|
||||
readonly smtpBenutzer: string;
|
||||
readonly smtpAbsender: string;
|
||||
readonly smtpPasswortGesetzt: boolean;
|
||||
readonly webhookUrl: string;
|
||||
readonly webhookGeheimnisGesetzt: boolean;
|
||||
/** Ohne `APP_URL` traegt keine Meldung einen brauchbaren Link. */
|
||||
readonly appUrlFehlt: boolean;
|
||||
readonly eigeneMail: string;
|
||||
};
|
||||
|
||||
function Rueckmeldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
|
||||
if (ergebnis.fehler) {
|
||||
return (
|
||||
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (ergebnis.erfolg) {
|
||||
return (
|
||||
<p role="status" className="max-w-prose text-sm text-erledigt">
|
||||
{ergebnis.erfolg}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function BenachrichtigungenFormular({ werte }: { werte: MeldungsWerte }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(meldungenSpeichernAktion, {});
|
||||
const [testErgebnis, testAktion, testLaeuft] = useActionState<AktionsErgebnis, FormData>(
|
||||
testmailAktion,
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<section>
|
||||
<div className="flex flex-wrap items-baseline gap-3">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<Mail className="size-[18px] text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
Benachrichtigungen
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
|
||||
Vier Anlässe gehen hinaus: ein Lauf wird jemandem <strong>zugewiesen</strong>, ein Lauf ist{" "}
|
||||
<strong>überfällig</strong>, ein Lauf wird <strong>abgeschlossen</strong>, und ein{" "}
|
||||
<strong>Verzeichnisabgleich schlägt fehl</strong>. Die Nachricht enthält Titel, Status und einen
|
||||
Link — <strong>keine Schrittinhalte</strong>, und damit auch kein erzeugtes Passwort.
|
||||
</p>
|
||||
{werte.appUrlFehlt ? (
|
||||
<p role="alert" className="mb-4 max-w-prose text-sm text-faellig">
|
||||
<code className="mono">APP_URL</code> ist nicht gesetzt. Die Meldungen gehen trotzdem hinaus, aber
|
||||
der Link darin führt nirgendwohin.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<form action={aktion} className="flex flex-col gap-8">
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold">SMTP</h3>
|
||||
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
|
||||
Ohne Host geht keine Mail hinaus — die Webhook-Zustellung unten funktioniert davon unabhängig.
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Host</span>
|
||||
<input
|
||||
name="smtpHost"
|
||||
defaultValue={werte.smtpHost}
|
||||
placeholder="mail.firma.de"
|
||||
className={FELD}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Port</span>
|
||||
<input
|
||||
name="smtpPort"
|
||||
defaultValue={werte.smtpPort}
|
||||
inputMode="numeric"
|
||||
placeholder="587"
|
||||
className={FELD}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Benutzer</span>
|
||||
<input
|
||||
name="smtpBenutzer"
|
||||
defaultValue={werte.smtpBenutzer}
|
||||
autoComplete="off"
|
||||
className={FELD}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">
|
||||
Passwort
|
||||
{werte.smtpPasswortGesetzt ? (
|
||||
<span className="ml-2 text-xs">(gesetzt — leer lassen, um es zu behalten)</span>
|
||||
) : null}
|
||||
</span>
|
||||
<input
|
||||
name="smtpPasswort"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={werte.smtpPasswortGesetzt ? "unverändert" : ""}
|
||||
className={FELD}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Absender</span>
|
||||
<input
|
||||
name="smtpAbsender"
|
||||
defaultValue={werte.smtpAbsender}
|
||||
placeholder="laufzettel@firma.de"
|
||||
className={FELD}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex min-h-11 items-center gap-3 self-end text-sm md:min-h-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="smtpTls"
|
||||
value="an"
|
||||
defaultChecked={werte.smtpTls}
|
||||
className="size-4 accent-akzent"
|
||||
/>
|
||||
<span>
|
||||
TLS verlangen
|
||||
<span className="block text-xs text-nebentext">
|
||||
Port 465 verschlüsselt von Anfang an, 587 per StartTLS.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Webhook className="size-4 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
Webhook
|
||||
</h3>
|
||||
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
|
||||
Dieselben vier Anlässe als <code className="mono">POST</code> mit JSON. Mit Geheimnis trägt jede
|
||||
Anfrage eine HMAC-SHA256-Signatur im Kopf <code className="mono">X-Laufzettel-Signatur</code>; wie
|
||||
sie nachzurechnen ist, steht in <code className="mono">docs/betrieb/benachrichtigungen.md</code>.
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Adresse</span>
|
||||
<input
|
||||
name="webhookUrl"
|
||||
defaultValue={werte.webhookUrl}
|
||||
placeholder="https://hook.firma.de/laufzettel"
|
||||
className={FELD}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">
|
||||
Geheimnis
|
||||
{werte.webhookGeheimnisGesetzt ? (
|
||||
<span className="ml-2 text-xs">(gesetzt — leer lassen, um es zu behalten)</span>
|
||||
) : null}
|
||||
</span>
|
||||
<input
|
||||
name="webhookGeheimnis"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={werte.webhookGeheimnisGesetzt ? "unverändert" : "ohne Signatur"}
|
||||
className={FELD}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/*
|
||||
* Eigene Beschriftung, nicht bloss „Speichern“: auf dieser Seite gibt
|
||||
* es zwei Formulare, und zwei gleich benannte Knoepfe sind fuer einen
|
||||
* Menschen genauso verwirrend wie fuer einen Test.
|
||||
*/}
|
||||
<Button type="submit" disabled={laeuft}>
|
||||
{laeuft ? "Wird gespeichert …" : "Benachrichtigungen speichern"}
|
||||
</Button>
|
||||
<Rueckmeldung ergebnis={ergebnis} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={testAktion} className="flex flex-col gap-3 border-t border-linie pt-6">
|
||||
<h3 className="text-sm font-semibold">Testmail</h3>
|
||||
<p className="max-w-prose text-sm text-nebentext">
|
||||
Geht <strong>direkt</strong> hinaus, nicht über die Warteschlange — ein Fehler erscheint deshalb
|
||||
sofort und im Klartext. Gespeichert sein muss der Zugang vorher.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="flex max-w-md flex-1 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">An</span>
|
||||
<input name="an" type="email" defaultValue={werte.eigeneMail} className={FELD} />
|
||||
</label>
|
||||
<Button type="submit" variant="secondary" disabled={testLaeuft}>
|
||||
<Send className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||
{testLaeuft ? "Wird geschickt …" : "Testmail schicken"}
|
||||
</Button>
|
||||
</div>
|
||||
<Rueckmeldung ergebnis={testErgebnis} />
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Check, CircleSlash, Square, TriangleAlert, type LucideIcon } from "lucide-react";
|
||||
import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { Wortmarke } from "@/components/wortmarke";
|
||||
import { groesseText, istBild } from "@/lib/anhaenge";
|
||||
import type { DruckAbschnitt, DruckKopf, DruckSchritt, LaufZumDrucken } from "@/server/queries/druck";
|
||||
import type { KommentarZeile } from "@/server/queries/laeufe";
|
||||
|
||||
@@ -173,6 +174,51 @@ function Kommentare({ kommentare }: { kommentare: readonly KommentarZeile[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anhaenge auf dem Ausdruck (#032).
|
||||
*
|
||||
* Bilder als Miniatur, ein PDF nur als Zeile — ein eingebettetes PDF liesse sich
|
||||
* auf Papier ohnehin nicht ansehen. Die Datei kommt ueber ihre Adresse, nicht
|
||||
* eingebettet: der Ausdruck entsteht im Browser des Benutzers, und dessen
|
||||
* Sitzung laesst den Abruf ohnehin pruefen.
|
||||
*/
|
||||
function Anhaenge({ anhaenge }: { anhaenge: DruckSchritt["anhaenge"] }) {
|
||||
if (anhaenge.length === 0) return null;
|
||||
const bilder = anhaenge.filter((anhang) => istBild(anhang.medientyp));
|
||||
const dateien = anhaenge.filter((anhang) => !istBild(anhang.medientyp));
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{bilder.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{bilder.map((anhang) => (
|
||||
<li key={anhang.id} className="flex flex-col items-start gap-0.5">
|
||||
{/* Ohne next/image: der Optimierer holte die Datei erneut und ohne
|
||||
Rechteprüfung. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`/api/anhaenge/${anhang.id}`}
|
||||
alt={anhang.dateiname}
|
||||
className="h-24 w-auto rounded border border-linie object-contain"
|
||||
/>
|
||||
<span className="max-w-32 truncate text-[10px] text-nebentext">{anhang.dateiname}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{dateien.length > 0 ? (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{dateien.map((anhang) => (
|
||||
<li key={anhang.id} className="text-xs text-nebentext">
|
||||
Anhang: {anhang.dateiname} ({groesseText(anhang.groesse)})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Schritt({ schritt }: { schritt: DruckSchritt }) {
|
||||
const Symbol = ZUSTAND_SYMBOL[schritt.zustand];
|
||||
return (
|
||||
@@ -204,6 +250,7 @@ function Schritt({ schritt }: { schritt: DruckSchritt }) {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Anhaenge anhaenge={schritt.anhaenge} />
|
||||
<Kommentare kommentare={schritt.kommentare} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,34 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Holt alle zehn Sekunden den Zustand des Laufs (#013).
|
||||
* Haelt die Abarbeitungsansicht aktuell (#013, Strom seit #033).
|
||||
*
|
||||
* Damit sehen zwei Browser am selben Lauf die Arbeit des anderen — der Fall aus
|
||||
* dem Auftrag: zwei Techniker teilen sich ein Onboarding.
|
||||
*
|
||||
* Vier Entscheidungen stecken darin:
|
||||
* **Seit #033 ueber einen Strom**, nicht mehr alle zehn Sekunden. Die Datenbank
|
||||
* meldet die Aenderung, der Server schiebt sie weiter, und der Haken des
|
||||
* Kollegen steht in unter einer Sekunde auf dem anderen Schirm statt nach bis zu
|
||||
* zehn. Das Polling bleibt als **Rueckfall** — an derselben Adresse, mit
|
||||
* derselben Nutzlast.
|
||||
*
|
||||
* Fuenf Entscheidungen stecken darin:
|
||||
*
|
||||
* 1. **Pause bei verstecktem Tab.** Ein Lauf bleibt oft stundenlang offen im
|
||||
* Hintergrund; ohne Pause waeren das 360 Abfragen je Stunde und Tab, fuer
|
||||
* niemanden.
|
||||
* Hintergrund. Der Strom wird dabei **geschlossen**, nicht nur ignoriert:
|
||||
* eine offene Verbindung je Hintergrundlasche ist genau das, was die
|
||||
* Obergrenze im Handler verhindern soll.
|
||||
* 2. **`router.refresh()` statt eigener Zustandsverwaltung.** Die Seite ist eine
|
||||
* Server-Komponente; ein Neuladen ihrer Daten ist genau das, was gebraucht
|
||||
* wird, und es gibt keine zweite Wahrheit im Browser. Aktualisiert wird nur,
|
||||
* wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite alle zehn
|
||||
* Sekunden neu rendern und dabei Eingaben verlieren, an denen jemand tippt.
|
||||
* wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite bei jedem
|
||||
* Ereignis neu rendern und dabei Eingaben verlieren, an denen jemand tippt.
|
||||
* 3. **Steht ein generiertes Passwort auf der Seite, wird nicht von selbst
|
||||
* neu geladen**, sondern gefragt. Das Passwort entsteht bei jedem Aufbau der
|
||||
* Seite neu; ein Neuladen im Hintergrund tauschte es dem Techniker zwischen
|
||||
* Lesen und Einfuegen aus. Die Antwort des Pollings traegt deshalb keine
|
||||
* Befehle (siehe `laufZustand`) — und diese Zusage waere nichts wert, wenn
|
||||
* das Polling stattdessen die ganze Seite neu bauen liesse.
|
||||
* Lesen und Einfuegen aus. Die Nutzlast traegt deshalb keine Befehle (siehe
|
||||
* `laufZustand`) — und diese Zusage waere nichts wert, wenn die
|
||||
* Aktualisierung stattdessen die ganze Seite neu bauen liesse.
|
||||
* 4. **Abbruch bei 401 und 404.** Eine abgelaufene Anmeldung oder ein
|
||||
* verschwundener Lauf sind kein Grund weiterzufragen.
|
||||
* 5. **Der Rueckfall wird einmal gemeldet, nicht bei jedem Versuch.** Sonst
|
||||
* schreibt eine Seite hinter einem puffernden Proxy im Minutentakt dieselbe
|
||||
* Zeile ins Protokoll der Konsole.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Was ein geaenderter Zustand ausloesen soll.
|
||||
*
|
||||
@@ -40,6 +51,22 @@ export function reaktionAufAenderung(passwortSichtbar: boolean): "neu-laden" | "
|
||||
return passwortSichtbar ? "fragen" : "neu-laden";
|
||||
}
|
||||
|
||||
/** `EventSource.CLOSED`, ohne im Test ein `EventSource` zu brauchen. */
|
||||
export const STROM_GESCHLOSSEN = 2;
|
||||
|
||||
/**
|
||||
* Ist ein Fehler am Strom das Ende – oder nur eine Unterbrechung?
|
||||
*
|
||||
* `EventSource` verbindet sich nach einem Abbruch **selbst** wieder; dann steht
|
||||
* `readyState` auf `CONNECTING`, und zurueckzufallen waere verfruehtes Aufgeben.
|
||||
* Endgueltig ist nur `CLOSED` — so antwortet der Browser etwa, wenn die Antwort
|
||||
* nicht `text/event-stream` ist. Genau das schickt der Handler bei erreichter
|
||||
* Obergrenze, und genau dann soll gepollt werden.
|
||||
*/
|
||||
export function sollZurueckfallen(readyState: number): boolean {
|
||||
return readyState === STROM_GESCHLOSSEN;
|
||||
}
|
||||
|
||||
export function LaufAktualisierung({
|
||||
laufId,
|
||||
passwortSichtbar = false,
|
||||
@@ -57,29 +84,46 @@ export function LaufAktualisierung({
|
||||
const letzterStand = useRef<string | null>(null);
|
||||
const [neues, setzeNeues] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let abgebrochen = false;
|
||||
let zeitgeber: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function holen(): Promise<void> {
|
||||
if (abgebrochen || document.visibilityState === "hidden") {
|
||||
plane();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const antwort = await fetch(`/api/laeufe/${laufId}/zustand`, { cache: "no-store" });
|
||||
if (antwort.status === 401 || antwort.status === 404) {
|
||||
abgebrochen = true;
|
||||
return;
|
||||
}
|
||||
if (antwort.ok) {
|
||||
const stand = JSON.stringify(await antwort.json());
|
||||
/**
|
||||
* Ein gelesener Stand: vergleichen, und nur bei echter Aenderung reagieren.
|
||||
*
|
||||
* Dieselbe Stelle fuer Strom und Polling. Waere der Vergleich zweimal
|
||||
* geschrieben, koennten die beiden Wege unterschiedlich reagieren — und der
|
||||
* Rueckfall waere dann kein Rueckfall, sondern ein zweites Verhalten.
|
||||
*/
|
||||
const standVerarbeiten = useCallback(
|
||||
(stand: string): void => {
|
||||
if (letzterStand.current !== null && letzterStand.current !== stand) {
|
||||
if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true);
|
||||
else router.refresh();
|
||||
}
|
||||
letzterStand.current = stand;
|
||||
},
|
||||
[passwortSichtbar, router],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const adresse = `/api/laeufe/${laufId}/zustand`;
|
||||
let eingestellt = false;
|
||||
let quelle: EventSource | undefined;
|
||||
let zeitgeber: ReturnType<typeof setTimeout> | undefined;
|
||||
let polltJetzt = false;
|
||||
let rueckfallGemeldet = false;
|
||||
|
||||
/* ---------------- Polling: der Rueckfall ---------------- */
|
||||
|
||||
async function holen(): Promise<void> {
|
||||
if (eingestellt || document.visibilityState === "hidden") {
|
||||
plane();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const antwort = await fetch(adresse, { cache: "no-store" });
|
||||
if (antwort.status === 401 || antwort.status === 404) {
|
||||
eingestellt = true;
|
||||
return;
|
||||
}
|
||||
if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json()));
|
||||
} catch {
|
||||
// Netz weg, Rechner im Standby: einfach beim naechsten Mal wieder.
|
||||
}
|
||||
@@ -87,23 +131,99 @@ export function LaufAktualisierung({
|
||||
}
|
||||
|
||||
function plane(): void {
|
||||
if (abgebrochen) return;
|
||||
if (eingestellt || !polltJetzt) return;
|
||||
zeitgeber = setTimeout(() => void holen(), 10_000);
|
||||
}
|
||||
|
||||
function aufPollingUmstellen(grund: string): void {
|
||||
if (eingestellt || polltJetzt) return;
|
||||
polltJetzt = true;
|
||||
stromSchliessen();
|
||||
if (!rueckfallGemeldet) {
|
||||
rueckfallGemeldet = true;
|
||||
// Einmal, nicht bei jedem Versuch – siehe Kopfkommentar.
|
||||
console.info(`Live-Aktualisierung nicht moeglich (${grund}), es wird gepollt.`);
|
||||
}
|
||||
void holen();
|
||||
// Kommt der Tab zurueck, sofort fragen statt bis zu zehn Sekunden zu warten.
|
||||
const beiSichtbarkeit = () => {
|
||||
if (document.visibilityState === "visible") void holen();
|
||||
}
|
||||
|
||||
/* ---------------- Strom: der Normalfall ---------------- */
|
||||
|
||||
function stromSchliessen(): void {
|
||||
quelle?.close();
|
||||
quelle = undefined;
|
||||
}
|
||||
|
||||
function stromOeffnen(): void {
|
||||
if (eingestellt || polltJetzt || quelle || typeof EventSource === "undefined") return;
|
||||
const neu = new EventSource(adresse);
|
||||
quelle = neu;
|
||||
|
||||
neu.addEventListener("zustand", (ereignis) => {
|
||||
standVerarbeiten((ereignis as MessageEvent<string>).data);
|
||||
});
|
||||
|
||||
// Der Server sagt, dass es nichts mehr zu sehen gibt – nicht weiterfragen.
|
||||
neu.addEventListener("ende", () => {
|
||||
eingestellt = true;
|
||||
stromSchliessen();
|
||||
});
|
||||
|
||||
neu.addEventListener("error", () => {
|
||||
if (sollZurueckfallen(neu.readyState)) aufPollingUmstellen("Strom geschlossen");
|
||||
// Sonst: `EventSource` verbindet selbst wieder, hier ist nichts zu tun.
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Sichtbarkeit ---------------- */
|
||||
|
||||
const beiSichtbarkeit = (): void => {
|
||||
if (eingestellt) return;
|
||||
if (document.visibilityState === "hidden") {
|
||||
// Verbindung wirklich freigeben, nicht bloss ignorieren.
|
||||
stromSchliessen();
|
||||
if (zeitgeber) clearTimeout(zeitgeber);
|
||||
return;
|
||||
}
|
||||
/*
|
||||
* Zurueck im Vordergrund: erst den Stand holen, dann wieder zuhoeren.
|
||||
* Waehrend die Lasche weg war, kam keine Meldung an — ohne diesen Abruf
|
||||
* stuende der alte Stand da, bis das naechste Mal jemand etwas aendert.
|
||||
*/
|
||||
if (polltJetzt) void holen();
|
||||
else {
|
||||
stromOeffnen();
|
||||
void einmalHolen();
|
||||
}
|
||||
};
|
||||
|
||||
/** Ein einzelner Abruf, ohne das Polling einzuschalten. */
|
||||
async function einmalHolen(): Promise<void> {
|
||||
try {
|
||||
const antwort = await fetch(adresse, { cache: "no-store" });
|
||||
if (antwort.status === 401 || antwort.status === 404) {
|
||||
eingestellt = true;
|
||||
stromSchliessen();
|
||||
return;
|
||||
}
|
||||
if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json()));
|
||||
} catch {
|
||||
// Der Strom liefert den Stand ohnehin gleich mit.
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof EventSource === "undefined") aufPollingUmstellen("kein EventSource");
|
||||
else stromOeffnen();
|
||||
|
||||
document.addEventListener("visibilitychange", beiSichtbarkeit);
|
||||
|
||||
return () => {
|
||||
abgebrochen = true;
|
||||
eingestellt = true;
|
||||
stromSchliessen();
|
||||
if (zeitgeber) clearTimeout(zeitgeber);
|
||||
document.removeEventListener("visibilitychange", beiSichtbarkeit);
|
||||
};
|
||||
}, [laufId, router, passwortSichtbar]);
|
||||
}, [laufId, standVerarbeiten]);
|
||||
|
||||
// Ohne Passwort auf der Seite ist die Wirkung unsichtbar: sie erneuert sich.
|
||||
if (!neues) return null;
|
||||
|
||||
@@ -51,6 +51,8 @@ export function LaufStarten({
|
||||
const [mitarbeiterId, setzeMitarbeiter] = useState(vorbelegung.mitarbeiterId ?? "");
|
||||
const [freitext, setzeFreitext] = useState("");
|
||||
const [kundeId, setzeKunde] = useState("");
|
||||
/** Nur fuer den Hinweis, welche Abschnitte die Abteilung hinzunimmt (#031). */
|
||||
const [abteilungId, setzeAbteilung] = useState("");
|
||||
const [variablen, setzeVariablen] = useState<Record<string, string>>({});
|
||||
|
||||
const vorlage = useMemo(
|
||||
@@ -58,6 +60,30 @@ export function LaufStarten({
|
||||
[vorlagen, versionId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Welche Abschnitte die gewaehlte Abteilung hinzunimmt — und welche fuer
|
||||
* andere gelten und deshalb wegbleiben (#031).
|
||||
*
|
||||
* Verglichen wird wie im Dienst: ohne Ruecksicht auf Gross- und
|
||||
* Kleinschreibung. Wuerde hier anders verglichen als dort, zeigte der Dialog
|
||||
* etwas anderes an, als der Lauf danach enthaelt.
|
||||
*/
|
||||
const { zusatzAbschnitte, nurFuerAndere } = useMemo(() => {
|
||||
const eingeschraenkt = vorlage?.abteilungsAbschnitte ?? [];
|
||||
const name = auswahl.abteilungen
|
||||
.find((abteilung) => abteilung.id === abteilungId)
|
||||
?.name.trim()
|
||||
.toLocaleLowerCase("de");
|
||||
const passt = (abteilungen: readonly string[]): boolean =>
|
||||
name !== undefined && abteilungen.some((eintrag) => eintrag.trim().toLocaleLowerCase("de") === name);
|
||||
return {
|
||||
zusatzAbschnitte: eingeschraenkt
|
||||
.filter((abschnitt) => passt(abschnitt.abteilungen))
|
||||
.map((abschnitt) => abschnitt.titel),
|
||||
nurFuerAndere: eingeschraenkt.filter((abschnitt) => !passt(abschnitt.abteilungen)),
|
||||
};
|
||||
}, [vorlage, abteilungId, auswahl.abteilungen]);
|
||||
|
||||
// Nach dem Anlegen direkt in den Lauf – wer ihn startet, will ihn abarbeiten.
|
||||
useEffect(() => {
|
||||
if (ergebnis.laufId) router.push(`/laeufe/${ergebnis.laufId}`);
|
||||
@@ -284,7 +310,12 @@ export function LaufStarten({
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Abteilung</span>
|
||||
<select name="abteilungId" defaultValue="" className={FELD}>
|
||||
<select
|
||||
name="abteilungId"
|
||||
value={abteilungId}
|
||||
onChange={(ereignis) => setzeAbteilung(ereignis.target.value)}
|
||||
className={FELD}
|
||||
>
|
||||
<option value="">keine</option>
|
||||
{auswahl.abteilungen.map((abteilung) => (
|
||||
<option key={abteilung.id} value={abteilung.id}>
|
||||
@@ -292,6 +323,26 @@ export function LaufStarten({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/*
|
||||
Vorher sagen, was die Abteilung hinzunimmt (#031). Ohne das wäre
|
||||
die Zahl der Schritte eine Überraschung, und wer sich bei der
|
||||
Abteilung vertippt, merkt es erst in der Abarbeitung.
|
||||
*/}
|
||||
{zusatzAbschnitte.length > 0 ? (
|
||||
<span className="text-xs text-nebentext">
|
||||
Nimmt{" "}
|
||||
{zusatzAbschnitte.length === 1
|
||||
? "einen Abschnitt"
|
||||
: `${zusatzAbschnitte.length} Abschnitte`}{" "}
|
||||
hinzu: {zusatzAbschnitte.join(", ")}
|
||||
</span>
|
||||
) : nurFuerAndere.length > 0 ? (
|
||||
<span className="text-xs text-nebentext">
|
||||
{nurFuerAndere.length === 1
|
||||
? "Ein Abschnitt gilt nur für andere Abteilungen und bleibt weg."
|
||||
: `${nurFuerAndere.length} Abschnitte gelten nur für andere Abteilungen und bleiben weg.`}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Zuweisen an</span>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useRef, useState } from "react";
|
||||
import { Camera, FileText, Paperclip, X } from "lucide-react";
|
||||
import { ERLAUBTE_MEDIENTYPEN, groesseText, istBild, maxGroesseBytes } from "@/lib/anhaenge";
|
||||
import {
|
||||
anhangEntfernenAktion,
|
||||
anhangHochladenAktion,
|
||||
type AktionsErgebnis,
|
||||
} from "@/server/actions/anhaenge";
|
||||
|
||||
/**
|
||||
* Anhaenge eines Schritts (#032).
|
||||
*
|
||||
* Zwei Wege zum Anhaengen, weil zwei Geraete gemeint sind: **Foto** oeffnet am
|
||||
* Handy direkt die Kamera (`capture="environment"`), **Datei** den Dateiwaehler.
|
||||
* Am Rechner tun beide dasselbe — dort gibt es keine Kamera-App, und der Browser
|
||||
* zeigt in beiden Faellen den Waehler.
|
||||
*
|
||||
* Abgeschickt wird sofort nach der Wahl, ohne zweiten Knopf: ein Foto, das man
|
||||
* gerade gemacht hat, will man nicht noch bestaetigen. Ein eigener
|
||||
* „Hochladen“-Knopf waere der Schritt, den man vergisst.
|
||||
*/
|
||||
|
||||
export type SchrittAnhang = {
|
||||
readonly id: string;
|
||||
readonly dateiname: string;
|
||||
readonly medientyp: string;
|
||||
readonly groesse: number;
|
||||
readonly erstelltVon: string | null;
|
||||
};
|
||||
|
||||
export function SchrittAnhaenge({
|
||||
laufId,
|
||||
vorlagenSchrittId,
|
||||
anhaenge,
|
||||
lesend,
|
||||
}: {
|
||||
laufId: string;
|
||||
vorlagenSchrittId: string;
|
||||
anhaenge: readonly SchrittAnhang[];
|
||||
lesend: boolean;
|
||||
}) {
|
||||
const [hochladen, hochladenAktion, laedt] = useActionState<AktionsErgebnis, FormData>(
|
||||
anhangHochladenAktion,
|
||||
{},
|
||||
);
|
||||
const [entfernen, entfernenAktion] = useActionState<AktionsErgebnis, FormData>(anhangEntfernenAktion, {});
|
||||
const formular = useRef<HTMLFormElement>(null);
|
||||
const [zuGross, setzeZuGross] = useState<string | null>(null);
|
||||
|
||||
function gewaehlt(ereignis: React.ChangeEvent<HTMLInputElement>): void {
|
||||
const datei = ereignis.target.files?.[0];
|
||||
if (!datei) return;
|
||||
/*
|
||||
* Die Groesse schon hier pruefen. Sonst wandern bei einem Fehlgriff 200 MB
|
||||
* durch die Leitung, nur damit der Server sie ablehnt — am Handy im
|
||||
* Mobilfunk ist das der Unterschied zwischen einer Meldung und einer
|
||||
* Minute Warten. Dienst und Action pruefen es trotzdem noch einmal.
|
||||
*/
|
||||
if (datei.size > maxGroesseBytes()) {
|
||||
setzeZuGross(
|
||||
`„${datei.name}“ ist ${groesseText(datei.size)} groß. Erlaubt sind höchstens ` +
|
||||
`${groesseText(maxGroesseBytes())}.`,
|
||||
);
|
||||
ereignis.target.value = "";
|
||||
return;
|
||||
}
|
||||
setzeZuGross(null);
|
||||
formular.current?.requestSubmit();
|
||||
}
|
||||
|
||||
const meldung = zuGross ?? hochladen.fehler ?? entfernen.fehler;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{anhaenge.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{anhaenge.map((anhang) => (
|
||||
<li key={anhang.id} className="flex flex-col gap-1">
|
||||
<a
|
||||
href={`/api/anhaenge/${anhang.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={`${anhang.dateiname} · ${groesseText(anhang.groesse)}${
|
||||
anhang.erstelltVon ? ` · ${anhang.erstelltVon}` : ""
|
||||
}`}
|
||||
className="flex items-center gap-2 rounded-karte border border-linie bg-flaeche-hoch p-1.5"
|
||||
>
|
||||
{istBild(anhang.medientyp) ? (
|
||||
// Das Bild selbst als Miniatur – ohne next/image, weil der
|
||||
// Optimierer die Datei erneut und ohne Rechteprüfung holen
|
||||
// würde.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/anhaenge/${anhang.id}`}
|
||||
alt={anhang.dateiname}
|
||||
className="size-14 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-14 items-center justify-center rounded bg-flaeche">
|
||||
<FileText className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<span className="flex min-w-0 max-w-40 flex-col text-xs">
|
||||
<span className="truncate">{anhang.dateiname}</span>
|
||||
<span className="text-nebentext">{groesseText(anhang.groesse)}</span>
|
||||
</span>
|
||||
</a>
|
||||
{lesend ? null : (
|
||||
<form action={entfernenAktion}>
|
||||
<input type="hidden" name="anhangId" value={anhang.id} />
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={`${anhang.dateiname} entfernen`}
|
||||
className="flex min-h-11 items-center gap-1 text-xs text-nebentext hover:text-fehler md:min-h-8"
|
||||
>
|
||||
<X className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
entfernen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{lesend ? null : (
|
||||
<form ref={formular} action={hochladenAktion} className="flex flex-wrap items-center gap-2">
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
<input type="hidden" name="vorlagenSchrittId" value={vorlagenSchrittId} />
|
||||
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9">
|
||||
<Camera className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Foto
|
||||
<input
|
||||
type="file"
|
||||
name="datei"
|
||||
accept={ERLAUBTE_MEDIENTYPEN.join(",")}
|
||||
capture="environment"
|
||||
onChange={gewaehlt}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9">
|
||||
<Paperclip className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Datei
|
||||
<input
|
||||
type="file"
|
||||
name="datei"
|
||||
accept={ERLAUBTE_MEDIENTYPEN.join(",")}
|
||||
onChange={gewaehlt}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{laedt ? <span className="text-sm text-nebentext">wird hochgeladen …</span> : null}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{meldung ? (
|
||||
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||
{meldung}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { vorgesetztenSetzenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
|
||||
|
||||
/**
|
||||
* Vorgesetzten setzen oder entfernen (#020).
|
||||
*
|
||||
* Ein Auswahlfeld und ein Knopf, kein eigener Bearbeiten-Modus: die Zuordnung
|
||||
* ist eine einzelne Angabe, und sie zu aendern soll nicht erst ein Formular
|
||||
* aufklappen.
|
||||
*
|
||||
* Die leere Option heisst ausdruecklich „niemand“ und nicht „—“: sie ist eine
|
||||
* Angabe, nicht das Fehlen einer. Wer sie waehlt und speichert, entfernt die
|
||||
* Zuordnung.
|
||||
*/
|
||||
export function MitarbeiterVorgesetzter({
|
||||
mitarbeiterId,
|
||||
vorgesetzterId,
|
||||
auswahl,
|
||||
ausDemVerzeichnis,
|
||||
}: {
|
||||
mitarbeiterId: string;
|
||||
vorgesetzterId: string | null;
|
||||
auswahl: readonly { readonly id: string; readonly anzeigename: string }[];
|
||||
ausDemVerzeichnis: boolean;
|
||||
}) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorgesetztenSetzenAktion, {});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form action={aktion} className="flex flex-wrap items-end gap-2">
|
||||
<input type="hidden" name="mitarbeiterId" value={mitarbeiterId} />
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1 text-sm">
|
||||
<span className="text-nebentext">Vorgesetzter</span>
|
||||
<select
|
||||
name="vorgesetzterId"
|
||||
defaultValue={vorgesetzterId ?? ""}
|
||||
className="min-h-11 w-full rounded-pille border border-linie bg-flaeche-hoch px-4 text-sm md:min-h-10"
|
||||
>
|
||||
<option value="">niemand</option>
|
||||
{auswahl.map((person) => (
|
||||
<option key={person.id} value={person.id}>
|
||||
{person.anzeigename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{ausDemVerzeichnis ? (
|
||||
<p className="mt-2 max-w-prose text-sm text-nebentext">
|
||||
Diese Person kommt aus dem Verzeichnis. Liefert es einen Vorgesetzten, setzt der nächste Abgleich
|
||||
die Zuordnung dort wieder nach seinem Stand; liefert es keinen, bleibt die hier gesetzte Zuordnung
|
||||
stehen.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
{ergebnis.erfolg ? (
|
||||
<p role="status" className="mt-2 text-sm text-erledigt">
|
||||
{ergebnis.erfolg}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Link from "next/link";
|
||||
import { Search } from "lucide-react";
|
||||
import { FELD } from "@/lib/formular";
|
||||
|
||||
/**
|
||||
* Filter des Protokolls (#023).
|
||||
*
|
||||
* Ein GET-Formular, kein React-State: eine gesetzte Suche ist verlinkbar, der
|
||||
* Zurueck-Knopf tut das Erwartete, und die Seite bleibt eine Server-Komponente.
|
||||
* Dieselbe Entscheidung wie beim Archiv (#015).
|
||||
*/
|
||||
|
||||
export type ProtokollFilterfelder = {
|
||||
readonly suche: string;
|
||||
readonly urheber: string;
|
||||
readonly von: string;
|
||||
readonly bis: string;
|
||||
};
|
||||
|
||||
export function ProtokollFilter({
|
||||
felder,
|
||||
urheber,
|
||||
}: {
|
||||
felder: ProtokollFilterfelder;
|
||||
urheber: readonly { readonly id: string; readonly name: string }[];
|
||||
}) {
|
||||
const gesetzt = Boolean(felder.suche || felder.urheber || felder.von || felder.bis);
|
||||
|
||||
return (
|
||||
<form method="get" className="mt-6 flex flex-col gap-3">
|
||||
{/* Beim Filtern immer auf Seite 1: die alte Seitenzahl zeigte sonst ins
|
||||
Leere, wenn die neue Auswahl kuerzer ist. */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="flex min-w-0 flex-1 basis-64 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Aktion oder Lauf</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="size-4 shrink-0 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
name="suche"
|
||||
defaultValue={felder.suche}
|
||||
placeholder="Rolle geändert, archiviert …"
|
||||
className={`${FELD} w-full`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-0 basis-56 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Urheber</span>
|
||||
<select name="urheber" defaultValue={felder.urheber} className={`${FELD} w-full`}>
|
||||
<option value="">alle</option>
|
||||
{urheber.map((eintrag) => (
|
||||
<option key={eintrag.id} value={eintrag.id}>
|
||||
{eintrag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-0 basis-40 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">von</span>
|
||||
<input type="date" name="von" defaultValue={felder.von} className={`${FELD} w-full`} />
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-0 basis-40 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">bis</span>
|
||||
<input type="date" name="bis" defaultValue={felder.bis} className={`${FELD} w-full`} />
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="min-h-11 shrink-0 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||
>
|
||||
Filtern
|
||||
</button>
|
||||
|
||||
{gesetzt ? (
|
||||
<Link
|
||||
href="/admin/protokoll"
|
||||
className="flex min-h-11 shrink-0 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||
>
|
||||
zurücksetzen
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import Link from "next/link";
|
||||
import { Bot } from "lucide-react";
|
||||
import type { ProtokollZeile } from "@/server/queries/protokoll";
|
||||
|
||||
/**
|
||||
* Die Einträge des Protokolls (#023).
|
||||
*
|
||||
* Keine Tabelle, sondern eine Liste aus Karten. Eine Tabelle mit sieben Spalten
|
||||
* ist bei 390 px nicht darstellbar, ohne horizontal zu scrollen — und genau das
|
||||
* ist ausgeschlossen (CLAUDE.md).
|
||||
*
|
||||
* `vorher`/`nachher` stehen in einem `details`: sie sind Json und koennen lang
|
||||
* werden. Zugeklappt bleibt die Liste lesbar, aufgeklappt steht alles da.
|
||||
*/
|
||||
|
||||
const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
/** Json lesbar, ohne Anführungszeichen-Wald für den häufigen Fall eines Objekts. */
|
||||
function alsText(wert: unknown): string {
|
||||
if (wert === null || wert === undefined) return "";
|
||||
if (typeof wert === "object" && !Array.isArray(wert)) {
|
||||
const paare = Object.entries(wert as Record<string, unknown>).map(
|
||||
([schluessel, inhalt]) =>
|
||||
`${schluessel}: ${typeof inhalt === "object" ? JSON.stringify(inhalt) : String(inhalt)}`,
|
||||
);
|
||||
return paare.join(" · ");
|
||||
}
|
||||
return JSON.stringify(wert);
|
||||
}
|
||||
|
||||
export function ProtokollListe({
|
||||
zeilen,
|
||||
kundenEbene,
|
||||
}: {
|
||||
zeilen: readonly ProtokollZeile[];
|
||||
kundenEbene: boolean;
|
||||
}) {
|
||||
if (zeilen.length === 0) {
|
||||
return (
|
||||
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
|
||||
Kein Eintrag passt zu diesen Filtern.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="mt-4 flex flex-col gap-2">
|
||||
{zeilen.map((zeile) => {
|
||||
const vorher = alsText(zeile.vorher);
|
||||
const nachher = alsText(zeile.nachher);
|
||||
return (
|
||||
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche p-4">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="mono shrink-0 text-xs text-nebentext">
|
||||
{ZEITPUNKT.format(zeile.erstelltAm)}
|
||||
</span>
|
||||
<span className="min-w-0 font-semibold break-words">{zeile.aktion}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-nebentext">
|
||||
{zeile.urheber ? (
|
||||
<span>{zeile.urheber.name}</span>
|
||||
) : (
|
||||
/* Ein Systemlauf ist keine Lücke – der Zeitplan des
|
||||
Verzeichnisabgleichs hat keinen Menschen hinter sich (#023). */
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Bot className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Systemlauf
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs">{zeile.entitaet}</span>
|
||||
{zeile.laufId && zeile.laufTitel ? (
|
||||
<Link
|
||||
href={`/laeufe/${zeile.laufId}`}
|
||||
className="min-w-0 break-words text-akzent underline underline-offset-2"
|
||||
>
|
||||
{zeile.laufTitel}
|
||||
</Link>
|
||||
) : null}
|
||||
{kundenEbene && zeile.kundeName ? <span>· {zeile.kundeName}</span> : null}
|
||||
</div>
|
||||
|
||||
{vorher || nachher ? (
|
||||
<details className="mt-2">
|
||||
<summary className="inline-flex min-h-11 cursor-pointer items-center text-sm text-nebentext hover:text-text md:min-h-0">
|
||||
Werte
|
||||
</summary>
|
||||
<dl className="mt-1 flex flex-col gap-1 text-sm">
|
||||
{vorher ? (
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
<dt className="shrink-0 text-nebentext">vorher</dt>
|
||||
<dd className="mono min-w-0 break-words">{vorher}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{nachher ? (
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
<dt className="shrink-0 text-nebentext">nachher</dt>
|
||||
<dd className="mono min-w-0 break-words">{nachher}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</details>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Plus, X } from "lucide-react";
|
||||
import type { GeraeteTyp } from "@/generated/prisma/enums";
|
||||
import { GERAETE_TYP_TEXT } from "@/lib/geraete-text";
|
||||
import { schnellstartSpeichernAktion, type AktionsErgebnis } from "@/server/actions/schnellstart";
|
||||
|
||||
export type Eintrag = {
|
||||
readonly vorlageId: string;
|
||||
readonly name: string;
|
||||
readonly kategorie: string | null;
|
||||
readonly kundeName: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Schnellstart eines Geraetetyps pflegen (#022).
|
||||
*
|
||||
* Die Reihenfolge laeuft ueber Hoch und Runter, nicht ueber Drag & Drop. Der
|
||||
* Editor benutzt dnd-kit, weil dort hundert Schritte in Abschnitten liegen; hier
|
||||
* sind es zwei bis vier Kacheln, und zwei Knoepfe sind am Handy verlaesslicher
|
||||
* als eine Ziehgeste neben einer scrollenden Seite.
|
||||
*
|
||||
* Gespeichert wird die **ganze** Liste als ein Feld, eine Kennung je Zeile. So
|
||||
* gibt es eine Wahrheit ueber die Reihenfolge: die des Formulars.
|
||||
*/
|
||||
export function SchnellstartPflege({
|
||||
typ,
|
||||
zugewiesen,
|
||||
auswahl,
|
||||
}: {
|
||||
typ: GeraeteTyp;
|
||||
zugewiesen: readonly Eintrag[];
|
||||
auswahl: readonly Eintrag[];
|
||||
}) {
|
||||
const [liste, setListe] = useState<readonly Eintrag[]>(zugewiesen);
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
|
||||
schnellstartSpeichernAktion,
|
||||
{},
|
||||
);
|
||||
|
||||
const offen = auswahl.filter(
|
||||
(eintrag) => !liste.some((gewaehlt) => gewaehlt.vorlageId === eintrag.vorlageId),
|
||||
);
|
||||
|
||||
function verschieben(stelle: number, richtung: -1 | 1) {
|
||||
const ziel = stelle + richtung;
|
||||
if (ziel < 0 || ziel >= liste.length) return;
|
||||
const kopie = [...liste];
|
||||
const [heraus] = kopie.splice(stelle, 1);
|
||||
if (heraus) kopie.splice(ziel, 0, heraus);
|
||||
setListe(kopie);
|
||||
}
|
||||
|
||||
const geaendert =
|
||||
liste.length !== zugewiesen.length ||
|
||||
liste.some((eintrag, stelle) => eintrag.vorlageId !== zugewiesen[stelle]?.vorlageId);
|
||||
|
||||
return (
|
||||
<section className="rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h3 className="text-lg font-semibold">{GERAETE_TYP_TEXT[typ]}</h3>
|
||||
|
||||
{liste.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-nebentext">
|
||||
Kein Schnellstart. Auf der Geräteseite erscheint dann nur „Vorlage zuweisen“.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="mt-3 flex flex-col gap-2">
|
||||
{liste.map((eintrag, stelle) => (
|
||||
<li
|
||||
key={eintrag.vorlageId}
|
||||
className="flex flex-wrap items-center gap-2 border-t border-linie py-2 first:border-t-0"
|
||||
>
|
||||
<span className="mono w-7 shrink-0 text-xs text-nebentext">
|
||||
{String(stelle + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 break-words">{eintrag.name}</span>
|
||||
{eintrag.kundeName ? (
|
||||
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
|
||||
nur {eintrag.kundeName}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => verschieben(stelle, -1)}
|
||||
disabled={stelle === 0}
|
||||
aria-label={`${eintrag.name} nach oben`}
|
||||
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:opacity-40 md:size-10"
|
||||
>
|
||||
<ArrowUp className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => verschieben(stelle, 1)}
|
||||
disabled={stelle === liste.length - 1}
|
||||
aria-label={`${eintrag.name} nach unten`}
|
||||
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:opacity-40 md:size-10"
|
||||
>
|
||||
<ArrowDown className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setListe(liste.filter((andere) => andere.vorlageId !== eintrag.vorlageId))}
|
||||
aria-label={`${eintrag.name} entfernen`}
|
||||
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-fehler md:size-10"
|
||||
>
|
||||
<X className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{offen.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap items-end gap-2">
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1 text-sm">
|
||||
<span className="text-nebentext">Vorlage hinzufügen</span>
|
||||
<select
|
||||
aria-label={`Vorlage für ${GERAETE_TYP_TEXT[typ]} hinzufügen`}
|
||||
value=""
|
||||
onChange={(ereignis) => {
|
||||
const gewaehlt = offen.find((eintrag) => eintrag.vorlageId === ereignis.target.value);
|
||||
if (gewaehlt) setListe([...liste, gewaehlt]);
|
||||
}}
|
||||
className="min-h-11 w-full rounded-pille border border-linie bg-flaeche-hoch px-4 text-sm md:min-h-10"
|
||||
>
|
||||
<option value="">auswählen …</option>
|
||||
{offen.map((eintrag) => (
|
||||
<option key={eintrag.vorlageId} value={eintrag.vorlageId}>
|
||||
{eintrag.name}
|
||||
{eintrag.kundeName ? ` (nur ${eintrag.kundeName})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Plus className="mb-3 size-4 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={aktion} className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<input type="hidden" name="geraeteTyp" value={typ} />
|
||||
<input type="hidden" name="vorlageIds" value={liste.map((eintrag) => eintrag.vorlageId).join("\n")} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
{geaendert ? <span className="text-sm text-faellig">nicht gespeichert</span> : null}
|
||||
</form>
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
{ergebnis.erfolg && !geaendert ? (
|
||||
<p role="status" className="mt-2 text-sm text-erledigt">
|
||||
{ergebnis.erfolg}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { KAESTCHEN } from "@/lib/formular";
|
||||
|
||||
/**
|
||||
* Fuer welche Abteilungen ein Abschnitt gilt (#031).
|
||||
*
|
||||
* Kein `<select multiple>`: das ist am Handy kaum bedienbar und zeigt nicht, was
|
||||
* gewaehlt ist, ohne zu scrollen. Stattdessen ein Kaestchen je gepflegter
|
||||
* Abteilung und darunter ein Freitextfeld — der Abschnitt haelt **Namen**, und
|
||||
* eine Abteilung, die es noch nicht gibt, soll sich schon nennen lassen.
|
||||
*
|
||||
* Ein Name, der gewaehlt ist, aber in keiner gepflegten Abteilung vorkommt,
|
||||
* erscheint als eigene Zeile mit Entfernen-Knopf. Sonst waere er unsichtbar und
|
||||
* beim naechsten Speichern still weg.
|
||||
*/
|
||||
export function AbteilungenWaehlen({
|
||||
gewaehlt,
|
||||
bekannte,
|
||||
lesend,
|
||||
setzen,
|
||||
}: {
|
||||
gewaehlt: readonly string[];
|
||||
bekannte: readonly string[];
|
||||
lesend: boolean;
|
||||
setzen: (namen: readonly string[]) => void;
|
||||
}) {
|
||||
const [eingabe, setzeEingabe] = useState("");
|
||||
|
||||
const kleinGewaehlt = new Set(gewaehlt.map((name) => name.trim().toLocaleLowerCase("de")));
|
||||
const fremde = gewaehlt.filter(
|
||||
(name) => !bekannte.some((bekannt) => bekannt.toLocaleLowerCase("de") === name.toLocaleLowerCase("de")),
|
||||
);
|
||||
|
||||
function umschalten(name: string, an: boolean): void {
|
||||
if (an) {
|
||||
if (kleinGewaehlt.has(name.trim().toLocaleLowerCase("de"))) return;
|
||||
setzen([...gewaehlt, name]);
|
||||
} else {
|
||||
setzen(
|
||||
gewaehlt.filter(
|
||||
(eintrag) => eintrag.trim().toLocaleLowerCase("de") !== name.trim().toLocaleLowerCase("de"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hinzufuegen(): void {
|
||||
const name = eingabe.trim();
|
||||
if (!name) return;
|
||||
setzeEingabe("");
|
||||
umschalten(name, true);
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm text-nebentext">Nur für diese Abteilungen</legend>
|
||||
<p className="max-w-prose text-sm text-nebentext">
|
||||
{gewaehlt.length === 0
|
||||
? "Keine Einschränkung — der Abschnitt gilt für jeden Lauf."
|
||||
: "Der Abschnitt erscheint nur in Läufen mit einer dieser Abteilungen. Ein Lauf ohne Abteilung bekommt ihn nicht."}
|
||||
</p>
|
||||
|
||||
{bekannte.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{bekannte.map((name) => (
|
||||
<label key={name} className="flex min-h-11 items-center gap-2 text-sm md:min-h-9">
|
||||
<input
|
||||
type="checkbox"
|
||||
className={KAESTCHEN}
|
||||
disabled={lesend}
|
||||
checked={kleinGewaehlt.has(name.toLocaleLowerCase("de"))}
|
||||
onChange={(ereignis) => umschalten(name, ereignis.target.checked)}
|
||||
/>
|
||||
{name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{fremde.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{fremde.map((name) => (
|
||||
<li
|
||||
key={name}
|
||||
className="inline-flex items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs"
|
||||
>
|
||||
{name}
|
||||
{lesend ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => umschalten(name, false)}
|
||||
aria-label={`${name} entfernen`}
|
||||
className="text-nebentext hover:text-fehler"
|
||||
>
|
||||
<X className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{lesend ? null : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={eingabe}
|
||||
onChange={(ereignis) => setzeEingabe(ereignis.target.value)}
|
||||
onKeyDown={(ereignis) => {
|
||||
// Enter fügt hinzu, statt das Formular abzuschicken – der Editor
|
||||
// speichert über einen eigenen Knopf.
|
||||
if (ereignis.key === "Enter") {
|
||||
ereignis.preventDefault();
|
||||
hinzufuegen();
|
||||
}
|
||||
}}
|
||||
placeholder="Abteilung, die es noch nicht gibt"
|
||||
aria-label="Weitere Abteilung"
|
||||
className="min-h-11 min-w-0 flex-1 rounded-karte border border-linie bg-grund px-3 text-sm outline-none focus-visible:border-akzent md:min-h-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={hinzufuegen}
|
||||
disabled={eingabe.trim() === ""}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:opacity-40 md:min-h-9"
|
||||
>
|
||||
<Plus className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Blocks } from "lucide-react";
|
||||
import { AbteilungenWaehlen } from "@/components/vorlagen/abteilungen-waehlen";
|
||||
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
|
||||
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
|
||||
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
|
||||
@@ -29,6 +31,7 @@ export function AbschnittKarte({
|
||||
index,
|
||||
anzahl,
|
||||
lesend,
|
||||
abteilungen,
|
||||
zuletztAngelegt,
|
||||
anfasser,
|
||||
aendern,
|
||||
@@ -43,6 +46,8 @@ export function AbschnittKarte({
|
||||
index: number;
|
||||
anzahl: number;
|
||||
lesend: boolean;
|
||||
/** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */
|
||||
abteilungen: readonly string[];
|
||||
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
|
||||
zuletztAngelegt: string | null;
|
||||
anfasser: React.ReactNode;
|
||||
@@ -57,6 +62,7 @@ export function AbschnittKarte({
|
||||
const [offen, setzeOffen] = useState(true);
|
||||
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
|
||||
const anzahlSchritte = abschnitt.schritte.length;
|
||||
const baustein = abschnitt.baustein;
|
||||
|
||||
return (
|
||||
<KlappKarte
|
||||
@@ -75,6 +81,17 @@ export function AbschnittKarte({
|
||||
<span className="shrink-0 text-xs font-normal text-nebentext">
|
||||
{anzahlSchritte} {anzahlSchritte === 1 ? "Schritt" : "Schritte"}
|
||||
</span>
|
||||
{baustein ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs font-normal text-nebentext">
|
||||
<Blocks className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
|
||||
Baustein
|
||||
</span>
|
||||
) : null}
|
||||
{abschnitt.nurFuerAbteilungen.length > 0 ? (
|
||||
<span className="shrink-0 rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs font-normal text-nebentext">
|
||||
nur {abschnitt.nurFuerAbteilungen.join(", ")}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
knoepfe={
|
||||
@@ -96,7 +113,32 @@ export function AbschnittKarte({
|
||||
)
|
||||
}
|
||||
>
|
||||
{lesend ? null : (
|
||||
{/*
|
||||
Ein Baustein-Abschnitt ist eine **Einheit** (#030): weder Titel noch
|
||||
Schritte lassen sich hier aendern. Beides kommt beim Veroeffentlichen aus
|
||||
dem Baustein, und ein Feld, das man ausfuellen kann und das danach
|
||||
ueberschrieben wird, waere eine Falle. Geaendert wird am Baustein.
|
||||
*/}
|
||||
{baustein ? (
|
||||
<>
|
||||
<p className="max-w-prose text-sm text-nebentext">
|
||||
Dieser Abschnitt kommt aus dem Baustein{" "}
|
||||
<span className="font-semibold text-text">{baustein.name}</span>
|
||||
{baustein.version > 0 ? `, Fassung ${baustein.version}` : ""}. Titel und Schritte werden beim
|
||||
Veröffentlichen von dort eingesetzt — geändert wird sie am Baustein, nicht hier.
|
||||
</p>
|
||||
<p className="max-w-prose text-sm text-nebentext">
|
||||
Eine neue Fassung des Bausteins wirkt erst in der <strong>nächsten</strong> veröffentlichten
|
||||
Version dieser Vorlage. Schon veröffentlichte Versionen und laufende Arbeiten bleiben, wie sie
|
||||
sind.
|
||||
</p>
|
||||
<p className="text-sm text-nebentext">
|
||||
{anzahlSchritte} {anzahlSchritte === 1 ? "Schritt" : "Schritte"} aus dem Baustein.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!baustein && !lesend ? (
|
||||
<Textfeld
|
||||
beschriftung="Titel des Abschnitts"
|
||||
wert={abschnitt.titel}
|
||||
@@ -104,9 +146,20 @@ export function AbschnittKarte({
|
||||
lesend={lesend}
|
||||
vorschlag="Vorbereitung"
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{anzahlSchritte === 0 ? (
|
||||
{/*
|
||||
Die Einschraenkung gehoert zur Vorlage und nicht zum Baustein — deshalb
|
||||
steht sie auch bei einem Baustein-Abschnitt (#031).
|
||||
*/}
|
||||
<AbteilungenWaehlen
|
||||
gewaehlt={abschnitt.nurFuerAbteilungen}
|
||||
bekannte={abteilungen}
|
||||
lesend={lesend}
|
||||
setzen={(nurFuerAbteilungen) => aendern({ nurFuerAbteilungen })}
|
||||
/>
|
||||
|
||||
{baustein ? null : anzahlSchritte === 0 ? (
|
||||
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
|
||||
) : (
|
||||
<SortierBereich
|
||||
@@ -137,7 +190,7 @@ export function AbschnittKarte({
|
||||
</SortierBereich>
|
||||
)}
|
||||
|
||||
{lesend ? null : (
|
||||
{baustein || lesend ? null : (
|
||||
<TextKnopf
|
||||
beschriftung={voll ? `Höchstens ${GRENZEN.schritteJeAbschnitt} Schritte` : "Schritt hinzufügen"}
|
||||
aus={voll}
|
||||
|
||||
@@ -13,7 +13,9 @@ import { Vorschau } from "@/components/vorlagen/vorschau";
|
||||
import {
|
||||
alsEditorAufbau,
|
||||
alsStruktur,
|
||||
bausteineAnreichern,
|
||||
neuerAbschnitt,
|
||||
neuerBausteinAbschnitt,
|
||||
neuerSchritt,
|
||||
neueVariable,
|
||||
umsortieren,
|
||||
@@ -67,20 +69,36 @@ type SpeicherZustand = {
|
||||
readonly gespeichertesJson: string;
|
||||
};
|
||||
|
||||
export type EinsetzbarerBaustein = {
|
||||
readonly bausteinVersionId: string;
|
||||
readonly name: string;
|
||||
readonly titel: string;
|
||||
readonly version: number;
|
||||
readonly anzahlSchritte: number;
|
||||
};
|
||||
|
||||
export function Editor({
|
||||
version,
|
||||
lesend,
|
||||
darfBearbeiten,
|
||||
beispielDatum,
|
||||
bausteine = [],
|
||||
abteilungen = [],
|
||||
}: {
|
||||
version: EditorVersion;
|
||||
lesend: boolean;
|
||||
darfBearbeiten: boolean;
|
||||
/** Heutiges Datum, auf dem Server formatiert — siehe `beispielwerte`. */
|
||||
beispielDatum: string;
|
||||
/** Veröffentlichte Bausteine, die sich einsetzen lassen (#030). */
|
||||
bausteine?: readonly EinsetzbarerBaustein[];
|
||||
/** Gepflegte Abteilungsnamen – für die Einschränkung je Abschnitt (#031). */
|
||||
abteilungen?: readonly string[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [aufbau, setzeAufbau] = useState<EditorAufbau>(() => alsEditorAufbau(version.struktur));
|
||||
const [aufbau, setzeAufbau] = useState<EditorAufbau>(() =>
|
||||
bausteineAnreichern(alsEditorAufbau(version.struktur), bausteine),
|
||||
);
|
||||
const aktuellesJson = useMemo(() => JSON.stringify(alsStruktur(aufbau)), [aufbau]);
|
||||
|
||||
/**
|
||||
@@ -92,8 +110,8 @@ export function Editor({
|
||||
* Speichern in die Datenbank.
|
||||
*/
|
||||
const anfangsJson = useMemo(
|
||||
() => JSON.stringify(alsStruktur(alsEditorAufbau(version.struktur))),
|
||||
[version.struktur],
|
||||
() => JSON.stringify(alsStruktur(bausteineAnreichern(alsEditorAufbau(version.struktur), bausteine))),
|
||||
[version.struktur, bausteine],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -321,6 +339,7 @@ export function Editor({
|
||||
abschnitt={abschnitt}
|
||||
index={index}
|
||||
anzahl={aufbau.abschnitte.length}
|
||||
abteilungen={abteilungen}
|
||||
lesend={lesend}
|
||||
zuletztAngelegt={zuletztAngelegt}
|
||||
anfasser={anfasser}
|
||||
@@ -359,6 +378,7 @@ export function Editor({
|
||||
</SortierBereich>
|
||||
)}
|
||||
{lesend ? null : (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<TextKnopf
|
||||
beschriftung={
|
||||
abschnitteVoll ? `Höchstens ${GRENZEN.abschnitte} Abschnitte` : "Abschnitt hinzufügen"
|
||||
@@ -370,6 +390,40 @@ export function Editor({
|
||||
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
|
||||
}}
|
||||
/>
|
||||
{/*
|
||||
Bausteine nur anbieten, wenn es welche gibt. Ein leeres
|
||||
Auswahlfeld mit „keine Bausteine“ wäre eine Frage ohne Antwort
|
||||
(#030).
|
||||
*/}
|
||||
{bausteine.length > 0 ? (
|
||||
<label className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span className="shrink-0 text-nebentext">Baustein einsetzen</span>
|
||||
<select
|
||||
aria-label="Baustein einsetzen"
|
||||
value=""
|
||||
disabled={abschnitteVoll}
|
||||
onChange={(ereignis) => {
|
||||
const gewaehlt = bausteine.find(
|
||||
(eintrag) => eintrag.bausteinVersionId === ereignis.target.value,
|
||||
);
|
||||
if (!gewaehlt) return;
|
||||
const abschnitt = neuerBausteinAbschnitt(gewaehlt);
|
||||
setzeZuletztAngelegt(abschnitt.kennung);
|
||||
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
|
||||
}}
|
||||
className="min-h-11 min-w-0 rounded-pille border border-linie bg-flaeche-hoch px-4 text-sm md:min-h-10"
|
||||
>
|
||||
<option value="">auswählen …</option>
|
||||
{bausteine.map((eintrag) => (
|
||||
<option key={eintrag.bausteinVersionId} value={eintrag.bausteinVersionId}>
|
||||
{eintrag.name} (Fassung {eintrag.version}, {eintrag.anzahlSchritte}{" "}
|
||||
{eintrag.anzahlSchritte === 1 ? "Schritt" : "Schritte"})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Der eine Einstiegspunkt, den Next je Server-Prozess genau einmal ruft (#016).
|
||||
*
|
||||
* Hier startet der Zeitplan des Verzeichnis-Abgleichs. Zwei Wachen davor:
|
||||
* Hier starten die beiden Zeitplaene: der Verzeichnis-Abgleich (#016) und die
|
||||
* Benachrichtigungen (#034). Zwei Wachen davor:
|
||||
*
|
||||
* - **Nur in der Node-Laufzeit.** `register` laeuft auch fuer die Edge-Laufzeit,
|
||||
* und dort gibt es weder Datenbank noch `node-cron`.
|
||||
@@ -15,4 +16,11 @@ export async function register(): Promise<void> {
|
||||
|
||||
const { zeitplanStarten } = await import("@/server/zeitplan/abgleich-plan");
|
||||
zeitplanStarten();
|
||||
|
||||
/*
|
||||
* Ein eigener Zeitplan und nicht derselbe: der Mailversand darf nicht warten,
|
||||
* weil der Abgleich gerade ein grosses Verzeichnis liest.
|
||||
*/
|
||||
const { meldungsplanStarten } = await import("@/server/zeitplan/meldungs-plan");
|
||||
meldungsplanStarten();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Grenzen und Formen der Anhaenge (#032).
|
||||
*
|
||||
* Framework-frei, weil beide Seiten davon lesen: der Dienst prueft damit, und
|
||||
* die Oberflaeche sagt damit **vorher**, was erlaubt ist. Stuenden die Grenzen
|
||||
* nur im Dienst, waere jede Abweisung eine Ueberraschung.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Was sich anhaengen laesst: Bilder und PDF.
|
||||
*
|
||||
* Bewusst eine Liste und kein Praefix-Vergleich wie `image/*`: ein Browser
|
||||
* schickt auch `image/svg+xml`, und SVG ist ein Dokument mit Skripten, das ein
|
||||
* Betrachter ausfuehrt. Ein Anhang, den jemand oeffnet, darf kein Angriffsweg
|
||||
* sein.
|
||||
*/
|
||||
export const ERLAUBTE_MEDIENTYPEN = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"application/pdf",
|
||||
] as const;
|
||||
|
||||
export type Medientyp = (typeof ERLAUBTE_MEDIENTYPEN)[number];
|
||||
|
||||
/** Die Endung, unter der eine Datei auf der Platte liegt. */
|
||||
export const ENDUNG_JE_TYP: Readonly<Record<Medientyp, string>> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/heic": "heic",
|
||||
"image/heif": "heif",
|
||||
"application/pdf": "pdf",
|
||||
};
|
||||
|
||||
export function istErlaubterMedientyp(typ: string): typ is Medientyp {
|
||||
return (ERLAUBTE_MEDIENTYPEN as readonly string[]).includes(typ);
|
||||
}
|
||||
|
||||
/** Bilder werden als Miniatur gezeigt, ein PDF nur als Zeile. */
|
||||
export function istBild(medientyp: string): boolean {
|
||||
return medientyp !== "application/pdf" && istErlaubterMedientyp(medientyp);
|
||||
}
|
||||
|
||||
const STANDARD_MAX_MB = 10;
|
||||
const STANDARD_JE_LAUF = 20;
|
||||
|
||||
/**
|
||||
* Hoechstgroesse je Datei in Bytes.
|
||||
*
|
||||
* Ein Handyfoto hat heute 3–6 MB. Zehn als Vorgabe lassen Luft, ohne dass eine
|
||||
* einzelne Datei eine Sicherung sprengt. Keine serverseitige Verkleinerung:
|
||||
* sie verliert Metadaten und zieht eine Bildbibliothek ins Projekt — eine
|
||||
* ehrliche Obergrenze ist weniger Magie.
|
||||
*/
|
||||
export function maxGroesseBytes(): number {
|
||||
const roh = Number.parseInt(process.env["ANHANG_MAX_MB"]?.trim() ?? "", 10);
|
||||
const mb = Number.isFinite(roh) && roh > 0 ? roh : STANDARD_MAX_MB;
|
||||
return mb * 1024 * 1024;
|
||||
}
|
||||
|
||||
/** Hoechstzahl Anhaenge je Lauf. */
|
||||
export function maxJeLauf(): number {
|
||||
const roh = Number.parseInt(process.env["ANHAENGE_JE_LAUF"]?.trim() ?? "", 10);
|
||||
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_JE_LAUF;
|
||||
}
|
||||
|
||||
/** Wo die Dateien liegen. Ohne Angabe ein Verzeichnis im Arbeitsbaum. */
|
||||
export function anhaengePfad(): string {
|
||||
return process.env["ANHAENGE_PFAD"]?.trim() || "./daten/anhaenge";
|
||||
}
|
||||
|
||||
/**
|
||||
* Die eine gewaehlte Datei aus mehreren Formularfeldern desselben Namens.
|
||||
*
|
||||
* Der Schritt hat **zwei** Felder `datei` in einem Formular: „Foto“ mit
|
||||
* `capture="environment"` und „Datei“ mit dem Dateiwaehler. Abgeschickt wird
|
||||
* immer das ganze Formular, also kommen beide Felder mit — das leere als
|
||||
* `File` mit `size === 0`. `FormData.get("datei")` liefert das **erste**, und
|
||||
* das war in #032 das leere: wer „Datei“ nutzte, bekam „Keine Datei gewählt“,
|
||||
* waehrend „Foto“ ging. Gefunden erst in der Klickprobe am gebauten Stand, weil
|
||||
* kein Dienst-Test zwei Felder schickt.
|
||||
*
|
||||
* Darum hier die Regel, und zwar als reine Funktion: das **erste nicht leere**
|
||||
* Feld gewinnt, unabhaengig von der Reihenfolge im Formular.
|
||||
*/
|
||||
export function gewaehlteDatei(eintraege: readonly unknown[]): File | undefined {
|
||||
return eintraege.find((eintrag): eintrag is File => eintrag instanceof File && eintrag.size > 0);
|
||||
}
|
||||
|
||||
/** `4,2 MB` – fuer Meldungen und die Anzeige. */
|
||||
export function groesseText(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Dateiname, der sich anzeigen laesst.
|
||||
*
|
||||
* Der Name vom Browser wird **nie** zum Pfad — der entsteht aus der `id`.
|
||||
* Trotzdem wird er beschnitten und von Steuerzeichen befreit: er landet in einer
|
||||
* Antwortkopfzeile und in der Oberflaeche.
|
||||
*/
|
||||
export function dateinameSaeubern(roh: string): string {
|
||||
const ohnePfad = roh.split(/[\\/]/).pop() ?? "";
|
||||
const sauber = ohnePfad.replace(/[\u0000-\u001f\u007f"]/g, "").trim();
|
||||
return (sauber || "anhang").slice(0, 200);
|
||||
}
|
||||
@@ -41,6 +41,20 @@ export type EditorAbschnitt = {
|
||||
readonly kennung: string;
|
||||
readonly titel: string;
|
||||
readonly schritte: readonly EditorSchritt[];
|
||||
/** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */
|
||||
readonly nurFuerAbteilungen: readonly string[];
|
||||
/**
|
||||
* Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030).
|
||||
*
|
||||
* Dann sind `titel` und `schritte` nur Anzeige: beim Veroeffentlichen setzt
|
||||
* der Dienst die Schritte des Bausteins ein. Bearbeiten laesst sich ein
|
||||
* solcher Abschnitt hier nicht — das geschieht am Baustein.
|
||||
*/
|
||||
readonly baustein?: {
|
||||
readonly versionId: string;
|
||||
readonly name: string;
|
||||
readonly version: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type EditorVariable = {
|
||||
@@ -204,7 +218,67 @@ export function neuerSchritt(): EditorSchritt {
|
||||
}
|
||||
|
||||
export function neuerAbschnitt(): EditorAbschnitt {
|
||||
return { kennung: neueKennung("abschnitt"), titel: "", schritte: [] };
|
||||
return { kennung: neueKennung("abschnitt"), titel: "", schritte: [], nurFuerAbteilungen: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Abschnitt, der einen Baustein einsetzt (#030).
|
||||
*
|
||||
* Titel und Schritte kommen zur Anzeige mit; gespeichert wird nur der Verweis,
|
||||
* und beim Veroeffentlichen setzt der Dienst die Schritte des Bausteins ein.
|
||||
*/
|
||||
export function neuerBausteinAbschnitt(baustein: {
|
||||
readonly bausteinVersionId: string;
|
||||
readonly name: string;
|
||||
readonly titel: string;
|
||||
readonly version: number;
|
||||
readonly anzahlSchritte: number;
|
||||
}): EditorAbschnitt {
|
||||
return {
|
||||
kennung: neueKennung("abschnitt"),
|
||||
titel: baustein.titel,
|
||||
baustein: { versionId: baustein.bausteinVersionId, name: baustein.name, version: baustein.version },
|
||||
// Ein Baustein-Abschnitt laesst sich einschraenken wie jeder andere: die
|
||||
// Einschraenkung gehoert zur Vorlage, nicht zum Baustein.
|
||||
nurFuerAbteilungen: [],
|
||||
// Platzhalter, damit die Anzahl stimmt. Gespeichert werden sie nicht.
|
||||
schritte: Array.from({ length: baustein.anzahlSchritte }, () => neuerSchritt()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Traegt Name und Fassung der verwiesenen Bausteine nach.
|
||||
*
|
||||
* Die gespeicherte Struktur kennt nur die Kennung; die Seite holt die Liste der
|
||||
* einsetzbaren Bausteine getrennt und reicht sie hier ein. Ein Verweis, dessen
|
||||
* Baustein nicht mehr einsetzbar ist (archiviert, oder eine aeltere Fassung),
|
||||
* behaelt seine Kennung und bekommt einen erkennbaren Namen — verschwinden darf
|
||||
* er nicht, sonst waere er beim naechsten Speichern still weg.
|
||||
*/
|
||||
export function bausteineAnreichern(
|
||||
aufbau: EditorAufbau,
|
||||
bekannte: readonly {
|
||||
readonly bausteinVersionId: string;
|
||||
readonly name: string;
|
||||
readonly version: number;
|
||||
}[],
|
||||
): EditorAufbau {
|
||||
const nachId = new Map(bekannte.map((eintrag) => [eintrag.bausteinVersionId, eintrag]));
|
||||
return {
|
||||
...aufbau,
|
||||
abschnitte: aufbau.abschnitte.map((abschnitt) => {
|
||||
if (abschnitt.baustein === undefined) return abschnitt;
|
||||
const treffer = nachId.get(abschnitt.baustein.versionId);
|
||||
return {
|
||||
...abschnitt,
|
||||
baustein: {
|
||||
versionId: abschnitt.baustein.versionId,
|
||||
name: treffer?.name ?? "(nicht mehr verfügbar)",
|
||||
version: treffer?.version ?? 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function neueVariable(): EditorVariable {
|
||||
@@ -233,6 +307,12 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
|
||||
kennung: neueKennung("abschnitt"),
|
||||
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
|
||||
titel: titelOhneNummer(abschnitt.titel),
|
||||
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||
// Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie
|
||||
// getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030).
|
||||
...(abschnitt.bausteinVersionId === undefined
|
||||
? {}
|
||||
: { baustein: { versionId: abschnitt.bausteinVersionId, name: "", version: 0 } }),
|
||||
schritte: abschnitt.schritte.map((schritt) => ({
|
||||
kennung: neueKennung("schritt"),
|
||||
titel: schritt.titel,
|
||||
@@ -266,7 +346,21 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
|
||||
})),
|
||||
abschnitte: aufbau.abschnitte.map((abschnitt) => ({
|
||||
titel: abschnitt.titel,
|
||||
schritte: abschnitt.schritte.map((schritt) => ({
|
||||
...(abschnitt.baustein === undefined ? {} : { bausteinVersionId: abschnitt.baustein.versionId }),
|
||||
// Leere Namen und Dubletten fallen weg: beides entsteht beim Tippen im
|
||||
// Freitextfeld und bedeutet dasselbe wie "nicht genannt".
|
||||
nurFuerAbteilungen: [
|
||||
...new Set(abschnitt.nurFuerAbteilungen.map((name) => name.trim()).filter(Boolean)),
|
||||
],
|
||||
/*
|
||||
* Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim
|
||||
* Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur
|
||||
* Platzhalter, damit die Anzahl in der Karte stimmt — und sie haben keinen
|
||||
* Titel. Wuerden sie mitgeschickt, wiese `SchrittSchema` den ganzen
|
||||
* Entwurf ab, und das Speichern scheiterte (#030, gefunden beim
|
||||
* Durchklicken des gebauten Stands).
|
||||
*/
|
||||
schritte: (abschnitt.baustein === undefined ? abschnitt.schritte : []).map((schritt) => ({
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext.trim() === "" ? undefined : schritt.hilfetext,
|
||||
typ: schritt.typ,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Welche Abschnitte fuer einen Lauf gelten (#031).
|
||||
*
|
||||
* Framework-frei und ohne Prisma, weil **drei** Schichten sie brauchen: der
|
||||
* Dienst beim Start und beim Abschluss, und die Lesemodelle der Abarbeitung und
|
||||
* der Druckansicht. Lebte sie in einem davon, importierte ein Dienst aus einem
|
||||
* Lesemodell — eine Abhaengigkeit in die falsche Richtung (CLAUDE.md).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Die Auswahl zum Zeitpunkt des Starts.
|
||||
*
|
||||
* Ein Abschnitt ohne Einschraenkung gilt immer. Einer mit Einschraenkung gilt,
|
||||
* wenn die Abteilung des Laufs darin vorkommt — ein Lauf **ohne** Abteilung
|
||||
* bekommt deshalb nur die uneingeschraenkten, und das ist kein Mangel: ein
|
||||
* Firewall-Rollout hat keine Abteilung.
|
||||
*
|
||||
* Verglichen wird ohne Ruecksicht auf Gross- und Kleinschreibung und mit
|
||||
* getrimmten Raendern. Die Namen kommen aus zwei Quellen — der gepflegten
|
||||
* `Abteilung` und dem Freitextfeld im Editor —, und „buchhaltung“ ist dieselbe
|
||||
* Abteilung wie „Buchhaltung“.
|
||||
*/
|
||||
export function geltendeAbschnitteErmitteln(
|
||||
abschnitte: readonly { readonly id: string; readonly nurFuerAbteilungen: readonly string[] }[],
|
||||
abteilungsName: string | null,
|
||||
): readonly string[] {
|
||||
const gesucht = abteilungsName?.trim().toLocaleLowerCase("de") ?? null;
|
||||
return abschnitte
|
||||
.filter((abschnitt) => {
|
||||
if (abschnitt.nurFuerAbteilungen.length === 0) return true;
|
||||
if (gesucht === null) return false;
|
||||
return abschnitt.nurFuerAbteilungen.some((name) => name.trim().toLocaleLowerCase("de") === gesucht);
|
||||
})
|
||||
.map((abschnitt) => abschnitt.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Abschnitte einer Version auf die eines Laufs eindampfen.
|
||||
*
|
||||
* `geltendeAbschnitte` ist beim Start festgeschrieben. **Leer heisst alle** — so
|
||||
* verhalten sich Laeufe von vor dieser Fassung unveraendert, ohne dass eine
|
||||
* Migration Daten nachfuehren muesste.
|
||||
*/
|
||||
export function nurGeltende<T extends { readonly id: string }>(
|
||||
abschnitte: readonly T[],
|
||||
geltendeAbschnitte: readonly string[],
|
||||
): readonly T[] {
|
||||
if (geltendeAbschnitte.length === 0) return abschnitte;
|
||||
const erlaubt = new Set(geltendeAbschnitte);
|
||||
return abschnitte.filter((abschnitt) => erlaubt.has(abschnitt.id));
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Die Texte der Benachrichtigungen (#034).
|
||||
*
|
||||
* Framework-frei und ohne Datenbank, weil hier die wichtigste Zusage dieses
|
||||
* Meilensteins sitzt und einzeln pruefbar sein muss:
|
||||
*
|
||||
* **In keine Meldung kommt ein Schrittinhalt.** Kein Wert, keine Notiz, kein
|
||||
* Befehl — und damit auch kein `{{generiertes_passwort}}`. Eine Mail mit dem
|
||||
* Lauf-Inhalt waere genau die Luecke, die dieses Projekt an jeder anderen Stelle
|
||||
* geschlossen hat: das Passwort entsteht bei jeder Anzeige neu und wird nirgends
|
||||
* gespeichert, aber eine Mail liegt fuer immer in einem Postfach, oft auf einem
|
||||
* Server, der nicht im Haus steht.
|
||||
*
|
||||
* Deshalb nimmt jede Funktion hier **nur** Kopfdaten: Titel, Status, Beteiligte,
|
||||
* Frist, Link. Der Typ laesst gar nichts anderes zu — das ist die Absicht. Wer
|
||||
* einen Schrittinhalt in eine Meldung bekommen wollte, muesste diese Datei
|
||||
* aendern, und dann steht er vor diesem Kommentar.
|
||||
*/
|
||||
|
||||
import { Meldeanlass } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Die drei Anlaesse, die zu einem Lauf gehoeren.
|
||||
*
|
||||
* `ABGLEICH_FEHLGESCHLAGEN` ist ausgeschlossen, und zwar **im Typ**: er gehoert
|
||||
* zu einer Verzeichnisquelle, nicht zu einem Lauf. Mit einem Wurf zur Laufzeit
|
||||
* waere derselbe Fehler erst im Betrieb aufgefallen.
|
||||
*/
|
||||
export type LaufAnlass = Exclude<Meldeanlass, typeof Meldeanlass.ABGLEICH_FEHLGESCHLAGEN>;
|
||||
|
||||
/** Was eine Lauf-Meldung ueber den Lauf wissen darf. Mehr gibt es nicht. */
|
||||
export type LaufKopf = {
|
||||
readonly titel: string;
|
||||
/** Hostname, Bezeichnung oder Tag-Code — was die Oberflaeche auch zeigt. */
|
||||
readonly geraet?: string | null;
|
||||
readonly person?: string | null;
|
||||
readonly faelligAm?: Date | null;
|
||||
readonly vorlage?: string | null;
|
||||
/** Wer zugewiesen oder abgeschlossen hat. */
|
||||
readonly handelnder?: string | null;
|
||||
};
|
||||
|
||||
export type Meldung = { readonly betreff: string; readonly rumpf: string };
|
||||
|
||||
/** `05.10.2026` – ohne Uhrzeit, weil eine Frist ein Tag ist. */
|
||||
function datum(wert: Date): string {
|
||||
return wert.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
}
|
||||
|
||||
/** Die Zeilen, die jeden Lauf beschreiben – leere bleiben weg. */
|
||||
function kopfzeilen(kopf: LaufKopf): string[] {
|
||||
const zeilen: string[] = [];
|
||||
if (kopf.vorlage) zeilen.push(`Vorlage: ${kopf.vorlage}`);
|
||||
if (kopf.geraet) zeilen.push(`Gerät: ${kopf.geraet}`);
|
||||
if (kopf.person) zeilen.push(`Person: ${kopf.person}`);
|
||||
if (kopf.faelligAm) zeilen.push(`Fällig: ${datum(kopf.faelligAm)}`);
|
||||
return zeilen;
|
||||
}
|
||||
|
||||
function zusammensetzen(absaetze: readonly (string | undefined)[]): string {
|
||||
return absaetze.filter((teil): teil is string => Boolean(teil)).join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Meldung zu einem Lauf.
|
||||
*
|
||||
* `link` ist die vollstaendige Adresse aus `APP_URL` — eine Mail mit einem
|
||||
* relativen Pfad ist eine Mail ohne Link.
|
||||
*/
|
||||
export function laufMeldung(anlass: LaufAnlass, kopf: LaufKopf, link: string): Meldung {
|
||||
const zeilen = kopfzeilen(kopf);
|
||||
const daten = zeilen.length > 0 ? zeilen.join("\n") : undefined;
|
||||
|
||||
switch (anlass) {
|
||||
case Meldeanlass.LAUF_ZUGEWIESEN:
|
||||
return {
|
||||
betreff: `Laufzettel: „${kopf.titel}“ ist dir zugewiesen`,
|
||||
rumpf: zusammensetzen([
|
||||
kopf.handelnder
|
||||
? `${kopf.handelnder} hat dir diesen Laufzettel zugewiesen.`
|
||||
: "Dir wurde ein Laufzettel zugewiesen.",
|
||||
daten,
|
||||
link,
|
||||
]),
|
||||
};
|
||||
|
||||
case Meldeanlass.LAUF_UEBERFAELLIG:
|
||||
return {
|
||||
betreff: `Laufzettel überfällig: „${kopf.titel}“`,
|
||||
rumpf: zusammensetzen([
|
||||
kopf.faelligAm
|
||||
? `Dieser Laufzettel war am ${datum(kopf.faelligAm)} fällig und ist noch offen.`
|
||||
: "Dieser Laufzettel ist überfällig und noch offen.",
|
||||
daten,
|
||||
link,
|
||||
]),
|
||||
};
|
||||
|
||||
case Meldeanlass.LAUF_ABGESCHLOSSEN:
|
||||
return {
|
||||
betreff: `Laufzettel abgeschlossen: „${kopf.titel}“`,
|
||||
rumpf: zusammensetzen([
|
||||
kopf.handelnder
|
||||
? `${kopf.handelnder} hat diesen Laufzettel abgeschlossen.`
|
||||
: "Dieser Laufzettel wurde abgeschlossen.",
|
||||
daten,
|
||||
link,
|
||||
]),
|
||||
};
|
||||
|
||||
default: {
|
||||
/*
|
||||
* Kaeme ein fuenfter Anlass dazu, der zu einem Lauf gehoert, ist diese
|
||||
* Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile
|
||||
* totem Code im Ergebnis.
|
||||
*/
|
||||
const unbekannt: never = anlass;
|
||||
throw new Error(`laufMeldung kennt den Anlass ${String(unbekannt)} nicht.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Meldung zu einem fehlgeschlagenen Verzeichnis-Abgleich. */
|
||||
export function abgleichMeldung(quelle: string, grund: string, link: string): Meldung {
|
||||
return {
|
||||
betreff: `Laufzettel: Verzeichnis-Abgleich „${quelle}“ fehlgeschlagen`,
|
||||
rumpf: zusammensetzen([
|
||||
`Der Abgleich der Verzeichnisquelle „${quelle}“ ist fehlgeschlagen.`,
|
||||
`Grund: ${grund}`,
|
||||
"Bis das behoben ist, bleiben neue und geänderte Konten aus dem Verzeichnis aus. " +
|
||||
"Vorhandene Personen bleiben unverändert — der Abgleich löscht nichts.",
|
||||
link,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Rumpf eines Webhooks.
|
||||
*
|
||||
* Dasselbe Prinzip: Kopfdaten und ein Link, keine Schrittinhalte. Als JSON, weil
|
||||
* die Gegenseite ein Programm ist — und mit `anlass` als erstem Feld, damit sich
|
||||
* ohne Auspacken filtern laesst.
|
||||
*/
|
||||
export function webhookRumpf(
|
||||
anlass: Meldeanlass,
|
||||
betreff: string,
|
||||
link: string,
|
||||
bezug: { readonly laufId?: string; readonly verzeichnisquelleId?: string },
|
||||
gesendetAm: Date,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
anlass,
|
||||
betreff,
|
||||
link,
|
||||
laufId: bezug.laufId ?? null,
|
||||
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
|
||||
zeitpunkt: gesendetAm.toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Die Signatur eines Webhooks (#034).
|
||||
*
|
||||
* Framework-frei, weil die Gegenseite sie nachrechnen muss — und weil genau das
|
||||
* geprueft werden soll, ohne einen Server zu starten. Das Format ist an das von
|
||||
* Stripe angelehnt, aus einem praktischen Grund: wer schon einmal einen Webhook
|
||||
* verdrahtet hat, kennt es, und `docs/betrieb/` kann ein Beispiel in drei Zeilen
|
||||
* zeigen.
|
||||
*
|
||||
* X-Laufzettel-Signatur: t=1762334400,v1=9f86d0818...
|
||||
*
|
||||
* **Der Zeitstempel ist Teil der signierten Nachricht, nicht Beiwerk.** Stuende
|
||||
* er nur daneben, koennte ihn jeder aendern, ohne die Signatur zu brechen — und
|
||||
* eine abgefangene Nachricht liesse sich in einem Jahr noch einspielen. Signiert
|
||||
* wird deshalb `<zeitstempel>.<rumpf>`.
|
||||
*/
|
||||
|
||||
/** Wie alt eine Nachricht hoechstens sein darf. Fuenf Minuten, wie bei Stripe. */
|
||||
export const TOLERANZ_SEKUNDEN = 300;
|
||||
|
||||
export const SIGNATUR_KOPF = "X-Laufzettel-Signatur";
|
||||
|
||||
function berechnen(geheimnis: string, zeitstempel: number, rumpf: string): string {
|
||||
return createHmac("sha256", geheimnis).update(`${zeitstempel}.${rumpf}`).digest("hex");
|
||||
}
|
||||
|
||||
/** Den Wert der Kopfzeile bauen. `zeitstempel` in Sekunden. */
|
||||
export function signaturBauen(geheimnis: string, rumpf: string, zeitstempel: number): string {
|
||||
return `t=${zeitstempel},v1=${berechnen(geheimnis, zeitstempel, rumpf)}`;
|
||||
}
|
||||
|
||||
export type SignaturPruefung =
|
||||
{ readonly gueltig: true } | { readonly gueltig: false; readonly grund: "form" | "abgelaufen" | "falsch" };
|
||||
|
||||
/**
|
||||
* Die Gegenprobe – so, wie die Gegenseite sie machen soll.
|
||||
*
|
||||
* Exportiert und geprueft, damit `docs/betrieb/` nicht beschreiben muss, was
|
||||
* niemand nachgerechnet hat.
|
||||
*/
|
||||
export function signaturPruefen(
|
||||
geheimnis: string,
|
||||
rumpf: string,
|
||||
kopf: string,
|
||||
jetztSekunden: number,
|
||||
toleranzSekunden = TOLERANZ_SEKUNDEN,
|
||||
): SignaturPruefung {
|
||||
const teile = new Map<string, string>();
|
||||
for (const stueck of kopf.split(",")) {
|
||||
const trenner = stueck.indexOf("=");
|
||||
if (trenner > 0) teile.set(stueck.slice(0, trenner).trim(), stueck.slice(trenner + 1).trim());
|
||||
}
|
||||
|
||||
const roherStempel = teile.get("t");
|
||||
const mitgeschickt = teile.get("v1");
|
||||
if (!roherStempel || !mitgeschickt) return { gueltig: false, grund: "form" };
|
||||
|
||||
const zeitstempel = Number.parseInt(roherStempel, 10);
|
||||
if (!Number.isFinite(zeitstempel)) return { gueltig: false, grund: "form" };
|
||||
|
||||
/*
|
||||
* In **beide** Richtungen pruefen. Nur „zu alt“ abzufangen liesse eine
|
||||
* Nachricht mit Zeitstempel im Jahr 2040 fuer immer gueltig sein.
|
||||
*/
|
||||
if (Math.abs(jetztSekunden - zeitstempel) > toleranzSekunden) {
|
||||
return { gueltig: false, grund: "abgelaufen" };
|
||||
}
|
||||
|
||||
const erwartet = berechnen(geheimnis, zeitstempel, rumpf);
|
||||
/*
|
||||
* Zeitkonstant vergleichen. Ein `===` auf Zeichenketten bricht beim ersten
|
||||
* Unterschied ab; aus der Dauer liesse sich die richtige Signatur Zeichen fuer
|
||||
* Zeichen erraten. `timingSafeEqual` verlangt gleiche Laenge, deshalb die
|
||||
* Pruefung davor — die Laenge ist kein Geheimnis, sie ist immer 64.
|
||||
*/
|
||||
const a = Buffer.from(erwartet, "utf8");
|
||||
const b = Buffer.from(mitgeschickt, "utf8");
|
||||
if (a.length !== b.length) return { gueltig: false, grund: "falsch" };
|
||||
return timingSafeEqual(a, b) ? { gueltig: true } : { gueltig: false, grund: "falsch" };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Server-Sent Events: Format und Grenzen (#033).
|
||||
*
|
||||
* Framework-frei, weil beides geprueft werden muss, ohne eine Verbindung
|
||||
* aufzubauen. Das Format ist winzig und hat genau eine Falle, die einen Strom
|
||||
* still zerstoert — siehe `sseEreignis`.
|
||||
*/
|
||||
|
||||
/** Der Kanal, auf dem die Datenbank Aenderungen meldet. Muss zur Migration passen. */
|
||||
export const KANAL = "lauf_geaendert";
|
||||
|
||||
/**
|
||||
* Ein Ereignis im SSE-Format.
|
||||
*
|
||||
* **Die Falle:** ein Zeilenumbruch in den Daten beendet das Feld. Steht in der
|
||||
* Nutzlast ein `\n`, zerfaellt die Nachricht, und der Client bekommt Schrott
|
||||
* oder gar nichts. Deshalb wird **jede** Zeile mit eigenem `data:` gesendet —
|
||||
* so schreibt es die Spezifikation vor, und der Client fuegt sie mit `\n`
|
||||
* wieder zusammen.
|
||||
*
|
||||
* Unsere Nutzlast ist JSON aus `JSON.stringify` ohne Einrueckung und hat heute
|
||||
* nie einen Umbruch. „Hat heute nie“ ist aber keine Zusage, sondern ein Zufall:
|
||||
* ein Lauftitel mit Umbruch, ein Notiztext, und der Strom reisst.
|
||||
*/
|
||||
export function sseEreignis(name: string, daten: string): string {
|
||||
const zeilen = daten.split("\n").map((zeile) => `data: ${zeile}`);
|
||||
return `event: ${name}\n${zeilen.join("\n")}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Herzschlag: ein Kommentar, den der Client verwirft.
|
||||
*
|
||||
* Ohne ihn schliesst ein Reverse Proxy die stille Verbindung nach seiner
|
||||
* Leerlaufzeit — nginx nach 60 s, und der Browser sieht nur einen Abbruch.
|
||||
* Ein Kommentar (Zeile mit `:`) kostet zwei Bytes und haelt sie offen.
|
||||
*/
|
||||
export const HERZSCHLAG = ": hz\n\n";
|
||||
|
||||
const STANDARD_HERZSCHLAG_MS = 25_000;
|
||||
const STANDARD_MAX_STROEME = 200;
|
||||
|
||||
/**
|
||||
* Abstand der Herzschlaege.
|
||||
*
|
||||
* 25 s, also unter der Leerlaufzeit von 60 s, die nginx und Caddy voreingestellt
|
||||
* haben. Umstellbar, weil der Test nicht eine Minute warten soll, um zu sehen,
|
||||
* dass ueberhaupt einer kommt.
|
||||
*/
|
||||
export function herzschlagMs(): number {
|
||||
const roh = Number.parseInt(process.env["SSE_HERZSCHLAG_MS"]?.trim() ?? "", 10);
|
||||
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_HERZSCHLAG_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoechstzahl gleichzeitig offener Stroeme je Instanz.
|
||||
*
|
||||
* Ein Strom belegt eine HTTP-Verbindung, solange die Lasche offen ist. Ohne
|
||||
* Grenze belegt ein Haus mit vierzig offenen Laschen vierzig Verbindungen, und
|
||||
* irgendwann nimmt der Server keine gewoehnliche Anfrage mehr an — die Seite
|
||||
* waere dann nicht langsam, sondern tot. Ist die Grenze erreicht, antwortet der
|
||||
* Handler gewoehnlich mit JSON, und der Client fuellt auf Polling zurueck.
|
||||
*/
|
||||
export function maxStroeme(): number {
|
||||
const roh = Number.parseInt(process.env["SSE_MAX_STROEME"]?.trim() ?? "", 10);
|
||||
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_MAX_STROEME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Will der Aufrufer einen Strom?
|
||||
*
|
||||
* `EventSource` schickt `Accept: text/event-stream` von sich aus — das ist der
|
||||
* einzige Unterschied zwischen dem Strom und dem Polling, und deshalb liegen
|
||||
* beide an **derselben** Adresse: eine Rechtepruefung, ein Lesemodell, eine
|
||||
* Wahrheit. Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand das
|
||||
* Recht vergessen kann.
|
||||
*/
|
||||
export function willStrom(accept: string | null): boolean {
|
||||
return (accept ?? "").split(",").some((teil) => teil.trim().startsWith("text/event-stream"));
|
||||
}
|
||||
@@ -96,6 +96,29 @@ 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),
|
||||
/**
|
||||
* Verweis auf eine veroeffentlichte Baustein-Version (#030).
|
||||
*
|
||||
* Nur im **Editor** von Belang: beim Veroeffentlichen setzt der Dienst hier die
|
||||
* Schritte des Bausteins ein. Eine **Austauschdatei** trägt das Feld nicht —
|
||||
* `vorlageExportieren` schreibt es nicht, und `vorlageImportieren` liest es
|
||||
* nicht. Eine Kennung aus einer anderen Installation bedeutet hier nichts, und
|
||||
* ein Verweis auf einen Baustein, den es dort nicht gibt, wäre eine Vorlage,
|
||||
* die sich nicht veroeffentlichen laesst.
|
||||
*/
|
||||
bausteinVersionId: leerAlsUndefiniert(z.string().trim().max(60)),
|
||||
/**
|
||||
* Nur fuer diese Abteilungen (#031). Leer heisst: fuer alle.
|
||||
*
|
||||
* **Namen und nicht Kennungen** — der Abschnitt gehoert zur Version und muss
|
||||
* ein Umbenennen oder Archivieren einer Abteilung ueberleben. Deshalb wandert
|
||||
* das Feld auch durch Export und Import: ein Abteilungsname bedeutet in einer
|
||||
* anderen Installation dasselbe, eine Kennung nicht.
|
||||
*/
|
||||
nurFuerAbteilungen: z
|
||||
.array(z.string().trim().min(1).max(200))
|
||||
.max(50, "Höchstens 50 Abteilungen je Abschnitt.")
|
||||
.default([]),
|
||||
schritte: z
|
||||
.array(SchrittSchema)
|
||||
.max(
|
||||
@@ -175,6 +198,25 @@ export const AustauschSchema = z.object({
|
||||
message: `Unbekanntes Format. Diese Fassung liest Format ${FORMAT_VERSION}.`,
|
||||
}),
|
||||
name: z.string().trim().min(1, "Die Vorlage braucht einen Namen.").max(200),
|
||||
/**
|
||||
* Kennung einer **mitgelieferten** Vorlage (#021).
|
||||
*
|
||||
* Nur fuer die Dateien unter `prisma/seed-daten/vorlagen/` gedacht, damit der
|
||||
* Seed seine Vorlagen am Schluessel erkennt und nicht am Namen, der sich
|
||||
* aendern darf. Der **Import uebernimmt sie nicht** — eine eingespielte Kopie
|
||||
* soll nicht den Platz des Originals einnehmen —, und der Export schreibt sie
|
||||
* nicht, weil ein Feld, das beim Einlesen verfaellt, nur verwirrt.
|
||||
*/
|
||||
seedSchluessel: leerAlsUndefiniert(
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(60)
|
||||
.regex(
|
||||
/^[a-z0-9-]+$/,
|
||||
"Der Seed-Schlüssel darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.",
|
||||
),
|
||||
),
|
||||
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([]),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user