docs(phase-2): Phase 2 in acht Meilensteine schneiden
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Phase 2 stand bisher nur als acht Stichpunkte im Epic. Jetzt je Meilenstein eine
Datei mit Ziel, Aufgaben und Akzeptanzkriterien, dazu im Tracker gespiegelt:
#48 bis #55, zusammen 76 h geschaetzt.

Die Reihenfolge ist nicht beliebig. M15 fuehrt die Quellenart ein, die M16
braucht; M17 legt die Zusammensetzung fest, auf der M18 aufsitzt. Die anderen
vier haengen an nichts und koennen jederzeit dazwischen.

Drei Entscheidungen fallen vor dem Code und stehen je Meilenstein als erste
Aufgabe mit Empfehlung:

- Wie ein Baustein die Unveraenderlichkeit einer veroeffentlichten Version
  ueberlebt (Verweis im Entwurf, tiefe Kopie beim Veroeffentlichen). Ein Lauf
  sieht weiterhin konkrete Schritte und muss nichts lernen.
- Wo Anhaenge liegen (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.
- Was eine Selbstauskunft zeigt: Titel, Status, Zeitraum, Geraet, aber keine
  Schritte, Notizen, Kommentare oder Anhaenge. In einem Lauf stehen
  Arbeitsnotizen ueber Menschen; eine Selbstauskunft ist keine Akteneinsicht.

Refs #2
This commit is contained in:
Florian Wilde
2026-10-04 18:20:12 +02:00
parent 7c9fc4eb21
commit 41778529fc
10 changed files with 494 additions and 13 deletions
+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
+55
View File
@@ -0,0 +1,55 @@
# #028 M15 – Quellenarten und Microsoft-Graph-Quelle
**Status:** offen
**Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48)
**Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend
**Geschätzt:** 10 h **Tatsächlich:** –
## Ziel
Ein Haus ohne lokales AD hat kein LDAP, auf das sich zeigen ließe. Für reine Entra-ID-Umgebungen
braucht es eine zweite Art von Verzeichnisquelle, die über Microsoft Graph liest — mit denselben
Regeln wie der LDAP-Abgleich: anlegen, aktualisieren, deaktivieren, **nie löschen**.
Dieser Meilenstein legt dazu die Weiche, die auch [#029](029-scim-endpunkt.md) braucht:
`Verzeichnisquelle` bekommt eine **Art**. Heute ist jede Quelle implizit LDAP.
## Zu entscheiden
- [ ] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder
`url`, `bindDn` und `basisDn` sind dann je Art unterschiedlich belegt; was eine Art braucht,
prüft der Service, nicht die Datenbank. Die Alternative wäre eine Tabelle je Art mit
gemeinsamer Oberklasse — mehr Struktur, als drei Arten rechtfertigen.
- [ ] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den
Platzhalter bei einer Graph-Quelle **leer** zu lassen, statt etwas hineinzudeuten — und das
in der Anleitung zu sagen. Wer OU-abhängige Befehle fährt, braucht ein lokales AD.
## Aufgaben
- [ ] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
- [ ] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie
in einer Antwort), Token über Client-Credentials-Flow, Zwischenspeicher bis kurz vor Ablauf
- [ ] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der
Vorgesetzte über `$expand=manager($select=id)`
- [ ] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`,
`userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`)
- [ ] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
- [ ] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
- [ ] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
unterscheidet sich. Sonst driften die Regeln auseinander
- [ ] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
- [ ] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
(App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung)
## Akzeptanzkriterien
- [ ] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster
Abgleich legt an, zweiter aktualisiert ohne Dubletten, ein `accountEnabled: false` setzt
`aktiv = false`
- [ ] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
- [ ] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
- [ ] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden
- [ ] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
- [ ] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die
Gegenprobe gehört in eine Prüfliste wie bei [#024](024-proxmox-echtlauf.md)
+56
View File
@@ -0,0 +1,56 @@
# #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich
**Status:** offen
**Im Tracker:** [#49](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/49)
**Meilenstein:** v0.2.0
**Labels:** phase-2, backend
**Geschätzt:** 12 h **Tatsächlich:** –
## Ziel
Der Abgleich holt heute. Ein Identitätsanbieter kann auch **schicken** — authentik, Entra ID und
Okta sprechen dafür SCIM 2.0. Das ist der Weg für Häuser, die Laufzettel keinen Lesezugriff auf ihr
Verzeichnis geben wollen oder aus einem Netz heraus betreiben, das das Verzeichnis nicht erreicht.
Setzt [#028](028-graph-quelle.md) voraus: die Quellenart `SCIM` braucht die Weiche.
## Zu entscheiden
- [ ] **Ein Token je Quelle, als Hash gespeichert** — Empfehlung, dasselbe Verfahren wie beim
lokalen Passwort (argon2). Es wird **einmal** angezeigt und ist danach nicht mehr lesbar. Das
Token identifiziert die Quelle und damit den Kunden; es ist der einzige Mandantenbeweis, den
ein Push mitbringt.
- [ ] **Was bei `DELETE /Users/{id}` geschieht:** `aktiv = false`, nicht löschen. Das ist die Regel
des ganzen Projekts, und ein Identitätsanbieter, der löschen will, bekommt `204` — aber die
Zeile bleibt, weil Läufe an ihr hängen. Das muss in der Anleitung stehen, sonst wirkt es wie
ein Fehler.
## Aufgaben
- [ ] Route Handler unter `src/app/api/scim/v2/`: `Users` (GET mit Filter `userName eq`, POST),
`Users/{id}` (GET, PUT, PATCH, DELETE), dazu `ServiceProviderConfig`, `Schemas`,
`ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen
- [ ] **Jeder dieser Handler prüft selbst**: das Token, und daraus die Quelle und den Kunden. Die
Hülle unter `(app)` liegt hier nicht darüber (CLAUDE.md)
- [ ] Zuordnung: `externalId` → `externeId`, `userName` → `upn`, `name.givenName`/`familyName`,
`emails[primary]` → `mail`, `active` → `aktiv`, `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User`
für `department` und `manager`
- [ ] Antwortform nach RFC 7644: `schemas`, `id`, `meta.resourceType`, `meta.location`, bei Listen
`totalResults`/`itemsPerPage`/`startIndex`; Fehler als `urn:ietf:params:scim:api:messages:2.0:Error`
- [ ] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken —
nicht der ganze Standard
- [ ] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person
- [ ] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer)
- [ ] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar
- [ ] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID
## Akzeptanzkriterien
- [ ] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden):
anlegen, aktualisieren, deaktivieren, wieder aktivieren
- [ ] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle
- [ ] Test: `DELETE` setzt `aktiv = false` und löscht nichts
- [ ] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile
- [ ] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort
- [ ] Test: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt
geschickt
+59
View File
@@ -0,0 +1,59 @@
# #030 M17 – Wiederverwendbare Bausteine
**Status:** offen
**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:** –
## 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
- [ ] **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.
- [ ] **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
- [ ] Modelle `Baustein` und `BausteinVersion` mit denselben Regeln wie bei Vorlagen: höchstens ein
Entwurf, veröffentlichte Version unveränderlich (Trigger, nicht nur Dienst)
- [ ] `VorlagenAbschnitt` kann statt eigener Schritte einen Baustein nennen — nur im **Entwurf**
- [ ] 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
- [ ] Editor: Baustein einfügen, als Einheit anzeigen, nicht Schritt für Schritt bearbeitbar; ein
Hinweis nennt Baustein und Version
- [ ] Eigene Verwaltung für Bausteine, Recht `vorlagenBearbeiten` (wer Vorlagen pflegt, pflegt
Bausteine)
- [ ] „Wo wird dieser Baustein benutzt“ — vor dem Ändern will man das wissen
- [ ] Anleitung Kapitel 04
## Akzeptanzkriterien
- [ ] Test: eine neue Baustein-Version ändert **keine** veröffentlichte Vorlagenversion und **keinen**
laufenden Lauf
- [ ] Test: die Druckansicht eines archivierten Laufs ist nach einer Baustein-Änderung Zeichen für
Zeichen dieselbe
- [ ] Test: die nächste veröffentlichte Vorlagenversion enthält die neue Baustein-Version
- [ ] Test: ein Baustein lässt sich nicht löschen, solange eine Vorlage ihn im Entwurf nennt
- [ ] 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
+49
View File
@@ -0,0 +1,49 @@
# #031 M18 – Abteilungsspezifische Zusatzabschnitte
**Status:** offen
**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:** –
## 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
- [ ] **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.
- [ ] **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
- [ ] `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`
- [ ] Beim Start: die geltenden Abschnitte ermitteln und am Lauf festhalten
- [ ] Die Abarbeitungsansicht zeigt nur geltende Abschnitte; die Nummerierung (`01 ·`) zählt nur sie
- [ ] **Die Abschlussregel zählt nur geltende Pflichtschritte.** Ein ausgefilterter Pflichtschritt
darf einen Lauf nicht blockieren — sonst ist der Abschluss unerreichbar
- [ ] Druckansicht und Archiv zeigen dieselbe Auswahl wie die Abarbeitung
- [ ] Editor: je Abschnitt eine Mehrfachauswahl über die gepflegten Abteilungen, mit Freitext für
eine, die es noch nicht gibt
- [ ] Der Start-Dialog sagt vorher, wie viele Abschnitte die gewählte Abteilung hinzunimmt
- [ ] Anleitung Kapitel 04 und 02
## Akzeptanzkriterien
- [ ] Test: ein Lauf für „Buchhaltung“ enthält den Buchhaltungs-Abschnitt, einer für „Vertrieb“ nicht
- [ ] Test: ein Lauf ohne Abteilung enthält nur die uneingeschränkten Abschnitte
- [ ] Test: die Abteilung eines laufenden Laufs ändern ändert **nicht** seine Abschnitte
- [ ] Test: ein ausgefilterter Pflichtschritt blockiert den Abschluss nicht
- [ ] Test: die Druckansicht zeigt genau die Abschnitte der Abarbeitung — nicht mehr und nicht weniger
- [ ] Am Handy kein horizontales Scrollen
+54
View File
@@ -0,0 +1,54 @@
# #032 M19 – Anhänge und Fotos an Schritten
**Status:** offen
**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:** –
## 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
- [ ] **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.
- [ ] **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
- [ ] Modell `Anhang`: `schrittergebnisId`, `kundeId`, Dateiname, Medientyp, Größe, Prüfsumme,
`erstelltVonId`, `erstelltAm`; Datei daneben im Dateisystem
- [ ] Hochladen über einen Route Handler mit `rechtErforderlich` — kein Weg daran vorbei, und
**jeder** Abruf prüft Recht und Mandant, auch der des Bildes selbst
- [ ] 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
- [ ] Am Handy direkt aus der Kamera (`capture="environment"`), am Rechner aus dem Dateiwähler
- [ ] **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
- [ ] Druckansicht: Bilder als Miniaturen mit Dateinamen, PDF nur als Zeile
- [ ] `docs/betrieb/sicherung.md`, `docker-compose.yml`, `deploy/install.sh`: Volume, Rechte,
Sicherung **und** Rückspielung
- [ ] Anleitung Kapitel 02 und 08
## Akzeptanzkriterien
- [ ] Test: Anhang an einem Schritt hochladen, in der Abarbeitung und im Druck sichtbar
- [ ] Test: an einem archivierten Lauf wird das Hochladen abgewiesen
- [ ] Test: ein Anhang eines fremden Kunden ist über seine Adresse **nicht** abrufbar
- [ ] Test: eine zu große Datei und ein falscher Medientyp werden mit Grund abgewiesen
- [ ] 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)
- [ ] Sicherung und **Rückspielung** einmal wirklich gefahren, nicht nur beschrieben
+52
View File
@@ -0,0 +1,52 @@
# #033 M20 – Live-Aktualisierung per SSE statt Polling
**Status:** offen
**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:** –
## 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
- [ ] **`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.
- [ ] **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
- [ ] Trigger und `NOTIFY` in einer handgeschriebenen Migration, mit dem üblichen Vermerk (Prisma
kennt Trigger nicht und will sie löschen)
- [ ] 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`
- [ ] **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)
- [ ] Client: `EventSource` mit Wiederverbindung; bei versteckter Lasche pausieren wie heute; bei
`error` auf Polling zurückfallen und das **einmal** melden, nicht bei jedem Versuch
- [ ] `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
- [ ] Obergrenze für gleichzeitige Ströme je Instanz, damit ein Browser mit vierzig Tabs die
Verbindungen nicht aufbraucht
## Akzeptanzkriterien
- [ ] Test: zwei Sitzungen am selben Lauf — hakt die eine ab, sieht die andere es in unter einer
Sekunde, ohne neu zu laden
- [ ] Test: ohne Recht wird der Strom abgewiesen, bevor das erste Ereignis fließt
- [ ] Test: ein Lauf eines fremden Kunden liefert keinen Strom
- [ ] Test: der Rückfall auf Polling funktioniert — gefahren mit abgeschaltetem `EventSource`
- [ ] Der Herzschlag kommt an: eine Verbindung überlebt 60 s ohne Änderung
+57
View File
@@ -0,0 +1,57 @@
# #034 M21 – Benachrichtigungen per Mail und Webhook
**Status:** offen
**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:** –
## 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
- [ ] **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.
- [ ] **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.
- [ ] **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
- [ ] Modell `Zustellung`: Ereignis, Empfänger, Weg (MAIL | WEBHOOK), Versuche, letzter Fehler,
Zustand; mit Index auf das, was der Zeitplan abholt
- [ ] SMTP-Einstellungen unter Admin → Einstellungen: Host, Port, TLS, Benutzer, Passwort
(verschlüsselt, nie in einer Antwort), Absender, „Testmail schicken“
- [ ] Webhook: Ziel-URL, Geheimnis, **HMAC-SHA256 über den Rumpf** im Kopf `X-Laufzettel-Signatur`,
dazu ein Zeitstempel gegen Wiedereinspielen
- [ ] Wiederholung mit wachsendem Abstand (1, 5, 30, 120 Minuten), dann aufgeben und sichtbar
machen. Eine stille Warteschlange ist schlimmer als keine
- [ ] Überfälligkeit erkennt der Zeitplan, der schon läuft (`node-cron` im Prozess) — kein zweiter
Dienst
- [ ] Je Benutzer abschaltbar; ein Benutzer ohne Mailadresse bekommt keine Mail und keinen Fehler
- [ ] Admin: die letzten Zustellungen mit Zustand und Grund
- [ ] **`{{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**
- [ ] `docs/betrieb/`: SMTP einrichten, Webhook prüfen, Signatur nachrechnen
- [ ] Anleitung Kapitel 07
## Akzeptanzkriterien
- [ ] Test gegen einen echten SMTP-Fänger im Container (mailpit): zugewiesen, überfällig,
abgeschlossen, Abgleich fehlgeschlagen — vier Mails, richtige Empfänger
- [ ] Test gegen einen lokalen Empfänger: die Signatur lässt sich mit dem Geheimnis nachrechnen, mit
einem falschen nicht
- [ ] Test: eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar
- [ ] Test: zwei gleichzeitige Durchgänge schicken nichts zweimal
- [ ] Test: in **keiner** Nachricht steht ein erzeugtes Passwort — über alle Rumpftexte geprüft, wie
in #012
- [ ] Test: ein Benutzer mit abgeschalteter Benachrichtigung bekommt nichts
+61
View File
@@ -0,0 +1,61 @@
# #035 M22 – Mitarbeiter-Selbstauskunft
**Status:** offen
**Im Tracker:** [#55](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/55)
**Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend
**Geschätzt:** 8 h **Tatsächlich:** –
## Ziel
„Was ist eigentlich mit meinem Notebook passiert?“ fragt heute jemand im Ticket, und ein Techniker
sieht nach. Ein Mitarbeiter soll die Läufe zu **seinen** Geräten und zu **seiner** Person selbst
ansehen können — nur lesend.
## Woran es hängt
Ein `Mitarbeiter` ist kein `Benutzer`. Die Anwendung kennt vier Rollen für Leute, die **arbeiten**;
ein Mitarbeiter ist Gegenstand der Arbeit, nicht ihr Träger. Es gibt heute keinen Weg, von einer
Anmeldung zu einer `Mitarbeiter`-Zeile zu kommen.
## Zu entscheiden
- [ ] **Anmeldung über OIDC, Zuordnung über den UPN, neue Rolle `MITARBEITER`** — Empfehlung. Das
Haus hat seine Leute ohnehin im Identitätsanbieter; wer sich anmeldet und keiner
Rollengruppe angehört, wird über `upn` einer `Mitarbeiter`-Zeile zugeordnet und bekommt genau
ein Recht: `selbstauskunft`. `Benutzer.mitarbeiterId` hält die Zuordnung fest.
Die Alternative — ein Link mit Token je Person — braucht keine Anmeldung, aber jeder
weitergeleitete Link ist dann ein Datenleck, und widerrufen müsste man je Person.
- [ ] **Was der Mitarbeiter sieht:** Titel, Status, Zeitraum und Gerät eines Laufs. **Nicht** die
Schritte, nicht die Notizen, nicht die Kommentare, nicht die Anhänge. In einem Lauf stehen
Arbeitsnotizen über Menschen und Zugangsdaten-Platzhalter; eine Selbstauskunft ist keine
Akteneinsicht. Das ist die wichtigste Entscheidung dieses Meilensteins und gehört in die
Anleitung, nicht nur in den Code.
- [ ] **Kein Zugang ohne Zuordnung.** Wer sich anmeldet und zu keiner `Mitarbeiter`-Zeile passt,
bekommt `/kein-zugriff` mit Begründung — nicht eine leere Seite, die wie ein Fehler aussieht.
## Aufgaben
- [ ] `Rolle` um `MITARBEITER` erweitern; `RECHTE` bekommt `selbstauskunft: ["MITARBEITER", …]`.
**Jede** Rollenprüfung in `tests/rollen.test.ts` wächst um eine Spalte — die Matrix ist
ausgeschrieben und nicht berechnet, genau damit das auffällt
- [ ] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel
- [ ] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein
Fehler und kein Zufallstreffer
- [ ] Eigene Seite `/meine-geraete`: meine Geräte, je Gerät die Läufe chronologisch, Titel und Status
- [ ] **Jede Abfrage schränkt auf die eigene Person ein** — nicht die Seite. Ein Lesemodell trägt
seine Sicht selbst (CLAUDE.md)
- [ ] Die Navigation zeigt einem `MITARBEITER` nur diese Seite; alles andere ist weg, nicht grau
- [ ] Anleitung: ein eigenes kurzes Kapitel, an Mitarbeiter gerichtet und nicht an Techniker
## Akzeptanzkriterien
- [ ] Test: ein `MITARBEITER` sieht die Läufe seiner Geräte und keinen anderen
- [ ] Test: er sieht **keine** Schritte, Notizen, Kommentare oder Anhänge — geprüft über die Antwort,
nicht über die Seite
- [ ] Test: er kann nichts schreiben — jede Server Action weist ihn ab (`tests/zugang.test.ts`
erkennt eine ungeprüfte Aktion ohnehin)
- [ ] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung
- [ ] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert
- [ ] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht
- [ ] Am Handy kein horizontales Scrollen
+10 -2
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 |
@@ -32,4 +32,12 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
| | **Summe** | | | **153 h** | **41 h 20 min** |
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – |
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | offen | v0.2.0 | 12 h | – |
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | offen | v0.2.0 | 8 h | – |
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | offen | v0.2.0 | 10 h | – |
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | offen | v0.2.0 | 6 h | – |
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | offen | v0.2.0 | 10 h | – |
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
| | **Summe** | | | **229 h** | **41 h 20 min** |