20 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 60aaf746cf docs(issues): #024 auf v0.3.0, es haelt den Tag nicht auf
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Der Proxmox-Einzeiler ist auf einem echten PVE-Host nie gelaufen — das ist eine
Gegenpruefung und kein fehlendes Stueck: shellcheck-geprueft, im --dry-run
gefahren, und der Installationsteil laeuft vollstaendig in einem
debian:13-Container. Was fehlt, ist pct create auf einem Host, den diese
Umgebung nicht hat. Die Pruefliste wartet unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-11 08:50:58 +02:00
Florian WildeandClaude Opus 5 150188274a chore(release): v0.2.0 vorbereiten
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Version auf 0.2.0, Changelog-Abschnitt geschlossen, Phasenzusammenfassung im
Devlog. Der Tag selbst folgt getrennt.

Gemessen, nicht uebernommen: 40.781 Zeilen unter src/ (ohne erzeugten
Prisma-Client), 25 Tabellen, 13 Aufzaehlungen, 14 Migrationen, 56 namentlich
aufgezaehlte Datenbankregeln, 1457 Vitest-Tests in 61 Dateien, 115
Ende-zu-Ende-Ablaeufe in 16 Dateien, 38 Issue-Dateien. Der Seed ist unveraendert
bei 7 Vorlagen mit 155 Schritten.

38 h 35 min fuer zwoelf Issues, geschaetzt waren 90 h. Acht davon waren
Meilensteine (M15-M22); zwei der nuetzlichsten (#036, #038) stammen aus keinem
Plan, sondern aus der Durchsicht.

Was offen bleibt, steht in der Zusammenfassung: #024 (Proxmox auf einem echten
Host) und zwei benannte Grenzen — gegen einen echten Entra-Mandanten und gegen
echte SCIM-Anbieter ist nichts gelaufen, die Formen in den Tests stammen aus der
Dokumentation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-11 06:12:08 +02:00
Florian WildeandClaude Opus 5 75c335df23 test: Zusagen pruefbar machen, die nur von Hand hielten (#038, #037)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
#038: Die E2E-Ablaeufe laufen ab jetzt vor jedem Merge nach main, nicht vor
jedem Commit. Gemessen: Bau 14 s, Start 7 s, Ablaeufe 256 s — zu den vier
Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor, das
umgangen wird. Vor jedem Merge waeren es in Phase 2 acht Laeufe gewesen, und
jeder der vier Befunde aus #027 waere am Tag seiner Entstehung aufgefallen.

Geprueft wurde jeder der 16 Ablaeufe gegen die Frage, ob es ein
Vitest-Gegenstueck gibt. Neun Zusagen haengen ausschliesslich dort, und keine
laesst sich holen: alle brauchen einen echten Browser. Die Liste steht in
CLAUDE.md.

Der eigentliche Fund dabei war keine Testluecke, sondern eine Konvention ohne
Pruefer: "jede Seite prueft selbst" steht seit #007 in CLAUDE.md, und geprueft
hat es bis hierher nur ein Mensch, der nachsieht. Der Review von Phase 2 hat
alle 27 Seiten durchgesehen — ein Ergebnis, das fuer genau einen Tag gilt.
tests/zugang.test.ts liest sie jetzt, samt jedem Route Handler; die Ausnahmen
(Auth, Healthcheck) stehen namentlich mit Begruendung, und ein Test prueft auch
die Gegenrichtung.

#037: Das Lesemodell der Anhaenge liegt in queries/ und engt selbst ein — vorher
hing das daran, dass beide Aufrufer den Lauf vorher pruefen. /meine-geraete wird
bei 390 px gemessen, mit einem echten MITARBEITER-Konto aus dem E2E-Vorlauf,
dazu ein Test, dass dort kein Link in die Verwaltung angeboten wird.

Dabei selbst einen Fehler gemacht, der die neue Regel begruendet: fuer das
zweite Konto brauchte `anmelden` eine Variante, die nicht auf / wartet, und ich
habe das Warten fuer alle gelockert. Das machte zwei unverwandte Ablaeufe
unzuverlaessig — sichtbar nur im vollen Durchgang, einzeln waren beide gruen.
Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ohne vollen
Durchgang haette ich zwei rote Ablaeufe gemergt.

1457 Vitest-Tests (vorher 1454), 115 E2E-Ablaeufe (vorher 113). Offen bleibt nur
noch #024, und das verlangt einen echten Proxmox-Host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-11 06:05:29 +02:00
Florian WildeandClaude Opus 5 80d97467da fix: Reviewbefunde aus der Durchsicht von Phase 2 (#036, #027)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.

Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.

Schwer:

- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
  (/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
  alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
  jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
  dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
  eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.

Mittel:

- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
  durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
  Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
  jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
  exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
  Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
  Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
  Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
  der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
  angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
  Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
  Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
  Graph-Leser sich im Kommentar darauf berief.

Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.

Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.

1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-11 01:32:09 +02:00
Florian WildeandClaude Opus 5 fcd67018b8 feat(verzeichnis): SCIM-Endpunkt als dritte Quellenart (#029)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Bisher holt Laufzettel Personen aus dem Verzeichnis. Das setzt Lesezugriff und
einen Netzweg dorthin voraus, und beides ist nicht ueberall gegeben. Mit der Art
SCIM ruft stattdessen der Anbieter an — sofort, nicht beim naechsten Intervall.

Umgesetzt ist, was authentik, Entra ID und Okta wirklich schicken: Users mit dem
Filter userName eq, POST/GET/PUT/PATCH/DELETE, dazu ServiceProviderConfig,
Schemas und ResourceTypes. Jeder Handler prueft sein Token selbst.

Ein Token je Quelle, argon2-gehasht, einmal angezeigt — es ist der einzige
Mandantenbeweis, den eine Zustellung mitbringt. DELETE deaktiviert und loescht
nichts. Eine SCIM-Quelle wird nicht abgeglichen, an zwei Stellen gehalten: ein
Abgleich, der dort nichts liest und ERFOLG meldet, haette ueber die
Abschaltregel die ganze Belegschaft deaktiviert.

Zwei Befunde kamen aus Gegenproben, nicht aus Tests:

- PATCH remove wirkte gar nicht. Der Schalter nurGesetzte las bei PATCH jedes
  null als "nicht mitgeschickt"; eine beim Anbieter entfernte Abteilung stand
  hier weiter. Noetig war er nie — patchAnwenden liefert immer den
  vollstaendigen Zielzustand.
- Entra ID schickt active als "True"/"False". Beim Deaktivieren kommt mit einem
  reinen value === true zufaellig das Richtige heraus, beim Aktivieren nicht:
  ein wieder freigeschaltetes Konto waere fuer immer inaktiv geblieben.

Dazu ein Fehler aus #028: in verbindungTestenAktion stand zweimal dieselbe
Bedingung, der zweite Zweig war seit dem ersten Tag unerreichbar.

1432 Tests (vorher 1373). Klickprobe am gebauten Stand mit einer echten
Zustellung. Gegen echte Mandanten ist nichts gelaufen — die Anfrageformen
stammen aus der Dokumentation, und das steht so in der Testdatei.

Closes #49

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-10 19:50:54 +02:00
Florian WildeandClaude Opus 5 76a61ec771 feat(verzeichnis): Entra ID als Quelle ueber Microsoft Graph (#48)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Ein Haus ohne lokales Active Directory hat kein LDAP, auf das sich
zeigen liesse. Eine Verzeichnisquelle hat jetzt eine **Art** — LDAP oder
GRAPH —, und beide laufen durch **denselben** Abgleich: anlegen,
aktualisieren, deaktivieren, nie loeschen. Nur das Holen der Eintraege
unterscheidet sich; zwei Abgleiche nebeneinander wuerden
auseinanderdriften, und die Zusage „es wird nichts geloescht“ gaelte
dann fuer eine Art und fuer die andere nicht mehr ganz.

Gelesen wird mit dem Client-Credentials-Flow, seitenweise ueber
@odata.nextLink, der Vorgesetzte ueber $expand=manager($select=id). Das
Client-Secret wird verschluesselt gespeichert wie das Bind-Passwort und
erreicht keine Antwort und keinen Protokolleintrag.

Die Art ist **unveraenderlich**, und das steht nicht im Issue: an ihr
haengt, was `externeId` bedeutet. Im AD eine objectGUID, in Entra ID
eine andere GUID — nach einem Wechsel wuerde kein einziger Mitarbeiter
wiedererkannt, der naechste Abgleich legte die ganze Belegschaft neu an
und setzte die alten Zeilen auf aktiv = false, mit allen Laeufen und
Geraeten daran.

Der teuerste Fund beim Bauen: „in Entra ID deaktiviert“ als
*uebersprungen* zu melden haette die Leute **aktiv** gelassen.
Uebersprungen heisst im Abgleich „nicht zuordenbar“, und solche
Eintraege werden seit #016 absichtlich nicht abgeschaltet. Gefiltert
wird deshalb serverseitig mit `$filter=accountEnabled eq true`, als
Gegenstueck zum LDAP-Standardfilter. Gefunden hat es der Test — aber nur,
weil der Doppelgaenger den Filter wirklich auswertet statt ihn bloss zu
empfangen.

{{ou}} bleibt bei einer Graph-Quelle leer: Entra ID hat keine OU, und
etwas hineinzudeuten waere ein Wert, den kein PowerShell-Befehl brauchen
kann — er saehe nur richtig aus. Steht an der Abteilung eine Ziel-OU
gepflegt, greift die.

Gegen einen echten Entra-Mandanten ist das **nicht** gelaufen. Geprueft
ist es gegen einen HTTP-Doppelgaenger mit aufgezeichneten Antworten; die
Pruefliste fuer den ersten echten Lauf steht am Ende von
docs/betrieb/entra-id.md.

Closes #48

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 23:36:36 +02:00
Florian WildeandClaude Opus 5 0158d0292f feat(selbstauskunft): Mitarbeiter sehen ihre eigenen Geraete (#55)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
„Was ist eigentlich mit meinem Notebook passiert?“ musste bisher ein
Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen**
Geraeten jetzt selbst ansehen — mit der fuenften Rolle MITARBEITER,
zugeordnet beim Anmelden ueber den UPN.

Was er sieht, sind Titel, Zeitraum und Status. Keine Schritte, keine
Notizen, keine Kommentare, keine Anhaenge: in einem Lauf stehen
Arbeitsnotizen ueber Menschen und Befehle mit Platzhaltern fuer
Zugangsdaten. Eine Selbstauskunft ist keine Akteneinsicht, und das haelt
nicht die Seite fest, sondern das Lesemodell — eine Seite, die filtert,
ist eine Seite, die es vergessen kann.

`selbstauskunft` ist ein **Ersatz** fuer `lesen` und kein Zusatz; auch
ein ADMIN hat es nicht. Ein zweiter Weg zu denselben Daten waere eine
zweite Stelle, an der jemand die Einschraenkung auf die eigene Person
vergisst. Die Datenbank verlangt das sogar: ein CHECK haelt fest, dass
Rolle und Zuordnung zusammengehoeren, in beide Richtungen.

Ein mehrdeutiger UPN wird abgewiesen und protokolliert. Zwei Kunden
koennen dieselbe Person fuehren; ein findFirst entschiede nach
Sortierung darueber, wessen Geraete jemand sieht.

**Verhaltensaenderung:** wer sich anmeldet und in keiner Rollengruppe
steht, bekommt nicht mehr automatisch LESER — also nicht mehr
Lesezugriff auf alle Laeufe des Hauses. Das greift nur mit
konfigurierten OIDC_GROUP_*; ohne sie bleibt alles wie vorher, denn ohne
abgebildete Gruppen laesst sich Techniker nicht von Mitarbeiter
unterscheiden.

Nebenbei behoben: `staerksteRolle` benutzte LESER als Startwert und
damit als Untergrenze — mit einer Rolle darunter lieferte sie mehr
Rechte als uebergeben. Gefunden von einem Test, der seit #007
unveraendert dasteht.

Closes #55

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 07:32:54 +02:00
Florian WildeandClaude Opus 5 a417426f43 feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) 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 22:52:36 +02:00
Florian WildeandClaude Opus 5 1be73b266c feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) 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 22:50:32 +02:00
florian.wilde 3ccf3aa2d9 feat(anhaenge): Fotos und Dateien an einem Schritt (#52) (#59)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
2026-10-05 20:49:26 +00: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
217 changed files with 24757 additions and 898 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
+238
View File
@@ -5,6 +5,240 @@ 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]
## [0.2.0] – 2026-10-11
### Geändert
- **Wer sich anmeldet und in keiner Rollengruppe steht, bekommt nicht mehr automatisch `LESER`.**
Bisher hieß das: Lesezugriff auf **alle** Läufe des Hauses — auch für einen ausgeschiedenen
Techniker, solange niemand sein Konto deaktivierte. Jetzt versucht Laufzettel, ihn über seinen UPN
einer Person im Verzeichnis zuzuordnen: klappt das, bekommt er die Selbstauskunft; klappt es
nicht, wird die Anmeldung mit einer Begründung abgewiesen.
**Diese Änderung greift nur, wenn Rollengruppen konfiguriert sind** (`OIDC_GROUP_*`) — ohne sie
bleibt alles wie vorher, denn ohne abgebildete Gruppen kann die Anwendung nicht unterscheiden, ob
jemand Techniker oder Mitarbeiter ist. Wer das alte Verhalten für bestimmte Leute braucht, trägt
sie in die Leser-Gruppe ein. Der lokale Notfallzugang hängt nicht an den Gruppen (#035)
- **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
- **Die Zusage „jede Seite und jeder Route Handler prüft sein Recht selbst“ wird jetzt geprüft.**
Sie steht seit #007 in den Konventionen und hielt bis hierher nur durch Nachsehen von Hand — das
gilt für den Tag, an dem es lief. `tests/zugang.test.ts` liest jetzt jede `page.tsx` und jeden
`route.ts`; die Ausnahmen (Auth, Healthcheck) stehen dort namentlich samt Begründung (#038)
- Das Lesemodell der Anhänge liegt jetzt in `queries/` und wendet seine Mandantensicht **selbst** an.
Vorher hing es daran, dass beide Aufrufer den Lauf vorher prüften — kein Schaden, aber die falsche
Form: der dritte Aufrufer ist der, der es vergisst (#037)
- `/meine-geraete` wird jetzt bei 390 px gemessen, mit einem echten `MITARBEITER`-Konto. Die Seite
ist mit „am Handy kein horizontales Scrollen“ abgenommen worden — von Hand. Dazu ein Test, dass
dort kein Link in die Verwaltung angeboten wird (#037)
- **Der Link in der Mail „Verzeichnis-Abgleich fehlgeschlagen“ zeigte ins Leere.** Gebaut wurde
`/verwaltung/verzeichnis`; `verwaltung` ist aber der Name des Rechts, die Seite liegt unter
`/admin/verzeichnis`. Das ist die eine Meldung, die an alle Administratoren geht, und zwar im
Moment eines Ausfalls — der Empfänger klickte und landete auf der Nicht-gefunden-Seite. Ein Test
prüft jetzt jeden Pfad einer Meldung gegen die vorhandenen Routen (#036)
- **Ein offener Ereignisstrom überlebte Abmelden, Deaktivieren und Sitzungswiderruf.** Das Recht
wurde genau einmal geprüft, beim Aufbau des Stroms. Ein Administrator deaktivierte ein Konto, und
eine offen gelassene Lasche bekam weiter live Status und Schrittzustände dieses einen Laufs, bis
jemand sie schloss. Der Herzschlag prüft die Sitzung jetzt alle 25 Sekunden nach; eine
Herabstufung während des Stroms engt ihn ein, statt ihn zu beenden (#036)
- **Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt.** Ein Baustein-Abschnitt
bekommt seine Schritte erst beim Veröffentlichen; wer eine noch unveröffentlichte Vorlage in eine
zweite Installation brachte, fand den geteilten Abschnitt dort unter dem Namen „wird ersetzt“ und
ohne einen einzigen Schritt. Beim Export werden Bausteine jetzt aufgelöst (#036)
- **`docs/betrieb/sicherung.md` nannte für den nativen Weg einen Anhänge-Pfad, den es nirgends
gibt** — als Rückfallwert in einem Skript, in dem ein `rm -rf` steht. Auf einer Installation ohne
`ANHAENGE_PFAD` in der Env-Datei wäre in ein Verzeichnis zurückgespielt worden, das die Anwendung
nie liest: „Rücksicherung gelaufen“, und danach fehlt jede Datei. Jetzt bricht der Befehl ab,
statt zu raten (#036)
- **`README.md` kannte die Rolle `MITARBEITER` nicht** und behauptete weiter, unbekannte Benutzer
würden beim ersten Login angelegt. Seit #035 wird jemand ohne Rollengruppe über seinen UPN
zugeordnet oder abgewiesen. Wer auf v0.2.0 hebt und nur das README liest, hätte genau diese Leute
ausgesperrt (#036)
- **Der Protokolleintrag „Anmeldung abgewiesen: UPN mehrdeutig“ nannte immer zwei Treffer**, auch bei
sieben. Wer das Protokoll liest, um den Fall aufzulösen, braucht die wirkliche Zahl (#036)
- **`anzahlSchritte` im Protokoll eines neuen Laufs zählte die Schritte der Version**, nicht die des
Laufs — bei einer Vorlage mit abteilungsgebundenen Abschnitten also zu viele (#036)
- Die Attributzuordnung einer Verzeichnisquelle wird jetzt **im Dienst** geprüft und nicht nur im
Formular. Ein Attributname mit Komma und `$filter` kam zuvor durch und schrieb einen zweiten
`$filter` in die Graph-Abfrage (#036)
- Der Graph-Seitenabruf folgt `@odata.nextLink` nur noch, wenn die Adresse auf denselben Host zeigt
wie `GRAPH_API_URL`. Jede Seite wird mit einem mandantenweit gültigen Token geholt (#036)
- **Die Ende-zu-Ende-Abläufe räumen auf, was sie anlegen** — zentral, vor und nach dem Lauf. In der
Entwicklungsdatenbank standen 105 Vorlagen aus früheren Durchgängen. Ein struktureller Test
schlägt an, wenn ein neuer Ablauf das Präfix nicht benutzt (#027)
- Vier Ende-zu-Ende-Abläufe waren seit #020, #030, #031 beziehungsweise #033 rot, ohne dass es
auffiel: `pnpm test:e2e` steht in keinem Tor. Alle vier behoben, die Lücke ist als #038 benannt
(#027)
- Der Verbindungstest einer Verzeichnisquelle unterschied „der Filter findet niemanden“ nicht mehr
von „gelesen, aber nichts zuordenbar“ — zwei verschiedene Ursachen, die den Administrator an
verschiedene Stellen schicken. Die Unterscheidung war in #028 eingebaut worden, aber durch eine
doppelte Bedingung seit dem ersten Tag unerreichbar (#029)
- **`staerksteRolle` konnte mehr Rechte liefern als übergeben.** `LESER` war Startwert und damit
zugleich Untergrenze: eine Rolle unterhalb davon kam nie heraus. Solange alle Rollen mindestens
`LESER` waren, fiel das nicht auf — mit der neuen Rolle `MITARBEITER` ergab
`staerksteRolle(["MITARBEITER"])` plötzlich `LESER`. Gefunden von einem Test, der seit #007
unverändert dasteht (#035)
- **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
- **SCIM 2.0: Personen können jetzt auch geschickt werden, statt geholt zu werden.** Bisher
verbindet sich Laufzettel nach Zeitplan mit einem Verzeichnis und liest. Das setzt zwei Dinge
voraus, die nicht überall gegeben sind: Lesezugriff auf das Verzeichnis, und ein Netzweg dorthin.
Mit der neuen Quellenart `SCIM` ruft stattdessen der Identitätsanbieter Laufzettel an — authentik,
Entra ID, Okta — und zwar in dem Moment, in dem sich bei ihm etwas ändert, nicht erst beim
nächsten Intervall.
Umgesetzt ist, was die Anbieter wirklich schicken: `Users` mit dem Filter `userName eq "…"`,
`POST`, `GET`, `PUT`, `PATCH`, `DELETE`, dazu `ServiceProviderConfig`, `Schemas` und
`ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen. Übernommen
werden Anmeldename, Name, Mailadresse, Titel, Abteilung, der Vorgesetzte und ob das Konto aktiv
ist; keine Gruppen, keine Rollen, keine Passwörter.
**`DELETE` deaktiviert und löscht nicht.** Der Anbieter bekommt `204` und hält die Person damit
für entfernt — aus seiner Sicht richtig. In Laufzettel bleibt sie inaktiv stehen, weil Läufe und
Geräte an ihr hängen. Das steht in der Anleitung, weil es sonst wie ein Fehler wirkt.
**Eine SCIM-Quelle wird nicht abgeglichen**, und das ist keine Kosmetik: ein Abgleich, der dort
nichts liest und „Erfolg“ meldet, hätte über die Abschaltregel die ganze Belegschaft deaktiviert.
Verbindungstest und „Jetzt abgleichen“ gibt es für sie deshalb nicht; an ihrer Stelle steht die
letzte Zustellung.
Das **Token** wird unter Admin → Verzeichnis erzeugt, **einmal** angezeigt und nur als
argon2-Hash gespeichert — es gibt keinen zweiten Weg, es zu lesen. Es ist der einzige
Mandantenbeweis, den eine Zustellung mitbringt: ein Token sieht nur die Personen seiner eigenen
Quelle. Einrichtung in authentik und Entra ID steht in
[Kapitel 06](docs/anleitung/06-verzeichnis.md#scim-der-anbieter-schickt), der Betrieb in
[`docs/betrieb/scim.md`](docs/betrieb/scim.md) (#029)
- **Entra ID als Verzeichnisquelle, über Microsoft Graph.** Ein Haus ohne lokales Active Directory
hat kein LDAP, auf das sich zeigen ließe. Eine Verzeichnisquelle hat jetzt eine **Art** — `LDAP`
oder `GRAPH` —, und beide laufen durch **denselben** Abgleich: anlegen, aktualisieren,
deaktivieren, **nie löschen**. Nur das Holen der Einträge unterscheidet sich, sonst drifteten die
Regeln auseinander.
Gelesen wird mit dem Client-Credentials-Flow über `GET /v1.0/users`, seitenweise über
`@odata.nextLink`, der Vorgesetzte über `$expand=manager($select=id)`. Das Client-Secret wird
verschlüsselt gespeichert wie das LDAP-Bind-Passwort und erscheint in keiner Antwort und in keinem
Protokolleintrag.
**Zwei Platzhalter bleiben bei Graph bewusst leer:** `{{ou}}` (Entra ID hat keine OU — steht an der
Abteilung eine Ziel-OU, greift die) und `{{sam}}` ohne AD-Synchronisierung. Was in Entra ID
einzurichten ist, steht in `docs/betrieb/entra-id.md` — mit einer Prüfliste, denn gegen einen
echten Mandanten ist dieser Weg **nicht** gelaufen (#028)
- **Mitarbeiter-Selbstauskunft.** „Was ist eigentlich mit meinem Notebook passiert?“ musste bisher
ein Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen** Geräten jetzt selbst
ansehen, unter **Meine Geräte** — mit der neuen Rolle `MITARBEITER`, zugeordnet über den **UPN**
beim Anmelden. Ein mehrdeutiger UPN wird abgewiesen und protokolliert: ein Zufallstreffer
entschiede nach Sortierung darüber, wessen Geräte jemand sieht.
**Was er sieht, sind Titel, Zeitraum und Status — und sonst nichts.** Keine Schritte, keine
Notizen, keine Kommentare, keine Anhänge: in einem Lauf stehen Arbeitsnotizen über Menschen und
Befehle mit Platzhaltern für Zugangsdaten. Eine Selbstauskunft ist keine Akteneinsicht, und das
hält nicht die Seite fest, sondern das Lesemodell.
Kapitel 09 der Anleitung richtet sich erstmals **an Mitarbeiter** und nicht an die IT (#035)
- **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 +424,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
+97 -6
View File
@@ -38,14 +38,18 @@ 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}` ·
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
`LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` ·
`SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` ·
`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}` ·
`QuellenArt {LDAP GRAPH SCIM}` ·
`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 +60,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.
```
@@ -75,12 +80,19 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
- **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus
`INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`.
- **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`.
**`lesen` hat nicht jede Rolle** — `MITARBEITER` darf es seit #035 nicht, und `selbstauskunft` ist
dessen Ersatz und kein Zusatz. Jeder Navigationseintrag nennt deshalb sein Recht; ein Eintrag
ohne `recht` ist die Ausnahme.
Rolle und Aktiv-Status werden pro Anfrage **aus der Datenbank** gelesen, nicht aus dem Token.
**Jede Seite prüft selbst** — die Hülle unter `src/app/(app)/layout.tsx` ist das zweite Netz,
nicht das erste: Next rendert Layouts bei Navigation innerhalb desselben Layouts nicht neu.
**Route Handler deckt die Hülle nicht ab**; alles unter `src/app/api/`, das mehr tut als Auth
und Healthcheck, ruft selbst `rechtErforderlich()`. Der Proxy prüft das Cookie absichtlich nur
auf Vorhandensein, nie inhaltlich.
Beides prüft `tests/zugang.test.ts` seit #036 **strukturell**: es liest jede `page.tsx` und jeden
`route.ts` und schlägt an, wenn eine Datei nichts prüft. Die Ausnahmen stehen dort namentlich,
samt Begründung — ein neuer Handler ohne Prüfung soll jemanden zwingen, sie aufzuschreiben.
Vorher hielt die Zusage nur durch Nachsehen von Hand, und das gilt für den Tag, an dem es lief.
- **Server-Module gehören nicht ins Browser-Bündel.** Eine Client-Komponente importiert nichts aus
`src/server/*`. Was beide Seiten brauchen (Beschriftungen, Formate), liegt in `src/lib` —
`ROLLEN_TEXT` etwa in `src/lib/rollen-text.ts`, während `RECHTE` und `darf()` serverseitig
@@ -97,9 +109,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 +135,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()`,
@@ -173,12 +193,72 @@ Wer eine Oberfläche abnimmt, klickt sie im gebauten Stand durch — hinzufügen
speichern, neu laden, und nachsehen, ob es wirklich in der Datenbank steht.
`pnpm docs:screenshots` verlangt deshalb ebenfalls den gebauten Stand.
## Die Ende-zu-Ende-Abläufe laufen vor jedem Merge nach `main`
```bash
pnpm build && PORT=3300 APP_URL=http://127.0.0.1:3300 pnpm start &
E2E_APP_URL=http://127.0.0.1:3300 pnpm test:e2e
```
**Nicht im Commit-Tor, und das ist eine Rechnung, keine Bequemlichkeit.** Gemessen am 2026-10-11:
Bau 14 s, Start 7 s, Abläufe **256 s** — zusammen 4 min 37 s, zu den 4 min des Commit-Tors. Neun
Minuten vor jedem Commit ist ein Tor, das umgangen wird, und ein umgangenes Tor prüft nichts.
**Vor jedem Merge ist die Regel, weil sie gereicht hätte.** In Phase 2 waren das acht Läufe. Beim
ersten vollständigen Durchgang nach Monaten waren **sieben Abläufe rot** (#027): ein Test, der die
Felder des Laufzustands aufzählt und `anzahlAnhaenge` nicht kannte (seit #033); ein Selektor, der
seit #020 zweimal traf; eine feste Zugweite von 300 px bei 532 px Abstand (seit #030/#031); und
Entwicklungsdaten, die vom Seed abwichen. Keiner davon war ein Produktfehler — und jeder wäre am Tag
seiner Entstehung aufgefallen.
**Diese Zusagen stehen ausschließlich dort**, und keine davon lässt sich nach Vitest holen: sie
brauchen einen echten Browser.
| Zusage | Warum nur im Browser |
|---|---|
| Kein horizontales Scrollen bei 390 px | verlangt Layout, nicht Logik |
| Scannen über `BarcodeDetector` | Browser-API samt Polyfill |
| Sortieren per Finger | Touch-Ereignisse und `dnd-kit` |
| Zwei Sitzungen sehen einander live | zwei Browser-Kontexte gleichzeitig |
| Rückfall auf Polling ohne `EventSource` | das Client-Verhalten; der Handler-Teil steht in Vitest |
| „Alle Befehle kopieren“ zeichengenau | Zwischenablage |
| Druckansicht und Etiketten | Print-CSS |
| Eine Rolle sieht keine Schreib-Knöpfe | gerendertes DOM |
| Dass überhaupt hydriert wird | der Grund für „gebauter Stand“, siehe oben |
**Was sich holen ließ, ist geholt.** Die Rechteprüfung der Route Handler steht seit #036 in
`tests/anhang-route.test.ts`, `tests/lauf-ereignisse.test.ts` und `tests/scim.test.ts` — die Handler
werden mit echten `Request`-Objekten gefahren. Dazu prüft `tests/zugang.test.ts` **strukturell**,
dass jede Seite und jeder Route Handler sein Recht selbst prüft; die Ausnahmen (Auth und
Healthcheck) stehen dort namentlich. Vorher hielt diese Zusage nur durch Nachsehen von Hand.
Die 35 übersprungenen Abläufe sind die modusabhängigen: sie laufen im jeweils anderen
`Betriebsmodus` und sagen das beim Überspringen.
**Die Abläufe räumen auf, was sie anlegen** (#027). Alles trägt `ZZ` am Anfang;
`tests/e2e/aufraeumen.ts` greift daran, **vor und nach** dem Lauf. Der Nachlauf fängt Testfehler und
Zeitabläufe, der Vorlauf fängt `Strg+C` — ein Vorlauf lässt sich nicht überspringen. Er sagt laut,
wenn er einen Rückstand findet.
## 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 +278,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.
+48 -9
View File
@@ -4,15 +4,15 @@ Checklisten für wiederkehrende IT-Arbeiten. Versionierte Vorlagen, daraus gesta
Team abgearbeitet, abgeschlossen und unveränderlich archiviert — mit Historie je Gerät und je
Mitarbeiter. Einstieg über QR-Aufkleber: Code scannen, Geräteseite öffnen, loslegen.
> **Stand:** in Entwicklung auf `v0.1.0` (MVP). Der Fortschritt steht in
> [`docs/issues/README.md`](docs/issues/README.md), Entscheidungen im
> **Stand:** `v0.1.0` ist veröffentlicht, Phase 2 liegt auf `main` und wird `v0.2.0`. Der
> Fortschritt steht in [`docs/issues/README.md`](docs/issues/README.md), Entscheidungen im
> [Entwicklungstagebuch](docs/devlog.md).
## Zwei Betriebsmodi
| Modus | Für wen | Kunden-Ebene | Verzeichnis |
|---|---|---|---|
| `INTERN` (Standard) | eine Firma, eigene Mitarbeiter | unsichtbar | eigenes AD per LDAP |
| `INTERN` (Standard) | eine Firma, eigene Mitarbeiter | unsichtbar | eigenes Verzeichnis per LDAP, Microsoft Graph oder SCIM |
| `DIENSTLEISTUNG` | IT-Dienstleister mit mehreren Kunden | sichtbar | manuell oder per CSV je Kunde |
Der Modus liegt in der Datenbank und ist vom Admin ohne Neustart umschaltbar. Jede kundenbezogene
@@ -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
@@ -152,15 +158,48 @@ zugeordnet. Unbekannte Benutzer werden beim ersten Login angelegt.
| `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen; alles was `TECHNIKER` darf |
| `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen |
| `LESER` | nur lesen |
| `MITARBEITER` | **nur die eigenen Geräte und deren Läufe** — ohne Schritte, Notizen und Kommentare |
**`MITARBEITER` hat keine eigene Gruppe, und das ist Absicht** (seit #035). Wer sich anmeldet und in
keiner der vier abgebildeten Gruppen steht, bekommt **nicht mehr** `LESER` — das hieße Lesezugriff
auf alle Läufe des Hauses. Stattdessen versucht Laufzettel, ihn über seinen UPN einer Person im
Verzeichnis zuzuordnen: klappt das, bekommt er die Selbstauskunft; klappt es nicht, wird die
Anmeldung mit einer Begründung abgewiesen.
Das greift **nur, wenn Rollengruppen konfiguriert sind** — ohne `OIDC_GROUP_*` bleibt alles wie
vorher, denn ohne abgebildete Gruppen kann die Anwendung nicht unterscheiden, ob jemand Techniker
oder Mitarbeiter ist. Wer das alte Verhalten für bestimmte Leute braucht, trägt sie in die
Leser-Gruppe ein. Einzelheiten in
[`docs/betrieb/authentik.md`](docs/betrieb/authentik.md).
Die Anbindung an authentik ist in [`docs/betrieb/authentik.md`](docs/betrieb/authentik.md)
Schritt für Schritt beschrieben.
## Verzeichnis-Abgleich (nur Modus `INTERN`)
## Verzeichnis (nur Modus `INTERN`)
Unter **Admin → Verzeichnis** wird eine LDAP-Quelle angelegt: Adresse (`ldaps://` empfohlen,
`ldap+starttls://` und `ldap://` gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und
optional ein eigener Filter. Ein Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis.
Unter **Admin → Verzeichnis** wird eine Quelle angelegt. Es gibt drei Arten, und sie unterscheiden
sich in der **Richtung**:
| Art | Für wen | Richtung |
|---|---|---|
| `LDAP` | lokales Active Directory oder anderes LDAP-Verzeichnis | Laufzettel holt nach Zeitplan |
| `GRAPH` | Entra ID über Microsoft Graph, für Häuser ohne lokales AD | Laufzettel holt nach Zeitplan |
| `SCIM` | authentik, Entra ID, Okta — wenn Laufzettel nicht ins Verzeichnis sehen soll oder es nicht erreicht | der Anbieter schickt, sofort |
**Die Art ist unveränderlich.** An ihr hängt, was die dauerhafte Kennung einer Person bedeutet;
nach einem Wechsel würde niemand wiedererkannt. Alle drei verhalten sich danach gleich: anlegen,
übernehmen, aktualisieren, deaktivieren, **nie löschen**.
Für **LDAP** gehören ins Formular: Adresse (`ldaps://` empfohlen, `ldap+starttls://` und `ldap://`
gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und optional ein eigener Filter. Ein
Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis. Für **Graph** eine App-Registrierung
mit der Anwendungsberechtigung `User.Read.All`, siehe `docs/betrieb/entra-id.md`.
Für **SCIM** gibt es keine Zugangsdaten: Quelle anlegen, Token erzeugen (es erscheint **einmal**,
gespeichert wird nur ein argon2-Hash), Token und die Adresse `…/api/scim/v2` beim Anbieter
eintragen. Verbindungstest, Zeitplan und Abgleich-Log gibt es dort nicht — an ihrer Stelle steht
die letzte Zustellung. Ein `DELETE` des Anbieters **deaktiviert** die Person und löscht nichts.
Einzelheiten in `docs/betrieb/scim.md`.
- **„Verbindung testen“** liest die ersten Einträge und zeigt sie **mit den gemappten Feldern**,
ohne etwas zu speichern. Erst daran sieht man, ob die Zuordnung stimmt.
+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
+143
View File
@@ -16,6 +16,141 @@ Zwei Dinge vorweg:
Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird
nie, sonst verlöre die Historie ihre Namen.
## Drei Arten von Quelle
Laufzettel braucht die Personen aus einem Verzeichnis. Dafür gibt es drei Wege, und du wählst ihn
beim Anlegen der Quelle:
| Art | Für wen | Richtung |
|---|---|---|
| **LDAP** | ein lokales Active Directory (oder ein anderes LDAP-Verzeichnis) | Laufzettel holt |
| **Microsoft Graph** | Entra ID, also Microsoft 365 — für Häuser ohne lokales AD | Laufzettel holt |
| **SCIM** | authentik, Entra ID, Okta — wenn Laufzettel nicht ins Verzeichnis sehen soll oder es nicht erreicht | der Anbieter schickt |
Die ersten zwei sind dieselbe Arbeitsweise mit anderen Zugangsdaten: Laufzettel verbindet sich nach
Zeitplan und liest. **SCIM ist die andere Richtung** — dort gibt es keine Zugangsdaten, keinen
Zeitplan und keinen Verbindungstest; der Anbieter schickt jede Änderung in dem Moment, in dem sie
dort entsteht. Für SCIM gibt es [einen eigenen Abschnitt](#scim-der-anbieter-schickt) weiter unten.
Alle drei verhalten sich danach gleich: anlegen, aktualisieren, deaktivieren, **nie löschen**. Was
LDAP und Graph unterscheidet, sind die Zugangsdaten und zwei Platzhalter:
- **`{{ou}}` bleibt bei Graph leer.** Entra ID hat keine OU. Steht an der Abteilung eine Ziel-OU
gepflegt, nimmt Laufzettel die; sonst bleibt der Platzhalter stehen.
- **`{{sam}}` ist bei Graph leer**, wenn der Mandant kein lokales AD synchronisiert — dort gibt es
keinen Anmeldenamen in diesem Sinn.
**Die Art lässt sich nachträglich nicht ändern.** An ihr hängt, was die dauerhafte Kennung einer
Person bedeutet; nach einem Wechsel würde niemand mehr wiedererkannt, und der nächste Abgleich legte
alle neu an. Wer wechseln muss, legt eine zweite Quelle an und lässt die erste stehen.
Was in Entra ID einzurichten ist — App-Registrierung, Berechtigung, Einwilligung — steht in
[`docs/betrieb/entra-id.md`](../betrieb/entra-id.md).
## SCIM: der Anbieter schickt
Bei den ersten zwei Arten fragt Laufzettel das Verzeichnis. Bei SCIM ist es umgekehrt: der
Identitätsanbieter ruft Laufzettel an, sobald sich bei ihm etwas ändert. Das ist der Weg für zwei
Lagen:
- Das Haus will Laufzettel **keinen Lesezugriff** auf sein Verzeichnis geben.
- Laufzettel läuft in einem Netz, das das Verzeichnis **nicht erreicht** — ein Push von außen nach
innen geht, ein Lesezugriff von innen nach außen nicht.
### Was anders ist
| | LDAP und Graph | SCIM |
|---|---|---|
| Zugangsdaten | Dienstkonto bzw. App-Registrierung | **keine** — ein Token für den Anbieter |
| Zeitplan | alle *n* Minuten | keiner, es kommt sofort |
| Verbindung testen | zeigt die ersten zehn Treffer | gibt es nicht — es gibt keine Verbindung |
| Abgleich-Log | je Lauf eine Zeile | gibt es nicht; stattdessen **letzte Zustellung** |
| Attributzuordnung | einstellbar | fest, der Anbieter bestimmt die Feldnamen (RFC 7643) |
| `{{ou}}` | aus dem DN bzw. der Abteilung | leer — SCIM kennt keine OU |
Was Laufzettel übernimmt: Anmeldename (`userName`), Vor- und Nachname, Anzeigename, Mailadresse,
Titel, Abteilung, der Vorgesetzte und ob das Konto aktiv ist. Mehr nicht — keine Gruppen, keine
Rollen, keine Passwörter.
### Einrichten: Laufzettel zuerst
1. **Quelle anlegen.** Unten auf der Seite **Art → SCIM (wird geliefert)** wählen, einen Namen
geben (etwa „authentik Push“), **Zustellungen annehmen** anhaken und speichern. Mehr Felder gibt
es nicht.
2. **Token erzeugen.** An der neuen Quelle steht **Token erzeugen**. Es erscheint **genau einmal** —
kopier es sofort. Gespeichert wird es nur als Hash; ein zweites Ansehen gibt es nicht. Wer es
verliert, erzeugt ein neues, und das alte gilt dann nicht mehr.
3. **Adresse ablesen.** Unter dem Namen der Quelle steht die Adresse, die der Anbieter braucht, etwa
`https://laufzettel.firma.de/api/scim/v2`.
### Einrichten in authentik
Unter **Applications → Providers → Create → SCIM Provider**:
| Feld | Wert |
|---|---|
| **Name** | etwa „Laufzettel“ |
| **URL** | die Adresse von oben, also `https://…/api/scim/v2` |
| **Token** | das erzeugte Token, unverändert eingefügt |
Bei **User filtering** wählst du die Gruppe, deren Mitglieder in Laufzettel stehen sollen — nicht
alle Konten, sondern die Menschen, für die Läufe entstehen. Unter **Attribute mapping** genügen die
Vorgaben; authentik schickt `userName`, `name`, `emails` und die Erweiterung
`enterprise:2.0:User` von selbst.
Danach **Save** und einmal **Sync** auslösen. In Laufzettel steht dann an der Quelle **Letzte
Zustellung am …** und die Personen stehen unter **Mitarbeiter**.
### Einrichten in Entra ID
Unter **Entra ID → Enterprise applications → New application → Create your own application →
Integrate any other application you don't find in the gallery**. Dann in der neuen Anwendung
**Provisioning → New configuration**:
| Feld | Wert |
|---|---|
| **Tenant URL** | die Adresse von oben, also `https://…/api/scim/v2` |
| **Secret Token** | das erzeugte Token |
**Test Connection** muss grün werden — Entra ID fragt dabei `ServiceProviderConfig`, `Schemas` und
`ResourceTypes` ab und legt probeweise niemanden an. Danach unter **Users and groups** die Gruppe
zuweisen und das Provisioning auf **On** stellen.
Zwei Eigenheiten von Entra ID, die hier nicht stören, aber erklären, was in den Protokollen steht:
- Entra ID schickt `active` als **Zeichenkette** `"True"`/`"False"`, nicht als `true`/`false`.
Laufzettel liest beides.
- Der erste vollständige Durchlauf beginnt bis zu **40 Minuten** nach dem Einschalten. Das ist
Entra ID und nichts, was sich hier beschleunigen ließe.
### `Löschen` beim Anbieter heißt hier `inaktiv`
Nimmt jemand eine Person beim Anbieter aus der zugewiesenen Gruppe oder löscht sie dort, schickt
der Anbieter ein `DELETE`. **Laufzettel setzt die Person dann auf inaktiv und löscht nichts.** Die
Zeile bleibt, weil Läufe und Geräte an ihr hängen: ein archivierter Lauf muss auch in zehn Jahren
noch sagen können, für wen er lief.
Der Anbieter bekommt `204` und hält die Person damit für entfernt — aus seiner Sicht richtig, denn
sie wird ihm nicht mehr zugewiesen. **In Laufzettel steht sie weiter in der Liste**, inaktiv. Das
sieht aus wie ein Fehler und ist keiner. Wer sie aus der Ansicht haben will, filtert auf „nur
aktive“ — das ist die Vorgabe der Mitarbeiterliste.
### Wenn nichts ankommt
| Was der Anbieter meldet | Woran es liegt |
|---|---|
| `401` | Das Token stimmt nicht, oder **Zustellungen annehmen** ist nicht angehakt. Beides ergibt dieselbe Antwort — absichtlich. |
| `404` auf eine bekannte Person | Die Person gehört zu einer **anderen** Quelle. Jedes Token sieht nur die Personen seiner eigenen. |
| `409` beim Anlegen | Die Person gibt es in dieser Quelle schon. Der Anbieter fragt normalerweise vorher mit einem Filter; schickt er trotzdem, ist `409` die richtige Antwort und kein Datenverlust. |
| `400 invalidFilter` | Der Anbieter benutzt einen Filter, den Laufzettel nicht unterstützt — nur `userName eq "…"` geht. |
| nichts, Zeitüberschreitung | Der Reverse Proxy lässt `/api/scim/` nicht durch, oder TLS stimmt nicht. Siehe [`docs/betrieb/scim.md`](../betrieb/scim.md). |
Eine Quelle **stilllegen**, ohne das Token beim Anbieter zu löschen: Häkchen **Zustellungen
annehmen** entfernen. Der Endpunkt antwortet dann mit `401`, und beim Anbieter stehen die
Zustellungen als Fehler — sie gehen nicht verloren, er versucht es erneut.
## Eine Quelle anlegen
![Die Seite Verzeichnis mit Quelle, Status und Formular](bilder/verzeichnis-desktop.png)
@@ -90,6 +225,10 @@ Namensänderung der Quelle ihre Anmeldung.
![Das Ergebnis eines Verbindungstests: „Die Verbindung steht. 4 Einträge gelesen, 1 übersprungen“ und darunter die ersten drei Einträge mit UPN, Mail, DN, sam und Abteilung](bilder/verbindungstest-desktop.png)
Die folgenden vier Abschnitte gelten für **LDAP und Graph**. Bei einer SCIM-Quelle gibt es weder
Verbindungstest noch Zeitplan noch Abgleich-Log — dort steht an ihrer Stelle die letzte Zustellung,
siehe [oben](#scim-der-anbieter-schickt).
**Verbindung testen** liest die ersten Einträge und zeigt sie so, wie sie ankämen — **gespeichert
wird nichts**. Mach das immer vor dem ersten Abgleich. Klappt es nicht, steht der Grund in Rot
darunter; die häufigsten stehen in [Kapitel 08](08-fehlerbehebung.md).
@@ -169,3 +308,7 @@ deshalb als Hinweis und nicht als Fehler da. Steigt die Zahl, prüf die Attribut
Eine Quelle ist keine Voraussetzung. Personen lassen sich von Hand anlegen oder aus einer CSV-Datei
einlesen — ein Verzeichnis nimmt nur die Arbeit ab. Siehe
[Kapitel 05](05-mitarbeiter-geraete.md).
Von Hand angelegte Personen gehen dabei nicht verloren, wenn später eine Quelle dazukommt: taucht
jemand mit demselben Anmeldenamen aus dem Verzeichnis oder per SCIM auf, **übernimmt** Laufzettel
die vorhandene Zeile statt eine zweite anzulegen. Ihre Läufe und Geräte bleiben an ihr hängen.
+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
+69
View File
@@ -0,0 +1,69 @@
# Meine Geräte
> **Dieses Kapitel ist für dich, wenn du kein IT-Mitarbeiter bist.** Alle anderen Kapitel richten
> sich an die Leute, die Laufzettel bedienen — dieses hier an die, über die darin etwas steht.
Wenn die IT an deinem Rechner gearbeitet hat, hält sie das in Laufzettel fest: was gemacht wurde,
wann und mit welchem Gerät. Unter **Meine Geräte** siehst du den Teil davon, der dich betrifft.
## Anmelden
Du meldest dich mit demselben Konto an wie überall sonst im Haus — über den Knopf **Mit dem Anbieter
anmelden**. Ein eigenes Passwort für Laufzettel gibt es nicht.
Danach landest du direkt auf **Meine Geräte**. Mehr gibt es für dich nicht zu sehen, und das ist
keine Einschränkung aus Versehen: alles andere in Laufzettel sind Arbeitsunterlagen der IT.
## Was dort steht
Für jedes Gerät, das auf dich läuft:
- **Name und Typ** — etwa „NB-042, Notebook“
- **Seriennummer und Aufkleber-Code** — hilfreich, wenn du bei der IT anrufst
- **Die Arbeiten daran**, neueste zuerst: Titel, Zeitraum und ob sie fertig sind
Darunter, falls es das gibt, ein Abschnitt **Zu mir persönlich**: Arbeiten, die an keinem Gerät
hängen — etwa das Einrichten deines Kontos, als du angefangen hast.
Und ganz oben **Meine Daten**: dein Name, deine Funktion, deine Abteilung und deine Mailadresse, so
wie das Verzeichnis sie führt. Stimmt da etwas nicht, sag es der IT — ändern lässt es sich hier
nicht, denn diese Angaben kommen aus dem Verzeichnis des Hauses.
## Was dort nicht steht, und warum
Du siehst **Titel, Zeitraum und Status** einer Arbeit. Du siehst **nicht**:
- die einzelnen Schritte
- Notizen der Techniker
- Kommentare
- angehängte Fotos
Das ist Absicht und keine Geheimniskrämerei. In einem Lauf stehen Arbeitsnotizen, die während der
Arbeit entstehen, und Befehle mit Platzhaltern für Zugangsdaten. Beides ist nicht dafür gedacht,
gelesen zu werden — weder von dir noch von sonst jemandem außerhalb der IT.
**Wenn du wissen willst, was genau gemacht wurde, frag die IT.** Dann antwortet ein Mensch, der den
Zusammenhang kennt, statt einer Liste, die man missverstehen kann.
## Ein archiviertes Gerät steht weiter da
Geräte, die nicht mehr in Benutzung sind, bleiben in der Liste — mit dem Vermerk **nicht mehr in
Benutzung**. „Was war eigentlich mit meinem alten Notebook?“ ist genau die Frage, um die es hier
geht, und das alte Notebook ist archiviert.
## Die Liste ist leer
Dann hat die IT zu dir nichts festgehalten. Kein Fehler — es heißt nur, dass auf dich gerade kein
Gerät läuft und keine Arbeit zu dir vermerkt ist.
## „Dein Konto ist keiner Person im Verzeichnis zugeordnet“
Diese Meldung bei der Anmeldung heißt: Laufzettel konnte dein Konto keiner Person zuordnen. Meist
liegt es daran, dass dein Anmeldename im Verzeichnis anders geschrieben steht als beim
Anmeldeanbieter. Das kann nur die IT auflösen — melde dich dort.
## Keine Mail, keine Benachrichtigung
Laufzettel schickt dir nichts. Die Benachrichtigungen, die es gibt, gehen an die IT: wenn eine
Arbeit jemandem zugewiesen wird, überfällig ist oder fertig. Du erfährst von einer Arbeit an deinem
Gerät auf dem üblichen Weg — der Techniker sagt es dir.
+8
View File
@@ -29,6 +29,14 @@ nie an ein zweites Gerät vergeben — er ist die einzige Angabe am Gerät, die
- [06 Verzeichnis anbinden](06-verzeichnis.md) — Active Directory als Quelle der Personen
- [07 Verwaltung](07-admin.md) — Benutzer, Rollen, Einstellungen, Betriebsmodus, Kunden
**Mitarbeiter** — du bedienst Laufzettel nicht, sondern willst nur wissen, was mit deinem Gerät
passiert ist:
- [09 Meine Geräte](09-selbstauskunft.md) — anmelden, was dort steht und was nicht
Dieses eine Kapitel ist **an Mitarbeiter gerichtet** und nicht an die IT. Wer die Selbstauskunft im
Haus freigibt, kann es weitergeben, ohne den Rest der Anleitung mitzuschicken.
## Zwei Betriebsmodi
Laufzettel läuft in einem von zwei Modi. Der Modus entscheidet, ob es eine Kundenebene gibt, und
+149 -10
View File
@@ -153,6 +153,17 @@ Zwei Folgen, beide verbindlich:
klickt den Editor durch: anlegen, tippen, sortieren mit dem Finger bei 390 px, speichern, neu
laden und nachsehen, ob es wirklich in der Datenbank steht.
Daraus folgt eine dritte, die erst #027 gelehrt hat: **diese Abläufe müssen regelmäßig laufen, weil
sie die einzigen Prüfer ihrer Zusagen sind.** Sie stehen nicht im Commit-Tor — gemessen 256 s für
die Abläufe, dazu Bau und Start, zu den vier Minuten des Tores; neun Minuten vor jedem Commit ist
ein Tor, das umgangen wird. Verbindlich ist deshalb **vor jedem Merge nach `main`**, und `CLAUDE.md`
führt auf, welche Zusagen ausschließlich dort hängen.
Beim ersten vollständigen Durchgang nach Monaten waren sieben Abläufe rot, vier Ursachen, **keine
ein Produktfehler** — ein veralteter Feldvergleich, ein seit #020 mehrdeutiger Selektor, eine feste
Pixelzahl bei gewachsenem Layout und abweichende Entwicklungsdaten. Dass keiner ein Produktfehler
war, ist kein Trost: es heißt, dass die Abläufe in dieser Zeit **nichts** geprüft haben.
Die Content-Security-Policy in `src/proxy.ts` ist deshalb zweigeteilt: im Entwicklungsmodus mit
`unsafe-eval` und WebSocket, im ausgelieferten Stand ohne beides. Die Entscheidung fällt an
`process.env.NODE_ENV`, und `tests/sicherheitsrichtlinie.test.ts` prüft sie am Proxy selbst — eine
@@ -185,21 +196,121 @@ 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.
### Warum ein Mitarbeiter keine Akteneinsicht bekommt
Ein `Mitarbeiter` ist kein `Benutzer`. Die vier Rollen bis #035 waren für Leute, die **arbeiten**;
ein Mitarbeiter ist Gegenstand der Arbeit, nicht ihr Träger. Mit der Selbstauskunft kommt die fünfte
Rolle `MITARBEITER` dazu, und sie ist die einzige, die **nicht** `lesen` darf.
**`selbstauskunft` ist ein Ersatz für `lesen`, kein Zusatz.** Darum hat auch ein `ADMIN` dieses
Recht nicht: er sieht über `lesen` ohnehin alles, und ein zweiter Weg zu denselben Daten wäre eine
zweite Stelle, an der jemand die Einschränkung auf die eigene Person vergisst. Wer beides braucht,
braucht zwei Konten — und genau das hält die Datenbank fest: der CHECK
`Benutzer_mitarbeiter_nur_bei_rolle` verlangt, dass Rolle und Zuordnung zusammengehören, in beide
Richtungen.
**Was zurückkommt, entscheidet das Lesemodell und nicht die Seite.**
`src/server/queries/selbstauskunft.ts` liefert Titel, Status, Zeitraum und Gerät — keine Schritte,
keine Notizen, keine Kommentare, keine Anhänge. In einem Lauf stehen Arbeitsnotizen über Menschen
(„Nutzerin war unkooperativ“) und Befehle mit Platzhaltern für Zugangsdaten; beides ist nicht dafür
gedacht, gelesen zu werden. Eine Seite, die filtert, wäre eine Seite, die es vergessen kann.
Geprüft wird das an der **Antwort** und nicht an der Seite: `tests/selbstauskunft.test.ts` hängt an
einen Lauf eine Notiz, einen Kommentar, einen Anhang und einen Befehlsschritt mit
`{{generiertes_passwort}}` und prüft, dass nichts davon durchkommt. Dazu ein struktureller Test, der
das `select` selbst liest — denn eine Gegenprobe zeigte, dass der erste Test grün blieb, als das
`select` versuchsweise Notizen mitholte: gerettet hatte ihn die Abbildungsfunktion, nicht die
Abfrage.
**Zugeordnet wird über den UPN, und mehrdeutig ist ein Fehler.** Zwei Kunden können dieselbe Person
führen; ein `findFirst` entschiede dann nach Sortierung darüber, wessen Geräte jemand sieht. Lieber
kein Zugang als der falsche — die Anmeldung wird abgewiesen und der Fall protokolliert.
**Die Weiche greift nur mit eingerichteter Gruppenzuordnung.** Ohne abgebildete Gruppen kann die
Anwendung nicht unterscheiden, ob jemand Techniker oder Mitarbeiter ist; dort bleibt alles wie vor
#035. Das ist keine Vorsicht, sondern Notwendigkeit: ein Update, das jeden aussperrt, dem die
Gruppen fehlen, wäre ein kaputtes Update.
## 5e. Der Aufkleber ist die Schnittstelle
Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert
@@ -278,6 +389,34 @@ was darauf steht, ist aus der Hand gegeben.
## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse
Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —,
und die erste Frage ist nicht, wie gelesen wird, sondern **in welche Richtung**:
| Art | Richtung | Wo der Code liegt |
|---|---|---|
| `LDAP` | Laufzettel holt | `src/server/ldap/*` |
| `GRAPH` | Laufzettel holt | `src/server/graph/*` |
| `SCIM` | der Anbieter schickt | `src/app/api/scim/*`, `src/server/scim/*`, `src/server/services/scim.ts` |
Die ersten zwei laufen durch **denselben** Abgleich; die eine Stelle, die die Art
kennt, ist `eintraegeDerQuelleStroemen` in
`src/server/verzeichnis/quellenleser.ts`. Eine zweite Verzweigung waere eine
zweite Stelle, die sich an eine neue Quellenart erinnern muss, und die vergisst
man.
**Bei `SCIM` gibt es keinen Abgleich**, und das ist an zwei Stellen gehalten:
`faelligeQuellen` nimmt solche Quellen nicht auf, und die Weiche wirft. Der Grund
ist nicht Ordnungsliebe — ein Abgleich, der dort nichts liest und `ERFOLG` meldet,
haette ueber die Abschaltregel die ganze Belegschaft deaktiviert. Dasselbe Muster
wie der Reviewbefund aus #016, nur mit einer anderen Ursache.
Was beim Schreiben danach gilt, gilt fuer alle drei gleich: anlegen, uebernehmen,
aktualisieren, deaktivieren, **nie loeschen** — auch ein `DELETE` eines
SCIM-Anbieters setzt nur `aktiv = false`. Das **Token** einer SCIM-Quelle ist der
einzige Mandantenbeweis, den eine Zustellung mitbringt; es steht als argon2-Hash
in der Datenbank, wird einmal angezeigt und kommt in keiner Sicht und keinem
Protokolleintrag vor.
Das LDAP-Modul liegt unter `src/server/ldap/` und ist absichtlich in vier Dateien
geteilt, von denen nur eine das Netz anfasst:
+6
View File
@@ -56,6 +56,12 @@ 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
- **[Entra ID als Verzeichnisquelle](entra-id.md)** — App-Registrierung, `User.Read.All`,
Administratoreinwilligung, und was anders ist als bei einem lokalen AD
- **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
- **[SCIM-Endpunkt](scim.md)** — die einzige Stelle, an der ein fremder Dienst von außen schreibend
zugreift: das Token, was nicht unterstützt wird, und was der Reverse Proxy durchlassen muss
## Wo sonst noch etwas steht
+46 -3
View File
@@ -58,9 +58,16 @@ Lege in authentik vier Gruppen an und trage die Mitglieder ein:
| `lz-redakteur` | `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen |
| `lz-techniker` | `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen |
| `lz-leser` | `LESER` | nur lesen |
| *(keine davon)* | `MITARBEITER` | **nur die eigenen Geräte**, siehe unten |
Ist jemand in mehreren Gruppen, gewinnt die stärkere Rolle.
> **Die fünfte Zeile hat keine Gruppe, und das ist Absicht.** Wer sich anmeldet und in keiner der
> vier Gruppen steht, ist für Laufzettel ein Mitarbeiter des Hauses und bekommt die
> Selbstauskunft — vorausgesetzt, er lässt sich einer Person im Verzeichnis zuordnen. Eine eigene
> Gruppe dafür anzulegen hieße, jeden Mitarbeiter einzeln einzutragen; „alle übrigen“ ist genau die
> Menge, die gemeint ist.
## 5. Laufzettel konfigurieren
In der `.env`:
@@ -93,11 +100,47 @@ docker compose up -d
- Ein unbekannter Benutzer wird beim ersten Login **angelegt** (JIT-Provisioning)
- Die Rolle wird bei **jedem** Login aus `groups` neu gesetzt — eine Änderung in authentik greift
ab der nächsten Anmeldung
- Ist der Benutzer in keiner der vier Gruppen, bekommt er `LESER`
- Ist der Benutzer in keiner der vier Gruppen, versucht Laufzettel, ihn über seinen **UPN** einer
Person im Verzeichnis zuzuordnen. Klappt das, bekommt er `MITARBEITER` und sieht nur seine
eigenen Geräte; klappt es nicht, wird die Anmeldung **abgewiesen** — mit einer Begründung auf der
Anmeldeseite
- **Bis Version 0.2.0 bekam er stattdessen `LESER`**, also Lesezugriff auf alle Läufe des Hauses.
Wer das Verhalten von damals braucht, trägt die betreffenden Leute in `lz-leser` ein
- Wer vor dem Login eine bestimmte Seite aufgerufen hat (z. B. `/t/LZ-7K3F9Q` nach dem Scannen
eines QR-Codes), landet danach genau dort
## 7. Notfallzugang
## 7. Selbstauskunft für Mitarbeiter
Ein Mitarbeiter, der in keiner Rollengruppe steht, sieht unter **Meine Geräte** die Arbeiten an
seinen eigenen Geräten — Titel, Zeitraum, Status. **Keine Schritte, keine Notizen, keine Kommentare,
keine Anhänge**; warum, steht in `docs/architektur.md` unter „Warum ein Mitarbeiter keine Akteneinsicht bekommt“.
Zugeordnet wird über den **UPN**. Laufzettel liest ihn in dieser Reihenfolge:
1. der Anspruch `upn` (so heißt er bei Entra ID)
2. `preferred_username` (authentik und Keycloak schicken dort den Anmeldenamen)
3. die Mailadresse
Verglichen wird mit `Mitarbeiter.upn`, unabhängig von Groß- und Kleinschreibung. Damit das greift,
muss der UPN in authentik derselbe sein wie der im Active Directory — bei einem AD als Quelle von
authentik ist er das meist von selbst.
**Zwei Fälle, die bewusst nicht durchgehen:**
| Fall | Was passiert |
|---|---|
| Zu dem UPN gibt es **keine aktive** Person | Anmeldung abgewiesen, mit Begründung auf der Anmeldeseite |
| Zu dem UPN gibt es **mehr als eine** Person | Anmeldung abgewiesen und im Protokoll festgehalten |
Der zweite Fall kommt im Modus **Dienstleistung** vor, wenn zwei Kunden dieselbe Person führen. Ein
Zufallstreffer wäre dort schlimmer als kein Zugang: er entschiede nach Sortierung darüber, wessen
Geräte jemand sieht.
**Abschalten lässt sich das Ganze**, indem du keine Rollengruppen konfigurierst — dann greift die
Weiche nicht, und alles bleibt wie vor 0.2.0. Oder indem du jeden, der hinein soll, in `lz-leser`
einträgst.
## 8. Notfallzugang
Fällt authentik aus, kommt niemand mehr in Laufzettel. Für diesen Fall gibt es den lokalen Login:
@@ -112,7 +155,7 @@ Passwörter liegen als argon2-Hash in der Datenbank.
Empfehlung: ein einziges lokales Admin-Konto, Passwort im Passwortmanager, `AUTH_LOCAL_ENABLED`
im Normalbetrieb auf `false` und nur im Notfall umgestellt.
## 8. Fehlersuche
## 9. Fehlersuche
| Symptom | Ursache |
|---|---|
+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`.
+126
View File
@@ -0,0 +1,126 @@
# Entra ID als Verzeichnisquelle
Ein Haus ohne lokales Active Directory hat kein LDAP, auf das sich zeigen ließe. Für reine
Microsoft-365-Umgebungen liest Laufzettel über **Microsoft Graph** — mit denselben Regeln wie der
LDAP-Abgleich: anlegen, aktualisieren, deaktivieren, **nie löschen**.
> **Dieser Weg ist nicht gegen einen echten Mandanten gelaufen.** Geprüft ist er gegen einen
> HTTP-Doppelgänger mit aufgezeichneten Graph-Antworten (`tests/graph.test.ts`), und die Form dieser
> Antworten stammt aus der Dokumentation, nicht aus einem Mitschnitt. Die Prüfliste am Ende dieser
> Seite gehört einmal abgearbeitet, so wie bei Proxmox (#024).
## Was in Entra ID einzurichten ist
### 1. App-Registrierung anlegen
Im Entra-Portal unter **Identität → Anwendungen → App-Registrierungen → Neue Registrierung**:
| Feld | Wert |
|---|---|
| Name | `Laufzettel Verzeichnisabgleich` |
| Unterstützte Kontotypen | **Nur Konten in diesem Organisationsverzeichnis** |
| Umleitungs-URI | **keiner** — Laufzettel meldet sich als Anwendung an, nicht im Namen eines Menschen |
Notiere von der Übersichtsseite die **Anwendungs-ID (Client)** und die **Verzeichnis-ID (Mandant)**.
### 2. Berechtigung erteilen
Unter **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph →
Anwendungsberechtigungen**:
| Berechtigung | Wofür |
|---|---|
| `User.Read.All` | die Benutzer lesen |
Dann **Administratoreinwilligung erteilen**. Ohne diesen Klick antwortet Graph mit `403` und
`Authorization_RequestDenied` — Laufzettel nennt den Grund in der Fehlermeldung, weil ihn sonst
niemand errät.
> **Anwendungsberechtigung, nicht delegierte.** Der Abgleich läuft nachts, wenn niemand angemeldet
> ist; eine delegierte Berechtigung braucht einen angemeldeten Menschen und funktioniert dort nicht.
`Directory.Read.All` wird **nicht** gebraucht. `User.Read.All` deckt die Benutzer und über
`$expand=manager` auch den Vorgesetzten ab — die weitergehende Berechtigung wäre mehr Zugriff, als
die Aufgabe verlangt.
### 3. Geheimnis erzeugen
Unter **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Die Laufzeit ist eine
Entscheidung mit Folgen: **läuft das Geheimnis ab, hört der Abgleich auf, und niemand merkt es**,
weil jeder Lauf weiter funktioniert. Setz dir eine Erinnerung, und sieh unter **Verwaltung →
Verzeichnis** nach dem Status, wenn Personen nicht mehr aktualisiert werden.
Der Wert ist **nur beim Anlegen sichtbar**. Kopier ihn sofort.
## In Laufzettel einrichten
Unter **Verwaltung → Verzeichnis → Quelle anlegen**, Art **Microsoft Graph**:
| Feld | Wert |
|---|---|
| Mandanten-ID | die Verzeichnis-ID oder der Domänenname (`contoso.onmicrosoft.com`) |
| Anwendungs-ID | die Anwendungs-ID (Client) aus Schritt 1 |
| Client-Secret | der Wert aus Schritt 3 — wird verschlüsselt gespeichert und nie wieder angezeigt |
Dann **Verbindung testen**: es erscheinen die ersten zehn zugeordneten Personen. Steht dort nichts,
hilft die Tabelle unter „Wenn es klemmt“.
## Was anders ist als bei einem lokalen AD
| | LDAP (AD) | Microsoft Graph (Entra ID) |
|---|---|---|
| Dauerhafte Kennung | `objectGUID` (binär, umsortiert) | `id` (GUID als Text) |
| Nachname | `sn` | `surname` |
| Funktion | `title` | `jobTitle` |
| Anmeldename | `sAMAccountName` | `onPremisesSamAccountName` |
| Vorgesetzter | `manager` als DN, danach aufgelöst | `manager` direkt als Kennung |
| Deaktivierte Konten | über den Filter ausgeschlossen | über `$filter=accountEnabled eq true` |
| `{{ou}}` | aus dem DN | **leer** |
### `{{ou}}` bleibt leer, und das ist Absicht
Entra ID hat keinen DN und keine OU. Etwas hineinzudeuten — den Abteilungsnamen, eine erfundene
Struktur — wäre ein Wert, den kein PowerShell-Befehl brauchen kann, und er sähe richtig aus.
Steht an der **Abteilung** eine Ziel-OU gepflegt, nimmt Laufzettel die (`{{ou}}` hat zwei Quellen,
siehe `docs/architektur.md`). Wer OU-abhängige Befehle fährt, braucht ein lokales AD — oder pflegt
die Ziel-OU je Abteilung von Hand.
### `{{sam}}` ist leer ohne AD-Synchronisierung
`onPremisesSamAccountName` kommt aus einem lokalen AD. Ein Mandant, der nie eines hatte, liefert dort
nichts — eine reine Cloud-Umgebung hat keinen Anmeldenamen in diesem Sinn. Vorlagen, die `{{sam}}`
benutzen, zeigen dann einen leeren Wert; gemeint ist meist ohnehin der UPN.
### Die Art einer Quelle lässt sich nicht ändern
An ihr hängt, was die dauerhafte Kennung bedeutet. Nach einem Wechsel von LDAP auf Graph würde
**kein einziger Mitarbeiter wiedererkannt**: der nächste Abgleich legte die ganze Belegschaft neu an
und setzte die alten Zeilen auf `aktiv = false` — mit allen Läufen und Geräten daran.
Wer wechselt, legt eine **zweite** Quelle an und lässt die erste stehen, bis die Personen übernommen
sind. Laufzettel übernimmt dabei von Hand angelegte und bereits vorhandene Personen über Name und
Mailadresse, statt Dubletten anzulegen (#016).
## Wenn es klemmt
| Meldung | Ursache |
|---|---|
| `401` beim Anmelden | Mandant, Anwendungs-ID oder Geheimnis stimmen nicht — oder das Geheimnis ist abgelaufen. Die ausführliche Antwort von Entra ID steht im Protokoll des Dienstes, nicht in der Oberfläche: sie nennt Mandant und Anwendung. |
| `403 Authorization_RequestDenied` | Die Administratoreinwilligung für `User.Read.All` fehlt. Erteilen und erneut testen. |
| `Invalid expand clause` | Sollte nicht vorkommen — Laufzettel baut `$expand=manager($select=id)` mit unkodierten Klammern. Tritt es auf, steht ein Proxy dazwischen, der die Adresse umschreibt. |
| Verbindung testen zeigt niemanden | Der Mandant hat keine Benutzer mit `accountEnabled = true`, oder alle wurden übersprungen. Die Liste der Übersprungenen steht unter dem Ergebnis. |
| Personen werden nicht mehr aktualisiert | Zuerst den Status der Quelle ansehen. Fast immer ist das Client-Secret abgelaufen. |
## Prüfliste für den ersten echten Lauf
Diese Punkte sind **nicht** automatisiert geprüft — sie brauchen einen echten Mandanten:
- [ ] App-Registrierung angelegt, `User.Read.All` als **Anwendungs**berechtigung, Einwilligung erteilt
- [ ] „Verbindung testen“ zeigt die ersten zehn Personen mit richtigen Namen und Abteilungen
- [ ] Der Vorgesetzte steht bei jemandem, der einen hat (`manager` über `$expand`)
- [ ] Erster Abgleich legt an; die Zahl passt zur Zahl der aktiven Konten im Mandanten
- [ ] Zweiter Abgleich legt **nichts** an und meldet nur Änderungen
- [ ] Ein Konto in Entra ID sperren → nächster Abgleich setzt `aktiv = false`, löscht aber nichts
- [ ] Ein Mandant mit mehr als 999 Benutzern: der Seitenabruf über `@odata.nextLink` läuft durch
- [ ] Nach einem abgelaufenen Geheimnis steht der Grund an der Quelle und nicht nur im Log
+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.
+159
View File
@@ -0,0 +1,159 @@
# SCIM-Endpunkt betreiben
Laufzettel kann Personen aus einem Verzeichnis **holen** (LDAP, Microsoft Graph) oder sich
**beliefern lassen**. Der zweite Weg ist SCIM 2.0, und er ist die einzige Stelle, an der ein
fremder Dienst von außen schreibend auf Laufzettel zugreift. Diese Seite beschreibt, was der
Betreiber dafür wissen muss. Wie eine Quelle eingerichtet wird, steht in
[Kapitel 06 der Anleitung](../anleitung/06-verzeichnis.md#scim-der-anbieter-schickt).
## Was der Endpunkt ist
| | |
|---|---|
| Adresse | `https://<APP_URL>/api/scim/v2` |
| Anmeldung | `Authorization: Bearer <Token>`, ein Token je Quelle |
| Medientyp | `application/scim+json` |
| Normen | RFC 7643 (Schema), RFC 7644 (Protokoll) |
Vollständig:
```
GET /api/scim/v2/ServiceProviderConfig
GET /api/scim/v2/Schemas
GET /api/scim/v2/ResourceTypes
GET /api/scim/v2/Users?filter=userName eq "…"
POST /api/scim/v2/Users
GET /api/scim/v2/Users/{id}
PUT /api/scim/v2/Users/{id}
PATCH /api/scim/v2/Users/{id}
DELETE /api/scim/v2/Users/{id}
```
Die drei Beschreibungs-Endpunkte sind nicht Zierde: manche Anbieter, Entra ID darunter, fragen sie
beim „Test Connection“ ab und weigern sich sonst anzufangen. Sie verlangen **ebenfalls** ein Token.
## Was nicht unterstützt wird — und warum das so gemeldet wird
`ServiceProviderConfig` sagt ausdrücklich `supported: false` für `bulk`, `sort`, `filter` und
`etag`. Die **Verneinungen** sind der wichtigere Teil der Antwort: ein Anbieter, der `bulk` für
unterstützt hält, schickt Anfragen, die niemand beantwortet, und meldet das als Fehler der
Gegenseite.
Nicht umgesetzt sind außerdem:
- **`/Groups`.** Laufzettel führt keine Gruppen. Rollen entstehen beim Anmelden aus den
OIDC-Gruppen (siehe [`authentik.md`](authentik.md)), nicht aus dem Provisioning. Ein Anbieter,
der Gruppen zuweisen will, bekommt nichts zugewiesen — `ResourceTypes` nennt nur `User`.
- **Allgemeine Filter.** Unterstützt wird genau `userName eq "…"` — die Frage, die ein Anbieter vor
jedem Anlegen stellt. Alles andere ergibt `400` mit `scimType: invalidFilter`. Das ist Absicht:
einen unverstandenen Filter zu ignorieren und **alle** Personen zu liefern wäre die gefährlichere
Antwort, denn der Anbieter schlösse daraus, die gesuchte Person gäbe es schon, und legte sie nie
an.
- **Paginierung über 200 Einträge.** Ein `GET /Users` ohne Filter liefert höchstens 200 Zeilen. Die
Anbieter fragen gefiltert; ein voller Abzug ist keine Betriebsart dieses Endpunkts.
## Das Token
Ein Token je Quelle, erzeugt unter **Admin → Verzeichnis**, Form `lzscim_` + 43 Zeichen aus
`randomBytes(32)`.
- Es steht in der Datenbank **nur als argon2-Hash**. Ein CHECK hält das fest
(`Verzeichnisquelle_scimtoken_gehasht`): ein roh gespeichertes Token weist die Datenbank ab, auch
wenn jemand am Dienst vorbei schreibt.
- Es wird **einmal** angezeigt, direkt nach dem Erzeugen. Danach gibt es keinen Weg, es zu lesen —
auch nicht für einen Administrator, auch nicht aus einem Backup der Anwendung.
- Es erscheint in **keinem Protokolleintrag**. Vermerkt wird „SCIM-Token erzeugt“ mit dem Namen der
Quelle, nicht das Token; das Protokoll ist in der Anwendung ansehbar.
- Ein neues ersetzt das alte. Es gibt genau eines je Quelle — zwei gültige wären zwei Dinge, die man
widerrufen muss, und eines davon vergisst man.
**Das Token ist der einzige Mandantenbeweis, den eine Zustellung mitbringt.** Aus ihm folgt die
Quelle, aus der Quelle der Kunde, und daran hängt, welche Personen die Anfrage sehen und ändern
darf. Ein Token einer anderen Quelle bekommt auf deren Personen `404` und nicht `403`: ob es eine
Person gibt, ist für eine fremde Quelle keine Auskunft.
Gleiche Antwort für **kein Token** und **falsches Token**: `401` mit
`WWW-Authenticate: Bearer realm="Laufzettel SCIM"`. Die Unterscheidung wäre eine Auskunft darüber,
ob ein geratenes Token die richtige Form hat.
Widerrufen geht jederzeit über **Token widerrufen**. Eine Quelle **stilllegen**, ohne das Token beim
Anbieter zu entfernen, geht über das Häkchen **Zustellungen annehmen** — der Endpunkt antwortet dann
ebenfalls mit `401`.
## Reverse Proxy
Der Pfad `/api/scim/` braucht **keine Sonderbehandlung**, aber er muss durchgelassen werden. Wer vor
Laufzettel eine Regel hat, die `/api/` einschränkt oder nur bekannte Pfade weiterleitet, trägt ihn
ein. Drei Punkte:
- **Kein Cache.** Der Endpunkt setzt `Cache-Control: no-store` auf jede Antwort; ein Proxy, der das
überschreibt, liefert veraltete Personenlisten.
- **Der `Authorization`-Kopf muss ankommen.** Manche Konfigurationen entfernen ihn, wenn davor eine
eigene Authentifizierung sitzt. Dann kommt jede Zustellung als `401` zurück.
- **Eine Zustellung ist klein**, einige Kilobyte. Die Grenzen für Anhänge
(`client_max_body_size`, siehe [`reverse-proxy.md`](reverse-proxy.md)) reichen weit aus.
Eine IP-Einschränkung auf die Adressen des Anbieters ist möglich und sinnvoll, wenn er feste hat.
authentik im eigenen Haus hat welche; Entra ID nicht zuverlässig — Microsoft veröffentlicht die
Bereiche, aber sie ändern sich, und eine veraltete Liste sperrt das Provisioning aus.
## Was im Betrieb auffällt
**Es wird nicht abgeglichen.** Eine SCIM-Quelle taucht im Zeitplan nicht auf. Das ist wichtig und
nicht nur Kosmetik: ein Abgleich, der an einer SCIM-Quelle nichts liest und „Erfolg“ meldet, würde
über die Abschaltregel die ganze Belegschaft deaktivieren. Der Zeitplan lässt sie deshalb aus, und
„Jetzt abgleichen“ bricht mit einer Erklärung ab.
**`DELETE` deaktiviert.** Der Anbieter bekommt `204`, die Zeile bleibt mit `aktiv = false`. An einer
Person hängen Läufe und Geräte; ein archivierter Lauf muss auch in zehn Jahren noch sagen können,
für wen er lief. Das steht auch in der Anleitung, weil es sonst wie ein Fehler wirkt.
**Protokolleinträge haben keinen Benutzer.** Jede Zustellung schreibt einen `Protokoll`-Eintrag mit
der Quelle als Urheber („Person angelegt (SCIM: authentik)“) und ohne `benutzerId`. Das ist ein
Systemlauf wie der Zeitplan; ein erfundener Administrator wäre eine Lüge im Protokoll.
**„Letzte Zustellung“ ist die Lebenszeichenanzeige.** Sie steht an der Quelle und wird bei jeder
Anfrage gesetzt, die etwas geschrieben hat. Steht dort nichts, ist die Einrichtung beim Anbieter
nicht fertig — nicht die Verbindung kaputt, denn es gibt keine.
## Prüfen von Hand
Mit dem Token aus der Oberfläche, von einem Rechner aus, der die öffentliche Adresse erreicht:
```bash
TOKEN='lzscim_…'
BASIS='https://laufzettel.firma.de/api/scim/v2'
# 1. Antwortet der Endpunkt überhaupt, und gilt das Token?
curl -s -H "Authorization: Bearer $TOKEN" "$BASIS/ServiceProviderConfig" | head -c 400
# 2. Ohne Token muss 401 kommen.
curl -s -o /dev/null -w '%{http_code}\n' "$BASIS/Users"
# 3. Die Frage, die jeder Anbieter vor dem Anlegen stellt.
curl -s -H "Authorization: Bearer $TOKEN" \
--get --data-urlencode 'filter=userName eq "mhuber@firma.example"' "$BASIS/Users"
```
Die zweite Zeile muss `401` ausgeben. Kommt dort `200`, läuft etwas vor Laufzettel, das den
`Authorization`-Kopf selbst setzt — dann ist der Endpunkt offen, und das gehört sofort behoben.
Anlegen von Hand ist zum Prüfen **nicht** nötig und auch nicht empfehlenswert: die Person stünde
danach in der Quelle, und der Anbieter kennt sie nicht. Löschen lässt sie sich nicht, nur
deaktivieren.
## Fehlerbilder
| Antwort | Ursache |
|---|---|
| `401` | Token falsch, widerrufen, oder **Zustellungen annehmen** nicht angehakt. |
| `404` auf eine bekannte Person | Sie gehört zu einer anderen Quelle. Jedes Token sieht nur seine eigene. |
| `409 uniqueness` | Die Person gibt es in dieser Quelle schon — richtige Antwort auf ein wiederholtes `POST`, kein Datenverlust. |
| `400 invalidFilter` | Ein Filter außer `userName eq "…"`. |
| `400 invalidSyntax` | Kein gültiges JSON, oder ein `PATCH` ohne `Operations`. |
| `500` | Ein Fehler in Laufzettel. Die Einzelheiten stehen im Protokoll des Dienstes, **nicht** in der Antwort: eine Datenbankmeldung nennt Host, Port und Benutzernamen. |
`docker compose logs -f web` beziehungsweise `journalctl -u laufzettel -f` zeigt die Zeilen, die
hinter einem `500` stehen, und auch die Pfade, die ein `PATCH` mitgeschickt und Laufzettel übergangen
hat (`SCIM-PATCH: übergangene Pfade: …`) — die sind kein Fehler, aber die Antwort auf die Frage
„warum steht das Feld nicht drin“.
+140 -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`, von `install.sh` auf `/var/lib/laufzettel/anhaenge` gesetzt |
> **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,26 @@ 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; `deploy/install.sh` setzt ihn auf
# /var/lib/laufzettel/anhaenge. **Kein Rückfallwert** – siehe den Kasten darunter.
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:?ANHAENGE_PFAD fehlt in /etc/laufzettel/laufzettel.env}"')
sudo tar czf /var/backups/laufzettel-anhaenge-$(date +%F).tar.gz -C "$ANHAENGE" .
```
> **Warum hier kein `${ANHAENGE_PFAD:-…}` steht.** Es stand einmal eines, und es war falsch:
> `/opt/laufzettel/daten/anhaenge` passt zu keiner Installation. `deploy/install.sh` setzt
> `/var/lib/laufzettel/anhaenge`, und ohne die Variable nimmt die Anwendung `./daten/anhaenge`
> **relativ zu ihrem Arbeitsverzeichnis** `/opt/laufzettel/.next/standalone` — also noch etwas
> anderes.
>
> Ein geratener Pfad ist beim Sichern eine leere Datei und beim Zurückspielen ein Schaden: dort
> steht ein `rm -rf` davor. Fehlt die Variable, soll der Befehl **abbrechen** und nicht raten.
> Deshalb `:?` und nicht `:-`. Gefunden bei der Durchsicht von Phase 2, nachdem derselbe Fehler in
> dieser Datei schon einmal Daten gekostet hätte (#032).
Und das Geheimnis, einmalig und in den Passwortmanager:
```bash
@@ -141,16 +197,32 @@ 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
# Ohne ANHAENGE_PFAD bricht das hier ab – und zwar **vor** dem rm -rf. Ein
# geratener Pfad hiesse: das echte Verzeichnis bleibt leer, die Anwendung meldet
# „Die Datei zu … fehlt im Dateisystem“, und niemand weiss, warum.
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:?ANHAENGE_PFAD fehlt in /etc/laufzettel/laufzettel.env}"')
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 +242,32 @@ 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
# Kein Rueckfallwert: ein geratener Pfad ergibt eine leere Sicherung, und die
# faellt erst beim Zuruckspielen auf.
anhaenge="${ANHAENGE_PFAD:?ANHAENGE_PFAD fehlt in /etc/laufzettel/laufzettel.env}"
if [ -d "$anhaenge" ]; then
tar czf "/var/backups/laufzettel-anhaenge-$tag.tar.gz" -C "$anhaenge" .
fi
```
`/etc/systemd/system/laufzettel-sicherung.timer`:
@@ -193,19 +286,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 +314,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 +360,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 +401,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. |
+725 -1
View File
@@ -22,11 +22,30 @@ 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 |
| #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 min |
| #028 | M15 – Quellenarten und Microsoft-Graph-Quelle | 4 h 10 min |
| #029 | M16 – SCIM-Endpunkt als Alternative zum Abgleich | 4 h 05 min |
| #036 | Reviewbefunde aus der Durchsicht von Phase 2 | 3 h 40 min |
| #027 | Die Editor-Abläufe lassen ihre Vorlagen stehen | 2 h 10 min |
| #038 | Die E2E-Abläufe laufen in keinem Tor | 1 h 35 min |
| #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min |
| | **Summe** | **73 h 55 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
| v0.1.0 | 35 h 20 min | veröffentlicht |
| v0.2.0 | 38 h 35 min | in Arbeit |
---
@@ -977,6 +996,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 +1105,662 @@ 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.
---
## 2026-10-06 · Sitzung 20 · 06:00–08:50
**Issue:** #035 (M22)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **Die fünfte Rolle `MITARBEITER`** mit genau einem Recht: `selbstauskunft`. Zugeordnet beim
Anmelden über den UPN, festgehalten in `Benutzer.mitarbeiterId`.
- **`/meine-geraete`**: meine Geräte, je Gerät die Arbeiten chronologisch, dazu meine Stammdaten aus
dem Verzeichnis. Titel, Zeitraum, Status — nichts weiter.
- **Drei PRs von gestern sind auf `main`** (#59, #60, #61); die beiden Folgezweige mussten nach dem
ersten Squash rebased werden.
- 1341 Vitest-Tests (vorher 1302), davon 25 neue. Im gebauten Stand mit einem echten
MITARBEITER-Konto durchgeklickt.
### Entscheidungen
- **`selbstauskunft` ist ein Ersatz für `lesen`, kein Zusatz** — deshalb hat auch ein ADMIN es
nicht. Ein zweiter Weg zu denselben Daten wäre eine zweite Stelle, an der jemand die
Einschränkung auf die eigene Person vergisst.
- **Was zurückkommt, entscheidet das Lesemodell und nicht die Seite.** Eine Seite, die filtert, ist
eine Seite, die es vergessen kann.
- **Kein zusammengesetzter Fremdschlüssel**, obwohl die Aufgabe ihn verlangte: ein `Benutzer` trägt
keine `kundeId`. Stattdessen ein CHECK, dass Rolle und Zuordnung zusammengehören, und ein
eindeutiger Index, dass eine Person höchstens ein Konto hat.
- **Ein mehrdeutiger UPN wird abgewiesen**, nicht geraten. Zwei Kunden können dieselbe Person
führen; ein `findFirst` entschiede nach Sortierung darüber, wessen Geräte jemand sieht.
- **Die Weiche greift nur mit konfigurierten Rollengruppen.** Ein Update, das jeden aussperrt, dem
die Gruppen fehlen, wäre ein kaputtes Update.
- **Nicht `/kein-zugriff` bei gescheiterter Anmeldung**, wie das Issue vorsah: diese Seite verlangt
eine Sitzung, die es dann nicht gibt — das wäre eine Schleife. Die Begründung steht auf der
Anmeldeseite, mit zwei eigenen Codes.
### Probleme
- **Eine Gegenprobe blieb grün, und das war der eigentliche Befund.** Der Test „sieht keine Notizen“
prüft die Antwort, und die ist durch `laufBauen` sicher. Als ich im `select` versuchsweise
`ergebnisse: { notiz }` ergänzte, blieb er deshalb grün — während der Kopfkommentar behauptet, das
`select` hole es „gar nicht erst“. **Eine Zusage ohne eigenen Test ist keine Zusage**, auch wenn
ein Nachbartest sie zufällig mitträgt. Jetzt liest ein Test die Datei selbst.
- **Ein Test von #007 fand eine echte Lücke.** `staerksteRolle` benutzte `LESER` als Startwert und
damit als Untergrenze; mit einer Rolle darunter lieferte sie **mehr** Rechte als übergeben. Drei
Jahre Projektzeit hätte das niemand gemerkt, weil es keine Rolle unter `LESER` gab.
- **Zwei Bedienfehler beim Durchklicken**, beide dieselbe Ursache: das Standardziel nach der
Anmeldung ist `/`, und die Übersicht verlangt `lesen`. Ein Mitarbeiter sah als Erstes „Dafür fehlt
dir die Berechtigung“ — und der Knopf dort führte zurück auf `/`, also im Kreis. Ohne Klickprobe
wäre das bis zum ersten echten Nutzer unentdeckt geblieben: **alle Tests waren grün.**
- **Die Navigation hätte sechs tote Links angeboten.** `recht` stand nur am Admin-Bereich, weil
bisher jede Rolle `lesen` durfte.
- **Ein falscher Satz in `docs/betrieb/authentik.md`:** „Ist der Benutzer in keiner der vier
Gruppen, bekommt er `LESER`.“ Richtig war er bis zu dieser Sitzung.
- **Drei eigene Testfehler**, keine Produktfehler: Tag-Codes müssen dem Alphabet ohne `0`, `1`, `I`
und `O` folgen; die Kundenbindungstests gaben nur eine Kundengruppe mit und hingen damit an der
neuen Weiche; und ein Einfüge-Skript meldete „ok“, ohne geschrieben zu haben — seitdem prüfe ich
nach dem Einfügen mit `grep`.
### Nächster Schritt
- **Phase 2 ist bis auf die beiden großen Verzeichnis-Themen fertig:** **M15 (#028)**
Microsoft-Graph-Quelle und **M16 (#029)** SCIM-Endpunkt.
- Vor beiden wäre ein unabhängiger Review über die sechs Meilensteine dieser Phase angebracht —
M17 bis M22 liegen ohne fremden Blick auf `main`.
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
---
## 2026-10-06 · Sitzung 21 · 09:00–13:10
**Issue:** #028 (M15)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **Eine Verzeichnisquelle hat eine Art**: `LDAP`, `GRAPH` oder `SCIM`. Bestehende Zeilen sind
`LDAP` — das war vor #028 die einzige.
- **Entra ID über Microsoft Graph**: Client-Credentials-Flow mit Token-Zwischenspeicher, Seitenabruf
über `@odata.nextLink`, Vorgesetzter über `$expand`.
- **Derselbe Abgleich für beide Arten.** Die Weiche sitzt in
`src/server/verzeichnis/quellenleser.ts` und ist die einzige Stelle, die die Art kennt.
- 1373 Vitest-Tests (vorher 1342), davon 31 neue gegen einen HTTP-Doppelgänger mit aufgezeichneten
Graph-Antworten.
- Im gebauten Stand eine Graph-Quelle wirklich angelegt: Art gespeichert, Geheimnis verschlüsselt,
nicht im Quelltext der Seite.
### Entscheidungen
- **Ein Enum auf der Quelle, keine Tabelle je Art.** Drei Arten rechtfertigen keine Vererbung; was
eine Art braucht, hält ein CHECK fest — in **beide** Richtungen, sonst wäre eine Graph-Quelle mit
`basisDn` möglich.
- **Die Art ist unveränderlich.** An ihr hängt, was `externeId` bedeutet; nach einem Wechsel würde
kein Mitarbeiter wiedererkannt. Steht so nicht im Issue und ist die wichtigste Zusage dieses
Meilensteins.
- **`{{ou}}` bleibt bei Graph leer.** Etwas hineinzudeuten wäre ein Wert, den kein PowerShell-Befehl
brauchen kann — und er sähe richtig aus.
- **`SCIM` steht im Enum, wird aber abgewiesen.** Ein Wert, der stillschweigend nichts tut, hätte
hier die ganze Belegschaft deaktiviert.
### Probleme
- **Der teuerste Fund: „deaktiviert“ als „übersprungen“ zu melden hätte Leute aktiv gelassen.**
„Übersprungen“ heißt im Abgleich „nicht zuordenbar“, und solche Einträge werden seit #016
absichtlich nicht abgeschaltet. Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt
worden als ein gesperrtes AD-Konto. Jetzt filtert die Abfrage serverseitig, als Gegenstück zum
LDAP-Standardfilter.
**Gefunden hat es der Test — aber nur, weil der Doppelgänger den Filter wirklich auswertet.** Hätte
er den Filter bloß empfangen und ignoriert, wäre der Test grün gewesen und der Fehler im Betrieb
aufgefallen. Ein Doppelgänger, der nur antwortet, prüft die Anfrage nicht.
- **`URLSearchParams` kodiert `$` zu `%24`** und die Klammern in `manager($select=id)`. Graph nimmt
das teils an und antwortet teils mit „Invalid expand clause“. Jetzt von Hand zusammengesetzt.
- **Ein Import-Zyklus drohte**: die Weiche braucht `zugangAus`, und der Dienst braucht die Weiche.
Beides aufgelöst durch ein eigenes Modul. In ESM läuft so ein Zyklus oft — aber nicht zuverlässig.
- **Zwei eigene Testfehler**: die Token-Grenze liegt bei +3540 s und nicht „ungefähr eine Stunde“,
und die Klickprobe zählte die Felder der Bearbeiten-Formulare mit.
### Was offen bleibt, und das ist benannt
Gegen einen **echten Entra-Mandanten** ist nichts davon gelaufen. Die Form der Antworten im
Doppelgänger stammt aus der Dokumentation, nicht aus einem Mitschnitt — stimmt sie nicht, stimmen
Test und Wirklichkeit nicht. Die Prüfliste steht am Ende von `docs/betrieb/entra-id.md`, acht
Punkte, darunter ein Mandant mit mehr als 999 Benutzern. Dieselbe Art offener Punkt wie #024.
### Nächster Schritt
- **M16 (#029)** SCIM-Endpunkt ist der letzte Meilenstein von Phase 2. Die Weiche dafür steht.
- Vor #029 wäre ein unabhängiger Review angebracht: **sieben Meilensteine** dieser Phase liegen ohne
fremden Blick auf `main`, und diese Sitzung hat wieder einen Fehler gezeigt, den die Suite nur
deshalb gefunden hat, weil der Doppelgänger streng genug war.
---
## 2026-10-10 · Sitzung 22 · 17:40–21:45
**Issue:** #029 (M16)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **SCIM 2.0 als dritte Quellenart.** Neun Endpunkte unter `/api/scim/v2`, jeder prüft sein Token
selbst — die Hülle unter `(app)` liegt dort nicht darüber, und der Proxy lässt `/api/scim`
absichtlich ohne Cookie durch.
- **Ein Token je Quelle**, argon2-gehasht, einmal angezeigt. Zwei CHECKs halten das in der
Datenbank: `…_scimtoken_gehasht` (`LIKE '$argon2%'`) und `…_scimtoken_mit_zeitpunkt`.
- **Oberfläche**: Art SCIM im Anlege-Formular, Token erzeugen und widerrufen, letzte Zustellung,
Endpunktadresse an der Quelle. Verbindungstest, Zeitplan, Abgleich-Log und Attributzuordnung
fallen dort weg — es gibt sie nicht, und ein Knopf, der nur eine Erklärung auswirft, ist kein
Knopf.
- 1432 Vitest-Tests (vorher 1373), davon 59 neue. Die Anfrageformen von authentik und Entra ID
stehen als benannte Konstanten oben in `tests/scim.test.ts`.
- **Klickprobe am gebauten Stand, zwanzig Prüfungen**, von „Art SCIM wählbar“ über eine echte
Zustellung mit genau dem Token aus der Oberfläche bis „390 px ohne horizontales Scrollen“. Die
Probe hat ihre Daten danach selbst entfernt — die Lehre aus #027.
- Anleitung Kapitel 06 um den SCIM-Abschnitt erweitert (Einrichtung in authentik und Entra ID,
Fehlerbilder), `docs/betrieb/scim.md` neu.
### Entscheidungen
- **Umgesetzt ist nicht der Standard, sondern was die Anbieter schicken.** Komplexe Filter,
Sortierung, `bulk`, `/Groups` — nichts davon. `ServiceProviderConfig` **meldet** das als
`supported: false`, und die Verneinungen sind der wichtigere Teil der Antwort: ein Anbieter, der
`bulk` für unterstützt hält, schickt Anfragen, die niemand beantwortet.
- **Ein unverstandener Filter ergibt `400`, nicht „alle Personen“.** Die wichtigste
Einzelentscheidung hier. Alles zu liefern wäre die gefährlichere Antwort: der Anbieter schlösse
daraus, die gesuchte Person gäbe es schon, und legte sie **nie** an.
- **`404` und nicht `403`** für eine Person einer fremden Quelle. Ob es sie gibt, ist für ein
fremdes Token keine Auskunft. Ebenso dieselbe `401` für „kein Token“ und „falsches Token“.
- **`DELETE` deaktiviert.** Der Anbieter bekommt `204` und hält die Person für entfernt — aus seiner
Sicht richtig. In der Anleitung steht das ausdrücklich, sonst wirkt die inaktive Zeile wie ein
Fehler.
- **SCIM-Quellen bleiben aus dem Zeitplan heraus**, an zwei Stellen gehalten. Ein Abgleich, der dort
nichts liest und `ERFOLG` meldet, hätte die ganze Belegschaft deaktiviert.
### Probleme
- **Zwei von acht Gegenproben blieben grün — und das waren die wertvollen.** Sechs schlugen sofort
an. Die beiden stillen:
- **`PATCH remove` wirkte gar nicht.** Kein Testloch, ein Fehler: `personAendern` hatte einen
Schalter `nurGesetzte`, der bei `PATCH` jedes `null` als „nicht mitgeschickt“ las. Nimmt jemand
beim Anbieter die Abteilung aus dem Profil, stand sie in Laufzettel weiter — sichtbar, gepflegt
aussehend, falsch. Der Schalter war nie nötig: `patchAnwenden` setzt auf dem vorhandenen Stand
auf und liefert immer einen vollständigen Zielzustand. Der Unterschied zwischen `PUT` und
`PATCH` liegt **vor** der Funktion, nicht in ihr.
- **Entra ID schickt `active` als `"True"`/`"False"`.** Richtig umgesetzt, aber nur halb geprüft:
beim Deaktivieren kommt mit einem reinen `value === true` **zufällig** das Richtige heraus. Nur
das Aktivieren wäre kaputt gewesen — ein wieder freigeschaltetes Konto, das in Laufzettel für
immer inaktiv bleibt. Ein Fehler, der nur in einer Richtung sichtbar ist, ist der teuerste.
- **`manager` wäre fast untergegangen.** Es stand im Issue, und umgesetzt war zunächst nur
`department`. Dazugekommen: drei Lesearten (`{value}`, `{$ref}`, nackte Zeichenkette), die
Auflösung, und `manager` im `Schemas`-Endpunkt — das Letzte ist das Entscheidende, denn ein
Anbieter schickt nur, was dort steht. Ohne den Eintrag bliebe der Vorgesetzte dauerhaft leer,
**ohne Fehlermeldung auf einer der beiden Seiten.**
- **`manager` war ein Weg über die Quellengrenze.** Eine Kennung aus einer fremden Quelle hätte über
die Vorgesetzten-Anzeige einen fremden Namen in die Oberfläche gebracht. Die Auflösung sucht nur
in der eigenen Quelle; die Gegenprobe ohne diese Einschränkung ist rot.
- **Ein Fehler aus #028 im Nachbarcode**: in `verbindungTestenAktion` stand zweimal dieselbe
Bedingung, der zweite Zweig war unerreichbar. Genau dieser Zweig war dort als Reviewbefund
eingebaut worden — „gelesen, aber nichts zuordenbar“ sollte anders klingen als „der Filter findet
niemanden“, weil die Ursachen den Administrator an verschiedene Stellen schicken. Wirkungslos
seit dem Tag, an dem er geschrieben wurde. Das ist dasselbe Muster wie die stillen Gegenproben:
**Code, der aussieht, als prüfte er etwas.**
- **Ein eigener Probenfehler**: die Klickprobe suchte „Klick Probe“ in der Mitarbeiterliste. Ohne
`displayName` baut Laufzettel „Nachname, Vorname“ — wie der Abgleich. Kein Produktfehler, aber
ein Beleg dafür, dass ein roter Befund erst gelesen und dann bewertet gehört.
- Ein Test aus #028 war **überholt**, nicht falsch: er verlangte, dass die Meldung der Weiche „#029“
nennt.
### Was offen bleibt, und das ist benannt
Gegen **echte Mandanten** ist nichts davon gelaufen. Die Anfrageformen stammen aus der
Dokumentation von authentik und Entra ID, nicht aus einem Mitschnitt — dieselbe Grenze wie bei #028,
und sie steht im Kopfkommentar von `tests/scim.test.ts`. Die Prüfbefehle für den ersten echten Lauf
stehen in `docs/betrieb/scim.md`.
### Nächster Schritt
- **Phase 2 ist damit vollständig.** Acht Meilensteine (M15–M22) liegen danach auf `main`.
- Offen bleiben **#024** (Proxmox-Einzeiler auf echtem PVE-Host) und **#027** (die Editor-Abläufe
lassen ihre Vorlagen in der Dev-Datenbank stehen).
- **Ein unabhängiger Review ist jetzt überfällig.** Acht Meilensteine dieser Phase liegen ohne
fremden Blick auf `main`, und diese Sitzung hat wieder zwei Dinge gezeigt, die eine grüne Suite
nicht sieht: einen Schalter, der eine Änderung verschluckte, und einen Zweig, der nie erreicht
wurde. Beide Befunde kamen aus Gegenproben, nicht aus Tests.
---
## 2026-10-11 · Sitzung 23 · 21:50–01:30
**Issues:** #036 (Reviewbefunde Phase 2), #027 (E2E-Reste)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **Der unabhängige Review ist gelaufen** — acht Meilensteine (M15–M22) lagen ohne fremden Blick auf
`main`, und das stand seit Sitzung 21 als „überfällig“ im Devlog. Ein Agent mit eigener Datenbank,
der den Code nicht geschrieben hat, gegen die Akzeptanzkriterien und gegen `CLAUDE.md`.
- **Dreizehn Befunde, zwölf behoben**, zwei als #037 und #038 benannt. Alle lagen am Rand: zwei
Route Handler, ein Export-Pfad, Betriebsdokumentation, Kommentare. **Die Kernregeln halten** —
fünf von fünf Gegenproben gegen Versionierung, Mandantengrenze, Abschlussregel und
Selbstauskunft wurden rot.
- **#027 abgeschlossen**: die 105 Altreste sind weg, geräumt wird jetzt zentral vor und nach dem
Lauf, und ein struktureller Test bewacht es.
- 1454 Vitest-Tests (vorher 1432), 113 E2E-Abläufe grün.
### Entscheidungen
- **Eine Liste, die nur in einer Richtung geprüft wird, läuft dem Code hinterher.** `/api/scim` kam
mit #029 in die Proxy-Allowlist und stand in keinem Test; die Zeile ließ sich entfernen, ohne
dass etwas rot wurde. `OEFFENTLICH` ist jetzt exportiert, und der Test vergleicht **beide**
Richtungen: wer die Allowlist erweitert, muss es aufschreiben. Dasselbe Muster wie die Zahlen in
`CLAUDE.md`, die dreimal der Wirklichkeit nachliefen.
- **Ein Rückspielskript rät nicht.** In `sicherung.md` stand `${ANHAENGE_PFAD:-/opt/…}` — ein
Rückfallwert, der zu keiner Installation passte, in einem Skript mit `rm -rf`. Jetzt `:?`: fehlt
die Variable, bricht es ab. Ein geratener Pfad ist beim Sichern eine leere Datei und beim
Zurückspielen ein Schaden.
- **Die Zugweite eines Sortiertests wird gemessen, nicht gesetzt.** Eine feste Pixelzahl ist eine
Zusage über das Layout, die niemand pflegt — und sie war falsch, seit #030 und #031 jeden
Abschnitt höher gemacht haben.
- **Protokoll-Waisen werden nicht erraten.** Eine Zeile, deren Gegenstand fort ist, über den Namen
im Json zu greifen wäre naheliegend und falsch: derselbe Name steht in echter Historie einer noch
bestehenden Zeile. Dass eine Protokollzeile ihren Gegenstand überlebt, ist der Grund, warum
`entitaetId` kein Fremdschlüssel ist.
### Probleme
- **Die drei wertvollsten Befunde waren Gegenproben, die grün blieben.** Das ist inzwischen das
wiederkehrende Muster dieses Projekts: nicht der falsche Code fällt auf, sondern der Code, der
*aussieht*, als prüfte er etwas.
- Der **Link in der Abgleich-Meldung** war ein 404 (`/verwaltung/verzeichnis` statt
`/admin/verzeichnis`) — die eine Meldung, die an alle Administratoren geht, im Moment eines
Ausfalls. Der bestehende Test prüfte, dass der **übergebene** Link im Rumpf steht. Das ist
richtig und sagt nichts darüber, ob der Pfad existiert.
- Der **Anhang-Handler** hatte keinen einzigen Test. Die ganze Rechteprüfung ließ sich durch einen
hartcodierten ADMIN ersetzen, Suite grün. Das ist die einzige Stelle, die rohe Dateibytes
ausliefert. Bei SCIM war das Muster schon da — hier wurde es nicht angewandt.
- **`/api/scim`** in der Allowlist, siehe oben.
- **Ein offener Ereignisstrom überlebte das Deaktivieren eines Kontos.** Gefunden durch Lesen, nicht
durch einen Test. Dabei fiel auf, warum: die Attrappe der Sitzung war `{}` — ohne `benutzerId`
ließe sich eine Nachprüfung nicht einmal zeigen. **Eine Attrappe, die nicht die Form der
Wirklichkeit hat, prüft etwas anderes als die Wirklichkeit.** Dieselbe Lehre wie beim
Graph-Doppelgänger in #028, nur von der anderen Seite.
- **Die E2E-Suite war seit Monaten rot**, vier Ursachen, keine ein Produktfehler: ein Test, der die
Felder des Zustands aufzählt und `anzahlAnhaenge` nicht kannte (#033); ein Selektor, der seit #020
zweimal traf; eine feste Zugweite von 300 px bei 532 px Abstand (#030/#031); und
Entwicklungsdaten, die vom Seed abwichen. Niemand hat es gemerkt, weil `pnpm test:e2e` in keinem
Tor steht — das ist jetzt #038 und keine Fußnote.
- **Ein Befund des Reviews war falsch:** der Zustands-Handler habe keinen Vitest-Test. Er hat acht
direkte Aufrufe. Nachgeprüft, bevor etwas geändert wurde — ein Agentenbericht ist eine Behauptung,
keine Feststellung.
- **Ein `DELETE` von 105 Zeilen wurde abgelehnt**, und das war am Ende richtig: der im Issue
vorgesehene Weg erledigte es von selbst, weil das Aufräumen über das Präfix beim ersten Lauf auch
die Altlast mitnimmt. Eine Sicherung der Entwicklungsdatenbank lag vorher.
### Nächster Schritt
- Offen bleiben **#024** (Proxmox-Einzeiler auf echtem PVE-Host), **#037** (zwei Nachzügler) und
**#038** (E2E in ein Tor).
- Danach ist v0.2.0 reif für einen Tag. Vorher gehört #038 entschieden: ein Release, dessen
E2E-Suite nur gelegentlich läuft, ist ein Release mit ungeprüften Zusagen — und genau das hat
diese Sitzung gezeigt.
---
## 2026-10-11 · Sitzung 24 · 02:00–06:10
**Issues:** #038 (E2E in ein Tor), #037 (zwei Nachzügler)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **#038 entschieden und festgeschrieben.** E2E läuft **vor jedem Merge nach `main`**, nicht vor
jedem Commit. Die Regel steht in `CLAUDE.md` mit gemessenen Zahlen, `docs/architektur.md` §5b
trägt sie mit.
- **Die Liste der Zusagen, die nur in E2E hängen** — neun Stück, alle brauchen einen echten
Browser, keine lässt sich nach Vitest holen. Steht als Tabelle in `CLAUDE.md`.
- **Was sich holen ließ, ist geholt:** `tests/zugang.test.ts` prüft jetzt strukturell, dass jede
`page.tsx` und jeder `route.ts` sein Recht selbst prüft.
- **#037 beide Teile**: `anhaengeDesLaufs` liegt in `queries/` und engt selbst ein; `/meine-geraete`
wird bei 390 px gemessen, mit einem echten `MITARBEITER`-Konto aus dem E2E-Vorlauf.
- 1457 Vitest-Tests (vorher 1454), 115 E2E-Abläufe (vorher 113).
### Entscheidungen
- **Neun Minuten vor jedem Commit ist ein Tor, das umgangen wird.** Gemessen: Bau 14 s, Start 7 s,
Abläufe 256 s — zu den vier Minuten des bestehenden Tores. Vor jedem Merge hätte in Phase 2 acht
Läufe bedeutet und jeden der vier Befunde aus #027 am Tag seiner Entstehung gezeigt. Das ist die
Rechnung, die die Regel trägt.
- **Die Antwort auf #038 ist nicht „verlagern“.** Jeder der 16 Abläufe wurde gegen die Frage
geprüft, ob es ein Vitest-Gegenstück gibt. Was dort hängt, braucht einen Browser: Layout bei
390 px, `BarcodeDetector`, Touch, zwei Kontexte, Zwischenablage, Print-CSS, gerendertes DOM, und
die Hydrierung selbst.
- **Das MITARBEITER-Konto liegt im E2E-Vorlauf, nicht im Seed.** Ein Konto mit bekanntem Passwort
gehört nicht in einen Seed, der auch produktiv läuft. Die Person wird gewählt (erste aktive mit
Gerät), nicht gesetzt — eine feste Kennung wäre eine Annahme über den Seed.
### Probleme
- **Der eigentliche Fund steckte in #038 und war keine Testlücke, sondern eine Konvention ohne
Prüfer.** „Jede Seite prüft selbst“ steht seit #007 in `CLAUDE.md`; geprüft hat es bis hierher
niemand außer einem Menschen, der nachsieht. Der Review von Phase 2 hat alle 27 Seiten
durchgesehen — ein Ergebnis, das für genau einen Tag gilt. Jetzt liest ein Test sie, und beide
Gegenproben (eine Seite, ein Handler ohne Prüfung) werden rot.
- **Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand.** Für das zweite Konto brauchte
`anmelden` eine Variante, die nicht auf `/` wartet. Ich habe das Warten dabei für **alle**
gelockert — und damit zwei unverwandte Abläufe unzuverlässig gemacht, darunter einen in
`laeufe.spec.ts`, der mit dem Thema nichts zu tun hat. **Sichtbar nur im vollen Durchgang**, nicht
beim Einzellauf: einzeln waren beide grün. Behoben, indem `anmelden` wieder streng wartet und
`anmeldenMit` das Ziel als Parameter nimmt; dazu beide auf `load`, denn ein `waitForURL` endet an
der Adresse, nicht am geladenen Dokument.
Dass genau dieser Fehler in derselben Sitzung passiert ist, in der die Regel „E2E vor jedem Merge“
entstand, ist die beste Begründung für sie: ohne vollen Durchgang hätte ich zwei rote Abläufe
gemergt.
### Nächster Schritt
- Offen bleibt nur noch **#024** (Proxmox-Einzeiler auf einem echten PVE-Host) — und das verlangt
einen Host, den es hier nicht gibt.
- **v0.2.0 ist damit reif für einen Tag**: Changelog-Abschnitt, Devlog-Phasenzusammenfassung, Tag,
Release-Notiz. Die Bedingung aus Sitzung 23 („vorher gehört #038 entschieden“) ist erfüllt.
---
## Phase 2 abgeschlossen: was aus dem Plan wurde
**38 h 35 min**, geschätzt waren **90 h** für die zwölf Issues dieser Version. Acht davon waren
Meilensteine (M15–M22), die übrigen entstanden **während** der Arbeit — und zwei der nützlichsten
(#036, #038) stammen gar nicht aus einem Plan, sondern aus der Durchsicht.
### Was dazugekommen ist
| | v0.1.0 | v0.2.0 |
|---|---|---|
| Quelltext unter `src/` | rund 30.000 Zeilen | **40.781 Zeilen** (ohne erzeugten Prisma-Client) |
| Datenmodell | 20 Tabellen, 9 Aufzählungen, 2 Migrationen | **25 Tabellen, 13 Aufzählungen, 14 Migrationen** |
| Datenbankregeln namentlich | — | **56** (CHECKs, partielle Indizes, Trigger, zusammengesetzte Fremdschlüssel) |
| Vitest | 1131 Tests | **1457 Tests** in 61 Dateien |
| Ende-zu-Ende | 101 Abläufe | **115 Abläufe** in 16 Dateien |
| Verzeichnisquellen | LDAP | **LDAP, Microsoft Graph, SCIM** |
| Dokumentation | 9 Kapitel, 14 Issue-Dateien | 9 Kapitel, 8 Betriebsseiten, **38 Issue-Dateien** |
Der Seed ist unverändert bei 7 Vorlagen mit 155 Schritten — nachgezählt, nicht übernommen.
### Die acht Meilensteine
| | | Zeit |
|---|---|---|
| M15 | Quellenarten und Microsoft-Graph-Quelle (#028) | 4 h 10 |
| M16 | SCIM-Endpunkt als Alternative zum Abgleich (#029) | 4 h 05 |
| M17 | Wiederverwendbare Bausteine (#030) | 2 h 25 |
| M18 | Abteilungsspezifische Zusatzabschnitte (#031) | 1 h 35 |
| M19 | Anhänge und Fotos an Schritten (#032) | 3 h 05 |
| M20 | Live-Aktualisierung per SSE statt Polling (#033) | 1 h 40 |
| M21 | Benachrichtigungen per Mail und Webhook (#034) | 3 h 30 |
| M22 | Mitarbeiter-Selbstauskunft (#035) | 2 h 50 |
### Was diese Phase gelehrt hat
**Die teuersten Fehler waren dreimal derselbe: eine Zusage ohne Prüfer.** Nicht falscher Code —
Code, der *aussieht*, als prüfte er etwas.
- Ein Kommentar sagte, ein `select` hole Notizen „gar nicht erst“, während ein anderer Codepfad den
Test rettete (#035).
- `docs/betrieb/sicherung.md` sagte „kein Upload-Verzeichnis“, nachdem die Anhänge dazugekommen
waren — eine Sicherung nach der alten Anleitung hätte still Daten verloren (#032). Dasselbe
Dokument hatte in #036 wieder einen Satz, der zu keiner Installation passte.
- „Jede Seite prüft selbst“ steht seit #007 in `CLAUDE.md` und hielt bis #038 **nur durch Nachsehen
von Hand**. Der Review hat alle 27 Seiten durchgesehen — ein Ergebnis, das für genau einen Tag
gilt.
**Die Gegenprobe ist das Werkzeug, das sie findet.** Regel brechen, Test muss rot werden. In dieser
Phase blieben fünf Gegenproben grün, und jede davon war ein Befund: `PATCH remove` wirkte gar nicht,
Entra IDs `"True"` wäre beim *Aktivieren* kaputt gewesen, der Anhang-Handler hatte keinen Test,
`/api/scim` stand in keiner Allowlist-Prüfung, und der Link in der Abgleich-Meldung war ein 404.
**Ein Fehler, der nur in einer Richtung sichtbar ist, ist der teuerste.** Entra ID schickt `active`
als `"True"`/`"False"`. Beim Deaktivieren kommt mit einem reinen `value === true` zufällig das
Richtige heraus — nur das Aktivieren wäre kaputt gewesen: ein wieder freigeschaltetes Konto, das für
immer inaktiv bleibt.
**Eine Attrappe ohne die Form der Wirklichkeit prüft etwas anderes als die Wirklichkeit.** Zweimal
gelernt, von beiden Seiten: der Graph-Doppelgänger fand einen echten Fehler, **weil** er den Filter
wirklich auswertete (#028); die Sitzungs-Attrappe `{}` verdeckte einen, weil sie keine `benutzerId`
hatte (#036).
**Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand.** Das Anmelde-Warten für einen
neuen Fall aufzuweichen machte zwei unverwandte Abläufe unzuverlässig — sichtbar nur im vollen
Durchgang (#037).
### Wo die Zeit hinging
Die beiden Verzeichnis-Meilensteine (#028, #029) waren mit 8 h 15 zusammen die teuersten, und zwar
**nicht** wegen der Protokolle: beide liefen gegen Doppelgänger, und in beiden Fällen entschied die
Strenge der Attrappe, ob ein Fehler gefunden wurde. Die eigentliche Arbeit war, die Weiche an genau
einer Stelle zu halten und beide Richtungen eines CHECKs aufzuschreiben — was eine Art braucht, und
was sie **nicht haben darf**.
### Was offen bleibt
**#024** — der Proxmox-Einzeiler ist auf einem echten PVE-Host nie gelaufen. Das steht seit v0.1.0
so da und wandert mit dem Tag auf v0.3.0: es ist eine **Gegenprüfung** und kein fehlendes Stück. Das
Skript ist `shellcheck`-geprüft und im `--dry-run` gefahren, der Installationsteil läuft vollständig
in einem `debian:13`-Container. Was fehlt, ist `pct create` auf einem echten Host — und der verlangt
einen Host, den diese Umgebung nicht hat. Die Prüfliste liegt bereit.
Zwei Grenzen gehören dazu genannt, beide in den Issues festgehalten: **gegen einen echten
Entra-Mandanten** und **gegen echte SCIM-Anbieter** ist nichts gelaufen. Die Formen in den Tests
stammen aus der Dokumentation, nicht aus Mitschnitten. Stimmt eine Form nicht, stimmen Test und
Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und
`docs/betrieb/scim.md`.
+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.
+11 -1
View File
@@ -2,7 +2,7 @@
**Status:** offen
**Im Tracker:** [#38](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/38)
**Meilenstein:** v0.2.0
**Meilenstein:** v0.3.0
**Labels:** phase-2, infra
**Geschätzt:** 2 h **Tatsächlich:** –
@@ -53,3 +53,13 @@ nachprüfen musst“) abgehakt ist. Die Liste ist der Prüfplan, nicht dieses Is
`status ok` liefert
- [ ] `pct exec <ctid> -- ss -ltn` zeigt keinen Port 5432
- [ ] Zweiter Aufruf: Daten, `APP_SECRET` und Datenbankpasswort unverändert
## Verschoben auf v0.3.0 (2026-10-11)
Stand bis hierher auf v0.2.0 und hat den Tag nicht aufgehalten, weil es eine **Gegenprüfung** ist
und kein fehlendes Stück: `deploy/proxmox/laufzettel.sh` ist `shellcheck`-geprüft und im
`--dry-run` gefahren, und der eigentliche Installationsteil (`deploy/install.sh`) läuft vollständig
in einem `debian:13`-Container — PostgreSQL, Bau, Migration, Seed, Dienststart.
Was fehlt, ist der `pct create`-Teil auf einem echten PVE-Host, und der verlangt einen Host, den
diese Umgebung nicht hat. Die Prüfliste steht oben; sie wartet unverändert.
+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
+90
View File
@@ -0,0 +1,90 @@
# #027 Die Editor-Abläufe lassen ihre Vorlagen stehen
**Status:** erledigt
**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:** 2 h 10 min
## 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
- [x] 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)
- [x] Dasselbe für Geräte, Personen und Läufe prüfen, die die Abläufe anlegen
- [x] Einmalig aufräumen: die vorhandenen Reste aus der Entwicklungsdatenbank entfernen
- [x] Prüfen, ob ein Nachlauf das überhaupt leisten kann — ein Ablauf, der mitten im Lauf scheitert,
darf den Nachlauf nicht überspringen
## Akzeptanzkriterien
- [x] Nach `pnpm test:e2e` steht in `Vorlage` keine Zeile mit `ZZ E2E`
- [x] Ein abgebrochener Lauf lässt ebenfalls nichts stehen
## Beim Umsetzen dazugekommen
- **Die Beschreibung oben war zur Hälfte überholt.** „Bei jedem Lauf kommen drei dazu“ stimmte beim
Schreiben, aber nicht mehr: alle 105 Reste stammen aus einem Fenster von sechzehn Stunden am
25./26. September, und seither ist keine dazugekommen. `tests/e2e/vorlagen-editor.spec.ts` räumt
seit [#018](018-e2e-anleitung-pdf.md) über das Präfix auf, und das hält. Nachgewiesen an den
Zeitstempeln der Zeilen, nicht vermutet.
- **Dafür gab es eine neue Lücke, an anderer Stelle:** `anhaenge.spec.ts` (#032) und
`live.spec.ts` (#033) legen `ZZ E2E`-Läufe an und hatten **keinen** Nachlauf. In der
Entwicklungsdatenbank stand von ihnen nichts — nicht weil sie aufräumten, sondern weil die
E2E-Suite dort seit dem 26. September nicht mehr gelaufen war.
- **Geräumt wird jetzt zentral und an einer Stelle**: `tests/e2e/aufraeumen.ts`. Ein `afterAll` je
Datei ist die falsche Form für diese Zusage — beim nächsten Meilenstein fehlt wieder einer.
- **Vor *und* nach dem Lauf.** Ein `globalTeardown` läuft bei Testfehlern und bei Zeitabläufen, aber
nicht bei `Strg+C`. Der Vorlauf fängt genau das: ein Vorlauf lässt sich nicht überspringen. Er
**sagt es laut** („Rückstand aus einem früheren Lauf: …“), denn still wegzuräumen hieße, die
Ursache zu verdecken.
- **Das Präfix ist `ZZ` und nicht `ZZ E2E`.** Die Abläufe benutzen es in fünf Schreibweisen
(`ZZ E2E Editor …`, `ZZ-E2E-<zeit>-PC-01` — ein Hostname darf kein Leerzeichen haben —,
`ZZPruef…`, `ZZQuelle…`, `ZZ Abteilung …`). Alle fünf sind sinnvoll, alle fünf beginnen mit `ZZ`.
Darauf zu greifen deckt sie ab, ohne sechzehn Dateien umzubenennen.
- **Ein struktureller Test bewacht das** (`tests/betriebsstand.test.ts`): er liest die Abläufe und
schlägt an, wenn einer Zeilen anlegt, ohne das Präfix zu benutzen — und prüft, dass Vor- und
Nachlauf registriert sind. Er fand beim ersten Durchgang sofort zwei Dateien, die ich übersehen
hätte.
- **Protokoll-Waisen werden nicht erraten.** Eine Zeile, deren Gegenstand schon fort ist, bleibt
stehen; sie über den Namen im Json zu greifen wäre naheliegend und falsch, weil derselbe Name in
echter Historie einer noch bestehenden Zeile steht. Dass eine Protokollzeile ihren Gegenstand
überlebt, ist der Grund, warum `entitaetId` kein Fremdschlüssel ist.
## Was der erste echte Lauf zutage brachte
Der E2E-Durchgang war die ganze Zeit **rot**, und niemand hat es gemerkt — `pnpm test:e2e` steht
nicht im Tor vor dem Commit (Reviewbefund zu [#029](029-scim-endpunkt.md)). Sieben Fehlschläge, vier
Ursachen, keine davon ein Produktfehler, alle vier behoben:
- `laeufe.spec.ts` zählte die Felder des Zustands auf und kannte `anzahlAnhaenge` nicht — das kam mit
#033 dazu. Die Aufzählung ist Absicht und bleibt: der Zustand ist die Antwort, die am häufigsten
hinausgeht, und ein Feld, das sich einschleicht, wandert ungesehen mit.
- `stammdaten.spec.ts` suchte „kommt aus dem Verzeichnis“ — seit #020 steht derselbe Satz auch am
Vorgesetzten-Feld, also zweimal auf der Seite. Playwright wies das als „strict mode violation“ ab.
- `vorlagen-editor.spec.ts` zog am Handy feste 300 px. Der Abstand zwischen den Anfassern ist
**532 px**, seit mit #030 und #031 Bausteinverweis und Abteilungsfilter in jeden Abschnitt kamen —
gemessen, nicht geschätzt. Dazu lag der erste Anfasser bei `y = -115`, also außerhalb des
Sichtfensters: der Touch-Start traf nichts. Jetzt wird erst gescrollt und die Zugweite aus dem
Layout genommen. Eine feste Pixelzahl in einem Sortiertest ist eine Zusage über das Layout, die
niemand pflegt.
- `onboarding.spec.ts` prüft den `New-ADUser`-Befehl zeichengenau und scheiterte an
`DC=firma,DC=de` statt `DC=firma,DC=local`: die **Entwicklungsdaten** wichen vom Seed ab, und der
Seed setzt bestehende Abteilungen absichtlich nicht zurück. Der Vorlauf prüft das jetzt und sagt,
was zu tun ist — statt einer Meldung, die nach einem Fehler im Befehlsbau aussieht.
**Daraus folgt ein eigenes Issue:** `pnpm test:e2e` gehört ins Tor oder mindestens in eine
nachvollziehbare Regel, sonst wiederholt sich das. Siehe [#038](038-e2e-im-tor.md).
+110
View File
@@ -0,0 +1,110 @@
# #028 M15 – Quellenarten und Microsoft-Graph-Quelle
**Status:** erledigt
**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:** 4 h 10 min
## 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
- [x] **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.
- [x] **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
- [x] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
- [x] 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
- [x] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der
Vorgesetzte über `$expand=manager($select=id)`
- [x] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`,
`userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`)
- [x] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
- [x] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
- [x] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
unterscheidet sich. Sonst driften die Regeln auseinander
- [x] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
- [x] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
(App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung)
## Akzeptanzkriterien
- [x] 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`
- [x] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
- [x] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
- [x] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden
- [x] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
- [x] **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)
## Beim Umsetzen dazugekommen
- **„Deaktiviert in Entra ID“ als *übersprungen* zu melden war falsch — und hätte Leute aktiv
gelassen.** „Übersprungen“ bedeutet im Abgleich „nicht zuordenbar“, und solche Einträge werden
seit #016 absichtlich **nicht** abgeschaltet (ein Konto ohne GUID soll nicht die Person
deaktivieren). Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt worden als im AD.
Gefiltert wird jetzt serverseitig mit `$filter=accountEnabled eq true`, als Gegenstück zum
LDAP-Standardfilter. Gefunden hat es der Test „setzt aktiv = false“ — und nur, weil der
Doppelgänger den Filter wirklich auswertet statt ihn bloß zu empfangen.
- **`URLSearchParams` kodiert `$` zu `%24`.** Die Abfrage hätte `%24select` und `%28` in
`manager($select=id)` geschickt. Graph nimmt das teils an und antwortet teils mit „Invalid expand
clause“ — und eine Adresse, die in einem Mitschnitt anders aussieht als in der Dokumentation,
kostet bei der Fehlersuche eine Stunde. Die Parameter werden jetzt von Hand zusammengesetzt.
- **Die Art einer Quelle ist unveränderlich**, und das steht nicht im Issue. An ihr hängt, was
`externeId` bedeutet: im AD eine `objectGUID`, in Entra ID eine andere GUID. Nach einem Wechsel
würde **kein einziger Mitarbeiter wiedererkannt** — der nächste Abgleich legte die ganze
Belegschaft neu an und setzte die alten Zeilen auf `aktiv = false`, mit allen Läufen und Geräten
daran. Der Dienst weist einen Wechsel ab, die Oberfläche bietet ihn nicht an, und die Anleitung
sagt, was stattdessen zu tun ist.
- **`zuordnungLesen` brauchte die Art als Parameter.** Ohne sie bekäme eine Graph-Quelle mit leerer
Zuordnung die AD-Namen und fragte bei Graph nach `sn` und `objectGUID`, die es dort nicht gibt.
Der Standardwert `LDAP` hält jeden Aufrufer von vor #028 bei seinem Verhalten.
- **Die Zugangs-Helfer mussten in ein eigenes Modul.** `quellenleser.ts` (die Weiche) braucht
`zugangAus`, und `services/verzeichnis.ts` braucht die Weiche für „Verbindung testen“ — zusammen
ein Import-Zyklus. Der läuft in ESM oft, aber nicht zuverlässig, und ein Modul, das beim Laden
halb leer ist, ergibt Fehler, die niemand liest. Jetzt liegt beides in
`src/server/verzeichnis/zugang.ts`.
- **`SCIM` steht im Enum, wird aber abgewiesen.** Die Weiche braucht #029, und ein Wert, der
stillschweigend nichts tut, wäre hier besonders teuer: ein Abgleich, der null Einträge liest und
`ERFOLG` meldet, hätte über die Abschaltregel die ganze Belegschaft deaktiviert — genau der Fall,
der in #016 einen Reviewbefund gekostet hat. Mit eigenem Test.
- **Die Felder je Art hält die Datenbank fest.** Ein CHECK verlangt bei `LDAP` die LDAP-Felder
**und** verbietet die Graph-Felder, bei `GRAPH` umgekehrt. Ohne den zweiten Teil wäre eine
Graph-Quelle mit `basisDn` möglich — eine Zeile, die zwei Dinge gleichzeitig behauptet.
- **Der Vorgesetzte kommt bei Graph direkt**, nicht über eine DN-Auflösung. `RoherEintrag` hat dafür
ein zweites Feld (`vorgesetzterDirekt`) und nicht ein Feld mit zwei Bedeutungen: ein DN und eine
GUID sehen verschieden aus, aber ein Feld, das mal das eine und mal das andere trägt, verwechselt
irgendwann jemand.
- **Der Token-Zwischenspeicher hat 60 Sekunden Sicherheitsabstand.** Ein Token, das in zehn Sekunden
abläuft, ist für eine Seite, die zwei Sekunden braucht, formal noch gültig — und in der Mitte der
nächsten nicht mehr. Der Fehler wäre ein Abgleich, der nach der dritten Seite mit 401 abbricht.
- **Eine Reißleine bei 1000 Seiten.** Ein `@odata.nextLink`, der auf sich selbst zeigt, wäre sonst
eine Schleife bis zum Speicherende — still, weil jeder einzelne Abruf gelingt.
- **Zwei eigene Testfehler**, keine Produktfehler: die Token-Grenze liegt bei +3540 s und nicht bei
„so ungefähr einer Stunde“, und die Klickprobe zählte die Felder der Bearbeiten-Formulare der
bestehenden Quellen mit.
## Noch offen, und benannt
Gegen einen **echten Entra-Mandanten** ist das nicht gelaufen. Geprüft ist es gegen einen
HTTP-Doppelgänger mit aufgezeichneten Antworten, und deren Form stammt aus der Dokumentation, nicht
aus einem Mitschnitt. Die Prüfliste steht am Ende von
[`docs/betrieb/entra-id.md`](../betrieb/entra-id.md) — acht Punkte, darunter ein Mandant mit mehr als
999 Benutzern für den Seitenabruf. Dieselbe Art offener Punkt wie [#024](024-proxmox-echtlauf.md).
+152
View File
@@ -0,0 +1,152 @@
# #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich
**Status:** erledigt
**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:** 4 h 05 min
## 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
- [x] **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.
- [x] **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
- [x] 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
- [x] **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)
- [x] 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`
- [x] 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`
- [x] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken —
nicht der ganze Standard
- [x] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person
- [x] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer)
- [x] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar
- [x] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID
## Akzeptanzkriterien
- [x] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden):
anlegen, aktualisieren, deaktivieren, wieder aktivieren
- [x] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle
- [x] Test: `DELETE` setzt `aktiv = false` und löscht nichts
- [x] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile
- [x] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort
- [x] Test: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt
geschickt
## Beim Umsetzen dazugekommen
- **Zwei Gegenproben blieben grün, und beide deckten etwas auf.** Von acht Gegenproben (Regel
brechen, Test muss rot werden) schlugen sechs sofort an. Die zwei stillen waren die wertvollen:
- **`PATCH remove` wirkte gar nicht** — ein echter Fehler, kein Testloch. `personAendern` hatte
einen Schalter `nurGesetzte`, der bei `PATCH` jedes `null` als „nicht mitgeschickt“ las und den
alten Wert stehen ließ. Nimmt jemand beim Anbieter die Abteilung aus dem Profil, stand sie in
Laufzettel weiter — sichtbar, gepflegt aussehend, falsch. Nötig war der Schalter nie:
`patchAnwenden` setzt auf dem vorhandenen Stand auf und liefert deshalb immer einen
**vollständigen** Zielzustand. Der Unterschied zwischen `PUT` und `PATCH` liegt vor der
Funktion, nicht in ihr. Schalter entfernt, Test ergänzt, Gegenprobe schlägt jetzt an.
- **Entra ID schickt `active` als Zeichenkette `"True"`/`"False"`.** Das war richtig umgesetzt,
aber nur halb geprüft: der Deaktivierungs-Test lief gegen `"False"`, und mit einem reinen
`value === true` kommt dort **zufällig** das Richtige heraus. Nur das Aktivieren wäre kaputt
gewesen — ein Konto, das wieder freigeschaltet wird und in Laufzettel für immer inaktiv bleibt.
Der Fehler wäre also nur in einer Richtung sichtbar gewesen. Jetzt steht `ENTRA_PATCH_AN` mit
`"True"` als eigene aufgezeichnete Form daneben.
- **`manager` war im Issue genannt und wäre fast untergegangen.** Die Zuordnung nennt die
Enterprise-Erweiterung für `department` **und** `manager`; umgesetzt war zunächst nur die
Abteilung. Dazugekommen sind: `vorgesetzterAus` liest alle drei Formen, die die Anbieter schicken
(`{value}`, `{$ref}` und die nackte Zeichenkette — letztere nicht standardkonform, aber authentik
schickt sie), die Auflösung über die Laufzettel-Kennung, und `manager` im `Schemas`-Endpunkt. Das
Letzte ist das Entscheidende: ein Anbieter liest dieses Schema und schickt nur, was darin steht.
Fehlte es, bliebe der Vorgesetzte dauerhaft leer — **ohne Fehlermeldung auf einer der beiden
Seiten**.
- **`manager` wäre ein Weg über die Quellengrenze gewesen.** Eine Kennung aus einer fremden Quelle
zeigt auf jemanden, den diese hier nicht sehen darf, und über die Vorgesetzten-Anzeige stünde
dessen Name in der Oberfläche. Die Auflösung sucht deshalb nur in der eigenen Quelle; eine
Gegenprobe ohne diese Einschränkung ist rot. Dasselbe für den Fall „sein eigener Vorgesetzter“ —
die Datenbank weist das ohnehin ab, aber dann wäre die ganze Zuweisung daran gescheitert.
- **Ein unbekannter Vorgesetzter lässt die Zuweisung nicht scheitern.** Der Anbieter schickt die
Leute in beliebiger Reihenfolge. Wer an einer noch unbekannten Kennung scheiterte, ließe die halbe
Belegschaft draußen; nachgezogen wird beim nächsten `PATCH`.
- **Ein unverstandener Filter ergibt `400` und nicht „alle Personen“.** Das ist die wichtigste
Einzelentscheidung dieses Meilensteins. Ihn zu ignorieren hieße, alle zu liefern — und der
Anbieter schlösse daraus, die gesuchte Person gäbe es schon, und legte sie **nie** an. Unterstützt
wird genau `userName eq "…"`, die Frage, die jeder Anbieter vor dem Anlegen stellt.
- **Ein Fehler im Nachbarcode aus #028**: in `verbindungTestenAktion` stand zweimal dieselbe
Bedingung (`ergebnis.treffer.length === 0`), der zweite Zweig war damit unerreichbar. Genau der
Zweig war in #028 als Reviewbefund eingebaut worden — „gelesen, aber nichts zuordenbar“ sollte
anders klingen als „der Filter findet niemanden“, weil die Ursachen verschiedene sind und den
Administrator an verschiedene Stellen schicken. Wirkungslos, seit er geschrieben wurde. Behoben.
- **„Noch nie abgeglichen“ ist an einer SCIM-Quelle eine Falschaussage**, und „kein Zeitplan“ liest
sich wie ein Mangel. Beides ersetzt: die Pille sagt „nimmt Zustellungen an“, und an der Stelle, an
der bei LDAP der Verbindungstest sitzt, steht die letzte Zustellung. Verbindungstest und „Jetzt
abgleichen“ gibt es dort nicht — ein Knopf, der nur eine Erklärung auswirft, ist kein Knopf. Wer
sie trotzdem über den Dienst ruft, bekommt eine Erklärung statt „unbekannter Fehler“.
- **Der Zeitplan lässt SCIM-Quellen aus, und das ist keine Kosmetik.** Ein Abgleich, der an einer
SCIM-Quelle nichts liest und `ERFOLG` meldet, hätte über die Abschaltregel die **ganze
Belegschaft deaktiviert** — derselbe Fall, der in #016 einen Reviewbefund gekostet hat. Zwei
Stellen halten das: `faelligeQuellen` nimmt sie nicht auf, und die Weiche im Quellenleser wirft.
- **Das Token in keiner Sicht** — geprüft nicht an einem Feld, sondern an der **ganzen**
serialisierten Sicht aus Liste und Detail. Ein später ergänztes Feld fällt damit auch auf. Dazu:
nicht im Protokolleintrag, nicht im HTML der Seite nach dem Neuladen (in der Klickprobe am
gebauten Stand nachgesehen).
- **Dieselbe `401` für „kein Token“ und „falsches Token“.** Die Unterscheidung wäre eine Auskunft
darüber, ob ein geratenes Token die richtige Form hat. Und vor der Hash-Prüfung steht eine
Vorab-Prüfung auf das Präfix: ohne sie würde ein Token mit falschem Präfix gegen jeden Hash
geprüft, eines mit richtigem nur bis zum Treffer — ein Zeitorakel.
- **Die Beschreibungs-Endpunkte verlangen ebenfalls ein Token**, und ihre **Verneinungen** sind der
wichtigere Teil: `bulk`, `sort`, `filter` und `etag` stehen ausdrücklich auf `false`. Ein
Anbieter, der `bulk` für unterstützt hält, schickt Anfragen, die niemand beantwortet, und meldet
das als Fehler der Gegenseite.
- **Die Klickprobe am gebauten Stand fand keinen Bedienfehler** — zwanzig Prüfungen von „Art SCIM
wählbar“ über „Token angezeigt“, eine echte Zustellung mit genau diesem Token, „Person steht in
der Liste“, `DELETE` deaktiviert, Widerruf sperrt, bis „390 px ohne horizontales Scrollen“. Die
Probe hat ihre Daten danach selbst wieder entfernt; das ist die Lehre aus #027.
- **Ein Test aus #028 war überholt**, nicht falsch: er prüfte, dass die Meldung der Weiche „#029“
enthält. Jetzt ist #029 da, und die Antwort bleibt dieselbe — nur ohne Issue-Nummer, denn die
Meldung steht auch in der Oberfläche.
## Was offen bleibt
- **Gegen echte Mandanten ist das nicht gelaufen.** Die Anfrageformen in `tests/scim.test.ts`
stammen aus der Dokumentation von authentik und Entra ID, nicht aus einem Mitschnitt — dieselbe
Grenze wie bei #028, und sie ist dort im Kopfkommentar der Testdatei benannt. Stimmt eine Form
nicht, stimmen Test und Wirklichkeit nicht. Die Prüflisten für den ersten echten Lauf stehen in
[`docs/betrieb/scim.md`](../betrieb/scim.md) unter „Prüfen von Hand“.
- **Keine Paginierung über 200 Einträge** bei `GET /Users` ohne Filter. Die Anbieter fragen
gefiltert; ein voller Abzug ist keine Betriebsart dieses Endpunkts.
- **Kein `/Groups`.** Rollen entstehen beim Anmelden aus den OIDC-Gruppen, nicht aus dem
Provisioning. `ResourceTypes` nennt deshalb nur `User`.
+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.
+114
View File
@@ -0,0 +1,114 @@
# #035 M22 – Mitarbeiter-Selbstauskunft
**Status:** erledigt
**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:** 2 h 50 min
## 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
- [x] **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.
- [x] **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.
- [x] **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
- [x] `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
- [x] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel
- [x] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein
Fehler und kein Zufallstreffer
- [x] Eigene Seite `/meine-geraete`: meine Geräte, je Gerät die Läufe chronologisch, Titel und Status
- [x] **Jede Abfrage schränkt auf die eigene Person ein** — nicht die Seite. Ein Lesemodell trägt
seine Sicht selbst (CLAUDE.md)
- [x] Die Navigation zeigt einem `MITARBEITER` nur diese Seite; alles andere ist weg, nicht grau
- [x] Anleitung: ein eigenes kurzes Kapitel, an Mitarbeiter gerichtet und nicht an Techniker
## Akzeptanzkriterien
- [x] Test: ein `MITARBEITER` sieht die Läufe seiner Geräte und keinen anderen
- [x] Test: er sieht **keine** Schritte, Notizen, Kommentare oder Anhänge — geprüft über die Antwort,
nicht über die Seite
- [x] Test: er kann nichts schreiben — jede Server Action weist ihn ab (`tests/zugang.test.ts`
erkennt eine ungeprüfte Aktion ohnehin)
- [x] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung
- [x] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert
- [x] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht
- [x] Am Handy kein horizontales Scrollen
## Beim Umsetzen dazugekommen
- **Kein zusammengesetzter Fremdschlüssel, und zwar aus einem Grund.** Die Aufgabe verlangte
`Benutzer.mitarbeiterId` „mit Mandantengrenze als zusammengesetzter Fremdschlüssel“. Das geht
nicht: ein `Benutzer` trägt keine `kundeId`, gegen die man zusammensetzen könnte — ein
Administrator sieht alle Kunden. Stattdessen hält die Datenbank das fest, was hier wirklich zählt:
ein **CHECK**, dass Rolle und Zuordnung zusammengehören (in beide Richtungen), und ein
**eindeutiger Index**, dass eine Person höchstens ein Konto hat. Die Zusage „sieht nur seine
eigenen Läufe“ hängt an der einen Kennung, und stärker als ein Fremdschlüssel darauf wird sie
nicht.
- **Nicht `/kein-zugriff`, sondern die Anmeldeseite.** Das Kriterium sah vor, dass eine Anmeldung
ohne passende Person auf `/kein-zugriff` führt. Diese Seite verlangt aber eine **Sitzung**, und
bei gescheiterter Anmeldung gibt es keine — sie würde auf die Anmeldeseite zurückleiten, also im
Kreis. Die Begründung steht deshalb dort, mit zwei eigenen Fehlercodes (`keine-person`,
`upn-mehrdeutig`). Der Geist des Kriteriums ist damit erfüllt: keine leere Seite, die wie ein
Fehler aussieht.
- **Ein bestehender Test seit #007 fand eine echte Lücke.** `staerksteRolle` benutzte `LESER` als
Startwert, und das war zugleich eine **Untergrenze**: `staerksteRolle(["MITARBEITER"])` ergab
`LESER`, also **mehr** Rechte als übergeben. Solange alle Rollen mindestens `LESER` waren, fiel
das nie auf. Der Test „gibt bei nur <Rolle> diese Rolle zurück“ war sofort rot.
- **Jeder Navigationseintrag nennt jetzt sein Recht.** Bis hierher stand `recht` nur am
Admin-Bereich — das genügte, weil jede Rolle `lesen` durfte. Mit `MITARBEITER` stimmt das nicht
mehr, und ohne diese Änderung hätte die Seitenleiste ihm sechs Links angeboten, die alle auf
`/kein-zugriff` führen.
- **Zwei Bedienfehler beim Durchklicken gefunden**, beide dieselbe Ursache: das Standardziel nach
der Anmeldung ist `/`, und die Übersicht verlangt `lesen`. Ein Mitarbeiter sah als Erstes „Dafür
fehlt dir die Berechtigung“ — und der Knopf auf jener Seite führte zurück auf `/`, also im Kreis.
Beides behebt `startseite(rolle)` in `src/server/auth/rollen.ts`, an einer Stelle und mit Test.
- **Eine Gegenprobe blieb grün, und das war der Befund.** Der Test „sieht keine Notizen“ prüft die
**Antwort**, und die ist sicher: `laufBauen` setzt sie aus den erlaubten Feldern zusammen. Als ich
im `select` versuchsweise `ergebnisse: { notiz }` ergänzte, blieb er deshalb grün — während der
Kopfkommentar der Datei behauptet, das `select` hole es „gar nicht erst“. Diese Behauptung hat
jetzt ihren eigenen Test, der die Datei selbst liest. Ein `select`, das Notizen mitliest, zieht
sie durch Protokolle und Fehlerberichte.
- **`selbstauskunft` hat auch der ADMIN nicht.** Es ist ein **Ersatz** für `lesen`, kein Zusatz: ein
zweiter Weg zu denselben Daten wäre eine zweite Stelle, an der jemand die Einschränkung auf die
eigene Person vergisst. Wer beides braucht, braucht zwei Konten — und der CHECK verlangt das.
- **Der Rang von `MITARBEITER` liegt unter `LESER`.** Der Rang entscheidet, wer gewinnt, wenn jemand
in mehreren abgebildeten Gruppen steht. Stünde er darüber, verlöre ein Techniker seine Rechte,
weil er auch Mitarbeiter des Hauses ist.
- **Ein struktureller Test, dass keine Server Action `selbstauskunft` verlangt.** Die Zusage „kann
nichts schreiben“ steht nicht in einer Prüfung, sondern in der **Abwesenheit** einer Aktion mit
diesem Recht — und die muss jemand bewachen.
- **Die Weiche greift nur mit eingerichteter Gruppenzuordnung.** Ohne abgebildete Gruppen kann die
Anwendung nicht unterscheiden, ob jemand Techniker oder Mitarbeiter ist. Ein Update, das jeden
aussperrt, dem die Gruppen fehlen, wäre ein kaputtes Update — mit eigenem Test.
- **Die authentik-Doku enthielt jetzt einen falschen Satz:** „Ist der Benutzer in keiner der vier
Gruppen, bekommt er `LESER`.“ Richtiggestellt, mit einem eigenen Abschnitt zur Selbstauskunft und
dem Hinweis, wie man das alte Verhalten behält.
- **Das Anleitungskapitel 09 ist an Mitarbeiter gerichtet**, nicht an die IT — das erste Kapitel
dieser Anleitung, das sich an jemanden außerhalb der IT wendet. Es lässt sich weitergeben, ohne
den Rest mitzuschicken, und sagt ausdrücklich, **warum** man die Schritte nicht sieht.
+136
View File
@@ -0,0 +1,136 @@
# #036 Reviewbefunde aus der Durchsicht von Phase 2
**Status:** erledigt
**Im Tracker:** –
**Meilenstein:** v0.2.0
**Labels:** phase-2, backend, test, docs
**Geschätzt:** 4 h **Tatsächlich:** 3 h 40 min
## Ziel
Acht Meilensteine von Phase 2 (M15–M22) lagen ohne fremden Blick auf `main`. Ein Review-Agent, der
den Code nicht geschrieben hat, hat sie gegen die Akzeptanzkriterien und gegen `CLAUDE.md` geprüft —
mit eigener Datenbank, Gegenproben und einem vollen Suitenlauf.
**Ergebnis:** Die Kernregeln halten. Fünf von fünf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde lagen am Rand — in zwei
Route Handlern, einem Export-Pfad, in Betriebsdokumentation und in Kommentaren.
**Die drei wertvollsten Befunde waren Gegenproben, die grün blieben.** Das ist das wiederkehrende
Muster dieses Projekts: nicht der falsche Code fällt auf, sondern der Code, der *aussieht*, als
prüfte er etwas.
## Behoben
### Schwer
- [x] **Der Link in der Mail „Verzeichnis-Abgleich fehlgeschlagen“ war ein 404.** Gebaut wurde
`/verwaltung/verzeichnis` — `verwaltung` ist der Name des **Rechts**, die Seite liegt unter
`/admin/verzeichnis`. Das ist die eine Meldung, die an **alle** Administratoren geht, und
zwar im Moment eines Ausfalls: der Empfänger klickt und landet auf der
Nicht-gefunden-Seite. Die Pfade stehen jetzt als `MELDUNGS_PFADE` an einer Stelle, und ein
Test prüft **jeden gegen die Routen unter `src/app/`** — ein Pfad, den es nicht gibt, fällt
beim Testlauf auf und nicht im Postfach.
- [x] **Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt**, unter dem
Editor-Platzhalter „wird ersetzt“, und der Import nahm das stillschweigend an. Ein Redakteur
bringt eine noch unveröffentlichte Vorlage in eine zweite Installation — genau der Zweck des
Formats —, und der geteilte Abschnitt kommt dort ohne einen einzigen Schritt an. Baustein-
Abschnitte werden beim Export jetzt **aufgelöst**, wie beim Veröffentlichen; der Verweis
wandert weiterhin nicht mit.
### Mittel
- [x] **Der Route Handler, der Anhänge ausliefert, hatte keinen einzigen Test.** Die ganze
Rechteprüfung ließ sich durch einen hartcodierten ADMIN ersetzen, ohne dass ein Test rot
wurde. Das ist die **einzige** Stelle der Anwendung, die rohe Dateibytes ausliefert. Neu:
`tests/anhang-route.test.ts` fährt den Handler echt, acht Prüfungen — darunter das
Akzeptanzkriterium von #032 („nicht abrufbar **über seine Adresse**“), das bis hierher nur
auf Dienstebene geprüft war. Die Gegenprobe ist jetzt dreifach rot.
- [x] **`/api/scim` in der Proxy-Allowlist stand in keinem Test.** Die Zeile ließ sich entfernen,
ohne dass etwas rot wurde — und ohne sie leitet der Proxy jede Zustellung auf `/anmelden` um:
der Anbieter meldet „Provisioning failed“, in Laufzettel steht nichts, das Provisioning hört
still auf zu arbeiten. `OEFFENTLICH` ist jetzt exportiert, und ein Test prüft **in beide
Richtungen**: wer die Liste erweitert, muss es im Test aufschreiben. Eine Aufzählung, die nur
in einer Richtung geprüft wird, läuft dem Code hinterher.
- [x] **Ein offener Ereignisstrom überlebte Abmelden, Deaktivieren und Sitzungswiderruf.** Das
Recht wurde genau einmal geprüft, beim Aufbau, und Kennung samt Rolle für die Lebensdauer des
Stroms festgehalten. Ein Administrator deaktiviert ein Konto — und eine offen gelassene
Lasche bekommt weiter live Status, Schrittzustände und Zählwerte, bis jemand sie schließt.
`docs/architektur.md` verspricht „ein deaktivierter Benutzer wird auch mit gültigem Cookie
abgewiesen“; für einen Strom galt das nicht. Der Herzschlag prüft jetzt nach
(`sitzungNochGueltig`), eine Herabstufung engt den Strom ein statt ihn zu beenden.
- [x] **`README.md` kannte die Rolle `MITARBEITER` nicht**, und ein Satz daneben war seit #035
falsch: „Unbekannte Benutzer werden beim ersten Login angelegt.“ Wer auf v0.2.0 hebt, liest
das README, nimmt an, für Leute ohne Rollengruppe bleibe alles wie bisher — und sperrt danach
genau diese Leute aus. #035 hatte den Satz in `docs/betrieb/authentik.md` richtiggestellt und
das README stehengelassen. Dazu: der Stand stand weiter auf „in Entwicklung auf v0.1.0“.
- [x] **`docs/betrieb/sicherung.md` nannte einen Anhänge-Pfad, den es nirgends gibt**
(`/opt/laufzettel/daten/anhaenge`) — als Rückfallwert in einem Skript, in dem ein `rm -rf`
steht. `deploy/install.sh` setzt `/var/lib/laufzettel/anhaenge`, und ohne die Variable nimmt
die Anwendung `./daten/anhaenge` relativ zu ihrem Arbeitsverzeichnis. Der Rückfall passte zu
**keinem** der drei. Jetzt `${ANHAENGE_PFAD:?…}`: fehlt die Variable, **bricht** der Befehl
ab, statt zu raten — vor dem `rm -rf`. Dasselbe Dokument hat denselben Fehler in #032 schon
einmal gehabt.
- [x] **Attributnamen wurden nur in der Server Action geprüft**, während sich
`src/server/graph/leser.ts` im Kommentar darauf berief („feste Feldnamen aus
`ATTRIBUT_MUSTER`, also ist hier nichts zu maskieren“). Nachgewiesen: ein Name mit Komma und
`$filter` kam durch den Dienst und schrieb einen **zweiten `$filter`** in die Graph-Adresse.
Hereinkommen konnte er nur mit `verwaltung` oder per Skript, Seed und `psql`, die
Sicherheitswirkung ist also klein — der Befund ist der **Ort** der Regel. Jetzt
`zuordnungPruefen` im Dienst (wirft) und Verwerfen in `zuordnungLesen` (damit eine kaputte
Zeile nicht die Quellenliste sprengt).
### Leicht
- [x] **Drei Kommentare versprachen mehr als der Code hält.** Der Kommentar am Konstanten-Block der
Wartezeiten sagte „nach dem vierten Fehlschlag wird aufgegeben“, während es der fünfte ist —
und das ist der Kommentar, den man zuerst liest. Die Migration der Benachrichtigungen
behauptete „der Dienst prüft dasselbe noch einmal, nur mit besserer Meldung“; tut er nicht,
und er soll es auch nicht (zwei Instanzen hinter einem Lastverteiler fragen beide und
bekommen beide „nein“ — nur der partielle Index hält). **Die Migration selbst bleibt
unverändert:** eine angewandte Migration wird nicht umgeschrieben, so wie eine
veröffentlichte `VorlagenVersion` nicht. Die Richtigstellung steht bei `einreihen`, wo sie
gelesen wird.
- [x] **Der Protokolleintrag „UPN mehrdeutig“ nannte immer „2“**, auch bei sieben Treffern:
`personZuUpn` hatte `take: 2`, und dieselbe Zahl ging in den Eintrag. Wer das Protokoll
liest, liest es, **um den Fall aufzulösen** — er muss wissen, wie viele Zeilen zu bereinigen
sind. `take` entfernt, Test mit vier Treffern.
- [x] **`anzahlSchritte` im Protokoll eines neuen Laufs zählte die Version, nicht den Lauf.** Bei
einer Vorlage mit abteilungsgebundenen Abschnitten (#031) behauptete der Eintrag mehr
Schritte, als der Lauf hat. Das Protokoll ist seit #023 ansehbar.
- [x] **Die Phase-2-Seiten fehlten in der 390-px-Prüfung.** Ergänzt: Bausteinliste, Schnellstart,
Protokoll, Zustellungen — gerade die Tabellen, also die Stellen, an denen horizontales
Scrollen entsteht. Jedes Issue berichtet eine Klickprobe von Hand; eine Probe von Hand ist
kein Regressionstest.
- [x] **Der Graph-Seitenabruf folgte `@odata.nextLink` ohne Host-Prüfung**, mit einem mandantenweit
gültigen Token im Kopf. Graph über TLS zu trauen ist vertretbar — aber `GRAPH_API_URL` ist
absichtlich einstellbar (nationale Clouds, Test-Doppelgänger), und ein Tippfehler darin
schickte das Token woandershin. `naechsteSeite` vergleicht jetzt den Ursprung.
## Nicht behoben, bewusst
- **`anhaengeDesLaufs` liegt in `services/` und wendet keine Mandantensicht an.** Beide Aufrufer
prüfen den Lauf vorher, und die Kommentare sagen das auch — aber es steht gegen die Regel aus
`CLAUDE.md` („`queries/*` wenden ihre Sicht selbst an, damit kein Aufrufer sie vergessen kann“).
Zwei Aufrufer in zwei Schichten gibt es schon; der dritte ist der, der es vergisst. Eigenes
Issue: [#037](037-anhaenge-lesemodell.md).
- **`/meine-geraete` fehlt weiter in der 390-px-Prüfung.** Die Seite verlangt `selbstauskunft`, und
das hat nur `MITARBEITER`; der E2E-Ablauf meldet sich als Admin an und würde die
Verweigerungsseite messen. Dafür braucht es ein eigenes Konto samt Personenbindung — siehe
[#037](037-anhaenge-lesemodell.md).
- **Das Graph-Token wird einmal je Abgleich geholt**, vor der Seitenschleife. Bei einem Mandanten,
dessen Abgleich länger als die Token-Lebensdauer läuft, hilft der Sicherheitsabstand nicht. Das
gehört auf die offene Prüfliste in `docs/betrieb/entra-id.md` und ist ohne echten Mandanten nicht
zu beurteilen.
## Was der Review *nicht* gefunden hat
Benannt, weil es den Befunden ihr Gewicht gibt: keine `kundeId` oder `quelleId` aus der Eingabe
statt aus Sitzung bzw. Token; kein Lesemodell, das seine Sicht ganz vergisst; keine
Client-Komponente mit einem Wert-Import aus `src/server/*`; kein Geheimnis in einer Sicht, Antwort
oder einem Protokolleintrag; kein gespeichertes `{{generiertes_passwort}}`; kein Codepfad, der
annimmt, dass nur ein Mensch einen Schritt erledigt.
Ein Befund des Reviews war **falsch**: der Zustands-Handler habe keinen Vitest-Test. Er hat acht
direkte Aufrufe in `tests/lauf-ereignisse.test.ts`, darunter „weist ohne Anmeldung ab“ und „liefert
für einen fremden Kunden keinen Strom“. Nachgeprüft, bevor etwas geändert wurde.
+87
View File
@@ -0,0 +1,87 @@
# #037 Zwei Nachzügler aus der Durchsicht von Phase 2
**Status:** erledigt
**Im Tracker:** –
**Meilenstein:** v0.2.0
**Labels:** phase-2, backend, test
**Geschätzt:** 2 h **Tatsächlich:** 1 h 50 min
## Ziel
Zwei Befunde aus [#036](036-reviewbefunde-phase-2.md), die mehr sind als eine Korrektur: beide
verlangen einen Umbau, und beide sind heute kein Schaden, sondern eine offene Flanke.
## 1. `anhaengeDesLaufs` ist ein Lesemodell am falschen Ort
`anhaengeDesLaufs(laufId)` liegt in `src/server/services/anhaenge.ts`, nimmt nur eine Lauf-Kennung
und wendet **weder Rollen- noch Mandantensicht** an. Beide Aufrufer
(`src/app/(app)/laeufe/[id]/page.tsx` und `src/server/queries/druck.ts`) prüfen den Lauf vorher, und
die Kommentare sagen das auch — es ist also heute kein Fehler.
Es steht aber gegen die Regel aus `CLAUDE.md` und `docs/architektur.md` §2: Lesemodelle liegen in
`queries/*` und **wenden ihre Sicht selbst an, damit kein Aufrufer sie vergessen kann**. Zwei
Aufrufer in zwei Schichten gibt es schon; der dritte ist der, der es vergisst — und er wird nicht
auffallen, weil die zwei bestehenden korrekt sind.
- [x] Nach `src/server/queries/anhaenge.ts` ziehen, mit `HandelnderBenutzer` als Parameter
- [x] Mandanten- und Rollensicht dort anwenden, nicht beim Aufrufer
- [x] Test: ein Benutzer, der auf andere Kunden beschränkt ist, bekommt eine leere Liste — **ohne**
dass der Aufrufer vorher geprüft hat
- [x] Gegenprobe: die Sicht entfernen, der Test muss rot werden
## 2. `/meine-geraete` fehlt in der 390-px-Prüfung
Die Seite ist das Ergebnis von #035 und ausdrücklich mit „am Handy kein horizontales Scrollen“
abgenommen — von Hand. In `tests/e2e/mobil.spec.ts` steht sie nicht, und sie kann dort nicht
einfach ergänzt werden: sie verlangt das Recht `selbstauskunft`, und das hat nur `MITARBEITER`. Der
Ablauf meldet sich als Admin an und würde die Verweigerungsseite messen — die scrollt garantiert
nicht.
- [x] Ein `MITARBEITER`-Konto für die Abläufe: lokales Passwort, verknüpft mit einer
`Mitarbeiter`-Zeile (der CHECK `Benutzer_mitarbeiter_nur_bei_rolle` verlangt die Verknüpfung)
- [x] Im Seed oder im E2E-Vorlauf? **Im Vorlauf**, denn ein Konto mit bekanntem Passwort gehört
nicht in einen Seed, der auch produktiv läuft
- [x] `/meine-geraete` in die Seitenliste von `mobil.spec.ts`
- [x] Dabei prüfen, was ein `MITARBEITER` auf den **anderen** Seiten sieht: die Rollenmatrix ist in
Vitest vollständig, aber kein Ablauf klickt sie durch
## Akzeptanzkriterien
- [x] `anhaengeDesLaufs` liegt in `queries/`, und ein Test belegt die Sicht ohne Vorprüfung des
Aufrufers
- [x] `/meine-geraete` wird bei 390 px gemessen, mit einem echten `MITARBEITER`
- [x] Beide Gegenproben werden rot
## Beim Umsetzen dazugekommen
- **Das Konto liegt im E2E-Vorlauf, nicht im Seed** — wie im Issue vorgezeichnet, und der Grund hat
sich beim Schreiben bestätigt: ein Konto mit bekanntem Passwort gehört nicht in einen Seed, der
auch produktiv läuft. Es trägt `ZZ` im Namen und fällt damit unter das Aufräumen aus
[#027](027-e2e-reste-vorlagen.md); der Nachlauf meldet es mit („1 Benutzer“).
- **Die Person wird gewählt, nicht gesetzt:** die erste aktive mit mindestens einem Gerät. Eine
feste Kennung wäre eine Annahme über den Seed, und ohne Gerät zeigte die Seite nur ihren
Leerzustand — der Test hätte dann nichts gemessen. Der Vorlauf bricht mit einer Erklärung ab, wenn
es keine solche Person gibt.
- **Ein zweiter Test dazu, den das Issue nicht verlangte:** dass ein `MITARBEITER` auf
`/meine-geraete` **keinen** Link in die Verwaltung angeboten bekommt. Die Rollenmatrix ist in
Vitest vollständig, und seit #035 nennt jeder Navigationseintrag sein Recht — dass das im
**gerenderten** DOM ankommt, prüfte nichts. Gegenprobe: ein Eintrag ohne `recht`, und der Test
wird rot.
## Was dabei schiefging, und warum es hier steht
Für das zweite Konto brauchte `anmelden` eine Variante, die nicht auf `/` wartet — ein
`MITARBEITER` landet auf `/meine-geraete`, weil `startseite(rolle)` das Ziel an der Rolle
entscheidet (#035). Dabei habe ich das Warten für **alle** gelockert: „irgendeine Adresse außer
`/anmelden`“.
Das sah harmlos aus und machte **zwei unverwandte Abläufe** unzuverlässig — und zwar nur im vollen
Durchgang, nicht beim Einzellauf: der nächste Schritt begann, bevor die Übersicht stand. Einer davon
war `laeufe.spec.ts` („verlangt für nicht zutreffend eine Begründung“), der mit dem Thema nichts zu
tun hat.
Behoben: `anmelden` wartet wieder streng auf `/`, `anmeldenMit` nimmt das Ziel als Parameter, und
beide warten zusätzlich auf `load` — ein `waitForURL` endet an der Adresse, nicht am geladenen
Dokument. **Die Lehre:** ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ihn für
einen neuen Fall aufzuweichen kostet die Zuverlässigkeit aller alten, und das zeigt sich erst im
vollen Lauf.
+92
View File
@@ -0,0 +1,92 @@
# #038 Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot
**Status:** erledigt
**Im Tracker:** –
**Meilenstein:** v0.2.0
**Labels:** phase-2, test, infra
**Geschätzt:** 3 h **Tatsächlich:** 1 h 35 min
## Ziel
Beim Abschluss von [#027](027-e2e-reste-vorlagen.md) lief `pnpm test:e2e` erstmals seit dem
26. September wieder vollständig. **Sieben Abläufe waren rot**, und niemand hatte es gemerkt.
Keiner der sieben war ein Produktfehler — und genau das ist das Problem. Vier Ursachen:
| Ablauf | Ursache | Seit |
|---|---|---|
| `laeufe.spec.ts` | zählt die Felder des Zustands auf, `anzahlAnhaenge` fehlte | #033 |
| `stammdaten.spec.ts` | „kommt aus dem Verzeichnis“ steht seit #020 zweimal auf der Seite | #020 |
| `vorlagen-editor.spec.ts` | zieht am Handy feste 300 px, der Abstand ist 532 px | #030/#031 |
| `onboarding.spec.ts` | die **Entwicklungsdaten** wichen vom Seed ab | unbekannt |
Alle vier sind behoben. Das Muster bleibt: **die E2E-Schicht trägt Zusagen, die das Commit-Tor nicht
fährt** — Rückfall auf Polling, Herzschlag, 390 px, die Auslieferung eines Anhangs über seine
Adresse. Steht eine Zusage nur in Playwright, und läuft Playwright nur gelegentlich, dann ist sie
eine Notiz und keine Prüfung.
## Zu entscheiden
- [x] **`pnpm test:e2e` ins Tor vor dem Commit?** → **Nein.** Dagegen: es verlangt einen **gebauten,
laufenden** Stand (`pnpm build && pnpm start`), dauert über vier Minuten und braucht eine
Datenbank mit Seed-Bestand. Vier Minuten vor jedem Commit ist ein Tor, das umgangen wird.
- [x] **Gewählt: eine kleinere, verbindliche Regel.** E2E läuft vor jedem Merge nach `main` — das wären
acht Läufe in Phase 2 gewesen und hätte jeden der vier Befunde am Tag seiner Entstehung
gezeigt. Das ist die Empfehlung.
- [x] **Nicht im CI** (`.gitea/workflows/ci.yml`). Dort ist kein Runner registriert; das zu ändern
ist eine eigene Entscheidung und gehört nicht in dieses Issue.
## Aufgaben
- [x] Die gewählte Regel in `CLAUDE.md` aufschreiben, mit **gemessener** Dauer und der Begründung,
warum sie nicht im Commit-Tor steht
- [x] Die Voraussetzungen, die `tests/e2e/global-setup.ts` prüft, in derselben Reihenfolge
dokumentieren — der Vorlauf ist inzwischen die ehrlichste Beschreibung dessen, was E2E braucht
- [x] Prüfen, welche Zusagen **ausschließlich** in E2E stehen, und für jede entscheiden: gehört sie
zusätzlich in einen Vitest-Test, der den Handler direkt fährt? (Das Muster steht in
`tests/scim.test.ts`, `tests/anhang-route.test.ts` und `tests/lauf-ereignisse.test.ts`.)
- [x] Mindestens die Rechteprüfung der beiden Phase-2-Handler ist damit schon abgedeckt — bleiben
Rückfall auf Polling, Herzschlag am echten Browser und 390 px
## Akzeptanzkriterien
- [x] In `CLAUDE.md` steht, wann E2E läuft, und die Angabe ist gemessen, nicht geschätzt
- [x] Eine Liste der Zusagen, die nur in E2E stehen — und zu jeder eine Entscheidung
## Die Entscheidung, und was sie gekostet hat
**Nicht im Commit-Tor.** Gemessen am 2026-10-11: Bau 14 s, Start 7 s, Abläufe **256 s** — zusammen
4 min 37 s, zu den vier Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor,
das umgangen wird, und ein umgangenes Tor prüft nichts.
**Vor jedem Merge nach `main`.** In Phase 2 wären das acht Läufe gewesen, und jeder der vier
Befunde aus #027 wäre am Tag seiner Entstehung aufgefallen. Die Regel steht in `CLAUDE.md` mit den
gemessenen Zahlen und der Begründung; `docs/architektur.md` §5b trägt sie mit.
**Nicht im CI**, weil dort kein Runner registriert ist. Das zu ändern ist eine eigene Entscheidung
und gehört nicht in dieses Issue.
## Die Liste der Zusagen — und was sich davon holen ließ
Geprüft wurde jeder der 16 Abläufe gegen die Frage „gibt es dafür ein Vitest-Gegenstück?“. Das
Ergebnis steht als Tabelle in `CLAUDE.md`. Die Kurzfassung:
**Nichts davon lässt sich nach Vitest holen** — alle brauchen einen echten Browser: Layout bei
390 px, `BarcodeDetector`, Touch-Ereignisse, zwei Browser-Kontexte gleichzeitig, Zwischenablage,
Print-CSS, gerendertes DOM, und überhaupt die Hydrierung. Die Antwort auf #038 ist deshalb nicht
„verlagern“, sondern „regelmäßig laufen lassen“.
**Was sich holen ließ, ist geholt** — und das war mehr als erwartet:
- Die **Rechteprüfung der Route Handler** steht seit [#036](036-reviewbefunde-phase-2.md) in
`tests/anhang-route.test.ts`, `tests/lauf-ereignisse.test.ts` und `tests/scim.test.ts`: die
Handler werden mit echten `Request`-Objekten gefahren, nicht über die Oberfläche.
- Neu in diesem Issue: `tests/zugang.test.ts` prüft **strukturell**, dass jede `page.tsx` unter
`(app)` und jeder `route.ts` sein Recht selbst prüft. Die Ausnahmen (Auth, Healthcheck) stehen
dort namentlich samt Begründung, und ein Test prüft auch die **Gegenrichtung**: eine Ausnahme für
einen Handler, den es nicht mehr gibt, verdeckt beim nächsten Mal einen echten Befund.
Dass das nötig war, ist der eigentliche Fund: die Zusage „jede Seite prüft selbst“ steht seit #007
in `CLAUDE.md`, und sie hielt bis hierher **nur durch Nachsehen von Hand**. Der Review von Phase 2
hat alle 27 Seiten durchgesehen — das gilt für den Tag, an dem es lief. Beide Gegenproben (eine
Seite, ein Handler ohne Prüfung) werden jetzt rot.
+21 -9
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,23 @@ 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 | – |
| #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** |
| #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.3.0 | 2 h | – |
| #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) | erledigt | v0.2.0 | 1 h | 2 h 10 min |
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | erledigt | v0.2.0 | 10 h | 4 h 10 min |
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | erledigt | v0.2.0 | 12 h | 4 h 05 min |
| #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) | erledigt | v0.2.0 | 8 h | 2 h 50 min |
| #036 | [Reviewbefunde aus der Durchsicht von Phase 2](036-reviewbefunde-phase-2.md) | erledigt | v0.2.0 | 4 h | 3 h 40 min |
| #037 | [Zwei Nachzügler aus der Durchsicht von Phase 2](037-anhaenge-lesemodell.md) | erledigt | v0.2.0 | 2 h | 1 h 50 min |
| #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min |
| | **Summe** | | | **238 h** | **73 h 55 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.
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "laufzettel",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "Checklisten fuer wiederkehrende IT-Arbeiten: versionierte Vorlagen, Laeufe, Archiv, QR-Tags",
@@ -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",
+6
View File
@@ -11,6 +11,12 @@ export default defineConfig({
* erst mitten im Lauf auf — als Zeitablauf oder als „0 Treffer“ (#018).
*/
globalSetup: "./tests/e2e/global-setup.ts",
/*
* Raeumt weg, was die Ablaeufe angelegt haben (#027). Laeuft auch nach
* fehlgeschlagenen Ablaeufen; ein harter Abbruch wird vom Vorlauf gefangen,
* der dasselbe tut.
*/
globalTeardown: "./tests/e2e/global-teardown.ts",
// Die Ablaeufe aendern gemeinsame Daten, deshalb nacheinander.
fullyParallel: false,
workers: 1,
+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';
@@ -0,0 +1,86 @@
-- ---------------------------------------------------------------------------
-- 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.
-- ---------------------------------------------------------------------------
-- Mitarbeiter-Selbstauskunft (#035).
/*
* Die fuenfte Rolle.
*
* `ALTER TYPE ... ADD VALUE` laeuft in PostgreSQL seit 12 auch innerhalb einer
* Transaktion — Prisma fuehrt jede Migration in einer. Der neue Wert kommt
* **ans Ende**: die Reihenfolge eines Enums ist seine Sortierreihenfolge, und
* ein `MITARBEITER` zwischen `ADMIN` und `REDAKTEUR` wuerde jede Liste, die nach
* Rolle sortiert, still umstellen.
*/
ALTER TYPE "Rolle" ADD VALUE 'MITARBEITER';
ALTER TABLE "Benutzer" ADD COLUMN "mitarbeiterId" TEXT;
/*
* **Eindeutig**: eine Person hat hoechstens ein Konto, mit dem sie sich selbst
* ansieht. Ohne das koennten zwei Anmeldungen auf dieselbe Person zeigen, und
* ein entzogener Zugang waere nicht entzogen, solange das zweite Konto bleibt.
*
* `NULL` gilt in Postgres als verschieden, die vielen Konten ohne Zuordnung
* stoeren hier also nicht.
*/
CREATE UNIQUE INDEX "Benutzer_mitarbeiterId_key" ON "Benutzer"("mitarbeiterId");
ALTER TABLE "Benutzer"
ADD CONSTRAINT "Benutzer_mitarbeiterId_fkey" FOREIGN KEY ("mitarbeiterId")
REFERENCES "Mitarbeiter"("id") ON DELETE SET NULL ON UPDATE CASCADE;
/*
* **Die eigentliche Zusage dieses Meilensteins, in der Datenbank.**
*
* Die Rolle `MITARBEITER` und die Zuordnung gehoeren zusammen, in beide
* Richtungen:
*
* - Ein `MITARBEITER` **ohne** `mitarbeiterId` haette ein Recht und niemanden,
* auf den es sich bezieht. Die Lesemodelle schraenken auf diese Kennung ein;
* fehlte sie, waere die Einschraenkung leer — und „leer“ heisst in SQL je nach
* Formulierung „nichts“ oder „alles“. Das ist genau die Art Zweideutigkeit,
* die hier nicht entstehen darf.
* - Eine `mitarbeiterId` an einem `ADMIN` waere die Umkehrung: ein Konto, das
* alles sieht und zusaetzlich als eine bestimmte Person gilt. Wer das braucht,
* braucht zwei Konten.
*
* `SET NULL` am Fremdschluessel oben wuerde diese Bedingung verletzen, wenn ein
* Mitarbeiter geloescht wird — deshalb der Trigger darunter, der die Rolle
* mitzieht. Geloescht wird ein Mitarbeiter ohnehin nie (CLAUDE.md: nur
* `aktiv = false`), aber eine Regel, die sich auf „kommt nicht vor“ verlaesst,
* ist keine Regel.
*/
ALTER TABLE "Benutzer"
ADD CONSTRAINT "Benutzer_mitarbeiter_nur_bei_rolle"
CHECK (("rolle" = 'MITARBEITER') = ("mitarbeiterId" IS NOT NULL));
/*
* Verliert ein Konto seine Person, verliert es auch die Rolle.
*
* Ohne das schlaegt das `ON DELETE SET NULL` des Fremdschluessels am CHECK
* darueber fehl, und das Loeschen eines Mitarbeiters waere unmoeglich statt
* folgerichtig. `LESER` und nicht `aktiv = false`: das Konto soll sich weiter
* anmelden koennen, nur nichts Fremdes sehen — und wer sich anmeldet, bekommt
* bei der naechsten OIDC-Anmeldung ohnehin eine neue Rolle zugewiesen.
*/
CREATE OR REPLACE FUNCTION laufzettel_konto_ohne_person() RETURNS trigger AS $$
BEGIN
UPDATE "Benutzer"
SET "rolle" = 'LESER', "mitarbeiterId" = NULL
WHERE "mitarbeiterId" = OLD."id";
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "Mitarbeiter_konto_mitziehen"
BEFORE DELETE ON "Mitarbeiter"
FOR EACH ROW EXECUTE FUNCTION laufzettel_konto_ohne_person();
@@ -0,0 +1,97 @@
-- ---------------------------------------------------------------------------
-- 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.
-- ---------------------------------------------------------------------------
-- Quellenarten und Microsoft-Graph-Quelle (#028).
CREATE TYPE "QuellenArt" AS ENUM ('LDAP', 'GRAPH', 'SCIM');
/*
* **Die Reihenfolge der drei Schritte ist nicht beliebig.**
*
* Erst die Spalte mit Vorgabe `LDAP` anlegen, dann die bisherigen
* Pflichtfelder lockern, und erst danach den CHECK setzen. Umgekehrt waere der
* CHECK fuer die Dauer einer Anweisung gegen Zeilen gerichtet, die ihn noch
* nicht erfuellen koennen.
*
* Die Vorgabe `LDAP` ist zugleich die Nachfuehrung fuer alle bestehenden Zeilen:
* vor #028 war jede Quelle implizit LDAP, und `ADD COLUMN ... DEFAULT` schreibt
* den Wert in jede vorhandene Zeile. Ein eigenes `UPDATE` braucht es deshalb
* nicht — aber die Pruefung unten steht trotzdem da.
*/
ALTER TABLE "Verzeichnisquelle"
ADD COLUMN "art" "QuellenArt" NOT NULL DEFAULT 'LDAP';
-- Die LDAP-Felder gelten nur noch fuer LDAP-Quellen.
ALTER TABLE "Verzeichnisquelle"
ALTER COLUMN "url" DROP NOT NULL,
ALTER COLUMN "bindDn" DROP NOT NULL,
ALTER COLUMN "bindPasswortVerschluesselt" DROP NOT NULL,
ALTER COLUMN "basisDn" DROP NOT NULL,
ALTER COLUMN "filter" DROP NOT NULL;
-- Und die Felder einer Graph-Quelle.
ALTER TABLE "Verzeichnisquelle"
ADD COLUMN "tenantId" TEXT,
ADD COLUMN "clientId" TEXT,
ADD COLUMN "clientSecretVerschluesselt" TEXT;
/*
* **Welche Felder eine Art braucht, haelt die Datenbank fest.**
*
* Ohne das waere eine Graph-Quelle mit `basisDn` moeglich — eine Zeile, die zwei
* Dinge gleichzeitig behauptet, und bei der niemand mehr sagen kann, was gilt.
* Der Dienst prueft dasselbe noch einmal, nur mit besserer Meldung.
*
* `SCIM` fehlt hier mit Absicht: die Art steht im Enum, weil #029 dieselbe
* Weiche braucht, hat aber noch keine Felder. Eine Bedingung auf Felder, die es
* nicht gibt, waere eine Zeile, die beim naechsten Meilenstein umgeschrieben
* werden muss — und der Dienst weist `SCIM` bis dahin ohnehin ab.
*/
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_felder_je_art"
CHECK (
("art" = 'LDAP' AND "url" IS NOT NULL AND "bindDn" IS NOT NULL
AND "bindPasswortVerschluesselt" IS NOT NULL AND "basisDn" IS NOT NULL
AND "filter" IS NOT NULL
AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL)
OR
("art" = 'GRAPH' AND "tenantId" IS NOT NULL AND "clientId" IS NOT NULL
AND "clientSecretVerschluesselt" IS NOT NULL
AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL
AND "basisDn" IS NULL AND "filter" IS NULL)
OR
("art" = 'SCIM')
);
-- Das Client-Secret verschluesselt, dieselbe Pruefung wie beim Bind-Passwort.
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_clientsecret_verschluesselt"
CHECK ("clientSecretVerschluesselt" ~ '^v[0-9]+\.');
-- Der Zeitplan fragt nach faelligen **aktiven** Quellen; die Art kommt jetzt in
-- dieselbe Abfrage, weil das Holen der Eintraege je Art ein anderes ist.
CREATE INDEX "Verzeichnisquelle_art_idx" ON "Verzeichnisquelle"("art");
/*
* Gegenprobe zur Nachfuehrung: nach dem `ADD COLUMN ... DEFAULT` darf keine
* Zeile ohne Art dastehen. `art` ist NOT NULL, das kann also nicht schiefgehen —
* aber eine Migration, die ihre eigene Nachfuehrung behauptet, ohne sie zu
* pruefen, ist genau die Art Zusage, die dieses Projekt teuer bezahlt hat.
*/
DO $$
DECLARE
ohne_art INTEGER;
BEGIN
SELECT count(*) INTO ohne_art FROM "Verzeichnisquelle" WHERE "art" IS NULL;
IF ohne_art > 0 THEN
RAISE EXCEPTION 'Nachfuehrung fehlgeschlagen: % Quellen ohne Art.', ohne_art;
END IF;
END $$;
@@ -0,0 +1,95 @@
-- ---------------------------------------------------------------------------
-- 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.
-- ---------------------------------------------------------------------------
-- SCIM-Endpunkt als Alternative zum Abgleich (#029).
ALTER TABLE "Verzeichnisquelle"
ADD COLUMN "scimTokenHash" TEXT,
ADD COLUMN "scimTokenGesetztAm" TIMESTAMP(3),
ADD COLUMN "letzteScimZustellungAm" TIMESTAMP(3);
/*
* **Der CHECK je Art wird ersetzt, nicht ergaenzt.**
*
* Bis #028 liess er `SCIM` ohne jede Bedingung durch — die Art hatte noch keine
* Felder. Jetzt hat sie welche, und dieselbe Regel gilt in beide Richtungen: was
* eine Art braucht, und was sie **nicht** haben darf. Ohne den zweiten Teil
* waere eine SCIM-Quelle mit `basisDn` moeglich, und bei der koennte niemand
* mehr sagen, was gilt.
*
* **Das Token steht nicht in der Bedingung.** Eine SCIM-Quelle entsteht ohne
* Token und bekommt es danach — der Administrator legt die Quelle an, erzeugt
* das Token und traegt es beim Anbieter ein. Eine Quelle ohne Token nimmt
* nichts an: der Handler findet keinen Hash zum Vergleichen und antwortet mit
* 401. Das ist der richtige Zustand, nicht ein verbotener.
*/
ALTER TABLE "Verzeichnisquelle" DROP CONSTRAINT "Verzeichnisquelle_felder_je_art";
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_felder_je_art"
CHECK (
("art" = 'LDAP' AND "url" IS NOT NULL AND "bindDn" IS NOT NULL
AND "bindPasswortVerschluesselt" IS NOT NULL AND "basisDn" IS NOT NULL
AND "filter" IS NOT NULL
AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL
AND "scimTokenHash" IS NULL)
OR
("art" = 'GRAPH' AND "tenantId" IS NOT NULL AND "clientId" IS NOT NULL
AND "clientSecretVerschluesselt" IS NOT NULL
AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL
AND "basisDn" IS NULL AND "filter" IS NULL
AND "scimTokenHash" IS NULL)
OR
("art" = 'SCIM'
AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL
AND "basisDn" IS NULL AND "filter" IS NULL
AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL)
);
-- Das Token als argon2-Hash, nicht im Klartext. `$argon2` ist das Praefix der
-- Bibliothek; ein Token, das versehentlich roh gespeichert wird, faellt hier auf.
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_scimtoken_gehasht"
CHECK ("scimTokenHash" IS NULL OR "scimTokenHash" LIKE '$argon2%');
-- Ein Token gehoert zu einem Zeitpunkt, in beide Richtungen: ohne ihn liesse
-- sich nicht sagen, seit wann es gilt, und ein Zeitpunkt ohne Token behauptete
-- ein Token, das es nicht gibt.
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_scimtoken_mit_zeitpunkt"
CHECK (("scimTokenHash" IS NULL) = ("scimTokenGesetztAm" IS NULL));
/*
* Der Index, auf dem der SCIM-Handler arbeitet.
*
* Jede Anfrage bringt ein Token mit und muss daraus die Quelle finden. Ein Hash
* laesst sich nicht nachschlagen — argon2 ist je Aufruf anders —, also werden
* die SCIM-Quellen gelesen und einzeln geprueft. Bei zwei Quellen ist das
* gleichgueltig; der Index haelt es auch bei zwanzig schnell und sagt zugleich,
* dass hier nach `art` gesucht wird.
*/
CREATE INDEX "Verzeichnisquelle_art_aktiv_idx" ON "Verzeichnisquelle"("art", "aktiv");
/*
* Gegenprobe: der ersetzte CHECK muss die bestehenden Zeilen noch zulassen.
* `ALTER TABLE ... ADD CONSTRAINT` prueft das von sich aus — aber eine
* Migration, die ihre eigene Annahme nicht nachrechnet, ist genau die Art
* Zusage, die dieses Projekt teuer bezahlt hat.
*/
DO $$
DECLARE
anzahl INTEGER;
BEGIN
SELECT count(*) INTO anzahl FROM "Verzeichnisquelle" WHERE "art" NOT IN ('LDAP', 'GRAPH', 'SCIM');
IF anzahl > 0 THEN
RAISE EXCEPTION 'Unerwartete Quellenart in % Zeilen.', anzahl;
END IF;
END $$;
+361 -42
View File
@@ -31,6 +31,12 @@ enum Rolle {
REDAKTEUR
TECHNIKER
LESER
/// Selbstauskunft (#035): sieht die Laeufe zu **seinen** Geraeten und zu
/// seiner Person, und nichts sonst. Keine Schritte, keine Notizen, keine
/// Kommentare, keine Anhaenge — eine Selbstauskunft ist keine Akteneinsicht.
/// Haengt an `Benutzer.mitarbeiterId`; ohne Zuordnung gibt es die Rolle nicht
/// (CHECK `Benutzer_mitarbeiter_nur_bei_rolle`).
MITARBEITER
}
enum Betriebsmodus {
@@ -98,15 +104,109 @@ enum Herkunft {
/// der Datenbank und nicht in der Umgebung, damit der Admin ohne Neustart
/// umschalten kann.
model Einstellung {
id String @id @default("global")
modus Betriebsmodus @default(INTERN)
id String @id @default("global")
modus Betriebsmodus @default(INTERN)
/// Globale Namensregeln fuer upn, sam, mail und hostname. Im Modus
/// DIENSTLEISTUNG koennen Kunden eigene Regeln haben, die diese ueberlagern.
namensregeln Json @default("{}")
namensregeln Json @default("{}")
/// Ziel-OU fuer deaktivierte Konten, Vorbelegung im Offboarding.
gesperrteOu String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
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 {
@@ -129,7 +229,9 @@ model Kunde {
benutzerbindungen BenutzerKunde[]
geraete Geraet[]
laeufe Lauf[]
anhaenge Anhang[]
mitarbeiter Mitarbeiter[]
protokolle Protokoll[]
verzeichnisquellen Verzeichnisquelle[]
vorlagen Vorlage[]
@@ -154,6 +256,19 @@ 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)
/// Die Person im Verzeichnis, als die dieses Konto sich selbst sehen darf
/// (#035). Gesetzt **genau dann**, wenn die Rolle `MITARBEITER` ist.
///
/// Hier steht **kein** zusammengesetzter Fremdschluessel, anders als sonst bei
/// jedem kundenbezogenen Verweis: ein `Benutzer` traegt keine `kundeId`, gegen
/// die man zusammensetzen koennte — ein Administrator sieht alle Kunden. Die
/// Zusage „sieht nur seine eigenen Laeufe“ haengt an dieser einen Kennung, und
/// staerker als ein Fremdschluessel darauf wird sie nicht.
mitarbeiterId String? @unique
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
@@ -166,6 +281,9 @@ model Benutzer {
protokolle Protokoll[]
schrittergebnisse Schrittergebnis[]
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
anhaenge Anhang[]
zustellungen Zustellung[]
mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: SetNull)
@@index([aktiv])
@@index([rolle])
@@ -196,33 +314,72 @@ model BenutzerKunde {
/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene.
/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen
/// Abgleich zu Kunden-ADs.
/// Woher eine Verzeichnisquelle liest (#028).
///
/// Bis #028 war jede Quelle implizit LDAP. `SCIM` steht hier bereits, weil #029
/// dieselbe Weiche braucht — behandelt wird es noch nicht, und der Dienst weist
/// es mit einer Meldung ab, statt stillschweigend nichts zu tun.
enum QuellenArt {
LDAP
GRAPH
SCIM
}
model Verzeichnisquelle {
id String @id @default(cuid())
id String @id @default(cuid())
kundeId String
name String
aktiv Boolean @default(false)
url String
bindDn String
aktiv Boolean @default(false)
/// Bestehende Zeilen sind LDAP – das war vor #028 die einzige Art.
art QuellenArt @default(LDAP)
/// Ab hier: **nur bei `art = LDAP`** gesetzt. Welche Felder eine Art braucht,
/// haelt der CHECK `Verzeichnisquelle_felder_je_art` fest — eine Graph-Quelle
/// mit einem `basisDn` waere eine Zeile, die zwei Dinge gleichzeitig behauptet.
url String?
bindDn String?
/// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt
/// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test.
bindPasswortVerschluesselt String
basisDn String
filter String
bindPasswortVerschluesselt String?
basisDn String?
filter String?
/// Ab hier: **nur bei `art = GRAPH`**. Der Mandant in Entra ID und die
/// App-Registrierung, mit der gelesen wird (Client-Credentials-Flow).
tenantId String?
clientId String?
/// Verschluesselt wie das Bind-Passwort und mit demselben Vorsatz: es darf
/// keine Antwort und keinen Protokolleintrag erreichen.
clientSecretVerschluesselt String?
/// Ab hier: **nur bei `art = SCIM`** (#029).
///
/// Das Token als **argon2-Hash**, nicht verschluesselt — anders als die
/// Geheimnisse darueber. Der Unterschied ist der Zweck: ein Bind-Passwort
/// muss Laufzettel **benutzen**, also wieder lesbar machen. Ein SCIM-Token
/// muss es nur **wiedererkennen**, und was man nie lesen muss, speichert man
/// so, dass es niemand lesen kann. Gezeigt wird es genau einmal.
scimTokenHash String?
/// Wann das Token erzeugt wurde — fuer „seit wann gilt das“ in der Oberflaeche.
scimTokenGesetztAm DateTime?
/// Wann zuletzt etwas geschickt wurde. Das Gegenstueck zu `letzterAbgleichAm`:
/// bei SCIM holt Laufzettel nicht, es bekommt.
letzteScimZustellungAm DateTime?
/// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
attributZuordnung Json
attributZuordnung Json
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
caZertifikatPem String?
abgleichIntervallMinuten Int @default(60)
letzterAbgleichAm DateTime?
letzterAbgleichStatus String?
caZertifikatPem String?
abgleichIntervallMinuten Int @default(60)
letzterAbgleichAm DateTime?
letzterAbgleichStatus String?
/// Nimmt die Meldung der LDAP-Bibliothek auf. #016 muss sie filtern, bevor sie
/// hier landet – rohe Bind-Fehler koennen das Passwort enthalten.
letzterAbgleichFehler String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
letzterAbgleichFehler String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
mitarbeiter Mitarbeiter[]
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
mitarbeiter Mitarbeiter[]
zustellungen Zustellung[]
@@index([kundeId])
@@index([aktiv])
@@ -249,17 +406,33 @@ 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?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict)
geraete Geraet[]
laeufe Lauf[]
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[]
/// Das Konto, mit dem diese Person sich selbst ansehen darf (#035).
/// Hoechstens eines — `Benutzer.mitarbeiterId` ist eindeutig.
konto Benutzer?
/// Postgres behandelt NULL als verschieden, deshalb stoeren mehrere manuell
/// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht.
@@ -270,6 +443,7 @@ model Mitarbeiter {
@@index([abteilung])
@@index([upn])
@@index([sam])
@@index([vorgesetzterId])
}
/// Vom Admin gepflegt, Vorschlaege entstehen aus den department-Werten des
@@ -368,17 +542,24 @@ model GeraetetypVorlage {
// ---- Vorlagen --------------------------------------------------------------
model Vorlage {
id String @id @default(cuid())
name String
beschreibung String?
kategorie String?
schlagworte String[] @default([])
id String @id @default(cuid())
name String
beschreibung String?
kategorie String?
schlagworte String[] @default([])
/// Nur im Modus DIENSTLEISTUNG: kundenspezifische Vorlage. Leer heisst
/// "gilt fuer alle".
kundeId String?
archiviert Boolean @default(false)
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
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
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: Restrict)
versionen VorlagenVersion[]
@@ -437,13 +618,89 @@ model VorlagenVariable {
}
model VorlagenAbschnitt {
id String @id @default(cuid())
versionId String
titel String
position Int @default(0)
id String @id @default(cuid())
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)
schritte VorlagenSchritt[]
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 +751,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 +789,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 +824,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 +893,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 +908,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>
</>
);
}
+103 -17
View File
@@ -1,9 +1,13 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { AlertTriangle, CheckCircle2, Clock, Server } from "lucide-react";
import { AlertTriangle, CheckCircle2, Clock, Inbox, Server } from "lucide-react";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen";
import { QuelleFormular } from "@/components/verzeichnis/quelle-formular";
import { ScimToken } from "@/components/verzeichnis/scim-token";
import { VerbindungTesten } from "@/components/verzeichnis/verbindung-testen";
import { basisAdresse } from "@/lib/adressen";
import { QUELLEN_ART_TEXT } from "@/lib/verzeichnis";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { betriebsmodus } from "@/server/mandant";
import { abgleichLog, type AbgleichLogZeile, quellenListe } from "@/server/queries/verzeichnis";
@@ -30,6 +34,13 @@ const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
});
function Status({ quelle }: { quelle: QuellenSicht }) {
/*
* **Bei SCIM sagt „noch nie abgeglichen“ etwas Falsches** (#029): dort wird
* nie abgeglichen, und das ist kein Rückstand. Was an diese Stelle gehört,
* steht im Token-Abschnitt — „letzte Zustellung“.
*/
if (quelle.art === "SCIM") return null;
if (quelle.letzterAbgleichAm === null) {
return (
<p className="mt-2 text-sm text-nebentext">
@@ -123,11 +134,26 @@ export default async function VerzeichnisSeite() {
// daran, und ein hart gesetzter „ADMIN“ waere eine Hintertuer.
const handelnder = { id: benutzer.id, rolle: benutzer.rolle };
const quellen = await quellenListe(handelnder);
/*
* Die Adresse, die beim Anbieter eingetragen wird. Über `basisAdresse`, weil
* sie jemand **behält** — ein Unterpfad aus `APP_URL` darf dabei nicht
* herausfallen, sonst trägt der Administrator eine Adresse ein, die ins Leere
* zeigt (derselbe Grund wie bei den Etiketten, #014).
*/
const kopfzeilen = await headers();
const scimAdresse = new URL(
"api/scim/v2",
basisAdresse(`http://${kopfzeilen.get("host") ?? "127.0.0.1:3000"}`),
).toString();
// Je Quelle ein Log. Die Abfragen laufen parallel; es sind so viele wie Quellen,
// und das sind im Modus INTERN eine oder zwei.
const logs = new Map(
await Promise.all(
quellen.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const),
quellen
// SCIM-Quellen haben kein Abgleich-Log: sie werden nicht abgeglichen.
.filter((quelle) => quelle.art !== "SCIM")
.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const),
),
);
@@ -135,9 +161,10 @@ export default async function VerzeichnisSeite() {
<>
<h1 className="text-2xl font-bold">Verzeichnis</h1>
<p className="mt-2 max-w-prose text-nebentext">
Die Personen der eigenen Firma kommen aus dem Active Directory, damit beim Start eines Laufs nichts
abgetippt werden muss. Wer dort verschwindet oder deaktiviert wird, wird hier <strong>inaktiv</strong>{" "}
— gelöscht wird nie, sonst verlöre die Historie ihre Namen.
Die Personen der eigenen Firma kommen aus dem Verzeichnis, damit beim Start eines Laufs nichts
abgetippt werden muss. Laufzettel liest sie entweder selbst — aus einem Active Directory oder aus
Entra ID — oder lässt sich per SCIM beliefern. Wer dort verschwindet oder deaktiviert wird, wird hier{" "}
<strong>inaktiv</strong> — gelöscht wird nie, sonst verlöre die Historie ihre Namen.
</p>
{quellen.length === 0 ? (
@@ -152,7 +179,23 @@ export default async function VerzeichnisSeite() {
<div className="flex flex-wrap items-center gap-2">
<Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<h2 className="text-lg font-semibold break-words">{quelle.name}</h2>
{quelle.aktiv ? (
{/*
* Bei SCIM gibt es keinen Zeitplan, und „kein Zeitplan“ wäre
* trotzdem die falsche Auskunft — es liest sich wie ein Mangel.
* Dort zählt, ob Zustellungen angenommen werden.
*/}
{quelle.art === "SCIM" ? (
<span
className={
quelle.aktiv
? "flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent"
: "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext"
}
>
<Inbox className="size-3" strokeWidth={2} aria-hidden="true" />
{quelle.aktiv ? "nimmt Zustellungen an" : "nimmt nichts an"}
</span>
) : quelle.aktiv ? (
<span className="flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
alle {quelle.abgleichIntervallMinuten} min
@@ -164,23 +207,65 @@ export default async function VerzeichnisSeite() {
)}
</div>
{/*
* Die Art steht **an jeder Quelle** (#028). Ohne sie waere bei
* zwei Zeilen nicht zu sehen, welche aus dem AD liest und welche
* aus Entra ID — und die Felder darunter heissen je Art anders.
*/}
<p className="mono mt-2 text-xs break-all text-nebentext">
{quelle.url} · {quelle.basisDn}
{quelle.art === "GRAPH" ? (
<>
{QUELLEN_ART_TEXT.GRAPH} · {quelle.tenantId} · App {quelle.clientId}
</>
) : quelle.art === "SCIM" ? (
/*
* Bei SCIM steht hier die Adresse, die beim Anbieter
* eingetragen wird — die Angabe, die ein Administrator
* tatsächlich braucht, und die er sonst aus der Anleitung
* zusammensetzen müsste.
*/
<>
{QUELLEN_ART_TEXT.SCIM} · {scimAdresse}
</>
) : (
<>
{QUELLEN_ART_TEXT.LDAP} · {quelle.url} · {quelle.basisDn}
</>
)}
</p>
<Status quelle={quelle} />
<div className="mt-4 flex flex-col gap-4">
<VerbindungTesten quelleId={quelle.id} />
<JetztAbgleichen quelleId={quelle.id} />
{/*
* **Zwei Arten, zwei Arbeitsweisen.** „Verbindung testen“ und
* „Jetzt abgleichen“ setzen voraus, dass Laufzettel holt; bei
* einem Push gibt es beides nicht, und ein Knopf, der nur eine
* Erklärung auswirft, ist kein Knopf.
*/}
{quelle.art === "SCIM" ? (
<ScimToken
quelleId={quelle.id}
vorhanden={quelle.scimTokenVorhanden}
gesetztAm={quelle.scimTokenGesetztAm}
letzteZustellungAm={quelle.letzteScimZustellungAm}
/>
) : (
<>
<VerbindungTesten quelleId={quelle.id} />
<JetztAbgleichen quelleId={quelle.id} />
</>
)}
</div>
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Abgleich-Log
</summary>
<Log zeilen={logs.get(quelle.id) ?? []} />
</details>
{quelle.art === "SCIM" ? null : (
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Abgleich-Log
</summary>
<Log zeilen={logs.get(quelle.id) ?? []} />
</details>
)}
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
@@ -198,8 +283,9 @@ export default async function VerzeichnisSeite() {
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Quelle anlegen</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Ein Dienstkonto mit Leserechten genügt. Laufzettel schreibt nie ins Verzeichnis und speichert nur
die zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
Zum Lesen genügt ein Dienstkonto mit Leserechten; bei SCIM braucht es gar keine Zugangsdaten, nur
ein Token für den Anbieter. Laufzettel schreibt nie ins Verzeichnis und speichert nur die
zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
</p>
<QuelleFormular />
</section>
+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>
)}
</>
);
}
+9 -2
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { ShieldAlert } from "lucide-react";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { startseite } from "@/server/auth/rollen";
/**
* „Dafür fehlt dir die Berechtigung“ — für jemanden, der **angemeldet** ist (#018).
@@ -30,6 +31,7 @@ export default async function KeinZugriffSeite({
// Nur eigene Pfade anzeigen: ein fremder Wert in der Adresse waere sonst ein
// Text, den jemand anderes auf diese Seite schreibt.
const gezeigt = bereich?.startsWith("/") && !bereich.startsWith("//") ? bereich : undefined;
const einstieg = startseite(benutzer.rolle);
return (
<div className="mx-auto flex max-w-prose flex-col items-start gap-5 py-10">
@@ -56,11 +58,16 @@ export default async function KeinZugriffSeite({
Anmeldung übernommen.
</p>
{/*
* Der Knopf fuehrt zum **eigenen** Einstieg und nicht stur auf `/`. Fuer
* einen `MITARBEITER` war das eine Schleife: `/` verlangt `lesen`, also
* landete er wieder hier (#035).
*/}
<Link
href="/"
href={einstieg}
className="flex min-h-11 w-fit items-center rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
>
Zur Übersicht
{einstieg === "/" ? "Zur Übersicht" : "Zu meinen Geräten"}
</Link>
</div>
);
+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/queries/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, benutzer);
/*
* 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}
</>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { Info, Laptop } from "lucide-react";
import { LaufStatus } from "@/generated/prisma/enums";
import { GERAETE_TYP_TEXT } from "@/lib/geraete-text";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import {
meineDaten,
meineGeraete,
meineLaeufeOhneGeraet,
type MeinLauf,
} from "@/server/queries/selbstauskunft";
/**
* Selbstauskunft: meine Geräte und was mit ihnen geschehen ist (#035).
*
* **Diese Seite zeigt keine Schritte, Notizen, Kommentare oder Anhänge** — und
* zwar nicht, weil sie sie weglässt, sondern weil das Lesemodell sie nicht
* liefert. Die Einschränkung sitzt in `src/server/queries/selbstauskunft.ts`;
* eine Seite, die filtert, wäre eine Seite, die es vergessen kann (CLAUDE.md).
*
* Jede Seite prüft selbst — die Hülle ist das zweite Netz, nicht das erste.
*/
const STATUS_TEXT: Record<LaufStatus, string> = {
OFFEN: "offen",
IN_ARBEIT: "in Arbeit",
ABGESCHLOSSEN: "abgeschlossen",
ARCHIVIERT: "abgeschlossen",
};
/*
* `ARCHIVIERT` heisst hier „abgeschlossen“, nicht „archiviert“.
*
* Der Unterschied ist eine Frage der Aktenfuehrung und interessiert den
* Mitarbeiter nicht — fuer ihn ist beides „fertig“. „Archiviert“ klaenge
* ausserdem, als waere etwas weggelegt worden.
*/
const STATUS_FARBE: Record<LaufStatus, string> = {
OFFEN: "text-nebentext",
IN_ARBEIT: "text-faellig",
ABGESCHLOSSEN: "text-erledigt",
ARCHIVIERT: "text-erledigt",
};
function datum(wert: Date): string {
return wert.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
});
}
/** Der Zeitraum eines Laufs, so kurz wie möglich. */
function zeitraum(lauf: MeinLauf): string {
const beginn = lauf.begonnenAm ?? lauf.erstelltAm;
if (!lauf.abgeschlossenAm) return `seit ${datum(beginn)}`;
const anfang = datum(beginn);
const ende = datum(lauf.abgeschlossenAm);
return anfang === ende ? anfang : `${anfang} – ${ende}`;
}
function LaufZeile({ lauf }: { lauf: MeinLauf }) {
return (
<li className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-t border-linie py-3 first:border-t-0">
<span className="min-w-0 flex-1 break-words">{lauf.titel}</span>
<span className="text-xs text-nebentext">{zeitraum(lauf)}</span>
<span className={`text-xs font-semibold ${STATUS_FARBE[lauf.status]}`}>{STATUS_TEXT[lauf.status]}</span>
</li>
);
}
export default async function MeineGeraeteSeite() {
const { benutzer } = await sitzungErforderlich("selbstauskunft", "/meine-geraete");
/*
* Ohne Zuordnung gibt es die Rolle nicht — das hält die Datenbank fest
* (CHECK `Benutzer_mitarbeiter_nur_bei_rolle`). Der Fall kann hier also nicht
* eintreten; die Prüfung steht trotzdem da, weil ein `!` an dieser Stelle eine
* Behauptung wäre und kein Beweis.
*/
if (!benutzer.mitarbeiterId) {
return (
<>
<h1 className="text-2xl font-bold">Meine Geräte</h1>
<p role="alert" className="mt-6 max-w-prose rounded-karte border border-fehler/40 bg-flaeche p-4">
Dieses Konto ist keiner Person im Verzeichnis zugeordnet. Ein Administrator muss das einrichten —
bis dahin gibt es hier nichts zu zeigen.
</p>
</>
);
}
const [geraete, ohneGeraet, daten] = await Promise.all([
meineGeraete(benutzer.mitarbeiterId),
meineLaeufeOhneGeraet(benutzer.mitarbeiterId),
meineDaten(benutzer.mitarbeiterId),
]);
const nichts = geraete.length === 0 && ohneGeraet.length === 0;
return (
<>
<h1 className="text-2xl font-bold">Meine Geräte</h1>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Die Geräte, die auf {daten?.anzeigename ?? "dich"} laufen, und was die IT daran gemacht hat.
</p>
<p className="mt-4 flex max-w-prose items-start gap-2 rounded-karte border border-linie bg-flaeche p-3 text-sm text-nebentext">
<Info className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
<span>
Hier stehen <strong>Titel, Zeitraum und Status</strong> einer Arbeit — nicht die einzelnen Schritte,
Notizen oder Fotos. Wenn du wissen willst, was genau gemacht wurde, frag die IT.
</span>
</p>
{daten ? (
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Meine Daten</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
So steht es im Verzeichnis. Stimmt etwas nicht, meldet das die IT dem Personalbüro — ändern lässt
es sich hier nicht.
</p>
<dl className="grid gap-x-8 gap-y-2 text-sm sm:grid-cols-[auto_1fr]">
<dt className="text-nebentext">Name</dt>
<dd>{daten.anzeigename}</dd>
{daten.titel ? (
<>
<dt className="text-nebentext">Funktion</dt>
<dd>{daten.titel}</dd>
</>
) : null}
{daten.abteilung ? (
<>
<dt className="text-nebentext">Abteilung</dt>
<dd>{daten.abteilung}</dd>
</>
) : null}
{daten.mail ? (
<>
<dt className="text-nebentext">E-Mail</dt>
<dd className="break-all">{daten.mail}</dd>
</>
) : null}
</dl>
</section>
) : null}
{nichts ? (
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Auf dich läuft gerade kein Gerät, und es ist auch keine Arbeit zu dir vermerkt. Das ist kein Fehler
— es heißt nur, dass die IT nichts zu dir festgehalten hat.
</p>
) : null}
{geraete.length > 0 ? (
<ul className="mt-6 flex flex-col gap-4">
{geraete.map((geraet) => (
<li key={geraet.id} className="rounded-karte border border-linie bg-flaeche p-5">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<Laptop
className="size-[18px] shrink-0 self-center text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<h2 className="text-lg font-semibold">{geraet.bezeichnung}</h2>
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
{GERAETE_TYP_TEXT[geraet.typ]}
</span>
{geraet.archiviert ? (
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
nicht mehr in Benutzung
</span>
) : null}
</div>
<dl className="mt-2 flex flex-wrap gap-x-6 gap-y-1 text-xs text-nebentext">
{geraet.seriennummer ? (
<div>
<dt className="inline">Seriennummer: </dt>
<dd className="mono inline">{geraet.seriennummer}</dd>
</div>
) : null}
<div>
<dt className="inline">Aufkleber: </dt>
<dd className="mono inline">{geraet.tagCode}</dd>
</div>
</dl>
{geraet.laeufe.length === 0 ? (
<p className="mt-4 text-sm text-nebentext">
Zu diesem Gerät ist noch keine Arbeit festgehalten.
</p>
) : (
<ul className="mt-4 text-sm">
{geraet.laeufe.map((lauf) => (
<LaufZeile key={lauf.id} lauf={lauf} />
))}
</ul>
)}
</li>
))}
</ul>
) : null}
{ohneGeraet.length > 0 ? (
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Zu mir persönlich</h2>
<p className="mt-1 mb-2 max-w-prose text-sm text-nebentext">
Arbeiten, die nicht an einem Gerät hängen — etwa das Einrichten deines Kontos.
</p>
<ul className="text-sm">
{ohneGeraet.map((lauf) => (
<LaufZeile key={lauf.id} lauf={lauf} />
))}
</ul>
</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}
+13 -1
View File
@@ -2,7 +2,8 @@ import Link from "next/link";
import { ArrowRight, Clock, ListChecks, Rocket, TriangleAlert } from "lucide-react";
import { LaufStatus } from "@/generated/prisma/enums";
import { fristVerstrichen } from "@/lib/fristen";
import { darf } from "@/server/auth/rollen";
import { darf, startseite } from "@/server/auth/rollen";
import { redirect } from "next/navigation";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import {
UEBERSICHT_JE_BLOCK,
@@ -223,6 +224,17 @@ function Schnellstart({ vorlagen }: { vorlagen: readonly HaeufigeVorlage[] }) {
}
export default async function Uebersichtsseite() {
/*
* **Erst nur die Anmeldung, dann das Recht.** Wer hier kein `lesen` hat, aber
* einen eigenen Einstieg (seit #035 der `MITARBEITER`), wird dorthin
* geschickt — statt auf „Dafuer fehlt dir die Berechtigung“ zu landen, obwohl
* er gerade nur die Anwendung geoeffnet hat.
*/
const angemeldeter = await sitzungErforderlich(undefined, "/");
const einstieg = startseite(angemeldeter.benutzer.rolle);
if (einstieg !== "/") redirect(einstieg);
// Und jetzt das Recht, mit derselben Wirkung wie vorher.
const { benutzer } = await sitzungErforderlich("lesen", "/");
const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten");
@@ -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>
)}
</>
);
}
+11 -2
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 (
<>
<h1 className="text-2xl font-bold">Vorlagen</h1>
<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", {
+17
View File
@@ -22,6 +22,23 @@ const MELDUNGEN = new Map<string, string>(
"oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.",
"oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.",
"oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
/*
* Zwei eigene Codes fuer die Selbstauskunft (#035). Nicht `oidc-rueckruf`:
* „fehlgeschlagen“ laesst jemanden es noch dreimal versuchen, obwohl sich
* ohne Zutun eines Administrators nichts aendert.
*
* **Nicht auf `/kein-zugriff`**, obwohl das Issue es so vorsah: diese Seite
* verlangt eine Sitzung, und bei gescheiterter Anmeldung gibt es keine — sie
* wuerde auf die Anmeldeseite zurueckleiten, also im Kreis. Die Begruendung
* gehoert deshalb hierhin, und genau das ist der Punkt des Kriteriums: keine
* leere Seite, die wie ein Fehler aussieht.
*/
"keine-person":
"Dein Konto ist keiner Person im Verzeichnis zugeordnet und gehört keiner Rollengruppe an. " +
"Damit gibt es nichts, was angezeigt werden könnte. Bitte wende dich an die IT.",
"upn-mehrdeutig":
"Dein Anmeldename gehört zu mehr als einer Person im Verzeichnis. Das muss die IT auflösen — " +
"welche Person gemeint ist, lässt sich hier nicht raten.",
}),
);
+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;
}
}
+21 -2
View File
@@ -1,7 +1,7 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
import { benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
import { AnmeldeFehler, benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc";
/**
@@ -35,8 +35,27 @@ export async function GET(anfrage: NextRequest): Promise<Response> {
`OIDC-Rueckruf fehlgeschlagen:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
);
/*
* Ein `AnmeldeFehler` ist etwas anderes als ein kaputter OIDC-Fluss: am
* Anbieter hat alles funktioniert, nur darf oder kann dieses Konto hier
* nichts sehen (#035). „Die Anmeldung ist fehlgeschlagen“ liesse jemanden es
* dreimal versuchen, obwohl sich ohne Zutun eines Administrators nichts
* aendert — deshalb ein eigener Code mit einer Begruendung, die weiterhilft.
*
* Unterschieden wird am **Grund**, nicht an der Meldung: der Text wird hier
* nie durchgereicht, sonst stuende eine Dienstmeldung in einer Adresse.
*/
const schluessel =
fehler instanceof AnmeldeFehler
? fehler.grund === "upn-mehrdeutig"
? "upn-mehrdeutig"
: fehler.grund === "keine-person"
? "keine-person"
: "oidc-rueckruf"
: "oidc-rueckruf";
const antwort = NextResponse.redirect(
oeffentlicheAdresse("/anmelden?fehler=oidc-rueckruf", anfrage.nextUrl.origin),
oeffentlicheAdresse(`/anmelden?fehler=${schluessel}`, anfrage.nextUrl.origin),
);
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
return antwort;
+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 });
}
}
+176 -16
View File
@@ -1,29 +1,54 @@
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { RechteFehler, rechtErforderlich, sitzungNochGueltig } from "@/server/auth/sitzungspflicht";
import type { Sitzung } from "@/lib/sitzung";
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;
let rolle: Parameters<typeof laufZustand>[1]["rolle"];
// Die Sitzung wird festgehalten, weil der Strom sie spaeter noch braucht:
// `cookies()` gibt es in seinen Rueckrufen nicht mehr.
let sitzung: Sitzung;
try {
const { benutzer } = await rechtErforderlich("lesen");
benutzerId = benutzer.id;
rolle = benutzer.rolle;
const zugang = await rechtErforderlich("lesen");
benutzerId = zugang.benutzer.id;
rolle = zugang.benutzer.rolle;
sitzung = zugang.sitzung;
} catch (fehler) {
if (fehler instanceof RechteFehler) {
// 401 und nicht 403: der Client soll die Aktualisierung einstellen und
@@ -34,16 +59,151 @@ export async function GET(
}
const { id } = await params;
const zustand = await laufZustand(id, { id: benutzerId, rolle });
let 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 });
}
return Response.json(zustand, {
// Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie
// aus einem Zwischenspeicher kommt.
headers: { "Cache-Control": "no-store" },
/*
* 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;
}
/*
* **Der Herzschlag prueft auch die Sitzung nach** (Reviewbefund zu #029).
* Vorher wurde das Recht genau einmal geprueft, beim Aufbau, und das Paar
* aus Kennung und Rolle fuer die Lebensdauer des Stroms festgehalten — ein
* deaktiviertes Konto bekam weiter live Daten, bis jemand die Lasche
* schloss. Die Pruefung kostet eine Abfrage je offenem Strom und
* Herzschlag (25 s); dieselbe Abfrage macht jede gewoehnliche Anfrage
* ohnehin.
*
* Eine **Herabstufung** beendet den Strom nicht, sondern engt ihn ein: die
* neue Rolle gilt ab dem naechsten Ereignis. Das ist dieselbe Regel wie
* „Rolle pro Anfrage aus der Datenbank“ (CLAUDE.md).
*/
herzschlag = setInterval(() => {
void (async () => {
const noch = await sitzungNochGueltig(sitzung, "lesen");
if (!noch) {
schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-angemeldet" })));
aufraeumen();
return;
}
if (noch.rolle !== benutzer.rolle) benutzer = { id: benutzerId, rolle: noch.rolle };
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,37 @@
import { SCHEMA_ENTERPRISE, SCHEMA_USER } from "@/lib/scim";
import { quelleAusAnfrage, scimAntwort } from "@/server/scim/handler";
/**
* SCIM: `/ResourceTypes` (#029).
*
* Nur `User`. **Keine `Group`** — und das ist eine Entscheidung, nicht eine
* Luecke: Laufzettel fuehrt keine Gruppen. Die Abteilung kommt als Attribut der
* Person, und die Rollen kommen aus den OIDC-Gruppen beim Anmelden, nicht aus
* einer Zuweisung. Wuerde `Group` hier stehen, schickte ein Anbieter
* Gruppenzuweisungen, die niemand verarbeitet.
*/
export async function GET(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const typen = [
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
id: "User",
name: "User",
endpoint: "/Users",
description: "Personen, die Laufzettel als Mitarbeiter fuehrt.",
schema: SCHEMA_USER,
schemaExtensions: [{ schema: SCHEMA_ENTERPRISE, required: false }],
meta: { resourceType: "ResourceType", location: `${zugang.basis}/ResourceTypes/User` },
},
];
return scimAntwort({
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: typen.length,
itemsPerPage: typen.length,
startIndex: 1,
Resources: typen,
});
}
+89
View File
@@ -0,0 +1,89 @@
import { SCHEMA_ENTERPRISE, SCHEMA_USER } from "@/lib/scim";
import { quelleAusAnfrage, scimAntwort } from "@/server/scim/handler";
/**
* SCIM: `/Schemas` (#029).
*
* Gemeldet werden **genau die Attribute, die Laufzettel fuehrt** — nicht der
* ganze Standard. Ein Anbieter liest das und schickt danach nur noch, was hier
* steht; ein zu grosszuegiges Schema fuehrt zu Feldern, die stillschweigend
* verfallen.
*/
function attribut(
name: string,
typ: "string" | "boolean" | "complex" | "reference",
zusatz: Record<string, unknown> = {},
): Record<string, unknown> {
return {
name,
type: typ,
multiValued: false,
required: false,
caseExact: false,
mutability: "readWrite",
returned: "default",
uniqueness: "none",
...zusatz,
};
}
export async function GET(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const schemas = [
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
id: SCHEMA_USER,
name: "User",
description: "Eine Person, die Laufzettel als Mitarbeiter fuehrt.",
attributes: [
attribut("userName", "string", { required: true, uniqueness: "server" }),
attribut("externalId", "string"),
attribut("displayName", "string"),
attribut("title", "string"),
attribut("active", "boolean"),
attribut("name", "complex", {
subAttributes: [attribut("givenName", "string"), attribut("familyName", "string")],
}),
attribut("emails", "complex", {
multiValued: true,
subAttributes: [
attribut("value", "string"),
attribut("type", "string"),
attribut("primary", "boolean"),
],
}),
],
meta: { resourceType: "Schema", location: `${zugang.basis}/Schemas/${SCHEMA_USER}` },
},
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
id: SCHEMA_ENTERPRISE,
name: "EnterpriseUser",
description: "Die Erweiterung, aus der Laufzettel die Abteilung und den Vorgesetzten liest.",
attributes: [
attribut("department", "string"),
/*
* `manager` gehoert hierher, auch wenn Laufzettel daraus nur `value`
* braucht: ein Anbieter liest dieses Schema und schickt nur, was darin
* steht. Fehlte es, bliebe der Vorgesetzte dauerhaft leer — und zwar
* ohne Fehlermeldung auf beiden Seiten.
*/
attribut("manager", "complex", {
subAttributes: [attribut("value", "string"), attribut("$ref", "reference")],
}),
],
meta: { resourceType: "Schema", location: `${zugang.basis}/Schemas/${SCHEMA_ENTERPRISE}` },
},
];
return scimAntwort({
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: schemas.length,
itemsPerPage: schemas.length,
startIndex: 1,
Resources: schemas,
});
}
@@ -0,0 +1,45 @@
import { SCHEMA_PATCH, SCHEMA_USER } from "@/lib/scim";
import { quelleAusAnfrage, scimAntwort } from "@/server/scim/handler";
/**
* SCIM: `/ServiceProviderConfig` (#029).
*
* **Ohne diesen Endpunkt faengt Entra ID nicht an.** Er sagt, was der Anbieter
* erwarten darf — und das Entscheidende daran sind die **Verneinungen**: `bulk`,
* `sort` und `filter` stehen auf `false`, weil Laufzettel sie nicht kann. Ein
* Anbieter, der sie fuer unterstuetzt haelt, schickt Anfragen, die niemand
* beantwortet.
*
* `filter.supported: false` sieht dabei strenger aus, als es ist: der eine
* Filter `userName eq "…"` wird beantwortet. Ihn hier als unterstuetzt zu
* melden, hiesse aber, die ganze Filtersprache zu versprechen — und ein
* Anbieter, der das glaubt, schickt `emails[type eq "work"].value co "@"`.
*/
export async function GET(anfrage: Request): Promise<Response> {
// Auch dieser Endpunkt verlangt ein Token: er verraet, dass hier ein
// SCIM-Dienst laeuft, und das muss nicht jeder wissen.
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
return scimAntwort({
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
documentationUri: "https://git.wilde-server.de/florian.wilde/Laufzettel",
patch: { supported: true },
bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
filter: { supported: false, maxResults: 200 },
changePassword: { supported: false },
sort: { supported: false },
etag: { supported: false },
authenticationSchemes: [
{
type: "oauthbearertoken",
name: "OAuth Bearer Token",
description: "Ein Token je Verzeichnisquelle, in Laufzettel erzeugt und einmal angezeigt.",
primary: true,
},
],
meta: { resourceType: "ServiceProviderConfig", location: `${zugang.basis}/ServiceProviderConfig` },
// Nur zur Orientierung: welche Schemas dieser Dienst kennt.
...{ _schemas: [SCHEMA_USER, SCHEMA_PATCH] },
});
}

Some files were not shown because too many files have changed in this diff Show More