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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user