Add non-destructive dev seed on startup
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"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()
|
||||
.min(2, "Mindestens 2 Zeichen.")
|
||||
.max(120, "Maximal 120 Zeichen.")
|
||||
.trim(),
|
||||
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
notdienstMinPerChildPerMonth: z.coerce
|
||||
.number()
|
||||
.int("Bitte ganze Zahl.")
|
||||
.min(0, "Nicht negativ.")
|
||||
.max(31, "Maximal 31 Tage pro Monat."),
|
||||
});
|
||||
|
||||
function checkboxToBool(value: unknown): boolean {
|
||||
// HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData.
|
||||
return value === "on" || value === "true" || value === true;
|
||||
}
|
||||
|
||||
export type OnboardingState = {
|
||||
errors?: {
|
||||
kitaName?: string[];
|
||||
notdienstMinPerChildPerMonth?: string[];
|
||||
_form?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export async function completeOnboardingAction(
|
||||
_prev: OnboardingState,
|
||||
formData: FormData,
|
||||
): Promise<OnboardingState> {
|
||||
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 { 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 {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user