continued the kita-planer

This commit is contained in:
t.indorf
2026-05-08 14:32:14 +02:00
parent b686e714ff
commit 7aff691803
85 changed files with 9434 additions and 588 deletions
+363 -86
View File
@@ -2,30 +2,46 @@
import crypto from "crypto";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
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 { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
// =====================================================================
// /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 INVITE_TOKEN_TTL_DAYS = 7;
const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const parentSchema = z.object({
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
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(),
});
const parentInputSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase()
.trim(),
});
const addFamilySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
parent1FirstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
parent1LastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
parent1Email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase()
.trim(),
parent2FirstName: z.string().max(100).trim().optional(),
parent2LastName: z.string().max(100).trim().optional(),
parent2Email: z.string().trim().optional(),
childCount: z.coerce
.number()
.int()
@@ -33,65 +49,139 @@ const parentSchema = z.object({
.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(),
const updateFamilySchema = z.object({
familyId: z.string().min(1),
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
parents: z.array(
parentInputSchema.extend({
id: z.string().min(1),
}),
).min(1).max(2),
newParents: z.array(parentInputSchema).max(1).default([]),
children: z
.array(
childSchema.extend({
id: z.string().min(1),
}),
)
.max(20),
newChildren: z.array(childSchema).max(10).default([]),
removedChildIds: z.array(z.string().min(1)).max(20).default([]),
});
export type AddFamilyState = {
errors?: {
firstName?: string[];
lastName?: string[];
email?: string[];
familyName?: string[];
parent1FirstName?: string[];
parent1LastName?: string[];
parent1Email?: string[];
parent2FirstName?: string[];
parent2LastName?: string[];
parent2Email?: 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";
type InviteJob = {
to: string;
parentName: string;
inviteUrl: string;
};
function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
const firstName = data.parent2FirstName?.trim() ?? "";
const lastName = data.parent2LastName?.trim() ?? "";
const email = data.parent2Email?.trim() ?? "";
if (!firstName && !lastName && !email) {
return { ok: true as const, parent: null };
}
const parsed = parentInputSchema.safeParse({ firstName, lastName, email });
if (!parsed.success) {
return {
ok: false as const,
errors: {
parent2FirstName: parsed.error.flatten().fieldErrors.firstName,
parent2LastName: parsed.error.flatten().fieldErrors.lastName,
parent2Email: parsed.error.flatten().fieldErrors.email,
},
};
}
return { ok: true as const, parent: parsed.data };
}
function createInviteToken() {
const token = crypto.randomUUID();
const expires = new Date(
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
);
return { token, expires, inviteUrl: `${BASE_URL}/invite/${token}` };
}
async function sendInviteJobs(kitaName: string, jobs: InviteJob[]) {
for (const job of jobs) {
const result = await sendAppEmail({
to: job.to,
subject: `Einladung zu ${kitaName} im Kita-Planer`,
react: createElement(InviteEmail, {
parentName: job.parentName,
kitaName,
inviteLink: job.inviteUrl,
}),
});
if (!result.success) {
return result.error;
}
}
return null;
}
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]);
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
// ── 2. Parent-Felder validieren ────────────────────────────────────
const parsedParent = parentSchema.safeParse(Object.fromEntries(formData));
if (!parsedParent.success) {
return { errors: parsedParent.error.flatten().fieldErrors };
if (!kitaId) {
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
}
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 parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
if (!parsedFamily.success) {
return { errors: parsedFamily.error.flatten().fieldErrors };
}
const optionalParent = optionalParentFromForm(parsedFamily.data);
if (!optionalParent.ok) {
return { errors: optionalParent.errors };
}
const childrenRaw: z.infer<typeof childSchema>[] = [];
for (let i = 0; i < parsedFamily.data.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(", ")}`] },
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] } };
@@ -106,54 +196,68 @@ export async function addFamilyAction(
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,
);
const parents = [
{
firstName: parsedFamily.data.parent1FirstName,
lastName: parsedFamily.data.parent1LastName,
email: parsedFamily.data.parent1Email,
},
...(optionalParent.parent ? [optionalParent.parent] : []),
];
const emailSet = new Set(parents.map((parent) => parent.email));
if (emailSet.size !== parents.length) {
return {
errors: {
_form: ["Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein."],
},
};
}
const inviteJobs: InviteJob[] = [];
try {
await prisma.$transaction(async (tx) => {
// 4a. Elternteil anlegen (kein Passwort → leerer passwordHash)
const parent = await tx.user.create({
const family = await tx.family.create({
data: {
email,
firstName,
lastName,
passwordHash: "", // wird beim Invite-Einlösen gesetzt
role: UserRole.ELTERN,
kitaId,
name: parsedFamily.data.familyName,
},
});
// 4b. Kinder anlegen + mit Elternteil verknüpfen
for (const child of childrenRaw) {
const createdChild = await tx.child.create({
for (const parent of parents) {
const createdParent = await tx.user.create({
data: {
kitaId,
firstName: child.firstName,
lastName: child.lastName,
familyId: family.id,
email: parent.email,
firstName: parent.firstName,
lastName: parent.lastName,
passwordHash: "",
role: UserRole.ELTERN,
},
});
await tx.childParent.create({
const invite = createInviteToken();
inviteJobs.push({
to: parent.email,
parentName: `${parent.firstName} ${parent.lastName}`,
inviteUrl: invite.inviteUrl,
});
await tx.verificationToken.create({
data: {
kitaId,
childId: createdChild.id,
userId: parent.id,
identifier: createdParent.id,
token: invite.token,
expires: invite.expires,
},
});
}
// 4c. Einladungs-Token erstellen
// identifier = userId (kein PII im Token selbst)
await tx.verificationToken.create({
data: {
identifier: parent.id,
token,
expires,
},
await tx.child.createMany({
data: childrenRaw.map((child) => ({
kitaId,
familyId: family.id,
firstName: child.firstName,
lastName: child.lastName,
})),
});
});
} catch (err) {
@@ -163,32 +267,205 @@ export async function addFamilyAction(
) {
return {
errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
_form: ["Mindestens eine E-Mail-Adresse existiert bereits."],
},
};
}
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) {
const emailError = await sendInviteJobs(kita.name, inviteJobs);
if (emailError) {
return {
errors: {
_form: [
`Familie wurde angelegt, aber die Einladung konnte nicht versendet werden: ${emailResult.error}`,
`Familie wurde angelegt, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
],
},
};
}
revalidatePath("/dashboard/families");
revalidatePath("/dashboard");
return { success: true };
}
export async function updateFamilyAction(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = updateFamilySchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const {
familyId,
familyName,
parents,
newParents,
children,
newChildren,
removedChildIds,
} = parsed.data;
const parentEmails = [...parents, ...newParents].map((parent) => parent.email);
if (new Set(parentEmails).size !== parentEmails.length) {
return { error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein." };
}
const removedChildIdSet = new Set(removedChildIds);
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
if (activeChildren.length + newChildren.length === 0) {
return { error: "Eine Familie benötigt mindestens ein Kind." };
}
if (newParents.length > 0) {
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { error: "Kita wurde nicht gefunden." };
}
const inviteJobs: InviteJob[] = [];
try {
const existingFamily = await prisma.family.findFirst({
where: { id: familyId, kitaId },
select: {
id: true,
users: { select: { id: true } },
children: { select: { id: true } },
},
});
if (!existingFamily) {
return { error: "Familie wurde nicht gefunden." };
}
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
return { error: "Mindestens ein Elternteil gehört nicht zu dieser Familie." };
}
if (!children.every((child) => existingChildIds.has(child.id))) {
return { error: "Mindestens ein Kind gehört nicht zu dieser Familie." };
}
if (!removedChildIds.every((childId) => existingChildIds.has(childId))) {
return { error: "Mindestens ein Kind gehört nicht zu dieser Familie." };
}
if (existingParentIds.size + newParents.length > 2) {
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
}
await prisma.$transaction(async (tx) => {
await tx.family.update({
where: { id: familyId },
data: { name: familyName },
});
for (const parent of parents) {
await tx.user.update({
where: { id: parent.id },
data: {
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
},
});
}
for (const parent of newParents) {
const createdParent = await tx.user.create({
data: {
kitaId,
familyId,
email: parent.email,
firstName: parent.firstName,
lastName: parent.lastName,
passwordHash: "",
role: UserRole.ELTERN,
},
});
const invite = createInviteToken();
inviteJobs.push({
to: parent.email,
parentName: `${parent.firstName} ${parent.lastName}`,
inviteUrl: invite.inviteUrl,
});
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: invite.token,
expires: invite.expires,
},
});
}
for (const child of activeChildren) {
await tx.child.update({
where: { id: child.id },
data: {
firstName: child.firstName,
lastName: child.lastName,
},
});
}
for (const childId of removedChildIds) {
await tx.child.delete({ where: { id: childId } });
}
for (const child of newChildren) {
await tx.child.create({
data: {
kitaId,
familyId,
firstName: child.firstName,
lastName: child.lastName,
},
});
}
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
}
console.error("Fehler beim Aktualisieren der Familie:", error);
return { error: "Familie konnte nicht aktualisiert werden." };
}
const emailError = await sendInviteJobs(kita.name, inviteJobs);
if (emailError) {
return {
error: `Familie wurde aktualisiert, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
};
}
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard");
return { success: true };
}