feat: add household emergency duty alarms and deadline worker

This commit is contained in:
t.indorf
2026-10-09 11:02:24 +02:00
parent a96ba0fd84
commit 8b32e4a832
34 changed files with 3947 additions and 646 deletions
+4
View File
@@ -30,6 +30,10 @@ Coolify erkennt Variablen aus `docker-compose.coolify.yml` und zeigt required Va
wie `DATABASE_URL`, `AUTH_SECRET`, `AUTH_URL`, `NEXTAUTH_URL`, `NEXT_PUBLIC_SITE_URL`,
`RESEND_API_KEY`, `EMAIL_FROM` und `ADMIN_EMAIL` in der UI an.
## Notdienstalarm
Die Compose-Vorlage benötigt zusätzlich `NOTDIENST_JOB_SECRET` (zufällig, mindestens 32 Zeichen). Der Produktionsstart führt die Fristprüfung über einen lokalen Hintergrundprozess ungefähr jede Minute aus. Ein alleiniger Start mit `npm run start` benötigt einen separat gestarteten Worker oder einen Scheduler. Details, Versandprüfung und das additive SQL-Upgrade stehen in [docs/NOTDIENSTALARM.md](../docs/NOTDIENSTALARM.md).
## Welche Coolify-Postgres-URL?
Wenn die App und die Postgres-DB im selben Coolify-Projekt/Netzwerk laufen, nimm
+9
View File
@@ -58,3 +58,12 @@
- [ ] Environment Variables in Coolify eintragen, nicht ins Repo committen
- [ ] Domain auf den `app`-Service und Container-Port `3000` routen
- [ ] Upload-Volume `kita_planer_uploads` bleibt zwischen Deployments erhalten
## Notdienstalarm – ergänzende Betriebsprüfung
- [ ] `NOTDIENST_JOB_SECRET` zufällig erzeugt, mindestens 32 Zeichen, nur als Runtime-Secret gespeichert.
- [ ] Schema-Upgrade gesichert und angewandt; `db push` oder einmaliges SQL-Upgrade, nicht beides hintereinander.
- [ ] Worker bzw. Scheduler startet nach Neustart automatisch und ruft die geschützte Jobroute jede Minute auf.
- [ ] Fristablauf mit geschlossenem Browser geprüft: einmaliger Hinweis an Admins und Koordination.
- [ ] Versanddienst und öffentliche App-Adresse geprüft; Rückmeldelinks erreichen die tatsächliche Domain.
- [ ] Versandübersicht auf Fehler geprüft; Annahme durch den Maildienst und Postfachzustellung getrennt geprüft.
+4
View File
@@ -36,6 +36,10 @@ RESEND_API_KEY="re_[PRODUCTION_API_KEY]"
EMAIL_FROM="Kita-Planer <[absender@deine-domain.de]>"
ADMIN_EMAIL="[kontakt@deine-domain.de]"
# Automatische Notdienst-Fristpruefung im App-Container.
# Eigenes Secret, mindestens 32 Zeichen; generieren mit: openssl rand -hex 32
NOTDIENST_JOB_SECRET="[GENERATE_ME]"
# Persistenter Upload-Speicher fuer Anhaenge vom Schwarzen Brett.
# Muss auf dem Server dauerhaft erhalten bleiben und darf nicht im Repo liegen.
UPLOAD_DIR="/var/lib/kita-planer/uploads/announcements"
+160
View File
@@ -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;
+1
View File
@@ -38,6 +38,7 @@ services:
RESEND_API_KEY: ${RESEND_API_KEY:?}
EMAIL_FROM: ${EMAIL_FROM:?}
ADMIN_EMAIL: ${ADMIN_EMAIL:?}
NOTDIENST_JOB_SECRET: ${NOTDIENST_JOB_SECRET:?}
UPLOAD_DIR: /app/uploads/announcements
volumes:
- kita_planer_uploads:/app/uploads
+6
View File
@@ -1,5 +1,11 @@
# Kita-Planer – Funktionsübersicht und aktueller Stand
## Ergänzung vom 09.10.2026: Notdienstalarm
Der Notdienstalarm ist inzwischen umgesetzt. Admins und Koordination alarmieren die eingeteilte Familie für einen ganzen Tag oder ein Zeitfenster. Persönliche Rückmeldelinks erzeugen eine gemeinsame Haushaltsantwort; Dashboard-Hinweise, eine 30-Minuten-Frist, Klärungsbedarf, verspätete Freigabe, telefonische Korrektur, Stornierung, Versandstatus und Hintergrundprüfung sind angebunden. Aktive Alarme sperren Planumzuordnung und Diensttausche. Siehe [NOTDIENSTALARM.md](NOTDIENSTALARM.md) für Bedienung und benötigte Hosting-Konfiguration.
Die folgende ursprüngliche Bestandsaufnahme bleibt als historischer Stand erhalten; darin als offen bezeichnete Alarmfunktionen sind durch diese Ergänzung überholt.
Stand: **07.10.2026**
Geprüfter Ausgangsbranch: `codex/notdienst-swaps`
+65
View File
@@ -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.
+1 -1
View File
@@ -2,7 +2,7 @@
Stand: 09.10.2026 · Branch `codex/notdienstalarm`
Status: Planung. Die folgenden Erweiterungen sind noch nicht implementiert.
Status: Umgesetzt. Dieser Plan hält die abgestimmten Entscheidungen fest. Bedienung, Betriebsanforderungen und Prüfungen stehen in [NOTDIENSTALARM.md](NOTDIENSTALARM.md).
## Festgelegter Umfang
+1
View File
@@ -11,6 +11,7 @@
"lint": "eslint",
"test:staff": "tsx --test tests/staff-calculations.test.ts",
"test:staff:integration": "tsx --test tests/staff-service.test.ts",
"test:alarms": "tsx --test tests/notdienst-alarm.test.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
+121
View File
@@ -84,6 +84,15 @@ enum NotdienstAlertStatus {
CANCELLED
}
enum NotdienstAlarmStatus {
PENDING
CONFIRMED
DECLINED
LATE_CONFIRMATION
REVIEW_REQUIRED
CANCELLED
}
enum NotdienstSwapStatus {
PENDING
ACCEPTED
@@ -143,6 +152,7 @@ model Kita {
notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[]
notdienstAlerts NotdienstAlert[]
notdienstAlarms NotdienstAlarm[]
notdienstSwapRequests NotdienstSwapRequest[]
staffProfiles StaffProfile[]
staffHolidays StaffHoliday[]
@@ -161,6 +171,7 @@ model Family {
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
name String
notdienstAlarms NotdienstAlarm[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -245,6 +256,9 @@ model User {
staffProfile StaffProfile?
staffNotifications StaffNotification[]
staffEmails StaffEmail[]
alarmRecipients NotdienstAlarmRecipient[]
alarmNotifications NotdienstAlarmNotification[]
alarmEmails NotdienstAlarmEmail[]
@@index([kitaId])
@@index([familyId])
@@ -890,6 +904,7 @@ model NotdienstAssignment {
updatedAt DateTime @updatedAt
alerts NotdienstAlert[]
alarms NotdienstAlarm[]
swapRequestsAsTarget NotdienstSwapRequest[] @relation("NotdienstSwapTargetAssignment")
swapRequestsAsOffered NotdienstSwapRequest[] @relation("NotdienstSwapOfferedAssignment")
@@ -936,8 +951,114 @@ model NotdienstSwapRequest {
/// Aktive Alarmierung — wird bei Krankmeldung einer Fachkraft
/// vom Koordinator ausgelöst. Bestätigung via Magic-Link (Token).
model NotdienstAlarm {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
assignmentId String?
assignment NotdienstAssignment? @relation(fields: [assignmentId], references: [id], onDelete: SetNull)
// Nullable unique reservation: cancelled or ended alarms release the assignment.
activeAssignmentId String? @unique
familyId String
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
familyName String
childName String
date DateTime @db.Date
wholeDay Boolean @default(true)
startMinute Int?
endMinute Int?
expiresAt DateTime
deadline DateTime
status NotdienstAlarmStatus @default(PENDING)
triggeredById String?
triggeredAt DateTime @default(now())
respondedById String?
respondedAt DateTime?
responseSource String?
escalatedAt DateTime?
educatorId String?
notes String?
cancelledAt DateTime?
cancelReason String?
recipients NotdienstAlarmRecipient[]
notifications NotdienstAlarmNotification[]
emails NotdienstAlarmEmail[]
history NotdienstAlarmHistory[]
legacyAlerts NotdienstAlert[]
@@index([kitaId, date])
@@index([status, deadline])
@@map("notdienst_alarms")
}
model NotdienstAlarmRecipient {
id String @id @default(cuid())
alarmId String
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique
@@unique([alarmId, userId])
@@map("notdienst_alarm_recipients")
}
model NotdienstAlarmNotification {
id String @id @default(cuid())
alarmId String
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
eventKey String
message String
createdAt DateTime @default(now())
readAt DateTime?
@@unique([alarmId, userId, eventKey])
@@index([userId, readAt])
@@map("notdienst_alarm_notifications")
}
model NotdienstAlarmEmail {
id String @id @default(cuid())
alarmId String
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
eventKey String
kind String
subject String
message String
path String
createdAt DateTime @default(now())
sentAt DateTime?
skippedAt DateTime?
leaseUntil DateTime?
failed Boolean @default(false)
attempts Int @default(0)
@@unique([alarmId, userId, eventKey])
@@index([sentAt, skippedAt, createdAt])
@@map("notdienst_alarm_emails")
}
model NotdienstAlarmHistory {
id String @id @default(cuid())
alarmId String
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
actorId String?
action String
details String
createdAt DateTime @default(now())
@@index([alarmId, createdAt])
@@map("notdienst_alarm_history")
}
model NotdienstAlert {
id String @id @default(cuid())
alarmId String?
alarm NotdienstAlarm? @relation(fields: [alarmId], references: [id], onDelete: SetNull)
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
+42
View File
@@ -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);
+9
View File
@@ -5,4 +5,13 @@ echo "Applying Prisma schema..."
npx prisma db push --skip-generate
echo "Starting Kita-Planer..."
if [ -n "${NOTDIENST_JOB_SECRET:-}" ]; then
if [ "${#NOTDIENST_JOB_SECRET}" -lt 32 ]; then
echo "NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben." >&2
exit 1
fi
node scripts/notdienst-alarm-worker.mjs &
else
echo "Notdienst-Fristprüfung deaktiviert: NOTDIENST_JOB_SECRET fehlt."
fi
exec npm run start:prod
+32 -27
View File
@@ -1,34 +1,39 @@
"use server";
import { revalidatePath } from "next/cache";
import { NotdienstAlertStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { respondToAlarm } from "@/lib/alarms/service";
import { deliverAlarmEmails } from "@/lib/alarms/email";
import { rateLimit } from "@/lib/rate-limit";
export async function confirmAlertAction(token: string) {
export async function respondAlertAction(token: string, approve: boolean) {
if (typeof token !== "string" || token.length > 128)
return { error: "Ungültiger Rückmeldelink." };
if (!rateLimit(`alarm-response:${token}`, 12, 60).allowed)
return { error: "Bitte kurz warten und erneut versuchen." };
try {
const alert = await prisma.notdienstAlert.findUnique({
where: { confirmationToken: token },
});
if (!alert) {
return { error: "Ungültiger Bestätigungs-Link." };
}
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");
return { success: true };
const result = await respondToAlarm(token, approve);
// The household response is already committed; transport failure must not undo it.
try {
await deliverAlarmEmails(result.emailIds);
} catch {
return { error: "Fehler bei der Bestätigung." };
/* Persistent jobs will retry. */
}
revalidatePath("/alert/[token]", "page");
revalidatePath("/dashboard");
revalidatePath("/dashboard/notdienst/alarm");
return { success: true };
} catch (error) {
if (
error instanceof Error &&
!error.message.includes("prisma") &&
!error.message.includes("Invalid `")
)
return { error: error.message };
return {
error:
"Die Rückmeldung konnte nicht gespeichert werden. Bitte den aktuellen Stand prüfen.",
};
}
}
export async function confirmAlertAction(token: string) {
return respondAlertAction(token, true);
}
+57 -29
View File
@@ -1,37 +1,65 @@
"use client";
import { useTransition } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { confirmAlertAction } from "./actions";
import { respondAlertAction } from "./actions";
export function AlertConfirmForm({ token }: { token: string }) {
const [isPending, startTransition] = useTransition();
const handleConfirm = () => {
startTransition(async () => {
const result = await confirmAlertAction(token);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Erfolgreich bestätigt!");
}
});
};
const [choice, setChoice] = useState<boolean | null>(null);
const [pending, start] = useTransition();
const [error, setError] = useState("");
const router = useRouter();
return (
<Button
size="lg"
className="w-full sm:w-auto font-semibold text-lg"
onClick={handleConfirm}
disabled={isPending}
>
{isPending ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : null}
Ich bestätige! (Bin auf dem Weg)
<div className="grid gap-4">
{choice === null ? (
<div className="grid gap-3 sm:grid-cols-2">
<Button onClick={() => setChoice(true)}>Wir übernehmen</Button>
<Button variant="outline" onClick={() => setChoice(false)}>
Wir können nicht
</Button>
</div>
) : (
<div className="grid gap-3 rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<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>
);
}
+90 -103
View File
@@ -1,127 +1,114 @@
import { notFound } from "next/navigation";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { BellRing, ShieldCheck, XCircle } from "lucide-react";
import { NotdienstAlertStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { BellRing, CheckCircle2 } from "lucide-react";
import { getPublicAlarm } from "@/lib/alarms/service";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
alarmLabel,
dateKey,
durationLabel,
instantLabel,
} from "@/lib/alarms/format";
import { AlertConfirmForm } from "./alert-confirm-form";
export const metadata = { title: "Notdienst Alarm · Kita-Planer" };
export const metadata = {
title: "Notdienstalarm · Kita-Planer",
robots: { index: false, follow: false },
referrer: "no-referrer" as const,
};
export const dynamic = "force-dynamic";
export default async function AlertPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const alert = await prisma.notdienstAlert.findUnique({
where: { confirmationToken: token },
select: {
status: true,
parentUser: {
select: {
firstName: true,
},
},
kita: {
select: {
name: true,
},
},
assignment: {
select: {
date: true,
child: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
});
if (!alert) {
notFound();
}
const isPending = alert.status === NotdienstAlertStatus.PENDING;
const isConfirmed = alert.status === NotdienstAlertStatus.CONFIRMED;
const alarm = await getPublicAlarm(token);
if (!alarm) notFound();
const now = new Date(),
expired = alarm.expiresAt <= now;
const canRespond = alarm.status === "PENDING" && !expired;
const overdue = alarm.deadline <= now;
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-md shadow-lg border-primary/20">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
{isPending ? (
<BellRing className="h-8 w-8 animate-pulse text-destructive" />
) : isConfirmed ? (
<ShieldCheck className="h-8 w-8 text-success" />
<main className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
<section className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--hairline)] bg-[var(--surface)] shadow-sm">
<header
className={`border-b border-[var(--hairline)] p-6 sm:p-8 ${alarm.status === "CONFIRMED" ? "bg-[var(--accent-soft)]" : "bg-[var(--surface-2)]"}`}
>
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--accent-deep)]">
Kita-Planer · {alarm.kitaName}
</p>
{alarm.status === "CONFIRMED" ? (
<CheckCircle2 className="mt-6 h-7 w-7 text-[var(--accent-deep)]" />
) : (
<XCircle className="h-8 w-8 text-muted-foreground" />
<BellRing className="mt-6 h-7 w-7 text-[var(--danger)]" />
)}
</div>
<CardTitle className="text-2xl">
{isPending
? "Notdienst Einsatz!"
: isConfirmed
? "Einsatz bestätigt"
: "Alarm abgebrochen"}
</CardTitle>
<CardDescription className="text-base mt-2">
Kita {alert.kita.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-4">
<div className="rounded-lg bg-background border p-4 text-center space-y-1">
<p className="text-sm text-muted-foreground">Datum</p>
<p className="font-semibold text-lg">
{format(alert.assignment.date, "EEEE, dd. MMMM yyyy", {
locale: de,
})}
</p>
<div className="h-2" />
<p className="text-sm text-muted-foreground">Eingeteiltes Kind</p>
<p className="font-medium">
{alert.assignment.child.firstName}{" "}
{alert.assignment.child.lastName}
<h1 className="mt-3 text-3xl text-[var(--ink)]">
{alarm.status === "PENDING"
? "Könnt ihr den Notdienst übernehmen?"
: alarmLabel(alarm)}
</h1>
<p className="mt-3 text-sm leading-6 text-[var(--ink-soft)]">
Hallo {alarm.firstName}, diese Rückmeldung gilt für euren gesamten
Haushalt.
</p>
</header>
<div className="grid gap-6 p-6 sm:p-8">
<dl className="grid grid-cols-2 gap-5 text-sm">
<div>
<dt className="text-[var(--ink-muted)]">Datum</dt>
<dd className="mt-1 font-semibold">
{dateKey(alarm.date).split("-").reverse().join(".")}
</dd>
</div>
<div className="text-center">
{isPending ? (
<div>
<dt className="text-[var(--ink-muted)]">Einsatz</dt>
<dd className="mt-1 font-semibold">{durationLabel(alarm)}</dd>
</div>
<div>
<dt className="text-[var(--ink-muted)]">Haushalt</dt>
<dd className="mt-1 font-semibold">{alarm.familyName}</dd>
</div>
<div>
<dt className="text-[var(--ink-muted)]">Antwortfrist</dt>
<dd className="mt-1 font-semibold">
{instantLabel(alarm.deadline)} Uhr
</dd>
</div>
</dl>
{canRespond ? (
<>
<p className="mb-6 text-sm text-muted-foreground">
Hallo {alert.parentUser.firstName}, eine Fachkraft hat sich
krankgemeldet und du bist heute für den Notdienst eingeteilt.
Bitte bestätige deinen Einsatz schnellstmöglich!
{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>
)}
<AlertConfirmForm token={token} />
</>
) : isConfirmed ? (
<p className="text-sm text-muted-foreground">
Danke, {alert.parentUser.firstName}! Dein Einsatz wurde
erfolgreich bestätigt. Die Kita rechnet mit dir.
</p>
) : (
<p className="text-sm text-muted-foreground">
Dieser Einsatz wurde vom Koordinator abgebrochen. Du musst nicht
einspringen.
</p>
<div
role="status"
className="rounded-xl border border-[var(--hairline)] p-4 text-sm leading-6"
>
{alarm.status === "CANCELLED"
? "Der Einsatz wurde storniert. Ihr müsst für diesen Alarm nicht einspringen."
: alarm.status === "CONFIRMED"
? "Euer Haushalt hat zugesagt. Die Kita rechnet mit euch. Bei einer Änderung kontaktiert bitte die Koordination."
: alarm.status === "LATE_CONFIRMATION"
? "Eure verspätete Zusage ist gespeichert. Wartet bitte auf die Freigabe durch die Koordination."
: alarm.status === "DECLINED"
? "Die Absage eures Haushalts ist gespeichert. Die Koordination kümmert sich um Ersatz."
: alarm.status === "REVIEW_REQUIRED"
? "Die bisherigen Rückmeldungen müssen von der Koordination geprüft werden. Bitte kontaktiert die Kita."
: "Der Einsatz ist beendet. Dieser Rückmeldelink ist abgelaufen."}
</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>
</CardContent>
</Card>
</div>
</section>
</main>
);
}
+32
View File
@@ -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 },
);
}
}
+5 -118
View File
@@ -1,123 +1,10 @@
"use server";
import crypto from "crypto";
import { createElement } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { revalidatePath } from "next/cache";
import { NotdienstAlertStatus, UserRole } from "@prisma/client";
import { AlertEmail } from "@/emails/AlertEmail";
import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
import { alarmAction } from "./notdienst/alarm/actions";
// Existing callers use the shared household workflow and the same guards.
export async function triggerAlertAction(assignmentId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
try {
const [existing, assignment] = await Promise.all([
// Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt
prisma.notdienstAlert.findFirst({
where: { assignmentId, kitaId },
}),
prisma.notdienstAssignment.findFirst({
where: { id: assignmentId, kitaId },
include: {
child: {
select: {
id: true,
firstName: true,
lastName: true,
family: {
select: {
users: {
where: { role: UserRole.ELTERN },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
},
},
},
},
},
}),
]);
if (existing) {
return { error: "Für diese Einteilung wurde bereits ein Alarm ausgelöst." };
}
if (!assignment) {
return { error: "Notdienst-Einteilung wurde nicht gefunden." };
}
const parents = assignment.child.family.users;
if (parents.length === 0) {
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
locale: de,
});
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
const alertJobs: { email: string; token: string }[] = [];
await prisma.$transaction(
parents.map((parent) => {
const token = crypto.randomUUID();
alertJobs.push({ email: parent.email, token });
return prisma.notdienstAlert.create({
data: {
kitaId,
return alarmAction("create", {
assignmentId,
parentUserId: parent.id,
triggeredById: session.user.id,
status: NotdienstAlertStatus.PENDING,
confirmationToken: token,
},
wholeDay: true,
replyMinutes: 30,
});
}),
);
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." };
}
}
+103 -26
View File
@@ -51,11 +51,15 @@ const activeNotdienstAlertStatuses = [
function getTodayUtcDateOnly() {
const today = new Date();
return new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
return new Date(
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
);
}
function getBaseUrl() {
return process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
return (
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"
);
}
function formatNotdienstDate(date: Date) {
@@ -68,7 +72,7 @@ function revalidateCalendarAndNotdienst() {
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard");
} catch (e) {
} catch {
// Ignore if called outside Next.js context
}
}
@@ -94,6 +98,7 @@ type SwapAssignmentWithFamily = {
alerts: {
id: string;
}[];
alarms?: { id: string }[];
};
async function getSwapAssignment(
@@ -126,7 +131,11 @@ async function getSwapAssignment(
},
},
alerts: {
where: { status: { in: activeNotdienstAlertStatuses } },
where: { alarmId: null, status: { in: activeNotdienstAlertStatuses } },
select: { id: true },
},
alarms: {
where: { activeAssignmentId: { not: null } },
select: { id: true },
},
},
@@ -171,7 +180,12 @@ function validateSwapAssignmentPair({
return "Du kannst keinen Tausch mit deinem eigenen Haushalt anfragen.";
}
if (targetAssignment.alerts.length > 0 || offeredAssignment.alerts.length > 0) {
if (
targetAssignment.alerts.length > 0 ||
offeredAssignment.alerts.length > 0 ||
targetAssignment.alarms?.length ||
offeredAssignment.alarms?.length
) {
return "Notdienste mit aktiver Alarmierung können nicht getauscht werden.";
}
@@ -215,7 +229,10 @@ async function sendSwapMail({
});
if (!result.success) {
console.error("Notdienst-Tausch-Mail konnte nicht versendet werden:", result.error);
console.error(
"Notdienst-Tausch-Mail konnte nicht versendet werden:",
result.error,
);
}
}
@@ -264,7 +281,9 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
now.setSeconds(0, 0);
if (startDate < now) {
return { error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen." };
return {
error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen.",
};
}
if (endDate < startDate) {
@@ -286,9 +305,7 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
? new Date(data.rsvpDeadline)
: null,
participantInfo:
data.requiresRsvp && data.participantInfo
? data.participantInfo
: null,
data.requiresRsvp && data.participantInfo ? data.participantInfo : null,
},
};
}
@@ -347,7 +364,10 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
return { error: "Notdienst nicht gefunden." };
}
if (existingRequest) {
return { error: "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage." };
return {
error:
"Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.",
};
}
await prisma.notdienstSwapRequest.create({
@@ -394,7 +414,8 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
const today = getTodayUtcDateOnly();
try {
const result = await prisma.$transaction(async (tx) => {
const result = await prisma.$transaction(
async (tx) => {
const request = await tx.notdienstSwapRequest.findFirst({
where: {
id: parsed.data,
@@ -420,14 +441,21 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
name: true,
users: {
where: { role: UserRole.ELTERN },
select: { email: true, firstName: true, lastName: true },
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
},
},
},
alerts: {
where: { status: { in: activeNotdienstAlertStatuses } },
where: {
alarmId: null,
status: { in: activeNotdienstAlertStatuses },
},
select: { id: true },
},
},
@@ -446,14 +474,21 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
name: true,
users: {
where: { role: UserRole.ELTERN },
select: { email: true, firstName: true, lastName: true },
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
},
},
},
alerts: {
where: { status: { in: activeNotdienstAlertStatuses } },
where: {
alarmId: null,
status: { in: activeNotdienstAlertStatuses },
},
select: { id: true },
},
},
@@ -462,11 +497,28 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
});
if (!request) {
throw new Error("Tauschanfrage nicht gefunden oder nicht mehr offen.");
throw new Error(
"Tauschanfrage nicht gefunden oder nicht mehr offen.",
);
}
if (
await tx.notdienstAlarm.count({
where: {
activeAssignmentId: {
in: [request.targetAssignmentId, request.offeredAssignmentId],
},
},
})
)
throw new Error(
"Notdienste mit aktiver Alarmierung können nicht getauscht werden.",
);
if (request.targetAssignment.child.familyId !== session.user.familyId) {
throw new Error("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
throw new Error(
"Nur der angefragte Haushalt kann diese Anfrage annehmen.",
);
}
const validationError = validateSwapAssignmentPair({
@@ -501,8 +553,16 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
OR: [
{ targetAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
{ offeredAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
{
targetAssignmentId: {
in: [request.targetAssignmentId, request.offeredAssignmentId],
},
},
{
offeredAssignmentId: {
in: [request.targetAssignmentId, request.offeredAssignmentId],
},
},
],
},
data: {
@@ -515,7 +575,9 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
targetAssignment: request.targetAssignment,
offeredAssignment: request.offeredAssignment,
};
});
},
{ isolationLevel: "Serializable" },
);
const kita = await prisma.kita.findUnique({
where: { id: session.user.kitaId },
@@ -550,7 +612,10 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
return { success: true };
} catch (error) {
return {
error: error instanceof Error ? error.message : "Tauschanfrage konnte nicht angenommen werden.",
error:
error instanceof Error
? error.message
: "Tauschanfrage konnte nicht angenommen werden.",
};
}
}
@@ -619,7 +684,9 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
}
if (request.targetAssignment.child.familyId !== session.user.familyId) {
return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." };
return {
error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen.",
};
}
await prisma.notdienstSwapRequest.update({
@@ -706,7 +773,10 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
revalidateCalendarAndNotdienst();
return { success: true };
} catch (error) {
console.error("Fehler beim Zurückziehen der Notdienst-Tauschanfrage:", error);
console.error(
"Fehler beim Zurückziehen der Notdienst-Tauschanfrage:",
error,
);
return { error: "Tauschanfrage konnte nicht zurückgezogen werden." };
}
}
@@ -885,7 +955,10 @@ export async function rejectTermin(terminId: string, reason?: string) {
}
}
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
export async function toggleMitbringselList(
terminId: string,
enabled: boolean,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
@@ -933,7 +1006,11 @@ export async function addMitbringsel(rawPayload: unknown) {
where: { id: terminId, kitaId: session.user.kitaId },
});
if (!termin || termin.status !== TerminStatus.CONFIRMED || !termin.mitbringselListEnabled) {
if (
!termin ||
termin.status !== TerminStatus.CONFIRMED ||
!termin.mitbringselListEnabled
) {
return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." };
}
@@ -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>
);
}
+383
View File
@@ -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>
);
}
+408
View File
@@ -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>
);
}
+66 -21
View File
@@ -12,6 +12,11 @@ import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
import { requireAlarmActor } from "@/lib/alarms/auth";
import {
validateAlarmActor,
assertAssignmentNotAlarmed,
} from "@/lib/alarms/service";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
@@ -153,14 +158,16 @@ export async function updateAssignmentAction(
date: string,
newChildId: string | null,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const actor = await requireAlarmActor();
const kitaId = actor.kitaId;
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
try {
const notifications = await prisma.$transaction(async (tx) => {
const notifications = await prisma.$transaction(
async (tx) => {
await validateAlarmActor(tx, actor);
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
@@ -178,9 +185,13 @@ export async function updateAssignmentAction(
if (isPublished) {
const today = new Date();
const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
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.");
throw new Error(
"Vergangene Notdienste können nicht geändert werden.",
);
}
}
@@ -202,14 +213,12 @@ export async function updateAssignmentAction(
},
});
let newChild:
| {
let newChild: {
id: string;
family: {
users: { email: string; firstName: string; lastName: string }[];
};
}
| null = null;
} | null = null;
if (newChildId) {
newChild = await tx.child.findFirst({
@@ -239,6 +248,9 @@ export async function updateAssignmentAction(
return [];
}
if (existingAssignment)
await assertAssignmentNotAlarmed(tx, existingAssignment.id, kitaId);
if (!newChildId) {
if (existingAssignment) {
await tx.notdienstAssignment.delete({
@@ -282,7 +294,11 @@ export async function updateAssignmentAction(
return [];
}
const dateLabel = format(new Date(yr, mo - 1, dy), "EEEE, dd. MMMM yyyy", { locale: de });
const dateLabel = format(
new Date(yr, mo - 1, dy),
"EEEE, dd. MMMM yyyy",
{ locale: de },
);
const messages: {
parentEmails: string[];
parentNames: string[];
@@ -294,11 +310,15 @@ export async function updateAssignmentAction(
}[] = [];
if (existingAssignment) {
const oldParents = existingAssignment.child.family.users.filter((u) => !!u.email);
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}`),
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.`,
@@ -313,7 +333,9 @@ export async function updateAssignmentAction(
if (newParents.length > 0) {
messages.push({
parentEmails: newParents.map((p) => p.email),
parentNames: newParents.map((p) => `${p.firstName} ${p.lastName}`),
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.`,
@@ -324,7 +346,9 @@ export async function updateAssignmentAction(
}
return messages;
});
},
{ isolationLevel: "Serializable" },
);
// E-Mails außerhalb der DB-Transaktion senden
if (notifications.length > 0) {
@@ -334,7 +358,10 @@ export async function updateAssignmentAction(
});
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`;
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 kitaId = session.user.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)
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
const monthDate = new Date(year, month - 1, 1);
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const BASE_URL =
process.env.NEXTAUTH_URL ??
process.env.AUTH_URL ??
"http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
let sentCount = 0;
@@ -486,7 +521,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
if (result.success) {
sentCount++;
} 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) {
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
@@ -535,6 +575,11 @@ export async function resetPlanAction(planId: string) {
return { success: true };
} catch (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.",
};
}
}
+23 -8
View File
@@ -1,14 +1,15 @@
import Link from "next/link";
import { addMonths, getYear, getMonth, format } from "date-fns";
import { de } from "date-fns/locale";
import { TerminType, UserRole } from "@prisma/client";
import { TerminType } from "@prisma/client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
import { PlanView } from "./plan-view";
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" };
@@ -17,14 +18,18 @@ export default async function PlanungsZentralePage({
}: {
searchParams: Promise<{ year?: string; month?: string }>;
}) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const actor = await requireAlarmActor();
const kitaId = actor.kitaId;
const resolvedSearchParams = await searchParams;
const now = new Date();
const nextMonth = addMonths(now, 1);
const targetYear = resolvedSearchParams.year ? parseInt(resolvedSearchParams.year, 10) : getYear(nextMonth);
const targetMonth = resolvedSearchParams.month ? parseInt(resolvedSearchParams.month, 10) : (getMonth(nextMonth) + 1);
const targetYear = resolvedSearchParams.year
? 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 monthName = format(monthDate, "MMMM", { locale: de });
@@ -33,7 +38,8 @@ export default async function PlanungsZentralePage({
const monthStart = new Date(targetYear, targetMonth - 1, 1);
const monthEnd = new Date(targetYear, targetMonth, 1);
const [plan, families, availabilities, closureCount, kita] = await Promise.all([
const [plan, families, availabilities, closureCount, kita] =
await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
@@ -48,6 +54,10 @@ export default async function PlanungsZentralePage({
childId: true,
date: true,
createdAt: true,
alarms: {
where: { activeAssignmentId: { not: null } },
select: { id: true },
},
},
},
},
@@ -179,7 +189,7 @@ export default async function PlanungsZentralePage({
// Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime()
(a) => a.date.getTime() === day.getTime(),
);
const isOriginallyUnassigned =
!assignment ||
@@ -190,6 +200,11 @@ export default async function PlanungsZentralePage({
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
isOriginallyUnassigned: !!isOriginallyUnassigned,
activeAlarmId: assignment?.alarms[0]?.id ?? null,
canAlarm:
!!assignment &&
plan?.status === "PUBLISHED" &&
day.toISOString().slice(0, 10) >= todayKey(),
};
});
+49 -6
View File
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client";
@@ -40,6 +41,8 @@ interface PlanViewProps {
days: {
date: string;
assignmentId: string | null;
activeAlarmId?: string | null;
canAlarm?: boolean;
childId: string | null;
isOriginallyUnassigned?: boolean;
}[];
@@ -92,7 +95,9 @@ export function PlanView({
const [resetDialogOpen, setResetDialogOpen] = useState(false);
const responsePercent =
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
totalFamilies > 0
? Math.round((respondedFamilies / totalFamilies) * 100)
: 0;
const handleGenerate = () => {
startTransition(async () => {
@@ -123,7 +128,9 @@ export function PlanView({
if ("error" in result && result.error) {
toast.error(result.error as string);
} 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}>
Trotzdem generieren
</Button>
<Button variant="ghost" onClick={handleSendReminders} disabled={isPending}>
<Button
variant="ghost"
onClick={handleSendReminders}
disabled={isPending}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
@@ -408,7 +419,8 @@ export function PlanView({
<DialogHeader>
<DialogTitle>Plan zurücksetzen?</DialogTitle>
<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>
</DialogHeader>
<DialogFooter>
@@ -542,6 +554,21 @@ function PlanPreviewTable({
({assignedChild.name})
</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 className="italic text-[var(--ink-faint)]">Noch offen</div>
<div>
@@ -616,7 +643,7 @@ function ManualAssignmentTable({
<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)]"
value={day.childId || "empty"}
disabled={isPending}
disabled={isPending || !!day.activeAlarmId}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
@@ -624,7 +651,9 @@ function ManualAssignmentTable({
<option value="empty">-- Unbesetzt --</option>
{allChildren.map((child) => {
const famId = child.familyId;
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
const count = famId
? (familyAssignmentCounts.get(famId) ?? 0)
: 0;
return (
<option key={child.id} value={child.id}>
{child.parentName} ({child.name}) — {count}x
@@ -637,6 +666,20 @@ function ManualAssignmentTable({
Veröffentlicht
</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>
);
+2
View File
@@ -19,6 +19,7 @@ import { prisma } from "@/lib/prisma";
import { Stat } from "@/components/ui/stat";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
import { AlarmDashboardHints } from "./notdienst/alarm/dashboard-hints";
export const metadata = { title: "Übersicht · Kita-Planer" };
@@ -200,6 +201,7 @@ export default async function DashboardPage() {
</div>
</header>
<AlarmDashboardHints />
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
<div className="min-w-0">
{session.user.role === UserRole.ERZIEHER && (
+9
View File
@@ -13,6 +13,7 @@ import {
Stethoscope,
Megaphone,
Clock3,
BellRing,
} from "lucide-react";
import { NavItem } from "@/components/ui/nav-item";
@@ -20,6 +21,14 @@ import { NavItem } from "@/components/ui/nav-item";
import { UserRole } from "@prisma/client";
const navItems = [
{
section: "Heute",
href: "/dashboard/notdienst/alarm",
label: "Notdienstalarm",
icon: BellRing,
exact: true,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ELTERN],
},
{
section: "Heute",
href: "/dashboard/personal",
+87
View File
@@ -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>
);
}
+20
View File
@@ -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;
}
+98
View File
@@ -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 };
}
+89
View File
@@ -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)
);
}
+40
View File
@@ -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 };
}
+688
View File
@@ -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,
},
});
}
+826
View File
@@ -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);
});