"use server"; import { redirect } from "next/navigation"; import { Prisma, UserRole } from "@prisma/client"; import { z } from "zod"; import { auth } from "@/auth"; import { prisma } from "@/lib/prisma"; // ===================================================================== // /onboarding · Server Action // --------------------------------------------------------------------- // Erstellt die Kita (Tenant) und verknüpft den eingeloggten Gründer in // einer einzigen Datenbank-Transaktion. Damit ist garantiert: entweder // existiert hinterher beides (Kita + verknüpfter Admin) oder gar nichts // — kein "halb-eingerichteter" Zwischenzustand. // ===================================================================== const onboardingSchema = z .object({ kitaName: z .string() .trim() .min(2, "Bitte mindestens 2 Zeichen angeben.") .max(120, "Maximal 120 Zeichen."), notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), notdienstMinPerChildPerMonth: z.coerce .number() .int("Bitte eine ganze Zahl angeben.") .min(0, "Bitte einen Wert von 0 oder mehr angeben.") .max(31, "Maximal 31 Tage pro Monat."), }) .superRefine((data, ctx) => { // Mindest-Verfügbarkeit nur relevant, wenn das Notdienst-Modul aktiv ist. // Bei aktivem Modul muss mindestens 1 Tag eingetragen sein — 0 wäre // fachlich sinnlos und würde den Planer leerlaufen lassen. if (data.notdienstModuleEnabled && data.notdienstMinPerChildPerMonth < 1) { ctx.addIssue({ path: ["notdienstMinPerChildPerMonth"], code: "custom", message: "Bei aktivem Notdienst-Modul: mindestens 1 Tag pro Monat angeben.", }); } }); function checkboxToBool(value: unknown): boolean { // HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData. return value === "on" || value === "true" || value === true; } export type OnboardingState = { // Bumped after every action call so the client can key uncontrolled inputs // and force a remount with the echoed values from a failed submit. attempt: number; // Echoed inputs so a failed submit doesn't wipe the form. values?: { kitaName?: string; notdienstModuleEnabled?: boolean; terminModuleEnabled?: boolean; adressbuchModuleEnabled?: boolean; notdienstMinPerChildPerMonth?: number; }; errors?: { kitaName?: string[]; notdienstMinPerChildPerMonth?: string[]; _form?: string[]; }; }; function echoValues(formData: FormData): OnboardingState["values"] { const min = formData.get("notdienstMinPerChildPerMonth"); return { kitaName: String(formData.get("kitaName") ?? ""), notdienstModuleEnabled: formData.get("notdienstModuleEnabled") === "on", terminModuleEnabled: formData.get("terminModuleEnabled") === "on", adressbuchModuleEnabled: formData.get("adressbuchModuleEnabled") === "on", notdienstMinPerChildPerMonth: min === null || min === "" ? undefined : Number(min), }; } export async function completeOnboardingAction( prev: OnboardingState, formData: FormData, ): Promise { const attempt = prev.attempt + 1; const session = await auth(); if (!session?.user) { redirect("/login"); } if (session.user.kitaId) { // Onboarding bereits abgeschlossen — verhindert doppeltes Einrichten // (z.B. durch zurück-navigieren oder Replay des Formulars). redirect("/dashboard"); } const parsed = onboardingSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return { attempt, values: echoValues(formData), errors: parsed.error.flatten().fieldErrors, }; } const data = parsed.data; try { await prisma.$transaction(async (tx) => { const kita = await tx.kita.create({ data: { name: data.kitaName, slug: await generateUniqueSlug(tx, data.kitaName), notdienstModuleEnabled: data.notdienstModuleEnabled, terminModuleEnabled: data.terminModuleEnabled, adressbuchModuleEnabled: data.adressbuchModuleEnabled, notdienstMinPerChildPerMonth: data.notdienstMinPerChildPerMonth, }, }); // Defense-in-Depth: erlaube den Update nur, wenn der User aktuell // tatsächlich noch keine Kita hat (verhindert Race-Conditions bei // parallelen Onboarding-Submits). const updated = await tx.user.updateMany({ where: { id: session.user.id, kitaId: null }, data: { kitaId: kita.id, role: UserRole.ADMIN, }, }); if (updated.count === 0) { throw new Error("USER_ALREADY_HAS_KITA"); } }); } catch (err) { if (err instanceof Error && err.message === "USER_ALREADY_HAS_KITA") { redirect("/dashboard"); } if (err instanceof Prisma.PrismaClientKnownRequestError) { return { attempt, values: echoValues(formData), errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] }, }; } throw err; } // Frischer JWT wird beim nächsten Request automatisch neu befüllt // (siehe jwt-Callback in auth.ts → re-validates from DB). redirect("/dashboard"); } // --------------------------------------------------------------------- // Helfer // --------------------------------------------------------------------- async function generateUniqueSlug( tx: Prisma.TransactionClient, name: string, ): Promise { const base = slugify(name) || "kita"; let candidate = base; let attempt = 0; // In der Praxis genügen 1-2 Iterationen; max. 50 als Safety-Net. while (attempt < 50) { const exists = await tx.kita.findUnique({ where: { slug: candidate } }); if (!exists) return candidate; attempt += 1; candidate = `${base}-${attempt + 1}`; } return `${base}-${Date.now()}`; } function slugify(input: string): string { return input .toLowerCase() .replace(/ß/g, "ss") .normalize("NFKD") // strippt kombinierende Diakritika (ä → a, ö → o, é → e …) .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, "") .slice(0, 60); }