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
@@ -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;
$$;
+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")
}
+249 -1
View File
@@ -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:");