300 lines
8.8 KiB
TypeScript
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(),
|
|
})),
|
|
};
|
|
}
|