feat: complete notdienst planning, quick wins and notifications

This commit is contained in:
t.indorf
2026-10-12 15:34:09 +02:00
parent b915857951
commit 00f602a7aa
50 changed files with 2443 additions and 513 deletions
+6 -1
View File
@@ -1,6 +1,8 @@
import { revalidatePath } from "next/cache";
import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
import { runNotdienstNotificationJob } from "@/lib/notdienst-notifications";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
@@ -16,9 +18,12 @@ export async function POST(request: Request) {
{ status },
);
try {
const result = await runAlarmJob();
const [alarm, planning] = await Promise.allSettled([runAlarmJob(), runNotdienstNotificationJob()]);
if (alarm.status === "rejected" || planning.status === "rejected") throw new Error("Ein Hintergrundlauf ist fehlgeschlagen.");
const result = { ...alarm.value, planning: planning.value };
revalidatePath("/dashboard");
revalidatePath("/dashboard/notdienst/alarm");
revalidatePath("/dashboard/notdienst/plan");
return Response.json(result);
} catch {
return Response.json(
+8 -3
View File
@@ -11,6 +11,8 @@ import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
import { UserFacingError, publicActionError } from "@/lib/action-error";
const INVITE_TOKEN_TTL_DAYS = 7;
const BASE_URL =
@@ -387,6 +389,9 @@ export async function updateFamilyAction(rawPayload: unknown) {
}
await prisma.$transaction(async (tx) => {
await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds);
const removed = await tx.child.count({ where: { kitaId, familyId, id: { in: removedChildIds } } });
if (removed !== removedChildIds.length) throw new UserFacingError("Mindestens ein Kind gehört nicht zu dieser Familie.");
await tx.family.update({
where: { id: familyId },
data: { name: familyName },
@@ -460,7 +465,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
},
});
}
});
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
@@ -469,8 +474,8 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
}
console.error("Fehler beim Aktualisieren der Familie:", error);
return { error: "Familie konnte nicht aktualisiert werden." };
if (!(error instanceof UserFacingError)) console.error("Fehler beim Aktualisieren der Familie:", error);
return { error: publicActionError(error, "Familie konnte nicht aktualisiert werden.") };
}
const emailError = await sendInviteJobs(kita.name, inviteJobs);
@@ -207,7 +207,7 @@ export function CalendarOverviewClient({
return (
<div className="space-y-5">
{!isAdmin && pendingSwapRequests.length > 0 && (
{pendingSwapRequests.length > 0 && (
<NotdienstSwapRequestsPanel requests={pendingSwapRequests} />
)}
@@ -469,7 +469,7 @@ export function CalendarOverviewClient({
</a>
</div>
)}
{!isAdmin && item.canRequestSwap && (
{item.canRequestSwap && (
<div className="mt-3 flex gap-2">
<NotdienstSwapDialog
target={item}
@@ -1,12 +1,16 @@
"use client";
import { useState, useTransition } from "react";
import { Trash2, Plus, Utensils } from "lucide-react";
import { Trash2, Plus, Utensils, Pencil } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { addMitbringsel, deleteMitbringsel } from "../actions";
import {
addMitbringsel,
deleteMitbringsel,
updateMitbringsel,
} from "../actions";
type MitbringselItemDto = {
id: string;
@@ -27,30 +31,42 @@ export function MitbringselList({
isAdmin: boolean;
}) {
const [content, setContent] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const [isPending, startTransition] = useTransition();
const handleAdd = () => {
if (!content.trim()) return;
if (isPending || !content.trim()) return;
startTransition(async () => {
const res = await addMitbringsel({ terminId, content });
if (res.error) {
toast.error(res.error);
} else {
if (res.error) toast.error(res.error);
else {
toast.success("Eintrag hinzugefügt.");
setContent("");
}
});
};
const handleSave = () => {
if (isPending || !editingId || !editContent.trim()) return;
startTransition(async () => {
const res = await updateMitbringsel({
itemId: editingId,
content: editContent,
});
if (res.error) toast.error(res.error);
else {
toast.success("Beitrag gespeichert.");
setEditingId(null);
}
});
};
const handleDelete = (id: string) => {
startTransition(async () => {
const res = await deleteMitbringsel(id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Eintrag gelöscht.");
}
if (res.error) toast.error(res.error);
else toast.success("Eintrag gelöscht.");
});
};
@@ -60,8 +76,7 @@ export function MitbringselList({
<Utensils className="h-4 w-4 text-[var(--ink-muted)] [stroke-width:1.7]" />
Mitbring-Liste
</div>
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
<div className="flex max-h-60 flex-col gap-2 overflow-y-auto">
{items.length === 0 ? (
<p className="text-xs italic text-[var(--ink-faint)]">
Noch keine Einträge vorhanden.
@@ -70,54 +85,118 @@ export function MitbringselList({
items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-2 rounded-md border border-[var(--hairline-soft)] bg-[var(--surface)] p-2 text-sm text-[var(--ink)]"
className="rounded-md border border-[var(--hairline-soft)] bg-[var(--surface)] p-2 text-sm text-[var(--ink)]"
>
<div className="flex flex-col">
<span className="text-xs font-medium text-[var(--accent-deep)]">
{item.user.firstName} {item.user.lastName}
</span>
<span>{item.content}</span>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<span className="text-xs font-medium text-[var(--accent-deep)]">
{item.user.firstName} {item.user.lastName}
</span>
{editingId !== item.id ? (
<p className="break-words">{item.content}</p>
) : null}
</div>
{editingId !== item.id ? (
<div className="flex shrink-0 gap-1">
{item.userId === currentUserId ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={`Beitrag bearbeiten: ${item.content}`}
onClick={() => {
setEditingId(item.id);
setEditContent(item.content);
}}
disabled={isPending}
>
<Pencil className="h-4 w-4" />
</Button>
) : null}
{isAdmin || item.userId === currentUserId ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
aria-label={`Beitrag löschen: ${item.content}`}
onClick={() => handleDelete(item.id)}
disabled={isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
</div>
) : null}
</div>
{(isAdmin || item.userId === currentUserId) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => handleDelete(item.id)}
disabled={isPending}
{editingId === item.id ? (
<form
className="mt-2 flex flex-wrap gap-2"
onSubmit={(event) => {
event.preventDefault();
handleSave();
}}
>
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button>
)}
<Input
autoFocus
aria-label="Mitbringbeitrag bearbeiten"
value={editContent}
onChange={(event) => setEditContent(event.target.value)}
maxLength={500}
disabled={isPending}
className="h-8 min-w-0 flex-[1_1_180px] text-sm"
/>
<Button
type="submit"
size="sm"
className="h-8"
disabled={isPending || !editContent.trim()}
>
Speichern
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8"
disabled={isPending}
onClick={() => setEditingId(null)}
>
Abbrechen
</Button>
</form>
) : null}
</div>
))
)}
</div>
<div className="flex gap-2 mt-1">
<form
className="mt-1 flex gap-2"
onSubmit={(event) => {
event.preventDefault();
handleAdd();
}}
>
<Input
aria-label="Neuer Mitbringbeitrag"
placeholder="Was bringst du mit?"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}}
onChange={(event) => setContent(event.target.value)}
maxLength={500}
disabled={isPending}
className="h-8 text-sm"
className="h-8 min-w-0 text-sm"
/>
<Button
type="submit"
size="sm"
className="h-8 shrink-0"
onClick={handleAdd}
disabled={!content.trim() || isPending}
>
<Plus className="mr-1 h-4 w-4 [stroke-width:1.7]" />
<Plus className="mr-1 h-4 w-4" />
Hinzufügen
</Button>
</div>
</form>
</div>
);
}
@@ -760,23 +760,18 @@ function NotdienstRow({
</>
)}
</div>
) : notdienst.canRequestSwap ? (
) : null}
{notdienst.canRequestSwap ? (
<NotdienstSwapDialog
target={notdienst}
options={notdienstSwapOptions}
trigger={
<Button
size="sm"
variant="outline"
className="h-8 px-2.5 text-xs"
>
<Button size="sm" variant="outline" className="h-8 px-2.5 text-xs">
Tausch anfragen
</Button>
}
/>
) : (
<div className="w-9 h-9" />
)}
) : !isAdmin ? <div className="w-9 h-9" /> : null}
</div>
</div>
);
+46 -10
View File
@@ -24,6 +24,7 @@ import { serializableTransaction } from "@/lib/transaction";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
import { lockEvent } from "@/lib/event-lock";
import { canRequestNotdienstSwap } from "@/lib/notdienst-swaps";
// =====================================================================
// /dashboard/kalender · Server Actions
@@ -127,7 +128,7 @@ async function getSwapAssignment(
select: {
name: true,
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: {
email: true,
firstName: true,
@@ -335,11 +336,8 @@ function buildTerminData(
export async function createNotdienstSwapRequest(rawPayload: unknown) {
const session = await requireKitaSession();
if (!(await rateLimit(`createNotdienstSwapRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
if (session.user.role !== UserRole.ELTERN) {
return { error: "Nur Eltern können Notdienst-Tausche anfragen." };
}
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
if (!canRequestNotdienstSwap(session.user.role, session.user.familyId)) {
return { error: "Nur Eltern mit einem eigenen Haushalt können Notdienst-Tausche anfragen." };
}
const parsed = swapRequestSchema.safeParse(rawPayload);
@@ -467,7 +465,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -493,7 +491,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -636,7 +634,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -654,7 +652,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -1059,6 +1057,44 @@ export async function addMitbringsel(rawPayload: unknown) {
}
}
const updateMitbringselSchema = z.object({
itemId: z.string().min(1).max(100),
content: mitbringselSchema.shape.content,
});
export async function updateMitbringsel(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = updateMitbringselSchema.safeParse(rawPayload);
if (!parsed.success) return { error: "Bitte einen Beitrag mit 1 bis 500 Zeichen eingeben." };
if (!(await rateLimit(`updateMitbringsel:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) {
return { error: "Zu viele Änderungen. Bitte versuche es später erneut." };
}
try {
await serializableTransaction(async (tx) => {
const item = await tx.mitbringselItem.findFirst({
where: { id: parsed.data.itemId, kitaId: session.user.kitaId, userId: session.user.id },
select: { terminId: true },
});
if (!item) throw new UserFacingError("Du kannst nur eigene Mitbringbeiträge bearbeiten.");
await lockEvent(tx, item.terminId, session.user.kitaId);
const termin = await tx.termin.findFirst({
where: { id: item.terminId, kitaId: session.user.kitaId, status: TerminStatus.CONFIRMED, mitbringselListEnabled: true },
select: { id: true },
});
if (!termin) throw new UserFacingError("Die Mitbring-Liste ist für diesen Termin nicht mehr aktiv.");
await tx.mitbringselItem.update({
where: { id: parsed.data.itemId, kitaId: session.user.kitaId, userId: session.user.id },
data: { content: parsed.data.content },
});
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
return { error: publicActionError(error, "Der Beitrag konnte nicht gespeichert werden.") };
}
}
export async function deleteMitbringsel(itemId: string) {
const session = await requireKitaSession();
+3 -3
View File
@@ -9,6 +9,7 @@ import { HelpCircle } from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { canRequestNotdienstSwap } from "@/lib/notdienst-swaps";
import { getRsvpEvents } from "@/lib/rsvp-events";
import { AdminTerminModal } from "./_components/admin-termin-modal";
import {
@@ -213,7 +214,7 @@ export default async function KalenderPage({
});
const pendingSwapRows =
!isAdmin && session.user.familyId
session.user.familyId && canRequestNotdienstSwap(session.user.role, session.user.familyId)
? await prisma.notdienstSwapRequest.findMany({
where: {
kitaId: session.user.kitaId,
@@ -306,9 +307,8 @@ export default async function KalenderPage({
...notdienstAssignmentRows.map((assignment) => {
const isOwn = assignment.child.familyId === familyIdForParticipation;
const canRequestSwap =
!isAdmin &&
canRequestNotdienstSwap(session.user.role, session.user.familyId) &&
!isOwn &&
!!session.user.familyId &&
assignment.date >= today;
return {
kind: "notdienst" as const,
+44 -72
View File
@@ -5,8 +5,10 @@ import { UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData } from "@/lib/date-utils";
import { formatUtcDateKey, getTargetMonthData } from "@/lib/date-utils";
import { serializableTransaction } from "@/lib/transaction";
import { UserFacingError, publicActionError } from "@/lib/action-error";
import { loadNotdienstDays } from "@/lib/notdienst-planning";
// =====================================================================
// /dashboard/notdienst · Server Actions
@@ -64,90 +66,60 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
// ── 2. Validierung (Soll-Tage) ─────────────────────────────────────
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: { notdienstMinPerChildPerMonth: true },
});
const requiredTotal = childrenIds.length * kita.notdienstMinPerChildPerMonth;
if (dates.length < requiredTotal) {
return {
error: `Bitte wähle mindestens ${requiredTotal} Tage aus.`,
};
}
// Sicherstellen, dass alle Kinder zum Haushalt des angemeldeten Users gehören.
const validChildren = await prisma.child.findMany({
where: {
id: { in: childrenIds },
kitaId,
familyId: session.user.familyId,
},
select: { id: true },
});
if (validChildren.length !== childrenIds.length) {
return { error: "Ungültige Kinder-Zuordnung erkannt." };
}
// Termine in Date-Objekte wandeln und sortieren
const parsedDates = dates.map(dateKeyToUtcDate);
const hasOutsideTargetMonth = parsedDates.some(
(date) =>
date.getUTCFullYear() !== targetYear ||
date.getUTCMonth() !== targetMonth - 1,
);
if (hasOutsideTargetMonth) {
return { error: "Bitte wähle Termine im Zielmonat aus." };
}
// Round-Robin Zuweisung
const inserts: {
kitaId: string;
childId: string;
userId: string;
date: Date;
}[] = [];
for (let i = 0; i < parsedDates.length; i++) {
const childId = validChildren[i % validChildren.length].id;
inserts.push({
kitaId,
childId,
userId: session.user.id,
date: parsedDates[i],
});
}
// ── 3. Datenbank-Transaktion ───────────────────────────────────────
try {
await prisma.$transaction(async (tx) => {
// a) Alle bestehenden Haushaltseinträge im Zielmonat für diese Kinder löschen
await serializableTransaction(async (tx) => {
const [kita, children, workingDays] = await Promise.all([
tx.kita.findUniqueOrThrow({
where: { id: kitaId },
select: { notdienstMinPerChildPerMonth: true },
}),
tx.child.findMany({
where: { kitaId, familyId: session.user.familyId!, active: true },
select: { id: true },
orderBy: { id: "asc" },
}),
loadNotdienstDays(tx, kitaId, targetYear, targetMonth),
]);
const suppliedIds = new Set(childrenIds);
if (!children.length || suppliedIds.size !== childrenIds.length ||
suppliedIds.size !== children.length || !children.every((child) => suppliedIds.has(child.id))) {
throw new UserFacingError("Bitte lade die Seite neu und wähle die Termine für alle aktiven Kinder deines Haushalts aus.");
}
const validDates = new Set(workingDays.map(formatUtcDateKey));
if (dates.some((date) => !validDates.has(date))) {
throw new UserFacingError("Bitte wähle nur geöffnete Werktage im Zielmonat aus. Wochenenden und Schließtage sind nicht möglich.");
}
const requiredTotal = children.length * kita.notdienstMinPerChildPerMonth;
if (dates.length < requiredTotal) {
throw new UserFacingError(`Bitte wähle mindestens ${requiredTotal} Tage aus.`);
}
const activeIds = children.map((child) => child.id);
await tx.notdienstAvailability.deleteMany({
where: {
kitaId,
childId: { in: childrenIds },
child: { kitaId, familyId: session.user.familyId! },
date: {
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
},
},
});
// b) Neue Einträge setzen
if (inserts.length > 0) {
await tx.notdienstAvailability.createMany({
data: inserts,
skipDuplicates: true,
});
}
await tx.notdienstAvailability.createMany({
data: dates.map((date, index) => ({
kitaId,
childId: activeIds[index % activeIds.length],
userId: session.user.id,
date: dateKeyToUtcDate(date),
})),
});
});
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard");
return { success: true, dates };
} catch (error) {
console.error("Fehler beim Speichern der Notdienste:", error);
return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." };
if (!(error instanceof UserFacingError)) console.error("Fehler beim Speichern der Notdienste:", error);
return { error: publicActionError(error, "Ein Fehler ist aufgetreten beim Speichern der Termine.") };
}
}
+14 -37
View File
@@ -26,7 +26,8 @@ import { de } from "date-fns/locale";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { formatDateKey, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { formatDateKey, formatUtcDateKey } from "@/lib/date-utils";
import { getNotdienstDays } from "@/lib/notdienst-days";
import { saveNotdienstAvailabilities } from "./actions";
export const notdienstEventCategories = {
@@ -100,23 +101,21 @@ export function NotdienstForm({
}: NotdienstFormProps) {
const router = useRouter();
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor.
const [selectedDates, setSelectedDates] = useState<Set<string>>(
const [selection, setSelectedDates] = useState<Set<string>>(
new Set(initialSelectedDates),
);
const [isPending, startTransition] = useTransition();
const closureDateKeys = useMemo(
() => getClosureDateKeys(calendarItems),
[calendarItems],
const selectableDateKeys = useMemo(
() => new Set(getNotdienstDays(targetYear, targetMonth,
calendarItems.filter((item) => item.category === "closure" || item.category === "team"),
).map(formatUtcDateKey)),
[calendarItems, targetMonth, targetYear],
);
const selectedDates = useMemo(
() => new Set(Array.from(selection).filter((key) => selectableDateKeys.has(key))),
[selection, selectableDateKeys],
);
const selectableDateKeys = useMemo(() => {
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
return new Set(
workingDays
.map(formatDateKey)
.filter((dateKey) => !closureDateKeys.has(dateKey)),
);
}, [closureDateKeys, targetMonth, targetYear]);
const monthStart = startOfMonth(new Date(targetYear, targetMonth - 1));
const selectedCount = selectedDates.size;
const hasMinimumRequirement = requiredDaysTotal > 0;
@@ -375,12 +374,9 @@ export function NotdienstCalendar({
const isInTargetMonth = isSameMonth(day, visibleMonthStart);
const isSelected = selected.has(dateKey);
const dayEvents = getCalendarItemsForDay(events, day);
const hasClosure = dayEvents.some(
(event) => event.category === "closure",
);
const isSelectable =
isInTargetMonth && selectableDateKeys.has(dateKey) && !hasClosure;
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
const hasClosure = isInTargetMonth && !isWeekend && !selectableDateKeys.has(dateKey);
const isSelectable = isInTargetMonth && selectableDateKeys.has(dateKey);
return (
<CalendarTile
@@ -603,25 +599,6 @@ function getCalendarItemsForDay(
});
}
function getClosureDateKeys(items: NotdienstCalendarItem[]) {
const closureKeys = new Set<string>();
for (const item of items) {
if (item.category !== "closure") continue;
const days = eachDayOfInterval({
start: startOfDay(item.startDate),
end: startOfDay(item.endDate),
});
for (const day of days) {
closureKeys.add(formatDateKey(day));
}
}
return closureKeys;
}
function formatEventDate(event: NotdienstCalendarItem) {
const date = format(event.startDate, "EEEE, d. MMMM yyyy", { locale: de });
return event.allDay
+7 -3
View File
@@ -5,7 +5,8 @@ import { Manrope } from "next/font/google";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { formatDateKey, getTargetMonthData } from "@/lib/date-utils";
import { formatUtcDateKey, getTargetMonthData } from "@/lib/date-utils";
import { getNotdienstDays } from "@/lib/notdienst-days";
import { Button } from "@/components/ui/button";
import { NotdienstEntry } from "./notdienst-entry";
@@ -77,7 +78,7 @@ export default async function NotdienstPage() {
kitaId,
status: TerminStatus.CONFIRMED,
startDate: { lt: targetMonthEnd },
endDate: { gte: targetMonthStart },
endDate: { gte: new Date(targetMonthStart.getTime() - 86400000) },
},
select: {
id: true,
@@ -93,7 +94,10 @@ export default async function NotdienstPage() {
])
: [[], []];
const selectedDates = availabilities.map((a) => formatDateKey(a.date));
const validDateKeys = new Set(getNotdienstDays(targetYear, targetMonth,
termine.filter((termin) => termin.type === TerminType.SCHLIESSTAG || termin.type === TerminType.TEAMTAG),
).map(formatUtcDateKey));
const selectedDates = availabilities.map((a) => formatUtcDateKey(a.date)).filter((key) => validDateKeys.has(key));
return (
<div className={`${manrope.className} mx-auto max-w-7xl px-4 py-8 sm:px-8`}>
@@ -0,0 +1,149 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import type { NotdienstEmailStatus } from "@/lib/notdienst-notifications";
import {
retryNotdienstEmailsAction,
sendNotdienstRemindersAction,
} from "../actions";
export function EmailStatusCard({
status,
year,
month,
remindersOpen,
daysBefore,
jobConfigured,
}: {
status: NotdienstEmailStatus;
year: number;
month: number;
remindersOpen: boolean;
daysBefore: number;
jobConfigured: boolean;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const run = (retry: boolean) =>
startTransition(async () => {
const result = retry
? await retryNotdienstEmailsAction(year, month)
: await sendNotdienstRemindersAction(year, month);
if (result.error) toast.error(result.error);
else if ("failed" in result && result.failed)
toast.warning(
"Einige E-Mails sind noch nicht versendet. Der nächste Versuch erfolgt automatisch oder über diesen Button.",
);
else if ("sent" in result)
toast.success(`${result.sent} E-Mails versendet.`);
router.refresh();
});
const labels = {
sent: "Versendet",
pending: "Wartet auf Versand",
failed: "Versand fehlgeschlagen",
skipped: "Nicht mehr erforderlich",
};
return (
<Card className="mb-5 print:hidden p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-medium text-[var(--ink)]">
E-Mail-Versand
</h2>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
{daysBefore
? `Automatische Erinnerung ab dem ${Math.max(1, 26 - daysBefore)}. um 9 Uhr. Eintragsfrist: der 26.`
: "Automatische Erinnerungen sind ausgeschaltet."}
</p>
{!jobConfigured ? (
<p className="mt-1 text-xs text-[var(--warn)]">
Der Hintergrundversand ist noch nicht konfiguriert. Offene E-Mails
können hier manuell gesendet werden.
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{remindersOpen ? (
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={() => run(false)}
>
An fehlende Verfügbarkeiten erinnern
</Button>
) : null}
{status.failed + status.pending > 0 ? (
<Button size="sm" disabled={pending} onClick={() => run(true)}>
Offene E-Mails erneut senden
</Button>
) : null}
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={() => router.refresh()}
>
Aktualisieren
</Button>
</div>
</div>
<p className="mt-3 text-sm text-[var(--ink-soft)]">
{status.sent} versendet · {status.pending} ausstehend · {status.failed}{" "}
fehlgeschlagen · {status.skipped} nicht mehr erforderlich
</p>
{status.entries.length ? (
<details className="mt-3 text-sm">
<summary className="cursor-pointer font-medium text-[var(--accent-deep)]">
Versanddetails ansehen
</summary>
<ul className="mt-3 max-h-64 overflow-y-auto divide-y divide-[var(--hairline-soft)]">
{status.entries.map((entry) => (
<li
key={entry.id}
className="flex flex-wrap justify-between gap-2 py-2"
>
<div>
<p>
{entry.name} · {entry.familyName}
</p>
<p className="text-xs text-[var(--ink-muted)]">
{entry.kind === "REMINDER"
? "Verfügbarkeitserinnerung"
: "Planveröffentlichung"}
</p>
{entry.error ? (
<p className="mt-1 text-xs text-[var(--danger)]">
{entry.error}
</p>
) : null}
</div>
<span
className={
entry.status === "failed"
? "text-[var(--danger)]"
: "text-[var(--ink-soft)]"
}
>
{labels[entry.status]}
</span>
</li>
))}
</ul>
</details>
) : (
<p className="mt-2 text-xs text-[var(--ink-muted)]">
Für diesen Monat wurden noch keine E-Mails vorbereitet.
</p>
)}
<p className="mt-3 text-xs text-[var(--ink-muted)]">
Versendet bedeutet: vom Maildienst angenommen. Die tatsächliche
Zustellung ins Postfach kann hier nicht bestätigt werden.
</p>
</Card>
);
}
@@ -35,8 +35,8 @@ export function SettingsModal({
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (minPerChild < 0 || reminderDays < 0) {
toast.error("Werte dürfen nicht negativ sein.");
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) {
toast.error("Bitte ein Soll von 1 bis 31 und eine Erinnerungsfrist von 0 bis 31 Tagen eingeben.");
return;
}
@@ -74,25 +74,26 @@ export function SettingsModal({
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="minPerChild">
Mindesteinteilungen pro Kind/Monat
Verfügbarkeitstage pro Kind/Monat
</Label>
<Input
id="minPerChild"
name="minPerChild"
type="number"
min="0"
min="1"
max="31"
required
value={minPerChild}
onChange={(e) => setMinPerChild(parseInt(e.target.value, 10))}
/>
<p className="text-xs text-[var(--ink-muted)]">
Bestimmt das Richtmaß der automatischen Verteilung für Kinder im jeweiligen Monat.
So viele unterschiedliche Tage müssen Eltern je aktivem Kind als Verfügbarkeit eintragen.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="reminderDays">
Erinnerungsfrist (Tage vor Monatsende)
Erinnerung (Tage vor Eintragsfrist)
</Label>
<Input
id="reminderDays"
@@ -100,11 +101,12 @@ export function SettingsModal({
type="number"
min="0"
required
max="31"
value={reminderDays}
onChange={(e) => setReminderDays(parseInt(e.target.value, 10))}
/>
<p className="text-xs text-[var(--ink-muted)]">
Frist, nach der Eltern automatisch per Mail an offene Verfügbarkeiten erinnert werden.
Die Eintragsfrist endet am 26. Bei 7 Tagen erfolgt die Erinnerung ab dem 19. um 9 Uhr. 0 deaktiviert automatische Erinnerungen.
</p>
</div>
</div>
+105 -210
View File
@@ -11,9 +11,12 @@ import { de } from "date-fns/locale";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
import { getTargetMonthData } from "@/lib/date-utils";
import { loadNotdienstDays } from "@/lib/notdienst-planning";
import { todayKey } from "@/lib/alarms/format";
import { serializableTransaction } from "@/lib/transaction";
import { queueNotdienstPublication, queueNotdienstReminders, deliverDueNotdienstEmails } from "@/lib/notdienst-notifications";
import { sendAppEmail } from "@/lib/mail";
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
import { requireAlarmActor } from "@/lib/alarms/auth";
import {
@@ -42,93 +45,44 @@ export async function generatePlanAction(year?: number, month?: number) {
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
// Prüfen, ob schon ein Plan existiert
const existingPlan = await prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
});
if (existingPlan) {
return { error: "Ein Plan für diesen Monat existiert bereits." };
}
// 1. Alle Verfügbarkeiten der Eltern für diesen Monat holen
const availabilities = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
},
},
include: { child: { select: { familyId: true } } },
});
// 2. Werktage holen
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
// 3. Algorithmus: Wir verteilen die Slots
// Counter für "Gerechtigkeit": wie oft wurde diese Familie schon eingeteilt?
const familyUsageCount: Record<string, number> = {};
const assignmentsToCreate: { childId: string; date: Date }[] = [];
for (const day of workingDays) {
// Alle Verfügbarkeiten für diesen spezifischen Tag
const availForDay = availabilities.filter(
(a) => a.date.getTime() === day.getTime(),
);
if (availForDay.length === 0) {
// Niemand verfügbar -> Bleibt leer ("Unbesetzt")
continue;
}
// Finde die Familie, die am wenigsten oft dran war
// Zufall einbauen bei Gleichstand
const sorted = availForDay.sort((a, b) => {
const countA = familyUsageCount[a.child.familyId] || 0;
const countB = familyUsageCount[b.child.familyId] || 0;
if (countA === countB) {
return Math.random() - 0.5; // Zufallsmischung
}
return countA - countB;
});
const selected = sorted[0];
assignmentsToCreate.push({
childId: selected.childId,
date: day,
});
// Usage-Counter erhöhen
familyUsageCount[selected.child.familyId] =
(familyUsageCount[selected.child.familyId] || 0) + 1;
}
// 4. In der DB speichern
try {
await prisma.$transaction(async (tx) => {
const plan = await tx.notdienstPlan.create({
data: {
kitaId,
year: targetYear,
month: targetMonth,
status: NotdienstPlanStatus.DRAFT,
createdById: session.user.id,
},
await serializableTransaction(async (tx) => {
const existingPlan = await tx.notdienstPlan.findUnique({
where: { kitaId_year_month: { kitaId, year: targetYear, month: targetMonth } },
});
if (existingPlan) throw new UserFacingError("Ein Plan für diesen Monat existiert bereits.");
if (assignmentsToCreate.length > 0) {
await tx.notdienstAssignment.createMany({
data: assignmentsToCreate.map((a) => ({
const [availabilities, workingDays] = await Promise.all([
tx.notdienstAvailability.findMany({
where: {
kitaId,
planId: plan.id,
childId: a.childId,
date: a.date,
})),
child: { kitaId, active: true },
date: {
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
},
},
include: { child: { select: { familyId: true } } },
}),
loadNotdienstDays(tx, kitaId, targetYear, targetMonth),
]);
const familyUsageCount = new Map<string, number>();
const assignments: { childId: string; date: Date }[] = [];
for (const day of workingDays) {
const candidates = availabilities.filter((a) => a.date.getTime() === day.getTime());
if (!candidates.length) continue;
const minUsage = Math.min(...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0));
const leastUsed = candidates.filter((a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage);
const selected = leastUsed[Math.floor(Math.random() * leastUsed.length)];
assignments.push({ childId: selected.childId, date: day });
familyUsageCount.set(selected.child.familyId, minUsage + 1);
}
const plan = await tx.notdienstPlan.create({
data: { kitaId, year: targetYear, month: targetMonth, status: NotdienstPlanStatus.DRAFT, createdById: session.user.id },
});
if (assignments.length) {
await tx.notdienstAssignment.createMany({
data: assignments.map((a) => ({ ...a, kitaId, planId: plan.id })),
});
}
});
@@ -137,7 +91,7 @@ export async function generatePlanAction(year?: number, month?: number) {
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler bei der Generierung des Plans." };
return { error: publicActionError(error, "Fehler bei der Generierung des Plans.") };
}
}
@@ -145,17 +99,35 @@ export async function publishPlanAction(planId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
await prisma.notdienstPlan.update({
where: { id: planId, kitaId },
data: {
status: NotdienstPlanStatus.PUBLISHED,
publishedAt: new Date(),
},
});
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
return { success: true };
try {
await serializableTransaction(async (tx) => {
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
include: { assignments: { include: { child: { select: { active: true } } } } },
});
if (!plan) throw new UserFacingError("Plan nicht gefunden.");
if (plan.status === NotdienstPlanStatus.PUBLISHED) return;
if (plan.status !== NotdienstPlanStatus.DRAFT) throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
const validDates = new Set(days.map((day) => day.getTime()));
if (plan.assignments.some((a) => !validDates.has(a.date.getTime()) || !a.child.active)) {
throw new UserFacingError("Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.");
}
await tx.notdienstPlan.update({
where: { id: planId, kitaId },
data: { status: NotdienstPlanStatus.PUBLISHED, publishedAt: new Date() },
});
await queueNotdienstPublication(tx, kitaId, planId, plan.year, plan.month);
});
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard/kalender");
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
revalidatePath("/dashboard/notdienst/plan");
return { success: true, ...delivery };
} catch (error) {
return { error: publicActionError(error, "Der Plan konnte nicht veröffentlicht werden.") };
}
}
export async function updateAssignmentAction(
@@ -189,11 +161,19 @@ export async function updateAssignmentAction(
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
}
if (newChildId && (parsedDate.getUTCFullYear() !== plan.year || parsedDate.getUTCMonth() + 1 !== plan.month)) {
throw new UserFacingError("Das Datum gehört nicht zum Planungsmonat.");
}
// Clearing obsolete entries remains possible after a new closure.
if (newChildId) {
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
if (!days.some((day) => day.getTime() === parsedDate.getTime())) {
throw new UserFacingError("An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.");
}
}
if (isPublished) {
const today = new Date();
const todayUtc = new Date(
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
);
const todayUtc = new Date(`${todayKey()}T00:00:00Z`);
if (parsedDate < todayUtc) {
throw new UserFacingError(
"Vergangene Notdienste können nicht geändert werden.",
@@ -202,7 +182,7 @@ export async function updateAssignmentAction(
}
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
where: { kitaId, planId, date: parsedDate },
include: {
child: {
include: {
@@ -431,118 +411,33 @@ 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) {
return { error: "Kein Mandant zugeordnet." };
}
const kitaId = session.user.kitaId!;
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
try {
// 1. Get Kita Name
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { error: "Kita nicht gefunden." };
}
// 2. Fetch all families in this Kita with at least one active child and retrieve parents
const families = await prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
include: {
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
},
},
});
// 3. For target month, find availability entries
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 1);
const respondedFamilies = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
child: {
select: {
familyId: true,
},
},
},
});
const respondedFamilyIds = new Set(
respondedFamilies.map((a) => a.child.familyId),
);
// 4. Find open families (not responded yet)
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
if (openFamilies.length === 0) {
return { success: true, count: 0 };
}
// 5. Build emails list and send
const monthDate = new Date(year, month - 1, 1);
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
const BASE_URL =
process.env.NEXTAUTH_URL ??
process.env.AUTH_URL ??
"http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
let sentCount = 0;
for (const family of openFamilies) {
for (const parent of family.users) {
if (!parent.email) continue;
const result = await sendAppEmail({
to: parent.email,
subject: `Erinnerung: Bitte Verfügbarkeiten für den Notdienst ${monthLabel} eintragen`,
react: createElement(NotdienstAvailabilityReminderEmail, {
parentName: `${parent.firstName} ${parent.lastName}`,
kitaName: kita.name,
monthLabel,
link,
}),
});
if (result.success) {
sentCount++;
} else {
console.error(
`E-Mail konnte nicht an ${parent.email} gesendet werden:`,
result.error,
);
}
}
}
return { success: true, count: sentCount };
const queued = await queueNotdienstReminders(kitaId, year, month);
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
revalidatePath("/dashboard/notdienst/plan");
return { success: true, queued, count: delivery.sent, ...delivery };
} catch (error) {
console.error("Fehler beim Versenden der Erinnerungen:", error);
return { error: "Fehler beim Versenden der Erinnerungen." };
return { error: publicActionError(error, "Die Erinnerungen konnten nicht vorbereitet werden.") };
}
}
export async function retryNotdienstEmailsAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte warte vor einem weiteren Versandversuch." };
try {
await prisma.notdienstEmail.updateMany({ where: { kitaId, year, month, failed: true, sentAt: null, skippedAt: null }, data: { nextAttemptAt: new Date() } });
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month });
revalidatePath("/dashboard/notdienst/plan");
return { success: true, ...delivery };
} catch {
return { error: "Der Versand bleibt gespeichert und wird erneut versucht." };
}
}
+36 -23
View File
@@ -1,11 +1,13 @@
import Link from "next/link";
import { addMonths, getYear, getMonth, format } from "date-fns";
import { de } from "date-fns/locale";
import { TerminType } from "@prisma/client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
import { formatUtcDateKey, getWorkingDaysOfMonth, getTargetMonthData } from "@/lib/date-utils";
import { loadNotdienstDays } from "@/lib/notdienst-planning";
import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications";
import { EmailStatusCard } from "./_components/email-status-card";
import { PlanView } from "./plan-view";
import { SettingsModal } from "./_components/settings-modal";
import { requireAlarmActor } from "@/lib/alarms/auth";
@@ -23,22 +25,21 @@ export default async function PlanungsZentralePage({
const resolvedSearchParams = await searchParams;
const now = new Date();
const nextMonth = addMonths(now, 1);
const defaultTarget = getTargetMonthData(now);
const targetYear = resolvedSearchParams.year
? parseInt(resolvedSearchParams.year, 10)
: getYear(nextMonth);
: defaultTarget.targetYear;
const targetMonth = resolvedSearchParams.month
? parseInt(resolvedSearchParams.month, 10)
: getMonth(nextMonth) + 1;
: defaultTarget.targetMonth;
const monthDate = new Date(targetYear, targetMonth - 1, 1);
const monthName = format(monthDate, "MMMM", { locale: de });
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
const monthStart = new Date(targetYear, targetMonth - 1, 1);
const monthEnd = new Date(targetYear, targetMonth, 1);
const monthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
const monthEnd = new Date(Date.UTC(targetYear, targetMonth, 1));
const [plan, families, availabilities, closureCount, kita] =
const [plan, families, rawAvailabilities, workingDays, kita] =
await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
@@ -80,6 +81,7 @@ export default async function PlanungsZentralePage({
prisma.notdienstAvailability.findMany({
where: {
kitaId,
child: { kitaId, active: true },
date: {
gte: monthStart,
lt: monthEnd,
@@ -95,16 +97,7 @@ export default async function PlanungsZentralePage({
},
orderBy: { date: "asc" },
}),
prisma.termin.count({
where: {
kitaId,
type: TerminType.SCHLIESSTAG,
startDate: {
gte: monthStart,
lt: monthEnd,
},
},
}),
loadNotdienstDays(prisma, kitaId, targetYear, targetMonth),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
@@ -152,6 +145,10 @@ export default async function PlanungsZentralePage({
};
});
const validDateKeys = new Set(workingDays.map(formatUtcDateKey));
const availabilities = rawAvailabilities.filter((a) => validDateKeys.has(formatUtcDateKey(a.date)));
const closureCount = getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
const availabilityDatesByFamily = new Map<string, Set<string>>();
availabilities.forEach((availability) => {
const familyId = availability.child.familyId;
@@ -174,11 +171,12 @@ export default async function PlanungsZentralePage({
}).format(new Date(date)),
})),
childCount: family.children.length,
requiredDays: family.children.length * kita.notdienstMinPerChildPerMonth,
};
});
const respondedFamilies = familyStatuses.filter(
(family) => family.selectedDays.length > 0,
(family) => family.selectedDays.length >= family.requiredDays,
).length;
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
const totalAvailabilitySlots = availabilities.length;
@@ -187,7 +185,13 @@ export default async function PlanungsZentralePage({
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
// Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => {
// Keep obsolete assignments visible so the coordinator can remove them.
const displayedDays = new Map(workingDays.map((day) => [formatUtcDateKey(day), day]));
for (const assignment of plan?.assignments ?? []) {
displayedDays.set(formatUtcDateKey(assignment.date), assignment.date);
}
const activeChildIds = new Set(allChildren.map((child) => child.id));
const daysWithAssignments = Array.from(displayedDays.values()).sort((a, b) => a.getTime() - b.getTime()).map((day) => {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime(),
);
@@ -195,19 +199,25 @@ export default async function PlanungsZentralePage({
!assignment ||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
const isInvalid = !validDateKeys.has(formatUtcDateKey(day)) || !!assignment && !activeChildIds.has(assignment.childId);
return {
date: day.toISOString(),
isInvalid,
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
isOriginallyUnassigned: !!isOriginallyUnassigned,
activeAlarmId: assignment?.alarms[0]?.id ?? null,
canAlarm:
!!assignment &&
!!assignment && !isInvalid &&
plan?.status === "PUBLISHED" &&
day.toISOString().slice(0, 10) >= todayKey(),
};
});
const mailStatus = await getNotdienstEmailStatus(kitaId, targetYear, targetMonth);
const target = getTargetMonthData(now);
const remindersOpen = !target.isLocked && target.targetYear === targetYear && target.targetMonth === targetMonth && plan?.status !== "PUBLISHED";
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
@@ -226,7 +236,7 @@ export default async function PlanungsZentralePage({
</p>
</div>
<div className="print:hidden flex shrink-0 items-center gap-2">
<div className="print:hidden flex flex-wrap items-center gap-2">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<Link
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, -1))}&month=${getMonth(addMonths(monthDate, -1)) + 1}`}
@@ -251,8 +261,11 @@ export default async function PlanungsZentralePage({
</div>
</header>
<EmailStatusCard status={mailStatus} year={targetYear} month={targetMonth} remindersOpen={remindersOpen} daysBefore={kita.notdienstReminderDaysBefore} jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32} />
<PlanView
planId={plan?.id}
remindersOpen={remindersOpen}
status={plan?.status}
days={daysWithAssignments}
allChildren={allChildren}
+30 -20
View File
@@ -37,6 +37,7 @@ import {
interface PlanViewProps {
planId?: string;
remindersOpen: boolean;
status?: NotdienstPlanStatus;
days: {
date: string;
@@ -45,6 +46,7 @@ interface PlanViewProps {
canAlarm?: boolean;
childId: string | null;
isOriginallyUnassigned?: boolean;
isInvalid?: boolean;
}[];
allChildren: {
id: string;
@@ -56,6 +58,7 @@ interface PlanViewProps {
id: string;
name: string;
childCount: number;
requiredDays: number;
selectedDays: {
iso: string;
label: string;
@@ -75,6 +78,7 @@ interface PlanViewProps {
export function PlanView({
planId,
remindersOpen,
status,
days,
allChildren,
@@ -116,8 +120,9 @@ export function PlanView({
const result = await publishPlanAction(planId);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Plan veröffentlicht! Er ist nun fix.");
} else if ("success" in result && result.success) {
if (result.failed || ("deferred" in result && result.deferred)) toast.warning("Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.");
else toast.success("Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.");
}
});
};
@@ -127,10 +132,9 @@ export function PlanView({
const result = await sendNotdienstRemindersAction(year, month);
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.`,
);
} else if ("success" in result && result.success) {
if (result.failed || ("deferred" in result && result.deferred)) toast.warning(`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`);
else toast.success(`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`);
}
});
};
@@ -153,7 +157,7 @@ export function PlanView({
const valueToSet = newChildId === "empty" ? null : newChildId;
startTransition(async () => {
const result = await updateAssignmentAction(planId, date, valueToSet);
const result = await updateAssignmentAction(planId, date.slice(0, 10), valueToSet);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
@@ -162,7 +166,7 @@ export function PlanView({
});
};
if (!planId && respondedFamilies === 0) {
if (!planId && totalAvailabilitySlots === 0) {
return (
<Card className="border-dashed">
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
@@ -183,7 +187,7 @@ export function PlanView({
<Button
variant="ghost"
onClick={handleSendReminders}
disabled={isPending}
disabled={isPending || !remindersOpen}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
@@ -211,7 +215,7 @@ export function PlanView({
</h2>
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
{respondedFamilies} von {totalFamilies} Familien haben
geantwortet · noch {openFamilies} offen
vollständig geantwortet · noch {openFamilies} offen
</p>
</div>
<span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
@@ -244,7 +248,7 @@ export function PlanView({
{family.childCount === 1 ? "" : "er"}
</p>
</div>
<FamilyStatusPill selectedDays={family.selectedDays.length} />
<FamilyStatusPill selectedDays={family.selectedDays.length} requiredDays={family.requiredDays} />
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
{family.selectedDays.length > 0
? family.selectedDays.map((day) => day.label).join(" · ")
@@ -258,7 +262,7 @@ export function PlanView({
<Button
variant="ghost"
onClick={handleSendReminders}
disabled={isPending || openFamilies === 0}
disabled={isPending || openFamilies === 0 || !remindersOpen}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
@@ -315,7 +319,7 @@ export function PlanView({
</h3>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Verteilt {requiredDays} Notdiensttage fair auf{" "}
{respondedFamilies} Familien.
die verfügbaren Familien.
</p>
<details className="mt-3 text-xs text-[var(--ink-soft)]">
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
@@ -447,7 +451,7 @@ export function PlanView({
);
}
function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number; requiredDays: number }) {
if (selectedDays === 0) {
return (
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
@@ -457,8 +461,8 @@ function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
}
return (
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]">
{selectedDays} Tage
<span className={selectedDays >= requiredDays ? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]" : "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"}>
{selectedDays} / {requiredDays} Tage
</span>
);
}
@@ -548,7 +552,7 @@ function PlanPreviewTable({
{format(dateObj, "EEEE", { locale: de })}
</div>
<div className="text-[var(--ink)]">
{assignedChild ? assignedChild.parentName : "Unbesetzt"}
{assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"}
{assignedChild && (
<span className="ml-1 text-[var(--ink-muted)]">
({assignedChild.name})
@@ -572,8 +576,8 @@ function PlanPreviewTable({
</div>
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
<div>
<Badge variant={assignedChild ? "success" : "destructive"}>
{assignedChild ? "Besetzt" : "Offen"}
<Badge variant={day.isInvalid ? "warning" : assignedChild ? "success" : "destructive"}>
{day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"}
</Badge>
</div>
</div>
@@ -649,18 +653,24 @@ function ManualAssignmentTable({
}
>
<option value="empty">-- Unbesetzt --</option>
{day.childId && !allChildren.some((child) => child.id === day.childId) ? (
<option value={day.childId} disabled>Inaktives Kind – bitte Einteilung entfernen</option>
) : null}
{allChildren.map((child) => {
const famId = child.familyId;
const count = famId
? (familyAssignmentCounts.get(famId) ?? 0)
: 0;
return (
<option key={child.id} value={child.id}>
<option key={child.id} value={child.id} disabled={day.isInvalid}>
{child.parentName} ({child.name}) — {count}x
</option>
);
})}
</select>
{day.isInvalid ? (
<span className="text-xs text-[var(--danger)]">Einteilung nicht mehr gültig – bitte entfernen.</span>
) : null}
{!isDraft && (
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
Veröffentlicht
+15 -14
View File
@@ -12,6 +12,8 @@ import {
} from "@/lib/child-birthdate";
import { serializableTransaction } from "@/lib/transaction";
import { prisma } from "@/lib/prisma";
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
import { UserFacingError, publicActionError } from "@/lib/action-error";
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
// Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
@@ -268,26 +270,25 @@ export async function deleteMyChild(childId: string) {
const session = await requireKitaSession();
try {
const child = await requireOwnFamilyChild(
childId,
session.user.familyId,
session.user.kitaId,
);
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
await prisma.child.delete({
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
await serializableTransaction(async (tx) => {
if (!session.user.familyId) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
const child = await tx.child.findFirst({
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
select: { id: true },
});
if (!child) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
await assertChildrenRemovable(tx, session.user.kitaId, session.user.familyId, [childId]);
await tx.child.delete({
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
});
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
console.error("Fehler beim Entfernen des Kindes:", error);
return { error: "Das Kind konnte nicht entfernt werden." };
if (!(error instanceof UserFacingError)) console.error("Fehler beim Entfernen des Kindes:", error);
return { error: publicActionError(error, "Das Kind konnte nicht entfernt werden.") };
}
}
+1 -3
View File
@@ -57,7 +57,6 @@ const navItems = [
label: "Notdienst (Eltern)",
icon: CalendarCheck2,
exact: true,
badge: "4",
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ELTERN],
},
{
@@ -157,7 +156,7 @@ export function SidebarNav({ role }: { role: UserRole }) {
<div className="flex flex-col gap-1">
{visibleItems
.filter((item) => item.section === section)
.map(({ href, label, icon, exact, badge }) => {
.map(({ href, label, icon, exact }) => {
const isActive = exact
? pathname === href
: pathname.startsWith(href);
@@ -168,7 +167,6 @@ export function SidebarNav({ role }: { role: UserRole }) {
label={label}
icon={icon}
active={isActive}
badge={badge}
/>
);
})}
@@ -17,6 +17,9 @@ type NotdienstAvailabilityReminderEmailProps = {
kitaName: string;
monthLabel: string;
link: string;
selectedDays?: number;
requiredDays?: number;
deadlineLabel?: string;
};
export function NotdienstAvailabilityReminderEmail({
@@ -24,6 +27,9 @@ export function NotdienstAvailabilityReminderEmail({
kitaName,
monthLabel,
link,
selectedDays,
requiredDays,
deadlineLabel,
}: NotdienstAvailabilityReminderEmailProps) {
return (
<Html lang="de">
@@ -46,6 +52,8 @@ export function NotdienstAvailabilityReminderEmail({
Bitte trage deine Verfügbarkeiten im Planer ein, damit wir die Tage fair verteilen können.
</Text>
{requiredDays !== undefined ? <Text style={styles.text}>Euer Haushalt hat bisher {selectedDays ?? 0} von {requiredDays} benötigten Tagen eingetragen. Bitte ergänzt die Auswahl{deadlineLabel ? ` bis einschließlich ${deadlineLabel}` : ""}.</Text> : null}
<Button href={link} style={styles.button}>
Verfügbarkeiten eintragen
</Button>
@@ -0,0 +1,98 @@
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import { baseBodyStyle, colors, fontStack } from "./_styles";
export function NotdienstPlanPublishedEmail({
parentName,
kitaName,
monthLabel,
dateLabels,
link,
}: {
parentName: string;
kitaName: string;
monthLabel: string;
dateLabels: string[];
link: string;
}) {
return (
<Html lang="de">
<Head />
<Preview>Deine Notdienste für {monthLabel}</Preview>
<Body style={baseBodyStyle}>
<Container
style={{
maxWidth: "600px",
padding: "32px 20px",
fontFamily: fontStack,
}}
>
<Section
style={{
backgroundColor: colors.cardBg,
padding: "28px",
borderRadius: "10px",
border: `1px solid ${colors.border}`,
}}
>
<Heading style={{ color: colors.textPrimary, fontSize: "26px" }}>
Notdienstplan {monthLabel}
</Heading>
<Text>Hallo {parentName},</Text>
<Text>
der Notdienstplan von <strong>{kitaName}</strong> ist
veröffentlicht. Euer Haushalt ist an folgenden Tagen eingeteilt:
</Text>
{dateLabels.map((date) => (
<Text
key={date}
style={{
padding: "10px 14px",
backgroundColor: "#f7f5ef",
borderLeft: `4px solid ${colors.brandGreen}`,
fontWeight: 700,
}}
>
{date}
</Text>
))}
<Text>
Bitte merkt euch diese Termine vor. Den aktuellen Plan und
mögliche Änderungen findet ihr im Kalender.
</Text>
<Button
href={link}
style={{
backgroundColor: colors.brandGreen,
color: "#ffffff",
borderRadius: "6px",
padding: "14px 20px",
textDecoration: "none",
}}
>
Zum Kalender
</Button>
<Text
style={{
color: colors.textMuted,
fontSize: "12px",
wordBreak: "break-all",
}}
>
{link}
</Text>
</Section>
</Container>
</Body>
</Html>
);
}
+36 -19
View File
@@ -1,35 +1,52 @@
import {
addMonths,
getDate,
getYear,
getMonth,
format,
} from "date-fns";
import { de } from "date-fns/locale";
import { format } from "date-fns";
/**
* Der Notdienst-Plan wird immer für den nächsten Monat erstellt.
* Nach dem 26. des aktuellen Monats ist der Plan gesperrt.
*/
const LOCK_DAY = 26;
export const NOTDIENST_LOCK_DAY = 26;
export function berlinDateKey(now = new Date()) {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
}
export function getTargetMonthData(now = new Date()) {
const currentDay = getDate(now);
const nextMonthDate = addMonths(now, 1);
const targetYear = getYear(nextMonthDate);
const targetMonth = getMonth(nextMonthDate) + 1; // 1-12
// Sperre aktiv, sobald der aktuelle Tag (im aktuellen Monat) > LOCK_DAY ist.
const isLocked = currentDay > LOCK_DAY;
const [year, month, currentDay] = berlinDateKey(now).split("-").map(Number);
const targetYear = month === 12 ? year + 1 : year;
const targetMonth = month === 12 ? 1 : month + 1;
return {
targetYear,
targetMonth,
isLocked,
monthName: format(nextMonthDate, "MMMM yyyy", { locale: de }),
isLocked: currentDay > NOTDIENST_LOCK_DAY,
monthName: new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
year: "numeric",
month: "long",
}).format(new Date(Date.UTC(targetYear, targetMonth - 1, 1))),
};
}
export function isNotdienstReminderDue(daysBefore: number, now = new Date()) {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Berlin",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
}).formatToParts(now);
const day = Number(parts.find((part) => part.type === "day")?.value);
const hour = Number(parts.find((part) => part.type === "hour")?.value);
return (
daysBefore > 0 &&
day >= Math.max(1, NOTDIENST_LOCK_DAY - daysBefore) &&
day <= NOTDIENST_LOCK_DAY &&
hour >= 9
);
}
export function getWorkingDaysOfMonth(year: number, month: number) {
const start = new Date(Date.UTC(year, month - 1, 1));
const end = new Date(Date.UTC(year, month, 0)); // last day of month
+16 -4
View File
@@ -1,4 +1,6 @@
import type { ReactNode } from "react";
import { createHash } from "node:crypto";
import type { ReactNode, ReactElement } from "react";
import { render } from "@react-email/render";
import { Resend } from "resend";
let resendClient: Resend | null = null;
@@ -10,6 +12,7 @@ type SendAppEmailInput = {
from?: string;
replyTo?: string | string[];
idempotencyKey?: string;
timeoutMs?: number;
};
type SendAppEmailResult =
@@ -38,11 +41,12 @@ export async function sendAppEmail({
from = process.env.EMAIL_FROM,
replyTo,
idempotencyKey,
timeoutMs = 10000,
}: SendAppEmailInput): Promise<SendAppEmailResult> {
if (Array.isArray(to)) {
let id: string | undefined;
for (const recipient of new Set(to)) {
const result = await sendAppEmail({ to: recipient, subject, react, from, replyTo });
const result = await sendAppEmail({ to: recipient, subject, react, from, replyTo, idempotencyKey: idempotencyKey ? `${idempotencyKey}/${createHash("sha256").update(recipient).digest("hex").slice(0, 16)}` : undefined, timeoutMs });
if (!result.success) return result;
id = result.id;
}
@@ -74,15 +78,23 @@ export async function sendAppEmail({
try {
resendClient ??= new Resend(process.env.RESEND_API_KEY);
const html = await render(react as ReactElement);
// Retries keep the same key for identical payloads, while corrected messages get a new version.
const payloadKey = idempotencyKey ? `${idempotencyKey}/${createHash("sha256").update(JSON.stringify({ to, subject, from, replyTo, html })).digest("hex")}` : undefined;
// The installed SDK forwards request options to fetch; its types omit signal.
const requestOptions: NonNullable<Parameters<typeof resendClient.emails.send>[1]> & { signal: AbortSignal } = {
idempotencyKey: payloadKey,
signal: AbortSignal.timeout(timeoutMs),
};
const { data, error } = await resendClient.emails.send(
{
from: from!,
to,
subject,
react,
html,
replyTo,
},
{ idempotencyKey },
requestOptions,
);
if (error) {
+26
View File
@@ -0,0 +1,26 @@
import { formatUtcDateKey, getWorkingDaysOfMonth } from "./date-utils";
export type NotdienstClosure = { startDate: Date; endDate: Date };
const berlinDate = new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
// Calendar events are instants; assignments are UTC-midnight date values.
// A closure excludes every Berlin calendar day from start through end, inclusive.
export function getNotdienstDays(
year: number,
month: number,
closures: NotdienstClosure[],
) {
const ranges = closures.map((closure) => ({
start: berlinDate.format(closure.startDate),
end: berlinDate.format(closure.endDate),
}));
return getWorkingDaysOfMonth(year, month).filter((day) => {
const key = formatUtcDateKey(day);
return !ranges.some((range) => range.start <= key && key <= range.end);
});
}
+529
View File
@@ -0,0 +1,529 @@
import { randomUUID } from "node:crypto";
import { createElement } from "react";
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { serializableTransaction } from "./transaction";
import { UserFacingError } from "./action-error";
import {
berlinDateKey,
formatUtcDateKey,
getTargetMonthData,
isNotdienstReminderDue,
NOTDIENST_LOCK_DAY,
} from "./date-utils";
import { loadNotdienstDays } from "./notdienst-planning";
import { sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
import { NotdienstPlanPublishedEmail } from "@/emails/NotdienstPlanPublishedEmail";
type Tx = Prisma.TransactionClient;
const parentRoles = ["ELTERN", "ADMIN", "KOORDINATOR"] as const;
const eligibleParents = (now: Date): Prisma.UserWhereInput => ({
role: { in: [...parentRoles] },
emailVerifiedAt: { not: null },
privacyPolicyAcceptedAt: { not: null },
disabledAt: null,
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
});
const monthLabel = (year: number, month: number) =>
new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
month: "long",
year: "numeric",
}).format(new Date(Date.UTC(year, month - 1, 1)));
const period = (year: number, month: number) => ({
gte: new Date(Date.UTC(year, month - 1, 1)),
lt: new Date(Date.UTC(year, month, 1)),
});
export async function getFamilyNotdienstProgress(
db: Pick<Tx, "child" | "notdienstAvailability" | "termin">,
kitaId: string,
familyId: string,
year: number,
month: number,
minimum: number,
) {
const [children, entries, days] = await Promise.all([
db.child.count({ where: { kitaId, familyId, active: true } }),
db.notdienstAvailability.findMany({
where: {
kitaId,
child: { kitaId, familyId, active: true },
date: period(year, month),
},
select: { date: true },
}),
loadNotdienstDays(db, kitaId, year, month),
]);
const valid = new Set(days.map(formatUtcDateKey));
const selected = new Set(
entries
.map((a) => formatUtcDateKey(a.date))
.filter((key) => valid.has(key)),
).size;
return { selected, required: children * minimum };
}
export async function queueNotdienstReminders(
kitaId: string,
year: number,
month: number,
automatic = false,
now = new Date(),
) {
return serializableTransaction(async (tx) => {
const target = getTargetMonthData(now);
if (
target.isLocked ||
target.targetYear !== year ||
target.targetMonth !== month
) {
if (automatic) return 0;
throw new UserFacingError(
"Erinnerungen sind nur während der Eintragsfrist für den Folgemonat möglich.",
);
}
const kita = await tx.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
notdienstModuleEnabled: true,
notdienstMinPerChildPerMonth: true,
notdienstReminderDaysBefore: true,
},
});
if (!kita.notdienstModuleEnabled) {
if (automatic) return 0;
throw new UserFacingError("Das Notdienstmodul ist deaktiviert.");
}
const plan = await tx.notdienstPlan.findUnique({
where: { kitaId_year_month: { kitaId, year, month } },
select: { status: true },
});
if (plan?.status === "PUBLISHED") return 0;
if (automatic) {
if (!isNotdienstReminderDue(kita.notdienstReminderDaysBefore, now))
return 0;
const run = await tx.notdienstReminderRun.createMany({
data: [{ id: randomUUID(), kitaId, year, month, createdAt: now }],
skipDuplicates: true,
});
if (!run.count) return 0;
}
const [families, entries, days] = await Promise.all([
tx.family.findMany({
where: { kitaId, children: { some: { kitaId, active: true } } },
select: {
id: true,
children: { where: { kitaId, active: true }, select: { id: true } },
users: {
where: { kitaId, ...eligibleParents(now) },
select: { id: true },
},
},
}),
tx.notdienstAvailability.findMany({
where: {
kitaId,
date: period(year, month),
child: { kitaId, active: true },
},
select: { date: true, child: { select: { familyId: true } } },
}),
loadNotdienstDays(tx, kitaId, year, month),
]);
const valid = new Set(days.map(formatUtcDateKey));
const selected = new Map<string, Set<string>>();
for (const entry of entries) {
const key = formatUtcDateKey(entry.date);
if (!valid.has(key)) continue;
const dates = selected.get(entry.child.familyId) ?? new Set<string>();
dates.add(key);
selected.set(entry.child.familyId, dates);
}
const jobs = families
.filter(
(family) =>
(selected.get(family.id)?.size ?? 0) <
family.children.length * kita.notdienstMinPerChildPerMonth,
)
.flatMap((family) =>
family.users.map((user) => ({
id: randomUUID(),
kitaId,
familyId: family.id,
userId: user.id,
year,
month,
kind: "REMINDER",
eventKey: `reminder:${berlinDateKey(now)}`,
createdAt: now,
nextAttemptAt: now,
})),
);
if (!jobs.length) return 0;
return (
await tx.notdienstEmail.createMany({ data: jobs, skipDuplicates: true })
).count;
});
}
// Must run in the same transaction as the first publication.
export async function queueNotdienstPublication(
tx: Tx,
kitaId: string,
planId: string,
year: number,
month: number,
now = new Date(),
) {
const assigned = await tx.notdienstAssignment.findMany({
where: { kitaId, planId, child: { kitaId, active: true } },
select: { child: { select: { familyId: true } } },
});
const families = await tx.family.findMany({
where: {
kitaId,
id: { in: [...new Set(assigned.map((a) => a.child.familyId))] },
},
select: {
id: true,
users: {
where: { kitaId, ...eligibleParents(now) },
select: { id: true },
},
},
});
const jobs = families.flatMap((family) =>
family.users.map((user) => ({
id: randomUUID(),
kitaId,
familyId: family.id,
userId: user.id,
planId,
year,
month,
kind: "PUBLICATION",
eventKey: `publication:${planId}`,
createdAt: now,
nextAttemptAt: now,
})),
);
return jobs.length
? (await tx.notdienstEmail.createMany({ data: jobs, skipDuplicates: true }))
.count
: 0;
}
export async function deliverNotdienstEmails(
ids: string[],
send = sendAppEmail,
now = new Date(),
) {
const result = { sent: 0, failed: 0, skipped: 0 };
const deliver = async (id: string) => {
const claimToken = randomUUID();
const leaseUntil = new Date(now.getTime() + 5 * 60_000);
const claim = await prisma.notdienstEmail.updateMany({
where: {
id,
sentAt: null,
skippedAt: null,
nextAttemptAt: { lte: now },
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
},
data: { claimToken, leaseUntil, attempts: { increment: 1 } },
});
if (!claim.count) return;
const finish = (data: Prisma.NotdienstEmailUpdateManyMutationInput) =>
prisma.notdienstEmail.updateMany({
where: { id, claimToken },
data: { ...data, claimToken: null, leaseUntil: null },
});
try {
const job = await prisma.notdienstEmail.findUniqueOrThrow({
where: { id },
include: { user: true, kita: true, plan: { select: { status: true } } },
});
const { user, kita } = job;
let eligible =
kita.notdienstModuleEnabled &&
user.kitaId === job.kitaId &&
user.familyId === job.familyId &&
parentRoles.some((role) => role === user.role) &&
!!user.emailVerifiedAt &&
!!user.privacyPolicyAcceptedAt &&
!user.disabledAt &&
(!user.lockedUntil || user.lockedUntil <= now);
let progress: { selected: number; required: number } | undefined;
let dates: string[] = [];
if (job.kind === "REMINDER") {
const target = getTargetMonthData(now);
const plan = await prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: {
kitaId: job.kitaId,
year: job.year,
month: job.month,
},
},
select: { status: true },
});
eligible &&=
!target.isLocked &&
target.targetYear === job.year &&
target.targetMonth === job.month &&
plan?.status !== "PUBLISHED";
if (eligible) {
progress = await getFamilyNotdienstProgress(
prisma,
job.kitaId,
job.familyId,
job.year,
job.month,
kita.notdienstMinPerChildPerMonth,
);
eligible &&=
progress.required > 0 && progress.selected < progress.required;
}
} else if (job.kind === "PUBLICATION") {
eligible &&= !!job.planId && job.plan?.status === "PUBLISHED";
if (eligible) {
const [assignments, days] = await Promise.all([
prisma.notdienstAssignment.findMany({
where: {
kitaId: job.kitaId,
planId: job.planId!,
child: {
kitaId: job.kitaId,
familyId: job.familyId,
active: true,
},
date: {
...period(job.year, job.month),
},
},
select: { date: true },
orderBy: { date: "asc" },
}),
loadNotdienstDays(prisma, job.kitaId, job.year, job.month),
]);
const valid = new Set(days.map(formatUtcDateKey));
dates = assignments
.map((a) => formatUtcDateKey(a.date))
.filter((key) => key >= berlinDateKey(now) && valid.has(key));
eligible &&= dates.length > 0;
}
} else eligible = false;
if (!eligible) {
await finish({ skippedAt: now, failed: false, lastError: null });
result.skipped++;
return;
}
const base =
process.env.NEXT_PUBLIC_SITE_URL ||
process.env.AUTH_URL ||
process.env.NEXTAUTH_URL;
if (!base) throw new Error("App-Adresse für E-Mails fehlt.");
const site = new URL(base);
if (
!["http:", "https:"].includes(site.protocol) ||
site.username ||
site.password
)
throw new Error("App-Adresse ungültig.");
const label = monthLabel(job.year, job.month);
const link = new URL(
job.kind === "REMINDER"
? "/dashboard/notdienst"
: "/dashboard/kalender",
site.origin,
).toString();
const response = await send({
to: user.email,
idempotencyKey: `notdienst-email/${job.id}`,
subject:
job.kind === "REMINDER"
? `Erinnerung: Notdienst-Verfügbarkeiten für ${label}`
: `Notdienstplan ${label} veröffentlicht`,
react:
job.kind === "REMINDER"
? createElement(NotdienstAvailabilityReminderEmail, {
parentName: user.firstName,
kitaName: kita.name,
monthLabel: label,
link,
selectedDays: progress!.selected,
requiredDays: progress!.required,
deadlineLabel: new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(
new Date(
Date.UTC(job.year, job.month - 2, NOTDIENST_LOCK_DAY),
),
),
})
: createElement(NotdienstPlanPublishedEmail, {
parentName: user.firstName,
kitaName: kita.name,
monthLabel: label,
dateLabels: dates.map((key) =>
new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
weekday: "long",
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(new Date(`${key}T00:00:00Z`)),
),
link,
}),
});
if (!response.success) throw new Error(response.error);
await finish({ sentAt: now, failed: false, lastError: null });
result.sent++;
} catch {
const current = await prisma.notdienstEmail.findUnique({
where: { id },
select: { attempts: true },
});
const delay = Math.min(
6 * 60 * 60_000,
60_000 * 2 ** Math.min(current?.attempts ?? 1, 8),
);
await finish({
failed: true,
lastError:
"E-Mail-Versand fehlgeschlagen. Der Versand wird erneut versucht.",
nextAttemptAt: new Date(now.getTime() + delay),
});
result.failed++;
}
};
const unique = [...new Set(ids)];
for (let i = 0; i < unique.length; i += 4)
await Promise.all(unique.slice(i, i + 4).map(deliver));
return result;
}
type MailScope = {
kitaId?: string;
year?: number;
month?: number;
planId?: string;
};
export async function deliverDueNotdienstEmails(
scope: MailScope = {},
send = sendAppEmail,
now = new Date(),
) {
const jobs = await prisma.notdienstEmail.findMany({
where: {
...scope,
sentAt: null,
skippedAt: null,
nextAttemptAt: { lte: now },
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
},
select: { id: true },
orderBy: [{ nextAttemptAt: "asc" }, { createdAt: "asc" }],
take: 12,
});
return deliverNotdienstEmails(
jobs.map((job) => job.id),
send,
now,
);
}
export async function runNotdienstNotificationJob(
send = sendAppEmail,
now = new Date(),
kitaId?: string,
) {
const target = getTargetMonthData(now);
let queued = 0;
if (!target.isLocked && isNotdienstReminderDue(31, now)) {
const candidates = await prisma.kita.findMany({
where: {
...(kitaId ? { id: kitaId } : {}),
notdienstModuleEnabled: true,
notdienstPlans: { none: { year: target.targetYear, month: target.targetMonth, status: "PUBLISHED" } },
notdienstReminderDaysBefore: {
gte: Math.max(
1,
NOTDIENST_LOCK_DAY - Number(berlinDateKey(now).slice(-2)),
),
},
notdienstReminderRuns: {
none: { year: target.targetYear, month: target.targetMonth },
},
},
select: { id: true, notdienstReminderDaysBefore: true },
orderBy: { id: "asc" },
take: 100,
});
for (const kita of candidates)
if (isNotdienstReminderDue(kita.notdienstReminderDaysBefore, now))
queued += await queueNotdienstReminders(
kita.id,
target.targetYear,
target.targetMonth,
true,
now,
);
}
return {
queued,
...(await deliverDueNotdienstEmails(kitaId ? { kitaId } : {}, send, now)),
};
}
export async function getNotdienstEmailStatus(
kitaId: string,
year: number,
month: number,
) {
const jobs = await prisma.notdienstEmail.findMany({
where: { kitaId, year, month },
select: {
id: true,
kind: true,
sentAt: true,
skippedAt: true,
failed: true,
lastError: true,
user: { select: { firstName: true, lastName: true } },
family: { select: { name: true } },
},
orderBy: { createdAt: "desc" },
});
const status = (job: (typeof jobs)[number]) =>
job.sentAt
? ("sent" as const)
: job.skippedAt
? ("skipped" as const)
: job.failed
? ("failed" as const)
: ("pending" as const);
return {
sent: jobs.filter((job) => status(job) === "sent").length,
failed: jobs.filter((job) => status(job) === "failed").length,
pending: jobs.filter((job) => status(job) === "pending").length,
skipped: jobs.filter((job) => status(job) === "skipped").length,
entries: jobs.slice(0, 30).map((job) => ({
id: job.id,
kind: job.kind,
status: status(job),
name: `${job.user.firstName} ${job.user.lastName}`,
familyName: job.family.name,
error: job.lastError,
})),
};
}
export type NotdienstEmailStatus = Awaited<
ReturnType<typeof getNotdienstEmailStatus>
>;
+56
View File
@@ -0,0 +1,56 @@
import { Prisma } from "@prisma/client";
import { UserFacingError } from "./action-error";
import { getNotdienstDays } from "./notdienst-days";
import { todayKey } from "./staff/calculations";
export async function loadNotdienstDays(
db: Pick<Prisma.TransactionClient, "termin">,
kitaId: string,
year: number,
month: number,
) {
const closures = await db.termin.findMany({
where: {
kitaId,
status: "CONFIRMED",
type: { in: ["SCHLIESSTAG", "TEAMTAG"] },
// Include closures crossing the month boundary and Berlin midnight.
startDate: { lt: new Date(Date.UTC(year, month, 1)) },
endDate: { gte: new Date(Date.UTC(year, month - 1, 1) - 86400000) },
},
select: { startDate: true, endDate: true },
});
return getNotdienstDays(year, month, closures);
}
export async function assertChildrenRemovable(
tx: Prisma.TransactionClient,
kitaId: string,
familyId: string,
childIds: string[],
) {
if (childIds.length === 0) return;
// Coordinate with the FK locks taken by concurrent assignment writes.
await tx.$queryRaw`SELECT id FROM children WHERE "kitaId" = ${kitaId}
AND "familyId" = ${familyId} AND id IN (${Prisma.join(childIds)}) ORDER BY id FOR UPDATE`;
const obligation = await tx.notdienstAssignment.findFirst({
where: {
kitaId,
childId: { in: childIds },
child: { kitaId, familyId },
OR: [
{
date: { gte: new Date(`${todayKey()}T00:00:00Z`) },
plan: { kitaId, status: "PUBLISHED" },
},
{ alarms: { some: { kitaId, activeAssignmentId: { not: null } } } },
],
},
select: { id: true },
});
if (obligation) {
throw new UserFacingError(
"Das Kind kann nicht entfernt werden, solange veröffentlichte Notdienste oder aktive Alarme bestehen. Bitte zuerst die Einteilung mit der Koordination klären.",
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { UserRole } from "@prisma/client";
export function canRequestNotdienstSwap(
role: UserRole,
familyId: string | null | undefined,
) {
return (
!!familyId &&
(role === "ELTERN" || role === "ADMIN" || role === "KOORDINATOR")
);
}