feat: add parent announcements and family or child event responses

This commit is contained in:
t.indorf
2026-10-09 21:44:19 +02:00
parent 2d3b82eb80
commit 979f795341
29 changed files with 2031 additions and 472 deletions
+73 -18
View File
@@ -6,6 +6,7 @@ import {
NotdienstAlertStatus,
NotdienstPlanStatus,
NotdienstSwapStatus,
RsvpMode,
TerminStatus,
TerminType,
UserRole,
@@ -18,6 +19,7 @@ import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
import { lockEvent } from "@/lib/event-lock";
// =====================================================================
// /dashboard/kalender · Server Actions
@@ -30,8 +32,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(),
});
@@ -274,13 +277,16 @@ 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) {
if (!allowPast && startDate < now) {
return {
error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen.",
};
@@ -290,6 +296,16 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
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,
@@ -298,8 +314,9 @@ 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)
@@ -852,6 +869,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);
@@ -859,7 +877,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;
@@ -872,19 +893,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");
@@ -918,6 +970,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);
@@ -948,6 +1001,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);
@@ -978,6 +1032,7 @@ export async function toggleMitbringselList(
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);