Add non-destructive dev seed on startup
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user