feat: add household emergency duty alarms and deadline worker
This commit is contained in:
@@ -12,6 +12,11 @@ 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
|
||||
@@ -153,41 +158,75 @@ export async function updateAssignmentAction(
|
||||
date: string,
|
||||
newChildId: string | null,
|
||||
) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
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) => {
|
||||
const plan = await tx.notdienstPlan.findUnique({
|
||||
where: { id: planId, kitaId },
|
||||
});
|
||||
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.");
|
||||
if (!plan) {
|
||||
throw new Error("Plan nicht gefunden.");
|
||||
}
|
||||
}
|
||||
|
||||
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
||||
where: { planId, date: parsedDate },
|
||||
include: {
|
||||
child: {
|
||||
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: {
|
||||
@@ -198,133 +237,118 @@ export async function updateAssignmentAction(
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
let newChild:
|
||||
| {
|
||||
id: string;
|
||||
family: {
|
||||
users: { email: string; firstName: string; lastName: string }[];
|
||||
};
|
||||
if (!newChild) {
|
||||
throw new Error("Kind gehört nicht zu dieser Kita.");
|
||||
}
|
||||
| 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?.childId === newChildId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!newChildId) {
|
||||
if (existingAssignment) {
|
||||
await tx.notdienstAssignment.delete({
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
} 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 (existingAssignment) {
|
||||
await tx.notdienstSwapRequest.updateMany({
|
||||
where: {
|
||||
kitaId,
|
||||
status: "PENDING",
|
||||
OR: [
|
||||
{ targetAssignmentId: existingAssignment.id },
|
||||
{ offeredAssignmentId: existingAssignment.id },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
if (!isPublished) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
});
|
||||
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) {
|
||||
@@ -334,7 +358,10 @@ export async function updateAssignmentAction(
|
||||
});
|
||||
|
||||
if (kita) {
|
||||
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
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) {
|
||||
@@ -396,7 +423,10 @@ 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) {
|
||||
@@ -452,7 +482,9 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
||||
},
|
||||
});
|
||||
|
||||
const respondedFamilyIds = new Set(respondedFamilies.map((a) => a.child.familyId));
|
||||
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));
|
||||
@@ -464,7 +496,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
||||
// 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 BASE_URL =
|
||||
process.env.NEXTAUTH_URL ??
|
||||
process.env.AUTH_URL ??
|
||||
"http://localhost:3000";
|
||||
const link = `${BASE_URL}/dashboard/notdienst`;
|
||||
|
||||
let sentCount = 0;
|
||||
@@ -486,7 +521,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
||||
if (result.success) {
|
||||
sentCount++;
|
||||
} else {
|
||||
console.error(`E-Mail konnte nicht an ${parent.email} gesendet werden:`, result.error);
|
||||
console.error(
|
||||
`E-Mail konnte nicht an ${parent.email} gesendet werden:`,
|
||||
result.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -517,7 +555,9 @@ export async function resetPlanAction(planId: string) {
|
||||
}
|
||||
|
||||
if (plan.status !== NotdienstPlanStatus.DRAFT) {
|
||||
throw new Error("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
|
||||
throw new Error(
|
||||
"Nur Pläne im Entwurfsstatus können zurückgesetzt werden.",
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all assignments
|
||||
@@ -535,6 +575,11 @@ export async function resetPlanAction(planId: string) {
|
||||
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." };
|
||||
return {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Fehler beim Zurücksetzen des Plans.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user