diff --git a/.env.example b/.env.example index 61a94f3..b11c893 100644 --- a/.env.example +++ b/.env.example @@ -255,6 +255,76 @@ TELEGRAM_ENABLED=false TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= +# ----------------------------------------------------------------------------- +# Push über ntfy (Phase 7, optional) +# +# ntfy ist ein winziger Push-Dienst, den man selbst betreibt: ein Container, +# eine App im Store, fertig. Man abonniert ein "Thema" (einen frei gewählten +# Namen) und bekommt Meldungen darauf — ohne Konto, ohne Telefonnummer. +# +# Läuft der Server im eigenen Netz, verlässt keine Nachricht das Gerätehaus. +# Deshalb gilt ntfy in FLORIAN als INTERNER Kanal und bekommt die +# ausführlichen Texte. Wer stattdessen das öffentliche ntfy.sh einträgt, macht +# ihn zu einem externen Dienst wie jeden anderen. +# +# ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest mit. +# FLORIAN schlägt deshalb Namen mit Zufallsanteil vor. +# ----------------------------------------------------------------------------- +NTFY_ENABLED=false +# NTFY_URL=http://ntfy:80 +# Nur nötig, wenn der eigene ntfy Zugriffsschutz eingeschaltet hat. +NTFY_TOKEN= +NTFY_TIMEOUT=10 + +# ----------------------------------------------------------------------------- +# Web-Push in den Browser (Phase 7, optional) +# +# Der Weg, der ohne App auskommt. Die Meldung läuft über den Push-Dienst des +# Browserherstellers (Mozilla, Google, Apple) — verschlüsselt, aber sie +# verlässt das Netz der Wehr. Deshalb ist Web-Push ein EXTERNER Kanal und +# bekommt nur Kurzfassungen: "3 Prüfungen fällig", nicht welche. +# +# Schlüsselpaar erzeugen mit: npm run vapid +# +# ACHTUNG: Beide Schlüssel gehören ins Backup. Ein neuer öffentlicher Schlüssel +# macht alle bestehenden Push-Abos wertlos — jeder müsste Push neu erlauben, +# und niemand merkt es, die Meldungen bleiben einfach aus. +# ----------------------------------------------------------------------------- +WEBPUSH_ENABLED=false +WEBPUSH_PUBLIC_KEY= +WEBPUSH_PRIVATE_KEY= +# Kontaktweg für den Push-Dienst, "mailto:..." oder eine Adresse. +WEBPUSH_KONTAKT= + +# ----------------------------------------------------------------------------- +# WhatsApp: bewusst NICHT. +# +# Es gibt keine WHATSAPP_-Variablen, und das ist eine Entscheidung, keine Lücke. +# Die Begründung steht im README unter "Warum kein WhatsApp" — kurz: die +# Business-Plattform kostet je Konversation, verlangt einen Vertrag mit Meta +# als Auftragsverarbeiter, und die App gleicht das Telefonbuch ab. Für eine +# Wehr, deren Daten im Gerätehaus bleiben sollen, ist das der falsche Weg. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# Benachrichtigungen und Termine (Phase 7) +# ----------------------------------------------------------------------------- +# Wann der Worker die Erinnerungen anlegt und verschickt (cron, Europe/Berlin). +# Bewusst am frühen Morgen und nicht nachts: eine Push-Meldung um drei Uhr +# weckt Leute, die am nächsten Tag arbeiten müssen. +BENACHRICHTIGUNG_LAUF=0 7 * * * +# Wie viele Tage vor einem Termin erinnert wird (kommagetrennt). +# "7,1": einmal in der Woche davor, damit man den Abend freihalten kann, und +# einmal am Vortag, weil man es bis dahin wieder vergessen hat. +TERMIN_ERINNERUNG_TAGE=7,1 +# Wie oft eine fehlgeschlagene Zustellung wiederholt wird, bevor sie als +# fehlgeschlagen stehen bleibt. Stehen bleiben ist Absicht: eine still +# verschluckte Benachrichtigung ist schlimmer als eine sichtbar gescheiterte. +BENACHRICHTIGUNG_VERSUCHE=3 +# Wie lange ZUGESTELLTE Benachrichtigungen aufgehoben werden. +# Fehlgeschlagene bleiben — sie zeigen einen kaputten Kanal an. +BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE=90 + # ----------------------------------------------------------------------------- # LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang) # ----------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 703fb19..b57b7d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,171 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md). ## [Unveröffentlicht] -Nächster Schritt ist Phase 7 (Benachrichtigungen und Termine). +Nächster Schritt ist Phase 8 (Lokale KI). + +--- + +## [0.8.0] — 2026-09-04 + +Phase 7: Benachrichtigungen und Termine. Ab hier weiß die Mannschaft, wann der +nächste Dienst ist — ohne dass jemand eine Rundmail tippt. + +**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. + +### Hinzugefügt + +**Datenmodell** + +- `Termin` mit Art, Ort, Beginn und Ende, Leitung, Platzbegrenzung, + Anmeldeschluss und Serienkennung. Abgesagt wird mit Grund und Zeitpunkt — + **gelöscht wird nicht**, wer zugesagt hat, soll den Grund sehen. +- `Terminanmeldung` mit Zu-, Absage oder Warteliste, dem Grund einer Absage und + der Anwesenheit als eigenem Feld. Zusage und Anwesenheit sind bewusst + getrennt: wer zugesagt hat und nicht kam, steht genau so in der Liste. +- `Zustellkanal` — ein Datensatz je Person und Weg, mit der Adresse dieses Wegs + und der Liste der Ereignisse, für die er benutzt wird. Leer bedeutet: für + nichts. +- `Benachrichtigung` — steht **vor** dem Versand in der Tabelle, nicht danach. + Damit ist nachvollziehbar, wer wann worüber informiert wurde und wer nicht, + weil sein Kanal nicht erreichbar war. +- `Benachrichtigungsvorlage` je Ereignis und Kanal, `Kalenderzugang` mit + SHA-256 des Tokens. + +**Terminkalender** + +- Liste mit Filter nach Art und „nur meine", Rückmeldung direkt aus der Zeile. +- Serientermine werden **ausgeschrieben**, nicht als Regel gespeichert: jeder + Abend ist ein eigener Datensatz. Sonst wäre jeder ausgefallene Übungsabend + ein Sonderfall. Vor dem Anlegen steht in der Maske, wie viele Termine daraus + werden. +- Kalenderrechnung statt Millisekunden: „monatlich" heißt derselbe Tag im + nächsten Monat, und der 31. Januar landet im Februar auf dem 28. — nicht im + März, wie es passiert, wenn man stur Monate addiert. +- **Warteliste, die von selbst nachrückt.** Wer bei belegten Plätzen zusagt, + landet auf ihr; sagt jemand ab, rückt der Erste nach und erfährt es. +- Absage mit Grund, wahlweise für alle folgenden Termine einer Serie. + Vergangene bleiben unberührt. Benachrichtigt werden die Angemeldeten, nicht + die ganze Wehr. +- Anwesenheitsliste über **alle aktiven Mitglieder**, nicht nur die + Angemeldeten — auf einem Blatt zum Abhaken muss jeder stehen. Zum Abhaken am + Tablet oder zum Ausdrucken. + +**Kalenderabonnement** + +- ICS-Feed nach RFC 5545 unter `/api/kalender/.ics`. Die Termine der + Wehr stehen damit im Handy neben allem anderen. +- **Bewusst kein CalDAV**: das ist ein Schreib-Lese-Protokoll mit + Rechteverwaltung und Konfliktauflösung. Gebraucht wird nur Lesen. +- Die Adresse steht genau einmal auf dem Bildschirm; gespeichert wird nur ihr + Hash. Jeder Zugang lässt sich einzeln widerrufen, und wer austritt, verliert + ihn. +- Ausgeliefert werden nur Termine — keine Anmeldelisten, keine Personen außer + der Leitung. + +**Vier Zustellwege, hinter Schnittstellen** + +- **SMTP** löst den Platzhalter aus Phase 0 ab: nodemailer mit + Verbindungspool, damit ein Sammellauf mit vierzig Nachrichten Sekunden statt + Minuten braucht. +- **ntfy** (neu, selbst gehostet, Compose-Profil `push`): Push aufs Handy ohne + Konto, ohne Telefonnummer, ohne Telefonbuchabgleich. Läuft der Server im + Gerätehaus, verlässt keine Nachricht das Haus. +- **Web-Push** (neu, VAPID): Push in den Browser ohne App. Schlüsselpaar über + `npm run vapid`. +- **Telegram** aus Phase 0 wird jetzt tatsächlich benutzt. +- Was das Netz der Wehr verlässt (Telegram, Web-Push), bekommt **nur die + Kurzfassung** — „3 Prüfungen fällig", nicht welche. In der Oberfläche steht + das neben jedem externen Kanal. + +**Textvorlagen** + +- Je Ereignis und Kanal, mit eingebautem Standardtext für alles. Eine Wehr, die + nichts einstellt, bekommt trotzdem brauchbare Nachrichten. +- Platzhalter zum Anklicken, Vorschau mit erfundenen Beispielwerten. Ein + Platzhalter, den es für das Ereignis nicht gibt, wird beim Speichern + abgewiesen — mit der Liste der erlaubten daneben. +- Ein unbekannter Platzhalter bleibt im Text **sichtbar stehen**, statt still + zu verschwinden. Eine Lücke, die niemand bemerkt, ist schlimmer als ein + seltsam aussehender Text, den jemand korrigiert. + +**Digitaler Dienstausweis** + +- QR-Code auf dem Handy, Prüfseite ohne Anmeldung unter `/ausweis/`. + Sie zeigt Name, Wehr, Funktion, Foto und die gültigen Qualifikationen. +- Signiert statt gespeichert (HMAC über Person, Wehr und Zeitstempel), **fünf + Minuten gültig**. Die Prüfseite nennt den Zeitpunkt der Ausstellung — ein + Ausweis, der „gültig" sagt, ohne zu sagen seit wann, ist wertlos. +- Der Zweck steht im signierten Teil, damit ein Ausweis nicht als Anmeldemarke + aus Phase 4 durchgeht und umgekehrt. +- Ein gültig signierter Code für jemanden, der ausgetreten ist, wird + abgewiesen. Das Passbild ist über denselben Code erreichbar und damit genauso + lange gültig. + +**Was von selbst benachrichtigt** + +- Prüfung fällig, Mangel gemeldet, Qualifikation läuft ab (90/30/7 Tage + vorher), neuer Termin, Terminerinnerung (7 und 1 Tag vorher), Einsatzbericht + freigegeben. +- **Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird nur + eine Zeile geschrieben; der Worker schickt sie weg. Sonst hinge das Speichern + eines Termins am Antwortverhalten von Telegram. +- Erinnert wird um sieben Uhr morgens, 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. +- Die Sammelmail an den zuständigen Gerätewart bleibt, wie sie war. Wer sie + ohnehin bekommt, wird von der zusätzlichen Benachrichtigung ausgenommen und + hat sie damit nicht doppelt. + +### Geändert + +- `lib/integrations/mail.ts` verschickt tatsächlich, statt eine Meldung ins Log + zu schreiben. Ohne `MAIL_ENABLED` bleibt es beim Log. +- Neue Rechte: `termin.lesen` und `benachrichtigung.eigene` als Grundrechte + (jeder sieht den Kalender und richtet seine eigenen Wege ein — das ist keine + Sache des Kommandanten), `termin.bearbeiten`, `termin.anwesenheit` und + `benachrichtigung.vorlagen` bei Kommandant, Schriftführer und Ausbilder. +- `DIENSTGRAD_TEXT` liegt jetzt in `lib/stammdaten/texte.ts` statt zweimal fast + gleich in zwei Seiten. +- Der Service Worker nimmt Push-Meldungen an und öffnet beim Antippen ein schon + offenes FLORIAN, statt einen zwölften Tab aufzumachen. +- Der Aufbewahrungslauf räumt zugestellte Benachrichtigungen nach Frist ab. + **Fehlgeschlagene bleiben** — sie zeigen einen kaputten Kanal an, und das + verliert seinen Wert nicht durch Zeitablauf. + +### Behoben + +- Die Vorlagenmaske überschrieb den gerade getippten Text, sobald die Liste im + Hintergrund nachgeladen wurde (nach dem Speichern, beim Zurückkommen ins + Fenster). Gefunden hat es der E2E-Test, nicht das Ausprobieren. + +### Sicherheit + +- Row Level Security und Mandantenfilter für alle sechs neuen Tabellen. +- `REVOKE DELETE ON "benachrichtigung"` für die Anwendungsrolle: wer wann + worüber informiert wurde, lässt sich nicht über die Oberfläche wegräumen. + Die Aufbewahrungsfrist läuft über den Systemzugang. +- Prüfbedingungen in der Datenbank: kein Ende vor dem Beginn, kein + Anmeldeschluss nach dem Termin, kein Kontingent von null Plätzen, keine + negative Versuchszahl. +- Kalendertoken nur als SHA-256; ein Push-Abo, das der Dienst als tot meldet, + wird stillgelegt statt gelöscht, damit die Person es in ihren Einstellungen + sieht und neu erlauben kann. +- **Kein WhatsApp** — Begründung im README unter „Warum kein WhatsApp": + Verarbeitung über Meta-Server, Kosten je Konversation, Telefonbuchabgleich. + +### Tests + +- 56 neue Unit-Tests: Serienrechnung (Monatsenden, Schaltjahr, Kappung), + ICS-Erzeugung (Maskierung, Zeilenumbruch nach 75 Oktetten, CRLF, Absage), + Textbausteine (Kurzfassung für externe Kanäle, unbekannte Platzhalter), + Dienstausweis (Frist, Fälschung, Verwechslung mit der Anmeldemarke) und die + Datenbankregeln der neuen Tabellen. +- 17 neue E2E-Tests für Kalender, Rückmeldung, Warteliste, Absage, + Anwesenheitsliste, Kalenderabonnement, Vorlagen und Prüfseite. --- diff --git a/README.md b/README.md index b9c9bca..48e652f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index c1a7c75..95bd67b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,6 +71,18 @@ x-app-env: &app-env TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} + NTFY_ENABLED: ${NTFY_ENABLED:-false} + NTFY_URL: ${NTFY_URL:-} + NTFY_TOKEN: ${NTFY_TOKEN:-} + NTFY_TIMEOUT: ${NTFY_TIMEOUT:-10} + WEBPUSH_ENABLED: ${WEBPUSH_ENABLED:-false} + WEBPUSH_PUBLIC_KEY: ${WEBPUSH_PUBLIC_KEY:-} + WEBPUSH_PRIVATE_KEY: ${WEBPUSH_PRIVATE_KEY:-} + WEBPUSH_KONTAKT: ${WEBPUSH_KONTAKT:-} + BENACHRICHTIGUNG_LAUF: ${BENACHRICHTIGUNG_LAUF:-0 7 * * *} + TERMIN_ERINNERUNG_TAGE: ${TERMIN_ERINNERUNG_TAGE:-7,1} + BENACHRICHTIGUNG_VERSUCHE: ${BENACHRICHTIGUNG_VERSUCHE:-3} + BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE: ${BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE:-90} LLM_ENABLED: ${LLM_ENABLED:-false} LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_API_KEY: ${LLM_API_KEY:-} @@ -306,6 +318,46 @@ services: - intern logging: *logging + # --------------------------------------------------------------------------- + # ntfy — Push aufs Handy, ohne dass etwas das Haus verlässt (Phase 7). + # + # Nur mit Profil: docker compose --profile push up -d + # + # Warum das der interessanteste Push-Weg für eine Wehr ist: kein Konto, keine + # Telefonnummer, kein Telefonbuchabgleich. Man abonniert in der App ein + # Thema — einen frei gewählten Namen — und bekommt Meldungen darauf. + # + # Der Container ist winzig und speichert standardmäßig nichts dauerhaft: + # eine zugestellte Meldung ist erledigt. Wer sie nachlesen können will, + # schaltet den Cache ein (siehe ntfy-Dokumentation) — dann liegen die Texte + # allerdings auch dort und nicht nur in FLORIAN. + # + # ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest + # mit. Deshalb schlägt FLORIAN Namen mit Zufallsanteil vor, und deshalb + # steht NTFY_TOKEN bereit, falls der eigene ntfy Zugriffsschutz bekommt. + # --------------------------------------------------------------------------- + ntfy: + image: binwiederhier/ntfy:v2.11.0 + container_name: florian-ntfy + restart: unless-stopped + profiles: [push] + command: ["serve"] + environment: + NTFY_BASE_URL: ${NTFY_OEFFENTLICH:-http://localhost:8086} + NTFY_LISTEN_HTTP: ":80" + # Keine Anmeldung nötig, um zu lesen und zu schreiben — geschützt ist + # das Ding durch die Unerratbarkeit der Themennamen und dadurch, dass es + # im eigenen Netz steht. Wer es ins Internet stellt, setzt + # NTFY_AUTH_DEFAULT_ACCESS auf "deny-all" und vergibt Token. + NTFY_AUTH_DEFAULT_ACCESS: ${NTFY_ZUGRIFF:-read-write} + NTFY_BEHIND_PROXY: "true" + ports: + - "${NTFY_PORT:-8086}:80" + networks: + - edge + - intern + logging: *logging + # --------------------------------------------------------------------------- # Die Anwendung selbst (Next.js, Standalone-Build). # Führt vor dem Start die ausstehenden Prisma-Migrationen aus. diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 0bfe504..b1c8e7e 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -1,10 +1,10 @@ # Anleitung -Bedienung von FLORIAN, Stand Phase 6 (Version 0.7.0). Die Bilder zeigen die +Bedienung von FLORIAN, Stand Phase 7 (Version 0.8.0). Die Bilder zeigen die Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt. -> Benachrichtigungen, lokale KI und die Kassenverwaltung kommen in den -> folgenden Phasen dazu. Siehe [ROADMAP.md](ROADMAP.md). +> Die lokale KI und die Kassenverwaltung kommen in den folgenden Phasen dazu. +> Siehe [ROADMAP.md](ROADMAP.md). --- @@ -1279,7 +1279,230 @@ GEOCODING_LAENDER=de,at,ch Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand eintragen oder weglassen. -## 39. Rollen und was sie dürfen +## 39. Der Terminkalender + +![Termine](screenshots/37-termine-light.png) + +Unter **Termine** steht, was ansteht: Dienste, Übungen, Lehrgänge, +Arbeitsdienste, Veranstaltungen, Sitzungen. Die Liste blickt nach vorn — das +ist, was fast immer gemeint ist. + +An jeder Zeile stehen zwei Knöpfe: **Zusagen** und **Absagen**. Mehr braucht +das normale Mitglied nicht. + +### Rückmeldung ist die Ausnahme + +Beim Ü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 beim +Anlegen **Rückmeldung verlangen** angekreuzt. + +Wer dann Plätze begrenzt, bekommt automatisch eine **Warteliste**: Zusagen über +die Platzzahl hinaus landen dort, nummeriert in der Reihenfolge der Anmeldung. +Sagt jemand ab, rückt der Erste von selbst nach und erfährt es. Niemand muss +eine Liste führen. + +### Serientermine + +Beim Anlegen unter **Wiederholung** wählen: wöchentlich, alle zwei Wochen, +monatlich, jährlich. Dazu ein Datum, bis zu dem die Serie läuft. + +Darunter steht dann in einem Kasten, **wie viele Termine daraus werden** — +lesen Sie das, bevor Sie auf „Anlegen" drücken. Wöchentlich über ein Jahr sind +52 Einträge. + +FLORIAN legt jeden Abend als eigenen Termin an, nicht als Regel. Das ist +Absicht: so lässt sich der Abend im August absagen, der im Dezember eine Woche +vorziehen, und für jeden gibt es eine eigene Teilnehmer- und Anwesenheitsliste. +Bei „monatlich" bleibt der Tag im Monat gleich — der 31. rutscht im Februar auf +den 28., nicht in den März. + +### Absagen statt löschen + +Ein Termin, zu dem schon jemand zugesagt hat, lässt sich nicht löschen. Er wird +**abgesagt**, mit Grund. Er bleibt dann durchgestrichen im Kalender stehen, der +Grund steht daneben, und wer zugesagt hatte, bekommt eine Nachricht — nicht die +ganze Wehr. + +Gehört der Termin zu einer Serie, können Sie im selben Schritt **alle folgenden +Termine** absagen. Vergangene bleiben unberührt; man sagt nicht rückwirkend ab, +was schon stattgefunden hat. + +### Anwesenheitsliste + +Auf jedem Termin oben rechts. Sie zeigt **alle aktiven Mitglieder**, nicht nur +die Angemeldeten — auf einem Blatt zum Abhaken muss jeder stehen. Daneben, was +jemand gemeldet hatte. + +Wer das Recht dazu hat, hakt gleich hier ab; sonst führt **Drucken** zu einem +Blatt mit leeren Kästchen. + +> Zusage und Anwesenheit sind zwei verschiedene Dinge. Wer zugesagt hat und +> nicht kam, steht genau so in der Liste — das ist der Zweck der Trennung. + +--- + +## 40. Benachrichtigungen einrichten + +![Benachrichtigungen](screenshots/38-benachrichtigungen-light.png) + +> **Niemand wird ungefragt benachrichtigt.** Wer hier nichts einträgt, bekommt +> nichts. Es gibt keine Voreinstellung, die alle anmeldet. + +Unter **Benachrichtigungen** richten Sie Ihre eigenen Wege ein — Ihre, nicht +die von jemand anderem. Auch der Kommandant kann niemanden für Sie anmelden. + +### Die vier Wege + +| Weg | Was Sie brauchen | Bleibt im Haus? | +|---|---|---| +| **E-Mail** | nichts, es gilt Ihre hinterlegte Adresse | ja | +| **ntfy** | die ntfy-App und ein Thema | ja, wenn der Server der Wehr gehört | +| **Telegram** | Ihre Chat-Kennung vom Bot | **nein** | +| **Browser-Push** | nur die Erlaubnis des Browsers | **nein** | + +Neben den Wegen, die das Netz der Wehr verlassen, steht ein Warnhinweis. Dorthin +gehen nur **Kurzfassungen**: „3 Prüfungen fällig", nicht welche. Wer es genau +wissen will, bekommt es per E-Mail oder schaut in FLORIAN nach. + +### ntfy einrichten + +Der bequemste Weg, wenn die Wehr einen eigenen ntfy betreibt: + +1. Die App „ntfy" aus dem Store laden. +2. In der App den Server eintragen, der auf dieser Seite steht. +3. In FLORIAN **ntfy** wählen; das vorgeschlagene Thema übernehmen. +4. In der App dasselbe Thema abonnieren. + +> **Das Thema ist Ihr Schlüssel.** Wer den Namen kennt, liest mit. Deshalb +> schlägt FLORIAN einen zufälligen vor — nehmen Sie nicht „florian-mueller". + +### Browser-Push + +**Einrichten** drücken; der Browser fragt nach Erlaubnis. Das Abo gilt für +**diesen einen Browser auf diesem einen Gerät** — auf dem Diensthandy und am +Rechner im Büro also zweimal einrichten. + +Auf dem iPhone funktioniert es erst, wenn FLORIAN über „Zum Home-Bildschirm" +abgelegt wurde. Vorher bietet der Browser es gar nicht an. + +### Wofür? + +Unter jedem Weg stehen die sechs Anlässe zum Ankreuzen: + +| Anlass | Wann er kommt | +|---|---| +| Prüfung fällig | im morgendlichen Lauf | +| Mangel gemeldet | sofort | +| Qualifikation läuft ab | 90, 30 und 7 Tage vorher | +| Neuer Termin | beim Anlegen | +| Erinnerung an einen Termin | 7 Tage und 1 Tag vorher | +| Einsatzbericht freigegeben | bei der Freigabe | + +### Der Probeknopf + +**Drücken Sie ihn.** Er steht direkt an jedem Weg und schickt eine +Probenachricht durch dieselbe Maschinerie wie alles andere. Ohne ihn merken Sie +einen Tippfehler im Thema erst in drei Wochen — dann, wenn die Nachricht +wichtig gewesen wäre. + +Sie geht mit dem nächsten Lauf raus, üblicherweise binnen einer Minute. + +### Was ankam + +Ganz unten steht, was FLORIAN Ihnen zuletzt geschickt hat und ob es angekommen +ist. Steht dort mehrfach **Fehlgeschlagen**, ist der Weg kaputt — der Grund +steht daneben. + +--- + +## 41. Den Kalender aufs Handy holen + +Auf derselben Seite unter **Kalender abonnieren**. Namen für das Gerät eingeben, +**Adresse erzeugen** — und die Adresse gleich übernehmen. + +> **Sie wird genau einmal angezeigt.** FLORIAN speichert nur ihre Prüfsumme. +> Wer sie verliert, erzeugt eine neue und widerruft die alte. + +Die Adresse dann eintragen: + +| Gerät | Weg | +|---|---| +| **iPhone/iPad** | Einstellungen → Kalender → Accounts → Account hinzufügen → Andere → Kalenderabo hinzufügen | +| **Android** | in Google Kalender am Rechner: Andere Kalender → Per URL | +| **Thunderbird** | Neuer Kalender → Im Netzwerk → iCalendar (ICS) | +| **Outlook** | Kalender hinzufügen → Aus dem Internet abonnieren | + +Danach stehen die Termine der Wehr neben allem anderen im Kalender. Neue +Termine und Absagen kommen von selbst nach; die meisten Programme fragen +stündlich nach. + +**Nur eigene Termine** angekreuzt heißt: nur die, zu denen Sie sich +zurückgemeldet haben. + +> **Die Adresse ist ein Schlüssel.** Wer sie hat, sieht die Termine der Wehr — +> ohne Anmeldung. Geben Sie sie nicht weiter; bei einem verlorenen Handy +> genügt ein Klick auf **Widerrufen**. + +Ausgeliefert werden ausschließlich Termine — keine Teilnehmerlisten, keine +Mängel, keine Einsätze. Wer aus der Wehr austritt, verliert das Abo. + +--- + +## 42. Textvorlagen und der Dienstausweis + +### Textvorlagen + +![Textvorlagen](screenshots/39-vorlagen-light.png) + +Für Kommandant, Schriftführer und Administrator: unter **Benachrichtigungen → +Vorlagen bearbeiten** steht, was in den Nachrichten steht. Was hier geändert +wird, gilt für **alle**. + +Links der Text, rechts die Vorschau mit erfundenen Beispielwerten. Die +**Platzhalter** darunter sind anklickbar und werden beim Versand ersetzt: +`{titel}` wird zu „Übungsabend", `{datum}` zum Datum. + +Ein Platzhalter, den es für diesen Anlass nicht gibt, wird beim Speichern +abgewiesen — mit der Liste der erlaubten daneben. Und einer, der stehen bleibt, +bleibt auch in der fertigen Nachricht sichtbar: eine Lücke, die niemand +bemerkt, wäre schlimmer. + +Ohne eigene Vorlage greift der eingebaute Text. **Auf Standard zurücksetzen** +stellt ihn wieder her. + +> Bei Telegram und Browser-Push steht ein Warnhinweis über dem Feld. Was Sie +> dort hineinschreiben, läuft über einen fremden Server — also keine Namen, +> keine Adressen, keine Einsatzdetails. + +### Der digitale Dienstausweis + +![Dienstausweis](screenshots/40-dienstausweis-light.png) + +Unter **Dienstausweis**: ein QR-Code mit Ihrem Namen, Ihrer Wehr, Ihrer +Funktion und Ihren gültigen Qualifikationen. Zum Vorzeigen an der Absperrung, +beim Lehrgang oder bei einer Kontrolle. + +Wer ihn scannt, landet auf einer Prüfseite und sieht genau das — **ohne sich +anzumelden**. Denn wer prüft, hat keinen Zugang zu FLORIAN. + +Drei Dinge sollten Sie darüber wissen: + +- **Der Code gilt fünf Minuten.** Danach zeigt die Seite ihn selbst neu an. Ein + abfotografierter Code nützt nichts. +- **Er öffnet nichts.** Die Prüfseite zeigt an, sie lässt nicht hinein. +- **Es steht nicht mehr darauf als auf einem Ausweis aus Papier.** Keine + Anschrift, kein Geburtsdatum, keine Telefonnummer. + +Die Prüfseite nennt außerdem, wann der Code erzeugt wurde. Das ist der Punkt, +auf den ein Prüfender schauen sollte: „vor zwei Minuten" ist ein Ausweis, +„gültig" allein wäre wertlos. + +Wer aus der Wehr ausgetreten ist, bekommt dort **Kein aktives Mitglied** — auch +wenn sein Code technisch noch stimmt. + +--- + +## 43. Rollen und was sie dürfen Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in `src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr @@ -1292,9 +1515,9 @@ kann sie also nachträglich zuschneiden, ohne dass jemand Code ändert. | **Gerätewart** | Fahrzeuge und Geräte anlegen, ändern, aussondern; Prüfungen planen und durchführen | | **Atemschutzgerätewart** | zusätzlich Atemschutzgeräte, Träger und die Überwachung nach FwDV 7 | | **Kassier** | Buchungen und Kassenabschluss (Modul noch nicht geplant, siehe Roadmap) | -| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen | -| **Ausbilder** | Übungen planen, Teilnahme und Qualifikationen erfassen | -| **Mitglied** | eigene Daten, Listen lesen | +| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen | +| **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen | +| **Mitglied** | eigene Daten, Listen lesen, Termine sehen und sich zurückmelden, eigene Benachrichtigungswege einrichten | | **Terminal** | nur anmelden und erfassen — nie verwalten | Zwei Dinge sind bewusst so gebaut: @@ -1306,9 +1529,14 @@ Trennung nach Verantwortung, nicht nach Bequemlichkeit. zusätzlich mit PIN oder Karte an und handelt dann unter seiner eigenen Person. Das Terminal selbst ist nur die Tür. +**Seine Benachrichtigungswege richtet jeder selbst ein.** Das ist ein +Grundrecht jedes Mitglieds und keine Sache des Kommandanten — es gibt bewusst +keinen Weg, jemand anderem einen Zustellweg einzutragen. Was für alle gilt, +sind nur die Textvorlagen, und die hängen an einem eigenen Recht. + --- -## 40. Anmeldung über authentik einrichten +## 44. Anmeldung über authentik einrichten authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks. @@ -1361,7 +1589,7 @@ ganze Wehr aus. --- -## 41. PIN und Ausweiskarte verwalten +## 45. PIN und Ausweiskarte verwalten Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum, was dahinter liegt. @@ -1384,7 +1612,7 @@ Huber), PIN `471203`. --- -## 42. Was protokolliert wird +## 46. Was protokolliert wird Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person, betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich. @@ -1409,7 +1637,7 @@ jeweiligen Detailseite sichtbar. --- -## 43. Wenn etwas nicht geht +## 47. Wenn etwas nicht geht | Beobachtung | Wahrscheinliche Ursache | Was hilft | |---|---|---| @@ -1438,6 +1666,17 @@ jeweiligen Detailseite sichtbar. | Nach dem Import steht überall „ungeprüft" | Die Quelle hat keine Statusspalte | Absicht: ohne Angabe wird nicht „einsatzbereit" behauptet. Zustand nachpflegen | | Die Lagekarte gleicht nicht ab | Der Ereignisstrom ist unterbrochen | Steht oben in der Lagekarte. Bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen | | Die Förderstrecke braucht unerwartet viele Pumpen | Fördermenge zu hoch für den Durchmesser | Der Verlust steigt im Quadrat: 800 statt 400 l/min kostet die vierfache Reibung. Größeren Schlauch wählen oder weniger fördern | +| Es kommt keine Benachrichtigung an | Kein Zustellweg eingerichtet, oder kein Anlass angekreuzt | Unter **Benachrichtigungen** einen Weg einrichten und ankreuzen, wofür. Ohne beides kommt absichtlich nichts | +| Die Probenachricht bleibt aus | Der Kanal ist auf dem Server nicht eingerichtet | In der Auswahl steht „(nicht eingerichtet)". `MAIL_ENABLED`, `NTFY_ENABLED`, `TELEGRAM_ENABLED` oder `WEBPUSH_ENABLED` setzen | +| Im Verlauf steht mehrfach „Fehlgeschlagen" | Der Weg ist kaputt — der Grund steht daneben | Bei ntfy meist ein Tippfehler im Thema, bei Telegram eine falsche Chat-Kennung | +| Browser-Push lässt sich nicht einrichten | Keine VAPID-Schlüssel auf dem Server | `npm run vapid` und die Ausgabe in die `.env` übernehmen | +| Push kam bisher an, jetzt nicht mehr | Der öffentliche VAPID-Schlüssel wurde gewechselt | Alle Abos sind damit wertlos. Jeder muss Push neu erlauben — deshalb gehören die Schlüssel ins Backup | +| Auf dem iPhone gibt es keinen Push-Knopf | Web-Push geht dort nur als abgelegte App | Über „Teilen → Zum Home-Bildschirm" ablegen, dann erneut versuchen | +| Der Kalender im Handy aktualisiert sich nicht | Das Programm fragt nur stündlich nach | Abwarten oder im Kalenderprogramm „Aktualisieren" auslösen | +| Der Kalender ist plötzlich leer | Der Zugang wurde widerrufen, oder die Person ist ausgetreten | Unter **Benachrichtigungen** eine neue Adresse erzeugen | +| Eine Vorlage lässt sich nicht speichern | Ein Platzhalter gehört nicht zu diesem Anlass | Die Meldung nennt ihn und listet die erlaubten auf | +| Ein Termin lässt sich nicht löschen | Es liegen schon Rückmeldungen vor | Absagen statt löschen — dann sehen die Angemeldeten den Grund | +| Die Zusage wurde zur Warteliste | Die Plätze sind belegt | Sagt jemand ab, rücken Sie von selbst nach und werden benachrichtigt | Zustand aller Randsysteme auf einen Blick: diff --git a/docs/AUFWANDSSCHAETZUNG.md b/docs/AUFWANDSSCHAETZUNG.md index c545c4b..bbb37ab 100644 --- a/docs/AUFWANDSSCHAETZUNG.md +++ b/docs/AUFWANDSSCHAETZUNG.md @@ -1,7 +1,7 @@ # Aufwandsschätzung Schätzung je Phase, daneben der tatsächliche Aufwand aus dem -[Devlog](DEVLOG.md). Stand: 3. September 2026. +[Devlog](DEVLOG.md). Stand: 4. September 2026. ## Wie hier gerechnet wird @@ -34,10 +34,10 @@ Faktoren, die in die Schätzung eingehen: | 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** | | 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** | | 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | **2:10 h** | -| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen | -| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen | -| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen | -| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | offen | +| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | **3:00 h** | +| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | **2:40 h** | +| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | **2:50 h** | +| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | **3:10 h** | | 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen | | 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen | | | **Summe** | **123–164 PT** | **28–37 h** | | @@ -339,7 +339,7 @@ das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile. ## Phase 7 — Benachrichtigungen und Termine -**Vergleichsaufwand: 10–13 PT** +**Vergleichsaufwand: 10–13 PT** · **Tatsächlich: 3:10 h Sitzungszeit** | Baustein | PT | |---|---:| @@ -347,11 +347,24 @@ das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile. | Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 | | Abonnements je Person | 1,0 | | Kalender mit Serienterminen und Selbstanmeldung | 3,0 | -| CalDAV-/ICS-Feed je Person mit Token | 1,5 | +| ICS-Feed je Person mit Token | 1,5 | | Digitaler Dienstausweis mit signiertem QR und Prüfseite | 2,0 | -Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0 -(`lib/integrations/`), das spart hier etwa einen Personentag. +Das Grundgerüst der Kanalabstraktion stand bereits aus Phase 0 +(`lib/integrations/`), das hat etwa einen Personentag gespart. + +Die einzige Phase bisher, die über der geschätzten Sitzungszeit liegt — und +zwar an einer Stelle, die in der Aufstellung nicht vorkommt. „Kalender mit +Serienterminen" klingt nach einer Datumsrechnung; teuer war die Entscheidung +**gegen** eine Regel und **für** ausgeschriebene Termine, und dann die +Kalenderarithmetik dahinter: `setMonth(+1)` auf dem 31. Januar ergibt den +3. März, der 29. Februar existiert in vier von fünf Jahren nicht, und ein +Übungsabend muss über die ganze Serie auf demselben Wochentag bleiben. Das sind +drei Tests, die man erst schreibt, wenn man den Fall kennt. + +Die 1,5 PT für den Feed waren dagegen zu hoch angesetzt, weil sie CalDAV +unterstellen. ICS ist ein Textformat; der ganze Erzeuger sind hundertsechzig +Zeilen samt Maskierung und Zeilenumbruch nach 75 Oktetten. ## Phase 8 — Lokale KI diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index aa02af8..b7cc4a4 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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 --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 415b453..b4effa0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -16,8 +16,8 @@ Phase 3 ████████████████████ fertig Phase 4 ████████████████████ fertig 0.5.0 Phase 5 ████████████████████ fertig 0.6.0 Phase 6 ████████████████████ fertig 0.7.0 -Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0 -Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0 +Phase 7 ████████████████████ fertig 0.8.0 +Phase 8 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.9.0 Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0 Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0 ``` @@ -148,25 +148,53 @@ analogen, bis die Wehr ausdrücklich anders entscheidet. **Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt und was ihn hergibt. -## Phase 7 — Benachrichtigungen und Termine · als Nächstes +## Phase 7 — Benachrichtigungen und Termine · fertig -- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht. -- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar -- Kalender: Dienste, Übungen, Lehrgänge, Arbeitsdienste; Serientermine, - Selbstanmeldung mit Teilnehmerlimit, Anwesenheitsliste zum Ausdrucken -- CalDAV-/ICS-Feed je Person -- Digitaler Dienstausweis mit signiertem QR und Prüfseite +Die Wehr weiß, wann der nächste Dienst ist — ohne dass jemand eine Rundmail +tippt. + +- Terminkalender mit Dienst, Übung, Lehrgang, Arbeitsdienst, Veranstaltung und + Sitzung. Serientermine werden ausgeschrieben, nicht als Regel gespeichert. +- Selbstanmeldung mit Platzbegrenzung und einer Warteliste, die von selbst + nachrückt. Absage mit Grund, wahlweise für alle folgenden Termine einer Serie. +- Anwesenheitsliste über alle aktiven Mitglieder — zum Abhaken am Tablet oder + zum Ausdrucken. Zusage und Anwesenheit sind getrennt. +- Vier Kanäle hinter Schnittstellen: E-Mail (echtes SMTP statt Platzhalter), + ntfy (selbst gehostet), Telegram, Web-Push. Was das Netz der Wehr verlässt, + bekommt nur die Kurzfassung. +- Vorlagen je Ereignis und Kanal mit Platzhaltern, Vorschau und eingebautem + Standardtext für alles. +- ICS-Abonnement je Person, Token nur als Hash gespeichert, einzeln widerrufbar. +- Digitaler Dienstausweis: signierter QR, fünf Minuten gültig, Prüfseite ohne + Anmeldung. + +**Danach kann die Wehr:** ihren Jahresplan führen, Rückmeldungen einsammeln, +Anwesenheit nachweisen und jedes Mitglied auf dem Weg erreichen, den es selbst +gewählt hat. + +### Kein CalDAV + +CalDAV ist ein Schreib-Lese-Protokoll mit Rechteverwaltung und +Konfliktauflösung. Gebraucht wird hier nur Lesen — die Termine entstehen in +FLORIAN, nicht im Kalender des Anwenders. Dafür genügt eine Adresse, die eine +ICS-Datei ausliefert; jeder Kalender kann das. CalDAV wäre ein Vielfaches an +Aufwand für dieselbe Wirkung. ### Warum kein WhatsApp Die WhatsApp Business API kostet je Konversation und verlangt einen zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen -Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nummern aller -Mitglieder gingen an Meta, was sich mit einer Anwendung, die bewusst im -Gerätehaus steht, nicht verträgt. Telegram, ntfy und Web-Push decken denselben -Bedarf ab, ntfy sogar vollständig selbst gehostet. +Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nachrichten liefen +über Server von Meta, die Wehr müsste einen Vertrag zur Auftragsverarbeitung +schließen, und die gewöhnliche App gleicht zusätzlich das Telefonbuch ab. Das +verträgt sich nicht mit einer Anwendung, die bewusst im Gerätehaus steht. +Telegram, ntfy und Web-Push decken denselben Bedarf ab, ntfy sogar vollständig +selbst gehostet. -## Phase 8 — Lokale KI +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. + +## Phase 8 — Lokale KI · als Nächstes Alles gegen Ollama, kein externer Anbieter, komplett abschaltbar. diff --git a/docs/screenshots/02-uebersicht-dark.png b/docs/screenshots/02-uebersicht-dark.png index 192e568..27e379d 100644 Binary files a/docs/screenshots/02-uebersicht-dark.png and b/docs/screenshots/02-uebersicht-dark.png differ diff --git a/docs/screenshots/02-uebersicht-light.png b/docs/screenshots/02-uebersicht-light.png index e8115e0..b64083c 100644 Binary files a/docs/screenshots/02-uebersicht-light.png and b/docs/screenshots/02-uebersicht-light.png differ diff --git a/docs/screenshots/03-geraeteliste-dark.png b/docs/screenshots/03-geraeteliste-dark.png index 554c6df..36bee78 100644 Binary files a/docs/screenshots/03-geraeteliste-dark.png and b/docs/screenshots/03-geraeteliste-dark.png differ diff --git a/docs/screenshots/03-geraeteliste-light.png b/docs/screenshots/03-geraeteliste-light.png index bcbf6a6..89fc4a1 100644 Binary files a/docs/screenshots/03-geraeteliste-light.png and b/docs/screenshots/03-geraeteliste-light.png differ diff --git a/docs/screenshots/04-geraet-dark.png b/docs/screenshots/04-geraet-dark.png index 6fdf924..8ccbaff 100644 Binary files a/docs/screenshots/04-geraet-dark.png and b/docs/screenshots/04-geraet-dark.png differ diff --git a/docs/screenshots/04-geraet-light.png b/docs/screenshots/04-geraet-light.png index 328f92a..4a27eef 100644 Binary files a/docs/screenshots/04-geraet-light.png and b/docs/screenshots/04-geraet-light.png differ diff --git a/docs/screenshots/05-fahrzeuge-dark.png b/docs/screenshots/05-fahrzeuge-dark.png index b00ca8a..900751c 100644 Binary files a/docs/screenshots/05-fahrzeuge-dark.png and b/docs/screenshots/05-fahrzeuge-dark.png differ diff --git a/docs/screenshots/05-fahrzeuge-light.png b/docs/screenshots/05-fahrzeuge-light.png index c7089f1..13bd5fb 100644 Binary files a/docs/screenshots/05-fahrzeuge-light.png and b/docs/screenshots/05-fahrzeuge-light.png differ diff --git a/docs/screenshots/06-fahrzeug-dark.png b/docs/screenshots/06-fahrzeug-dark.png index 44ae50e..d5c9697 100644 Binary files a/docs/screenshots/06-fahrzeug-dark.png and b/docs/screenshots/06-fahrzeug-dark.png differ diff --git a/docs/screenshots/06-fahrzeug-light.png b/docs/screenshots/06-fahrzeug-light.png index e62db8e..22918bd 100644 Binary files a/docs/screenshots/06-fahrzeug-light.png and b/docs/screenshots/06-fahrzeug-light.png differ diff --git a/docs/screenshots/07-mitglieder-dark.png b/docs/screenshots/07-mitglieder-dark.png index 72b0491..a028f58 100644 Binary files a/docs/screenshots/07-mitglieder-dark.png and b/docs/screenshots/07-mitglieder-dark.png differ diff --git a/docs/screenshots/07-mitglieder-light.png b/docs/screenshots/07-mitglieder-light.png index 3d6a96a..fdd68fa 100644 Binary files a/docs/screenshots/07-mitglieder-light.png and b/docs/screenshots/07-mitglieder-light.png differ diff --git a/docs/screenshots/08-qualifikationen-dark.png b/docs/screenshots/08-qualifikationen-dark.png index aa15299..1f8c5d0 100644 Binary files a/docs/screenshots/08-qualifikationen-dark.png and b/docs/screenshots/08-qualifikationen-dark.png differ diff --git a/docs/screenshots/08-qualifikationen-light.png b/docs/screenshots/08-qualifikationen-light.png index ed7243e..04e0e8b 100644 Binary files a/docs/screenshots/08-qualifikationen-light.png and b/docs/screenshots/08-qualifikationen-light.png differ diff --git a/docs/screenshots/09-import-dark.png b/docs/screenshots/09-import-dark.png index 6dfe25f..39f7d8b 100644 Binary files a/docs/screenshots/09-import-dark.png and b/docs/screenshots/09-import-dark.png differ diff --git a/docs/screenshots/09-import-light.png b/docs/screenshots/09-import-light.png index 85f5340..5298236 100644 Binary files a/docs/screenshots/09-import-light.png and b/docs/screenshots/09-import-light.png differ diff --git a/docs/screenshots/10-geraet-neu-dark.png b/docs/screenshots/10-geraet-neu-dark.png index f0b7d67..ed87183 100644 Binary files a/docs/screenshots/10-geraet-neu-dark.png and b/docs/screenshots/10-geraet-neu-dark.png differ diff --git a/docs/screenshots/10-geraet-neu-light.png b/docs/screenshots/10-geraet-neu-light.png index c3a5bb9..2543251 100644 Binary files a/docs/screenshots/10-geraet-neu-light.png and b/docs/screenshots/10-geraet-neu-light.png differ diff --git a/docs/screenshots/11-fristen-dark.png b/docs/screenshots/11-fristen-dark.png index a32dfad..719550d 100644 Binary files a/docs/screenshots/11-fristen-dark.png and b/docs/screenshots/11-fristen-dark.png differ diff --git a/docs/screenshots/11-fristen-light.png b/docs/screenshots/11-fristen-light.png index d110d2f..7751bd0 100644 Binary files a/docs/screenshots/11-fristen-light.png and b/docs/screenshots/11-fristen-light.png differ diff --git a/docs/screenshots/12-vorlagen-dark.png b/docs/screenshots/12-vorlagen-dark.png index 95cbbdc..ad0cedc 100644 Binary files a/docs/screenshots/12-vorlagen-dark.png and b/docs/screenshots/12-vorlagen-dark.png differ diff --git a/docs/screenshots/12-vorlagen-light.png b/docs/screenshots/12-vorlagen-light.png index e0e9ccb..ad8db97 100644 Binary files a/docs/screenshots/12-vorlagen-light.png and b/docs/screenshots/12-vorlagen-light.png differ diff --git a/docs/screenshots/13-maengel-dark.png b/docs/screenshots/13-maengel-dark.png index fd475d8..55a3cc2 100644 Binary files a/docs/screenshots/13-maengel-dark.png and b/docs/screenshots/13-maengel-dark.png differ diff --git a/docs/screenshots/13-maengel-light.png b/docs/screenshots/13-maengel-light.png index 92d5c96..3612889 100644 Binary files a/docs/screenshots/13-maengel-light.png and b/docs/screenshots/13-maengel-light.png differ diff --git a/docs/screenshots/14-rueckrufe-dark.png b/docs/screenshots/14-rueckrufe-dark.png index 5b69c37..4cf6912 100644 Binary files a/docs/screenshots/14-rueckrufe-dark.png and b/docs/screenshots/14-rueckrufe-dark.png differ diff --git a/docs/screenshots/14-rueckrufe-light.png b/docs/screenshots/14-rueckrufe-light.png index 45b050a..9309453 100644 Binary files a/docs/screenshots/14-rueckrufe-light.png and b/docs/screenshots/14-rueckrufe-light.png differ diff --git a/docs/screenshots/15-lebensdauer-dark.png b/docs/screenshots/15-lebensdauer-dark.png index d6bf9d2..e495f2a 100644 Binary files a/docs/screenshots/15-lebensdauer-dark.png and b/docs/screenshots/15-lebensdauer-dark.png differ diff --git a/docs/screenshots/15-lebensdauer-light.png b/docs/screenshots/15-lebensdauer-light.png index d25f2bc..a0aa0e5 100644 Binary files a/docs/screenshots/15-lebensdauer-light.png and b/docs/screenshots/15-lebensdauer-light.png differ diff --git a/docs/screenshots/16-protokolle-dark.png b/docs/screenshots/16-protokolle-dark.png index 76d221e..42f3d04 100644 Binary files a/docs/screenshots/16-protokolle-dark.png and b/docs/screenshots/16-protokolle-dark.png differ diff --git a/docs/screenshots/16-protokolle-light.png b/docs/screenshots/16-protokolle-light.png index b8e8f9b..5707679 100644 Binary files a/docs/screenshots/16-protokolle-light.png and b/docs/screenshots/16-protokolle-light.png differ diff --git a/docs/screenshots/17-checkliste-dark.png b/docs/screenshots/17-checkliste-dark.png index ca585c2..4668f0c 100644 Binary files a/docs/screenshots/17-checkliste-dark.png and b/docs/screenshots/17-checkliste-dark.png differ diff --git a/docs/screenshots/17-checkliste-light.png b/docs/screenshots/17-checkliste-light.png index e5e44b5..42a42b7 100644 Binary files a/docs/screenshots/17-checkliste-light.png and b/docs/screenshots/17-checkliste-light.png differ diff --git a/docs/screenshots/18-pruefprotokoll-dark.png b/docs/screenshots/18-pruefprotokoll-dark.png index 03a21af..4c424cb 100644 Binary files a/docs/screenshots/18-pruefprotokoll-dark.png and b/docs/screenshots/18-pruefprotokoll-dark.png differ diff --git a/docs/screenshots/18-pruefprotokoll-light.png b/docs/screenshots/18-pruefprotokoll-light.png index 6fd9491..37f621f 100644 Binary files a/docs/screenshots/18-pruefprotokoll-light.png and b/docs/screenshots/18-pruefprotokoll-light.png differ diff --git a/docs/screenshots/19-einsaetze-dark.png b/docs/screenshots/19-einsaetze-dark.png index 76f19df..37cd54a 100644 Binary files a/docs/screenshots/19-einsaetze-dark.png and b/docs/screenshots/19-einsaetze-dark.png differ diff --git a/docs/screenshots/19-einsaetze-light.png b/docs/screenshots/19-einsaetze-light.png index a24303a..292b5a6 100644 Binary files a/docs/screenshots/19-einsaetze-light.png and b/docs/screenshots/19-einsaetze-light.png differ diff --git a/docs/screenshots/20-statistik-dark.png b/docs/screenshots/20-statistik-dark.png index 1201364..7684d31 100644 Binary files a/docs/screenshots/20-statistik-dark.png and b/docs/screenshots/20-statistik-dark.png differ diff --git a/docs/screenshots/20-statistik-light.png b/docs/screenshots/20-statistik-light.png index d0b7d55..ea86438 100644 Binary files a/docs/screenshots/20-statistik-light.png and b/docs/screenshots/20-statistik-light.png differ diff --git a/docs/screenshots/21-uebungen-dark.png b/docs/screenshots/21-uebungen-dark.png index d2dfb80..17125b9 100644 Binary files a/docs/screenshots/21-uebungen-dark.png and b/docs/screenshots/21-uebungen-dark.png differ diff --git a/docs/screenshots/21-uebungen-light.png b/docs/screenshots/21-uebungen-light.png index b7289f0..4b23a5b 100644 Binary files a/docs/screenshots/21-uebungen-light.png and b/docs/screenshots/21-uebungen-light.png differ diff --git a/docs/screenshots/22-einsatz-dark.png b/docs/screenshots/22-einsatz-dark.png index 92a449b..8745ab2 100644 Binary files a/docs/screenshots/22-einsatz-dark.png and b/docs/screenshots/22-einsatz-dark.png differ diff --git a/docs/screenshots/22-einsatz-light.png b/docs/screenshots/22-einsatz-light.png index 22d391f..83f4771 100644 Binary files a/docs/screenshots/22-einsatz-light.png and b/docs/screenshots/22-einsatz-light.png differ diff --git a/docs/screenshots/23-uebung-dark.png b/docs/screenshots/23-uebung-dark.png index be1eef3..c6a7677 100644 Binary files a/docs/screenshots/23-uebung-dark.png and b/docs/screenshots/23-uebung-dark.png differ diff --git a/docs/screenshots/23-uebung-light.png b/docs/screenshots/23-uebung-light.png index 5cd6479..b49ca79 100644 Binary files a/docs/screenshots/23-uebung-light.png and b/docs/screenshots/23-uebung-light.png differ diff --git a/docs/screenshots/24-portal-dark.png b/docs/screenshots/24-portal-dark.png index 98ece0c..32a5744 100644 Binary files a/docs/screenshots/24-portal-dark.png and b/docs/screenshots/24-portal-dark.png differ diff --git a/docs/screenshots/24-portal-light.png b/docs/screenshots/24-portal-light.png index d51d243..1a4a47b 100644 Binary files a/docs/screenshots/24-portal-light.png and b/docs/screenshots/24-portal-light.png differ diff --git a/docs/screenshots/26-kiosk-mangel-dark.png b/docs/screenshots/26-kiosk-mangel-dark.png index 7f06b84..dee352d 100644 Binary files a/docs/screenshots/26-kiosk-mangel-dark.png and b/docs/screenshots/26-kiosk-mangel-dark.png differ diff --git a/docs/screenshots/26-kiosk-mangel-light.png b/docs/screenshots/26-kiosk-mangel-light.png index 0ac6bf8..7a4864d 100644 Binary files a/docs/screenshots/26-kiosk-mangel-light.png and b/docs/screenshots/26-kiosk-mangel-light.png differ diff --git a/docs/screenshots/27-kiosk-monitor-dark.png b/docs/screenshots/27-kiosk-monitor-dark.png index b7a4d97..756cf6b 100644 Binary files a/docs/screenshots/27-kiosk-monitor-dark.png and b/docs/screenshots/27-kiosk-monitor-dark.png differ diff --git a/docs/screenshots/27-kiosk-monitor-light.png b/docs/screenshots/27-kiosk-monitor-light.png index a6827e7..aaa9355 100644 Binary files a/docs/screenshots/27-kiosk-monitor-light.png and b/docs/screenshots/27-kiosk-monitor-light.png differ diff --git a/docs/screenshots/28-terminal-uebersicht-dark.png b/docs/screenshots/28-terminal-uebersicht-dark.png index 300e646..cacec69 100644 Binary files a/docs/screenshots/28-terminal-uebersicht-dark.png and b/docs/screenshots/28-terminal-uebersicht-dark.png differ diff --git a/docs/screenshots/28-terminal-uebersicht-light.png b/docs/screenshots/28-terminal-uebersicht-light.png index 7e1c739..0b8c91e 100644 Binary files a/docs/screenshots/28-terminal-uebersicht-light.png and b/docs/screenshots/28-terminal-uebersicht-light.png differ diff --git a/docs/screenshots/29-anmeldemarke-dark.png b/docs/screenshots/29-anmeldemarke-dark.png index d22c17e..358212c 100644 Binary files a/docs/screenshots/29-anmeldemarke-dark.png and b/docs/screenshots/29-anmeldemarke-dark.png differ diff --git a/docs/screenshots/29-anmeldemarke-light.png b/docs/screenshots/29-anmeldemarke-light.png index e61072d..3efc3b2 100644 Binary files a/docs/screenshots/29-anmeldemarke-light.png and b/docs/screenshots/29-anmeldemarke-light.png differ diff --git a/docs/screenshots/31-atemschutz-dark.png b/docs/screenshots/31-atemschutz-dark.png index 4edec7c..757df2e 100644 Binary files a/docs/screenshots/31-atemschutz-dark.png and b/docs/screenshots/31-atemschutz-dark.png differ diff --git a/docs/screenshots/31-atemschutz-light.png b/docs/screenshots/31-atemschutz-light.png index 8d50d08..d1a59ed 100644 Binary files a/docs/screenshots/31-atemschutz-light.png and b/docs/screenshots/31-atemschutz-light.png differ diff --git a/docs/screenshots/32-atemschutz-nachweise-dark.png b/docs/screenshots/32-atemschutz-nachweise-dark.png index 9d0f994..73bd60a 100644 Binary files a/docs/screenshots/32-atemschutz-nachweise-dark.png and b/docs/screenshots/32-atemschutz-nachweise-dark.png differ diff --git a/docs/screenshots/32-atemschutz-nachweise-light.png b/docs/screenshots/32-atemschutz-nachweise-light.png index f517665..2a08e74 100644 Binary files a/docs/screenshots/32-atemschutz-nachweise-light.png and b/docs/screenshots/32-atemschutz-nachweise-light.png differ diff --git a/docs/screenshots/34-karte-dark.png b/docs/screenshots/34-karte-dark.png index 9060e58..d57179c 100644 Binary files a/docs/screenshots/34-karte-dark.png and b/docs/screenshots/34-karte-dark.png differ diff --git a/docs/screenshots/34-karte-light.png b/docs/screenshots/34-karte-light.png index 1ca0ec7..de07086 100644 Binary files a/docs/screenshots/34-karte-light.png and b/docs/screenshots/34-karte-light.png differ diff --git a/docs/screenshots/35-objekte-dark.png b/docs/screenshots/35-objekte-dark.png index f0eefa6..c7c938a 100644 Binary files a/docs/screenshots/35-objekte-dark.png and b/docs/screenshots/35-objekte-dark.png differ diff --git a/docs/screenshots/35-objekte-light.png b/docs/screenshots/35-objekte-light.png index 95acd9f..6387ce6 100644 Binary files a/docs/screenshots/35-objekte-light.png and b/docs/screenshots/35-objekte-light.png differ diff --git a/docs/screenshots/36-wasserfoerderung-dark.png b/docs/screenshots/36-wasserfoerderung-dark.png index f18a0ad..3136a35 100644 Binary files a/docs/screenshots/36-wasserfoerderung-dark.png and b/docs/screenshots/36-wasserfoerderung-dark.png differ diff --git a/docs/screenshots/36-wasserfoerderung-light.png b/docs/screenshots/36-wasserfoerderung-light.png index d39ca15..ebd8247 100644 Binary files a/docs/screenshots/36-wasserfoerderung-light.png and b/docs/screenshots/36-wasserfoerderung-light.png differ diff --git a/docs/screenshots/37-termine-dark.png b/docs/screenshots/37-termine-dark.png new file mode 100644 index 0000000..1b20536 Binary files /dev/null and b/docs/screenshots/37-termine-dark.png differ diff --git a/docs/screenshots/37-termine-light.png b/docs/screenshots/37-termine-light.png new file mode 100644 index 0000000..5fa0dbd Binary files /dev/null and b/docs/screenshots/37-termine-light.png differ diff --git a/docs/screenshots/38-benachrichtigungen-dark.png b/docs/screenshots/38-benachrichtigungen-dark.png new file mode 100644 index 0000000..41b99a6 Binary files /dev/null and b/docs/screenshots/38-benachrichtigungen-dark.png differ diff --git a/docs/screenshots/38-benachrichtigungen-light.png b/docs/screenshots/38-benachrichtigungen-light.png new file mode 100644 index 0000000..f442cd1 Binary files /dev/null and b/docs/screenshots/38-benachrichtigungen-light.png differ diff --git a/docs/screenshots/39-vorlagen-dark.png b/docs/screenshots/39-vorlagen-dark.png new file mode 100644 index 0000000..b85638b Binary files /dev/null and b/docs/screenshots/39-vorlagen-dark.png differ diff --git a/docs/screenshots/39-vorlagen-light.png b/docs/screenshots/39-vorlagen-light.png new file mode 100644 index 0000000..ddd1f64 Binary files /dev/null and b/docs/screenshots/39-vorlagen-light.png differ diff --git a/docs/screenshots/40-dienstausweis-dark.png b/docs/screenshots/40-dienstausweis-dark.png new file mode 100644 index 0000000..66b414e Binary files /dev/null and b/docs/screenshots/40-dienstausweis-dark.png differ diff --git a/docs/screenshots/40-dienstausweis-light.png b/docs/screenshots/40-dienstausweis-light.png new file mode 100644 index 0000000..6164a99 Binary files /dev/null and b/docs/screenshots/40-dienstausweis-light.png differ diff --git a/package-lock.json b/package-lock.json index aa16ba6..9364264 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "next": "^15.5.4", "next-auth": "^5.0.0-beta.29", "next-themes": "^0.4.6", + "nodemailer": "^8.0.11", "papaparse": "^5.7.0", "qrcode": "^1.5.4", "react": "^19.1.1", @@ -49,6 +50,7 @@ "server-only": "^0.0.1", "superjson": "^2.2.6", "tailwind-merge": "^3.3.1", + "web-push": "^3.6.7", "zod": "^3.25.76" }, "devDependencies": { @@ -57,10 +59,12 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@types/node": "^22.18.6", + "@types/nodemailer": "^8.0.1", "@types/papaparse": "^5.5.2", "@types/qrcode": "^1.5.6", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.0.3", "dotenv": "^17.4.2", "esbuild": "^0.25.10", @@ -4445,6 +4449,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/papaparse": { "version": "5.5.2", "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", @@ -4485,6 +4499,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.69.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", @@ -5331,7 +5355,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -5663,6 +5686,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -5838,6 +5873,12 @@ "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", "license": "MIT" }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", @@ -5934,6 +5975,12 @@ "node": ">=8.0.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-indexof-polyfill": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", @@ -6831,6 +6878,15 @@ "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", "license": "ISC" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/effect": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", @@ -8286,6 +8342,15 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -8304,7 +8369,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -9147,6 +9211,27 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kdbush": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", @@ -9814,6 +9899,12 @@ "node": ">=4" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -10129,6 +10220,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", + "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -11338,6 +11438,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sax": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", @@ -13912,6 +14018,25 @@ "node": ">=18" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index abf33bf..f4f8100 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "florian", - "version": "0.7.0", + "version": "0.8.0", "private": true, "description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "engines": { @@ -24,7 +24,8 @@ "db:seed": "tsx prisma/seed.ts", "db:studio": "prisma studio", "screenshots": "tsx scripts/screenshots.ts", - "icons": "tsx scripts/icons.ts" + "icons": "tsx scripts/icons.ts", + "vapid": "tsx scripts/vapid.ts" }, "dependencies": { "@prisma/client": "^6.16.2", @@ -61,6 +62,7 @@ "next": "^15.5.4", "next-auth": "^5.0.0-beta.29", "next-themes": "^0.4.6", + "nodemailer": "^8.0.11", "papaparse": "^5.7.0", "qrcode": "^1.5.4", "react": "^19.1.1", @@ -68,6 +70,7 @@ "server-only": "^0.0.1", "superjson": "^2.2.6", "tailwind-merge": "^3.3.1", + "web-push": "^3.6.7", "zod": "^3.25.76" }, "devDependencies": { @@ -76,10 +79,12 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@types/node": "^22.18.6", + "@types/nodemailer": "^8.0.1", "@types/papaparse": "^5.5.2", "@types/qrcode": "^1.5.6", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.0.3", "dotenv": "^17.4.2", "esbuild": "^0.25.10", diff --git a/prisma/migrations/20260904043550_benachrichtigungen_termine/migration.sql b/prisma/migrations/20260904043550_benachrichtigungen_termine/migration.sql new file mode 100644 index 0000000..efae4b5 --- /dev/null +++ b/prisma/migrations/20260904043550_benachrichtigungen_termine/migration.sql @@ -0,0 +1,209 @@ +-- Phase 7 — Benachrichtigungen und Termine +-- +-- Von Prisma erzeugt, danach von Hand um die DropIndex-Anweisungen erleichtert +-- (siehe Kopf der Migration 20260903190219_atemschutz_fwdv7). Rechte, +-- Mandantentrennung und die Regeln fuer Termine stehen in der Migration +-- daneben. + +-- CreateEnum +CREATE TYPE "EreignisTyp" AS ENUM ('PRUEFUNG_FAELLIG', 'MANGEL_GEMELDET', 'QUALIFIKATION_LAEUFT_AB', 'TERMIN_NEU', 'TERMIN_ERINNERUNG', 'TERMIN_ABGESAGT', 'EINSATZBERICHT_FREIGEGEBEN'); + +-- CreateEnum +CREATE TYPE "Kanal" AS ENUM ('MAIL', 'TELEGRAM', 'NTFY', 'WEBPUSH'); + +-- CreateEnum +CREATE TYPE "TerminArt" AS ENUM ('DIENST', 'UEBUNG', 'LEHRGANG', 'ARBEITSDIENST', 'VERANSTALTUNG', 'SITZUNG', 'SONSTIGES'); + +-- CreateEnum +CREATE TYPE "Serienmuster" AS ENUM ('KEINE', 'WOECHENTLICH', 'ZWEIWOECHENTLICH', 'MONATLICH', 'JAEHRLICH'); + +-- CreateEnum +CREATE TYPE "Teilnahmestand" AS ENUM ('ZUGESAGT', 'ABGESAGT', 'WARTELISTE'); + +-- CreateEnum +CREATE TYPE "Zustellstand" AS ENUM ('WARTET', 'GESENDET', 'FEHLGESCHLAGEN', 'UEBERSPRUNGEN'); + +-- CreateTable +CREATE TABLE "benachrichtigungsvorlage" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "ereignis" "EreignisTyp" NOT NULL, + "kanal" "Kanal" NOT NULL, + "betreff" VARCHAR(200), + "text" VARCHAR(2000) NOT NULL, + "rollen" "RolleSchluessel"[], + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "benachrichtigungsvorlage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "zustellkanal" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "personId" UUID NOT NULL, + "kanal" "Kanal" NOT NULL, + "ziel" VARCHAR(500) NOT NULL, + "p256dh" VARCHAR(200), + "auth" VARCHAR(200), + "ereignisse" "EreignisTyp"[], + "bezeichnung" VARCHAR(120), + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "letzteZustellung" TIMESTAMPTZ(3), + "fehlversuche" INTEGER NOT NULL DEFAULT 0, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "zustellkanal_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "benachrichtigung" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "ereignis" "EreignisTyp" NOT NULL, + "kanal" "Kanal" NOT NULL, + "personId" UUID NOT NULL, + "betreff" VARCHAR(200), + "text" VARCHAR(2000) NOT NULL, + "ziel" VARCHAR(500) NOT NULL, + "entitaet" VARCHAR(80), + "entitaetId" UUID, + "stand" "Zustellstand" NOT NULL DEFAULT 'WARTET', + "versuche" INTEGER NOT NULL DEFAULT 0, + "gesendetAm" TIMESTAMPTZ(3), + "fehler" VARCHAR(500), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "benachrichtigung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "termin" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "art" "TerminArt" NOT NULL DEFAULT 'DIENST', + "titel" VARCHAR(200) NOT NULL, + "beschreibung" VARCHAR(4000), + "ort" VARCHAR(200), + "beginn" TIMESTAMPTZ(3) NOT NULL, + "ende" TIMESTAMPTZ(3), + "ganztaegig" BOOLEAN NOT NULL DEFAULT false, + "leiterId" UUID, + "teilnehmerLimit" INTEGER, + "anmeldeschluss" TIMESTAMPTZ(3), + "anmeldungNoetig" BOOLEAN NOT NULL DEFAULT false, + "serieId" UUID, + "serienmuster" "Serienmuster" NOT NULL DEFAULT 'KEINE', + "abgesagtAm" TIMESTAMPTZ(3), + "absageGrund" VARCHAR(500), + "erinnertAm" TIMESTAMPTZ(3), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + "geloeschtAm" TIMESTAMPTZ(3), + + CONSTRAINT "termin_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "terminanmeldung" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "terminId" UUID NOT NULL, + "personId" UUID NOT NULL, + "stand" "Teilnahmestand" NOT NULL DEFAULT 'ZUGESAGT', + "grund" VARCHAR(300), + "anwesend" BOOLEAN, + "gemeldetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "terminanmeldung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "kalenderzugang" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "personId" UUID NOT NULL, + "bezeichnung" VARCHAR(120) NOT NULL, + "tokenHash" VARCHAR(64) NOT NULL, + "nurEigene" BOOLEAN NOT NULL DEFAULT false, + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "letzterZugriff" TIMESTAMPTZ(3), + "zugriffe" INTEGER NOT NULL DEFAULT 0, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "widerrufenAm" TIMESTAMPTZ(3), + + CONSTRAINT "kalenderzugang_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "benachrichtigungsvorlage_organisationId_ereignis_kanal_key" ON "benachrichtigungsvorlage"("organisationId", "ereignis", "kanal"); + +-- CreateIndex +CREATE INDEX "zustellkanal_organisationId_kanal_idx" ON "zustellkanal"("organisationId", "kanal"); + +-- CreateIndex +CREATE UNIQUE INDEX "zustellkanal_personId_kanal_ziel_key" ON "zustellkanal"("personId", "kanal", "ziel"); + +-- CreateIndex +CREATE INDEX "benachrichtigung_organisationId_stand_idx" ON "benachrichtigung"("organisationId", "stand"); + +-- CreateIndex +CREATE INDEX "benachrichtigung_organisationId_personId_erstelltAm_idx" ON "benachrichtigung"("organisationId", "personId", "erstelltAm" DESC); + +-- CreateIndex +CREATE INDEX "termin_organisationId_beginn_idx" ON "termin"("organisationId", "beginn"); + +-- CreateIndex +CREATE INDEX "termin_organisationId_serieId_idx" ON "termin"("organisationId", "serieId"); + +-- CreateIndex +CREATE INDEX "terminanmeldung_organisationId_personId_idx" ON "terminanmeldung"("organisationId", "personId"); + +-- CreateIndex +CREATE UNIQUE INDEX "terminanmeldung_terminId_personId_key" ON "terminanmeldung"("terminId", "personId"); + +-- CreateIndex +CREATE INDEX "kalenderzugang_organisationId_personId_idx" ON "kalenderzugang"("organisationId", "personId"); + +-- CreateIndex +CREATE UNIQUE INDEX "kalenderzugang_tokenHash_key" ON "kalenderzugang"("tokenHash"); + +-- AddForeignKey +ALTER TABLE "benachrichtigungsvorlage" ADD CONSTRAINT "benachrichtigungsvorlage_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "zustellkanal" ADD CONSTRAINT "zustellkanal_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "zustellkanal" ADD CONSTRAINT "zustellkanal_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "benachrichtigung" ADD CONSTRAINT "benachrichtigung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "benachrichtigung" ADD CONSTRAINT "benachrichtigung_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "termin" ADD CONSTRAINT "termin_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "termin" ADD CONSTRAINT "termin_leiterId_fkey" FOREIGN KEY ("leiterId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "terminanmeldung" ADD CONSTRAINT "terminanmeldung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "terminanmeldung" ADD CONSTRAINT "terminanmeldung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "termin"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "terminanmeldung" ADD CONSTRAINT "terminanmeldung_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kalenderzugang" ADD CONSTRAINT "kalenderzugang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kalenderzugang" ADD CONSTRAINT "kalenderzugang_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260904043600_benachrichtigungen_rls/migration.sql b/prisma/migrations/20260904043600_benachrichtigungen_rls/migration.sql new file mode 100644 index 0000000..1fc0acc --- /dev/null +++ b/prisma/migrations/20260904043600_benachrichtigungen_rls/migration.sql @@ -0,0 +1,111 @@ +-- ============================================================================= +-- Phase 7: Mandantentrennung und die Regeln fuer Termine und Zustellung. +-- +-- 1. Row Level Security fuer die sechs neuen Tabellen. +-- +-- 2. Bedingungen, die Unmoegliches abweisen: ein Termin, der endet, bevor er +-- beginnt; ein Teilnehmerlimit von null; ein Anmeldeschluss nach dem +-- Termin. +-- +-- 3. Teilindizes fuer die beiden Abfragen, die staendig laufen: was steht an +-- (kommende Termine) und was ist noch zu verschicken (offene +-- Benachrichtigungen). +-- +-- 4. Ein zugestellter Eintrag wird nicht geloescht. Er ist der Nachweis +-- dafuer, wer wann worueber informiert wurde — und wer nicht. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- Row Level Security +-- ----------------------------------------------------------------------------- +DO $$ +DECLARE + tabelle text; +BEGIN + FOREACH tabelle IN ARRAY ARRAY[ + 'benachrichtigungsvorlage', + 'zustellkanal', + 'benachrichtigung', + 'termin', + 'terminanmeldung', + 'kalenderzugang' + ] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle); + EXECUTE format( + 'CREATE POLICY %I ON %I FOR ALL ' + 'USING ("organisationId" = aktuelle_organisation()) ' + 'WITH CHECK ("organisationId" = aktuelle_organisation())', + tabelle || '_mandant', tabelle + ); + END LOOP; +END; +$$; + +-- ----------------------------------------------------------------------------- +-- Was es nicht geben darf +-- ----------------------------------------------------------------------------- +ALTER TABLE "termin" ADD CONSTRAINT "termin_ende_nach_beginn" CHECK ( + "ende" IS NULL OR "ende" >= "beginn" +); + +ALTER TABLE "termin" ADD CONSTRAINT "termin_limit_sinnvoll" CHECK ( + "teilnehmerLimit" IS NULL OR "teilnehmerLimit" > 0 +); + +-- Ein Anmeldeschluss nach dem Termin ist keiner. +ALTER TABLE "termin" ADD CONSTRAINT "termin_anmeldeschluss_vorher" CHECK ( + "anmeldeschluss" IS NULL OR "anmeldeschluss" <= "beginn" +); + +ALTER TABLE "benachrichtigung" ADD CONSTRAINT "benachrichtigung_versuche_plausibel" CHECK ( + "versuche" >= 0 AND "versuche" <= 100 +); + +-- ----------------------------------------------------------------------------- +-- Teilindizes fuer den laufenden Betrieb +-- ----------------------------------------------------------------------------- + +-- Was noch raus muss. Die Tabelle waechst ueber Jahre, die Warteschlange ist +-- immer kurz. +CREATE INDEX "benachrichtigung_offen" ON "benachrichtigung" ("organisationId", "erstelltAm") + WHERE "stand" = 'WARTET'; + +-- Was ansteht. Abgesagte und geloeschte Termine stehen im Kalender nicht mehr. +CREATE INDEX "termin_kommend" ON "termin" ("organisationId", "beginn") + WHERE "geloeschtAm" IS NULL AND "abgesagtAm" IS NULL; + +-- Wer sich zu was gemeldet hat — fuer die Anwesenheitsliste. +CREATE INDEX "terminanmeldung_zugesagt" ON "terminanmeldung" ("terminId") + WHERE "stand" = 'ZUGESAGT'; + +-- ----------------------------------------------------------------------------- +-- Volltextsuche ueber Termine +-- ----------------------------------------------------------------------------- +CREATE INDEX "termin_suche_titel" ON "termin" USING GIN ("titel" gin_trgm_ops); + +-- ----------------------------------------------------------------------------- +-- Rechte fuer die Anwendungsrolle +-- +-- Eine zugestellte Benachrichtigung wird nicht geloescht: sie ist der Nachweis +-- dafuer, wer wann worueber informiert wurde. Geaendert werden darf sie, denn +-- der Hintergrundprozess traegt Zustellstand und Fehlversuche nach. +-- ----------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN + EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app'; + EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app'; + + EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app'; + + EXECUTE 'REVOKE DELETE ON "benachrichtigung" FROM florian_app'; + ELSE + RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.'; + END IF; +END; +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0e6209b..3e60c3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -209,14 +209,20 @@ model Organisation { belastungsuebungen Belastungsuebung[] // Phase 6 — Kartenmodule - hydranten Hydrant[] - objekte Objekt[] - objektkontakte Objektkontakt[] - objektplaene Objektplan[] - lagekarten Lagekarte[] - lageelemente Lageelement[] - wasserfoerderungen Wasserfoerderung[] - foerderpunkte Foerderpunkt[] + hydranten Hydrant[] + objekte Objekt[] + objektkontakte Objektkontakt[] + objektplaene Objektplan[] + lagekarten Lagekarte[] + lageelemente Lageelement[] + wasserfoerderungen Wasserfoerderung[] + foerderpunkte Foerderpunkt[] + benachrichtigungsvorlages Benachrichtigungsvorlage[] + zustellkanals Zustellkanal[] + benachrichtigungs Benachrichtigung[] + termins Termin[] + terminanmeldungs Terminanmeldung[] + kalenderzugangs Kalenderzugang[] @@index([aktiv]) @@map("organisation") @@ -324,6 +330,13 @@ model Person { gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner") geplanteFoerderstrecken Wasserfoerderung[] @relation("WasserfoerderungPlaner") + // Phase 7 — Benachrichtigungen und Termine + benachrichtigungen Benachrichtigung[] @relation("BenachrichtigungEmpfaenger") + zustellkanaele Zustellkanal[] @relation("ZustellkanalPerson") + geleiteteTermine Termin[] @relation("TerminLeitung") + terminanmeldungen Terminanmeldung[] @relation("TerminanmeldungPerson") + kalenderzugaenge Kalenderzugang[] @relation("KalenderzugangPerson") + /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe /// Person kann in zwei Wehren Mitglied sein. @@unique([organisationId, email]) @@ -2964,3 +2977,302 @@ model Foerderpunkt { @@index([organisationId, wasserfoerderungId]) @@map("foerderpunkt") } + +// ============================================================================= +// Phase 7 — Benachrichtigungen und Termine +// +// Zwei Themen, die zusammengehören: ein Termin ist der häufigste Anlass für +// eine Benachrichtigung, und eine Benachrichtigung ohne Termin dahinter ist +// meistens eine Frist. +// +// Der Aufbau folgt einer Regel: **niemand wird ungefragt benachrichtigt.** +// Jede Person entscheidet je Ereignis und Kanal selbst. Die Wehr legt fest, +// was es überhaupt gibt (Vorlagen) und wen es betrifft (Rollen) — ob +// zugestellt wird, entscheidet die Person. +// ============================================================================= + +/// Wofür es eine Benachrichtigung geben kann. +enum EreignisTyp { + PRUEFUNG_FAELLIG + MANGEL_GEMELDET + QUALIFIKATION_LAEUFT_AB + TERMIN_NEU + TERMIN_ERINNERUNG + TERMIN_ABGESAGT + EINSATZBERICHT_FREIGEGEBEN +} + +/// Wie zugestellt wird. +enum Kanal { + /// SMTP über den eigenen Mailserver. + MAIL + /// Telegram-Bot. Externer Dienst — nur Hinweise ohne Personenbezug. + TELEGRAM + /// ntfy, selbst gehostet. Der einzige Push-Weg ohne fremden Server. + NTFY + /// Web-Push über den Browser. Braucht VAPID-Schlüssel. + WEBPUSH +} + +/// Was für ein Termin. +enum TerminArt { + DIENST + UEBUNG + LEHRGANG + ARBEITSDIENST + VERANSTALTUNG + SITZUNG + SONSTIGES +} + +/// Wie oft ein Termin wiederkehrt. +enum Serienmuster { + KEINE + WOECHENTLICH + ZWEIWOECHENTLICH + MONATLICH + JAEHRLICH +} + +/// Was jemand zu einem Termin gesagt hat. +enum Teilnahmestand { + ZUGESAGT + ABGESAGT + /// Angemeldet, aber die Liste war voll — rückt nach, wenn jemand absagt. + WARTELISTE +} + +/// Wie es einer Benachrichtigung ergangen ist. +enum Zustellstand { + WARTET + GESENDET + FEHLGESCHLAGEN + /// Niemand hat diesen Kanal abonniert oder der Kanal ist aus. + UEBERSPRUNGEN +} + +/// Eine Vorlage je Ereignis und Kanal. +/// +/// Der Text enthält Platzhalter in geschweiften Klammern: `{titel}`, `{datum}`, +/// `{wehr}`. Welche es gibt, steht in lib/benachrichtigung/vorlagen.ts — und +/// die Oberfläche zeigt sie beim Bearbeiten an, damit niemand raten muss. +/// +/// Fehlt eine Vorlage, greift der eingebaute Standardtext. Eine Wehr, die +/// nichts einstellt, bekommt trotzdem brauchbare Nachrichten. +model Benachrichtigungsvorlage { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + ereignis EreignisTyp + kanal Kanal + + /// Nur für E-Mail. Push-Dienste kennen keinen Betreff. + betreff String? @db.VarChar(200) + text String @db.VarChar(2000) + + /// Wen es angeht. Leer heißt: alle, die es abonniert haben. + rollen RolleSchluessel[] + + aktiv Boolean @default(true) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + + @@unique([organisationId, ereignis, kanal]) + @@map("benachrichtigungsvorlage") +} + +/// Wohin eine Person benachrichtigt werden will. +/// +/// Ein Datensatz je Person und Kanal, mit der Adresse dieses Kanals: die +/// Chat-Kennung bei Telegram, das Thema bei ntfy, das Abonnement des Browsers +/// bei Web-Push. Die E-Mail steht schon an der Person. +/// +/// `ereignisse` sagt, wofür dieser Kanal benutzt wird. Leer heißt: für nichts — +/// **niemand wird ungefragt benachrichtigt.** +model Zustellkanal { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + personId String @db.Uuid + + kanal Kanal + + /// Chat-Kennung, ntfy-Thema oder die Endpunktadresse des Browsers. + ziel String @db.VarChar(500) + /// Web-Push braucht zwei Schlüssel dazu. + p256dh String? @db.VarChar(200) + auth String? @db.VarChar(200) + + /// Wofür dieser Kanal benutzt wird. + ereignisse EreignisTyp[] + + bezeichnung String? @db.VarChar(120) + aktiv Boolean @default(true) + + /// Wann zuletzt etwas darüber ankam — ein toter Kanal fällt sonst nicht auf. + letzteZustellung DateTime? @db.Timestamptz(3) + fehlversuche Int @default(0) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + person Person @relation("ZustellkanalPerson", fields: [personId], references: [id], onDelete: Cascade) + + @@unique([personId, kanal, ziel]) + @@index([organisationId, kanal]) + @@map("zustellkanal") +} + +/// Eine verschickte oder zu verschickende Benachrichtigung. +/// +/// Sie steht **vor** dem Versand in der Tabelle, nicht danach: der +/// Hintergrundprozess arbeitet sie ab und vermerkt, wie es ausging. Damit ist +/// nachvollziehbar, wer wann worüber informiert wurde — und wer nicht, weil +/// sein Kanal nicht erreichbar war. +model Benachrichtigung { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + ereignis EreignisTyp + kanal Kanal + personId String @db.Uuid + + betreff String? @db.VarChar(200) + text String @db.VarChar(2000) + /// Wohin es ging — zum Nachvollziehen, auch wenn der Kanal später wegfällt. + ziel String @db.VarChar(500) + + /// Worum es ging: "Wartungstermin", "Termin", "Mangel" … + entitaet String? @db.VarChar(80) + entitaetId String? @db.Uuid + + stand Zustellstand @default(WARTET) + versuche Int @default(0) + gesendetAm DateTime? @db.Timestamptz(3) + fehler String? @db.VarChar(500) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + person Person @relation("BenachrichtigungEmpfaenger", fields: [personId], references: [id], onDelete: Cascade) + + @@index([organisationId, stand]) + @@index([organisationId, personId, erstelltAm(sort: Desc)]) + @@map("benachrichtigung") +} + +/// Ein Termin im Kalender der Wehr. +/// +/// Serientermine werden **ausgeschrieben**, nicht gerechnet: jeder Abend ist +/// ein eigener Datensatz mit Verweis auf den ersten. Sonst ließe sich ein +/// einzelner Abend nicht absagen, keine Teilnehmerliste führen und keine +/// Anwesenheit abhaken — und genau das ist der Alltag. +model Termin { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + art TerminArt @default(DIENST) + titel String @db.VarChar(200) + + beschreibung String? @db.VarChar(4000) + ort String? @db.VarChar(200) + + beginn DateTime @db.Timestamptz(3) + ende DateTime? @db.Timestamptz(3) + ganztaegig Boolean @default(false) + + /// Wer ihn leitet. + leiterId String? @db.Uuid + + /// Ab wie vielen Zusagen die Warteliste beginnt. Null heißt: unbegrenzt. + teilnehmerLimit Int? + /// Bis wann man sich anmelden kann. + anmeldeschluss DateTime? @db.Timestamptz(3) + /// Erwartet die Wehr eine Rückmeldung? Sonst ist es nur ein Eintrag. + anmeldungNoetig Boolean @default(false) + + /// Serie: der erste Termin trägt das Muster, alle tragen dieselbe Serienkennung. + serieId String? @db.Uuid + serienmuster Serienmuster @default(KEINE) + + /// Abgesagt statt gelöscht — wer schon zugesagt hat, soll den Grund sehen. + abgesagtAm DateTime? @db.Timestamptz(3) + absageGrund String? @db.VarChar(500) + + /// Wann erinnert wurde, damit es nicht zweimal passiert. + erinnertAm DateTime? @db.Timestamptz(3) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + geloeschtAm DateTime? @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + leiter Person? @relation("TerminLeitung", fields: [leiterId], references: [id], onDelete: SetNull) + anmeldungen Terminanmeldung[] + + @@index([organisationId, beginn]) + @@index([organisationId, serieId]) + @@map("termin") +} + +/// Zu- oder Absage einer Person zu einem Termin. +model Terminanmeldung { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + terminId String @db.Uuid + personId String @db.Uuid + + stand Teilnahmestand @default(ZUGESAGT) + /// Bei einer Absage: warum. Freiwillig, aber hilfreich für die Planung. + grund String? @db.VarChar(300) + + /// Beim Abhaken der Anwesenheitsliste gesetzt. + anwesend Boolean? + + gemeldetAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + termin Termin @relation(fields: [terminId], references: [id], onDelete: Cascade) + person Person @relation("TerminanmeldungPerson", fields: [personId], references: [id], onDelete: Cascade) + + @@unique([terminId, personId]) + @@index([organisationId, personId]) + @@map("terminanmeldung") +} + +/// Ein Kalenderabonnement je Person. +/// +/// Der Kalender im Handy fragt die Adresse regelmäßig ab — ohne Anmeldung, +/// also mit einem Geheimnis in der Adresse. Wie beim Einsatzportal aus Phase 3 +/// liegt davon nur der Hash in der Datenbank: wer die Tabelle liest, bekommt +/// keinen Zugang. +model Kalenderzugang { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + personId String @db.Uuid + + bezeichnung String @db.VarChar(120) + /// SHA-256 des Tokens. Das Token selbst sieht man genau einmal. + tokenHash String @db.VarChar(64) + + /// Nur eigene Termine oder alle der Wehr? + nurEigene Boolean @default(false) + + aktiv Boolean @default(true) + letzterZugriff DateTime? @db.Timestamptz(3) + zugriffe Int @default(0) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + widerrufenAm DateTime? @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + person Person @relation("KalenderzugangPerson", fields: [personId], references: [id], onDelete: Cascade) + + @@unique([tokenHash]) + @@index([organisationId, personId]) + @@map("kalenderzugang") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 7d2f771..e8ff219 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -15,7 +15,10 @@ * Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene * Überwachung mit vollständigem Protokoll. Ab Phase 6 dazu: Koordinaten für * die Standorte, 24 Löschwasserstellen im Ausrückegebiet und drei Objekte mit - * Feuerwehrplan. + * Feuerwehrplan. Ab Phase 7 dazu: der Jahresplan — Übungsabend als Serie, + * Lehrgang mit Warteliste, Arbeitsdienst, Jahreshauptversammlung, ein + * abgesagter Termin und eine abgehakte Anwesenheitsliste. Bewusst OHNE + * Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed. * * Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang, * der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt @@ -57,13 +60,17 @@ import { HydrantStatus, HydrantTyp, ObjektKategorie, + Serienmuster, StandortArt, + Teilnahmestand, + TerminArt, TruppStatus, UebungsArt, UebungsartAtemschutz, WaescheStatus, } from "@prisma/client"; import bcrypt from "bcryptjs"; +import { serientermine } from "../src/lib/termin/serie"; const prisma = new PrismaClient({ datasources: { @@ -2754,6 +2761,244 @@ async function kartendatenAnlegen(organisationId: string) { ); } +// ----------------------------------------------------------------------------- +// Phase 7 — Benachrichtigungen und Termine +// +// Der Jahresplan einer Wehr, wie er wirklich aussieht: der Übungsabend jeden +// zweiten Dienstag, ein Lehrgang mit begrenzten Plätzen (samt Warteliste, weil +// die immer entsteht), der Arbeitsdienst im Frühjahr, die +// Jahreshauptversammlung — und ein Termin, der abgesagt wurde, damit man sieht, +// wie das aussieht. +// +// Bewusst KEINE Zustellkanäle im Seed: niemand wird ungefragt benachrichtigt, +// und das gilt auch für Beispieldaten. Wer Push ausprobieren will, richtet ihn +// unter /benachrichtigungen selbst ein — genau so, wie es später jedes +// Mitglied tut. +// ----------------------------------------------------------------------------- + +async function termineAnlegen(organisationId: string): Promise { + const vorhanden = await prisma.termin.count({ where: { organisationId } }); + if (vorhanden > 0) { + log("Termine bereits vorhanden — übersprungen."); + return; + } + + const start = new Date(); + start.setUTCHours(0, 0, 0, 0); + + /** Ein Zeitpunkt in n Tagen, zur angegebenen Stunde (Ortszeit-nah, UTC+1). */ + function inTagen(tage: number, stunde = 19, minute = 0): Date { + const wann = new Date(start); + wann.setUTCDate(wann.getUTCDate() + tage); + wann.setUTCHours(stunde - 1, minute, 0, 0); + return wann; + } + + const personen = await prisma.person.findMany({ + where: { organisationId, aktiv: true, geloeschtAm: null }, + orderBy: { mitgliedsnummer: "asc" }, + select: { id: true }, + }); + + const kommandant = await prisma.person.findFirst({ + where: { + organisationId, + rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } }, + }, + select: { id: true }, + }); + + // --- Der Übungsabend als Serie ------------------------------------------- + // + // Alle zwei Wochen, ein halbes Jahr im Voraus. Ausgeschrieben als einzelne + // Termine, nicht als Regel — warum, steht in lib/termin/serie.ts. + const ersterAbend = await prisma.termin.create({ + data: { + organisationId, + art: TerminArt.UEBUNG, + titel: "Übungsabend", + beschreibung: + "Regelmäßiger Übungsdienst der aktiven Mannschaft. Einsatzkleidung mitbringen.", + ort: "Gerätehaus Musterhausen", + beginn: inTagen(4), + ende: inTagen(4, 21), + leiterId: kommandant?.id ?? null, + serienmuster: Serienmuster.ZWEIWOECHENTLICH, + }, + }); + + await prisma.termin.update({ + where: { id: ersterAbend.id }, + data: { serieId: ersterAbend.id }, + }); + + const weitereAbende = serientermine({ + beginn: ersterAbend.beginn, + muster: Serienmuster.ZWEIWOECHENTLICH, + bis: inTagen(4 + 182), + }).slice(1); + + await prisma.termin.createMany({ + data: weitereAbende.map((zeitpunkt) => ({ + organisationId, + art: TerminArt.UEBUNG, + titel: "Übungsabend", + beschreibung: + "Regelmäßiger Übungsdienst der aktiven Mannschaft. Einsatzkleidung mitbringen.", + ort: "Gerätehaus Musterhausen", + beginn: zeitpunkt, + ende: new Date(zeitpunkt.getTime() + 2 * 3_600_000), + leiterId: kommandant?.id ?? null, + serieId: ersterAbend.id, + serienmuster: Serienmuster.ZWEIWOECHENTLICH, + })), + }); + + // --- Lehrgang mit Platzbegrenzung ---------------------------------------- + // + // Acht Plätze, elf Anmeldungen. Die Warteliste ist der Regelfall, nicht der + // Sonderfall — deshalb steht sie schon im Seed. + const lehrgang = await prisma.termin.create({ + data: { + organisationId, + art: TerminArt.LEHRGANG, + titel: "Maschinistenlehrgang Teil 1", + beschreibung: + "Pumpenkunde, Wasserförderung, Fahrzeugtechnik. Voraussetzung für die Ausbildung zum Maschinisten.", + ort: "Feuerwehrschule Landkreis", + beginn: inTagen(21, 8), + ende: inTagen(21, 17), + leiterId: kommandant?.id ?? null, + anmeldungNoetig: true, + teilnehmerLimit: 8, + anmeldeschluss: inTagen(14, 23, 59), + }, + }); + + const lehrgangsplaetze = personen.slice(0, 11); + + await prisma.terminanmeldung.createMany({ + data: lehrgangsplaetze.map((person, stelle) => ({ + organisationId, + terminId: lehrgang.id, + personId: person.id, + // Die ersten acht haben einen Platz, der Rest steht in der Reihenfolge + // der Anmeldung auf der Warteliste. + stand: stelle < 8 ? Teilnahmestand.ZUGESAGT : Teilnahmestand.WARTELISTE, + gemeldetAm: new Date(Date.now() - (11 - stelle) * 3_600_000), + })), + }); + + // --- Arbeitsdienst -------------------------------------------------------- + const arbeitsdienst = await prisma.termin.create({ + data: { + organisationId, + art: TerminArt.ARBEITSDIENST, + titel: "Frühjahrsputz am Gerätehaus", + beschreibung: + "Fahrzeughalle, Schlauchturm und Hof. Arbeitskleidung, kein Atemschutz nötig. Brotzeit ist gestellt.", + ort: "Gerätehaus Musterhausen", + beginn: inTagen(11, 9), + ende: inTagen(11, 13), + anmeldungNoetig: true, + }, + }); + + // Gemischte Rückmeldungen — mit Gründen, wie sie wirklich kommen. + const GRUENDE = ["Schicht", "Urlaub", "Kind krank", "auswärts"]; + + await prisma.terminanmeldung.createMany({ + data: personen.slice(0, 12).map((person, stelle) => { + const abgesagtVon = stelle % 4 === 3; + return { + organisationId, + terminId: arbeitsdienst.id, + personId: person.id, + stand: abgesagtVon ? Teilnahmestand.ABGESAGT : Teilnahmestand.ZUGESAGT, + grund: abgesagtVon ? (GRUENDE[stelle % GRUENDE.length] ?? null) : null, + }; + }), + }); + + // --- Jahreshauptversammlung ---------------------------------------------- + await prisma.termin.create({ + data: { + organisationId, + art: TerminArt.SITZUNG, + titel: "Jahreshauptversammlung", + beschreibung: + "Berichte des Kommandanten, des Kassiers und des Jugendwarts. Ehrungen, Neuwahl der Kassenprüfer.", + ort: "Gasthaus zur Post, Nebenzimmer", + beginn: inTagen(45, 19, 30), + ende: inTagen(45, 22), + leiterId: kommandant?.id ?? null, + }, + }); + + // --- Ein abgesagter Termin ------------------------------------------------ + // + // Damit gleich sichtbar ist, wie eine Absage aussieht: durchgestrichen im + // Kalender, mit Grund — nicht verschwunden. + const abgesagt = await prisma.termin.create({ + data: { + organisationId, + art: TerminArt.VERANSTALTUNG, + titel: "Waldfest der Feuerwehr", + beschreibung: "Aufbau ab 14 Uhr, Ausschank ab 17 Uhr.", + ort: "Festplatz am Mühlweg", + beginn: inTagen(30, 14), + ende: inTagen(30, 23), + abgesagtAm: new Date(), + absageGrund: "Dauerregen angekündigt, Festplatz nicht befahrbar. Neuer Termin folgt.", + }, + }); + + await prisma.terminanmeldung.createMany({ + data: personen.slice(0, 6).map((person) => ({ + organisationId, + terminId: abgesagt.id, + personId: person.id, + stand: Teilnahmestand.ZUGESAGT, + })), + }); + + // --- Ein vergangener Termin mit abgehakter Anwesenheit -------------------- + // + // Der Nachweis, um den es am Ende geht: wer war da. Mit zweien, die zugesagt + // hatten und nicht kamen — das ist der Fall, für den die Trennung von Zusage + // und Anwesenheit überhaupt existiert. + const vergangen = await prisma.termin.create({ + data: { + organisationId, + art: TerminArt.UEBUNG, + titel: "Übungsabend Löschangriff", + beschreibung: "Wasserentnahme aus offenem Gewässer, Löschangriff nach FwDV 3.", + ort: "Mühlweg, Saugstelle", + beginn: inTagen(-10), + ende: inTagen(-10, 21), + leiterId: kommandant?.id ?? null, + }, + }); + + await prisma.terminanmeldung.createMany({ + data: personen.slice(0, 14).map((person, stelle) => ({ + organisationId, + terminId: vergangen.id, + personId: person.id, + stand: Teilnahmestand.ZUGESAGT, + anwesend: stelle !== 5 && stelle !== 9, + })), + }); + + const gesamt = await prisma.termin.count({ where: { organisationId } }); + + log( + `Termine: ${gesamt} insgesamt (Übungsserie mit ${weitereAbende.length + 1} Abenden, ` + + `Lehrgang mit 8 Plätzen und ${lehrgangsplaetze.length - 8} auf der Warteliste, ` + + `ein abgesagter Termin, eine abgehakte Anwesenheitsliste)`, + ); +} + // ----------------------------------------------------------------------------- // Ablauf // ----------------------------------------------------------------------------- @@ -2794,6 +3039,9 @@ async function main(): Promise { // Phase 6 — Kartenmodule await kartendatenAnlegen(organisation.id); + // Phase 7 — Benachrichtigungen und Termine + await termineAnlegen(organisation.id); + log("Fertig."); log(""); log("Anmeldung für den Erststart:"); diff --git a/public/sw.js b/public/sw.js index e85932b..3d275e0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -161,3 +161,54 @@ self.addEventListener("message", (ereignis) => { ereignis.waitUntil(caches.delete(DATEN)); } }); + +/** + * Web-Push (Phase 7). + * + * Ein Push erreicht den Browser auch dann, wenn FLORIAN nicht offen ist — + * deshalb muss der Service Worker ihn annehmen. Er tut zwei Dinge und sonst + * nichts: die Meldung anzeigen und beim Antippen die passende Seite öffnen. + * + * `tag` sorgt dafür, dass eine neue Meldung zum selben Ereignis die alte + * ersetzt statt sich daneben zu legen. Sonst stapeln sich nachts fünf + * Erinnerungen an denselben Termin. + */ +self.addEventListener("push", (ereignis) => { + if (!ereignis.data) return; + + let inhalt = {}; + try { + inhalt = ereignis.data.json(); + } catch (fehler) { + inhalt = { titel: "FLORIAN", text: ereignis.data.text() }; + } + + ereignis.waitUntil( + self.registration.showNotification(inhalt.titel || "FLORIAN", { + body: inhalt.text || "", + icon: "/icons/florian-192.png", + badge: "/icons/florian-192.png", + tag: inhalt.kennzeichen || "florian", + renotify: Boolean(inhalt.kennzeichen), + data: { verweis: inhalt.verweis || "/" }, + }), + ); +}); + +self.addEventListener("notificationclick", (ereignis) => { + ereignis.notification.close(); + const ziel = (ereignis.notification.data && ereignis.notification.data.verweis) || "/"; + + ereignis.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((fenster) => { + // Ein schon offenes FLORIAN in den Vordergrund holen, statt ein zweites + // aufzumachen — sonst hat man nach einer Woche zwölf Tabs. + for (const eintrag of fenster) { + if (eintrag.url.includes(new URL(ziel, self.location.origin).pathname)) { + return eintrag.focus(); + } + } + return self.clients.openWindow(ziel); + }), + ); +}); diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index 53637b2..fb81e46 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -204,6 +204,32 @@ const AUFNAHMEN: Aufnahme[] = [ beschreibung: "Wasserförderung über lange Wegstrecke", angemeldet: true, }, + + // Phase 7 — Benachrichtigungen und Termine + { + name: "37-termine", + pfad: "/termine", + beschreibung: "Terminkalender mit Rückmeldung", + angemeldet: true, + }, + { + name: "38-benachrichtigungen", + pfad: "/benachrichtigungen", + beschreibung: "Eigene Zustellwege und Kalenderabonnement", + angemeldet: true, + }, + { + name: "39-vorlagen", + pfad: "/benachrichtigungen/vorlagen", + beschreibung: "Textvorlagen mit Platzhaltern und Vorschau", + angemeldet: true, + }, + { + name: "40-dienstausweis", + pfad: "/ausweis", + beschreibung: "Digitaler Dienstausweis mit QR-Code", + angemeldet: true, + }, ]; /** @@ -298,15 +324,41 @@ async function fertigGeladen(seite: Page): Promise { await seite.waitForTimeout(500); } +/** + * Anmelden — mit Wiederholung, weil der erste Versuch bei kaltem .next + * regelmaessig ins Leere geht. + * + * Der Grund ist keine Schwaeche der Anwendung, sondern der + * Entwicklungsserver: "networkidle" sagt, dass die Skripte geladen sind, nicht + * dass React sie schon ausgefuehrt hat. Wird davor geklickt, laeuft der + * onSubmit-Handler nicht, und das Formular schickt sich als gewoehnliches GET + * ab — erkennbar daran, dass Adresse und Passwort in der Adresszeile stehen. + * + * Statt blind eine Wartezeit einzubauen, wird das erkannt und der Versuch + * wiederholt. Beim zweiten Mal ist die Seite uebersetzt und hydriert. + */ async function anmelden(seite: Page): Promise { - await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" }); - await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL); - await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT); - await seite.getByRole("button", { name: "Anmelden" }).click(); - // Grosszuegig: bei kaltem .next uebersetzt "next dev" die Route erst beim - // ersten Aufruf, und das dauert deutlich laenger als eine Anmeldung. - await seite.waitForURL(`${BASIS}/`, { timeout: 90_000 }); - await seite.waitForLoadState("networkidle"); + for (let versuch = 1; versuch <= 3; versuch += 1) { + await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" }); + await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL); + await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT); + await seite.getByRole("button", { name: "Anmelden" }).click(); + + try { + // Grosszuegig: bei kaltem .next uebersetzt "next dev" jede Route erst + // beim ersten Aufruf, und das dauert deutlich laenger als eine Anmeldung. + await seite.waitForURL(`${BASIS}/`, { timeout: 90_000 }); + await seite.waitForLoadState("networkidle"); + return; + } catch (fehler) { + const nichtHydriert = seite.url().includes("?email="); + if (versuch === 3 || !nichtHydriert) throw fehler; + + console.warn( + `[screenshots] Anmeldung lief ins Leere (Seite war noch nicht hydriert) — Versuch ${versuch + 1}`, + ); + } + } } /** diff --git a/scripts/vapid.ts b/scripts/vapid.ts new file mode 100644 index 0000000..a296c7c --- /dev/null +++ b/scripts/vapid.ts @@ -0,0 +1,32 @@ +import webpush from "web-push"; + +/** + * VAPID-Schlüsselpaar für Web-Push erzeugen. + * + * npm run vapid + * + * Die Ausgabe kommt in die .env. Beide Schlüssel gehören ins Backup: + * + * * Der **öffentliche** wird dem Browser mitgegeben, wenn sich jemand für + * Push anmeldet. Wechselt er, sind alle bestehenden Abos wertlos — jeder + * müsste Push von Hand neu erlauben. Das merkt niemand von allein; die + * Meldungen bleiben einfach aus. + * * Der **private** signiert jede Nachricht an den Push-Dienst. Wer ihn hat, + * kann im Namen dieser Installation Meldungen schicken. + * + * Erzeugt wird lokal, ohne Netzzugriff — es ist reine Kryptografie. + */ + +const paar = webpush.generateVAPIDKeys(); + +console.log(` +Schlüsselpaar erzeugt. Diese Zeilen in die .env übernehmen: + +WEBPUSH_ENABLED=true +WEBPUSH_PUBLIC_KEY=${paar.publicKey} +WEBPUSH_PRIVATE_KEY=${paar.privateKey} +WEBPUSH_KONTAKT=mailto:kommandant@example.org + +Hinweis: Ein neuer öffentlicher Schlüssel macht alle bestehenden Push-Abos +wertlos. Beide Schlüssel gehören deshalb ins Backup — nicht ins Git. +`); diff --git a/src/app/(app)/ausweis/anzeige.tsx b/src/app/(app)/ausweis/anzeige.tsx new file mode 100644 index 0000000..80d0607 --- /dev/null +++ b/src/app/(app)/ausweis/anzeige.tsx @@ -0,0 +1,133 @@ +"use client"; + +import * as React from "react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { RefreshCw, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { alsDatum } from "@/lib/utils"; + +/** + * Die Ansicht des Dienstausweises. + * + * Sie ist bewusst wie ein Ausweis aufgebaut und nicht wie eine Datenmaske: + * groß der Name, darunter Wehr und Funktion, daneben der Code. Wer ihn + * vorzeigt, hält ein Telefon hin — der Gegenüber soll in zwei Sekunden + * erfassen, wen er vor sich hat. + * + * Der Code läuft nach fünf Minuten ab. Statt ihn stur zu erneuern zeigt die + * Seite an, wie lange er noch gilt, und holt sich kurz vorher einen neuen. + */ +export function AusweisAnzeige({ + bild, + name, + dienstgrad, + wehr, + qualifikationen, + gueltigSekunden, +}: { + bild: string; + name: string; + dienstgrad: string | null; + wehr: string; + qualifikationen: Array<{ name: string; kuerzel: string | null; gueltigBis: Date | null }>; + gueltigSekunden: number; +}) { + const router = useRouter(); + const [rest, setRest] = React.useState(gueltigSekunden); + + React.useEffect(() => { + setRest(gueltigSekunden); + + const zeitgeber = setInterval(() => { + setRest((wert) => { + if (wert <= 1) { + router.refresh(); + return gueltigSekunden; + } + return wert - 1; + }); + }, 1000); + + return () => clearInterval(zeitgeber); + }, [bild, gueltigSekunden, router]); + + const minuten = Math.floor(rest / 60); + const sekunden = rest % 60; + + return ( +
+
+

Dienstausweis

+

+ Zum Vorzeigen an der Absperrung, beim Lehrgang oder bei einer Kontrolle. +

+
+ + + + {name} + + {dienstgrad ? `${dienstgrad} · ` : ""} + {wehr} + + + + + Dienstausweis als QR-Code + +

+ Gültig noch {minuten > 0 ? `${minuten}:${String(sekunden).padStart(2, "0")} Minuten` : `${sekunden} Sekunden`} +

+ +
+
+
+ + {qualifikationen.length > 0 && ( +
+

Gültige Qualifikationen

+
    + {qualifikationen.map((eintrag) => ( +
  • + + {eintrag.kuerzel ?? eintrag.name} + {eintrag.gueltigBis ? ` bis ${alsDatum(eintrag.gueltigBis)}` : ""} + +
  • + ))} +
+
+ )} + + + + + +
+ +

+ Der Code enthält keine Daten über dich, sondern eine kurzlebige Unterschrift. Wer ihn + scannt, sieht Name, Wehr, Funktion und die gültigen Qualifikationen — nicht mehr. Nach + fünf Minuten ist er wertlos, ein Foto davon nützt also nichts. +

+
+
+ ); +} diff --git a/src/app/(app)/ausweis/page.tsx b/src/app/(app)/ausweis/page.tsx new file mode 100644 index 0000000..b34b32b --- /dev/null +++ b/src/app/(app)/ausweis/page.tsx @@ -0,0 +1,69 @@ +import { redirect } from "next/navigation"; +import { aktuelleSitzung } from "@/lib/auth"; +import { fuerOrganisation } from "@/lib/db"; +import { env } from "@/lib/env"; +import { AUSWEIS_GUELTIG_SEKUNDEN, ausweisErzeugen } from "@/lib/dienstausweis"; +import { qrTextAlsDataUri } from "@/lib/stammdaten/qr"; +import { dienstgradText } from "@/lib/stammdaten/texte"; +import { AusweisAnzeige } from "@/app/(app)/ausweis/anzeige"; + +export const metadata = { title: "Dienstausweis" }; +export const dynamic = "force-dynamic"; + +/** + * Der digitale Dienstausweis. + * + * Was ein Ausweis aus Karton kann, kann diese Seite auch: vorzeigen, wer man + * ist und zu welcher Wehr man gehört. Der Unterschied ist, dass der Prüfende + * den Code scannt und die Angaben von FLORIAN bestätigt bekommt statt von + * einem laminierten Stück Papier. + * + * Der Code wird auf dem Server signiert — mit AUTH_SECRET, das im Browser + * nichts zu suchen hat — und gilt fünf Minuten. + */ +export default async function AusweisSeite() { + const sitzung = await aktuelleSitzung(); + if (!sitzung) redirect("/anmelden"); + + const daten = await fuerOrganisation(sitzung.organisationId, async (db) => { + const person = await db.person.findUnique({ + where: { id: sitzung.personId }, + select: { + vorname: true, + nachname: true, + dienstgrad: true, + eintrittsdatum: true, + organisation: { select: { name: true, kurzname: true } }, + qualifikationen: { + where: { + geloeschtAm: null, + OR: [{ gueltigBis: null }, { gueltigBis: { gte: new Date() } }], + }, + orderBy: { erworbenAm: "desc" }, + select: { gueltigBis: true, typ: { select: { name: true, kuerzel: true } } }, + }, + }, + }); + + return person; + }); + + const code = ausweisErzeugen(sitzung.personId, sitzung.organisationId); + const adresse = `${env.APP_URL.replace(/\/+$/, "")}/ausweis/${encodeURIComponent(code)}`; + const bild = await qrTextAlsDataUri(adresse, 420); + + return ( + ({ + name: eintrag.typ.name, + kuerzel: eintrag.typ.kuerzel, + gueltigBis: eintrag.gueltigBis, + })) ?? []} + gueltigSekunden={AUSWEIS_GUELTIG_SEKUNDEN} + /> + ); +} diff --git a/src/app/(app)/benachrichtigungen/einstellungen.tsx b/src/app/(app)/benachrichtigungen/einstellungen.tsx new file mode 100644 index 0000000..2da0a76 --- /dev/null +++ b/src/app/(app)/benachrichtigungen/einstellungen.tsx @@ -0,0 +1,546 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { BellRing, Copy, Send, Trash2, TriangleAlert } from "lucide-react"; +import { EreignisTyp, Kanal } from "@prisma/client"; +import type { inferRouterOutputs } from "@trpc/server"; +import { trpc } from "@/lib/trpc/client"; +import type { AppRouter } from "@/server/routers/_app"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Fehlermeldung } from "@/components/stammdaten/anzeige"; +import { KANAL_EXTERN, KANAL_TEXT } from "@/lib/benachrichtigung/vorlagen"; +import { ZUSTELLSTAND_TEXT } from "@/lib/termin/texte"; +import { pushAbonnieren, pushMoeglich } from "@/lib/benachrichtigung/push-browser"; +import { alsDatumZeit } from "@/lib/utils"; + +/** + * Die Einstellseite. + * + * Aufgebaut nach dem, was jemand nacheinander tun will: + * + * 1. Einen Weg einrichten (und ihn sofort ausprobieren). + * 2. Ankreuzen, wofür er benutzt wird. + * 3. Den Kalender abonnieren. + * + * Der Probeknopf steht bewusst direkt an jedem Weg. Ohne ihn merkt jemand + * erst in drei Wochen, dass sein ntfy-Thema einen Tippfehler hat — dann, + * wenn die Nachricht wichtig gewesen wäre. + */ +export function BenachrichtigungEinstellungen({ darfVorlagen }: { darfVorlagen: boolean }) { + const werkzeuge = trpc.useUtils(); + const [meldung, setMeldung] = React.useState(null); + const [hinweis, setHinweis] = React.useState(null); + + const moeglichkeiten = trpc.benachrichtigung.moeglichkeiten.useQuery(); + const kanaele = trpc.benachrichtigung.eigeneKanaele.useQuery(); + const verlauf = trpc.benachrichtigung.eigeneListe.useQuery({ anzahl: 20 }); + + const aendern = trpc.benachrichtigung.kanalAendern.useMutation({ + onSuccess: () => void werkzeuge.benachrichtigung.invalidate(), + onError: (fehler) => setMeldung(fehler.message), + }); + + const loeschen = trpc.benachrichtigung.kanalLoeschen.useMutation({ + onSuccess: () => void werkzeuge.benachrichtigung.invalidate(), + onError: (fehler) => setMeldung(fehler.message), + }); + + const probe = trpc.benachrichtigung.probe.useMutation({ + onSuccess: (ergebnis) => setHinweis(ergebnis.hinweis), + onError: (fehler) => setMeldung(fehler.message), + }); + + const ereignisse = moeglichkeiten.data?.ereignisse ?? []; + + return ( +
+ {meldung && } + + {hinweis && ( +
+ {hinweis} +
+ )} + + + + Deine Zustellwege + + Ohne einen Weg hier bekommst du nichts — außer dem, was in FLORIAN selbst steht. + Niemand wird ungefragt benachrichtigt. + + + + + {kanaele.data?.length === 0 && ( +

+ Noch kein Zustellweg eingerichtet. +

+ )} + + {kanaele.data?.map((kanal) => ( +
+
+
+
+ {KANAL_TEXT[kanal.kanal]} + {kanal.bezeichnung && ( + + {kanal.bezeichnung} + + )} + {KANAL_EXTERN[kanal.kanal] && ( + + + extern + + )} + {!kanal.aktiv && stillgelegt} +
+ +

+ {kanal.letzteZustellung + ? `Zuletzt zugestellt: ${alsDatumZeit(kanal.letzteZustellung)}` + : "Noch nichts zugestellt."} + {kanal.fehlversuche > 0 && ` · ${kanal.fehlversuche} Fehlversuche`} +

+
+ +
+ + +
+
+ + {KANAL_EXTERN[kanal.kanal] && ( +

+ Über diesen Weg gehen nur Kurzfassungen ohne Namen, Adressen und + Einsatzdetails — er verlässt das Netz der Wehr. +

+ )} + +
+ Wofür? +
+ {ereignisse.map((ereignis) => { + const an = kanal.ereignisse.includes(ereignis.wert); + return ( + + ); + })} +
+
+
+ ))} + + void werkzeuge.benachrichtigung.invalidate()} + fehler={setMeldung} + /> +
+
+ + + + + + Zuletzt an dich verschickt + + Was FLORIAN dir geschickt hat — und ob es angekommen ist. + + + + {verlauf.data?.length === 0 ? ( +

Noch nichts verschickt.

+ ) : ( +
    + {verlauf.data?.map((eintrag) => ( +
  • + + {eintrag.betreff ?? "(ohne Betreff)"} + + {" "} + · {KANAL_TEXT[eintrag.kanal]} · {alsDatumZeit(eintrag.erstelltAm)} + + {eintrag.fehler && ( + {eintrag.fehler} + )} + + + {ZUSTELLSTAND_TEXT[eintrag.stand]} + +
  • + ))} +
+ )} +
+
+ + {darfVorlagen && ( + + + Textvorlagen der Wehr + + Was in den Nachrichten steht, gilt für alle. Ohne eigene Vorlage greifen die + eingebauten Texte. + + + + + + + )} +
+ ); +} + +/** + * Was die Oberfläche an Kanälen und Ereignissen anbieten darf. + * + * Direkt aus dem Router abgeleitet: kommt dort ein Kanal dazu, taucht er hier + * ohne Zutun auf — und wenn eines der Felder wegfällt, meckert der Compiler. + */ +type Moeglichkeiten = inferRouterOutputs["benachrichtigung"]["moeglichkeiten"]; + +/** + * Einen neuen Weg einrichten. + * + * Jeder Kanal braucht etwas anderes, und das ist der Grund, warum es hier ein + * Formular gibt statt eines einzelnen Knopfes: + * + * E-Mail nichts — es gilt die hinterlegte Adresse + * Telegram die Chat-Kennung, die der Bot ausgibt + * ntfy ein Thema, das man selbst wählt + * Web-Push gar nichts von Hand: der Browser fragt und liefert die Daten + */ +function KanalAnlegen({ + moeglichkeiten, + fertig, + fehler, +}: { + moeglichkeiten: Moeglichkeiten | undefined; + fertig: () => void; + fehler: (nachricht: string) => void; +}) { + const [kanal, setKanal] = React.useState(Kanal.MAIL); + const [ziel, setZiel] = React.useState(""); + const [bezeichnung, setBezeichnung] = React.useState(""); + + const anlegen = trpc.benachrichtigung.kanalAnlegen.useMutation({ + onSuccess: () => { + setZiel(""); + setBezeichnung(""); + fertig(); + }, + onError: (aufruf) => fehler(aufruf.message), + }); + + const eingerichtet = + moeglichkeiten?.kanaele.find((eintrag) => eintrag.wert === kanal)?.eingerichtet ?? true; + + async function speichern() { + // Web-Push ist der einzige Weg, bei dem der Browser die Adresse liefert: + // er fragt die Person um Erlaubnis und meldet sich beim Push-Dienst an. + if (kanal === Kanal.WEBPUSH) { + if (!moeglichkeiten?.webPushSchluessel) { + fehler("Für Web-Push fehlen die Schlüssel auf dem Server (WEBPUSH_PUBLIC_KEY)."); + return; + } + + const abo = await pushAbonnieren(moeglichkeiten.webPushSchluessel).catch((grund: Error) => { + fehler(grund.message); + return null; + }); + + if (!abo) return; + + anlegen.mutate({ + kanal, + ziel: abo.endpunkt, + p256dh: abo.p256dh, + auth: abo.auth, + bezeichnung: bezeichnung || "Dieser Browser", + ereignisse: [], + }); + return; + } + + anlegen.mutate({ + kanal, + ziel: kanal === Kanal.MAIL ? "" : ziel, + bezeichnung: bezeichnung || null, + ereignisse: [], + }); + } + + return ( +
+

+ + Weg hinzufügen +

+ +
+
+ + +
+ + {kanal !== Kanal.MAIL && kanal !== Kanal.WEBPUSH && ( +
+ + setZiel(ereignis.target.value)} + placeholder={ + kanal === Kanal.TELEGRAM + ? "123456789" + : (moeglichkeiten?.ntfyVorschlag ?? "florian-…") + } + /> +
+ )} + +
+ + setBezeichnung(ereignis.target.value)} + placeholder="Diensthandy" + /> +
+
+ + {kanal === Kanal.NTFY && moeglichkeiten?.ntfyServer && ( +

+ In der ntfy-App den Server {moeglichkeiten.ntfyServer} eintragen und + dieses Thema abonnieren. Der Themenname ist das Geheimnis — wer ihn kennt, liest mit. + Deshalb einen langen, zufälligen nehmen. +

+ )} + + {kanal === Kanal.WEBPUSH && !pushMoeglich() && ( +

+ Dieser Browser kann kein Web-Push. Auf dem iPhone geht es erst, wenn FLORIAN zum + Startbildschirm hinzugefügt wurde. +

+ )} + + {!eingerichtet && ( +

+ Dieser Weg ist auf dem Server nicht eingerichtet. Du kannst ihn eintragen, aber es + wird nichts ankommen, bis der Administrator ihn aktiviert. +

+ )} + + +
+ ); +} + +/** Die Kalenderadressen fürs Handy. */ +function Kalenderzugaenge({ fehler }: { fehler: (nachricht: string) => void }) { + const werkzeuge = trpc.useUtils(); + const [bezeichnung, setBezeichnung] = React.useState(""); + const [nurEigene, setNurEigene] = React.useState(false); + const [neueAdresse, setNeueAdresse] = React.useState(null); + + const zugaenge = trpc.benachrichtigung.kalenderzugaenge.useQuery(); + + const anlegen = trpc.benachrichtigung.kalenderzugangAnlegen.useMutation({ + onSuccess: (ergebnis) => { + setNeueAdresse(ergebnis.adresse); + setBezeichnung(""); + void werkzeuge.benachrichtigung.invalidate(); + }, + onError: (aufruf) => fehler(aufruf.message), + }); + + const widerrufen = trpc.benachrichtigung.kalenderzugangWiderrufen.useMutation({ + onSuccess: () => void werkzeuge.benachrichtigung.invalidate(), + onError: (aufruf) => fehler(aufruf.message), + }); + + return ( + + + Kalender abonnieren + + Die Termine der Wehr im Kalender des Handys — neben allem anderen, ohne FLORIAN zu + öffnen. Die Adresse ist ein Schlüssel: wer sie hat, sieht die Termine. + + + + + {neueAdresse && ( +
+

+ Diese Adresse wird nur jetzt angezeigt — bitte gleich eintragen. +

+
+ + +
+
+ )} + + {zugaenge.data?.map((zugang) => ( +
+
+ {zugang.bezeichnung} + {zugang.nurEigene && ( + + nur eigene + + )} + {!zugang.aktiv && ( + + widerrufen + + )} + + {zugang.zugriffe} Abrufe + {zugang.letzterZugriff && `, zuletzt ${alsDatumZeit(zugang.letzterZugriff)}`} + +
+ + {zugang.aktiv && ( + + )} +
+ ))} + +
+
+ + setBezeichnung(ereignis.target.value)} + placeholder="Mein Handy" + /> +
+ + + + +
+
+
+ ); +} diff --git a/src/app/(app)/benachrichtigungen/page.tsx b/src/app/(app)/benachrichtigungen/page.tsx new file mode 100644 index 0000000..a626f45 --- /dev/null +++ b/src/app/(app)/benachrichtigungen/page.tsx @@ -0,0 +1,33 @@ +import { redirect } from "next/navigation"; +import { aktuelleSitzung } from "@/lib/auth"; +import { hatRecht } from "@/lib/authz"; +import { BenachrichtigungEinstellungen } from "@/app/(app)/benachrichtigungen/einstellungen"; + +export const metadata = { title: "Benachrichtigungen" }; + +/** + * Die eigenen Benachrichtigungswege. + * + * Der Satz, der über der ganzen Seite steht und der auch die Technik darunter + * bestimmt: **niemand wird ungefragt benachrichtigt.** Wer nichts einträgt, + * bekommt nichts. Es gibt keine Voreinstellung, die alle anmeldet. + */ +export default async function BenachrichtigungenSeite() { + const sitzung = await aktuelleSitzung(); + if (!sitzung) redirect("/anmelden"); + + return ( +
+
+

Benachrichtigungen

+

+ Wo du worüber informiert werden willst — und der Kalender fürs Handy. +

+
+ + +
+ ); +} diff --git a/src/app/(app)/benachrichtigungen/vorlagen/bearbeiten.tsx b/src/app/(app)/benachrichtigungen/vorlagen/bearbeiten.tsx new file mode 100644 index 0000000..5ff57cb --- /dev/null +++ b/src/app/(app)/benachrichtigungen/vorlagen/bearbeiten.tsx @@ -0,0 +1,271 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { ArrowLeft, RotateCcw } from "lucide-react"; +import { EreignisTyp, Kanal } from "@prisma/client"; +import { trpc } from "@/lib/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Fehlermeldung } from "@/components/stammdaten/anzeige"; +import { EREIGNIS_TEXT, KANAL_EXTERN, KANAL_TEXT } from "@/lib/benachrichtigung/vorlagen"; + +/** + * Vorlagen bearbeiten. + * + * Der Aufbau folgt dem, was schiefgehen kann: links der Text, rechts die + * Vorschau mit Beispielwerten. Ein Platzhalter, den es für dieses Ereignis + * nicht gibt, bleibt in der Vorschau als `{tippfehler}` stehen — und wird beim + * Speichern abgelehnt, mit der Liste der erlaubten daneben. + */ +export function VorlagenBearbeiten() { + const werkzeuge = trpc.useUtils(); + + const [ereignis, setEreignis] = React.useState(EreignisTyp.TERMIN_NEU); + const [kanal, setKanal] = React.useState(Kanal.MAIL); + const [betreff, setBetreff] = React.useState(""); + const [text, setText] = React.useState(""); + const [meldung, setMeldung] = React.useState(null); + const [gespeichert, setGespeichert] = React.useState(false); + + /** + * Den passenden Text laden — aber nur einmal je Auswahl. + * + * Der Merker ist wichtiger, als er aussieht: die Liste wird im Hintergrund + * nachgeladen (nach dem Speichern, beim Zurückkommen ins Fenster). Ohne ihn + * würde jedes Nachladen den Text überschreiben, den gerade jemand tippt — + * ein Fehler, der sich beim Ausprobieren kaum zeigt und im Alltag ständig. + */ + const geladenFuer = React.useRef(null); + + const vorlagen = trpc.benachrichtigung.vorlagen.useQuery(); + + const vorschau = trpc.benachrichtigung.vorschau.useQuery( + { ereignis, kanal, betreff: betreff || null, text }, + { enabled: text.length > 0 }, + ); + + const speichern = trpc.benachrichtigung.vorlageSpeichern.useMutation({ + onSuccess: () => { + setGespeichert(true); + setMeldung(null); + void werkzeuge.benachrichtigung.invalidate(); + }, + onError: (fehler) => { + setGespeichert(false); + setMeldung(fehler.message); + }, + }); + + const zuruecksetzen = trpc.benachrichtigung.vorlageZuruecksetzen.useMutation({ + onSuccess: () => { + // Den Merker freigeben, damit der eingebaute Text wieder eingelesen wird. + geladenFuer.current = null; + void werkzeuge.benachrichtigung.invalidate(); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + const eigene = vorlagen.data?.eigene.find( + (eintrag) => eintrag.ereignis === ereignis && eintrag.kanal === kanal, + ); + + const standard = vorlagen.data?.standard.find((eintrag) => eintrag.ereignis === ereignis); + + React.useEffect(() => { + if (!vorlagen.data) return; + + const auswahl = `${ereignis}:${kanal}`; + if (geladenFuer.current === auswahl) return; + geladenFuer.current = auswahl; + + setGespeichert(false); + setMeldung(null); + + if (eigene) { + setBetreff(eigene.betreff ?? ""); + setText(eigene.text); + return; + } + + if (standard) { + setBetreff(standard.lang.betreff); + setText(KANAL_EXTERN[kanal] ? standard.lang.kurz : standard.lang.text); + } + }, [ereignis, kanal, eigene, standard, vorlagen.data]); + + return ( +
+ + +
+
+ + +
+ +
+ + +
+
+ + {KANAL_EXTERN[kanal] && ( +
+ {KANAL_TEXT[kanal]} verlässt das Netz der Wehr. Was hier steht, läuft + über einen fremden Server. Deshalb gehören dort keine Namen, keine Adressen und keine + Einsatzdetails hinein — ein Hinweis genügt, das Ausführliche steht in FLORIAN. +
+ )} + +
+ + + Text + + {eigene + ? "Eine eigene Vorlage ist hinterlegt." + : "Noch keine eigene Vorlage — hier steht der eingebaute Text."} + + + + + {kanal === Kanal.MAIL && ( +
+ + setBetreff(ereignisDaten.target.value)} + /> +
+ )} + +
+ +