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:
co-authored by
Claude Opus 5
parent
c779266450
commit
0b8874da26
+320
-8
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user