Merge current features with security hardening and automatic safe initialization

This commit is contained in:
t.indorf
2026-10-09 23:29:19 +02:00
85 changed files with 13522 additions and 1326 deletions
+125 -37
View File
@@ -8,6 +8,7 @@ import {
NotdienstAlertStatus,
NotdienstPlanStatus,
NotdienstSwapStatus,
RsvpMode,
TerminStatus,
TerminType,
UserRole,
@@ -22,6 +23,7 @@ import { sendAppEmail } from "@/lib/mail";
import { serializableTransaction } from "@/lib/transaction";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
import { lockEvent } from "@/lib/event-lock";
// =====================================================================
// /dashboard/kalender · Server Actions
@@ -34,8 +36,9 @@ const terminSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
allDay: z.boolean().default(false),
mitbringselListEnabled: z.boolean().default(false),
requiresRsvp: z.boolean().default(false),
mitbringselListEnabled: z.boolean().optional(),
requiresRsvp: z.boolean().optional(),
rsvpMode: z.nativeEnum(RsvpMode).optional(),
rsvpDeadline: z.string().datetime().nullable().optional(),
participantInfo: z.string().trim().max(1000).optional(),
});
@@ -55,11 +58,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) {
@@ -72,7 +79,7 @@ function revalidateCalendarAndNotdienst() {
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard");
} catch (e) {
} catch {
// Ignore if called outside Next.js context
}
}
@@ -98,6 +105,7 @@ type SwapAssignmentWithFamily = {
alerts: {
id: string;
}[];
alarms?: { id: string }[];
};
async function getSwapAssignment(
@@ -131,7 +139,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 },
},
},
@@ -176,7 +188,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.";
}
@@ -220,7 +237,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,
);
}
}
@@ -262,20 +282,35 @@ async function notifyFamilyAboutSwap({
);
}
function buildTerminData(data: z.infer<typeof terminSchema>) {
function buildTerminData(
data: z.infer<typeof terminSchema>,
allowPast = false,
) {
const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate);
const now = new Date();
now.setSeconds(0, 0);
if (startDate < now) {
return { error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen." };
if (!allowPast && startDate < now) {
return {
error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen.",
};
}
if (endDate < startDate) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
}
if (
data.requiresRsvp &&
data.rsvpDeadline &&
new Date(data.rsvpDeadline) > startDate
) {
return {
error: "Die Rückmeldefrist darf nicht nach dem Terminbeginn liegen.",
};
}
return {
data: {
title: data.title,
@@ -284,16 +319,15 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
startDate,
endDate,
allDay: data.allDay,
mitbringselListEnabled: data.mitbringselListEnabled,
requiresRsvp: data.requiresRsvp,
mitbringselListEnabled: data.mitbringselListEnabled ?? false,
requiresRsvp: data.requiresRsvp ?? false,
rsvpMode: data.rsvpMode ?? RsvpMode.CHILD,
rsvpDeadline:
data.requiresRsvp && data.rsvpDeadline
? new Date(data.rsvpDeadline)
: null,
participantInfo:
data.requiresRsvp && data.participantInfo
? data.participantInfo
: null,
data.requiresRsvp && data.participantInfo ? data.participantInfo : null,
},
};
}
@@ -357,6 +391,8 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
throw new UserFacingError("Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.");
}
if (await tx.notdienstAlarm.count({ where: { kitaId: session.user.kitaId, activeAssignmentId: { in: [targetAssignment.id, offeredAssignment.id] } } })) throw new UserFacingError("Notdienste mit aktiver Alarmierung können nicht getauscht werden.");
await tx.notdienstSwapRequest.create({
data: {
kitaId: session.user.kitaId,
@@ -388,7 +424,7 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
if (!(error instanceof Error && error.message === "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.")) {
console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
}
return { error: "Tauschanfrage konnte nicht erstellt werden." };
return { error: publicActionError(error, "Tauschanfrage konnte nicht erstellt werden.") };
}
}
@@ -491,6 +527,8 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
throw new UserFacingError(validationError);
}
if (await tx.notdienstAlarm.count({ where: { kitaId: session.user.kitaId, activeAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } } })) throw new UserFacingError("Notdienste mit aktiver Alarmierung können nicht getauscht werden.");
await tx.notdienstAssignment.update({
where: { id: request.targetAssignmentId },
data: { childId: request.offeredAssignment.childId },
@@ -632,7 +670,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.",
};
}
const responded = await prisma.notdienstSwapRequest.updateMany({
@@ -721,7 +761,10 @@ 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." };
}
}
@@ -734,7 +777,7 @@ export async function createTerminRequest(rawPayload: unknown) {
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const data = parsed.data;
const terminData = buildTerminData(data);
@@ -771,12 +814,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);
@@ -798,6 +841,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen des Termins:", error);
@@ -805,7 +849,10 @@ export async function createTerminAdmin(rawPayload: unknown) {
}
}
export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
export async function updateTerminAdmin(
terminId: string,
rawPayload: unknown,
): Promise<{ error?: string; success?: boolean }> {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
@@ -818,19 +865,50 @@ export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
return { error: "Ungültige Eingabedaten." };
}
const terminData = buildTerminData(parsed.data);
if ("error" in terminData) {
return { error: terminData.error };
}
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId,
},
data: terminData.data,
const result = await prisma.$transaction(async (tx) => {
await lockEvent(tx, terminId, kitaId);
const existing = await tx.termin.findFirst({
where: { id: terminId, kitaId },
include: {
_count: {
select: { participations: true, familyParticipations: true },
},
},
});
if (!existing) return { error: "Termin wurde nicht gefunden." };
const data = {
...parsed.data,
mitbringselListEnabled:
parsed.data.mitbringselListEnabled ?? existing.mitbringselListEnabled,
requiresRsvp: parsed.data.requiresRsvp ?? existing.requiresRsvp,
rsvpMode: parsed.data.rsvpMode ?? existing.rsvpMode,
rsvpDeadline:
parsed.data.rsvpDeadline === undefined
? (existing.rsvpDeadline?.toISOString() ?? null)
: parsed.data.rsvpDeadline,
participantInfo:
parsed.data.participantInfo ?? existing.participantInfo ?? "",
};
if (
data.rsvpMode !== existing.rsvpMode &&
existing._count.participations + existing._count.familyParticipations >
0
) {
return {
error:
"Die Antwortart kann nach der ersten Rückmeldung nicht mehr geändert werden.",
};
}
const terminData = buildTerminData(data, true);
if ("error" in terminData) return { error: terminData.error };
await tx.termin.update({
where: { id: terminId, kitaId },
data: terminData.data,
});
return { success: true };
});
if (result.error) return result;
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
@@ -864,6 +942,7 @@ export async function approveTermin(terminId: string) {
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Bestätigen des Termins:", error);
@@ -894,6 +973,7 @@ export async function rejectTermin(terminId: string, reason?: string) {
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Ablehnen des Termins:", error);
@@ -901,7 +981,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) {
@@ -921,6 +1004,7 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
@@ -941,7 +1025,7 @@ export async function addMitbringsel(rawPayload: unknown) {
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const { terminId, content } = parsed.data;
try {
@@ -950,7 +1034,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." };
}