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
+30 -25
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." };
const result = await respondToAlarm(token, approve);
// The household response is already committed; transport failure must not undo it.
try {
await deliverAlarmEmails(result.emailIds);
} catch {
/* Persistent jobs will retry. */
}
if (alert.status !== NotdienstAlertStatus.PENDING) {
return { error: "Dieser Alarm wurde bereits bestätigt oder abgebrochen." };
}
await prisma.notdienstAlert.update({
where: { id: alert.id },
data: {
status: NotdienstAlertStatus.CONFIRMED,
confirmedAt: new Date(),
},
});
revalidatePath("/alert/[token]", "page");
revalidatePath("/dashboard");
revalidatePath("/dashboard/notdienst/alarm");
return { success: true };
} catch {
return { error: "Fehler bei der Bestätigung." };
} catch (error) {
if (
error instanceof Error &&
!error.message.includes("prisma") &&
!error.message.includes("Invalid `")
)
return { error: error.message };
return {
error:
"Die Rückmeldung konnte nicht gespeichert werden. Bitte den aktuellen Stand prüfen.",
};
}
}
export async function confirmAlertAction(token: string) {
return respondAlertAction(token, true);
}
+58 -30
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)
</Button>
<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>
);
}
+100 -113
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" />
) : (
<XCircle className="h-8 w-8 text-muted-foreground" />
)}
</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}
</p>
</div>
<div className="text-center">
{isPending ? (
<>
<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!
<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)]" />
) : (
<BellRing className="mt-6 h-7 w-7 text-[var(--danger)]" />
)}
<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>
<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 ? (
<>
{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>
</CardContent>
</Card>
</div>
)}
<AlertConfirmForm token={token} />
</>
) : (
<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>
</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 },
);
}
}
+7 -120
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,
assignmentId,
parentUserId: parent.id,
triggeredById: session.user.id,
status: NotdienstAlertStatus.PENDING,
confirmationToken: token,
},
});
}),
);
for (const job of alertJobs) {
const emailResult = await sendAppEmail({
to: job.email,
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
react: createElement(AlertEmail, {
date: dateLabel,
childName,
confirmLink: `${BASE_URL}/alert/${job.token}`,
}),
});
if (!emailResult.success) {
revalidatePath("/dashboard");
return {
error: `Alarm wurde angelegt, aber mindestens eine E-Mail konnte nicht versendet werden: ${emailResult.error}`,
};
}
}
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler beim Auslösen des Alarms." };
}
return alarmAction("create", {
assignmentId,
wholeDay: true,
replyMinutes: 30,
});
}
+203 -126
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,128 +414,170 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
const today = getTodayUtcDateOnly();
try {
const result = await prisma.$transaction(async (tx) => {
const request = await tx.notdienstSwapRequest.findFirst({
where: {
id: parsed.data,
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
},
select: {
id: true,
requesterFamilyId: true,
targetAssignmentId: true,
offeredAssignmentId: true,
targetAssignment: {
select: {
id: true,
childId: true,
date: true,
plan: { select: { status: true } },
child: {
select: {
familyId: true,
family: {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
select: { email: true, firstName: true, lastName: true },
const result = await prisma.$transaction(
async (tx) => {
const request = await tx.notdienstSwapRequest.findFirst({
where: {
id: parsed.data,
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
},
select: {
id: true,
requesterFamilyId: true,
targetAssignmentId: true,
offeredAssignmentId: true,
targetAssignment: {
select: {
id: true,
childId: true,
date: true,
plan: { select: { status: true } },
child: {
select: {
familyId: true,
family: {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
},
},
},
},
alerts: {
where: { status: { in: activeNotdienstAlertStatuses } },
select: { id: true },
alerts: {
where: {
alarmId: null,
status: { in: activeNotdienstAlertStatuses },
},
select: { id: true },
},
},
},
},
offeredAssignment: {
select: {
id: true,
childId: true,
date: true,
plan: { select: { status: true } },
child: {
select: {
familyId: true,
family: {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
select: { email: true, firstName: true, lastName: true },
offeredAssignment: {
select: {
id: true,
childId: true,
date: true,
plan: { select: { status: true } },
child: {
select: {
familyId: true,
family: {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
},
},
},
},
alerts: {
where: { status: { in: activeNotdienstAlertStatuses } },
select: { id: true },
alerts: {
where: {
alarmId: null,
status: { in: activeNotdienstAlertStatuses },
},
select: { id: true },
},
},
},
},
},
});
});
if (!request) {
throw new Error("Tauschanfrage nicht gefunden oder nicht mehr offen.");
}
if (!request) {
throw new Error(
"Tauschanfrage nicht gefunden oder nicht mehr offen.",
);
}
if (request.targetAssignment.child.familyId !== session.user.familyId) {
throw new Error("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
}
if (
await tx.notdienstAlarm.count({
where: {
activeAssignmentId: {
in: [request.targetAssignmentId, request.offeredAssignmentId],
},
},
})
)
throw new Error(
"Notdienste mit aktiver Alarmierung können nicht getauscht werden.",
);
const validationError = validateSwapAssignmentPair({
targetAssignment: request.targetAssignment,
offeredAssignment: request.offeredAssignment,
requesterFamilyId: request.requesterFamilyId,
today,
});
if (validationError) {
throw new Error(validationError);
}
if (request.targetAssignment.child.familyId !== session.user.familyId) {
throw new Error(
"Nur der angefragte Haushalt kann diese Anfrage annehmen.",
);
}
await tx.notdienstAssignment.update({
where: { id: request.targetAssignmentId },
data: { childId: request.offeredAssignment.childId },
});
await tx.notdienstAssignment.update({
where: { id: request.offeredAssignmentId },
data: { childId: request.targetAssignment.childId },
});
await tx.notdienstSwapRequest.update({
where: { id: request.id },
data: {
status: NotdienstSwapStatus.ACCEPTED,
respondedById: session.user.id,
respondedAt: new Date(),
},
});
await tx.notdienstSwapRequest.updateMany({
where: {
id: { not: request.id },
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
OR: [
{ targetAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
{ offeredAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
],
},
data: {
status: NotdienstSwapStatus.CANCELLED,
respondedAt: new Date(),
},
});
const validationError = validateSwapAssignmentPair({
targetAssignment: request.targetAssignment,
offeredAssignment: request.offeredAssignment,
requesterFamilyId: request.requesterFamilyId,
today,
});
if (validationError) {
throw new Error(validationError);
}
return {
targetAssignment: request.targetAssignment,
offeredAssignment: request.offeredAssignment,
};
});
await tx.notdienstAssignment.update({
where: { id: request.targetAssignmentId },
data: { childId: request.offeredAssignment.childId },
});
await tx.notdienstAssignment.update({
where: { id: request.offeredAssignmentId },
data: { childId: request.targetAssignment.childId },
});
await tx.notdienstSwapRequest.update({
where: { id: request.id },
data: {
status: NotdienstSwapStatus.ACCEPTED,
respondedById: session.user.id,
respondedAt: new Date(),
},
});
await tx.notdienstSwapRequest.updateMany({
where: {
id: { not: request.id },
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
OR: [
{
targetAssignmentId: {
in: [request.targetAssignmentId, request.offeredAssignmentId],
},
},
{
offeredAssignmentId: {
in: [request.targetAssignmentId, request.offeredAssignmentId],
},
},
],
},
data: {
status: NotdienstSwapStatus.CANCELLED,
respondedAt: new Date(),
},
});
return {
targetAssignment: request.targetAssignment,
offeredAssignment: request.offeredAssignment,
};
},
{ isolationLevel: "Serializable" },
);
const kita = await prisma.kita.findUnique({
where: { id: session.user.kitaId },
@@ -550,7 +612,10 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
return { success: true };
} catch (error) {
return {
error: error instanceof Error ? error.message : "Tauschanfrage konnte nicht angenommen werden.",
error:
error instanceof Error
? error.message
: "Tauschanfrage konnte nicht angenommen werden.",
};
}
}
@@ -619,7 +684,9 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
}
if (request.targetAssignment.child.familyId !== session.user.familyId) {
return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." };
return {
error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen.",
};
}
await prisma.notdienstSwapRequest.update({
@@ -706,19 +773,22 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
revalidateCalendarAndNotdienst();
return { success: true };
} catch (error) {
console.error("Fehler beim Zurückziehen der Notdienst-Tauschanfrage:", error);
console.error(
"Fehler beim Zurückziehen der Notdienst-Tauschanfrage:",
error,
);
return { error: "Tauschanfrage konnte nicht zurückgezogen werden." };
}
}
export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const data = parsed.data;
const terminData = buildTerminData(data);
@@ -755,12 +825,12 @@ export async function createTerminAdmin(rawPayload: unknown) {
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const data = parsed.data;
const terminData = buildTerminData(data);
@@ -885,7 +955,10 @@ export async function rejectTermin(terminId: string, reason?: string) {
}
}
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
export async function toggleMitbringselList(
terminId: string,
enabled: boolean,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
@@ -919,12 +992,12 @@ const mitbringselSchema = z.object({
export async function addMitbringsel(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = mitbringselSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const { terminId, content } = parsed.data;
try {
@@ -933,7 +1006,11 @@ export async function addMitbringsel(rawPayload: unknown) {
where: { id: terminId, kitaId: session.user.kitaId },
});
if (!termin || termin.status !== TerminStatus.CONFIRMED || !termin.mitbringselListEnabled) {
if (
!termin ||
termin.status !== TerminStatus.CONFIRMED ||
!termin.mitbringselListEnabled
) {
return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." };
}
@@ -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>
);
}
+192 -147
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,41 +158,75 @@ 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 plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
const notifications = await prisma.$transaction(
async (tx) => {
await validateAlarmActor(tx, actor);
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
if (!plan) {
throw new Error("Plan nicht gefunden.");
}
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
if (!isDraft && !isPublished) {
throw new Error("Plan kann nicht bearbeitet werden.");
}
if (isPublished) {
const today = new Date();
const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
if (parsedDate < todayUtc) {
throw new Error("Vergangene Notdienste können nicht geändert werden.");
if (!plan) {
throw new Error("Plan nicht gefunden.");
}
}
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
include: {
child: {
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
if (!isDraft && !isPublished) {
throw new Error("Plan kann nicht bearbeitet werden.");
}
if (isPublished) {
const today = new Date();
const todayUtc = new Date(
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
);
if (parsedDate < todayUtc) {
throw new Error(
"Vergangene Notdienste können nicht geändert werden.",
);
}
}
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
include: {
child: {
include: {
family: {
include: {
users: {
where: { role: UserRole.ELTERN },
select: { email: true, firstName: true, lastName: true },
},
},
},
},
},
},
});
let newChild: {
id: string;
family: {
users: { email: string; firstName: string; lastName: string }[];
};
} | null = null;
if (newChildId) {
newChild = await tx.child.findFirst({
where: {
id: newChildId,
kitaId,
active: true,
},
include: {
family: {
include: {
@@ -198,133 +237,118 @@ export async function updateAssignmentAction(
},
},
},
},
},
});
});
let newChild:
| {
id: string;
family: {
users: { email: string; firstName: string; lastName: string }[];
};
if (!newChild) {
throw new Error("Kind gehört nicht zu dieser Kita.");
}
| null = null;
if (newChildId) {
newChild = await tx.child.findFirst({
where: {
id: newChildId,
kitaId,
active: true,
},
include: {
family: {
include: {
users: {
where: { role: UserRole.ELTERN },
select: { email: true, firstName: true, lastName: true },
},
},
},
},
});
if (!newChild) {
throw new Error("Kind gehört nicht zu dieser Kita.");
}
}
if (existingAssignment?.childId === newChildId) {
return [];
}
if (existingAssignment?.childId === newChildId) {
return [];
}
if (!newChildId) {
if (existingAssignment) {
await tx.notdienstAssignment.delete({
if (existingAssignment)
await assertAssignmentNotAlarmed(tx, existingAssignment.id, kitaId);
if (!newChildId) {
if (existingAssignment) {
await tx.notdienstAssignment.delete({
where: { id: existingAssignment.id },
});
}
} else if (existingAssignment) {
await tx.notdienstAssignment.update({
where: { id: existingAssignment.id },
data: { childId: newChildId },
});
} else {
await tx.notdienstAssignment.create({
data: {
kitaId,
planId,
childId: newChildId,
date: parsedDate,
},
});
}
} else if (existingAssignment) {
await tx.notdienstAssignment.update({
where: { id: existingAssignment.id },
data: { childId: newChildId },
});
} else {
await tx.notdienstAssignment.create({
data: {
kitaId,
planId,
childId: newChildId,
date: parsedDate,
},
});
}
if (existingAssignment) {
await tx.notdienstSwapRequest.updateMany({
where: {
kitaId,
status: "PENDING",
OR: [
{ targetAssignmentId: existingAssignment.id },
{ offeredAssignmentId: existingAssignment.id },
],
},
data: {
status: "CANCELLED",
respondedAt: new Date(),
},
});
}
if (!isPublished) {
return [];
}
const dateLabel = format(new Date(yr, mo - 1, dy), "EEEE, dd. MMMM yyyy", { locale: de });
const messages: {
parentEmails: string[];
parentNames: string[];
subject: string;
heading: string;
preview: string;
body: string;
dateLabel: string;
}[] = [];
if (existingAssignment) {
const oldParents = existingAssignment.child.family.users.filter((u) => !!u.email);
if (oldParents.length > 0) {
messages.push({
parentEmails: oldParents.map((p) => p.email),
parentNames: oldParents.map((p) => `${p.firstName} ${p.lastName}`),
subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
heading: "Notdienst wurde geändert",
preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
dateLabel,
if (existingAssignment) {
await tx.notdienstSwapRequest.updateMany({
where: {
kitaId,
status: "PENDING",
OR: [
{ targetAssignmentId: existingAssignment.id },
{ offeredAssignmentId: existingAssignment.id },
],
},
data: {
status: "CANCELLED",
respondedAt: new Date(),
},
});
}
}
if (newChild && existingAssignment?.childId !== newChild.id) {
const newParents = newChild.family.users.filter((u) => !!u.email);
if (newParents.length > 0) {
messages.push({
parentEmails: newParents.map((p) => p.email),
parentNames: newParents.map((p) => `${p.firstName} ${p.lastName}`),
subject: `Notdienst-Einteilung am ${dateLabel}`,
heading: "Neuer Notdienst-Termin zugewiesen",
preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
dateLabel,
});
if (!isPublished) {
return [];
}
}
return messages;
});
const dateLabel = format(
new Date(yr, mo - 1, dy),
"EEEE, dd. MMMM yyyy",
{ locale: de },
);
const messages: {
parentEmails: string[];
parentNames: string[];
subject: string;
heading: string;
preview: string;
body: string;
dateLabel: string;
}[] = [];
if (existingAssignment) {
const oldParents = existingAssignment.child.family.users.filter(
(u) => !!u.email,
);
if (oldParents.length > 0) {
messages.push({
parentEmails: oldParents.map((p) => p.email),
parentNames: oldParents.map(
(p) => `${p.firstName} ${p.lastName}`,
),
subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
heading: "Notdienst wurde geändert",
preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
dateLabel,
});
}
}
if (newChild && existingAssignment?.childId !== newChild.id) {
const newParents = newChild.family.users.filter((u) => !!u.email);
if (newParents.length > 0) {
messages.push({
parentEmails: newParents.map((p) => p.email),
parentNames: newParents.map(
(p) => `${p.firstName} ${p.lastName}`,
),
subject: `Notdienst-Einteilung am ${dateLabel}`,
heading: "Neuer Notdienst-Termin zugewiesen",
preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
dateLabel,
});
}
}
return messages;
},
{ isolationLevel: "Serializable" },
);
// E-Mails außerhalb der DB-Transaktion senden
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.",
};
}
}
+87 -72
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,79 +38,84 @@ 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([
prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
select: {
id: true,
status: true,
publishedAt: true,
assignments: {
select: {
id: true,
childId: true,
date: true,
createdAt: true,
const [plan, families, availabilities, closureCount, kita] =
await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
select: {
id: true,
status: true,
publishedAt: true,
assignments: {
select: {
id: true,
childId: true,
date: true,
createdAt: true,
alarms: {
where: { activeAssignmentId: { not: null } },
select: { id: true },
},
},
},
},
},
}),
prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
select: {
id: true,
name: true,
children: {
where: { active: true },
select: { id: true },
}),
prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
},
orderBy: { name: "asc" },
}),
prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
date: true,
child: {
select: {
familyId: true,
select: {
id: true,
name: true,
children: {
where: { active: true },
select: { id: true },
},
},
},
orderBy: { date: "asc" },
}),
prisma.termin.count({
where: {
kitaId,
type: TerminType.SCHLIESSTAG,
startDate: {
gte: monthStart,
lt: monthEnd,
orderBy: { name: "asc" },
}),
prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
},
}),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
notdienstMinPerChildPerMonth: true,
notdienstReminderDaysBefore: true,
},
}),
]);
select: {
date: true,
child: {
select: {
familyId: true,
},
},
},
orderBy: { date: "asc" },
}),
prisma.termin.count({
where: {
kitaId,
type: TerminType.SCHLIESSTAG,
startDate: {
gte: monthStart,
lt: monthEnd,
},
},
}),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
notdienstMinPerChildPerMonth: true,
notdienstReminderDaysBefore: true,
},
}),
]);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
// aber für MVP reichen alle aktiven Kinder.
const allChildrenRaw = await prisma.child.findMany({
where: { kitaId, active: 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,
},
});
}