"use server"; import crypto from "crypto"; import { createElement } from "react"; import { revalidatePath } from "next/cache"; import { Prisma, UserRole } from "@prisma/client"; import { z } from "zod"; import { InviteEmail } from "@/emails/InviteEmail"; import { requireRole } from "@/lib/auth-utils"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { prisma } from "@/lib/prisma"; const INVITE_TOKEN_TTL_DAYS = 7; const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"; const childSchema = z.object({ firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(), lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(), }); const parentInputSchema = z.object({ firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(), lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(), email: z .string() .email("Bitte eine gültige E-Mail-Adresse angeben.") .toLowerCase() .trim(), }); const addFamilySchema = z.object({ familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(), parent1FirstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(), parent1LastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(), parent1Email: z .string() .email("Bitte eine gültige E-Mail-Adresse angeben.") .toLowerCase() .trim(), 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().min(1, "Familienname ist erforderlich.").max(120).trim(), 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; }; type InviteJob = { to: string; parentName: string; inviteUrl: 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 }; } function createInviteToken() { const token = crypto.randomUUID(); const expires = new Date( Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000, ); return { token, expires, inviteUrl: `${BASE_URL}/invite/${token}` }; } async function sendInviteJobs(kitaName: string, jobs: InviteJob[]) { for (const job of jobs) { const result = await sendAppEmail({ to: job.to, subject: `Einladung zu ${kitaName} im Kita-Planer`, react: createElement(InviteEmail, { parentName: job.parentName, kitaName, inviteLink: job.inviteUrl, }), }); if (!result.success) { return result.error; } } return null; } 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 mailConfigError = getAppEmailConfigError(); if (mailConfigError) { return { errors: { _form: [mailConfigError] } }; } 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: InviteJob[] = []; 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 = createInviteToken(); inviteJobs.push({ to: parent.email, parentName: `${parent.firstName} ${parent.lastName}`, inviteUrl: invite.inviteUrl, }); await tx.verificationToken.create({ data: { identifier: createdParent.id, token: invite.token, expires: invite.expires, }, }); } 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."], }, }; } throw err; } const emailError = await sendInviteJobs(kita.name, inviteJobs); if (emailError) { return { errors: { _form: [ `Familie wurde angelegt, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`, ], }, }; } revalidatePath("/dashboard/families"); revalidatePath("/dashboard"); return { success: true }; } export async function updateFamilyAction(rawPayload: unknown) { 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." }; } if (newParents.length > 0) { const mailConfigError = getAppEmailConfigError(); if (mailConfigError) { return { error: mailConfigError }; } } const kita = await prisma.kita.findUnique({ where: { id: kitaId }, select: { name: true }, }); if (!kita) { return { error: "Kita wurde nicht gefunden." }; } const inviteJobs: InviteJob[] = []; 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) { await tx.user.update({ where: { id: parent.id }, data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email, }, }); } 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 = createInviteToken(); inviteJobs.push({ to: parent.email, parentName: `${parent.firstName} ${parent.lastName}`, inviteUrl: invite.inviteUrl, }); await tx.verificationToken.create({ data: { identifier: createdParent.id, token: invite.token, expires: invite.expires, }, }); } 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 emailError = await sendInviteJobs(kita.name, inviteJobs); if (emailError) { return { error: `Familie wurde aktualisiert, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`, }; } revalidatePath("/dashboard/families"); revalidatePath("/dashboard/profil"); revalidatePath("/dashboard/notdienst"); revalidatePath("/dashboard"); return { success: true }; }