import { UpdateVacationStatusSchema, getPublicHolidays, buildTaskAction } from "@planarchy/shared"; import { VacationStatus, VacationType } from "@planarchy/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"; /** Types that consume from annual leave balance */ const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER]; 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: `Planarchy — ${title}`, text: body, }); } } export const vacationRouter = createTRPCRouter({ /** * 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 }) => { const vacations = await ctx.db.vacation.findMany({ where: { ...(input.resourceId ? { resourceId: input.resourceId } : {}), ...(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 }, requestedBy: { select: { id: true, name: true, email: true } }, approvedBy: { select: { id: true, name: true, email: true } }, }, }), "Vacation", ); const directory = await getAnonymizationDirectory(ctx.db); return anonymizeVacationRecord(vacation, directory); }), /** * Create a vacation request. * - MANAGER/ADMIN → auto-approved * - USER → PENDING * Adds isHalfDay + halfDayPart support. */ create: protectedProcedure .input( 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(), }).refine((d) => d.endDate >= d.startDate, { message: "End date must be after start date", path: ["endDate"], }), ) .mutation(async ({ ctx, input }) => { 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 }, }, }); if (overlapping) { throw new TRPCError({ code: "BAD_REQUEST", message: "Overlapping vacation already exists for this resource in the selected period", }); } 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); return anonymizeVacationRecord(vacation, directory); }), /** * 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})`, }); void dispatchWebhooks(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) { void notifyVacationStatus(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}` : ""}`, }); void notifyVacationStatus(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 }); void notifyVacationStatus(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 }); void notifyVacationStatus(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 }) => { 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 }, 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 }) => { // 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, }); }), /** * 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 holidays = getPublicHolidays(input.year, input.federalState); if (holidays.length === 0) { return { created: 0 }; } const resources = await ctx.db.resource.findMany({ where: { isActive: true, ...(input.chapter ? { chapter: input.chapter } : {}), }, select: { id: true }, }); 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; for (const resource of resources) { 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: holidays.length, 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: holidays.length, 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; }), });