"use server"; import { revalidatePath } from "next/cache"; import { AbsenceReason, Prisma, UserRole } from "@prisma/client"; import { dateOnlySchema } from "@/lib/date-schema"; import { z } from "zod"; import { requireKitaSession, requireRole } from "@/lib/auth-utils"; import { prisma } from "@/lib/prisma"; import { absenceDate, absenceToday, canManageKitaAbsences, } from "@/lib/absence-policy"; const dateSchema = dateOnlySchema; const fieldsSchema = z.object({ startDate: dateSchema, endDate: dateSchema, reason: z.nativeEnum(AbsenceReason), note: z.string().trim().max(500).optional(), }); const reportSchema = fieldsSchema.extend({ childId: z.string().min(1).max(128), }); const identitySchema = z.object({ id: z.string().min(1).max(128), updatedAt: z.string().datetime({ offset: true }), }); const updateSchema = fieldsSchema.extend(identitySchema.shape); type MutationResult = { success?: boolean; error?: string }; export type DailyAbsenceDto = { id: string; childName: string; familyName: string; reason: AbsenceReason; note: string | null; startDate: string; endDate: string; updatedAt: string; }; function refreshAbsences() { revalidatePath("/dashboard"); revalidatePath("/dashboard/abwesenheiten"); revalidatePath("/dashboard/admin/abwesenheiten"); } async function currentActor( tx: Prisma.TransactionClient, id: string, kitaId: string, ) { return tx.user.findFirst({ where: { id, kitaId, role: { in: [ UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ERZIEHER, ], }, }, select: { id: true, role: true, familyId: true }, }); } async function lockChild( tx: Prisma.TransactionClient, childId: string, kitaId: string, ) { // Serializes overlapping create/edit/delete operations for this child. await tx.$queryRaw`SELECT "id" FROM "children" WHERE "id" = ${childId} AND "kitaId" = ${kitaId} FOR UPDATE`; } export async function reportAbsence( rawPayload: unknown, ): Promise { const session = await requireKitaSession(); const parsed = reportSchema.safeParse(rawPayload); if (!parsed.success) return { error: "Bitte gültige Daten und einen Grund angeben. Die Bemerkung darf höchstens 500 Zeichen haben.", }; const { childId, startDate, endDate, reason, note } = parsed.data; if (endDate < startDate) return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." }; const result = await prisma.$transaction(async (tx) => { const actor = await currentActor(tx, session.user.id, session.user.kitaId); if (!actor?.familyId) return { error: "Dein Account ist keinem Haushalt zugeordnet." }; if (!canManageKitaAbsences(actor.role) && startDate < absenceToday()) return { error: "Vergangene Tage kann nur die Kita nachtragen. Bitte melde dich dort.", }; await lockChild(tx, childId, session.user.kitaId); const child = await tx.child.findFirst({ where: { id: childId, kitaId: session.user.kitaId, familyId: actor.familyId, active: true, }, select: { id: true }, }); if (!child) return { error: "Dieses Kind gehört nicht zu deinem Haushalt oder ist nicht mehr aktiv.", }; const start = absenceDate(startDate), end = absenceDate(endDate); const overlap = await tx.absence.findFirst({ where: { kitaId: session.user.kitaId, childId, startDate: { lte: end }, endDate: { gte: start }, }, select: { id: true }, }); if (overlap) return { error: "Für diesen Zeitraum existiert bereits eine Abmeldung." }; await tx.absence.create({ data: { kitaId: session.user.kitaId, childId, startDate: start, endDate: end, reason, note: note || null, }, }); return { success: true }; }); if (result.success) refreshAbsences(); return result; } async function changeAbsence( rawPayload: unknown, remove: boolean, ): Promise { const session = await requireKitaSession(); const parsed = remove ? identitySchema.safeParse(rawPayload) : updateSchema.safeParse(rawPayload); if (!parsed.success) return { error: "Ungültige Eingabedaten. Bitte die Ansicht aktualisieren und gültige Daten angeben.", }; const input = parsed.data; const fields = remove ? null : updateSchema.parse(rawPayload); if (fields && fields.endDate < fields.startDate) return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." }; const result = await prisma.$transaction(async (tx) => { const actor = await currentActor(tx, session.user.id, session.user.kitaId); if (!actor) return { error: "Keine Berechtigung für diese Meldung." }; const team = canManageKitaAbsences(actor.role); if (!team && !actor.familyId) return { error: "Dein Account ist keinem Haushalt zugeordnet." }; const where: Prisma.AbsenceWhereInput = { id: input.id, kitaId: session.user.kitaId, child: { kitaId: session.user.kitaId, ...(team ? {} : { familyId: actor.familyId! }), }, }; const candidate = await tx.absence.findFirst({ where, select: { childId: true }, }); if (!candidate) return { error: "Meldung wurde nicht gefunden oder gehört nicht zu deinem Haushalt.", }; await lockChild(tx, candidate.childId, session.user.kitaId); const absence = await tx.absence.findFirst({ where }); if (!absence) return { error: "Die Meldung ist nicht mehr vorhanden. Bitte die Ansicht aktualisieren.", }; if ( absence.updatedAt.toISOString() !== new Date(input.updatedAt).toISOString() ) return { error: "Die Meldung wurde inzwischen geändert. Bitte die Ansicht aktualisieren.", }; const today = absenceToday(); if ( !team && (absence.startDate.toISOString().slice(0, 10) <= today || (fields && fields.startDate <= today)) ) return { error: "Bereits begonnene Meldungen und laufende Tage verwaltet die Kita. Bitte melde dich dort.", }; if (remove) await tx.absence.delete({ where: { id: absence.id } }); else if (fields) { const startDate = absenceDate(fields.startDate), endDate = absenceDate(fields.endDate); const overlap = await tx.absence.findFirst({ where: { kitaId: session.user.kitaId, childId: absence.childId, id: { not: absence.id }, startDate: { lte: endDate }, endDate: { gte: startDate }, }, select: { id: true }, }); if (overlap) return { error: "Für diesen Zeitraum existiert bereits eine andere Abmeldung.", }; await tx.absence.update({ where: { id: absence.id }, data: { startDate, endDate, reason: fields.reason, note: fields.note || null, }, }); } return { success: true }; }); if (result.success) refreshAbsences(); return result; } export async function updateAbsence(rawPayload: unknown) { return changeAbsence(rawPayload, false); } export async function deleteAbsence(rawPayload: unknown) { return changeAbsence(rawPayload, true); } export async function getDailyAbsences( rawPayload: unknown, ): Promise<{ absences?: DailyAbsenceDto[]; error?: string }> { const session = await requireRole([ UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ERZIEHER, ]); if (!session.user.kitaId) return { error: "Kein Mandant zugeordnet." }; const parsed = z.object({ date: dateSchema }).safeParse(rawPayload); if (!parsed.success) return { error: "Ungültiges Datum." }; const date = absenceDate(parsed.data.date); const rows = await prisma.absence.findMany({ where: { kitaId: session.user.kitaId, startDate: { lte: date }, endDate: { gte: date }, }, select: { id: true, reason: true, note: true, startDate: true, endDate: true, updatedAt: true, child: { select: { firstName: true, lastName: true, family: { select: { name: true } }, }, }, }, orderBy: [{ child: { lastName: "asc" } }, { child: { firstName: "asc" } }], }); return { absences: rows.map((absence) => ({ id: absence.id, childName: `${absence.child.firstName} ${absence.child.lastName}`, familyName: absence.child.family.name, reason: absence.reason, note: absence.note, startDate: absence.startDate.toISOString().slice(0, 10), endDate: absence.endDate.toISOString().slice(0, 10), updatedAt: absence.updatedAt.toISOString(), })), }; }