Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99d3a63eed | ||
|
|
00f602a7aa |
+14
-1
@@ -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.
|
`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 **42 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 **45 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
|
## Verifikation und Updates
|
||||||
|
|
||||||
@@ -72,3 +72,16 @@ Docker-Images sind auf Digests fixiert. Neue Sicherheitsreleases regelmäßig pr
|
|||||||
## Familieneinladungen und Abwesenheiten
|
## Familieneinladungen und Abwesenheiten
|
||||||
|
|
||||||
Die Migration `20261010000000_family_invitation_flows` ergänzt den Versandstatus und die mandantengebundene Empfängerreferenz. Der bestehende Migrationsdienst spielt sie vor dem App-Start ein; kein `prisma db push` und kein erneutes Löschen erforderlich. Fehlgeschlagene Familieneinladungen können in der Familienübersicht erneut versendet werden. Fachliche Abläufe: [Familien und Abwesenheiten](../docs/FAMILIEN_UND_ABWESENHEITEN.md).
|
Die Migration `20261010000000_family_invitation_flows` ergänzt den Versandstatus und die mandantengebundene Empfängerreferenz. Der bestehende Migrationsdienst spielt sie vor dem App-Start ein; kein `prisma db push` und kein erneutes Löschen erforderlich. Fehlgeschlagene Familieneinladungen können in der Familienübersicht erneut versendet werden. Fachliche Abläufe: [Familien und Abwesenheiten](../docs/FAMILIEN_UND_ABWESENHEITEN.md).
|
||||||
|
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ RESEND_API_KEY="re_[PRODUCTION_API_KEY]"
|
|||||||
EMAIL_FROM="Kita-Planer <[absender@deine-domain.de]>"
|
EMAIL_FROM="Kita-Planer <[absender@deine-domain.de]>"
|
||||||
ADMIN_EMAIL="[kontakt@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
|
# Eigenes Secret, mindestens 32 Zeichen; generieren mit: openssl rand -hex 32
|
||||||
NOTDIENST_JOB_SECRET="[GENERATE_ME]"
|
NOTDIENST_JOB_SECRET="[GENERATE_ME]"
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
-- 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;
|
BEGIN;
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
|
|||||||
@@ -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.
|
-- 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;
|
BEGIN;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
|
|||||||
@@ -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.
|
-- 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;
|
BEGIN;
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
# Kita-Planer – Funktionsübersicht und aktueller Stand
|
# 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -60,12 +60,9 @@ Parallele Änderungen laufen in serialisierbaren Datenbanktransaktionen mit Wied
|
|||||||
|
|
||||||
## Einführung und Schema
|
## 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.
|
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.
|
||||||
- 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 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,9 @@
|
|||||||
|
|
||||||
## Bestehende Daten und Schemaaktualisierung
|
## 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:
|
`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.
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
||||||
## Verifikation
|
## Verifikation
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,12 @@
|
|||||||
"test:alarms": "tsx --experimental-test-module-mocks --test tests/notdienst-alarm.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": "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",
|
||||||
"test:family-absence": "tsx --test tests/absence-policy.test.ts",
|
"test:family-absence": "tsx --test tests/absence-policy.test.ts",
|
||||||
"test:family-absence:integration": "tsx --experimental-test-module-mocks --test tests/family-absence-flows.integration.test.ts"
|
"test:family-absence:integration": "tsx --experimental-test-module-mocks --test tests/family-absence-flows.integration.test.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -159,6 +159,8 @@ model Kita {
|
|||||||
termine Termin[]
|
termine Termin[]
|
||||||
mitbringselItems MitbringselItem[]
|
mitbringselItems MitbringselItem[]
|
||||||
eventParticipations EventParticipation[]
|
eventParticipations EventParticipation[]
|
||||||
|
notdienstEmails NotdienstEmail[]
|
||||||
|
notdienstReminderRuns NotdienstReminderRun[]
|
||||||
notdienstPlans NotdienstPlan[]
|
notdienstPlans NotdienstPlan[]
|
||||||
notdienstAvailabilities NotdienstAvailability[]
|
notdienstAvailabilities NotdienstAvailability[]
|
||||||
notdienstAssignments NotdienstAssignment[]
|
notdienstAssignments NotdienstAssignment[]
|
||||||
@@ -184,6 +186,7 @@ model Family {
|
|||||||
|
|
||||||
name String
|
name String
|
||||||
notdienstAlarms NotdienstAlarm[]
|
notdienstAlarms NotdienstAlarm[]
|
||||||
|
notdienstEmails NotdienstEmail[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -276,6 +279,7 @@ model User {
|
|||||||
alarmRecipients NotdienstAlarmRecipient[]
|
alarmRecipients NotdienstAlarmRecipient[]
|
||||||
alarmNotifications NotdienstAlarmNotification[]
|
alarmNotifications NotdienstAlarmNotification[]
|
||||||
alarmEmails NotdienstAlarmEmail[]
|
alarmEmails NotdienstAlarmEmail[]
|
||||||
|
notdienstEmails NotdienstEmail[]
|
||||||
|
|
||||||
@@index([kitaId])
|
@@index([kitaId])
|
||||||
@@index([familyId])
|
@@index([familyId])
|
||||||
@@ -912,6 +916,7 @@ model NotdienstPlan {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
assignments NotdienstAssignment[]
|
assignments NotdienstAssignment[]
|
||||||
|
emails NotdienstEmail[]
|
||||||
|
|
||||||
@@unique([kitaId, year, month])
|
@@unique([kitaId, year, month])
|
||||||
@@index([kitaId])
|
@@index([kitaId])
|
||||||
@@ -1218,3 +1223,46 @@ model PendingRegistration {
|
|||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
@@map("pending_registrations")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
-- CreateEnum
|
||||||
CREATE TYPE "RsvpMode" AS ENUM ('CHILD', 'FAMILY');
|
CREATE TYPE "RsvpMode" AS ENUM ('CHILD', 'FAMILY');
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
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
|
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"
|
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;
|
) violations WHERE invalid_rows > 0;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
const secret = process.env.NOTDIENST_JOB_SECRET;
|
const secret = process.env.NOTDIENST_JOB_SECRET;
|
||||||
if (!secret || secret.length < 32) {
|
if (!secret || secret.length < 32) {
|
||||||
console.error(
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -23,11 +23,11 @@ async function tick() {
|
|||||||
);
|
);
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
console.error(
|
console.error(
|
||||||
`Notdienst-Fristprüfung: HTTP ${response.status}; erneuter Versuch in einer Minute.`,
|
`Notdienst-Hintergrundjob: HTTP ${response.status}; erneuter Versuch in einer Minute.`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
console.error(
|
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)
|
if (!stopped)
|
||||||
|
|||||||
@@ -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/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/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/verify-runtime-isolation.mjs', '.next/standalone/scripts/verify-runtime-isolation.mjs');
|
||||||
|
|
||||||
|
cpSync('scripts/start-runtime.mjs', '.next/standalone/scripts/start-runtime.mjs');
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ if [ -n "${APP_DATABASE_PASSWORD_FILE:-}" ]; then
|
|||||||
fi
|
fi
|
||||||
unset POSTGRES_PASSWORD PGADMIN_DEFAULT_PASSWORD APP_DATABASE_PASSWORD MIGRATION_DATABASE_URL
|
unset POSTGRES_PASSWORD PGADMIN_DEFAULT_PASSWORD APP_DATABASE_PASSWORD MIGRATION_DATABASE_URL
|
||||||
# Schema changes are applied by the maintenance image before runtime startup.
|
# Schema changes are applied by the maintenance image before runtime startup.
|
||||||
if [ -n "${NOTDIENST_JOB_SECRET:-}" ]; then
|
if [ -n "${NOTDIENST_JOB_SECRET:-}" ] && [ "${#NOTDIENST_JOB_SECRET}" -lt 32 ]; then
|
||||||
if [ "${#NOTDIENST_JOB_SECRET}" -lt 32 ]; then
|
|
||||||
echo "NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben." >&2
|
echo "NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
node scripts/notdienst-alarm-worker.mjs &
|
exec node scripts/start-runtime.mjs
|
||||||
fi
|
|
||||||
exec node server.js
|
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
);
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
|
import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
|
||||||
|
|
||||||
|
import { runNotdienstNotificationJob } from "@/lib/notdienst-notifications";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -16,9 +18,12 @@ export async function POST(request: Request) {
|
|||||||
{ status },
|
{ status },
|
||||||
);
|
);
|
||||||
try {
|
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");
|
||||||
revalidatePath("/dashboard/notdienst/alarm");
|
revalidatePath("/dashboard/notdienst/alarm");
|
||||||
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
return Response.json(result);
|
return Response.json(result);
|
||||||
} catch {
|
} catch {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
resendFamilyInvitation,
|
resendFamilyInvitation,
|
||||||
} from "@/lib/family-invitations";
|
} from "@/lib/family-invitations";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
||||||
|
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||||
|
|
||||||
const childSchema = z.object({
|
const childSchema = z.object({
|
||||||
firstName: z.string().trim().min(1, "Vorname des Kindes fehlt.").max(100),
|
firstName: z.string().trim().min(1, "Vorname des Kindes fehlt.").max(100),
|
||||||
@@ -366,6 +368,9 @@ export async function updateFamilyAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
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({
|
await tx.family.update({
|
||||||
where: { id: familyId },
|
where: { id: familyId },
|
||||||
data: { name: familyName },
|
data: { name: familyName },
|
||||||
@@ -434,7 +439,7 @@ export async function updateFamilyAction(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
@@ -443,8 +448,8 @@ export async function updateFamilyAction(
|
|||||||
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
|
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("Fehler beim Aktualisieren der Familie:", error);
|
if (!(error instanceof UserFacingError)) console.error("Fehler beim Aktualisieren der Familie:", error);
|
||||||
return { error: "Familie konnte nicht aktualisiert werden." };
|
return { error: publicActionError(error, "Familie konnte nicht aktualisiert werden.") };
|
||||||
}
|
}
|
||||||
|
|
||||||
const warning = await sendInviteJobs(inviteJobs);
|
const warning = await sendInviteJobs(inviteJobs);
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export function CalendarOverviewClient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{!isAdmin && pendingSwapRequests.length > 0 && (
|
{pendingSwapRequests.length > 0 && (
|
||||||
<NotdienstSwapRequestsPanel requests={pendingSwapRequests} />
|
<NotdienstSwapRequestsPanel requests={pendingSwapRequests} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -469,7 +469,7 @@ export function CalendarOverviewClient({
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isAdmin && item.canRequestSwap && (
|
{item.canRequestSwap && (
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<NotdienstSwapDialog
|
<NotdienstSwapDialog
|
||||||
target={item}
|
target={item}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { Trash2, Plus, Utensils } from "lucide-react";
|
import { Trash2, Plus, Utensils, Pencil } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { addMitbringsel, deleteMitbringsel } from "../actions";
|
import {
|
||||||
|
addMitbringsel,
|
||||||
|
deleteMitbringsel,
|
||||||
|
updateMitbringsel,
|
||||||
|
} from "../actions";
|
||||||
|
|
||||||
type MitbringselItemDto = {
|
type MitbringselItemDto = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,30 +31,42 @@ export function MitbringselList({
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editContent, setEditContent] = useState("");
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
if (!content.trim()) return;
|
if (isPending || !content.trim()) return;
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const res = await addMitbringsel({ terminId, content });
|
const res = await addMitbringsel({ terminId, content });
|
||||||
if (res.error) {
|
if (res.error) toast.error(res.error);
|
||||||
toast.error(res.error);
|
else {
|
||||||
} else {
|
|
||||||
toast.success("Eintrag hinzugefügt.");
|
toast.success("Eintrag hinzugefügt.");
|
||||||
setContent("");
|
setContent("");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (isPending || !editingId || !editContent.trim()) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await updateMitbringsel({
|
||||||
|
itemId: editingId,
|
||||||
|
content: editContent,
|
||||||
|
});
|
||||||
|
if (res.error) toast.error(res.error);
|
||||||
|
else {
|
||||||
|
toast.success("Beitrag gespeichert.");
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const res = await deleteMitbringsel(id);
|
const res = await deleteMitbringsel(id);
|
||||||
if (res.error) {
|
if (res.error) toast.error(res.error);
|
||||||
toast.error(res.error);
|
else toast.success("Eintrag gelöscht.");
|
||||||
} else {
|
|
||||||
toast.success("Eintrag gelöscht.");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,8 +76,7 @@ export function MitbringselList({
|
|||||||
<Utensils className="h-4 w-4 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
<Utensils className="h-4 w-4 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
||||||
Mitbring-Liste
|
Mitbring-Liste
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex max-h-60 flex-col gap-2 overflow-y-auto">
|
||||||
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-xs italic text-[var(--ink-faint)]">
|
<p className="text-xs italic text-[var(--ink-faint)]">
|
||||||
Noch keine Einträge vorhanden.
|
Noch keine Einträge vorhanden.
|
||||||
@@ -70,54 +85,118 @@ export function MitbringselList({
|
|||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center justify-between gap-2 rounded-md border border-[var(--hairline-soft)] bg-[var(--surface)] p-2 text-sm text-[var(--ink)]"
|
className="rounded-md border border-[var(--hairline-soft)] bg-[var(--surface)] p-2 text-sm text-[var(--ink)]"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<span className="text-xs font-medium text-[var(--accent-deep)]">
|
<span className="text-xs font-medium text-[var(--accent-deep)]">
|
||||||
{item.user.firstName} {item.user.lastName}
|
{item.user.firstName} {item.user.lastName}
|
||||||
</span>
|
</span>
|
||||||
<span>{item.content}</span>
|
{editingId !== item.id ? (
|
||||||
|
<p className="break-words">{item.content}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{(isAdmin || item.userId === currentUserId) && (
|
{editingId !== item.id ? (
|
||||||
|
<div className="flex shrink-0 gap-1">
|
||||||
|
{item.userId === currentUserId ? (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 shrink-0 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
className="h-8 w-8"
|
||||||
|
aria-label={`Beitrag bearbeiten: ${item.content}`}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setEditContent(item.content);
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{isAdmin || item.userId === currentUserId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||||
|
aria-label={`Beitrag löschen: ${item.content}`}
|
||||||
onClick={() => handleDelete(item.id)}
|
onClick={() => handleDelete(item.id)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<form
|
||||||
|
className="mt-2 flex flex-wrap gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
aria-label="Mitbringbeitrag bearbeiten"
|
||||||
|
value={editContent}
|
||||||
|
onChange={(event) => setEditContent(event.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
disabled={isPending}
|
||||||
|
className="h-8 min-w-0 flex-[1_1_180px] text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={isPending || !editContent.trim()}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<form
|
||||||
<div className="flex gap-2 mt-1">
|
className="mt-1 flex gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleAdd();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
|
aria-label="Neuer Mitbringbeitrag"
|
||||||
placeholder="Was bringst du mit?"
|
placeholder="Was bringst du mit?"
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(event) => setContent(event.target.value)}
|
||||||
onKeyDown={(e) => {
|
maxLength={500}
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleAdd();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="h-8 text-sm"
|
className="h-8 min-w-0 text-sm"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 shrink-0"
|
className="h-8 shrink-0"
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={!content.trim() || isPending}
|
disabled={!content.trim() || isPending}
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-4 w-4 [stroke-width:1.7]" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
Hinzufügen
|
Hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -760,23 +760,18 @@ function NotdienstRow({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : notdienst.canRequestSwap ? (
|
) : null}
|
||||||
|
{notdienst.canRequestSwap ? (
|
||||||
<NotdienstSwapDialog
|
<NotdienstSwapDialog
|
||||||
target={notdienst}
|
target={notdienst}
|
||||||
options={notdienstSwapOptions}
|
options={notdienstSwapOptions}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button size="sm" variant="outline" className="h-8 px-2.5 text-xs">
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 px-2.5 text-xs"
|
|
||||||
>
|
|
||||||
Tausch anfragen
|
Tausch anfragen
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : !isAdmin ? <div className="w-9 h-9" /> : null}
|
||||||
<div className="w-9 h-9" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { serializableTransaction } from "@/lib/transaction";
|
|||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { lockEvent } from "@/lib/event-lock";
|
import { lockEvent } from "@/lib/event-lock";
|
||||||
|
import { canRequestNotdienstSwap } from "@/lib/notdienst-swaps";
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// /dashboard/kalender · Server Actions
|
// /dashboard/kalender · Server Actions
|
||||||
@@ -127,7 +128,7 @@ async function getSwapAssignment(
|
|||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||||
select: {
|
select: {
|
||||||
email: true,
|
email: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
@@ -335,11 +336,8 @@ function buildTerminData(
|
|||||||
export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
if (!(await rateLimit(`createNotdienstSwapRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
if (!(await rateLimit(`createNotdienstSwapRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
||||||
if (session.user.role !== UserRole.ELTERN) {
|
if (!canRequestNotdienstSwap(session.user.role, session.user.familyId)) {
|
||||||
return { error: "Nur Eltern können Notdienst-Tausche anfragen." };
|
return { error: "Nur Eltern mit einem eigenen Haushalt können Notdienst-Tausche anfragen." };
|
||||||
}
|
|
||||||
if (!session.user.familyId) {
|
|
||||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = swapRequestSchema.safeParse(rawPayload);
|
const parsed = swapRequestSchema.safeParse(rawPayload);
|
||||||
@@ -467,7 +465,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||||
select: { email: true, firstName: true, lastName: true },
|
select: { email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -493,7 +491,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||||
select: { email: true, firstName: true, lastName: true },
|
select: { email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -636,7 +634,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||||
select: { email: true, firstName: true, lastName: true },
|
select: { email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -654,7 +652,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||||
select: { email: true, firstName: true, lastName: true },
|
select: { email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1059,6 +1057,44 @@ export async function addMitbringsel(rawPayload: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateMitbringselSchema = z.object({
|
||||||
|
itemId: z.string().min(1).max(100),
|
||||||
|
content: mitbringselSchema.shape.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateMitbringsel(rawPayload: unknown) {
|
||||||
|
const session = await requireKitaSession();
|
||||||
|
const parsed = updateMitbringselSchema.safeParse(rawPayload);
|
||||||
|
if (!parsed.success) return { error: "Bitte einen Beitrag mit 1 bis 500 Zeichen eingeben." };
|
||||||
|
if (!(await rateLimit(`updateMitbringsel:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) {
|
||||||
|
return { error: "Zu viele Änderungen. Bitte versuche es später erneut." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await serializableTransaction(async (tx) => {
|
||||||
|
const item = await tx.mitbringselItem.findFirst({
|
||||||
|
where: { id: parsed.data.itemId, kitaId: session.user.kitaId, userId: session.user.id },
|
||||||
|
select: { terminId: true },
|
||||||
|
});
|
||||||
|
if (!item) throw new UserFacingError("Du kannst nur eigene Mitbringbeiträge bearbeiten.");
|
||||||
|
await lockEvent(tx, item.terminId, session.user.kitaId);
|
||||||
|
const termin = await tx.termin.findFirst({
|
||||||
|
where: { id: item.terminId, kitaId: session.user.kitaId, status: TerminStatus.CONFIRMED, mitbringselListEnabled: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!termin) throw new UserFacingError("Die Mitbring-Liste ist für diesen Termin nicht mehr aktiv.");
|
||||||
|
await tx.mitbringselItem.update({
|
||||||
|
where: { id: parsed.data.itemId, kitaId: session.user.kitaId, userId: session.user.id },
|
||||||
|
data: { content: parsed.data.content },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
revalidatePath("/dashboard/kalender");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: publicActionError(error, "Der Beitrag konnte nicht gespeichert werden.") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteMitbringsel(itemId: string) {
|
export async function deleteMitbringsel(itemId: string) {
|
||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { HelpCircle } from "lucide-react";
|
|||||||
|
|
||||||
import { requireKitaSession } from "@/lib/auth-utils";
|
import { requireKitaSession } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { canRequestNotdienstSwap } from "@/lib/notdienst-swaps";
|
||||||
import { getRsvpEvents } from "@/lib/rsvp-events";
|
import { getRsvpEvents } from "@/lib/rsvp-events";
|
||||||
import { AdminTerminModal } from "./_components/admin-termin-modal";
|
import { AdminTerminModal } from "./_components/admin-termin-modal";
|
||||||
import {
|
import {
|
||||||
@@ -213,7 +214,7 @@ export default async function KalenderPage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pendingSwapRows =
|
const pendingSwapRows =
|
||||||
!isAdmin && session.user.familyId
|
session.user.familyId && canRequestNotdienstSwap(session.user.role, session.user.familyId)
|
||||||
? await prisma.notdienstSwapRequest.findMany({
|
? await prisma.notdienstSwapRequest.findMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
@@ -306,9 +307,8 @@ export default async function KalenderPage({
|
|||||||
...notdienstAssignmentRows.map((assignment) => {
|
...notdienstAssignmentRows.map((assignment) => {
|
||||||
const isOwn = assignment.child.familyId === familyIdForParticipation;
|
const isOwn = assignment.child.familyId === familyIdForParticipation;
|
||||||
const canRequestSwap =
|
const canRequestSwap =
|
||||||
!isAdmin &&
|
canRequestNotdienstSwap(session.user.role, session.user.familyId) &&
|
||||||
!isOwn &&
|
!isOwn &&
|
||||||
!!session.user.familyId &&
|
|
||||||
assignment.date >= today;
|
assignment.date >= today;
|
||||||
return {
|
return {
|
||||||
kind: "notdienst" as const,
|
kind: "notdienst" as const,
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { UserRole } from "@prisma/client";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { formatUtcDateKey, getTargetMonthData } from "@/lib/date-utils";
|
||||||
import { getTargetMonthData } from "@/lib/date-utils";
|
import { serializableTransaction } from "@/lib/transaction";
|
||||||
|
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||||
|
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// /dashboard/notdienst · Server Actions
|
// /dashboard/notdienst · Server Actions
|
||||||
@@ -64,90 +66,60 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
|
|||||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Validierung (Soll-Tage) ─────────────────────────────────────
|
try {
|
||||||
const kita = await prisma.kita.findUniqueOrThrow({
|
await serializableTransaction(async (tx) => {
|
||||||
|
const [kita, children, workingDays] = await Promise.all([
|
||||||
|
tx.kita.findUniqueOrThrow({
|
||||||
where: { id: kitaId },
|
where: { id: kitaId },
|
||||||
select: { notdienstMinPerChildPerMonth: true },
|
select: { notdienstMinPerChildPerMonth: true },
|
||||||
});
|
}),
|
||||||
|
tx.child.findMany({
|
||||||
const requiredTotal = childrenIds.length * kita.notdienstMinPerChildPerMonth;
|
where: { kitaId, familyId: session.user.familyId!, active: true },
|
||||||
if (dates.length < requiredTotal) {
|
|
||||||
return {
|
|
||||||
error: `Bitte wähle mindestens ${requiredTotal} Tage aus.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sicherstellen, dass alle Kinder zum Haushalt des angemeldeten Users gehören.
|
|
||||||
const validChildren = await prisma.child.findMany({
|
|
||||||
where: {
|
|
||||||
id: { in: childrenIds },
|
|
||||||
kitaId,
|
|
||||||
familyId: session.user.familyId,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
orderBy: { id: "asc" },
|
||||||
|
}),
|
||||||
if (validChildren.length !== childrenIds.length) {
|
loadNotdienstDays(tx, kitaId, targetYear, targetMonth),
|
||||||
return { error: "Ungültige Kinder-Zuordnung erkannt." };
|
]);
|
||||||
|
const suppliedIds = new Set(childrenIds);
|
||||||
|
if (!children.length || suppliedIds.size !== childrenIds.length ||
|
||||||
|
suppliedIds.size !== children.length || !children.every((child) => suppliedIds.has(child.id))) {
|
||||||
|
throw new UserFacingError("Bitte lade die Seite neu und wähle die Termine für alle aktiven Kinder deines Haushalts aus.");
|
||||||
}
|
}
|
||||||
|
const validDates = new Set(workingDays.map(formatUtcDateKey));
|
||||||
// Termine in Date-Objekte wandeln und sortieren
|
if (dates.some((date) => !validDates.has(date))) {
|
||||||
const parsedDates = dates.map(dateKeyToUtcDate);
|
throw new UserFacingError("Bitte wähle nur geöffnete Werktage im Zielmonat aus. Wochenenden und Schließtage sind nicht möglich.");
|
||||||
const hasOutsideTargetMonth = parsedDates.some(
|
|
||||||
(date) =>
|
|
||||||
date.getUTCFullYear() !== targetYear ||
|
|
||||||
date.getUTCMonth() !== targetMonth - 1,
|
|
||||||
);
|
|
||||||
if (hasOutsideTargetMonth) {
|
|
||||||
return { error: "Bitte wähle Termine im Zielmonat aus." };
|
|
||||||
}
|
}
|
||||||
|
const requiredTotal = children.length * kita.notdienstMinPerChildPerMonth;
|
||||||
// Round-Robin Zuweisung
|
if (dates.length < requiredTotal) {
|
||||||
const inserts: {
|
throw new UserFacingError(`Bitte wähle mindestens ${requiredTotal} Tage aus.`);
|
||||||
kitaId: string;
|
|
||||||
childId: string;
|
|
||||||
userId: string;
|
|
||||||
date: Date;
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < parsedDates.length; i++) {
|
|
||||||
const childId = validChildren[i % validChildren.length].id;
|
|
||||||
inserts.push({
|
|
||||||
kitaId,
|
|
||||||
childId,
|
|
||||||
userId: session.user.id,
|
|
||||||
date: parsedDates[i],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const activeIds = children.map((child) => child.id);
|
||||||
// ── 3. Datenbank-Transaktion ───────────────────────────────────────
|
|
||||||
try {
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
// a) Alle bestehenden Haushaltseinträge im Zielmonat für diese Kinder löschen
|
|
||||||
await tx.notdienstAvailability.deleteMany({
|
await tx.notdienstAvailability.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId,
|
kitaId,
|
||||||
childId: { in: childrenIds },
|
child: { kitaId, familyId: session.user.familyId! },
|
||||||
date: {
|
date: {
|
||||||
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
||||||
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// b) Neue Einträge setzen
|
|
||||||
if (inserts.length > 0) {
|
|
||||||
await tx.notdienstAvailability.createMany({
|
await tx.notdienstAvailability.createMany({
|
||||||
data: inserts,
|
data: dates.map((date, index) => ({
|
||||||
skipDuplicates: true,
|
kitaId,
|
||||||
|
childId: activeIds[index % activeIds.length],
|
||||||
|
userId: session.user.id,
|
||||||
|
date: dateKeyToUtcDate(date),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/dashboard/notdienst");
|
revalidatePath("/dashboard/notdienst");
|
||||||
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
return { success: true, dates };
|
return { success: true, dates };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Speichern der Notdienste:", error);
|
if (!(error instanceof UserFacingError)) console.error("Fehler beim Speichern der Notdienste:", error);
|
||||||
return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." };
|
return { error: publicActionError(error, "Ein Fehler ist aufgetreten beim Speichern der Termine.") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import { de } from "date-fns/locale";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatDateKey, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
import { formatDateKey, formatUtcDateKey } from "@/lib/date-utils";
|
||||||
|
import { getNotdienstDays } from "@/lib/notdienst-days";
|
||||||
import { saveNotdienstAvailabilities } from "./actions";
|
import { saveNotdienstAvailabilities } from "./actions";
|
||||||
|
|
||||||
export const notdienstEventCategories = {
|
export const notdienstEventCategories = {
|
||||||
@@ -100,23 +101,21 @@ export function NotdienstForm({
|
|||||||
}: NotdienstFormProps) {
|
}: NotdienstFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor.
|
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor.
|
||||||
const [selectedDates, setSelectedDates] = useState<Set<string>>(
|
const [selection, setSelectedDates] = useState<Set<string>>(
|
||||||
new Set(initialSelectedDates),
|
new Set(initialSelectedDates),
|
||||||
);
|
);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const closureDateKeys = useMemo(
|
const selectableDateKeys = useMemo(
|
||||||
() => getClosureDateKeys(calendarItems),
|
() => new Set(getNotdienstDays(targetYear, targetMonth,
|
||||||
[calendarItems],
|
calendarItems.filter((item) => item.category === "closure" || item.category === "team"),
|
||||||
|
).map(formatUtcDateKey)),
|
||||||
|
[calendarItems, targetMonth, targetYear],
|
||||||
);
|
);
|
||||||
const selectableDateKeys = useMemo(() => {
|
const selectedDates = useMemo(
|
||||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
() => new Set(Array.from(selection).filter((key) => selectableDateKeys.has(key))),
|
||||||
return new Set(
|
[selection, selectableDateKeys],
|
||||||
workingDays
|
|
||||||
.map(formatDateKey)
|
|
||||||
.filter((dateKey) => !closureDateKeys.has(dateKey)),
|
|
||||||
);
|
);
|
||||||
}, [closureDateKeys, targetMonth, targetYear]);
|
|
||||||
const monthStart = startOfMonth(new Date(targetYear, targetMonth - 1));
|
const monthStart = startOfMonth(new Date(targetYear, targetMonth - 1));
|
||||||
const selectedCount = selectedDates.size;
|
const selectedCount = selectedDates.size;
|
||||||
const hasMinimumRequirement = requiredDaysTotal > 0;
|
const hasMinimumRequirement = requiredDaysTotal > 0;
|
||||||
@@ -375,12 +374,9 @@ export function NotdienstCalendar({
|
|||||||
const isInTargetMonth = isSameMonth(day, visibleMonthStart);
|
const isInTargetMonth = isSameMonth(day, visibleMonthStart);
|
||||||
const isSelected = selected.has(dateKey);
|
const isSelected = selected.has(dateKey);
|
||||||
const dayEvents = getCalendarItemsForDay(events, day);
|
const dayEvents = getCalendarItemsForDay(events, day);
|
||||||
const hasClosure = dayEvents.some(
|
|
||||||
(event) => event.category === "closure",
|
|
||||||
);
|
|
||||||
const isSelectable =
|
|
||||||
isInTargetMonth && selectableDateKeys.has(dateKey) && !hasClosure;
|
|
||||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
|
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
|
||||||
|
const hasClosure = isInTargetMonth && !isWeekend && !selectableDateKeys.has(dateKey);
|
||||||
|
const isSelectable = isInTargetMonth && selectableDateKeys.has(dateKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CalendarTile
|
<CalendarTile
|
||||||
@@ -603,25 +599,6 @@ function getCalendarItemsForDay(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClosureDateKeys(items: NotdienstCalendarItem[]) {
|
|
||||||
const closureKeys = new Set<string>();
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.category !== "closure") continue;
|
|
||||||
|
|
||||||
const days = eachDayOfInterval({
|
|
||||||
start: startOfDay(item.startDate),
|
|
||||||
end: startOfDay(item.endDate),
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const day of days) {
|
|
||||||
closureKeys.add(formatDateKey(day));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return closureKeys;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatEventDate(event: NotdienstCalendarItem) {
|
function formatEventDate(event: NotdienstCalendarItem) {
|
||||||
const date = format(event.startDate, "EEEE, d. MMMM yyyy", { locale: de });
|
const date = format(event.startDate, "EEEE, d. MMMM yyyy", { locale: de });
|
||||||
return event.allDay
|
return event.allDay
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { Manrope } from "next/font/google";
|
|||||||
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { formatDateKey, getTargetMonthData } from "@/lib/date-utils";
|
import { formatUtcDateKey, getTargetMonthData } from "@/lib/date-utils";
|
||||||
|
import { getNotdienstDays } from "@/lib/notdienst-days";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { NotdienstEntry } from "./notdienst-entry";
|
import { NotdienstEntry } from "./notdienst-entry";
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export default async function NotdienstPage() {
|
|||||||
kitaId,
|
kitaId,
|
||||||
status: TerminStatus.CONFIRMED,
|
status: TerminStatus.CONFIRMED,
|
||||||
startDate: { lt: targetMonthEnd },
|
startDate: { lt: targetMonthEnd },
|
||||||
endDate: { gte: targetMonthStart },
|
endDate: { gte: new Date(targetMonthStart.getTime() - 86400000) },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -93,7 +94,10 @@ export default async function NotdienstPage() {
|
|||||||
])
|
])
|
||||||
: [[], []];
|
: [[], []];
|
||||||
|
|
||||||
const selectedDates = availabilities.map((a) => formatDateKey(a.date));
|
const validDateKeys = new Set(getNotdienstDays(targetYear, targetMonth,
|
||||||
|
termine.filter((termin) => termin.type === TerminType.SCHLIESSTAG || termin.type === TerminType.TEAMTAG),
|
||||||
|
).map(formatUtcDateKey));
|
||||||
|
const selectedDates = availabilities.map((a) => formatUtcDateKey(a.date)).filter((key) => validDateKeys.has(key));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${manrope.className} mx-auto max-w-7xl px-4 py-8 sm:px-8`}>
|
<div className={`${manrope.className} mx-auto max-w-7xl px-4 py-8 sm:px-8`}>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Card className="mb-5 print:hidden p-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-[var(--ink)]">
|
||||||
|
E-Mail-Versand
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||||
|
{daysBefore
|
||||||
|
? `Automatische Erinnerung ab dem ${Math.max(1, 26 - daysBefore)}. um 9 Uhr. Eintragsfrist: der 26.`
|
||||||
|
: "Automatische Erinnerungen sind ausgeschaltet."}
|
||||||
|
</p>
|
||||||
|
{!jobConfigured ? (
|
||||||
|
<p className="mt-1 text-xs text-[var(--warn)]">
|
||||||
|
Der Hintergrundversand ist noch nicht konfiguriert. Offene E-Mails
|
||||||
|
können hier manuell gesendet werden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{remindersOpen ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => run(false)}
|
||||||
|
>
|
||||||
|
An fehlende Verfügbarkeiten erinnern
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{status.failed + status.pending > 0 ? (
|
||||||
|
<Button size="sm" disabled={pending} onClick={() => run(true)}>
|
||||||
|
Offene E-Mails erneut senden
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => router.refresh()}
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-[var(--ink-soft)]">
|
||||||
|
{status.sent} versendet · {status.pending} ausstehend · {status.failed}{" "}
|
||||||
|
fehlgeschlagen · {status.skipped} nicht mehr erforderlich
|
||||||
|
</p>
|
||||||
|
{status.entries.length ? (
|
||||||
|
<details className="mt-3 text-sm">
|
||||||
|
<summary className="cursor-pointer font-medium text-[var(--accent-deep)]">
|
||||||
|
Versanddetails ansehen
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-3 max-h-64 overflow-y-auto divide-y divide-[var(--hairline-soft)]">
|
||||||
|
{status.entries.map((entry) => (
|
||||||
|
<li
|
||||||
|
key={entry.id}
|
||||||
|
className="flex flex-wrap justify-between gap-2 py-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{entry.name} · {entry.familyName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--ink-muted)]">
|
||||||
|
{entry.kind === "REMINDER"
|
||||||
|
? "Verfügbarkeitserinnerung"
|
||||||
|
: "Planveröffentlichung"}
|
||||||
|
</p>
|
||||||
|
{entry.error ? (
|
||||||
|
<p className="mt-1 text-xs text-[var(--danger)]">
|
||||||
|
{entry.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
entry.status === "failed"
|
||||||
|
? "text-[var(--danger)]"
|
||||||
|
: "text-[var(--ink-soft)]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{labels[entry.status]}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-[var(--ink-muted)]">
|
||||||
|
Für diesen Monat wurden noch keine E-Mails vorbereitet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-3 text-xs text-[var(--ink-muted)]">
|
||||||
|
Versendet bedeutet: vom Maildienst angenommen. Die tatsächliche
|
||||||
|
Zustellung ins Postfach kann hier nicht bestätigt werden.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,8 +35,8 @@ export function SettingsModal({
|
|||||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (minPerChild < 0 || reminderDays < 0) {
|
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) {
|
||||||
toast.error("Werte dürfen nicht negativ sein.");
|
toast.error("Bitte ein Soll von 1 bis 31 und eine Erinnerungsfrist von 0 bis 31 Tagen eingeben.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,25 +74,26 @@ export function SettingsModal({
|
|||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="minPerChild">
|
<Label htmlFor="minPerChild">
|
||||||
Mindesteinteilungen pro Kind/Monat
|
Verfügbarkeitstage pro Kind/Monat
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="minPerChild"
|
id="minPerChild"
|
||||||
name="minPerChild"
|
name="minPerChild"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="1"
|
||||||
|
max="31"
|
||||||
required
|
required
|
||||||
value={minPerChild}
|
value={minPerChild}
|
||||||
onChange={(e) => setMinPerChild(parseInt(e.target.value, 10))}
|
onChange={(e) => setMinPerChild(parseInt(e.target.value, 10))}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-[var(--ink-muted)]">
|
<p className="text-xs text-[var(--ink-muted)]">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="reminderDays">
|
<Label htmlFor="reminderDays">
|
||||||
Erinnerungsfrist (Tage vor Monatsende)
|
Erinnerung (Tage vor Eintragsfrist)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="reminderDays"
|
id="reminderDays"
|
||||||
@@ -100,11 +101,12 @@ export function SettingsModal({
|
|||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
required
|
required
|
||||||
|
max="31"
|
||||||
value={reminderDays}
|
value={reminderDays}
|
||||||
onChange={(e) => setReminderDays(parseInt(e.target.value, 10))}
|
onChange={(e) => setReminderDays(parseInt(e.target.value, 10))}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-[var(--ink-muted)]">
|
<p className="text-xs text-[var(--ink-muted)]">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ import { de } from "date-fns/locale";
|
|||||||
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
import { getTargetMonthData } from "@/lib/date-utils";
|
||||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||||
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
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 { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
||||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||||
import {
|
import {
|
||||||
@@ -42,93 +45,44 @@ export async function generatePlanAction(year?: number, month?: number) {
|
|||||||
|
|
||||||
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
|
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
|
||||||
|
|
||||||
// Prüfen, ob schon ein Plan existiert
|
try {
|
||||||
const existingPlan = await prisma.notdienstPlan.findUnique({
|
await serializableTransaction(async (tx) => {
|
||||||
where: {
|
const existingPlan = await tx.notdienstPlan.findUnique({
|
||||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
where: { kitaId_year_month: { kitaId, year: targetYear, month: targetMonth } },
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (existingPlan) throw new UserFacingError("Ein Plan für diesen Monat existiert bereits.");
|
||||||
|
|
||||||
if (existingPlan) {
|
const [availabilities, workingDays] = await Promise.all([
|
||||||
return { error: "Ein Plan für diesen Monat existiert bereits." };
|
tx.notdienstAvailability.findMany({
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Alle Verfügbarkeiten der Eltern für diesen Monat holen
|
|
||||||
const availabilities = await prisma.notdienstAvailability.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
kitaId,
|
kitaId,
|
||||||
|
child: { kitaId, active: true },
|
||||||
date: {
|
date: {
|
||||||
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
||||||
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: { child: { select: { familyId: true } } },
|
include: { child: { select: { familyId: true } } },
|
||||||
});
|
}),
|
||||||
|
loadNotdienstDays(tx, kitaId, targetYear, targetMonth),
|
||||||
// 2. Werktage holen
|
]);
|
||||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
const familyUsageCount = new Map<string, number>();
|
||||||
|
const assignments: { childId: string; date: Date }[] = [];
|
||||||
// 3. Algorithmus: Wir verteilen die Slots
|
|
||||||
// Counter für "Gerechtigkeit": wie oft wurde diese Familie schon eingeteilt?
|
|
||||||
const familyUsageCount: Record<string, number> = {};
|
|
||||||
|
|
||||||
const assignmentsToCreate: { childId: string; date: Date }[] = [];
|
|
||||||
|
|
||||||
for (const day of workingDays) {
|
for (const day of workingDays) {
|
||||||
// Alle Verfügbarkeiten für diesen spezifischen Tag
|
const candidates = availabilities.filter((a) => a.date.getTime() === day.getTime());
|
||||||
const availForDay = availabilities.filter(
|
if (!candidates.length) continue;
|
||||||
(a) => a.date.getTime() === day.getTime(),
|
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)];
|
||||||
if (availForDay.length === 0) {
|
assignments.push({ childId: selected.childId, date: day });
|
||||||
// Niemand verfügbar -> Bleibt leer ("Unbesetzt")
|
familyUsageCount.set(selected.child.familyId, minUsage + 1);
|
||||||
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({
|
const plan = await tx.notdienstPlan.create({
|
||||||
data: {
|
data: { kitaId, year: targetYear, month: targetMonth, status: NotdienstPlanStatus.DRAFT, createdById: session.user.id },
|
||||||
kitaId,
|
|
||||||
year: targetYear,
|
|
||||||
month: targetMonth,
|
|
||||||
status: NotdienstPlanStatus.DRAFT,
|
|
||||||
createdById: session.user.id,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (assignments.length) {
|
||||||
if (assignmentsToCreate.length > 0) {
|
|
||||||
await tx.notdienstAssignment.createMany({
|
await tx.notdienstAssignment.createMany({
|
||||||
data: assignmentsToCreate.map((a) => ({
|
data: assignments.map((a) => ({ ...a, kitaId, planId: plan.id })),
|
||||||
kitaId,
|
|
||||||
planId: plan.id,
|
|
||||||
childId: a.childId,
|
|
||||||
date: a.date,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -137,7 +91,7 @@ export async function generatePlanAction(year?: number, month?: number) {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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 session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
const kitaId = session.user.kitaId!;
|
const kitaId = session.user.kitaId!;
|
||||||
|
|
||||||
await prisma.notdienstPlan.update({
|
try {
|
||||||
|
await serializableTransaction(async (tx) => {
|
||||||
|
const plan = await tx.notdienstPlan.findUnique({
|
||||||
where: { id: planId, kitaId },
|
where: { id: planId, kitaId },
|
||||||
data: {
|
include: { assignments: { include: { child: { select: { active: true } } } } },
|
||||||
status: NotdienstPlanStatus.PUBLISHED,
|
});
|
||||||
publishedAt: new Date(),
|
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/plan");
|
||||||
revalidatePath("/dashboard/notdienst");
|
revalidatePath("/dashboard/notdienst");
|
||||||
return { success: true };
|
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(
|
export async function updateAssignmentAction(
|
||||||
@@ -189,11 +161,19 @@ export async function updateAssignmentAction(
|
|||||||
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
|
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) {
|
if (isPublished) {
|
||||||
const today = new Date();
|
const todayUtc = new Date(`${todayKey()}T00:00:00Z`);
|
||||||
const todayUtc = new Date(
|
|
||||||
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
|
|
||||||
);
|
|
||||||
if (parsedDate < todayUtc) {
|
if (parsedDate < todayUtc) {
|
||||||
throw new UserFacingError(
|
throw new UserFacingError(
|
||||||
"Vergangene Notdienste können nicht geändert werden.",
|
"Vergangene Notdienste können nicht geändert werden.",
|
||||||
@@ -202,7 +182,7 @@ export async function updateAssignmentAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
||||||
where: { planId, date: parsedDate },
|
where: { kitaId, planId, date: parsedDate },
|
||||||
include: {
|
include: {
|
||||||
child: {
|
child: {
|
||||||
include: {
|
include: {
|
||||||
@@ -431,118 +411,33 @@ export async function updateKitaNotdienstSettingsAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendNotdienstRemindersAction(
|
export async function sendNotdienstRemindersAction(year: number, month: number) {
|
||||||
year: number,
|
|
||||||
month: number,
|
|
||||||
) {
|
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
const kitaId = session.user.kitaId;
|
const kitaId = session.user.kitaId!;
|
||||||
if (!kitaId) {
|
|
||||||
return { error: "Kein Mandant zugeordnet." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
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." };
|
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 {
|
try {
|
||||||
// 1. Get Kita Name
|
const queued = await queueNotdienstReminders(kitaId, year, month);
|
||||||
const kita = await prisma.kita.findUnique({
|
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
||||||
where: { id: kitaId },
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
select: { name: true },
|
return { success: true, queued, count: delivery.sent, ...delivery };
|
||||||
});
|
|
||||||
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 };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Versenden der Erinnerungen:", error);
|
return { error: publicActionError(error, "Die Erinnerungen konnten nicht vorbereitet werden.") };
|
||||||
return { error: "Fehler beim Versenden der Erinnerungen." };
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { addMonths, getYear, getMonth, format } from "date-fns";
|
import { addMonths, getYear, getMonth, format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { TerminType } from "@prisma/client";
|
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
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 { PlanView } from "./plan-view";
|
||||||
import { SettingsModal } from "./_components/settings-modal";
|
import { SettingsModal } from "./_components/settings-modal";
|
||||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||||
@@ -23,22 +25,21 @@ export default async function PlanungsZentralePage({
|
|||||||
|
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nextMonth = addMonths(now, 1);
|
const defaultTarget = getTargetMonthData(now);
|
||||||
const targetYear = resolvedSearchParams.year
|
const targetYear = resolvedSearchParams.year
|
||||||
? parseInt(resolvedSearchParams.year, 10)
|
? parseInt(resolvedSearchParams.year, 10)
|
||||||
: getYear(nextMonth);
|
: defaultTarget.targetYear;
|
||||||
const targetMonth = resolvedSearchParams.month
|
const targetMonth = resolvedSearchParams.month
|
||||||
? parseInt(resolvedSearchParams.month, 10)
|
? parseInt(resolvedSearchParams.month, 10)
|
||||||
: getMonth(nextMonth) + 1;
|
: defaultTarget.targetMonth;
|
||||||
|
|
||||||
const monthDate = new Date(targetYear, targetMonth - 1, 1);
|
const monthDate = new Date(targetYear, targetMonth - 1, 1);
|
||||||
const monthName = format(monthDate, "MMMM", { locale: de });
|
const monthName = format(monthDate, "MMMM", { locale: de });
|
||||||
|
|
||||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
const monthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
|
||||||
const monthStart = new Date(targetYear, targetMonth - 1, 1);
|
const monthEnd = new Date(Date.UTC(targetYear, targetMonth, 1));
|
||||||
const monthEnd = new Date(targetYear, targetMonth, 1);
|
|
||||||
|
|
||||||
const [plan, families, availabilities, closureCount, kita] =
|
const [plan, families, rawAvailabilities, workingDays, kita] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.notdienstPlan.findUnique({
|
prisma.notdienstPlan.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -80,6 +81,7 @@ export default async function PlanungsZentralePage({
|
|||||||
prisma.notdienstAvailability.findMany({
|
prisma.notdienstAvailability.findMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId,
|
kitaId,
|
||||||
|
child: { kitaId, active: true },
|
||||||
date: {
|
date: {
|
||||||
gte: monthStart,
|
gte: monthStart,
|
||||||
lt: monthEnd,
|
lt: monthEnd,
|
||||||
@@ -95,16 +97,7 @@ export default async function PlanungsZentralePage({
|
|||||||
},
|
},
|
||||||
orderBy: { date: "asc" },
|
orderBy: { date: "asc" },
|
||||||
}),
|
}),
|
||||||
prisma.termin.count({
|
loadNotdienstDays(prisma, kitaId, targetYear, targetMonth),
|
||||||
where: {
|
|
||||||
kitaId,
|
|
||||||
type: TerminType.SCHLIESSTAG,
|
|
||||||
startDate: {
|
|
||||||
gte: monthStart,
|
|
||||||
lt: monthEnd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.kita.findUniqueOrThrow({
|
prisma.kita.findUniqueOrThrow({
|
||||||
where: { id: kitaId },
|
where: { id: kitaId },
|
||||||
select: {
|
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<string, Set<string>>();
|
const availabilityDatesByFamily = new Map<string, Set<string>>();
|
||||||
availabilities.forEach((availability) => {
|
availabilities.forEach((availability) => {
|
||||||
const familyId = availability.child.familyId;
|
const familyId = availability.child.familyId;
|
||||||
@@ -174,11 +171,12 @@ export default async function PlanungsZentralePage({
|
|||||||
}).format(new Date(date)),
|
}).format(new Date(date)),
|
||||||
})),
|
})),
|
||||||
childCount: family.children.length,
|
childCount: family.children.length,
|
||||||
|
requiredDays: family.children.length * kita.notdienstMinPerChildPerMonth,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const respondedFamilies = familyStatuses.filter(
|
const respondedFamilies = familyStatuses.filter(
|
||||||
(family) => family.selectedDays.length > 0,
|
(family) => family.selectedDays.length >= family.requiredDays,
|
||||||
).length;
|
).length;
|
||||||
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
|
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
|
||||||
const totalAvailabilitySlots = availabilities.length;
|
const totalAvailabilitySlots = availabilities.length;
|
||||||
@@ -187,7 +185,13 @@ export default async function PlanungsZentralePage({
|
|||||||
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
|
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
|
||||||
|
|
||||||
// Wir mappen die Assignments auf die Werktage
|
// 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(
|
const assignment = plan?.assignments.find(
|
||||||
(a) => a.date.getTime() === day.getTime(),
|
(a) => a.date.getTime() === day.getTime(),
|
||||||
);
|
);
|
||||||
@@ -195,19 +199,25 @@ export default async function PlanungsZentralePage({
|
|||||||
!assignment ||
|
!assignment ||
|
||||||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
||||||
|
|
||||||
|
const isInvalid = !validDateKeys.has(formatUtcDateKey(day)) || !!assignment && !activeChildIds.has(assignment.childId);
|
||||||
return {
|
return {
|
||||||
date: day.toISOString(),
|
date: day.toISOString(),
|
||||||
|
isInvalid,
|
||||||
assignmentId: assignment?.id || null,
|
assignmentId: assignment?.id || null,
|
||||||
childId: assignment?.childId || null,
|
childId: assignment?.childId || null,
|
||||||
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
||||||
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
||||||
canAlarm:
|
canAlarm:
|
||||||
!!assignment &&
|
!!assignment && !isInvalid &&
|
||||||
plan?.status === "PUBLISHED" &&
|
plan?.status === "PUBLISHED" &&
|
||||||
day.toISOString().slice(0, 10) >= todayKey(),
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
|
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
|
||||||
@@ -226,7 +236,7 @@ export default async function PlanungsZentralePage({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="print:hidden flex shrink-0 items-center gap-2">
|
<div className="print:hidden flex flex-wrap items-center gap-2">
|
||||||
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, -1))}&month=${getMonth(addMonths(monthDate, -1)) + 1}`}
|
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, -1))}&month=${getMonth(addMonths(monthDate, -1)) + 1}`}
|
||||||
@@ -251,8 +261,11 @@ export default async function PlanungsZentralePage({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<EmailStatusCard status={mailStatus} year={targetYear} month={targetMonth} remindersOpen={remindersOpen} daysBefore={kita.notdienstReminderDaysBefore} jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32} />
|
||||||
|
|
||||||
<PlanView
|
<PlanView
|
||||||
planId={plan?.id}
|
planId={plan?.id}
|
||||||
|
remindersOpen={remindersOpen}
|
||||||
status={plan?.status}
|
status={plan?.status}
|
||||||
days={daysWithAssignments}
|
days={daysWithAssignments}
|
||||||
allChildren={allChildren}
|
allChildren={allChildren}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
|
|
||||||
interface PlanViewProps {
|
interface PlanViewProps {
|
||||||
planId?: string;
|
planId?: string;
|
||||||
|
remindersOpen: boolean;
|
||||||
status?: NotdienstPlanStatus;
|
status?: NotdienstPlanStatus;
|
||||||
days: {
|
days: {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -45,6 +46,7 @@ interface PlanViewProps {
|
|||||||
canAlarm?: boolean;
|
canAlarm?: boolean;
|
||||||
childId: string | null;
|
childId: string | null;
|
||||||
isOriginallyUnassigned?: boolean;
|
isOriginallyUnassigned?: boolean;
|
||||||
|
isInvalid?: boolean;
|
||||||
}[];
|
}[];
|
||||||
allChildren: {
|
allChildren: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,6 +58,7 @@ interface PlanViewProps {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
childCount: number;
|
childCount: number;
|
||||||
|
requiredDays: number;
|
||||||
selectedDays: {
|
selectedDays: {
|
||||||
iso: string;
|
iso: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -75,6 +78,7 @@ interface PlanViewProps {
|
|||||||
|
|
||||||
export function PlanView({
|
export function PlanView({
|
||||||
planId,
|
planId,
|
||||||
|
remindersOpen,
|
||||||
status,
|
status,
|
||||||
days,
|
days,
|
||||||
allChildren,
|
allChildren,
|
||||||
@@ -116,8 +120,9 @@ export function PlanView({
|
|||||||
const result = await publishPlanAction(planId);
|
const result = await publishPlanAction(planId);
|
||||||
if ("error" in result && result.error) {
|
if ("error" in result && result.error) {
|
||||||
toast.error(result.error as string);
|
toast.error(result.error as string);
|
||||||
} else {
|
} else if ("success" in result && result.success) {
|
||||||
toast.success("Plan veröffentlicht! Er ist nun fix.");
|
if (result.failed || ("deferred" in result && result.deferred)) toast.warning("Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.");
|
||||||
|
else toast.success("Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -127,10 +132,9 @@ export function PlanView({
|
|||||||
const result = await sendNotdienstRemindersAction(year, month);
|
const result = await sendNotdienstRemindersAction(year, month);
|
||||||
if ("error" in result && result.error) {
|
if ("error" in result && result.error) {
|
||||||
toast.error(result.error as string);
|
toast.error(result.error as string);
|
||||||
} else if (result.success) {
|
} else if ("success" in result && result.success) {
|
||||||
toast.success(
|
if (result.failed || ("deferred" in result && result.deferred)) toast.warning(`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`);
|
||||||
`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`,
|
else toast.success(`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -153,7 +157,7 @@ export function PlanView({
|
|||||||
const valueToSet = newChildId === "empty" ? null : newChildId;
|
const valueToSet = newChildId === "empty" ? null : newChildId;
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await updateAssignmentAction(planId, date, valueToSet);
|
const result = await updateAssignmentAction(planId, date.slice(0, 10), valueToSet);
|
||||||
if ("error" in result && result.error) {
|
if ("error" in result && result.error) {
|
||||||
toast.error(result.error as string);
|
toast.error(result.error as string);
|
||||||
} else {
|
} else {
|
||||||
@@ -162,7 +166,7 @@ export function PlanView({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!planId && respondedFamilies === 0) {
|
if (!planId && totalAvailabilitySlots === 0) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-dashed">
|
<Card className="border-dashed">
|
||||||
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
|
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
|
||||||
@@ -183,7 +187,7 @@ export function PlanView({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleSendReminders}
|
onClick={handleSendReminders}
|
||||||
disabled={isPending}
|
disabled={isPending || !remindersOpen}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||||
@@ -211,7 +215,7 @@ export function PlanView({
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
|
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
|
||||||
{respondedFamilies} von {totalFamilies} Familien haben
|
{respondedFamilies} von {totalFamilies} Familien haben
|
||||||
geantwortet · noch {openFamilies} offen
|
vollständig geantwortet · noch {openFamilies} offen
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
|
<span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
|
||||||
@@ -244,7 +248,7 @@ export function PlanView({
|
|||||||
{family.childCount === 1 ? "" : "er"}
|
{family.childCount === 1 ? "" : "er"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<FamilyStatusPill selectedDays={family.selectedDays.length} />
|
<FamilyStatusPill selectedDays={family.selectedDays.length} requiredDays={family.requiredDays} />
|
||||||
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
|
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
|
||||||
{family.selectedDays.length > 0
|
{family.selectedDays.length > 0
|
||||||
? family.selectedDays.map((day) => day.label).join(" · ")
|
? family.selectedDays.map((day) => day.label).join(" · ")
|
||||||
@@ -258,7 +262,7 @@ export function PlanView({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleSendReminders}
|
onClick={handleSendReminders}
|
||||||
disabled={isPending || openFamilies === 0}
|
disabled={isPending || openFamilies === 0 || !remindersOpen}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||||
@@ -315,7 +319,7 @@ export function PlanView({
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||||
Verteilt {requiredDays} Notdiensttage fair auf{" "}
|
Verteilt {requiredDays} Notdiensttage fair auf{" "}
|
||||||
{respondedFamilies} Familien.
|
die verfügbaren Familien.
|
||||||
</p>
|
</p>
|
||||||
<details className="mt-3 text-xs text-[var(--ink-soft)]">
|
<details className="mt-3 text-xs text-[var(--ink-soft)]">
|
||||||
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
|
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
|
||||||
@@ -447,7 +451,7 @@ export function PlanView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
|
function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number; requiredDays: number }) {
|
||||||
if (selectedDays === 0) {
|
if (selectedDays === 0) {
|
||||||
return (
|
return (
|
||||||
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
|
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
|
||||||
@@ -457,8 +461,8 @@ function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]">
|
<span className={selectedDays >= requiredDays ? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]" : "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"}>
|
||||||
{selectedDays} Tage
|
{selectedDays} / {requiredDays} Tage
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -548,7 +552,7 @@ function PlanPreviewTable({
|
|||||||
{format(dateObj, "EEEE", { locale: de })}
|
{format(dateObj, "EEEE", { locale: de })}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[var(--ink)]">
|
<div className="text-[var(--ink)]">
|
||||||
{assignedChild ? assignedChild.parentName : "Unbesetzt"}
|
{assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"}
|
||||||
{assignedChild && (
|
{assignedChild && (
|
||||||
<span className="ml-1 text-[var(--ink-muted)]">
|
<span className="ml-1 text-[var(--ink-muted)]">
|
||||||
({assignedChild.name})
|
({assignedChild.name})
|
||||||
@@ -572,8 +576,8 @@ function PlanPreviewTable({
|
|||||||
</div>
|
</div>
|
||||||
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
||||||
<div>
|
<div>
|
||||||
<Badge variant={assignedChild ? "success" : "destructive"}>
|
<Badge variant={day.isInvalid ? "warning" : assignedChild ? "success" : "destructive"}>
|
||||||
{assignedChild ? "Besetzt" : "Offen"}
|
{day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -649,18 +653,24 @@ function ManualAssignmentTable({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="empty">-- Unbesetzt --</option>
|
<option value="empty">-- Unbesetzt --</option>
|
||||||
|
{day.childId && !allChildren.some((child) => child.id === day.childId) ? (
|
||||||
|
<option value={day.childId} disabled>Inaktives Kind – bitte Einteilung entfernen</option>
|
||||||
|
) : null}
|
||||||
{allChildren.map((child) => {
|
{allChildren.map((child) => {
|
||||||
const famId = child.familyId;
|
const famId = child.familyId;
|
||||||
const count = famId
|
const count = famId
|
||||||
? (familyAssignmentCounts.get(famId) ?? 0)
|
? (familyAssignmentCounts.get(famId) ?? 0)
|
||||||
: 0;
|
: 0;
|
||||||
return (
|
return (
|
||||||
<option key={child.id} value={child.id}>
|
<option key={child.id} value={child.id} disabled={day.isInvalid}>
|
||||||
{child.parentName} ({child.name}) — {count}x
|
{child.parentName} ({child.name}) — {count}x
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
|
{day.isInvalid ? (
|
||||||
|
<span className="text-xs text-[var(--danger)]">Einteilung nicht mehr gültig – bitte entfernen.</span>
|
||||||
|
) : null}
|
||||||
{!isDraft && (
|
{!isDraft && (
|
||||||
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
|
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
|
||||||
Veröffentlicht
|
Veröffentlicht
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from "@/lib/child-birthdate";
|
} from "@/lib/child-birthdate";
|
||||||
import { serializableTransaction } from "@/lib/transaction";
|
import { serializableTransaction } from "@/lib/transaction";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
||||||
|
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||||
|
|
||||||
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
|
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
|
||||||
// Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
|
// Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
|
||||||
@@ -268,26 +270,25 @@ export async function deleteMyChild(childId: string) {
|
|||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const child = await requireOwnFamilyChild(
|
await serializableTransaction(async (tx) => {
|
||||||
childId,
|
if (!session.user.familyId) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
|
||||||
session.user.familyId,
|
const child = await tx.child.findFirst({
|
||||||
session.user.kitaId,
|
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
|
||||||
);
|
select: { id: true },
|
||||||
|
});
|
||||||
if (!child) {
|
if (!child) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
|
||||||
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
|
await assertChildrenRemovable(tx, session.user.kitaId, session.user.familyId, [childId]);
|
||||||
}
|
await tx.child.delete({
|
||||||
|
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
|
||||||
await prisma.child.delete({
|
});
|
||||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/dashboard/profil");
|
revalidatePath("/dashboard/profil");
|
||||||
revalidatePath("/dashboard/families");
|
revalidatePath("/dashboard/families");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Entfernen des Kindes:", error);
|
if (!(error instanceof UserFacingError)) console.error("Fehler beim Entfernen des Kindes:", error);
|
||||||
return { error: "Das Kind konnte nicht entfernt werden." };
|
return { error: publicActionError(error, "Das Kind konnte nicht entfernt werden.") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const navItems = [
|
|||||||
label: "Notdienst (Eltern)",
|
label: "Notdienst (Eltern)",
|
||||||
icon: CalendarCheck2,
|
icon: CalendarCheck2,
|
||||||
exact: true,
|
exact: true,
|
||||||
badge: "4",
|
|
||||||
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ELTERN],
|
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ELTERN],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -165,7 +164,7 @@ export function SidebarNav({ role }: { role: UserRole }) {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{visibleItems
|
{visibleItems
|
||||||
.filter((item) => item.section === section)
|
.filter((item) => item.section === section)
|
||||||
.map(({ href, label, icon, exact, badge }) => {
|
.map(({ href, label, icon, exact }) => {
|
||||||
const isActive = exact
|
const isActive = exact
|
||||||
? pathname === href
|
? pathname === href
|
||||||
: pathname.startsWith(href);
|
: pathname.startsWith(href);
|
||||||
@@ -176,7 +175,6 @@ export function SidebarNav({ role }: { role: UserRole }) {
|
|||||||
label={label}
|
label={label}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
active={isActive}
|
active={isActive}
|
||||||
badge={badge}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ type NotdienstAvailabilityReminderEmailProps = {
|
|||||||
kitaName: string;
|
kitaName: string;
|
||||||
monthLabel: string;
|
monthLabel: string;
|
||||||
link: string;
|
link: string;
|
||||||
|
selectedDays?: number;
|
||||||
|
requiredDays?: number;
|
||||||
|
deadlineLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NotdienstAvailabilityReminderEmail({
|
export function NotdienstAvailabilityReminderEmail({
|
||||||
@@ -24,6 +27,9 @@ export function NotdienstAvailabilityReminderEmail({
|
|||||||
kitaName,
|
kitaName,
|
||||||
monthLabel,
|
monthLabel,
|
||||||
link,
|
link,
|
||||||
|
selectedDays,
|
||||||
|
requiredDays,
|
||||||
|
deadlineLabel,
|
||||||
}: NotdienstAvailabilityReminderEmailProps) {
|
}: NotdienstAvailabilityReminderEmailProps) {
|
||||||
return (
|
return (
|
||||||
<Html lang="de">
|
<Html lang="de">
|
||||||
@@ -46,6 +52,8 @@ export function NotdienstAvailabilityReminderEmail({
|
|||||||
Bitte trage deine Verfügbarkeiten im Planer ein, damit wir die Tage fair verteilen können.
|
Bitte trage deine Verfügbarkeiten im Planer ein, damit wir die Tage fair verteilen können.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{requiredDays !== undefined ? <Text style={styles.text}>Euer Haushalt hat bisher {selectedDays ?? 0} von {requiredDays} benötigten Tagen eingetragen. Bitte ergänzt die Auswahl{deadlineLabel ? ` bis einschließlich ${deadlineLabel}` : ""}.</Text> : null}
|
||||||
|
|
||||||
<Button href={link} style={styles.button}>
|
<Button href={link} style={styles.button}>
|
||||||
Verfügbarkeiten eintragen
|
Verfügbarkeiten eintragen
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
import { baseBodyStyle, colors, fontStack } from "./_styles";
|
||||||
|
|
||||||
|
export function NotdienstPlanPublishedEmail({
|
||||||
|
parentName,
|
||||||
|
kitaName,
|
||||||
|
monthLabel,
|
||||||
|
dateLabels,
|
||||||
|
link,
|
||||||
|
}: {
|
||||||
|
parentName: string;
|
||||||
|
kitaName: string;
|
||||||
|
monthLabel: string;
|
||||||
|
dateLabels: string[];
|
||||||
|
link: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Html lang="de">
|
||||||
|
<Head />
|
||||||
|
<Preview>Deine Notdienste für {monthLabel}</Preview>
|
||||||
|
<Body style={baseBodyStyle}>
|
||||||
|
<Container
|
||||||
|
style={{
|
||||||
|
maxWidth: "600px",
|
||||||
|
padding: "32px 20px",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
padding: "28px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
border: `1px solid ${colors.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Heading style={{ color: colors.textPrimary, fontSize: "26px" }}>
|
||||||
|
Notdienstplan {monthLabel}
|
||||||
|
</Heading>
|
||||||
|
<Text>Hallo {parentName},</Text>
|
||||||
|
<Text>
|
||||||
|
der Notdienstplan von <strong>{kitaName}</strong> ist
|
||||||
|
veröffentlicht. Euer Haushalt ist an folgenden Tagen eingeteilt:
|
||||||
|
</Text>
|
||||||
|
{dateLabels.map((date) => (
|
||||||
|
<Text
|
||||||
|
key={date}
|
||||||
|
style={{
|
||||||
|
padding: "10px 14px",
|
||||||
|
backgroundColor: "#f7f5ef",
|
||||||
|
borderLeft: `4px solid ${colors.brandGreen}`,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{date}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text>
|
||||||
|
Bitte merkt euch diese Termine vor. Den aktuellen Plan und
|
||||||
|
mögliche Änderungen findet ihr im Kalender.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
href={link}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.brandGreen,
|
||||||
|
color: "#ffffff",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "14px 20px",
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zum Kalender
|
||||||
|
</Button>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: "12px",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
+36
-19
@@ -1,35 +1,52 @@
|
|||||||
import {
|
import { format } from "date-fns";
|
||||||
addMonths,
|
|
||||||
getDate,
|
|
||||||
getYear,
|
|
||||||
getMonth,
|
|
||||||
format,
|
|
||||||
} from "date-fns";
|
|
||||||
import { de } from "date-fns/locale";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Notdienst-Plan wird immer für den nächsten Monat erstellt.
|
* Der Notdienst-Plan wird immer für den nächsten Monat erstellt.
|
||||||
* Nach dem 26. des aktuellen Monats ist der Plan gesperrt.
|
* Nach dem 26. des aktuellen Monats ist der Plan gesperrt.
|
||||||
*/
|
*/
|
||||||
const LOCK_DAY = 26;
|
export const NOTDIENST_LOCK_DAY = 26;
|
||||||
|
export function berlinDateKey(now = new Date()) {
|
||||||
|
return new Intl.DateTimeFormat("sv-SE", {
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(now);
|
||||||
|
}
|
||||||
|
|
||||||
export function getTargetMonthData(now = new Date()) {
|
export function getTargetMonthData(now = new Date()) {
|
||||||
const currentDay = getDate(now);
|
const [year, month, currentDay] = berlinDateKey(now).split("-").map(Number);
|
||||||
const nextMonthDate = addMonths(now, 1);
|
const targetYear = month === 12 ? year + 1 : year;
|
||||||
const targetYear = getYear(nextMonthDate);
|
const targetMonth = month === 12 ? 1 : month + 1;
|
||||||
const targetMonth = getMonth(nextMonthDate) + 1; // 1-12
|
|
||||||
|
|
||||||
// Sperre aktiv, sobald der aktuelle Tag (im aktuellen Monat) > LOCK_DAY ist.
|
|
||||||
const isLocked = currentDay > LOCK_DAY;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
targetYear,
|
targetYear,
|
||||||
targetMonth,
|
targetMonth,
|
||||||
isLocked,
|
isLocked: currentDay > NOTDIENST_LOCK_DAY,
|
||||||
monthName: format(nextMonthDate, "MMMM yyyy", { locale: de }),
|
monthName: new Intl.DateTimeFormat("de-DE", {
|
||||||
|
timeZone: "UTC",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
}).format(new Date(Date.UTC(targetYear, targetMonth - 1, 1))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNotdienstReminderDue(daysBefore: number, now = new Date()) {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-GB", {
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
}).formatToParts(now);
|
||||||
|
const day = Number(parts.find((part) => part.type === "day")?.value);
|
||||||
|
const hour = Number(parts.find((part) => part.type === "hour")?.value);
|
||||||
|
return (
|
||||||
|
daysBefore > 0 &&
|
||||||
|
day >= Math.max(1, NOTDIENST_LOCK_DAY - daysBefore) &&
|
||||||
|
day <= NOTDIENST_LOCK_DAY &&
|
||||||
|
hour >= 9
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getWorkingDaysOfMonth(year: number, month: number) {
|
export function getWorkingDaysOfMonth(year: number, month: number) {
|
||||||
const start = new Date(Date.UTC(year, month - 1, 1));
|
const start = new Date(Date.UTC(year, month - 1, 1));
|
||||||
const end = new Date(Date.UTC(year, month, 0)); // last day of month
|
const end = new Date(Date.UTC(year, month, 0)); // last day of month
|
||||||
|
|||||||
+16
-4
@@ -1,4 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import { createHash } from "node:crypto";
|
||||||
|
import type { ReactNode, ReactElement } from "react";
|
||||||
|
import { render } from "@react-email/render";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
|
|
||||||
let resendClient: Resend | null = null;
|
let resendClient: Resend | null = null;
|
||||||
@@ -10,6 +12,7 @@ type SendAppEmailInput = {
|
|||||||
from?: string;
|
from?: string;
|
||||||
replyTo?: string | string[];
|
replyTo?: string | string[];
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SendAppEmailResult =
|
type SendAppEmailResult =
|
||||||
@@ -38,11 +41,12 @@ export async function sendAppEmail({
|
|||||||
from = process.env.EMAIL_FROM,
|
from = process.env.EMAIL_FROM,
|
||||||
replyTo,
|
replyTo,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
|
timeoutMs = 10000,
|
||||||
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
||||||
if (Array.isArray(to)) {
|
if (Array.isArray(to)) {
|
||||||
let id: string | undefined;
|
let id: string | undefined;
|
||||||
for (const recipient of new Set(to)) {
|
for (const recipient of new Set(to)) {
|
||||||
const result = await sendAppEmail({ to: recipient, subject, react, from, replyTo });
|
const result = await sendAppEmail({ to: recipient, subject, react, from, replyTo, idempotencyKey: idempotencyKey ? `${idempotencyKey}/${createHash("sha256").update(recipient).digest("hex").slice(0, 16)}` : undefined, timeoutMs });
|
||||||
if (!result.success) return result;
|
if (!result.success) return result;
|
||||||
id = result.id;
|
id = result.id;
|
||||||
}
|
}
|
||||||
@@ -74,15 +78,23 @@ export async function sendAppEmail({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
resendClient ??= new Resend(process.env.RESEND_API_KEY);
|
resendClient ??= new Resend(process.env.RESEND_API_KEY);
|
||||||
|
const html = await render(react as ReactElement);
|
||||||
|
// Retries keep the same key for identical payloads, while corrected messages get a new version.
|
||||||
|
const payloadKey = idempotencyKey ? `${idempotencyKey}/${createHash("sha256").update(JSON.stringify({ to, subject, from, replyTo, html })).digest("hex")}` : undefined;
|
||||||
|
// The installed SDK forwards request options to fetch; its types omit signal.
|
||||||
|
const requestOptions: NonNullable<Parameters<typeof resendClient.emails.send>[1]> & { signal: AbortSignal } = {
|
||||||
|
idempotencyKey: payloadKey,
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
};
|
||||||
const { data, error } = await resendClient.emails.send(
|
const { data, error } = await resendClient.emails.send(
|
||||||
{
|
{
|
||||||
from: from!,
|
from: from!,
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
react,
|
html,
|
||||||
replyTo,
|
replyTo,
|
||||||
},
|
},
|
||||||
{ idempotencyKey },
|
requestOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { formatUtcDateKey, getWorkingDaysOfMonth } from "./date-utils";
|
||||||
|
|
||||||
|
export type NotdienstClosure = { startDate: Date; endDate: Date };
|
||||||
|
const berlinDate = new Intl.DateTimeFormat("sv-SE", {
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calendar events are instants; assignments are UTC-midnight date values.
|
||||||
|
// A closure excludes every Berlin calendar day from start through end, inclusive.
|
||||||
|
export function getNotdienstDays(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
closures: NotdienstClosure[],
|
||||||
|
) {
|
||||||
|
const ranges = closures.map((closure) => ({
|
||||||
|
start: berlinDate.format(closure.startDate),
|
||||||
|
end: berlinDate.format(closure.endDate),
|
||||||
|
}));
|
||||||
|
return getWorkingDaysOfMonth(year, month).filter((day) => {
|
||||||
|
const key = formatUtcDateKey(day);
|
||||||
|
return !ranges.some((range) => range.start <= key && key <= range.end);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { serializableTransaction } from "./transaction";
|
||||||
|
import { UserFacingError } from "./action-error";
|
||||||
|
import {
|
||||||
|
berlinDateKey,
|
||||||
|
formatUtcDateKey,
|
||||||
|
getTargetMonthData,
|
||||||
|
isNotdienstReminderDue,
|
||||||
|
NOTDIENST_LOCK_DAY,
|
||||||
|
} from "./date-utils";
|
||||||
|
import { loadNotdienstDays } from "./notdienst-planning";
|
||||||
|
import { sendAppEmail } from "@/lib/mail";
|
||||||
|
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
||||||
|
import { NotdienstPlanPublishedEmail } from "@/emails/NotdienstPlanPublishedEmail";
|
||||||
|
|
||||||
|
type Tx = Prisma.TransactionClient;
|
||||||
|
const parentRoles = ["ELTERN", "ADMIN", "KOORDINATOR"] as const;
|
||||||
|
const eligibleParents = (now: Date): Prisma.UserWhereInput => ({
|
||||||
|
role: { in: [...parentRoles] },
|
||||||
|
emailVerifiedAt: { not: null },
|
||||||
|
privacyPolicyAcceptedAt: { not: null },
|
||||||
|
disabledAt: null,
|
||||||
|
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
|
||||||
|
});
|
||||||
|
const monthLabel = (year: number, month: number) =>
|
||||||
|
new Intl.DateTimeFormat("de-DE", {
|
||||||
|
timeZone: "UTC",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(Date.UTC(year, month - 1, 1)));
|
||||||
|
const period = (year: number, month: number) => ({
|
||||||
|
gte: new Date(Date.UTC(year, month - 1, 1)),
|
||||||
|
lt: new Date(Date.UTC(year, month, 1)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getFamilyNotdienstProgress(
|
||||||
|
db: Pick<Tx, "child" | "notdienstAvailability" | "termin">,
|
||||||
|
kitaId: string,
|
||||||
|
familyId: string,
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
minimum: number,
|
||||||
|
) {
|
||||||
|
const [children, entries, days] = await Promise.all([
|
||||||
|
db.child.count({ where: { kitaId, familyId, active: true } }),
|
||||||
|
db.notdienstAvailability.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId,
|
||||||
|
child: { kitaId, familyId, active: true },
|
||||||
|
date: period(year, month),
|
||||||
|
},
|
||||||
|
select: { date: true },
|
||||||
|
}),
|
||||||
|
loadNotdienstDays(db, kitaId, year, month),
|
||||||
|
]);
|
||||||
|
const valid = new Set(days.map(formatUtcDateKey));
|
||||||
|
const selected = new Set(
|
||||||
|
entries
|
||||||
|
.map((a) => formatUtcDateKey(a.date))
|
||||||
|
.filter((key) => valid.has(key)),
|
||||||
|
).size;
|
||||||
|
return { selected, required: children * minimum };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueNotdienstReminders(
|
||||||
|
kitaId: string,
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
automatic = false,
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
return serializableTransaction(async (tx) => {
|
||||||
|
const target = getTargetMonthData(now);
|
||||||
|
if (
|
||||||
|
target.isLocked ||
|
||||||
|
target.targetYear !== year ||
|
||||||
|
target.targetMonth !== month
|
||||||
|
) {
|
||||||
|
if (automatic) return 0;
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Erinnerungen sind nur während der Eintragsfrist für den Folgemonat möglich.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const kita = await tx.kita.findUniqueOrThrow({
|
||||||
|
where: { id: kitaId },
|
||||||
|
select: {
|
||||||
|
notdienstModuleEnabled: true,
|
||||||
|
notdienstMinPerChildPerMonth: true,
|
||||||
|
notdienstReminderDaysBefore: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!kita.notdienstModuleEnabled) {
|
||||||
|
if (automatic) return 0;
|
||||||
|
throw new UserFacingError("Das Notdienstmodul ist deaktiviert.");
|
||||||
|
}
|
||||||
|
const plan = await tx.notdienstPlan.findUnique({
|
||||||
|
where: { kitaId_year_month: { kitaId, year, month } },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
if (plan?.status === "PUBLISHED") return 0;
|
||||||
|
if (automatic) {
|
||||||
|
if (!isNotdienstReminderDue(kita.notdienstReminderDaysBefore, now))
|
||||||
|
return 0;
|
||||||
|
const run = await tx.notdienstReminderRun.createMany({
|
||||||
|
data: [{ id: randomUUID(), kitaId, year, month, createdAt: now }],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
if (!run.count) return 0;
|
||||||
|
}
|
||||||
|
const [families, entries, days] = await Promise.all([
|
||||||
|
tx.family.findMany({
|
||||||
|
where: { kitaId, children: { some: { kitaId, active: true } } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
children: { where: { kitaId, active: true }, select: { id: true } },
|
||||||
|
users: {
|
||||||
|
where: { kitaId, ...eligibleParents(now) },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tx.notdienstAvailability.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId,
|
||||||
|
date: period(year, month),
|
||||||
|
child: { kitaId, active: true },
|
||||||
|
},
|
||||||
|
select: { date: true, child: { select: { familyId: true } } },
|
||||||
|
}),
|
||||||
|
loadNotdienstDays(tx, kitaId, year, month),
|
||||||
|
]);
|
||||||
|
const valid = new Set(days.map(formatUtcDateKey));
|
||||||
|
const selected = new Map<string, Set<string>>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const key = formatUtcDateKey(entry.date);
|
||||||
|
if (!valid.has(key)) continue;
|
||||||
|
const dates = selected.get(entry.child.familyId) ?? new Set<string>();
|
||||||
|
dates.add(key);
|
||||||
|
selected.set(entry.child.familyId, dates);
|
||||||
|
}
|
||||||
|
const jobs = families
|
||||||
|
.filter(
|
||||||
|
(family) =>
|
||||||
|
(selected.get(family.id)?.size ?? 0) <
|
||||||
|
family.children.length * kita.notdienstMinPerChildPerMonth,
|
||||||
|
)
|
||||||
|
.flatMap((family) =>
|
||||||
|
family.users.map((user) => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
kitaId,
|
||||||
|
familyId: family.id,
|
||||||
|
userId: user.id,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
kind: "REMINDER",
|
||||||
|
eventKey: `reminder:${berlinDateKey(now)}`,
|
||||||
|
createdAt: now,
|
||||||
|
nextAttemptAt: now,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if (!jobs.length) return 0;
|
||||||
|
return (
|
||||||
|
await tx.notdienstEmail.createMany({ data: jobs, skipDuplicates: true })
|
||||||
|
).count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must run in the same transaction as the first publication.
|
||||||
|
export async function queueNotdienstPublication(
|
||||||
|
tx: Tx,
|
||||||
|
kitaId: string,
|
||||||
|
planId: string,
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
const assigned = await tx.notdienstAssignment.findMany({
|
||||||
|
where: { kitaId, planId, child: { kitaId, active: true } },
|
||||||
|
select: { child: { select: { familyId: true } } },
|
||||||
|
});
|
||||||
|
const families = await tx.family.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId,
|
||||||
|
id: { in: [...new Set(assigned.map((a) => a.child.familyId))] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
users: {
|
||||||
|
where: { kitaId, ...eligibleParents(now) },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const jobs = families.flatMap((family) =>
|
||||||
|
family.users.map((user) => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
kitaId,
|
||||||
|
familyId: family.id,
|
||||||
|
userId: user.id,
|
||||||
|
planId,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
kind: "PUBLICATION",
|
||||||
|
eventKey: `publication:${planId}`,
|
||||||
|
createdAt: now,
|
||||||
|
nextAttemptAt: now,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return jobs.length
|
||||||
|
? (await tx.notdienstEmail.createMany({ data: jobs, skipDuplicates: true }))
|
||||||
|
.count
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deliverNotdienstEmails(
|
||||||
|
ids: string[],
|
||||||
|
send = sendAppEmail,
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
const result = { sent: 0, failed: 0, skipped: 0 };
|
||||||
|
const deliver = async (id: string) => {
|
||||||
|
const claimToken = randomUUID();
|
||||||
|
const leaseUntil = new Date(now.getTime() + 5 * 60_000);
|
||||||
|
const claim = await prisma.notdienstEmail.updateMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
sentAt: null,
|
||||||
|
skippedAt: null,
|
||||||
|
nextAttemptAt: { lte: now },
|
||||||
|
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
|
||||||
|
},
|
||||||
|
data: { claimToken, leaseUntil, attempts: { increment: 1 } },
|
||||||
|
});
|
||||||
|
if (!claim.count) return;
|
||||||
|
const finish = (data: Prisma.NotdienstEmailUpdateManyMutationInput) =>
|
||||||
|
prisma.notdienstEmail.updateMany({
|
||||||
|
where: { id, claimToken },
|
||||||
|
data: { ...data, claimToken: null, leaseUntil: null },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const job = await prisma.notdienstEmail.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: { user: true, kita: true, plan: { select: { status: true } } },
|
||||||
|
});
|
||||||
|
const { user, kita } = job;
|
||||||
|
let eligible =
|
||||||
|
kita.notdienstModuleEnabled &&
|
||||||
|
user.kitaId === job.kitaId &&
|
||||||
|
user.familyId === job.familyId &&
|
||||||
|
parentRoles.some((role) => role === user.role) &&
|
||||||
|
!!user.emailVerifiedAt &&
|
||||||
|
!!user.privacyPolicyAcceptedAt &&
|
||||||
|
!user.disabledAt &&
|
||||||
|
(!user.lockedUntil || user.lockedUntil <= now);
|
||||||
|
let progress: { selected: number; required: number } | undefined;
|
||||||
|
let dates: string[] = [];
|
||||||
|
if (job.kind === "REMINDER") {
|
||||||
|
const target = getTargetMonthData(now);
|
||||||
|
const plan = await prisma.notdienstPlan.findUnique({
|
||||||
|
where: {
|
||||||
|
kitaId_year_month: {
|
||||||
|
kitaId: job.kitaId,
|
||||||
|
year: job.year,
|
||||||
|
month: job.month,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
eligible &&=
|
||||||
|
!target.isLocked &&
|
||||||
|
target.targetYear === job.year &&
|
||||||
|
target.targetMonth === job.month &&
|
||||||
|
plan?.status !== "PUBLISHED";
|
||||||
|
if (eligible) {
|
||||||
|
progress = await getFamilyNotdienstProgress(
|
||||||
|
prisma,
|
||||||
|
job.kitaId,
|
||||||
|
job.familyId,
|
||||||
|
job.year,
|
||||||
|
job.month,
|
||||||
|
kita.notdienstMinPerChildPerMonth,
|
||||||
|
);
|
||||||
|
eligible &&=
|
||||||
|
progress.required > 0 && progress.selected < progress.required;
|
||||||
|
}
|
||||||
|
} else if (job.kind === "PUBLICATION") {
|
||||||
|
eligible &&= !!job.planId && job.plan?.status === "PUBLISHED";
|
||||||
|
if (eligible) {
|
||||||
|
const [assignments, days] = await Promise.all([
|
||||||
|
prisma.notdienstAssignment.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId: job.kitaId,
|
||||||
|
planId: job.planId!,
|
||||||
|
child: {
|
||||||
|
kitaId: job.kitaId,
|
||||||
|
familyId: job.familyId,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
...period(job.year, job.month),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { date: true },
|
||||||
|
orderBy: { date: "asc" },
|
||||||
|
}),
|
||||||
|
loadNotdienstDays(prisma, job.kitaId, job.year, job.month),
|
||||||
|
]);
|
||||||
|
const valid = new Set(days.map(formatUtcDateKey));
|
||||||
|
dates = assignments
|
||||||
|
.map((a) => formatUtcDateKey(a.date))
|
||||||
|
.filter((key) => key >= berlinDateKey(now) && valid.has(key));
|
||||||
|
eligible &&= dates.length > 0;
|
||||||
|
}
|
||||||
|
} else eligible = false;
|
||||||
|
if (!eligible) {
|
||||||
|
await finish({ skippedAt: now, failed: false, lastError: null });
|
||||||
|
result.skipped++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const base =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL;
|
||||||
|
if (!base) throw new Error("App-Adresse für E-Mails fehlt.");
|
||||||
|
const site = new URL(base);
|
||||||
|
if (
|
||||||
|
!["http:", "https:"].includes(site.protocol) ||
|
||||||
|
site.username ||
|
||||||
|
site.password
|
||||||
|
)
|
||||||
|
throw new Error("App-Adresse ungültig.");
|
||||||
|
const label = monthLabel(job.year, job.month);
|
||||||
|
const link = new URL(
|
||||||
|
job.kind === "REMINDER"
|
||||||
|
? "/dashboard/notdienst"
|
||||||
|
: "/dashboard/kalender",
|
||||||
|
site.origin,
|
||||||
|
).toString();
|
||||||
|
const response = await send({
|
||||||
|
to: user.email,
|
||||||
|
idempotencyKey: `notdienst-email/${job.id}`,
|
||||||
|
subject:
|
||||||
|
job.kind === "REMINDER"
|
||||||
|
? `Erinnerung: Notdienst-Verfügbarkeiten für ${label}`
|
||||||
|
: `Notdienstplan ${label} veröffentlicht`,
|
||||||
|
react:
|
||||||
|
job.kind === "REMINDER"
|
||||||
|
? createElement(NotdienstAvailabilityReminderEmail, {
|
||||||
|
parentName: user.firstName,
|
||||||
|
kitaName: kita.name,
|
||||||
|
monthLabel: label,
|
||||||
|
link,
|
||||||
|
selectedDays: progress!.selected,
|
||||||
|
requiredDays: progress!.required,
|
||||||
|
deadlineLabel: new Intl.DateTimeFormat("de-DE", {
|
||||||
|
timeZone: "UTC",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(
|
||||||
|
new Date(
|
||||||
|
Date.UTC(job.year, job.month - 2, NOTDIENST_LOCK_DAY),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: createElement(NotdienstPlanPublishedEmail, {
|
||||||
|
parentName: user.firstName,
|
||||||
|
kitaName: kita.name,
|
||||||
|
monthLabel: label,
|
||||||
|
dateLabels: dates.map((key) =>
|
||||||
|
new Intl.DateTimeFormat("de-DE", {
|
||||||
|
timeZone: "UTC",
|
||||||
|
weekday: "long",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(`${key}T00:00:00Z`)),
|
||||||
|
),
|
||||||
|
link,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.success) throw new Error(response.error);
|
||||||
|
await finish({ sentAt: now, failed: false, lastError: null });
|
||||||
|
result.sent++;
|
||||||
|
} catch {
|
||||||
|
const current = await prisma.notdienstEmail.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { attempts: true },
|
||||||
|
});
|
||||||
|
const delay = Math.min(
|
||||||
|
6 * 60 * 60_000,
|
||||||
|
60_000 * 2 ** Math.min(current?.attempts ?? 1, 8),
|
||||||
|
);
|
||||||
|
await finish({
|
||||||
|
failed: true,
|
||||||
|
lastError:
|
||||||
|
"E-Mail-Versand fehlgeschlagen. Der Versand wird erneut versucht.",
|
||||||
|
nextAttemptAt: new Date(now.getTime() + delay),
|
||||||
|
});
|
||||||
|
result.failed++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const unique = [...new Set(ids)];
|
||||||
|
for (let i = 0; i < unique.length; i += 4)
|
||||||
|
await Promise.all(unique.slice(i, i + 4).map(deliver));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MailScope = {
|
||||||
|
kitaId?: string;
|
||||||
|
year?: number;
|
||||||
|
month?: number;
|
||||||
|
planId?: string;
|
||||||
|
};
|
||||||
|
export async function deliverDueNotdienstEmails(
|
||||||
|
scope: MailScope = {},
|
||||||
|
send = sendAppEmail,
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
const jobs = await prisma.notdienstEmail.findMany({
|
||||||
|
where: {
|
||||||
|
...scope,
|
||||||
|
sentAt: null,
|
||||||
|
skippedAt: null,
|
||||||
|
nextAttemptAt: { lte: now },
|
||||||
|
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: [{ nextAttemptAt: "asc" }, { createdAt: "asc" }],
|
||||||
|
take: 12,
|
||||||
|
});
|
||||||
|
return deliverNotdienstEmails(
|
||||||
|
jobs.map((job) => job.id),
|
||||||
|
send,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runNotdienstNotificationJob(
|
||||||
|
send = sendAppEmail,
|
||||||
|
now = new Date(),
|
||||||
|
kitaId?: string,
|
||||||
|
) {
|
||||||
|
const target = getTargetMonthData(now);
|
||||||
|
let queued = 0;
|
||||||
|
if (!target.isLocked && isNotdienstReminderDue(31, now)) {
|
||||||
|
const candidates = await prisma.kita.findMany({
|
||||||
|
where: {
|
||||||
|
...(kitaId ? { id: kitaId } : {}),
|
||||||
|
notdienstModuleEnabled: true,
|
||||||
|
notdienstPlans: { none: { year: target.targetYear, month: target.targetMonth, status: "PUBLISHED" } },
|
||||||
|
notdienstReminderDaysBefore: {
|
||||||
|
gte: Math.max(
|
||||||
|
1,
|
||||||
|
NOTDIENST_LOCK_DAY - Number(berlinDateKey(now).slice(-2)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
notdienstReminderRuns: {
|
||||||
|
none: { year: target.targetYear, month: target.targetMonth },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, notdienstReminderDaysBefore: true },
|
||||||
|
orderBy: { id: "asc" },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
for (const kita of candidates)
|
||||||
|
if (isNotdienstReminderDue(kita.notdienstReminderDaysBefore, now))
|
||||||
|
queued += await queueNotdienstReminders(
|
||||||
|
kita.id,
|
||||||
|
target.targetYear,
|
||||||
|
target.targetMonth,
|
||||||
|
true,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
queued,
|
||||||
|
...(await deliverDueNotdienstEmails(kitaId ? { kitaId } : {}, send, now)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotdienstEmailStatus(
|
||||||
|
kitaId: string,
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
) {
|
||||||
|
const jobs = await prisma.notdienstEmail.findMany({
|
||||||
|
where: { kitaId, year, month },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
kind: true,
|
||||||
|
sentAt: true,
|
||||||
|
skippedAt: true,
|
||||||
|
failed: true,
|
||||||
|
lastError: true,
|
||||||
|
user: { select: { firstName: true, lastName: true } },
|
||||||
|
family: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
const status = (job: (typeof jobs)[number]) =>
|
||||||
|
job.sentAt
|
||||||
|
? ("sent" as const)
|
||||||
|
: job.skippedAt
|
||||||
|
? ("skipped" as const)
|
||||||
|
: job.failed
|
||||||
|
? ("failed" as const)
|
||||||
|
: ("pending" as const);
|
||||||
|
return {
|
||||||
|
sent: jobs.filter((job) => status(job) === "sent").length,
|
||||||
|
failed: jobs.filter((job) => status(job) === "failed").length,
|
||||||
|
pending: jobs.filter((job) => status(job) === "pending").length,
|
||||||
|
skipped: jobs.filter((job) => status(job) === "skipped").length,
|
||||||
|
entries: jobs.slice(0, 30).map((job) => ({
|
||||||
|
id: job.id,
|
||||||
|
kind: job.kind,
|
||||||
|
status: status(job),
|
||||||
|
name: `${job.user.firstName} ${job.user.lastName}`,
|
||||||
|
familyName: job.family.name,
|
||||||
|
error: job.lastError,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type NotdienstEmailStatus = Awaited<
|
||||||
|
ReturnType<typeof getNotdienstEmailStatus>
|
||||||
|
>;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { UserFacingError } from "./action-error";
|
||||||
|
import { getNotdienstDays } from "./notdienst-days";
|
||||||
|
import { todayKey } from "./staff/calculations";
|
||||||
|
|
||||||
|
export async function loadNotdienstDays(
|
||||||
|
db: Pick<Prisma.TransactionClient, "termin">,
|
||||||
|
kitaId: string,
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
) {
|
||||||
|
const closures = await db.termin.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
type: { in: ["SCHLIESSTAG", "TEAMTAG"] },
|
||||||
|
// Include closures crossing the month boundary and Berlin midnight.
|
||||||
|
startDate: { lt: new Date(Date.UTC(year, month, 1)) },
|
||||||
|
endDate: { gte: new Date(Date.UTC(year, month - 1, 1) - 86400000) },
|
||||||
|
},
|
||||||
|
select: { startDate: true, endDate: true },
|
||||||
|
});
|
||||||
|
return getNotdienstDays(year, month, closures);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertChildrenRemovable(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
kitaId: string,
|
||||||
|
familyId: string,
|
||||||
|
childIds: string[],
|
||||||
|
) {
|
||||||
|
if (childIds.length === 0) return;
|
||||||
|
// Coordinate with the FK locks taken by concurrent assignment writes.
|
||||||
|
await tx.$queryRaw`SELECT id FROM children WHERE "kitaId" = ${kitaId}
|
||||||
|
AND "familyId" = ${familyId} AND id IN (${Prisma.join(childIds)}) ORDER BY id FOR UPDATE`;
|
||||||
|
const obligation = await tx.notdienstAssignment.findFirst({
|
||||||
|
where: {
|
||||||
|
kitaId,
|
||||||
|
childId: { in: childIds },
|
||||||
|
child: { kitaId, familyId },
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
date: { gte: new Date(`${todayKey()}T00:00:00Z`) },
|
||||||
|
plan: { kitaId, status: "PUBLISHED" },
|
||||||
|
},
|
||||||
|
{ alarms: { some: { kitaId, activeAssignmentId: { not: null } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (obligation) {
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Das Kind kann nicht entfernt werden, solange veröffentlichte Notdienste oder aktive Alarme bestehen. Bitte zuerst die Einteilung mit der Koordination klären.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { UserRole } from "@prisma/client";
|
||||||
|
|
||||||
|
export function canRequestNotdienstSwap(
|
||||||
|
role: UserRole,
|
||||||
|
familyId: string | null | undefined,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
!!familyId &&
|
||||||
|
(role === "ELTERN" || role === "ADMIN" || role === "KOORDINATOR")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports -- Test real actions with isolated request and mail boundaries. */
|
||||||
|
const { test, before, beforeEach, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const ts = require('typescript');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { randomUUID } = require('node:crypto');
|
||||||
|
let prisma, current, fixture, edit, swap, accept;
|
||||||
|
const tenants = [], emails = [];
|
||||||
|
function loader(overrides = {}) {
|
||||||
|
const cache = new Map();
|
||||||
|
function load(file) {
|
||||||
|
file = path.resolve(file);
|
||||||
|
if (cache.has(file)) return cache.get(file).exports;
|
||||||
|
const mod = { exports: {} }; cache.set(file, mod);
|
||||||
|
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
|
||||||
|
fileName: file,
|
||||||
|
}).outputText;
|
||||||
|
const req = spec => {
|
||||||
|
if (Object.hasOwn(overrides, spec)) return overrides[spec];
|
||||||
|
if (spec === 'server-only') return {};
|
||||||
|
if (spec === '@/lib/prisma') return { prisma };
|
||||||
|
if (spec === 'next/headers') return { headers: async () => new Headers() };
|
||||||
|
if (spec === 'next/cache') return { revalidatePath() {} };
|
||||||
|
if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } };
|
||||||
|
if (spec.startsWith('@/') || spec.startsWith('.')) {
|
||||||
|
const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec);
|
||||||
|
for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix);
|
||||||
|
}
|
||||||
|
return require(spec);
|
||||||
|
};
|
||||||
|
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports);
|
||||||
|
return mod.exports;
|
||||||
|
}
|
||||||
|
return load;
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const raw = process.env.TEST_DATABASE_URL;
|
||||||
|
if (!raw) throw new Error('TEST_DATABASE_URL muss ausdrücklich auf die lokale kita_quickwins_test zeigen.');
|
||||||
|
const url = new URL(raw);
|
||||||
|
assert.ok(['127.0.0.1','localhost'].includes(url.hostname));
|
||||||
|
assert.equal(url.pathname,'/kita_quickwins_test');
|
||||||
|
prisma = new PrismaClient({ datasourceUrl: raw });
|
||||||
|
const load = loader({
|
||||||
|
'@/lib/auth-utils': {
|
||||||
|
requireKitaSession: async () => ({ user: current }),
|
||||||
|
requireRole: async roles => { assert.ok(roles.includes(current.role)); return { user: current }; },
|
||||||
|
},
|
||||||
|
'@/lib/mail': { sendAppEmail: async input => { emails.push(input); return { success: true }; } },
|
||||||
|
});
|
||||||
|
({ updateMitbringsel: edit, createNotdienstSwapRequest: swap, acceptNotdienstSwapRequest: accept } = load('src/app/dashboard/kalender/actions.ts'));
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
if (!prisma) return;
|
||||||
|
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
emails.length = 0;
|
||||||
|
const kita = await prisma.kita.create({ data: { name: 'Kalender-Test', slug: `calendar-test-${randomUUID()}` } }); tenants.push(kita.id);
|
||||||
|
const family = await prisma.family.create({ data: { kitaId: kita.id, name: 'Eigener Haushalt' } });
|
||||||
|
const targetFamily = await prisma.family.create({ data: { kitaId: kita.id, name: 'Angefragter Haushalt' } });
|
||||||
|
const makeUser = (role, familyId) => prisma.user.create({ data: { kitaId: kita.id, familyId, role, email: `${randomUUID()}@example.invalid`, firstName: 'Test', lastName: role, passwordHash: 'test', emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
|
||||||
|
const owner = await makeUser('ELTERN',family.id);
|
||||||
|
const targetAdmin = await makeUser('ADMIN',targetFamily.id);
|
||||||
|
const targetCoord = await makeUser('KOORDINATOR',targetFamily.id);
|
||||||
|
const event = await prisma.termin.create({ data: { kitaId: kita.id, title: 'Sommerfest', type: 'KITA_FEST', status: 'CONFIRMED', mitbringselListEnabled: true, startDate: new Date('2030-11-04T10:00:00Z'), endDate: new Date('2030-11-04T16:00:00Z') } });
|
||||||
|
const item = await prisma.mitbringselItem.create({ data: { kitaId: kita.id, terminId: event.id, userId: owner.id, content: 'Nudelsalat' } });
|
||||||
|
const child = await prisma.child.create({ data: { kitaId: kita.id, familyId: family.id, firstName: 'Eigenes Kind', lastName: 'Test' } });
|
||||||
|
const targetChild = await prisma.child.create({ data: { kitaId: kita.id, familyId: targetFamily.id, firstName: 'Anderes Kind', lastName: 'Test' } });
|
||||||
|
const plan = await prisma.notdienstPlan.create({ data: { kitaId: kita.id, year: 2030, month: 11, status: 'PUBLISHED' } });
|
||||||
|
const offered = await prisma.notdienstAssignment.create({ data: { kitaId: kita.id, planId: plan.id, childId: child.id, date: new Date('2030-11-04T00:00:00Z') } });
|
||||||
|
const target = await prisma.notdienstAssignment.create({ data: { kitaId: kita.id, planId: plan.id, childId: targetChild.id, date: new Date('2030-11-05T00:00:00Z') } });
|
||||||
|
fixture = { kita, family, targetFamily, owner, targetAdmin, targetCoord, event, item, child, targetChild, plan, offered, target };
|
||||||
|
current = owner;
|
||||||
|
});
|
||||||
|
const edited = content => edit({ itemId: fixture.item.id, content });
|
||||||
|
const swapPayload = () => ({ targetAssignmentId: fixture.target.id, offeredAssignmentId: fixture.offered.id, message: 'Kannst du tauschen?' });
|
||||||
|
const storedItem = () => prisma.mitbringselItem.findUniqueOrThrow({ where: { id: fixture.item.id } });
|
||||||
|
async function setRole(role) { current = await prisma.user.update({ where: { id: fixture.owner.id }, data: { role } }); }
|
||||||
|
|
||||||
|
test('Eigener Beitrag wird direkt bearbeitet und behält Autor, Termin und ID', async () => {
|
||||||
|
assert.equal((await edited(' Kartoffelsalat ')).success,true);
|
||||||
|
const item = await storedItem();
|
||||||
|
assert.equal(item.content,'Kartoffelsalat');
|
||||||
|
assert.equal(item.userId,fixture.owner.id);
|
||||||
|
assert.equal(item.terminId,fixture.event.id);
|
||||||
|
assert.equal(await prisma.mitbringselItem.count({ where: { terminId: fixture.event.id } }),1);
|
||||||
|
});
|
||||||
|
for (const [label,content] of [['leer',''],['Leerzeichen',' '],['zu lang','a'.repeat(501)]]) {
|
||||||
|
test(`Ungültiger Beitrag (${label}) erhält den alten Inhalt`, async () => {
|
||||||
|
assert.ok((await edited(content)).error);
|
||||||
|
assert.equal((await storedItem()).content,'Nudelsalat');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('500 Zeichen werden vollständig gespeichert', async () => {
|
||||||
|
assert.equal((await edited('a'.repeat(500))).success,true);
|
||||||
|
assert.equal((await storedItem()).content.length,500);
|
||||||
|
});
|
||||||
|
for (const role of ['ELTERN','ADMIN','KOORDINATOR']) {
|
||||||
|
test(`${role} kann fremde Mitbringbeiträge nicht bearbeiten`, async () => {
|
||||||
|
current = { ...fixture.targetAdmin, role };
|
||||||
|
assert.match((await edited('Fremde Änderung')).error,/nur eigene/);
|
||||||
|
assert.equal((await storedItem()).content,'Nudelsalat');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const role of ['ADMIN','KOORDINATOR']) {
|
||||||
|
test(`${role} kann einen eigenen Mitbringbeitrag bearbeiten`, async () => {
|
||||||
|
await setRole(role);
|
||||||
|
assert.equal((await edited('Gemüsesalat')).success,true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('Mitbringbeiträge einer anderen Kita sind nicht bearbeitbar', async () => {
|
||||||
|
const kita = await prisma.kita.create({ data: { name: 'Andere Kita', slug: randomUUID() } }); tenants.push(kita.id);
|
||||||
|
current = { ...fixture.owner, kitaId: kita.id };
|
||||||
|
assert.ok((await edited('Tenant-Wechsel')).error);
|
||||||
|
assert.equal((await storedItem()).content,'Nudelsalat');
|
||||||
|
});
|
||||||
|
for (const status of ['PENDING','REJECTED']) {
|
||||||
|
test(`Ein ${status}-Termin erlaubt keine Bearbeitung`, async () => {
|
||||||
|
await prisma.termin.update({ where: { id: fixture.event.id }, data: { status } });
|
||||||
|
assert.match((await edited('Brot')).error,/nicht mehr aktiv/);
|
||||||
|
assert.equal((await storedItem()).content,'Nudelsalat');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('Deaktivierte Mitbringliste schützt vorhandene Beiträge', async () => {
|
||||||
|
await prisma.termin.update({ where: { id: fixture.event.id }, data: { mitbringselListEnabled: false } });
|
||||||
|
assert.ok((await edited('Brot')).error);
|
||||||
|
assert.equal((await storedItem()).content,'Nudelsalat');
|
||||||
|
});
|
||||||
|
test('Gelöschter Beitrag wird nicht neu angelegt', async () => {
|
||||||
|
await prisma.mitbringselItem.delete({ where: { id: fixture.item.id } });
|
||||||
|
assert.ok((await edited('Brot')).error);
|
||||||
|
assert.equal(await prisma.mitbringselItem.count({ where: { id: fixture.item.id } }),0);
|
||||||
|
});
|
||||||
|
for (const role of ['ELTERN','ADMIN','KOORDINATOR']) {
|
||||||
|
test(`${role} fragt mit eigenem Haushalt einen Diensttausch an und benachrichtigt Eltern mit Verwaltungsrolle`, async () => {
|
||||||
|
await setRole(role);
|
||||||
|
assert.equal((await swap(swapPayload())).success,true);
|
||||||
|
const request = await prisma.notdienstSwapRequest.findFirstOrThrow({ where: { kitaId: fixture.kita.id } });
|
||||||
|
assert.equal(request.requesterUserId,current.id);
|
||||||
|
assert.equal(request.requesterFamilyId,fixture.family.id);
|
||||||
|
assert.deepEqual(emails.map(e => e.to).sort(),[fixture.targetAdmin.email,fixture.targetCoord.email].sort());
|
||||||
|
current = fixture.targetCoord;
|
||||||
|
assert.equal((await accept(request.id)).success,true);
|
||||||
|
assert.equal((await prisma.notdienstAssignment.findUniqueOrThrow({ where: { id: fixture.target.id } })).childId,fixture.child.id);
|
||||||
|
assert.equal((await prisma.notdienstAssignment.findUniqueOrThrow({ where: { id: fixture.offered.id } })).childId,fixture.targetChild.id);
|
||||||
|
assert.ok(emails.some(e => e.to === fixture.owner.email && e.subject === 'Notdienst-Tausch bestätigt'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const role of ['ADMIN','KOORDINATOR']) {
|
||||||
|
test(`${role} ohne eigenen Haushalt erhält kein Tauschrecht`, async () => {
|
||||||
|
await setRole(role); current = { ...current, familyId: null };
|
||||||
|
assert.ok((await swap(swapPayload())).error);
|
||||||
|
assert.equal(await prisma.notdienstSwapRequest.count({ where: { kitaId: fixture.kita.id } }),0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('Erzieherrolle erhält auch mit einem Haushalt kein Tauschrecht', async () => {
|
||||||
|
await setRole('ERZIEHER');
|
||||||
|
assert.ok((await swap(swapPayload())).error);
|
||||||
|
});
|
||||||
|
test('Admin kann keine fremde Einteilung als eigenes Angebot verwenden', async () => {
|
||||||
|
await setRole('ADMIN');
|
||||||
|
assert.match((await swap({ targetAssignmentId: fixture.offered.id, offeredAssignmentId: fixture.target.id })).error,/nur eigene/);
|
||||||
|
assert.equal(await prisma.notdienstSwapRequest.count({ where: { kitaId: fixture.kita.id } }),0);
|
||||||
|
});
|
||||||
|
test('Koordination kann keinen Tausch innerhalb des eigenen Haushalts starten', async () => {
|
||||||
|
await setRole('KOORDINATOR');
|
||||||
|
await prisma.notdienstAssignment.update({ where: { id: fixture.target.id }, data: { childId: fixture.child.id } });
|
||||||
|
assert.match((await swap(swapPayload())).error,/eigenen Haushalt/);
|
||||||
|
});
|
||||||
|
test('Aktiver Notdienstalarm blockiert auch einen Admin-Tausch', async () => {
|
||||||
|
await setRole('ADMIN');
|
||||||
|
await prisma.notdienstAlarm.create({ data: { kitaId: fixture.kita.id, assignmentId: fixture.offered.id, activeAssignmentId: fixture.offered.id, familyId: fixture.family.id, familyName: fixture.family.name, childName: 'Eigenes Kind', date: fixture.offered.date, expiresAt: new Date('2030-11-04T18:00:00Z'), deadline: new Date('2030-11-04T10:00:00Z') } });
|
||||||
|
assert.match((await swap(swapPayload())).error,/aktiver Alarmierung/);
|
||||||
|
});
|
||||||
|
test('Unverifizierte und deaktivierte Konten bleiben vom Tausch-Mailversand ausgeschlossen', async () => {
|
||||||
|
await prisma.user.update({ where: { id: fixture.targetAdmin.id }, data: { emailVerifiedAt: null } });
|
||||||
|
await prisma.user.update({ where: { id: fixture.targetCoord.id }, data: { disabledAt: new Date() } });
|
||||||
|
await setRole('ADMIN');
|
||||||
|
assert.equal((await swap(swapPayload())).success,true);
|
||||||
|
assert.equal(emails.length,0);
|
||||||
|
});
|
||||||
|
test('Doppelte offene Anfrage bleibt auch für Koordination gesperrt', async () => {
|
||||||
|
await setRole('KOORDINATOR');
|
||||||
|
assert.equal((await swap(swapPayload())).success,true);
|
||||||
|
assert.match((await swap(swapPayload())).error,/offene Tauschanfrage/);
|
||||||
|
assert.equal(await prisma.notdienstSwapRequest.count({ where: { kitaId: fixture.kita.id } }),1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports -- Execute the installed SDK and real TypeScript mail adapter. */
|
||||||
|
const { test, before, after }=require('node:test');const assert=require('node:assert/strict');
|
||||||
|
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),ts=require('typescript');
|
||||||
|
let prisma; // The mail adapter never accesses a database.
|
||||||
|
function loader(overrides = {}) {
|
||||||
|
const cache = new Map();
|
||||||
|
function load(file) {
|
||||||
|
file = path.resolve(file);
|
||||||
|
if (cache.has(file)) return cache.get(file).exports;
|
||||||
|
const mod = { exports: {} }; cache.set(file, mod);
|
||||||
|
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
|
||||||
|
fileName: file,
|
||||||
|
}).outputText;
|
||||||
|
const req = spec => {
|
||||||
|
if (Object.hasOwn(overrides, spec)) return overrides[spec];
|
||||||
|
if (spec === 'server-only') return {};
|
||||||
|
if (spec === '@/lib/prisma') return { prisma };
|
||||||
|
if (spec === 'next/headers') return { headers: async () => new Headers() };
|
||||||
|
if (spec === 'next/cache') return { revalidatePath() {} };
|
||||||
|
if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } };
|
||||||
|
if (spec.startsWith('@/') || spec.startsWith('.')) {
|
||||||
|
const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec);
|
||||||
|
for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix);
|
||||||
|
}
|
||||||
|
return require(spec);
|
||||||
|
};
|
||||||
|
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports);
|
||||||
|
return mod.exports;
|
||||||
|
}
|
||||||
|
return load;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalFetch=global.fetch;const saved=Object.fromEntries(['RESEND_API_KEY','EMAIL_FROM','NODE_ENV'].map(k=>[k,process.env[k]]));let send;
|
||||||
|
before(()=>{process.env.RESEND_API_KEY='re_test_not_real';process.env.EMAIL_FROM='Test <test@example.invalid>';process.env.NODE_ENV='test';send=loader()('src/lib/mail.ts').sendAppEmail;});
|
||||||
|
after(()=>{global.fetch=originalFetch;for(const [k,v] of Object.entries(saved)){if(v===undefined)delete process.env[k];else process.env[k]=v;}});
|
||||||
|
const input={to:'recipient@example.invalid',subject:'Test',react:'Test',idempotencyKey:'stable-job'};
|
||||||
|
test('SDK receives the stable key and an AbortSignal on the actual fetch call',async()=>{
|
||||||
|
let captured;global.fetch=async(_url,options)=>{captured=options;return new Response(JSON.stringify({id:'test-id'}),{status:200});};
|
||||||
|
assert.equal((await send(input)).success,true);assert.ok(captured.signal instanceof AbortSignal);assert.match(new Headers(captured.headers).get('Idempotency-Key'),/^stable-job\/[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
test('Transport timeout aborts the pending fetch rather than merely abandoning its promise',async()=>{
|
||||||
|
let aborted=false;global.fetch=async(_url,options)=>new Promise((_resolve,reject)=>options.signal.addEventListener('abort',()=>{aborted=true;reject(options.signal.reason);},{once:true}));
|
||||||
|
const keepAlive=setTimeout(()=>{},1000);try{assert.equal((await send({...input,timeoutMs:20})).success,false);assert.equal(aborted,true);}finally{clearTimeout(keepAlive);}
|
||||||
|
});
|
||||||
|
test('Array recipients have distinct stable keys and duplicate addresses are removed',async()=>{
|
||||||
|
const keys=[];global.fetch=async(_url,options)=>{keys.push(new Headers(options.headers).get('Idempotency-Key'));return new Response(JSON.stringify({id:'test'}),{status:200});};
|
||||||
|
const message={...input,to:['one@example.invalid','two@example.invalid','one@example.invalid']};await send(message);await send(message);
|
||||||
|
assert.equal(keys.length,4);assert.notEqual(keys[0],keys[1]);assert.equal(keys[0],keys[2]);assert.equal(keys[1],keys[3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Corrected message payload receives a new version without changing stable retries',async()=>{
|
||||||
|
const keys=[];global.fetch=async(_url,options)=>{keys.push(new Headers(options.headers).get('Idempotency-Key'));return new Response(JSON.stringify({id:'test'}),{status:200});};
|
||||||
|
await send(input);await send(input);await send({...input,react:'Corrected dates'});assert.equal(keys[0],keys[1]);assert.notEqual(keys[1],keys[2]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { getNotdienstDays } from "../src/lib/notdienst-days";
|
||||||
|
|
||||||
|
const keys = (year: number, month: number, start: string, end: string) =>
|
||||||
|
getNotdienstDays(year, month, [
|
||||||
|
{ startDate: new Date(start), endDate: new Date(end) },
|
||||||
|
]).map((d) => d.toISOString().slice(0, 10));
|
||||||
|
|
||||||
|
test("Schließtage schließen inklusive beider Randtage aus", () => {
|
||||||
|
const days = keys(2030, 11, "2030-11-04T10:00:00Z", "2030-11-06T12:00:00Z");
|
||||||
|
for (const key of ["2030-11-04", "2030-11-05", "2030-11-06"])
|
||||||
|
assert.ok(!days.includes(key));
|
||||||
|
assert.ok(days.includes("2030-11-07"));
|
||||||
|
assert.ok(days.every((key) => ![0, 6].includes(new Date(key).getUTCDay())));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Berliner Mitternacht am Monatsanfang zählt zum richtigen Monat", () => {
|
||||||
|
const days = keys(2030, 11, "2030-10-31T23:00:00Z", "2030-11-01T22:59:00Z");
|
||||||
|
assert.ok(!days.includes("2030-11-01"));
|
||||||
|
assert.ok(days.includes("2030-11-04"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Sommerzeit und Schließzeiten über Monatsgrenzen", () => {
|
||||||
|
const days = keys(2030, 7, "2030-06-28T22:00:00Z", "2030-07-02T21:59:00Z");
|
||||||
|
assert.ok(!days.includes("2030-07-01"));
|
||||||
|
assert.ok(!days.includes("2030-07-02"));
|
||||||
|
assert.ok(days.includes("2030-07-03"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Überlappende Schließtermine erzeugen keine doppelten Tage", () => {
|
||||||
|
const closures = [
|
||||||
|
{ startDate: new Date("2030-11-04"), endDate: new Date("2030-11-05") },
|
||||||
|
{ startDate: new Date("2030-11-05"), endDate: new Date("2030-11-06") },
|
||||||
|
];
|
||||||
|
const days = getNotdienstDays(2030, 11, closures);
|
||||||
|
assert.equal(new Set(days.map(Number)).size, days.length);
|
||||||
|
assert.equal(days.length, getNotdienstDays(2030, 11, []).length - 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("Berliner Monatswechsel bestimmt Zielmonat und Sperrfrist", async () => {
|
||||||
|
const { getTargetMonthData } = await import("../src/lib/date-utils");
|
||||||
|
assert.equal(getTargetMonthData(new Date("2030-10-26T22:00:00Z")).isLocked, true);
|
||||||
|
const data = getTargetMonthData(new Date("2030-12-31T23:00:00Z"));
|
||||||
|
assert.equal(data.targetYear, 2031); assert.equal(data.targetMonth, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Automatische Erinnerung beginnt um 9 Uhr vor der Eintragsfrist", async () => {
|
||||||
|
const { isNotdienstReminderDue } = await import("../src/lib/date-utils");
|
||||||
|
assert.equal(isNotdienstReminderDue(7, new Date("2030-10-18T07:00:00Z")), false);
|
||||||
|
assert.equal(isNotdienstReminderDue(7, new Date("2030-10-19T06:59:00Z")), false);
|
||||||
|
assert.equal(isNotdienstReminderDue(7, new Date("2030-10-19T07:00:00Z")), true);
|
||||||
|
assert.equal(isNotdienstReminderDue(0, new Date("2030-10-19T07:00:00Z")), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports -- Load real server actions with isolated request and mail transport boundaries. */
|
||||||
|
const { test, before, beforeEach, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm');
|
||||||
|
const ts = require('typescript'); const { randomUUID } = require('node:crypto');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { render } = require('@react-email/render');
|
||||||
|
let prisma, current, f, service, publish, retry;
|
||||||
|
let failMail = false; const emails = [], tenants = [];
|
||||||
|
function loader(overrides = {}) {
|
||||||
|
const cache = new Map();
|
||||||
|
function load(file) {
|
||||||
|
file = path.resolve(file);
|
||||||
|
if (cache.has(file)) return cache.get(file).exports;
|
||||||
|
const mod = { exports: {} }; cache.set(file, mod);
|
||||||
|
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
|
||||||
|
fileName: file,
|
||||||
|
}).outputText;
|
||||||
|
const req = spec => {
|
||||||
|
if (Object.hasOwn(overrides, spec)) return overrides[spec];
|
||||||
|
if (spec === 'server-only') return {};
|
||||||
|
if (spec === '@/lib/prisma') return { prisma };
|
||||||
|
if (spec === 'next/headers') return { headers: async () => new Headers() };
|
||||||
|
if (spec === 'next/cache') return { revalidatePath() {} };
|
||||||
|
if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } };
|
||||||
|
if (spec.startsWith('@/') || spec.startsWith('.')) {
|
||||||
|
const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec);
|
||||||
|
for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix);
|
||||||
|
}
|
||||||
|
return require(spec);
|
||||||
|
};
|
||||||
|
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports);
|
||||||
|
return mod.exports;
|
||||||
|
}
|
||||||
|
return load;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date('2030-10-19T07:00:00Z');
|
||||||
|
const sender = async input => { emails.push(input); return failMail ? { success: false, error: 'Test mail failure' } : { success: true, id: 'simulated' }; };
|
||||||
|
before(async () => {
|
||||||
|
const raw=process.env.TEST_DATABASE_URL;
|
||||||
|
if (!raw) throw new Error('TEST_DATABASE_URL muss auf die separate lokale kita_notifications_test zeigen.');
|
||||||
|
const url=new URL(raw); assert.ok(['127.0.0.1','localhost'].includes(url.hostname)); assert.equal(url.pathname,'/kita_notifications_test');
|
||||||
|
process.env.AUTH_URL='http://127.0.0.1:3014'; prisma=new PrismaClient({datasourceUrl:raw});
|
||||||
|
const load=loader({ '@/lib/auth-utils': { requireKitaSession:async()=>({user:current}), requireRole:async roles=>{assert.ok(roles.includes(current.role));return {user:current};} }, '@/lib/mail':{sendAppEmail:sender} });
|
||||||
|
service=load('src/lib/notdienst-notifications.ts');
|
||||||
|
({publishPlanAction:publish,retryNotdienstEmailsAction:retry}=load('src/app/dashboard/notdienst/plan/actions.ts'));
|
||||||
|
});
|
||||||
|
after(async()=>{if(prisma){await prisma.kita.deleteMany({where:{id:{in:tenants}}});await prisma.$disconnect();}});
|
||||||
|
beforeEach(async()=>{
|
||||||
|
failMail=false;emails.length=0;
|
||||||
|
const kita=await prisma.kita.create({data:{name:'Benachrichtigungen Test',slug:randomUUID(),notdienstMinPerChildPerMonth:3,notdienstReminderDaysBefore:7}});tenants.push(kita.id);
|
||||||
|
const family=await prisma.family.create({data:{kitaId:kita.id,name:'Testhaushalt'}});
|
||||||
|
const parent=await prisma.user.create({data:{kitaId:kita.id,familyId:family.id,role:'ADMIN',email:`${randomUUID()}@example.invalid`,firstName:'Test',lastName:'Admin',passwordHash:'test',emailVerifiedAt:new Date(),privacyPolicyAcceptedAt:new Date()}});
|
||||||
|
const children=await Promise.all(['A','B'].map(firstName=>prisma.child.create({data:{kitaId:kita.id,familyId:family.id,firstName,lastName:'Test'}})));
|
||||||
|
f={kita,family,parent,children};current=parent;
|
||||||
|
});
|
||||||
|
const jobs=()=>prisma.notdienstEmail.findMany({where:{kitaId:f.kita.id},orderBy:{createdAt:'asc'}});
|
||||||
|
const manual=(time=now)=>service.queueNotdienstReminders(f.kita.id,2030,11,false,time);
|
||||||
|
const auto=(time=now)=>service.runNotdienstNotificationJob(sender,time,f.kita.id);
|
||||||
|
async function availability(keys,child=f.children[0]){await prisma.notdienstAvailability.createMany({data:keys.map(key=>({kitaId:f.kita.id,childId:child.id,userId:f.parent.id,date:new Date(key)})),skipDuplicates:true});}
|
||||||
|
async function draft(){return prisma.notdienstPlan.create({data:{kitaId:f.kita.id,year:2030,month:11,assignments:{create:[{kitaId:f.kita.id,childId:f.children[0].id,date:new Date('2030-11-04')},{kitaId:f.kita.id,childId:f.children[1].id,date:new Date('2030-11-05')}]}}});}
|
||||||
|
async function partner(role='KOORDINATOR'){return prisma.user.create({data:{kitaId:f.kita.id,familyId:f.family.id,role,email:`${randomUUID()}@example.invalid`,firstName:'Partner',lastName:role,passwordHash:'test',emailVerifiedAt:new Date(),privacyPolicyAcceptedAt:new Date()}});}
|
||||||
|
|
||||||
|
test('Automatik erinnert den fehlenden Haushalt genau einmal im Monat',async()=>{
|
||||||
|
const first=await auto();assert.equal(first.queued,1);assert.equal(first.sent,1);
|
||||||
|
assert.equal((await auto()).queued,0);assert.equal((await auto(new Date('2030-10-20T07:00:00Z'))).queued,0);
|
||||||
|
assert.equal(emails.length,1);assert.equal(await prisma.notdienstReminderRun.count({where:{kitaId:f.kita.id}}),1);
|
||||||
|
});
|
||||||
|
test('Zu wenige gültige Tage werden als unvollständig erkannt; Mail enthält Soll und Frist',async()=>{
|
||||||
|
await availability(['2030-11-04','2030-11-05']);assert.equal((await auto()).sent,1);
|
||||||
|
const html=(await render(emails[0].react)).replace(/<!--.*?-->/g,'');assert.ok(html.includes('2 von 6'));assert.ok(html.includes('26.10.2030'));
|
||||||
|
});
|
||||||
|
test('Vollständige sechs Tage werden nicht erinnert',async()=>{
|
||||||
|
await availability(['2030-11-04','2030-11-05','2030-11-06','2030-11-07','2030-11-08','2030-11-11']);
|
||||||
|
assert.equal((await auto()).queued,0);assert.equal(emails.length,0);
|
||||||
|
});
|
||||||
|
test('Geschwister-Doppelbuchungen und alte Wochenenddaten reduzieren das Soll nicht',async()=>{
|
||||||
|
await availability(['2030-11-04','2030-11-05','2030-11-02']);await availability(['2030-11-04','2030-11-05','2030-11-03'],f.children[1]);
|
||||||
|
const progress=await service.getFamilyNotdienstProgress(prisma,f.kita.id,f.family.id,2030,11,3);assert.deepEqual(progress,{selected:2,required:6});
|
||||||
|
assert.equal((await auto()).sent,1);
|
||||||
|
});
|
||||||
|
test('Vor 9 Uhr Berliner Zeit und vor Erinnerungstag wird nichts vorbereitet',async()=>{
|
||||||
|
assert.equal((await auto(new Date('2030-10-19T06:59:00Z'))).queued,0);
|
||||||
|
assert.equal((await auto(new Date('2030-10-18T07:00:00Z'))).queued,0);assert.equal((await jobs()).length,0);
|
||||||
|
});
|
||||||
|
test('Verpasster Erinnerungstag wird bis zum 26. nachgeholt',async()=>{assert.equal((await auto(new Date('2030-10-25T07:00:00Z'))).sent,1);});
|
||||||
|
test('Nach der Sperrfrist werden keine Erinnerungen vorbereitet',async()=>{
|
||||||
|
assert.equal((await auto(new Date('2030-10-27T09:00:00Z'))).queued,0);
|
||||||
|
await assert.rejects(manual(new Date('2030-10-27T09:00:00Z')),/Eintragsfrist/);
|
||||||
|
});
|
||||||
|
test('0 schaltet automatische Erinnerungen ab; manueller Versand bleibt möglich',async()=>{
|
||||||
|
await prisma.kita.update({where:{id:f.kita.id},data:{notdienstReminderDaysBefore:0}});
|
||||||
|
assert.equal((await auto()).queued,0);assert.equal(await manual(),1);
|
||||||
|
});
|
||||||
|
test('Deaktiviertes Modul wird weder vorbereitet noch versendet',async()=>{
|
||||||
|
await manual();await prisma.kita.update({where:{id:f.kita.id},data:{notdienstModuleEnabled:false}});
|
||||||
|
assert.equal((await auto()).sent,0);assert.equal(emails.length,0);assert.ok((await jobs())[0].skippedAt);
|
||||||
|
});
|
||||||
|
test('Admin- und Koordinationskonten desselben Haushalts erhalten die Erinnerung',async()=>{
|
||||||
|
const p=await partner();assert.equal((await auto()).sent,2);assert.deepEqual(emails.map(e=>e.to).sort(),[p.email,f.parent.email].sort());
|
||||||
|
});
|
||||||
|
test('Unverifizierte, gesperrte, deaktivierte und Erzieherkonten werden nicht angeschrieben',async()=>{
|
||||||
|
await partner('ERZIEHER');await prisma.user.update({where:{id:f.parent.id},data:{lockedUntil:new Date('2031-01-01')}});
|
||||||
|
assert.equal((await auto()).sent,0);assert.equal(emails.length,0);
|
||||||
|
});
|
||||||
|
test('Manuell und automatisch am selben Tag erzeugen nur einen Auftrag je Konto',async()=>{
|
||||||
|
assert.equal(await manual(),1);assert.equal(await manual(),0);assert.equal((await auto()).queued,0);assert.equal(emails.length,1);
|
||||||
|
});
|
||||||
|
test('Parallele automatische Läufe erzeugen nur eine Mailrunde',async()=>{
|
||||||
|
const results=await Promise.all([auto(),auto()]);assert.equal(results.reduce((n,r)=>n+r.queued,0),1);assert.equal(emails.length,1);
|
||||||
|
});
|
||||||
|
test('Bei inzwischen vollständiger Auswahl wird ein gespeicherter Reminder übersprungen',async()=>{
|
||||||
|
await manual();await availability(['2030-11-04','2030-11-05','2030-11-06','2030-11-07','2030-11-08','2030-11-11']);
|
||||||
|
assert.equal((await service.deliverDueNotdienstEmails({kitaId:f.kita.id},sender,now)).skipped,1);assert.equal(emails.length,0);
|
||||||
|
});
|
||||||
|
test('Haushaltswechsel und fehlende Mailbestätigung machen einen Auftrag ungültig',async()=>{
|
||||||
|
await manual();await prisma.user.update({where:{id:f.parent.id},data:{emailVerifiedAt:null}});
|
||||||
|
assert.equal((await service.deliverDueNotdienstEmails({kitaId:f.kita.id},sender,now)).skipped,1);assert.equal(emails.length,0);
|
||||||
|
});
|
||||||
|
test('Versandfehler bleibt sichtbar; Wiederholung verwendet denselben Idempotenzschlüssel',async()=>{
|
||||||
|
await manual();failMail=true;const first=await service.deliverDueNotdienstEmails({kitaId:f.kita.id},sender,now);assert.equal(first.failed,1);
|
||||||
|
const failed=(await jobs())[0];assert.equal(failed.failed,true);assert.ok(failed.nextAttemptAt>now);assert.equal(failed.sentAt,null);
|
||||||
|
assert.equal((await service.getNotdienstEmailStatus(f.kita.id,2030,11)).failed,1);
|
||||||
|
assert.equal((await service.deliverDueNotdienstEmails({kitaId:f.kita.id},sender,now)).sent,0);
|
||||||
|
failMail=false;assert.equal((await service.deliverDueNotdienstEmails({kitaId:f.kita.id},sender,failed.nextAttemptAt)).sent,1);
|
||||||
|
assert.equal(emails[0].idempotencyKey,emails[1].idempotencyKey);assert.equal((await jobs())[0].failed,false);
|
||||||
|
});
|
||||||
|
test('Paralleler Versand reserviert einen Auftrag nur einmal',async()=>{
|
||||||
|
await manual();const id=(await jobs())[0].id;let count=0;
|
||||||
|
const slow=async()=>{count++;await new Promise(r=>setTimeout(r,20));return {success:true};};
|
||||||
|
await Promise.all([service.deliverNotdienstEmails([id],slow,now),service.deliverNotdienstEmails([id],slow,now)]);assert.equal(count,1);
|
||||||
|
});
|
||||||
|
test('Abgelaufene Reservierung nach Worker-Abbruch wird wieder aufgenommen',async()=>{
|
||||||
|
await manual();const id=(await jobs())[0].id;await prisma.notdienstEmail.update({where:{id},data:{leaseUntil:new Date(now.getTime()-1000),claimToken:'old'}});
|
||||||
|
assert.equal((await service.deliverDueNotdienstEmails({kitaId:f.kita.id},sender,now)).sent,1);
|
||||||
|
});
|
||||||
|
test('Eine fehlerhafte Adresse blockiert die zweite Person nicht',async()=>{
|
||||||
|
await partner();await manual();const result=await service.deliverDueNotdienstEmails({kitaId:f.kita.id},async input=>{emails.push(input);return input.to===f.parent.email?{success:false,error:'failure'}:{success:true};},now);
|
||||||
|
assert.equal(result.failed,1);assert.equal(result.sent,1);
|
||||||
|
});
|
||||||
|
test('Veröffentlichung fasst Geschwisterdienste pro Konto in einer Mail zusammen',async()=>{
|
||||||
|
const p=await partner();const plan=await draft();assert.equal((await publish(plan.id)).success,true);assert.equal(emails.length,2);
|
||||||
|
const html=await render(emails[0].react);assert.ok(html.includes('04.11.2030'));assert.ok(html.includes('05.11.2030'));
|
||||||
|
assert.deepEqual(emails.map(e=>e.to).sort(),[p.email,f.parent.email].sort());assert.equal((await jobs()).filter(j=>j.kind==='PUBLICATION').length,2);
|
||||||
|
});
|
||||||
|
test('Mehrfaches und paralleles Veröffentlichen erzeugt keine doppelten Mails',async()=>{
|
||||||
|
const plan=await draft();const result=await Promise.all([publish(plan.id),publish(plan.id)]);assert.ok(result.every(r=>r.success));
|
||||||
|
assert.equal((await jobs()).length,1);assert.equal(emails.length,1);assert.equal((await publish(plan.id)).success,true);assert.equal(emails.length,1);
|
||||||
|
});
|
||||||
|
test('Mailfehler macht einen veröffentlichten Plan nicht rückgängig',async()=>{
|
||||||
|
const plan=await draft();failMail=true;const result=await publish(plan.id);assert.equal(result.success,true);assert.equal(result.failed,1);
|
||||||
|
assert.equal((await prisma.notdienstPlan.findUniqueOrThrow({where:{id:plan.id}})).status,'PUBLISHED');assert.equal((await jobs())[0].failed,true);
|
||||||
|
failMail=false;assert.equal((await retry(2030,11)).sent,1);assert.equal((await jobs())[0].sentAt!==null,true);
|
||||||
|
});
|
||||||
|
test('Beim Speichern der Mailaufträge fehlgeschlagene Veröffentlichung bleibt Entwurf',async()=>{
|
||||||
|
const plan=await draft();const load=loader({'@/lib/auth-utils':{requireRole:async()=>({user:current})},'@/lib/notdienst-notifications':{queueNotdienstPublication:async()=>{throw new Error('Test storage failure');}}});
|
||||||
|
const action=load('src/app/dashboard/notdienst/plan/actions.ts').publishPlanAction;assert.ok((await action(plan.id)).error);
|
||||||
|
assert.equal((await prisma.notdienstPlan.findUniqueOrThrow({where:{id:plan.id}})).status,'DRAFT');
|
||||||
|
});
|
||||||
|
test('Veröffentlichter Plan verhindert weitere automatische Erinnerungen',async()=>{
|
||||||
|
const plan=await draft();await publish(plan.id);emails.length=0;assert.equal((await auto()).queued,0);assert.equal(emails.length,0);
|
||||||
|
});
|
||||||
|
test('Einteilung wird vor einem Wiederholungsversuch erneut geprüft',async()=>{
|
||||||
|
const plan=await draft();failMail=true;await publish(plan.id);const job=(await jobs())[0];
|
||||||
|
await prisma.notdienstAssignment.deleteMany({where:{planId:plan.id}});failMail=false;
|
||||||
|
assert.equal((await service.deliverNotdienstEmails([job.id],sender,job.nextAttemptAt)).skipped,1);assert.equal(emails.length,1);
|
||||||
|
});
|
||||||
|
test('Empfänger sehen bei Wiederholung die aktuelle eigene Einteilung',async()=>{
|
||||||
|
const plan=await draft();failMail=true;await publish(plan.id);const job=(await jobs())[0];
|
||||||
|
await prisma.notdienstAssignment.deleteMany({where:{planId:plan.id,date:new Date('2030-11-04')}});failMail=false;
|
||||||
|
await service.deliverNotdienstEmails([job.id],sender,job.nextAttemptAt);const html=await render(emails[1].react);
|
||||||
|
assert.ok(!html.includes('04.11.2030'));assert.ok(html.includes('05.11.2030'));
|
||||||
|
});
|
||||||
|
test('Kein Auftrag für Haushalte ohne eigene Einteilung',async()=>{
|
||||||
|
const plan=await prisma.notdienstPlan.create({data:{kitaId:f.kita.id,year:2030,month:11}});await publish(plan.id);assert.equal((await jobs()).length,0);
|
||||||
|
});
|
||||||
|
test('Datenbank verhindert Empfängerbeziehungen zu einer fremden Kita',async()=>{
|
||||||
|
const other=await prisma.kita.create({data:{name:'Andere Kita',slug:randomUUID()}});tenants.push(other.id);
|
||||||
|
await assert.rejects(prisma.notdienstEmail.create({data:{kitaId:other.id,familyId:f.family.id,userId:f.parent.id,year:2030,month:11,kind:'REMINDER',eventKey:'cross-tenant'}}));
|
||||||
|
});
|
||||||
|
test('Wiederholungsaktion begrenzt den Versand auf die eigene Kita',async()=>{
|
||||||
|
const own=await draft();failMail=true;await publish(own.id);const other=await prisma.kita.create({data:{name:'Andere Kita',slug:randomUUID()}});tenants.push(other.id);
|
||||||
|
const beforeCount=emails.length;current={...current,kitaId:other.id};failMail=false;assert.equal((await retry(2030,11)).sent,0);assert.equal(emails.length,beforeCount);
|
||||||
|
});
|
||||||
|
test('Monatswechsel und Jahreswechsel verwenden Berliner Kalenderdaten',async()=>{
|
||||||
|
const dateUtils=loader()('src/lib/date-utils.ts');const target=dateUtils.getTargetMonthData(new Date('2030-12-31T23:00:00Z'));
|
||||||
|
assert.equal(target.targetYear,2031);assert.equal(target.targetMonth,2);
|
||||||
|
assert.equal(dateUtils.isNotdienstReminderDue(7,new Date('2030-10-26T22:00:00Z')),false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Noch nicht fällige Kitas verdrängen fällige Erinnerungen nicht aus dem Batch',async()=>{
|
||||||
|
const rows=Array.from({length:101},(_,i)=>({id:`000-not-due-${randomUUID()}-${i}`,name:'Noch nicht fällig',slug:randomUUID(),notdienstReminderDaysBefore:1}));
|
||||||
|
await prisma.kita.createMany({data:rows});tenants.push(...rows.map(r=>r.id));
|
||||||
|
await service.runNotdienstNotificationJob(sender,now);
|
||||||
|
assert.equal(await prisma.notdienstReminderRun.count({where:{kitaId:f.kita.id,year:2030,month:11}}),1);
|
||||||
|
assert.equal((await jobs()).filter(j=>j.kind==='REMINDER').length,1);
|
||||||
|
});
|
||||||
|
test('Geschützter Job startet ohne gültiges Secret keine Verarbeitung',async()=>{
|
||||||
|
const authorization=loader()('src/lib/alarms/job.ts').authorizeAlarmJob;let called=0;
|
||||||
|
const route=loader({'@/lib/alarms/job':{authorizeAlarmJob:authorization,runAlarmJob:async()=>{called++;return {};}},'@/lib/notdienst-notifications':{runNotdienstNotificationJob:async()=>{called++;return {};}}})('src/app/api/jobs/notdienstalarme/route.ts').POST;
|
||||||
|
const old=process.env.NOTDIENST_JOB_SECRET;
|
||||||
|
try{delete process.env.NOTDIENST_JOB_SECRET;assert.equal((await route(new Request('http://127.0.0.1/api/jobs/notdienstalarme',{method:'POST'}))).status,503);
|
||||||
|
process.env.NOTDIENST_JOB_SECRET='test-only-secret-32-characters-long';assert.equal((await route(new Request('http://127.0.0.1/api/jobs/notdienstalarme',{method:'POST',headers:{authorization:'Bearer wrong'}}))).status,401);assert.equal(called,0);
|
||||||
|
const result=await route(new Request('http://127.0.0.1/api/jobs/notdienstalarme',{method:'POST',headers:{authorization:`Bearer ${process.env.NOTDIENST_JOB_SECRET}`}}));assert.equal(result.status,200);assert.equal(called,2);
|
||||||
|
}finally{if(old===undefined)delete process.env.NOTDIENST_JOB_SECRET;else process.env.NOTDIENST_JOB_SECRET=old;}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zusammengesetzte Mail-Fremdschlüssel schützen auch Nutzer und Plan separat',async()=>{
|
||||||
|
const other=await prisma.kita.create({data:{name:'Fremde Kita',slug:randomUUID()}});tenants.push(other.id);
|
||||||
|
const foreignUser=await prisma.user.create({data:{kitaId:other.id,email:`${randomUUID()}@example.invalid`,firstName:'Foreign',lastName:'Test',passwordHash:'test'}});
|
||||||
|
const foreignPlan=await prisma.notdienstPlan.create({data:{kitaId:other.id,year:2030,month:11}});
|
||||||
|
const base={kitaId:f.kita.id,familyId:f.family.id,userId:f.parent.id,year:2030,month:11,kind:'REMINDER',eventKey:'tenant-check'};
|
||||||
|
await assert.rejects(prisma.notdienstEmail.create({data:{...base,userId:foreignUser.id}}),e=>e.code==='P2003');
|
||||||
|
await assert.rejects(prisma.notdienstEmail.create({data:{...base,kind:'PUBLICATION',planId:foreignPlan.id}}),e=>e.code==='P2003');
|
||||||
|
});
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports -- Load real server actions with isolated request and mail boundaries. */
|
||||||
|
const { test, before, after, beforeEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const ts = require('typescript');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { randomUUID } = require('node:crypto');
|
||||||
|
let prisma, current, fixture, save, generate, publish, assign, removeChild, updateFamily, days;
|
||||||
|
const tenants = [];
|
||||||
|
function loader(overrides = {}) {
|
||||||
|
const cache = new Map();
|
||||||
|
function load(file) {
|
||||||
|
file = path.resolve(file);
|
||||||
|
if (cache.has(file)) return cache.get(file).exports;
|
||||||
|
const mod = { exports: {} }; cache.set(file, mod);
|
||||||
|
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
|
||||||
|
fileName: file,
|
||||||
|
}).outputText;
|
||||||
|
const req = spec => {
|
||||||
|
if (Object.hasOwn(overrides, spec)) return overrides[spec];
|
||||||
|
if (spec === 'server-only') return {};
|
||||||
|
if (spec === '@/lib/prisma') return { prisma };
|
||||||
|
if (spec === 'next/headers') return { headers: async () => new Headers() };
|
||||||
|
if (spec === 'next/cache') return { revalidatePath() {} };
|
||||||
|
if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } };
|
||||||
|
if (spec.startsWith('@/') || spec.startsWith('.')) {
|
||||||
|
const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec);
|
||||||
|
for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix);
|
||||||
|
}
|
||||||
|
return require(spec);
|
||||||
|
};
|
||||||
|
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports);
|
||||||
|
return mod.exports;
|
||||||
|
}
|
||||||
|
return load;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = key => new Date(`${key}T00:00:00Z`);
|
||||||
|
const month = { targetYear: 2030, targetMonth: 11, isLocked: false };
|
||||||
|
const mail = { getAppEmailConfigError: () => null, sendAppEmail: async () => ({ success: true }) };
|
||||||
|
before(async () => {
|
||||||
|
const raw = process.env.TEST_DATABASE_URL;
|
||||||
|
if (!raw) throw new Error('TEST_DATABASE_URL muss auf eine separate lokale Notdienst-Testdatenbank zeigen.');
|
||||||
|
const url = new URL(raw);
|
||||||
|
assert.ok(['127.0.0.1', 'localhost'].includes(url.hostname));
|
||||||
|
assert.equal(url.pathname, '/kita_notdienst_test');
|
||||||
|
prisma = new PrismaClient({ datasourceUrl: raw });
|
||||||
|
const originalDates = loader()('src/lib/date-utils.ts');
|
||||||
|
const load = loader({
|
||||||
|
'@/lib/auth-utils': {
|
||||||
|
requireKitaSession: async () => ({ user: current }),
|
||||||
|
requireRole: async roles => { assert.ok(roles.includes(current.role)); return { user: current }; },
|
||||||
|
},
|
||||||
|
'@/lib/alarms/auth': { requireAlarmActor: async () => ({ id: current.id, kitaId: current.kitaId, role: current.role }) },
|
||||||
|
'@/lib/date-utils': { ...originalDates, getTargetMonthData: () => month },
|
||||||
|
'@/lib/mail': mail,
|
||||||
|
});
|
||||||
|
({ saveNotdienstAvailabilities: save } = load('src/app/dashboard/notdienst/actions.ts'));
|
||||||
|
({ generatePlanAction: generate, publishPlanAction: publish, updateAssignmentAction: assign } = load('src/app/dashboard/notdienst/plan/actions.ts'));
|
||||||
|
({ deleteMyChild: removeChild } = load('src/app/dashboard/profil/actions.ts'));
|
||||||
|
({ updateFamilyAction: updateFamily } = load('src/app/dashboard/families/actions.ts'));
|
||||||
|
({ loadNotdienstDays: days } = load('src/lib/notdienst-planning.ts'));
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
const id = randomUUID();
|
||||||
|
const kita = await prisma.kita.create({ data: { name: 'Notdienst-Test', slug: `notdienst-${id}`, notdienstMinPerChildPerMonth: 3 } });
|
||||||
|
tenants.push(kita.id);
|
||||||
|
const family = await prisma.family.create({ data: { kitaId: kita.id, name: 'Familie Test' } });
|
||||||
|
const parent = await prisma.user.create({ data: { kitaId: kita.id, familyId: family.id, email: `${id}@example.invalid`, firstName: 'Test', lastName: 'Eltern', passwordHash: 'test', emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
|
||||||
|
const children = await Promise.all(['A','B'].map(firstName => prisma.child.create({ data: { kitaId: kita.id, familyId: family.id, firstName, lastName: 'Test' } })));
|
||||||
|
fixture = { kita, family, parent, children };
|
||||||
|
current = parent;
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
if (!prisma) return;
|
||||||
|
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
const payload = (dates = ['2030-11-04','2030-11-05','2030-11-06','2030-11-07','2030-11-08','2030-11-11']) => ({ childrenIds: fixture.children.map(c => c.id), dates });
|
||||||
|
async function admin() { current = await prisma.user.update({ where: { id: fixture.parent.id }, data: { role: 'ADMIN' } }); }
|
||||||
|
async function closure(start, end = start, status = 'CONFIRMED', kitaId = fixture.kita.id) {
|
||||||
|
return prisma.termin.create({ data: { kitaId, title: 'Geschlossen', type: 'SCHLIESSTAG', status, startDate: new Date(start), endDate: new Date(end), allDay: true } });
|
||||||
|
}
|
||||||
|
async function makePlan(key = '2030-11-04', status = 'DRAFT', childId = fixture.children[0].id) {
|
||||||
|
return prisma.notdienstPlan.create({ data: { kitaId: fixture.kita.id, year: 2030, month: 11, status, assignments: { create: { kitaId: fixture.kita.id, childId, date: date(key) } } }, include: { assignments: true } });
|
||||||
|
}
|
||||||
|
async function seedAvailability(childId, keys) {
|
||||||
|
return prisma.notdienstAvailability.createMany({ data: keys.map(key => ({ kitaId: fixture.kita.id, childId, userId: fixture.parent.id, date: date(key) })) });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Vollständiger Haushalt speichert sechs verschiedene Tage für zwei Kinder', async () => {
|
||||||
|
assert.equal((await save(payload())).success, true);
|
||||||
|
const entries = await prisma.notdienstAvailability.findMany({ where: { kitaId: fixture.kita.id } });
|
||||||
|
assert.equal(entries.length, 6);
|
||||||
|
for (const child of fixture.children) assert.equal(entries.filter(e => e.childId === child.id).length, 3);
|
||||||
|
});
|
||||||
|
test('Eine reduzierte Kindermenge kann das Soll nicht umgehen', async () => {
|
||||||
|
assert.match((await save({ childrenIds: [fixture.children[0].id], dates: payload().dates.slice(0,3) })).error, /alle aktiven Kinder/);
|
||||||
|
assert.equal(await prisma.notdienstAvailability.count({ where: { kitaId: fixture.kita.id } }), 0);
|
||||||
|
});
|
||||||
|
test('Fünf Tage reichen bei zwei Kindern und Soll drei nicht', async () => {
|
||||||
|
assert.match((await save(payload(payload().dates.slice(0,5)))).error, /mindestens 6/);
|
||||||
|
});
|
||||||
|
test('Doppelte Datumsangaben zählen nur einmal', async () => {
|
||||||
|
assert.match((await save(payload(Array(6).fill('2030-11-04')))).error, /mindestens 6/);
|
||||||
|
});
|
||||||
|
test('Doppelte Kinder-IDs werden abgewiesen', async () => {
|
||||||
|
assert.ok((await save({ ...payload(), childrenIds: Array(2).fill(fixture.children[0].id) })).error);
|
||||||
|
});
|
||||||
|
test('Inaktive Kinder können das aktive Kinderset nicht ersetzen', async () => {
|
||||||
|
const inactive = await prisma.child.create({ data: { kitaId: fixture.kita.id, familyId: fixture.family.id, firstName: 'Inaktiv', lastName: 'Test', active: false } });
|
||||||
|
assert.ok((await save({ ...payload(), childrenIds: [fixture.children[0].id, inactive.id] })).error);
|
||||||
|
assert.equal((await save(payload())).success, true);
|
||||||
|
});
|
||||||
|
test('Kinder eines anderen Haushalts sind nicht wählbar', async () => {
|
||||||
|
const family = await prisma.family.create({ data: { kitaId: fixture.kita.id, name: 'Andere Familie' } });
|
||||||
|
const foreign = await prisma.child.create({ data: { kitaId: fixture.kita.id, familyId: family.id, firstName: 'Fremd', lastName: 'Test' } });
|
||||||
|
assert.ok((await save({ ...payload(), childrenIds: [fixture.children[0].id, foreign.id] })).error);
|
||||||
|
});
|
||||||
|
for (const [label, key] of [['Samstag','2030-11-02'], ['Sonntag','2030-11-03'], ['falscher Monat','2030-12-02']]) {
|
||||||
|
test(`${label} wird serverseitig abgewiesen und erhält vorhandene Eingaben`, async () => {
|
||||||
|
assert.equal((await save(payload())).success, true);
|
||||||
|
assert.ok((await save(payload([key, ...payload().dates.slice(1)]))).error);
|
||||||
|
assert.equal(await prisma.notdienstAvailability.count({ where: { kitaId: fixture.kita.id } }), 6);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('Schließtag wird auch bei einer Uhrzeit nach Mitternacht abgewiesen', async () => {
|
||||||
|
await closure('2030-11-04T08:00:00Z','2030-11-04T17:00:00Z');
|
||||||
|
assert.match((await save(payload())).error, /Schließtage/);
|
||||||
|
});
|
||||||
|
test('Monatsübergreifende Schließtage einschließlich Berliner Mitternacht', async () => {
|
||||||
|
await closure('2030-10-31T23:00:00Z','2030-11-04T22:59:00Z');
|
||||||
|
const allowed = (await days(prisma, fixture.kita.id, 2030, 11)).map(d => d.toISOString().slice(0,10));
|
||||||
|
assert.ok(!allowed.includes('2030-11-01'));
|
||||||
|
assert.ok(!allowed.includes('2030-11-04'));
|
||||||
|
assert.ok(allowed.includes('2030-11-05'));
|
||||||
|
});
|
||||||
|
test('Ausstehende und abgelehnte Schließtermine blockieren keine Eingaben', async () => {
|
||||||
|
await closure('2030-11-04','2030-11-05','PENDING');
|
||||||
|
await closure('2030-11-06','2030-11-07','REJECTED');
|
||||||
|
assert.equal((await save(payload())).success, true);
|
||||||
|
});
|
||||||
|
test('Schließtage einer anderen Kita beeinflussen die Planung nicht', async () => {
|
||||||
|
const other = await prisma.kita.create({ data: { name: 'Andere Kita', slug: randomUUID() } }); tenants.push(other.id);
|
||||||
|
await closure('2030-11-04','2030-11-11','CONFIRMED',other.id);
|
||||||
|
assert.equal((await save(payload())).success, true);
|
||||||
|
});
|
||||||
|
test('Generator ignoriert alte Verfügbarkeiten an Schließtagen und für inaktive Kinder', async () => {
|
||||||
|
await seedAvailability(fixture.children[0].id, ['2030-11-01','2030-11-02','2030-11-04','2030-11-05']);
|
||||||
|
await seedAvailability(fixture.children[1].id, ['2030-11-06']);
|
||||||
|
await prisma.child.update({ where: { id: fixture.children[1].id }, data: { active: false } });
|
||||||
|
await closure('2030-10-31T23:00:00Z','2030-11-04T22:59:00Z');
|
||||||
|
await admin();
|
||||||
|
assert.equal((await generate(2030,11)).success, true);
|
||||||
|
const assignments = await prisma.notdienstAssignment.findMany({ where: { kitaId: fixture.kita.id } });
|
||||||
|
assert.deepEqual(assignments.map(a => a.date.toISOString().slice(0,10)), ['2030-11-05']);
|
||||||
|
});
|
||||||
|
for (const key of ['2030-11-02','2030-12-02','2030-11-04']) {
|
||||||
|
test(`Manuelle Einteilung an unzulässigem Tag ${key} wird verhindert`, async () => {
|
||||||
|
await admin(); const plan = await makePlan('2030-11-05');
|
||||||
|
await closure('2030-11-04');
|
||||||
|
assert.ok((await assign(plan.id, key, fixture.children[0].id)).error);
|
||||||
|
assert.equal(await prisma.notdienstAssignment.count({ where: { planId: plan.id } }),1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('Alte ungültige Einteilungen bleiben entfernbar und können danach veröffentlicht werden', async () => {
|
||||||
|
await admin(); const plan = await makePlan('2030-12-02');
|
||||||
|
assert.ok((await publish(plan.id)).error);
|
||||||
|
assert.equal((await assign(plan.id,'2030-12-02',null)).success,true);
|
||||||
|
assert.equal((await publish(plan.id)).success,true);
|
||||||
|
});
|
||||||
|
test('Neu angelegte Schließtage verhindern die Veröffentlichung alter Entwürfe', async () => {
|
||||||
|
await admin(); const plan = await makePlan(); await closure('2030-11-04');
|
||||||
|
assert.match((await publish(plan.id)).error,/geschlossenen Tagen/);
|
||||||
|
assert.equal((await prisma.notdienstPlan.findUniqueOrThrow({ where: { id: plan.id } })).status,'DRAFT');
|
||||||
|
assert.equal((await assign(plan.id,'2030-11-04',null)).success,true);
|
||||||
|
assert.equal((await publish(plan.id)).success,true);
|
||||||
|
});
|
||||||
|
test('Inaktive Kinder verhindern die Veröffentlichung eines Entwurfs', async () => {
|
||||||
|
await admin(); const plan = await makePlan();
|
||||||
|
await prisma.child.update({ where: { id: fixture.children[0].id }, data: { active: false } });
|
||||||
|
assert.ok((await publish(plan.id)).error);
|
||||||
|
});
|
||||||
|
test('Gültigen Plan veröffentlichen ist wiederholbar und erhält publishedAt', async () => {
|
||||||
|
await admin(); const plan = await makePlan();
|
||||||
|
assert.equal((await publish(plan.id)).success,true);
|
||||||
|
const first = await prisma.notdienstPlan.findUniqueOrThrow({ where: { id: plan.id } });
|
||||||
|
assert.equal((await publish(plan.id)).success,true);
|
||||||
|
assert.equal((await prisma.notdienstPlan.findUniqueOrThrow({ where: { id: plan.id } })).publishedAt.getTime(),first.publishedAt.getTime());
|
||||||
|
});
|
||||||
|
test('Fremde Pläne sind auch bei manuell manipulierter ID nicht veröffentlichbar', async () => {
|
||||||
|
const plan = await makePlan(); const old = current;
|
||||||
|
const other = await prisma.kita.create({ data: { name: 'Andere Kita', slug: randomUUID() } }); tenants.push(other.id);
|
||||||
|
current = { ...old, kitaId: other.id, role: 'ADMIN' };
|
||||||
|
assert.ok((await publish(plan.id)).error);
|
||||||
|
assert.equal((await prisma.notdienstPlan.findUniqueOrThrow({ where: { id: plan.id } })).status,'DRAFT');
|
||||||
|
});
|
||||||
|
test('Eltern können ein Kind mit veröffentlichtem zukünftigen Dienst nicht löschen', async () => {
|
||||||
|
const plan = await makePlan('2030-11-04','PUBLISHED');
|
||||||
|
assert.match((await removeChild(fixture.children[0].id)).error,/Koordination/);
|
||||||
|
assert.equal(await prisma.notdienstAssignment.count({ where: { planId: plan.id } }),1);
|
||||||
|
assert.equal(await prisma.child.count({ where: { id: fixture.children[0].id } }),1);
|
||||||
|
});
|
||||||
|
test('Kind ohne veröffentlichte Verpflichtung bleibt löschbar', async () => {
|
||||||
|
await makePlan('2030-11-04','DRAFT');
|
||||||
|
assert.equal((await removeChild(fixture.children[0].id)).success,true);
|
||||||
|
assert.equal(await prisma.child.count({ where: { id: fixture.children[0].id } }),0);
|
||||||
|
});
|
||||||
|
test('Vergangene veröffentlichte Dienste blockieren eine Löschung nicht', async () => {
|
||||||
|
await makePlan('2020-11-04','PUBLISHED');
|
||||||
|
assert.equal((await removeChild(fixture.children[0].id)).success,true);
|
||||||
|
});
|
||||||
|
test('Löschung fremder Kinder bleibt untersagt', async () => {
|
||||||
|
current = { ...current, familyId: 'foreign-family' };
|
||||||
|
assert.ok((await removeChild(fixture.children[0].id)).error);
|
||||||
|
assert.equal(await prisma.child.count({ where: { id: fixture.children[0].id } }),1);
|
||||||
|
});
|
||||||
|
test('Admin-Familienbearbeitung kann die Löschsperre nicht umgehen; alles wird zurückgerollt', async () => {
|
||||||
|
await makePlan('2030-11-04','PUBLISHED'); await admin();
|
||||||
|
const result = await updateFamily({
|
||||||
|
familyId: fixture.family.id, familyName: 'Darf nicht gespeichert werden',
|
||||||
|
parents: [{ id: current.id, firstName: current.firstName, lastName: current.lastName, email: current.email }],
|
||||||
|
children: [{ id: fixture.children[1].id, firstName: 'Geändert', lastName: 'Test' }],
|
||||||
|
removedChildIds: [fixture.children[0].id],
|
||||||
|
});
|
||||||
|
assert.match(result.error,/Koordination/);
|
||||||
|
assert.equal((await prisma.family.findUniqueOrThrow({ where: { id: fixture.family.id } })).name,'Familie Test');
|
||||||
|
assert.equal((await prisma.child.findUniqueOrThrow({ where: { id: fixture.children[1].id } })).firstName,'B');
|
||||||
|
});
|
||||||
|
test('Nach manueller Neuzuordnung ist das ursprünglich eingeteilte Kind löschbar', async () => {
|
||||||
|
await admin(); const plan = await makePlan('2030-11-04','PUBLISHED');
|
||||||
|
assert.equal((await assign(plan.id,'2030-11-04',fixture.children[1].id)).success,true);
|
||||||
|
assert.equal((await removeChild(fixture.children[0].id)).success,true);
|
||||||
|
assert.equal((await prisma.notdienstAssignment.findUniqueOrThrow({ where: { id: plan.assignments[0].id } })).childId,fixture.children[1].id);
|
||||||
|
});
|
||||||
|
test('Paralleles Veröffentlichen und Löschen lässt keinen veröffentlichten Dienst unbemerkt verschwinden', async () => {
|
||||||
|
await admin(); const plan = await makePlan();
|
||||||
|
const results = await Promise.all([publish(plan.id), removeChild(fixture.children[0].id)]);
|
||||||
|
const stored = await prisma.notdienstPlan.findUniqueOrThrow({ where: { id: plan.id }, include: { assignments: true } });
|
||||||
|
if (results[1].error) assert.equal(stored.assignments.length,1);
|
||||||
|
else { assert.equal(stored.assignments.length,0); assert.equal(await prisma.child.count({ where: { id: fixture.children[0].id } }),0); }
|
||||||
|
assert.ok(results.every(r => r.success || r.error));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Teamtag mit geschlossener Kita ist ebenfalls kein Notdiensttag', async () => {
|
||||||
|
const event = await closure('2030-11-04');
|
||||||
|
await prisma.termin.update({ where: { id: event.id }, data: { type: 'TEAMTAG' } });
|
||||||
|
assert.match((await save(payload())).error,/Schließtage/);
|
||||||
|
await seedAvailability(fixture.children[0].id,['2030-11-04','2030-11-05']);
|
||||||
|
await admin(); assert.equal((await generate(2030,11)).success,true);
|
||||||
|
assert.equal(await prisma.notdienstAssignment.count({ where: { kitaId: fixture.kita.id, date: date('2030-11-04') } }),0);
|
||||||
|
});
|
||||||
|
test('Heutige veröffentlichte Verpflichtung blockiert die Kindlöschung', async () => {
|
||||||
|
const berlin = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
|
||||||
|
await makePlan(berlin,'PUBLISHED');
|
||||||
|
assert.match((await removeChild(fixture.children[0].id)).error,/Koordination/);
|
||||||
|
});
|
||||||
|
test('Aktiver Alarm schützt auch ein Kind mit vergangener Einteilung', async () => {
|
||||||
|
const plan = await makePlan('2020-11-04','PUBLISHED');
|
||||||
|
const assignment = plan.assignments[0];
|
||||||
|
await prisma.notdienstAlarm.create({ data: {
|
||||||
|
kitaId: fixture.kita.id, assignmentId: assignment.id, activeAssignmentId: assignment.id,
|
||||||
|
familyId: fixture.family.id, familyName: fixture.family.name, childName: 'A Test',
|
||||||
|
date: assignment.date, expiresAt: new Date(Date.now()+3600000), deadline: new Date(Date.now()+1800000),
|
||||||
|
} });
|
||||||
|
assert.match((await removeChild(fixture.children[0].id)).error,/aktive Alarme/);
|
||||||
|
assert.equal(await prisma.child.count({ where: { id: fixture.children[0].id } }),1);
|
||||||
|
});
|
||||||
|
test('Admin kann ein Kind ohne Verpflichtung weiterhin entfernen', async () => {
|
||||||
|
await admin();
|
||||||
|
assert.equal((await updateFamily({
|
||||||
|
familyId: fixture.family.id, familyName: 'Familie geändert',
|
||||||
|
parents: [{ id: current.id, firstName: current.firstName, lastName: current.lastName, email: current.email }],
|
||||||
|
children: [{ id: fixture.children[1].id, firstName: 'B', lastName: 'Test' }],
|
||||||
|
removedChildIds: [fixture.children[0].id],
|
||||||
|
})).success,true);
|
||||||
|
assert.equal(await prisma.child.count({ where: { id: fixture.children[0].id } }),0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports -- CommonJS fixtures exercise the native Node process supervisor. */
|
||||||
|
const {test}=require('node:test');const assert=require('node:assert/strict');const {spawn}=require('node:child_process');const {mkdtempSync,mkdirSync,writeFileSync,rmSync}=require('node:fs');const {tmpdir}=require('node:os');const {join,resolve}=require('node:path');
|
||||||
|
function fixture(server,worker){const root=mkdtempSync(join(tmpdir(),'kita-runtime-test-'));mkdirSync(join(root,'scripts'));writeFileSync(join(root,'server.js'),server);writeFileSync(join(root,'scripts/notdienst-alarm-worker.mjs'),worker);return root;}
|
||||||
|
function waitForOutput(child,marker){return new Promise((resolvePromise,reject)=>{let text='';const timer=setTimeout(()=>reject(new Error(`Missing ${marker}`)),10000);child.stdout.on('data',chunk=>{text+=chunk;if(text.includes(marker)){clearTimeout(timer);resolvePromise(text);}});});}
|
||||||
|
function exited(child){return new Promise(resolvePromise=>child.once('exit',(code,signal)=>resolvePromise({code,signal})));}
|
||||||
|
function gone(pid){try{process.kill(pid,0);return false;}catch{return true;}}
|
||||||
|
test('Worker crash is restarted while the server stays alive; shutdown stops both',async()=>{
|
||||||
|
const root=fixture('console.log(`server:${process.pid}`);setInterval(()=>{},1000);',`import {existsSync,writeFileSync} from 'node:fs';if(!existsSync('once')){writeFileSync('once','1');console.log('worker-crashed');process.exit(1);}console.log(\`worker-ready:\${process.pid}\`);setInterval(()=>{},1000);`);
|
||||||
|
const child=spawn(process.execPath,[resolve('scripts/start-runtime.mjs')],{cwd:root,env:{...process.env,NOTDIENST_JOB_SECRET:'test-only-secret-32-characters-long'},stdio:['ignore','pipe','pipe']});
|
||||||
|
try{const text=await waitForOutput(child,'worker-ready:');const serverPid=Number(text.match(/server:(\d+)/)[1]);const workerPid=Number(text.match(/worker-ready:(\d+)/)[1]);assert.equal(gone(serverPid),false);const ended=exited(child);child.kill('SIGTERM');assert.equal((await ended).code,0);assert.equal(gone(serverPid),true);assert.equal(gone(workerPid),true);}finally{if(child.exitCode===null)child.kill('SIGTERM');rmSync(root,{recursive:true,force:true});}
|
||||||
|
});
|
||||||
|
test('Server failure shuts down the worker and preserves the failure exit code',async()=>{
|
||||||
|
const root=fixture('setTimeout(()=>process.exit(7),150);', 'console.log(`worker:${process.pid}`);setInterval(()=>{},1000);');
|
||||||
|
const child=spawn(process.execPath,[resolve('scripts/start-runtime.mjs')],{cwd:root,env:{...process.env,NOTDIENST_JOB_SECRET:'test-only-secret-32-characters-long'},stdio:['ignore','pipe','pipe']});
|
||||||
|
try{const ended=exited(child);const text=await waitForOutput(child,'worker:');const pid=Number(text.match(/worker:(\d+)/)[1]);assert.equal((await ended).code,7);assert.equal(gone(pid),true);}finally{if(child.exitCode===null)child.kill('SIGTERM');rmSync(root,{recursive:true,force:true});}
|
||||||
|
});
|
||||||
@@ -384,7 +384,7 @@ test('upload quota holds under concurrent writes and accepted MIME comes from fi
|
|||||||
|
|
||||||
test('all supplemental database tenant constraints are present', async () => {
|
test('all supplemental database tenant constraints are present', async () => {
|
||||||
const [row] = await prisma.$queryRaw`SELECT count(*)::int AS count FROM pg_constraint WHERE conname LIKE '%_tenant_fkey'`;
|
const [row] = await prisma.$queryRaw`SELECT count(*)::int AS count FROM pg_constraint WHERE conname LIKE '%_tenant_fkey'`;
|
||||||
assert.equal(row.count,42);
|
assert.equal(row.count,45);
|
||||||
});
|
});
|
||||||
test('role guard authenticates once and keeps role validation on that session', async () => {
|
test('role guard authenticates once and keeps role validation on that session', async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user