Phase 7: Benachrichtigungen und Termine #8

Merged
florian.wilde merged 1 commits from feat/phase-7-benachrichtigungen into main 2026-09-04 07:19:14 +00:00
134 changed files with 9340 additions and 123 deletions
+70
View File
@@ -255,6 +255,76 @@ TELEGRAM_ENABLED=false
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID= 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) # LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+165 -1
View File
@@ -8,7 +8,171 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht] ## [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/<token>.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/<code>`.
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.
--- ---
+196 -4
View File
@@ -30,7 +30,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig | | Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
| Phase 5 — Atemschutz (FwDV 7) | fertig | | Phase 5 — Atemschutz (FwDV 7) | fertig |
| Phase 6 — Kartenmodule | fertig | | Phase 6 — Kartenmodule | fertig |
| Phase 7 — Benachrichtigungen und Termine | offen | | Phase 7 — Benachrichtigungen und Termine | fertig |
| Phase 8 — Lokale KI | offen | | Phase 8 — Lokale KI | offen |
| Phase 9 — Kassenverwaltung | 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 ## Befehle
```bash ```bash
@@ -798,6 +986,7 @@ npm run db:migrate # ausstehende Migrationen anwenden
npm run db:seed # Beispielwehr einspielen npm run db:seed # Beispielwehr einspielen
npm run db:studio # Prisma Studio npm run db:studio # Prisma Studio
npm run screenshots # Bilder für docs/ANLEITUNG.md erzeugen 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) ## 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. Schriften verwendet, die auf dem Gerät vorhanden sind.
- Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`, - Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`,
`AUDIT_AUFBEWAHRUNG_TAGE`, `PERSON_ANONYMISIERUNG_TAGE`). `AUDIT_AUFBEWAHRUNG_TAGE`, `PERSON_ANONYMISIERUNG_TAGE`).
- Externe Dienste (Mail, Telegram, LLM) sitzen hinter Schnittstellen in - Externe Dienste (Mail, Telegram, ntfy, Web-Push, LLM) sitzen hinter
`src/lib/integrations/` und sind einzeln abschaltbar. Ohne Freischaltung Schnittstellen in `src/lib/integrations/` und sind einzeln abschaltbar. Ohne
wird nur ins Log geschrieben. 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 ## Sicherheitshinweis
+52
View File
@@ -71,6 +71,18 @@ x-app-env: &app-env
TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false} TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} 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_ENABLED: ${LLM_ENABLED:-false}
LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_API_KEY: ${LLM_API_KEY:-} LLM_API_KEY: ${LLM_API_KEY:-}
@@ -306,6 +318,46 @@ services:
- intern - intern
logging: *logging 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). # Die Anwendung selbst (Next.js, Standalone-Build).
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus. # Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
+250 -11
View File
@@ -1,10 +1,10 @@
# Anleitung # 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. Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
> Benachrichtigungen, lokale KI und die Kassenverwaltung kommen in den > Die lokale KI und die Kassenverwaltung kommen in den folgenden Phasen dazu.
> folgenden Phasen dazu. Siehe [ROADMAP.md](ROADMAP.md). > 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 Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
eintragen oder weglassen. 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 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 `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 | | **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 | | **Atemschutzgerätewart** | zusätzlich Atemschutzgeräte, Träger und die Überwachung nach FwDV 7 |
| **Kassier** | Buchungen und Kassenabschluss (Modul noch nicht geplant, siehe Roadmap) | | **Kassier** | Buchungen und Kassenabschluss (Modul noch nicht geplant, siehe Roadmap) |
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen | | **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen |
| **Ausbilder** | Übungen planen, Teilnahme und Qualifikationen erfassen | | **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen |
| **Mitglied** | eigene Daten, Listen lesen | | **Mitglied** | eigene Daten, Listen lesen, Termine sehen und sich zurückmelden, eigene Benachrichtigungswege einrichten |
| **Terminal** | nur anmelden und erfassen — nie verwalten | | **Terminal** | nur anmelden und erfassen — nie verwalten |
Zwei Dinge sind bewusst so gebaut: 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. zusätzlich mit PIN oder Karte an und handelt dann unter seiner eigenen Person.
Das Terminal selbst ist nur die Tür. 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. 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, Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
was dahinter liegt. 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, Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich. 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 | | 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 | | 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 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 | | 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: Zustand aller Randsysteme auf einen Blick:
+22 -9
View File
@@ -1,7 +1,7 @@
# Aufwandsschätzung # Aufwandsschätzung
Schätzung je Phase, daneben der tatsächliche Aufwand aus dem 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 ## 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** | | 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** | | 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** | | 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 | | 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 | offen | | 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 | offen | | 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 | offen | | 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 | | 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen |
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen | | 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
| | **Summe** | **123–164 PT** | **28–37 h** | | | | **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 ## Phase 7 — Benachrichtigungen und Termine
**Vergleichsaufwand: 10–13 PT** **Vergleichsaufwand: 10–13 PT** · **Tatsächlich: 3:10 h Sitzungszeit**
| Baustein | PT | | 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 | | Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 |
| Abonnements je Person | 1,0 | | Abonnements je Person | 1,0 |
| Kalender mit Serienterminen und Selbstanmeldung | 3,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 | | Digitaler Dienstausweis mit signiertem QR und Prüfseite | 2,0 |
Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0 Das Grundgerüst der Kanalabstraktion stand bereits aus Phase 0
(`lib/integrations/`), das spart hier etwa einen Personentag. (`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 ## Phase 8 — Lokale KI
+120 -1
View File
@@ -18,7 +18,126 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 | | Phase 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 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.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
--- ---
+42 -14
View File
@@ -16,8 +16,8 @@ Phase 3 ████████████████████ fertig
Phase 4 ████████████████████ fertig 0.5.0 Phase 4 ████████████████████ fertig 0.5.0
Phase 5 ████████████████████ fertig 0.6.0 Phase 5 ████████████████████ fertig 0.6.0
Phase 6 ████████████████████ fertig 0.7.0 Phase 6 ████████████████████ fertig 0.7.0
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0 Phase 7 ████████████████████ fertig 0.8.0
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0 Phase 8 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.9.0
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0 Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.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 **Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
und was ihn hergibt. 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. Die Wehr weiß, wann der nächste Dienst ist — ohne dass jemand eine Rundmail
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar tippt.
- Kalender: Dienste, Übungen, Lehrgänge, Arbeitsdienste; Serientermine,
Selbstanmeldung mit Teilnehmerlimit, Anwesenheitsliste zum Ausdrucken - Terminkalender mit Dienst, Übung, Lehrgang, Arbeitsdienst, Veranstaltung und
- CalDAV-/ICS-Feed je Person Sitzung. Serientermine werden ausgeschrieben, nicht als Regel gespeichert.
- Digitaler Dienstausweis mit signiertem QR und Prüfseite - 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 ### Warum kein WhatsApp
Die WhatsApp Business API kostet je Konversation und verlangt einen Die WhatsApp Business API kostet je Konversation und verlangt einen
zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nummern aller Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nachrichten liefen
Mitglieder gingen an Meta, was sich mit einer Anwendung, die bewusst im über Server von Meta, die Wehr müsste einen Vertrag zur Auftragsverarbeitung
Gerätehaus steht, nicht verträgt. Telegram, ntfy und Web-Push decken denselben schließen, und die gewöhnliche App gleicht zusätzlich das Telefonbuch ab. Das
Bedarf ab, ntfy sogar vollständig selbst gehostet. 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. Alles gegen Ollama, kein externer Anbieter, komplett abschaltbar.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 792 KiB

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 KiB

After

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 KiB

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 KiB

After

Width:  |  Height:  |  Size: 904 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+127 -2
View File
@@ -42,6 +42,7 @@
"next": "^15.5.4", "next": "^15.5.4",
"next-auth": "^5.0.0-beta.29", "next-auth": "^5.0.0-beta.29",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.11",
"papaparse": "^5.7.0", "papaparse": "^5.7.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.1.1", "react": "^19.1.1",
@@ -49,6 +50,7 @@
"server-only": "^0.0.1", "server-only": "^0.0.1",
"superjson": "^2.2.6", "superjson": "^2.2.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
@@ -57,10 +59,12 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/node": "^22.18.6", "@types/node": "^22.18.6",
"@types/nodemailer": "^8.0.1",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.13", "@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.3", "@vitejs/plugin-react": "^5.0.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.25.10", "esbuild": "^0.25.10",
@@ -4445,6 +4449,16 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/papaparse": {
"version": "5.5.2", "version": "5.5.2",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
@@ -4485,6 +4499,16 @@
"@types/react": "^19.2.0" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.69.0", "version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz",
@@ -5331,7 +5355,6 @@
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
@@ -5663,6 +5686,18 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -5838,6 +5873,12 @@
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT" "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": { "node_modules/brace-expansion": {
"version": "1.1.18", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
@@ -5934,6 +5975,12 @@
"node": ">=8.0.0" "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": { "node_modules/buffer-indexof-polyfill": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", "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==", "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC" "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": { "node_modules/effect": {
"version": "3.21.0", "version": "3.21.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", "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": "^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": { "node_modules/http-proxy-agent": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -8304,7 +8369,6 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "agent-base": "^7.1.2",
@@ -9147,6 +9211,27 @@
"safe-buffer": "~5.1.0" "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": { "node_modules/kdbush": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
@@ -9814,6 +9899,12 @@
"node": ">=4" "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": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -10129,6 +10220,15 @@
"node": ">=18" "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": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -11338,6 +11438,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/sax": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
@@ -13912,6 +14018,25 @@
"node": ">=18" "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": { "node_modules/webidl-conversions": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "florian", "name": "florian",
"version": "0.7.0", "version": "0.8.0",
"private": true, "private": true,
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
"engines": { "engines": {
@@ -24,7 +24,8 @@
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"screenshots": "tsx scripts/screenshots.ts", "screenshots": "tsx scripts/screenshots.ts",
"icons": "tsx scripts/icons.ts" "icons": "tsx scripts/icons.ts",
"vapid": "tsx scripts/vapid.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.16.2", "@prisma/client": "^6.16.2",
@@ -61,6 +62,7 @@
"next": "^15.5.4", "next": "^15.5.4",
"next-auth": "^5.0.0-beta.29", "next-auth": "^5.0.0-beta.29",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.11",
"papaparse": "^5.7.0", "papaparse": "^5.7.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.1.1", "react": "^19.1.1",
@@ -68,6 +70,7 @@
"server-only": "^0.0.1", "server-only": "^0.0.1",
"superjson": "^2.2.6", "superjson": "^2.2.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
@@ -76,10 +79,12 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/node": "^22.18.6", "@types/node": "^22.18.6",
"@types/nodemailer": "^8.0.1",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.13", "@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.3", "@vitejs/plugin-react": "^5.0.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.25.10", "esbuild": "^0.25.10",
@@ -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;
@@ -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;
$$;
+312
View File
@@ -217,6 +217,12 @@ model Organisation {
lageelemente Lageelement[] lageelemente Lageelement[]
wasserfoerderungen Wasserfoerderung[] wasserfoerderungen Wasserfoerderung[]
foerderpunkte Foerderpunkt[] foerderpunkte Foerderpunkt[]
benachrichtigungsvorlages Benachrichtigungsvorlage[]
zustellkanals Zustellkanal[]
benachrichtigungs Benachrichtigung[]
termins Termin[]
terminanmeldungs Terminanmeldung[]
kalenderzugangs Kalenderzugang[]
@@index([aktiv]) @@index([aktiv])
@@map("organisation") @@map("organisation")
@@ -324,6 +330,13 @@ model Person {
gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner") gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner")
geplanteFoerderstrecken Wasserfoerderung[] @relation("WasserfoerderungPlaner") 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 /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein. /// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email]) @@unique([organisationId, email])
@@ -2964,3 +2977,302 @@ model Foerderpunkt {
@@index([organisationId, wasserfoerderungId]) @@index([organisationId, wasserfoerderungId])
@@map("foerderpunkt") @@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")
}
+249 -1
View File
@@ -15,7 +15,10 @@
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene * Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene
* Überwachung mit vollständigem Protokoll. Ab Phase 6 dazu: Koordinaten für * Überwachung mit vollständigem Protokoll. Ab Phase 6 dazu: Koordinaten für
* die Standorte, 24 Löschwasserstellen im Ausrückegebiet und drei Objekte mit * 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, * 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 * der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
@@ -57,13 +60,17 @@ import {
HydrantStatus, HydrantStatus,
HydrantTyp, HydrantTyp,
ObjektKategorie, ObjektKategorie,
Serienmuster,
StandortArt, StandortArt,
Teilnahmestand,
TerminArt,
TruppStatus, TruppStatus,
UebungsArt, UebungsArt,
UebungsartAtemschutz, UebungsartAtemschutz,
WaescheStatus, WaescheStatus,
} from "@prisma/client"; } from "@prisma/client";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { serientermine } from "../src/lib/termin/serie";
const prisma = new PrismaClient({ const prisma = new PrismaClient({
datasources: { 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<void> {
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 // Ablauf
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -2794,6 +3039,9 @@ async function main(): Promise<void> {
// Phase 6 — Kartenmodule // Phase 6 — Kartenmodule
await kartendatenAnlegen(organisation.id); await kartendatenAnlegen(organisation.id);
// Phase 7 — Benachrichtigungen und Termine
await termineAnlegen(organisation.id);
log("Fertig."); log("Fertig.");
log(""); log("");
log("Anmeldung für den Erststart:"); log("Anmeldung für den Erststart:");
+51
View File
@@ -161,3 +161,54 @@ self.addEventListener("message", (ereignis) => {
ereignis.waitUntil(caches.delete(DATEN)); 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);
}),
);
});
+54 -2
View File
@@ -204,6 +204,32 @@ const AUFNAHMEN: Aufnahme[] = [
beschreibung: "Wasserförderung über lange Wegstrecke", beschreibung: "Wasserförderung über lange Wegstrecke",
angemeldet: true, 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<void> {
await seite.waitForTimeout(500); 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<void> { async function anmelden(seite: Page): Promise<void> {
for (let versuch = 1; versuch <= 3; versuch += 1) {
await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" }); await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" });
await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL); await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT); await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT);
await seite.getByRole("button", { name: "Anmelden" }).click(); 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. 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.waitForURL(`${BASIS}/`, { timeout: 90_000 });
await seite.waitForLoadState("networkidle"); 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}`,
);
}
}
} }
/** /**
+32
View File
@@ -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.
`);
+133
View File
@@ -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 (
<div className="mx-auto max-w-md space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dienstausweis</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Zum Vorzeigen an der Absperrung, beim Lehrgang oder bei einer Kontrolle.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-xl">{name}</CardTitle>
<CardDescription>
{dienstgrad ? `${dienstgrad} · ` : ""}
{wehr}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Image
src={bild}
alt="Dienstausweis als QR-Code"
width={420}
height={420}
unoptimized
className="mx-auto h-auto w-full max-w-xs rounded-lg border border-[var(--border)] bg-white p-3"
/>
<p className="text-center text-sm text-[var(--muted-foreground)]" aria-live="polite">
Gültig noch {minuten > 0 ? `${minuten}:${String(sekunden).padStart(2, "0")} Minuten` : `${sekunden} Sekunden`}
</p>
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full bg-[var(--primary)] transition-all duration-1000"
style={{ width: `${(rest / gueltigSekunden) * 100}%` }}
role="presentation"
/>
</div>
{qualifikationen.length > 0 && (
<div className="space-y-2 border-t border-[var(--border)] pt-4">
<p className="text-sm font-medium">Gültige Qualifikationen</p>
<ul className="flex flex-wrap gap-2">
{qualifikationen.map((eintrag) => (
<li key={`${eintrag.name}-${eintrag.gueltigBis?.toISOString() ?? "unbefristet"}`}>
<Badge variant="secondary" title={eintrag.name}>
{eintrag.kuerzel ?? eintrag.name}
{eintrag.gueltigBis ? ` bis ${alsDatum(eintrag.gueltigBis)}` : ""}
</Badge>
</li>
))}
</ul>
</div>
)}
<Button variant="outline" className="w-full" onClick={() => router.refresh()}>
<RefreshCw aria-hidden />
Neuen Code anzeigen
</Button>
</CardContent>
</Card>
<div className="flex gap-3 rounded-lg border border-[var(--border)] p-4 text-sm text-[var(--muted-foreground)]">
<ShieldCheck className="mt-0.5 size-5 shrink-0" aria-hidden />
<p>
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.
</p>
</div>
</div>
);
}
+69
View File
@@ -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 (
<AusweisAnzeige
bild={bild}
name={daten ? `${daten.vorname} ${daten.nachname}` : sitzung.name}
dienstgrad={dienstgradText(daten?.dienstgrad)}
wehr={daten?.organisation.name ?? ""}
qualifikationen={daten?.qualifikationen.map((eintrag) => ({
name: eintrag.typ.name,
kuerzel: eintrag.typ.kuerzel,
gueltigBis: eintrag.gueltigBis,
})) ?? []}
gueltigSekunden={AUSWEIS_GUELTIG_SEKUNDEN}
/>
);
}
@@ -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<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(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 (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="benachrichtigungsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<Card>
<CardHeader>
<CardTitle>Deine Zustellwege</CardTitle>
<CardDescription>
Ohne einen Weg hier bekommst du nichts — außer dem, was in FLORIAN selbst steht.
Niemand wird ungefragt benachrichtigt.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{kanaele.data?.length === 0 && (
<p className="text-sm text-[var(--muted-foreground)]">
Noch kein Zustellweg eingerichtet.
</p>
)}
{kanaele.data?.map((kanal) => (
<div
key={kanal.id}
className="space-y-3 rounded-lg border border-[var(--border)] p-4"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{KANAL_TEXT[kanal.kanal]}</span>
{kanal.bezeichnung && (
<span className="text-sm text-[var(--muted-foreground)]">
{kanal.bezeichnung}
</span>
)}
{KANAL_EXTERN[kanal.kanal] && (
<Badge variant="pruefungFaellig" title="Läuft über einen fremden Server">
<TriangleAlert className="mr-1 size-3" aria-hidden />
extern
</Badge>
)}
{!kanal.aktiv && <Badge variant="gesperrt">stillgelegt</Badge>}
</div>
<p className="text-xs text-[var(--muted-foreground)]">
{kanal.letzteZustellung
? `Zuletzt zugestellt: ${alsDatumZeit(kanal.letzteZustellung)}`
: "Noch nichts zugestellt."}
{kanal.fehlversuche > 0 && ` · ${kanal.fehlversuche} Fehlversuche`}
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={probe.isPending}
onClick={() => {
setHinweis(null);
probe.mutate({ id: kanal.id });
}}
>
<Send aria-hidden />
Probe
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => loeschen.mutate({ id: kanal.id })}
aria-label={`${KANAL_TEXT[kanal.kanal]} entfernen`}
>
<Trash2 aria-hidden />
</Button>
</div>
</div>
{KANAL_EXTERN[kanal.kanal] && (
<p className="text-xs text-[var(--muted-foreground)]">
Über diesen Weg gehen nur Kurzfassungen ohne Namen, Adressen und
Einsatzdetails — er verlässt das Netz der Wehr.
</p>
)}
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Wofür?</legend>
<div className="grid gap-2 sm:grid-cols-2">
{ereignisse.map((ereignis) => {
const an = kanal.ereignisse.includes(ereignis.wert);
return (
<label key={ereignis.wert} className="flex items-center gap-2 text-sm">
<Checkbox
checked={an}
onCheckedChange={(wert) =>
aendern.mutate({
id: kanal.id,
ereignisse:
wert === true
? [...kanal.ereignisse, ereignis.wert]
: kanal.ereignisse.filter((e) => e !== ereignis.wert),
})
}
/>
{ereignis.text}
</label>
);
})}
</div>
</fieldset>
</div>
))}
<KanalAnlegen
moeglichkeiten={moeglichkeiten.data}
fertig={() => void werkzeuge.benachrichtigung.invalidate()}
fehler={setMeldung}
/>
</CardContent>
</Card>
<Kalenderzugaenge fehler={setMeldung} />
<Card>
<CardHeader>
<CardTitle>Zuletzt an dich verschickt</CardTitle>
<CardDescription>
Was FLORIAN dir geschickt hat — und ob es angekommen ist.
</CardDescription>
</CardHeader>
<CardContent>
{verlauf.data?.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Noch nichts verschickt.</p>
) : (
<ul className="space-y-2 text-sm">
{verlauf.data?.map((eintrag) => (
<li
key={eintrag.id}
className="flex flex-wrap items-baseline justify-between gap-2 border-b border-[var(--border)] pb-2"
>
<span className="min-w-0">
<span className="font-medium">{eintrag.betreff ?? "(ohne Betreff)"}</span>
<span className="text-[var(--muted-foreground)]">
{" "}
· {KANAL_TEXT[eintrag.kanal]} · {alsDatumZeit(eintrag.erstelltAm)}
</span>
{eintrag.fehler && (
<span className="block text-[var(--destructive)]">{eintrag.fehler}</span>
)}
</span>
<Badge
variant={
eintrag.stand === "GESENDET"
? "einsatzbereit"
: eintrag.stand === "FEHLGESCHLAGEN"
? "gesperrt"
: "secondary"
}
>
{ZUSTELLSTAND_TEXT[eintrag.stand]}
</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{darfVorlagen && (
<Card>
<CardHeader>
<CardTitle>Textvorlagen der Wehr</CardTitle>
<CardDescription>
Was in den Nachrichten steht, gilt für alle. Ohne eigene Vorlage greifen die
eingebauten Texte.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" asChild>
<Link href="/benachrichtigungen/vorlagen">Vorlagen bearbeiten</Link>
</Button>
</CardContent>
</Card>
)}
</div>
);
}
/**
* 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<AppRouter>["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>(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 (
<div className="space-y-3 rounded-lg border border-dashed border-[var(--border)] p-4">
<p className="flex items-center gap-2 text-sm font-medium">
<BellRing className="size-4" aria-hidden />
Weg hinzufügen
</p>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="kanal-art">Über</Label>
<Select value={kanal} onValueChange={(wert) => setKanal(wert as Kanal)}>
<SelectTrigger id="kanal-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{moeglichkeiten?.kanaele.map((eintrag) => (
<SelectItem key={eintrag.wert} value={eintrag.wert}>
{eintrag.text}
{!eintrag.eingerichtet && " (nicht eingerichtet)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{kanal !== Kanal.MAIL && kanal !== Kanal.WEBPUSH && (
<div className="space-y-2">
<Label htmlFor="kanal-ziel">
{kanal === Kanal.TELEGRAM ? "Chat-Kennung" : "Thema"}
</Label>
<Input
id="kanal-ziel"
value={ziel}
onChange={(ereignis) => setZiel(ereignis.target.value)}
placeholder={
kanal === Kanal.TELEGRAM
? "123456789"
: (moeglichkeiten?.ntfyVorschlag ?? "florian-…")
}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="kanal-bezeichnung">Name (freiwillig)</Label>
<Input
id="kanal-bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Diensthandy"
/>
</div>
</div>
{kanal === Kanal.NTFY && moeglichkeiten?.ntfyServer && (
<p className="text-xs text-[var(--muted-foreground)]">
In der ntfy-App den Server <code>{moeglichkeiten.ntfyServer}</code> eintragen und
dieses Thema abonnieren. Der Themenname ist das Geheimnis — wer ihn kennt, liest mit.
Deshalb einen langen, zufälligen nehmen.
</p>
)}
{kanal === Kanal.WEBPUSH && !pushMoeglich() && (
<p className="text-xs text-[var(--muted-foreground)]">
Dieser Browser kann kein Web-Push. Auf dem iPhone geht es erst, wenn FLORIAN zum
Startbildschirm hinzugefügt wurde.
</p>
)}
{!eingerichtet && (
<p className="text-xs text-[var(--pruefung-faellig)]">
Dieser Weg ist auf dem Server nicht eingerichtet. Du kannst ihn eintragen, aber es
wird nichts ankommen, bis der Administrator ihn aktiviert.
</p>
)}
<Button
size="sm"
onClick={() => void speichern()}
disabled={
anlegen.isPending ||
(kanal !== Kanal.MAIL && kanal !== Kanal.WEBPUSH && !ziel.trim())
}
>
{anlegen.isPending ? "Wird eingerichtet …" : "Einrichten"}
</Button>
</div>
);
}
/** 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<string | null>(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 (
<Card>
<CardHeader>
<CardTitle>Kalender abonnieren</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{neueAdresse && (
<div className="space-y-2 rounded-md border border-[var(--primary)] bg-[var(--muted)] p-3">
<p className="text-sm font-medium">
Diese Adresse wird nur jetzt angezeigt — bitte gleich eintragen.
</p>
<div className="flex gap-2">
<Input readOnly value={neueAdresse} className="font-mono text-xs" />
<Button
variant="outline"
size="icon"
aria-label="Adresse kopieren"
onClick={() => void navigator.clipboard?.writeText(neueAdresse)}
>
<Copy aria-hidden />
</Button>
</div>
</div>
)}
{zugaenge.data?.map((zugang) => (
<div
key={zugang.id}
className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--border)] pb-2 text-sm"
>
<div>
<span className="font-medium">{zugang.bezeichnung}</span>
{zugang.nurEigene && (
<Badge variant="secondary" className="ml-2">
nur eigene
</Badge>
)}
{!zugang.aktiv && (
<Badge variant="gesperrt" className="ml-2">
widerrufen
</Badge>
)}
<span className="block text-xs text-[var(--muted-foreground)]">
{zugang.zugriffe} Abrufe
{zugang.letzterZugriff && `, zuletzt ${alsDatumZeit(zugang.letzterZugriff)}`}
</span>
</div>
{zugang.aktiv && (
<Button
size="sm"
variant="ghost"
onClick={() => widerrufen.mutate({ id: zugang.id })}
>
Widerrufen
</Button>
)}
</div>
))}
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-2">
<Label htmlFor="kalender-name">Für welches Gerät?</Label>
<Input
id="kalender-name"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Mein Handy"
/>
</div>
<label className="flex items-center gap-2 pb-2 text-sm">
<Checkbox
checked={nurEigene}
onCheckedChange={(wert) => setNurEigene(wert === true)}
/>
Nur Termine mit meiner Rückmeldung
</label>
<Button
className="mb-0.5"
disabled={!bezeichnung.trim() || anlegen.isPending}
onClick={() => anlegen.mutate({ bezeichnung, nurEigene })}
>
Adresse erzeugen
</Button>
</div>
</CardContent>
</Card>
);
}
+33
View File
@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Benachrichtigungen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Wo du worüber informiert werden willst — und der Kalender fürs Handy.
</p>
</div>
<BenachrichtigungEinstellungen
darfVorlagen={hatRecht(sitzung, "benachrichtigung.vorlagen")}
/>
</div>
);
}
@@ -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>(EreignisTyp.TERMIN_NEU);
const [kanal, setKanal] = React.useState<Kanal>(Kanal.MAIL);
const [betreff, setBetreff] = React.useState("");
const [text, setText] = React.useState("");
const [meldung, setMeldung] = React.useState<string | null>(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<string | null>(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 (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link href="/benachrichtigungen">
<ArrowLeft aria-hidden />
Zurück zu den Benachrichtigungen
</Link>
</Button>
<div className="flex flex-wrap gap-4">
<div className="space-y-2">
<Label htmlFor="vorlage-ereignis">Wofür</Label>
<Select value={ereignis} onValueChange={(wert) => setEreignis(wert as EreignisTyp)}>
<SelectTrigger id="vorlage-ereignis" className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(EreignisTyp).map((wert) => (
<SelectItem key={wert} value={wert}>
{EREIGNIS_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="vorlage-kanal">Über</Label>
<Select value={kanal} onValueChange={(wert) => setKanal(wert as Kanal)}>
<SelectTrigger id="vorlage-kanal" className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(Kanal).map((wert) => (
<SelectItem key={wert} value={wert}>
{KANAL_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{KANAL_EXTERN[kanal] && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
<strong>{KANAL_TEXT[kanal]} verlässt das Netz der Wehr.</strong> 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.
</div>
)}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Text</CardTitle>
<CardDescription>
{eigene
? "Eine eigene Vorlage ist hinterlegt."
: "Noch keine eigene Vorlage — hier steht der eingebaute Text."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{kanal === Kanal.MAIL && (
<div className="space-y-2">
<Label htmlFor="vorlage-betreff">Betreff</Label>
<Input
id="vorlage-betreff"
value={betreff}
onChange={(ereignisDaten) => setBetreff(ereignisDaten.target.value)}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="vorlage-text">Nachricht</Label>
<Textarea
id="vorlage-text"
rows={10}
value={text}
onChange={(ereignisDaten) => setText(ereignisDaten.target.value)}
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Verfügbare Platzhalter</p>
<div className="flex flex-wrap gap-1.5">
{standard?.platzhalter.map((name) => (
<button
key={name}
type="button"
onClick={() => setText((bisher) => `${bisher}{${name}}`)}
title="Anhängen"
>
<Badge variant="outline" className="font-mono">
{`{${name}}`}
</Badge>
</button>
))}
</div>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="vorlagenfehler" />}
{gespeichert && (
<p className="text-sm text-[var(--einsatzbereit)]">Vorlage gespeichert.</p>
)}
<div className="flex gap-2">
<Button
onClick={() =>
speichern.mutate({
ereignis,
kanal,
betreff: kanal === Kanal.MAIL ? betreff || null : null,
text,
aktiv: true,
})
}
disabled={!text.trim() || speichern.isPending}
>
{speichern.isPending ? "Wird gespeichert …" : "Speichern"}
</Button>
{eigene && (
<Button
variant="outline"
onClick={() => zuruecksetzen.mutate({ id: eigene.id })}
disabled={zuruecksetzen.isPending}
>
<RotateCcw aria-hidden />
Auf Standard zurücksetzen
</Button>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Vorschau</CardTitle>
<CardDescription>
Mit erfundenen Beispielwerten. Bleibt ein Platzhalter stehen, gibt es ihn für
dieses Ereignis nicht.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-4">
{kanal === Kanal.MAIL && vorschau.data?.betreff && (
<p className="mb-3 border-b border-[var(--border)] pb-2 font-medium">
{vorschau.data.betreff}
</p>
)}
<p className="whitespace-pre-wrap text-sm">
{vorschau.data?.text ?? "Noch kein Text."}
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { VorlagenBearbeiten } from "@/app/(app)/benachrichtigungen/vorlagen/bearbeiten";
export const metadata = { title: "Textvorlagen" };
/**
* Die Textvorlagen der Wehr.
*
* Was hier steht, bekommen alle. Deshalb hängt die Seite an einem eigenen
* Recht und nicht am Grundrecht, mit dem jeder seine eigenen Wege einstellt.
*/
export default async function VorlagenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "benachrichtigung.vorlagen")) redirect("/benachrichtigungen");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Textvorlagen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Was in den Benachrichtigungen steht. Ohne eigene Vorlage greift der eingebaute Text.
</p>
</div>
<VorlagenBearbeiten />
</div>
);
}
+1 -14
View File
@@ -9,23 +9,10 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige"; import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
export const metadata = { title: "Mitglied" }; export const metadata = { title: "Mitglied" };
const DIENSTGRAD_TEXT: Record<string, string> = {
FEUERWEHRANWAERTER: "Feuerwehranwärter",
FEUERWEHRMANN: "Feuerwehrmann",
OBERFEUERWEHRMANN: "Oberfeuerwehrmann",
HAUPTFEUERWEHRMANN: "Hauptfeuerwehrmann",
LOESCHMEISTER: "Löschmeister",
OBERLOESCHMEISTER: "Oberlöschmeister",
HAUPTLOESCHMEISTER: "Hauptlöschmeister",
BRANDMEISTER: "Brandmeister",
OBERBRANDMEISTER: "Oberbrandmeister",
HAUPTBRANDMEISTER: "Hauptbrandmeister",
KEIN: "—",
};
/** /**
* Mitgliedsdetail. * Mitgliedsdetail.
* *
+1 -14
View File
@@ -14,23 +14,10 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { NichtsGefunden } from "@/components/stammdaten/anzeige"; import { NichtsGefunden } from "@/components/stammdaten/anzeige";
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
export const metadata = { title: "Mitglieder" }; export const metadata = { title: "Mitglieder" };
const DIENSTGRAD_TEXT: Record<string, string> = {
FEUERWEHRANWAERTER: "Feuerwehranwärter",
FEUERWEHRMANN: "Feuerwehrmann",
OBERFEUERWEHRMANN: "Oberfeuerwehrmann",
HAUPTFEUERWEHRMANN: "Hauptfeuerwehrmann",
LOESCHMEISTER: "Löschmeister",
OBERLOESCHMEISTER: "Oberlöschmeister",
HAUPTLOESCHMEISTER: "Hauptlöschmeister",
BRANDMEISTER: "Brandmeister",
OBERBRANDMEISTER: "Oberbrandmeister",
HAUPTBRANDMEISTER: "Hauptbrandmeister",
KEIN: "—",
};
/** /**
* Mitgliederliste, nach Abteilung gruppiert. * Mitgliederliste, nach Abteilung gruppiert.
* *
+21
View File
@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { TerminDetail } from "@/app/(app)/termine/[id]/termin-detail";
export const metadata = { title: "Termin" };
export default async function TerminSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
return (
<TerminDetail
id={id}
darfBearbeiten={hatRecht(sitzung, "termin.bearbeiten")}
darfAnwesenheit={hatRecht(sitzung, "termin.anwesenheit")}
/>
);
}
@@ -0,0 +1,500 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, ClipboardList, Printer, Repeat } from "lucide-react";
import { Teilnahmestand } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { TEILNAHME_TEXT, TERMINART_TEXT, nochHin } from "@/lib/termin/texte";
import { alsDatum, alsUhrzeit } from "@/lib/utils";
/**
* Ein einzelner Termin.
*
* Drei Dinge passieren hier, und sie gehören zu verschiedenen Zeitpunkten:
* zusagen (vorher), absagen (vorher, durch die Leitung), Anwesenheit abhaken
* (nachher). Die Ansicht zeigt deshalb nach dem Termin einen anderen
* Schwerpunkt als davor.
*/
export function TerminDetail({
id,
darfBearbeiten,
darfAnwesenheit,
}: {
id: string;
darfBearbeiten: boolean;
darfAnwesenheit: boolean;
}) {
const router = useRouter();
const werkzeuge = trpc.useUtils();
const [meldung, setMeldung] = React.useState<string | null>(null);
const [absageOffen, setAbsageOffen] = React.useState(false);
const [listeOffen, setListeOffen] = React.useState(false);
const termin = trpc.termin.einzeln.useQuery({ id });
const rueckmelden = trpc.termin.rueckmelden.useMutation({
onSuccess: () => void werkzeuge.termin.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
if (termin.isLoading) {
return <div className="h-64 animate-pulse rounded-lg bg-[var(--muted)]" aria-hidden />;
}
if (termin.error || !termin.data) {
return <Fehlermeldung meldung={termin.error?.message ?? "Termin nicht gefunden."} />;
}
const daten = termin.data;
const abgesagt = Boolean(daten.abgesagtAm);
const vorbei = daten.beginn.getTime() < Date.now();
const zugesagt = daten.anmeldungen.filter((e) => e.stand === Teilnahmestand.ZUGESAGT);
const warteliste = daten.anmeldungen.filter((e) => e.stand === Teilnahmestand.WARTELISTE);
const abgemeldet = daten.anmeldungen.filter((e) => e.stand === Teilnahmestand.ABGESAGT);
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild className="nicht-drucken">
<Link href="/termine">
<ArrowLeft aria-hidden />
Zurück zu den Terminen
</Link>
</Button>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{daten.titel}</h1>
<Badge variant="outline">{TERMINART_TEXT[daten.art]}</Badge>
{daten.serieId && (
<Badge variant="secondary">
<Repeat className="mr-1 size-3" aria-hidden />
Teil einer Serie
</Badge>
)}
{abgesagt && <Badge variant="gesperrt">Abgesagt</Badge>}
</div>
<p className="text-[var(--muted-foreground)]">
{alsDatum(daten.beginn)}
{!daten.ganztaegig && `, ${alsUhrzeit(daten.beginn)} Uhr`}
{daten.ende && !daten.ganztaegig && ` bis ${alsUhrzeit(daten.ende)} Uhr`}
{!vorbei && ` · ${nochHin(daten.beginn)}`}
</p>
</div>
<div className="flex flex-wrap gap-2 nicht-drucken">
<Button variant="outline" onClick={() => setListeOffen(true)}>
<ClipboardList aria-hidden />
Anwesenheitsliste
</Button>
{darfBearbeiten && !abgesagt && (
<Button variant="outline" onClick={() => setAbsageOffen(true)}>
Termin absagen
</Button>
)}
</div>
</div>
{abgesagt && (
<div
role="alert"
className="rounded-md border border-[var(--gesperrt)]/40 bg-[var(--gesperrt)]/10 p-4 text-sm"
>
<p className="font-medium">Dieser Termin fällt aus.</p>
{daten.absageGrund && <p className="mt-1">Grund: {daten.absageGrund}</p>}
</div>
)}
{meldung && <Fehlermeldung meldung={meldung} />}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Angaben</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<Feld titel="Ort" wert={daten.ort} />
<Feld
titel="Leitung"
wert={daten.leiter ? `${daten.leiter.vorname} ${daten.leiter.nachname}` : null}
/>
<Feld
titel="Rückmeldung"
wert={daten.anmeldungNoetig ? "wird erwartet" : "nicht nötig"}
/>
<Feld
titel="Plätze"
wert={
daten.teilnehmerLimit
? `${zugesagt.length} von ${daten.teilnehmerLimit} belegt`
: "unbegrenzt"
}
/>
{daten.anmeldeschluss && (
<Feld titel="Anmeldeschluss" wert={alsDatum(daten.anmeldeschluss)} />
)}
</dl>
{daten.beschreibung && (
<div className="border-t border-[var(--border)] pt-4">
<p className="whitespace-pre-wrap text-sm">{daten.beschreibung}</p>
</div>
)}
</CardContent>
</Card>
<Card className="nicht-drucken">
<CardHeader>
<CardTitle>Deine Rückmeldung</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{abgesagt ? (
<p className="text-sm text-[var(--muted-foreground)]">
Der Termin ist abgesagt — eine Rückmeldung ändert daran nichts.
</p>
) : (
<>
<p className="text-sm">
{daten.eigenerStand
? `Aktuell: ${TEILNAHME_TEXT[daten.eigenerStand]}`
: "Noch keine Rückmeldung."}
</p>
<div className="flex gap-2">
<Button
className="flex-1"
variant={daten.eigenerStand === "ZUGESAGT" ? "default" : "outline"}
disabled={rueckmelden.isPending}
onClick={() => rueckmelden.mutate({ terminId: id, stand: "ZUGESAGT" })}
>
Zusagen
</Button>
<Button
className="flex-1"
variant={daten.eigenerStand === "ABGESAGT" ? "default" : "outline"}
disabled={rueckmelden.isPending}
onClick={() => rueckmelden.mutate({ terminId: id, stand: "ABGESAGT" })}
>
Absagen
</Button>
</div>
{daten.freiePlaetze === 0 && daten.eigenerStand !== "ZUGESAGT" && (
<p className="text-sm text-[var(--muted-foreground)]">
Die Plätze sind belegt. Eine Zusage stellt dich auf die Warteliste — du
rückst nach, sobald jemand absagt.
</p>
)}
</>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-3">
<Teilnehmerliste titel={`Zugesagt (${zugesagt.length})`} eintraege={zugesagt} />
{warteliste.length > 0 && (
<Teilnehmerliste
titel={`Warteliste (${warteliste.length})`}
eintraege={warteliste}
nummeriert
/>
)}
<Teilnehmerliste titel={`Abgesagt (${abgemeldet.length})`} eintraege={abgemeldet} mitGrund />
</div>
{darfBearbeiten && (
<AbsageDialog
offen={absageOffen}
terminId={id}
inSerie={Boolean(daten.serieId)}
schliessen={() => setAbsageOffen(false)}
fertig={() => {
setAbsageOffen(false);
void werkzeuge.termin.invalidate();
router.refresh();
}}
/>
)}
<AnwesenheitDialog
offen={listeOffen}
terminId={id}
darfAendern={darfAnwesenheit}
schliessen={() => setListeOffen(false)}
/>
</div>
);
}
function Feld({ titel, wert }: { titel: string; wert: string | null }) {
return (
<div>
<dt className="text-[var(--muted-foreground)]">{titel}</dt>
<dd className="font-medium">{wert ?? "—"}</dd>
</div>
);
}
function Teilnehmerliste({
titel,
eintraege,
nummeriert = false,
mitGrund = false,
}: {
titel: string;
eintraege: Array<{
id: string;
grund: string | null;
person: { vorname: string; nachname: string };
}>;
nummeriert?: boolean;
mitGrund?: boolean;
}) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{titel}</CardTitle>
</CardHeader>
<CardContent>
{eintraege.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Niemand.</p>
) : (
<ol className="space-y-1 text-sm">
{eintraege.map((eintrag, stelle) => (
<li key={eintrag.id}>
{nummeriert && (
<span className="text-[var(--muted-foreground)]">{stelle + 1}. </span>
)}
{eintrag.person.nachname}, {eintrag.person.vorname}
{mitGrund && eintrag.grund && (
<span className="text-[var(--muted-foreground)]"> — {eintrag.grund}</span>
)}
</li>
))}
</ol>
)}
</CardContent>
</Card>
);
}
/** Absage mit Grund — und der Frage, ob die ganze Serie gemeint ist. */
function AbsageDialog({
offen,
terminId,
inSerie,
schliessen,
fertig,
}: {
offen: boolean;
terminId: string;
inSerie: boolean;
schliessen: () => void;
fertig: () => void;
}) {
const [grund, setGrund] = React.useState("");
const [ganzeSerie, setGanzeSerie] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const absagen = trpc.termin.absagen.useMutation({
onSuccess: fertig,
onError: (fehler) => setMeldung(fehler.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin absagen</DialogTitle>
<DialogDescription>
Der Termin bleibt im Kalender stehen — durchgestrichen, mit dem Grund. Wer zugesagt
hat, wird benachrichtigt.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="absage-grund">Grund</Label>
<Textarea
id="absage-grund"
rows={3}
value={grund}
onChange={(ereignis) => setGrund(ereignis.target.value)}
placeholder="Sturmwarnung, Übungsgelände nicht befahrbar."
/>
</div>
{inSerie && (
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={ganzeSerie}
onCheckedChange={(wert) => setGanzeSerie(wert === true)}
/>
<span>
Alle folgenden Termine dieser Serie ebenfalls absagen.
<span className="block text-[var(--muted-foreground)]">
Vergangene bleiben unberührt.
</span>
</span>
</label>
)}
{meldung && <Fehlermeldung meldung={meldung} />}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => absagen.mutate({ id: terminId, grund, ganzeSerie })}
disabled={!grund.trim() || absagen.isPending}
>
{absagen.isPending ? "Wird abgesagt …" : "Absagen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Die Anwesenheitsliste.
*
* Zeigt **alle aktiven Mitglieder**, nicht nur die Angemeldeten — auf einem
* Blatt zum Abhaken muss jeder stehen. Wer darf, hakt direkt hier ab; wer
* nicht darf, druckt sie aus und hakt auf Papier ab. Beides kommt vor, und
* beides ist in Ordnung.
*/
function AnwesenheitDialog({
offen,
terminId,
darfAendern,
schliessen,
}: {
offen: boolean;
terminId: string;
darfAendern: boolean;
schliessen: () => void;
}) {
const werkzeuge = trpc.useUtils();
const [meldung, setMeldung] = React.useState<string | null>(null);
const [aenderungen, setAenderungen] = React.useState<Record<string, boolean>>({});
const liste = trpc.termin.anwesenheitsliste.useQuery({ id: terminId }, { enabled: offen });
const speichern = trpc.termin.anwesenheit.useMutation({
onSuccess: () => {
setAenderungen({});
void werkzeuge.termin.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const zeilen = liste.data?.zeilen ?? [];
function stand(personId: string, gespeichert: boolean | null): boolean {
return aenderungen[personId] ?? gespeichert ?? false;
}
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Anwesenheitsliste</DialogTitle>
<DialogDescription>
{liste.data?.termin.titel} — {liste.data && alsDatum(liste.data.termin.beginn)}
</DialogDescription>
</DialogHeader>
{liste.isLoading && <p className="text-sm">Wird geladen …</p>}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="py-2">Name</th>
<th className="py-2">Gemeldet</th>
<th className="py-2 text-right">Anwesend</th>
</tr>
</thead>
<tbody>
{zeilen.map((zeile) => (
<tr key={zeile.id} className="border-b border-[var(--border)]">
<td className="py-2">
{zeile.nachname}, {zeile.vorname}
</td>
<td className="py-2 text-[var(--muted-foreground)]">
{zeile.stand ? TEILNAHME_TEXT[zeile.stand] : "—"}
{zeile.grund && ` (${zeile.grund})`}
</td>
<td className="py-2 text-right">
{darfAendern ? (
<Checkbox
checked={stand(zeile.id, zeile.anwesend)}
onCheckedChange={(wert) =>
setAenderungen((bisher) => ({ ...bisher, [zeile.id]: wert === true }))
}
aria-label={`Anwesend: ${zeile.vorname} ${zeile.nachname}`}
/>
) : (
// Ohne Recht zum Abhaken: ein Kästchen zum Ausdrucken.
<span className="inline-block size-4 rounded border border-[var(--border)]" />
)}
</td>
</tr>
))}
</tbody>
</table>
{meldung && <Fehlermeldung meldung={meldung} />}
<DialogFooter className="nicht-drucken">
<Button variant="outline" onClick={() => window.print()}>
<Printer aria-hidden />
Drucken
</Button>
{darfAendern && (
<Button
disabled={Object.keys(aenderungen).length === 0 || speichern.isPending}
onClick={() =>
speichern.mutate({
terminId,
eintraege: Object.entries(aenderungen).map(([personId, anwesend]) => ({
personId,
anwesend,
})),
})
}
>
{speichern.isPending ? "Wird gespeichert …" : "Anwesenheit speichern"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { TerminListe } from "@/app/(app)/termine/termin-liste";
export const metadata = { title: "Termine" };
/**
* Der Terminkalender der Wehr.
*
* Übungsabende, Lehrgänge, Arbeitsdienste, Sitzungen — was auf dem
* Jahresplan steht, steht hier, und wer will, hat es im Kalender seines
* Handys.
*
* Die Ansicht zeigt standardmäßig nach vorn. Wer zurückschaut, tut das für
* den Nachweis, und dafür gibt es die Anwesenheitsliste am einzelnen Termin.
*/
export default async function TermineSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Termine</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Dienste, Übungen und Veranstaltungen — mit Rückmeldung und Anwesenheit.
</p>
</div>
<TerminListe darfBearbeiten={hatRecht(sitzung, "termin.bearbeiten")} />
</div>
);
}

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