Files
kita-planer/src/app/onboarding/actions.ts
T
2026-05-19 11:09:53 +02:00

189 lines
6.2 KiB
TypeScript

"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<OnboardingState> {
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<string> {
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);
}