diff --git a/packages/api/src/__tests__/vacation-create-support.test.ts b/packages/api/src/__tests__/vacation-create-support.test.ts new file mode 100644 index 0000000..903fbb0 --- /dev/null +++ b/packages/api/src/__tests__/vacation-create-support.test.ts @@ -0,0 +1,207 @@ +import { VacationStatus, VacationType } from "@capakraken/db"; +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createVacationRequest, + CreateVacationRequestSchema, +} from "../router/vacation-create-support.js"; + +const { + emitVacationCreated, + createAuditEntry, + createVacationApprovalTasks, + getAnonymizationDirectory, + resolveVacationCreationChargeability, +} = vi.hoisted(() => ({ + emitVacationCreated: vi.fn(), + createAuditEntry: vi.fn(), + createVacationApprovalTasks: vi.fn(), + getAnonymizationDirectory: vi.fn(), + resolveVacationCreationChargeability: vi.fn(), +})); + +vi.mock("../sse/event-bus.js", () => ({ + emitVacationCreated, +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +vi.mock("../lib/anonymization.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + anonymizeResource: vi.fn((value) => value), + anonymizeUser: vi.fn((value) => value), + getAnonymizationDirectory, + }; +}); + +vi.mock("../router/vacation-chargeability.js", () => ({ + resolveVacationCreationChargeability, +})); + +vi.mock("../router/vacation-side-effects.js", () => ({ + createVacationApprovalTasks, +})); + +function createContext(overrides: Record = {}) { + return { + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }), + }, + resource: { + findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }), + }, + vacation: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: "vac_1", + resourceId: "res_1", + type: VacationType.ANNUAL, + status: VacationStatus.PENDING, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-03T00:00:00.000Z"), + isHalfDay: false, + halfDayPart: null, + resource: { id: "res_1", displayName: "Alice", eid: "E-001" }, + requestedBy: { id: "user_1", name: "User", email: "user@example.com" }, + }), + }, + }, + ...overrides, + }; +} + +describe("vacation create support", () => { + beforeEach(() => { + emitVacationCreated.mockReset(); + createAuditEntry.mockReset(); + createVacationApprovalTasks.mockReset(); + getAnonymizationDirectory.mockReset(); + resolveVacationCreationChargeability.mockReset(); + + getAnonymizationDirectory.mockResolvedValue({ + resourcesById: new Map(), + usersById: new Map(), + }); + resolveVacationCreationChargeability.mockResolvedValue({ + effectiveDays: 3, + deductionSnapshotWriteData: { deductedDays: 3, deductionSnapshot: { source: "calendar" } }, + }); + }); + + it("validates half-day requests against cross-day ranges", () => { + expect(() => CreateVacationRequestSchema.parse({ + resourceId: "res_1", + type: VacationType.ANNUAL, + startDate: "2026-06-01", + endDate: "2026-06-02", + isHalfDay: true, + halfDayPart: "MORNING", + })).toThrowError(/Half-day requests must start and end on the same day/); + }); + + it("creates pending vacations for regular users and schedules approval tasks", async () => { + const ctx = createContext(); + + const result = await createVacationRequest(ctx as never, { + resourceId: "res_1", + type: VacationType.ANNUAL, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-03T00:00:00.000Z"), + note: "Summer", + isHalfDay: false, + }); + + expect(ctx.db.vacation.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + status: VacationStatus.PENDING, + requestedById: "user_1", + deductedDays: 3, + }), + })); + expect(createVacationApprovalTasks).toHaveBeenCalledWith(expect.objectContaining({ + submittedByUserId: "user_1", + vacationId: "vac_1", + })); + expect(emitVacationCreated).toHaveBeenCalledWith({ + id: "vac_1", + resourceId: "res_1", + status: VacationStatus.PENDING, + }); + expect(result).toMatchObject({ + id: "vac_1", + effectiveDays: 3, + }); + }); + + it("auto-approves manager-created vacations without approval tasks", async () => { + const ctx = createContext({ + db: { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }), + }, + resource: { + findUnique: vi.fn(), + }, + vacation: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: "vac_mgr", + resourceId: "res_2", + type: VacationType.ANNUAL, + status: VacationStatus.APPROVED, + startDate: new Date("2026-07-01T00:00:00.000Z"), + endDate: new Date("2026-07-02T00:00:00.000Z"), + isHalfDay: false, + halfDayPart: null, + resource: { id: "res_2", displayName: "Bob", eid: "E-002" }, + requestedBy: { id: "mgr_1", name: "Manager", email: "user@example.com" }, + }), + }, + }, + }); + + await createVacationRequest(ctx as never, { + resourceId: "res_2", + type: VacationType.ANNUAL, + startDate: new Date("2026-07-01T00:00:00.000Z"), + endDate: new Date("2026-07-02T00:00:00.000Z"), + isHalfDay: false, + }); + + expect(ctx.db.resource.findUnique).not.toHaveBeenCalled(); + expect(ctx.db.vacation.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + status: VacationStatus.APPROVED, + approvedById: "mgr_1", + }), + })); + expect(createVacationApprovalTasks).not.toHaveBeenCalled(); + }); + + it("rejects manual public holiday creation before hitting the database", async () => { + const ctx = createContext(); + + await expect(createVacationRequest(ctx as never, { + resourceId: "res_1", + type: VacationType.PUBLIC_HOLIDAY, + startDate: new Date("2026-12-25T00:00:00.000Z"), + endDate: new Date("2026-12-25T00:00:00.000Z"), + isHalfDay: false, + })).rejects.toThrowError(new TRPCError({ + code: "BAD_REQUEST", + message: "Public holidays must be managed via Holiday Calendars or the legacy holiday import, not via manual vacation requests", + })); + + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + expect(ctx.db.vacation.create).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/router/vacation-create-support.ts b/packages/api/src/router/vacation-create-support.ts new file mode 100644 index 0000000..5419e0e --- /dev/null +++ b/packages/api/src/router/vacation-create-support.ts @@ -0,0 +1,168 @@ +import { VacationStatus, VacationType } from "@capakraken/db"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; +import { emitVacationCreated } from "../sse/event-bus.js"; +import { type TRPCContext } from "../trpc.js"; +import { getAnonymizationDirectory } from "../lib/anonymization.js"; +import { createAuditEntry } from "../lib/audit.js"; +import { VACATION_BALANCE_TYPES } from "../lib/vacation-deduction-snapshot.js"; +import { resolveVacationCreationChargeability } from "./vacation-chargeability.js"; +import { anonymizeVacationRecord, isSameUtcDay } from "./vacation-read.js"; +import { createVacationApprovalTasks } from "./vacation-side-effects.js"; + +export const CreateVacationRequestSchema = z.object({ + resourceId: z.string(), + type: z.nativeEnum(VacationType), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + note: z.string().max(500).optional(), + isHalfDay: z.boolean().optional(), + halfDayPart: z.enum(["MORNING", "AFTERNOON"]).optional(), +}).superRefine((data, ctx) => { + if (data.endDate < data.startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End date must be after start date", + path: ["endDate"], + }); + } + + if (data.isHalfDay && !isSameUtcDay(data.startDate, data.endDate)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Half-day requests must start and end on the same day", + path: ["isHalfDay"], + }); + } + + if (data.isHalfDay && !data.halfDayPart) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Half-day requests require a half-day part", + path: ["halfDayPart"], + }); + } + + if (!data.isHalfDay && data.halfDayPart) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Half-day part is only allowed for half-day requests", + path: ["halfDayPart"], + }); + } +}); + +export type CreateVacationRequestInput = z.infer; + +type VacationCreateContext = Pick; + +export async function createVacationRequest( + ctx: VacationCreateContext, + input: CreateVacationRequestInput, +) { + 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" }); + } + + 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", + }); + } + } + + const overlapping = await ctx.db.vacation.findFirst({ + where: { + resourceId: input.resourceId, + status: { in: ["APPROVED", "PENDING"] }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + ...(VACATION_BALANCE_TYPES.has(input.type) + ? { type: { not: VacationType.PUBLIC_HOLIDAY } } + : {}), + }, + }); + if (overlapping) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Overlapping vacation already exists for this resource in the selected period", + }); + } + + const { effectiveDays, deductionSnapshotWriteData } = await resolveVacationCreationChargeability(ctx.db, { + resourceId: input.resourceId, + type: input.type, + startDate: input.startDate, + endDate: input.endDate, + isHalfDay: input.isHalfDay ?? false, + }); + + const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING; + const vacation = await ctx.db.vacation.create({ + data: { + resourceId: input.resourceId, + type: input.type, + status, + startDate: input.startDate, + endDate: input.endDate, + ...(input.note !== undefined ? { note: input.note } : {}), + isHalfDay: input.isHalfDay ?? false, + ...(input.halfDayPart !== undefined ? { halfDayPart: input.halfDayPart } : {}), + ...(deductionSnapshotWriteData ?? { deductedDays: 0 }), + requestedById: userRecord.id, + ...(isManager ? { approvedById: userRecord.id, approvedAt: new Date() } : {}), + }, + include: { + resource: { select: RESOURCE_BRIEF_SELECT }, + requestedBy: { select: { id: true, name: true, email: true } }, + }, + }); + + emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Vacation", + entityId: vacation.id, + entityName: `${vacation.resource?.displayName ?? "Unknown"} - ${vacation.type}`, + action: "CREATE", + userId: userRecord.id, + after: vacation as unknown as Record, + source: "ui", + }); + + if (status === VacationStatus.PENDING) { + await createVacationApprovalTasks({ + db: ctx.db, + submittedByUserId: userRecord.id, + vacationId: vacation.id, + resourceName: vacation.resource?.displayName ?? "Unknown", + vacationType: input.type, + startDate: input.startDate, + endDate: input.endDate, + }); + } + + const directory = await getAnonymizationDirectory(ctx.db); + const result = anonymizeVacationRecord(vacation, directory); + return effectiveDays === null ? result : { ...result, effectiveDays }; +} diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index 11c954b..8a45a14 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -1,58 +1,10 @@ -import { VacationStatus, VacationType } from "@capakraken/db"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod"; -import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; -import { emitVacationCreated } from "../sse/event-bus.js"; import { createTRPCRouter, protectedProcedure } from "../trpc.js"; -import { getAnonymizationDirectory } from "../lib/anonymization.js"; -import { createAuditEntry } from "../lib/audit.js"; -import { resolveVacationCreationChargeability } from "./vacation-chargeability.js"; +import { + createVacationRequest, + CreateVacationRequestSchema, +} from "./vacation-create-support.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 { createVacationApprovalTasks } from "./vacation-side-effects.js"; - -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"], - }); - } -}); +import { vacationReadProcedures } from "./vacation-read.js"; export const vacationRouter = createTRPCRouter({ ...vacationReadProcedures, @@ -60,110 +12,5 @@ export const vacationRouter = createTRPCRouter({ 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" }); - } - - 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", - }); - } - } - - const overlapping = await ctx.db.vacation.findFirst({ - where: { - resourceId: input.resourceId, - status: { in: ["APPROVED", "PENDING"] }, - startDate: { lte: input.endDate }, - endDate: { gte: input.startDate }, - ...(VACATION_BALANCE_TYPES.has(input.type) - ? { type: { not: VacationType.PUBLIC_HOLIDAY } } - : {}), - }, - }); - if (overlapping) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Overlapping vacation already exists for this resource in the selected period", - }); - } - - const { effectiveDays, deductionSnapshotWriteData } = await resolveVacationCreationChargeability(ctx.db, { - resourceId: input.resourceId, - type: input.type, - startDate: input.startDate, - endDate: input.endDate, - isHalfDay: input.isHalfDay ?? false, - }); - - const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING; - const vacation = await ctx.db.vacation.create({ - data: { - resourceId: input.resourceId, - type: input.type, - status, - startDate: input.startDate, - endDate: input.endDate, - ...(input.note !== undefined ? { note: input.note } : {}), - isHalfDay: input.isHalfDay ?? false, - ...(input.halfDayPart !== undefined ? { halfDayPart: input.halfDayPart } : {}), - ...(deductionSnapshotWriteData ?? { deductedDays: 0 }), - requestedById: userRecord.id, - ...(isManager ? { approvedById: userRecord.id, approvedAt: new Date() } : {}), - }, - include: { - resource: { select: RESOURCE_BRIEF_SELECT }, - requestedBy: { select: { id: true, name: true, email: true } }, - }, - }); - - emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status }); - - void createAuditEntry({ - db: ctx.db, - entityType: "Vacation", - entityId: vacation.id, - entityName: `${vacation.resource?.displayName ?? "Unknown"} - ${vacation.type}`, - action: "CREATE", - userId: userRecord.id, - after: vacation as unknown as Record, - source: "ui", - }); - - if (status === VacationStatus.PENDING) { - await createVacationApprovalTasks({ - db: ctx.db, - submittedByUserId: userRecord.id, - vacationId: vacation.id, - resourceName: vacation.resource?.displayName ?? "Unknown", - vacationType: input.type, - startDate: input.startDate, - endDate: input.endDate, - }); - } - - const directory = await getAnonymizationDirectory(ctx.db); - const result = anonymizeVacationRecord(vacation, directory); - return effectiveDays === null ? result : { ...result, effectiveDays }; - }), + .mutation(({ ctx, input }) => createVacationRequest(ctx, input)), });