Phase 7: Benachrichtigungen und Termine

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 08:39:38 +02:00
co-authored by Claude Opus 5
parent c779266450
commit 0b8874da26
134 changed files with 9340 additions and 123 deletions
+320 -8
View File
@@ -209,14 +209,20 @@ model Organisation {
belastungsuebungen Belastungsuebung[]
// Phase 6 — Kartenmodule
hydranten Hydrant[]
objekte Objekt[]
objektkontakte Objektkontakt[]
objektplaene Objektplan[]
lagekarten Lagekarte[]
lageelemente Lageelement[]
wasserfoerderungen Wasserfoerderung[]
foerderpunkte Foerderpunkt[]
hydranten Hydrant[]
objekte Objekt[]
objektkontakte Objektkontakt[]
objektplaene Objektplan[]
lagekarten Lagekarte[]
lageelemente Lageelement[]
wasserfoerderungen Wasserfoerderung[]
foerderpunkte Foerderpunkt[]
benachrichtigungsvorlages Benachrichtigungsvorlage[]
zustellkanals Zustellkanal[]
benachrichtigungs Benachrichtigung[]
termins Termin[]
terminanmeldungs Terminanmeldung[]
kalenderzugangs Kalenderzugang[]
@@index([aktiv])
@@map("organisation")
@@ -324,6 +330,13 @@ model Person {
gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner")
geplanteFoerderstrecken Wasserfoerderung[] @relation("WasserfoerderungPlaner")
// Phase 7 — Benachrichtigungen und Termine
benachrichtigungen Benachrichtigung[] @relation("BenachrichtigungEmpfaenger")
zustellkanaele Zustellkanal[] @relation("ZustellkanalPerson")
geleiteteTermine Termin[] @relation("TerminLeitung")
terminanmeldungen Terminanmeldung[] @relation("TerminanmeldungPerson")
kalenderzugaenge Kalenderzugang[] @relation("KalenderzugangPerson")
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email])
@@ -2964,3 +2977,302 @@ model Foerderpunkt {
@@index([organisationId, wasserfoerderungId])
@@map("foerderpunkt")
}
// =============================================================================
// Phase 7 — Benachrichtigungen und Termine
//
// Zwei Themen, die zusammengehören: ein Termin ist der häufigste Anlass für
// eine Benachrichtigung, und eine Benachrichtigung ohne Termin dahinter ist
// meistens eine Frist.
//
// Der Aufbau folgt einer Regel: **niemand wird ungefragt benachrichtigt.**
// Jede Person entscheidet je Ereignis und Kanal selbst. Die Wehr legt fest,
// was es überhaupt gibt (Vorlagen) und wen es betrifft (Rollen) — ob
// zugestellt wird, entscheidet die Person.
// =============================================================================
/// Wofür es eine Benachrichtigung geben kann.
enum EreignisTyp {
PRUEFUNG_FAELLIG
MANGEL_GEMELDET
QUALIFIKATION_LAEUFT_AB
TERMIN_NEU
TERMIN_ERINNERUNG
TERMIN_ABGESAGT
EINSATZBERICHT_FREIGEGEBEN
}
/// Wie zugestellt wird.
enum Kanal {
/// SMTP über den eigenen Mailserver.
MAIL
/// Telegram-Bot. Externer Dienst — nur Hinweise ohne Personenbezug.
TELEGRAM
/// ntfy, selbst gehostet. Der einzige Push-Weg ohne fremden Server.
NTFY
/// Web-Push über den Browser. Braucht VAPID-Schlüssel.
WEBPUSH
}
/// Was für ein Termin.
enum TerminArt {
DIENST
UEBUNG
LEHRGANG
ARBEITSDIENST
VERANSTALTUNG
SITZUNG
SONSTIGES
}
/// Wie oft ein Termin wiederkehrt.
enum Serienmuster {
KEINE
WOECHENTLICH
ZWEIWOECHENTLICH
MONATLICH
JAEHRLICH
}
/// Was jemand zu einem Termin gesagt hat.
enum Teilnahmestand {
ZUGESAGT
ABGESAGT
/// Angemeldet, aber die Liste war voll — rückt nach, wenn jemand absagt.
WARTELISTE
}
/// Wie es einer Benachrichtigung ergangen ist.
enum Zustellstand {
WARTET
GESENDET
FEHLGESCHLAGEN
/// Niemand hat diesen Kanal abonniert oder der Kanal ist aus.
UEBERSPRUNGEN
}
/// Eine Vorlage je Ereignis und Kanal.
///
/// Der Text enthält Platzhalter in geschweiften Klammern: `{titel}`, `{datum}`,
/// `{wehr}`. Welche es gibt, steht in lib/benachrichtigung/vorlagen.ts — und
/// die Oberfläche zeigt sie beim Bearbeiten an, damit niemand raten muss.
///
/// Fehlt eine Vorlage, greift der eingebaute Standardtext. Eine Wehr, die
/// nichts einstellt, bekommt trotzdem brauchbare Nachrichten.
model Benachrichtigungsvorlage {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
ereignis EreignisTyp
kanal Kanal
/// Nur für E-Mail. Push-Dienste kennen keinen Betreff.
betreff String? @db.VarChar(200)
text String @db.VarChar(2000)
/// Wen es angeht. Leer heißt: alle, die es abonniert haben.
rollen RolleSchluessel[]
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
@@unique([organisationId, ereignis, kanal])
@@map("benachrichtigungsvorlage")
}
/// Wohin eine Person benachrichtigt werden will.
///
/// Ein Datensatz je Person und Kanal, mit der Adresse dieses Kanals: die
/// Chat-Kennung bei Telegram, das Thema bei ntfy, das Abonnement des Browsers
/// bei Web-Push. Die E-Mail steht schon an der Person.
///
/// `ereignisse` sagt, wofür dieser Kanal benutzt wird. Leer heißt: für nichts —
/// **niemand wird ungefragt benachrichtigt.**
model Zustellkanal {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
kanal Kanal
/// Chat-Kennung, ntfy-Thema oder die Endpunktadresse des Browsers.
ziel String @db.VarChar(500)
/// Web-Push braucht zwei Schlüssel dazu.
p256dh String? @db.VarChar(200)
auth String? @db.VarChar(200)
/// Wofür dieser Kanal benutzt wird.
ereignisse EreignisTyp[]
bezeichnung String? @db.VarChar(120)
aktiv Boolean @default(true)
/// Wann zuletzt etwas darüber ankam — ein toter Kanal fällt sonst nicht auf.
letzteZustellung DateTime? @db.Timestamptz(3)
fehlversuche Int @default(0)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("ZustellkanalPerson", fields: [personId], references: [id], onDelete: Cascade)
@@unique([personId, kanal, ziel])
@@index([organisationId, kanal])
@@map("zustellkanal")
}
/// Eine verschickte oder zu verschickende Benachrichtigung.
///
/// Sie steht **vor** dem Versand in der Tabelle, nicht danach: der
/// Hintergrundprozess arbeitet sie ab und vermerkt, wie es ausging. Damit ist
/// nachvollziehbar, wer wann worüber informiert wurde — und wer nicht, weil
/// sein Kanal nicht erreichbar war.
model Benachrichtigung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
ereignis EreignisTyp
kanal Kanal
personId String @db.Uuid
betreff String? @db.VarChar(200)
text String @db.VarChar(2000)
/// Wohin es ging — zum Nachvollziehen, auch wenn der Kanal später wegfällt.
ziel String @db.VarChar(500)
/// Worum es ging: "Wartungstermin", "Termin", "Mangel" …
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
stand Zustellstand @default(WARTET)
versuche Int @default(0)
gesendetAm DateTime? @db.Timestamptz(3)
fehler String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("BenachrichtigungEmpfaenger", fields: [personId], references: [id], onDelete: Cascade)
@@index([organisationId, stand])
@@index([organisationId, personId, erstelltAm(sort: Desc)])
@@map("benachrichtigung")
}
/// Ein Termin im Kalender der Wehr.
///
/// Serientermine werden **ausgeschrieben**, nicht gerechnet: jeder Abend ist
/// ein eigener Datensatz mit Verweis auf den ersten. Sonst ließe sich ein
/// einzelner Abend nicht absagen, keine Teilnehmerliste führen und keine
/// Anwesenheit abhaken — und genau das ist der Alltag.
model Termin {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
art TerminArt @default(DIENST)
titel String @db.VarChar(200)
beschreibung String? @db.VarChar(4000)
ort String? @db.VarChar(200)
beginn DateTime @db.Timestamptz(3)
ende DateTime? @db.Timestamptz(3)
ganztaegig Boolean @default(false)
/// Wer ihn leitet.
leiterId String? @db.Uuid
/// Ab wie vielen Zusagen die Warteliste beginnt. Null heißt: unbegrenzt.
teilnehmerLimit Int?
/// Bis wann man sich anmelden kann.
anmeldeschluss DateTime? @db.Timestamptz(3)
/// Erwartet die Wehr eine Rückmeldung? Sonst ist es nur ein Eintrag.
anmeldungNoetig Boolean @default(false)
/// Serie: der erste Termin trägt das Muster, alle tragen dieselbe Serienkennung.
serieId String? @db.Uuid
serienmuster Serienmuster @default(KEINE)
/// Abgesagt statt gelöscht — wer schon zugesagt hat, soll den Grund sehen.
abgesagtAm DateTime? @db.Timestamptz(3)
absageGrund String? @db.VarChar(500)
/// Wann erinnert wurde, damit es nicht zweimal passiert.
erinnertAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
leiter Person? @relation("TerminLeitung", fields: [leiterId], references: [id], onDelete: SetNull)
anmeldungen Terminanmeldung[]
@@index([organisationId, beginn])
@@index([organisationId, serieId])
@@map("termin")
}
/// Zu- oder Absage einer Person zu einem Termin.
model Terminanmeldung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
terminId String @db.Uuid
personId String @db.Uuid
stand Teilnahmestand @default(ZUGESAGT)
/// Bei einer Absage: warum. Freiwillig, aber hilfreich für die Planung.
grund String? @db.VarChar(300)
/// Beim Abhaken der Anwesenheitsliste gesetzt.
anwesend Boolean?
gemeldetAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
termin Termin @relation(fields: [terminId], references: [id], onDelete: Cascade)
person Person @relation("TerminanmeldungPerson", fields: [personId], references: [id], onDelete: Cascade)
@@unique([terminId, personId])
@@index([organisationId, personId])
@@map("terminanmeldung")
}
/// Ein Kalenderabonnement je Person.
///
/// Der Kalender im Handy fragt die Adresse regelmäßig ab — ohne Anmeldung,
/// also mit einem Geheimnis in der Adresse. Wie beim Einsatzportal aus Phase 3
/// liegt davon nur der Hash in der Datenbank: wer die Tabelle liest, bekommt
/// keinen Zugang.
model Kalenderzugang {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
bezeichnung String @db.VarChar(120)
/// SHA-256 des Tokens. Das Token selbst sieht man genau einmal.
tokenHash String @db.VarChar(64)
/// Nur eigene Termine oder alle der Wehr?
nurEigene Boolean @default(false)
aktiv Boolean @default(true)
letzterZugriff DateTime? @db.Timestamptz(3)
zugriffe Int @default(0)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
widerrufenAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("KalenderzugangPerson", fields: [personId], references: [id], onDelete: Cascade)
@@unique([tokenHash])
@@index([organisationId, personId])
@@map("kalenderzugang")
}