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
+196 -4
View File
@@ -30,7 +30,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
| Phase 5 — Atemschutz (FwDV 7) | fertig |
| Phase 6 — Kartenmodule | fertig |
| Phase 7 — Benachrichtigungen und Termine | offen |
| Phase 7 — Benachrichtigungen und Termine | fertig |
| Phase 8 — Lokale KI | offen |
| Phase 9 — Kassenverwaltung | offen |
@@ -785,6 +785,194 @@ Verteiler.**
---
## Phase 7 — Benachrichtigungen und Termine
Der Satz, der über der ganzen Phase steht und der auch die Technik darunter
bestimmt: **Niemand wird ungefragt benachrichtigt.** Wer nichts einträgt,
bekommt nichts. Es gibt keine Voreinstellung, die stillschweigend alle
anmeldet — ein neu eingerichteter Zustellweg hat eine leere Ereignisliste, und
erst wer ankreuzt, wofür er ihn will, bekommt etwas.
### Der Terminkalender
Übungsabende, Lehrgänge, Arbeitsdienste, Sitzungen. Drei Entscheidungen prägen
ihn, und alle drei kommen aus dem Alltag einer Wehr:
**Anmeldung ist die Ausnahme, nicht die Regel.** Beim normalen Übungsabend
meldet sich niemand an, man kommt oder man kommt nicht. Erst beim Lehrgang mit
zwölf Plätzen und beim Ausflug mit Busbestellung wird eine Rückmeldung
verlangt.
**Abgesagt, nicht gelöscht.** Ein Termin, der ohne ein Wort verschwindet, führt
dazu, dass am Dienstagabend jemand vor dem verschlossenen Gerätehaus steht.
Eine Absage braucht einen Grund, bleibt im Kalender stehen und erreicht die,
die zugesagt hatten — nicht die ganze Wehr.
**Die Warteliste rückt von selbst nach.** Wer bei belegten Plätzen zusagt,
landet auf der Warteliste; sagt jemand ab, rückt der Erste nach und erfährt es.
Sonst führt jemand die Liste von Hand, und das tut niemand zuverlässig.
Serientermine werden **ausgeschrieben**, nicht als Regel gespeichert: jeder
Abend ist ein eigener Datensatz. Der Grund ist derselbe Alltag — 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. Mit einer Regel wäre jede
dieser Ausnahmen ein Sonderfall. Vor dem Anlegen steht in der Maske, wie viele
Termine daraus werden.
Die Anwesenheitsliste zeigt **alle aktiven Mitglieder**, nicht nur die
Angemeldeten: auf einem Blatt zum Abhaken muss jeder stehen. Zusage und
Anwesenheit sind getrennt — wer zugesagt hat und nicht kam, steht genau so in
der Liste.
### Der Kalender fürs Handy
Eine Adresse, die man im Handy oder im Mailprogramm abonniert; die Termine
stehen dann neben allem anderen, ohne dass jemand FLORIAN öffnen muss. Das ist
der Kanal mit der größten Wirkung pro Zeile Code in dieser Phase.
**Bewusst kein CalDAV.** Das ist ein Schreib-Lese-Protokoll mit
Rechteverwaltung und Konfliktauflösung; gebraucht wird hier nur Lesen, und dafür
genügt eine Adresse, die eine ICS-Datei ausliefert. Jeder Kalender kann das.
Die Adresse ist der Schlüssel — Kalenderprogramme können sich nicht anmelden,
sie rufen stumpf eine URL ab. Daraus folgt:
- Gespeichert wird nur der SHA-256 des Tokens; die Adresse steht **genau
einmal** auf dem Bildschirm.
- Jeder Zugang lässt sich einzeln widerrufen. Ein verlorenes Handy kostet einen
Klick, nicht ein neues Passwort für alle.
- Ausgeliefert werden **nur Termine** — keine Anmeldelisten, keine Personen
außer der Leitung, keine Mängel, keine Einsätze.
- Wer aus der Wehr austritt, verliert das Abonnement.
### Die vier Kanäle
| Kanal | Läuft über | Bekommt |
|---|---|---|
| **E-Mail** (SMTP) | den eigenen bzw. den Mailserver der Gemeinde | den ausführlichen Text |
| **ntfy** (selbst gehostet) | den eigenen Server, Profil `push` | den ausführlichen Text |
| **Telegram** | einen fremden Server | nur die Kurzfassung |
| **Web-Push** | den Push-Dienst des Browserherstellers | nur die Kurzfassung |
Die Spalte ganz rechts ist keine Bequemlichkeit, sondern die Regel: was das Netz
der Wehr verlässt, bekommt einen Hinweis ohne Namen, Gerätebezeichnungen und
Einsatzdetails — „3 Prüfungen fällig", nicht welche. In der Oberfläche steht das
neben jedem externen Kanal, und die Vorlagenmaske sagt es noch einmal, bevor
jemand einen Namen in eine Telegram-Vorlage tippt.
**ntfy** ist der interessanteste Weg für eine Wehr: ein Container, eine App im
Store, kein Konto, keine Telefonnummer, kein Telefonbuchabgleich. Man abonniert
ein Thema — einen frei gewählten Namen — und bekommt Meldungen darauf. Läuft
der Server im Gerätehaus, verlässt keine Nachricht das Haus. Der Preis: ein
Thema ist nur so geheim wie sein Name, deshalb schlägt FLORIAN Namen mit
Zufallsanteil vor.
```bash
docker compose --profile push up -d # ntfy startet auf Port 8086
npm run vapid # Schlüsselpaar für Web-Push
```
### Warum kein WhatsApp
Es gibt keine `WHATSAPP_`-Variablen, und das ist eine Entscheidung, keine
Lücke. Drei Gründe, in dieser Reihenfolge:
**Datenschutz.** Der einzige zulässige Weg für eine Organisation ist die
WhatsApp Business Platform. Nachrichten laufen über Server von Meta, und die
Wehr müsste einen Vertrag zur Auftragsverarbeitung schließen — für eine
Freiwillige Feuerwehr, deren Daten ausdrücklich im Gerätehaus bleiben sollen,
ist das der falsche Weg. Die gewöhnliche App scheidet ohnehin aus: sie gleicht
das Telefonbuch ab und gibt damit die Kontakte aller Mitglieder weiter.
**Kosten.** Abgerechnet wird je Konversation. Eine Wehr mit fünfzig Mitgliedern
und zwei Terminerinnerungen im Monat zahlt dauerhaft für etwas, das über den
eigenen Mailserver nichts kostet.
**Aufwand.** Business-Konto, verifizierte Telefonnummer, genehmigte
Nachrichtenvorlagen, ein Meta-Entwicklerkonto. Das ist mehr Verwaltung als der
gesamte Rest dieser Phase.
Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts
und verrät niemandem etwas.
### Der digitale Dienstausweis
Ein QR-Code auf dem Handy, den man an der Absperrung, beim Lehrgang oder bei
einer Kontrolle vorzeigt. Wer ihn scannt, landet auf einer Prüfseite und sieht
Name, Wehr, Funktion, Foto und die gültigen Qualifikationen — **ohne sich
anzumelden**, denn wer prüft, hat keinen Zugang zu FLORIAN.
- **Signiert, nicht gespeichert.** HMAC über Person, Wehr und Zeitstempel.
Keine Tabelle, kein Aufräumjob.
- **Fünf Minuten gültig.** Er wird vorgezeigt, abgelesen und weggesteckt; ein
abfotografierter Code ist danach wertlos. Die Prüfseite nennt deshalb den
Zeitpunkt der Ausstellung — ein Ausweis, der „gültig" sagt, ohne zu sagen
seit wann, ist wertlos.
- **Er öffnet nichts.** Die Prüfseite zeigt an, sie lässt nicht hinein. Wer den
Code hat, bekommt keinen Zugang zu einem einzigen Datensatz.
- Was **nicht** darauf steht: Anschrift, Geburtsdatum, Telefonnummer,
Mitgliedsnummer. Ein Ausweis weist aus; er ist keine Personalakte.
- Ein gültig signierter Code für jemanden, der ausgetreten ist, wird abgewiesen.
### Was von selbst benachrichtigt
Sechs Anlässe, an die vorhandenen Abläufe angehängt:
| Anlass | Wann | An wen |
|---|---|---|
| Prüfung fällig | im Erinnerungslauf | wer es abonniert hat |
| Mangel gemeldet | sofort | wer es abonniert hat |
| Qualifikation läuft ab | 90, 30 und 7 Tage vorher | die Person selbst |
| Neuer Termin | beim Anlegen | wer es abonniert hat |
| Terminerinnerung | 7 Tage und 1 Tag vorher | Angemeldete, sonst Abonnenten |
| Einsatzbericht freigegeben | bei der Freigabe | wer es abonniert hat |
Die Sammelmail an den zuständigen Gerätewart bleibt, wie sie war — sie geht an
den, der zuständig ist, unabhängig davon, ob er irgendwo ein Kreuzchen gesetzt
hat. Ein Prüftermin ist eine Pflicht, kein Abo. Wer sie ohnehin bekommt, wird
von der Benachrichtigung ausgenommen und hat sie damit nicht doppelt.
Erinnert wird um sieben Uhr morgens, nicht nachts: eine Push-Meldung um drei Uhr
weckt Leute, die am nächsten Tag arbeiten müssen. Zugestellt wird dagegen jede
Minute — eine Absage am Dienstagabend muss ankommen, solange sie noch etwas
nützt.
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird nur eine
Zeile geschrieben; der Worker schickt sie weg und vermerkt, wie es ausging.
Sonst hinge das Speichern eines Termins am Antwortverhalten von Telegram. Eine
fehlgeschlagene Zustellung bleibt sichtbar stehen und wird nicht nach Frist
abgeräumt — sie ist der Hinweis darauf, dass ein Kanal kaputt ist, und der
verliert seinen Wert nicht durch Zeitablauf.
Der Probeknopf steht direkt an jedem Zustellweg und läuft durch dieselbe
Maschinerie wie alles andere. Ohne ihn merkt jemand erst in drei Wochen, dass
sein ntfy-Thema einen Tippfehler hat — nämlich dann, wenn die Nachricht wichtig
gewesen wäre.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `NTFY_ENABLED` | `false` | Push über den eigenen ntfy |
| `NTFY_URL` | — | Adresse des eigenen ntfy, z. B. `http://ntfy:80` |
| `NTFY_TOKEN` | — | nur bei eingeschaltetem Zugriffsschutz |
| `NTFY_PORT` | `8086` | Port des ntfy-Containers (Profil `push`) |
| `WEBPUSH_ENABLED` | `false` | Push in den Browser |
| `WEBPUSH_PUBLIC_KEY` | — | aus `npm run vapid`; **ins Backup** |
| `WEBPUSH_PRIVATE_KEY` | — | dito |
| `WEBPUSH_KONTAKT` | `APP_URL` | Kontaktweg für den Push-Dienst |
| `BENACHRICHTIGUNG_LAUF` | `0 7 * * *` | wann erinnert wird |
| `TERMIN_ERINNERUNG_TAGE` | `7,1` | Vorlauf der Terminerinnerungen |
| `BENACHRICHTIGUNG_VERSUCHE` | `3` | Zustellversuche, bevor es stehen bleibt |
| `BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE` | `90` | Frist für **zugestellte** Nachrichten |
Ein neuer öffentlicher VAPID-Schlüssel macht alle bestehenden Push-Abos
wertlos — jeder müsste Push von Hand neu erlauben, und niemand merkt es, die
Meldungen bleiben einfach aus. Beide Schlüssel gehören deshalb ins Backup.
---
## Befehle
```bash
@@ -798,6 +986,7 @@ npm run db:migrate # ausstehende Migrationen anwenden
npm run db:seed # Beispielwehr einspielen
npm run db:studio # Prisma Studio
npm run screenshots # Bilder für docs/ANLEITUNG.md erzeugen
npm run vapid # VAPID-Schlüsselpaar für Web-Push erzeugen
```
## Dienste und Ports (Entwicklung)
@@ -836,9 +1025,12 @@ tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.
Schriften verwendet, die auf dem Gerät vorhanden sind.
- Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`,
`AUDIT_AUFBEWAHRUNG_TAGE`, `PERSON_ANONYMISIERUNG_TAGE`).
- Externe Dienste (Mail, Telegram, LLM) sitzen hinter Schnittstellen in
`src/lib/integrations/` und sind einzeln abschaltbar. Ohne Freischaltung
wird nur ins Log geschrieben.
- Externe Dienste (Mail, Telegram, ntfy, Web-Push, LLM) sitzen hinter
Schnittstellen in `src/lib/integrations/` und sind einzeln abschaltbar. Ohne
Freischaltung wird nur ins Log geschrieben.
- Über Kanäle, die das Netz der Wehr verlassen (Telegram, Web-Push), gehen nur
Kurzfassungen ohne Namen, Gerätebezeichnungen und Einsatzdetails.
- Kein WhatsApp — die Begründung steht oben unter „Warum kein WhatsApp".
## Sicherheitshinweis