Phase 7: Benachrichtigungen und Termine

Terminkalender mit Serienterminen, Selbstanmeldung, Warteliste und
Anwesenheitsliste. Vier Zustellwege hinter Schnittstellen: echtes SMTP,
ntfy (selbst gehostet), Telegram und Web-Push. Kalenderabonnement als
ICS-Feed, digitaler Dienstausweis mit signiertem QR und Pruefseite.

Niemand wird ungefragt benachrichtigt: ein neuer Zustellweg hat eine leere
Ereignisliste, und der Seed legt bewusst keine an. Was das Netz der Wehr
verlaesst, bekommt nur die Kurzfassung ohne Namen und Details.

Kein CalDAV (Lesen genuegt, ICS kann jeder Kalender) und kein WhatsApp
(Verarbeitung ueber Meta, Kosten je Konversation, Telefonbuchabgleich) --
beides im README begruendet.

56 neue Unit-Tests, 17 neue E2E-Tests, Version 0.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 08:39:38 +02:00
co-authored by Claude Opus 5
parent c779266450
commit 0b8874da26
134 changed files with 9340 additions and 123 deletions
+120 -1
View File
@@ -18,7 +18,126 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
| **Summe** | **16:10 h** | **8** | |
| Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
| **Summe** | **19:20 h** | **9** | |
---
## 2026-09-04 · Phase 7 — Benachrichtigungen und Termine
**04:10 – 07:20 · 3:10 h · Version 0.8.0**
### Was entstanden ist
Der Jahresplan der Wehr und die Wege, auf denen die Mannschaft davon erfährt.
| Bereich | Dateien |
|---|---|
| Rechnen | `lib/termin/serie.ts`, `ics.ts`, `lib/benachrichtigung/vorlagen.ts`, `versand.ts`, `lib/dienstausweis.ts` |
| Kanäle | `lib/integrations/mail.ts` (echtes SMTP), `ntfy.ts`, `webpush.ts` |
| Server | `server/routers/termin/termin.ts`, `benachrichtigung.ts`, `stammdaten/dienstausweis.ts` |
| Öffentlich | `api/kalender/[token]`, `ausweis/[code]`, `api/ausweis/[code]/foto` |
| Oberfläche | `app/(app)/termine/`, `benachrichtigungen/`, `ausweis/` |
| Hintergrund | `worker/benachrichtigung.ts`, zwei neue Zeitpläne |
| Tests | 56 Unit, 17 E2E je Browser |
### Entscheidungen
**Niemand wird ungefragt benachrichtigt.** Ein neu eingerichteter Zustellweg
hat eine leere Ereignisliste. Es gibt keinen Weg, jemand anderem einen Kanal
einzutragen — auch nicht für den Kommandanten. Der Seed legt bewusst *keine*
Zustellkanäle an, obwohl das die Beispieldaten hübscher machen würde. Wer eine
Wehr ungefragt anmeldet, hat binnen einer Woche eine Wehr, die die
Benachrichtigungen abschaltet, und danach kommen auch die wichtigen nicht mehr
an.
**Serientermine werden ausgeschrieben, nicht gerechnet.** Eine Regel wäre
weniger Zeilen in der Datenbank und mehr Sonderfälle im Code: der Übungsabend
fällt im August aus, im Dezember ist er eine Woche früher, und für jeden
einzelnen gibt es Zusagen und eine Anwesenheitsliste. Ausgeschrieben ist jede
dieser Ausnahmen einfach ein Datensatz, den man ändert. Der Preis sind hundert
Zeilen für zwei Jahre — Kleingeld, und die Zahl steht vor dem Anlegen in der
Maske.
**Gerechnet wird auf dem Kalender, nicht in Millisekunden.** „Monatlich" heißt
derselbe Tag im nächsten Monat. `setMonth(+1)` auf dem 31. Januar ergibt den
3. März, weil der Februar keinen 31. hat — ein Fehler, der in einer
Übungsserie erst im Frühjahr auffällt. Fällt der Tag im Zielmonat aus, rutscht
der Termin auf den letzten des Monats.
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird eine
Zeile geschrieben, sonst nichts. Der Worker nimmt sich, was auf WARTET steht.
Wären es eine Sache, hinge das Speichern eines Termins am Antwortverhalten von
Telegram. So ist die schlimmste Folge eines toten Mailservers eine
Benachrichtigung, die auf FEHLGESCHLAGEN steht und sichtbar ist.
**Intern bekommt den langen Text, extern die Kurzfassung.** Der Mailserver
einer Wehr ist ihr eigener oder der der Gemeinde, und ein selbst betriebener
ntfy steht im Gerätehaus — dorthin dürfen Namen, Gerätebezeichnungen und
Einsatznummern. Telegram und Web-Push laufen über fremde Server und bekommen
„3 Prüfungen fällig", nicht welche. Das steht in `KANAL_EXTERN` an einer
Stelle, wird im Test für *jeden* externen Kanal geprüft und in der Oberfläche
zweimal gesagt: neben dem Kanal und über dem Vorlagenfeld.
**Kein CalDAV, sondern ICS.** CalDAV ist ein Schreib-Lese-Protokoll mit
Rechteverwaltung und Konfliktauflösung. Gebraucht wird Lesen. Eine Adresse, die
eine ICS-Datei ausliefert, kann jeder Kalender — das Format ist eine Liste von
Zeilen, und die interessanten Teile (Zeitzone, Umbruch nach 75 Oktetten) stehen
in zwanzig Zeilen. Eine Abhängigkeit dafür wäre in einer Anwendung ohne
Netzzugang der schlechtere Tausch.
**Der Ausweis gilt fünf Minuten, die Anmeldemarke eine.** Beide sind mit
AUTH_SECRET signiert; was sie trennt, ist der Zweck im signierten Teil. Ohne
den ginge das eine als das andere durch, und der Ausweis wäre plötzlich ein
Anmeldeweg. Die fünf Minuten sind der Unterschied zwischen „wird gescannt" und
„wird vorgezeigt, abgelesen und weggesteckt" — und die Prüfseite nennt den
Zeitpunkt der Ausstellung, weil ein Ausweis, der „gültig" sagt ohne zu sagen
seit wann, wertlos ist.
**Fehlgeschlagene Benachrichtigungen werden nicht abgeräumt.** Der
Aufbewahrungslauf nimmt sich nur die zugestellten. Eine fehlgeschlagene ist der
Hinweis darauf, dass ein Kanal kaputt ist, und der verliert seinen Wert nicht
durch Zeitablauf.
**Erinnert wird um sieben, zugestellt jede Minute.** Eine Push-Meldung um drei
Uhr weckt Leute, die am nächsten Tag arbeiten müssen. Eine Absage am
Dienstagabend muss dagegen ankommen, solange sie noch etwas nützt.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Die Vorlagenmaske überschrieb den gerade getippten Text | Der Effekt, der den passenden Text lädt, hing an `vorlagen.data`. Wird die Liste im Hintergrund nachgeladen — nach dem Speichern, beim Zurückkommen ins Fenster —, war die Eingabe weg | Merker auf `ereignis:kanal`; geladen wird nur beim Wechsel der Auswahl. Gefunden hat es der E2E-Test, nicht das Ausprobieren |
| `personId` wurde zweimal gesetzt und überschrieb sich | Einschränkung und Ausschluss als zwei Objekt-Einträge im selben `where` — der zweite gewinnt still | Beide in eine Bedingung zusammengeführt |
| Der Aufräumlauf durfte nicht löschen | `REVOKE DELETE ON "benachrichtigung"` gilt der Anwendungsrolle, und `fuerOrganisation` benutzt genau die | Über `ohneMandantenfilter` mit ausdrücklichem Mandantenfilter in der Bedingung — begründet, wie es die Regel verlangt |
| Zwei E2E-Selektoren trafen zu viel | „Absagen" steckt auch in „Termin absagen"; `{titel}` steht im Textfeld *und* auf dem Platzhalterknopf | `exact: true` und Suche innerhalb des Dialogs |
| Der Warteliste-Test hing an der Reihenfolge im Seed | Ob der angemeldete Nutzer schon einen Platz hat, entscheidet die Mitgliedsnummer | Test prüft jetzt die Anzeige der Warteliste — die gilt für alle |
### Abweichung von der Vorgabe
**Kein CalDAV**, obwohl Phase-7.md „CalDAV-/ICS-Feed" nennt. Begründung oben;
sie steht auch im README und in der Roadmap.
**Termine einer Serie werden einzeln geändert, nicht „alle folgenden".** Der
Fall ist selten und mehrdeutig — meint „alle folgenden" auch die, die jemand
schon von Hand verschoben hat? Absagen geht dagegen für die ganze Serie, weil
das der Fall ist, der wirklich vorkommt (Sommerpause, Bauarbeiten am
Gerätehaus).
**Kein Push für iOS ohne Umweg.** Web-Push funktioniert auf dem iPhone erst,
wenn FLORIAN zum Startbildschirm hinzugefügt wurde. Das steht in der
Oberfläche; ein eigener Weg dafür wäre eine App im Store, und die gibt es
bewusst nicht.
### Nachweise
- `npx tsc --noEmit` — ohne Ausgabe
- `npm run test` — 422 Tests grün, davon 15 neue gegen eine echte Datenbank
- `npx playwright test tests/e2e/termine.spec.ts` — 17 grün
- `npm run build` — 50 Routen, darunter `/termine`, `/benachrichtigungen`,
`/ausweis` und die beiden öffentlichen Endpunkte
- `npm run db:seed` — 19 Termine, Warteliste und abgehakte Anwesenheit
- `docker compose config` — ntfy im Profil `push` prüft durch
---