From ae1a0ca268db122ea705bcfee65cd4d97e7edd0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 11:12:56 +0200 Subject: [PATCH] refactor(api): extract vacation management procedures --- .../router/vacation-management-procedures.ts | 436 +++++++++++++++ packages/api/src/router/vacation.ts | 495 +----------------- 2 files changed, 445 insertions(+), 486 deletions(-) create mode 100644 packages/api/src/router/vacation-management-procedures.ts diff --git a/packages/api/src/router/vacation-management-procedures.ts b/packages/api/src/router/vacation-management-procedures.ts new file mode 100644 index 0000000..741ce82 --- /dev/null +++ b/packages/api/src/router/vacation-management-procedures.ts @@ -0,0 +1,436 @@ +import { UpdateVacationStatusSchema } from "@capakraken/shared"; +import { VacationStatus } 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 { 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"; + +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", + ); + 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 = 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: { + 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(), + }); + + 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", + ); + 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 }); + + 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: { + 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", + }); + + 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: { + status: VacationStatus.REJECTED, + ...(input.rejectionReason !== undefined ? { 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", + ); + if (existing.status === VacationStatus.CANCELLED) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" }); + } + + const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); + 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; + }), + + 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" }); + } + + 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; + }), +}; diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index 133a05f..11c954b 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -1,32 +1,16 @@ -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 { emitVacationCreated } from "../sse/event-bus.js"; +import { createTRPCRouter, 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 { - buildVacationApprovalWriteData, - resolveVacationCreationChargeability, - assertVacationStillChargeable, -} from "./vacation-chargeability.js"; -import { - batchCreatePublicHolidayVacations, -} from "./vacation-public-holidays.js"; -import { - VACATION_BALANCE_TYPES, -} from "../lib/vacation-deduction-snapshot.js"; +import { resolveVacationCreationChargeability } from "./vacation-chargeability.js"; +import { vacationManagementProcedures } from "./vacation-management-procedures.js"; +import { VACATION_BALANCE_TYPES } from "../lib/vacation-deduction-snapshot.js"; import { anonymizeVacationRecord, isSameUtcDay, vacationReadProcedures } from "./vacation-read.js"; -import { - completeVacationApprovalTasks, - createVacationApprovalTasks, - dispatchVacationWebhookInBackground, - notifyVacationStatusInBackground, -} from "./vacation-side-effects.js"; +import { createVacationApprovalTasks } from "./vacation-side-effects.js"; const CreateVacationRequestSchema = z.object({ resourceId: z.string(), @@ -72,13 +56,8 @@ const CreateVacationRequestSchema = z.object({ export const vacationRouter = createTRPCRouter({ ...vacationReadProcedures, + ...vacationManagementProcedures, - /** - * Create a vacation request. - * - MANAGER/ADMIN → auto-approved - * - USER → PENDING - * Adds isHalfDay + halfDayPart support. - */ create: protectedProcedure .input(CreateVacationRequestSchema) .mutation(async ({ ctx, input }) => { @@ -97,7 +76,6 @@ export const vacationRouter = createTRPCRouter({ 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({ @@ -112,7 +90,6 @@ export const vacationRouter = createTRPCRouter({ } } - // Check for overlapping APPROVED or PENDING vacations const overlapping = await ctx.db.vacation.findFirst({ where: { resourceId: input.resourceId, @@ -140,7 +117,6 @@ export const vacationRouter = createTRPCRouter({ }); const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING; - const vacation = await ctx.db.vacation.create({ data: { resourceId: input.resourceId, @@ -153,9 +129,7 @@ export const vacationRouter = createTRPCRouter({ ...(input.halfDayPart !== undefined ? { halfDayPart: input.halfDayPart } : {}), ...(deductionSnapshotWriteData ?? { deductedDays: 0 }), requestedById: userRecord.id, - ...(isManager - ? { approvedById: userRecord.id, approvedAt: new Date() } - : {}), + ...(isManager ? { approvedById: userRecord.id, approvedAt: new Date() } : {}), }, include: { resource: { select: RESOURCE_BRIEF_SELECT }, @@ -176,14 +150,12 @@ export const vacationRouter = createTRPCRouter({ 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, + resourceName: vacation.resource?.displayName ?? "Unknown", vacationType: input.type, startDate: input.startDate, endDate: input.endDate, @@ -194,453 +166,4 @@ export const vacationRouter = createTRPCRouter({ 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 = await buildVacationApprovalWriteData(ctx.db, { - resourceId: existing.resourceId, - type: existing.type, - startDate: existing.startDate, - endDate: existing.endDate, - isHalfDay: existing.isHalfDay, - }); - - 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 = await buildVacationApprovalWriteData(ctx.db, { - resourceId: v.resourceId, - type: v.type, - startDate: v.startDate, - endDate: v.endDate, - isHalfDay: v.isHalfDay, - }); - 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 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: holidayCount, 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: holidayCount, 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: holidayCount, resources }; - }), - - /** - * 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; - }), });