From 73cfc9341b35fb7bb4b06216b761777fc21240dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 14:27:54 +0200 Subject: [PATCH] refactor(api): extract vacation management support --- .../vacation-management-support.test.ts | 126 ++++++++++++++++++ .../router/vacation-management-procedures.ts | 102 +++++++------- .../src/router/vacation-management-support.ts | 99 ++++++++++++++ 3 files changed, 273 insertions(+), 54 deletions(-) create mode 100644 packages/api/src/__tests__/vacation-management-support.test.ts create mode 100644 packages/api/src/router/vacation-management-support.ts diff --git a/packages/api/src/__tests__/vacation-management-support.test.ts b/packages/api/src/__tests__/vacation-management-support.test.ts new file mode 100644 index 0000000..800dbe5 --- /dev/null +++ b/packages/api/src/__tests__/vacation-management-support.test.ts @@ -0,0 +1,126 @@ +import { VacationStatus } from "@capakraken/db"; +import { TRPCError } from "@trpc/server"; +import { describe, expect, it } from "vitest"; +import { + approvableVacationStatuses, + assertVacationApprovable, + assertVacationCancelable, + assertVacationRejectable, + buildApprovedVacationUpdateData, + buildRejectedVacationUpdateData, + buildVacationStatusUpdateData, + canActorCancelVacation, + isVacationManagerRole, +} from "../router/vacation-management-support.js"; + +describe("vacation management support", () => { + it("exposes approvable statuses and guards invalid transitions", () => { + expect(approvableVacationStatuses).toEqual([ + VacationStatus.PENDING, + VacationStatus.CANCELLED, + VacationStatus.REJECTED, + ]); + + expect(() => assertVacationApprovable(VacationStatus.PENDING)).not.toThrow(); + expect(() => assertVacationRejectable(VacationStatus.PENDING)).not.toThrow(); + expect(() => assertVacationCancelable(VacationStatus.PENDING)).not.toThrow(); + + expect(() => assertVacationApprovable(VacationStatus.APPROVED)).toThrowError( + new TRPCError({ + code: "BAD_REQUEST", + message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved", + }), + ); + expect(() => assertVacationRejectable(VacationStatus.APPROVED)).toThrowError( + new TRPCError({ + code: "BAD_REQUEST", + message: "Only PENDING vacations can be rejected", + }), + ); + expect(() => assertVacationCancelable(VacationStatus.CANCELLED)).toThrowError( + new TRPCError({ + code: "BAD_REQUEST", + message: "Already cancelled", + }), + ); + }); + + it("builds approval, rejection, and generic status payloads", () => { + const approvedAt = new Date("2026-02-01T10:00:00.000Z"); + + expect(buildApprovedVacationUpdateData({ + deductionSnapshotWriteData: { deductedDays: 3, deductionSnapshot: { reason: "calendar" } }, + approvedById: "user_mgr", + approvedAt, + })).toEqual({ + status: VacationStatus.APPROVED, + rejectionReason: null, + deductedDays: 3, + deductionSnapshot: { reason: "calendar" }, + approvedById: "user_mgr", + approvedAt, + }); + + expect(buildRejectedVacationUpdateData({ + rejectionReason: "Capacity conflict", + })).toEqual({ + status: VacationStatus.REJECTED, + rejectionReason: "Capacity conflict", + }); + + expect(buildVacationStatusUpdateData({ + status: "APPROVED", + note: "Reviewed", + approvedById: "user_mgr", + approvedAt, + })).toEqual({ + status: "APPROVED", + approvedById: "user_mgr", + approvedAt, + rejectionReason: null, + note: "Reviewed", + }); + + expect(buildVacationStatusUpdateData({ + status: "CANCELLED", + approvedById: "user_mgr", + approvedAt, + })).toEqual({ + status: "CANCELLED", + }); + }); + + it("captures manager bypass and ownership-based cancellation access", () => { + expect(isVacationManagerRole("ADMIN")).toBe(true); + expect(isVacationManagerRole("MANAGER")).toBe(true); + expect(isVacationManagerRole("USER")).toBe(false); + + expect(canActorCancelVacation({ + actorId: "user_1", + actorRole: "USER", + requestedById: "user_1", + resourceUserId: "user_2", + })).toBe(true); + + expect(canActorCancelVacation({ + actorId: "user_1", + actorRole: "USER", + requestedById: "user_2", + resourceUserId: "user_1", + })).toBe(true); + + expect(canActorCancelVacation({ + actorId: "user_mgr", + actorRole: "MANAGER", + requestedById: "user_2", + resourceUserId: null, + })).toBe(true); + + expect(canActorCancelVacation({ + actorId: "user_1", + actorRole: "USER", + requestedById: "user_2", + resourceUserId: "user_3", + })).toBe(false); + }); +}); diff --git a/packages/api/src/router/vacation-management-procedures.ts b/packages/api/src/router/vacation-management-procedures.ts index 741ce82..822e24a 100644 --- a/packages/api/src/router/vacation-management-procedures.ts +++ b/packages/api/src/router/vacation-management-procedures.ts @@ -18,6 +18,16 @@ import { dispatchVacationWebhookInBackground, notifyVacationStatusInBackground, } from "./vacation-side-effects.js"; +import { + assertVacationApprovable, + assertVacationCancelable, + assertVacationRejectable, + buildApprovedVacationUpdateData, + buildRejectedVacationUpdateData, + buildVacationStatusUpdateData, + canActorCancelVacation, + isVacationManagerRole, +} from "./vacation-management-support.js"; const BatchCreatePublicHolidaysSchema = z.object({ year: z.number().int().min(2000).max(2100), @@ -44,10 +54,7 @@ export const vacationManagementProcedures = { 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" }); - } + assertVacationApprovable(existing.status); await assertVacationStillChargeable(ctx.db, { resourceId: existing.resourceId, @@ -74,13 +81,11 @@ export const vacationManagementProcedures = { const updated = await ctx.db.vacation.update({ where: { id: input.id }, - data: { - status: VacationStatus.APPROVED, - rejectionReason: null, - ...deductionSnapshotWriteData, - ...(userRecord?.id ? { approvedById: userRecord.id } : {}), + data: buildApprovedVacationUpdateData({ + deductionSnapshotWriteData, + approvedById: userRecord?.id, approvedAt: new Date(), - }, + }), }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); @@ -121,16 +126,13 @@ export const vacationManagementProcedures = { 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" }); - } + assertVacationRejectable(existing.status); const updated = await ctx.db.vacation.update({ where: { id: input.id }, - data: { - status: VacationStatus.REJECTED, - ...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}), - }, + data: buildRejectedVacationUpdateData({ + rejectionReason: input.rejectionReason, + }), }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); @@ -200,13 +202,11 @@ export const vacationManagementProcedures = { }); const updated = await ctx.db.vacation.update({ where: { id: vacation.id }, - data: { - status: VacationStatus.APPROVED, - rejectionReason: null, - ...deductionSnapshotWriteData, - ...(userRecord?.id ? { approvedById: userRecord.id } : {}), + data: buildApprovedVacationUpdateData({ + deductionSnapshotWriteData, + approvedById: userRecord?.id, approvedAt: new Date(), - }, + }), }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); @@ -252,10 +252,9 @@ export const vacationManagementProcedures = { await ctx.db.vacation.updateMany({ where: { id: { in: vacations.map((vacation) => vacation.id) } }, - data: { - status: VacationStatus.REJECTED, - ...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}), - }, + data: buildRejectedVacationUpdateData({ + rejectionReason: input.rejectionReason, + }), }); for (const vacation of vacations) { @@ -293,28 +292,29 @@ export const vacationManagementProcedures = { ctx.db.vacation.findUnique({ where: { id: input.id } }), "Vacation", ); - if (existing.status === VacationStatus.CANCELLED) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" }); - } + assertVacationCancelable(existing.status); 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({ + const resource = isVacationManagerRole(userRecord.systemRole) || existing.requestedById === userRecord.id + ? null + : 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", - }); - } - } + + if (!canActorCancelVacation({ + actorId: userRecord.id, + actorRole: userRecord.systemRole, + requestedById: existing.requestedById, + resourceUserId: resource?.userId, + })) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only cancel your own vacation requests", + }); } const updated = await ctx.db.vacation.update({ @@ -396,24 +396,18 @@ export const vacationManagementProcedures = { throw new TRPCError({ code: "UNAUTHORIZED" }); } - const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER"; - if (input.status !== "CANCELLED" && !isManager) { + if (input.status !== "CANCELLED" && !isVacationManagerRole(userRecord.systemRole)) { 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, + data: buildVacationStatusUpdateData({ + status: input.status, + note: input.note, + approvedById: userRecord.id, + approvedAt: new Date(), + }), }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); diff --git a/packages/api/src/router/vacation-management-support.ts b/packages/api/src/router/vacation-management-support.ts new file mode 100644 index 0000000..5329f13 --- /dev/null +++ b/packages/api/src/router/vacation-management-support.ts @@ -0,0 +1,99 @@ +import type { Prisma } from "@capakraken/db"; +import { VacationStatus } from "@capakraken/db"; +import type { UpdateVacationStatusInput } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; + +type VacationApprovalWriteData = Record; + +export const approvableVacationStatuses: readonly VacationStatus[] = [ + VacationStatus.PENDING, + VacationStatus.CANCELLED, + VacationStatus.REJECTED, +]; + +export function assertVacationApprovable(status: VacationStatus): void { + if (!approvableVacationStatuses.includes(status)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved", + }); + } +} + +export function assertVacationRejectable(status: VacationStatus): void { + if (status !== VacationStatus.PENDING) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only PENDING vacations can be rejected", + }); + } +} + +export function assertVacationCancelable(status: VacationStatus): void { + if (status === VacationStatus.CANCELLED) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Already cancelled", + }); + } +} + +export function isVacationManagerRole(role: string | null | undefined): boolean { + return role === "ADMIN" || role === "MANAGER"; +} + +export function canActorCancelVacation(input: { + actorId: string; + actorRole: string | null | undefined; + requestedById: string | null | undefined; + resourceUserId: string | null | undefined; +}): boolean { + if (isVacationManagerRole(input.actorRole)) { + return true; + } + + return input.requestedById === input.actorId || input.resourceUserId === input.actorId; +} + +export function buildApprovedVacationUpdateData(input: { + deductionSnapshotWriteData: VacationApprovalWriteData; + approvedById?: string | undefined; + approvedAt: Date; +}): Prisma.VacationUncheckedUpdateInput { + return { + status: VacationStatus.APPROVED, + rejectionReason: null, + ...input.deductionSnapshotWriteData, + ...(input.approvedById ? { approvedById: input.approvedById } : {}), + approvedAt: input.approvedAt, + }; +} + +export function buildRejectedVacationUpdateData(input: { + rejectionReason?: string | undefined; +}): Prisma.VacationUncheckedUpdateInput { + return { + status: VacationStatus.REJECTED, + ...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}), + }; +} + +export function buildVacationStatusUpdateData(input: { + status: UpdateVacationStatusInput["status"]; + note?: string | undefined; + approvedById: string; + approvedAt: Date; +}): Prisma.VacationUncheckedUpdateInput { + const data: Prisma.VacationUncheckedUpdateInput = { status: input.status }; + + if (input.status === VacationStatus.APPROVED) { + data.approvedById = input.approvedById; + data.approvedAt = input.approvedAt; + data.rejectionReason = null; + } + if (input.note !== undefined) { + data.note = input.note; + } + + return data; +}