import { UpdateVacationStatusSchema } from "@capakraken/shared"; import { VacationStatus } from "@capakraken/db"; import { TRPCError } from "@trpc/server"; import { VACATION_BALANCE_TYPES } from "../lib/vacation-deduction-snapshot.js"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { createAuditEntry } from "../lib/audit.js"; import { checkBatchVacationConflicts, checkVacationConflicts } from "../lib/vacation-conflicts.js"; import { emitVacationUpdated } from "../sse/event-bus.js"; import { adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { assertVacationStillChargeable, buildVacationApprovalWriteData, } from "./vacation-chargeability.js"; import { batchCreatePublicHolidayVacations } from "./vacation-public-holidays.js"; import { completeVacationApprovalTasks, dispatchVacationWebhookInBackground, notifyVacationStatusInBackground, } from "./vacation-side-effects.js"; import { assertVacationApprovable, assertVacationCancelable, assertVacationRejectable, buildApprovedVacationUpdateData, buildRejectedVacationUpdateData, buildVacationStatusUpdateData, canActorCancelVacation, isVacationManagerRole, } from "./vacation-management-support.js"; const BatchCreatePublicHolidaysSchema = z.object({ year: z.number().int().min(2000).max(2100), federalState: z.string().optional(), chapter: z.string().optional(), replaceExisting: z.boolean().default(false), }); async function findVacationActor( db: Parameters[0]>[0]["ctx"]["db"], email: string | null | undefined, ) { return db.user.findUnique({ where: { email: email ?? "" }, select: { id: true, systemRole: true }, }); } export const vacationManagementProcedures = { approve: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const existing = await findUniqueOrThrow( ctx.db.vacation.findUnique({ where: { id: input.id } }), "Vacation", ); assertVacationApprovable(existing.status); await assertVacationStillChargeable(ctx.db, { resourceId: existing.resourceId, type: existing.type, startDate: existing.startDate, endDate: existing.endDate, isHalfDay: existing.isHalfDay, }); const deductionSnapshotWriteData = await buildVacationApprovalWriteData(ctx.db, { resourceId: existing.resourceId, type: existing.type, startDate: existing.startDate, endDate: existing.endDate, isHalfDay: existing.isHalfDay, }); const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); const conflictResult = await checkVacationConflicts( // eslint-disable-next-line @typescript-eslint/no-explicit-any ctx.db as any, input.id, userRecord?.id, ); const updated = await ctx.db.vacation.update({ where: { id: input.id }, data: buildApprovedVacationUpdateData({ deductionSnapshotWriteData, approvedById: userRecord?.id, approvedAt: new Date(), }), }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); void createAuditEntry({ db: ctx.db, entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", ...(userRecord?.id ? { userId: userRecord.id } : {}), before: existing as unknown as Record, after: updated as unknown as Record, source: "ui", summary: `Approved vacation (was ${existing.status})`, }); dispatchVacationWebhookInBackground(ctx.db, "vacation.approved", { id: updated.id, resourceId: updated.resourceId, startDate: updated.startDate.toISOString(), endDate: updated.endDate.toISOString(), }); await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id); if (existing.status === VacationStatus.PENDING) { notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED); } return { ...updated, warnings: conflictResult.warnings }; }), reject: managerProcedure .input(z.object({ id: z.string(), rejectionReason: z.string().max(500).optional() })) .mutation(async ({ ctx, input }) => { const existing = await findUniqueOrThrow( ctx.db.vacation.findUnique({ where: { id: input.id } }), "Vacation", ); assertVacationRejectable(existing.status); const updated = await ctx.db.vacation.update({ where: { id: input.id }, data: buildRejectedVacationUpdateData({ rejectionReason: input.rejectionReason, }), }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id); void createAuditEntry({ db: ctx.db, entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", ...(userRecord?.id ? { userId: userRecord.id } : {}), before: existing as unknown as Record, after: updated as unknown as Record, source: "ui", summary: `Rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`, }); notifyVacationStatusInBackground( ctx.db, updated.id, updated.resourceId, VacationStatus.REJECTED, input.rejectionReason, ); return updated; }), batchApprove: managerProcedure .input(z.object({ ids: z.array(z.string()).min(1).max(100) })) .mutation(async ({ ctx, input }) => { const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); const vacations = await ctx.db.vacation.findMany({ where: { id: { in: input.ids }, status: VacationStatus.PENDING }, select: { id: true, resourceId: true, type: true, startDate: true, endDate: true, isHalfDay: true, }, }); for (const vacation of vacations) { await assertVacationStillChargeable(ctx.db, vacation); } const conflictMap = await checkBatchVacationConflicts( // eslint-disable-next-line @typescript-eslint/no-explicit-any ctx.db as any, vacations.map((vacation) => vacation.id), userRecord?.id, ); for (const vacation of vacations) { const deductionSnapshotWriteData = await buildVacationApprovalWriteData(ctx.db, { resourceId: vacation.resourceId, type: vacation.type, startDate: vacation.startDate, endDate: vacation.endDate, isHalfDay: vacation.isHalfDay, }); const updated = await ctx.db.vacation.update({ where: { id: vacation.id }, data: buildApprovedVacationUpdateData({ deductionSnapshotWriteData, approvedById: userRecord?.id, approvedAt: new Date(), }), }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED); void createAuditEntry({ db: ctx.db, entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", ...(userRecord?.id ? { userId: userRecord.id } : {}), after: updated as unknown as Record, source: "ui", summary: "Batch approved vacation", }); await completeVacationApprovalTasks(ctx.db, updated.id, userRecord?.id); } const warnings: string[] = []; for (const [, vacationWarnings] of conflictMap) { warnings.push(...vacationWarnings); } return { approved: vacations.length, warnings }; }), batchReject: managerProcedure .input( z.object({ ids: z.array(z.string()).min(1).max(100), rejectionReason: z.string().max(500).optional(), }), ) .mutation(async ({ ctx, input }) => { const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); const vacations = await ctx.db.vacation.findMany({ where: { id: { in: input.ids }, status: VacationStatus.PENDING }, select: { id: true, resourceId: true }, }); await ctx.db.vacation.updateMany({ where: { id: { in: vacations.map((vacation) => vacation.id) } }, data: buildRejectedVacationUpdateData({ rejectionReason: input.rejectionReason, }), }); for (const vacation of vacations) { emitVacationUpdated({ id: vacation.id, resourceId: vacation.resourceId, status: VacationStatus.REJECTED }); notifyVacationStatusInBackground( ctx.db, vacation.id, vacation.resourceId, VacationStatus.REJECTED, input.rejectionReason, ); void createAuditEntry({ db: ctx.db, entityType: "Vacation", entityId: vacation.id, entityName: `Vacation ${vacation.id}`, action: "UPDATE", ...(userRecord?.id ? { userId: userRecord.id } : {}), after: { status: VacationStatus.REJECTED, rejectionReason: input.rejectionReason } as unknown as Record, source: "ui", summary: `Batch rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`, }); await completeVacationApprovalTasks(ctx.db, vacation.id, userRecord?.id); } return { rejected: vacations.length }; }), cancel: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const existing = await findUniqueOrThrow( ctx.db.vacation.findUnique({ where: { id: input.id } }), "Vacation", ); assertVacationCancelable(existing.status); const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); if (!userRecord) { throw new TRPCError({ code: "UNAUTHORIZED" }); } const resource = isVacationManagerRole(userRecord.systemRole) || existing.requestedById === userRecord.id ? null : await ctx.db.resource.findUnique({ where: { id: existing.resourceId }, select: { userId: true }, }); if (!canActorCancelVacation({ actorId: userRecord.id, actorRole: userRecord.systemRole, requestedById: existing.requestedById, resourceUserId: resource?.userId, })) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only cancel your own vacation requests", }); } const wasApproved = existing.status === VacationStatus.APPROVED; const shouldReverseEntitlement = wasApproved && VACATION_BALANCE_TYPES.has(existing.type) && typeof existing.deductedDays === "number" && existing.deductedDays > 0; const updated = await ctx.db.vacation.update({ where: { id: input.id }, data: { status: VacationStatus.CANCELLED }, }); if (shouldReverseEntitlement) { const year = existing.startDate.getFullYear(); await ctx.db.vacationEntitlement.updateMany({ where: { resourceId: existing.resourceId, year }, data: { usedDays: { decrement: existing.deductedDays as number } }, }); } emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); void createAuditEntry({ db: ctx.db, entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", userId: userRecord.id, before: existing as unknown as Record, after: updated as unknown as Record, source: "ui", summary: `Cancelled vacation (was ${existing.status})`, }); return updated; }), getPendingApprovals: managerProcedure.query(async ({ ctx }) => { return ctx.db.vacation.findMany({ where: { status: VacationStatus.PENDING }, include: { resource: { select: { ...RESOURCE_BRIEF_SELECT, chapter: true } }, requestedBy: { select: { id: true, name: true, email: true } }, }, orderBy: { startDate: "asc" }, }); }), batchCreatePublicHolidays: adminProcedure .input(BatchCreatePublicHolidaysSchema) .mutation(async ({ ctx, input }) => { const adminUser = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true }, }); if (!adminUser) { throw new TRPCError({ code: "UNAUTHORIZED" }); } const { created, holidays, resources } = await batchCreatePublicHolidayVacations( ctx.db, input, adminUser.id, ); void createAuditEntry({ db: ctx.db, entityType: "Vacation", entityId: `public-holidays-${input.year}`, entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`, action: "CREATE", userId: adminUser.id, after: { created, holidays, resources, year: input.year, federalState: input.federalState } as unknown as Record, source: "ui", summary: `Batch created ${created} public holidays for ${resources} resources (${input.year})`, }); return { created, holidays, resources }; }), updateStatus: protectedProcedure .input(UpdateVacationStatusSchema) .mutation(async ({ ctx, input }) => { const existing = await findUniqueOrThrow( ctx.db.vacation.findUnique({ where: { id: input.id } }), "Vacation", ); const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); if (!userRecord) { throw new TRPCError({ code: "UNAUTHORIZED" }); } if (input.status !== "CANCELLED" && !isVacationManagerRole(userRecord.systemRole)) { throw new TRPCError({ code: "FORBIDDEN", message: "Manager role required to approve/reject" }); } const updated = await ctx.db.vacation.update({ where: { id: input.id }, data: buildVacationStatusUpdateData({ status: input.status, note: input.note, approvedById: userRecord.id, approvedAt: new Date(), }), }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); void createAuditEntry({ db: ctx.db, entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", userId: userRecord.id, before: existing as unknown as Record, after: updated as unknown as Record, source: "ui", summary: `Updated vacation status to ${input.status}`, }); return updated; }), };