Files
kita-planer/src/actions/absences.ts
T

300 lines
8.8 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
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 dateSchema = dateOnlySchema;
const fieldsSchema = z.object({
startDate: dateSchema,
endDate: dateSchema,
reason: z.nativeEnum(AbsenceReason),
note: z.string().trim().max(500).optional(),
});
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;
childName: string;
familyName: string;
reason: AbsenceReason;
note: string | null;
startDate: string;
endDate: string;
updatedAt: string;
};
function refreshAbsences() {
revalidatePath("/dashboard");
revalidatePath("/dashboard/abwesenheiten");
revalidatePath("/dashboard/admin/abwesenheiten");
}
async function currentActor(
tx: Prisma.TransactionClient,
id: string,
kitaId: string,
) {
return tx.user.findFirst({
where: {
id,
kitaId,
role: {
in: [
UserRole.ELTERN,
UserRole.ADMIN,
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
],
},
},
select: { id: true, role: true, familyId: true },
});
}
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`;
}
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;
}
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(
rawPayload: unknown,
): Promise<{ absences?: DailyAbsenceDto[]; error?: string }> {
const session = await requireRole([
UserRole.ADMIN,
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
]);
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,
startDate: { lte: date },
endDate: { gte: date },
},
select: {
id: true,
reason: true,
note: true,
startDate: true,
endDate: true,
updatedAt: true,
child: {
select: {
firstName: true,
lastName: true,
family: { select: { name: true } },
},
},
},
orderBy: [{ child: { lastName: "asc" } }, { child: { firstName: "asc" } }],
});
return {
absences: rows.map((absence) => ({
id: absence.id,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
familyName: absence.child.family.name,
reason: absence.reason,
note: absence.note,
startDate: absence.startDate.toISOString().slice(0, 10),
endDate: absence.endDate.toISOString().slice(0, 10),
updatedAt: absence.updatedAt.toISOString(),
})),
};
}