Files

610 lines
19 KiB
TypeScript

"use server";
import { UserFacingError, publicActionError } from "@/lib/action-error";
import { dateOnlySchema, validPlanMonth } from "@/lib/date-schema";
import { rateLimit } from "@/lib/rate-limit";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
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 {
validateAlarmActor,
assertAssignmentNotAlarmed,
} from "@/lib/alarms/service";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
// =====================================================================
export async function generatePlanAction(year?: number, month?: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
let targetYear: number;
let targetMonth: number;
if (year !== undefined && month !== undefined) {
targetYear = year;
targetMonth = month;
} else {
const data = getTargetMonthData();
targetYear = data.targetYear;
targetMonth = data.targetMonth;
}
if (!validPlanMonth(targetYear, targetMonth))
return { error: "Ungültiger Planungsmonat." };
try {
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.",
);
const [availabilities, workingDays] = await Promise.all([
tx.notdienstAvailability.findMany({
where: {
kitaId,
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 })),
});
}
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error(error);
return {
error: publicActionError(error, "Fehler bei der Generierung des Plans."),
};
}
}
export async function publishPlanAction(planId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
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(
planId: string,
date: string,
newChildId: string | null,
) {
const actor = await requireAlarmActor();
const kitaId = actor.kitaId;
if (!dateOnlySchema.safeParse(date).success)
return { error: "Ungültiges Datum." };
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) => {
await validateAlarmActor(tx, actor);
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
if (!plan) {
throw new UserFacingError("Plan nicht gefunden.");
}
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
if (!isDraft && !isPublished) {
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 todayUtc = new Date(`${todayKey()}T00:00:00Z`);
if (parsedDate < todayUtc) {
throw new UserFacingError(
"Vergangene Notdienste können nicht geändert werden.",
);
}
}
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { kitaId, planId, date: parsedDate },
include: {
child: {
include: {
family: {
include: {
users: {
where: {
role: {
in: [
UserRole.ELTERN,
UserRole.ADMIN,
UserRole.KOORDINATOR,
],
},
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
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: {
users: {
where: {
role: {
in: [
UserRole.ELTERN,
UserRole.ADMIN,
UserRole.KOORDINATOR,
],
},
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
select: { email: true, firstName: true, lastName: true },
},
},
},
},
});
if (!newChild) {
throw new UserFacingError("Kind gehört nicht zu dieser Kita.");
}
}
if (existingAssignment?.childId === newChildId) {
return [];
}
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,
},
});
}
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 (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) {
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (kita) {
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) {
for (let i = 0; i < notification.parentEmails.length; i++) {
const email = notification.parentEmails[i];
const name = notification.parentNames[i];
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 {
error: publicActionError(error, "Update fehlgeschlagen."),
};
}
}
export async function updateKitaNotdienstSettingsAction(
minPerChild: number,
reminderDays: number,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
if (
!Number.isInteger(minPerChild) ||
minPerChild < 1 ||
minPerChild > 31 ||
!Number.isInteger(reminderDays) ||
reminderDays < 0 ||
reminderDays > 31
)
return { error: "Ungültige Einstellungen." };
try {
await prisma.kita.update({
where: { id: kitaId },
data: {
notdienstMinPerChildPerMonth: minPerChild,
notdienstReminderDaysBefore: reminderDays,
},
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler beim Speichern der Einstellungen." };
}
}
export async function sendNotdienstRemindersAction(
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-reminders:${kitaId}`, 3, 3600)).allowed)
return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
try {
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) {
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.",
};
}
}
export async function resetPlanAction(planId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.$transaction(async (tx) => {
// Ensure the plan belongs to this kita and is draft
const plan = await tx.notdienstPlan.findFirst({
where: { id: planId, kitaId },
});
if (!plan) {
throw new UserFacingError("Plan nicht gefunden.");
}
if (plan.status !== NotdienstPlanStatus.DRAFT) {
throw new UserFacingError(
"Nur Pläne im Entwurfsstatus können zurückgesetzt werden.",
);
}
// Delete all assignments
await tx.notdienstAssignment.deleteMany({
where: { planId },
});
// Delete the plan itself
await tx.notdienstPlan.delete({
where: { id: planId },
});
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error("Fehler beim Zurücksetzen des Plans:", error);
return {
error: publicActionError(error, "Fehler beim Zurücksetzen des Plans."),
};
}
}