Files
CapaKraken/packages/api/src/router/assistant-tools/staffing-demand.ts
T

454 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<DemandRecord[]>;
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<DemandAssignmentResult>;
getResourceAvailabilitySummary: (params: {
resourceId: string;
startDate: Date;
endDate: Date;
}) => Promise<unknown>;
};
createStaffingCaller: (ctx: TRPCContext) => {
getProjectStaffingSuggestions: (params: {
projectId: string;
roleName?: string;
startDate?: Date;
endDate?: Date;
limit?: number;
}) => Promise<unknown>;
searchCapacity: (params: {
startDate: Date;
endDate: Date;
minHoursPerDay: number;
roleName?: string;
chapter?: string;
limit?: number;
}) => Promise<unknown>;
};
createRoleCaller: (ctx: TRPCContext) => {
resolveByIdentifier: (params: { identifier: string }) => Promise<ResolvedRole>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
resolveProjectIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<ResolvedProject | AssistantToolErrorResult>;
resolveResourceIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<ResolvedResource | AssistantToolErrorResult>;
resolveEntityOrAssistantError: <T>(
resolve: () => Promise<T>,
notFoundMessage: string,
) => Promise<T | AssistantToolErrorResult>;
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<string, ToolExecutor> {
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 } : {}),
});
},
};
}