"use server"; 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; } // 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 = {}; 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; 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 Error("Plan nicht gefunden."); } const isDraft = plan.status === NotdienstPlanStatus.DRAFT; const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED; if (!isDraft && !isPublished) { throw new Error("Plan kann nicht bearbeitet werden."); } if (isPublished) { const today = new Date(); const todayUtc = new Date( Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()), ); if (parsedDate < todayUtc) { throw new Error( "Vergangene Notdienste können nicht geändert werden.", ); } } const existingAssignment = await tx.notdienstAssignment.findFirst({ where: { planId, date: parsedDate }, include: { child: { include: { family: { include: { users: { where: { role: UserRole.ELTERN }, select: { email: true, firstName: true, lastName: true }, }, }, }, }, }, }, }); let newChild: { id: string; family: { users: { email: string; firstName: string; lastName: string }[]; }; } | null = null; if (newChildId) { newChild = await tx.child.findFirst({ where: { id: newChildId, kitaId, active: true, }, include: { family: { include: { users: { where: { role: UserRole.ELTERN }, select: { email: true, firstName: true, lastName: true }, }, }, }, }, }); if (!newChild) { throw new Error("Kind gehört nicht zu dieser Kita."); } } if (existingAssignment?.childId === newChildId) { return []; } if (existingAssignment) 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: error instanceof Error ? error.message : "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." }; } 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." }; } 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 }, }, }, }); // 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 Error("Plan nicht gefunden."); } if (plan.status !== NotdienstPlanStatus.DRAFT) { throw new Error( "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: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans.", }; } }