"use server"; import { revalidatePath } from "next/cache"; import { Prisma, UserRole } from "@prisma/client"; import { z } from "zod"; import { requireRole } from "@/lib/auth-utils"; import { deliverFamilyInvitations, queueFamilyInvitation, resendFamilyInvitation, } from "@/lib/family-invitations"; import { prisma } from "@/lib/prisma"; const childSchema = z.object({ firstName: z.string().trim().min(1, "Vorname des Kindes fehlt.").max(100), lastName: z.string().trim().min(1, "Nachname des Kindes fehlt.").max(100), }); const parentInputSchema = z.object({ firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100), lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100), email: z .string() .trim() .toLowerCase() .email("Bitte eine gültige E-Mail-Adresse angeben."), }); const addFamilySchema = z.object({ familyName: z .string() .trim() .min(1, "Familienname ist erforderlich.") .max(120), parent1FirstName: z .string() .trim() .min(1, "Vorname ist erforderlich.") .max(100), parent1LastName: z .string() .trim() .min(1, "Nachname ist erforderlich.") .max(100), parent1Email: z .string() .trim() .toLowerCase() .email("Bitte eine gültige E-Mail-Adresse angeben."), parent2FirstName: z.string().max(100).trim().optional(), parent2LastName: z.string().max(100).trim().optional(), parent2Email: z.string().trim().optional(), childCount: z.coerce .number() .int() .min(1, "Mindestens ein Kind erforderlich.") .max(10), }); const updateFamilySchema = z.object({ familyId: z.string().min(1), familyName: z .string() .trim() .min(1, "Familienname ist erforderlich.") .max(120), parents: z .array( parentInputSchema.extend({ id: z.string().min(1), }), ) .min(1) .max(2), newParents: z.array(parentInputSchema).max(1).default([]), children: z .array( childSchema.extend({ id: z.string().min(1), }), ) .max(20), newChildren: z.array(childSchema).max(10).default([]), removedChildIds: z.array(z.string().min(1)).max(20).default([]), }); export type AddFamilyState = { errors?: { familyName?: string[]; parent1FirstName?: string[]; parent1LastName?: string[]; parent1Email?: string[]; parent2FirstName?: string[]; parent2LastName?: string[]; parent2Email?: string[]; children?: string[]; _form?: string[]; }; success?: boolean; warning?: string; }; function optionalParentFromForm(data: z.infer) { const firstName = data.parent2FirstName?.trim() ?? ""; const lastName = data.parent2LastName?.trim() ?? ""; const email = data.parent2Email?.trim() ?? ""; if (!firstName && !lastName && !email) { return { ok: true as const, parent: null }; } const parsed = parentInputSchema.safeParse({ firstName, lastName, email }); if (!parsed.success) { return { ok: false as const, errors: { parent2FirstName: parsed.error.flatten().fieldErrors.firstName, parent2LastName: parsed.error.flatten().fieldErrors.lastName, parent2Email: parsed.error.flatten().fieldErrors.email, }, }; } return { ok: true as const, parent: parsed.data }; } async function sendInviteJobs(ids: string[]) { try { const result = await deliverFamilyInvitations(ids); if (!result.failed) return undefined; } catch { // A committed family remains successful even if delivery/status storage fails. } return "Familie gespeichert. Mindestens eine Einladung konnte nicht versendet werden. Den Versand kannst du beim jeweiligen Elternteil erneut versuchen."; } export async function addFamilyAction( _prev: AddFamilyState, formData: FormData, ): Promise { const session = await requireRole([UserRole.ADMIN]); const kitaId = session.user.kitaId; if (!kitaId) { return { errors: { _form: ["Kein Mandant zugeordnet."] } }; } const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData)); if (!parsedFamily.success) { return { errors: parsedFamily.error.flatten().fieldErrors }; } const optionalParent = optionalParentFromForm(parsedFamily.data); if (!optionalParent.ok) { return { errors: optionalParent.errors }; } const childrenRaw: z.infer[] = []; for (let i = 0; i < parsedFamily.data.childCount; i++) { const parsed = childSchema.safeParse({ firstName: formData.get(`childFirstName_${i}`), lastName: formData.get(`childLastName_${i}`), }); if (!parsed.success) { return { errors: { children: [ `Kind ${i + 1}: ${Object.values(parsed.error.flatten().fieldErrors).flat().join(", ")}`, ], }, }; } childrenRaw.push(parsed.data); } const kita = await prisma.kita.findUnique({ where: { id: kitaId }, select: { name: true }, }); if (!kita) { return { errors: { _form: ["Kita wurde nicht gefunden."] } }; } const parents = [ { firstName: parsedFamily.data.parent1FirstName, lastName: parsedFamily.data.parent1LastName, email: parsedFamily.data.parent1Email, }, ...(optionalParent.parent ? [optionalParent.parent] : []), ]; const emailSet = new Set(parents.map((parent) => parent.email)); if (emailSet.size !== parents.length) { return { errors: { _form: [ "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein.", ], }, }; } const inviteJobs: string[] = []; try { await prisma.$transaction(async (tx) => { const family = await tx.family.create({ data: { kitaId, name: parsedFamily.data.familyName, }, }); for (const parent of parents) { const createdParent = await tx.user.create({ data: { kitaId, familyId: family.id, email: parent.email, firstName: parent.firstName, lastName: parent.lastName, passwordHash: "", role: UserRole.ELTERN, }, }); const invite = await queueFamilyInvitation( tx, createdParent.id, session.user.id, ); inviteJobs.push(invite.id); } await tx.child.createMany({ data: childrenRaw.map((child) => ({ kitaId, familyId: family.id, firstName: child.firstName, lastName: child.lastName, })), }); }); } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" ) { return { errors: { _form: ["Mindestens eine E-Mail-Adresse existiert bereits."], }, }; } console.error("Familie konnte nicht angelegt werden:", err); return { errors: { _form: [ "Familie konnte nicht angelegt werden. Bitte erneut versuchen.", ], }, }; } const warning = await sendInviteJobs(inviteJobs); revalidatePath("/dashboard/families"); revalidatePath("/dashboard"); return { success: true, warning }; } export async function updateFamilyAction( rawPayload: unknown, ): Promise<{ success?: boolean; error?: string; warning?: string }> { const session = await requireRole([UserRole.ADMIN]); const kitaId = session.user.kitaId; if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } const parsed = updateFamilySchema.safeParse(rawPayload); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } const { familyId, familyName, parents, newParents, children, newChildren, removedChildIds, } = parsed.data; const parentEmails = [...parents, ...newParents].map( (parent) => parent.email, ); if (new Set(parentEmails).size !== parentEmails.length) { return { error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein.", }; } const removedChildIdSet = new Set(removedChildIds); const activeChildren = children.filter( (child) => !removedChildIdSet.has(child.id), ); if (activeChildren.length + newChildren.length === 0) { return { error: "Eine Familie benötigt mindestens ein Kind." }; } const kita = await prisma.kita.findUnique({ where: { id: kitaId }, select: { name: true }, }); if (!kita) { return { error: "Kita wurde nicht gefunden." }; } const inviteJobs: string[] = []; try { const existingFamily = await prisma.family.findFirst({ where: { id: familyId, kitaId }, select: { id: true, users: { select: { id: true } }, children: { select: { id: true } }, }, }); if (!existingFamily) { return { error: "Familie wurde nicht gefunden." }; } const existingParentIds = new Set( existingFamily.users.map((user) => user.id), ); const existingChildIds = new Set( existingFamily.children.map((child) => child.id), ); if (!parents.every((parent) => existingParentIds.has(parent.id))) { return { error: "Mindestens ein Elternteil gehört nicht zu dieser Familie.", }; } if (!children.every((child) => existingChildIds.has(child.id))) { return { error: "Mindestens ein Kind gehört nicht zu dieser Familie." }; } if (!removedChildIds.every((childId) => existingChildIds.has(childId))) { return { error: "Mindestens ein Kind gehört nicht zu dieser Familie." }; } if (existingParentIds.size + newParents.length > 2) { return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." }; } await prisma.$transaction(async (tx) => { await tx.family.update({ where: { id: familyId }, data: { name: familyName }, }); for (const parent of parents) { const previous = await tx.user.findUniqueOrThrow({ where: { id: parent.id }, }); await tx.user.update({ where: { id: parent.id }, data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email, }, }); if (!previous.emailVerifiedAt && previous.email !== parent.email) { const invite = await queueFamilyInvitation( tx, parent.id, session.user.id, ); inviteJobs.push(invite.id); } } for (const parent of newParents) { const createdParent = await tx.user.create({ data: { kitaId, familyId, email: parent.email, firstName: parent.firstName, lastName: parent.lastName, passwordHash: "", role: UserRole.ELTERN, }, }); const invite = await queueFamilyInvitation( tx, createdParent.id, session.user.id, ); inviteJobs.push(invite.id); } for (const child of activeChildren) { await tx.child.update({ where: { id: child.id }, data: { firstName: child.firstName, lastName: child.lastName, }, }); } for (const childId of removedChildIds) { await tx.child.delete({ where: { id: childId } }); } for (const child of newChildren) { await tx.child.create({ data: { kitaId, familyId, firstName: child.firstName, lastName: child.lastName, }, }); } }); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" ) { return { error: "Mindestens eine E-Mail-Adresse existiert bereits." }; } console.error("Fehler beim Aktualisieren der Familie:", error); return { error: "Familie konnte nicht aktualisiert werden." }; } const warning = await sendInviteJobs(inviteJobs); revalidatePath("/dashboard/families"); revalidatePath("/dashboard/profil"); revalidatePath("/dashboard/notdienst"); revalidatePath("/dashboard"); return { success: true, warning }; } export async function resendFamilyInvitationAction( userId: string, ): Promise<{ success?: boolean; error?: string; warning?: string }> { const session = await requireRole([UserRole.ADMIN]); if (!session.user.kitaId || typeof userId !== "string" || !userId) return { error: "Ungültiges Elternkonto." }; let result; try { result = await resendFamilyInvitation( session.user.kitaId, userId, session.user.id, ); } catch { result = { error: "Einladung konnte nicht erneut versendet werden. Bitte den Status prüfen und erneut versuchen.", }; } revalidatePath("/dashboard/families"); return result; }