Files
CapaKraken/packages/api/src/lib/scenario-schema.ts
T
Hartmut 9a42615a21 fix(api): add Zod bounds on financial fields, type vacation router, type scenarioData
- dailyCostCents, hoursPerDay, percentage now validated at API boundary
- vacation router no longer uses ctx.db as any
- scenarioData reads through typed Zod schema

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:08:16 +02:00

144 lines
4.8 KiB
TypeScript

/**
* Typed Zod schemas for scenario simulation data structures.
*
* The scenario feature does not persist scenarioData in JSONB — it computes
* everything on-the-fly from assignments, projects, and resources. These schemas
* describe the shapes that flow in and out of the simulation layer so that any
* deserialization (e.g. from a future scenario snapshot) can be validated at
* the API boundary rather than silently returning wrong-typed data.
*
* TODO(scenario-snapshots): if we ever persist scenario results to a JSONB
* column or a dedicated table, add `.parse()` at the DB read-boundary here.
*/
import { z } from "zod";
// ---------------------------------------------------------------------------
// Input schemas (mirrored from scenario-procedure-support for central reference)
// ---------------------------------------------------------------------------
export const ScenarioChangeInputSchema = 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().positive().max(24),
remove: z.boolean().optional(),
});
export const ScenarioSimulationRequestSchema = z.object({
projectId: z.string(),
changes: z.array(ScenarioChangeInputSchema).min(1),
});
// ---------------------------------------------------------------------------
// Output schemas (describe the shape returned by simulateProjectScenario)
// ---------------------------------------------------------------------------
const ScenarioSummarySchema = z.object({
totalCostCents: z.number().int(),
totalHours: z.number(),
headcount: z.number().int(),
skillCount: z.number().int(),
});
const ScenarioResourceImpactSchema = z.object({
resourceId: z.string(),
resourceName: z.string(),
chargeabilityTarget: z.number().nullable(),
currentUtilization: z.number(),
scenarioUtilization: z.number(),
utilizationDelta: z.number(),
isOverallocated: z.boolean(),
});
export const ScenarioSimulationResultSchema = z.object({
baseline: ScenarioSummarySchema,
scenario: ScenarioSummarySchema,
delta: z.object({
costCents: z.number().int(),
hours: z.number(),
headcount: z.number().int(),
skillCoveragePct: z.number().int(),
}),
resourceImpacts: z.array(ScenarioResourceImpactSchema),
warnings: z.array(z.string()),
budgetCents: z.number().int(),
});
// ---------------------------------------------------------------------------
// Baseline output schema (describe the shape returned by readProjectScenarioBaseline)
// ---------------------------------------------------------------------------
const ScenarioBaselineAllocationSchema = z.object({
id: z.string(),
resourceId: z.string().nullable(),
resourceName: z.string(),
resourceEid: z.string(),
lcrCents: z.number().int(),
roleId: z.string().nullable(),
roleName: z.string(),
roleColor: z.string().nullable(),
startDate: z.string(),
endDate: z.string(),
hoursPerDay: z.number(),
status: z.string(),
costCents: z.number().int(),
totalHours: z.number(),
workingDays: z.number(),
});
const ScenarioBaselineDemandSchema = z.object({
id: z.string(),
roleId: z.string().nullable(),
roleName: z.string(),
roleColor: z.string().nullable(),
startDate: z.string(),
endDate: z.string(),
hoursPerDay: z.number(),
headcount: z.number().int(),
status: z.string(),
});
export const ScenarioBaselineResultSchema = z.object({
project: z.object({
id: z.string(),
name: z.string(),
shortCode: z.string().nullable(),
startDate: z.date(),
endDate: z.date(),
budgetCents: z.number().int().nullable(),
orderType: z.string().nullable(),
}),
assignments: z.array(ScenarioBaselineAllocationSchema),
demands: z.array(ScenarioBaselineDemandSchema),
totalCostCents: z.number().int(),
totalHours: z.number(),
budgetCents: z.number().int().nullable(),
});
// ---------------------------------------------------------------------------
// Helper: validate scenario simulation result at the API boundary
//
// Usage:
// const result = parseScenarioSimulationResult(await simulateProjectScenario(db, input));
// ---------------------------------------------------------------------------
export function parseScenarioSimulationResult(
raw: unknown,
): z.infer<typeof ScenarioSimulationResultSchema> {
return ScenarioSimulationResultSchema.parse(raw);
}
export function parseScenarioBaselineResult(
raw: unknown,
): z.infer<typeof ScenarioBaselineResultSchema> {
return ScenarioBaselineResultSchema.parse(raw);
}
export type ScenarioChangeInput = z.infer<typeof ScenarioChangeInputSchema>;
export type ScenarioSimulationRequest = z.infer<typeof ScenarioSimulationRequestSchema>;
export type ScenarioSimulationResult = z.infer<typeof ScenarioSimulationResultSchema>;
export type ScenarioBaselineResult = z.infer<typeof ScenarioBaselineResultSchema>;