diff --git a/packages/api/src/__tests__/timeline-project-procedure-support.test.ts b/packages/api/src/__tests__/timeline-project-procedure-support.test.ts new file mode 100644 index 0000000..c6d3e2c --- /dev/null +++ b/packages/api/src/__tests__/timeline-project-procedure-support.test.ts @@ -0,0 +1,262 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../lib/anonymization.js", () => ({ + getAnonymizationDirectory: vi.fn(), +})); + +vi.mock("../router/timeline-project-load-support.js", () => ({ + loadTimelineProjectContext: vi.fn(), + previewTimelineProjectShift: vi.fn(), +})); + +vi.mock("../router/timeline-project-context-support.js", () => ({ + loadTimelineProjectContextDetailArtifacts: vi.fn(), +})); + +vi.mock("../router/timeline-project-context-response-support.js", () => ({ + buildTimelineProjectContextResponse: vi.fn(), + buildTimelineProjectContextDetailResponse: vi.fn(), +})); + +vi.mock("@capakraken/application", () => ({ + listAssignmentBookings: vi.fn(), +})); + +vi.mock("@capakraken/engine", () => ({ + computeBudgetStatus: vi.fn(), +})); + +vi.mock("../router/timeline-project-query-support.js", () => ({ + findTimelineProjectOrThrow: vi.fn(), + timelineBudgetStatusProjectSelect: { id: true }, + timelineShiftPreviewProjectSelect: { id: true }, +})); + +vi.mock("../router/timeline-project-read-support.js", () => ({ + buildTimelineBudgetStatusAllocations: vi.fn(), + buildTimelineBudgetStatusResponse: vi.fn(), + buildTimelineShiftPreviewDetailResponse: vi.fn(), +})); + +import { listAssignmentBookings } from "@capakraken/application"; +import { computeBudgetStatus } from "@capakraken/engine"; +import { getAnonymizationDirectory } from "../lib/anonymization.js"; +import { + loadTimelineProjectContextDetailArtifacts, +} from "../router/timeline-project-context-support.js"; +import { + buildTimelineProjectContextDetailResponse, + buildTimelineProjectContextResponse, +} from "../router/timeline-project-context-response-support.js"; +import { + loadTimelineProjectContext, + previewTimelineProjectShift, +} from "../router/timeline-project-load-support.js"; +import { + buildTimelineBudgetStatusAllocations, + buildTimelineBudgetStatusResponse, + buildTimelineShiftPreviewDetailResponse, +} from "../router/timeline-project-read-support.js"; +import { + findTimelineProjectOrThrow, + timelineBudgetStatusProjectSelect, + timelineShiftPreviewProjectSelect, +} from "../router/timeline-project-query-support.js"; +import { + readTimelineProjectBudgetStatusResponse, + readTimelineProjectContextDetailResponse, + readTimelineProjectContextResponse, + readTimelineProjectShiftPreviewDetail, +} from "../router/timeline-project-procedure-support.js"; + +const getAnonymizationDirectoryMock = vi.mocked(getAnonymizationDirectory); +const loadTimelineProjectContextMock = vi.mocked(loadTimelineProjectContext); +const previewTimelineProjectShiftMock = vi.mocked(previewTimelineProjectShift); +const loadTimelineProjectContextDetailArtifactsMock = vi.mocked(loadTimelineProjectContextDetailArtifacts); +const buildTimelineProjectContextResponseMock = vi.mocked(buildTimelineProjectContextResponse); +const buildTimelineProjectContextDetailResponseMock = vi.mocked(buildTimelineProjectContextDetailResponse); +const listAssignmentBookingsMock = vi.mocked(listAssignmentBookings); +const computeBudgetStatusMock = vi.mocked(computeBudgetStatus); +const findTimelineProjectOrThrowMock = vi.mocked(findTimelineProjectOrThrow); +const buildTimelineBudgetStatusAllocationsMock = vi.mocked(buildTimelineBudgetStatusAllocations); +const buildTimelineBudgetStatusResponseMock = vi.mocked(buildTimelineBudgetStatusResponse); +const buildTimelineShiftPreviewDetailResponseMock = vi.mocked(buildTimelineShiftPreviewDetailResponse); + +describe("timeline project procedure support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("builds project context responses from loaded context and anonymization directory", async () => { + loadTimelineProjectContextMock.mockResolvedValueOnce({ + project: { id: "project_1" }, + allocations: [{ id: "allocation_1" }], + demands: [{ id: "demand_1" }], + assignments: [{ id: "assignment_1" }], + allResourceAllocations: [{ id: "booking_1" }], + resourceIds: ["resource_1"], + } as never); + getAnonymizationDirectoryMock.mockResolvedValueOnce({ enabled: true } as never); + buildTimelineProjectContextResponseMock.mockReturnValueOnce({ ok: true } as never); + + await expect(readTimelineProjectContextResponse({} as never, "project_1")).resolves.toEqual({ + ok: true, + }); + + expect(loadTimelineProjectContextMock).toHaveBeenCalledWith({}, "project_1"); + expect(getAnonymizationDirectoryMock).toHaveBeenCalledWith({}); + expect(buildTimelineProjectContextResponseMock).toHaveBeenCalledWith({ + project: { id: "project_1" }, + allocations: [{ id: "allocation_1" }], + demands: [{ id: "demand_1" }], + assignments: [{ id: "assignment_1" }], + allResourceAllocations: [{ id: "booking_1" }], + resourceIds: ["resource_1"], + directory: { enabled: true }, + }); + }); + + it("builds project context detail responses from loaded artifacts", async () => { + loadTimelineProjectContextMock.mockResolvedValueOnce({ + project: { + id: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + }, + allocations: [{ id: "allocation_1" }], + demands: [{ id: "demand_1", startDate: new Date("2026-04-01T00:00:00.000Z") }], + assignments: [{ id: "assignment_1", startDate: new Date("2026-04-02T00:00:00.000Z") }], + allResourceAllocations: [{ id: "booking_1" }], + resourceIds: ["resource_1"], + } as never); + getAnonymizationDirectoryMock.mockResolvedValueOnce({ enabled: true } as never); + loadTimelineProjectContextDetailArtifactsMock.mockResolvedValueOnce({ + period: { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + }, + holidayOverlays: [{ id: "overlay_1" }], + assignmentConflicts: [{ assignmentId: "assignment_1" }], + } as never); + buildTimelineProjectContextDetailResponseMock.mockReturnValueOnce({ detail: true } as never); + + await expect( + readTimelineProjectContextDetailResponse({} as never, { + projectId: "project_1", + startDate: "2026-04-03", + endDate: "2026-04-12", + durationDays: 10, + }), + ).resolves.toEqual({ detail: true }); + + expect(loadTimelineProjectContextDetailArtifactsMock).toHaveBeenCalledWith({}, { + projectId: "project_1", + requestedStartDate: "2026-04-03", + requestedEndDate: "2026-04-12", + durationDays: 10, + projectStartDate: new Date("2026-04-01T00:00:00.000Z"), + projectEndDate: new Date("2026-04-10T00:00:00.000Z"), + firstAssignmentStartDate: new Date("2026-04-02T00:00:00.000Z"), + firstDemandStartDate: new Date("2026-04-01T00:00:00.000Z"), + assignments: [{ id: "assignment_1", startDate: new Date("2026-04-02T00:00:00.000Z") }], + allResourceAllocations: [{ id: "booking_1" }], + resourceIds: ["resource_1"], + }); + expect(buildTimelineProjectContextDetailResponseMock).toHaveBeenCalledWith({ + project: { + id: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + }, + period: { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + }, + allocations: [{ id: "allocation_1" }], + demands: [{ id: "demand_1", startDate: new Date("2026-04-01T00:00:00.000Z") }], + assignments: [{ id: "assignment_1", startDate: new Date("2026-04-02T00:00:00.000Z") }], + allResourceAllocations: [{ id: "booking_1" }], + resourceIds: ["resource_1"], + assignmentConflicts: [{ assignmentId: "assignment_1" }], + holidayOverlays: [{ id: "overlay_1" }], + directory: { enabled: true }, + }); + }); + + it("builds shift preview detail responses", async () => { + findTimelineProjectOrThrowMock.mockResolvedValueOnce({ id: "project_1" } as never); + previewTimelineProjectShiftMock.mockResolvedValueOnce({ valid: true } as never); + buildTimelineShiftPreviewDetailResponseMock.mockReturnValueOnce({ preview: true } as never); + + await expect( + readTimelineProjectShiftPreviewDetail({} as never, { + projectId: "project_1", + newStartDate: new Date("2026-04-03T00:00:00.000Z"), + newEndDate: new Date("2026-04-12T00:00:00.000Z"), + }), + ).resolves.toEqual({ preview: true }); + + expect(findTimelineProjectOrThrowMock).toHaveBeenCalledWith({}, { + projectId: "project_1", + select: timelineShiftPreviewProjectSelect, + }); + expect(previewTimelineProjectShiftMock).toHaveBeenCalledWith({}, { + projectId: "project_1", + newStartDate: new Date("2026-04-03T00:00:00.000Z"), + newEndDate: new Date("2026-04-12T00:00:00.000Z"), + }); + expect(buildTimelineShiftPreviewDetailResponseMock).toHaveBeenCalledWith({ + project: { id: "project_1" }, + requestedShift: { + newStartDate: new Date("2026-04-03T00:00:00.000Z"), + newEndDate: new Date("2026-04-12T00:00:00.000Z"), + }, + preview: { valid: true }, + }); + }); + + it("builds budget status responses from project bookings", async () => { + findTimelineProjectOrThrowMock.mockResolvedValueOnce({ + id: "project_1", + budgetCents: 100_000, + winProbability: 80, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + } as never); + listAssignmentBookingsMock.mockResolvedValueOnce([{ id: "booking_1" }] as never); + buildTimelineBudgetStatusAllocationsMock.mockReturnValueOnce([{ id: "allocation_1" }] as never); + computeBudgetStatusMock.mockReturnValueOnce({ withinBudget: true } as never); + buildTimelineBudgetStatusResponseMock.mockReturnValueOnce({ budget: true } as never); + + await expect(readTimelineProjectBudgetStatusResponse({} as never, "project_1")).resolves.toEqual({ + budget: true, + }); + + expect(findTimelineProjectOrThrowMock).toHaveBeenCalledWith({}, { + projectId: "project_1", + select: timelineBudgetStatusProjectSelect, + }); + expect(listAssignmentBookingsMock).toHaveBeenCalledWith({}, { + projectIds: ["project_1"], + }); + expect(buildTimelineBudgetStatusAllocationsMock).toHaveBeenCalledWith([{ id: "booking_1" }]); + expect(computeBudgetStatusMock).toHaveBeenCalledWith( + 100_000, + 80, + [{ id: "allocation_1" }], + new Date("2026-04-01T00:00:00.000Z"), + new Date("2026-04-10T00:00:00.000Z"), + ); + expect(buildTimelineBudgetStatusResponseMock).toHaveBeenCalledWith({ + project: { + id: "project_1", + budgetCents: 100_000, + winProbability: 80, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + }, + budgetStatus: { withinBudget: true }, + totalAllocations: 1, + }); + }); +}); diff --git a/packages/api/src/router/timeline-project-procedure-support.ts b/packages/api/src/router/timeline-project-procedure-support.ts new file mode 100644 index 0000000..497e6f9 --- /dev/null +++ b/packages/api/src/router/timeline-project-procedure-support.ts @@ -0,0 +1,150 @@ +import { listAssignmentBookings } from "@capakraken/application"; +import { computeBudgetStatus } from "@capakraken/engine"; +import { getAnonymizationDirectory } from "../lib/anonymization.js"; +import { + loadTimelineProjectContextDetailArtifacts, +} from "./timeline-project-context-support.js"; +import { + buildTimelineProjectContextDetailResponse, + buildTimelineProjectContextResponse, +} from "./timeline-project-context-response-support.js"; +import { + loadTimelineProjectContext, + previewTimelineProjectShift, +} from "./timeline-project-load-support.js"; +import { + buildTimelineBudgetStatusAllocations, + buildTimelineBudgetStatusResponse, + buildTimelineShiftPreviewDetailResponse, +} from "./timeline-project-read-support.js"; +import { + findTimelineProjectOrThrow, + timelineBudgetStatusProjectSelect, + timelineShiftPreviewProjectSelect, +} from "./timeline-project-query-support.js"; + +type TimelineProjectProcedureDb = + & Parameters[0] + & Parameters[0] + & Parameters[0] + & Parameters[0] + & Parameters[0]; + +export async function readTimelineProjectContextResponse( + db: TimelineProjectProcedureDb, + projectId: string, +) { + const { + project, + allocations, + demands, + assignments, + allResourceAllocations, + resourceIds, + } = await loadTimelineProjectContext(db, projectId); + const directory = await getAnonymizationDirectory(db); + + return buildTimelineProjectContextResponse({ + project, + allocations, + demands, + assignments, + allResourceAllocations, + resourceIds, + directory, + }); +} + +export async function readTimelineProjectContextDetailResponse( + db: TimelineProjectProcedureDb, + input: { + projectId: string; + startDate?: string | undefined; + endDate?: string | undefined; + durationDays?: number | undefined; + }, +) { + const projectContext = await loadTimelineProjectContext(db, input.projectId); + const directory = await getAnonymizationDirectory(db); + const { period, holidayOverlays, assignmentConflicts } = + await loadTimelineProjectContextDetailArtifacts(db, { + projectId: input.projectId, + requestedStartDate: input.startDate, + requestedEndDate: input.endDate, + durationDays: input.durationDays, + projectStartDate: projectContext.project.startDate, + projectEndDate: projectContext.project.endDate, + firstAssignmentStartDate: projectContext.assignments[0]?.startDate, + firstDemandStartDate: projectContext.demands[0]?.startDate, + assignments: projectContext.assignments, + allResourceAllocations: projectContext.allResourceAllocations, + resourceIds: projectContext.resourceIds, + }); + + return buildTimelineProjectContextDetailResponse({ + project: projectContext.project, + period, + allocations: projectContext.allocations, + demands: projectContext.demands, + assignments: projectContext.assignments, + allResourceAllocations: projectContext.allResourceAllocations, + resourceIds: projectContext.resourceIds, + assignmentConflicts, + holidayOverlays, + directory, + }); +} + +export async function readTimelineProjectShiftPreviewDetail( + db: TimelineProjectProcedureDb, + input: { + projectId: string; + newStartDate: Date; + newEndDate: Date; + }, +) { + const [project, preview] = await Promise.all([ + findTimelineProjectOrThrow(db, { + projectId: input.projectId, + select: timelineShiftPreviewProjectSelect, + }), + previewTimelineProjectShift(db, input), + ]); + + return buildTimelineShiftPreviewDetailResponse({ + project, + requestedShift: { + newStartDate: input.newStartDate, + newEndDate: input.newEndDate, + }, + preview, + }); +} + +export async function readTimelineProjectBudgetStatusResponse( + db: TimelineProjectProcedureDb, + projectId: string, +) { + const project = await findTimelineProjectOrThrow(db, { + projectId, + select: timelineBudgetStatusProjectSelect, + }); + + const bookings = await listAssignmentBookings(db, { + projectIds: [project.id], + }); + + const budgetStatus = computeBudgetStatus( + project.budgetCents, + project.winProbability, + buildTimelineBudgetStatusAllocations(bookings), + project.startDate, + project.endDate, + ); + + return buildTimelineBudgetStatusResponse({ + project, + budgetStatus, + totalAllocations: bookings.length, + }); +} diff --git a/packages/api/src/router/timeline-project-read.ts b/packages/api/src/router/timeline-project-read.ts index 63b209c..239807b 100644 --- a/packages/api/src/router/timeline-project-read.ts +++ b/packages/api/src/router/timeline-project-read.ts @@ -1,55 +1,18 @@ -import { listAssignmentBookings } from "@capakraken/application"; -import { computeBudgetStatus } from "@capakraken/engine"; import { ShiftProjectSchema } from "@capakraken/shared"; import { z } from "zod"; -import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { controllerProcedure } from "../trpc.js"; +import { previewTimelineProjectShift } from "./timeline-project-load-support.js"; import { - loadTimelineProjectContextDetailArtifacts, -} from "./timeline-project-context-support.js"; -import { - loadTimelineProjectContext, - previewTimelineProjectShift, -} from "./timeline-project-load-support.js"; -import { - buildTimelineProjectContextDetailResponse, - buildTimelineProjectContextResponse, -} from "./timeline-project-context-response-support.js"; -import { - buildTimelineBudgetStatusAllocations, - buildTimelineBudgetStatusResponse, - buildTimelineShiftPreviewDetailResponse, -} from "./timeline-project-read-support.js"; -import { - findTimelineProjectOrThrow, - timelineBudgetStatusProjectSelect, - timelineShiftPreviewProjectSelect, -} from "./timeline-project-query-support.js"; + readTimelineProjectBudgetStatusResponse, + readTimelineProjectContextDetailResponse, + readTimelineProjectContextResponse, + readTimelineProjectShiftPreviewDetail, +} from "./timeline-project-procedure-support.js"; export const timelineProjectReadProcedures = { getProjectContext: controllerProcedure .input(z.object({ projectId: z.string() })) - .query(async ({ ctx, input }) => { - const { - project, - allocations, - demands, - assignments, - allResourceAllocations, - resourceIds, - } = await loadTimelineProjectContext(ctx.db, input.projectId); - const directory = await getAnonymizationDirectory(ctx.db); - - return buildTimelineProjectContextResponse({ - project, - allocations, - demands, - assignments, - allResourceAllocations, - resourceIds, - directory, - }); - }), + .query(async ({ ctx, input }) => readTimelineProjectContextResponse(ctx.db, input.projectId)), getProjectContextDetail: controllerProcedure .input( @@ -60,37 +23,7 @@ export const timelineProjectReadProcedures = { durationDays: z.number().int().min(1).max(366).optional(), }), ) - .query(async ({ ctx, input }) => { - const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId); - const directory = await getAnonymizationDirectory(ctx.db); - const { period, holidayOverlays, assignmentConflicts } = - await loadTimelineProjectContextDetailArtifacts(ctx.db, { - projectId: input.projectId, - requestedStartDate: input.startDate, - requestedEndDate: input.endDate, - durationDays: input.durationDays, - projectStartDate: projectContext.project.startDate, - projectEndDate: projectContext.project.endDate, - firstAssignmentStartDate: projectContext.assignments[0]?.startDate, - firstDemandStartDate: projectContext.demands[0]?.startDate, - assignments: projectContext.assignments, - allResourceAllocations: projectContext.allResourceAllocations, - resourceIds: projectContext.resourceIds, - }); - - return buildTimelineProjectContextDetailResponse({ - project: projectContext.project, - period, - allocations: projectContext.allocations, - demands: projectContext.demands, - assignments: projectContext.assignments, - allResourceAllocations: projectContext.allResourceAllocations, - resourceIds: projectContext.resourceIds, - assignmentConflicts, - holidayOverlays, - directory, - }); - }), + .query(async ({ ctx, input }) => readTimelineProjectContextDetailResponse(ctx.db, input)), previewShift: controllerProcedure .input(ShiftProjectSchema) @@ -98,49 +31,9 @@ export const timelineProjectReadProcedures = { getShiftPreviewDetail: controllerProcedure .input(ShiftProjectSchema) - .query(async ({ ctx, input }) => { - const [project, preview] = await Promise.all([ - findTimelineProjectOrThrow(ctx.db, { - projectId: input.projectId, - select: timelineShiftPreviewProjectSelect, - }), - previewTimelineProjectShift(ctx.db, input), - ]); - - return buildTimelineShiftPreviewDetailResponse({ - project, - requestedShift: { - newStartDate: input.newStartDate, - newEndDate: input.newEndDate, - }, - preview, - }); - }), + .query(async ({ ctx, input }) => readTimelineProjectShiftPreviewDetail(ctx.db, input)), getBudgetStatus: controllerProcedure .input(z.object({ projectId: z.string() })) - .query(async ({ ctx, input }) => { - const project = await findTimelineProjectOrThrow(ctx.db, { - projectId: input.projectId, - select: timelineBudgetStatusProjectSelect, - }); - - const bookings = await listAssignmentBookings(ctx.db, { - projectIds: [project.id], - }); - - const budgetStatus = computeBudgetStatus( - project.budgetCents, - project.winProbability, - buildTimelineBudgetStatusAllocations(bookings), - project.startDate, - project.endDate, - ); - - return buildTimelineBudgetStatusResponse({ - project, - budgetStatus, - totalAllocations: bookings.length, - }); - }), + .query(async ({ ctx, input }) => readTimelineProjectBudgetStatusResponse(ctx.db, input.projectId)), };