import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared"; import type { TRPCContext } from "../../trpc.js"; import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js"; type AssistantToolErrorResult = { error: string }; type ResolvedProject = { id: string; name: string; shortCode: string; }; type ResolvedResource = { id: string; displayName: string; }; type ResolvedRole = { id: string; name: string; }; type DemandRecord = { id: string; status: string; headcount: number; hoursPerDay: number; startDate: Date | null; endDate: Date | null; role?: string | null; roleEntity?: { name?: string | null } | null; project: { name: string; shortCode: string; }; assignments: unknown[]; }; type DemandAssignmentResult = { assignment: { id: string; }; demandRequirement: { role?: string | null; roleEntity?: { name?: string | null } | null; project: { name: string; shortCode: string; }; }; }; type StaffingDemandDeps = { assertPermission: (ctx: ToolContext, perm: PermissionKey) => void; createAllocationCaller: (ctx: TRPCContext) => { listDemands: (params: { projectId?: string; status?: AllocationStatus; }) => Promise; createDemand: (params: { projectId: string; roleId: string; role: string; headcount: number; hoursPerDay: number; startDate: Date; endDate: Date; }) => Promise<{ id: string }>; assignResourceToDemand: (params: { demandRequirementId: string; resourceId: string; }) => Promise; getResourceAvailabilitySummary: (params: { resourceId: string; startDate: Date; endDate: Date; }) => Promise; }; createStaffingCaller: (ctx: TRPCContext) => { getProjectStaffingSuggestions: (params: { projectId: string; roleName?: string; startDate?: Date; endDate?: Date; limit?: number; }) => Promise; searchCapacity: (params: { startDate: Date; endDate: Date; minHoursPerDay: number; roleName?: string; chapter?: string; limit?: number; }) => Promise; }; createRoleCaller: (ctx: TRPCContext) => { resolveByIdentifier: (params: { identifier: string }) => Promise; }; createScopedCallerContext: (ctx: ToolContext) => TRPCContext; resolveProjectIdentifier: ( ctx: ToolContext, identifier: string, ) => Promise; resolveResourceIdentifier: ( ctx: ToolContext, identifier: string, ) => Promise; resolveEntityOrAssistantError: ( resolve: () => Promise, notFoundMessage: string, ) => Promise; parseIsoDate: (value: string, fieldName: string) => Date; parseOptionalIsoDate: (value: string | undefined, fieldName: string) => Date | undefined; fmtDate: (value: Date | null | undefined) => string | null; toAssistantDemandCreationError: ( error: unknown, ) => AssistantToolErrorResult | null; toAssistantDemandFillError: ( error: unknown, ) => AssistantToolErrorResult | null; }; export const staffingDemandReadToolDefinitions: ToolDef[] = withToolAccess([ { type: "function", function: { name: "list_demands", description: "List staffing demand requirements for projects. Shows open positions that need to be filled.", parameters: { type: "object", properties: { projectId: { type: "string", description: "Filter by project ID or short code" }, status: { type: "string", description: "Filter by status: OPEN, PARTIALLY_FILLED, FILLED, CANCELLED" }, limit: { type: "integer", description: "Max results. Default: 30" }, }, }, }, }, { type: "function", function: { name: "check_resource_availability", description: "Check if a resource is available in a given date range (no conflicting allocations or vacations).", parameters: { type: "object", properties: { resourceId: { type: "string", description: "Resource ID, eid, or name" }, startDate: { type: "string", description: "Start date YYYY-MM-DD" }, endDate: { type: "string", description: "End date YYYY-MM-DD" }, }, required: ["resourceId", "startDate", "endDate"], }, }, }, { type: "function", function: { name: "get_staffing_suggestions", description: "Get AI-powered staffing suggestions for a project based on required skills, availability, and cost.", parameters: { type: "object", properties: { projectId: { type: "string", description: "Project ID or short code" }, roleName: { type: "string", description: "Role to find candidates for" }, startDate: { type: "string", description: "Start date YYYY-MM-DD" }, endDate: { type: "string", description: "End date YYYY-MM-DD" }, limit: { type: "integer", description: "Max suggestions. Default: 5" }, }, required: ["projectId"], }, }, }, { type: "function", function: { name: "find_capacity", description: "Find resources with available capacity in a date range.", parameters: { type: "object", properties: { startDate: { type: "string", description: "Start date YYYY-MM-DD" }, endDate: { type: "string", description: "End date YYYY-MM-DD" }, minHoursPerDay: { type: "number", description: "Minimum available hours/day. Default: 4" }, roleName: { type: "string", description: "Filter by role name" }, chapter: { type: "string", description: "Filter by chapter" }, limit: { type: "integer", description: "Max results. Default: 20" }, }, required: ["startDate", "endDate"], }, }, }, ], { list_demands: { requiresPlanningRead: true, }, check_resource_availability: { requiresPlanningRead: true, }, get_staffing_suggestions: { requiresPlanningRead: true, requiresCostView: true, }, find_capacity: { requiresPlanningRead: true, }, }); export const staffingDemandMutationToolDefinitions: ToolDef[] = withToolAccess([ { type: "function", function: { name: "create_demand", description: "Create a staffing demand requirement on a project. Requires manageAllocations permission. Always confirm first.", parameters: { type: "object", properties: { projectId: { type: "string", description: "Project ID or short code" }, roleName: { type: "string", description: "Role name for the demand" }, headcount: { type: "integer", description: "Number of people needed. Default: 1" }, hoursPerDay: { type: "number", description: "Hours per day required" }, startDate: { type: "string", description: "Start date YYYY-MM-DD" }, endDate: { type: "string", description: "End date YYYY-MM-DD" }, }, required: ["projectId", "roleName", "hoursPerDay", "startDate", "endDate"], }, }, }, { type: "function", function: { name: "fill_demand", description: "Fill/assign a resource to an open demand requirement. Requires manageAllocations permission. Always confirm first.", parameters: { type: "object", properties: { demandId: { type: "string", description: "Demand requirement ID" }, resourceId: { type: "string", description: "Resource ID or name to assign" }, }, required: ["demandId", "resourceId"], }, }, }, ], { create_demand: { requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS], allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER], }, fill_demand: { requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS], allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER], }, }); export function createStaffingDemandExecutors( deps: StaffingDemandDeps, ): Record { return { async list_demands( params: { projectId?: string; status?: string; limit?: number }, ctx: ToolContext, ) { const limit = Math.min(params.limit ?? 30, 50); const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx)); const resolvedProject = params.projectId ? await deps.resolveProjectIdentifier(ctx, params.projectId) : null; if (resolvedProject && "error" in resolvedProject) { return resolvedProject; } const demands = await caller.listDemands({ ...(resolvedProject ? { projectId: resolvedProject.id } : {}), ...(params.status ? { status: params.status as AllocationStatus } : {}), }); return demands.map((demand) => ({ id: demand.id, project: demand.project.name, projectCode: demand.project.shortCode, role: demand.roleEntity?.name ?? demand.role ?? "Unspecified", status: demand.status, headcount: demand.headcount, filled: demand.assignments.length, remaining: demand.headcount - demand.assignments.length, hoursPerDay: demand.hoursPerDay, start: deps.fmtDate(demand.startDate), end: deps.fmtDate(demand.endDate), })).slice(0, limit); }, async create_demand( params: { projectId: string; roleName: string; headcount?: number; hoursPerDay: number; startDate: string; endDate: string; }, ctx: ToolContext, ) { deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const scopedContext = deps.createScopedCallerContext(ctx); const roleCaller = deps.createRoleCaller(scopedContext); const [project, role] = await Promise.all([ deps.resolveProjectIdentifier(ctx, params.projectId), deps.resolveEntityOrAssistantError( () => roleCaller.resolveByIdentifier({ identifier: params.roleName }), `Role not found: ${params.roleName}`, ), ]); if ("error" in project) { return project; } if ("error" in role) { return role; } const caller = deps.createAllocationCaller(scopedContext); let demand; try { demand = await caller.createDemand({ projectId: project.id, roleId: role.id, role: role.name, headcount: params.headcount ?? 1, hoursPerDay: params.hoursPerDay, startDate: deps.parseIsoDate(params.startDate, "startDate"), endDate: deps.parseIsoDate(params.endDate, "endDate"), }); } catch (error) { const mapped = deps.toAssistantDemandCreationError(error); if (mapped) { return mapped; } throw error; } return { __action: "invalidate", scope: ["allocation"], success: true, message: `Created demand: ${role.name} × ${params.headcount ?? 1} for ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`, demandId: demand.id, }; }, async fill_demand( params: { demandId: string; resourceId: string }, ctx: ToolContext, ) { deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const allocationCaller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx)); const resource = await deps.resolveResourceIdentifier(ctx, params.resourceId); if ("error" in resource) { return resource; } let result; try { result = await allocationCaller.assignResourceToDemand({ demandRequirementId: params.demandId, resourceId: resource.id, }); } catch (error) { const mapped = deps.toAssistantDemandFillError(error); if (mapped) { return mapped; } throw error; } const roleName = result.demandRequirement.roleEntity?.name ?? result.demandRequirement.role ?? null; return { __action: "invalidate", scope: ["allocation", "timeline"], success: true, message: `Assigned ${resource.displayName} to ${roleName ?? "demand"} on ${result.demandRequirement.project.name} (${result.demandRequirement.project.shortCode})`, assignmentId: result.assignment.id, }; }, async check_resource_availability( params: { resourceId: string; startDate: string; endDate: string }, ctx: ToolContext, ) { const resource = await deps.resolveResourceIdentifier(ctx, params.resourceId); if ("error" in resource) { return resource; } const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx)); return caller.getResourceAvailabilitySummary({ resourceId: resource.id, startDate: deps.parseIsoDate(params.startDate, "startDate"), endDate: deps.parseIsoDate(params.endDate, "endDate"), }); }, async get_staffing_suggestions( params: { projectId: string; roleName?: string; startDate?: string; endDate?: string; limit?: number; }, ctx: ToolContext, ) { const project = await deps.resolveProjectIdentifier(ctx, params.projectId); if ("error" in project) { return project; } const caller = deps.createStaffingCaller(deps.createScopedCallerContext(ctx)); const startDate = deps.parseOptionalIsoDate(params.startDate, "startDate"); const endDate = deps.parseOptionalIsoDate(params.endDate, "endDate"); return caller.getProjectStaffingSuggestions({ projectId: project.id, ...(params.roleName ? { roleName: params.roleName } : {}), ...(startDate ? { startDate } : {}), ...(endDate ? { endDate } : {}), ...(params.limit ? { limit: params.limit } : {}), }); }, async find_capacity( params: { startDate: string; endDate: string; minHoursPerDay?: number; roleName?: string; chapter?: string; limit?: number; }, ctx: ToolContext, ) { const caller = deps.createStaffingCaller(deps.createScopedCallerContext(ctx)); return caller.searchCapacity({ startDate: deps.parseIsoDate(params.startDate, "startDate"), endDate: deps.parseIsoDate(params.endDate, "endDate"), minHoursPerDay: params.minHoursPerDay ?? 4, ...(params.roleName ? { roleName: params.roleName } : {}), ...(params.chapter ? { chapter: params.chapter } : {}), ...(params.limit ? { limit: params.limit } : {}), }); }, }; }