feat: implement emergency duty (Notdienst) swapping for parents

This commit is contained in:
t.indorf
2026-05-23 19:18:30 +02:00
parent 4cd16773c1
commit 3ce5638449
9 changed files with 1638 additions and 119 deletions
+130 -55
View File
@@ -11,7 +11,7 @@ import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
import { NotdienstAssignmentEmail } from "@/emails/NotdienstAssignmentEmail";
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
@@ -160,14 +160,7 @@ export async function updateAssignmentAction(
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
try {
const { shouldSendEmails, assignedChildInfo } = await prisma.$transaction(async (tx) => {
let txShouldSendEmails = false;
let txAssignedChildInfo: {
parentEmails: string[];
parentNames: string[];
dateLabel: string;
} | null = null;
const notifications = await prisma.$transaction(async (tx) => {
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
@@ -183,27 +176,43 @@ export async function updateAssignmentAction(
throw new Error("Plan kann nicht bearbeitet werden.");
}
// Wenn der Plan bereits veröffentlicht ist, dürfen nur unbesetzte Termine geändert werden.
// Ein Termin gilt als "unbesetzt", wenn am Veröffentlichungstag kein Assignment existierte
// bzw. das Assignment erst nach dem Veröffentlichungs-Zeitpunkt erstellt wurde.
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
});
if (isPublished && existingAssignment) {
if (!plan.publishedAt || existingAssignment.createdAt <= plan.publishedAt) {
throw new Error("In einem bereits veröffentlichten Plan können nur unbesetzte Termine geändert 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.");
}
}
// Altes Assignment für diesen Tag löschen
await tx.notdienstAssignment.deleteMany({
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 },
},
},
},
},
},
},
});
// Neues anlegen, falls ausgewählt
let newChild:
| {
id: string;
family: {
users: { email: string; firstName: string; lastName: string }[];
};
}
| null = null;
if (newChildId) {
const child = await tx.child.findFirst({
newChild = await tx.child.findFirst({
where: {
id: newChildId,
kitaId,
@@ -221,10 +230,27 @@ export async function updateAssignmentAction(
},
});
if (!child) {
if (!newChild) {
throw new Error("Kind gehört nicht zu dieser Kita.");
}
}
if (existingAssignment?.childId === newChildId) {
return [];
}
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,
@@ -233,30 +259,75 @@ export async function updateAssignmentAction(
date: parsedDate,
},
});
}
// Wenn der Plan bereits veröffentlicht ist, bereiten wir E-Mail-Benachrichtigungen vor
if (isPublished) {
const parents = child.family.users.filter((u) => !!u.email);
if (parents.length > 0) {
const localFormatDate = new Date(yr, mo - 1, dy);
txAssignedChildInfo = {
parentEmails: parents.map((p) => p.email!),
parentNames: parents.map((p) => `${p.firstName} ${p.lastName}`),
dateLabel: format(localFormatDate, "EEEE, dd. MMMM yyyy", { locale: de }),
};
txShouldSendEmails = true;
}
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,
});
}
}
return {
shouldSendEmails: txShouldSendEmails,
assignedChildInfo: txAssignedChildInfo,
};
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;
});
// E-Mails außerhalb der DB-Transaktion senden
if (shouldSendEmails && assignedChildInfo) {
if (notifications.length > 0) {
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
@@ -266,25 +337,30 @@ export async function updateAssignmentAction(
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
for (let i = 0; i < assignedChildInfo.parentEmails.length; i++) {
const email = assignedChildInfo.parentEmails[i];
const name = assignedChildInfo.parentNames[i];
for (const notification of notifications) {
for (let i = 0; i < notification.parentEmails.length; i++) {
const email = notification.parentEmails[i];
const name = notification.parentNames[i];
await sendAppEmail({
to: email,
subject: `Notdienst-Einteilung am ${assignedChildInfo.dateLabel}`,
react: createElement(NotdienstAssignmentEmail, {
parentName: name,
kitaName: kita.name,
dateLabel: assignedChildInfo.dateLabel,
link,
}),
});
await sendAppEmail({
to: email,
subject: notification.subject,
react: createElement(NotdienstAssignmentChangeEmail, {
parentName: name,
heading: notification.heading,
preview: notification.preview,
body: notification.body,
dateLabel: notification.dateLabel,
link,
}),
});
}
}
}
}
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
return {
@@ -462,4 +538,3 @@ export async function resetPlanAction(planId: string) {
return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." };
}
}