454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
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 } : {}),
|
||
});
|
||
},
|
||
};
|
||
}
|