13 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 2d3325ef20 feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand die Uebersicht
oeffnete; ein ueberfaelliger ueberhaupt nicht. Vier Anlaesse gehen jetzt
hinaus — zugewiesen, ueberfaellig, abgeschlossen und Verzeichnisabgleich
fehlgeschlagen —, je Person abschaltbar. Niemand bekommt Post ueber die
eigene Tat: das ist der haeufigste Grund, Benachrichtigungen ganz
abzuschalten, und deshalb faellt der Handelnde immer aus der
Empfaengerliste.

Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht. Ein haengender
Mailserver darf kein Abhaken blockieren, also schreibt der Dienst eine
Zeile in `Zustellung` und ein eigener Zeitplan schickt sie. Betreff und
Rumpf stehen fertig darin: eine Meldung sagt, was *damals* war.

In keiner Nachricht steht ein Schrittinhalt, und damit auch kein
erzeugtes Passwort. Darueber wacht src/lib/meldungen.ts — die Funktionen
dort nehmen nur Kopfdaten, der Typ laesst nichts anderes zu. Geprueft
wird es zusaetzlich ueber alle Rumpftexte, die wirklich verschickt
wurden, so wie #012 es fuer die Druckansicht tat.

Zwei Dinge haetten ohne Datenbank nicht gehalten. Die Einmaligkeit der
Ueberfaelligkeitsmeldung liegt in einem partiellen eindeutigen Index:
der Zeitplan sieht jede Minute nach, und ohne ihn bekaeme der
Zustaendige fuer einen Lauf, der ueber das Wochenende steht, 2880
Mails — jede fuer sich korrekt, kein Test rot. Und die Sperre der
Warteschlange liegt in derselben Tabelle wie die des Abgleichs, denn
hinter einem Lastverteiler laufen zwei Instanzen.

Eine stille Warteschlange ist schlimmer als keine: Verwaltung →
Zustellungen zeigt mit Grund, was hinausging und was nicht.

Geprueft gegen einen echten SMTP-Faenger (mailpit) statt gegen eine
abgefangene Funktion — und `mailpit` gehoert damit ins Tor vor dem
Commit, sonst ueberspringt die Suite 3 Tests und sieht gruen aus.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 17:22:20 +02:00
Florian WildeandClaude Opus 5 4aa79c93ff feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt
melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine
LISTEN-Verbindung je Prozess hoert zu, und der Route Handler schiebt
weiter. Gemessen: der Haken des Kollegen ist nach 485 ms beim Zuschauer
(am Handy 487 ms), vorher bis zu zehn Sekunden. Zwanzig offene Laschen
kosten nichts mehr statt zwanzig Abfragen je zehn Sekunden.

Das Ereignis kommt aus der Datenbank und nicht aus dem Dienst, aus
demselben Grund, aus dem auch die Regeln dort stehen: ein Trigger sieht
auch den Seed, eine Migration und die Reparatur per psql. Und NOTIFY
erreicht jede Instanz der Anwendung, ohne einen zweiten Dienst.

Welche Tabellen melden, haengt an dem, was `laufZustand` liefert — nicht
daran, was das Issue vorgeschlagen hat. Mit Triggern nur auf
Schrittergebnis und Lauf waere der Strom ein **Rueckschritt** gewesen:
das Polling verglich den ganzen Stand und sah deshalb auch Kommentare,
ein Strom sieht nur, was gemeldet wird. Der Kommentar des Kollegen waere
nie angekommen, und die Seite haette ausgesehen wie immer. Es melden
jetzt vier Tabellen, und `laufZustand` traegt `anzahlAnhaenge` — ein
Ereignis ohne Entsprechung im Zustand loest nichts aus.

Das Polling bleibt als Rueckfall an **derselben** Adresse, unterschieden
nur am Accept-Kopf: eine Rechtepruefung, ein Lesemodell, eine Nutzlast.
Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand
rechtErforderlich vergessen kann. Bei erreichter Obergrenze antwortet
der Handler gewoehnlich mit JSON; EventSource meldet darauf einen Fehler
und versucht es nicht wieder, also faellt der Client von sich aus
zurueck — ohne Sonderfall.

Nebenbei zwei Fehler in docs/betrieb/reverse-proxy.md behoben, beide aus
#52: `client_max_body_size 8m` bei `ANHANG_MAX_MB=10` haette ein
10-MB-Foto mit 413 abgewiesen, bevor die eigene Meldung zum Zuge kommt.

Closes #53

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 09:36:10 +02:00
Florian WildeandClaude Opus 5 3cbd5026f8 feat(anhaenge): Fotos und Dateien an einem Schritt (#52)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
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, Groesse und Pruefsumme. Der Pfad entsteht aus der
Kennung, nie aus dem Dateinamen vom Browser.

Jeder Abruf prueft Recht und Mandantengrenze — auch der des Bildes
selbst: der Route Handler ruft rechtErforderlich() selbst, weil die
Huelle unter (app) Route Handler nicht abdeckt. Hochgeladen wird ueber
eine Server Action statt ueber einen zweiten Endpunkt; der
Archiv-Schreibschutz haengt an derselben einen Stelle wie alles andere
am Lauf. Kein SVG: ein SVG ist ein Dokument, das Skripte ausfuehrt.

In der Datenbank halten zwei CHECK-Bedingungen und zwei
zusammengesetzte Fremdschluessel fest, was auch gilt, wenn jemand an
der Service-Schicht vorbei schreibt — ein Anhang kann strukturell nicht
an einem Schritt eines anderen Laufs oder bei einem fremden Kunden
haengen.

docs/betrieb/sicherung.md ist neu geschrieben: dort stand "Kein
Upload-Verzeichnis, keine erzeugten Dateien", und eine Sicherung nach
der alten Anleitung haette ab hier still Daten verloren. Sicherung und
Ruecksicherung sind einmal wirklich gefahren, mit Pruefsummenvergleich.

Gefunden in der Klickprobe am gebauten Stand und nur dort: zwei Felder
"datei" in einem Formular, und FormData.get nahm das leere. Wer "Datei"
waehlte, bekam "Keine Datei gewaehlt", waehrend "Foto" ging. Die Regel
liegt jetzt als reine Funktion in src/lib, und tests/e2e/anhaenge.spec.ts
laedt bewusst ueber den zweiten Knopf hoch.

Closes #52

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 23:05:00 +02:00
florian.wilde 855164066c feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen (#58)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
2026-10-04 18:04:51 +00:00
florian.wilde 80870133b8 feat(bausteine): Abschnitte, die mehrere Vorlagen teilen (#57)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
2026-10-04 17:07:12 +00:00
florian.wilde b688e2ebc8 docs(phase-2): Phase 2 in acht Meilensteine schneiden (#56)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
2026-10-04 16:20:42 +00:00
florian.wilde 7c9fc4eb21 docs(issues): Folge-Issue #46 mit dem Tracker verbinden (#47)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
2026-10-04 16:07:46 +00:00
florian.wilde e1f244e4e8 feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht (#45)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
2026-10-04 16:06:26 +00:00
florian.wilde f5936327a0 feat(schnellstart): Kacheln je Geraetetyp in der Oberflaeche pflegen (#44)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
2026-10-04 15:41:47 +00:00
florian.wilde 0060a55387 feat(mitarbeiter): Vorgesetzter auch fuer Personen ohne Verzeichnis (#43)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
2026-10-04 15:10:34 +00:00
florian.wilde 0fa41b9e08 feat(seed): mitgelieferte Vorlagen am Schluessel erkennen (#42)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
2026-10-04 14:49:00 +00:00
florian.wilde 88d7db8c3d fix(abnahme): Healthcheck schweigt, zwei Konventionen praezisiert (#41)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
2026-10-04 14:12:28 +00:00
Florian Wilde 9027229b55 docs(abnahme): Release v0.1.0 festhalten und Abnahme abschliessen
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Tag und Gitea-Release stehen, das Anleitungs-PDF haengt daran. Damit sind 60 der
61 Abnahmekriterien abgehakt; offen bleibt allein der Lauf des
Proxmox-Einzeilers auf einem echten PVE-Host (#38).
2026-10-03 00:18:42 +02:00
154 changed files with 14570 additions and 391 deletions
+28
View File
@@ -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=
+4
View File
@@ -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
View File
@@ -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
+41 -5
View File
@@ -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.
+8 -2
View File
@@ -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
+13
View File
@@ -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"
+24
View File
@@ -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,
+13
View File
@@ -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
View File
@@ -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
+44 -2
View File
@@ -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.
![Der Start-Dialog mit Vorlage, Gerät, Person und den Angaben zum Lauf](bilder/lauf-starten-desktop.png)
@@ -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
+3 -2
View File
@@ -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)**,
+81 -3
View File
@@ -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
+97 -4
View File
@@ -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
![Der Bestätigungsdialog für den Moduswechsel mit den Folgen](bilder/admin-moduswechsel-desktop.png)
@@ -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.
+52
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+170
View File
@@ -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`.
+1 -1
View File
@@ -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`. |
+37 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+41 -11
View File
@@ -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 -3
View File
@@ -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
+29 -12
View File
@@ -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
+38 -11
View File
@@ -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.
+32 -12
View File
@@ -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.
+7 -7
View File
@@ -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
+22 -14
View File
@@ -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
+35
View File
@@ -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
+55
View File
@@ -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)
+56
View File
@@ -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
+82
View File
@@ -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.
+69
View File
@@ -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).
+92
View File
@@ -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.
+94
View File
@@ -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.
+106
View File
@@ -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.
+61
View File
@@ -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
View File
@@ -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** |
+31
View File
@@ -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.
+2
View File
@@ -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",
+19
View File
@@ -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';
+262
View File
@@ -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
+28 -4
View File
@@ -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}-` : "";
+69 -15
View File
@@ -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"],
+63
View File
@@ -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`);
+32
View File
@@ -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>
) : (
+3
View File
@@ -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
+21 -2
View File
@@ -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">
+115
View File
@@ -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}
</>
);
}
+51
View File
@@ -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>
</>
);
}
+195
View File
@@ -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>
)}
</>
);
}
+67 -1
View File
@@ -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}
</>
);
}
+18 -3
View File
@@ -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>
</>
);
}
+125
View File
@@ -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>
)}
</>
);
}
+10 -1
View File
@@ -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", {
+61
View File
@@ -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;
}
}
+17 -3
View File
@@ -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 });
}
}
+139 -8
View File
@@ -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>
);
}
+151
View File
@@ -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>
);
}
+47
View File
@@ -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>
+153 -33
View File
@@ -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;
+52 -1
View File
@@ -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>
+170
View File
@@ -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>
);
}
+167
View File
@@ -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>
);
}
+57 -4
View File
@@ -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}
+57 -3
View File
@@ -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>
+9 -1
View File
@@ -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();
}
+111
View File
@@ -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);
}
+96 -2
View File
@@ -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,
+51
View File
@@ -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));
}
+164
View File
@@ -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(),
});
}
+82
View File
@@ -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" };
}
+79
View File
@@ -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"));
}
+42
View File
@@ -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