feat: complete notdienst planning, quick wins and notifications
This commit is contained in:
@@ -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." };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user