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

399 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { NotdienstPlanStatus } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import {
CHILD_MAX_AGE_YEARS,
getChildBirthdateRange,
parseDateInput,
} from "@/lib/child-birthdate";
import { serializableTransaction } from "@/lib/transaction";
import {
assertKitaAdminRemains,
lockKitaAccounts,
} from "@/lib/kita-admin-guard";
import { prisma } from "@/lib/prisma";
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
import { UserFacingError, publicActionError } from "@/lib/action-error";
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
// Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
// Schließt damit "abc-123-not-a-phone" aus, lässt aber internationale Formate zu.
const phonePattern = /^[+]?[0-9][\d\s\-()/.]{4,}$/;
// PLZ: Ziffern/Buchstaben/Leerzeichen/Bindestriche, 3–10 Zeichen.
// Kein striktes DE-Format (5 Ziffern), damit auch AT/CH/UK denkbar sind.
const postalCodePattern = /^[0-9A-Za-z][0-9A-Za-z\s-]{2,9}$/;
const childSchema = z
.object({
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
dateOfBirth: z.string().optional(),
})
.superRefine((data, ctx) => {
if (!data.dateOfBirth) return;
const parsed = parseDateInput(data.dateOfBirth);
if (!parsed) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Bitte ein gültiges Datum angeben.",
});
return;
}
const { min, max } = getChildBirthdateRange();
if (data.dateOfBirth > max) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Geburtsdatum darf nicht in der Zukunft liegen.",
});
}
if (data.dateOfBirth < min) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: `Kinder dürfen maximal ${CHILD_MAX_AGE_YEARS} Jahre alt sein.`,
});
}
});
const familySchema = z.object({
familyName: z
.string()
.min(1, "Familienname ist erforderlich.")
.max(120)
.trim(),
});
const contactSchema = z.object({
phone: z
.string()
.trim()
.max(50)
.optional()
.refine(
(v) => !v || phonePattern.test(v),
"Bitte eine gültige Telefonnummer angeben.",
),
street: z.string().trim().max(120).optional(),
postalCode: z
.string()
.trim()
.max(20)
.optional()
.refine(
(v) => !v || postalCodePattern.test(v),
"Bitte eine gültige Postleitzahl angeben.",
),
city: z.string().trim().max(100).optional(),
directoryOptIn: z.boolean().optional(),
});
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
// damit der User nicht nur "Ungültige Eingabedaten" sieht.
function firstZodMessage(error: z.ZodError, fallback: string): string {
const issue = error.issues[0];
return issue?.message || fallback;
}
async function requireOwnFamilyChild(
childId: string,
familyId: string | null,
kitaId: string,
) {
if (!familyId) return null;
return prisma.child.findFirst({
where: { id: childId, familyId, kitaId },
});
}
export async function createMyChild(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = childSchema.safeParse(rawPayload);
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
if (!parsed.success) {
return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
}
try {
await serializableTransaction(async (tx) => {
const count = await tx.child.count({
where: {
kitaId: session.user.kitaId,
familyId: session.user.familyId!,
},
});
if (count >= 20) throw new Error("Household child limit reached");
await tx.child.create({
data: {
kitaId: session.user.kitaId,
familyId: session.user.familyId!,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
},
});
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen des Kindes:", error);
return { error: "Das Kind konnte nicht angelegt werden." };
}
}
export async function createMyFamily(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = familySchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Bitte gib einen gültigen Familiennamen ein." };
}
if (session.user.familyId) {
return { error: "Dein Account ist bereits einem Haushalt zugeordnet." };
}
try {
await prisma.$transaction(async (tx) => {
const family = await tx.family.create({
data: {
kitaId: session.user.kitaId,
name: parsed.data.familyName,
},
});
const attached = await tx.user.updateMany({
where: {
id: session.user.id,
kitaId: session.user.kitaId,
familyId: null,
disabledAt: null,
sessionVersion: session.user.sessionVersion,
},
data: { familyId: family.id },
});
if (attached.count !== 1)
throw new Error("Account already assigned or revoked");
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/adressbuch");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
if (
!(
error instanceof Error &&
error.message === "Account already assigned or revoked"
)
) {
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
}
return { error: "Der Haushalt konnte nicht angelegt werden." };
}
}
export async function updateMyContact(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = contactSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: firstZodMessage(parsed.error, "Ungültige Kontaktdaten.") };
}
try {
const currentUser = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
select: { directoryOptInAt: true },
});
let newDirectoryOptInAt: Date | null = currentUser.directoryOptInAt;
if (parsed.data.directoryOptIn) {
if (!newDirectoryOptInAt) {
newDirectoryOptInAt = new Date();
}
} else {
newDirectoryOptInAt = null;
}
await prisma.user.update({
where: { id: session.user.id },
data: {
phone: parsed.data.phone || null,
street: parsed.data.street || null,
postalCode: parsed.data.postalCode || null,
city: parsed.data.city || null,
directoryOptInAt: newDirectoryOptInAt,
},
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard");
revalidatePath("/dashboard/adressbuch");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren der Kontaktdaten:", error);
return { error: "Kontaktdaten konnten nicht gespeichert werden." };
}
}
export async function updateMyChild(childId: string, rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = childSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
}
try {
const child = await requireOwnFamilyChild(
childId,
session.user.familyId,
session.user.kitaId,
);
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
await prisma.child.update({
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId!,
},
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
},
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren des Kindes:", error);
return { error: "Das Kind konnte nicht aktualisiert werden." };
}
}
export async function deleteMyChild(childId: string) {
const session = await requireKitaSession();
try {
await serializableTransaction(async (tx) => {
if (!session.user.familyId)
throw new UserFacingError(
"Dieses Kind gehört nicht zu deinem Haushalt.",
);
const child = await tx.child.findFirst({
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId,
},
select: { id: true },
});
if (!child)
throw new UserFacingError(
"Dieses Kind gehört nicht zu deinem Haushalt.",
);
await assertChildrenRemovable(
tx,
session.user.kitaId,
session.user.familyId,
[childId],
);
await tx.child.delete({
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId,
},
});
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
if (!(error instanceof UserFacingError))
console.error("Fehler beim Entfernen des Kindes:", error);
return {
error: publicActionError(error, "Das Kind konnte nicht entfernt werden."),
};
}
}
export async function deleteMyAccount(): Promise<{
success?: boolean;
error?: string;
}> {
const session = await requireKitaSession();
try {
return await serializableTransaction(async (tx) => {
await lockKitaAccounts(tx, session.user.kitaId);
const user = await tx.user.findFirst({
where: { id: session.user.id, kitaId: session.user.kitaId },
select: { id: true, familyId: true },
});
if (!user) throw new UserFacingError("Account wurde nicht gefunden.");
await assertKitaAdminRemains(tx, session.user.kitaId, [user.id]);
const today = new Date();
today.setHours(0, 0, 0, 0);
const assignments = user.familyId
? await tx.notdienstAssignment.count({
where: {
kitaId: session.user.kitaId,
date: { gte: today },
plan: { status: NotdienstPlanStatus.PUBLISHED },
child: { familyId: user.familyId },
},
})
: 0;
if (assignments)
throw new UserFacingError(
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
);
if (user.familyId) {
const family = await tx.family.findFirst({
where: { id: user.familyId, kitaId: session.user.kitaId },
select: { id: true, users: { select: { id: true } } },
});
if (!family)
throw new UserFacingError("Haushalt wurde nicht gefunden.");
if (family.users.length <= 1) {
await tx.family.delete({ where: { id: family.id } });
return { success: true };
}
}
await tx.user.delete({ where: { id: user.id } });
return { success: true };
});
} catch (error) {
if (!(error instanceof UserFacingError))
console.error("Fehler beim Löschen des Accounts:", error);
return {
error: publicActionError(
error,
"Ein Fehler ist beim Löschen aufgetreten.",
),
};
}
}