continued the kita-planer
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user