Merge branch 'worktree-agent-a90e1bc2'
This commit is contained in:
@@ -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<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>;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<typeof ScenarioProjectIdInputSchema>,
|
||||
) {
|
||||
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<TRPCContext, "db">,
|
||||
input: z.infer<typeof ScenarioSimulationInputSchema>,
|
||||
) {
|
||||
return simulateProjectScenario(ctx.db, input);
|
||||
const result = await simulateProjectScenario(ctx.db, input);
|
||||
return parseScenarioSimulationResult(result);
|
||||
}
|
||||
|
||||
export async function applyScenario(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user