refactor(api): extract timeline project procedure support

This commit is contained in:
2026-03-31 17:38:09 +02:00
parent 1bdae5816f
commit 7a64fe5ce5
3 changed files with 422 additions and 117 deletions
@@ -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,
});
});
});
@@ -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<typeof getAnonymizationDirectory>[0]
& Parameters<typeof loadTimelineProjectContext>[0]
& Parameters<typeof loadTimelineProjectContextDetailArtifacts>[0]
& Parameters<typeof findTimelineProjectOrThrow>[0]
& Parameters<typeof listAssignmentBookings>[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,
});
}
+10 -117
View File
@@ -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)),
};