"use server"; import { UserFacingError, publicActionError } from "@/lib/action-error"; import { createElement } from "react"; import { revalidatePath } from "next/cache"; import { Prisma, NotdienstAlertStatus, NotdienstPlanStatus, NotdienstSwapStatus, RsvpMode, TerminStatus, TerminType, UserRole, } from "@prisma/client"; import { format } from "date-fns"; import { de } from "date-fns/locale"; import { z } from "zod"; import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail"; import { requireKitaSession, requireRole } from "@/lib/auth-utils"; 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"; import { canRequestNotdienstSwap } from "@/lib/notdienst-swaps"; // ===================================================================== // /dashboard/kalender · Server Actions // ===================================================================== const terminSchema = z.object({ title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein").max(160), description: z.string().trim().max(20000).optional(), type: z.nativeEnum(TerminType), startDate: z.string().datetime(), endDate: z.string().datetime(), allDay: 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(), }); const swapRequestSchema = z.object({ targetAssignmentId: z.string().min(1), offeredAssignmentId: z.string().min(1), message: z.string().trim().max(500).optional(), }); const swapIdSchema = z.string().min(1); const activeNotdienstAlertStatuses = [ NotdienstAlertStatus.PENDING, NotdienstAlertStatus.CONFIRMED, ]; function getTodayUtcDateOnly() { const today = new Date(); 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" ); } function formatNotdienstDate(date: Date) { return format(date, "EEEE, dd. MMMM yyyy", { locale: de }); } function revalidateCalendarAndNotdienst() { try { revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/notdienst/plan"); revalidatePath("/dashboard/notdienst"); revalidatePath("/dashboard"); } catch { // Ignore if called outside Next.js context } } type SwapAssignmentWithFamily = { id: string; childId: string; date: Date; plan: { status: NotdienstPlanStatus; }; child: { familyId: string; family: { name: string; users: { email: string; firstName: string; lastName: string; }[]; }; }; alerts: { id: string; }[]; alarms?: { id: string }[]; }; async function getSwapAssignment( assignmentId: string, kitaId: string, db: Prisma.TransactionClient = prisma, ): Promise { return db.notdienstAssignment.findFirst({ where: { id: assignmentId, kitaId }, select: { id: true, childId: true, date: true, plan: { select: { status: true } }, child: { select: { familyId: true, family: { select: { name: true, users: { where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true, firstName: true, lastName: true, }, }, }, }, }, }, alerts: { where: { alarmId: null, status: { in: activeNotdienstAlertStatuses } }, select: { id: true }, }, alarms: { where: { activeAssignmentId: { not: null } }, select: { id: true }, }, }, }); } function validateSwapAssignmentPair({ targetAssignment, offeredAssignment, requesterFamilyId, today, }: { targetAssignment: SwapAssignmentWithFamily | null; offeredAssignment: SwapAssignmentWithFamily | null; requesterFamilyId: string; today: Date; }) { if (!targetAssignment || !offeredAssignment) { return "Notdienst nicht gefunden."; } if (targetAssignment.id === offeredAssignment.id) { return "Bitte wähle zwei unterschiedliche Notdienste aus."; } if ( targetAssignment.plan.status !== NotdienstPlanStatus.PUBLISHED || offeredAssignment.plan.status !== NotdienstPlanStatus.PUBLISHED ) { return "Es können nur veröffentlichte Notdienste getauscht werden."; } if (targetAssignment.date < today || offeredAssignment.date < today) { return "Vergangene Notdienste können nicht getauscht werden."; } if (offeredAssignment.child.familyId !== requesterFamilyId) { return "Du kannst nur eigene Notdienste als Tausch anbieten."; } if (targetAssignment.child.familyId === requesterFamilyId) { return "Du kannst keinen Tausch mit deinem eigenen Haushalt anfragen."; } 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."; } return null; } async function sendSwapMail({ to, parentName, subject, heading, preview, kitaName, body, targetDate, offeredDate, }: { to: string; parentName: string; subject: string; heading: string; preview: string; kitaName: string; body: string; targetDate: Date; offeredDate: Date; }) { const result = await sendAppEmail({ to, subject, react: createElement(NotdienstSwapEmail, { parentName, heading, preview, kitaName, body, targetDateLabel: formatNotdienstDate(targetDate), offeredDateLabel: formatNotdienstDate(offeredDate), link: `${getBaseUrl()}/dashboard/kalender`, }), }); if (!result.success) { console.error( "Notdienst-Tausch-Mail konnte nicht versendet werden:", result.error, ); } } async function notifyFamilyAboutSwap({ family, subject, heading, preview, kitaName, body, targetDate, offeredDate, }: { family: SwapAssignmentWithFamily["child"]["family"]; subject: string; heading: string; preview: string; kitaName: string; body: string; targetDate: Date; offeredDate: Date; }) { await Promise.all( family.users .filter((parent) => !!parent.email) .map((parent) => sendSwapMail({ to: parent.email, parentName: `${parent.firstName} ${parent.lastName}`, subject, heading, preview, kitaName, body, targetDate, offeredDate, }), ), ); } function buildTerminData( data: z.infer, allowPast = false, ) { const startDate = new Date(data.startDate); const endDate = new Date(data.endDate); const now = new Date(); now.setSeconds(0, 0); 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, description: data.description || null, type: data.type, startDate, endDate, allDay: data.allDay, 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, }, }; } export async function createNotdienstSwapRequest(rawPayload: unknown) { const session = await requireKitaSession(); if (!(await rateLimit(`createNotdienstSwapRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." }; if (!canRequestNotdienstSwap(session.user.role, session.user.familyId)) { return { error: "Nur Eltern mit einem eigenen Haushalt können Notdienst-Tausche anfragen." }; } const parsed = swapRequestSchema.safeParse(rawPayload); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } const { targetAssignmentId, offeredAssignmentId, message } = parsed.data; const today = getTodayUtcDateOnly(); try { const { targetAssignment, offeredAssignment, kita } = await serializableTransaction(async tx => { const [targetAssignment, offeredAssignment, kita, existingRequest] = await Promise.all([ getSwapAssignment(targetAssignmentId, session.user.kitaId, tx), getSwapAssignment(offeredAssignmentId, session.user.kitaId, tx), tx.kita.findUnique({ where: { id: session.user.kitaId }, select: { name: true }, }), tx.notdienstSwapRequest.findFirst({ where: { kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING, OR: [ { targetAssignmentId }, { offeredAssignmentId }, { targetAssignmentId: offeredAssignmentId }, { offeredAssignmentId: targetAssignmentId }, ], }, select: { id: true }, }), ]); const validationError = validateSwapAssignmentPair({ targetAssignment, offeredAssignment, requesterFamilyId: session.user.familyId!, today, }); if (validationError) { throw new UserFacingError(validationError); } if (!targetAssignment || !offeredAssignment || !kita) { throw new UserFacingError("Notdienst nicht gefunden."); } if (existingRequest) { 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, requesterUserId: session.user.id, requesterFamilyId: session.user.familyId!, targetAssignmentId, offeredAssignmentId, message: message || null, }, }); return { targetAssignment, offeredAssignment, kita }; }); await notifyFamilyAboutSwap({ family: targetAssignment.child.family, subject: `Notdienst-Tauschanfrage für ${formatNotdienstDate(targetAssignment.date)}`, heading: "Neue Tauschanfrage", preview: "Du hast eine neue Notdienst-Tauschanfrage.", kitaName: kita.name, body: `Ein Elternteil aus einem anderen Haushalt möchte einen Notdienst mit dir tauschen.${message ? ` Nachricht: ${message}` : ""}`, targetDate: targetAssignment.date, offeredDate: offeredAssignment.date, }); revalidateCalendarAndNotdienst(); return { success: true }; } catch (error) { 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: publicActionError(error, "Tauschanfrage konnte nicht erstellt werden.") }; } } export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) { const session = await requireKitaSession(); if (!session.user.familyId) { return { error: "Dein Account ist noch keinem Haushalt zugeordnet." }; } const parsed = swapIdSchema.safeParse(requestIdRaw); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } const today = getTodayUtcDateOnly(); try { const result = await serializableTransaction(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: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true, firstName: true, lastName: true }, }, }, }, }, }, alerts: { where: { 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: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true, firstName: true, lastName: true }, }, }, }, }, }, alerts: { where: { status: { in: activeNotdienstAlertStatuses } }, select: { id: true }, }, }, }, }, }); if (!request) { throw new UserFacingError("Tauschanfrage nicht gefunden oder nicht mehr offen."); } if (request.targetAssignment.child.familyId !== session.user.familyId) { throw new UserFacingError("Nur der angefragte Haushalt kann diese Anfrage annehmen."); } const validationError = validateSwapAssignmentPair({ targetAssignment: request.targetAssignment, offeredAssignment: request.offeredAssignment, requesterFamilyId: request.requesterFamilyId, today, }); if (validationError) { 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 }, }); await tx.notdienstAssignment.update({ where: { id: request.offeredAssignmentId }, data: { childId: request.targetAssignment.childId }, }); const responded = await tx.notdienstSwapRequest.updateMany({ where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING }, data: { status: NotdienstSwapStatus.ACCEPTED, respondedById: session.user.id, respondedAt: new Date(), }, }); if (responded.count !== 1) throw new UserFacingError("Diese Anfrage ist nicht mehr offen."); 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, }; }); const kita = await prisma.kita.findUnique({ where: { id: session.user.kitaId }, select: { name: true }, }); if (kita) { await Promise.all([ notifyFamilyAboutSwap({ family: result.targetAssignment.child.family, subject: "Notdienst-Tausch bestätigt", heading: "Tausch bestätigt", preview: "Ein Notdienst-Tausch wurde bestätigt.", kitaName: kita.name, body: "die Tauschanfrage wurde angenommen. Der Notdienst-Plan wurde automatisch aktualisiert.", targetDate: result.targetAssignment.date, offeredDate: result.offeredAssignment.date, }), notifyFamilyAboutSwap({ family: result.offeredAssignment.child.family, subject: "Notdienst-Tausch bestätigt", heading: "Tausch bestätigt", preview: "Ein Notdienst-Tausch wurde bestätigt.", kitaName: kita.name, body: "deine Tauschanfrage wurde angenommen. Der Notdienst-Plan wurde automatisch aktualisiert.", targetDate: result.targetAssignment.date, offeredDate: result.offeredAssignment.date, }), ]); } revalidateCalendarAndNotdienst(); return { success: true }; } catch (error) { return { error: publicActionError(error, "Tauschanfrage konnte nicht angenommen werden."), }; } } export async function declineNotdienstSwapRequest(requestIdRaw: unknown) { const session = await requireKitaSession(); if (!session.user.familyId) { return { error: "Dein Account ist noch keinem Haushalt zugeordnet." }; } const parsed = swapIdSchema.safeParse(requestIdRaw); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } try { const request = await prisma.notdienstSwapRequest.findFirst({ where: { id: parsed.data, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING, }, select: { id: true, targetAssignment: { select: { date: true, child: { select: { familyId: true, family: { select: { name: true, users: { where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true, firstName: true, lastName: true }, }, }, }, }, }, }, }, offeredAssignment: { select: { date: true, child: { select: { family: { select: { name: true, users: { where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true, firstName: true, lastName: true }, }, }, }, }, }, }, }, }, }); if (!request) { 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.", }; } const responded = await prisma.notdienstSwapRequest.updateMany({ where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING }, data: { status: NotdienstSwapStatus.DECLINED, respondedById: session.user.id, respondedAt: new Date(), }, }); if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." }; const kita = await prisma.kita.findUnique({ where: { id: session.user.kitaId }, select: { name: true }, }); if (kita) { await Promise.all([ notifyFamilyAboutSwap({ family: request.targetAssignment.child.family, subject: "Notdienst-Tauschanfrage abgelehnt", heading: "Tauschanfrage abgelehnt", preview: "Eine Notdienst-Tauschanfrage wurde abgelehnt.", kitaName: kita.name, body: "du hast die Tauschanfrage abgelehnt. Der Notdienst-Plan bleibt unverändert.", targetDate: request.targetAssignment.date, offeredDate: request.offeredAssignment.date, }), notifyFamilyAboutSwap({ family: request.offeredAssignment.child.family, subject: "Notdienst-Tauschanfrage abgelehnt", heading: "Tauschanfrage abgelehnt", preview: "Eine Notdienst-Tauschanfrage wurde abgelehnt.", kitaName: kita.name, body: "deine Tauschanfrage wurde abgelehnt. Der Notdienst-Plan bleibt unverändert.", targetDate: request.targetAssignment.date, offeredDate: request.offeredAssignment.date, }), ]); } revalidateCalendarAndNotdienst(); return { success: true }; } catch (error) { console.error("Fehler beim Ablehnen der Notdienst-Tauschanfrage:", error); return { error: "Tauschanfrage konnte nicht abgelehnt werden." }; } } export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) { const session = await requireKitaSession(); if (!session.user.familyId) { return { error: "Dein Account ist noch keinem Haushalt zugeordnet." }; } const parsed = swapIdSchema.safeParse(requestIdRaw); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } try { const request = await prisma.notdienstSwapRequest.findFirst({ where: { id: parsed.data, kitaId: session.user.kitaId, requesterFamilyId: session.user.familyId, status: NotdienstSwapStatus.PENDING, }, select: { id: true }, }); if (!request) { return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." }; } const responded = await prisma.notdienstSwapRequest.updateMany({ where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING }, data: { status: NotdienstSwapStatus.CANCELLED, respondedById: session.user.id, respondedAt: new Date(), }, }); if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." }; revalidateCalendarAndNotdienst(); return { success: true }; } catch (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(); if (!(await rateLimit(`createTerminRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." }; const parsed = terminSchema.safeParse(rawPayload); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } const data = parsed.data; const terminData = buildTerminData(data); if ("error" in terminData) { return { error: terminData.error }; } try { await prisma.termin.create({ data: { kitaId: session.user.kitaId, createdById: session.user.id, title: terminData.data.title, description: terminData.data.description, type: terminData.data.type, startDate: terminData.data.startDate, endDate: terminData.data.endDate, allDay: terminData.data.allDay, status: TerminStatus.PENDING, }, }); revalidatePath("/dashboard/kalender"); return { success: true }; } catch (error) { console.error("Fehler beim Erstellen der Terminanfrage:", error); return { error: "Ein Fehler ist aufgetreten." }; } } export async function createTerminAdmin(rawPayload: unknown) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId; 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); if ("error" in terminData) { return { error: terminData.error }; } try { await prisma.termin.create({ data: { kitaId, createdById: session.user.id, ...terminData.data, status: TerminStatus.CONFIRMED, approvedById: session.user.id, approvedAt: new Date(), }, }); revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/admin/kalender"); revalidatePath("/dashboard"); return { success: true }; } catch (error) { console.error("Fehler beim Erstellen des Termins:", error); return { error: "Ein Fehler ist aufgetreten." }; } } 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; if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } const parsed = terminSchema.safeParse(rawPayload); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } try { 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"); revalidatePath("/dashboard"); return { success: true }; } catch (error) { console.error("Fehler beim Aktualisieren des Termins:", error); return { error: "Termin konnte nicht aktualisiert werden." }; } } export async function approveTermin(terminId: string) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId; if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } try { await prisma.termin.update({ where: { id: terminId, kitaId, }, data: { status: TerminStatus.CONFIRMED, approvedById: session.user.id, approvedAt: new Date(), }, }); revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/admin/kalender"); revalidatePath("/dashboard"); return { success: true }; } catch (error) { console.error("Fehler beim Bestätigen des Termins:", error); return { error: "Ein Fehler ist aufgetreten." }; } } export async function rejectTermin(terminId: string, reason?: string) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId; if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } try { await prisma.termin.update({ where: { id: terminId, kitaId, }, data: { status: TerminStatus.REJECTED, rejectionReason: reason, approvedById: session.user.id, approvedAt: new Date(), }, }); revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/admin/kalender"); revalidatePath("/dashboard"); return { success: true }; } catch (error) { console.error("Fehler beim Ablehnen des Termins:", error); return { error: "Ein Fehler ist aufgetreten." }; } } export async function toggleMitbringselList( terminId: string, enabled: boolean, ) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId; if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } try { await prisma.termin.update({ where: { id: terminId, kitaId, }, data: { mitbringselListEnabled: enabled, }, }); revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/admin/kalender"); revalidatePath("/dashboard"); return { success: true }; } catch (error) { console.error("Fehler beim Umschalten der Mitbring-Liste:", error); return { error: "Ein Fehler ist aufgetreten." }; } } const mitbringselSchema = z.object({ terminId: z.string(), content: z.string().trim().min(1, "Eintrag darf nicht leer sein").max(500), }); export async function addMitbringsel(rawPayload: unknown) { const session = await requireKitaSession(); if (!(await rateLimit(`addMitbringsel:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." }; const parsed = mitbringselSchema.safeParse(rawPayload); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } const { terminId, content } = parsed.data; try { // Verify termin is active and list is enabled const termin = await prisma.termin.findUnique({ where: { id: terminId, kitaId: session.user.kitaId }, }); if ( !termin || termin.status !== TerminStatus.CONFIRMED || !termin.mitbringselListEnabled ) { return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." }; } await prisma.mitbringselItem.create({ data: { kitaId: session.user.kitaId, terminId: terminId, userId: session.user.id, content: content, }, }); revalidatePath("/dashboard/kalender"); return { success: true }; } catch (error) { console.error("Fehler beim Hinzufügen des Eintrags:", error); return { error: "Ein Fehler ist aufgetreten." }; } } const updateMitbringselSchema = z.object({ itemId: z.string().min(1).max(100), content: mitbringselSchema.shape.content, }); export async function updateMitbringsel(rawPayload: unknown) { const session = await requireKitaSession(); const parsed = updateMitbringselSchema.safeParse(rawPayload); if (!parsed.success) return { error: "Bitte einen Beitrag mit 1 bis 500 Zeichen eingeben." }; if (!(await rateLimit(`updateMitbringsel:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) { return { error: "Zu viele Änderungen. Bitte versuche es später erneut." }; } try { await serializableTransaction(async (tx) => { const item = await tx.mitbringselItem.findFirst({ where: { id: parsed.data.itemId, kitaId: session.user.kitaId, userId: session.user.id }, select: { terminId: true }, }); if (!item) throw new UserFacingError("Du kannst nur eigene Mitbringbeiträge bearbeiten."); await lockEvent(tx, item.terminId, session.user.kitaId); const termin = await tx.termin.findFirst({ where: { id: item.terminId, kitaId: session.user.kitaId, status: TerminStatus.CONFIRMED, mitbringselListEnabled: true }, select: { id: true }, }); if (!termin) throw new UserFacingError("Die Mitbring-Liste ist für diesen Termin nicht mehr aktiv."); await tx.mitbringselItem.update({ where: { id: parsed.data.itemId, kitaId: session.user.kitaId, userId: session.user.id }, data: { content: parsed.data.content }, }); }); revalidatePath("/dashboard/kalender"); return { success: true }; } catch (error) { return { error: publicActionError(error, "Der Beitrag konnte nicht gespeichert werden.") }; } } export async function deleteMitbringsel(itemId: string) { const session = await requireKitaSession(); try { const item = await prisma.mitbringselItem.findUnique({ where: { id: itemId, kitaId: session.user.kitaId }, }); if (!item) { return { error: "Eintrag nicht gefunden." }; } // Admins/Koords can delete any item. Users can only delete their own. if ( session.user.role !== UserRole.ADMIN && session.user.role !== UserRole.KOORDINATOR && item.userId !== session.user.id ) { return { error: "Keine Berechtigung diesen Eintrag zu löschen." }; } await prisma.mitbringselItem.delete({ where: { id: itemId }, }); revalidatePath("/dashboard/kalender"); return { success: true }; } catch (error) { console.error("Fehler beim Löschen des Eintrags:", error); return { error: "Ein Fehler ist aufgetreten." }; } } export async function deleteTerminAdmin(terminId: string) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId; if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } try { await prisma.termin.delete({ where: { id: terminId, kitaId, }, }); revalidatePath("/dashboard/kalender"); revalidatePath("/dashboard/admin/kalender"); revalidatePath("/dashboard"); return { success: true }; } catch (error) { console.error("Fehler beim Löschen des Termins:", error); return { error: "Termin konnte nicht gelöscht werden." }; } }