import { UpdateVacationStatusSchema } from "@capakraken/shared"; import { VacationStatus } from "@capakraken/db"; import { approveVacation, batchApproveVacations, batchRejectVacations, cancelVacation, rejectVacation, } from "@capakraken/application"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { makeAuditLogger } from "../lib/audit-helpers.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), }); export const vacationManagementProcedures = { approve: managerProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => { const userRecord = ctx.dbUser; const audit = makeAuditLogger(ctx.db, userRecord?.id); const result = await approveVacation( ctx.db, { id: input.id, actorUserId: userRecord?.id }, { assertVacationApprovable, assertVacationStillChargeable, buildVacationApprovalWriteData, checkVacationConflicts, buildApprovedVacationUpdateData, }, ); const { vacation: updated, existingStatus, warnings } = result; emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); audit({ entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", after: updated as unknown as Record, summary: `Approved vacation (was ${existingStatus})`, }); 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 (existingStatus === VacationStatus.PENDING) { notifyVacationStatusInBackground( ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED, ); } return { ...updated, warnings }; }), reject: managerProcedure .input(z.object({ id: z.string(), rejectionReason: z.string().max(500).optional() })) .mutation(async ({ ctx, input }) => { const result = await rejectVacation( ctx.db, { id: input.id, rejectionReason: input.rejectionReason }, { assertVacationRejectable, buildRejectedVacationUpdateData }, ); const { vacation: updated } = result; emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status, }); const userRecord = ctx.dbUser; const audit = makeAuditLogger(ctx.db, userRecord?.id); await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id); audit({ entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", after: updated as unknown as Record, 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 = ctx.dbUser; const audit = makeAuditLogger(ctx.db, userRecord?.id); const result = await batchApproveVacations( ctx.db, { ids: input.ids, actorUserId: userRecord?.id }, { assertVacationStillChargeable, buildVacationApprovalWriteData, checkBatchVacationConflicts, buildApprovedVacationUpdateData, }, ); for (const updated of result.updatedVacations) { emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status, }); notifyVacationStatusInBackground( ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED, ); await completeVacationApprovalTasks(ctx.db, updated.id, userRecord?.id); audit({ entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", after: updated.existingVacation as unknown as Record, summary: "Batch approved vacation", }); } return { approved: result.approved, warnings: result.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 = ctx.dbUser; const audit = makeAuditLogger(ctx.db, userRecord?.id); const result = await batchRejectVacations( ctx.db, { ids: input.ids, rejectionReason: input.rejectionReason }, { buildRejectedVacationUpdateData }, ); for (const vacation of result.vacations) { emitVacationUpdated({ id: vacation.id, resourceId: vacation.resourceId, status: VacationStatus.REJECTED, }); notifyVacationStatusInBackground( ctx.db, vacation.id, vacation.resourceId, VacationStatus.REJECTED, input.rejectionReason, ); audit({ entityType: "Vacation", entityId: vacation.id, entityName: `Vacation ${vacation.id}`, action: "UPDATE", after: { status: VacationStatus.REJECTED, rejectionReason: input.rejectionReason, } as unknown as Record, summary: `Batch rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`, }); await completeVacationApprovalTasks(ctx.db, vacation.id, userRecord?.id); } return { rejected: result.rejected }; }), cancel: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const userRecord = ctx.dbUser; const audit = makeAuditLogger(ctx.db, userRecord?.id); if (!userRecord) { throw new TRPCError({ code: "UNAUTHORIZED" }); } const result = await cancelVacation( ctx.db, { id: input.id, actorId: userRecord.id, actorRole: userRecord.systemRole, }, { assertVacationCancelable, isVacationManagerRole, canActorCancelVacation, }, ); const { vacation: updated, existingStatus } = result; emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status, }); audit({ entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", after: updated as unknown as Record, summary: `Cancelled vacation (was ${existingStatus})`, }); 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 = ctx.dbUser; if (!adminUser) { throw new TRPCError({ code: "UNAUTHORIZED" }); } const audit = makeAuditLogger(ctx.db, adminUser.id); const { created, holidays, resources } = await batchCreatePublicHolidayVacations( ctx.db, input, adminUser.id, ); audit({ entityType: "Vacation", entityId: `public-holidays-${input.year}`, entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`, action: "CREATE", after: { created, holidays, resources, year: input.year, federalState: input.federalState, } as unknown as Record, 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 ctx.db.vacation.findUnique({ where: { id: input.id } }); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" }); } const userRecord = ctx.dbUser; const audit = makeAuditLogger(ctx.db, userRecord?.id); 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, }); audit({ entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", before: existing as unknown as Record, after: updated as unknown as Record, summary: `Updated vacation status to ${input.status}`, }); return updated; }), };