refactor(api): extract assistant staffing demand slice
This commit is contained in:
@@ -36,12 +36,13 @@
|
||||
- the embedded notification, task, reminder, and broadcast assistant helpers now live in their own domain module, keeping the collaboration workflow wiring out of the monolithic router without changing the assistant contract
|
||||
- the estimate read and mutation helpers now live in their own domain module, keeping estimate lifecycle orchestration out of the monolithic assistant router without changing the assistant contract
|
||||
- the project search, lifecycle, and cover-art helpers now live in their own domain module, keeping project orchestration out of the monolithic assistant router without changing the assistant contract
|
||||
- the demand, staffing-suggestion, capacity, and resource-availability assistant helpers now live in their own domain module, keeping staffing orchestration out of the monolithic assistant router without changing the assistant contract
|
||||
|
||||
## Next Up
|
||||
|
||||
Pin the next structural cleanup on the API side:
|
||||
continue splitting `packages/api/src/router/assistant-tools.ts` into domain-oriented tool modules without changing the public tool contract.
|
||||
The next clean slice should stay adjacent to the extracted domains and target one cohesive block such as the remaining resource-management or staffing-demand helpers that are still in the monolithic router.
|
||||
The next clean slice should stay adjacent to the extracted domains and target one cohesive block such as the remaining resource-management helpers or other tightly bound CRUD/read-model clusters still in the monolithic router.
|
||||
|
||||
## Remaining Major Themes
|
||||
|
||||
|
||||
@@ -113,6 +113,11 @@ import {
|
||||
projectMutationToolDefinitions,
|
||||
projectReadToolDefinitions,
|
||||
} from "./assistant-tools/projects.js";
|
||||
import {
|
||||
createStaffingDemandExecutors,
|
||||
staffingDemandMutationToolDefinitions,
|
||||
staffingDemandReadToolDefinitions,
|
||||
} from "./assistant-tools/staffing-demand.js";
|
||||
import {
|
||||
withToolAccess,
|
||||
type ToolAccessRequirements,
|
||||
@@ -404,15 +409,6 @@ const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial<Record<string, ToolAccessRequiremen
|
||||
get_pending_vacation_approvals: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
||||
get_entitlement_summary: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
||||
set_entitlement: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
||||
list_demands: { requiresPlanningRead: true },
|
||||
create_demand: { requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS] },
|
||||
fill_demand: { requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS] },
|
||||
check_resource_availability: { requiresPlanningRead: true },
|
||||
get_staffing_suggestions: {
|
||||
requiresPlanningRead: true,
|
||||
requiresCostView: true,
|
||||
},
|
||||
find_capacity: { requiresPlanningRead: true },
|
||||
list_blueprints: { requiresPlanningRead: true },
|
||||
get_blueprint: { requiresPlanningRead: true },
|
||||
list_rate_cards: {
|
||||
@@ -2367,108 +2363,8 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
|
||||
},
|
||||
|
||||
// ── DEMAND / STAFFING ──
|
||||
{
|
||||
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: "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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
...staffingDemandReadToolDefinitions,
|
||||
...staffingDemandMutationToolDefinitions,
|
||||
|
||||
// ── BLUEPRINT ──
|
||||
{
|
||||
@@ -3144,6 +3040,21 @@ const executors = {
|
||||
toAssistantProjectCreationError,
|
||||
toAssistantProjectNotFoundError,
|
||||
}),
|
||||
...createStaffingDemandExecutors({
|
||||
assertPermission,
|
||||
createAllocationCaller,
|
||||
createStaffingCaller,
|
||||
createRoleCaller,
|
||||
createScopedCallerContext,
|
||||
resolveProjectIdentifier,
|
||||
resolveResourceIdentifier,
|
||||
resolveEntityOrAssistantError,
|
||||
parseIsoDate,
|
||||
parseOptionalIsoDate,
|
||||
fmtDate,
|
||||
toAssistantDemandCreationError,
|
||||
toAssistantDemandFillError,
|
||||
}),
|
||||
|
||||
...createAdvancedTimelineExecutors({
|
||||
assertPermission,
|
||||
@@ -3736,169 +3647,6 @@ const executors = {
|
||||
};
|
||||
},
|
||||
|
||||
// ── DEMAND / STAFFING ──
|
||||
|
||||
async list_demands(params: { projectId?: string; status?: string; limit?: number }, ctx: ToolContext) {
|
||||
const limit = Math.min(params.limit ?? 30, 50);
|
||||
const caller = createAllocationCaller(createScopedCallerContext(ctx));
|
||||
const resolvedProject = params.projectId
|
||||
? await 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((d) => ({
|
||||
id: d.id,
|
||||
project: d.project.name,
|
||||
projectCode: d.project.shortCode,
|
||||
role: d.roleEntity?.name ?? d.role ?? "Unspecified",
|
||||
status: d.status,
|
||||
headcount: d.headcount,
|
||||
filled: d.assignments.length,
|
||||
remaining: d.headcount - d.assignments.length,
|
||||
hoursPerDay: d.hoursPerDay,
|
||||
start: fmtDate(d.startDate),
|
||||
end: fmtDate(d.endDate),
|
||||
})).slice(0, limit);
|
||||
},
|
||||
|
||||
async create_demand(params: {
|
||||
projectId: string; roleName: string; headcount?: number;
|
||||
hoursPerDay: number; startDate: string; endDate: string;
|
||||
}, ctx: ToolContext) {
|
||||
assertPermission(ctx, "manageAllocations" as PermissionKey);
|
||||
const roleCaller = createRoleCaller(createScopedCallerContext(ctx));
|
||||
const [project, role] = await Promise.all([
|
||||
resolveProjectIdentifier(ctx, params.projectId),
|
||||
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 = createAllocationCaller(createScopedCallerContext(ctx));
|
||||
let demand;
|
||||
try {
|
||||
demand = await caller.createDemand({
|
||||
projectId: project.id,
|
||||
roleId: role.id,
|
||||
role: role.name,
|
||||
headcount: params.headcount ?? 1,
|
||||
hoursPerDay: params.hoursPerDay,
|
||||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = 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) {
|
||||
assertPermission(ctx, "manageAllocations" as PermissionKey);
|
||||
const allocationCaller = createAllocationCaller(createScopedCallerContext(ctx));
|
||||
const resource = await 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 = 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 resolveResourceIdentifier(ctx, params.resourceId);
|
||||
if ("error" in resource) {
|
||||
return resource;
|
||||
}
|
||||
|
||||
const caller = createAllocationCaller(createScopedCallerContext(ctx));
|
||||
return caller.getResourceAvailabilitySummary({
|
||||
resourceId: resource.id,
|
||||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||||
});
|
||||
},
|
||||
|
||||
async get_staffing_suggestions(params: {
|
||||
projectId: string; roleName?: string;
|
||||
startDate?: string; endDate?: string; limit?: number;
|
||||
}, ctx: ToolContext) {
|
||||
const project = await resolveProjectIdentifier(ctx, params.projectId);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const caller = createStaffingCaller(createScopedCallerContext(ctx));
|
||||
const startDate = parseOptionalIsoDate(params.startDate, "startDate");
|
||||
const endDate = 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 = createStaffingCaller(createScopedCallerContext(ctx));
|
||||
return caller.searchCapacity({
|
||||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||||
minHoursPerDay: params.minHoursPerDay ?? 4,
|
||||
...(params.roleName ? { roleName: params.roleName } : {}),
|
||||
...(params.chapter ? { chapter: params.chapter } : {}),
|
||||
...(params.limit ? { limit: params.limit } : {}),
|
||||
});
|
||||
},
|
||||
|
||||
// ── BLUEPRINT ──
|
||||
|
||||
async list_blueprints(_params: Record<string, never>, ctx: ToolContext) {
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
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 } : {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user