Phase 7: Benachrichtigungen und Termine (#8)
@@ -255,6 +255,76 @@ TELEGRAM_ENABLED=false
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Push über ntfy (Phase 7, optional)
|
||||
#
|
||||
# ntfy ist ein winziger Push-Dienst, den man selbst betreibt: ein Container,
|
||||
# eine App im Store, fertig. Man abonniert ein "Thema" (einen frei gewählten
|
||||
# Namen) und bekommt Meldungen darauf — ohne Konto, ohne Telefonnummer.
|
||||
#
|
||||
# Läuft der Server im eigenen Netz, verlässt keine Nachricht das Gerätehaus.
|
||||
# Deshalb gilt ntfy in FLORIAN als INTERNER Kanal und bekommt die
|
||||
# ausführlichen Texte. Wer stattdessen das öffentliche ntfy.sh einträgt, macht
|
||||
# ihn zu einem externen Dienst wie jeden anderen.
|
||||
#
|
||||
# ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest mit.
|
||||
# FLORIAN schlägt deshalb Namen mit Zufallsanteil vor.
|
||||
# -----------------------------------------------------------------------------
|
||||
NTFY_ENABLED=false
|
||||
# NTFY_URL=http://ntfy:80
|
||||
# Nur nötig, wenn der eigene ntfy Zugriffsschutz eingeschaltet hat.
|
||||
NTFY_TOKEN=
|
||||
NTFY_TIMEOUT=10
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Web-Push in den Browser (Phase 7, optional)
|
||||
#
|
||||
# Der Weg, der ohne App auskommt. Die Meldung läuft über den Push-Dienst des
|
||||
# Browserherstellers (Mozilla, Google, Apple) — verschlüsselt, aber sie
|
||||
# verlässt das Netz der Wehr. Deshalb ist Web-Push ein EXTERNER Kanal und
|
||||
# bekommt nur Kurzfassungen: "3 Prüfungen fällig", nicht welche.
|
||||
#
|
||||
# Schlüsselpaar erzeugen mit: npm run vapid
|
||||
#
|
||||
# ACHTUNG: Beide Schlüssel gehören ins Backup. Ein neuer öffentlicher Schlüssel
|
||||
# macht alle bestehenden Push-Abos wertlos — jeder müsste Push neu erlauben,
|
||||
# und niemand merkt es, die Meldungen bleiben einfach aus.
|
||||
# -----------------------------------------------------------------------------
|
||||
WEBPUSH_ENABLED=false
|
||||
WEBPUSH_PUBLIC_KEY=
|
||||
WEBPUSH_PRIVATE_KEY=
|
||||
# Kontaktweg für den Push-Dienst, "mailto:..." oder eine Adresse.
|
||||
WEBPUSH_KONTAKT=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# WhatsApp: bewusst NICHT.
|
||||
#
|
||||
# Es gibt keine WHATSAPP_-Variablen, und das ist eine Entscheidung, keine Lücke.
|
||||
# Die Begründung steht im README unter "Warum kein WhatsApp" — kurz: die
|
||||
# Business-Plattform kostet je Konversation, verlangt einen Vertrag mit Meta
|
||||
# als Auftragsverarbeiter, und die App gleicht das Telefonbuch ab. Für eine
|
||||
# Wehr, deren Daten im Gerätehaus bleiben sollen, ist das der falsche Weg.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Benachrichtigungen und Termine (Phase 7)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wann der Worker die Erinnerungen anlegt und verschickt (cron, Europe/Berlin).
|
||||
# Bewusst am frühen Morgen und nicht nachts: eine Push-Meldung um drei Uhr
|
||||
# weckt Leute, die am nächsten Tag arbeiten müssen.
|
||||
BENACHRICHTIGUNG_LAUF=0 7 * * *
|
||||
# Wie viele Tage vor einem Termin erinnert wird (kommagetrennt).
|
||||
# "7,1": einmal in der Woche davor, damit man den Abend freihalten kann, und
|
||||
# einmal am Vortag, weil man es bis dahin wieder vergessen hat.
|
||||
TERMIN_ERINNERUNG_TAGE=7,1
|
||||
# Wie oft eine fehlgeschlagene Zustellung wiederholt wird, bevor sie als
|
||||
# fehlgeschlagen stehen bleibt. Stehen bleiben ist Absicht: eine still
|
||||
# verschluckte Benachrichtigung ist schlimmer als eine sichtbar gescheiterte.
|
||||
BENACHRICHTIGUNG_VERSUCHE=3
|
||||
# Wie lange ZUGESTELLTE Benachrichtigungen aufgehoben werden.
|
||||
# Fehlgeschlagene bleiben — sie zeigen einen kaputten Kanal an.
|
||||
BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE=90
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,171 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
Nächster Schritt ist Phase 7 (Benachrichtigungen und Termine).
|
||||
Nächster Schritt ist Phase 8 (Lokale KI).
|
||||
|
||||
---
|
||||
|
||||
## [0.8.0] — 2026-09-04
|
||||
|
||||
Phase 7: Benachrichtigungen und Termine. Ab hier weiß die Mannschaft, wann der
|
||||
nächste Dienst ist — ohne dass jemand eine Rundmail tippt.
|
||||
|
||||
**Niemand wird ungefragt benachrichtigt.** Wer nichts einträgt, bekommt nichts.
|
||||
Es gibt keine Voreinstellung, die stillschweigend alle anmeldet: ein neu
|
||||
eingerichteter Zustellweg hat eine leere Ereignisliste, und erst wer ankreuzt,
|
||||
wofür er ihn will, bekommt etwas.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Datenmodell**
|
||||
|
||||
- `Termin` mit Art, Ort, Beginn und Ende, Leitung, Platzbegrenzung,
|
||||
Anmeldeschluss und Serienkennung. Abgesagt wird mit Grund und Zeitpunkt —
|
||||
**gelöscht wird nicht**, wer zugesagt hat, soll den Grund sehen.
|
||||
- `Terminanmeldung` mit Zu-, Absage oder Warteliste, dem Grund einer Absage und
|
||||
der Anwesenheit als eigenem Feld. Zusage und Anwesenheit sind bewusst
|
||||
getrennt: wer zugesagt hat und nicht kam, steht genau so in der Liste.
|
||||
- `Zustellkanal` — ein Datensatz je Person und Weg, mit der Adresse dieses Wegs
|
||||
und der Liste der Ereignisse, für die er benutzt wird. Leer bedeutet: für
|
||||
nichts.
|
||||
- `Benachrichtigung` — steht **vor** dem Versand in der Tabelle, nicht danach.
|
||||
Damit ist nachvollziehbar, wer wann worüber informiert wurde und wer nicht,
|
||||
weil sein Kanal nicht erreichbar war.
|
||||
- `Benachrichtigungsvorlage` je Ereignis und Kanal, `Kalenderzugang` mit
|
||||
SHA-256 des Tokens.
|
||||
|
||||
**Terminkalender**
|
||||
|
||||
- Liste mit Filter nach Art und „nur meine", Rückmeldung direkt aus der Zeile.
|
||||
- Serientermine werden **ausgeschrieben**, nicht als Regel gespeichert: jeder
|
||||
Abend ist ein eigener Datensatz. Sonst wäre jeder ausgefallene Übungsabend
|
||||
ein Sonderfall. Vor dem Anlegen steht in der Maske, wie viele Termine daraus
|
||||
werden.
|
||||
- Kalenderrechnung statt Millisekunden: „monatlich" heißt derselbe Tag im
|
||||
nächsten Monat, und der 31. Januar landet im Februar auf dem 28. — nicht im
|
||||
März, wie es passiert, wenn man stur Monate addiert.
|
||||
- **Warteliste, die von selbst nachrückt.** Wer bei belegten Plätzen zusagt,
|
||||
landet auf ihr; sagt jemand ab, rückt der Erste nach und erfährt es.
|
||||
- Absage mit Grund, wahlweise für alle folgenden Termine einer Serie.
|
||||
Vergangene bleiben unberührt. Benachrichtigt werden die Angemeldeten, nicht
|
||||
die ganze Wehr.
|
||||
- Anwesenheitsliste über **alle aktiven Mitglieder**, nicht nur die
|
||||
Angemeldeten — auf einem Blatt zum Abhaken muss jeder stehen. Zum Abhaken am
|
||||
Tablet oder zum Ausdrucken.
|
||||
|
||||
**Kalenderabonnement**
|
||||
|
||||
- ICS-Feed nach RFC 5545 unter `/api/kalender/<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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
||||
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
|
||||
| Phase 5 — Atemschutz (FwDV 7) | fertig |
|
||||
| Phase 6 — Kartenmodule | fertig |
|
||||
| Phase 7 — Benachrichtigungen und Termine | offen |
|
||||
| Phase 7 — Benachrichtigungen und Termine | fertig |
|
||||
| Phase 8 — Lokale KI | offen |
|
||||
| Phase 9 — Kassenverwaltung | offen |
|
||||
|
||||
@@ -785,6 +785,194 @@ Verteiler.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Benachrichtigungen und Termine
|
||||
|
||||
Der Satz, der über der ganzen Phase steht und der auch die Technik darunter
|
||||
bestimmt: **Niemand wird ungefragt benachrichtigt.** Wer nichts einträgt,
|
||||
bekommt nichts. Es gibt keine Voreinstellung, die stillschweigend alle
|
||||
anmeldet — ein neu eingerichteter Zustellweg hat eine leere Ereignisliste, und
|
||||
erst wer ankreuzt, wofür er ihn will, bekommt etwas.
|
||||
|
||||
### Der Terminkalender
|
||||
|
||||
Übungsabende, Lehrgänge, Arbeitsdienste, Sitzungen. Drei Entscheidungen prägen
|
||||
ihn, und alle drei kommen aus dem Alltag einer Wehr:
|
||||
|
||||
**Anmeldung ist die Ausnahme, nicht die Regel.** Beim normalen Übungsabend
|
||||
meldet sich niemand an, man kommt oder man kommt nicht. Erst beim Lehrgang mit
|
||||
zwölf Plätzen und beim Ausflug mit Busbestellung wird eine Rückmeldung
|
||||
verlangt.
|
||||
|
||||
**Abgesagt, nicht gelöscht.** Ein Termin, der ohne ein Wort verschwindet, führt
|
||||
dazu, dass am Dienstagabend jemand vor dem verschlossenen Gerätehaus steht.
|
||||
Eine Absage braucht einen Grund, bleibt im Kalender stehen und erreicht die,
|
||||
die zugesagt hatten — nicht die ganze Wehr.
|
||||
|
||||
**Die Warteliste rückt von selbst nach.** Wer bei belegten Plätzen zusagt,
|
||||
landet auf der Warteliste; sagt jemand ab, rückt der Erste nach und erfährt es.
|
||||
Sonst führt jemand die Liste von Hand, und das tut niemand zuverlässig.
|
||||
|
||||
Serientermine werden **ausgeschrieben**, nicht als Regel gespeichert: jeder
|
||||
Abend ist ein eigener Datensatz. Der Grund ist derselbe Alltag — der Übungsabend
|
||||
fällt im August aus, im Dezember ist er eine Woche früher, und für jeden
|
||||
einzelnen gibt es Zusagen und eine Anwesenheitsliste. Mit einer Regel wäre jede
|
||||
dieser Ausnahmen ein Sonderfall. Vor dem Anlegen steht in der Maske, wie viele
|
||||
Termine daraus werden.
|
||||
|
||||
Die Anwesenheitsliste zeigt **alle aktiven Mitglieder**, nicht nur die
|
||||
Angemeldeten: auf einem Blatt zum Abhaken muss jeder stehen. Zusage und
|
||||
Anwesenheit sind getrennt — wer zugesagt hat und nicht kam, steht genau so in
|
||||
der Liste.
|
||||
|
||||
### Der Kalender fürs Handy
|
||||
|
||||
Eine Adresse, die man im Handy oder im Mailprogramm abonniert; die Termine
|
||||
stehen dann neben allem anderen, ohne dass jemand FLORIAN öffnen muss. Das ist
|
||||
der Kanal mit der größten Wirkung pro Zeile Code in dieser Phase.
|
||||
|
||||
**Bewusst kein CalDAV.** Das ist ein Schreib-Lese-Protokoll mit
|
||||
Rechteverwaltung und Konfliktauflösung; gebraucht wird hier nur Lesen, und dafür
|
||||
genügt eine Adresse, die eine ICS-Datei ausliefert. Jeder Kalender kann das.
|
||||
|
||||
Die Adresse ist der Schlüssel — Kalenderprogramme können sich nicht anmelden,
|
||||
sie rufen stumpf eine URL ab. Daraus folgt:
|
||||
|
||||
- Gespeichert wird nur der SHA-256 des Tokens; die Adresse steht **genau
|
||||
einmal** auf dem Bildschirm.
|
||||
- Jeder Zugang lässt sich einzeln widerrufen. Ein verlorenes Handy kostet einen
|
||||
Klick, nicht ein neues Passwort für alle.
|
||||
- Ausgeliefert werden **nur Termine** — keine Anmeldelisten, keine Personen
|
||||
außer der Leitung, keine Mängel, keine Einsätze.
|
||||
- Wer aus der Wehr austritt, verliert das Abonnement.
|
||||
|
||||
### Die vier Kanäle
|
||||
|
||||
| Kanal | Läuft über | Bekommt |
|
||||
|---|---|---|
|
||||
| **E-Mail** (SMTP) | den eigenen bzw. den Mailserver der Gemeinde | den ausführlichen Text |
|
||||
| **ntfy** (selbst gehostet) | den eigenen Server, Profil `push` | den ausführlichen Text |
|
||||
| **Telegram** | einen fremden Server | nur die Kurzfassung |
|
||||
| **Web-Push** | den Push-Dienst des Browserherstellers | nur die Kurzfassung |
|
||||
|
||||
Die Spalte ganz rechts ist keine Bequemlichkeit, sondern die Regel: was das Netz
|
||||
der Wehr verlässt, bekommt einen Hinweis ohne Namen, Gerätebezeichnungen und
|
||||
Einsatzdetails — „3 Prüfungen fällig", nicht welche. In der Oberfläche steht das
|
||||
neben jedem externen Kanal, und die Vorlagenmaske sagt es noch einmal, bevor
|
||||
jemand einen Namen in eine Telegram-Vorlage tippt.
|
||||
|
||||
**ntfy** ist der interessanteste Weg für eine Wehr: ein Container, eine App im
|
||||
Store, kein Konto, keine Telefonnummer, kein Telefonbuchabgleich. Man abonniert
|
||||
ein Thema — einen frei gewählten Namen — und bekommt Meldungen darauf. Läuft
|
||||
der Server im Gerätehaus, verlässt keine Nachricht das Haus. Der Preis: ein
|
||||
Thema ist nur so geheim wie sein Name, deshalb schlägt FLORIAN Namen mit
|
||||
Zufallsanteil vor.
|
||||
|
||||
```bash
|
||||
docker compose --profile push up -d # ntfy startet auf Port 8086
|
||||
npm run vapid # Schlüsselpaar für Web-Push
|
||||
```
|
||||
|
||||
### Warum kein WhatsApp
|
||||
|
||||
Es gibt keine `WHATSAPP_`-Variablen, und das ist eine Entscheidung, keine
|
||||
Lücke. Drei Gründe, in dieser Reihenfolge:
|
||||
|
||||
**Datenschutz.** Der einzige zulässige Weg für eine Organisation ist die
|
||||
WhatsApp Business Platform. Nachrichten laufen über Server von Meta, und die
|
||||
Wehr müsste einen Vertrag zur Auftragsverarbeitung schließen — für eine
|
||||
Freiwillige Feuerwehr, deren Daten ausdrücklich im Gerätehaus bleiben sollen,
|
||||
ist das der falsche Weg. Die gewöhnliche App scheidet ohnehin aus: sie gleicht
|
||||
das Telefonbuch ab und gibt damit die Kontakte aller Mitglieder weiter.
|
||||
|
||||
**Kosten.** Abgerechnet wird je Konversation. Eine Wehr mit fünfzig Mitgliedern
|
||||
und zwei Terminerinnerungen im Monat zahlt dauerhaft für etwas, das über den
|
||||
eigenen Mailserver nichts kostet.
|
||||
|
||||
**Aufwand.** Business-Konto, verifizierte Telefonnummer, genehmigte
|
||||
Nachrichtenvorlagen, ein Meta-Entwicklerkonto. Das ist mehr Verwaltung als der
|
||||
gesamte Rest dieser Phase.
|
||||
|
||||
Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
|
||||
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts
|
||||
und verrät niemandem etwas.
|
||||
|
||||
### Der digitale Dienstausweis
|
||||
|
||||
Ein QR-Code auf dem Handy, den man an der Absperrung, beim Lehrgang oder bei
|
||||
einer Kontrolle vorzeigt. Wer ihn scannt, landet auf einer Prüfseite und sieht
|
||||
Name, Wehr, Funktion, Foto und die gültigen Qualifikationen — **ohne sich
|
||||
anzumelden**, denn wer prüft, hat keinen Zugang zu FLORIAN.
|
||||
|
||||
- **Signiert, nicht gespeichert.** HMAC über Person, Wehr und Zeitstempel.
|
||||
Keine Tabelle, kein Aufräumjob.
|
||||
- **Fünf Minuten gültig.** Er wird vorgezeigt, abgelesen und weggesteckt; ein
|
||||
abfotografierter Code ist danach wertlos. Die Prüfseite nennt deshalb den
|
||||
Zeitpunkt der Ausstellung — ein Ausweis, der „gültig" sagt, ohne zu sagen
|
||||
seit wann, ist wertlos.
|
||||
- **Er öffnet nichts.** Die Prüfseite zeigt an, sie lässt nicht hinein. Wer den
|
||||
Code hat, bekommt keinen Zugang zu einem einzigen Datensatz.
|
||||
- Was **nicht** darauf steht: Anschrift, Geburtsdatum, Telefonnummer,
|
||||
Mitgliedsnummer. Ein Ausweis weist aus; er ist keine Personalakte.
|
||||
- Ein gültig signierter Code für jemanden, der ausgetreten ist, wird abgewiesen.
|
||||
|
||||
### Was von selbst benachrichtigt
|
||||
|
||||
Sechs Anlässe, an die vorhandenen Abläufe angehängt:
|
||||
|
||||
| Anlass | Wann | An wen |
|
||||
|---|---|---|
|
||||
| Prüfung fällig | im Erinnerungslauf | wer es abonniert hat |
|
||||
| Mangel gemeldet | sofort | wer es abonniert hat |
|
||||
| Qualifikation läuft ab | 90, 30 und 7 Tage vorher | die Person selbst |
|
||||
| Neuer Termin | beim Anlegen | wer es abonniert hat |
|
||||
| Terminerinnerung | 7 Tage und 1 Tag vorher | Angemeldete, sonst Abonnenten |
|
||||
| Einsatzbericht freigegeben | bei der Freigabe | wer es abonniert hat |
|
||||
|
||||
Die Sammelmail an den zuständigen Gerätewart bleibt, wie sie war — sie geht an
|
||||
den, der zuständig ist, unabhängig davon, ob er irgendwo ein Kreuzchen gesetzt
|
||||
hat. Ein Prüftermin ist eine Pflicht, kein Abo. Wer sie ohnehin bekommt, wird
|
||||
von der Benachrichtigung ausgenommen und hat sie damit nicht doppelt.
|
||||
|
||||
Erinnert wird um sieben Uhr morgens, nicht nachts: eine Push-Meldung um drei Uhr
|
||||
weckt Leute, die am nächsten Tag arbeiten müssen. Zugestellt wird dagegen jede
|
||||
Minute — eine Absage am Dienstagabend muss ankommen, solange sie noch etwas
|
||||
nützt.
|
||||
|
||||
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird nur eine
|
||||
Zeile geschrieben; der Worker schickt sie weg und vermerkt, wie es ausging.
|
||||
Sonst hinge das Speichern eines Termins am Antwortverhalten von Telegram. Eine
|
||||
fehlgeschlagene Zustellung bleibt sichtbar stehen und wird nicht nach Frist
|
||||
abgeräumt — sie ist der Hinweis darauf, dass ein Kanal kaputt ist, und der
|
||||
verliert seinen Wert nicht durch Zeitablauf.
|
||||
|
||||
Der Probeknopf steht direkt an jedem Zustellweg und läuft durch dieselbe
|
||||
Maschinerie wie alles andere. Ohne ihn merkt jemand erst in drei Wochen, dass
|
||||
sein ntfy-Thema einen Tippfehler hat — nämlich dann, wenn die Nachricht wichtig
|
||||
gewesen wäre.
|
||||
|
||||
### Zusätzliche Umgebungsvariablen
|
||||
|
||||
| Variable | Vorgabe | Bedeutung |
|
||||
|---|---|---|
|
||||
| `NTFY_ENABLED` | `false` | Push über den eigenen ntfy |
|
||||
| `NTFY_URL` | — | Adresse des eigenen ntfy, z. B. `http://ntfy:80` |
|
||||
| `NTFY_TOKEN` | — | nur bei eingeschaltetem Zugriffsschutz |
|
||||
| `NTFY_PORT` | `8086` | Port des ntfy-Containers (Profil `push`) |
|
||||
| `WEBPUSH_ENABLED` | `false` | Push in den Browser |
|
||||
| `WEBPUSH_PUBLIC_KEY` | — | aus `npm run vapid`; **ins Backup** |
|
||||
| `WEBPUSH_PRIVATE_KEY` | — | dito |
|
||||
| `WEBPUSH_KONTAKT` | `APP_URL` | Kontaktweg für den Push-Dienst |
|
||||
| `BENACHRICHTIGUNG_LAUF` | `0 7 * * *` | wann erinnert wird |
|
||||
| `TERMIN_ERINNERUNG_TAGE` | `7,1` | Vorlauf der Terminerinnerungen |
|
||||
| `BENACHRICHTIGUNG_VERSUCHE` | `3` | Zustellversuche, bevor es stehen bleibt |
|
||||
| `BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE` | `90` | Frist für **zugestellte** Nachrichten |
|
||||
|
||||
Ein neuer öffentlicher VAPID-Schlüssel macht alle bestehenden Push-Abos
|
||||
wertlos — jeder müsste Push von Hand neu erlauben, und niemand merkt es, die
|
||||
Meldungen bleiben einfach aus. Beide Schlüssel gehören deshalb ins Backup.
|
||||
|
||||
---
|
||||
|
||||
## Befehle
|
||||
|
||||
```bash
|
||||
@@ -798,6 +986,7 @@ npm run db:migrate # ausstehende Migrationen anwenden
|
||||
npm run db:seed # Beispielwehr einspielen
|
||||
npm run db:studio # Prisma Studio
|
||||
npm run screenshots # Bilder für docs/ANLEITUNG.md erzeugen
|
||||
npm run vapid # VAPID-Schlüsselpaar für Web-Push erzeugen
|
||||
```
|
||||
|
||||
## Dienste und Ports (Entwicklung)
|
||||
@@ -836,9 +1025,12 @@ tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.
|
||||
Schriften verwendet, die auf dem Gerät vorhanden sind.
|
||||
- Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`,
|
||||
`AUDIT_AUFBEWAHRUNG_TAGE`, `PERSON_ANONYMISIERUNG_TAGE`).
|
||||
- Externe Dienste (Mail, Telegram, LLM) sitzen hinter Schnittstellen in
|
||||
`src/lib/integrations/` und sind einzeln abschaltbar. Ohne Freischaltung
|
||||
wird nur ins Log geschrieben.
|
||||
- Externe Dienste (Mail, Telegram, ntfy, Web-Push, LLM) sitzen hinter
|
||||
Schnittstellen in `src/lib/integrations/` und sind einzeln abschaltbar. Ohne
|
||||
Freischaltung wird nur ins Log geschrieben.
|
||||
- Über Kanäle, die das Netz der Wehr verlassen (Telegram, Web-Push), gehen nur
|
||||
Kurzfassungen ohne Namen, Gerätebezeichnungen und Einsatzdetails.
|
||||
- Kein WhatsApp — die Begründung steht oben unter „Warum kein WhatsApp".
|
||||
|
||||
## Sicherheitshinweis
|
||||
|
||||
|
||||
@@ -71,6 +71,18 @@ x-app-env: &app-env
|
||||
TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
NTFY_ENABLED: ${NTFY_ENABLED:-false}
|
||||
NTFY_URL: ${NTFY_URL:-}
|
||||
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||
NTFY_TIMEOUT: ${NTFY_TIMEOUT:-10}
|
||||
WEBPUSH_ENABLED: ${WEBPUSH_ENABLED:-false}
|
||||
WEBPUSH_PUBLIC_KEY: ${WEBPUSH_PUBLIC_KEY:-}
|
||||
WEBPUSH_PRIVATE_KEY: ${WEBPUSH_PRIVATE_KEY:-}
|
||||
WEBPUSH_KONTAKT: ${WEBPUSH_KONTAKT:-}
|
||||
BENACHRICHTIGUNG_LAUF: ${BENACHRICHTIGUNG_LAUF:-0 7 * * *}
|
||||
TERMIN_ERINNERUNG_TAGE: ${TERMIN_ERINNERUNG_TAGE:-7,1}
|
||||
BENACHRICHTIGUNG_VERSUCHE: ${BENACHRICHTIGUNG_VERSUCHE:-3}
|
||||
BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE: ${BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE:-90}
|
||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
@@ -306,6 +318,46 @@ services:
|
||||
- intern
|
||||
logging: *logging
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ntfy — Push aufs Handy, ohne dass etwas das Haus verlässt (Phase 7).
|
||||
#
|
||||
# Nur mit Profil: docker compose --profile push up -d
|
||||
#
|
||||
# Warum das der interessanteste Push-Weg für eine Wehr ist: kein Konto, keine
|
||||
# Telefonnummer, kein Telefonbuchabgleich. Man abonniert in der App ein
|
||||
# Thema — einen frei gewählten Namen — und bekommt Meldungen darauf.
|
||||
#
|
||||
# Der Container ist winzig und speichert standardmäßig nichts dauerhaft:
|
||||
# eine zugestellte Meldung ist erledigt. Wer sie nachlesen können will,
|
||||
# schaltet den Cache ein (siehe ntfy-Dokumentation) — dann liegen die Texte
|
||||
# allerdings auch dort und nicht nur in FLORIAN.
|
||||
#
|
||||
# ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest
|
||||
# mit. Deshalb schlägt FLORIAN Namen mit Zufallsanteil vor, und deshalb
|
||||
# steht NTFY_TOKEN bereit, falls der eigene ntfy Zugriffsschutz bekommt.
|
||||
# ---------------------------------------------------------------------------
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy:v2.11.0
|
||||
container_name: florian-ntfy
|
||||
restart: unless-stopped
|
||||
profiles: [push]
|
||||
command: ["serve"]
|
||||
environment:
|
||||
NTFY_BASE_URL: ${NTFY_OEFFENTLICH:-http://localhost:8086}
|
||||
NTFY_LISTEN_HTTP: ":80"
|
||||
# Keine Anmeldung nötig, um zu lesen und zu schreiben — geschützt ist
|
||||
# das Ding durch die Unerratbarkeit der Themennamen und dadurch, dass es
|
||||
# im eigenen Netz steht. Wer es ins Internet stellt, setzt
|
||||
# NTFY_AUTH_DEFAULT_ACCESS auf "deny-all" und vergibt Token.
|
||||
NTFY_AUTH_DEFAULT_ACCESS: ${NTFY_ZUGRIFF:-read-write}
|
||||
NTFY_BEHIND_PROXY: "true"
|
||||
ports:
|
||||
- "${NTFY_PORT:-8086}:80"
|
||||
networks:
|
||||
- edge
|
||||
- intern
|
||||
logging: *logging
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die Anwendung selbst (Next.js, Standalone-Build).
|
||||
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Anleitung
|
||||
|
||||
Bedienung von FLORIAN, Stand Phase 6 (Version 0.7.0). Die Bilder zeigen die
|
||||
Bedienung von FLORIAN, Stand Phase 7 (Version 0.8.0). Die Bilder zeigen die
|
||||
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
||||
|
||||
> Benachrichtigungen, lokale KI und die Kassenverwaltung kommen in den
|
||||
> folgenden Phasen dazu. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
> Die lokale KI und die Kassenverwaltung kommen in den folgenden Phasen dazu.
|
||||
> Siehe [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -1279,7 +1279,230 @@ GEOCODING_LAENDER=de,at,ch
|
||||
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
|
||||
eintragen oder weglassen.
|
||||
|
||||
## 39. Rollen und was sie dürfen
|
||||
## 39. Der Terminkalender
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
> **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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
Unter **Dienstausweis**: ein QR-Code mit Ihrem Namen, Ihrer Wehr, Ihrer
|
||||
Funktion und Ihren gültigen Qualifikationen. Zum Vorzeigen an der Absperrung,
|
||||
beim Lehrgang oder bei einer Kontrolle.
|
||||
|
||||
Wer ihn scannt, landet auf einer Prüfseite und sieht genau das — **ohne sich
|
||||
anzumelden**. Denn wer prüft, hat keinen Zugang zu FLORIAN.
|
||||
|
||||
Drei Dinge sollten Sie darüber wissen:
|
||||
|
||||
- **Der Code gilt fünf Minuten.** Danach zeigt die Seite ihn selbst neu an. Ein
|
||||
abfotografierter Code nützt nichts.
|
||||
- **Er öffnet nichts.** Die Prüfseite zeigt an, sie lässt nicht hinein.
|
||||
- **Es steht nicht mehr darauf als auf einem Ausweis aus Papier.** Keine
|
||||
Anschrift, kein Geburtsdatum, keine Telefonnummer.
|
||||
|
||||
Die Prüfseite nennt außerdem, wann der Code erzeugt wurde. Das ist der Punkt,
|
||||
auf den ein Prüfender schauen sollte: „vor zwei Minuten" ist ein Ausweis,
|
||||
„gültig" allein wäre wertlos.
|
||||
|
||||
Wer aus der Wehr ausgetreten ist, bekommt dort **Kein aktives Mitglied** — auch
|
||||
wenn sein Code technisch noch stimmt.
|
||||
|
||||
---
|
||||
|
||||
## 43. Rollen und was sie dürfen
|
||||
|
||||
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
||||
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
||||
@@ -1292,9 +1515,9 @@ kann sie also nachträglich zuschneiden, ohne dass jemand Code ändert.
|
||||
| **Gerätewart** | Fahrzeuge und Geräte anlegen, ändern, aussondern; Prüfungen planen und durchführen |
|
||||
| **Atemschutzgerätewart** | zusätzlich Atemschutzgeräte, Träger und die Überwachung nach FwDV 7 |
|
||||
| **Kassier** | Buchungen und Kassenabschluss (Modul noch nicht geplant, siehe Roadmap) |
|
||||
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen |
|
||||
| **Ausbilder** | Übungen planen, Teilnahme und Qualifikationen erfassen |
|
||||
| **Mitglied** | eigene Daten, Listen lesen |
|
||||
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen |
|
||||
| **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen |
|
||||
| **Mitglied** | eigene Daten, Listen lesen, Termine sehen und sich zurückmelden, eigene Benachrichtigungswege einrichten |
|
||||
| **Terminal** | nur anmelden und erfassen — nie verwalten |
|
||||
|
||||
Zwei Dinge sind bewusst so gebaut:
|
||||
@@ -1306,9 +1529,14 @@ Trennung nach Verantwortung, nicht nach Bequemlichkeit.
|
||||
zusätzlich mit PIN oder Karte an und handelt dann unter seiner eigenen Person.
|
||||
Das Terminal selbst ist nur die Tür.
|
||||
|
||||
**Seine Benachrichtigungswege richtet jeder selbst ein.** Das ist ein
|
||||
Grundrecht jedes Mitglieds und keine Sache des Kommandanten — es gibt bewusst
|
||||
keinen Weg, jemand anderem einen Zustellweg einzutragen. Was für alle gilt,
|
||||
sind nur die Textvorlagen, und die hängen an einem eigenen Recht.
|
||||
|
||||
---
|
||||
|
||||
## 40. Anmeldung über authentik einrichten
|
||||
## 44. Anmeldung über authentik einrichten
|
||||
|
||||
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
|
||||
|
||||
@@ -1361,7 +1589,7 @@ ganze Wehr aus.
|
||||
|
||||
---
|
||||
|
||||
## 41. PIN und Ausweiskarte verwalten
|
||||
## 45. PIN und Ausweiskarte verwalten
|
||||
|
||||
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
|
||||
was dahinter liegt.
|
||||
@@ -1384,7 +1612,7 @@ Huber), PIN `471203`.
|
||||
|
||||
---
|
||||
|
||||
## 42. Was protokolliert wird
|
||||
## 46. Was protokolliert wird
|
||||
|
||||
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
||||
@@ -1409,7 +1637,7 @@ jeweiligen Detailseite sichtbar.
|
||||
|
||||
---
|
||||
|
||||
## 43. Wenn etwas nicht geht
|
||||
## 47. Wenn etwas nicht geht
|
||||
|
||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||
|---|---|---|
|
||||
@@ -1438,6 +1666,17 @@ jeweiligen Detailseite sichtbar.
|
||||
| Nach dem Import steht überall „ungeprüft" | Die Quelle hat keine Statusspalte | Absicht: ohne Angabe wird nicht „einsatzbereit" behauptet. Zustand nachpflegen |
|
||||
| Die Lagekarte gleicht nicht ab | Der Ereignisstrom ist unterbrochen | Steht oben in der Lagekarte. Bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen |
|
||||
| Die Förderstrecke braucht unerwartet viele Pumpen | Fördermenge zu hoch für den Durchmesser | Der Verlust steigt im Quadrat: 800 statt 400 l/min kostet die vierfache Reibung. Größeren Schlauch wählen oder weniger fördern |
|
||||
| Es kommt keine Benachrichtigung an | Kein Zustellweg eingerichtet, oder kein Anlass angekreuzt | Unter **Benachrichtigungen** einen Weg einrichten und ankreuzen, wofür. Ohne beides kommt absichtlich nichts |
|
||||
| Die Probenachricht bleibt aus | Der Kanal ist auf dem Server nicht eingerichtet | In der Auswahl steht „(nicht eingerichtet)". `MAIL_ENABLED`, `NTFY_ENABLED`, `TELEGRAM_ENABLED` oder `WEBPUSH_ENABLED` setzen |
|
||||
| Im Verlauf steht mehrfach „Fehlgeschlagen" | Der Weg ist kaputt — der Grund steht daneben | Bei ntfy meist ein Tippfehler im Thema, bei Telegram eine falsche Chat-Kennung |
|
||||
| Browser-Push lässt sich nicht einrichten | Keine VAPID-Schlüssel auf dem Server | `npm run vapid` und die Ausgabe in die `.env` übernehmen |
|
||||
| Push kam bisher an, jetzt nicht mehr | Der öffentliche VAPID-Schlüssel wurde gewechselt | Alle Abos sind damit wertlos. Jeder muss Push neu erlauben — deshalb gehören die Schlüssel ins Backup |
|
||||
| Auf dem iPhone gibt es keinen Push-Knopf | Web-Push geht dort nur als abgelegte App | Über „Teilen → Zum Home-Bildschirm" ablegen, dann erneut versuchen |
|
||||
| Der Kalender im Handy aktualisiert sich nicht | Das Programm fragt nur stündlich nach | Abwarten oder im Kalenderprogramm „Aktualisieren" auslösen |
|
||||
| Der Kalender ist plötzlich leer | Der Zugang wurde widerrufen, oder die Person ist ausgetreten | Unter **Benachrichtigungen** eine neue Adresse erzeugen |
|
||||
| Eine Vorlage lässt sich nicht speichern | Ein Platzhalter gehört nicht zu diesem Anlass | Die Meldung nennt ihn und listet die erlaubten auf |
|
||||
| Ein Termin lässt sich nicht löschen | Es liegen schon Rückmeldungen vor | Absagen statt löschen — dann sehen die Angemeldeten den Grund |
|
||||
| Die Zusage wurde zur Warteliste | Die Plätze sind belegt | Sagt jemand ab, rücken Sie von selbst nach und werden benachrichtigt |
|
||||
|
||||
Zustand aller Randsysteme auf einen Blick:
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Aufwandsschätzung
|
||||
|
||||
Schätzung je Phase, daneben der tatsächliche Aufwand aus dem
|
||||
[Devlog](DEVLOG.md). Stand: 3. September 2026.
|
||||
[Devlog](DEVLOG.md). Stand: 4. September 2026.
|
||||
|
||||
## Wie hier gerechnet wird
|
||||
|
||||
@@ -34,10 +34,10 @@ Faktoren, die in die Schätzung eingehen:
|
||||
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
|
||||
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** |
|
||||
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | **2:10 h** |
|
||||
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen |
|
||||
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
|
||||
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen |
|
||||
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | offen |
|
||||
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | **3:00 h** |
|
||||
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | **2:40 h** |
|
||||
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | **2:50 h** |
|
||||
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | **3:10 h** |
|
||||
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen |
|
||||
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
|
||||
| | **Summe** | **123–164 PT** | **28–37 h** | |
|
||||
@@ -339,7 +339,7 @@ das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile.
|
||||
|
||||
## Phase 7 — Benachrichtigungen und Termine
|
||||
|
||||
**Vergleichsaufwand: 10–13 PT**
|
||||
**Vergleichsaufwand: 10–13 PT** · **Tatsächlich: 3:10 h Sitzungszeit**
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -347,11 +347,24 @@ das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile.
|
||||
| Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 |
|
||||
| Abonnements je Person | 1,0 |
|
||||
| Kalender mit Serienterminen und Selbstanmeldung | 3,0 |
|
||||
| CalDAV-/ICS-Feed je Person mit Token | 1,5 |
|
||||
| ICS-Feed je Person mit Token | 1,5 |
|
||||
| Digitaler Dienstausweis mit signiertem QR und Prüfseite | 2,0 |
|
||||
|
||||
Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0
|
||||
(`lib/integrations/`), das spart hier etwa einen Personentag.
|
||||
Das Grundgerüst der Kanalabstraktion stand bereits aus Phase 0
|
||||
(`lib/integrations/`), das hat etwa einen Personentag gespart.
|
||||
|
||||
Die einzige Phase bisher, die über der geschätzten Sitzungszeit liegt — und
|
||||
zwar an einer Stelle, die in der Aufstellung nicht vorkommt. „Kalender mit
|
||||
Serienterminen" klingt nach einer Datumsrechnung; teuer war die Entscheidung
|
||||
**gegen** eine Regel und **für** ausgeschriebene Termine, und dann die
|
||||
Kalenderarithmetik dahinter: `setMonth(+1)` auf dem 31. Januar ergibt den
|
||||
3. März, der 29. Februar existiert in vier von fünf Jahren nicht, und ein
|
||||
Übungsabend muss über die ganze Serie auf demselben Wochentag bleiben. Das sind
|
||||
drei Tests, die man erst schreibt, wenn man den Fall kennt.
|
||||
|
||||
Die 1,5 PT für den Feed waren dagegen zu hoch angesetzt, weil sie CalDAV
|
||||
unterstellen. ICS ist ein Textformat; der ganze Erzeuger sind hundertsechzig
|
||||
Zeilen samt Maskierung und Zeilenumbruch nach 75 Oktetten.
|
||||
|
||||
## Phase 8 — Lokale KI
|
||||
|
||||
|
||||
@@ -18,7 +18,126 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
||||
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
|
||||
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
|
||||
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
|
||||
| **Summe** | **16:10 h** | **8** | |
|
||||
| Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
|
||||
| **Summe** | **19:20 h** | **9** | |
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-04 · Phase 7 — Benachrichtigungen und Termine
|
||||
|
||||
**04:10 – 07:20 · 3:10 h · Version 0.8.0**
|
||||
|
||||
### Was entstanden ist
|
||||
|
||||
Der Jahresplan der Wehr und die Wege, auf denen die Mannschaft davon erfährt.
|
||||
|
||||
| Bereich | Dateien |
|
||||
|---|---|
|
||||
| Rechnen | `lib/termin/serie.ts`, `ics.ts`, `lib/benachrichtigung/vorlagen.ts`, `versand.ts`, `lib/dienstausweis.ts` |
|
||||
| Kanäle | `lib/integrations/mail.ts` (echtes SMTP), `ntfy.ts`, `webpush.ts` |
|
||||
| Server | `server/routers/termin/termin.ts`, `benachrichtigung.ts`, `stammdaten/dienstausweis.ts` |
|
||||
| Öffentlich | `api/kalender/[token]`, `ausweis/[code]`, `api/ausweis/[code]/foto` |
|
||||
| Oberfläche | `app/(app)/termine/`, `benachrichtigungen/`, `ausweis/` |
|
||||
| Hintergrund | `worker/benachrichtigung.ts`, zwei neue Zeitpläne |
|
||||
| Tests | 56 Unit, 17 E2E je Browser |
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
**Niemand wird ungefragt benachrichtigt.** Ein neu eingerichteter Zustellweg
|
||||
hat eine leere Ereignisliste. Es gibt keinen Weg, jemand anderem einen Kanal
|
||||
einzutragen — auch nicht für den Kommandanten. Der Seed legt bewusst *keine*
|
||||
Zustellkanäle an, obwohl das die Beispieldaten hübscher machen würde. Wer eine
|
||||
Wehr ungefragt anmeldet, hat binnen einer Woche eine Wehr, die die
|
||||
Benachrichtigungen abschaltet, und danach kommen auch die wichtigen nicht mehr
|
||||
an.
|
||||
|
||||
**Serientermine werden ausgeschrieben, nicht gerechnet.** Eine Regel wäre
|
||||
weniger Zeilen in der Datenbank und mehr Sonderfälle im Code: der Übungsabend
|
||||
fällt im August aus, im Dezember ist er eine Woche früher, und für jeden
|
||||
einzelnen gibt es Zusagen und eine Anwesenheitsliste. Ausgeschrieben ist jede
|
||||
dieser Ausnahmen einfach ein Datensatz, den man ändert. Der Preis sind hundert
|
||||
Zeilen für zwei Jahre — Kleingeld, und die Zahl steht vor dem Anlegen in der
|
||||
Maske.
|
||||
|
||||
**Gerechnet wird auf dem Kalender, nicht in Millisekunden.** „Monatlich" heißt
|
||||
derselbe Tag im nächsten Monat. `setMonth(+1)` auf dem 31. Januar ergibt den
|
||||
3. März, weil der Februar keinen 31. hat — ein Fehler, der in einer
|
||||
Übungsserie erst im Frühjahr auffällt. Fällt der Tag im Zielmonat aus, rutscht
|
||||
der Termin auf den letzten des Monats.
|
||||
|
||||
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird eine
|
||||
Zeile geschrieben, sonst nichts. Der Worker nimmt sich, was auf WARTET steht.
|
||||
Wären es eine Sache, hinge das Speichern eines Termins am Antwortverhalten von
|
||||
Telegram. So ist die schlimmste Folge eines toten Mailservers eine
|
||||
Benachrichtigung, die auf FEHLGESCHLAGEN steht und sichtbar ist.
|
||||
|
||||
**Intern bekommt den langen Text, extern die Kurzfassung.** Der Mailserver
|
||||
einer Wehr ist ihr eigener oder der der Gemeinde, und ein selbst betriebener
|
||||
ntfy steht im Gerätehaus — dorthin dürfen Namen, Gerätebezeichnungen und
|
||||
Einsatznummern. Telegram und Web-Push laufen über fremde Server und bekommen
|
||||
„3 Prüfungen fällig", nicht welche. Das steht in `KANAL_EXTERN` an einer
|
||||
Stelle, wird im Test für *jeden* externen Kanal geprüft und in der Oberfläche
|
||||
zweimal gesagt: neben dem Kanal und über dem Vorlagenfeld.
|
||||
|
||||
**Kein CalDAV, sondern ICS.** CalDAV ist ein Schreib-Lese-Protokoll mit
|
||||
Rechteverwaltung und Konfliktauflösung. Gebraucht wird Lesen. Eine Adresse, die
|
||||
eine ICS-Datei ausliefert, kann jeder Kalender — das Format ist eine Liste von
|
||||
Zeilen, und die interessanten Teile (Zeitzone, Umbruch nach 75 Oktetten) stehen
|
||||
in zwanzig Zeilen. Eine Abhängigkeit dafür wäre in einer Anwendung ohne
|
||||
Netzzugang der schlechtere Tausch.
|
||||
|
||||
**Der Ausweis gilt fünf Minuten, die Anmeldemarke eine.** Beide sind mit
|
||||
AUTH_SECRET signiert; was sie trennt, ist der Zweck im signierten Teil. Ohne
|
||||
den ginge das eine als das andere durch, und der Ausweis wäre plötzlich ein
|
||||
Anmeldeweg. Die fünf Minuten sind der Unterschied zwischen „wird gescannt" und
|
||||
„wird vorgezeigt, abgelesen und weggesteckt" — und die Prüfseite nennt den
|
||||
Zeitpunkt der Ausstellung, weil ein Ausweis, der „gültig" sagt ohne zu sagen
|
||||
seit wann, wertlos ist.
|
||||
|
||||
**Fehlgeschlagene Benachrichtigungen werden nicht abgeräumt.** Der
|
||||
Aufbewahrungslauf nimmt sich nur die zugestellten. Eine fehlgeschlagene ist der
|
||||
Hinweis darauf, dass ein Kanal kaputt ist, und der verliert seinen Wert nicht
|
||||
durch Zeitablauf.
|
||||
|
||||
**Erinnert wird um sieben, zugestellt jede Minute.** Eine Push-Meldung um drei
|
||||
Uhr weckt Leute, die am nächsten Tag arbeiten müssen. Eine Absage am
|
||||
Dienstagabend muss dagegen ankommen, solange sie noch etwas nützt.
|
||||
|
||||
### Wo es gehakt hat
|
||||
|
||||
| Problem | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| Die Vorlagenmaske überschrieb den gerade getippten Text | Der Effekt, der den passenden Text lädt, hing an `vorlagen.data`. Wird die Liste im Hintergrund nachgeladen — nach dem Speichern, beim Zurückkommen ins Fenster —, war die Eingabe weg | Merker auf `ereignis:kanal`; geladen wird nur beim Wechsel der Auswahl. Gefunden hat es der E2E-Test, nicht das Ausprobieren |
|
||||
| `personId` wurde zweimal gesetzt und überschrieb sich | Einschränkung und Ausschluss als zwei Objekt-Einträge im selben `where` — der zweite gewinnt still | Beide in eine Bedingung zusammengeführt |
|
||||
| Der Aufräumlauf durfte nicht löschen | `REVOKE DELETE ON "benachrichtigung"` gilt der Anwendungsrolle, und `fuerOrganisation` benutzt genau die | Über `ohneMandantenfilter` mit ausdrücklichem Mandantenfilter in der Bedingung — begründet, wie es die Regel verlangt |
|
||||
| Zwei E2E-Selektoren trafen zu viel | „Absagen" steckt auch in „Termin absagen"; `{titel}` steht im Textfeld *und* auf dem Platzhalterknopf | `exact: true` und Suche innerhalb des Dialogs |
|
||||
| Der Warteliste-Test hing an der Reihenfolge im Seed | Ob der angemeldete Nutzer schon einen Platz hat, entscheidet die Mitgliedsnummer | Test prüft jetzt die Anzeige der Warteliste — die gilt für alle |
|
||||
|
||||
### Abweichung von der Vorgabe
|
||||
|
||||
**Kein CalDAV**, obwohl Phase-7.md „CalDAV-/ICS-Feed" nennt. Begründung oben;
|
||||
sie steht auch im README und in der Roadmap.
|
||||
|
||||
**Termine einer Serie werden einzeln geändert, nicht „alle folgenden".** Der
|
||||
Fall ist selten und mehrdeutig — meint „alle folgenden" auch die, die jemand
|
||||
schon von Hand verschoben hat? Absagen geht dagegen für die ganze Serie, weil
|
||||
das der Fall ist, der wirklich vorkommt (Sommerpause, Bauarbeiten am
|
||||
Gerätehaus).
|
||||
|
||||
**Kein Push für iOS ohne Umweg.** Web-Push funktioniert auf dem iPhone erst,
|
||||
wenn FLORIAN zum Startbildschirm hinzugefügt wurde. Das steht in der
|
||||
Oberfläche; ein eigener Weg dafür wäre eine App im Store, und die gibt es
|
||||
bewusst nicht.
|
||||
|
||||
### Nachweise
|
||||
|
||||
- `npx tsc --noEmit` — ohne Ausgabe
|
||||
- `npm run test` — 422 Tests grün, davon 15 neue gegen eine echte Datenbank
|
||||
- `npx playwright test tests/e2e/termine.spec.ts` — 17 grün
|
||||
- `npm run build` — 50 Routen, darunter `/termine`, `/benachrichtigungen`,
|
||||
`/ausweis` und die beiden öffentlichen Endpunkte
|
||||
- `npm run db:seed` — 19 Termine, Warteliste und abgehakte Anwesenheit
|
||||
- `docker compose config` — ntfy im Profil `push` prüft durch
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ Phase 3 ████████████████████ fertig
|
||||
Phase 4 ████████████████████ fertig 0.5.0
|
||||
Phase 5 ████████████████████ fertig 0.6.0
|
||||
Phase 6 ████████████████████ fertig 0.7.0
|
||||
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
|
||||
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
|
||||
Phase 7 ████████████████████ fertig 0.8.0
|
||||
Phase 8 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.9.0
|
||||
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
|
||||
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
|
||||
```
|
||||
@@ -148,25 +148,53 @@ analogen, bis die Wehr ausdrücklich anders entscheidet.
|
||||
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
|
||||
und was ihn hergibt.
|
||||
|
||||
## Phase 7 — Benachrichtigungen und Termine · als Nächstes
|
||||
## Phase 7 — Benachrichtigungen und Termine · fertig
|
||||
|
||||
- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht.
|
||||
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
|
||||
- Kalender: Dienste, Übungen, Lehrgänge, Arbeitsdienste; Serientermine,
|
||||
Selbstanmeldung mit Teilnehmerlimit, Anwesenheitsliste zum Ausdrucken
|
||||
- CalDAV-/ICS-Feed je Person
|
||||
- Digitaler Dienstausweis mit signiertem QR und Prüfseite
|
||||
Die Wehr weiß, wann der nächste Dienst ist — ohne dass jemand eine Rundmail
|
||||
tippt.
|
||||
|
||||
- Terminkalender mit Dienst, Übung, Lehrgang, Arbeitsdienst, Veranstaltung und
|
||||
Sitzung. Serientermine werden ausgeschrieben, nicht als Regel gespeichert.
|
||||
- Selbstanmeldung mit Platzbegrenzung und einer Warteliste, die von selbst
|
||||
nachrückt. Absage mit Grund, wahlweise für alle folgenden Termine einer Serie.
|
||||
- Anwesenheitsliste über alle aktiven Mitglieder — zum Abhaken am Tablet oder
|
||||
zum Ausdrucken. Zusage und Anwesenheit sind getrennt.
|
||||
- Vier Kanäle hinter Schnittstellen: E-Mail (echtes SMTP statt Platzhalter),
|
||||
ntfy (selbst gehostet), Telegram, Web-Push. Was das Netz der Wehr verlässt,
|
||||
bekommt nur die Kurzfassung.
|
||||
- Vorlagen je Ereignis und Kanal mit Platzhaltern, Vorschau und eingebautem
|
||||
Standardtext für alles.
|
||||
- ICS-Abonnement je Person, Token nur als Hash gespeichert, einzeln widerrufbar.
|
||||
- Digitaler Dienstausweis: signierter QR, fünf Minuten gültig, Prüfseite ohne
|
||||
Anmeldung.
|
||||
|
||||
**Danach kann die Wehr:** ihren Jahresplan führen, Rückmeldungen einsammeln,
|
||||
Anwesenheit nachweisen und jedes Mitglied auf dem Weg erreichen, den es selbst
|
||||
gewählt hat.
|
||||
|
||||
### Kein CalDAV
|
||||
|
||||
CalDAV ist ein Schreib-Lese-Protokoll mit Rechteverwaltung und
|
||||
Konfliktauflösung. Gebraucht wird hier nur Lesen — die Termine entstehen in
|
||||
FLORIAN, nicht im Kalender des Anwenders. Dafür genügt eine Adresse, die eine
|
||||
ICS-Datei ausliefert; jeder Kalender kann das. CalDAV wäre ein Vielfaches an
|
||||
Aufwand für dieselbe Wirkung.
|
||||
|
||||
### Warum kein WhatsApp
|
||||
|
||||
Die WhatsApp Business API kostet je Konversation und verlangt einen
|
||||
zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen
|
||||
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nummern aller
|
||||
Mitglieder gingen an Meta, was sich mit einer Anwendung, die bewusst im
|
||||
Gerätehaus steht, nicht verträgt. Telegram, ntfy und Web-Push decken denselben
|
||||
Bedarf ab, ntfy sogar vollständig selbst gehostet.
|
||||
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nachrichten liefen
|
||||
über Server von Meta, die Wehr müsste einen Vertrag zur Auftragsverarbeitung
|
||||
schließen, und die gewöhnliche App gleicht zusätzlich das Telefonbuch ab. Das
|
||||
verträgt sich nicht mit einer Anwendung, die bewusst im Gerätehaus steht.
|
||||
Telegram, ntfy und Web-Push decken denselben Bedarf ab, ntfy sogar vollständig
|
||||
selbst gehostet.
|
||||
|
||||
## Phase 8 — Lokale KI
|
||||
Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
|
||||
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts.
|
||||
|
||||
## Phase 8 — Lokale KI · als Nächstes
|
||||
|
||||
Alles gegen Ollama, kein externer Anbieter, komplett abschaltbar.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 431 KiB |
|
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 792 KiB After Width: | Height: | Size: 808 KiB |
|
Before Width: | Height: | Size: 816 KiB After Width: | Height: | Size: 831 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 1012 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 830 KiB After Width: | Height: | Size: 888 KiB |
|
Before Width: | Height: | Size: 846 KiB After Width: | Height: | Size: 904 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 663 KiB After Width: | Height: | Size: 719 KiB |
|
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 724 KiB |
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 378 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 535 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 540 KiB After Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 255 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 888 KiB |
|
After Width: | Height: | Size: 907 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 201 KiB |
@@ -42,6 +42,7 @@
|
||||
"next": "^15.5.4",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^8.0.11",
|
||||
"papaparse": "^5.7.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.1",
|
||||
@@ -49,6 +50,7 @@
|
||||
"server-only": "^0.0.1",
|
||||
"superjson": "^2.2.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -57,10 +59,12 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^22.18.6",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"esbuild": "^0.25.10",
|
||||
@@ -4445,6 +4449,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/papaparse": {
|
||||
"version": "5.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
|
||||
@@ -4485,6 +4499,16 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz",
|
||||
@@ -5331,7 +5355,6 @@
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
@@ -5663,6 +5686,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -5838,6 +5873,12 @@
|
||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
@@ -5934,6 +5975,12 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-indexof-polyfill": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
|
||||
@@ -6831,6 +6878,15 @@
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.21.0",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
||||
@@ -8286,6 +8342,15 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@@ -8304,7 +8369,6 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
@@ -9147,6 +9211,27 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
||||
@@ -9814,6 +9899,12 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -10129,6 +10220,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.11",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
|
||||
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -11338,6 +11438,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||
@@ -13912,6 +14018,25 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "florian",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||
"engines": {
|
||||
@@ -24,7 +24,8 @@
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"screenshots": "tsx scripts/screenshots.ts",
|
||||
"icons": "tsx scripts/icons.ts"
|
||||
"icons": "tsx scripts/icons.ts",
|
||||
"vapid": "tsx scripts/vapid.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
@@ -61,6 +62,7 @@
|
||||
"next": "^15.5.4",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^8.0.11",
|
||||
"papaparse": "^5.7.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.1",
|
||||
@@ -68,6 +70,7 @@
|
||||
"server-only": "^0.0.1",
|
||||
"superjson": "^2.2.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -76,10 +79,12 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^22.18.6",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"esbuild": "^0.25.10",
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -217,6 +217,12 @@ model Organisation {
|
||||
lageelemente Lageelement[]
|
||||
wasserfoerderungen Wasserfoerderung[]
|
||||
foerderpunkte Foerderpunkt[]
|
||||
benachrichtigungsvorlages Benachrichtigungsvorlage[]
|
||||
zustellkanals Zustellkanal[]
|
||||
benachrichtigungs Benachrichtigung[]
|
||||
termins Termin[]
|
||||
terminanmeldungs Terminanmeldung[]
|
||||
kalenderzugangs Kalenderzugang[]
|
||||
|
||||
@@index([aktiv])
|
||||
@@map("organisation")
|
||||
@@ -324,6 +330,13 @@ model Person {
|
||||
gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner")
|
||||
geplanteFoerderstrecken Wasserfoerderung[] @relation("WasserfoerderungPlaner")
|
||||
|
||||
// Phase 7 — Benachrichtigungen und Termine
|
||||
benachrichtigungen Benachrichtigung[] @relation("BenachrichtigungEmpfaenger")
|
||||
zustellkanaele Zustellkanal[] @relation("ZustellkanalPerson")
|
||||
geleiteteTermine Termin[] @relation("TerminLeitung")
|
||||
terminanmeldungen Terminanmeldung[] @relation("TerminanmeldungPerson")
|
||||
kalenderzugaenge Kalenderzugang[] @relation("KalenderzugangPerson")
|
||||
|
||||
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||
/// Person kann in zwei Wehren Mitglied sein.
|
||||
@@unique([organisationId, email])
|
||||
@@ -2964,3 +2977,302 @@ model Foerderpunkt {
|
||||
@@index([organisationId, wasserfoerderungId])
|
||||
@@map("foerderpunkt")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 7 — Benachrichtigungen und Termine
|
||||
//
|
||||
// Zwei Themen, die zusammengehören: ein Termin ist der häufigste Anlass für
|
||||
// eine Benachrichtigung, und eine Benachrichtigung ohne Termin dahinter ist
|
||||
// meistens eine Frist.
|
||||
//
|
||||
// Der Aufbau folgt einer Regel: **niemand wird ungefragt benachrichtigt.**
|
||||
// Jede Person entscheidet je Ereignis und Kanal selbst. Die Wehr legt fest,
|
||||
// was es überhaupt gibt (Vorlagen) und wen es betrifft (Rollen) — ob
|
||||
// zugestellt wird, entscheidet die Person.
|
||||
// =============================================================================
|
||||
|
||||
/// Wofür es eine Benachrichtigung geben kann.
|
||||
enum EreignisTyp {
|
||||
PRUEFUNG_FAELLIG
|
||||
MANGEL_GEMELDET
|
||||
QUALIFIKATION_LAEUFT_AB
|
||||
TERMIN_NEU
|
||||
TERMIN_ERINNERUNG
|
||||
TERMIN_ABGESAGT
|
||||
EINSATZBERICHT_FREIGEGEBEN
|
||||
}
|
||||
|
||||
/// Wie zugestellt wird.
|
||||
enum Kanal {
|
||||
/// SMTP über den eigenen Mailserver.
|
||||
MAIL
|
||||
/// Telegram-Bot. Externer Dienst — nur Hinweise ohne Personenbezug.
|
||||
TELEGRAM
|
||||
/// ntfy, selbst gehostet. Der einzige Push-Weg ohne fremden Server.
|
||||
NTFY
|
||||
/// Web-Push über den Browser. Braucht VAPID-Schlüssel.
|
||||
WEBPUSH
|
||||
}
|
||||
|
||||
/// Was für ein Termin.
|
||||
enum TerminArt {
|
||||
DIENST
|
||||
UEBUNG
|
||||
LEHRGANG
|
||||
ARBEITSDIENST
|
||||
VERANSTALTUNG
|
||||
SITZUNG
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
/// Wie oft ein Termin wiederkehrt.
|
||||
enum Serienmuster {
|
||||
KEINE
|
||||
WOECHENTLICH
|
||||
ZWEIWOECHENTLICH
|
||||
MONATLICH
|
||||
JAEHRLICH
|
||||
}
|
||||
|
||||
/// Was jemand zu einem Termin gesagt hat.
|
||||
enum Teilnahmestand {
|
||||
ZUGESAGT
|
||||
ABGESAGT
|
||||
/// Angemeldet, aber die Liste war voll — rückt nach, wenn jemand absagt.
|
||||
WARTELISTE
|
||||
}
|
||||
|
||||
/// Wie es einer Benachrichtigung ergangen ist.
|
||||
enum Zustellstand {
|
||||
WARTET
|
||||
GESENDET
|
||||
FEHLGESCHLAGEN
|
||||
/// Niemand hat diesen Kanal abonniert oder der Kanal ist aus.
|
||||
UEBERSPRUNGEN
|
||||
}
|
||||
|
||||
/// Eine Vorlage je Ereignis und Kanal.
|
||||
///
|
||||
/// Der Text enthält Platzhalter in geschweiften Klammern: `{titel}`, `{datum}`,
|
||||
/// `{wehr}`. Welche es gibt, steht in lib/benachrichtigung/vorlagen.ts — und
|
||||
/// die Oberfläche zeigt sie beim Bearbeiten an, damit niemand raten muss.
|
||||
///
|
||||
/// Fehlt eine Vorlage, greift der eingebaute Standardtext. Eine Wehr, die
|
||||
/// nichts einstellt, bekommt trotzdem brauchbare Nachrichten.
|
||||
model Benachrichtigungsvorlage {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
ereignis EreignisTyp
|
||||
kanal Kanal
|
||||
|
||||
/// Nur für E-Mail. Push-Dienste kennen keinen Betreff.
|
||||
betreff String? @db.VarChar(200)
|
||||
text String @db.VarChar(2000)
|
||||
|
||||
/// Wen es angeht. Leer heißt: alle, die es abonniert haben.
|
||||
rollen RolleSchluessel[]
|
||||
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([organisationId, ereignis, kanal])
|
||||
@@map("benachrichtigungsvorlage")
|
||||
}
|
||||
|
||||
/// Wohin eine Person benachrichtigt werden will.
|
||||
///
|
||||
/// Ein Datensatz je Person und Kanal, mit der Adresse dieses Kanals: die
|
||||
/// Chat-Kennung bei Telegram, das Thema bei ntfy, das Abonnement des Browsers
|
||||
/// bei Web-Push. Die E-Mail steht schon an der Person.
|
||||
///
|
||||
/// `ereignisse` sagt, wofür dieser Kanal benutzt wird. Leer heißt: für nichts —
|
||||
/// **niemand wird ungefragt benachrichtigt.**
|
||||
model Zustellkanal {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
personId String @db.Uuid
|
||||
|
||||
kanal Kanal
|
||||
|
||||
/// Chat-Kennung, ntfy-Thema oder die Endpunktadresse des Browsers.
|
||||
ziel String @db.VarChar(500)
|
||||
/// Web-Push braucht zwei Schlüssel dazu.
|
||||
p256dh String? @db.VarChar(200)
|
||||
auth String? @db.VarChar(200)
|
||||
|
||||
/// Wofür dieser Kanal benutzt wird.
|
||||
ereignisse EreignisTyp[]
|
||||
|
||||
bezeichnung String? @db.VarChar(120)
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
/// Wann zuletzt etwas darüber ankam — ein toter Kanal fällt sonst nicht auf.
|
||||
letzteZustellung DateTime? @db.Timestamptz(3)
|
||||
fehlversuche Int @default(0)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
person Person @relation("ZustellkanalPerson", fields: [personId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([personId, kanal, ziel])
|
||||
@@index([organisationId, kanal])
|
||||
@@map("zustellkanal")
|
||||
}
|
||||
|
||||
/// Eine verschickte oder zu verschickende Benachrichtigung.
|
||||
///
|
||||
/// Sie steht **vor** dem Versand in der Tabelle, nicht danach: der
|
||||
/// Hintergrundprozess arbeitet sie ab und vermerkt, wie es ausging. Damit ist
|
||||
/// nachvollziehbar, wer wann worüber informiert wurde — und wer nicht, weil
|
||||
/// sein Kanal nicht erreichbar war.
|
||||
model Benachrichtigung {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
ereignis EreignisTyp
|
||||
kanal Kanal
|
||||
personId String @db.Uuid
|
||||
|
||||
betreff String? @db.VarChar(200)
|
||||
text String @db.VarChar(2000)
|
||||
/// Wohin es ging — zum Nachvollziehen, auch wenn der Kanal später wegfällt.
|
||||
ziel String @db.VarChar(500)
|
||||
|
||||
/// Worum es ging: "Wartungstermin", "Termin", "Mangel" …
|
||||
entitaet String? @db.VarChar(80)
|
||||
entitaetId String? @db.Uuid
|
||||
|
||||
stand Zustellstand @default(WARTET)
|
||||
versuche Int @default(0)
|
||||
gesendetAm DateTime? @db.Timestamptz(3)
|
||||
fehler String? @db.VarChar(500)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
person Person @relation("BenachrichtigungEmpfaenger", fields: [personId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([organisationId, stand])
|
||||
@@index([organisationId, personId, erstelltAm(sort: Desc)])
|
||||
@@map("benachrichtigung")
|
||||
}
|
||||
|
||||
/// Ein Termin im Kalender der Wehr.
|
||||
///
|
||||
/// Serientermine werden **ausgeschrieben**, nicht gerechnet: jeder Abend ist
|
||||
/// ein eigener Datensatz mit Verweis auf den ersten. Sonst ließe sich ein
|
||||
/// einzelner Abend nicht absagen, keine Teilnehmerliste führen und keine
|
||||
/// Anwesenheit abhaken — und genau das ist der Alltag.
|
||||
model Termin {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
art TerminArt @default(DIENST)
|
||||
titel String @db.VarChar(200)
|
||||
|
||||
beschreibung String? @db.VarChar(4000)
|
||||
ort String? @db.VarChar(200)
|
||||
|
||||
beginn DateTime @db.Timestamptz(3)
|
||||
ende DateTime? @db.Timestamptz(3)
|
||||
ganztaegig Boolean @default(false)
|
||||
|
||||
/// Wer ihn leitet.
|
||||
leiterId String? @db.Uuid
|
||||
|
||||
/// Ab wie vielen Zusagen die Warteliste beginnt. Null heißt: unbegrenzt.
|
||||
teilnehmerLimit Int?
|
||||
/// Bis wann man sich anmelden kann.
|
||||
anmeldeschluss DateTime? @db.Timestamptz(3)
|
||||
/// Erwartet die Wehr eine Rückmeldung? Sonst ist es nur ein Eintrag.
|
||||
anmeldungNoetig Boolean @default(false)
|
||||
|
||||
/// Serie: der erste Termin trägt das Muster, alle tragen dieselbe Serienkennung.
|
||||
serieId String? @db.Uuid
|
||||
serienmuster Serienmuster @default(KEINE)
|
||||
|
||||
/// Abgesagt statt gelöscht — wer schon zugesagt hat, soll den Grund sehen.
|
||||
abgesagtAm DateTime? @db.Timestamptz(3)
|
||||
absageGrund String? @db.VarChar(500)
|
||||
|
||||
/// Wann erinnert wurde, damit es nicht zweimal passiert.
|
||||
erinnertAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
leiter Person? @relation("TerminLeitung", fields: [leiterId], references: [id], onDelete: SetNull)
|
||||
anmeldungen Terminanmeldung[]
|
||||
|
||||
@@index([organisationId, beginn])
|
||||
@@index([organisationId, serieId])
|
||||
@@map("termin")
|
||||
}
|
||||
|
||||
/// Zu- oder Absage einer Person zu einem Termin.
|
||||
model Terminanmeldung {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
terminId String @db.Uuid
|
||||
personId String @db.Uuid
|
||||
|
||||
stand Teilnahmestand @default(ZUGESAGT)
|
||||
/// Bei einer Absage: warum. Freiwillig, aber hilfreich für die Planung.
|
||||
grund String? @db.VarChar(300)
|
||||
|
||||
/// Beim Abhaken der Anwesenheitsliste gesetzt.
|
||||
anwesend Boolean?
|
||||
|
||||
gemeldetAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
termin Termin @relation(fields: [terminId], references: [id], onDelete: Cascade)
|
||||
person Person @relation("TerminanmeldungPerson", fields: [personId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([terminId, personId])
|
||||
@@index([organisationId, personId])
|
||||
@@map("terminanmeldung")
|
||||
}
|
||||
|
||||
/// Ein Kalenderabonnement je Person.
|
||||
///
|
||||
/// Der Kalender im Handy fragt die Adresse regelmäßig ab — ohne Anmeldung,
|
||||
/// also mit einem Geheimnis in der Adresse. Wie beim Einsatzportal aus Phase 3
|
||||
/// liegt davon nur der Hash in der Datenbank: wer die Tabelle liest, bekommt
|
||||
/// keinen Zugang.
|
||||
model Kalenderzugang {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
personId String @db.Uuid
|
||||
|
||||
bezeichnung String @db.VarChar(120)
|
||||
/// SHA-256 des Tokens. Das Token selbst sieht man genau einmal.
|
||||
tokenHash String @db.VarChar(64)
|
||||
|
||||
/// Nur eigene Termine oder alle der Wehr?
|
||||
nurEigene Boolean @default(false)
|
||||
|
||||
aktiv Boolean @default(true)
|
||||
letzterZugriff DateTime? @db.Timestamptz(3)
|
||||
zugriffe Int @default(0)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
widerrufenAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
person Person @relation("KalenderzugangPerson", fields: [personId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tokenHash])
|
||||
@@index([organisationId, personId])
|
||||
@@map("kalenderzugang")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene
|
||||
* Überwachung mit vollständigem Protokoll. Ab Phase 6 dazu: Koordinaten für
|
||||
* die Standorte, 24 Löschwasserstellen im Ausrückegebiet und drei Objekte mit
|
||||
* Feuerwehrplan.
|
||||
* Feuerwehrplan. Ab Phase 7 dazu: der Jahresplan — Übungsabend als Serie,
|
||||
* Lehrgang mit Warteliste, Arbeitsdienst, Jahreshauptversammlung, ein
|
||||
* abgesagter Termin und eine abgehakte Anwesenheitsliste. Bewusst OHNE
|
||||
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
|
||||
*
|
||||
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
||||
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||||
@@ -57,13 +60,17 @@ import {
|
||||
HydrantStatus,
|
||||
HydrantTyp,
|
||||
ObjektKategorie,
|
||||
Serienmuster,
|
||||
StandortArt,
|
||||
Teilnahmestand,
|
||||
TerminArt,
|
||||
TruppStatus,
|
||||
UebungsArt,
|
||||
UebungsartAtemschutz,
|
||||
WaescheStatus,
|
||||
} from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { serientermine } from "../src/lib/termin/serie";
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
@@ -2754,6 +2761,244 @@ async function kartendatenAnlegen(organisationId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Phase 7 — Benachrichtigungen und Termine
|
||||
//
|
||||
// Der Jahresplan einer Wehr, wie er wirklich aussieht: der Übungsabend jeden
|
||||
// zweiten Dienstag, ein Lehrgang mit begrenzten Plätzen (samt Warteliste, weil
|
||||
// die immer entsteht), der Arbeitsdienst im Frühjahr, die
|
||||
// Jahreshauptversammlung — und ein Termin, der abgesagt wurde, damit man sieht,
|
||||
// wie das aussieht.
|
||||
//
|
||||
// Bewusst KEINE Zustellkanäle im Seed: niemand wird ungefragt benachrichtigt,
|
||||
// und das gilt auch für Beispieldaten. Wer Push ausprobieren will, richtet ihn
|
||||
// unter /benachrichtigungen selbst ein — genau so, wie es später jedes
|
||||
// Mitglied tut.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function termineAnlegen(organisationId: string): Promise<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
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -2794,6 +3039,9 @@ async function main(): Promise<void> {
|
||||
// Phase 6 — Kartenmodule
|
||||
await kartendatenAnlegen(organisation.id);
|
||||
|
||||
// Phase 7 — Benachrichtigungen und Termine
|
||||
await termineAnlegen(organisation.id);
|
||||
|
||||
log("Fertig.");
|
||||
log("");
|
||||
log("Anmeldung für den Erststart:");
|
||||
|
||||
@@ -161,3 +161,54 @@ self.addEventListener("message", (ereignis) => {
|
||||
ereignis.waitUntil(caches.delete(DATEN));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Web-Push (Phase 7).
|
||||
*
|
||||
* Ein Push erreicht den Browser auch dann, wenn FLORIAN nicht offen ist —
|
||||
* deshalb muss der Service Worker ihn annehmen. Er tut zwei Dinge und sonst
|
||||
* nichts: die Meldung anzeigen und beim Antippen die passende Seite öffnen.
|
||||
*
|
||||
* `tag` sorgt dafür, dass eine neue Meldung zum selben Ereignis die alte
|
||||
* ersetzt statt sich daneben zu legen. Sonst stapeln sich nachts fünf
|
||||
* Erinnerungen an denselben Termin.
|
||||
*/
|
||||
self.addEventListener("push", (ereignis) => {
|
||||
if (!ereignis.data) return;
|
||||
|
||||
let inhalt = {};
|
||||
try {
|
||||
inhalt = ereignis.data.json();
|
||||
} catch (fehler) {
|
||||
inhalt = { titel: "FLORIAN", text: ereignis.data.text() };
|
||||
}
|
||||
|
||||
ereignis.waitUntil(
|
||||
self.registration.showNotification(inhalt.titel || "FLORIAN", {
|
||||
body: inhalt.text || "",
|
||||
icon: "/icons/florian-192.png",
|
||||
badge: "/icons/florian-192.png",
|
||||
tag: inhalt.kennzeichen || "florian",
|
||||
renotify: Boolean(inhalt.kennzeichen),
|
||||
data: { verweis: inhalt.verweis || "/" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (ereignis) => {
|
||||
ereignis.notification.close();
|
||||
const ziel = (ereignis.notification.data && ereignis.notification.data.verweis) || "/";
|
||||
|
||||
ereignis.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((fenster) => {
|
||||
// Ein schon offenes FLORIAN in den Vordergrund holen, statt ein zweites
|
||||
// aufzumachen — sonst hat man nach einer Woche zwölf Tabs.
|
||||
for (const eintrag of fenster) {
|
||||
if (eintrag.url.includes(new URL(ziel, self.location.origin).pathname)) {
|
||||
return eintrag.focus();
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(ziel);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -204,6 +204,32 @@ const AUFNAHMEN: Aufnahme[] = [
|
||||
beschreibung: "Wasserförderung über lange Wegstrecke",
|
||||
angemeldet: true,
|
||||
},
|
||||
|
||||
// Phase 7 — Benachrichtigungen und Termine
|
||||
{
|
||||
name: "37-termine",
|
||||
pfad: "/termine",
|
||||
beschreibung: "Terminkalender mit Rückmeldung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "38-benachrichtigungen",
|
||||
pfad: "/benachrichtigungen",
|
||||
beschreibung: "Eigene Zustellwege und Kalenderabonnement",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "39-vorlagen",
|
||||
pfad: "/benachrichtigungen/vorlagen",
|
||||
beschreibung: "Textvorlagen mit Platzhaltern und Vorschau",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "40-dienstausweis",
|
||||
pfad: "/ausweis",
|
||||
beschreibung: "Digitaler Dienstausweis mit QR-Code",
|
||||
angemeldet: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -298,15 +324,41 @@ async function fertigGeladen(seite: Page): Promise<void> {
|
||||
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> {
|
||||
for (let versuch = 1; versuch <= 3; versuch += 1) {
|
||||
await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" });
|
||||
await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||
await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
||||
await seite.getByRole("button", { name: "Anmelden" }).click();
|
||||
// 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.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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
`);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,23 +9,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -14,23 +14,10 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||