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 { 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 { 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 => { 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; } } }