"use server"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { NotdienstPlanStatus } from "@prisma/client"; import { requireKitaSession } from "@/lib/auth-utils"; import { CHILD_MAX_AGE_YEARS, getChildBirthdateRange, parseDateInput, } from "@/lib/child-birthdate"; import { serializableTransaction } from "@/lib/transaction"; import { assertKitaAdminRemains, lockKitaAccounts, } from "@/lib/kita-admin-guard"; import { prisma } from "@/lib/prisma"; import { assertChildrenRemovable } from "@/lib/notdienst-planning"; import { UserFacingError, publicActionError } from "@/lib/action-error"; // Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche, // Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist. // Schließt damit "abc-123-not-a-phone" aus, lässt aber internationale Formate zu. const phonePattern = /^[+]?[0-9][\d\s\-()/.]{4,}$/; // PLZ: Ziffern/Buchstaben/Leerzeichen/Bindestriche, 3–10 Zeichen. // Kein striktes DE-Format (5 Ziffern), damit auch AT/CH/UK denkbar sind. const postalCodePattern = /^[0-9A-Za-z][0-9A-Za-z\s-]{2,9}$/; const childSchema = z .object({ firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(), lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(), dateOfBirth: z.string().optional(), }) .superRefine((data, ctx) => { if (!data.dateOfBirth) return; const parsed = parseDateInput(data.dateOfBirth); if (!parsed) { ctx.addIssue({ path: ["dateOfBirth"], code: "custom", message: "Bitte ein gültiges Datum angeben.", }); return; } const { min, max } = getChildBirthdateRange(); if (data.dateOfBirth > max) { ctx.addIssue({ path: ["dateOfBirth"], code: "custom", message: "Geburtsdatum darf nicht in der Zukunft liegen.", }); } if (data.dateOfBirth < min) { ctx.addIssue({ path: ["dateOfBirth"], code: "custom", message: `Kinder dürfen maximal ${CHILD_MAX_AGE_YEARS} Jahre alt sein.`, }); } }); const familySchema = z.object({ familyName: z .string() .min(1, "Familienname ist erforderlich.") .max(120) .trim(), }); const contactSchema = z.object({ phone: z .string() .trim() .max(50) .optional() .refine( (v) => !v || phonePattern.test(v), "Bitte eine gültige Telefonnummer angeben.", ), street: z.string().trim().max(120).optional(), postalCode: z .string() .trim() .max(20) .optional() .refine( (v) => !v || postalCodePattern.test(v), "Bitte eine gültige Postleitzahl angeben.", ), city: z.string().trim().max(100).optional(), directoryOptIn: z.boolean().optional(), }); // Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück, // damit der User nicht nur "Ungültige Eingabedaten" sieht. function firstZodMessage(error: z.ZodError, fallback: string): string { const issue = error.issues[0]; return issue?.message || fallback; } async function requireOwnFamilyChild( childId: string, familyId: string | null, kitaId: string, ) { if (!familyId) return null; return prisma.child.findFirst({ where: { id: childId, familyId, kitaId }, }); } export async function createMyChild(rawPayload: unknown) { const session = await requireKitaSession(); const parsed = childSchema.safeParse(rawPayload); if (!session.user.familyId) { return { error: "Dein Account ist noch keinem Haushalt zugeordnet." }; } if (!parsed.success) { return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") }; } try { await serializableTransaction(async (tx) => { const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId!, }, }); if (count >= 20) throw new Error("Household child limit reached"); await tx.child.create({ data: { kitaId: session.user.kitaId, familyId: session.user.familyId!, firstName: parsed.data.firstName, lastName: parsed.data.lastName, dateOfBirth: parseDateInput(parsed.data.dateOfBirth), }, }); }); revalidatePath("/dashboard/profil"); revalidatePath("/dashboard/families"); return { success: true }; } catch (error) { console.error("Fehler beim Anlegen des Kindes:", error); return { error: "Das Kind konnte nicht angelegt werden." }; } } export async function createMyFamily(rawPayload: unknown) { const session = await requireKitaSession(); const parsed = familySchema.safeParse(rawPayload); if (!parsed.success) { return { error: "Bitte gib einen gültigen Familiennamen ein." }; } if (session.user.familyId) { return { error: "Dein Account ist bereits einem Haushalt zugeordnet." }; } try { await prisma.$transaction(async (tx) => { const family = await tx.family.create({ data: { kitaId: session.user.kitaId, name: parsed.data.familyName, }, }); const attached = await tx.user.updateMany({ where: { id: session.user.id, kitaId: session.user.kitaId, familyId: null, disabledAt: null, sessionVersion: session.user.sessionVersion, }, data: { familyId: family.id }, }); if (attached.count !== 1) throw new Error("Account already assigned or revoked"); }); revalidatePath("/dashboard/profil"); revalidatePath("/dashboard/families"); revalidatePath("/dashboard/adressbuch"); revalidatePath("/dashboard"); return { success: true }; } catch (error) { if ( !( error instanceof Error && error.message === "Account already assigned or revoked" ) ) { console.error("Fehler beim Anlegen des eigenen Haushalts:", error); } return { error: "Der Haushalt konnte nicht angelegt werden." }; } } export async function updateMyContact(rawPayload: unknown) { const session = await requireKitaSession(); const parsed = contactSchema.safeParse(rawPayload); if (!parsed.success) { return { error: firstZodMessage(parsed.error, "Ungültige Kontaktdaten.") }; } try { const currentUser = await prisma.user.findUniqueOrThrow({ where: { id: session.user.id }, select: { directoryOptInAt: true }, }); let newDirectoryOptInAt: Date | null = currentUser.directoryOptInAt; if (parsed.data.directoryOptIn) { if (!newDirectoryOptInAt) { newDirectoryOptInAt = new Date(); } } else { newDirectoryOptInAt = null; } await prisma.user.update({ where: { id: session.user.id }, data: { phone: parsed.data.phone || null, street: parsed.data.street || null, postalCode: parsed.data.postalCode || null, city: parsed.data.city || null, directoryOptInAt: newDirectoryOptInAt, }, }); revalidatePath("/dashboard/profil"); revalidatePath("/dashboard"); revalidatePath("/dashboard/adressbuch"); return { success: true }; } catch (error) { console.error("Fehler beim Aktualisieren der Kontaktdaten:", error); return { error: "Kontaktdaten konnten nicht gespeichert werden." }; } } export async function updateMyChild(childId: string, rawPayload: unknown) { const session = await requireKitaSession(); const parsed = childSchema.safeParse(rawPayload); if (!parsed.success) { return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") }; } try { const child = await requireOwnFamilyChild( childId, session.user.familyId, session.user.kitaId, ); if (!child) { return { error: "Dieses Kind gehört nicht zu deinem Haushalt." }; } await prisma.child.update({ where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId!, }, data: { firstName: parsed.data.firstName, lastName: parsed.data.lastName, dateOfBirth: parseDateInput(parsed.data.dateOfBirth), }, }); revalidatePath("/dashboard/profil"); revalidatePath("/dashboard/families"); return { success: true }; } catch (error) { console.error("Fehler beim Aktualisieren des Kindes:", error); return { error: "Das Kind konnte nicht aktualisiert werden." }; } } export async function deleteMyChild(childId: string) { const session = await requireKitaSession(); try { await serializableTransaction(async (tx) => { if (!session.user.familyId) throw new UserFacingError( "Dieses Kind gehört nicht zu deinem Haushalt.", ); const child = await tx.child.findFirst({ where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId, }, select: { id: true }, }); if (!child) throw new UserFacingError( "Dieses Kind gehört nicht zu deinem Haushalt.", ); await assertChildrenRemovable( tx, session.user.kitaId, session.user.familyId, [childId], ); await tx.child.delete({ where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId, }, }); }); revalidatePath("/dashboard/profil"); revalidatePath("/dashboard/families"); return { success: true }; } catch (error) { if (!(error instanceof UserFacingError)) console.error("Fehler beim Entfernen des Kindes:", error); return { error: publicActionError(error, "Das Kind konnte nicht entfernt werden."), }; } } export async function deleteMyAccount(): Promise<{ success?: boolean; error?: string; }> { const session = await requireKitaSession(); try { return await serializableTransaction(async (tx) => { await lockKitaAccounts(tx, session.user.kitaId); const user = await tx.user.findFirst({ where: { id: session.user.id, kitaId: session.user.kitaId }, select: { id: true, familyId: true }, }); if (!user) throw new UserFacingError("Account wurde nicht gefunden."); await assertKitaAdminRemains(tx, session.user.kitaId, [user.id]); const today = new Date(); today.setHours(0, 0, 0, 0); const assignments = user.familyId ? await tx.notdienstAssignment.count({ where: { kitaId: session.user.kitaId, date: { gte: today }, plan: { status: NotdienstPlanStatus.PUBLISHED }, child: { familyId: user.familyId }, }, }) : 0; if (assignments) throw new UserFacingError( "Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.", ); if (user.familyId) { const family = await tx.family.findFirst({ where: { id: user.familyId, kitaId: session.user.kitaId }, select: { id: true, users: { select: { id: true } } }, }); if (!family) throw new UserFacingError("Haushalt wurde nicht gefunden."); if (family.users.length <= 1) { await tx.family.delete({ where: { id: family.id } }); return { success: true }; } } await tx.user.delete({ where: { id: user.id } }); return { success: true }; }); } catch (error) { if (!(error instanceof UserFacingError)) console.error("Fehler beim Löschen des Accounts:", error); return { error: publicActionError( error, "Ein Fehler ist beim Löschen aufgetreten.", ), }; } }