495 lines
14 KiB
TypeScript
495 lines
14 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 { rateLimit } from "@/lib/rate-limit";
|
|
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."] } };
|
|
}
|
|
|
|
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { errors: { _form: ["Zu viele Einladungen. Bitte versuche es später erneut."] } };
|
|
|
|
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 > 20) return { error: "Maximal 20 Kinder pro Familie." };
|
|
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, email: true } },
|
|
children: { select: { id: true } },
|
|
},
|
|
});
|
|
|
|
if (!existingFamily) {
|
|
return { error: "Familie wurde nicht gefunden." };
|
|
}
|
|
|
|
const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email);
|
|
if (needsEmail) {
|
|
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." };
|
|
}
|
|
|
|
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 current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } });
|
|
if (!current) throw new Error("Parent assignment changed");
|
|
const emailChanged = current.email !== parent.email;
|
|
await tx.user.update({
|
|
where: { id: parent.id, kitaId, familyId },
|
|
data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email,
|
|
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
|
|
});
|
|
if (emailChanged) {
|
|
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, kitaId, familyId },
|
|
data: {
|
|
firstName: child.firstName,
|
|
lastName: child.lastName,
|
|
},
|
|
});
|
|
}
|
|
|
|
for (const childId of removedChildIds) {
|
|
await tx.child.delete({ where: { id: childId, kitaId, familyId } });
|
|
}
|
|
|
|
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 || userId.length > 64)
|
|
return { error: "Ungültiges Elternkonto." };
|
|
if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed)
|
|
return { error: "Bitte versuche es später erneut." };
|
|
if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed)
|
|
return { error: "Bitte versuche es später erneut." };
|
|
const recipient = await prisma.user.findFirst({
|
|
where: { id: userId, kitaId: session.user.kitaId, role: UserRole.ELTERN,
|
|
emailVerifiedAt: null, passwordHash: "", disabledAt: null },
|
|
select: { email: true },
|
|
});
|
|
if (!recipient) return { error: "Keine offene Einladung gefunden." };
|
|
let result;
|
|
try {
|
|
result = await resendFamilyInvitation(
|
|
session.user.kitaId,
|
|
userId,
|
|
session.user.id,
|
|
recipient.email,
|
|
);
|
|
} catch {
|
|
result = {
|
|
error:
|
|
"Einladung konnte nicht erneut versendet werden. Bitte den Status prüfen und erneut versuchen.",
|
|
};
|
|
}
|
|
revalidatePath("/dashboard/families");
|
|
return result;
|
|
}
|
|
|
|
export async function resendParentInvite(userId: string) {
|
|
return resendFamilyInvitationAction(userId);
|
|
}
|