feat: add household emergency duty alarms and deadline worker

This commit is contained in:
t.indorf
2026-10-09 11:02:24 +02:00
parent a96ba0fd84
commit 8b32e4a832
34 changed files with 3947 additions and 646 deletions
+203 -126
View File
@@ -51,11 +51,15 @@ const activeNotdienstAlertStatuses = [
function getTodayUtcDateOnly() {
const today = new Date();
return new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
return new Date(
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
);
}
function getBaseUrl() {
return process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
return (
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"
);
}
function formatNotdienstDate(date: Date) {
@@ -68,7 +72,7 @@ function revalidateCalendarAndNotdienst() {
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard");
} catch (e) {
} catch {
// Ignore if called outside Next.js context
}
}
@@ -94,6 +98,7 @@ type SwapAssignmentWithFamily = {
alerts: {
id: string;
}[];
alarms?: { id: string }[];
};
async function getSwapAssignment(
@@ -126,7 +131,11 @@ async function getSwapAssignment(
},
},
alerts: {
where: { status: { in: activeNotdienstAlertStatuses } },
where: { alarmId: null, status: { in: activeNotdienstAlertStatuses } },
select: { id: true },
},
alarms: {
where: { activeAssignmentId: { not: null } },
select: { id: true },
},
},
@@ -171,7 +180,12 @@ function validateSwapAssignmentPair({
return "Du kannst keinen Tausch mit deinem eigenen Haushalt anfragen.";
}
if (targetAssignment.alerts.length > 0 || offeredAssignment.alerts.length > 0) {
if (
targetAssignment.alerts.length > 0 ||
offeredAssignment.alerts.length > 0 ||
targetAssignment.alarms?.length ||
offeredAssignment.alarms?.length
) {
return "Notdienste mit aktiver Alarmierung können nicht getauscht werden.";
}
@@ -215,7 +229,10 @@ async function sendSwapMail({
});
if (!result.success) {
console.error("Notdienst-Tausch-Mail konnte nicht versendet werden:", result.error);
console.error(
"Notdienst-Tausch-Mail konnte nicht versendet werden:",
result.error,
);
}
}
@@ -264,7 +281,9 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
now.setSeconds(0, 0);
if (startDate < now) {
return { error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen." };
return {
error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen.",
};
}
if (endDate < startDate) {
@@ -286,9 +305,7 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
? new Date(data.rsvpDeadline)
: null,
participantInfo:
data.requiresRsvp && data.participantInfo
? data.participantInfo
: null,
data.requiresRsvp && data.participantInfo ? data.participantInfo : null,
},
};
}
@@ -347,7 +364,10 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
return { error: "Notdienst nicht gefunden." };
}
if (existingRequest) {
return { error: "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage." };
return {
error:
"Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.",
};
}
await prisma.notdienstSwapRequest.create({
@@ -394,128 +414,170 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
const today = getTodayUtcDateOnly();
try {
const result = await prisma.$transaction(async (tx) => {
const request = await tx.notdienstSwapRequest.findFirst({
where: {
id: parsed.data,
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
},
select: {
id: true,
requesterFamilyId: true,
targetAssignmentId: true,
offeredAssignmentId: true,
targetAssignment: {
select: {
id: true,
childId: true,
date: true,
plan: { select: { status: true } },
child: {
select: {
familyId: true,
family: {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
select: { email: true, firstName: true, lastName: true },
const result = await prisma.$transaction(
async (tx) => {
const request = await tx.notdienstSwapRequest.findFirst({
where: {
id: parsed.data,
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
},
select: {
id: true,
requesterFamilyId: true,
targetAssignmentId: true,
offeredAssignmentId: true,
targetAssignment: {
select: {
id: true,
childId: true,
date: true,
plan: { select: { status: true } },
child: {
select: {
familyId: true,
family: {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
},
},
},
},
alerts: {
where: { status: { in: activeNotdienstAlertStatuses } },
select: { id: true },
alerts: {
where: {
alarmId: null,
status: { in: activeNotdienstAlertStatuses },
},
select: { id: true },
},
},
},
},
offeredAssignment: {
select: {
id: true,
childId: true,
date: true,
plan: { select: { status: true } },
child: {
select: {
familyId: true,
family: {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
select: { email: true, firstName: true, lastName: true },
offeredAssignment: {
select: {
id: true,
childId: true,
date: true,
plan: { select: { status: true } },
child: {
select: {
familyId: true,
family: {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
},
},
},
},
alerts: {
where: { status: { in: activeNotdienstAlertStatuses } },
select: { id: true },
alerts: {
where: {
alarmId: null,
status: { in: activeNotdienstAlertStatuses },
},
select: { id: true },
},
},
},
},
},
});
});
if (!request) {
throw new Error("Tauschanfrage nicht gefunden oder nicht mehr offen.");
}
if (!request) {
throw new Error(
"Tauschanfrage nicht gefunden oder nicht mehr offen.",
);
}
if (request.targetAssignment.child.familyId !== session.user.familyId) {
throw new Error("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
}
if (
await tx.notdienstAlarm.count({
where: {
activeAssignmentId: {
in: [request.targetAssignmentId, request.offeredAssignmentId],
},
},
})
)
throw new Error(
"Notdienste mit aktiver Alarmierung können nicht getauscht werden.",
);
const validationError = validateSwapAssignmentPair({
targetAssignment: request.targetAssignment,
offeredAssignment: request.offeredAssignment,
requesterFamilyId: request.requesterFamilyId,
today,
});
if (validationError) {
throw new Error(validationError);
}
if (request.targetAssignment.child.familyId !== session.user.familyId) {
throw new Error(
"Nur der angefragte Haushalt kann diese Anfrage annehmen.",
);
}
await tx.notdienstAssignment.update({
where: { id: request.targetAssignmentId },
data: { childId: request.offeredAssignment.childId },
});
await tx.notdienstAssignment.update({
where: { id: request.offeredAssignmentId },
data: { childId: request.targetAssignment.childId },
});
await tx.notdienstSwapRequest.update({
where: { id: request.id },
data: {
status: NotdienstSwapStatus.ACCEPTED,
respondedById: session.user.id,
respondedAt: new Date(),
},
});
await tx.notdienstSwapRequest.updateMany({
where: {
id: { not: request.id },
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
OR: [
{ targetAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
{ offeredAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
],
},
data: {
status: NotdienstSwapStatus.CANCELLED,
respondedAt: new Date(),
},
});
const validationError = validateSwapAssignmentPair({
targetAssignment: request.targetAssignment,
offeredAssignment: request.offeredAssignment,
requesterFamilyId: request.requesterFamilyId,
today,
});
if (validationError) {
throw new Error(validationError);
}
return {
targetAssignment: request.targetAssignment,
offeredAssignment: request.offeredAssignment,
};
});
await tx.notdienstAssignment.update({
where: { id: request.targetAssignmentId },
data: { childId: request.offeredAssignment.childId },
});
await tx.notdienstAssignment.update({
where: { id: request.offeredAssignmentId },
data: { childId: request.targetAssignment.childId },
});
await tx.notdienstSwapRequest.update({
where: { id: request.id },
data: {
status: NotdienstSwapStatus.ACCEPTED,
respondedById: session.user.id,
respondedAt: new Date(),
},
});
await tx.notdienstSwapRequest.updateMany({
where: {
id: { not: request.id },
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
OR: [
{
targetAssignmentId: {
in: [request.targetAssignmentId, request.offeredAssignmentId],
},
},
{
offeredAssignmentId: {
in: [request.targetAssignmentId, request.offeredAssignmentId],
},
},
],
},
data: {
status: NotdienstSwapStatus.CANCELLED,
respondedAt: new Date(),
},
});
return {
targetAssignment: request.targetAssignment,
offeredAssignment: request.offeredAssignment,
};
},
{ isolationLevel: "Serializable" },
);
const kita = await prisma.kita.findUnique({
where: { id: session.user.kitaId },
@@ -550,7 +612,10 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
return { success: true };
} catch (error) {
return {
error: error instanceof Error ? error.message : "Tauschanfrage konnte nicht angenommen werden.",
error:
error instanceof Error
? error.message
: "Tauschanfrage konnte nicht angenommen werden.",
};
}
}
@@ -619,7 +684,9 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
}
if (request.targetAssignment.child.familyId !== session.user.familyId) {
return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." };
return {
error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen.",
};
}
await prisma.notdienstSwapRequest.update({
@@ -706,19 +773,22 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
revalidateCalendarAndNotdienst();
return { success: true };
} catch (error) {
console.error("Fehler beim Zurückziehen der Notdienst-Tauschanfrage:", error);
console.error(
"Fehler beim Zurückziehen der Notdienst-Tauschanfrage:",
error,
);
return { error: "Tauschanfrage konnte nicht zurückgezogen werden." };
}
}
export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const data = parsed.data;
const terminData = buildTerminData(data);
@@ -755,12 +825,12 @@ export async function createTerminAdmin(rawPayload: unknown) {
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const data = parsed.data;
const terminData = buildTerminData(data);
@@ -885,7 +955,10 @@ export async function rejectTermin(terminId: string, reason?: string) {
}
}
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
export async function toggleMitbringselList(
terminId: string,
enabled: boolean,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
@@ -919,12 +992,12 @@ const mitbringselSchema = z.object({
export async function addMitbringsel(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = mitbringselSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const { terminId, content } = parsed.data;
try {
@@ -933,7 +1006,11 @@ export async function addMitbringsel(rawPayload: unknown) {
where: { id: terminId, kitaId: session.user.kitaId },
});
if (!termin || termin.status !== TerminStatus.CONFIRMED || !termin.mitbringselListEnabled) {
if (
!termin ||
termin.status !== TerminStatus.CONFIRMED ||
!termin.mitbringselListEnabled
) {
return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." };
}