472 lines
13 KiB
TypeScript
472 lines
13 KiB
TypeScript
"use server";
|
|
|
|
import crypto from "crypto";
|
|
import { createElement } from "react";
|
|
import { revalidatePath } from "next/cache";
|
|
import { Prisma, UserRole } from "@prisma/client";
|
|
import { z } from "zod";
|
|
|
|
import { InviteEmail } from "@/emails/InviteEmail";
|
|
import { requireRole } from "@/lib/auth-utils";
|
|
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
const INVITE_TOKEN_TTL_DAYS = 7;
|
|
const BASE_URL =
|
|
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
|
|
|
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()
|
|
.min(1, "Mindestens ein Kind erforderlich.")
|
|
.max(10),
|
|
});
|
|
|
|
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?: {
|
|
familyName?: string[];
|
|
parent1FirstName?: string[];
|
|
parent1LastName?: string[];
|
|
parent1Email?: string[];
|
|
parent2FirstName?: string[];
|
|
parent2LastName?: string[];
|
|
parent2Email?: string[];
|
|
children?: string[];
|
|
_form?: string[];
|
|
};
|
|
success?: boolean;
|
|
};
|
|
|
|
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> {
|
|
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 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 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) => {
|
|
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 = 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,
|
|
},
|
|
});
|
|
}
|
|
|
|
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."],
|
|
},
|
|
};
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const emailError = await sendInviteJobs(kita.name, inviteJobs);
|
|
if (emailError) {
|
|
return {
|
|
errors: {
|
|
_form: [
|
|
`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 };
|
|
}
|