import { UpdateVacationStatusSchema, buildTaskAction } 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, emitTaskAssigned } from "../sse/event-bus.js"; import { createNotification } from "../lib/create-notification.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { sendEmail } from "../lib/email.js"; import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js"; import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.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 { loadResourceHolidayContext } from "../lib/resource-holiday-context.js"; import { logger } from "../lib/logger.js"; import type { TRPCContext } from "../trpc.js"; /** Types that consume from annual leave balance */ const BALANCE_TYPES = new Set([VacationType.ANNUAL, VacationType.OTHER]); type VacationReadContext = Pick; function canManageVacationReads(ctx: { dbUser: { systemRole: string } | null }): boolean { const role = ctx.dbUser?.systemRole; return role === "ADMIN" || role === "MANAGER"; } function runVacationBackgroundEffect( effectName: string, execute: () => unknown, metadata: Record = {}, ): void { void Promise.resolve() .then(execute) .catch((error) => { logger.error( { err: error, effectName, ...metadata }, "Vacation background side effect failed", ); }); } function notifyVacationStatusInBackground( db: Parameters[0]>[0]["ctx"]["db"], vacationId: string, resourceId: string, newStatus: VacationStatus, rejectionReason?: string | null, ): void { runVacationBackgroundEffect( "notifyVacationStatus", () => notifyVacationStatus(db, vacationId, resourceId, newStatus, rejectionReason), { vacationId, resourceId, newStatus }, ); } function dispatchVacationWebhookInBackground( db: Parameters[0]>[0]["ctx"]["db"], event: string, payload: Record, ): void { runVacationBackgroundEffect( "dispatchWebhooks", () => dispatchWebhooks(db, event, payload), { event }, ); } async function findOwnedResourceId( ctx: VacationReadContext, ): Promise { if (!ctx.dbUser?.id) { return null; } if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { return null; } const resource = await ctx.db.resource.findFirst({ where: { userId: ctx.dbUser.id }, select: { id: true }, }); return resource?.id ?? null; } async function assertCanReadVacationResource( ctx: VacationReadContext, resourceId: string, ): Promise { if (canManageVacationReads(ctx)) { return; } const ownedResourceId = await findOwnedResourceId(ctx); if (!ownedResourceId || ownedResourceId !== resourceId) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only view vacation data for your own resource", }); } } function isSameUtcDay(left: Date, right: Date): boolean { return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10); } function mapTeamOverlapDetail(params: { resource: { displayName: string; chapter: string | null }; startDate: Date; endDate: Date; overlaps: Array<{ type: VacationType; status: VacationStatus; startDate: Date; endDate: Date; resource: { displayName: string }; }>; }) { return { resource: params.resource.displayName, chapter: params.resource.chapter, period: `${params.startDate.toISOString().slice(0, 10)} to ${params.endDate.toISOString().slice(0, 10)}`, overlappingVacations: params.overlaps.map((vacation) => ({ resource: vacation.resource.displayName, type: vacation.type, status: vacation.status, start: vacation.startDate.toISOString().slice(0, 10), end: vacation.endDate.toISOString().slice(0, 10), })), overlapCount: params.overlaps.length, }; } const PreviewVacationRequestSchema = z.object({ resourceId: z.string(), type: z.nativeEnum(VacationType), startDate: z.coerce.date(), endDate: z.coerce.date(), isHalfDay: z.boolean().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"], }); } }); 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"], }); } }); function anonymizeVacationRecord( vacation: T, directory: Awaited>, ): T { return { ...vacation, ...(vacation.resource ? { resource: anonymizeResource(vacation.resource, directory) } : {}), ...(vacation.requestedBy ? { requestedBy: anonymizeUser(vacation.requestedBy, directory) } : {}), ...(vacation.approvedBy ? { approvedBy: anonymizeUser(vacation.approvedBy, directory) } : {}), }; } /** Send in-app notification + optional email when vacation status changes */ async function notifyVacationStatus( db: Parameters[0]>[0]["ctx"]["db"], vacationId: string, resourceId: string, newStatus: VacationStatus, rejectionReason?: string | null, ) { // Find the resource's linked user const resource = await db.resource.findUnique({ where: { id: resourceId }, select: { displayName: true, user: { select: { id: true, email: true, name: true } }, }, }); if (!resource?.user) return; const statusLabel = newStatus === VacationStatus.APPROVED ? "approved" : "rejected"; const title = `Vacation request ${statusLabel}`; const body = rejectionReason ? `Your vacation request was ${statusLabel}. Reason: ${rejectionReason}` : `Your vacation request has been ${statusLabel}.`; // In-app notification await createNotification({ db, userId: resource.user.id, type: `VACATION_${newStatus}`, title, body, entityId: vacationId, entityType: "vacation", }); // Email (non-blocking) if (resource.user.email) { void sendEmail({ to: resource.user.email, subject: `CapaKraken — ${title}`, text: body, }); } } export const vacationRouter = createTRPCRouter({ previewRequest: protectedProcedure .input(PreviewVacationRequestSchema) .query(async ({ ctx, input }) => { const holidayContext = await loadResourceHolidayContext( ctx.db, input.resourceId, input.startDate, input.endDate, ); const vacation = { startDate: input.startDate, endDate: input.endDate, isHalfDay: input.isHalfDay ?? false, }; const requestedDays = countCalendarDaysInPeriod(vacation); const effectiveDays = BALANCE_TYPES.has(input.type) ? countVacationChargeableDays({ vacation, countryCode: holidayContext.countryCode, federalState: holidayContext.federalState, metroCityName: holidayContext.metroCityName, calendarHolidayStrings: holidayContext.calendarHolidayStrings, publicHolidayStrings: holidayContext.publicHolidayStrings, }) : requestedDays; const publicHolidayDates = [...new Set([ ...holidayContext.calendarHolidayStrings, ...holidayContext.publicHolidayStrings, ])].sort(); const holidayDetails = publicHolidayDates.map((date) => ({ date, source: holidayContext.calendarHolidayStrings.includes(date) && holidayContext.publicHolidayStrings.includes(date) ? "CALENDAR_AND_LEGACY" : holidayContext.calendarHolidayStrings.includes(date) ? "CALENDAR" : "LEGACY_PUBLIC_HOLIDAY", })); return { requestedDays, effectiveDays, deductedDays: BALANCE_TYPES.has(input.type) ? effectiveDays : 0, publicHolidayDates, holidayDetails, holidayContext: { countryCode: holidayContext.countryCode ?? null, countryName: holidayContext.countryName ?? null, federalState: holidayContext.federalState ?? null, metroCityName: holidayContext.metroCityName ?? null, sources: { hasCalendarHolidays: holidayContext.calendarHolidayStrings.length > 0, hasLegacyPublicHolidayEntries: holidayContext.publicHolidayStrings.length > 0, }, }, }; }), /** * List vacations with optional filters. */ list: protectedProcedure .input( z.object({ resourceId: z.string().optional(), status: z.union([z.nativeEnum(VacationStatus), z.array(z.nativeEnum(VacationStatus))]).optional(), type: z.nativeEnum(VacationType).optional(), startDate: z.coerce.date().optional(), endDate: z.coerce.date().optional(), limit: z.number().min(1).max(500).default(100), }), ) .query(async ({ ctx, input }) => { let resourceIdFilter = input.resourceId; if (!canManageVacationReads(ctx)) { const ownedResourceId = await findOwnedResourceId(ctx); if (input.resourceId && input.resourceId !== ownedResourceId) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only view vacation data for your own resource", }); } if (!ownedResourceId) { return []; } resourceIdFilter = ownedResourceId; } const vacations = await ctx.db.vacation.findMany({ where: { ...(resourceIdFilter ? { resourceId: resourceIdFilter } : {}), ...(input.status ? { status: Array.isArray(input.status) ? { in: input.status } : input.status } : {}), ...(input.type ? { type: input.type } : {}), ...(input.startDate ? { endDate: { gte: input.startDate } } : {}), ...(input.endDate ? { startDate: { lte: input.endDate } } : {}), }, include: { resource: { select: RESOURCE_BRIEF_SELECT }, requestedBy: { select: { id: true, name: true, email: true } }, approvedBy: { select: { id: true, name: true, email: true } }, }, orderBy: { startDate: "asc" }, take: input.limit, }); const directory = await getAnonymizationDirectory(ctx.db); return vacations.map((vacation) => anonymizeVacationRecord(vacation, directory)); }), /** * Get a single vacation by ID. */ getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const vacation = await findUniqueOrThrow( ctx.db.vacation.findUnique({ where: { id: input.id }, include: { resource: { select: { ...RESOURCE_BRIEF_SELECT, userId: true } }, requestedBy: { select: { id: true, name: true, email: true } }, approvedBy: { select: { id: true, name: true, email: true } }, }, }), "Vacation", ); if (!canManageVacationReads(ctx)) { const isOwnVacation = vacation.resource?.userId === ctx.dbUser?.id || vacation.requestedById === ctx.dbUser?.id; if (!isOwnVacation) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only view your own vacation data", }); } } const directory = await getAnonymizationDirectory(ctx.db); const anonymized = anonymizeVacationRecord(vacation, directory); return { ...anonymized, resource: anonymized.resource ? { id: anonymized.resource.id, displayName: anonymized.resource.displayName, eid: anonymized.resource.eid, lcrCents: anonymized.resource.lcrCents, chapter: anonymized.resource.chapter, } : null, }; }), /** * 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 }, ...(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; if (BALANCE_TYPES.has(input.type)) { const holidayContext = await loadResourceHolidayContext( ctx.db, input.resourceId, input.startDate, input.endDate, ); effectiveDays = countVacationChargeableDays({ vacation: { startDate: input.startDate, endDate: input.endDate, isHalfDay: input.isHalfDay ?? false, }, countryCode: holidayContext.countryCode, federalState: holidayContext.federalState, metroCityName: holidayContext.metroCityName, calendarHolidayStrings: holidayContext.calendarHolidayStrings, publicHolidayStrings: holidayContext.publicHolidayStrings, }); 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 } : {}), 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"; const startStr = input.startDate.toISOString().slice(0, 10); const endStr = input.endDate.toISOString().slice(0, 10); const managers = await ctx.db.user.findMany({ where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, select: { id: true }, }); for (const manager of managers) { if (manager.id === userRecord.id) continue; const taskId = await createNotification({ db: ctx.db, userId: manager.id, category: "APPROVAL", type: "VACATION_APPROVAL", priority: "NORMAL", title: `Vacation approval: ${resourceName}`, body: `${resourceName} requests ${input.type} from ${startStr} to ${endStr}`, taskStatus: "OPEN", taskAction: buildTaskAction("approve_vacation", vacation.id), entityId: vacation.id, entityType: "vacation", link: "/vacations", senderId: userRecord.id, channel: "in_app", }); emitTaskAssigned(manager.id, taskId); } } 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" }); } 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, ...(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 ctx.db.notification.updateMany({ where: { taskAction: buildTaskAction("approve_vacation", input.id), category: "APPROVAL", taskStatus: "OPEN", }, data: { taskStatus: "DONE", completedAt: new Date(), ...(userRecord?.id ? { completedBy: 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 ctx.db.notification.updateMany({ where: { taskAction: buildTaskAction("approve_vacation", input.id), category: "APPROVAL", taskStatus: "OPEN", }, data: { taskStatus: "DONE", completedAt: new Date(), ...(userRecord?.id ? { completedBy: 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 }, }); // 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, ); await ctx.db.vacation.updateMany({ where: { id: { in: vacations.map((v) => v.id) } }, data: { status: VacationStatus.APPROVED, rejectionReason: null, ...(userRecord?.id ? { approvedById: userRecord.id } : {}), approvedAt: new Date(), }, }); for (const v of vacations) { emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED }); notifyVacationStatusInBackground(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED); void createAuditEntry({ db: ctx.db, entityType: "Vacation", entityId: v.id, entityName: `Vacation ${v.id}`, action: "UPDATE", ...(userRecord?.id ? { userId: userRecord.id } : {}), after: { status: VacationStatus.APPROVED } as unknown as Record, source: "ui", summary: "Batch approved vacation", }); // Mark approval tasks as DONE await ctx.db.notification.updateMany({ where: { taskAction: buildTaskAction("approve_vacation", v.id), category: "APPROVAL", taskStatus: "OPEN", }, data: { taskStatus: "DONE", completedAt: new Date(), ...(userRecord?.id ? { completedBy: 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 ctx.db.notification.updateMany({ where: { taskAction: buildTaskAction("approve_vacation", v.id), category: "APPROVAL", taskStatus: "OPEN", }, data: { taskStatus: "DONE", completedAt: new Date(), ...(userRecord?.id ? { completedBy: 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 APPROVED vacations for a resource in a date range (used by calculator). */ getForResource: protectedProcedure .input( z.object({ resourceId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), }), ) .query(async ({ ctx, input }) => { await assertCanReadVacationResource(ctx, input.resourceId); return ctx.db.vacation.findMany({ where: { resourceId: input.resourceId, status: VacationStatus.APPROVED, startDate: { lte: input.endDate }, endDate: { gte: input.startDate }, }, select: { id: true, startDate: true, endDate: true, type: true, status: true, }, orderBy: { startDate: "asc" }, }); }), /** * 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" }, }); }), /** * Get team overlap: other vacations in the same chapter for a given period. * Used by the creation modal to warn the requester. */ getTeamOverlap: protectedProcedure .input( z.object({ resourceId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), }), ) .query(async ({ ctx, input }) => { await assertCanReadVacationResource(ctx, input.resourceId); // Find the chapter of the requesting resource const resource = await ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { chapter: true }, }); if (!resource?.chapter) return []; // Find team members in the same chapter who are off in this period return ctx.db.vacation.findMany({ where: { resource: { chapter: resource.chapter }, resourceId: { not: input.resourceId }, status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, startDate: { lte: input.endDate }, endDate: { gte: input.startDate }, }, include: { resource: { select: RESOURCE_BRIEF_SELECT }, }, orderBy: { startDate: "asc" }, take: 20, }); }), getTeamOverlapDetail: protectedProcedure .input( z.object({ resourceId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), }), ) .query(async ({ ctx, input }) => { await assertCanReadVacationResource(ctx, input.resourceId); const resource = await ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { displayName: true, chapter: true }, }); if (!resource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found", }); } if (!resource.chapter) { return mapTeamOverlapDetail({ resource, startDate: input.startDate, endDate: input.endDate, overlaps: [], }); } const overlaps = await ctx.db.vacation.findMany({ where: { resource: { chapter: resource.chapter }, resourceId: { not: input.resourceId }, status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, startDate: { lte: input.endDate }, endDate: { gte: input.startDate }, }, include: { resource: { select: RESOURCE_BRIEF_SELECT }, }, orderBy: { startDate: "asc" }, take: 20, }); return mapTeamOverlapDetail({ resource, startDate: input.startDate, endDate: input.endDate, overlaps, }); }), /** * 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; }), });