diff --git a/deploy/README.md b/deploy/README.md
index 771a55a..0266dec 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -53,7 +53,7 @@ Grenzen: 10 MiB pro Datei, fünf Dateien und 25 MiB pro Nachricht; standardmäß
`npm run uploads:cleanup` zeigt zunächst nur verwaiste Dateien mit mindestens 24 Stunden Alter. Nach Prüfung entfernt `npm run uploads:cleanup -- --delete` diese Dateien. Backups, Speicherüberwachung und eine Aufbewahrungs-/Löschstrategie für Backups separat einrichten. Dateien gelöschter Datensätze werden auch unmittelbar bestmöglich entfernt.
-Die SQL-Migrationen ergänzen **41 zusammengesetzte Tenant-Fremdschlüssel**, die Prisma wegen der vorhandenen optionalen Cascade-/SetNull-Beziehungen nicht vollständig abbildet. Zukünftige generierte Migrationen auf unbeabsichtigtes Entfernen dieser Constraints prüfen. Kein `prisma db push` auf verwalteten Datenbanken einsetzen. Runtime-Grants nach zukünftigen Migrationen erneut bereitstellen.
+Die SQL-Migrationen ergänzen **44 zusammengesetzte Tenant-Fremdschlüssel**, die Prisma wegen der vorhandenen optionalen Cascade-/SetNull-Beziehungen nicht vollständig abbildet. Zukünftige generierte Migrationen auf unbeabsichtigtes Entfernen dieser Constraints prüfen. Kein `prisma db push` auf verwalteten Datenbanken einsetzen. Runtime-Grants nach zukünftigen Migrationen erneut bereitstellen.
## Verifikation und Updates
@@ -68,3 +68,16 @@ npm audit --omit=dev
Die Integrationstests setzen eine ausdrücklich isolierte lokale PostgreSQL-Datenbank namens `kita_security_test` voraus; `SECURITY_TEST_DATABASE_URL` darf keine Anwendungsdaten enthalten. `npm run test:security` prüft Aktionen und echte DB-Transaktionen. `npm run test:security:http` benötigt den Production-Server auf localhost:3057 mit derselben Testdatenbank, `UPLOAD_DIR` und dem im Test dokumentierten Test-Secret. Niemals dieses Test-Secret in Produktion verwenden.
Docker-Images sind auf Digests fixiert. Neue Sicherheitsreleases regelmäßig prüfen und die Digests bewusst aktualisieren. Zum Prüfzeitpunkt verbleibt eine ungepatchte `braces`-Schwachstelle in der Entwicklungs-Lint-Kette; `npm audit --force` löst sie nicht sinnvoll. Der Runtime-Audit ist ohne bekannte Findings. NextAuth bleibt eine Beta-Abhängigkeit und benötigt weiterhin Update-Beobachtung.
+
+
+## Notdienst-Erinnerungen und Veröffentlichungsmails
+
+Die Migration `20261012000000_notdienst_notifications` ergänzt Mailaufträge und die monatliche Erinnerungsrunde. Vor dem Start der neuen App `npm run db:migrate:deploy` ausführen und die Runtime-Grants erneut bereitstellen. Der bestehende Initialisierungsdienst erledigt beide Schritte im Compose-Deployment. Kein `db push` verwenden; die bisherigen Tenant-Constraints bleiben erhalten.
+
+`NOTDIENST_JOB_SECRET` (mindestens 32 Zeichen), `AUTH_URL`/`NEXT_PUBLIC_SITE_URL`, `RESEND_API_KEY` und `EMAIL_FROM` müssen eingerichtet sein. Ein neues Secret oder ein zusätzlicher externer Cronjob ist nicht nötig, wenn der vorhandene Notdienstjob bereits konfiguriert ist. Der Produktionsstart überwacht Webserver und Worker gemeinsam: der Worker wird nach einem Abbruch neu gestartet; beim Ende des Webservers werden beide Prozesse beendet.
+
+Der geschützte `POST /api/jobs/notdienstalarme` verarbeitet weiterhin Alarme und zusätzlich Planungserinnerungen/gespeicherte Benachrichtigungen. Die JSON-Antwort ergänzt `planning` mit `queued`, `sent`, `failed` und `skipped`. Ein Lauf bearbeitet bis zu 100 fällige Kitas und zwölf Planungsmails (vier parallel). Der Worker ruft die Route minütlich auf. Versanddetails und manuelle Wiederholung finden sich in der Monatsplanung.
+
+Die automatische Runde läuft einmal pro Kita und Folgemonat, ab 9 Uhr Berliner Zeit, standardmäßig ab dem 19. (sieben Tage vor Ende der Eintragsfrist am 26.). Ein verpasster Termin wird bis zum 26. nachgeholt. Die Einstellung 0 schaltet diese Runde ab; ein bereits veröffentlichter Plan beendet Verfügbarkeitserinnerungen. Manuelle Erinnerungen sind für den offenen Folgemonat möglich und je Konto/Tag mit der automatischen Runde zusammen dedupliziert.
+
+Veröffentlichung und Mailvorbereitung sind eine Transaktion. Anschließend wird der Versand versucht; ein Mailfehler setzt den Plan nicht zurück. Wiederholungen prüfen aktuelle Konten, Haushalt, aktive Kinder und Einteilungen erneut. Pro Konto wird eine Mail mit allen aktuellen eigenen Diensten vorbereitet. Versandaufträge erhalten einen stabilen Idempotenzschlüssel je Nachrichteninhalt und eine fünfminütige Reservierung; Fehler erhalten zunehmende Wartezeiten und bleiben sichtbar. Der Mailtransport bricht HTTP-Anfragen nach zehn Sekunden ab. Anbieterannahme, wiederholter Versand nach unbekanntem Transportausgang und tatsächliche Postfachzustellung sind weiterhin durch die Garantien des Mailanbieters begrenzt.
diff --git a/deploy/production.env.example b/deploy/production.env.example
index 029f47f..09667bc 100644
--- a/deploy/production.env.example
+++ b/deploy/production.env.example
@@ -40,7 +40,7 @@ RESEND_API_KEY="re_[PRODUCTION_API_KEY]"
EMAIL_FROM="Kita-Planer <[absender@deine-domain.de]>"
ADMIN_EMAIL="[kontakt@deine-domain.de]"
-# Automatische Notdienst-Fristpruefung im App-Container.
+# Automatische Notdienst-Fristpruefung, Verfuegbarkeitserinnerungen und Mail-Wiederholungen.
# Eigenes Secret, mindestens 32 Zeichen; generieren mit: openssl rand -hex 32
NOTDIENST_JOB_SECRET="[GENERATE_ME]"
diff --git a/deploy/sql/notdienstalarm.sql b/deploy/sql/notdienstalarm.sql
index e435689..387ce33 100644
--- a/deploy/sql/notdienstalarm.sql
+++ b/deploy/sql/notdienstalarm.sql
@@ -1,5 +1,7 @@
+-- HISTORISCHER UPGRADEPFAD: aktuelle Releases verwenden prisma/migrations und deploy/README.md.
+-- Nicht zusätzlich zu versionierten Migrationen oder auf bereits aktualisierten Datenbanken ausführen.
-- Additives Upgrade für einen Personalbereich einschließlich vorhandener Mail-Erweiterung.
--- Einmalig vor dem Start der neuen Anwendung ausführen, sofern nicht prisma db push verwendet wird.
+-- Beschreibt ausschließlich den damaligen Ausgangsstand vor Einführung der Migrationshistorie.
BEGIN;
-- CreateEnum
diff --git a/deploy/sql/personalbereich-email.sql b/deploy/sql/personalbereich-email.sql
index 99b96ab..025c138 100644
--- a/deploy/sql/personalbereich-email.sql
+++ b/deploy/sql/personalbereich-email.sql
@@ -1,5 +1,7 @@
+-- HISTORISCHER UPGRADEPFAD: aktuelle Releases verwenden prisma/migrations und deploy/README.md.
+-- Nicht zusätzlich zu versionierten Migrationen oder auf bereits aktualisierten Datenbanken ausführen.
-- Nur E-Mail-Erweiterung für einen bereits mit 043fed2 eingeführten Personalbereich.
--- Alternative zu prisma db push, nicht zusätzlich oder mehrfach ausführen.
+-- Beschreibt ausschließlich den damaligen Ausgangsstand vor Einführung der Migrationshistorie.
BEGIN;
-- AlterTable
diff --git a/deploy/sql/personalbereich.sql b/deploy/sql/personalbereich.sql
index dd7ac0f..a5115d5 100644
--- a/deploy/sql/personalbereich.sql
+++ b/deploy/sql/personalbereich.sql
@@ -1,5 +1,7 @@
+-- HISTORISCHER UPGRADEPFAD: aktuelle Releases verwenden prisma/migrations und deploy/README.md.
+-- Nicht zusätzlich zu versionierten Migrationen oder auf bereits aktualisierten Datenbanken ausführen.
-- Einmalige Einführung des vollständigen Personalbereichs inklusive E-Mails ab ec3f4f9.
--- Alternative zu prisma db push, nicht zusätzlich oder mehrfach ausführen.
+-- Beschreibt ausschließlich den damaligen Ausgangsstand vor Einführung der Migrationshistorie.
BEGIN;
-- CreateEnum
diff --git a/docs/FUNKTIONSUEBERSICHT.md b/docs/FUNKTIONSUEBERSICHT.md
index bd2be46..8198642 100644
--- a/docs/FUNKTIONSUEBERSICHT.md
+++ b/docs/FUNKTIONSUEBERSICHT.md
@@ -1,5 +1,44 @@
# Kita-Planer – Funktionsübersicht und aktueller Stand
+## Ergänzung vom 12.10.2026: Notdienst-Benachrichtigungen
+
+- Automatische Erinnerungen erfassen auch unvollständige Verfügbarkeiten. Das Soll wird aus allen aktiven Kindern und die Istzahl aus unterschiedlichen gültigen Tagen berechnet. Admin-/Koordinationskonten mit eigenem Haushalt gehören zur Eltern-Empfängergruppe.
+- Die Erinnerungsfrist bezieht sich jetzt ausdrücklich auf den 26. als Ende der Eintragsfrist. Standard: sieben Tage vorher ab 9 Uhr Berliner Zeit; 0 deaktiviert die automatische Runde. Verpasste Läufe werden innerhalb der noch offenen Frist nachgeholt. Jede Kita/Folgemonat erhält eine automatische Runde; manuelle Erinnerungen sind je Konto/Tag dedupliziert.
+- Beim ersten Veröffentlichen werden Mailaufträge in derselben Transaktion gespeichert. Pro erreichbarem Konto wird eine gemeinsame Nachricht mit den aktuellen eigenen Diensten erzeugt. Mehrfaches Veröffentlichen erzeugt keine zweite Runde. Mailfehler ändern den veröffentlichten Status nicht.
+- Die Monatsplanung zeigt Versandzahlen und Einzelstatus sowie eine manuelle Wiederholung offener/fehlgeschlagener Nachrichten. Bereits versendete oder inzwischen überholte Aufträge werden dabei nicht neu verschickt.
+- Der bestehende geschützte Notdienstjob verarbeitet die Nachrichten automatisch. Gespeicherte Reservierungen, Idempotenzschlüssel, zunehmende Wiederholungsabstände und erneute Berechtigungs-/Einteilungsprüfung sichern die Verarbeitung. Der Produktionsstart startet einen abgebrochenen Worker erneut.
+- Additive Migration: `20261012000000_notdienst_notifications`. Sie erhält die bisherigen Constraints und ergänzt drei weitere Tenant-Fremdschlüssel. Einrichtung und Grenzen stehen in [deploy/README.md](../deploy/README.md).
+
+Prüfbefehle: `npm run test:mail`, `npm run test:runtime`, `npm run test:notdienst` und `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_notifications_test npm run test:notdienst:notifications` nach Anwendung der Migrationen auf dieser separaten Testdatenbank. Die Versandtests verwenden ersetzte Transporte; reale Eltern werden nicht angeschrieben.
+
+Abnahme: 33 neue Versand-Integrationstests, vier Tests des tatsächlichen SDK-Transports, zwei Supervisor-Tests, sechs Datums-/Fristtests sowie 106 bestehende Sicherheits-, Planungs-, Kalender- und Elternablauftests erfolgreich (151 insgesamt, keine übersprungenen Tests). Browserprüfung mit lokalem Mailserver: automatische Erinnerung einmalig, Veröffentlichung trotz simuliertem Versandfehler, sichtbare Einzelstatus, erfolgreiche Wiederholung und mobile Anzeige. TypeScript und Produktionsbuild erfolgreich; ESLint ohne Fehler mit acht bestehenden Warnungen.
+Zusätzliche Upgrade-Prüfung gegen das vorige Schema: vorhandene Familien, Konten, Kinder, veröffentlichter Plan und Einteilung unverändert erhalten; keine nachträglichen Veröffentlichungsmails angelegt. Die Zahl der ergänzenden Tenant-Fremdschlüssel steigt von 41 auf 44.
+
+## Ergänzung vom 12.10.2026: Kalender und kleine Bedienungsverbesserungen
+
+- Eigene Mitbringbeiträge lassen sich direkt mit Speichern/Abbrechen bearbeiten. Enter speichert; Escape schließt den Termindialog und verwirft den Entwurf. Eingaben werden auf 1 bis 500 Zeichen begrenzt; fremde Beiträge können auch von Admin/Koordination nicht umgeschrieben werden. Ein bestätigter Termin mit aktiver Mitbringliste und die Kita-/Autorzuordnung werden serverseitig geprüft.
+- Eltern mit Admin- oder Koordinationsrolle können für den eigenen Haushalt Dienste tauschen. Kalenderknöpfe und offene eingehende/ausgehende Anfragen stehen ihnen ebenfalls zur Verfügung. Tauschmails berücksichtigen verifizierte, aktive Elternkonten aller drei Rollen im jeweils beteiligten Haushalt. Konten ohne eigenen Haushalt erhalten dadurch kein Tauschrecht; fremde Angebote und aktive Alarme bleiben gesperrt.
+- Die fest eingetragene „4“ am Menüpunkt Notdienst wurde entfernt.
+- Kein Schema-Upgrade erforderlich. Automatische Erinnerungen und Veröffentlichungsmails bleiben die nächste Etappe.
+
+Prüfbefehl für die neuen Abläufe: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_quickwins_test npm run test:calendar:integration`. Er verlangt eine ausdrücklich benannte lokale Testdatenbank mit den vorhandenen Migrationen. Die 26 Fälle prüfen echte Server Actions und PostgreSQL; Mailtransport und Anmeldesession werden isoliert ersetzt. Die bestehenden Elternabläufe wurden ebenfalls mit 9 Integrationstests und 9 Berechnungstests geprüft.
+
+Im Browser geprüft: eigener Beitrag speichern und abbrechen, Anzeige bei 390 Pixeln, fehlender Bearbeiten-Knopf bei fremden Beiträgen, Navigation ohne festen Zähler, Admin-Tauschanfrage mit eigenem Angebot sowie Anzeige und Annahme durch Koordination. E-Mails wurden dabei ausschließlich im Entwicklungsmodus simuliert.
+
+Abnahme dieser Etappe: 26 neue Integrationstests, 18 bestehende Elternablauftests und 28 Sicherheitstests erfolgreich (72 insgesamt, keine übersprungenen Tests). TypeScript und Produktionsbuild erfolgreich; projektweites ESLint ohne Fehler mit acht bestehenden Warnungen.
+
+## Ergänzung vom 12.10.2026: Verlässliche Notdienst-Planungsregeln
+
+- Das Verfügbarkeitssoll wird aus allen aktiven Kindern des angemeldeten Haushalts berechnet. Ausgelassene, doppelte, fremde oder inaktive Kinder werden zurückgewiesen; doppelte Datumsangaben zählen nur einmal.
+- Oberfläche, Speicherung, Generierung und manuelle Zuweisung verwenden gemeinsame Regeln: nur Werktage des Planungsmonats, ohne bestätigte Schließtage und Teamtage. Mehrtägige Termine schließen beide Randtage ein; Kalenderzeiten werden in `Europe/Berlin` ausgewertet. Ausstehende/abgelehnte Termine und Termine anderer Kitas blockieren nichts.
+- Alte ungültige Verfügbarkeiten werden nicht für neue Pläne verwendet. Vor der Veröffentlichung werden Entwürfe auf zulässige Tage und aktive Kinder geprüft. Ungültige vorhandene Einteilungen bleiben sichtbar und können über „Manuell zuweisen“ entfernt werden; aktive Alarme und vergangene veröffentlichte Dienste behalten ihre Bearbeitungssperre.
+- Kindlöschung im Elternprofil und in der Familienverwaltung ist bei heutigen/zukünftigen veröffentlichten Notdiensten oder aktiven Alarmen gesperrt. Zuerst muss die Koordination die Einteilung klären. Prüfung und Löschung laufen in derselben serialisierbaren Transaktion mit einer Sperre der betroffenen Kinder.
+- Kein Schema-Upgrade erforderlich. Automatische Erinnerungen und Benachrichtigungen bei Erstveröffentlichung sind weiterhin offen.
+
+Prüfbefehle: `npm run test:notdienst` (Datumsregeln) und `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_notdienst_test npm run test:notdienst:integration` (echte Server Actions und PostgreSQL). Der Integrationsbefehl verlangt ausdrücklich eine separate lokale Testdatenbank; fehlende Konfiguration führt zu einem Fehler. Zuvor die vorhandenen Migrationen auf dieser Testdatenbank anwenden.
+
+Abnahme: 4 Datumstests, 34 PostgreSQL-Regressionstests und 28 bestehende Sicherheitstests erfolgreich. Im Browser geprüft: gesperrter Teamtag/Wochenende, Speicherung des vollständigen Haushaltssolls, Veröffentlichungssperre, Entfernen einer alten ungültigen Einteilung und gültige manuelle Zuweisung mit anschließender Veröffentlichung. TypeScript und Produktionsbuild erfolgreich; projektweites ESLint ohne Fehler mit acht bestehenden Warnungen.
+
## Ergänzung vom 09.10.2026: Notdienstalarm
Der Notdienstalarm ist inzwischen umgesetzt. Admins und Koordination alarmieren die eingeteilte Familie für einen ganzen Tag oder ein Zeitfenster. Persönliche Rückmeldelinks erzeugen eine gemeinsame Haushaltsantwort; Dashboard-Hinweise, eine 30-Minuten-Frist, Klärungsbedarf, verspätete Freigabe, telefonische Korrektur, Stornierung, Versandstatus und Hintergrundprüfung sind angebunden. Aktive Alarme sperren Planumzuordnung und Diensttausche. Siehe [NOTDIENSTALARM.md](NOTDIENSTALARM.md) für Bedienung und benötigte Hosting-Konfiguration.
diff --git a/docs/NOTDIENSTALARM.md b/docs/NOTDIENSTALARM.md
index a097079..c43db11 100644
--- a/docs/NOTDIENSTALARM.md
+++ b/docs/NOTDIENSTALARM.md
@@ -47,7 +47,7 @@ HTTP 401 bedeutet fehlende/falsche Berechtigung, 503 fehlendes/zu kurzes Job-Sec
Das Schema ergänzt einen gemeinsamen Alarmvorgang, einzelne Empfänger und Tokens, Hinweise, Mailaufträge und einen Verlauf. Die bisherigen `NotdienstAlert`-Datensätze bleiben erhalten. Ein aktiver Alarm reserviert seine Einteilung eindeutig; Transaktionen schützen konkurrierende Antworten und Planänderungen.
-Der bestehende Docker-Start nutzt `prisma db push --skip-generate`. Für kontrollierte SQL-Upgrades liegt alternativ [deploy/sql/notdienstalarm.sql](../deploy/sql/notdienstalarm.sql) vor. Es setzt den vollständigen Personalbereich einschließlich seiner Mail-Erweiterung voraus, ist einmalig auszuführen und enthält keine Datenlöschung. Nicht zusätzlich ausführen, wenn `db push` diese Änderungen bereits angewandt hat. Datenbanksicherung und Prüfung des Ausgangsschemas gehören zum Deployment. Die Auswahl der SQL-Alternativen erklärt die [zentrale Deployment- und Mailanleitung](../deploy/DEPLOYMENT_UND_MAILVERSAND.md).
+Aktuelle Releases verwenden versionierte Prisma-Migrationen über den separaten Initialisierungsdienst. Die Einrichtung beschreibt [deploy/README.md](../deploy/README.md); `prisma db push` und die historischen SQL-Upgrades sind kein aktueller Betriebsweg. Der bestehende Hintergrundjob übernimmt zusätzlich Verfügbarkeitserinnerungen und gespeicherte Veröffentlichungsmails.
Bisherige Einzelalarme werden beim Verwaltungsaufruf, Joblauf oder Aufruf eines alten Links in Gruppen pro Einteilung übernommen. Alte gültige Tokens bleiben nutzbar; Originalantworten bleiben gespeichert. Nur vollständig übereinstimmende gültige Antworten ergeben eine sichere gemeinsame Zusage. Widersprüche, fehlende/ungeklärte Empfänger oder doppelte Einzelalarme führen zu Klärungsbedarf. Bei doppelten Datensätzen eines Elternkontos wird ein Rückmeldetoken übernommen; die übrigen Originaldatensätze bleiben erhalten. Die Übernahme verschickt keine erneute Alarmanfrage.
diff --git a/docs/PERSONALBEREICH.md b/docs/PERSONALBEREICH.md
index 1bc9b10..247d37d 100644
--- a/docs/PERSONALBEREICH.md
+++ b/docs/PERSONALBEREICH.md
@@ -60,12 +60,9 @@ Parallele Änderungen laufen in serialisierbaren Datenbanktransaktionen mit Wied
## Einführung und Schema
-Das Projekt verwendet weiterhin seinen vorhandenen Ablauf mit `prisma db push`. Es gibt keine vollständige versionierte Prisma-Migrationshistorie. Die neuen Tabellen sind additiv; vorhandene Personen erhalten noch kein automatisch befülltes Beschäftigungsprofil.
+Aktuelle Releases verwenden die versionierten Migrationen in `prisma/migrations` und einen separaten Initialisierungsdienst vor dem App-Start. Die [zentrale Betriebsanleitung](../deploy/README.md) beschreibt Installation, Updates, Baseline für bestehende Datenbanken und Runtime-Grants. `scripts/start-production.sh` startet die Anwendung; es verändert das Schema nicht. Kein `prisma db push` auf verwalteten Datenbanken verwenden.
-- Lokal: Abhängigkeiten installieren, Prisma-Client generieren, Schema gegen eine dafür vorgesehene Datenbank anwenden, App starten und Profile einrichten.
-- Im vorhandenen Docker-Ablauf erzeugt `npm run build:prod` den Client; `scripts/start-production.sh` wendet das Schema vor App-Start an.
-- [Prüfbares SQL](../deploy/sql/personalbereich.sql) beschreibt dieselbe additive Änderung gegen den dokumentierten Ausgangsstand. Es ist einmalig und nicht idempotent. Entweder den vorhandenen Schemaabgleich **oder** dieses SQL verwenden; das SQL nicht nach bereits erfolgtem Schemaabgleich erneut ausführen.
-- Ist der Personalbereich bereits auf Stand `043fed2` eingeführt, beschreibt [Personalbereich-E-Mail-SQL](../deploy/sql/personalbereich-email.sql) ausschließlich die zusätzlichen Mail-Tabellen und die Profileinstellung. Auch dieses Skript ist eine einmalige Alternative zum Schemaabgleich.
+Die historischen SQL-Dateien [Personalbereich](../deploy/sql/personalbereich.sql) und [Personalbereich-E-Mails](../deploy/sql/personalbereich-email.sql) dokumentieren ihre damaligen Ausgangsstände. Sie sind kein aktueller Deployment-Schritt und dürfen nicht zusätzlich zu den versionierten Migrationen ausgeführt werden. Vorhandene Personen erhalten weiterhin erst durch Einrichtung ein Beschäftigungsprofil.
Die reale Projekt- und Produktionsdatenbank wurde bei dieser Umsetzung nicht verändert. Alle DB- und Browserprüfungen liefen mit synthetischen Daten in einer separaten lokalen PostgreSQL-Datenbank.
diff --git a/docs/elternablaeufe.md b/docs/elternablaeufe.md
index 9261157..ffd8f78 100644
--- a/docs/elternablaeufe.md
+++ b/docs/elternablaeufe.md
@@ -12,16 +12,9 @@
## Bestehende Daten und Schemaaktualisierung
-Das Projekt verwendet weiterhin `prisma db push`; es besitzt keine Prisma-Migrationshistorie. Der vorhandene Produktionsstart aktualisiert das Schema automatisch. Die neuen Pflichtfelder haben kompatible Defaults: bisherige Termine bleiben `CHILD`, bisherige Meldungen erhalten `ALLE`. Bestehende Kinderantworten werden nicht verändert.
+Aktuelle Releases verwenden die versionierten Migrationen in `prisma/migrations`. Der separate Initialisierungsdienst wendet sie vor dem App-Start an und stellt die Runtime-Grants bereit. Einrichtung und Updates beschreibt [deploy/README.md](../deploy/README.md). Kein `prisma db push` auf verwalteten Datenbanken einsetzen: zusätzliche Tenant-Constraints ergänzen das Prisma-Modell.
-Für eine kontrollierte, einmalige Aktualisierung einer Datenbank mit dem bisherigen Schema liegt zusätzlich die additive SQL-Datei `prisma/updates/20261007_parent_flows.sql` vor. Sie ist eine Alternative zum ersten `db push`, kein nachträglich anzuwendender zweiter Schritt. Beispiel:
-
-```sh
-psql "$DATABASE_URL" -v ON_ERROR_STOP=1 --single-transaction -f prisma/updates/20261007_parent_flows.sql
-npm run prisma:generate
-```
-
-Die Datei gehört bewusst nicht in `prisma/migrations`: Eine Einführung von `prisma migrate deploy` würde zuerst eine Baseline für bereits existierende Datenbanken verlangen.
+`prisma/updates/20261007_parent_flows.sql` dokumentiert den historischen Ausgangsstand vor Einführung der Migrationshistorie. Es gehört nicht zum aktuellen Deployment und darf nicht zusätzlich zu bereits angewandten Migrationen ausgeführt werden. Die kompatiblen Defaults für bisherige Termine (`CHILD`) und Meldungen (`ALLE`) sind in den versionierten Migrationen enthalten.
## Verifikation
diff --git a/package.json b/package.json
index eed8161..9683a1a 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,13 @@
"test:staff:integration": "tsx --test tests/staff-service.test.ts",
"test:alarms": "tsx --experimental-test-module-mocks --test tests/notdienst-alarm.test.ts",
"test:parent-flows": "tsx --test tests/parent-flows.test.ts",
- "test:parent-flows:integration": "tsx --experimental-test-module-mocks --test tests/parent-flows.integration.test.ts"
+ "test:parent-flows:integration": "tsx --experimental-test-module-mocks --test tests/parent-flows.integration.test.ts",
+ "test:notdienst": "tsx --test tests/notdienst-days.test.ts",
+ "test:notdienst:integration": "node --test tests/notdienst-planning.integration.test.cjs",
+ "test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs",
+ "test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs",
+ "test:mail": "node --test tests/mail-transport.test.cjs",
+ "test:runtime": "node --test tests/runtime-supervisor.test.cjs"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
diff --git a/prisma/migrations/20261012000000_notdienst_notifications/migration.sql b/prisma/migrations/20261012000000_notdienst_notifications/migration.sql
new file mode 100644
index 0000000..39b4832
--- /dev/null
+++ b/prisma/migrations/20261012000000_notdienst_notifications/migration.sql
@@ -0,0 +1,47 @@
+BEGIN;
+CREATE TABLE "notdienst_emails" (
+ "id" TEXT NOT NULL,
+ "kitaId" TEXT NOT NULL,
+ "familyId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "planId" TEXT,
+ "year" INTEGER NOT NULL,
+ "month" INTEGER NOT NULL,
+ "kind" TEXT NOT NULL,
+ "eventKey" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "sentAt" TIMESTAMP(3),
+ "skippedAt" TIMESTAMP(3),
+ "leaseUntil" TIMESTAMP(3),
+ "claimToken" TEXT,
+ "nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "failed" BOOLEAN NOT NULL DEFAULT false,
+ "attempts" INTEGER NOT NULL DEFAULT 0,
+ "lastError" TEXT,
+ CONSTRAINT "notdienst_emails_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "notdienst_emails_month_check" CHECK ("month" BETWEEN 1 AND 12),
+ CONSTRAINT "notdienst_emails_kind_check" CHECK ("kind" IN ('REMINDER','PUBLICATION')),
+ CONSTRAINT "notdienst_emails_plan_check" CHECK (("kind" = 'PUBLICATION' AND "planId" IS NOT NULL) OR ("kind" = 'REMINDER' AND "planId" IS NULL))
+);
+CREATE UNIQUE INDEX "notdienst_emails_kitaId_userId_kind_year_month_eventKey_key" ON "notdienst_emails"("kitaId","userId","kind","year","month","eventKey");
+CREATE INDEX "notdienst_emails_sentAt_skippedAt_nextAttemptAt_idx" ON "notdienst_emails"("sentAt","skippedAt","nextAttemptAt");
+CREATE INDEX "notdienst_emails_kitaId_year_month_idx" ON "notdienst_emails"("kitaId","year","month");
+ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_planId_fkey" FOREIGN KEY ("planId") REFERENCES "notdienst_plans"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_familyId_tenant_fkey" FOREIGN KEY ("familyId","kitaId") REFERENCES "families"("id","kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
+ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_userId_tenant_fkey" FOREIGN KEY ("userId","kitaId") REFERENCES "users"("id","kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
+ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_planId_tenant_fkey" FOREIGN KEY ("planId","kitaId") REFERENCES "notdienst_plans"("id","kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
+CREATE TABLE "notdienst_reminder_runs" (
+ "id" TEXT NOT NULL,
+ "kitaId" TEXT NOT NULL,
+ "year" INTEGER NOT NULL,
+ "month" INTEGER NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "notdienst_reminder_runs_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "notdienst_reminder_runs_month_check" CHECK ("month" BETWEEN 1 AND 12)
+);
+CREATE UNIQUE INDEX "notdienst_reminder_runs_kitaId_year_month_key" ON "notdienst_reminder_runs"("kitaId","year","month");
+ALTER TABLE "notdienst_reminder_runs" ADD CONSTRAINT "notdienst_reminder_runs_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+COMMIT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b95c7b2..a2e6b84 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -159,7 +159,9 @@ model Kita {
termine Termin[]
mitbringselItems MitbringselItem[]
eventParticipations EventParticipation[]
- notdienstPlans NotdienstPlan[]
+ notdienstEmails NotdienstEmail[]
+ notdienstReminderRuns NotdienstReminderRun[]
+ notdienstPlans NotdienstPlan[]
notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[]
notdienstAlerts NotdienstAlert[]
@@ -184,6 +186,7 @@ model Family {
name String
notdienstAlarms NotdienstAlarm[]
+ notdienstEmails NotdienstEmail[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -275,6 +278,7 @@ model User {
alarmRecipients NotdienstAlarmRecipient[]
alarmNotifications NotdienstAlarmNotification[]
alarmEmails NotdienstAlarmEmail[]
+ notdienstEmails NotdienstEmail[]
@@index([kitaId])
@@index([familyId])
@@ -902,6 +906,7 @@ model NotdienstPlan {
updatedAt DateTime @updatedAt
assignments NotdienstAssignment[]
+ emails NotdienstEmail[]
@@unique([kitaId, year, month])
@@index([kitaId])
@@ -1208,3 +1213,46 @@ model PendingRegistration {
@@index([expiresAt])
@@map("pending_registrations")
}
+
+/// Durable notification jobs. No bearer tokens or personal response links.
+model NotdienstEmail {
+ id String @id @default(cuid())
+ kitaId String
+ kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
+ familyId String
+ family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ planId String?
+ plan NotdienstPlan? @relation(fields: [planId], references: [id], onDelete: Cascade)
+ year Int
+ month Int
+ kind String
+ eventKey String
+ createdAt DateTime @default(now())
+ sentAt DateTime?
+ skippedAt DateTime?
+ leaseUntil DateTime?
+ claimToken String?
+ nextAttemptAt DateTime @default(now())
+ failed Boolean @default(false)
+ attempts Int @default(0)
+ lastError String?
+
+ @@unique([kitaId, userId, kind, year, month, eventKey])
+ @@index([sentAt, skippedAt, nextAttemptAt])
+ @@index([kitaId, year, month])
+ @@map("notdienst_emails")
+}
+
+model NotdienstReminderRun {
+ id String @id @default(cuid())
+ kitaId String
+ kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
+ year Int
+ month Int
+ createdAt DateTime @default(now())
+
+ @@unique([kitaId, year, month])
+ @@map("notdienst_reminder_runs")
+}
diff --git a/prisma/updates/20261007_parent_flows.sql b/prisma/updates/20261007_parent_flows.sql
index 8ce3dda..89ba257 100644
--- a/prisma/updates/20261007_parent_flows.sql
+++ b/prisma/updates/20261007_parent_flows.sql
@@ -1,3 +1,5 @@
+-- HISTORISCHER UPGRADEPFAD: aktuelle Releases verwenden prisma/migrations und deploy/README.md.
+-- Nicht zusätzlich zu versionierten Migrationen oder auf bereits aktualisierten Datenbanken ausführen.
-- CreateEnum
CREATE TYPE "RsvpMode" AS ENUM ('CHILD', 'FAMILY');
diff --git a/scripts/check-tenant-integrity.sql b/scripts/check-tenant-integrity.sql
index a7a125e..32d8667 100644
--- a/scripts/check-tenant-integrity.sql
+++ b/scripts/check-tenant-integrity.sql
@@ -59,4 +59,11 @@ UNION ALL
SELECT 'notdienst_alerts.educatorId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "educators" p ON c."educatorId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'announcement_reads.userId' AS relation, count(*) AS invalid_rows FROM announcement_reads r JOIN announcements a ON r."announcementId" = a.id JOIN users u ON r."userId" = u.id WHERE u."kitaId" IS DISTINCT FROM a."kitaId"
+
+UNION ALL
+SELECT 'notdienst_emails.familyId' AS relation, count(*) AS invalid_rows FROM "notdienst_emails" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
+UNION ALL
+SELECT 'notdienst_emails.userId' AS relation, count(*) AS invalid_rows FROM "notdienst_emails" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
+UNION ALL
+SELECT 'notdienst_emails.planId' AS relation, count(*) AS invalid_rows FROM "notdienst_emails" c JOIN "notdienst_plans" p ON c."planId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
) violations WHERE invalid_rows > 0;
diff --git a/scripts/notdienst-alarm-worker.mjs b/scripts/notdienst-alarm-worker.mjs
index 7a6b9a2..a6bd1c0 100644
--- a/scripts/notdienst-alarm-worker.mjs
+++ b/scripts/notdienst-alarm-worker.mjs
@@ -2,7 +2,7 @@
const secret = process.env.NOTDIENST_JOB_SECRET;
if (!secret || secret.length < 32) {
console.error(
- "Notdienst-Fristprüfung deaktiviert: NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben.",
+ "Notdienst-Hintergrundjob deaktiviert: NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben.",
);
process.exit(1);
}
@@ -23,11 +23,11 @@ async function tick() {
);
if (!response.ok)
console.error(
- `Notdienst-Fristprüfung: HTTP ${response.status}; erneuter Versuch in einer Minute.`,
+ `Notdienst-Hintergrundjob: HTTP ${response.status}; erneuter Versuch in einer Minute.`,
);
} catch {
console.error(
- "Notdienst-Fristprüfung momentan nicht erreichbar; erneuter Versuch in einer Minute.",
+ "Notdienst-Hintergrundjob momentan nicht erreichbar; erneuter Versuch in einer Minute.",
);
}
if (!stopped)
diff --git a/scripts/prepare-standalone.mjs b/scripts/prepare-standalone.mjs
index 1b9319b..0e73723 100644
--- a/scripts/prepare-standalone.mjs
+++ b/scripts/prepare-standalone.mjs
@@ -6,3 +6,5 @@ cpSync('scripts/start-production.sh', '.next/standalone/scripts/start-production
cpSync('scripts/notdienst-alarm-worker.mjs', '.next/standalone/scripts/notdienst-alarm-worker.mjs');
cpSync('scripts/runtime-database-url.mjs', '.next/standalone/scripts/runtime-database-url.mjs');
cpSync('scripts/verify-runtime-isolation.mjs', '.next/standalone/scripts/verify-runtime-isolation.mjs');
+
+cpSync('scripts/start-runtime.mjs', '.next/standalone/scripts/start-runtime.mjs');
diff --git a/scripts/start-production.sh b/scripts/start-production.sh
index f6eb204..d7cd3a6 100755
--- a/scripts/start-production.sh
+++ b/scripts/start-production.sh
@@ -7,7 +7,4 @@ if [ -n "${APP_DATABASE_PASSWORD_FILE:-}" ]; then
fi
unset POSTGRES_PASSWORD PGADMIN_DEFAULT_PASSWORD APP_DATABASE_PASSWORD MIGRATION_DATABASE_URL
# Schema changes are applied by the maintenance image before runtime startup.
-if [ -n "${NOTDIENST_JOB_SECRET:-}" ]; then
- node scripts/notdienst-alarm-worker.mjs &
-fi
-exec node server.js
+exec node scripts/start-runtime.mjs
diff --git a/scripts/start-runtime.mjs b/scripts/start-runtime.mjs
new file mode 100644
index 0000000..4696489
--- /dev/null
+++ b/scripts/start-runtime.mjs
@@ -0,0 +1,58 @@
+import { spawn } from "node:child_process";
+let stopping = false,
+ worker,
+ restartTimer,
+ stopTimer;
+const server = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
+function shutdown(code = 0) {
+ if (stopping) return;
+ stopping = true;
+ process.exitCode = code;
+ clearTimeout(restartTimer);
+ server.kill("SIGTERM");
+ worker?.kill("SIGTERM");
+ stopTimer = setTimeout(() => {
+ server.kill("SIGKILL");
+ worker?.kill("SIGKILL");
+ process.exit(code);
+ }, 4000);
+ stopTimer.unref();
+ Promise.all(
+ [server, worker]
+ .filter(Boolean)
+ .map((child) =>
+ child.exitCode !== null || child.signalCode !== null
+ ? Promise.resolve()
+ : new Promise((resolve) => child.once("close", resolve)),
+ ),
+ ).then(() => {
+ clearTimeout(stopTimer);
+ process.exit(code);
+ });
+}
+function startWorker() {
+ if (stopping) return;
+ worker = spawn(process.execPath, ["scripts/notdienst-alarm-worker.mjs"], {
+ stdio: "inherit",
+ });
+ worker.on("close", () => {
+ if (stopping) return;
+ console.error(
+ "Notdienst-Hintergrundjob beendet; Neustart in fünf Sekunden.",
+ );
+ restartTimer = setTimeout(startWorker, 5000);
+ });
+ worker.on("error", () => {
+ if (!stopping)
+ console.error("Notdienst-Hintergrundjob konnte nicht starten.");
+ });
+}
+server.on("exit", (code) => shutdown(code ?? 1));
+server.on("error", () => shutdown(1));
+for (const signal of ["SIGTERM", "SIGINT"])
+ process.on(signal, () => shutdown(0));
+if ((process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32) startWorker();
+else
+ console.warn(
+ "Automatische Notdienst-Jobs deaktiviert: gültiges NOTDIENST_JOB_SECRET fehlt.",
+ );
diff --git a/src/app/api/jobs/notdienstalarme/route.ts b/src/app/api/jobs/notdienstalarme/route.ts
index dde469e..68ea06f 100644
--- a/src/app/api/jobs/notdienstalarme/route.ts
+++ b/src/app/api/jobs/notdienstalarme/route.ts
@@ -1,6 +1,8 @@
import { revalidatePath } from "next/cache";
import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
+import { runNotdienstNotificationJob } from "@/lib/notdienst-notifications";
+
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
@@ -16,9 +18,12 @@ export async function POST(request: Request) {
{ status },
);
try {
- const result = await runAlarmJob();
+ const [alarm, planning] = await Promise.allSettled([runAlarmJob(), runNotdienstNotificationJob()]);
+ if (alarm.status === "rejected" || planning.status === "rejected") throw new Error("Ein Hintergrundlauf ist fehlgeschlagen.");
+ const result = { ...alarm.value, planning: planning.value };
revalidatePath("/dashboard");
revalidatePath("/dashboard/notdienst/alarm");
+ revalidatePath("/dashboard/notdienst/plan");
return Response.json(result);
} catch {
return Response.json(
diff --git a/src/app/dashboard/families/actions.ts b/src/app/dashboard/families/actions.ts
index 4249c70..d06c2d0 100644
--- a/src/app/dashboard/families/actions.ts
+++ b/src/app/dashboard/families/actions.ts
@@ -11,6 +11,8 @@ import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
+import { assertChildrenRemovable } from "@/lib/notdienst-planning";
+import { UserFacingError, publicActionError } from "@/lib/action-error";
const INVITE_TOKEN_TTL_DAYS = 7;
const BASE_URL =
@@ -387,6 +389,9 @@ export async function updateFamilyAction(rawPayload: unknown) {
}
await prisma.$transaction(async (tx) => {
+ await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds);
+ const removed = await tx.child.count({ where: { kitaId, familyId, id: { in: removedChildIds } } });
+ if (removed !== removedChildIds.length) throw new UserFacingError("Mindestens ein Kind gehört nicht zu dieser Familie.");
await tx.family.update({
where: { id: familyId },
data: { name: familyName },
@@ -460,7 +465,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
},
});
}
- });
+ }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
@@ -469,8 +474,8 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
}
- console.error("Fehler beim Aktualisieren der Familie:", error);
- return { error: "Familie konnte nicht aktualisiert werden." };
+ if (!(error instanceof UserFacingError)) console.error("Fehler beim Aktualisieren der Familie:", error);
+ return { error: publicActionError(error, "Familie konnte nicht aktualisiert werden.") };
}
const emailError = await sendInviteJobs(kita.name, inviteJobs);
diff --git a/src/app/dashboard/kalender/_components/calendar-overview-client.tsx b/src/app/dashboard/kalender/_components/calendar-overview-client.tsx
index 43d1733..de5c647 100644
--- a/src/app/dashboard/kalender/_components/calendar-overview-client.tsx
+++ b/src/app/dashboard/kalender/_components/calendar-overview-client.tsx
@@ -207,7 +207,7 @@ export function CalendarOverviewClient({
return (
diff --git a/src/app/dashboard/notdienst/plan/_components/email-status-card.tsx b/src/app/dashboard/notdienst/plan/_components/email-status-card.tsx
new file mode 100644
index 0000000..301bc31
--- /dev/null
+++ b/src/app/dashboard/notdienst/plan/_components/email-status-card.tsx
@@ -0,0 +1,149 @@
+"use client";
+import { useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import type { NotdienstEmailStatus } from "@/lib/notdienst-notifications";
+import {
+ retryNotdienstEmailsAction,
+ sendNotdienstRemindersAction,
+} from "../actions";
+
+export function EmailStatusCard({
+ status,
+ year,
+ month,
+ remindersOpen,
+ daysBefore,
+ jobConfigured,
+}: {
+ status: NotdienstEmailStatus;
+ year: number;
+ month: number;
+ remindersOpen: boolean;
+ daysBefore: number;
+ jobConfigured: boolean;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const run = (retry: boolean) =>
+ startTransition(async () => {
+ const result = retry
+ ? await retryNotdienstEmailsAction(year, month)
+ : await sendNotdienstRemindersAction(year, month);
+ if (result.error) toast.error(result.error);
+ else if ("failed" in result && result.failed)
+ toast.warning(
+ "Einige E-Mails sind noch nicht versendet. Der nächste Versuch erfolgt automatisch oder über diesen Button.",
+ );
+ else if ("sent" in result)
+ toast.success(`${result.sent} E-Mails versendet.`);
+ router.refresh();
+ });
+ const labels = {
+ sent: "Versendet",
+ pending: "Wartet auf Versand",
+ failed: "Versand fehlgeschlagen",
+ skipped: "Nicht mehr erforderlich",
+ };
+ return (
+
+
+
+
+ E-Mail-Versand
+
+
+ {daysBefore
+ ? `Automatische Erinnerung ab dem ${Math.max(1, 26 - daysBefore)}. um 9 Uhr. Eintragsfrist: der 26.`
+ : "Automatische Erinnerungen sind ausgeschaltet."}
+
+ {!jobConfigured ? (
+
+ Der Hintergrundversand ist noch nicht konfiguriert. Offene E-Mails
+ können hier manuell gesendet werden.
+
+ Für diesen Monat wurden noch keine E-Mails vorbereitet.
+
+ )}
+
+ Versendet bedeutet: vom Maildienst angenommen. Die tatsächliche
+ Zustellung ins Postfach kann hier nicht bestätigt werden.
+
+
+ );
+}
diff --git a/src/app/dashboard/notdienst/plan/_components/settings-modal.tsx b/src/app/dashboard/notdienst/plan/_components/settings-modal.tsx
index 9610a38..16d4378 100644
--- a/src/app/dashboard/notdienst/plan/_components/settings-modal.tsx
+++ b/src/app/dashboard/notdienst/plan/_components/settings-modal.tsx
@@ -35,8 +35,8 @@ export function SettingsModal({
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
- if (minPerChild < 0 || reminderDays < 0) {
- toast.error("Werte dürfen nicht negativ sein.");
+ if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) {
+ toast.error("Bitte ein Soll von 1 bis 31 und eine Erinnerungsfrist von 0 bis 31 Tagen eingeben.");
return;
}
@@ -74,25 +74,26 @@ export function SettingsModal({
setMinPerChild(parseInt(e.target.value, 10))}
/>
- Bestimmt das Richtmaß der automatischen Verteilung für Kinder im jeweiligen Monat.
+ So viele unterschiedliche Tage müssen Eltern je aktivem Kind als Verfügbarkeit eintragen.
setReminderDays(parseInt(e.target.value, 10))}
/>
- Frist, nach der Eltern automatisch per Mail an offene Verfügbarkeiten erinnert werden.
+ Die Eintragsfrist endet am 26. Bei 7 Tagen erfolgt die Erinnerung ab dem 19. um 9 Uhr. 0 deaktiviert automatische Erinnerungen.
diff --git a/src/app/dashboard/notdienst/plan/actions.ts b/src/app/dashboard/notdienst/plan/actions.ts
index 9b285dd..1303df5 100644
--- a/src/app/dashboard/notdienst/plan/actions.ts
+++ b/src/app/dashboard/notdienst/plan/actions.ts
@@ -11,9 +11,12 @@ import { de } from "date-fns/locale";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
-import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
-import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
-import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
+import { getTargetMonthData } from "@/lib/date-utils";
+import { loadNotdienstDays } from "@/lib/notdienst-planning";
+import { todayKey } from "@/lib/alarms/format";
+import { serializableTransaction } from "@/lib/transaction";
+import { queueNotdienstPublication, queueNotdienstReminders, deliverDueNotdienstEmails } from "@/lib/notdienst-notifications";
+import { sendAppEmail } from "@/lib/mail";
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
import { requireAlarmActor } from "@/lib/alarms/auth";
import {
@@ -42,93 +45,44 @@ export async function generatePlanAction(year?: number, month?: number) {
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
- // Prüfen, ob schon ein Plan existiert
- const existingPlan = await prisma.notdienstPlan.findUnique({
- where: {
- kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
- },
- });
-
- if (existingPlan) {
- return { error: "Ein Plan für diesen Monat existiert bereits." };
- }
-
- // 1. Alle Verfügbarkeiten der Eltern für diesen Monat holen
- const availabilities = await prisma.notdienstAvailability.findMany({
- where: {
- kitaId,
- date: {
- gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
- lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
- },
- },
- include: { child: { select: { familyId: true } } },
- });
-
- // 2. Werktage holen
- const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
-
- // 3. Algorithmus: Wir verteilen die Slots
- // Counter für "Gerechtigkeit": wie oft wurde diese Familie schon eingeteilt?
- const familyUsageCount: Record = {};
-
- const assignmentsToCreate: { childId: string; date: Date }[] = [];
-
- for (const day of workingDays) {
- // Alle Verfügbarkeiten für diesen spezifischen Tag
- const availForDay = availabilities.filter(
- (a) => a.date.getTime() === day.getTime(),
- );
-
- if (availForDay.length === 0) {
- // Niemand verfügbar -> Bleibt leer ("Unbesetzt")
- continue;
- }
-
- // Finde die Familie, die am wenigsten oft dran war
- // Zufall einbauen bei Gleichstand
- const sorted = availForDay.sort((a, b) => {
- const countA = familyUsageCount[a.child.familyId] || 0;
- const countB = familyUsageCount[b.child.familyId] || 0;
- if (countA === countB) {
- return Math.random() - 0.5; // Zufallsmischung
- }
- return countA - countB;
- });
-
- const selected = sorted[0];
-
- assignmentsToCreate.push({
- childId: selected.childId,
- date: day,
- });
-
- // Usage-Counter erhöhen
- familyUsageCount[selected.child.familyId] =
- (familyUsageCount[selected.child.familyId] || 0) + 1;
- }
-
- // 4. In der DB speichern
try {
- await prisma.$transaction(async (tx) => {
- const plan = await tx.notdienstPlan.create({
- data: {
- kitaId,
- year: targetYear,
- month: targetMonth,
- status: NotdienstPlanStatus.DRAFT,
- createdById: session.user.id,
- },
+ await serializableTransaction(async (tx) => {
+ const existingPlan = await tx.notdienstPlan.findUnique({
+ where: { kitaId_year_month: { kitaId, year: targetYear, month: targetMonth } },
});
+ if (existingPlan) throw new UserFacingError("Ein Plan für diesen Monat existiert bereits.");
- if (assignmentsToCreate.length > 0) {
- await tx.notdienstAssignment.createMany({
- data: assignmentsToCreate.map((a) => ({
+ const [availabilities, workingDays] = await Promise.all([
+ tx.notdienstAvailability.findMany({
+ where: {
kitaId,
- planId: plan.id,
- childId: a.childId,
- date: a.date,
- })),
+ child: { kitaId, active: true },
+ date: {
+ gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
+ lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
+ },
+ },
+ include: { child: { select: { familyId: true } } },
+ }),
+ loadNotdienstDays(tx, kitaId, targetYear, targetMonth),
+ ]);
+ const familyUsageCount = new Map();
+ const assignments: { childId: string; date: Date }[] = [];
+ for (const day of workingDays) {
+ const candidates = availabilities.filter((a) => a.date.getTime() === day.getTime());
+ if (!candidates.length) continue;
+ const minUsage = Math.min(...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0));
+ const leastUsed = candidates.filter((a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage);
+ const selected = leastUsed[Math.floor(Math.random() * leastUsed.length)];
+ assignments.push({ childId: selected.childId, date: day });
+ familyUsageCount.set(selected.child.familyId, minUsage + 1);
+ }
+ const plan = await tx.notdienstPlan.create({
+ data: { kitaId, year: targetYear, month: targetMonth, status: NotdienstPlanStatus.DRAFT, createdById: session.user.id },
+ });
+ if (assignments.length) {
+ await tx.notdienstAssignment.createMany({
+ data: assignments.map((a) => ({ ...a, kitaId, planId: plan.id })),
});
}
});
@@ -137,7 +91,7 @@ export async function generatePlanAction(year?: number, month?: number) {
return { success: true };
} catch (error) {
console.error(error);
- return { error: "Fehler bei der Generierung des Plans." };
+ return { error: publicActionError(error, "Fehler bei der Generierung des Plans.") };
}
}
@@ -145,17 +99,35 @@ export async function publishPlanAction(planId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
- await prisma.notdienstPlan.update({
- where: { id: planId, kitaId },
- data: {
- status: NotdienstPlanStatus.PUBLISHED,
- publishedAt: new Date(),
- },
- });
-
- revalidatePath("/dashboard/notdienst/plan");
- revalidatePath("/dashboard/notdienst");
- return { success: true };
+ try {
+ await serializableTransaction(async (tx) => {
+ const plan = await tx.notdienstPlan.findUnique({
+ where: { id: planId, kitaId },
+ include: { assignments: { include: { child: { select: { active: true } } } } },
+ });
+ if (!plan) throw new UserFacingError("Plan nicht gefunden.");
+ if (plan.status === NotdienstPlanStatus.PUBLISHED) return;
+ if (plan.status !== NotdienstPlanStatus.DRAFT) throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
+ const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
+ const validDates = new Set(days.map((day) => day.getTime()));
+ if (plan.assignments.some((a) => !validDates.has(a.date.getTime()) || !a.child.active)) {
+ throw new UserFacingError("Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.");
+ }
+ await tx.notdienstPlan.update({
+ where: { id: planId, kitaId },
+ data: { status: NotdienstPlanStatus.PUBLISHED, publishedAt: new Date() },
+ });
+ await queueNotdienstPublication(tx, kitaId, planId, plan.year, plan.month);
+ });
+ revalidatePath("/dashboard/notdienst/plan");
+ revalidatePath("/dashboard/notdienst");
+ revalidatePath("/dashboard/kalender");
+ const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
+ revalidatePath("/dashboard/notdienst/plan");
+ return { success: true, ...delivery };
+ } catch (error) {
+ return { error: publicActionError(error, "Der Plan konnte nicht veröffentlicht werden.") };
+ }
}
export async function updateAssignmentAction(
@@ -189,11 +161,19 @@ export async function updateAssignmentAction(
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
}
+ if (newChildId && (parsedDate.getUTCFullYear() !== plan.year || parsedDate.getUTCMonth() + 1 !== plan.month)) {
+ throw new UserFacingError("Das Datum gehört nicht zum Planungsmonat.");
+ }
+ // Clearing obsolete entries remains possible after a new closure.
+ if (newChildId) {
+ const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
+ if (!days.some((day) => day.getTime() === parsedDate.getTime())) {
+ throw new UserFacingError("An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.");
+ }
+ }
+
if (isPublished) {
- const today = new Date();
- const todayUtc = new Date(
- Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
- );
+ const todayUtc = new Date(`${todayKey()}T00:00:00Z`);
if (parsedDate < todayUtc) {
throw new UserFacingError(
"Vergangene Notdienste können nicht geändert werden.",
@@ -202,7 +182,7 @@ export async function updateAssignmentAction(
}
const existingAssignment = await tx.notdienstAssignment.findFirst({
- where: { planId, date: parsedDate },
+ where: { kitaId, planId, date: parsedDate },
include: {
child: {
include: {
@@ -431,118 +411,33 @@ export async function updateKitaNotdienstSettingsAction(
}
}
-export async function sendNotdienstRemindersAction(
- year: number,
- month: number,
-) {
+export async function sendNotdienstRemindersAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
- const kitaId = session.user.kitaId;
- if (!kitaId) {
- return { error: "Kein Mandant zugeordnet." };
- }
-
+ const kitaId = session.user.kitaId!;
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
- const mailConfigError = getAppEmailConfigError();
- if (mailConfigError) {
- return { error: mailConfigError };
- }
-
try {
- // 1. Get Kita Name
- const kita = await prisma.kita.findUnique({
- where: { id: kitaId },
- select: { name: true },
- });
- if (!kita) {
- return { error: "Kita nicht gefunden." };
- }
-
- // 2. Fetch all families in this Kita with at least one active child and retrieve parents
- const families = await prisma.family.findMany({
- where: {
- kitaId,
- children: { some: { active: true } },
- },
- include: {
- users: {
- where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
- },
- },
- });
-
- // 3. For target month, find availability entries
- const monthStart = new Date(year, month - 1, 1);
- const monthEnd = new Date(year, month, 1);
-
- const respondedFamilies = await prisma.notdienstAvailability.findMany({
- where: {
- kitaId,
- date: {
- gte: monthStart,
- lt: monthEnd,
- },
- },
- select: {
- child: {
- select: {
- familyId: true,
- },
- },
- },
- });
-
- const respondedFamilyIds = new Set(
- respondedFamilies.map((a) => a.child.familyId),
- );
-
- // 4. Find open families (not responded yet)
- const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
-
- if (openFamilies.length === 0) {
- return { success: true, count: 0 };
- }
-
- // 5. Build emails list and send
- const monthDate = new Date(year, month - 1, 1);
- const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
- const BASE_URL =
- process.env.NEXTAUTH_URL ??
- process.env.AUTH_URL ??
- "http://localhost:3000";
- const link = `${BASE_URL}/dashboard/notdienst`;
-
- let sentCount = 0;
- for (const family of openFamilies) {
- for (const parent of family.users) {
- if (!parent.email) continue;
-
- const result = await sendAppEmail({
- to: parent.email,
- subject: `Erinnerung: Bitte Verfügbarkeiten für den Notdienst ${monthLabel} eintragen`,
- react: createElement(NotdienstAvailabilityReminderEmail, {
- parentName: `${parent.firstName} ${parent.lastName}`,
- kitaName: kita.name,
- monthLabel,
- link,
- }),
- });
-
- if (result.success) {
- sentCount++;
- } else {
- console.error(
- `E-Mail konnte nicht an ${parent.email} gesendet werden:`,
- result.error,
- );
- }
- }
- }
-
- return { success: true, count: sentCount };
+ const queued = await queueNotdienstReminders(kitaId, year, month);
+ const delivery = await deliverDueNotdienstEmails({ kitaId, year, month }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
+ revalidatePath("/dashboard/notdienst/plan");
+ return { success: true, queued, count: delivery.sent, ...delivery };
} catch (error) {
- console.error("Fehler beim Versenden der Erinnerungen:", error);
- return { error: "Fehler beim Versenden der Erinnerungen." };
+ return { error: publicActionError(error, "Die Erinnerungen konnten nicht vorbereitet werden.") };
+ }
+}
+
+export async function retryNotdienstEmailsAction(year: number, month: number) {
+ const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
+ const kitaId = session.user.kitaId!;
+ if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
+ if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte warte vor einem weiteren Versandversuch." };
+ try {
+ await prisma.notdienstEmail.updateMany({ where: { kitaId, year, month, failed: true, sentAt: null, skippedAt: null }, data: { nextAttemptAt: new Date() } });
+ const delivery = await deliverDueNotdienstEmails({ kitaId, year, month });
+ revalidatePath("/dashboard/notdienst/plan");
+ return { success: true, ...delivery };
+ } catch {
+ return { error: "Der Versand bleibt gespeichert und wird erneut versucht." };
}
}
diff --git a/src/app/dashboard/notdienst/plan/page.tsx b/src/app/dashboard/notdienst/plan/page.tsx
index 99c8c3a..743d354 100644
--- a/src/app/dashboard/notdienst/plan/page.tsx
+++ b/src/app/dashboard/notdienst/plan/page.tsx
@@ -1,11 +1,13 @@
import Link from "next/link";
import { addMonths, getYear, getMonth, format } from "date-fns";
import { de } from "date-fns/locale";
-import { TerminType } from "@prisma/client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { prisma } from "@/lib/prisma";
-import { getWorkingDaysOfMonth } from "@/lib/date-utils";
+import { formatUtcDateKey, getWorkingDaysOfMonth, getTargetMonthData } from "@/lib/date-utils";
+import { loadNotdienstDays } from "@/lib/notdienst-planning";
+import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications";
+import { EmailStatusCard } from "./_components/email-status-card";
import { PlanView } from "./plan-view";
import { SettingsModal } from "./_components/settings-modal";
import { requireAlarmActor } from "@/lib/alarms/auth";
@@ -23,22 +25,21 @@ export default async function PlanungsZentralePage({
const resolvedSearchParams = await searchParams;
const now = new Date();
- const nextMonth = addMonths(now, 1);
+ const defaultTarget = getTargetMonthData(now);
const targetYear = resolvedSearchParams.year
? parseInt(resolvedSearchParams.year, 10)
- : getYear(nextMonth);
+ : defaultTarget.targetYear;
const targetMonth = resolvedSearchParams.month
? parseInt(resolvedSearchParams.month, 10)
- : getMonth(nextMonth) + 1;
+ : defaultTarget.targetMonth;
const monthDate = new Date(targetYear, targetMonth - 1, 1);
const monthName = format(monthDate, "MMMM", { locale: de });
- const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
- const monthStart = new Date(targetYear, targetMonth - 1, 1);
- const monthEnd = new Date(targetYear, targetMonth, 1);
+ const monthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
+ const monthEnd = new Date(Date.UTC(targetYear, targetMonth, 1));
- const [plan, families, availabilities, closureCount, kita] =
+ const [plan, families, rawAvailabilities, workingDays, kita] =
await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
@@ -80,6 +81,7 @@ export default async function PlanungsZentralePage({
prisma.notdienstAvailability.findMany({
where: {
kitaId,
+ child: { kitaId, active: true },
date: {
gte: monthStart,
lt: monthEnd,
@@ -95,16 +97,7 @@ export default async function PlanungsZentralePage({
},
orderBy: { date: "asc" },
}),
- prisma.termin.count({
- where: {
- kitaId,
- type: TerminType.SCHLIESSTAG,
- startDate: {
- gte: monthStart,
- lt: monthEnd,
- },
- },
- }),
+ loadNotdienstDays(prisma, kitaId, targetYear, targetMonth),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
@@ -152,6 +145,10 @@ export default async function PlanungsZentralePage({
};
});
+ const validDateKeys = new Set(workingDays.map(formatUtcDateKey));
+ const availabilities = rawAvailabilities.filter((a) => validDateKeys.has(formatUtcDateKey(a.date)));
+ const closureCount = getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
+
const availabilityDatesByFamily = new Map>();
availabilities.forEach((availability) => {
const familyId = availability.child.familyId;
@@ -174,11 +171,12 @@ export default async function PlanungsZentralePage({
}).format(new Date(date)),
})),
childCount: family.children.length,
+ requiredDays: family.children.length * kita.notdienstMinPerChildPerMonth,
};
});
const respondedFamilies = familyStatuses.filter(
- (family) => family.selectedDays.length > 0,
+ (family) => family.selectedDays.length >= family.requiredDays,
).length;
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
const totalAvailabilitySlots = availabilities.length;
@@ -187,7 +185,13 @@ export default async function PlanungsZentralePage({
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
// Wir mappen die Assignments auf die Werktage
- const daysWithAssignments = workingDays.map((day) => {
+ // Keep obsolete assignments visible so the coordinator can remove them.
+ const displayedDays = new Map(workingDays.map((day) => [formatUtcDateKey(day), day]));
+ for (const assignment of plan?.assignments ?? []) {
+ displayedDays.set(formatUtcDateKey(assignment.date), assignment.date);
+ }
+ const activeChildIds = new Set(allChildren.map((child) => child.id));
+ const daysWithAssignments = Array.from(displayedDays.values()).sort((a, b) => a.getTime() - b.getTime()).map((day) => {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime(),
);
@@ -195,19 +199,25 @@ export default async function PlanungsZentralePage({
!assignment ||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
+ const isInvalid = !validDateKeys.has(formatUtcDateKey(day)) || !!assignment && !activeChildIds.has(assignment.childId);
return {
date: day.toISOString(),
+ isInvalid,
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
isOriginallyUnassigned: !!isOriginallyUnassigned,
activeAlarmId: assignment?.alarms[0]?.id ?? null,
canAlarm:
- !!assignment &&
+ !!assignment && !isInvalid &&
plan?.status === "PUBLISHED" &&
day.toISOString().slice(0, 10) >= todayKey(),
};
});
+ const mailStatus = await getNotdienstEmailStatus(kitaId, targetYear, targetMonth);
+ const target = getTargetMonthData(now);
+ const remindersOpen = !target.isLocked && target.targetYear === targetYear && target.targetMonth === targetMonth && plan?.status !== "PUBLISHED";
+
return (
@@ -226,7 +236,7 @@ export default async function PlanungsZentralePage({
-
+
+ = 32} />
+
{
- const result = await updateAssignmentAction(planId, date, valueToSet);
+ const result = await updateAssignmentAction(planId, date.slice(0, 10), valueToSet);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
@@ -162,7 +166,7 @@ export function PlanView({
});
};
- if (!planId && respondedFamilies === 0) {
+ if (!planId && totalAvailabilitySlots === 0) {
return (