feat: add household emergency duty alarms and deadline worker
This commit is contained in:
@@ -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`,
|
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.
|
`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?
|
## Welche Coolify-Postgres-URL?
|
||||||
|
|
||||||
Wenn die App und die Postgres-DB im selben Coolify-Projekt/Netzwerk laufen, nimm
|
Wenn die App und die Postgres-DB im selben Coolify-Projekt/Netzwerk laufen, nimm
|
||||||
|
|||||||
@@ -58,3 +58,12 @@
|
|||||||
- [ ] Environment Variables in Coolify eintragen, nicht ins Repo committen
|
- [ ] Environment Variables in Coolify eintragen, nicht ins Repo committen
|
||||||
- [ ] Domain auf den `app`-Service und Container-Port `3000` routen
|
- [ ] Domain auf den `app`-Service und Container-Port `3000` routen
|
||||||
- [ ] Upload-Volume `kita_planer_uploads` bleibt zwischen Deployments erhalten
|
- [ ] 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.
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ 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.
|
||||||
|
# Eigenes Secret, mindestens 32 Zeichen; generieren mit: openssl rand -hex 32
|
||||||
|
NOTDIENST_JOB_SECRET="[GENERATE_ME]"
|
||||||
|
|
||||||
# Persistenter Upload-Speicher fuer Anhaenge vom Schwarzen Brett.
|
# Persistenter Upload-Speicher fuer Anhaenge vom Schwarzen Brett.
|
||||||
# Muss auf dem Server dauerhaft erhalten bleiben und darf nicht im Repo liegen.
|
# Muss auf dem Server dauerhaft erhalten bleiben und darf nicht im Repo liegen.
|
||||||
UPLOAD_DIR="/var/lib/kita-planer/uploads/announcements"
|
UPLOAD_DIR="/var/lib/kita-planer/uploads/announcements"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -38,6 +38,7 @@ services:
|
|||||||
RESEND_API_KEY: ${RESEND_API_KEY:?}
|
RESEND_API_KEY: ${RESEND_API_KEY:?}
|
||||||
EMAIL_FROM: ${EMAIL_FROM:?}
|
EMAIL_FROM: ${EMAIL_FROM:?}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:?}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:?}
|
||||||
|
NOTDIENST_JOB_SECRET: ${NOTDIENST_JOB_SECRET:?}
|
||||||
UPLOAD_DIR: /app/uploads/announcements
|
UPLOAD_DIR: /app/uploads/announcements
|
||||||
volumes:
|
volumes:
|
||||||
- kita_planer_uploads:/app/uploads
|
- kita_planer_uploads:/app/uploads
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Kita-Planer – Funktionsübersicht und aktueller Stand
|
# 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**
|
Stand: **07.10.2026**
|
||||||
|
|
||||||
Geprüfter Ausgangsbranch: `codex/notdienst-swaps`
|
Geprüfter Ausgangsbranch: `codex/notdienst-swaps`
|
||||||
|
|||||||
@@ -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 <NOTDIENST_JOB_SECRET>` 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.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Stand: 09.10.2026 · Branch `codex/notdienstalarm`
|
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
|
## Festgelegter Umfang
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test:staff": "tsx --test tests/staff-calculations.test.ts",
|
"test:staff": "tsx --test tests/staff-calculations.test.ts",
|
||||||
"test:staff:integration": "tsx --test tests/staff-service.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:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
|
|||||||
+127
-6
@@ -84,6 +84,15 @@ enum NotdienstAlertStatus {
|
|||||||
CANCELLED
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum NotdienstAlarmStatus {
|
||||||
|
PENDING
|
||||||
|
CONFIRMED
|
||||||
|
DECLINED
|
||||||
|
LATE_CONFIRMATION
|
||||||
|
REVIEW_REQUIRED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
enum NotdienstSwapStatus {
|
enum NotdienstSwapStatus {
|
||||||
PENDING
|
PENDING
|
||||||
ACCEPTED
|
ACCEPTED
|
||||||
@@ -143,6 +152,7 @@ model Kita {
|
|||||||
notdienstAvailabilities NotdienstAvailability[]
|
notdienstAvailabilities NotdienstAvailability[]
|
||||||
notdienstAssignments NotdienstAssignment[]
|
notdienstAssignments NotdienstAssignment[]
|
||||||
notdienstAlerts NotdienstAlert[]
|
notdienstAlerts NotdienstAlert[]
|
||||||
|
notdienstAlarms NotdienstAlarm[]
|
||||||
notdienstSwapRequests NotdienstSwapRequest[]
|
notdienstSwapRequests NotdienstSwapRequest[]
|
||||||
staffProfiles StaffProfile[]
|
staffProfiles StaffProfile[]
|
||||||
staffHolidays StaffHoliday[]
|
staffHolidays StaffHoliday[]
|
||||||
@@ -160,7 +170,8 @@ model Family {
|
|||||||
kitaId String
|
kitaId String
|
||||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
name String
|
name String
|
||||||
|
notdienstAlarms NotdienstAlarm[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -240,11 +251,14 @@ model User {
|
|||||||
termineApproved Termin[] @relation("TerminApprover")
|
termineApproved Termin[] @relation("TerminApprover")
|
||||||
|
|
||||||
mitbringselItems MitbringselItem[]
|
mitbringselItems MitbringselItem[]
|
||||||
invitationsCreated Invitation[] @relation("InvitationCreator")
|
invitationsCreated Invitation[] @relation("InvitationCreator")
|
||||||
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
|
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
|
||||||
staffProfile StaffProfile?
|
staffProfile StaffProfile?
|
||||||
staffNotifications StaffNotification[]
|
staffNotifications StaffNotification[]
|
||||||
staffEmails StaffEmail[]
|
staffEmails StaffEmail[]
|
||||||
|
alarmRecipients NotdienstAlarmRecipient[]
|
||||||
|
alarmNotifications NotdienstAlarmNotification[]
|
||||||
|
alarmEmails NotdienstAlarmEmail[]
|
||||||
|
|
||||||
@@index([kitaId])
|
@@index([kitaId])
|
||||||
@@index([familyId])
|
@@index([familyId])
|
||||||
@@ -890,6 +904,7 @@ model NotdienstAssignment {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
alerts NotdienstAlert[]
|
alerts NotdienstAlert[]
|
||||||
|
alarms NotdienstAlarm[]
|
||||||
swapRequestsAsTarget NotdienstSwapRequest[] @relation("NotdienstSwapTargetAssignment")
|
swapRequestsAsTarget NotdienstSwapRequest[] @relation("NotdienstSwapTargetAssignment")
|
||||||
swapRequestsAsOffered NotdienstSwapRequest[] @relation("NotdienstSwapOfferedAssignment")
|
swapRequestsAsOffered NotdienstSwapRequest[] @relation("NotdienstSwapOfferedAssignment")
|
||||||
|
|
||||||
@@ -936,10 +951,116 @@ model NotdienstSwapRequest {
|
|||||||
|
|
||||||
/// Aktive Alarmierung — wird bei Krankmeldung einer Fachkraft
|
/// Aktive Alarmierung — wird bei Krankmeldung einer Fachkraft
|
||||||
/// vom Koordinator ausgelöst. Bestätigung via Magic-Link (Token).
|
/// 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 {
|
model NotdienstAlert {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
kitaId String
|
alarmId String?
|
||||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
alarm NotdienstAlarm? @relation(fields: [alarmId], references: [id], onDelete: SetNull)
|
||||||
|
kitaId String
|
||||||
|
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
assignmentId String
|
assignmentId String
|
||||||
assignment NotdienstAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
assignment NotdienstAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -5,4 +5,13 @@ echo "Applying Prisma schema..."
|
|||||||
npx prisma db push --skip-generate
|
npx prisma db push --skip-generate
|
||||||
|
|
||||||
echo "Starting Kita-Planer..."
|
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
|
exec npm run start:prod
|
||||||
|
|||||||
@@ -1,34 +1,39 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { NotdienstAlertStatus } from "@prisma/client";
|
import { respondToAlarm } from "@/lib/alarms/service";
|
||||||
import { prisma } from "@/lib/prisma";
|
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 {
|
try {
|
||||||
const alert = await prisma.notdienstAlert.findUnique({
|
const result = await respondToAlarm(token, approve);
|
||||||
where: { confirmationToken: token },
|
// The household response is already committed; transport failure must not undo it.
|
||||||
});
|
try {
|
||||||
|
await deliverAlarmEmails(result.emailIds);
|
||||||
if (!alert) {
|
} catch {
|
||||||
return { error: "Ungültiger Bestätigungs-Link." };
|
/* Persistent jobs will retry. */
|
||||||
}
|
}
|
||||||
|
revalidatePath("/alert/[token]", "page");
|
||||||
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("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/dashboard/notdienst/alarm");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch {
|
} catch (error) {
|
||||||
return { error: "Fehler bei der Bestätigung." };
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,37 +1,65 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
import { useTransition } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { confirmAlertAction } from "./actions";
|
import { respondAlertAction } from "./actions";
|
||||||
|
|
||||||
export function AlertConfirmForm({ token }: { token: string }) {
|
export function AlertConfirmForm({ token }: { token: string }) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [choice, setChoice] = useState<boolean | null>(null);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
const handleConfirm = () => {
|
const [error, setError] = useState("");
|
||||||
startTransition(async () => {
|
const router = useRouter();
|
||||||
const result = await confirmAlertAction(token);
|
|
||||||
if ("error" in result && result.error) {
|
|
||||||
toast.error(result.error as string);
|
|
||||||
} else {
|
|
||||||
toast.success("Erfolgreich bestätigt!");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<div className="grid gap-4">
|
||||||
size="lg"
|
{choice === null ? (
|
||||||
className="w-full sm:w-auto font-semibold text-lg"
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
onClick={handleConfirm}
|
<Button onClick={() => setChoice(true)}>Wir übernehmen</Button>
|
||||||
disabled={isPending}
|
<Button variant="outline" onClick={() => setChoice(false)}>
|
||||||
>
|
Wir können nicht
|
||||||
{isPending ? (
|
</Button>
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
Ich bestätige! (Bin auf dem Weg)
|
<div className="grid gap-3 rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||||
</Button>
|
<p className="text-sm">
|
||||||
|
{choice
|
||||||
|
? "Wir sagen für unseren Haushalt zu."
|
||||||
|
: "Unser Haushalt kann diesen Einsatz nicht übernehmen."}{" "}
|
||||||
|
Diese Antwort gilt für alle Elternkonten des Haushalts.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
start(async () => {
|
||||||
|
setError("");
|
||||||
|
const result = await respondAlertAction(token, choice);
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
router.refresh();
|
||||||
|
} else router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pending ? "Wird gespeichert …" : "Antwort verbindlich speichern"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
setChoice(null);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-113
@@ -1,127 +1,114 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { format } from "date-fns";
|
import { BellRing, CheckCircle2 } from "lucide-react";
|
||||||
import { de } from "date-fns/locale";
|
import { getPublicAlarm } from "@/lib/alarms/service";
|
||||||
import { BellRing, ShieldCheck, XCircle } from "lucide-react";
|
|
||||||
import { NotdienstAlertStatus } from "@prisma/client";
|
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
alarmLabel,
|
||||||
CardContent,
|
dateKey,
|
||||||
CardDescription,
|
durationLabel,
|
||||||
CardHeader,
|
instantLabel,
|
||||||
CardTitle,
|
} from "@/lib/alarms/format";
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { AlertConfirmForm } from "./alert-confirm-form";
|
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({
|
export default async function AlertPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ token: string }>;
|
params: Promise<{ token: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { token } = await params;
|
const { token } = await params;
|
||||||
|
const alarm = await getPublicAlarm(token);
|
||||||
const alert = await prisma.notdienstAlert.findUnique({
|
if (!alarm) notFound();
|
||||||
where: { confirmationToken: token },
|
const now = new Date(),
|
||||||
select: {
|
expired = alarm.expiresAt <= now;
|
||||||
status: true,
|
const canRespond = alarm.status === "PENDING" && !expired;
|
||||||
parentUser: {
|
const overdue = alarm.deadline <= now;
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/30 px-4 py-12">
|
<main className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
|
||||||
<Card className="w-full max-w-md shadow-lg border-primary/20">
|
<section className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--hairline)] bg-[var(--surface)] shadow-sm">
|
||||||
<CardHeader className="text-center pb-2">
|
<header
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
|
className={`border-b border-[var(--hairline)] p-6 sm:p-8 ${alarm.status === "CONFIRMED" ? "bg-[var(--accent-soft)]" : "bg-[var(--surface-2)]"}`}
|
||||||
{isPending ? (
|
>
|
||||||
<BellRing className="h-8 w-8 animate-pulse text-destructive" />
|
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--accent-deep)]">
|
||||||
) : isConfirmed ? (
|
Kita-Planer · {alarm.kitaName}
|
||||||
<ShieldCheck className="h-8 w-8 text-success" />
|
</p>
|
||||||
) : (
|
{alarm.status === "CONFIRMED" ? (
|
||||||
<XCircle className="h-8 w-8 text-muted-foreground" />
|
<CheckCircle2 className="mt-6 h-7 w-7 text-[var(--accent-deep)]" />
|
||||||
)}
|
) : (
|
||||||
</div>
|
<BellRing className="mt-6 h-7 w-7 text-[var(--danger)]" />
|
||||||
<CardTitle className="text-2xl">
|
)}
|
||||||
{isPending
|
<h1 className="mt-3 text-3xl text-[var(--ink)]">
|
||||||
? "Notdienst Einsatz!"
|
{alarm.status === "PENDING"
|
||||||
: isConfirmed
|
? "Könnt ihr den Notdienst übernehmen?"
|
||||||
? "Einsatz bestätigt"
|
: alarmLabel(alarm)}
|
||||||
: "Alarm abgebrochen"}
|
</h1>
|
||||||
</CardTitle>
|
<p className="mt-3 text-sm leading-6 text-[var(--ink-soft)]">
|
||||||
<CardDescription className="text-base mt-2">
|
Hallo {alarm.firstName}, diese Rückmeldung gilt für euren gesamten
|
||||||
Kita {alert.kita.name}
|
Haushalt.
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</header>
|
||||||
<CardContent className="space-y-6 pt-4">
|
<div className="grid gap-6 p-6 sm:p-8">
|
||||||
<div className="rounded-lg bg-background border p-4 text-center space-y-1">
|
<dl className="grid grid-cols-2 gap-5 text-sm">
|
||||||
<p className="text-sm text-muted-foreground">Datum</p>
|
<div>
|
||||||
<p className="font-semibold text-lg">
|
<dt className="text-[var(--ink-muted)]">Datum</dt>
|
||||||
{format(alert.assignment.date, "EEEE, dd. MMMM yyyy", {
|
<dd className="mt-1 font-semibold">
|
||||||
locale: de,
|
{dateKey(alarm.date).split("-").reverse().join(".")}
|
||||||
})}
|
</dd>
|
||||||
</p>
|
</div>
|
||||||
<div className="h-2" />
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Eingeteiltes Kind</p>
|
<dt className="text-[var(--ink-muted)]">Einsatz</dt>
|
||||||
<p className="font-medium">
|
<dd className="mt-1 font-semibold">{durationLabel(alarm)}</dd>
|
||||||
{alert.assignment.child.firstName}{" "}
|
</div>
|
||||||
{alert.assignment.child.lastName}
|
<div>
|
||||||
</p>
|
<dt className="text-[var(--ink-muted)]">Haushalt</dt>
|
||||||
</div>
|
<dd className="mt-1 font-semibold">{alarm.familyName}</dd>
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div>
|
||||||
{isPending ? (
|
<dt className="text-[var(--ink-muted)]">Antwortfrist</dt>
|
||||||
<>
|
<dd className="mt-1 font-semibold">
|
||||||
<p className="mb-6 text-sm text-muted-foreground">
|
{instantLabel(alarm.deadline)} Uhr
|
||||||
Hallo {alert.parentUser.firstName}, eine Fachkraft hat sich
|
</dd>
|
||||||
krankgemeldet und du bist heute für den Notdienst eingeteilt.
|
</div>
|
||||||
Bitte bestätige deinen Einsatz schnellstmöglich!
|
</dl>
|
||||||
|
{canRespond ? (
|
||||||
|
<>
|
||||||
|
{overdue && (
|
||||||
|
<p className="rounded-lg bg-[var(--surface-2)] p-4 text-sm leading-6">
|
||||||
|
Die Antwortfrist ist abgelaufen. Ihr könnt noch antworten.
|
||||||
|
Eine verspätete Zusage muss die Koordination freigeben, da
|
||||||
|
möglicherweise bereits Ersatz organisiert wird.
|
||||||
</p>
|
</p>
|
||||||
<AlertConfirmForm token={token} />
|
)}
|
||||||
</>
|
<AlertConfirmForm token={token} />
|
||||||
) : isConfirmed ? (
|
</>
|
||||||
<p className="text-sm text-muted-foreground">
|
) : (
|
||||||
Danke, {alert.parentUser.firstName}! Dein Einsatz wurde
|
<div
|
||||||
erfolgreich bestätigt. Die Kita rechnet mit dir.
|
role="status"
|
||||||
</p>
|
className="rounded-xl border border-[var(--hairline)] p-4 text-sm leading-6"
|
||||||
) : (
|
>
|
||||||
<p className="text-sm text-muted-foreground">
|
{alarm.status === "CANCELLED"
|
||||||
Dieser Einsatz wurde vom Koordinator abgebrochen. Du musst nicht
|
? "Der Einsatz wurde storniert. Ihr müsst für diesen Alarm nicht einspringen."
|
||||||
einspringen.
|
: alarm.status === "CONFIRMED"
|
||||||
</p>
|
? "Euer Haushalt hat zugesagt. Die Kita rechnet mit euch. Bei einer Änderung kontaktiert bitte die Koordination."
|
||||||
)}
|
: alarm.status === "LATE_CONFIRMATION"
|
||||||
</div>
|
? "Eure verspätete Zusage ist gespeichert. Wartet bitte auf die Freigabe durch die Koordination."
|
||||||
</CardContent>
|
: alarm.status === "DECLINED"
|
||||||
</Card>
|
? "Die Absage eures Haushalts ist gespeichert. Die Koordination kümmert sich um Ersatz."
|
||||||
</div>
|
: 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."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs leading-5 text-[var(--ink-muted)]">
|
||||||
|
Den persönlichen Link bitte innerhalb eures Haushalts behalten.
|
||||||
|
Änderungen einer gespeicherten Antwort klärt ihr mit der Kita.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,123 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
import { alarmAction } from "./notdienst/alarm/actions";
|
||||||
import crypto from "crypto";
|
// Existing callers use the shared household workflow and the same guards.
|
||||||
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";
|
|
||||||
|
|
||||||
export async function triggerAlertAction(assignmentId: string) {
|
export async function triggerAlertAction(assignmentId: string) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
return alarmAction("create", {
|
||||||
const kitaId = session.user.kitaId!;
|
assignmentId,
|
||||||
|
wholeDay: true,
|
||||||
try {
|
replyMinutes: 30,
|
||||||
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." };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,15 @@ const activeNotdienstAlertStatuses = [
|
|||||||
|
|
||||||
function getTodayUtcDateOnly() {
|
function getTodayUtcDateOnly() {
|
||||||
const today = new Date();
|
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() {
|
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) {
|
function formatNotdienstDate(date: Date) {
|
||||||
@@ -68,7 +72,7 @@ function revalidateCalendarAndNotdienst() {
|
|||||||
revalidatePath("/dashboard/notdienst/plan");
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
revalidatePath("/dashboard/notdienst");
|
revalidatePath("/dashboard/notdienst");
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Ignore if called outside Next.js context
|
// Ignore if called outside Next.js context
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,6 +98,7 @@ type SwapAssignmentWithFamily = {
|
|||||||
alerts: {
|
alerts: {
|
||||||
id: string;
|
id: string;
|
||||||
}[];
|
}[];
|
||||||
|
alarms?: { id: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getSwapAssignment(
|
async function getSwapAssignment(
|
||||||
@@ -126,7 +131,11 @@ async function getSwapAssignment(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
alerts: {
|
alerts: {
|
||||||
where: { status: { in: activeNotdienstAlertStatuses } },
|
where: { alarmId: null, status: { in: activeNotdienstAlertStatuses } },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
alarms: {
|
||||||
|
where: { activeAssignmentId: { not: null } },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -171,7 +180,12 @@ function validateSwapAssignmentPair({
|
|||||||
return "Du kannst keinen Tausch mit deinem eigenen Haushalt anfragen.";
|
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.";
|
return "Notdienste mit aktiver Alarmierung können nicht getauscht werden.";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +229,10 @@ async function sendSwapMail({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
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<typeof terminSchema>) {
|
|||||||
now.setSeconds(0, 0);
|
now.setSeconds(0, 0);
|
||||||
|
|
||||||
if (startDate < now) {
|
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) {
|
if (endDate < startDate) {
|
||||||
@@ -286,9 +305,7 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
|
|||||||
? new Date(data.rsvpDeadline)
|
? new Date(data.rsvpDeadline)
|
||||||
: null,
|
: null,
|
||||||
participantInfo:
|
participantInfo:
|
||||||
data.requiresRsvp && data.participantInfo
|
data.requiresRsvp && data.participantInfo ? data.participantInfo : null,
|
||||||
? data.participantInfo
|
|
||||||
: null,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -347,7 +364,10 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
|||||||
return { error: "Notdienst nicht gefunden." };
|
return { error: "Notdienst nicht gefunden." };
|
||||||
}
|
}
|
||||||
if (existingRequest) {
|
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({
|
await prisma.notdienstSwapRequest.create({
|
||||||
@@ -394,128 +414,170 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
const today = getTodayUtcDateOnly();
|
const today = getTodayUtcDateOnly();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(
|
||||||
const request = await tx.notdienstSwapRequest.findFirst({
|
async (tx) => {
|
||||||
where: {
|
const request = await tx.notdienstSwapRequest.findFirst({
|
||||||
id: parsed.data,
|
where: {
|
||||||
kitaId: session.user.kitaId,
|
id: parsed.data,
|
||||||
status: NotdienstSwapStatus.PENDING,
|
kitaId: session.user.kitaId,
|
||||||
},
|
status: NotdienstSwapStatus.PENDING,
|
||||||
select: {
|
},
|
||||||
id: true,
|
select: {
|
||||||
requesterFamilyId: true,
|
id: true,
|
||||||
targetAssignmentId: true,
|
requesterFamilyId: true,
|
||||||
offeredAssignmentId: true,
|
targetAssignmentId: true,
|
||||||
targetAssignment: {
|
offeredAssignmentId: true,
|
||||||
select: {
|
targetAssignment: {
|
||||||
id: true,
|
select: {
|
||||||
childId: true,
|
id: true,
|
||||||
date: true,
|
childId: true,
|
||||||
plan: { select: { status: true } },
|
date: true,
|
||||||
child: {
|
plan: { select: { status: true } },
|
||||||
select: {
|
child: {
|
||||||
familyId: true,
|
select: {
|
||||||
family: {
|
familyId: true,
|
||||||
select: {
|
family: {
|
||||||
name: true,
|
select: {
|
||||||
users: {
|
name: true,
|
||||||
where: { role: UserRole.ELTERN },
|
users: {
|
||||||
select: { email: true, firstName: true, lastName: true },
|
where: { role: UserRole.ELTERN },
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
alerts: {
|
||||||
alerts: {
|
where: {
|
||||||
where: { status: { in: activeNotdienstAlertStatuses } },
|
alarmId: null,
|
||||||
select: { id: true },
|
status: { in: activeNotdienstAlertStatuses },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
offeredAssignment: {
|
||||||
offeredAssignment: {
|
select: {
|
||||||
select: {
|
id: true,
|
||||||
id: true,
|
childId: true,
|
||||||
childId: true,
|
date: true,
|
||||||
date: true,
|
plan: { select: { status: true } },
|
||||||
plan: { select: { status: true } },
|
child: {
|
||||||
child: {
|
select: {
|
||||||
select: {
|
familyId: true,
|
||||||
familyId: true,
|
family: {
|
||||||
family: {
|
select: {
|
||||||
select: {
|
name: true,
|
||||||
name: true,
|
users: {
|
||||||
users: {
|
where: { role: UserRole.ELTERN },
|
||||||
where: { role: UserRole.ELTERN },
|
select: {
|
||||||
select: { email: true, firstName: true, lastName: true },
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
alerts: {
|
||||||
alerts: {
|
where: {
|
||||||
where: { status: { in: activeNotdienstAlertStatuses } },
|
alarmId: null,
|
||||||
select: { id: true },
|
status: { in: activeNotdienstAlertStatuses },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (!request) {
|
if (!request) {
|
||||||
throw new Error("Tauschanfrage nicht gefunden oder nicht mehr offen.");
|
throw new Error(
|
||||||
}
|
"Tauschanfrage nicht gefunden oder nicht mehr offen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
if (
|
||||||
throw new Error("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
|
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({
|
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
||||||
targetAssignment: request.targetAssignment,
|
throw new Error(
|
||||||
offeredAssignment: request.offeredAssignment,
|
"Nur der angefragte Haushalt kann diese Anfrage annehmen.",
|
||||||
requesterFamilyId: request.requesterFamilyId,
|
);
|
||||||
today,
|
}
|
||||||
});
|
|
||||||
if (validationError) {
|
|
||||||
throw new Error(validationError);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.notdienstAssignment.update({
|
const validationError = validateSwapAssignmentPair({
|
||||||
where: { id: request.targetAssignmentId },
|
targetAssignment: request.targetAssignment,
|
||||||
data: { childId: request.offeredAssignment.childId },
|
offeredAssignment: request.offeredAssignment,
|
||||||
});
|
requesterFamilyId: request.requesterFamilyId,
|
||||||
await tx.notdienstAssignment.update({
|
today,
|
||||||
where: { id: request.offeredAssignmentId },
|
});
|
||||||
data: { childId: request.targetAssignment.childId },
|
if (validationError) {
|
||||||
});
|
throw new Error(validationError);
|
||||||
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 {
|
await tx.notdienstAssignment.update({
|
||||||
targetAssignment: request.targetAssignment,
|
where: { id: request.targetAssignmentId },
|
||||||
offeredAssignment: request.offeredAssignment,
|
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({
|
const kita = await prisma.kita.findUnique({
|
||||||
where: { id: session.user.kitaId },
|
where: { id: session.user.kitaId },
|
||||||
@@ -550,7 +612,10 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
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." };
|
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
|
||||||
}
|
}
|
||||||
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
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({
|
await prisma.notdienstSwapRequest.update({
|
||||||
@@ -706,7 +773,10 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
revalidateCalendarAndNotdienst();
|
revalidateCalendarAndNotdienst();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} 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." };
|
return { error: "Tauschanfrage konnte nicht zurückgezogen werden." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
const kitaId = session.user.kitaId;
|
const kitaId = session.user.kitaId;
|
||||||
if (!kitaId) {
|
if (!kitaId) {
|
||||||
@@ -933,7 +1006,11 @@ export async function addMitbringsel(rawPayload: unknown) {
|
|||||||
where: { id: terminId, kitaId: session.user.kitaId },
|
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." };
|
return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, unknown>,
|
||||||
|
): Promise<AlarmActionResult> {
|
||||||
|
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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="mb-6 rounded-2xl border border-[var(--accent-mid)] bg-[var(--surface)] p-5">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="text-lg font-semibold">Aktuelle Notdienstalarme</h2>
|
||||||
|
<AlarmRefresh />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{alarms.map((a) => (
|
||||||
|
<Link
|
||||||
|
key={a.id}
|
||||||
|
href={`/dashboard/notdienst/alarm?alarm=${a.id}`}
|
||||||
|
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 text-sm ${needsClarification(a) ? "border-[var(--danger)] text-[var(--danger)]" : "border-[var(--hairline)]"}`}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{dateKey(a.date).split("-").reverse().join(".")} · {a.familyName}
|
||||||
|
<span className="mt-1 block text-xs text-[var(--ink-soft)]">
|
||||||
|
{durationLabel(a)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<strong>{alarmLabel(a)} →</strong>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => router.refresh()}>
|
||||||
|
Stand aktualisieren
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button disabled={!candidates.length}>
|
||||||
|
<BellRing className="mr-2 h-4 w-4" />
|
||||||
|
Notdienstalarm auslösen
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Notdienstalarm auslösen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Die eingeteilte Familie wird per E-Mail und in der App informiert.
|
||||||
|
Eine Antwort gilt für den Haushalt.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="grid gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<fieldset disabled={pending} className="grid gap-4">
|
||||||
|
<label
|
||||||
|
className="grid gap-1.5 text-sm"
|
||||||
|
htmlFor={`${prefix}-assignment`}
|
||||||
|
>
|
||||||
|
Eingeteilte Familie
|
||||||
|
<select
|
||||||
|
id={`${prefix}-assignment`}
|
||||||
|
className={control}
|
||||||
|
required
|
||||||
|
value={assignmentId}
|
||||||
|
onChange={(event) => setAssignmentId(event.target.value)}
|
||||||
|
>
|
||||||
|
{candidates.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="rounded-lg bg-[var(--surface-2)] p-3 text-sm">
|
||||||
|
<p className="font-medium">Empfänger</p>
|
||||||
|
{candidate?.emails.length ? (
|
||||||
|
<ul className="mt-1 grid gap-1 break-all">
|
||||||
|
{candidate.emails.map((email) => (
|
||||||
|
<li key={email}>{email}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-[var(--danger)]">
|
||||||
|
Keine Elternkonten hinterlegt. Bitte den Haushalt zuerst
|
||||||
|
vervollständigen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
className="grid gap-1.5 text-sm"
|
||||||
|
htmlFor={`${prefix}-duration`}
|
||||||
|
>
|
||||||
|
Einsatzdauer
|
||||||
|
<select
|
||||||
|
id={`${prefix}-duration`}
|
||||||
|
className={control}
|
||||||
|
value={wholeDay ? "whole" : "hours"}
|
||||||
|
onChange={(event) =>
|
||||||
|
setWholeDay(event.target.value === "whole")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="whole">Ganzer Kita-Tag</option>
|
||||||
|
<option value="hours">Bestimmte Uhrzeiten</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{!wholeDay && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label
|
||||||
|
className="grid gap-1.5 text-sm"
|
||||||
|
htmlFor={`${prefix}-start`}
|
||||||
|
>
|
||||||
|
Beginn
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-start`}
|
||||||
|
type="time"
|
||||||
|
name="start"
|
||||||
|
required
|
||||||
|
defaultValue="08:00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="grid gap-1.5 text-sm"
|
||||||
|
htmlFor={`${prefix}-end`}
|
||||||
|
>
|
||||||
|
Ende
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-end`}
|
||||||
|
type="time"
|
||||||
|
name="end"
|
||||||
|
required
|
||||||
|
defaultValue="16:00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label
|
||||||
|
className="grid gap-1.5 text-sm"
|
||||||
|
htmlFor={`${prefix}-minutes`}
|
||||||
|
>
|
||||||
|
Antwortfrist in Minuten
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-minutes`}
|
||||||
|
name="replyMinutes"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1440}
|
||||||
|
step={1}
|
||||||
|
defaultValue={30}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||||
|
Die Frist beginnt beim Auslösen und endet spätestens mit dem
|
||||||
|
Einsatz. Eine Absage erfordert sofort Klärung.
|
||||||
|
</p>
|
||||||
|
<label
|
||||||
|
className="grid gap-1.5 text-sm"
|
||||||
|
htmlFor={`${prefix}-educator`}
|
||||||
|
>
|
||||||
|
Ausgefallene Fachkraft (optional, intern)
|
||||||
|
<select
|
||||||
|
id={`${prefix}-educator`}
|
||||||
|
className={control}
|
||||||
|
name="educatorId"
|
||||||
|
>
|
||||||
|
<option value="">Ohne Zuordnung</option>
|
||||||
|
{educators.map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>
|
||||||
|
{e.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1.5 text-sm" htmlFor={`${prefix}-notes`}>
|
||||||
|
Interne Notiz (optional)
|
||||||
|
<textarea
|
||||||
|
id={`${prefix}-notes`}
|
||||||
|
className={control}
|
||||||
|
name="notes"
|
||||||
|
maxLength={500}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button type="submit" disabled={!candidate?.emails.length}>
|
||||||
|
{pending
|
||||||
|
? "Alarm wird gespeichert …"
|
||||||
|
: "Alarm auslösen und Familie informieren"}
|
||||||
|
</Button>
|
||||||
|
</fieldset>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function AlarmCommand({
|
||||||
|
alarmId,
|
||||||
|
command,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
alarmId: string;
|
||||||
|
command: "cancel" | "recordResponse" | "approveLate";
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false),
|
||||||
|
[pending, start] = useTransition(),
|
||||||
|
[error, setError] = useState("");
|
||||||
|
const router = useRouter(),
|
||||||
|
prefix = useId();
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{label}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{command === "cancel"
|
||||||
|
? "Alle angeschriebenen Elternkonten erhalten eine Stornierung. Ihre Rückmeldelinks erlauben danach keine weitere Antwort."
|
||||||
|
: "Die Rückmeldung gilt für den gesamten Haushalt. Die Familie und die Koordination werden über den Stand informiert."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="grid gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
setError("");
|
||||||
|
start(async () => {
|
||||||
|
const result = await alarmAction(command, {
|
||||||
|
alarmId,
|
||||||
|
reason: String(data.get("reason")),
|
||||||
|
...(command === "recordResponse"
|
||||||
|
? { approve: data.get("response") === "yes" }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.warning) toast.warning(result.warning);
|
||||||
|
else toast.success("Gespeichert.");
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{command === "recordResponse" && (
|
||||||
|
<label
|
||||||
|
className="grid gap-1.5 text-sm"
|
||||||
|
htmlFor={`${prefix}-response`}
|
||||||
|
>
|
||||||
|
Telefonische Rückmeldung / Korrektur
|
||||||
|
<select
|
||||||
|
id={`${prefix}-response`}
|
||||||
|
name="response"
|
||||||
|
className={control}
|
||||||
|
>
|
||||||
|
<option value="yes">Haushalt übernimmt</option>
|
||||||
|
<option value="no">Haushalt kann nicht übernehmen</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="grid gap-1.5 text-sm" htmlFor={`${prefix}-reason`}>
|
||||||
|
Interne Begründung
|
||||||
|
<textarea
|
||||||
|
id={`${prefix}-reason`}
|
||||||
|
className={control}
|
||||||
|
name="reason"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={500}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-[var(--ink-soft)]">
|
||||||
|
Die Begründung bleibt intern und wird nicht per E-Mail versendet.
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button disabled={pending} type="submit">
|
||||||
|
{pending
|
||||||
|
? "Wird gespeichert …"
|
||||||
|
: command === "cancel"
|
||||||
|
? "Alarm stornieren"
|
||||||
|
: "Rückmeldung speichern"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function AlarmSimpleAction({
|
||||||
|
command,
|
||||||
|
data,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
command: "retry" | "readNotice";
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
start(async () => {
|
||||||
|
const result = await alarmAction(command, data);
|
||||||
|
if (result.error) toast.error(result.error);
|
||||||
|
else if (result.warning) toast.warning(result.warning);
|
||||||
|
else toast.success("Gespeichert.");
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pending ? "Bitte warten …" : children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { BellRing } from "lucide-react";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||||
|
import { managerRoles, importLegacyAlarms } from "@/lib/alarms/service";
|
||||||
|
import {
|
||||||
|
alarmLabel,
|
||||||
|
dateKey,
|
||||||
|
durationLabel,
|
||||||
|
instantLabel,
|
||||||
|
needsClarification,
|
||||||
|
todayKey,
|
||||||
|
} from "@/lib/alarms/format";
|
||||||
|
import { addDays, dateOnly } from "@/lib/staff/calculations";
|
||||||
|
import {
|
||||||
|
AlarmCreateForm,
|
||||||
|
AlarmCommand,
|
||||||
|
AlarmSimpleAction,
|
||||||
|
AlarmRefresh,
|
||||||
|
} from "./forms";
|
||||||
|
|
||||||
|
export const metadata = { title: "Notdienstalarm · Kita-Planer" };
|
||||||
|
const historyLabels: Record<string, string> = {
|
||||||
|
CONFIRMED: "Übernahme bestätigt",
|
||||||
|
DECLINED: "Absage",
|
||||||
|
LATE_CONFIRMATION: "Verspätete Zusage – Freigabe erforderlich",
|
||||||
|
};
|
||||||
|
export default async function AlarmPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ alarm?: string; assignment?: string }>;
|
||||||
|
}) {
|
||||||
|
const actor = await requireAlarmActor(false),
|
||||||
|
manager = managerRoles.includes(actor.role);
|
||||||
|
const params = await searchParams,
|
||||||
|
now = new Date(),
|
||||||
|
today = todayKey(now);
|
||||||
|
if (manager) await importLegacyAlarms(actor.kitaId);
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: actor.id },
|
||||||
|
select: { familyId: true },
|
||||||
|
});
|
||||||
|
const [kita, alarms, assignments, educators, notifications] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.kita.findUniqueOrThrow({
|
||||||
|
where: { id: actor.kitaId },
|
||||||
|
select: { name: true, notdienstModuleEnabled: true },
|
||||||
|
}),
|
||||||
|
prisma.notdienstAlarm.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId: actor.kitaId,
|
||||||
|
...(manager ? {} : { familyId: user.familyId ?? "" }),
|
||||||
|
...(params.alarm
|
||||||
|
? { id: params.alarm }
|
||||||
|
: { date: { gte: dateOnly(addDays(today, -30)) } }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
include: { user: { select: { email: true, firstName: true } } },
|
||||||
|
},
|
||||||
|
emails: { orderBy: { createdAt: "desc" } },
|
||||||
|
history: manager
|
||||||
|
? { orderBy: { createdAt: "desc" }, take: 20 }
|
||||||
|
: false,
|
||||||
|
},
|
||||||
|
orderBy: [{ date: "desc" }, { triggeredAt: "desc" }],
|
||||||
|
take: 100,
|
||||||
|
}),
|
||||||
|
manager
|
||||||
|
? prisma.notdienstAssignment.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId: actor.kitaId,
|
||||||
|
date: { gte: dateOnly(today), lt: dateOnly(addDays(today, 93)) },
|
||||||
|
plan: { status: "PUBLISHED" },
|
||||||
|
alarms: { none: { activeAssignmentId: { not: null } } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
child: {
|
||||||
|
include: {
|
||||||
|
family: {
|
||||||
|
include: {
|
||||||
|
users: {
|
||||||
|
where: { role: "ELTERN", kitaId: actor.kitaId },
|
||||||
|
select: { email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { date: "asc" },
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
manager
|
||||||
|
? prisma.educator.findMany({
|
||||||
|
where: { kitaId: actor.kitaId, active: true },
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
orderBy: { lastName: "asc" },
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
prisma.notdienstAlarmNotification.findMany({
|
||||||
|
where: {
|
||||||
|
userId: actor.id,
|
||||||
|
alarm: {
|
||||||
|
kitaId: actor.kitaId,
|
||||||
|
...(manager ? {} : { familyId: user.familyId ?? "" }),
|
||||||
|
},
|
||||||
|
readAt: null,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 20,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const candidates = assignments.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
label: `${dateKey(a.date).split("-").reverse().join(".")} · ${a.child.family.name}`,
|
||||||
|
emails: a.child.family.users.map((u) => u.email),
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||||
|
<header className="mb-7 flex flex-wrap items-end justify-between gap-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
|
||||||
|
NOTDIENST · {kita.name}
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-4xl tracking-tight text-[var(--ink)]">
|
||||||
|
Notdienst
|
||||||
|
<span className="italic text-[var(--accent-deep)]">alarm</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--ink-soft)]">
|
||||||
|
{manager
|
||||||
|
? "Familien anfragen, Rückmeldungen verfolgen und offene Einsätze klären."
|
||||||
|
: "Eure Rückmeldung gilt für den gesamten Haushalt."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<AlarmRefresh />
|
||||||
|
{manager && kita.notdienstModuleEnabled && (
|
||||||
|
<AlarmCreateForm
|
||||||
|
candidates={candidates}
|
||||||
|
educators={educators.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
name: `${e.firstName} ${e.lastName}`,
|
||||||
|
}))}
|
||||||
|
selected={params.assignment}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{manager &&
|
||||||
|
(!process.env.NOTDIENST_JOB_SECRET ||
|
||||||
|
process.env.NOTDIENST_JOB_SECRET.length < 32) && (
|
||||||
|
<p className="mb-6 rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm leading-6">
|
||||||
|
Die automatische Fristprüfung ist noch nicht eingerichtet. Für
|
||||||
|
Erinnerungen bei geschlossener App muss die Hosting-Konfiguration
|
||||||
|
ergänzt werden. Fristabläufe sind hier bereits sichtbar.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!kita.notdienstModuleEnabled && (
|
||||||
|
<p className="mb-6 text-sm">
|
||||||
|
Das Notdienstmodul ist deaktiviert; vorhandene Alarme bleiben
|
||||||
|
einsehbar.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<details className="mb-6 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4">
|
||||||
|
<summary className="cursor-pointer text-sm font-semibold">
|
||||||
|
{notifications.length} neue Hinweise
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className="max-w-3xl text-sm underline decoration-[var(--accent-mid)] underline-offset-4"
|
||||||
|
href={`/dashboard/notdienst/alarm?alarm=${n.alarmId}`}
|
||||||
|
>
|
||||||
|
{n.message}
|
||||||
|
</Link>
|
||||||
|
<AlarmSimpleAction command="readNotice" data={{ id: n.id }}>
|
||||||
|
Gelesen
|
||||||
|
</AlarmSimpleAction>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{params.alarm && (
|
||||||
|
<Link
|
||||||
|
className="mb-5 inline-block text-sm text-[var(--accent-deep)] underline"
|
||||||
|
href="/dashboard/notdienst/alarm"
|
||||||
|
>
|
||||||
|
Alle Alarme anzeigen
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{manager && !candidates.length && !params.alarm && (
|
||||||
|
<p className="mb-5 text-sm text-[var(--ink-soft)]">
|
||||||
|
Neue Alarme benötigen eine aktuelle oder zukünftige Einteilung in
|
||||||
|
einem veröffentlichten Plan.{" "}
|
||||||
|
<Link href="/dashboard/notdienst/plan" className="underline">
|
||||||
|
Notdienstplan öffnen
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-5">
|
||||||
|
{alarms.length ? (
|
||||||
|
alarms.map((a) => {
|
||||||
|
const clarify = needsClarification(a, now),
|
||||||
|
ended = a.expiresAt <= now;
|
||||||
|
const pending = a.emails.filter((e) => !e.sentAt && !e.skippedAt);
|
||||||
|
const ownRecipient = a.recipients.find(
|
||||||
|
(r) => r.userId === actor.id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={a.id}
|
||||||
|
id={`alarm-${a.id}`}
|
||||||
|
className={`overflow-hidden rounded-2xl border bg-[var(--surface)] ${clarify ? "border-[var(--danger)]" : "border-[var(--hairline)]"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-start justify-between gap-4 border-b border-[var(--hairline-soft)] p-5 sm:p-6 ${clarify ? "bg-[var(--surface-2)]" : ""}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-[var(--ink-soft)]">
|
||||||
|
<BellRing className="h-4 w-4" />
|
||||||
|
{dateKey(a.date) === today ? "Heute · " : ""}
|
||||||
|
{dateKey(a.date).split("-").reverse().join(".")}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-2xl">{a.familyName}</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||||
|
{durationLabel(a)}
|
||||||
|
{ended ? " · Einsatz beendet" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`max-w-full rounded-full border px-3 py-1.5 text-xs font-semibold ${clarify ? "border-[var(--danger)] text-[var(--danger)]" : "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"}`}
|
||||||
|
>
|
||||||
|
{alarmLabel(a, now)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 p-5 sm:p-6">
|
||||||
|
<div className="flex flex-wrap gap-x-8 gap-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="text-[var(--ink-muted)]">
|
||||||
|
Antwortfrist
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
<strong>{instantLabel(a.deadline)} Uhr</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-[var(--ink-muted)]">Ausgelöst</span>
|
||||||
|
<br />
|
||||||
|
{instantLabel(a.triggeredAt)} Uhr
|
||||||
|
</p>
|
||||||
|
{a.respondedAt && (
|
||||||
|
<p>
|
||||||
|
<span className="text-[var(--ink-muted)]">
|
||||||
|
Rückmeldung
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
{instantLabel(a.respondedAt)} Uhr ·{" "}
|
||||||
|
{a.responseSource === "PHONE"
|
||||||
|
? "Telefonisch dokumentiert"
|
||||||
|
: a.responseSource === "COORDINATION"
|
||||||
|
? "Freigabe durch Koordination"
|
||||||
|
: a.responseSource === "LEGACY"
|
||||||
|
? "Bisherige Alarmierung"
|
||||||
|
: "Haushaltsantwort"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{manager ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{a.status === "LATE_CONFIRMATION" && !ended && (
|
||||||
|
<AlarmCommand
|
||||||
|
command="approveLate"
|
||||||
|
alarmId={a.id}
|
||||||
|
label="Verspätete Zusage freigeben"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{a.status !== "CANCELLED" && !ended && (
|
||||||
|
<AlarmCommand
|
||||||
|
command="recordResponse"
|
||||||
|
alarmId={a.id}
|
||||||
|
label="Telefonische Rückmeldung / Korrektur"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{a.status !== "CANCELLED" && (
|
||||||
|
<AlarmCommand
|
||||||
|
command="cancel"
|
||||||
|
alarmId={a.id}
|
||||||
|
label="Alarm stornieren"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{pending.length > 0 && (
|
||||||
|
<AlarmSimpleAction
|
||||||
|
command="retry"
|
||||||
|
data={{ alarmId: a.id }}
|
||||||
|
>
|
||||||
|
Ausstehende E-Mails erneut senden ({pending.length})
|
||||||
|
</AlarmSimpleAction>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{clarify && (
|
||||||
|
<p className="rounded-lg bg-[var(--surface-2)] p-3 text-sm leading-6">
|
||||||
|
Bitte den Haushalt kontaktieren oder Ersatz manuell
|
||||||
|
organisieren. Weitere Familien werden nicht
|
||||||
|
automatisch alarmiert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<details className="border-t border-[var(--hairline-soft)] pt-4">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">
|
||||||
|
Versandstand (
|
||||||
|
{a.emails.filter((e) => e.sentAt).length} angenommen,{" "}
|
||||||
|
{pending.length} ausstehend)
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 grid gap-2 text-sm">
|
||||||
|
{a.emails.map((e) => (
|
||||||
|
<p key={e.id} className="break-all">
|
||||||
|
{a.recipients.find((r) => r.userId === e.userId)
|
||||||
|
?.user.email ?? "Koordination / Admin"}{" "}
|
||||||
|
·{" "}
|
||||||
|
{e.kind === "REQUEST"
|
||||||
|
? "Anfrage"
|
||||||
|
: e.kind === "CANCEL"
|
||||||
|
? "Stornierung"
|
||||||
|
: "Hinweis"}
|
||||||
|
:{" "}
|
||||||
|
{e.sentAt
|
||||||
|
? "Vom Maildienst angenommen"
|
||||||
|
: e.skippedAt
|
||||||
|
? "Nicht mehr erforderlich / Empfänger geändert"
|
||||||
|
: e.failed
|
||||||
|
? "Versand fehlgeschlagen – wiederholbar"
|
||||||
|
: "Versand ausstehend"}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details className="border-t border-[var(--hairline-soft)] pt-4">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">
|
||||||
|
Interne Details und Verlauf
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 grid gap-2 text-sm">
|
||||||
|
{a.educatorId && (
|
||||||
|
<p>
|
||||||
|
Ausgefallene Fachkraft:{" "}
|
||||||
|
{educators.find((e) => e.id === a.educatorId)
|
||||||
|
?.firstName ??
|
||||||
|
"Ehemalige / inaktive Fachkraft"}{" "}
|
||||||
|
{educators.find((e) => e.id === a.educatorId)
|
||||||
|
?.lastName ?? ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{a.notes && <p>Interne Notiz: {a.notes}</p>}
|
||||||
|
{a.cancelReason && (
|
||||||
|
<p>Stornierungsgrund: {a.cancelReason}</p>
|
||||||
|
)}
|
||||||
|
{a.history.map((h) => (
|
||||||
|
<p key={h.id}>
|
||||||
|
{instantLabel(h.createdAt)} · {h.action}:{" "}
|
||||||
|
{historyLabels[h.details] ?? h.details}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{ownRecipient && a.status === "PENDING" && !ended && (
|
||||||
|
<Link
|
||||||
|
className="inline-flex w-fit rounded-lg bg-[var(--accent-deep)] px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
href={`/alert/${ownRecipient.token}`}
|
||||||
|
>
|
||||||
|
Für unseren Haushalt antworten
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-[var(--ink-soft)]">
|
||||||
|
{a.status === "LATE_CONFIRMATION"
|
||||||
|
? "Eure verspätete Zusage wartet auf Freigabe durch die Koordination."
|
||||||
|
: a.status === "CONFIRMED"
|
||||||
|
? "Die Kita rechnet mit euch. Änderungen bitte mit der Koordination klären."
|
||||||
|
: a.status === "CANCELLED"
|
||||||
|
? "Ihr müsst für diesen Alarm nicht einspringen."
|
||||||
|
: a.status === "DECLINED"
|
||||||
|
? "Eure Absage ist gespeichert. Die Koordination organisiert Ersatz."
|
||||||
|
: "Eine Antwort eines Elternteils gilt für euren ganzen Haushalt."}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface)] p-8 text-sm text-[var(--ink-soft)]">
|
||||||
|
{params.alarm
|
||||||
|
? "Kein zugänglicher Alarm gefunden."
|
||||||
|
: "Noch keine Notdienstalarme in diesem Zeitraum."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,11 @@ import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
|||||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||||
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
||||||
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
||||||
|
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||||
|
import {
|
||||||
|
validateAlarmActor,
|
||||||
|
assertAssignmentNotAlarmed,
|
||||||
|
} from "@/lib/alarms/service";
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// /dashboard/notdienst/plan · Server Actions
|
// /dashboard/notdienst/plan · Server Actions
|
||||||
@@ -153,41 +158,75 @@ export async function updateAssignmentAction(
|
|||||||
date: string,
|
date: string,
|
||||||
newChildId: string | null,
|
newChildId: string | null,
|
||||||
) {
|
) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const actor = await requireAlarmActor();
|
||||||
const kitaId = session.user.kitaId!;
|
const kitaId = actor.kitaId;
|
||||||
|
|
||||||
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
||||||
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const notifications = await prisma.$transaction(async (tx) => {
|
const notifications = await prisma.$transaction(
|
||||||
const plan = await tx.notdienstPlan.findUnique({
|
async (tx) => {
|
||||||
where: { id: planId, kitaId },
|
await validateAlarmActor(tx, actor);
|
||||||
});
|
const plan = await tx.notdienstPlan.findUnique({
|
||||||
|
where: { id: planId, kitaId },
|
||||||
|
});
|
||||||
|
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
throw new Error("Plan nicht gefunden.");
|
throw new Error("Plan nicht gefunden.");
|
||||||
}
|
|
||||||
|
|
||||||
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
|
|
||||||
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
|
|
||||||
|
|
||||||
if (!isDraft && !isPublished) {
|
|
||||||
throw new Error("Plan kann nicht bearbeitet werden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPublished) {
|
|
||||||
const today = new Date();
|
|
||||||
const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
|
|
||||||
if (parsedDate < todayUtc) {
|
|
||||||
throw new Error("Vergangene Notdienste können nicht geändert werden.");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
|
||||||
where: { planId, date: parsedDate },
|
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
|
||||||
include: {
|
|
||||||
child: {
|
if (!isDraft && !isPublished) {
|
||||||
|
throw new Error("Plan kann nicht bearbeitet werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPublished) {
|
||||||
|
const today = new Date();
|
||||||
|
const todayUtc = new Date(
|
||||||
|
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||||
|
);
|
||||||
|
if (parsedDate < todayUtc) {
|
||||||
|
throw new Error(
|
||||||
|
"Vergangene Notdienste können nicht geändert werden.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
||||||
|
where: { planId, date: parsedDate },
|
||||||
|
include: {
|
||||||
|
child: {
|
||||||
|
include: {
|
||||||
|
family: {
|
||||||
|
include: {
|
||||||
|
users: {
|
||||||
|
where: { role: UserRole.ELTERN },
|
||||||
|
select: { email: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let newChild: {
|
||||||
|
id: string;
|
||||||
|
family: {
|
||||||
|
users: { email: string; firstName: string; lastName: string }[];
|
||||||
|
};
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
if (newChildId) {
|
||||||
|
newChild = await tx.child.findFirst({
|
||||||
|
where: {
|
||||||
|
id: newChildId,
|
||||||
|
kitaId,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
family: {
|
family: {
|
||||||
include: {
|
include: {
|
||||||
@@ -198,133 +237,118 @@ export async function updateAssignmentAction(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let newChild:
|
if (!newChild) {
|
||||||
| {
|
throw new Error("Kind gehört nicht zu dieser Kita.");
|
||||||
id: string;
|
|
||||||
family: {
|
|
||||||
users: { email: string; firstName: string; lastName: string }[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
| null = null;
|
|
||||||
|
|
||||||
if (newChildId) {
|
|
||||||
newChild = await tx.child.findFirst({
|
|
||||||
where: {
|
|
||||||
id: newChildId,
|
|
||||||
kitaId,
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
family: {
|
|
||||||
include: {
|
|
||||||
users: {
|
|
||||||
where: { role: UserRole.ELTERN },
|
|
||||||
select: { email: true, firstName: true, lastName: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!newChild) {
|
|
||||||
throw new Error("Kind gehört nicht zu dieser Kita.");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (existingAssignment?.childId === newChildId) {
|
if (existingAssignment?.childId === newChildId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!newChildId) {
|
if (existingAssignment)
|
||||||
if (existingAssignment) {
|
await assertAssignmentNotAlarmed(tx, existingAssignment.id, kitaId);
|
||||||
await tx.notdienstAssignment.delete({
|
|
||||||
|
if (!newChildId) {
|
||||||
|
if (existingAssignment) {
|
||||||
|
await tx.notdienstAssignment.delete({
|
||||||
|
where: { id: existingAssignment.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (existingAssignment) {
|
||||||
|
await tx.notdienstAssignment.update({
|
||||||
where: { id: existingAssignment.id },
|
where: { id: existingAssignment.id },
|
||||||
|
data: { childId: newChildId },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await tx.notdienstAssignment.create({
|
||||||
|
data: {
|
||||||
|
kitaId,
|
||||||
|
planId,
|
||||||
|
childId: newChildId,
|
||||||
|
date: parsedDate,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (existingAssignment) {
|
|
||||||
await tx.notdienstAssignment.update({
|
|
||||||
where: { id: existingAssignment.id },
|
|
||||||
data: { childId: newChildId },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await tx.notdienstAssignment.create({
|
|
||||||
data: {
|
|
||||||
kitaId,
|
|
||||||
planId,
|
|
||||||
childId: newChildId,
|
|
||||||
date: parsedDate,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingAssignment) {
|
if (existingAssignment) {
|
||||||
await tx.notdienstSwapRequest.updateMany({
|
await tx.notdienstSwapRequest.updateMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId,
|
kitaId,
|
||||||
status: "PENDING",
|
status: "PENDING",
|
||||||
OR: [
|
OR: [
|
||||||
{ targetAssignmentId: existingAssignment.id },
|
{ targetAssignmentId: existingAssignment.id },
|
||||||
{ offeredAssignmentId: existingAssignment.id },
|
{ offeredAssignmentId: existingAssignment.id },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: "CANCELLED",
|
status: "CANCELLED",
|
||||||
respondedAt: new Date(),
|
respondedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPublished) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateLabel = format(new Date(yr, mo - 1, dy), "EEEE, dd. MMMM yyyy", { locale: de });
|
|
||||||
const messages: {
|
|
||||||
parentEmails: string[];
|
|
||||||
parentNames: string[];
|
|
||||||
subject: string;
|
|
||||||
heading: string;
|
|
||||||
preview: string;
|
|
||||||
body: string;
|
|
||||||
dateLabel: string;
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
if (existingAssignment) {
|
|
||||||
const oldParents = existingAssignment.child.family.users.filter((u) => !!u.email);
|
|
||||||
if (oldParents.length > 0) {
|
|
||||||
messages.push({
|
|
||||||
parentEmails: oldParents.map((p) => p.email),
|
|
||||||
parentNames: oldParents.map((p) => `${p.firstName} ${p.lastName}`),
|
|
||||||
subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
|
|
||||||
heading: "Notdienst wurde geändert",
|
|
||||||
preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
|
|
||||||
body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
|
|
||||||
dateLabel,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (newChild && existingAssignment?.childId !== newChild.id) {
|
if (!isPublished) {
|
||||||
const newParents = newChild.family.users.filter((u) => !!u.email);
|
return [];
|
||||||
if (newParents.length > 0) {
|
|
||||||
messages.push({
|
|
||||||
parentEmails: newParents.map((p) => p.email),
|
|
||||||
parentNames: newParents.map((p) => `${p.firstName} ${p.lastName}`),
|
|
||||||
subject: `Notdienst-Einteilung am ${dateLabel}`,
|
|
||||||
heading: "Neuer Notdienst-Termin zugewiesen",
|
|
||||||
preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
|
|
||||||
body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
|
|
||||||
dateLabel,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
const dateLabel = format(
|
||||||
});
|
new Date(yr, mo - 1, dy),
|
||||||
|
"EEEE, dd. MMMM yyyy",
|
||||||
|
{ locale: de },
|
||||||
|
);
|
||||||
|
const messages: {
|
||||||
|
parentEmails: string[];
|
||||||
|
parentNames: string[];
|
||||||
|
subject: string;
|
||||||
|
heading: string;
|
||||||
|
preview: string;
|
||||||
|
body: string;
|
||||||
|
dateLabel: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
if (existingAssignment) {
|
||||||
|
const oldParents = existingAssignment.child.family.users.filter(
|
||||||
|
(u) => !!u.email,
|
||||||
|
);
|
||||||
|
if (oldParents.length > 0) {
|
||||||
|
messages.push({
|
||||||
|
parentEmails: oldParents.map((p) => p.email),
|
||||||
|
parentNames: oldParents.map(
|
||||||
|
(p) => `${p.firstName} ${p.lastName}`,
|
||||||
|
),
|
||||||
|
subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
|
||||||
|
heading: "Notdienst wurde geändert",
|
||||||
|
preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
|
||||||
|
body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
|
||||||
|
dateLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newChild && existingAssignment?.childId !== newChild.id) {
|
||||||
|
const newParents = newChild.family.users.filter((u) => !!u.email);
|
||||||
|
if (newParents.length > 0) {
|
||||||
|
messages.push({
|
||||||
|
parentEmails: newParents.map((p) => p.email),
|
||||||
|
parentNames: newParents.map(
|
||||||
|
(p) => `${p.firstName} ${p.lastName}`,
|
||||||
|
),
|
||||||
|
subject: `Notdienst-Einteilung am ${dateLabel}`,
|
||||||
|
heading: "Neuer Notdienst-Termin zugewiesen",
|
||||||
|
preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
|
||||||
|
body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
|
||||||
|
dateLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
},
|
||||||
|
{ isolationLevel: "Serializable" },
|
||||||
|
);
|
||||||
|
|
||||||
// E-Mails außerhalb der DB-Transaktion senden
|
// E-Mails außerhalb der DB-Transaktion senden
|
||||||
if (notifications.length > 0) {
|
if (notifications.length > 0) {
|
||||||
@@ -334,7 +358,10 @@ export async function updateAssignmentAction(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (kita) {
|
if (kita) {
|
||||||
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
const BASE_URL =
|
||||||
|
process.env.NEXTAUTH_URL ??
|
||||||
|
process.env.AUTH_URL ??
|
||||||
|
"http://localhost:3000";
|
||||||
const link = `${BASE_URL}/dashboard/notdienst`;
|
const link = `${BASE_URL}/dashboard/notdienst`;
|
||||||
|
|
||||||
for (const notification of notifications) {
|
for (const notification of notifications) {
|
||||||
@@ -396,7 +423,10 @@ export async function updateKitaNotdienstSettingsAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendNotdienstRemindersAction(year: number, month: number) {
|
export async function sendNotdienstRemindersAction(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
const kitaId = session.user.kitaId;
|
const kitaId = session.user.kitaId;
|
||||||
if (!kitaId) {
|
if (!kitaId) {
|
||||||
@@ -452,7 +482,9 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const respondedFamilyIds = new Set(respondedFamilies.map((a) => a.child.familyId));
|
const respondedFamilyIds = new Set(
|
||||||
|
respondedFamilies.map((a) => a.child.familyId),
|
||||||
|
);
|
||||||
|
|
||||||
// 4. Find open families (not responded yet)
|
// 4. Find open families (not responded yet)
|
||||||
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
|
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
|
||||||
@@ -464,7 +496,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
|||||||
// 5. Build emails list and send
|
// 5. Build emails list and send
|
||||||
const monthDate = new Date(year, month - 1, 1);
|
const monthDate = new Date(year, month - 1, 1);
|
||||||
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
|
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
|
||||||
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
const BASE_URL =
|
||||||
|
process.env.NEXTAUTH_URL ??
|
||||||
|
process.env.AUTH_URL ??
|
||||||
|
"http://localhost:3000";
|
||||||
const link = `${BASE_URL}/dashboard/notdienst`;
|
const link = `${BASE_URL}/dashboard/notdienst`;
|
||||||
|
|
||||||
let sentCount = 0;
|
let sentCount = 0;
|
||||||
@@ -486,7 +521,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
sentCount++;
|
sentCount++;
|
||||||
} else {
|
} else {
|
||||||
console.error(`E-Mail konnte nicht an ${parent.email} gesendet werden:`, result.error);
|
console.error(
|
||||||
|
`E-Mail konnte nicht an ${parent.email} gesendet werden:`,
|
||||||
|
result.error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -517,7 +555,9 @@ export async function resetPlanAction(planId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (plan.status !== NotdienstPlanStatus.DRAFT) {
|
if (plan.status !== NotdienstPlanStatus.DRAFT) {
|
||||||
throw new Error("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
|
throw new Error(
|
||||||
|
"Nur Pläne im Entwurfsstatus können zurückgesetzt werden.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete all assignments
|
// Delete all assignments
|
||||||
@@ -535,6 +575,11 @@ export async function resetPlanAction(planId: string) {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Zurücksetzen des Plans:", error);
|
console.error("Fehler beim Zurücksetzen des Plans:", error);
|
||||||
return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." };
|
return {
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Fehler beim Zurücksetzen des Plans.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
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, UserRole } from "@prisma/client";
|
import { TerminType } from "@prisma/client";
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
|
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||||
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 { todayKey } from "@/lib/alarms/format";
|
||||||
|
|
||||||
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
|
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
|
||||||
|
|
||||||
@@ -17,14 +18,18 @@ export default async function PlanungsZentralePage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ year?: string; month?: string }>;
|
searchParams: Promise<{ year?: string; month?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const actor = await requireAlarmActor();
|
||||||
const kitaId = session.user.kitaId!;
|
const kitaId = actor.kitaId;
|
||||||
|
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nextMonth = addMonths(now, 1);
|
const nextMonth = addMonths(now, 1);
|
||||||
const targetYear = resolvedSearchParams.year ? parseInt(resolvedSearchParams.year, 10) : getYear(nextMonth);
|
const targetYear = resolvedSearchParams.year
|
||||||
const targetMonth = resolvedSearchParams.month ? parseInt(resolvedSearchParams.month, 10) : (getMonth(nextMonth) + 1);
|
? parseInt(resolvedSearchParams.year, 10)
|
||||||
|
: getYear(nextMonth);
|
||||||
|
const targetMonth = resolvedSearchParams.month
|
||||||
|
? parseInt(resolvedSearchParams.month, 10)
|
||||||
|
: getMonth(nextMonth) + 1;
|
||||||
|
|
||||||
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 });
|
||||||
@@ -33,76 +38,81 @@ export default async function PlanungsZentralePage({
|
|||||||
const monthStart = new Date(targetYear, targetMonth - 1, 1);
|
const monthStart = new Date(targetYear, targetMonth - 1, 1);
|
||||||
const monthEnd = new Date(targetYear, targetMonth, 1);
|
const monthEnd = new Date(targetYear, targetMonth, 1);
|
||||||
|
|
||||||
const [plan, families, availabilities, closureCount, kita] = await Promise.all([
|
const [plan, families, availabilities, closureCount, kita] =
|
||||||
prisma.notdienstPlan.findUnique({
|
await Promise.all([
|
||||||
where: {
|
prisma.notdienstPlan.findUnique({
|
||||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
where: {
|
||||||
},
|
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||||
select: {
|
},
|
||||||
id: true,
|
select: {
|
||||||
status: true,
|
id: true,
|
||||||
publishedAt: true,
|
status: true,
|
||||||
assignments: {
|
publishedAt: true,
|
||||||
select: {
|
assignments: {
|
||||||
id: true,
|
select: {
|
||||||
childId: true,
|
id: true,
|
||||||
date: true,
|
childId: true,
|
||||||
createdAt: true,
|
date: true,
|
||||||
|
createdAt: true,
|
||||||
|
alarms: {
|
||||||
|
where: { activeAssignmentId: { not: null } },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
}),
|
prisma.family.findMany({
|
||||||
prisma.family.findMany({
|
where: {
|
||||||
where: {
|
kitaId,
|
||||||
kitaId,
|
children: { some: { active: true } },
|
||||||
children: { some: { active: true } },
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
children: {
|
|
||||||
where: { active: true },
|
|
||||||
select: { id: true },
|
|
||||||
},
|
},
|
||||||
},
|
select: {
|
||||||
orderBy: { name: "asc" },
|
id: true,
|
||||||
}),
|
name: true,
|
||||||
prisma.notdienstAvailability.findMany({
|
children: {
|
||||||
where: {
|
where: { active: true },
|
||||||
kitaId,
|
select: { id: true },
|
||||||
date: {
|
|
||||||
gte: monthStart,
|
|
||||||
lt: monthEnd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
date: true,
|
|
||||||
child: {
|
|
||||||
select: {
|
|
||||||
familyId: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
orderBy: { name: "asc" },
|
||||||
orderBy: { date: "asc" },
|
}),
|
||||||
}),
|
prisma.notdienstAvailability.findMany({
|
||||||
prisma.termin.count({
|
where: {
|
||||||
where: {
|
kitaId,
|
||||||
kitaId,
|
date: {
|
||||||
type: TerminType.SCHLIESSTAG,
|
gte: monthStart,
|
||||||
startDate: {
|
lt: monthEnd,
|
||||||
gte: monthStart,
|
},
|
||||||
lt: monthEnd,
|
|
||||||
},
|
},
|
||||||
},
|
select: {
|
||||||
}),
|
date: true,
|
||||||
prisma.kita.findUniqueOrThrow({
|
child: {
|
||||||
where: { id: kitaId },
|
select: {
|
||||||
select: {
|
familyId: true,
|
||||||
notdienstMinPerChildPerMonth: true,
|
},
|
||||||
notdienstReminderDaysBefore: true,
|
},
|
||||||
},
|
},
|
||||||
}),
|
orderBy: { date: "asc" },
|
||||||
]);
|
}),
|
||||||
|
prisma.termin.count({
|
||||||
|
where: {
|
||||||
|
kitaId,
|
||||||
|
type: TerminType.SCHLIESSTAG,
|
||||||
|
startDate: {
|
||||||
|
gte: monthStart,
|
||||||
|
lt: monthEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.kita.findUniqueOrThrow({
|
||||||
|
where: { id: kitaId },
|
||||||
|
select: {
|
||||||
|
notdienstMinPerChildPerMonth: true,
|
||||||
|
notdienstReminderDaysBefore: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
|
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
|
||||||
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
|
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
|
||||||
@@ -179,7 +189,7 @@ export default async function PlanungsZentralePage({
|
|||||||
// Wir mappen die Assignments auf die Werktage
|
// Wir mappen die Assignments auf die Werktage
|
||||||
const daysWithAssignments = workingDays.map((day) => {
|
const daysWithAssignments = workingDays.map((day) => {
|
||||||
const assignment = plan?.assignments.find(
|
const assignment = plan?.assignments.find(
|
||||||
(a) => a.date.getTime() === day.getTime()
|
(a) => a.date.getTime() === day.getTime(),
|
||||||
);
|
);
|
||||||
const isOriginallyUnassigned =
|
const isOriginallyUnassigned =
|
||||||
!assignment ||
|
!assignment ||
|
||||||
@@ -190,6 +200,11 @@ export default async function PlanungsZentralePage({
|
|||||||
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,
|
||||||
|
canAlarm:
|
||||||
|
!!assignment &&
|
||||||
|
plan?.status === "PUBLISHED" &&
|
||||||
|
day.toISOString().slice(0, 10) >= todayKey(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { NotdienstPlanStatus } from "@prisma/client";
|
import { NotdienstPlanStatus } from "@prisma/client";
|
||||||
@@ -40,6 +41,8 @@ interface PlanViewProps {
|
|||||||
days: {
|
days: {
|
||||||
date: string;
|
date: string;
|
||||||
assignmentId: string | null;
|
assignmentId: string | null;
|
||||||
|
activeAlarmId?: string | null;
|
||||||
|
canAlarm?: boolean;
|
||||||
childId: string | null;
|
childId: string | null;
|
||||||
isOriginallyUnassigned?: boolean;
|
isOriginallyUnassigned?: boolean;
|
||||||
}[];
|
}[];
|
||||||
@@ -92,7 +95,9 @@ export function PlanView({
|
|||||||
const [resetDialogOpen, setResetDialogOpen] = useState(false);
|
const [resetDialogOpen, setResetDialogOpen] = useState(false);
|
||||||
|
|
||||||
const responsePercent =
|
const responsePercent =
|
||||||
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
|
totalFamilies > 0
|
||||||
|
? Math.round((respondedFamilies / totalFamilies) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const handleGenerate = () => {
|
const handleGenerate = () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -123,7 +128,9 @@ export function PlanView({
|
|||||||
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 (result.success) {
|
||||||
toast.success(`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`);
|
toast.success(
|
||||||
|
`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -173,7 +180,11 @@ export function PlanView({
|
|||||||
<Button disabled={isPending} onClick={handleGenerate}>
|
<Button disabled={isPending} onClick={handleGenerate}>
|
||||||
Trotzdem generieren
|
Trotzdem generieren
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={handleSendReminders} disabled={isPending}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleSendReminders}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
{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]" />
|
||||||
) : (
|
) : (
|
||||||
@@ -408,7 +419,8 @@ export function PlanView({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Plan zurücksetzen?</DialogTitle>
|
<DialogTitle>Plan zurücksetzen?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für diesen Monat gehen unwiderruflich verloren.
|
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für
|
||||||
|
diesen Monat gehen unwiderruflich verloren.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -542,6 +554,21 @@ function PlanPreviewTable({
|
|||||||
({assignedChild.name})
|
({assignedChild.name})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{day.activeAlarmId ? (
|
||||||
|
<Link
|
||||||
|
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
|
||||||
|
href={`/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`}
|
||||||
|
>
|
||||||
|
Aktiven Alarm öffnen
|
||||||
|
</Link>
|
||||||
|
) : day.canAlarm && day.assignmentId ? (
|
||||||
|
<Link
|
||||||
|
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
|
||||||
|
href={`/dashboard/notdienst/alarm?assignment=${day.assignmentId}`}
|
||||||
|
>
|
||||||
|
Notdienstalarm auslösen
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -616,7 +643,7 @@ function ManualAssignmentTable({
|
|||||||
<select
|
<select
|
||||||
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||||
value={day.childId || "empty"}
|
value={day.childId || "empty"}
|
||||||
disabled={isPending}
|
disabled={isPending || !!day.activeAlarmId}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onAssignmentChange(day.date, event.target.value)
|
onAssignmentChange(day.date, event.target.value)
|
||||||
}
|
}
|
||||||
@@ -624,7 +651,9 @@ function ManualAssignmentTable({
|
|||||||
<option value="empty">-- Unbesetzt --</option>
|
<option value="empty">-- Unbesetzt --</option>
|
||||||
{allChildren.map((child) => {
|
{allChildren.map((child) => {
|
||||||
const famId = child.familyId;
|
const famId = child.familyId;
|
||||||
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
|
const count = famId
|
||||||
|
? (familyAssignmentCounts.get(famId) ?? 0)
|
||||||
|
: 0;
|
||||||
return (
|
return (
|
||||||
<option key={child.id} value={child.id}>
|
<option key={child.id} value={child.id}>
|
||||||
{child.parentName} ({child.name}) — {count}x
|
{child.parentName} ({child.name}) — {count}x
|
||||||
@@ -637,6 +666,20 @@ function ManualAssignmentTable({
|
|||||||
Veröffentlicht
|
Veröffentlicht
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{day.assignmentId && day.canAlarm && (
|
||||||
|
<Link
|
||||||
|
className="text-xs font-semibold text-[var(--accent-deep)] underline underline-offset-4"
|
||||||
|
href={
|
||||||
|
day.activeAlarmId
|
||||||
|
? `/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`
|
||||||
|
: `/dashboard/notdienst/alarm?assignment=${day.assignmentId}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{day.activeAlarmId
|
||||||
|
? "Aktiven Alarm öffnen"
|
||||||
|
: "Notdienstalarm auslösen"}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { Stat } from "@/components/ui/stat";
|
import { Stat } from "@/components/ui/stat";
|
||||||
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
||||||
import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
|
import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
|
||||||
|
import { AlarmDashboardHints } from "./notdienst/alarm/dashboard-hints";
|
||||||
|
|
||||||
export const metadata = { title: "Übersicht · Kita-Planer" };
|
export const metadata = { title: "Übersicht · Kita-Planer" };
|
||||||
|
|
||||||
@@ -200,6 +201,7 @@ export default async function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<AlarmDashboardHints />
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
|
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{session.user.role === UserRole.ERZIEHER && (
|
{session.user.role === UserRole.ERZIEHER && (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Stethoscope,
|
Stethoscope,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
Clock3,
|
Clock3,
|
||||||
|
BellRing,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { NavItem } from "@/components/ui/nav-item";
|
import { NavItem } from "@/components/ui/nav-item";
|
||||||
@@ -20,6 +21,14 @@ import { NavItem } from "@/components/ui/nav-item";
|
|||||||
import { UserRole } from "@prisma/client";
|
import { UserRole } from "@prisma/client";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
{
|
||||||
|
section: "Heute",
|
||||||
|
href: "/dashboard/notdienst/alarm",
|
||||||
|
label: "Notdienstalarm",
|
||||||
|
icon: BellRing,
|
||||||
|
exact: true,
|
||||||
|
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ELTERN],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
section: "Heute",
|
section: "Heute",
|
||||||
href: "/dashboard/personal",
|
href: "/dashboard/personal",
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
import { baseBodyStyle, colors, fontStack } from "./_styles";
|
||||||
|
|
||||||
|
export function NotdienstAlarmEmail({
|
||||||
|
name,
|
||||||
|
kitaName,
|
||||||
|
message,
|
||||||
|
link,
|
||||||
|
request,
|
||||||
|
cancelled,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
kitaName: string;
|
||||||
|
message: string;
|
||||||
|
link: string;
|
||||||
|
request: boolean;
|
||||||
|
cancelled: boolean;
|
||||||
|
}) {
|
||||||
|
const title = request
|
||||||
|
? "Notdienst: Eure Rückmeldung zählt"
|
||||||
|
: cancelled
|
||||||
|
? "Notdiensteinsatz storniert"
|
||||||
|
: "Neuer Stand beim Notdienst";
|
||||||
|
return (
|
||||||
|
<Html lang="de">
|
||||||
|
<Head />
|
||||||
|
<Preview>
|
||||||
|
{title} · {kitaName}
|
||||||
|
</Preview>
|
||||||
|
<Body style={baseBodyStyle}>
|
||||||
|
<Container
|
||||||
|
style={{
|
||||||
|
maxWidth: "600px",
|
||||||
|
padding: "32px 24px",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: "12px",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
KITA-PLANER · {kitaName}
|
||||||
|
</Text>
|
||||||
|
<Heading style={{ color: request ? "#9f1239" : colors.textPrimary }}>
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
<Text>Hallo {name},</Text>
|
||||||
|
<Text style={{ lineHeight: "26px" }}>{message}</Text>
|
||||||
|
{request && (
|
||||||
|
<Text>
|
||||||
|
Eine Antwort gilt für euren ganzen Haushalt. Bitte wählt auf der
|
||||||
|
Rückmeldeseite „Wir übernehmen“ oder „Wir können nicht“.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
href={link}
|
||||||
|
style={{
|
||||||
|
backgroundColor: request ? "#9f1239" : colors.brandGreen,
|
||||||
|
color: "#fff",
|
||||||
|
padding: "14px 20px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{request
|
||||||
|
? "Für unseren Haushalt antworten"
|
||||||
|
: "Notdienstalarm öffnen"}
|
||||||
|
</Button>
|
||||||
|
<Text style={{ fontSize: "12px", color: colors.textMuted }}>
|
||||||
|
Falls der Button nicht funktioniert: {link}
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireKitaSession } from "@/lib/auth-utils";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { managerRoles, validateAlarmActor, type AlarmActor } from "./service";
|
||||||
|
|
||||||
|
export async function requireAlarmActor(manager = true): Promise<AlarmActor> {
|
||||||
|
const session = await requireKitaSession();
|
||||||
|
const actor = {
|
||||||
|
id: session.user.id!,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
role: session.user.role,
|
||||||
|
};
|
||||||
|
if (manager && !managerRoles.includes(actor.role)) redirect("/forbidden");
|
||||||
|
try {
|
||||||
|
await validateAlarmActor(prisma, actor, manager);
|
||||||
|
} catch {
|
||||||
|
redirect("/forbidden");
|
||||||
|
}
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { createElement } from "react";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendAppEmail } from "@/lib/mail";
|
||||||
|
import { NotdienstAlarmEmail } from "@/emails/NotdienstAlarmEmail";
|
||||||
|
import { managerRoles } from "./service";
|
||||||
|
|
||||||
|
export async function deliverAlarmEmails(
|
||||||
|
ids: string[],
|
||||||
|
send = sendAppEmail,
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
let failed = 0,
|
||||||
|
sent = 0;
|
||||||
|
const deliver = async (id: string) => {
|
||||||
|
const leaseUntil = new Date(Date.now() + 5 * 60_000);
|
||||||
|
const claimed = await prisma.notdienstAlarmEmail.updateMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
sentAt: null,
|
||||||
|
skippedAt: null,
|
||||||
|
OR: [{ leaseUntil: null }, { leaseUntil: { lte: new Date() } }],
|
||||||
|
},
|
||||||
|
data: { leaseUntil, attempts: { increment: 1 } },
|
||||||
|
});
|
||||||
|
if (!claimed.count) return;
|
||||||
|
const finish = (data: {
|
||||||
|
sentAt?: Date;
|
||||||
|
skippedAt?: Date;
|
||||||
|
failed: boolean;
|
||||||
|
}) =>
|
||||||
|
prisma.notdienstAlarmEmail.updateMany({
|
||||||
|
where: { id, leaseUntil },
|
||||||
|
data: { ...data, leaseUntil: null },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const job = await prisma.notdienstAlarmEmail.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: { user: true, alarm: { include: { kita: true } } },
|
||||||
|
});
|
||||||
|
const { user, alarm } = job;
|
||||||
|
let eligible = user.kitaId === alarm.kitaId;
|
||||||
|
if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role);
|
||||||
|
else
|
||||||
|
eligible &&= user.role === "ELTERN" && user.familyId === alarm.familyId;
|
||||||
|
if (job.kind === "REQUEST") {
|
||||||
|
const token = job.path.split("/").pop();
|
||||||
|
const recipient = await prisma.notdienstAlarmRecipient.findFirst({
|
||||||
|
where: { alarmId: alarm.id, userId: user.id, token },
|
||||||
|
});
|
||||||
|
eligible &&=
|
||||||
|
!!recipient && alarm.status === "PENDING" && alarm.expiresAt > now;
|
||||||
|
}
|
||||||
|
if (!eligible) {
|
||||||
|
await finish({ skippedAt: new Date(), failed: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const base =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL;
|
||||||
|
if (!base) throw new Error("App-Adresse fehlt.");
|
||||||
|
const site = new URL(base);
|
||||||
|
if (
|
||||||
|
!["https:", "http:"].includes(site.protocol) ||
|
||||||
|
site.username ||
|
||||||
|
site.password
|
||||||
|
)
|
||||||
|
throw new Error("App-Adresse ungültig.");
|
||||||
|
const result = await send({
|
||||||
|
to: user.email,
|
||||||
|
subject: job.subject,
|
||||||
|
idempotencyKey: `notdienst-alarm/${job.id}`,
|
||||||
|
react: createElement(NotdienstAlarmEmail, {
|
||||||
|
name: user.firstName,
|
||||||
|
kitaName: alarm.kita.name,
|
||||||
|
message: job.message,
|
||||||
|
link: new URL(job.path, site.origin).toString(),
|
||||||
|
request: job.kind === "REQUEST",
|
||||||
|
cancelled: job.kind === "CANCEL",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
await finish({ sentAt: new Date(), failed: false });
|
||||||
|
sent++;
|
||||||
|
} else {
|
||||||
|
await finish({ failed: true });
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await finish({ failed: true });
|
||||||
|
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 { sent, failed };
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { NotdienstAlarm } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
dateSchema,
|
||||||
|
dateKey,
|
||||||
|
todayKey,
|
||||||
|
minuteClock,
|
||||||
|
} from "@/lib/staff/calculations";
|
||||||
|
|
||||||
|
export { dateKey, todayKey };
|
||||||
|
const berlin = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
export function berlinInstant(date: string, minute: number) {
|
||||||
|
dateSchema.parse(date);
|
||||||
|
const target = new Date(`${date}T00:00:00Z`).getTime() + minute * 60_000;
|
||||||
|
let value = target;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
berlin.formatToParts(new Date(value)).map((p) => [p.type, p.value]),
|
||||||
|
);
|
||||||
|
const represented = Date.parse(
|
||||||
|
`${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:00Z`,
|
||||||
|
);
|
||||||
|
const diff = target - represented;
|
||||||
|
if (!diff) return new Date(value);
|
||||||
|
value += diff;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"Diese Uhrzeit existiert wegen der Zeitumstellung nicht. Bitte eine andere Uhrzeit wählen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function endOfAlarm(
|
||||||
|
date: string,
|
||||||
|
wholeDay: boolean,
|
||||||
|
endMinute?: number | null,
|
||||||
|
) {
|
||||||
|
return wholeDay
|
||||||
|
? berlinInstant(addDays(date, 1), 0)
|
||||||
|
: berlinInstant(date, endMinute!);
|
||||||
|
}
|
||||||
|
export function durationLabel(
|
||||||
|
alarm: Pick<NotdienstAlarm, "wholeDay" | "startMinute" | "endMinute">,
|
||||||
|
) {
|
||||||
|
return alarm.wholeDay
|
||||||
|
? "Ganzer Kita-Tag"
|
||||||
|
: `${minuteClock(alarm.startMinute!)}–${minuteClock(alarm.endMinute!)} Uhr`;
|
||||||
|
}
|
||||||
|
export function instantLabel(value: Date | string) {
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
export function alarmLabel(
|
||||||
|
alarm: Pick<NotdienstAlarm, "status" | "deadline" | "expiresAt">,
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
if (alarm.status === "CANCELLED") return "Storniert";
|
||||||
|
if (alarm.status === "CONFIRMED") return "Übernahme bestätigt";
|
||||||
|
if (alarm.status === "DECLINED") return "Klärung erforderlich – Absage";
|
||||||
|
if (alarm.status === "LATE_CONFIRMATION")
|
||||||
|
return "Verspätete Zusage – Freigabe erforderlich";
|
||||||
|
if (alarm.status === "REVIEW_REQUIRED")
|
||||||
|
return "Klärung erforderlich – bisherige Rückmeldungen prüfen";
|
||||||
|
if (alarm.deadline <= now || alarm.expiresAt <= now)
|
||||||
|
return "Klärung erforderlich – Frist abgelaufen";
|
||||||
|
return "Antwort ausstehend";
|
||||||
|
}
|
||||||
|
export function needsClarification(
|
||||||
|
alarm: Pick<NotdienstAlarm, "status" | "deadline" | "expiresAt">,
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
!["CONFIRMED", "CANCELLED"].includes(alarm.status) &&
|
||||||
|
(alarm.status !== "PENDING" ||
|
||||||
|
alarm.deadline <= now ||
|
||||||
|
alarm.expiresAt <= now)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { importLegacyAlarms, processAlarmDeadlines } from "./service";
|
||||||
|
import { deliverAlarmEmails } from "./email";
|
||||||
|
|
||||||
|
export function authorizeAlarmJob(
|
||||||
|
header: string | null,
|
||||||
|
secret = process.env.NOTDIENST_JOB_SECRET,
|
||||||
|
) {
|
||||||
|
if (!secret || secret.length < 32) return 503;
|
||||||
|
const expected = Buffer.from(`Bearer ${secret}`),
|
||||||
|
received = Buffer.from(header ?? "");
|
||||||
|
return expected.length === received.length &&
|
||||||
|
timingSafeEqual(expected, received)
|
||||||
|
? 200
|
||||||
|
: 401;
|
||||||
|
}
|
||||||
|
export async function runAlarmJob(
|
||||||
|
send?: Parameters<typeof deliverAlarmEmails>[1],
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
const imported = await importLegacyAlarms();
|
||||||
|
const deadlineEmails = await processAlarmDeadlines(now);
|
||||||
|
const jobs = await prisma.notdienstAlarmEmail.findMany({
|
||||||
|
where: {
|
||||||
|
sentAt: null,
|
||||||
|
skippedAt: null,
|
||||||
|
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: [{ attempts: "asc" }, { createdAt: "asc" }],
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
const delivery = await deliverAlarmEmails(
|
||||||
|
jobs.map((j) => j.id),
|
||||||
|
send,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
return { imported, deadlineJobs: deadlineEmails.length, ...delivery };
|
||||||
|
}
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { Prisma, type NotdienstAlarm, type UserRole } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
dateKey,
|
||||||
|
todayKey,
|
||||||
|
endOfAlarm,
|
||||||
|
berlinInstant,
|
||||||
|
durationLabel,
|
||||||
|
instantLabel,
|
||||||
|
} from "./format";
|
||||||
|
|
||||||
|
export type AlarmActor = { id: string; kitaId: string; role: UserRole };
|
||||||
|
export const managerRoles: UserRole[] = ["ADMIN", "KOORDINATOR"];
|
||||||
|
const id = z.string().min(1).max(100);
|
||||||
|
const tokenSchema = z.string().min(20).max(128);
|
||||||
|
const note = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(3, "Bitte eine Begründung angeben.")
|
||||||
|
.max(500);
|
||||||
|
type Tx = Prisma.TransactionClient;
|
||||||
|
async function transaction<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(run, {
|
||||||
|
isolationLevel: "Serializable",
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
["P2034", "P2002"].includes(error.code) &&
|
||||||
|
attempt < 3
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function validateAlarmActor(
|
||||||
|
tx: Tx,
|
||||||
|
actor: AlarmActor,
|
||||||
|
manager = true,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
manager
|
||||||
|
? !managerRoles.includes(actor.role)
|
||||||
|
: ![...managerRoles, "ELTERN"].includes(actor.role)
|
||||||
|
)
|
||||||
|
throw new Error("Kein Zugriff auf die Notdienstalarmierung.");
|
||||||
|
const user = await tx.user.findFirst({
|
||||||
|
where: {
|
||||||
|
id: actor.id,
|
||||||
|
kitaId: actor.kitaId,
|
||||||
|
role: actor.role,
|
||||||
|
privacyPolicyAcceptedAt: { not: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user || (user.lockedUntil && user.lockedUntil > new Date()))
|
||||||
|
throw new Error(
|
||||||
|
"Dein Konto hat keinen Zugriff auf die Notdienstalarmierung.",
|
||||||
|
);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
export async function assertAssignmentNotAlarmed(
|
||||||
|
tx: Tx,
|
||||||
|
assignmentId: string,
|
||||||
|
kitaId: string,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
(await tx.notdienstAlarm.count({
|
||||||
|
where: { kitaId, activeAssignmentId: assignmentId },
|
||||||
|
})) ||
|
||||||
|
(await tx.notdienstAlert.count({
|
||||||
|
where: {
|
||||||
|
kitaId,
|
||||||
|
assignmentId,
|
||||||
|
alarmId: null,
|
||||||
|
status: { not: "CANCELLED" },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Für diese Einteilung besteht ein aktiver Alarm. Bitte den Alarm zuerst stornieren.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function history(
|
||||||
|
tx: Tx,
|
||||||
|
alarmId: string,
|
||||||
|
actorId: string | null,
|
||||||
|
action: string,
|
||||||
|
details: string,
|
||||||
|
) {
|
||||||
|
await tx.notdienstAlarmHistory.create({
|
||||||
|
data: { alarmId, actorId, action, details },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function queue(
|
||||||
|
tx: Tx,
|
||||||
|
alarm: NotdienstAlarm,
|
||||||
|
audience: "parents" | "managers",
|
||||||
|
kind: string,
|
||||||
|
eventKey: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
const users =
|
||||||
|
audience === "managers"
|
||||||
|
? await tx.user.findMany({
|
||||||
|
where: { kitaId: alarm.kitaId, role: { in: managerRoles } },
|
||||||
|
})
|
||||||
|
: await tx.user.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId: alarm.kitaId,
|
||||||
|
familyId: alarm.familyId,
|
||||||
|
role: "ELTERN",
|
||||||
|
alarmRecipients: { some: { alarmId: alarm.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const jobs: string[] = [];
|
||||||
|
for (const user of users) {
|
||||||
|
const recipient =
|
||||||
|
kind === "REQUEST"
|
||||||
|
? await tx.notdienstAlarmRecipient.findUnique({
|
||||||
|
where: { alarmId_userId: { alarmId: alarm.id, userId: user.id } },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
await tx.notdienstAlarmNotification.create({
|
||||||
|
data: { alarmId: alarm.id, userId: user.id, eventKey, message },
|
||||||
|
});
|
||||||
|
const job = await tx.notdienstAlarmEmail.create({
|
||||||
|
data: {
|
||||||
|
alarmId: alarm.id,
|
||||||
|
userId: user.id,
|
||||||
|
eventKey,
|
||||||
|
kind,
|
||||||
|
subject: `Notdienst ${dateKey(alarm.date)} · ${kind === "REQUEST" ? "Bitte Rückmeldung" : kind === "CANCEL" ? "Einsatz storniert" : "Neue Rückmeldung"}`,
|
||||||
|
message,
|
||||||
|
path: recipient
|
||||||
|
? `/alert/${recipient.token}`
|
||||||
|
: `/dashboard/notdienst/alarm?alarm=${alarm.id}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
jobs.push(job.id);
|
||||||
|
}
|
||||||
|
return jobs;
|
||||||
|
}
|
||||||
|
export function alarmDetails(alarm: NotdienstAlarm) {
|
||||||
|
return `${dateKey(alarm.date)} · ${durationLabel(alarm)} · ${alarm.familyName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeAlarmCommand(
|
||||||
|
actor: AlarmActor,
|
||||||
|
command: string,
|
||||||
|
raw: unknown,
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<{ alarmId?: string; emailIds: string[] }> {
|
||||||
|
const data = z.record(z.string(), z.unknown()).parse(raw);
|
||||||
|
return transaction(async (tx) => {
|
||||||
|
const user = await validateAlarmActor(tx, actor, command !== "readNotice");
|
||||||
|
if (command === "readNotice") {
|
||||||
|
await tx.notdienstAlarmNotification.updateMany({
|
||||||
|
where: {
|
||||||
|
id: id.parse(data.id),
|
||||||
|
userId: user.id,
|
||||||
|
alarm: { kitaId: actor.kitaId },
|
||||||
|
},
|
||||||
|
data: { readAt: now },
|
||||||
|
});
|
||||||
|
return { emailIds: [] };
|
||||||
|
}
|
||||||
|
if (command === "create") {
|
||||||
|
const input = z
|
||||||
|
.object({
|
||||||
|
assignmentId: id,
|
||||||
|
wholeDay: z.boolean(),
|
||||||
|
startMinute: z.number().int().min(0).max(1439).optional(),
|
||||||
|
endMinute: z.number().int().min(1).max(1439).optional(),
|
||||||
|
replyMinutes: z.number().int().min(1).max(1440).default(30),
|
||||||
|
educatorId: id.optional(),
|
||||||
|
notes: z.string().trim().max(500).optional(),
|
||||||
|
})
|
||||||
|
.parse(data);
|
||||||
|
if (
|
||||||
|
!input.wholeDay &&
|
||||||
|
(input.startMinute === undefined ||
|
||||||
|
input.endMinute === undefined ||
|
||||||
|
input.endMinute <= input.startMinute)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Bitte gültige Einsatzzeiten mit Ende nach Beginn angeben.",
|
||||||
|
);
|
||||||
|
const assignment = await tx.notdienstAssignment.findFirst({
|
||||||
|
where: { id: input.assignmentId, kitaId: actor.kitaId },
|
||||||
|
include: {
|
||||||
|
kita: true,
|
||||||
|
plan: true,
|
||||||
|
child: {
|
||||||
|
include: {
|
||||||
|
family: {
|
||||||
|
include: {
|
||||||
|
users: { where: { role: "ELTERN", kitaId: actor.kitaId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!assignment ||
|
||||||
|
assignment.child.kitaId !== actor.kitaId ||
|
||||||
|
assignment.child.family.kitaId !== actor.kitaId ||
|
||||||
|
assignment.plan.kitaId !== actor.kitaId
|
||||||
|
)
|
||||||
|
throw new Error("Einteilung nicht gefunden.");
|
||||||
|
if (!assignment.kita.notdienstModuleEnabled)
|
||||||
|
throw new Error("Notdienst ist für diese Kita deaktiviert.");
|
||||||
|
if (assignment.plan.status !== "PUBLISHED")
|
||||||
|
throw new Error("Bitte den Notdienstplan zuerst veröffentlichen.");
|
||||||
|
const date = dateKey(assignment.date);
|
||||||
|
if (date < todayKey(now))
|
||||||
|
throw new Error("Vergangene Notdienste können nicht alarmiert werden.");
|
||||||
|
const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
|
||||||
|
if (expiresAt <= now)
|
||||||
|
throw new Error("Dieser Einsatz ist bereits beendet.");
|
||||||
|
if (!input.wholeDay) berlinInstant(date, input.startMinute!);
|
||||||
|
if (
|
||||||
|
(await tx.notdienstAlarm.count({
|
||||||
|
where: { activeAssignmentId: assignment.id },
|
||||||
|
})) ||
|
||||||
|
(await tx.notdienstAlert.count({
|
||||||
|
where: {
|
||||||
|
assignmentId: assignment.id,
|
||||||
|
alarmId: null,
|
||||||
|
status: { not: "CANCELLED" },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Für diese Einteilung besteht bereits ein aktiver Alarm.",
|
||||||
|
);
|
||||||
|
const parents = assignment.child.family.users;
|
||||||
|
if (!parents.length)
|
||||||
|
throw new Error(
|
||||||
|
"Für diesen Haushalt sind keine Elternkonten hinterlegt.",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
input.educatorId &&
|
||||||
|
!(await tx.educator.count({
|
||||||
|
where: { id: input.educatorId, kitaId: actor.kitaId, active: true },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
throw new Error("Fachkraft nicht gefunden.");
|
||||||
|
const alarm = await tx.notdienstAlarm.create({
|
||||||
|
data: {
|
||||||
|
kitaId: actor.kitaId,
|
||||||
|
assignmentId: assignment.id,
|
||||||
|
activeAssignmentId: assignment.id,
|
||||||
|
familyId: assignment.child.familyId,
|
||||||
|
familyName: assignment.child.family.name,
|
||||||
|
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
|
||||||
|
date: assignment.date,
|
||||||
|
wholeDay: input.wholeDay,
|
||||||
|
startMinute: input.wholeDay ? null : input.startMinute,
|
||||||
|
endMinute: input.wholeDay ? null : input.endMinute,
|
||||||
|
expiresAt,
|
||||||
|
deadline: new Date(
|
||||||
|
Math.min(
|
||||||
|
now.getTime() + input.replyMinutes * 60_000,
|
||||||
|
expiresAt.getTime(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
triggeredAt: now,
|
||||||
|
triggeredById: actor.id,
|
||||||
|
educatorId: input.educatorId,
|
||||||
|
notes: input.notes,
|
||||||
|
recipients: {
|
||||||
|
create: parents.map((parent) => ({
|
||||||
|
userId: parent.id,
|
||||||
|
token: randomUUID(),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await history(
|
||||||
|
tx,
|
||||||
|
alarm.id,
|
||||||
|
actor.id,
|
||||||
|
"Alarm ausgelöst",
|
||||||
|
alarmDetails(alarm),
|
||||||
|
);
|
||||||
|
const emailIds = await queue(
|
||||||
|
tx,
|
||||||
|
alarm,
|
||||||
|
"parents",
|
||||||
|
"REQUEST",
|
||||||
|
"request",
|
||||||
|
`${alarmDetails(alarm)}. Bitte gebt bis ${instantLabel(alarm.deadline)} Uhr für euren Haushalt Bescheid, ob ihr den Einsatz übernehmt.`,
|
||||||
|
);
|
||||||
|
return { alarmId: alarm.id, emailIds };
|
||||||
|
}
|
||||||
|
const alarm = await tx.notdienstAlarm.findFirst({
|
||||||
|
where: { id: id.parse(data.alarmId), kitaId: actor.kitaId },
|
||||||
|
});
|
||||||
|
if (!alarm) throw new Error("Alarm nicht gefunden.");
|
||||||
|
if (command === "retry") {
|
||||||
|
const jobs = await tx.notdienstAlarmEmail.findMany({
|
||||||
|
where: { alarmId: alarm.id, sentAt: null, skippedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
take: 100,
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
return { alarmId: alarm.id, emailIds: jobs.map((job) => job.id) };
|
||||||
|
}
|
||||||
|
if (command === "cancel") {
|
||||||
|
const reason = note.parse(data.reason);
|
||||||
|
if (alarm.status === "CANCELLED")
|
||||||
|
throw new Error("Der Alarm ist bereits storniert.");
|
||||||
|
const updated = await tx.notdienstAlarm.update({
|
||||||
|
where: { id: alarm.id },
|
||||||
|
data: {
|
||||||
|
status: "CANCELLED",
|
||||||
|
activeAssignmentId: null,
|
||||||
|
cancelledAt: now,
|
||||||
|
cancelReason: reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await history(tx, alarm.id, actor.id, "Alarm storniert", reason);
|
||||||
|
return {
|
||||||
|
alarmId: alarm.id,
|
||||||
|
emailIds: await queue(
|
||||||
|
tx,
|
||||||
|
updated,
|
||||||
|
"parents",
|
||||||
|
"CANCEL",
|
||||||
|
randomUUID(),
|
||||||
|
`${alarmDetails(updated)}: Der Einsatz wurde storniert. Ihr müsst für diesen Alarm nicht einspringen.`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command === "recordResponse" || command === "approveLate") {
|
||||||
|
if (alarm.status === "CANCELLED" || alarm.expiresAt <= now)
|
||||||
|
throw new Error(
|
||||||
|
"Dieser Alarm ist storniert oder der Einsatz bereits beendet.",
|
||||||
|
);
|
||||||
|
const reason = note.parse(data.reason);
|
||||||
|
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION")
|
||||||
|
throw new Error("Es liegt keine verspätete Zusage zur Freigabe vor.");
|
||||||
|
const approve =
|
||||||
|
command === "approveLate" || z.boolean().parse(data.approve);
|
||||||
|
const updated = await tx.notdienstAlarm.update({
|
||||||
|
where: { id: alarm.id },
|
||||||
|
data: {
|
||||||
|
status: approve ? "CONFIRMED" : "DECLINED",
|
||||||
|
respondedAt: now,
|
||||||
|
respondedById: actor.id,
|
||||||
|
responseSource: command === "approveLate" ? "COORDINATION" : "PHONE",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await history(
|
||||||
|
tx,
|
||||||
|
alarm.id,
|
||||||
|
actor.id,
|
||||||
|
command === "approveLate"
|
||||||
|
? "Verspätete Zusage freigegeben"
|
||||||
|
: "Rückmeldung dokumentiert",
|
||||||
|
reason,
|
||||||
|
);
|
||||||
|
const message = `${alarmDetails(updated)}: ${approve ? "Übernahme bestätigt" : "Absage – Klärung erforderlich"}.`;
|
||||||
|
const emails = await queue(
|
||||||
|
tx,
|
||||||
|
updated,
|
||||||
|
"managers",
|
||||||
|
"MANAGER",
|
||||||
|
randomUUID(),
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
emails.push(
|
||||||
|
...(await queue(
|
||||||
|
tx,
|
||||||
|
updated,
|
||||||
|
"parents",
|
||||||
|
"STATUS",
|
||||||
|
randomUUID(),
|
||||||
|
message,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
return { alarmId: alarm.id, emailIds: emails };
|
||||||
|
}
|
||||||
|
throw new Error("Unbekannte Alarmaktion.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recipientForToken(tx: Tx, token: string) {
|
||||||
|
const recipient = await tx.notdienstAlarmRecipient.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
alarm: { include: { kita: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!recipient ||
|
||||||
|
recipient.user.role !== "ELTERN" ||
|
||||||
|
(recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) ||
|
||||||
|
recipient.user.kitaId !== recipient.alarm.kitaId ||
|
||||||
|
recipient.user.familyId !== recipient.alarm.familyId
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
export async function getPublicAlarm(token: string) {
|
||||||
|
if (!tokenSchema.safeParse(token).success) return null;
|
||||||
|
const legacy = await prisma.notdienstAlert.findUnique({
|
||||||
|
where: { confirmationToken: token },
|
||||||
|
select: { alarmId: true, assignmentId: true },
|
||||||
|
});
|
||||||
|
if (legacy && !legacy.alarmId)
|
||||||
|
await importLegacyAlarms(undefined, legacy.assignmentId);
|
||||||
|
const recipient = await recipientForToken(prisma, token);
|
||||||
|
if (!recipient) return null;
|
||||||
|
const a = recipient.alarm;
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
kitaName: a.kita.name,
|
||||||
|
firstName: recipient.user.firstName,
|
||||||
|
familyName: a.familyName,
|
||||||
|
date: a.date,
|
||||||
|
wholeDay: a.wholeDay,
|
||||||
|
startMinute: a.startMinute,
|
||||||
|
endMinute: a.endMinute,
|
||||||
|
deadline: a.deadline,
|
||||||
|
expiresAt: a.expiresAt,
|
||||||
|
status: a.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function respondToAlarm(
|
||||||
|
token: string,
|
||||||
|
approve: boolean,
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
tokenSchema.parse(token);
|
||||||
|
z.boolean().parse(approve);
|
||||||
|
// A legacy token is imported before the response transaction, without sending any new request.
|
||||||
|
await getPublicAlarm(token);
|
||||||
|
return transaction(async (tx) => {
|
||||||
|
const recipient = await recipientForToken(tx, token);
|
||||||
|
if (!recipient) throw new Error("Ungültiger Rückmeldelink.");
|
||||||
|
const alarm = recipient.alarm;
|
||||||
|
if (alarm.status !== "PENDING")
|
||||||
|
throw new Error(
|
||||||
|
"Für diesen Haushalt liegt bereits eine Rückmeldung vor oder der Alarm wurde storniert.",
|
||||||
|
);
|
||||||
|
if (alarm.expiresAt <= now)
|
||||||
|
throw new Error(
|
||||||
|
"Der Einsatz ist beendet. Dieser Rückmeldelink ist abgelaufen.",
|
||||||
|
);
|
||||||
|
const updated = await tx.notdienstAlarm.update({
|
||||||
|
where: { id: alarm.id },
|
||||||
|
data: {
|
||||||
|
status: approve
|
||||||
|
? alarm.deadline <= now
|
||||||
|
? "LATE_CONFIRMATION"
|
||||||
|
: "CONFIRMED"
|
||||||
|
: "DECLINED",
|
||||||
|
respondedAt: now,
|
||||||
|
respondedById: recipient.user.id,
|
||||||
|
responseSource: "LINK",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await history(
|
||||||
|
tx,
|
||||||
|
alarm.id,
|
||||||
|
recipient.user.id,
|
||||||
|
approve ? "Zusage des Haushalts" : "Absage des Haushalts",
|
||||||
|
updated.status,
|
||||||
|
);
|
||||||
|
const message = `${alarmDetails(updated)}: ${updated.status === "CONFIRMED" ? "Übernahme bestätigt" : updated.status === "LATE_CONFIRMATION" ? "Verspätete Zusage – bitte freigeben" : "Absage – Klärung erforderlich"}.`;
|
||||||
|
return {
|
||||||
|
alarmId: alarm.id,
|
||||||
|
emailIds: await queue(
|
||||||
|
tx,
|
||||||
|
updated,
|
||||||
|
"managers",
|
||||||
|
"MANAGER",
|
||||||
|
randomUUID(),
|
||||||
|
message,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processAlarmDeadlines(now = new Date(), kitaId?: string) {
|
||||||
|
const candidates = await prisma.notdienstAlarm.findMany({
|
||||||
|
where: {
|
||||||
|
status: "PENDING",
|
||||||
|
escalatedAt: null,
|
||||||
|
deadline: { lte: now },
|
||||||
|
...(kitaId ? { kitaId } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { deadline: "asc" },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
const emailIds: string[] = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
emailIds.push(
|
||||||
|
...(await transaction(async (tx) => {
|
||||||
|
const alarm = await tx.notdienstAlarm.findUnique({
|
||||||
|
where: { id: candidate.id },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!alarm ||
|
||||||
|
alarm.status !== "PENDING" ||
|
||||||
|
alarm.escalatedAt ||
|
||||||
|
alarm.deadline > now
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
const updated = await tx.notdienstAlarm.update({
|
||||||
|
where: { id: alarm.id },
|
||||||
|
data: { escalatedAt: now },
|
||||||
|
});
|
||||||
|
await history(
|
||||||
|
tx,
|
||||||
|
alarm.id,
|
||||||
|
null,
|
||||||
|
"Antwortfrist abgelaufen",
|
||||||
|
"Klärung erforderlich; Ersatz manuell organisieren.",
|
||||||
|
);
|
||||||
|
return queue(
|
||||||
|
tx,
|
||||||
|
updated,
|
||||||
|
"managers",
|
||||||
|
"MANAGER",
|
||||||
|
"deadline",
|
||||||
|
`${alarmDetails(updated)}: Keine Antwort innerhalb der Frist. Klärung erforderlich; bitte Familie kontaktieren oder Ersatz organisieren.`,
|
||||||
|
);
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await prisma.notdienstAlarm.updateMany({
|
||||||
|
where: {
|
||||||
|
expiresAt: { lte: now },
|
||||||
|
activeAssignmentId: { not: null },
|
||||||
|
...(kitaId ? { kitaId } : {}),
|
||||||
|
},
|
||||||
|
data: { activeAssignmentId: null },
|
||||||
|
});
|
||||||
|
return emailIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importLegacyAlarms(
|
||||||
|
kitaId?: string,
|
||||||
|
assignmentId?: string,
|
||||||
|
) {
|
||||||
|
const groups = await prisma.notdienstAlert.findMany({
|
||||||
|
where: {
|
||||||
|
alarmId: null,
|
||||||
|
...(kitaId ? { kitaId } : {}),
|
||||||
|
...(assignmentId ? { assignmentId } : {}),
|
||||||
|
},
|
||||||
|
distinct: ["assignmentId"],
|
||||||
|
select: { assignmentId: true },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
for (const group of groups)
|
||||||
|
await transaction(async (tx) => {
|
||||||
|
const assignment = await tx.notdienstAssignment.findUnique({
|
||||||
|
where: { id: group.assignmentId },
|
||||||
|
include: {
|
||||||
|
child: { include: { family: true } },
|
||||||
|
alerts: {
|
||||||
|
where: { alarmId: null },
|
||||||
|
include: { parentUser: true },
|
||||||
|
orderBy: { triggeredAt: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!assignment || !assignment.alerts.length) return;
|
||||||
|
const old = assignment.alerts;
|
||||||
|
const valid = old.filter(
|
||||||
|
(a) =>
|
||||||
|
a.parentUser.role === "ELTERN" &&
|
||||||
|
a.parentUser.familyId === assignment.child.familyId &&
|
||||||
|
a.parentUser.kitaId === assignment.kitaId,
|
||||||
|
);
|
||||||
|
const uniqueRecipients = [
|
||||||
|
...new Map(valid.map((a) => [a.parentUserId, a])).values(),
|
||||||
|
];
|
||||||
|
const cancelled = old.every((a) => a.status === "CANCELLED");
|
||||||
|
const confirmed =
|
||||||
|
uniqueRecipients.length === old.length &&
|
||||||
|
old.every((a) => a.status === "CONFIRMED");
|
||||||
|
const pending =
|
||||||
|
uniqueRecipients.length === old.length &&
|
||||||
|
old.every((a) => a.status === "PENDING");
|
||||||
|
const expiresAt = endOfAlarm(dateKey(assignment.date), true);
|
||||||
|
const alarm = await tx.notdienstAlarm.create({
|
||||||
|
data: {
|
||||||
|
kitaId: assignment.kitaId,
|
||||||
|
assignmentId: assignment.id,
|
||||||
|
activeAssignmentId:
|
||||||
|
!cancelled && expiresAt > new Date() ? assignment.id : null,
|
||||||
|
familyId: assignment.child.familyId,
|
||||||
|
familyName: assignment.child.family.name,
|
||||||
|
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
|
||||||
|
date: assignment.date,
|
||||||
|
expiresAt,
|
||||||
|
deadline: new Date(
|
||||||
|
Math.min(
|
||||||
|
old[0].triggeredAt.getTime() + 30 * 60_000,
|
||||||
|
expiresAt.getTime(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
triggeredAt: old[0].triggeredAt,
|
||||||
|
triggeredById: old[0].triggeredById,
|
||||||
|
educatorId: old[0].educatorId,
|
||||||
|
notes:
|
||||||
|
old
|
||||||
|
.map((a) => a.notes)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((note, index, all) => all.indexOf(note) === index)
|
||||||
|
.join("\n") || null,
|
||||||
|
status: cancelled
|
||||||
|
? "CANCELLED"
|
||||||
|
: confirmed
|
||||||
|
? "CONFIRMED"
|
||||||
|
: pending
|
||||||
|
? "PENDING"
|
||||||
|
: "REVIEW_REQUIRED",
|
||||||
|
responseSource: "LEGACY",
|
||||||
|
respondedAt: confirmed ? old[0].confirmedAt : null,
|
||||||
|
recipients: {
|
||||||
|
create: uniqueRecipients.map((a) => ({
|
||||||
|
userId: a.parentUserId,
|
||||||
|
token: a.confirmationToken,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.notdienstAlert.updateMany({
|
||||||
|
where: { id: { in: old.map((a) => a.id) } },
|
||||||
|
data: { alarmId: alarm.id },
|
||||||
|
});
|
||||||
|
await history(
|
||||||
|
tx,
|
||||||
|
alarm.id,
|
||||||
|
null,
|
||||||
|
"Bisherige Alarmierung übernommen",
|
||||||
|
`Einzelstatus erhalten. ${alarm.status === "REVIEW_REQUIRED" ? "Rückmeldungen müssen manuell geprüft werden." : "Gemeinsamer Status eindeutig."}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return groups.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlarmFeed(actor: AlarmActor, now = new Date()) {
|
||||||
|
const user = await validateAlarmActor(prisma, actor, false);
|
||||||
|
const manager = managerRoles.includes(user.role);
|
||||||
|
if (!manager && !user.familyId) return [];
|
||||||
|
return prisma.notdienstAlarm.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId: actor.kitaId,
|
||||||
|
...(manager ? {} : { familyId: user.familyId! }),
|
||||||
|
OR: [
|
||||||
|
{ expiresAt: { gt: now } },
|
||||||
|
{
|
||||||
|
status: "CANCELLED",
|
||||||
|
cancelledAt: { gte: new Date(now.getTime() - 86400000) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [{ date: "asc" }, { triggeredAt: "desc" }],
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
date: true,
|
||||||
|
familyName: true,
|
||||||
|
wholeDay: true,
|
||||||
|
startMinute: true,
|
||||||
|
endMinute: true,
|
||||||
|
deadline: true,
|
||||||
|
expiresAt: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,826 @@
|
|||||||
|
import { after, test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { UserRole } from "@prisma/client";
|
||||||
|
import { render } from "@react-email/render";
|
||||||
|
import { prisma } from "../src/lib/prisma";
|
||||||
|
import {
|
||||||
|
executeAlarmCommand,
|
||||||
|
respondToAlarm,
|
||||||
|
getPublicAlarm,
|
||||||
|
getAlarmFeed,
|
||||||
|
processAlarmDeadlines,
|
||||||
|
importLegacyAlarms,
|
||||||
|
assertAssignmentNotAlarmed,
|
||||||
|
type AlarmActor,
|
||||||
|
} from "../src/lib/alarms/service";
|
||||||
|
import { deliverAlarmEmails } from "../src/lib/alarms/email";
|
||||||
|
import { authorizeAlarmJob } from "../src/lib/alarms/job";
|
||||||
|
import { updateAssignmentAction } from "../src/app/dashboard/notdienst/plan/actions";
|
||||||
|
import {
|
||||||
|
createNotdienstSwapRequest,
|
||||||
|
acceptNotdienstSwapRequest,
|
||||||
|
} from "../src/app/dashboard/kalender/actions";
|
||||||
|
import {
|
||||||
|
berlinInstant,
|
||||||
|
endOfAlarm,
|
||||||
|
alarmLabel,
|
||||||
|
} from "../src/lib/alarms/format";
|
||||||
|
import { dateOnly } from "../src/lib/staff/calculations";
|
||||||
|
|
||||||
|
const db = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
|
||||||
|
if (
|
||||||
|
db.hostname !== "127.0.0.1" ||
|
||||||
|
db.port !== "55439" ||
|
||||||
|
db.pathname !== "/kita_staff_test"
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Alarmtests benötigen die isolierte Datenbank 127.0.0.1:55439/kita_staff_test.",
|
||||||
|
);
|
||||||
|
process.env.AUTH_URL = "http://127.0.0.1:3008";
|
||||||
|
const tenants: string[] = [];
|
||||||
|
after(async () => {
|
||||||
|
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
const date = "2090-01-09",
|
||||||
|
now = berlinInstant(date, 480);
|
||||||
|
async function fixture() {
|
||||||
|
const kita = await prisma.kita.create({
|
||||||
|
data: { name: "Alarm-Testkita", slug: `alarm-test-${randomUUID()}` },
|
||||||
|
});
|
||||||
|
tenants.push(kita.id);
|
||||||
|
const family = await prisma.family.create({
|
||||||
|
data: { kitaId: kita.id, name: "Testfamilie" },
|
||||||
|
});
|
||||||
|
const otherFamily = await prisma.family.create({
|
||||||
|
data: { kitaId: kita.id, name: "Anderer Haushalt" },
|
||||||
|
});
|
||||||
|
const makeUser = (role: UserRole, familyId?: string) =>
|
||||||
|
prisma.user.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
role,
|
||||||
|
familyId,
|
||||||
|
email: `${randomUUID()}@alarm-test.invalid`,
|
||||||
|
firstName: "Test",
|
||||||
|
lastName: role,
|
||||||
|
passwordHash: "test-only",
|
||||||
|
privacyPolicyAcceptedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const adminUser = await makeUser("ADMIN"),
|
||||||
|
coordUser = await makeUser("KOORDINATOR"),
|
||||||
|
staffUser = await makeUser("ERZIEHER"),
|
||||||
|
p1 = await makeUser("ELTERN", family.id),
|
||||||
|
p2 = await makeUser("ELTERN", family.id),
|
||||||
|
outsider = await makeUser("ELTERN", otherFamily.id);
|
||||||
|
const actor = (u: typeof p1): AlarmActor => ({
|
||||||
|
id: u.id,
|
||||||
|
kitaId: kita.id,
|
||||||
|
role: u.role,
|
||||||
|
});
|
||||||
|
const admin = actor(adminUser),
|
||||||
|
coord = actor(coordUser),
|
||||||
|
staff = actor(staffUser),
|
||||||
|
parent = actor(p1),
|
||||||
|
otherParent = actor(outsider);
|
||||||
|
const child = await prisma.child.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
familyId: family.id,
|
||||||
|
firstName: "Testkind",
|
||||||
|
lastName: "Testfamilie",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const educator = await prisma.educator.create({
|
||||||
|
data: { kitaId: kita.id, firstName: "Interne", lastName: "Fachkraft" },
|
||||||
|
});
|
||||||
|
const plan = await prisma.notdienstPlan.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
year: 2090,
|
||||||
|
month: 1,
|
||||||
|
status: "PUBLISHED",
|
||||||
|
publishedAt: now,
|
||||||
|
createdById: admin.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const assignment = await prisma.notdienstAssignment.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
planId: plan.id,
|
||||||
|
childId: child.id,
|
||||||
|
date: dateOnly(date),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const create = (
|
||||||
|
data: Record<string, unknown> = {},
|
||||||
|
who = admin,
|
||||||
|
time = now,
|
||||||
|
) =>
|
||||||
|
executeAlarmCommand(
|
||||||
|
who,
|
||||||
|
"create",
|
||||||
|
{ assignmentId: assignment.id, wholeDay: true, ...data },
|
||||||
|
time,
|
||||||
|
);
|
||||||
|
const command = (
|
||||||
|
name: string,
|
||||||
|
alarmId: string,
|
||||||
|
data: Record<string, unknown> = {},
|
||||||
|
who = coord,
|
||||||
|
time = now,
|
||||||
|
) => executeAlarmCommand(who, name, { alarmId, ...data }, time);
|
||||||
|
const recipients = (alarmId: string) =>
|
||||||
|
prisma.notdienstAlarmRecipient.findMany({
|
||||||
|
where: { alarmId },
|
||||||
|
orderBy: { userId: "asc" },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
kita,
|
||||||
|
family,
|
||||||
|
otherFamily,
|
||||||
|
adminUser,
|
||||||
|
coordUser,
|
||||||
|
admin,
|
||||||
|
coord,
|
||||||
|
staff,
|
||||||
|
parent,
|
||||||
|
otherParent,
|
||||||
|
p1,
|
||||||
|
p2,
|
||||||
|
outsider,
|
||||||
|
child,
|
||||||
|
educator,
|
||||||
|
plan,
|
||||||
|
assignment,
|
||||||
|
create,
|
||||||
|
command,
|
||||||
|
recipients,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Hinweise können nur vom eigenen Empfänger gelesen werden", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
const alarm = await f.create();
|
||||||
|
const notice = await prisma.notdienstAlarmNotification.findFirstOrThrow({
|
||||||
|
where: { alarmId: alarm.alarmId, userId: f.p1.id },
|
||||||
|
});
|
||||||
|
await executeAlarmCommand(
|
||||||
|
f.otherParent,
|
||||||
|
"readNotice",
|
||||||
|
{ id: notice.id },
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await prisma.notdienstAlarmNotification.findUniqueOrThrow({
|
||||||
|
where: { id: notice.id },
|
||||||
|
})
|
||||||
|
).readAt,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await executeAlarmCommand(f.parent, "readNotice", { id: notice.id }, now);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await prisma.notdienstAlarmNotification.findUniqueOrThrow({
|
||||||
|
where: { id: notice.id },
|
||||||
|
})
|
||||||
|
).readAt?.getTime(),
|
||||||
|
now.getTime(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Gesicherte Konten und fehlende Einwilligung verhindern Verwaltungszugriff", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: f.admin.id },
|
||||||
|
data: { privacyPolicyAcceptedAt: null },
|
||||||
|
});
|
||||||
|
await assert.rejects(f.create(), /keinen Zugriff/);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: f.admin.id },
|
||||||
|
data: {
|
||||||
|
privacyPolicyAcceptedAt: new Date(),
|
||||||
|
lockedUntil: new Date(Date.now() + 3600000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(f.create(), /keinen Zugriff/);
|
||||||
|
const alarm = await f.create({}, f.coord);
|
||||||
|
const token = (await f.recipients(alarm.alarmId!)).find(
|
||||||
|
(r) => r.userId === f.p1.id,
|
||||||
|
)!.token;
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: f.p1.id },
|
||||||
|
data: { lockedUntil: new Date(Date.now() + 3600000) },
|
||||||
|
});
|
||||||
|
assert.equal(await getPublicAlarm(token), null);
|
||||||
|
await assert.rejects(respondToAlarm(token, true, now), /Ungültiger/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Aktive Alarme sperren manuelle Zuordnung sowie neue und bereits angefragte Tausche", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
const otherChild = await prisma.child.create({
|
||||||
|
data: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
familyId: f.otherFamily.id,
|
||||||
|
firstName: "Anderes",
|
||||||
|
lastName: "Testkind",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const otherAssignment = await prisma.notdienstAssignment.create({
|
||||||
|
data: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
planId: f.plan.id,
|
||||||
|
childId: otherChild.id,
|
||||||
|
date: dateOnly("2090-01-10"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const request = await prisma.notdienstSwapRequest.create({
|
||||||
|
data: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
requesterUserId: f.outsider.id,
|
||||||
|
requesterFamilyId: f.otherFamily.id,
|
||||||
|
targetAssignmentId: f.assignment.id,
|
||||||
|
offeredAssignmentId: otherAssignment.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await f.create();
|
||||||
|
const names = [
|
||||||
|
"TEST_MODE",
|
||||||
|
"TEST_USER_ID",
|
||||||
|
"TEST_USER_ROLE",
|
||||||
|
"TEST_KITA_ID",
|
||||||
|
"TEST_FAMILY_ID",
|
||||||
|
];
|
||||||
|
const previous = names.map((key) => process.env[key]);
|
||||||
|
function login(actor: AlarmActor, familyId: string) {
|
||||||
|
process.env.TEST_MODE = "true";
|
||||||
|
process.env.TEST_USER_ID = actor.id;
|
||||||
|
process.env.TEST_USER_ROLE = actor.role;
|
||||||
|
process.env.TEST_KITA_ID = actor.kitaId;
|
||||||
|
process.env.TEST_FAMILY_ID = familyId;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
login(f.admin, f.family.id);
|
||||||
|
const manual = await updateAssignmentAction(f.plan.id, date, otherChild.id);
|
||||||
|
assert.match(manual.error ?? "", /Alarm/);
|
||||||
|
login(f.otherParent, f.otherFamily.id);
|
||||||
|
const newSwap = await createNotdienstSwapRequest({
|
||||||
|
targetAssignmentId: f.assignment.id,
|
||||||
|
offeredAssignmentId: otherAssignment.id,
|
||||||
|
});
|
||||||
|
assert.match(newSwap.error ?? "", /Alarmierung/);
|
||||||
|
login(f.parent, f.family.id);
|
||||||
|
const accept = await acceptNotdienstSwapRequest(request.id);
|
||||||
|
assert.match(accept.error ?? "", /Alarmierung/);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await prisma.notdienstSwapRequest.findUniqueOrThrow({
|
||||||
|
where: { id: request.id },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
"PENDING",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await prisma.notdienstAssignment.findUniqueOrThrow({
|
||||||
|
where: { id: f.assignment.id },
|
||||||
|
})
|
||||||
|
).childId,
|
||||||
|
f.child.id,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await prisma.notdienstAssignment.findUniqueOrThrow({
|
||||||
|
where: { id: otherAssignment.id },
|
||||||
|
})
|
||||||
|
).childId,
|
||||||
|
otherChild.id,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
names.forEach((key, index) => {
|
||||||
|
if (previous[index] === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = previous[index];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Nur aktuelle Admins und Koordination derselben Kita dürfen Alarme verwalten", async () => {
|
||||||
|
const f = await fixture(),
|
||||||
|
foreign = await fixture();
|
||||||
|
await assert.rejects(f.create({}, f.staff), /Kein Zugriff/);
|
||||||
|
await assert.rejects(f.create({}, f.parent), /Kein Zugriff/);
|
||||||
|
await assert.rejects(f.create({}, foreign.admin), /nicht gefunden/);
|
||||||
|
await assert.rejects(
|
||||||
|
f.create({}, { ...f.admin, kitaId: foreign.kita.id }),
|
||||||
|
/keinen Zugriff/,
|
||||||
|
);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: f.admin.id },
|
||||||
|
data: { role: "ELTERN" },
|
||||||
|
});
|
||||||
|
await assert.rejects(f.create(), /keinen Zugriff/);
|
||||||
|
const result = await f.create({}, f.coord);
|
||||||
|
await assert.rejects(
|
||||||
|
f.command("cancel", result.alarmId!, { reason: "Teststorno" }, f.parent),
|
||||||
|
/Kein Zugriff/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Eingeteilter Haushalt erhält zwei persönliche Links, gemeinsame Antwort und 30 Minuten Frist", async () => {
|
||||||
|
const f = await fixture(),
|
||||||
|
result = await f.create();
|
||||||
|
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||||
|
where: { id: result.alarmId },
|
||||||
|
});
|
||||||
|
assert.equal(alarm.deadline.getTime() - now.getTime(), 30 * 60_000);
|
||||||
|
assert.equal(alarm.wholeDay, true);
|
||||||
|
const recipients = await f.recipients(alarm.id);
|
||||||
|
assert.deepEqual(
|
||||||
|
recipients.map((r) => r.userId).sort(),
|
||||||
|
[f.p1.id, f.p2.id].sort(),
|
||||||
|
);
|
||||||
|
assert.notEqual(recipients[0].token, recipients[1].token);
|
||||||
|
assert.equal(result.emailIds.length, 2);
|
||||||
|
assert.equal((await getAlarmFeed(f.parent, now)).length, 1);
|
||||||
|
assert.equal((await getAlarmFeed(f.otherParent, now)).length, 0);
|
||||||
|
assert.equal(await getPublicAlarm(randomUUID()), null);
|
||||||
|
await respondToAlarm(
|
||||||
|
recipients[0].token,
|
||||||
|
true,
|
||||||
|
new Date(now.getTime() + 60_000),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await getPublicAlarm(recipients[1].token))?.status,
|
||||||
|
"CONFIRMED",
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
respondToAlarm(recipients[1].token, false, now),
|
||||||
|
/bereits/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Entwürfe, deaktivierte Module, vergangene Einsätze und ungültige Uhrzeiten werden abgewiesen", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await prisma.notdienstPlan.update({
|
||||||
|
where: { id: f.plan.id },
|
||||||
|
data: { status: "DRAFT" },
|
||||||
|
});
|
||||||
|
await assert.rejects(f.create(), /veröffentlichen/);
|
||||||
|
await prisma.notdienstPlan.update({
|
||||||
|
where: { id: f.plan.id },
|
||||||
|
data: { status: "PUBLISHED" },
|
||||||
|
});
|
||||||
|
await prisma.kita.update({
|
||||||
|
where: { id: f.kita.id },
|
||||||
|
data: { notdienstModuleEnabled: false },
|
||||||
|
});
|
||||||
|
await assert.rejects(f.create(), /deaktiviert/);
|
||||||
|
await prisma.kita.update({
|
||||||
|
where: { id: f.kita.id },
|
||||||
|
data: { notdienstModuleEnabled: true },
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
f.create({ wholeDay: false, startMinute: 900, endMinute: 800 }),
|
||||||
|
/Ende nach Beginn/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
f.create({ wholeDay: false, startMinute: 360, endMinute: 420 }),
|
||||||
|
/beendet/,
|
||||||
|
);
|
||||||
|
await assert.rejects(f.create({ educatorId: randomUUID() }), /Fachkraft/);
|
||||||
|
await assert.rejects(f.create({ replyMinutes: 0 }));
|
||||||
|
await prisma.notdienstAssignment.update({
|
||||||
|
where: { id: f.assignment.id },
|
||||||
|
data: { date: dateOnly("2090-01-08") },
|
||||||
|
});
|
||||||
|
await assert.rejects(f.create(), /Vergangene/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Uhrzeiten gelten in Berlin und eine kurze Einsatzdauer begrenzt die Antwortfrist", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
const result = await f.create({
|
||||||
|
wholeDay: false,
|
||||||
|
startMinute: 480,
|
||||||
|
endMinute: 495,
|
||||||
|
});
|
||||||
|
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||||
|
where: { id: result.alarmId },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
alarm.expiresAt.toISOString(),
|
||||||
|
berlinInstant(date, 495).toISOString(),
|
||||||
|
);
|
||||||
|
assert.equal(alarm.deadline.toISOString(), alarm.expiresAt.toISOString());
|
||||||
|
assert.equal(alarm.startMinute, 480);
|
||||||
|
await assert.rejects(
|
||||||
|
respondToAlarm(
|
||||||
|
(await f.recipients(alarm.id))[0].token,
|
||||||
|
true,
|
||||||
|
alarm.expiresAt,
|
||||||
|
),
|
||||||
|
/abgelaufen/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Zeitumstellung und Tagesende werden unabhängig von der Serverzeitzone berechnet", () => {
|
||||||
|
assert.equal(
|
||||||
|
berlinInstant("2026-10-09", 480).toISOString(),
|
||||||
|
"2026-10-09T06:00:00.000Z",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
berlinInstant("2026-12-09", 480).toISOString(),
|
||||||
|
"2026-12-09T07:00:00.000Z",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
endOfAlarm("2026-03-29", true).toISOString(),
|
||||||
|
"2026-03-29T22:00:00.000Z",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
endOfAlarm("2026-10-25", true).toISOString(),
|
||||||
|
"2026-10-25T23:00:00.000Z",
|
||||||
|
);
|
||||||
|
assert.throws(() => berlinInstant("2026-03-29", 150), /Zeitumstellung/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Parallele Auslösung erstellt genau einen aktiven Vorgang", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
const results = await Promise.allSettled([f.create(), f.create()]);
|
||||||
|
assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
|
||||||
|
assert.equal(
|
||||||
|
await prisma.notdienstAlarm.count({
|
||||||
|
where: { activeAssignmentId: f.assignment.id },
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await prisma.notdienstAlarmRecipient.count({
|
||||||
|
where: { alarm: { kitaId: f.kita.id } },
|
||||||
|
}),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Gegensätzliche gleichzeitige Haushaltsantworten können nicht beide gespeichert werden", async () => {
|
||||||
|
const f = await fixture(),
|
||||||
|
result = await f.create();
|
||||||
|
const recipients = await f.recipients(result.alarmId!);
|
||||||
|
const answers = await Promise.allSettled([
|
||||||
|
respondToAlarm(recipients[0].token, true, now),
|
||||||
|
respondToAlarm(recipients[1].token, false, now),
|
||||||
|
]);
|
||||||
|
assert.equal(answers.filter((a) => a.status === "fulfilled").length, 1);
|
||||||
|
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||||
|
where: { id: result.alarmId },
|
||||||
|
});
|
||||||
|
assert.ok(["CONFIRMED", "DECLINED"].includes(alarm.status));
|
||||||
|
assert.equal(
|
||||||
|
await prisma.notdienstAlarmHistory.count({
|
||||||
|
where: {
|
||||||
|
alarmId: alarm.id,
|
||||||
|
action: { in: ["Zusage des Haushalts", "Absage des Haushalts"] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await prisma.notdienstAlarmEmail.count({
|
||||||
|
where: { alarmId: alarm.id, kind: "MANAGER" },
|
||||||
|
}),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fristprüfung eskaliert ohne Elternantwort genau einmal und erzeugt nur Koordinationshinweise", async () => {
|
||||||
|
const f = await fixture(),
|
||||||
|
result = await f.create();
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await processAlarmDeadlines(
|
||||||
|
new Date(now.getTime() + 29 * 60_000),
|
||||||
|
f.kita.id,
|
||||||
|
)
|
||||||
|
).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const deadline = new Date(now.getTime() + 30 * 60_000);
|
||||||
|
const results = await Promise.all([
|
||||||
|
processAlarmDeadlines(deadline, f.kita.id),
|
||||||
|
processAlarmDeadlines(deadline, f.kita.id),
|
||||||
|
]);
|
||||||
|
assert.equal(results.flat().length, 2);
|
||||||
|
const jobs = await prisma.notdienstAlarmEmail.findMany({
|
||||||
|
where: { alarmId: result.alarmId, eventKey: "deadline" },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
jobs.map((j) => j.userId).sort(),
|
||||||
|
[f.admin.id, f.coord.id].sort(),
|
||||||
|
);
|
||||||
|
assert.equal((await processAlarmDeadlines(deadline, f.kita.id)).length, 0);
|
||||||
|
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||||
|
where: { id: result.alarmId },
|
||||||
|
});
|
||||||
|
assert.ok(alarm.escalatedAt);
|
||||||
|
assert.match(alarmLabel(alarm, deadline), /Frist abgelaufen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Rechtzeitige Zusagen verhindern Fristmeldungen; Absagen informieren sofort", async () => {
|
||||||
|
const f = await fixture(),
|
||||||
|
result = await f.create();
|
||||||
|
const token = (await f.recipients(result.alarmId!))[0].token;
|
||||||
|
const response = await respondToAlarm(token, false, now);
|
||||||
|
assert.equal(response.emailIds.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await processAlarmDeadlines(
|
||||||
|
new Date(now.getTime() + 60 * 60_000),
|
||||||
|
f.kita.id,
|
||||||
|
)
|
||||||
|
).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||||
|
where: { id: result.alarmId },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
"DECLINED",
|
||||||
|
);
|
||||||
|
const other = await fixture(),
|
||||||
|
accepted = await other.create();
|
||||||
|
await respondToAlarm(
|
||||||
|
(await other.recipients(accepted.alarmId!))[0].token,
|
||||||
|
true,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await processAlarmDeadlines(
|
||||||
|
new Date(now.getTime() + 60 * 60_000),
|
||||||
|
other.kita.id,
|
||||||
|
)
|
||||||
|
).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Verspätete Zusagen benötigen Freigabe; telefonische Korrekturen bleiben intern begründet", async () => {
|
||||||
|
const f = await fixture(),
|
||||||
|
result = await f.create({
|
||||||
|
notes: "PRIVATE_KRANKHEITSNOTIZ",
|
||||||
|
educatorId: f.educator.id,
|
||||||
|
});
|
||||||
|
const late = new Date(now.getTime() + 31 * 60_000),
|
||||||
|
token = (await f.recipients(result.alarmId!))[0].token;
|
||||||
|
await respondToAlarm(token, true, late);
|
||||||
|
assert.equal((await getPublicAlarm(token))?.status, "LATE_CONFIRMATION");
|
||||||
|
await f.command(
|
||||||
|
"approveLate",
|
||||||
|
result.alarmId!,
|
||||||
|
{ reason: "PRIVATE_FREIGABE" },
|
||||||
|
f.coord,
|
||||||
|
late,
|
||||||
|
);
|
||||||
|
assert.equal((await getPublicAlarm(token))?.status, "CONFIRMED");
|
||||||
|
await f.command(
|
||||||
|
"recordResponse",
|
||||||
|
result.alarmId!,
|
||||||
|
{ approve: false, reason: "PRIVATE_TELEFONNOTIZ" },
|
||||||
|
f.admin,
|
||||||
|
late,
|
||||||
|
);
|
||||||
|
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||||
|
where: { id: result.alarmId },
|
||||||
|
});
|
||||||
|
assert.equal(alarm.status, "DECLINED");
|
||||||
|
assert.equal(alarm.responseSource, "PHONE");
|
||||||
|
const jobs = await prisma.notdienstAlarmEmail.findMany({
|
||||||
|
where: { alarmId: alarm.id },
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
jobs.every(
|
||||||
|
(job) =>
|
||||||
|
!job.message.includes("PRIVATE_") &&
|
||||||
|
!job.message.includes("Interne Fachkraft"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Stornierung invalidiert alte Links, informiert den Haushalt und erlaubt neue Alarmierung", async () => {
|
||||||
|
const f = await fixture(),
|
||||||
|
result = await f.create();
|
||||||
|
await assert.rejects(
|
||||||
|
assertAssignmentNotAlarmed(prisma, f.assignment.id, f.kita.id),
|
||||||
|
/aktiver Alarm/,
|
||||||
|
);
|
||||||
|
const token = (await f.recipients(result.alarmId!))[0].token;
|
||||||
|
const cancelled = await f.command("cancel", result.alarmId!, {
|
||||||
|
reason: "PRIVATE_STORNIERUNG",
|
||||||
|
});
|
||||||
|
assert.equal(cancelled.emailIds.length, 2);
|
||||||
|
await assert.rejects(respondToAlarm(token, true, now), /storniert/);
|
||||||
|
await assertAssignmentNotAlarmed(prisma, f.assignment.id, f.kita.id);
|
||||||
|
const renewed = await f.create();
|
||||||
|
assert.notEqual(renewed.alarmId, result.alarmId);
|
||||||
|
assert.equal((await getPublicAlarm(token))?.status, "CANCELLED");
|
||||||
|
let requests = 0;
|
||||||
|
await deliverAlarmEmails(
|
||||||
|
result.emailIds,
|
||||||
|
async () => {
|
||||||
|
requests++;
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
assert.equal(requests, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Versandfehler stoppen keine anderen Empfänger; erneuter Versand lässt Frist unverändert", async () => {
|
||||||
|
const f = await fixture(),
|
||||||
|
result = await f.create();
|
||||||
|
let calls = 0;
|
||||||
|
const messages: string[] = [];
|
||||||
|
const delivery = await deliverAlarmEmails(
|
||||||
|
result.emailIds,
|
||||||
|
async (input) => {
|
||||||
|
const number = ++calls;
|
||||||
|
messages.push(await render(input.react));
|
||||||
|
assert.ok(input.idempotencyKey?.startsWith("notdienst-alarm/"));
|
||||||
|
return number === 1
|
||||||
|
? { success: false, error: "Testausfall" }
|
||||||
|
: { success: true };
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
assert.equal(delivery.failed, 1);
|
||||||
|
assert.equal(delivery.sent, 1);
|
||||||
|
assert.ok(
|
||||||
|
messages.every(
|
||||||
|
(html) => html.includes("2090-01-09") && html.includes("Ganzer Kita-Tag"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const original = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||||
|
where: { id: result.alarmId },
|
||||||
|
});
|
||||||
|
const retry = await f.command("retry", original.id);
|
||||||
|
assert.equal(retry.emailIds.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await deliverAlarmEmails(
|
||||||
|
retry.emailIds,
|
||||||
|
async () => ({ success: true }),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
).sent,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||||
|
where: { id: original.id },
|
||||||
|
})
|
||||||
|
).deadline.getTime(),
|
||||||
|
original.deadline.getTime(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Paralleler Versand verwendet Aufträge einmal; geänderte Haushalte erhalten keine Anfrage", async () => {
|
||||||
|
const f = await fixture(),
|
||||||
|
result = await f.create();
|
||||||
|
let calls = 0;
|
||||||
|
const send = async () => {
|
||||||
|
calls++;
|
||||||
|
return { success: true as const };
|
||||||
|
};
|
||||||
|
await Promise.all([
|
||||||
|
deliverAlarmEmails(result.emailIds, send, now),
|
||||||
|
deliverAlarmEmails(result.emailIds, send, now),
|
||||||
|
]);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
const next = await fixture(),
|
||||||
|
queued = await next.create();
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: next.p1.id },
|
||||||
|
data: { familyId: next.otherFamily.id },
|
||||||
|
});
|
||||||
|
const token = (await next.recipients(queued.alarmId!)).find(
|
||||||
|
(r) => r.userId === next.p1.id,
|
||||||
|
)!.token;
|
||||||
|
assert.equal(await getPublicAlarm(token), null);
|
||||||
|
const sent = await deliverAlarmEmails(queued.emailIds, send, now);
|
||||||
|
assert.equal(sent.sent, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Abgelaufene Tokens können nicht antworten und abgelaufene Anfragen werden nicht versendet", async () => {
|
||||||
|
const f = await fixture(),
|
||||||
|
result = await f.create({
|
||||||
|
wholeDay: false,
|
||||||
|
startMinute: 480,
|
||||||
|
endMinute: 540,
|
||||||
|
});
|
||||||
|
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||||
|
where: { id: result.alarmId },
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
respondToAlarm(
|
||||||
|
(await f.recipients(alarm.id))[0].token,
|
||||||
|
true,
|
||||||
|
alarm.expiresAt,
|
||||||
|
),
|
||||||
|
/abgelaufen/,
|
||||||
|
);
|
||||||
|
let calls = 0;
|
||||||
|
await deliverAlarmEmails(
|
||||||
|
result.emailIds,
|
||||||
|
async () => {
|
||||||
|
calls++;
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
alarm.expiresAt,
|
||||||
|
);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
await processAlarmDeadlines(alarm.expiresAt, f.kita.id);
|
||||||
|
assert.equal(
|
||||||
|
(await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: alarm.id } }))
|
||||||
|
.activeAssignmentId,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Bisherige Einzelalarme werden vorsichtig übernommen und Originalstatus bleibt erhalten", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
for (const [user, status] of [
|
||||||
|
[f.p1, "CONFIRMED"],
|
||||||
|
[f.p2, "PENDING"],
|
||||||
|
] as const)
|
||||||
|
await prisma.notdienstAlert.create({
|
||||||
|
data: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
assignmentId: f.assignment.id,
|
||||||
|
parentUserId: user.id,
|
||||||
|
confirmationToken: randomUUID(),
|
||||||
|
status,
|
||||||
|
triggeredAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
assertAssignmentNotAlarmed(prisma, f.assignment.id, f.kita.id),
|
||||||
|
/aktiver Alarm/,
|
||||||
|
);
|
||||||
|
assert.equal(await importLegacyAlarms(f.kita.id), 1);
|
||||||
|
assert.equal(await importLegacyAlarms(f.kita.id), 0);
|
||||||
|
const alarm = await prisma.notdienstAlarm.findFirstOrThrow({
|
||||||
|
where: { kitaId: f.kita.id },
|
||||||
|
});
|
||||||
|
assert.equal(alarm.status, "REVIEW_REQUIRED");
|
||||||
|
assert.equal(
|
||||||
|
await prisma.notdienstAlarmEmail.count({ where: { alarmId: alarm.id } }),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const legacy = await prisma.notdienstAlert.findMany({
|
||||||
|
where: { kitaId: f.kita.id },
|
||||||
|
});
|
||||||
|
assert.deepEqual(legacy.map((l) => l.status).sort(), [
|
||||||
|
"CONFIRMED",
|
||||||
|
"PENDING",
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
(await getPublicAlarm(legacy[0].confirmationToken))?.status,
|
||||||
|
"REVIEW_REQUIRED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Gültige alte Links werden gemeinsam übernommen; unzuständige Konten können sie nicht benutzen", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
const tokens = [randomUUID(), randomUUID()];
|
||||||
|
for (const [index, user] of [f.p1, f.p2].entries())
|
||||||
|
await prisma.notdienstAlert.create({
|
||||||
|
data: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
assignmentId: f.assignment.id,
|
||||||
|
parentUserId: user.id,
|
||||||
|
confirmationToken: tokens[index],
|
||||||
|
triggeredAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal((await getPublicAlarm(tokens[0]))?.status, "PENDING");
|
||||||
|
await respondToAlarm(tokens[0], true, now);
|
||||||
|
assert.equal((await getPublicAlarm(tokens[1]))?.status, "CONFIRMED");
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: f.p2.id },
|
||||||
|
data: { role: "ERZIEHER" },
|
||||||
|
});
|
||||||
|
assert.equal(await getPublicAlarm(tokens[1]), null);
|
||||||
|
await assert.rejects(respondToAlarm(tokens[1], false, now), /Ungültiger/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Jobzugriff benötigt ein eigenes langes Secret und validiert den Bearer-Header", () => {
|
||||||
|
const secret = "test-only-notdienst-alarm-secret-0123456789";
|
||||||
|
assert.equal(authorizeAlarmJob(null, ""), 503);
|
||||||
|
assert.equal(authorizeAlarmJob(null, secret), 401);
|
||||||
|
assert.equal(authorizeAlarmJob(`Bearer ${secret}x`, secret), 401);
|
||||||
|
assert.equal(authorizeAlarmJob(`Bearer ${secret}`, secret), 200);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user