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
+249
-1
@@ -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:");
|
||||
|
||||
Reference in New Issue
Block a user