Files
kita-planer/src/app/dashboard/families/actions.ts
T

480 lines
13 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import {
deliverFamilyInvitations,
queueFamilyInvitation,
resendFamilyInvitation,
} from "@/lib/family-invitations";
import { prisma } from "@/lib/prisma";
const childSchema = z.object({
firstName: z.string().trim().min(1, "Vorname des Kindes fehlt.").max(100),
lastName: z.string().trim().min(1, "Nachname des Kindes fehlt.").max(100),
});
const parentInputSchema = z.object({
firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100),
lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100),
email: z
.string()
.trim()
.toLowerCase()
.email("Bitte eine gültige E-Mail-Adresse angeben."),
});
const addFamilySchema = z.object({
familyName: z
.string()
.trim()
.min(1, "Familienname ist erforderlich.")
.max(120),
parent1FirstName: z
.string()
.trim()
.min(1, "Vorname ist erforderlich.")
.max(100),
parent1LastName: z
.string()
.trim()
.min(1, "Nachname ist erforderlich.")
.max(100),
parent1Email: z
.string()
.trim()
.toLowerCase()
.email("Bitte eine gültige E-Mail-Adresse angeben."),
parent2FirstName: z.string().max(100).trim().optional(),
parent2LastName: z.string().max(100).trim().optional(),
parent2Email: z.string().trim().optional(),
childCount: z.coerce
.number()
.int()
.min(1, "Mindestens ein Kind erforderlich.")
.max(10),
});
const updateFamilySchema = z.object({
familyId: z.string().min(1),
familyName: z
.string()
.trim()
.min(1, "Familienname ist erforderlich.")
.max(120),
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?: {
familyName?: string[];
parent1FirstName?: string[];
parent1LastName?: string[];
parent1Email?: string[];
parent2FirstName?: string[];
parent2LastName?: string[];
parent2Email?: string[];
children?: string[];
_form?: string[];
};
success?: boolean;
warning?: 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 };
}
async function sendInviteJobs(ids: string[]) {
try {
const result = await deliverFamilyInvitations(ids);
if (!result.failed) return undefined;
} catch {
// A committed family remains successful even if delivery/status storage fails.
}
return "Familie gespeichert. Mindestens eine Einladung konnte nicht versendet werden. Den Versand kannst du beim jeweiligen Elternteil erneut versuchen.";
}
export async function addFamilyAction(
_prev: AddFamilyState,
formData: FormData,
): Promise<AddFamilyState> {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
}
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(", ")}`,
],
},
};
}
childrenRaw.push(parsed.data);
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { errors: { _form: ["Kita wurde nicht gefunden."] } };
}
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: string[] = [];
try {
await prisma.$transaction(async (tx) => {
const family = await tx.family.create({
data: {
kitaId,
name: parsedFamily.data.familyName,
},
});
for (const parent of parents) {
const createdParent = await tx.user.create({
data: {
kitaId,
familyId: family.id,
email: parent.email,
firstName: parent.firstName,
lastName: parent.lastName,
passwordHash: "",
role: UserRole.ELTERN,
},
});
const invite = await queueFamilyInvitation(
tx,
createdParent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
await tx.child.createMany({
data: childrenRaw.map((child) => ({
kitaId,
familyId: family.id,
firstName: child.firstName,
lastName: child.lastName,
})),
});
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return {
errors: {
_form: ["Mindestens eine E-Mail-Adresse existiert bereits."],
},
};
}
console.error("Familie konnte nicht angelegt werden:", err);
return {
errors: {
_form: [
"Familie konnte nicht angelegt werden. Bitte erneut versuchen.",
],
},
};
}
const warning = await sendInviteJobs(inviteJobs);
revalidatePath("/dashboard/families");
revalidatePath("/dashboard");
return { success: true, warning };
}
export async function updateFamilyAction(
rawPayload: unknown,
): Promise<{ success?: boolean; error?: string; warning?: string }> {
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." };
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { error: "Kita wurde nicht gefunden." };
}
const inviteJobs: string[] = [];
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) {
const previous = await tx.user.findUniqueOrThrow({
where: { id: parent.id },
});
await tx.user.update({
where: { id: parent.id },
data: {
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
},
});
if (!previous.emailVerifiedAt && previous.email !== parent.email) {
const invite = await queueFamilyInvitation(
tx,
parent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
}
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 = await queueFamilyInvitation(
tx,
createdParent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
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 warning = await sendInviteJobs(inviteJobs);
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard");
return { success: true, warning };
}
export async function resendFamilyInvitationAction(
userId: string,
): Promise<{ success?: boolean; error?: string; warning?: string }> {
const session = await requireRole([UserRole.ADMIN]);
if (!session.user.kitaId || typeof userId !== "string" || !userId)
return { error: "Ungültiges Elternkonto." };
let result;
try {
result = await resendFamilyInvitation(
session.user.kitaId,
userId,
session.user.id,
);
} catch {
result = {
error:
"Einladung konnte nicht erneut versendet werden. Bitte den Status prüfen und erneut versuchen.",
};
}
revalidatePath("/dashboard/families");
return result;
}