Merge family absence flows while preserving security hardening

This commit is contained in:
t.indorf
2026-10-10 07:08:41 +02:00
30 changed files with 2822 additions and 644 deletions
+223 -123
View File
@@ -1,24 +1,33 @@
"use server";
import { revalidatePath } from "next/cache";
import { AbsenceReason, UserRole } from "@prisma/client";
import { AbsenceReason, Prisma, UserRole } from "@prisma/client";
import { dateOnlySchema } from "@/lib/date-schema";
import { z } from "zod";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import {
absenceDate,
absenceToday,
canManageKitaAbsences,
} from "@/lib/absence-policy";
const reportAbsenceSchema = z.object({
childId: z.string().min(1),
startDate: dateOnlySchema,
endDate: dateOnlySchema,
const dateSchema = dateOnlySchema;
const fieldsSchema = z.object({
startDate: dateSchema,
endDate: dateSchema,
reason: z.nativeEnum(AbsenceReason),
note: z.string().trim().max(500).optional(),
});
const dailyAbsenceSchema = z.object({
date: dateOnlySchema,
const reportSchema = fieldsSchema.extend({
childId: z.string().min(1).max(128),
});
const identitySchema = z.object({
id: z.string().min(1).max(128),
updatedAt: z.string().datetime({ offset: true }),
});
const updateSchema = fieldsSchema.extend(identitySchema.shape);
type MutationResult = { success?: boolean; error?: string };
export type DailyAbsenceDto = {
id: string;
@@ -28,112 +37,216 @@ export type DailyAbsenceDto = {
note: string | null;
startDate: string;
endDate: string;
updatedAt: string;
};
function parseDateOnly(value: string) {
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
if (Number.isNaN(date.getTime())) {
throw new Error("Ungueltiges Datum.");
}
return date;
}
function toDateKey(date: Date) {
return date.toISOString().slice(0, 10);
}
export async function reportAbsence(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = reportAbsenceSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
const startDate = parseDateOnly(parsed.data.startDate);
const endDate = parseDateOnly(parsed.data.endDate);
if (endDate < startDate) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
}
const child = await prisma.child.findFirst({
where: {
id: parsed.data.childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId,
active: true,
},
select: { id: true },
});
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
const overlappingAbsence = await prisma.absence.findFirst({
where: {
kitaId: session.user.kitaId,
childId: child.id,
startDate: { lte: endDate },
endDate: { gte: startDate },
},
select: { id: true },
});
if (overlappingAbsence) {
return { error: "Für diesen Zeitraum existiert bereits eine Abmeldung." };
}
await prisma.absence.create({
data: {
kitaId: session.user.kitaId,
childId: child.id,
startDate,
endDate,
reason: parsed.data.reason,
note: parsed.data.note || null,
},
});
function refreshAbsences() {
revalidatePath("/dashboard");
revalidatePath("/dashboard/abwesenheiten");
revalidatePath("/dashboard/admin/abwesenheiten");
return { success: true };
}
export async function deleteAbsence(id: string) {
const session = await requireKitaSession();
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
const absence = await prisma.absence.findFirst({
async function currentActor(
tx: Prisma.TransactionClient,
id: string,
kitaId: string,
) {
return tx.user.findFirst({
where: {
id,
kitaId: session.user.kitaId,
child: {
familyId: session.user.familyId,
kitaId,
role: {
in: [
UserRole.ELTERN,
UserRole.ADMIN,
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
],
},
},
select: { id: true },
select: { id: true, role: true, familyId: true },
});
}
if (!absence) {
return { error: "Diese Abmeldung gehört nicht zu deinem Haushalt." };
}
async function lockChild(
tx: Prisma.TransactionClient,
childId: string,
kitaId: string,
) {
// Serializes overlapping create/edit/delete operations for this child.
await tx.$queryRaw`SELECT "id" FROM "children" WHERE "id" = ${childId} AND "kitaId" = ${kitaId} FOR UPDATE`;
}
await prisma.absence.delete({
where: { id: absence.id },
export async function reportAbsence(
rawPayload: unknown,
): Promise<MutationResult> {
const session = await requireKitaSession();
const parsed = reportSchema.safeParse(rawPayload);
if (!parsed.success)
return {
error:
"Bitte gültige Daten und einen Grund angeben. Die Bemerkung darf höchstens 500 Zeichen haben.",
};
const { childId, startDate, endDate, reason, note } = parsed.data;
if (endDate < startDate)
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
const result = await prisma.$transaction(async (tx) => {
const actor = await currentActor(tx, session.user.id, session.user.kitaId);
if (!actor?.familyId)
return { error: "Dein Account ist keinem Haushalt zugeordnet." };
if (!canManageKitaAbsences(actor.role) && startDate < absenceToday())
return {
error:
"Vergangene Tage kann nur die Kita nachtragen. Bitte melde dich dort.",
};
await lockChild(tx, childId, session.user.kitaId);
const child = await tx.child.findFirst({
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: actor.familyId,
active: true,
},
select: { id: true },
});
if (!child)
return {
error:
"Dieses Kind gehört nicht zu deinem Haushalt oder ist nicht mehr aktiv.",
};
const start = absenceDate(startDate),
end = absenceDate(endDate);
const overlap = await tx.absence.findFirst({
where: {
kitaId: session.user.kitaId,
childId,
startDate: { lte: end },
endDate: { gte: start },
},
select: { id: true },
});
if (overlap)
return { error: "Für diesen Zeitraum existiert bereits eine Abmeldung." };
await tx.absence.create({
data: {
kitaId: session.user.kitaId,
childId,
startDate: start,
endDate: end,
reason,
note: note || null,
},
});
return { success: true };
});
if (result.success) refreshAbsences();
return result;
}
revalidatePath("/dashboard");
revalidatePath("/dashboard/admin/abwesenheiten");
return { success: true };
async function changeAbsence(
rawPayload: unknown,
remove: boolean,
): Promise<MutationResult> {
const session = await requireKitaSession();
const parsed = remove
? identitySchema.safeParse(rawPayload)
: updateSchema.safeParse(rawPayload);
if (!parsed.success)
return {
error:
"Ungültige Eingabedaten. Bitte die Ansicht aktualisieren und gültige Daten angeben.",
};
const input = parsed.data;
const fields = remove ? null : updateSchema.parse(rawPayload);
if (fields && fields.endDate < fields.startDate)
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
const result = await prisma.$transaction(async (tx) => {
const actor = await currentActor(tx, session.user.id, session.user.kitaId);
if (!actor) return { error: "Keine Berechtigung für diese Meldung." };
const team = canManageKitaAbsences(actor.role);
if (!team && !actor.familyId)
return { error: "Dein Account ist keinem Haushalt zugeordnet." };
const where: Prisma.AbsenceWhereInput = {
id: input.id,
kitaId: session.user.kitaId,
child: {
kitaId: session.user.kitaId,
...(team ? {} : { familyId: actor.familyId! }),
},
};
const candidate = await tx.absence.findFirst({
where,
select: { childId: true },
});
if (!candidate)
return {
error:
"Meldung wurde nicht gefunden oder gehört nicht zu deinem Haushalt.",
};
await lockChild(tx, candidate.childId, session.user.kitaId);
const absence = await tx.absence.findFirst({ where });
if (!absence)
return {
error:
"Die Meldung ist nicht mehr vorhanden. Bitte die Ansicht aktualisieren.",
};
if (
absence.updatedAt.toISOString() !==
new Date(input.updatedAt).toISOString()
)
return {
error:
"Die Meldung wurde inzwischen geändert. Bitte die Ansicht aktualisieren.",
};
const today = absenceToday();
if (
!team &&
(absence.startDate.toISOString().slice(0, 10) <= today ||
(fields && fields.startDate <= today))
)
return {
error:
"Bereits begonnene Meldungen und laufende Tage verwaltet die Kita. Bitte melde dich dort.",
};
if (remove) await tx.absence.delete({ where: { id: absence.id } });
else if (fields) {
const startDate = absenceDate(fields.startDate),
endDate = absenceDate(fields.endDate);
const overlap = await tx.absence.findFirst({
where: {
kitaId: session.user.kitaId,
childId: absence.childId,
id: { not: absence.id },
startDate: { lte: endDate },
endDate: { gte: startDate },
},
select: { id: true },
});
if (overlap)
return {
error: "Für diesen Zeitraum existiert bereits eine andere Abmeldung.",
};
await tx.absence.update({
where: { id: absence.id },
data: {
startDate,
endDate,
reason: fields.reason,
note: fields.note || null,
},
});
}
return { success: true };
});
if (result.success) refreshAbsences();
return result;
}
export async function updateAbsence(rawPayload: unknown) {
return changeAbsence(rawPayload, false);
}
export async function deleteAbsence(rawPayload: unknown) {
return changeAbsence(rawPayload, true);
}
export async function getDailyAbsences(
@@ -144,17 +257,10 @@ export async function getDailyAbsences(
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
]);
if (!session.user.kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = dailyAbsenceSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültiges Datum." };
}
const date = parseDateOnly(parsed.data.date);
if (!session.user.kitaId) return { error: "Kein Mandant zugeordnet." };
const parsed = z.object({ date: dateSchema }).safeParse(rawPayload);
if (!parsed.success) return { error: "Ungültiges Datum." };
const date = absenceDate(parsed.data.date);
const rows = await prisma.absence.findMany({
where: {
kitaId: session.user.kitaId,
@@ -167,24 +273,17 @@ export async function getDailyAbsences(
note: true,
startDate: true,
endDate: true,
updatedAt: true,
child: {
select: {
firstName: true,
lastName: true,
family: {
select: {
name: true,
},
},
family: { select: { name: true } },
},
},
},
orderBy: [
{ child: { lastName: "asc" } },
{ child: { firstName: "asc" } },
],
orderBy: [{ child: { lastName: "asc" } }, { child: { firstName: "asc" } }],
});
return {
absences: rows.map((absence) => ({
id: absence.id,
@@ -192,8 +291,9 @@ export async function getDailyAbsences(
familyName: absence.child.family.name,
reason: absence.reason,
note: absence.note,
startDate: toDateKey(absence.startDate),
endDate: toDateKey(absence.endDate),
startDate: absence.startDate.toISOString().slice(0, 10),
endDate: absence.endDate.toISOString().slice(0, 10),
updatedAt: absence.updatedAt.toISOString(),
})),
};
}