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
+194
View File
@@ -0,0 +1,194 @@
"use server";
import crypto from "crypto";
import { createElement } from "react";
import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireRole } from "@/lib/auth-utils";
import { InviteEmail } from "@/emails/InviteEmail";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
// =====================================================================
// /dashboard/families · Server Actions
// ---------------------------------------------------------------------
// addFamilyAction: Erstellt Elternteil + Kinder + VerificationToken in
// einer atomaren Prisma-Transaktion. Die kitaId kommt ausschließlich
// aus der validierten Session — nie aus dem Formular-Input.
// =====================================================================
const parentSchema = z.object({
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase()
.trim(),
childCount: z.coerce
.number()
.int()
.min(1, "Mindestens ein Kind erforderlich.")
.max(10),
});
const childSchema = z.object({
firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(),
lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(),
});
export type AddFamilyState = {
errors?: {
firstName?: string[];
lastName?: string[];
email?: string[];
children?: string[];
_form?: string[];
};
success?: boolean;
};
const PRIVACY_POLICY_VERSION = "2026-05-01";
const INVITE_TOKEN_TTL_DAYS = 7;
const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
export async function addFamilyAction(
_prev: AddFamilyState,
formData: FormData,
): Promise<AddFamilyState> {
// ── 1. Nur Admins dürfen Familien anlegen ──────────────────────────
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
// ── 2. Parent-Felder validieren ────────────────────────────────────
const parsedParent = parentSchema.safeParse(Object.fromEntries(formData));
if (!parsedParent.success) {
return { errors: parsedParent.error.flatten().fieldErrors };
}
const { firstName, lastName, email, childCount } = parsedParent.data;
// ── 3. Kinder-Felder validieren ────────────────────────────────────
const childrenRaw: { firstName: string; lastName: string }[] = [];
for (let i = 0; i < childCount; i++) {
const parsed = childSchema.safeParse({
firstName: formData.get(`childFirstName_${i}`),
lastName: formData.get(`childLastName_${i}`),
});
if (!parsed.success) {
return {
errors: { children: [`Kind ${i + 1}: ${Object.values(parsed.error.flatten().fieldErrors).flat().join(", ")}`] },
};
}
childrenRaw.push(parsed.data);
}
// ── 4. Datenbank-Transaktion ───────────────────────────────────────
// kitaId kommt ausschließlich aus der Session (Mandanten-Isolation!).
// SUPERADMIN hat keine kitaId → dieser Pfad sollte nie erreicht werden,
// aber wir prüfen explizit, um den Typ zu narrowen.
const kitaId = session.user.kitaId;
if (!kitaId) {
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { errors: { _form: [mailConfigError] } };
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { errors: { _form: ["Kita wurde nicht gefunden."] } };
}
const parentName = `${firstName} ${lastName}`;
const token = crypto.randomUUID();
const inviteUrl = `${BASE_URL}/invite/${token}`;
const expires = new Date(
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
);
try {
await prisma.$transaction(async (tx) => {
// 4a. Elternteil anlegen (kein Passwort → leerer passwordHash)
const parent = await tx.user.create({
data: {
email,
firstName,
lastName,
passwordHash: "", // wird beim Invite-Einlösen gesetzt
role: UserRole.ELTERN,
kitaId,
},
});
// 4b. Kinder anlegen + mit Elternteil verknüpfen
for (const child of childrenRaw) {
const createdChild = await tx.child.create({
data: {
kitaId,
firstName: child.firstName,
lastName: child.lastName,
},
});
await tx.childParent.create({
data: {
kitaId,
childId: createdChild.id,
userId: parent.id,
},
});
}
// 4c. Einladungs-Token erstellen
// identifier = userId (kein PII im Token selbst)
await tx.verificationToken.create({
data: {
identifier: parent.id,
token,
expires,
},
});
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return {
errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
},
};
}
throw err;
}
const emailResult = await sendAppEmail({
to: email,
subject: `Einladung zu ${kita.name} im Kita-Planer`,
react: createElement(InviteEmail, {
parentName,
kitaName: kita.name,
inviteLink: inviteUrl,
}),
});
if (!emailResult.success) {
return {
errors: {
_form: [
`Familie wurde angelegt, aber die Einladung konnte nicht versendet werden: ${emailResult.error}`,
],
},
};
}
return { success: true };
}