Add non-destructive dev seed on startup

This commit is contained in:
t.indorf
2026-05-06 22:31:07 +02:00
parent 9f452fccac
commit b686e714ff
77 changed files with 10862 additions and 87 deletions
+144
View File
@@ -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);
}
+72
View File
@@ -0,0 +1,72 @@
import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client";
import { requireSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export const metadata = { title: "Datenschutz-Einwilligung · Kita-Planer" };
// Diese Seite wird nur in einem sehr seltenen Edge-Case erreicht:
// Ein User mit kitaId hat noch kein `privacyPolicyAcceptedAt` gesetzt
// (z.B. über einen alten Seed oder manuellen DB-Eintrag).
// Im normalen Registrierungs-Flow wird die Einwilligung bereits
// in registerAction() zum Zeitpunkt der Kontoerstellung erfasst.
export default async function ConsentPage() {
const session = await requireSession();
// SUPERADMIN benötigt keinen Consent-Flow — hat keinen Kita-Kontext.
if (session.user.role === UserRole.SUPERADMIN) {
redirect("/admin");
}
// User ohne kitaId gehört in den Onboarding-Wizard.
if (!session.user.kitaId) {
redirect("/onboarding");
}
// Prüfen, ob der Consent wirklich fehlt (DB-seitiger Check).
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { privacyPolicyAcceptedAt: true },
});
// Consent bereits vorhanden → weiter ins Dashboard.
if (user?.privacyPolicyAcceptedAt) {
redirect("/dashboard");
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-md">
<CardHeader className="space-y-2">
<CardTitle>Datenschutz-Einwilligung erforderlich</CardTitle>
<CardDescription>
Dein Konto wurde ohne Datenschutz-Einwilligung angelegt. Bitte
wende dich an deinen Kita-Administrator oder den Support, um deinen
Account zu reaktivieren.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<p>
Falls du glaubst, dass dies ein Fehler ist, kontaktiere bitte{" "}
<a
href="mailto:support@kita-planer.de"
className="font-medium text-foreground underline"
>
support@kita-planer.de
</a>
.
</p>
</CardContent>
</Card>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { completeOnboardingAction, type OnboardingState } from "./actions";
const initialState: OnboardingState = {};
export function OnboardingForm() {
const [state, formAction, pending] = useActionState(
completeOnboardingAction,
initialState,
);
return (
<form action={formAction} className="space-y-8">
{/* ----- Schritt 1: Name ----- */}
<fieldset className="space-y-4">
<legend className="text-sm font-semibold text-muted-foreground">
Schritt 1 · Grunddaten
</legend>
<div className="space-y-1.5">
<Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label>
<Input
id="kitaName"
name="kitaName"
required
placeholder="z.B. Waldameisen e.V."
aria-invalid={!!state.errors?.kitaName}
/>
{state.errors?.kitaName?.[0] && (
<p className="text-xs text-destructive">{state.errors.kitaName[0]}</p>
)}
</div>
</fieldset>
{/* ----- Schritt 2: Module ----- */}
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-muted-foreground">
Schritt 2 · Module aktivieren
</legend>
<ModuleCheckbox
name="notdienstModuleEnabled"
label="Notdienst-Planung"
description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren."
defaultChecked
/>
<ModuleCheckbox
name="terminModuleEnabled"
label="Terminkalender"
description="Kita-Feste, Schließtage und private Anfragen koordinieren."
defaultChecked
/>
<ModuleCheckbox
name="adressbuchModuleEnabled"
label="Eltern-Adressbuch"
description="Eltern können sich auf Opt-In-Basis untereinander finden."
defaultChecked
/>
</fieldset>
{/* ----- Schritt 3: Notdienst-Regeln ----- */}
<fieldset className="space-y-4">
<legend className="text-sm font-semibold text-muted-foreground">
Schritt 3 · Notdienst-Regel
</legend>
<div className="space-y-1.5">
<Label htmlFor="notdienstMinPerChildPerMonth">
Mindest-Verfügbarkeiten pro Kind und Monat
</Label>
<Input
id="notdienstMinPerChildPerMonth"
name="notdienstMinPerChildPerMonth"
type="number"
min={0}
max={31}
defaultValue={2}
required
aria-invalid={!!state.errors?.notdienstMinPerChildPerMonth}
/>
<p className="text-xs text-muted-foreground">
Wie viele Tage müssen Eltern pro Monat als verfügbar markieren?
Diesen Wert kannst du später jederzeit ändern.
</p>
{state.errors?.notdienstMinPerChildPerMonth?.[0] && (
<p className="text-xs text-destructive">
{state.errors.notdienstMinPerChildPerMonth[0]}
</p>
)}
</div>
</fieldset>
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
</p>
)}
<Button type="submit" className="w-full" disabled={pending} size="lg">
{pending ? "Wird eingerichtet…" : "Kita einrichten & loslegen"}
</Button>
</form>
);
}
function ModuleCheckbox({
name,
label,
description,
defaultChecked,
}: {
name: string;
label: string;
description: string;
defaultChecked?: boolean;
}) {
return (
<label className="flex items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40">
<Checkbox name={name} defaultChecked={defaultChecked} className="mt-0.5" />
<div className="grid gap-1 leading-none">
<span className="text-sm font-medium">{label}</span>
<span className="text-xs text-muted-foreground">{description}</span>
</div>
</label>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client";
import { requireSession } from "@/lib/auth-utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { OnboardingForm } from "./onboarding-form";
export const metadata = { title: "Kita einrichten · Kita-Planer" };
export default async function OnboardingPage() {
const session = await requireSession();
// Guard: User MIT bereits zugeordneter Kita gehört nicht hierhin.
if (session.user.kitaId) {
redirect("/dashboard");
}
// Superadmins haben grundsätzlich keine Kita; sie nutzen /admin.
if (session.user.role === UserRole.SUPERADMIN) {
redirect("/admin");
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-2xl">
<CardHeader className="space-y-2">
<CardTitle>Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!</CardTitle>
<CardDescription>
Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle
Einstellungen später noch anpassen.
</CardDescription>
</CardHeader>
<CardContent>
<OnboardingForm />
</CardContent>
</Card>
</div>
);
}