import { UpdateVacationStatusSchema } from "@capakraken/shared"; import { VacationStatus, VacationType } from "@capakraken/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { emitVacationCreated, emitVacationUpdated } from "../sse/event-bus.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js"; import { createAuditEntry } from "../lib/audit.js"; import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js"; import { VACATION_BALANCE_TYPES, buildVacationDeductionSnapshotWriteData, calculateVacationDeductionSnapshot, type VacationChargeableInput, } from "../lib/vacation-deduction-snapshot.js"; import type { TRPCContext } from "../trpc.js"; import { anonymizeVacationRecord, isSameUtcDay, vacationReadProcedures } from "./vacation-read.js"; import { completeVacationApprovalTasks, createVacationApprovalTasks, dispatchVacationWebhookInBackground, notifyVacationStatusInBackground, } from "./vacation-side-effects.js"; async function calculateVacationEffectiveDays( db: TRPCContext["db"], vacation: VacationChargeableInput, ): Promise { if (!VACATION_BALANCE_TYPES.has(vacation.type)) { return countCalendarDaysInPeriod(vacation); } const snapshot = await calculateVacationDeductionSnapshot(db, vacation); return snapshot.deductedDays; } async function assertVacationStillChargeable( db: TRPCContext["db"], vacation: VacationChargeableInput, ): Promise { if (!VACATION_BALANCE_TYPES.has(vacation.type)) { return; } const effectiveDays = await calculateVacationEffectiveDays(db, vacation); if (effectiveDays <= 0) { throw new TRPCError({ code: "BAD_REQUEST", message: "Vacation no longer deducts any vacation days for the current holiday calendar and cannot be approved", }); } } const CreateVacationRequestSchema = z.object({ resourceId: z.string(), type: z.nativeEnum(VacationType), startDate: z.coerce.date(), endDate: z.coerce.date(), note: z.string().max(500).optional(), isHalfDay: z.boolean().optional(), halfDayPart: z.enum(["MORNING", "AFTERNOON"]).optional(), }).superRefine((data, ctx) => { if (data.endDate < data.startDate) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "End date must be after start date", path: ["endDate"], }); } if (data.isHalfDay && !isSameUtcDay(data.startDate, data.endDate)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Half-day requests must start and end on the same day", path: ["isHalfDay"], }); } if (data.isHalfDay && !data.halfDayPart) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Half-day requests require a half-day part", path: ["halfDayPart"], }); } if (!data.isHalfDay && data.halfDayPart) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Half-day part is only allowed for half-day requests", path: ["halfDayPart"], }); } }); export const vacationRouter = createTRPCRouter({ ...vacationReadProcedures, /** * Create a vacation request. * - MANAGER/ADMIN → auto-approved * - USER → PENDING * Adds isHalfDay + halfDayPart support. */ create: protectedProcedure .input(CreateVacationRequestSchema) .mutation(async ({ ctx, input }) => { if (input.type === VacationType.PUBLIC_HOLIDAY) { throw new TRPCError({ code: "BAD_REQUEST", message: "Public holidays must be managed via Holiday Calendars or the legacy holiday import, not via manual vacation requests", }); } const userRecord = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true, systemRole: true }, }); if (!userRecord) { throw new TRPCError({ code: "UNAUTHORIZED" }); } // Ownership check: USER role can only create vacations for their own resource const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER"; if (!isManager) { const resource = await ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { userId: true }, }); if (!resource || resource.userId !== userRecord.id) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only create vacation requests for your own resource", }); } } // Check for overlapping APPROVED or PENDING vacations const overlapping = await ctx.db.vacation.findFirst({ where: { resourceId: input.resourceId, status: { in: ["APPROVED", "PENDING"] }, startDate: { lte: input.endDate }, endDate: { gte: input.startDate }, ...(VACATION_BALANCE_TYPES.has(input.type) ? { type: { not: VacationType.PUBLIC_HOLIDAY } } : {}), }, }); if (overlapping) { throw new TRPCError({ code: "BAD_REQUEST", message: "Overlapping vacation already exists for this resource in the selected period", }); } let effectiveDays: number | null = null; let deductionSnapshotWriteData: ReturnType | null = null; if (VACATION_BALANCE_TYPES.has(input.type)) { const deductionSnapshot = await calculateVacationDeductionSnapshot(ctx.db, { resourceId: input.resourceId, type: input.type, startDate: input.startDate, endDate: input.endDate, isHalfDay: input.isHalfDay ?? false, }); effectiveDays = deductionSnapshot.deductedDays; deductionSnapshotWriteData = buildVacationDeductionSnapshotWriteData(deductionSnapshot); if (effectiveDays <= 0) { throw new TRPCError({ code: "BAD_REQUEST", message: "Selected vacation period only contains public holidays and does not deduct any vacation days", }); } } const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING; const vacation = await ctx.db.vacation.create({ data: { resourceId: input.resourceId, type: input.type, status, startDate: input.startDate, endDate: input.endDate, ...(input.note !== undefined ? { note: input.note } : {}), isHalfDay: input.isHalfDay ?? false, ...(input.halfDayPart !== undefined ? { halfDayPart: input.halfDayPart } : {}), ...(deductionSnapshotWriteData ?? { deductedDays: 0 }), requestedById: userRecord.id, ...(isManager ? { approvedById: userRecord.id, approvedAt: new Date() } : {}), }, include: { resource: { select: RESOURCE_BRIEF_SELECT }, requestedBy: { select: { id: true, name: true, email: true } }, }, }); emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status }); void createAuditEntry({ db: ctx.db, entityType: "Vacation", entityId: vacation.id, entityName: `${vacation.resource?.displayName ?? "Unknown"} - ${vacation.type}`, action: "CREATE", userId: userRecord.id, after: vacation as unknown as Record, source: "ui", }); // Create approval tasks for managers when a non-manager submits a vacation request if (status === VacationStatus.PENDING) { const resourceName = vacation.resource?.displayName ?? "Unknown"; await createVacationApprovalTasks({ db: ctx.db, submittedByUserId: userRecord.id, vacationId: vacation.id, resourceName, vacationType: input.type, startDate: input.startDate, endDate: input.endDate, }); } const directory = await getAnonymizationDirectory(ctx.db); const result = anonymizeVacationRecord(vacation, directory); return effectiveDays === null ? result : { ...result, effectiveDays }; }), /** * Approve a vacation (manager/admin only). */ 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", ); const approvableStatuses: string[] = [VacationStatus.PENDING, VacationStatus.CANCELLED, VacationStatus.REJECTED]; if (!approvableStatuses.includes(existing.status)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved" }); } await assertVacationStillChargeable(ctx.db, { resourceId: existing.resourceId, type: existing.type, startDate: existing.startDate, endDate: existing.endDate, isHalfDay: existing.isHalfDay, }); const deductionSnapshotWriteData = VACATION_BALANCE_TYPES.has(existing.type) ? buildVacationDeductionSnapshotWriteData( await calculateVacationDeductionSnapshot(ctx.db, { resourceId: existing.resourceId, type: existing.type, startDate: existing.startDate, endDate: existing.endDate, isHalfDay: existing.isHalfDay, }), ) : { deductedDays: 0 }; const userRecord = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true }, }); // Check for team conflicts before approving (non-blocking) // eslint-disable-next-line @typescript-eslint/no-explicit-any const conflictResult = await checkVacationConflicts(ctx.db as any, input.id, userRecord?.id); const updated = await ctx.db.vacation.update({ where: { id: input.id }, data: { status: VacationStatus.APPROVED, rejectionReason: null, ...deductionSnapshotWriteData, ...(userRecord?.id ? { 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(), }); // Mark approval tasks as DONE 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 a vacation (manager/admin only). */ 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", ); if (existing.status !== VacationStatus.PENDING) { throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING vacations can be rejected" }); } const updated = await ctx.db.vacation.update({ where: { id: input.id }, data: { status: VacationStatus.REJECTED, ...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}), }, }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); // Mark approval tasks as DONE const userRecord = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true }, }); 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; }), /** * Batch approve multiple pending vacations (manager/admin only). */ batchApprove: managerProcedure .input(z.object({ ids: z.array(z.string()).min(1).max(100) })) .mutation(async ({ ctx, input }) => { const userRecord = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true }, }); 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); } // Check for team conflicts before approving (non-blocking) const conflictMap = await checkBatchVacationConflicts( // eslint-disable-next-line @typescript-eslint/no-explicit-any ctx.db as any, vacations.map((v) => v.id), userRecord?.id, ); for (const v of vacations) { const deductionSnapshotWriteData = VACATION_BALANCE_TYPES.has(v.type) ? buildVacationDeductionSnapshotWriteData( await calculateVacationDeductionSnapshot(ctx.db, { resourceId: v.resourceId, type: v.type, startDate: v.startDate, endDate: v.endDate, isHalfDay: v.isHalfDay, }), ) : { deductedDays: 0 }; const updated = await ctx.db.vacation.update({ where: { id: v.id }, data: { status: VacationStatus.APPROVED, rejectionReason: null, ...deductionSnapshotWriteData, ...(userRecord?.id ? { 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", }); // Mark approval tasks as DONE await completeVacationApprovalTasks(ctx.db, updated.id, userRecord?.id); } // Flatten all warnings into a single array const warnings: string[] = []; for (const [, w] of conflictMap) { warnings.push(...w); } return { approved: vacations.length, warnings }; }), /** * Batch reject multiple pending vacations (manager/admin only). */ 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 ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true }, }); 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((v) => v.id) } }, data: { status: VacationStatus.REJECTED, ...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}), }, }); for (const v of vacations) { emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED }); notifyVacationStatusInBackground( ctx.db, v.id, v.resourceId, VacationStatus.REJECTED, input.rejectionReason, ); void createAuditEntry({ db: ctx.db, entityType: "Vacation", entityId: v.id, entityName: `Vacation ${v.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}` : ""}`, }); // Mark approval tasks as DONE await completeVacationApprovalTasks(ctx.db, v.id, userRecord?.id); } return { rejected: vacations.length }; }), /** * Cancel a vacation (owner or manager). */ 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", ); if (existing.status === VacationStatus.CANCELLED) { throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" }); } // Ownership check: USER can only cancel their own vacations const userRecord = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true, systemRole: true }, }); if (!userRecord) { throw new TRPCError({ code: "UNAUTHORIZED" }); } const isManagerOrAdmin = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER"; if (!isManagerOrAdmin) { if (existing.requestedById !== userRecord.id) { const resource = await ctx.db.resource.findUnique({ where: { id: existing.resourceId }, select: { userId: true }, }); if (!resource || resource.userId !== userRecord.id) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only cancel your own vacation requests", }); } } } const updated = await ctx.db.vacation.update({ where: { id: input.id }, data: { status: VacationStatus.CANCELLED }, }); 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; }), /** * Get all PENDING vacations awaiting approval (manager/admin only). */ 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" }, }); }), /** * Batch-create public holidays for all resources (or a chapter) for a given year+state. * Admin-only. Creates as APPROVED automatically. */ batchCreatePublicHolidays: adminProcedure .input( z.object({ year: z.number().int().min(2000).max(2100), federalState: z.string().optional(), // e.g. "BY" chapter: z.string().optional(), // filter to a chapter replaceExisting: z.boolean().default(false), }), ) .mutation(async ({ ctx, input }) => { const resources = await ctx.db.resource.findMany({ where: { isActive: true, ...(input.chapter ? { chapter: input.chapter } : {}), }, select: { id: true, federalState: true, countryId: true, metroCityId: true, country: { select: { code: true } }, metroCity: { select: { name: true } }, }, }); if (resources.length === 0) { return { created: 0 }; } const adminUser = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true }, }); if (!adminUser) throw new TRPCError({ code: "UNAUTHORIZED" }); let created = 0; let holidayCount = 0; for (const resource of resources) { const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`), periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`), countryId: resource.countryId, countryCode: resource.country?.code, federalState: input.federalState ?? resource.federalState, metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name, }); holidayCount += holidays.length; for (const holiday of holidays) { const startDate = new Date(holiday.date); const endDate = new Date(holiday.date); if (input.replaceExisting) { // Remove any existing public holiday on this exact date for this resource await ctx.db.vacation.deleteMany({ where: { resourceId: resource.id, type: VacationType.PUBLIC_HOLIDAY, startDate, endDate, }, }); } // Check if one already exists const exists = await ctx.db.vacation.findFirst({ where: { resourceId: resource.id, type: VacationType.PUBLIC_HOLIDAY, startDate, endDate, }, }); if (exists) continue; await ctx.db.vacation.create({ data: { resourceId: resource.id, type: VacationType.PUBLIC_HOLIDAY, status: VacationStatus.APPROVED, startDate, endDate, note: holiday.name, requestedById: adminUser.id, approvedById: adminUser.id, approvedAt: new Date(), }, }); created++; } } 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: holidayCount, resources: resources.length, year: input.year, federalState: input.federalState } as unknown as Record, source: "ui", summary: `Batch created ${created} public holidays for ${resources.length} resources (${input.year})`, }); return { created, holidays: holidayCount, resources: resources.length }; }), /** * Update vacation status (approve/reject/cancel via schema). */ updateStatus: protectedProcedure .input(UpdateVacationStatusSchema) .mutation(async ({ ctx, input }) => { const existing = await findUniqueOrThrow( ctx.db.vacation.findUnique({ where: { id: input.id } }), "Vacation", ); const userRecord = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true, systemRole: true }, }); if (!userRecord) throw new TRPCError({ code: "UNAUTHORIZED" }); const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER"; if (input.status !== "CANCELLED" && !isManager) { throw new TRPCError({ code: "FORBIDDEN", message: "Manager role required to approve/reject" }); } const data: Record = { status: input.status }; if (input.status === "APPROVED") { data.approvedById = userRecord.id; data.approvedAt = new Date(); data.rejectionReason = null; } if (input.note !== undefined) { data.note = input.note; } const updated = await ctx.db.vacation.update({ where: { id: input.id }, data, }); 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; }), });