diff --git a/packages/api/src/__tests__/timeline-project-load-support.test.ts b/packages/api/src/__tests__/timeline-project-load-support.test.ts new file mode 100644 index 0000000..86f99c4 --- /dev/null +++ b/packages/api/src/__tests__/timeline-project-load-support.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@capakraken/application", () => ({ + listAssignmentBookings: vi.fn(), +})); + +vi.mock("../router/project-planning-read-model.js", () => ({ + loadProjectPlanningReadModel: vi.fn(), +})); + +vi.mock("../router/timeline-project-query-support.js", () => ({ + findTimelineProjectOrThrow: vi.fn(), + projectShiftContextSelect: { id: true }, + timelineProjectContextSelect: { id: true }, +})); + +vi.mock("../router/timeline-shift-planning.js", () => ({ + buildTimelineShiftPlan: vi.fn(), +})); + +vi.mock("../router/timeline-project-read-support.js", () => ({ + buildTimelineShiftValidationBookings: vi.fn(), +})); + +vi.mock("../router/timeline-read-shared.js", async () => { + const actual = await vi.importActual( + "../router/timeline-read-shared.js", + ); + + return { + ...actual, + getAssignmentResourceIds: vi.fn(), + }; +}); + +vi.mock("../router/timeline-shift-support.js", () => ({ + buildTimelineProjectShiftValidation: vi.fn(), +})); + +import { listAssignmentBookings } from "@capakraken/application"; +import { loadProjectPlanningReadModel } from "../router/project-planning-read-model.js"; +import { + loadProjectShiftContext, + loadTimelineProjectContext, + previewTimelineProjectShift, +} from "../router/timeline-project-load-support.js"; +import { findTimelineProjectOrThrow } from "../router/timeline-project-query-support.js"; +import { buildTimelineShiftPlan } from "../router/timeline-shift-planning.js"; +import { buildTimelineShiftValidationBookings } from "../router/timeline-project-read-support.js"; +import { getAssignmentResourceIds } from "../router/timeline-read-shared.js"; +import { buildTimelineProjectShiftValidation } from "../router/timeline-shift-support.js"; + +const findTimelineProjectOrThrowMock = vi.mocked(findTimelineProjectOrThrow); +const loadProjectPlanningReadModelMock = vi.mocked(loadProjectPlanningReadModel); +const listAssignmentBookingsMock = vi.mocked(listAssignmentBookings); +const getAssignmentResourceIdsMock = vi.mocked(getAssignmentResourceIds); +const buildTimelineShiftValidationBookingsMock = vi.mocked(buildTimelineShiftValidationBookings); +const buildTimelineShiftPlanMock = vi.mocked(buildTimelineShiftPlan); +const buildTimelineProjectShiftValidationMock = vi.mocked(buildTimelineProjectShiftValidation); + +describe("timeline project load support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads shift context with booking windows when assignment resources exist", async () => { + findTimelineProjectOrThrowMock.mockResolvedValueOnce({ id: "project_1" } as never); + loadProjectPlanningReadModelMock.mockResolvedValueOnce({ + demandRequirements: [{ id: "demand_1" }], + assignments: [{ id: "assignment_1" }], + readModel: { + allocations: [], + demands: [], + assignments: [{ resourceId: "resource_1" }], + }, + } as never); + getAssignmentResourceIdsMock.mockReturnValueOnce(["resource_1"]); + listAssignmentBookingsMock.mockResolvedValueOnce([{ id: "booking_1" }] as never); + buildTimelineShiftValidationBookingsMock.mockReturnValueOnce([{ id: "window_1" }] as never); + buildTimelineShiftPlanMock.mockReturnValueOnce({ shifts: 1 } as never); + + await expect(loadProjectShiftContext({} as never, "project_1")).resolves.toEqual({ + project: { id: "project_1" }, + demandRequirements: [{ id: "demand_1" }], + assignments: [{ id: "assignment_1" }], + shiftPlan: { shifts: 1 }, + }); + + expect(listAssignmentBookingsMock).toHaveBeenCalledWith({}, { + resourceIds: ["resource_1"], + }); + expect(buildTimelineShiftValidationBookingsMock).toHaveBeenCalledWith([{ id: "booking_1" }]); + expect(buildTimelineShiftPlanMock).toHaveBeenCalledWith({ + demandRequirements: [{ id: "demand_1" }], + assignments: [{ id: "assignment_1" }], + allAssignmentWindows: [{ id: "window_1" }], + }); + }); + + it("loads project context and skips booking lookup without assignment resources", async () => { + findTimelineProjectOrThrowMock.mockResolvedValueOnce({ + id: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + } as never); + loadProjectPlanningReadModelMock.mockResolvedValueOnce({ + readModel: { + allocations: [{ id: "allocation_1" }], + demands: [{ id: "demand_1" }], + assignments: [{ id: "assignment_1" }], + }, + } as never); + getAssignmentResourceIdsMock.mockReturnValueOnce([]); + + await expect(loadTimelineProjectContext({} as never, "project_1")).resolves.toEqual({ + 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" }], + assignments: [{ id: "assignment_1" }], + allResourceAllocations: [], + resourceIds: [], + }); + + expect(listAssignmentBookingsMock).not.toHaveBeenCalled(); + }); + + it("builds project shift previews from loaded context", async () => { + findTimelineProjectOrThrowMock.mockResolvedValueOnce({ id: "project_1" } as never); + loadProjectPlanningReadModelMock.mockResolvedValueOnce({ + demandRequirements: [{ id: "demand_1" }], + assignments: [{ id: "assignment_1" }], + readModel: { + allocations: [], + demands: [], + assignments: [{ resourceId: null }], + }, + } as never); + getAssignmentResourceIdsMock.mockReturnValueOnce([]); + buildTimelineShiftPlanMock.mockReturnValueOnce({ shifts: [] } as never); + buildTimelineProjectShiftValidationMock.mockReturnValueOnce({ ok: true } as never); + + await expect( + previewTimelineProjectShift({} as never, { + projectId: "project_1", + newStartDate: new Date("2026-04-02T00:00:00.000Z"), + newEndDate: new Date("2026-04-08T00:00:00.000Z"), + }), + ).resolves.toEqual({ ok: true }); + + expect(buildTimelineProjectShiftValidationMock).toHaveBeenCalledWith({ + context: { + project: { id: "project_1" }, + demandRequirements: [{ id: "demand_1" }], + assignments: [{ id: "assignment_1" }], + shiftPlan: { shifts: [] }, + }, + newStartDate: new Date("2026-04-02T00:00:00.000Z"), + newEndDate: new Date("2026-04-08T00:00:00.000Z"), + }); + }); +}); diff --git a/packages/api/src/router/timeline-project-load-support.ts b/packages/api/src/router/timeline-project-load-support.ts new file mode 100644 index 0000000..2622216 --- /dev/null +++ b/packages/api/src/router/timeline-project-load-support.ts @@ -0,0 +1,93 @@ +import { listAssignmentBookings } from "@capakraken/application"; +import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; +import { + findTimelineProjectOrThrow, + projectShiftContextSelect, + timelineProjectContextSelect, +} from "./timeline-project-query-support.js"; +import { buildTimelineShiftPlan } from "./timeline-shift-planning.js"; +import { getAssignmentResourceIds, ShiftDbClient } from "./timeline-read-shared.js"; +import { buildTimelineProjectShiftValidation } from "./timeline-shift-support.js"; +import { buildTimelineShiftValidationBookings } from "./timeline-project-read-support.js"; + +export async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { + const [project, planningRead] = await Promise.all([ + findTimelineProjectOrThrow(db, { + projectId, + select: projectShiftContextSelect, + }), + loadProjectPlanningReadModel(db, { projectId, activeOnly: true }), + ]); + + const { demandRequirements, assignments, readModel: projectReadModel } = planningRead; + + const resourceIds = getAssignmentResourceIds(projectReadModel); + const allAssignmentWindows = + resourceIds.length === 0 + ? [] + : buildTimelineShiftValidationBookings( + await listAssignmentBookings(db, { + resourceIds, + }), + ); + + const shiftPlan = buildTimelineShiftPlan({ + demandRequirements, + assignments, + allAssignmentWindows, + }); + + return { + project, + demandRequirements, + assignments, + shiftPlan, + }; +} + +export async function loadTimelineProjectContext(db: ShiftDbClient, projectId: string) { + const [project, planningRead] = await Promise.all([ + findTimelineProjectOrThrow(db, { + projectId, + select: timelineProjectContextSelect, + }), + loadProjectPlanningReadModel(db, { + projectId, + activeOnly: true, + }), + ]); + + const resourceIds = getAssignmentResourceIds(planningRead.readModel); + const allResourceAllocations = + resourceIds.length === 0 + ? [] + : await listAssignmentBookings(db, { + resourceIds, + }); + + return { + project, + allocations: planningRead.readModel.allocations, + demands: planningRead.readModel.demands, + assignments: planningRead.readModel.assignments, + allResourceAllocations, + resourceIds, + }; +} + +export async function previewTimelineProjectShift( + db: ShiftDbClient, + input: { + projectId: string; + newStartDate: Date; + newEndDate: Date; + }, +) { + const context = await loadProjectShiftContext(db, input.projectId); + + return buildTimelineProjectShiftValidation({ + context, + newStartDate: input.newStartDate, + newEndDate: input.newEndDate, + }); +} diff --git a/packages/api/src/router/timeline-project-read.ts b/packages/api/src/router/timeline-project-read.ts index d23f64d..63b209c 100644 --- a/packages/api/src/router/timeline-project-read.ts +++ b/packages/api/src/router/timeline-project-read.ts @@ -4,10 +4,13 @@ import { ShiftProjectSchema } from "@capakraken/shared"; import { z } from "zod"; import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { controllerProcedure } from "../trpc.js"; -import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { loadTimelineProjectContextDetailArtifacts, } from "./timeline-project-context-support.js"; +import { + loadTimelineProjectContext, + previewTimelineProjectShift, +} from "./timeline-project-load-support.js"; import { buildTimelineProjectContextDetailResponse, buildTimelineProjectContextResponse, @@ -16,100 +19,12 @@ import { buildTimelineBudgetStatusAllocations, buildTimelineBudgetStatusResponse, buildTimelineShiftPreviewDetailResponse, - buildTimelineShiftValidationBookings, } from "./timeline-project-read-support.js"; import { findTimelineProjectOrThrow, - projectShiftContextSelect, timelineBudgetStatusProjectSelect, - timelineProjectContextSelect, timelineShiftPreviewProjectSelect, } from "./timeline-project-query-support.js"; -import { buildTimelineShiftPlan } from "./timeline-shift-planning.js"; -import { getAssignmentResourceIds, ShiftDbClient } from "./timeline-read-shared.js"; -import { buildTimelineProjectShiftValidation } from "./timeline-shift-support.js"; - -export async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { - const [project, planningRead] = await Promise.all([ - findTimelineProjectOrThrow(db, { - projectId, - select: projectShiftContextSelect, - }), - loadProjectPlanningReadModel(db, { projectId, activeOnly: true }), - ]); - - const { demandRequirements, assignments, readModel: projectReadModel } = planningRead; - - const resourceIds = getAssignmentResourceIds(projectReadModel); - const allAssignmentWindows = - resourceIds.length === 0 - ? [] - : buildTimelineShiftValidationBookings( - await listAssignmentBookings(db, { - resourceIds, - }), - ); - - const shiftPlan = buildTimelineShiftPlan({ - demandRequirements, - assignments, - allAssignmentWindows, - }); - - return { - project, - demandRequirements, - assignments, - shiftPlan, - }; -} - -export async function loadTimelineProjectContext(db: ShiftDbClient, projectId: string) { - const [project, planningRead] = await Promise.all([ - findTimelineProjectOrThrow(db, { - projectId, - select: timelineProjectContextSelect, - }), - loadProjectPlanningReadModel(db, { - projectId, - activeOnly: true, - }), - ]); - - const resourceIds = getAssignmentResourceIds(planningRead.readModel); - const allResourceAllocations = - resourceIds.length === 0 - ? [] - : await listAssignmentBookings(db, { - resourceIds, - }); - - return { - project, - allocations: planningRead.readModel.allocations, - demands: planningRead.readModel.demands, - assignments: planningRead.readModel.assignments, - allResourceAllocations, - resourceIds, - }; -} - -export async function previewTimelineProjectShift( - db: ShiftDbClient, - input: { - projectId: string; - newStartDate: Date; - newEndDate: Date; - }, -) { - const context = await loadProjectShiftContext(db, input.projectId); - - return buildTimelineProjectShiftValidation({ - context, - newStartDate: input.newStartDate, - newEndDate: input.newEndDate, - }); -} export const timelineProjectReadProcedures = { getProjectContext: controllerProcedure diff --git a/packages/api/src/router/timeline.ts b/packages/api/src/router/timeline.ts index d983eae..1dcb437 100644 --- a/packages/api/src/router/timeline.ts +++ b/packages/api/src/router/timeline.ts @@ -3,7 +3,7 @@ import { emitProjectShifted } from "../sse/event-bus.js"; import { createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js"; import { timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js"; import { timelineReadProcedures } from "./timeline-read.js"; -import { loadProjectShiftContext } from "./timeline-project-read.js"; +import { loadProjectShiftContext } from "./timeline-project-load-support.js"; import { applyTimelineProjectShift } from "./timeline-shift-support.js"; export const timelineRouter = createTRPCRouter({