diff --git a/packages/api/src/lib/scenario-schema.ts b/packages/api/src/lib/scenario-schema.ts new file mode 100644 index 0000000..bb6f9a8 --- /dev/null +++ b/packages/api/src/lib/scenario-schema.ts @@ -0,0 +1,143 @@ +/** + * 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 { + return ScenarioSimulationResultSchema.parse(raw); +} + +export function parseScenarioBaselineResult( + raw: unknown, +): z.infer { + return ScenarioBaselineResultSchema.parse(raw); +} + +export type ScenarioChangeInput = z.infer; +export type ScenarioSimulationRequest = z.infer; +export type ScenarioSimulationResult = z.infer; +export type ScenarioBaselineResult = z.infer; diff --git a/packages/api/src/lib/vacation-conflicts.ts b/packages/api/src/lib/vacation-conflicts.ts index db954dc..241fbd5 100644 --- a/packages/api/src/lib/vacation-conflicts.ts +++ b/packages/api/src/lib/vacation-conflicts.ts @@ -1,7 +1,7 @@ import { VacationStatus } from "@capakraken/db"; import { createNotification } from "./create-notification.js"; -type DbClient = { +export type DbClient = { vacation: { findUnique: (args: { where: { id: string }; diff --git a/packages/api/src/router/scenario-procedure-support.ts b/packages/api/src/router/scenario-procedure-support.ts index a02b1ff..76fcd1b 100644 --- a/packages/api/src/router/scenario-procedure-support.ts +++ b/packages/api/src/router/scenario-procedure-support.ts @@ -1,5 +1,9 @@ import { PermissionKey } from "@capakraken/shared"; import { z } from "zod"; +import { + parseScenarioBaselineResult, + parseScenarioSimulationResult, +} from "../lib/scenario-schema.js"; import type { TRPCContext } from "../trpc.js"; import { requirePermission } from "../trpc.js"; import { applyProjectScenario } from "./scenario-apply.js"; @@ -34,14 +38,16 @@ export async function getProjectScenarioBaseline( input: z.infer, ) { requirePermission(ctx, PermissionKey.VIEW_COSTS); - return readProjectScenarioBaseline(ctx.db, input.projectId); + const result = await readProjectScenarioBaseline(ctx.db, input.projectId); + return parseScenarioBaselineResult(result); } export async function simulateScenario( ctx: Pick, input: z.infer, ) { - return simulateProjectScenario(ctx.db, input); + const result = await simulateProjectScenario(ctx.db, input); + return parseScenarioSimulationResult(result); } export async function applyScenario( diff --git a/packages/api/src/router/vacation-management-procedures.ts b/packages/api/src/router/vacation-management-procedures.ts index 938dbfd..e4e8aa1 100644 --- a/packages/api/src/router/vacation-management-procedures.ts +++ b/packages/api/src/router/vacation-management-procedures.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { makeAuditLogger } from "../lib/audit-helpers.js"; -import { checkBatchVacationConflicts, checkVacationConflicts } from "../lib/vacation-conflicts.js"; +import { checkBatchVacationConflicts, checkVacationConflicts, type DbClient as VacationConflictDbClient } from "../lib/vacation-conflicts.js"; import { emitVacationUpdated } from "../sse/event-bus.js"; import { adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { @@ -76,8 +76,7 @@ export const vacationManagementProcedures = { const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); const audit = makeAuditLogger(ctx.db, userRecord?.id); const conflictResult = await checkVacationConflicts( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ctx.db as any, + ctx.db as unknown as VacationConflictDbClient, input.id, userRecord?.id, ); @@ -195,8 +194,7 @@ export const vacationManagementProcedures = { } const conflictMap = await checkBatchVacationConflicts( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ctx.db as any, + ctx.db as unknown as VacationConflictDbClient, vacations.map((vacation) => vacation.id), userRecord?.id, ); diff --git a/packages/shared/src/schemas/allocation.schema.ts b/packages/shared/src/schemas/allocation.schema.ts index ea649f9..f37ace3 100644 --- a/packages/shared/src/schemas/allocation.schema.ts +++ b/packages/shared/src/schemas/allocation.schema.ts @@ -6,7 +6,7 @@ export const CreateAllocationBaseSchema = z.object({ projectId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), - hoursPerDay: z.number().min(0).max(24), + hoursPerDay: z.number().positive().max(24), percentage: z.number().min(0).max(100), role: z.string().max(200).optional(), roleId: z.string().optional(), @@ -20,7 +20,7 @@ export const CreateDemandRequirementBaseSchema = z.object({ projectId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), - hoursPerDay: z.number().min(0).max(24), + hoursPerDay: z.number().positive().max(24), percentage: z.number().min(0).max(100), role: z.string().max(200).optional(), roleId: z.string().optional(), @@ -36,7 +36,7 @@ export const CreateAssignmentBaseSchema = z.object({ projectId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), - hoursPerDay: z.number().min(0).max(24), + hoursPerDay: z.number().positive().max(24), percentage: z.number().min(0).max(100), role: z.string().max(200).optional(), roleId: z.string().optional(),