593 lines
18 KiB
TypeScript
593 lines
18 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, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
|
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
|
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
|
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
|
import { requireAlarmActor } from "@/lib/alarms/auth";
|
|
import {
|
|
validateAlarmActor,
|
|
assertAssignmentNotAlarmed,
|
|
} from "@/lib/alarms/service";
|
|
|
|
// =====================================================================
|
|
// /dashboard/notdienst/plan · Server Actions
|
|
// =====================================================================
|
|
|
|
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." };
|
|
|
|
// 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,
|
|
},
|
|
});
|
|
|
|
if (assignmentsToCreate.length > 0) {
|
|
await tx.notdienstAssignment.createMany({
|
|
data: assignmentsToCreate.map((a) => ({
|
|
kitaId,
|
|
planId: plan.id,
|
|
childId: a.childId,
|
|
date: a.date,
|
|
})),
|
|
});
|
|
}
|
|
});
|
|
|
|
revalidatePath("/dashboard/notdienst/plan");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error(error);
|
|
return { 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!;
|
|
|
|
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 };
|
|
}
|
|
|
|
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 (isPublished) {
|
|
const today = new Date();
|
|
const todayUtc = new Date(
|
|
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
|
|
);
|
|
if (parsedDate < todayUtc) {
|
|
throw new UserFacingError(
|
|
"Vergangene Notdienste können nicht geändert werden.",
|
|
);
|
|
}
|
|
}
|
|
|
|
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
|
where: { planId, date: parsedDate },
|
|
include: {
|
|
child: {
|
|
include: {
|
|
family: {
|
|
include: {
|
|
users: {
|
|
where: { role: UserRole.ELTERN, 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: UserRole.ELTERN, 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 (!kitaId) {
|
|
return { error: "Kein Mandant zugeordnet." };
|
|
}
|
|
|
|
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 };
|
|
} catch (error) {
|
|
console.error("Fehler beim Versenden der Erinnerungen:", error);
|
|
return { error: "Fehler beim Versenden der Erinnerungen." };
|
|
}
|
|
}
|
|
|
|
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."),
|
|
};
|
|
}
|
|
}
|