refactor(api): extract timeline project load support

This commit is contained in:
2026-03-31 17:32:43 +02:00
parent acb4ec5243
commit 1bdae5816f
4 changed files with 263 additions and 90 deletions
@@ -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<typeof import("../router/timeline-read-shared.js")>(
"../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"),
});
});
});
@@ -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,
});
}
@@ -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
+1 -1
View File
@@ -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({