Merge branch 'worktree-agent-a90e1bc2'

This commit is contained in:
2026-04-09 14:19:18 +02:00
5 changed files with 158 additions and 11 deletions
+143
View File
@@ -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 -1
View File
@@ -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,
);