From 8b32e4a832bcbca22306597bf805f2b05d8e961b Mon Sep 17 00:00:00 2001 From: "t.indorf" Date: Fri, 9 Oct 2026 11:02:24 +0200 Subject: [PATCH] feat: add household emergency duty alarms and deadline worker --- deploy/README.md | 4 + deploy/production-checklist.md | 9 + deploy/production.env.example | 4 + deploy/sql/notdienstalarm.sql | 160 ++++ docker-compose.coolify.yml | 1 + docs/FUNKTIONSUEBERSICHT.md | 6 + docs/NOTDIENSTALARM.md | 65 ++ docs/NOTDIENSTALARM_PLAN.md | 2 +- package.json | 1 + prisma/schema.prisma | 133 ++- scripts/notdienst-alarm-worker.mjs | 42 + scripts/start-production.sh | 9 + src/app/alert/[token]/actions.ts | 55 +- src/app/alert/[token]/alert-confirm-form.tsx | 88 +- src/app/alert/[token]/page.tsx | 213 +++-- src/app/api/jobs/notdienstalarme/route.ts | 32 + src/app/dashboard/actions.ts | 127 +-- src/app/dashboard/kalender/actions.ts | 329 ++++--- src/app/dashboard/notdienst/alarm/actions.ts | 59 ++ .../notdienst/alarm/dashboard-hints.tsx | 51 ++ src/app/dashboard/notdienst/alarm/forms.tsx | 383 ++++++++ src/app/dashboard/notdienst/alarm/page.tsx | 408 +++++++++ src/app/dashboard/notdienst/plan/actions.ts | 339 +++---- src/app/dashboard/notdienst/plan/page.tsx | 159 ++-- .../dashboard/notdienst/plan/plan-view.tsx | 55 +- src/app/dashboard/page.tsx | 2 + src/components/dashboard/sidebar-nav.tsx | 9 + src/emails/NotdienstAlarmEmail.tsx | 87 ++ src/lib/alarms/auth.ts | 20 + src/lib/alarms/email.ts | 98 +++ src/lib/alarms/format.ts | 89 ++ src/lib/alarms/job.ts | 40 + src/lib/alarms/service.ts | 688 +++++++++++++++ tests/notdienst-alarm.test.ts | 826 ++++++++++++++++++ 34 files changed, 3947 insertions(+), 646 deletions(-) create mode 100644 deploy/sql/notdienstalarm.sql create mode 100644 docs/NOTDIENSTALARM.md create mode 100644 scripts/notdienst-alarm-worker.mjs create mode 100644 src/app/api/jobs/notdienstalarme/route.ts create mode 100644 src/app/dashboard/notdienst/alarm/actions.ts create mode 100644 src/app/dashboard/notdienst/alarm/dashboard-hints.tsx create mode 100644 src/app/dashboard/notdienst/alarm/forms.tsx create mode 100644 src/app/dashboard/notdienst/alarm/page.tsx create mode 100644 src/emails/NotdienstAlarmEmail.tsx create mode 100644 src/lib/alarms/auth.ts create mode 100644 src/lib/alarms/email.ts create mode 100644 src/lib/alarms/format.ts create mode 100644 src/lib/alarms/job.ts create mode 100644 src/lib/alarms/service.ts create mode 100644 tests/notdienst-alarm.test.ts diff --git a/deploy/README.md b/deploy/README.md index f84d1a2..8423f21 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -30,6 +30,10 @@ Coolify erkennt Variablen aus `docker-compose.coolify.yml` und zeigt required Va wie `DATABASE_URL`, `AUTH_SECRET`, `AUTH_URL`, `NEXTAUTH_URL`, `NEXT_PUBLIC_SITE_URL`, `RESEND_API_KEY`, `EMAIL_FROM` und `ADMIN_EMAIL` in der UI an. +## Notdienstalarm + +Die Compose-Vorlage benötigt zusätzlich `NOTDIENST_JOB_SECRET` (zufällig, mindestens 32 Zeichen). Der Produktionsstart führt die Fristprüfung über einen lokalen Hintergrundprozess ungefähr jede Minute aus. Ein alleiniger Start mit `npm run start` benötigt einen separat gestarteten Worker oder einen Scheduler. Details, Versandprüfung und das additive SQL-Upgrade stehen in [docs/NOTDIENSTALARM.md](../docs/NOTDIENSTALARM.md). + ## Welche Coolify-Postgres-URL? Wenn die App und die Postgres-DB im selben Coolify-Projekt/Netzwerk laufen, nimm diff --git a/deploy/production-checklist.md b/deploy/production-checklist.md index e4c1910..c055c50 100644 --- a/deploy/production-checklist.md +++ b/deploy/production-checklist.md @@ -58,3 +58,12 @@ - [ ] Environment Variables in Coolify eintragen, nicht ins Repo committen - [ ] Domain auf den `app`-Service und Container-Port `3000` routen - [ ] Upload-Volume `kita_planer_uploads` bleibt zwischen Deployments erhalten + +## Notdienstalarm – ergänzende Betriebsprüfung + +- [ ] `NOTDIENST_JOB_SECRET` zufällig erzeugt, mindestens 32 Zeichen, nur als Runtime-Secret gespeichert. +- [ ] Schema-Upgrade gesichert und angewandt; `db push` oder einmaliges SQL-Upgrade, nicht beides hintereinander. +- [ ] Worker bzw. Scheduler startet nach Neustart automatisch und ruft die geschützte Jobroute jede Minute auf. +- [ ] Fristablauf mit geschlossenem Browser geprüft: einmaliger Hinweis an Admins und Koordination. +- [ ] Versanddienst und öffentliche App-Adresse geprüft; Rückmeldelinks erreichen die tatsächliche Domain. +- [ ] Versandübersicht auf Fehler geprüft; Annahme durch den Maildienst und Postfachzustellung getrennt geprüft. diff --git a/deploy/production.env.example b/deploy/production.env.example index f3d8fb0..74693cd 100644 --- a/deploy/production.env.example +++ b/deploy/production.env.example @@ -36,6 +36,10 @@ RESEND_API_KEY="re_[PRODUCTION_API_KEY]" EMAIL_FROM="Kita-Planer <[absender@deine-domain.de]>" ADMIN_EMAIL="[kontakt@deine-domain.de]" +# Automatische Notdienst-Fristpruefung im App-Container. +# Eigenes Secret, mindestens 32 Zeichen; generieren mit: openssl rand -hex 32 +NOTDIENST_JOB_SECRET="[GENERATE_ME]" + # Persistenter Upload-Speicher fuer Anhaenge vom Schwarzen Brett. # Muss auf dem Server dauerhaft erhalten bleiben und darf nicht im Repo liegen. UPLOAD_DIR="/var/lib/kita-planer/uploads/announcements" diff --git a/deploy/sql/notdienstalarm.sql b/deploy/sql/notdienstalarm.sql new file mode 100644 index 0000000..df4184c --- /dev/null +++ b/deploy/sql/notdienstalarm.sql @@ -0,0 +1,160 @@ +-- Additives Upgrade nach personalbereich.sql und personalbereich-email.sql. +-- Einmalig vor dem Start der neuen Anwendung ausführen, sofern nicht prisma db push verwendet wird. +BEGIN; + +-- CreateEnum +CREATE TYPE "NotdienstAlarmStatus" AS ENUM ('PENDING', 'CONFIRMED', 'DECLINED', 'LATE_CONFIRMATION', 'REVIEW_REQUIRED', 'CANCELLED'); + +-- AlterTable +ALTER TABLE "notdienst_alerts" ADD COLUMN "alarmId" TEXT; + +-- CreateTable +CREATE TABLE "notdienst_alarms" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "assignmentId" TEXT, + "activeAssignmentId" TEXT, + "familyId" TEXT NOT NULL, + "familyName" TEXT NOT NULL, + "childName" TEXT NOT NULL, + "date" DATE NOT NULL, + "wholeDay" BOOLEAN NOT NULL DEFAULT true, + "startMinute" INTEGER, + "endMinute" INTEGER, + "expiresAt" TIMESTAMP(3) NOT NULL, + "deadline" TIMESTAMP(3) NOT NULL, + "status" "NotdienstAlarmStatus" NOT NULL DEFAULT 'PENDING', + "triggeredById" TEXT, + "triggeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "respondedById" TEXT, + "respondedAt" TIMESTAMP(3), + "responseSource" TEXT, + "escalatedAt" TIMESTAMP(3), + "educatorId" TEXT, + "notes" TEXT, + "cancelledAt" TIMESTAMP(3), + "cancelReason" TEXT, + + CONSTRAINT "notdienst_alarms_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notdienst_alarm_recipients" ( + "id" TEXT NOT NULL, + "alarmId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + + CONSTRAINT "notdienst_alarm_recipients_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notdienst_alarm_notifications" ( + "id" TEXT NOT NULL, + "alarmId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "eventKey" TEXT NOT NULL, + "message" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "readAt" TIMESTAMP(3), + + CONSTRAINT "notdienst_alarm_notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notdienst_alarm_emails" ( + "id" TEXT NOT NULL, + "alarmId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "eventKey" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "message" TEXT NOT NULL, + "path" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sentAt" TIMESTAMP(3), + "skippedAt" TIMESTAMP(3), + "leaseUntil" TIMESTAMP(3), + "failed" BOOLEAN NOT NULL DEFAULT false, + "attempts" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "notdienst_alarm_emails_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notdienst_alarm_history" ( + "id" TEXT NOT NULL, + "alarmId" TEXT NOT NULL, + "actorId" TEXT, + "action" TEXT NOT NULL, + "details" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notdienst_alarm_history_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "notdienst_alarms_activeAssignmentId_key" ON "notdienst_alarms"("activeAssignmentId"); + +-- CreateIndex +CREATE INDEX "notdienst_alarms_kitaId_date_idx" ON "notdienst_alarms"("kitaId", "date"); + +-- CreateIndex +CREATE INDEX "notdienst_alarms_status_deadline_idx" ON "notdienst_alarms"("status", "deadline"); + +-- CreateIndex +CREATE UNIQUE INDEX "notdienst_alarm_recipients_token_key" ON "notdienst_alarm_recipients"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "notdienst_alarm_recipients_alarmId_userId_key" ON "notdienst_alarm_recipients"("alarmId", "userId"); + +-- CreateIndex +CREATE INDEX "notdienst_alarm_notifications_userId_readAt_idx" ON "notdienst_alarm_notifications"("userId", "readAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "notdienst_alarm_notifications_alarmId_userId_eventKey_key" ON "notdienst_alarm_notifications"("alarmId", "userId", "eventKey"); + +-- CreateIndex +CREATE INDEX "notdienst_alarm_emails_sentAt_skippedAt_createdAt_idx" ON "notdienst_alarm_emails"("sentAt", "skippedAt", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "notdienst_alarm_emails_alarmId_userId_eventKey_key" ON "notdienst_alarm_emails"("alarmId", "userId", "eventKey"); + +-- CreateIndex +CREATE INDEX "notdienst_alarm_history_alarmId_createdAt_idx" ON "notdienst_alarm_history"("alarmId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alarm_recipients" ADD CONSTRAINT "notdienst_alarm_recipients_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alarm_recipients" ADD CONSTRAINT "notdienst_alarm_recipients_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alarm_notifications" ADD CONSTRAINT "notdienst_alarm_notifications_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alarm_notifications" ADD CONSTRAINT "notdienst_alarm_notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alarm_emails" ADD CONSTRAINT "notdienst_alarm_emails_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alarm_emails" ADD CONSTRAINT "notdienst_alarm_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alarm_history" ADD CONSTRAINT "notdienst_alarm_history_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE SET NULL ON UPDATE CASCADE; + + +COMMIT; diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 4fbc043..baa22d7 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -38,6 +38,7 @@ services: RESEND_API_KEY: ${RESEND_API_KEY:?} EMAIL_FROM: ${EMAIL_FROM:?} ADMIN_EMAIL: ${ADMIN_EMAIL:?} + NOTDIENST_JOB_SECRET: ${NOTDIENST_JOB_SECRET:?} UPLOAD_DIR: /app/uploads/announcements volumes: - kita_planer_uploads:/app/uploads diff --git a/docs/FUNKTIONSUEBERSICHT.md b/docs/FUNKTIONSUEBERSICHT.md index 41852ec..bd2be46 100644 --- a/docs/FUNKTIONSUEBERSICHT.md +++ b/docs/FUNKTIONSUEBERSICHT.md @@ -1,5 +1,11 @@ # Kita-Planer – Funktionsübersicht und aktueller Stand +## 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. + +Die folgende ursprüngliche Bestandsaufnahme bleibt als historischer Stand erhalten; darin als offen bezeichnete Alarmfunktionen sind durch diese Ergänzung überholt. + Stand: **07.10.2026** Geprüfter Ausgangsbranch: `codex/notdienst-swaps` diff --git a/docs/NOTDIENSTALARM.md b/docs/NOTDIENSTALARM.md new file mode 100644 index 0000000..1be07af --- /dev/null +++ b/docs/NOTDIENSTALARM.md @@ -0,0 +1,65 @@ +# Notdienstalarm + +Stand: 09.10.2026 · Umsetzung auf `codex/notdienstalarm` + +## Bedienung + +Kita-Admins und Notdienst-Koordination finden den Alarm in der Navigation und bei eingeteilten Tagen eines veröffentlichten Notdienstplans. Der Dialog zeigt die betroffene Familie und ihre Elternadressen vor dem Auslösen. Er umfasst einen ganzen Kita-Tag oder ein Zeitfenster, eine Antwortfrist (standardmäßig 30 Minuten), eine optionale interne Fachkraftzuordnung und eine interne Notiz. + +Nur die eingeteilte Familie wird angefragt. Jedes Elternkonto erhält einen eigenen persönlichen Rückmeldelink und einen Hinweis in der App. Die Antwort „Wir übernehmen“ oder „Wir können nicht“ muss ausdrücklich gespeichert werden und gilt für den gesamten Haushalt. Das Öffnen des Links speichert keine Antwort. Die erste gespeicherte Antwort entscheidet auch bei gleichzeitigen Antworten; Änderungen dokumentiert die Koordination. + +| Anzeige | Bedeutung / nächster Schritt | +| --- | --- | +| Antwort ausstehend | Haushalt hat noch nicht geantwortet. | +| Übernahme bestätigt | Rechtzeitige Zusage oder dokumentierte Freigabe durch Koordination. | +| Absage – Klärung erforderlich | Alle Admins und KoordinatorInnen der Kita erhalten sofort einen Hinweis. Ersatz manuell organisieren. | +| Klärung erforderlich – Frist abgelaufen | Nach Ablauf informiert der Hintergrundjob einmalig die Admins und KoordinatorInnen. | +| Verspätete Zusage | Muss durch Koordination freigegeben werden; keine automatische sichere Besetzung. | +| Klärung erforderlich | Uneindeutige frühere Einzelantworten wurden übernommen. Haushalt kontaktieren. | +| Storniert | Haushalt wird informiert; der Link kann keine Antwort mehr speichern. | + +Telefonische Rückmeldungen, Korrekturen, verspätete Freigaben und Stornierungen benötigen eine interne Begründung. Interne Fachkraftdaten, Notizen und Verlauf erscheinen ausschließlich in der Verwaltungsansicht und werden nicht per E-Mail übermittelt. Eltern sehen ihre eigenen Alarme; andere Haushalte und Fachkräfte haben keinen Zugriff. + +Aktive Alarme sperren manuelle Umzuordnung und Diensttausche. Für einen Ersatz zuerst den Alarm stornieren, dann die Einteilung ändern und neu alarmieren. Ein ausstehender E-Mail-Versand lässt sich erneut versuchen, ohne die Antwortfrist neu zu starten. Die Anzeige „vom Maildienst angenommen“ bestätigt die Annahme durch den Versanddienst, nicht die Zustellung im Postfach. + +## Zeiten und Grenzen + +- Kita-Zeitzone: `Europe/Berlin`, einschließlich Sommer-/Winterzeit. Nicht existierende Uhrzeiten bei der Zeitumstellung werden abgelehnt. +- Ganze Tage enden um Mitternacht zum Folgetag. Zeitfenster müssen am selben Tag liegen; Ende nach Beginn. +- Die Antwortfrist startet beim Auslösen, beträgt 1–1440 Minuten und endet spätestens mit dem Einsatz. Alarme sind für heute und zukünftige veröffentlichte Einteilungen möglich. +- Nach Einsatzende können Rückmeldelinks keine Antwort mehr speichern. Der letzte Status bleibt lesbar. +- Die Übersicht zeigt bis zu 100 Alarme seit 30 Tagen vor heute. Direkte Detail-Links öffnen auch ältere Vorgänge. Im Dialog stehen Einteilungen der nächsten 93 Tage zur Wahl; das Dashboard zeigt bis zu zehn aktuelle Alarme. +- Kein SMS-/Push-Versand und keine automatische Alarmierung anderer Familien. Notdienstmails gehören zum Alarmablauf und verwenden nicht die persönliche Einstellung für Personal-Antragsmails. + +## Betrieb und Hintergrundjob + +Benötigt werden die vorhandene Resend-Konfiguration (`RESEND_API_KEY`, `EMAIL_FROM`), die korrekte öffentliche App-Adresse (`NEXT_PUBLIC_SITE_URL`, alternativ `AUTH_URL`/`NEXTAUTH_URL`) und neu `NOTDIENST_JOB_SECRET` mit mindestens 32 Zeichen. Ein zufälliges Secret kann beispielsweise mit `openssl rand -hex 32` erzeugt und in der Hosting-Konfiguration gespeichert werden. Das Secret gehört nicht in Git oder ins Browser-Frontend. + +Der bestehende Docker-Produktionsstart startet `scripts/notdienst-alarm-worker.mjs`. Dieser ruft nach fünf Sekunden und danach ungefähr jede Minute lokal `POST /api/jobs/notdienstalarme` auf. Fristmeldungen entstehen dadurch auch ohne geöffneten Browser, normalerweise innerhalb einer Minute nach Fristende, zuzüglich Laufzeit und möglicher Ausfälle. Bei fehlendem Secret startet der allgemeine Produktionsstart ohne Fristjob und die Verwaltungsseite zeigt einen Hinweis; die Coolify-Vorlage verlangt das Secret. Ein zu kurzes gesetztes Secret stoppt den Produktionsstart. + +Bei einem anderen Hosting-Verfahren muss der Worker als eigener dauerhaft überwachter Prozess gestartet werden oder ein externer Scheduler die Route mindestens einmal pro Minute per POST und `Authorization: Bearer ` aufrufen. Nur `npm run start` startet den Worker nicht. Der Scheduler/Host muss auch nach Neustarts laufen; ein gesetztes Secret allein belegt keine laufende Fristprüfung. + +Der Job bearbeitet bis zu 100 Fristfälle und 100 E-Mail-Aufträge je Lauf. Wiederholte und gleichzeitige Aufrufe werden abgefangen: Fristmeldungen besitzen einen eindeutigen Ereignisschlüssel, Mailaufträge eine Versandreservierung und einen stabilen Idempotenzschlüssel. Versandfehler bleiben gespeichert und werden bei späteren Läufen erneut versucht; bereits angenommene Aufträge werden nicht erneut angefragt. Rollen- und Haushaltszuordnungen werden vor dem Versand erneut geprüft. Überholte Anfragen werden übersprungen. Provider-Garantien zur Idempotenz und tatsächliche Postfachzustellung bleiben Grenzen außerhalb der Anwendung. + +HTTP 401 bedeutet fehlende/falsche Berechtigung, 503 fehlendes/zu kurzes Job-Secret, 500 einen fehlgeschlagenen Lauf. HTTP 200 enthält die Bearbeitungszahlen einschließlich fehlgeschlagener E-Mails; die Versandübersicht und Server-/Worker-Logs sind für die Betriebsprüfung heranzuziehen. Bei Ausfall wird die Fristüberschreitung schon anhand der Uhrzeit in der Oberfläche angezeigt; E-Mail-Eskalation und Reservierungsfreigabe benötigen den nächsten erfolgreichen Joblauf. + +## Datenbank und vorhandene Alarme + +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 Stand nach Personalbereich und Personalbereich-E-Mail 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. + +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. + +## Prüfung + +Abschließender Prüfstand: 20 Alarmtests, 17 Personal-Service-Tests und neun Berechnungstests erfolgreich; TypeScript, ESLint der betroffenen Dateien und Produktionsbuild erfolgreich. Das additive SQL-Upgrade wurde auf einem separaten Schema der isolierten Datenbank angewandt und per Prisma mit dem Zielmodell verglichen: keine Abweichungen. Worker und Jobroute wurden lokal geprüft (401 ohne Secret, 200 mit Secret, Fristmeldung je zuständigem Konto einmalig). Die vorhandenen globalen ESLint-Fehler in `auth-utils.ts` liegen außerhalb dieser Änderung. + +Automatisierte Alarmtests prüfen Rollen und Mandanten, gemeinsame und konkurrierende Antworten, Fristprüfung, Absagen, verspätete Freigaben, Stornierung, Tokens, Versandfehler/erneuten Versand, Altbestände sowie die Sperre vorhandener Plan- und Diensttausch-Aktionen. Sie benötigen ausdrücklich die isolierte lokale Testdatenbank `127.0.0.1:55439/kita_staff_test` und bereinigen nur ihre eigenen Test-Kitas. + +```sh +DATABASE_URL='postgresql://USER@127.0.0.1:55439/kita_staff_test' npm run test:alarms +npm run test:staff +``` + +Die Browserprüfung verwendet ausschließlich Demo-Daten und deaktivierten tatsächlichen E-Mail-Versand. Geprüft wurden Auslösen aus der Einteilung, Zeitfenster, Eltern-Dashboard-Hinweis, persönliche Rückmeldeseite mit expliziter Bestätigung und eine mobile Ansicht ohne horizontalen Überlauf. Produktive Zustellung und Deployment sind separat durchzuführen. diff --git a/docs/NOTDIENSTALARM_PLAN.md b/docs/NOTDIENSTALARM_PLAN.md index 9260dc4..f86cf78 100644 --- a/docs/NOTDIENSTALARM_PLAN.md +++ b/docs/NOTDIENSTALARM_PLAN.md @@ -2,7 +2,7 @@ Stand: 09.10.2026 · Branch `codex/notdienstalarm` -Status: Planung. Die folgenden Erweiterungen sind noch nicht implementiert. +Status: Umgesetzt. Dieser Plan hält die abgestimmten Entscheidungen fest. Bedienung, Betriebsanforderungen und Prüfungen stehen in [NOTDIENSTALARM.md](NOTDIENSTALARM.md). ## Festgelegter Umfang diff --git a/package.json b/package.json index a5bd26b..dbb2375 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "lint": "eslint", "test:staff": "tsx --test tests/staff-calculations.test.ts", "test:staff:integration": "tsx --test tests/staff-service.test.ts", + "test:alarms": "tsx --test tests/notdienst-alarm.test.ts", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 57ffc50..d071404 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -84,6 +84,15 @@ enum NotdienstAlertStatus { CANCELLED } +enum NotdienstAlarmStatus { + PENDING + CONFIRMED + DECLINED + LATE_CONFIRMATION + REVIEW_REQUIRED + CANCELLED +} + enum NotdienstSwapStatus { PENDING ACCEPTED @@ -143,6 +152,7 @@ model Kita { notdienstAvailabilities NotdienstAvailability[] notdienstAssignments NotdienstAssignment[] notdienstAlerts NotdienstAlert[] + notdienstAlarms NotdienstAlarm[] notdienstSwapRequests NotdienstSwapRequest[] staffProfiles StaffProfile[] staffHolidays StaffHoliday[] @@ -160,7 +170,8 @@ model Family { kitaId String kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade) - name String + name String + notdienstAlarms NotdienstAlarm[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -240,11 +251,14 @@ model User { termineApproved Termin[] @relation("TerminApprover") mitbringselItems MitbringselItem[] - invitationsCreated Invitation[] @relation("InvitationCreator") - auditLogsAuthored AuditLog[] @relation("AuditLogActor") + invitationsCreated Invitation[] @relation("InvitationCreator") + auditLogsAuthored AuditLog[] @relation("AuditLogActor") staffProfile StaffProfile? staffNotifications StaffNotification[] staffEmails StaffEmail[] + alarmRecipients NotdienstAlarmRecipient[] + alarmNotifications NotdienstAlarmNotification[] + alarmEmails NotdienstAlarmEmail[] @@index([kitaId]) @@index([familyId]) @@ -890,6 +904,7 @@ model NotdienstAssignment { updatedAt DateTime @updatedAt alerts NotdienstAlert[] + alarms NotdienstAlarm[] swapRequestsAsTarget NotdienstSwapRequest[] @relation("NotdienstSwapTargetAssignment") swapRequestsAsOffered NotdienstSwapRequest[] @relation("NotdienstSwapOfferedAssignment") @@ -936,10 +951,116 @@ model NotdienstSwapRequest { /// Aktive Alarmierung — wird bei Krankmeldung einer Fachkraft /// vom Koordinator ausgelöst. Bestätigung via Magic-Link (Token). +model NotdienstAlarm { + id String @id @default(cuid()) + kitaId String + kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade) + assignmentId String? + assignment NotdienstAssignment? @relation(fields: [assignmentId], references: [id], onDelete: SetNull) + // Nullable unique reservation: cancelled or ended alarms release the assignment. + activeAssignmentId String? @unique + familyId String + family Family @relation(fields: [familyId], references: [id], onDelete: Cascade) + familyName String + childName String + date DateTime @db.Date + wholeDay Boolean @default(true) + startMinute Int? + endMinute Int? + expiresAt DateTime + deadline DateTime + status NotdienstAlarmStatus @default(PENDING) + triggeredById String? + triggeredAt DateTime @default(now()) + respondedById String? + respondedAt DateTime? + responseSource String? + escalatedAt DateTime? + educatorId String? + notes String? + cancelledAt DateTime? + cancelReason String? + recipients NotdienstAlarmRecipient[] + notifications NotdienstAlarmNotification[] + emails NotdienstAlarmEmail[] + history NotdienstAlarmHistory[] + legacyAlerts NotdienstAlert[] + + @@index([kitaId, date]) + @@index([status, deadline]) + @@map("notdienst_alarms") +} + +model NotdienstAlarmRecipient { + id String @id @default(cuid()) + alarmId String + alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @unique + + @@unique([alarmId, userId]) + @@map("notdienst_alarm_recipients") +} + +model NotdienstAlarmNotification { + id String @id @default(cuid()) + alarmId String + alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + eventKey String + message String + createdAt DateTime @default(now()) + readAt DateTime? + + @@unique([alarmId, userId, eventKey]) + @@index([userId, readAt]) + @@map("notdienst_alarm_notifications") +} + +model NotdienstAlarmEmail { + id String @id @default(cuid()) + alarmId String + alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + eventKey String + kind String + subject String + message String + path String + createdAt DateTime @default(now()) + sentAt DateTime? + skippedAt DateTime? + leaseUntil DateTime? + failed Boolean @default(false) + attempts Int @default(0) + + @@unique([alarmId, userId, eventKey]) + @@index([sentAt, skippedAt, createdAt]) + @@map("notdienst_alarm_emails") +} + +model NotdienstAlarmHistory { + id String @id @default(cuid()) + alarmId String + alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade) + actorId String? + action String + details String + createdAt DateTime @default(now()) + + @@index([alarmId, createdAt]) + @@map("notdienst_alarm_history") +} + model NotdienstAlert { - id String @id @default(cuid()) - kitaId String - kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + alarmId String? + alarm NotdienstAlarm? @relation(fields: [alarmId], references: [id], onDelete: SetNull) + kitaId String + kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade) assignmentId String assignment NotdienstAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) diff --git a/scripts/notdienst-alarm-worker.mjs b/scripts/notdienst-alarm-worker.mjs new file mode 100644 index 0000000..7a6b9a2 --- /dev/null +++ b/scripts/notdienst-alarm-worker.mjs @@ -0,0 +1,42 @@ +// Runs inside the application container. No external cron service or browser is required. +const secret = process.env.NOTDIENST_JOB_SECRET; +if (!secret || secret.length < 32) { + console.error( + "Notdienst-Fristprüfung deaktiviert: NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben.", + ); + process.exit(1); +} +const port = Number(process.env.PORT || 3000); +if (!Number.isInteger(port) || port < 1 || port > 65535) process.exit(1); +let stopped = false; +let timer; +async function tick() { + const started = Date.now(); + try { + const response = await fetch( + `http://127.0.0.1:${port}/api/jobs/notdienstalarme`, + { + method: "POST", + headers: { authorization: `Bearer ${secret}` }, + signal: AbortSignal.timeout(50000), + }, + ); + if (!response.ok) + console.error( + `Notdienst-Fristprüfung: HTTP ${response.status}; erneuter Versuch in einer Minute.`, + ); + } catch { + console.error( + "Notdienst-Fristprüfung momentan nicht erreichbar; erneuter Versuch in einer Minute.", + ); + } + if (!stopped) + timer = setTimeout(tick, Math.max(1000, 60000 - (Date.now() - started))); +} +for (const signal of ["SIGINT", "SIGTERM"]) + process.on(signal, () => { + stopped = true; + clearTimeout(timer); + process.exit(0); + }); +timer = setTimeout(tick, 5000); diff --git a/scripts/start-production.sh b/scripts/start-production.sh index 0ee8b4a..247ee4d 100755 --- a/scripts/start-production.sh +++ b/scripts/start-production.sh @@ -5,4 +5,13 @@ echo "Applying Prisma schema..." npx prisma db push --skip-generate echo "Starting Kita-Planer..." +if [ -n "${NOTDIENST_JOB_SECRET:-}" ]; then + if [ "${#NOTDIENST_JOB_SECRET}" -lt 32 ]; then + echo "NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben." >&2 + exit 1 + fi + node scripts/notdienst-alarm-worker.mjs & +else + echo "Notdienst-Fristprüfung deaktiviert: NOTDIENST_JOB_SECRET fehlt." +fi exec npm run start:prod diff --git a/src/app/alert/[token]/actions.ts b/src/app/alert/[token]/actions.ts index 6b5536d..393b875 100644 --- a/src/app/alert/[token]/actions.ts +++ b/src/app/alert/[token]/actions.ts @@ -1,34 +1,39 @@ "use server"; - import { revalidatePath } from "next/cache"; -import { NotdienstAlertStatus } from "@prisma/client"; -import { prisma } from "@/lib/prisma"; +import { respondToAlarm } from "@/lib/alarms/service"; +import { deliverAlarmEmails } from "@/lib/alarms/email"; +import { rateLimit } from "@/lib/rate-limit"; -export async function confirmAlertAction(token: string) { +export async function respondAlertAction(token: string, approve: boolean) { + if (typeof token !== "string" || token.length > 128) + return { error: "Ungültiger Rückmeldelink." }; + if (!rateLimit(`alarm-response:${token}`, 12, 60).allowed) + return { error: "Bitte kurz warten und erneut versuchen." }; try { - const alert = await prisma.notdienstAlert.findUnique({ - where: { confirmationToken: token }, - }); - - if (!alert) { - return { error: "Ungültiger Bestätigungs-Link." }; + const result = await respondToAlarm(token, approve); + // The household response is already committed; transport failure must not undo it. + try { + await deliverAlarmEmails(result.emailIds); + } catch { + /* Persistent jobs will retry. */ } - - if (alert.status !== NotdienstAlertStatus.PENDING) { - return { error: "Dieser Alarm wurde bereits bestätigt oder abgebrochen." }; - } - - await prisma.notdienstAlert.update({ - where: { id: alert.id }, - data: { - status: NotdienstAlertStatus.CONFIRMED, - confirmedAt: new Date(), - }, - }); - + revalidatePath("/alert/[token]", "page"); revalidatePath("/dashboard"); + revalidatePath("/dashboard/notdienst/alarm"); return { success: true }; - } catch { - return { error: "Fehler bei der Bestätigung." }; + } catch (error) { + if ( + error instanceof Error && + !error.message.includes("prisma") && + !error.message.includes("Invalid `") + ) + return { error: error.message }; + return { + error: + "Die Rückmeldung konnte nicht gespeichert werden. Bitte den aktuellen Stand prüfen.", + }; } } +export async function confirmAlertAction(token: string) { + return respondAlertAction(token, true); +} diff --git a/src/app/alert/[token]/alert-confirm-form.tsx b/src/app/alert/[token]/alert-confirm-form.tsx index 6310c47..f78ce81 100644 --- a/src/app/alert/[token]/alert-confirm-form.tsx +++ b/src/app/alert/[token]/alert-confirm-form.tsx @@ -1,37 +1,65 @@ "use client"; - -import { useTransition } from "react"; -import { Loader2 } from "lucide-react"; -import { toast } from "sonner"; - +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { confirmAlertAction } from "./actions"; +import { respondAlertAction } from "./actions"; export function AlertConfirmForm({ token }: { token: string }) { - const [isPending, startTransition] = useTransition(); - - const handleConfirm = () => { - startTransition(async () => { - const result = await confirmAlertAction(token); - if ("error" in result && result.error) { - toast.error(result.error as string); - } else { - toast.success("Erfolgreich bestätigt!"); - } - }); - }; - + const [choice, setChoice] = useState(null); + const [pending, start] = useTransition(); + const [error, setError] = useState(""); + const router = useRouter(); return ( - +
+ {choice === null ? ( +
+ + +
+ ) : ( +
+

+ {choice + ? "Wir sagen für unseren Haushalt zu." + : "Unser Haushalt kann diesen Einsatz nicht übernehmen."}{" "} + Diese Antwort gilt für alle Elternkonten des Haushalts. +

+
+ + +
+
+ )} + {error && ( +

+ {error} +

+ )} +
); } diff --git a/src/app/alert/[token]/page.tsx b/src/app/alert/[token]/page.tsx index 174a015..d4bc5fe 100644 --- a/src/app/alert/[token]/page.tsx +++ b/src/app/alert/[token]/page.tsx @@ -1,127 +1,114 @@ import { notFound } from "next/navigation"; -import { format } from "date-fns"; -import { de } from "date-fns/locale"; -import { BellRing, ShieldCheck, XCircle } from "lucide-react"; -import { NotdienstAlertStatus } from "@prisma/client"; - -import { prisma } from "@/lib/prisma"; +import { BellRing, CheckCircle2 } from "lucide-react"; +import { getPublicAlarm } from "@/lib/alarms/service"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; + alarmLabel, + dateKey, + durationLabel, + instantLabel, +} from "@/lib/alarms/format"; import { AlertConfirmForm } from "./alert-confirm-form"; -export const metadata = { title: "Notdienst Alarm · Kita-Planer" }; - +export const metadata = { + title: "Notdienstalarm · Kita-Planer", + robots: { index: false, follow: false }, + referrer: "no-referrer" as const, +}; +export const dynamic = "force-dynamic"; export default async function AlertPage({ params, }: { params: Promise<{ token: string }>; }) { const { token } = await params; - - const alert = await prisma.notdienstAlert.findUnique({ - where: { confirmationToken: token }, - select: { - status: true, - parentUser: { - select: { - firstName: true, - }, - }, - kita: { - select: { - name: true, - }, - }, - assignment: { - select: { - date: true, - child: { - select: { - firstName: true, - lastName: true, - }, - }, - }, - }, - }, - }); - - if (!alert) { - notFound(); - } - - const isPending = alert.status === NotdienstAlertStatus.PENDING; - const isConfirmed = alert.status === NotdienstAlertStatus.CONFIRMED; - + const alarm = await getPublicAlarm(token); + if (!alarm) notFound(); + const now = new Date(), + expired = alarm.expiresAt <= now; + const canRespond = alarm.status === "PENDING" && !expired; + const overdue = alarm.deadline <= now; return ( -
- - -
- {isPending ? ( - - ) : isConfirmed ? ( - - ) : ( - - )} -
- - {isPending - ? "Notdienst Einsatz!" - : isConfirmed - ? "Einsatz bestätigt" - : "Alarm abgebrochen"} - - - Kita {alert.kita.name} - -
- -
-

Datum

-

- {format(alert.assignment.date, "EEEE, dd. MMMM yyyy", { - locale: de, - })} -

-
-

Eingeteiltes Kind

-

- {alert.assignment.child.firstName}{" "} - {alert.assignment.child.lastName} -

-
- -
- {isPending ? ( - <> -

- Hallo {alert.parentUser.firstName}, eine Fachkraft hat sich - krankgemeldet und du bist heute für den Notdienst eingeteilt. - Bitte bestätige deinen Einsatz schnellstmöglich! +

+
+
+

+ Kita-Planer · {alarm.kitaName} +

+ {alarm.status === "CONFIRMED" ? ( + + ) : ( + + )} +

+ {alarm.status === "PENDING" + ? "Könnt ihr den Notdienst übernehmen?" + : alarmLabel(alarm)} +

+

+ Hallo {alarm.firstName}, diese Rückmeldung gilt für euren gesamten + Haushalt. +

+
+
+
+
+
Datum
+
+ {dateKey(alarm.date).split("-").reverse().join(".")} +
+
+
+
Einsatz
+
{durationLabel(alarm)}
+
+
+
Haushalt
+
{alarm.familyName}
+
+
+
Antwortfrist
+
+ {instantLabel(alarm.deadline)} Uhr +
+
+
+ {canRespond ? ( + <> + {overdue && ( +

+ Die Antwortfrist ist abgelaufen. Ihr könnt noch antworten. + Eine verspätete Zusage muss die Koordination freigeben, da + möglicherweise bereits Ersatz organisiert wird.

- - - ) : isConfirmed ? ( -

- Danke, {alert.parentUser.firstName}! Dein Einsatz wurde - erfolgreich bestätigt. Die Kita rechnet mit dir. -

- ) : ( -

- Dieser Einsatz wurde vom Koordinator abgebrochen. Du musst nicht - einspringen. -

- )} -
- - -
+ )} + + + ) : ( +
+ {alarm.status === "CANCELLED" + ? "Der Einsatz wurde storniert. Ihr müsst für diesen Alarm nicht einspringen." + : alarm.status === "CONFIRMED" + ? "Euer Haushalt hat zugesagt. Die Kita rechnet mit euch. Bei einer Änderung kontaktiert bitte die Koordination." + : alarm.status === "LATE_CONFIRMATION" + ? "Eure verspätete Zusage ist gespeichert. Wartet bitte auf die Freigabe durch die Koordination." + : alarm.status === "DECLINED" + ? "Die Absage eures Haushalts ist gespeichert. Die Koordination kümmert sich um Ersatz." + : alarm.status === "REVIEW_REQUIRED" + ? "Die bisherigen Rückmeldungen müssen von der Koordination geprüft werden. Bitte kontaktiert die Kita." + : "Der Einsatz ist beendet. Dieser Rückmeldelink ist abgelaufen."} +
+ )} +

+ Den persönlichen Link bitte innerhalb eures Haushalts behalten. + Änderungen einer gespeicherten Antwort klärt ihr mit der Kita. +

+
+ + ); } diff --git a/src/app/api/jobs/notdienstalarme/route.ts b/src/app/api/jobs/notdienstalarme/route.ts new file mode 100644 index 0000000..dde469e --- /dev/null +++ b/src/app/api/jobs/notdienstalarme/route.ts @@ -0,0 +1,32 @@ +import { revalidatePath } from "next/cache"; +import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export async function POST(request: Request) { + const status = authorizeAlarmJob(request.headers.get("authorization")); + if (status !== 200) + return Response.json( + { + error: + status === 503 + ? "Fristprüfung ist nicht konfiguriert." + : "Nicht autorisiert.", + }, + { status }, + ); + try { + const result = await runAlarmJob(); + revalidatePath("/dashboard"); + revalidatePath("/dashboard/notdienst/alarm"); + return Response.json(result); + } catch { + return Response.json( + { + error: + "Fristprüfung fehlgeschlagen; nächster Aufruf versucht es erneut.", + }, + { status: 500 }, + ); + } +} diff --git a/src/app/dashboard/actions.ts b/src/app/dashboard/actions.ts index 1557dc8..0ea30b6 100644 --- a/src/app/dashboard/actions.ts +++ b/src/app/dashboard/actions.ts @@ -1,123 +1,10 @@ "use server"; - -import crypto from "crypto"; -import { createElement } from "react"; -import { format } from "date-fns"; -import { de } from "date-fns/locale"; -import { revalidatePath } from "next/cache"; -import { NotdienstAlertStatus, UserRole } from "@prisma/client"; - -import { AlertEmail } from "@/emails/AlertEmail"; -import { requireRole } from "@/lib/auth-utils"; -import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; -import { prisma } from "@/lib/prisma"; - -const BASE_URL = - process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"; - +import { alarmAction } from "./notdienst/alarm/actions"; +// Existing callers use the shared household workflow and the same guards. export async function triggerAlertAction(assignmentId: string) { - const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); - const kitaId = session.user.kitaId!; - - try { - const [existing, assignment] = await Promise.all([ - // Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt - prisma.notdienstAlert.findFirst({ - where: { assignmentId, kitaId }, - }), - prisma.notdienstAssignment.findFirst({ - where: { id: assignmentId, kitaId }, - include: { - child: { - select: { - id: true, - firstName: true, - lastName: true, - family: { - select: { - users: { - where: { role: UserRole.ELTERN }, - select: { - id: true, - email: true, - firstName: true, - lastName: true, - }, - }, - }, - }, - }, - }, - }, - }), - ]); - - if (existing) { - return { error: "Für diese Einteilung wurde bereits ein Alarm ausgelöst." }; - } - - if (!assignment) { - return { error: "Notdienst-Einteilung wurde nicht gefunden." }; - } - - const parents = assignment.child.family.users; - - if (parents.length === 0) { - return { error: "Kein Elternteil für diesen Notdienst hinterlegt." }; - } - - const mailConfigError = getAppEmailConfigError(); - if (mailConfigError) { - return { error: mailConfigError }; - } - - const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", { - locale: de, - }); - const childName = `${assignment.child.firstName} ${assignment.child.lastName}`; - const alertJobs: { email: string; token: string }[] = []; - - await prisma.$transaction( - parents.map((parent) => { - const token = crypto.randomUUID(); - alertJobs.push({ email: parent.email, token }); - return prisma.notdienstAlert.create({ - data: { - kitaId, - assignmentId, - parentUserId: parent.id, - triggeredById: session.user.id, - status: NotdienstAlertStatus.PENDING, - confirmationToken: token, - }, - }); - }), - ); - - for (const job of alertJobs) { - const emailResult = await sendAppEmail({ - to: job.email, - subject: `Dringender Notdienst-Alarm für ${dateLabel}`, - react: createElement(AlertEmail, { - date: dateLabel, - childName, - confirmLink: `${BASE_URL}/alert/${job.token}`, - }), - }); - - if (!emailResult.success) { - revalidatePath("/dashboard"); - return { - error: `Alarm wurde angelegt, aber mindestens eine E-Mail konnte nicht versendet werden: ${emailResult.error}`, - }; - } - } - - revalidatePath("/dashboard"); - - return { success: true }; - } catch (error) { - console.error(error); - return { error: "Fehler beim Auslösen des Alarms." }; - } + return alarmAction("create", { + assignmentId, + wholeDay: true, + replyMinutes: 30, + }); } diff --git a/src/app/dashboard/kalender/actions.ts b/src/app/dashboard/kalender/actions.ts index a4b18e9..faa5fb1 100644 --- a/src/app/dashboard/kalender/actions.ts +++ b/src/app/dashboard/kalender/actions.ts @@ -51,11 +51,15 @@ const activeNotdienstAlertStatuses = [ function getTodayUtcDateOnly() { const today = new Date(); - return new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate())); + return new Date( + Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()), + ); } function getBaseUrl() { - return process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"; + return ( + process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000" + ); } function formatNotdienstDate(date: Date) { @@ -68,7 +72,7 @@ function revalidateCalendarAndNotdienst() { revalidatePath("/dashboard/notdienst/plan"); revalidatePath("/dashboard/notdienst"); revalidatePath("/dashboard"); - } catch (e) { + } catch { // Ignore if called outside Next.js context } } @@ -94,6 +98,7 @@ type SwapAssignmentWithFamily = { alerts: { id: string; }[]; + alarms?: { id: string }[]; }; async function getSwapAssignment( @@ -126,7 +131,11 @@ async function getSwapAssignment( }, }, alerts: { - where: { status: { in: activeNotdienstAlertStatuses } }, + where: { alarmId: null, status: { in: activeNotdienstAlertStatuses } }, + select: { id: true }, + }, + alarms: { + where: { activeAssignmentId: { not: null } }, select: { id: true }, }, }, @@ -171,7 +180,12 @@ function validateSwapAssignmentPair({ return "Du kannst keinen Tausch mit deinem eigenen Haushalt anfragen."; } - if (targetAssignment.alerts.length > 0 || offeredAssignment.alerts.length > 0) { + if ( + targetAssignment.alerts.length > 0 || + offeredAssignment.alerts.length > 0 || + targetAssignment.alarms?.length || + offeredAssignment.alarms?.length + ) { return "Notdienste mit aktiver Alarmierung können nicht getauscht werden."; } @@ -215,7 +229,10 @@ async function sendSwapMail({ }); if (!result.success) { - console.error("Notdienst-Tausch-Mail konnte nicht versendet werden:", result.error); + console.error( + "Notdienst-Tausch-Mail konnte nicht versendet werden:", + result.error, + ); } } @@ -264,7 +281,9 @@ function buildTerminData(data: z.infer) { now.setSeconds(0, 0); if (startDate < now) { - return { error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen." }; + return { + error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen.", + }; } if (endDate < startDate) { @@ -286,9 +305,7 @@ function buildTerminData(data: z.infer) { ? new Date(data.rsvpDeadline) : null, participantInfo: - data.requiresRsvp && data.participantInfo - ? data.participantInfo - : null, + data.requiresRsvp && data.participantInfo ? data.participantInfo : null, }, }; } @@ -347,7 +364,10 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) { return { error: "Notdienst nicht gefunden." }; } if (existingRequest) { - return { error: "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage." }; + return { + error: + "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.", + }; } await prisma.notdienstSwapRequest.create({ @@ -394,128 +414,170 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) { const today = getTodayUtcDateOnly(); try { - const result = await prisma.$transaction(async (tx) => { - const request = await tx.notdienstSwapRequest.findFirst({ - where: { - id: parsed.data, - kitaId: session.user.kitaId, - status: NotdienstSwapStatus.PENDING, - }, - select: { - id: true, - requesterFamilyId: true, - targetAssignmentId: true, - offeredAssignmentId: true, - targetAssignment: { - select: { - id: true, - childId: true, - date: true, - plan: { select: { status: true } }, - child: { - select: { - familyId: true, - family: { - select: { - name: true, - users: { - where: { role: UserRole.ELTERN }, - select: { email: true, firstName: true, lastName: true }, + const result = await prisma.$transaction( + async (tx) => { + const request = await tx.notdienstSwapRequest.findFirst({ + where: { + id: parsed.data, + kitaId: session.user.kitaId, + status: NotdienstSwapStatus.PENDING, + }, + select: { + id: true, + requesterFamilyId: true, + targetAssignmentId: true, + offeredAssignmentId: true, + targetAssignment: { + select: { + id: true, + childId: true, + date: true, + plan: { select: { status: true } }, + child: { + select: { + familyId: true, + family: { + select: { + name: true, + users: { + where: { role: UserRole.ELTERN }, + select: { + email: true, + firstName: true, + lastName: true, + }, + }, }, }, }, }, - }, - alerts: { - where: { status: { in: activeNotdienstAlertStatuses } }, - select: { id: true }, + alerts: { + where: { + alarmId: null, + status: { in: activeNotdienstAlertStatuses }, + }, + select: { id: true }, + }, }, }, - }, - offeredAssignment: { - select: { - id: true, - childId: true, - date: true, - plan: { select: { status: true } }, - child: { - select: { - familyId: true, - family: { - select: { - name: true, - users: { - where: { role: UserRole.ELTERN }, - select: { email: true, firstName: true, lastName: true }, + offeredAssignment: { + select: { + id: true, + childId: true, + date: true, + plan: { select: { status: true } }, + child: { + select: { + familyId: true, + family: { + select: { + name: true, + users: { + where: { role: UserRole.ELTERN }, + select: { + email: true, + firstName: true, + lastName: true, + }, + }, }, }, }, }, - }, - alerts: { - where: { status: { in: activeNotdienstAlertStatuses } }, - select: { id: true }, + alerts: { + where: { + alarmId: null, + status: { in: activeNotdienstAlertStatuses }, + }, + select: { id: true }, + }, }, }, }, - }, - }); + }); - if (!request) { - throw new Error("Tauschanfrage nicht gefunden oder nicht mehr offen."); - } + if (!request) { + throw new Error( + "Tauschanfrage nicht gefunden oder nicht mehr offen.", + ); + } - if (request.targetAssignment.child.familyId !== session.user.familyId) { - throw new Error("Nur der angefragte Haushalt kann diese Anfrage annehmen."); - } + if ( + await tx.notdienstAlarm.count({ + where: { + activeAssignmentId: { + in: [request.targetAssignmentId, request.offeredAssignmentId], + }, + }, + }) + ) + throw new Error( + "Notdienste mit aktiver Alarmierung können nicht getauscht werden.", + ); - const validationError = validateSwapAssignmentPair({ - targetAssignment: request.targetAssignment, - offeredAssignment: request.offeredAssignment, - requesterFamilyId: request.requesterFamilyId, - today, - }); - if (validationError) { - throw new Error(validationError); - } + if (request.targetAssignment.child.familyId !== session.user.familyId) { + throw new Error( + "Nur der angefragte Haushalt kann diese Anfrage annehmen.", + ); + } - await tx.notdienstAssignment.update({ - where: { id: request.targetAssignmentId }, - data: { childId: request.offeredAssignment.childId }, - }); - await tx.notdienstAssignment.update({ - where: { id: request.offeredAssignmentId }, - data: { childId: request.targetAssignment.childId }, - }); - await tx.notdienstSwapRequest.update({ - where: { id: request.id }, - data: { - status: NotdienstSwapStatus.ACCEPTED, - respondedById: session.user.id, - respondedAt: new Date(), - }, - }); - await tx.notdienstSwapRequest.updateMany({ - where: { - id: { not: request.id }, - kitaId: session.user.kitaId, - status: NotdienstSwapStatus.PENDING, - OR: [ - { targetAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } }, - { offeredAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } }, - ], - }, - data: { - status: NotdienstSwapStatus.CANCELLED, - respondedAt: new Date(), - }, - }); + const validationError = validateSwapAssignmentPair({ + targetAssignment: request.targetAssignment, + offeredAssignment: request.offeredAssignment, + requesterFamilyId: request.requesterFamilyId, + today, + }); + if (validationError) { + throw new Error(validationError); + } - return { - targetAssignment: request.targetAssignment, - offeredAssignment: request.offeredAssignment, - }; - }); + await tx.notdienstAssignment.update({ + where: { id: request.targetAssignmentId }, + data: { childId: request.offeredAssignment.childId }, + }); + await tx.notdienstAssignment.update({ + where: { id: request.offeredAssignmentId }, + data: { childId: request.targetAssignment.childId }, + }); + await tx.notdienstSwapRequest.update({ + where: { id: request.id }, + data: { + status: NotdienstSwapStatus.ACCEPTED, + respondedById: session.user.id, + respondedAt: new Date(), + }, + }); + await tx.notdienstSwapRequest.updateMany({ + where: { + id: { not: request.id }, + kitaId: session.user.kitaId, + status: NotdienstSwapStatus.PENDING, + OR: [ + { + targetAssignmentId: { + in: [request.targetAssignmentId, request.offeredAssignmentId], + }, + }, + { + offeredAssignmentId: { + in: [request.targetAssignmentId, request.offeredAssignmentId], + }, + }, + ], + }, + data: { + status: NotdienstSwapStatus.CANCELLED, + respondedAt: new Date(), + }, + }); + + return { + targetAssignment: request.targetAssignment, + offeredAssignment: request.offeredAssignment, + }; + }, + { isolationLevel: "Serializable" }, + ); const kita = await prisma.kita.findUnique({ where: { id: session.user.kitaId }, @@ -550,7 +612,10 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) { return { success: true }; } catch (error) { return { - error: error instanceof Error ? error.message : "Tauschanfrage konnte nicht angenommen werden.", + error: + error instanceof Error + ? error.message + : "Tauschanfrage konnte nicht angenommen werden.", }; } } @@ -619,7 +684,9 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) { return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." }; } if (request.targetAssignment.child.familyId !== session.user.familyId) { - return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." }; + return { + error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen.", + }; } await prisma.notdienstSwapRequest.update({ @@ -706,19 +773,22 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) { revalidateCalendarAndNotdienst(); return { success: true }; } catch (error) { - console.error("Fehler beim Zurückziehen der Notdienst-Tauschanfrage:", error); + console.error( + "Fehler beim Zurückziehen der Notdienst-Tauschanfrage:", + error, + ); return { error: "Tauschanfrage konnte nicht zurückgezogen werden." }; } } export async function createTerminRequest(rawPayload: unknown) { const session = await requireKitaSession(); - + const parsed = terminSchema.safeParse(rawPayload); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } - + const data = parsed.data; const terminData = buildTerminData(data); @@ -755,12 +825,12 @@ export async function createTerminAdmin(rawPayload: unknown) { if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } - + const parsed = terminSchema.safeParse(rawPayload); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } - + const data = parsed.data; const terminData = buildTerminData(data); @@ -885,7 +955,10 @@ export async function rejectTermin(terminId: string, reason?: string) { } } -export async function toggleMitbringselList(terminId: string, enabled: boolean) { +export async function toggleMitbringselList( + terminId: string, + enabled: boolean, +) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId; if (!kitaId) { @@ -919,12 +992,12 @@ const mitbringselSchema = z.object({ export async function addMitbringsel(rawPayload: unknown) { const session = await requireKitaSession(); - + const parsed = mitbringselSchema.safeParse(rawPayload); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } - + const { terminId, content } = parsed.data; try { @@ -933,7 +1006,11 @@ export async function addMitbringsel(rawPayload: unknown) { where: { id: terminId, kitaId: session.user.kitaId }, }); - if (!termin || termin.status !== TerminStatus.CONFIRMED || !termin.mitbringselListEnabled) { + if ( + !termin || + termin.status !== TerminStatus.CONFIRMED || + !termin.mitbringselListEnabled + ) { return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." }; } diff --git a/src/app/dashboard/notdienst/alarm/actions.ts b/src/app/dashboard/notdienst/alarm/actions.ts new file mode 100644 index 0000000..f67cec8 --- /dev/null +++ b/src/app/dashboard/notdienst/alarm/actions.ts @@ -0,0 +1,59 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { requireAlarmActor } from "@/lib/alarms/auth"; +import { executeAlarmCommand } from "@/lib/alarms/service"; +import { deliverAlarmEmails } from "@/lib/alarms/email"; + +export type AlarmActionResult = { + error?: string; + warning?: string; + success?: boolean; + alarmId?: string; +}; +export async function alarmAction( + command: string, + data: Record, +): Promise { + const actor = await requireAlarmActor(command !== "readNotice"); + try { + const result = await executeAlarmCommand(actor, command, data); + let warning: string | undefined; + if (result.emailIds.length) { + try { + const delivery = await deliverAlarmEmails(result.emailIds); + if (delivery.failed) + warning = + "Gespeichert. Einige E-Mails warten noch auf den Versand. Bitte den Versandstand im Alarm prüfen."; + else if ( + process.env.NODE_ENV === "development" && + !process.env.RESEND_API_KEY + ) + warning = "Gespeichert. E-Mail-Versand lokal simuliert."; + } catch { + warning = "Gespeichert. Der E-Mail-Versand ist noch ausstehend."; + } + } + for (const path of [ + "/dashboard", + "/dashboard/notdienst/alarm", + "/dashboard/notdienst/plan", + "/dashboard/kalender", + ]) + revalidatePath(path); + revalidatePath("/alert/[token]", "page"); + return { success: true, alarmId: result.alarmId, warning }; + } catch (error) { + if (error instanceof z.ZodError) + return { + error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.", + }; + if ( + error instanceof Error && + !error.message.includes("prisma") && + !error.message.includes("Invalid `") + ) + return { error: error.message }; + return { error: "Die Alarmaktion konnte nicht gespeichert werden." }; + } +} diff --git a/src/app/dashboard/notdienst/alarm/dashboard-hints.tsx b/src/app/dashboard/notdienst/alarm/dashboard-hints.tsx new file mode 100644 index 0000000..aee3b6b --- /dev/null +++ b/src/app/dashboard/notdienst/alarm/dashboard-hints.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; +import { requireKitaSession } from "@/lib/auth-utils"; +import { getAlarmFeed, managerRoles } from "@/lib/alarms/service"; +import { + alarmLabel, + durationLabel, + dateKey, + needsClarification, +} from "@/lib/alarms/format"; +import { AlarmRefresh } from "./forms"; + +export async function AlarmDashboardHints() { + const { user } = await requireKitaSession(); + if (![...managerRoles, "ELTERN"].includes(user.role)) return null; + let alarms; + try { + alarms = await getAlarmFeed({ + id: user.id!, + kitaId: user.kitaId, + role: user.role, + }); + } catch { + return null; + } + if (!alarms.length) return null; + return ( +
+
+

Aktuelle Notdienstalarme

+ +
+
+ {alarms.map((a) => ( + + + {dateKey(a.date).split("-").reverse().join(".")} · {a.familyName} + + {durationLabel(a)} + + + {alarmLabel(a)} → + + ))} +
+
+ ); +} diff --git a/src/app/dashboard/notdienst/alarm/forms.tsx b/src/app/dashboard/notdienst/alarm/forms.tsx new file mode 100644 index 0000000..d7eb271 --- /dev/null +++ b/src/app/dashboard/notdienst/alarm/forms.tsx @@ -0,0 +1,383 @@ +"use client"; +import { useEffect, useId, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { BellRing } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { clockMinute } from "@/lib/staff/calculations"; +import { alarmAction } from "./actions"; + +const control = + "w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-mid)]"; +export function AlarmRefresh() { + const router = useRouter(); + useEffect(() => { + const timer = setInterval(() => { + if (!document.hidden) router.refresh(); + }, 30000); + return () => clearInterval(timer); + }, [router]); + return ( + + ); +} +export type AlarmCandidate = { id: string; label: string; emails: string[] }; +export function AlarmCreateForm({ + candidates, + educators, + selected, +}: { + candidates: AlarmCandidate[]; + educators: { id: string; name: string }[]; + selected?: string; +}) { + const [open, setOpen] = useState(!!selected); + const [assignmentId, setAssignmentId] = useState( + candidates.find((c) => c.id === selected)?.id ?? candidates[0]?.id ?? "", + ); + const [wholeDay, setWholeDay] = useState(true); + const [pending, start] = useTransition(); + const [error, setError] = useState(""); + const router = useRouter(); + const prefix = useId(); + const candidate = candidates.find((c) => c.id === assignmentId); + return ( + + + + + + + Notdienstalarm auslösen + + Die eingeteilte Familie wird per E-Mail und in der App informiert. + Eine Antwort gilt für den Haushalt. + + +
{ + event.preventDefault(); + const form = new FormData(event.currentTarget); + setError(""); + start(async () => { + const result = await alarmAction("create", { + assignmentId, + wholeDay, + replyMinutes: Number(form.get("replyMinutes")), + ...(wholeDay + ? {} + : { + startMinute: clockMinute(String(form.get("start"))), + endMinute: clockMinute(String(form.get("end"))), + }), + ...(form.get("educatorId") + ? { educatorId: String(form.get("educatorId")) } + : {}), + notes: String(form.get("notes") ?? ""), + }); + if (result.error) { + setError(result.error); + return; + } + if (result.warning) toast.warning(result.warning); + else toast.success("Alarm ausgelöst."); + setOpen(false); + router.push(`/dashboard/notdienst/alarm?alarm=${result.alarmId}`); + router.refresh(); + }); + }} + > +
+ +
+

Empfänger

+ {candidate?.emails.length ? ( +
    + {candidate.emails.map((email) => ( +
  • {email}
  • + ))} +
+ ) : ( +

+ Keine Elternkonten hinterlegt. Bitte den Haushalt zuerst + vervollständigen. +

+ )} +
+ + {!wholeDay && ( +
+ + +
+ )} + +

+ Die Frist beginnt beim Auslösen und endet spätestens mit dem + Einsatz. Eine Absage erfordert sofort Klärung. +

+ +