Merge family absence flows while preserving security hardening
This commit is contained in:
+223
-123
@@ -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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user