Merge current features with security hardening and automatic safe initialization
This commit is contained in:
@@ -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." };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user