399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
"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.",
|
||
),
|
||
};
|
||
}
|
||
}
|