Files
kita-planer/src/lib/staff/service.ts
T

1436 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { UserFacingError } from "@/lib/action-error";
import { randomUUID } from "node:crypto";
import { hashToken, newToken } from "@/lib/tokens";
import {
Prisma,
UserRole,
StaffLeaveKind,
StaffRequestStatus,
} from "@prisma/client";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import {
addDays,
calculateLeaveDays,
calculateWeek,
dateKey,
dateOnly,
dateSchema,
getTimeBalance,
scheduledMinutes,
todayKey,
vacationBalance,
weekStart,
} from "./calculations";
export type StaffActor = { id: string; kitaId: string; role: UserRole };
export type StaffNotice = {
profileId: string;
text: string;
toAdmins: boolean;
};
type CommandResult = {
educatorId?: string;
emailIds?: string[];
notice?: StaffNotice;
invite?: { email: string; name: string; token: string; kitaName: string };
};
const idSchema = z.string().min(1).max(100);
const educatorInput = z.object({
firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100),
lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100),
active: z.boolean().default(true),
email: z
.string()
.trim()
.max(254)
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase(),
});
const onboardingDates = z
.object({
employmentStart: dateSchema,
trackingStart: dateSchema,
})
.refine((input) => input.trackingStart >= input.employmentStart, {
message:
"Der Start der Zeiterfassung darf nicht vor dem Beschäftigungsbeginn liegen.",
});
async function inviteStaff(
tx: Prisma.TransactionClient,
actor: StaffActor,
profile: {
id: string;
kitaId: string;
userId: string | null;
user: { email: string; emailVerifiedAt: Date | null } | null;
educator: { firstName: string; lastName: string };
},
email: string,
): Promise<CommandResult> {
assertStaffAccess(actor, profile, true);
if (
profile.userId &&
(profile.user?.email !== email || profile.user.emailVerifiedAt)
)
throw new UserFacingError(
"Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.",
);
const existingUser = await tx.user.findUnique({ where: { email } });
if (
existingUser &&
(existingUser.kitaId !== actor.kitaId ||
!(
existingUser.role === UserRole.ERZIEHER ||
(existingUser.role === UserRole.ADMIN &&
existingUser.id === profile.userId)
) ||
(await tx.staffProfile.count({
where: { userId: existingUser.id, id: { not: profile.id } },
})))
)
throw new UserFacingError(
"Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.",
);
const user =
existingUser ??
(await tx.user.create({
data: {
email,
firstName: profile.educator.firstName,
lastName: profile.educator.lastName,
passwordHash: "",
role: UserRole.ERZIEHER,
kitaId: actor.kitaId,
},
}));
await tx.staffProfile.update({
where: { id: profile.id },
data: { userId: user.id },
});
await tx.staffHistory.create({
data: {
profileId: profile.id,
actorId: actor.id,
action: "Account verknüpft",
details: email,
},
});
if (user.emailVerifiedAt) return {};
const token = newToken();
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
await tx.verificationToken.create({
data: {
identifier: user.id,
token: hashToken(token),
expires: new Date(Date.now() + 7 * 86400000),
},
});
const kita = await tx.kita.findUniqueOrThrow({
where: { id: actor.kitaId },
select: { name: true },
});
const job = await tx.staffEmail.create({
data: {
profileId: profile.id,
userId: user.id,
subject: `Einladung zu ${kita.name} im Kita-Planer`,
message: "Account aktivieren",
path: `/invite/${token}`,
inviteToken: token,
},
});
return {
emailIds: [job.id],
invite: {
email,
name: `${profile.educator.firstName} ${profile.educator.lastName}`,
token,
kitaName: kita.name,
},
};
}
const reasonSchema = z
.string()
.trim()
.min(3, "Bitte eine Begründung angeben.")
.max(500);
const commandSchema = z.object({
command: z.string(),
data: z.record(z.string(), z.unknown()),
});
const activeLeaveStatuses: StaffRequestStatus[] = [
"PENDING",
"APPROVED",
"CANCEL_REQUESTED",
];
const profileInclude = {
educator: true,
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
emailVerifiedAt: true,
},
},
workPatterns: { orderBy: { effectiveFrom: "asc" as const } },
dayOverrides: true,
leaveAccounts: true,
leaves: { include: { days: true }, orderBy: { createdAt: "desc" as const } },
timeEntries: {
orderBy: [{ date: "asc" as const }, { startMinute: "asc" as const }],
},
weeks: { orderBy: { weekStart: "desc" as const } },
adjustments: true,
history: { orderBy: { createdAt: "desc" as const }, take: 30 },
} satisfies Prisma.StaffProfileInclude;
export type StaffProfileData = Prisma.StaffProfileGetPayload<{
include: typeof profileInclude;
}>;
function requireAdmin(actor: StaffActor) {
if (actor.role !== UserRole.ADMIN)
throw new UserFacingError("Nur Kita-Admins dürfen Personal verwalten.");
}
export function assertStaffAccess(
actor: StaffActor,
profile: { kitaId: string; userId: string | null },
adminOnly = false,
) {
if (
profile.kitaId !== actor.kitaId ||
![UserRole.ADMIN, UserRole.ERZIEHER].includes(
actor.role as "ADMIN" | "ERZIEHER",
)
)
throw new UserFacingError("Kein Zugriff auf dieses Personalprofil.");
if (adminOnly) requireAdmin(actor);
else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id)
throw new UserFacingError(
"Du kannst nur dein eigenes Personalprofil verwalten.",
);
}
export async function getStaffProfile(actor: StaffActor, profileId?: string) {
if (
![UserRole.ADMIN, UserRole.ERZIEHER].includes(
actor.role as "ADMIN" | "ERZIEHER",
)
)
throw new UserFacingError("Kein Zugriff auf den Personalbereich.");
const profile = await prisma.staffProfile.findFirst({
where: {
kitaId: actor.kitaId,
...(actor.role === UserRole.ADMIN && profileId
? { id: profileId }
: { userId: actor.id }),
},
include: profileInclude,
});
if (profile) assertStaffAccess(actor, profile);
return profile;
}
function ensureDayEditable(profile: StaffProfileData, key: string) {
const sheet = profile.weeks.find(
(w) => dateKey(w.weekStart) === weekStart(key),
);
if (sheet && sheet.status !== "DRAFT")
throw new UserFacingError(
"Diese Woche ist eingereicht oder genehmigt. Der Admin muss sie zuerst zur Korrektur zurückgeben.",
);
}
function ensureRangeEditable(
profile: StaffProfileData,
start: string,
end: string,
) {
if (
profile.weeks.some(
(w) =>
w.status !== "DRAFT" &&
dateKey(w.weekStart) <= end &&
addDays(dateKey(w.weekStart), 6) >= start,
)
)
throw new UserFacingError(
"Im Zeitraum liegt eine eingereichte oder genehmigte Woche. Bitte zuerst zur Korrektur zurückgeben.",
);
}
function ensureModelEditable(profile: StaffProfileData, from: string) {
if (
profile.weeks.some(
(w) => w.status !== "DRAFT" && addDays(dateKey(w.weekStart), 6) >= from,
) ||
profile.leaves.some(
(l) =>
["APPROVED", "CANCEL_REQUESTED"].includes(l.status) &&
dateKey(l.endDate) >= from,
)
) {
throw new UserFacingError(
"Ab diesem Datum bestehen bereits Freigaben. Bitte betroffene Wochen zurückgeben bzw. Abwesenheiten stornieren oder einen späteren Beginn wählen.",
);
}
}
function assertVacationFunds(
profile: StaffProfileData,
days: { date: string; vacationUnits: number }[],
excludeId?: string,
) {
for (const year of new Set(
days.filter((d) => d.vacationUnits).map((d) => Number(d.date.slice(0, 4))),
)) {
const account = profile.leaveAccounts.find((a) => a.year === year);
if (!account)
throw new UserFacingError(
`Für ${year} muss der Admin zuerst ein Urlaubskonto hinterlegen.`,
);
const balance = vacationBalance(
account,
profile.leaves.filter((l) => l.id !== excludeId),
);
const requested = days
.filter((d) => Number(d.date.slice(0, 4)) === year)
.reduce((sum, d) => sum + d.vacationUnits, 0);
if (balance.available - balance.pending < requested)
throw new UserFacingError(
`Für ${year} reichen die noch nicht verplanten Urlaubstage nicht aus.`,
);
}
}
/** Only called after authentication. Every command runs in a serializable transaction. */
export async function executeStaffCommand(
actor: StaffActor,
raw: unknown,
): Promise<CommandResult> {
const { command, data } = commandSchema.parse(raw);
if (
![UserRole.ADMIN, UserRole.ERZIEHER].includes(
actor.role as "ADMIN" | "ERZIEHER",
)
)
throw new UserFacingError("Kein Zugriff auf den Personalbereich.");
for (let attempt = 0; ; attempt++) {
try {
return await prisma.$transaction(
async (tx) => {
// Also validate the actor against the DB; a forged tenant or stale role cannot be used.
const dbActor = await tx.user.findFirst({
where: {
id: actor.id,
kitaId: actor.kitaId,
role: actor.role,
privacyPolicyAcceptedAt: { not: null },
emailVerifiedAt: { not: null },
disabledAt: null,
},
});
if (
!dbActor ||
(dbActor.lockedUntil && dbActor.lockedUntil > new Date())
)
throw new UserFacingError(
"Dein Konto hat keinen Zugriff auf den Personalbereich.",
);
if (
["educatorCreate", "educatorUpdate", "educatorInvite"].includes(
command,
)
) {
requireAdmin(actor);
if (command === "educatorInvite") {
const educator = await tx.educator.findFirst({
where: {
id: idSchema.parse(data.educatorId),
kitaId: actor.kitaId,
},
include: {
staffProfile: { include: { user: true, educator: true } },
},
});
if (!educator?.staffProfile?.user)
throw new UserFacingError(
"Bitte zuerst E-Mail-Adresse und Personalprofil ergänzen.",
);
return inviteStaff(
tx,
actor,
educator.staffProfile,
educator.staffProfile.user.email,
);
}
const input = educatorInput.parse(data);
const existing =
command === "educatorUpdate"
? await tx.educator.findFirst({
where: {
id: idSchema.parse(data.educatorId),
kitaId: actor.kitaId,
},
include: { staffProfile: { include: { user: true } } },
})
: null;
if (command === "educatorUpdate" && !existing)
throw new UserFacingError("Fachkraft nicht gefunden.");
if (
existing?.staffProfile?.user &&
existing.staffProfile.user.email !== input.email
)
throw new UserFacingError(
"Die Adresse eines verknüpften Accounts kann hier nicht geändert werden.",
);
if (existing?.staffProfile)
assertStaffAccess(actor, existing.staffProfile, true);
// Dates are only supplied when creating the profile. Existing balances and dates stay intact.
const dates = !existing?.staffProfile
? onboardingDates.parse(data)
: null;
const values = {
firstName: input.firstName,
lastName: input.lastName,
active: input.active,
};
const educator = existing
? await tx.educator.update({
where: { id: existing.id },
data: values,
})
: await tx.educator.create({
data: { ...values, kitaId: actor.kitaId },
});
const profile =
existing?.staffProfile ??
(await tx.staffProfile.create({
data: {
kitaId: actor.kitaId,
educatorId: educator.id,
employmentStart: dateOnly(dates!.employmentStart),
trackingStart: dateOnly(dates!.trackingStart),
},
include: { user: true },
}));
await tx.staffHistory.create({
data: {
profileId: profile.id,
actorId: actor.id,
action: existing ? "Stammdaten geändert" : "Fachkraft angelegt",
details: `${educator.firstName} ${educator.lastName}${dates ? ` · Beschäftigung ${dates.employmentStart}, Erfassung ${dates.trackingStart}, Anfangssaldo 0 Min.` : ""}`,
},
});
if (
profile.user &&
(profile.user.kitaId !== actor.kitaId ||
!([UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]).includes(
profile.user.role,
))
)
throw new UserFacingError(
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
);
if (profile.userId) {
await tx.user.update({
where: { id: profile.userId },
data: { firstName: input.firstName, lastName: input.lastName },
});
return { educatorId: educator.id };
}
const invitation = await inviteStaff(
tx,
actor,
{ ...profile, educator },
input.email,
);
return { ...invitation, educatorId: educator.id };
}
if (command === "emailPreference") {
if (
!(await tx.staffProfile.count({
where: { userId: actor.id, kitaId: actor.kitaId },
}))
)
throw new UserFacingError(
"Diese Einstellung ist für Fachkräfte vorgesehen.",
);
await tx.user.update({
where: { id: actor.id },
data: {
staffDecisionEmails: z.boolean().parse(data.enabled),
},
});
return {};
}
if (command === "emailRetry") {
const jobs = await tx.staffEmail.findMany({
where: {
profile: { kitaId: actor.kitaId },
...(actor.role === UserRole.ADMIN ? {} : { userId: actor.id }),
sentAt: null,
skippedAt: null,
},
select: { id: true },
orderBy: { createdAt: "asc" },
take: 50,
});
return { emailIds: jobs.map((job) => job.id) };
}
if (command === "holiday") {
requireAdmin(actor);
const input = z
.object({
date: dateSchema,
name: z.string().trim().min(2).max(100),
remove: z.boolean().optional(),
})
.parse(data);
const date = dateOnly(input.date);
const protectedWeek = await tx.staffWeek.count({
where: {
profile: { kitaId: actor.kitaId },
status: { not: "DRAFT" },
weekStart: dateOnly(weekStart(input.date)),
},
});
const protectedLeave = await tx.staffLeave.count({
where: {
profile: { kitaId: actor.kitaId },
status: { in: ["APPROVED", "CANCEL_REQUESTED"] },
startDate: { lte: date },
endDate: { gte: date },
},
});
if (protectedWeek || protectedLeave)
throw new UserFacingError(
"An diesem Tag bestehen bereits Freigaben. Bitte zuerst zurückgeben oder stornieren.",
);
if (input.remove)
await tx.staffHoliday.deleteMany({
where: { kitaId: actor.kitaId, date },
});
else
await tx.staffHoliday.upsert({
where: { kitaId_date: { kitaId: actor.kitaId, date } },
create: { kitaId: actor.kitaId, date, name: input.name },
update: { name: input.name },
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: dbActor.email,
action: "STAFF_HOLIDAY_CHANGE",
targetType: "KITA",
targetId: actor.kitaId,
targetLabel: `${input.date}: ${input.name}`,
targetKitaId: actor.kitaId,
},
});
return {};
}
if (command === "setup") {
requireAdmin(actor);
const input = z
.object({
educatorId: idSchema,
employmentStart: dateSchema,
employmentEnd: dateSchema.nullable(),
trackingStart: dateSchema,
openingMinutes: z.number().int().min(-200000).max(200000),
reason: reasonSchema,
})
.parse(data);
if (
input.employmentEnd &&
input.employmentEnd < input.employmentStart
)
throw new UserFacingError(
"Das Beschäftigungsende liegt vor dem Beginn.",
);
if (
input.trackingStart < input.employmentStart ||
(input.employmentEnd && input.trackingStart > input.employmentEnd)
)
throw new UserFacingError(
"Der Erfassungsbeginn muss in der Beschäftigung liegen.",
);
const educator = await tx.educator.findFirst({
where: { id: input.educatorId, kitaId: actor.kitaId },
});
if (!educator)
throw new UserFacingError("Fachkraft nicht gefunden.");
const existing = await tx.staffProfile.findFirst({
where: { educatorId: educator.id, kitaId: actor.kitaId },
include: profileInclude,
});
if (
existing &&
(existing.timeEntries.length ||
existing.leaves.length ||
existing.weeks.length)
)
throw new UserFacingError(
"Die Grunddaten sind nach Beginn der Erfassung geschützt. Verwende datierte Arbeitszeitmodelle und begründete Zeitkorrekturen.",
);
const values = {
employmentStart: dateOnly(input.employmentStart),
employmentEnd: input.employmentEnd
? dateOnly(input.employmentEnd)
: null,
trackingStart: dateOnly(input.trackingStart),
openingMinutes: input.openingMinutes,
};
const profile = await tx.staffProfile.upsert({
where: { educatorId: educator.id },
create: {
...values,
educatorId: educator.id,
kitaId: actor.kitaId,
},
update: values,
});
await tx.staffHistory.create({
data: {
profileId: profile.id,
actorId: actor.id,
action: "Beschäftigungsprofil",
details: `${input.reason} · Beginn ${input.employmentStart}, Erfassung ${input.trackingStart}, Anfangssaldo ${input.openingMinutes} Min.`,
},
});
return {};
}
const profileId = idSchema.parse(data.profileId);
const profile = await tx.staffProfile.findFirst({
where: { id: profileId, kitaId: actor.kitaId },
include: profileInclude,
});
if (!profile)
throw new UserFacingError("Personalprofil nicht gefunden.");
assertStaffAccess(actor, profile);
const holidays = new Set(
(
await tx.staffHoliday.findMany({
where: { kitaId: actor.kitaId },
})
).map((h) => dateKey(h.date)),
);
const history = async (action: string, details: string) => {
await tx.staffHistory.create({
data: { profileId, actorId: actor.id, action, details },
});
};
const notice = async (
text: string,
toAdmins: boolean,
email?: { message: string; query: string },
): Promise<CommandResult> => {
const recipients = await tx.user.findMany({
where: {
kitaId: actor.kitaId,
...(toAdmins
? { role: UserRole.ADMIN }
: { id: profile.userId ?? "" }),
},
select: { id: true, role: true, staffDecisionEmails: true },
});
await tx.staffNotification.createMany({
data: recipients.map((user) => ({
userId: user.id,
profileId,
message: `${profile.educator.firstName} ${profile.educator.lastName}: ${text}`,
})),
});
const emailIds: string[] = [];
if (email) {
for (const user of recipients) {
if (
!toAdmins &&
(!(
[UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]
).includes(user.role) ||
!user.staffDecisionEmails)
)
continue;
const job = await tx.staffEmail.create({
data: {
profileId,
userId: user.id,
toAdmins,
subject: "Kita-Planer: Hinweis im Personalbereich",
message: `${profile.educator.firstName} ${profile.educator.lastName}: ${email.message}`,
path: `/dashboard/personal?educator=${encodeURIComponent(profile.educatorId)}&${email.query}`,
},
});
emailIds.push(job.id);
}
}
return { notice: { profileId, text, toAdmins }, emailIds };
};
if (command === "noticeRead") {
await tx.staffNotification.updateMany({
where: {
id: idSchema.parse(data.id),
userId: actor.id,
profileId,
},
data: { readAt: new Date() },
});
return {};
}
if (command === "invite") {
requireAdmin(actor);
const email = z
.string()
.trim()
.email()
.toLowerCase()
.parse(data.email);
return inviteStaff(tx, actor, profile, email);
}
if (command === "pattern") {
requireAdmin(actor);
const input = z
.object({
effectiveFrom: dateSchema,
dailyMinutes: z
.array(z.number().int().min(0).max(960))
.length(7),
reason: reasonSchema,
})
.parse(data);
if (input.effectiveFrom < dateKey(profile.employmentStart))
throw new UserFacingError(
"Das Modell darf nicht vor Beschäftigungsbeginn gelten.",
);
ensureModelEditable(profile, input.effectiveFrom);
await tx.staffWorkPattern.upsert({
where: {
profileId_effectiveFrom: {
profileId,
effectiveFrom: dateOnly(input.effectiveFrom),
},
},
create: {
profileId,
effectiveFrom: dateOnly(input.effectiveFrom),
dailyMinutes: input.dailyMinutes,
},
update: { dailyMinutes: input.dailyMinutes },
});
await history(
"Wochenmodell",
`${input.effectiveFrom}: ${input.dailyMinutes.join(" / ")} Min. · ${input.reason}`,
);
return {};
}
if (command === "employmentEnd") {
requireAdmin(actor);
const input = z
.object({
employmentEnd: dateSchema.nullable(),
reason: reasonSchema,
})
.parse(data);
if (
input.employmentEnd &&
input.employmentEnd < dateKey(profile.employmentStart)
)
throw new UserFacingError(
"Das Beschäftigungsende liegt vor dem Beginn.",
);
if (
input.employmentEnd &&
(input.employmentEnd < dateKey(profile.trackingStart) ||
profile.timeEntries.some(
(e) => dateKey(e.date) > input.employmentEnd!,
) ||
profile.leaves.some(
(l) =>
activeLeaveStatuses.includes(l.status) &&
dateKey(l.endDate) > input.employmentEnd!,
))
)
throw new UserFacingError(
"Nach diesem Ende bestehen Erfassungsbeginn, Arbeitszeiten oder aktive Anträge. Bitte zuerst korrigieren.",
);
const oldEnd = profile.employmentEnd
? dateKey(profile.employmentEnd)
: null;
if (oldEnd !== input.employmentEnd) {
const changedFrom = [oldEnd, input.employmentEnd]
.filter((d): d is string => d !== null)
.sort()[0];
if (changedFrom)
ensureModelEditable(profile, addDays(changedFrom, 1));
}
await tx.staffProfile.update({
where: { id: profileId },
data: {
employmentEnd: input.employmentEnd
? dateOnly(input.employmentEnd)
: null,
},
});
await history(
"Beschäftigungsende",
`${input.employmentEnd ?? "unbefristet"} · ${input.reason}`,
);
return {};
}
if (command === "override") {
requireAdmin(actor);
const input = z
.object({
date: dateSchema,
minutes: z.number().int().min(0).max(960),
reason: reasonSchema,
remove: z.boolean().optional(),
})
.parse(data);
ensureDayEditable(profile, input.date);
if (
profile.leaves.some(
(l) =>
["APPROVED", "CANCEL_REQUESTED"].includes(l.status) &&
dateKey(l.startDate) <= input.date &&
dateKey(l.endDate) >= input.date,
)
)
throw new UserFacingError(
"Bitte die genehmigte Abwesenheit zuerst stornieren.",
);
if (
input.date < dateKey(profile.employmentStart) ||
(profile.employmentEnd &&
input.date > dateKey(profile.employmentEnd))
)
throw new UserFacingError(
"Der Tag liegt außerhalb der Beschäftigung.",
);
if (input.remove)
await tx.staffDayOverride.deleteMany({
where: { profileId, date: dateOnly(input.date) },
});
else
await tx.staffDayOverride.upsert({
where: {
profileId_date: { profileId, date: dateOnly(input.date) },
},
create: {
profileId,
date: dateOnly(input.date),
minutes: input.minutes,
reason: input.reason,
},
update: { minutes: input.minutes, reason: input.reason },
});
await history(
"Tagesausnahme",
`${input.date}: ${input.remove ? "entfernt" : `${input.minutes} Min.`} · ${input.reason}`,
);
return {};
}
if (command === "account") {
requireAdmin(actor);
const input = z
.object({
year: z.number().int().min(2000).max(2100),
entitlementUnits: z.number().int().min(0).max(800),
carryUnits: z.number().int().min(0).max(800),
reason: reasonSchema,
})
.parse(data);
const used = vacationBalance({ ...input }, profile.leaves);
if (used.available < 0 || used.available < used.pending)
throw new UserFacingError(
"Der Anspruch reicht nicht für bereits genehmigten oder beantragten Urlaub.",
);
await tx.staffLeaveAccount.upsert({
where: { profileId_year: { profileId, year: input.year } },
create: {
profileId,
year: input.year,
entitlementUnits: input.entitlementUnits,
carryUnits: input.carryUnits,
},
update: {
entitlementUnits: input.entitlementUnits,
carryUnits: input.carryUnits,
},
});
await history(
"Urlaubsanspruch",
`${input.year}: ${input.entitlementUnits / 2} Tage + ${input.carryUnits / 2} Übertrag · ${input.reason}`,
);
return {};
}
if (command === "adjust") {
requireAdmin(actor);
const input = z
.object({
date: dateSchema,
minutes: z
.number()
.int()
.min(-200000)
.max(200000)
.refine((n) => n !== 0),
reason: reasonSchema,
})
.parse(data);
if (input.date < dateKey(profile.trackingStart))
throw new UserFacingError(
"Korrekturen müssen ab Erfassungsbeginn liegen.",
);
await tx.staffAdjustment.create({
data: {
profileId,
date: dateOnly(input.date),
minutes: input.minutes,
reason: input.reason,
actorId: actor.id,
},
});
await history(
"Zeitkorrektur",
`${input.date}: ${input.minutes} Min. · ${input.reason}`,
);
return {};
}
if (command === "time" || command === "deleteTime") {
if (!profile.educator.active)
throw new UserFacingError("Die Fachkraft ist nicht mehr aktiv.");
const existing = data.id
? await tx.staffTimeEntry.findFirst({
where: { id: idSchema.parse(data.id), profileId },
})
: null;
if (data.id && !existing)
throw new UserFacingError("Zeiteintrag nicht gefunden.");
if (existing) ensureDayEditable(profile, dateKey(existing.date));
if (command === "deleteTime") {
if (!existing)
throw new UserFacingError("Zeiteintrag nicht gefunden.");
await tx.staffTimeEntry.delete({ where: { id: existing.id } });
await history(
"Arbeitszeit gelöscht",
`${dateKey(existing.date)}: ${existing.startMinute}–${existing.endMinute} Min.`,
);
return {};
}
const input = z
.object({
date: dateSchema,
startMinute: z.number().int().min(0).max(1439),
endMinute: z.number().int().min(1).max(1440),
breakMinutes: z.number().int().min(0).max(1439),
note: z.string().trim().max(500).optional(),
})
.parse(data);
if (
input.date > todayKey() ||
input.date < dateKey(profile.trackingStart) ||
input.date < dateKey(profile.employmentStart) ||
(profile.employmentEnd &&
input.date > dateKey(profile.employmentEnd))
)
throw new UserFacingError(
"Bitte einen vergangenen oder heutigen Tag innerhalb des Erfassungszeitraums wählen.",
);
if (
!profile.workPatterns.some(
(p) => dateKey(p.effectiveFrom) <= input.date,
)
)
throw new UserFacingError(
"Bitte zuerst ein Arbeitszeitmodell hinterlegen.",
);
if (
input.endMinute <= input.startMinute ||
input.breakMinutes >= input.endMinute - input.startMinute
)
throw new UserFacingError(
"Ende muss nach Beginn liegen; die Pause muss kürzer als der Arbeitsblock sein.",
);
ensureDayEditable(profile, input.date);
if (
profile.timeEntries.some(
(e) =>
e.id !== existing?.id &&
dateKey(e.date) === input.date &&
e.startMinute < input.endMinute &&
e.endMinute > input.startMinute,
)
)
throw new UserFacingError(
"Dieser Arbeitsblock überschneidet sich mit einem vorhandenen Eintrag.",
);
const absences = profile.leaves
.filter((l) =>
["APPROVED", "CANCEL_REQUESTED"].includes(l.status),
)
.flatMap((l) => l.days)
.filter((d) => dateKey(d.date) === input.date);
const reservedMinutes = absences.reduce(
(sum, d) => sum + d.creditMinutes,
0,
);
const worked = profile.timeEntries
.filter(
(e) => e.id !== existing?.id && dateKey(e.date) === input.date,
)
.reduce(
(sum, e) => sum + e.endMinute - e.startMinute - e.breakMinutes,
input.endMinute - input.startMinute - input.breakMinutes,
);
if (
reservedMinutes &&
worked + reservedMinutes >
scheduledMinutes(profile, input.date, holidays)
)
throw new UserFacingError(
"Arbeitszeit und genehmigte Abwesenheit überschreiten die Sollzeit. Bitte die Abwesenheit zuerst korrigieren.",
);
const values = {
profileId,
date: dateOnly(input.date),
startMinute: input.startMinute,
endMinute: input.endMinute,
breakMinutes: input.breakMinutes,
note: input.note || null,
};
if (existing)
await tx.staffTimeEntry.update({
where: { id: existing.id },
data: values,
});
else await tx.staffTimeEntry.create({ data: values });
await history(
"Arbeitszeit gespeichert",
`${input.date}: ${input.startMinute}–${input.endMinute}, Pause ${input.breakMinutes} Min.`,
);
return {};
}
if (command === "weekSubmit" || command === "weekReview") {
const start = weekStart(dateSchema.parse(data.weekStart));
const end = addDays(start, 6);
if (end < dateKey(profile.trackingStart) || start > todayKey())
throw new UserFacingError(
"Diese Woche liegt außerhalb der bisherigen Erfassung.",
);
const existing = profile.weeks.find(
(w) => dateKey(w.weekStart) === start,
);
if (command === "weekSubmit") {
if (existing && existing.status !== "DRAFT")
throw new UserFacingError(
"Die Woche ist bereits eingereicht oder genehmigt.",
);
if (
!profile.workPatterns.some(
(p) =>
dateKey(p.effectiveFrom) <=
(start < dateKey(profile.trackingStart)
? dateKey(profile.trackingStart)
: start),
)
)
throw new UserFacingError(
"Bitte zuerst ein Arbeitszeitmodell für die Woche hinterlegen.",
);
if (
profile.leaves.some(
(l) =>
["PENDING", "CANCEL_REQUESTED"].includes(l.status) &&
dateKey(l.startDate) <= end &&
dateKey(l.endDate) >= start,
)
)
throw new UserFacingError(
"Bitte zuerst offene Abwesenheitsanträge dieser Woche klären.",
);
const days = calculateWeek(
profile,
holidays,
start,
profile.timeEntries,
profile.leaves,
);
if (days.some((d) => d.date > todayKey() && d.expected > 0))
throw new UserFacingError(
"Die Woche kann erst nach ihrem letzten vorgesehenen Arbeitstag eingereicht werden.",
);
await tx.staffWeek.upsert({
where: {
profileId_weekStart: {
profileId,
weekStart: dateOnly(start),
},
},
create: {
profileId,
weekStart: dateOnly(start),
status: "SUBMITTED",
submittedAt: new Date(),
},
update: {
status: "SUBMITTED",
submittedAt: new Date(),
dailySnapshot: Prisma.DbNull,
decisionNote: null,
},
});
await history("Woche eingereicht", start);
return notice(
`Arbeitswoche ab ${start} wurde zur Prüfung eingereicht.`,
true,
{
message: `Arbeitswoche ${start}–${end} wurde zur Prüfung eingereicht.`,
query: `tab=zeit&week=${start}`,
},
);
}
requireAdmin(actor);
if (profile.userId === actor.id)
throw new UserFacingError(
"Eigene Wochen dürfen nicht selbst freigegeben werden.",
);
const input = z
.object({ approve: z.boolean(), reason: reasonSchema })
.parse(data);
if (
!existing ||
(input.approve
? existing.status !== "SUBMITTED"
: existing.status === "DRAFT")
)
throw new UserFacingError(
"Diese Woche kann in ihrem aktuellen Status nicht bearbeitet werden.",
);
if (
input.approve &&
profile.leaves.some(
(l) =>
["PENDING", "CANCEL_REQUESTED"].includes(l.status) &&
dateKey(l.startDate) <= end &&
dateKey(l.endDate) >= start,
)
)
throw new UserFacingError(
"Bitte offene Abwesenheiten zuerst klären; gegebenenfalls die Woche zur Korrektur zurückgeben.",
);
const days = calculateWeek(
profile,
holidays,
start,
profile.timeEntries,
profile.leaves,
);
const snapshot = days.map((d) => ({ ...d }));
await tx.staffWeek.update({
where: { id: existing.id },
data: {
status: input.approve ? "APPROVED" : "DRAFT",
dailySnapshot: input.approve ? snapshot : Prisma.DbNull,
reviewedAt: new Date(),
reviewedById: actor.id,
decisionNote: input.reason,
},
});
await history(
input.approve ? "Woche genehmigt" : "Woche zur Korrektur",
`${start} · ${input.reason}`,
);
return notice(
`Arbeitswoche ab ${start}: ${input.approve ? "genehmigt" : "zur Korrektur zurückgegeben"}. ${input.reason}`,
false,
{
message: `Arbeitswoche ${start}–${end}: ${input.approve ? "genehmigt" : "zur Korrektur zurückgegeben"}.`,
query: `tab=zeit&week=${start}`,
},
);
}
if (
command === "leave" ||
command === "leaveReview" ||
command === "leaveCancel"
) {
const existing = data.id
? profile.leaves.find((l) => l.id === idSchema.parse(data.id))
: undefined;
if (data.id && !existing)
throw new UserFacingError("Antrag nicht gefunden.");
if (command === "leaveCancel") {
if (
!existing ||
!["PENDING", "APPROVED"].includes(existing.status)
)
throw new UserFacingError(
"Dieser Antrag kann nicht zurückgezogen werden.",
);
const status =
existing.status === "PENDING"
? "CANCELLED"
: "CANCEL_REQUESTED";
await tx.staffLeave.update({
where: { id: existing.id },
data: { status },
});
await history(
"Abwesenheit zurückgezogen",
`${existing.id}: ${status}`,
);
return notice(
status === "CANCELLED"
? "Ein offener Abwesenheitsantrag wurde zurückgezogen."
: "Die Stornierung einer genehmigten Abwesenheit wurde beantragt.",
true,
existing.kind === "SICKNESS"
? undefined
: {
message: `Abwesenheitsantrag ${dateKey(existing.startDate)}–${dateKey(existing.endDate)}: ${status === "CANCELLED" ? "zurückgezogen" : "Stornierung beantragt"}.`,
query: `tab=urlaub&year=${existing.startDate.getUTCFullYear()}`,
},
);
}
if (command === "leaveReview") {
requireAdmin(actor);
if (profile.userId === actor.id)
throw new UserFacingError(
"Eigene Anträge dürfen nicht selbst genehmigt werden.",
);
const input = z
.object({ approve: z.boolean(), reason: reasonSchema })
.parse(data);
if (
!existing ||
!["PENDING", "CANCEL_REQUESTED"].includes(existing.status)
)
throw new UserFacingError(
"Dieser Antrag ist nicht mehr offen.",
);
const cancelling = existing.status === "CANCEL_REQUESTED";
if (input.approve)
ensureRangeEditable(
profile,
dateKey(existing.startDate),
dateKey(existing.endDate),
);
if (input.approve && !cancelling) {
const days = calculateLeaveDays(profile, holidays, {
kind: existing.kind,
startDate: dateKey(existing.startDate),
endDate: dateKey(existing.endDate),
halfDay: existing.halfDay,
minutes: existing.minutes ?? undefined,
});
if (existing.kind === "VACATION")
assertVacationFunds(profile, days, existing.id);
if (existing.kind === "TIME_OFF") {
const balance = getTimeBalance(
profile,
profile.weeks,
profile.adjustments,
profile.leaves,
);
if (balance.available < (existing.minutes ?? 0))
throw new UserFacingError(
"Das bestätigte Zeitguthaben reicht nicht aus.",
);
}
for (const d of days) {
const worked = profile.timeEntries
.filter((e) => dateKey(e.date) === d.date)
.reduce(
(sum, e) =>
sum + e.endMinute - e.startMinute - e.breakMinutes,
0,
);
if (
worked + d.creditMinutes >
scheduledMinutes(profile, d.date, holidays)
)
throw new UserFacingError(
"An diesem Tag bestehen Arbeitszeiten. Bitte Zeitraum oder Arbeitszeit zuerst korrigieren.",
);
}
await tx.staffLeaveDay.deleteMany({
where: { leaveId: existing.id },
});
await tx.staffLeaveDay.createMany({
data: days.map((d) => ({
...d,
date: dateOnly(d.date),
leaveId: existing.id,
})),
});
}
const status: StaffRequestStatus = cancelling
? input.approve
? "CANCELLED"
: "APPROVED"
: input.approve
? "APPROVED"
: "REJECTED";
await tx.staffLeave.update({
where: { id: existing.id },
data: {
status,
reviewedById: actor.id,
reviewedAt: new Date(),
decisionNote: input.reason,
},
});
await history(
"Abwesenheit entschieden",
`${existing.id}: ${status} · ${input.reason}`,
);
return notice(
`${existing.kind === "VACATION" ? "Urlaub" : existing.kind === "TIME_OFF" ? "Freizeitausgleich" : "Krankmeldung"}: ${status === "CANCELLED" ? "storniert" : status === "REJECTED" ? "abgelehnt" : "genehmigt"}. ${input.reason}`,
false,
existing.kind === "SICKNESS"
? undefined
: {
message: `${existing.kind === "VACATION" ? "Urlaub" : "Freizeitausgleich"} ${dateKey(existing.startDate)}–${dateKey(existing.endDate)}: ${status === "CANCELLED" ? "storniert" : status === "REJECTED" ? "abgelehnt" : existing.status === "CANCEL_REQUESTED" ? "Stornierung abgelehnt; Genehmigung bleibt bestehen" : "genehmigt"}.`,
query: `tab=urlaub&year=${existing.startDate.getUTCFullYear()}`,
},
);
}
if (!profile.educator.active)
throw new UserFacingError("Die Fachkraft ist nicht mehr aktiv.");
const input = z
.object({
kind: z.nativeEnum(StaffLeaveKind),
startDate: dateSchema,
endDate: dateSchema,
halfDay: z.boolean(),
minutes: z.number().int().min(1).max(960).optional(),
note: z.string().trim().max(500).optional(),
})
.parse(data);
if (input.kind === "SICKNESS") requireAdmin(actor);
if (existing && existing.status !== "PENDING")
throw new UserFacingError(
"Nur offene Anträge können bearbeitet werden.",
);
if (input.startDate < dateKey(profile.trackingStart))
throw new UserFacingError(
"Abwesenheiten bitte ab Erfassungsbeginn erfassen.",
);
ensureRangeEditable(profile, input.startDate, input.endDate);
if (
profile.leaves.some(
(l) =>
l.id !== existing?.id &&
activeLeaveStatuses.includes(l.status) &&
dateKey(l.startDate) <= input.endDate &&
dateKey(l.endDate) >= input.startDate,
)
)
throw new UserFacingError(
"Der Zeitraum überschneidet sich mit einer offenen oder genehmigten Abwesenheit.",
);
const days = calculateLeaveDays(profile, holidays, input);
if (input.kind === "VACATION")
assertVacationFunds(profile, days, existing?.id);
if (input.kind === "TIME_OFF") {
const balance = getTimeBalance(
profile,
profile.weeks,
profile.adjustments,
profile.leaves,
);
const pending = profile.leaves
.filter(
(l) =>
l.id !== existing?.id &&
l.kind === "TIME_OFF" &&
l.status === "PENDING",
)
.reduce((sum, l) => sum + (l.minutes ?? 0), 0);
if (balance.available - pending < (input.minutes ?? 0))
throw new UserFacingError(
"Das noch nicht verplante bestätigte Zeitguthaben reicht nicht aus.",
);
}
if (input.kind === "SICKNESS") {
for (const day of days)
if (
profile.timeEntries.some((e) => dateKey(e.date) === day.date)
)
throw new UserFacingError(
"Für Krankheitstage mit Arbeitszeit bitte zuerst die Arbeitszeit korrigieren.",
);
}
const values = {
kind: input.kind,
startDate: dateOnly(input.startDate),
endDate: dateOnly(input.endDate),
halfDay: input.halfDay,
minutes: input.kind === "TIME_OFF" ? input.minutes : null,
note: input.note || null,
status:
input.kind === "SICKNESS"
? StaffRequestStatus.APPROVED
: StaffRequestStatus.PENDING,
...(input.kind === "SICKNESS"
? { reviewedById: actor.id, reviewedAt: new Date() }
: {}),
};
const leave = existing
? await tx.staffLeave.update({
where: { id: existing.id },
data: values,
})
: await tx.staffLeave.create({
data: {
...values,
profileId,
requestedById: actor.id,
},
});
await tx.staffLeaveDay.deleteMany({ where: { leaveId: leave.id } });
await tx.staffLeaveDay.createMany({
data: days.map((d) => ({
...d,
date: dateOnly(d.date),
leaveId: leave.id,
})),
});
await history(
"Abwesenheit erfasst",
`${input.kind}: ${input.startDate}–${input.endDate}`,
);
return notice(
`${input.kind === "SICKNESS" ? "Krankheit wurde hinterlegt" : "Ein Abwesenheitsantrag wurde eingereicht"}: ${input.startDate}–${input.endDate}.`,
input.kind !== "SICKNESS",
input.kind === "SICKNESS"
? undefined
: {
message: `${input.kind === "VACATION" ? "Urlaubsantrag" : "Antrag auf Freizeitausgleich"} ${input.startDate}–${input.endDate} wurde ${existing ? "geändert" : "eingereicht"}.`,
query: `tab=urlaub&year=${input.startDate.slice(0, 4)}`,
},
);
}
throw new UserFacingError("Unbekannte Aktion.");
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
timeout: 15000,
},
);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2034" &&
attempt < 2
)
continue;
throw error;
}
}
}