189 lines
6.2 KiB
TypeScript
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);
|
|
}
|