refactor(api): extract scenario procedures
This commit is contained in:
@@ -0,0 +1,104 @@
|
|||||||
|
import { PermissionKey } from "@capakraken/shared";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
applyScenario,
|
||||||
|
getProjectScenarioBaseline,
|
||||||
|
ScenarioSimulationInputSchema,
|
||||||
|
simulateScenario,
|
||||||
|
} from "../router/scenario-procedure-support.js";
|
||||||
|
|
||||||
|
const {
|
||||||
|
readProjectScenarioBaseline,
|
||||||
|
simulateProjectScenario,
|
||||||
|
applyProjectScenario,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
readProjectScenarioBaseline: vi.fn(),
|
||||||
|
simulateProjectScenario: vi.fn(),
|
||||||
|
applyProjectScenario: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../router/scenario-baseline.js", () => ({
|
||||||
|
readProjectScenarioBaseline,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../router/scenario-simulation.js", () => ({
|
||||||
|
simulateProjectScenario,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../router/scenario-apply.js", () => ({
|
||||||
|
applyProjectScenario,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("scenario procedure support", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
readProjectScenarioBaseline.mockReset();
|
||||||
|
simulateProjectScenario.mockReset();
|
||||||
|
applyProjectScenario.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces at least one scenario change at the schema boundary", () => {
|
||||||
|
expect(() => ScenarioSimulationInputSchema.parse({
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [],
|
||||||
|
})).toThrowError(/at least 1 element/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the project baseline only after cost permission is present", async () => {
|
||||||
|
readProjectScenarioBaseline.mockResolvedValue({ project: { id: "project_1" } });
|
||||||
|
|
||||||
|
const result = await getProjectScenarioBaseline({
|
||||||
|
db: { project: {} } as never,
|
||||||
|
dbUser: { id: "user_1", systemRole: "USER", permissionOverrides: null },
|
||||||
|
permissions: new Set([PermissionKey.VIEW_COSTS]),
|
||||||
|
}, {
|
||||||
|
projectId: "project_1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ project: { id: "project_1" } });
|
||||||
|
expect(readProjectScenarioBaseline).toHaveBeenCalledWith(expect.anything(), "project_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates pure simulation to the shared simulation service", async () => {
|
||||||
|
simulateProjectScenario.mockResolvedValue({ warnings: [] });
|
||||||
|
const ctx = { db: { project: {} } };
|
||||||
|
const input = {
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [{
|
||||||
|
resourceId: "resource_1",
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await simulateScenario(ctx as never, input);
|
||||||
|
|
||||||
|
expect(result).toEqual({ warnings: [] });
|
||||||
|
expect(simulateProjectScenario).toHaveBeenCalledWith(ctx.db, input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the acting user id through when applying a scenario", async () => {
|
||||||
|
applyProjectScenario.mockResolvedValue({ appliedCount: 2 });
|
||||||
|
const ctx = {
|
||||||
|
db: { assignment: {} },
|
||||||
|
dbUser: { id: "user_1", systemRole: "CONTROLLER", permissionOverrides: null },
|
||||||
|
};
|
||||||
|
const input = {
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [{
|
||||||
|
assignmentId: "assignment_1",
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-03T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 6,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyScenario(ctx as never, input);
|
||||||
|
|
||||||
|
expect(result).toEqual({ appliedCount: 2 });
|
||||||
|
expect(applyProjectScenario).toHaveBeenCalledWith(ctx.db, {
|
||||||
|
...input,
|
||||||
|
userId: "user_1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { PermissionKey } from "@capakraken/shared";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { TRPCContext } from "../trpc.js";
|
||||||
|
import { requirePermission } from "../trpc.js";
|
||||||
|
import { applyProjectScenario } from "./scenario-apply.js";
|
||||||
|
import { readProjectScenarioBaseline } from "./scenario-baseline.js";
|
||||||
|
import { simulateProjectScenario } from "./scenario-simulation.js";
|
||||||
|
|
||||||
|
export const ScenarioChangeSchema = z.object({
|
||||||
|
assignmentId: z.string().optional(),
|
||||||
|
resourceId: z.string().optional(),
|
||||||
|
roleId: z.string().optional(),
|
||||||
|
startDate: z.coerce.date(),
|
||||||
|
endDate: z.coerce.date(),
|
||||||
|
hoursPerDay: z.number().min(0).max(24),
|
||||||
|
remove: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ScenarioProjectIdInputSchema = z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ScenarioSimulationInputSchema = z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
changes: z.array(ScenarioChangeSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ScenarioBaselineContext = Pick<TRPCContext, "db" | "dbUser"> & {
|
||||||
|
permissions: Set<PermissionKey>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getProjectScenarioBaseline(
|
||||||
|
ctx: ScenarioBaselineContext,
|
||||||
|
input: z.infer<typeof ScenarioProjectIdInputSchema>,
|
||||||
|
) {
|
||||||
|
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
||||||
|
return readProjectScenarioBaseline(ctx.db, input.projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function simulateScenario(
|
||||||
|
ctx: Pick<TRPCContext, "db">,
|
||||||
|
input: z.infer<typeof ScenarioSimulationInputSchema>,
|
||||||
|
) {
|
||||||
|
return simulateProjectScenario(ctx.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyScenario(
|
||||||
|
ctx: Pick<TRPCContext, "db" | "dbUser">,
|
||||||
|
input: z.infer<typeof ScenarioSimulationInputSchema>,
|
||||||
|
) {
|
||||||
|
return applyProjectScenario(ctx.db, {
|
||||||
|
...input,
|
||||||
|
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,54 +1,22 @@
|
|||||||
import { PermissionKey } from "@capakraken/shared";
|
import { createTRPCRouter, controllerProcedure, planningReadProcedure } from "../trpc.js";
|
||||||
import { z } from "zod";
|
import {
|
||||||
import { createTRPCRouter, controllerProcedure, planningReadProcedure, requirePermission } from "../trpc.js";
|
applyScenario,
|
||||||
import { applyProjectScenario } from "./scenario-apply.js";
|
getProjectScenarioBaseline,
|
||||||
import { readProjectScenarioBaseline } from "./scenario-baseline.js";
|
ScenarioProjectIdInputSchema,
|
||||||
import { simulateProjectScenario } from "./scenario-simulation.js";
|
ScenarioSimulationInputSchema,
|
||||||
|
simulateScenario,
|
||||||
const ScenarioChangeSchema = z.object({
|
} from "./scenario-procedure-support.js";
|
||||||
/** Existing assignment to modify — omit to add a new allocation */
|
|
||||||
assignmentId: z.string().optional(),
|
|
||||||
resourceId: z.string().optional(),
|
|
||||||
roleId: z.string().optional(),
|
|
||||||
startDate: z.coerce.date(),
|
|
||||||
endDate: z.coerce.date(),
|
|
||||||
hoursPerDay: z.number().min(0).max(24),
|
|
||||||
/** Set to true to mark an existing assignment for removal */
|
|
||||||
remove: z.boolean().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const SimulateInputSchema = z.object({
|
|
||||||
projectId: z.string(),
|
|
||||||
changes: z.array(ScenarioChangeSchema).min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const scenarioRouter = createTRPCRouter({
|
export const scenarioRouter = createTRPCRouter({
|
||||||
/**
|
|
||||||
* Returns current allocations/costs for a project — the baseline for comparison.
|
|
||||||
*/
|
|
||||||
getProjectBaseline: planningReadProcedure
|
getProjectBaseline: planningReadProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(ScenarioProjectIdInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(({ ctx, input }) => getProjectScenarioBaseline(ctx, input)),
|
||||||
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
|
||||||
return readProjectScenarioBaseline(ctx.db, input.projectId);
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure simulation: computes cost/hours/utilization impact of scenario changes
|
|
||||||
* without persisting anything.
|
|
||||||
*/
|
|
||||||
simulate: controllerProcedure
|
simulate: controllerProcedure
|
||||||
.input(SimulateInputSchema)
|
.input(ScenarioSimulationInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => simulateProjectScenario(ctx.db, input)),
|
.mutation(({ ctx, input }) => simulateScenario(ctx, input)),
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a scenario: creates real assignments from scenario changes.
|
|
||||||
* Manager+ access required.
|
|
||||||
*/
|
|
||||||
applyScenario: controllerProcedure
|
applyScenario: controllerProcedure
|
||||||
.input(SimulateInputSchema)
|
.input(ScenarioSimulationInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => applyProjectScenario(ctx.db, {
|
.mutation(({ ctx, input }) => applyScenario(ctx, input)),
|
||||||
...input,
|
|
||||||
userId: ctx.dbUser?.id,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user