b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
546 lines
19 KiB
TypeScript
546 lines
19 KiB
TypeScript
import type { TRPCContext } from "../../trpc.js";
|
|
import { AllocationStatus, PermissionKey, UpdateAssignmentSchema } from "@nexus/shared";
|
|
import { SystemRole } from "@nexus/shared";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
import { fmtEur } from "../../lib/format-utils.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 AllocationPlanningDeps = {
|
|
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
|
|
createAllocationCaller: (ctx: TRPCContext) => {
|
|
listView: (params: {
|
|
resourceId?: string;
|
|
projectId?: string;
|
|
status?: AllocationStatus;
|
|
}) => Promise<{
|
|
assignments: Array<{
|
|
id: string;
|
|
status: string;
|
|
hoursPerDay: number;
|
|
dailyCostCents?: number | null;
|
|
startDate: Date | string;
|
|
endDate: Date | string;
|
|
role?: string | null;
|
|
roleEntity?: { name?: string | null } | null;
|
|
resource?: { displayName?: string | null; eid?: string | null } | null;
|
|
project?: { name?: string | null; shortCode?: string | null } | null;
|
|
}>;
|
|
}>;
|
|
ensureAssignment: (params: {
|
|
resourceId: string;
|
|
projectId: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
hoursPerDay: number;
|
|
role?: string;
|
|
}) => Promise<{
|
|
action: "created" | "reactivated";
|
|
assignment: {
|
|
id: string;
|
|
status: string;
|
|
};
|
|
}>;
|
|
resolveAssignment: (params: {
|
|
assignmentId?: string;
|
|
resourceId?: string;
|
|
projectId?: string;
|
|
startDate?: Date;
|
|
endDate?: Date;
|
|
selectionMode: "WINDOW" | "EXACT_START";
|
|
excludeCancelled?: boolean;
|
|
}) => Promise<{
|
|
id: string;
|
|
status: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
resource: { displayName: string };
|
|
project: { name: string; shortCode: string };
|
|
}>;
|
|
updateAssignment: (params: {
|
|
id: string;
|
|
data: z.input<typeof UpdateAssignmentSchema>;
|
|
}) => Promise<unknown>;
|
|
};
|
|
createTimelineCaller: (ctx: TRPCContext) => {
|
|
getBudgetStatus: (params: { projectId: string }) => Promise<{
|
|
projectName: string;
|
|
projectCode: string;
|
|
budgetCents: number;
|
|
confirmedCents: number;
|
|
proposedCents: number;
|
|
allocatedCents: number;
|
|
remainingCents: number;
|
|
utilizationPercent: number;
|
|
winProbabilityWeightedCents: number;
|
|
totalAllocations: number;
|
|
}>;
|
|
};
|
|
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
|
|
resolveProjectIdentifier: (
|
|
ctx: ToolContext,
|
|
identifier: string,
|
|
) => Promise<ResolvedProject | AssistantToolErrorResult>;
|
|
resolveResourceIdentifier: (
|
|
ctx: ToolContext,
|
|
identifier: string,
|
|
) => Promise<ResolvedResource | AssistantToolErrorResult>;
|
|
parseIsoDate: (value: string, fieldName: string) => Date;
|
|
parseOptionalIsoDate: (value: string | undefined, fieldName: string) => Date | undefined;
|
|
fmtDate: (value: Date | null | undefined) => string | null;
|
|
toAssistantAllocationNotFoundError: (error: unknown) => unknown;
|
|
};
|
|
|
|
export const allocationPlanningReadToolDefinitions: ToolDef[] = withToolAccess(
|
|
[
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "list_allocations",
|
|
description:
|
|
"List assignments/allocations, optionally filtered by resource or project. Shows who is assigned where, hours/day, dates, and cost.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
resourceId: { type: "string", description: "Filter by resource ID" },
|
|
projectId: { type: "string", description: "Filter by project ID" },
|
|
resourceName: {
|
|
type: "string",
|
|
description: "Filter by resource name (partial match)",
|
|
},
|
|
projectCode: {
|
|
type: "string",
|
|
description: "Filter by project short code (partial match)",
|
|
},
|
|
status: {
|
|
type: "string",
|
|
description: "Filter by status: PROPOSED, CONFIRMED, ACTIVE, COMPLETED, CANCELLED",
|
|
},
|
|
limit: { type: "integer", description: "Max results. Default: 30" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_budget_status",
|
|
description:
|
|
"Get the budget status of a project: total budget, confirmed/proposed costs, remaining, utilization percentage.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
projectId: { type: "string", description: "Project ID or short code" },
|
|
},
|
|
required: ["projectId"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
{
|
|
list_allocations: {
|
|
requiresPlanningRead: true,
|
|
},
|
|
get_budget_status: {
|
|
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
|
requiresCostView: true,
|
|
},
|
|
},
|
|
);
|
|
|
|
export const allocationPlanningMutationToolDefinitions: ToolDef[] = withToolAccess(
|
|
[
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "create_allocation",
|
|
description:
|
|
"Create a new allocation/booking for a resource on a project. Requires manageAllocations permission. Always confirm with the user before calling this. Created with PROPOSED status.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
resourceId: { type: "string", description: "Resource ID" },
|
|
projectId: { type: "string", description: "Project ID" },
|
|
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
|
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
|
hoursPerDay: { type: "number", description: "Hours per day (e.g. 8)" },
|
|
role: { type: "string", description: "Optional role name" },
|
|
},
|
|
required: ["resourceId", "projectId", "startDate", "endDate", "hoursPerDay"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "cancel_allocation",
|
|
description:
|
|
"Cancel an existing allocation. Can find by allocation ID, or by resource name + project code + date range. Requires manageAllocations permission. Always confirm with the user before calling this.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
allocationId: { type: "string", description: "Allocation ID (if known)" },
|
|
resourceName: { type: "string", description: "Resource name (partial match)" },
|
|
projectCode: { type: "string", description: "Project short code (partial match)" },
|
|
startDate: { type: "string", description: "Filter by start date YYYY-MM-DD" },
|
|
endDate: { type: "string", description: "Filter by end date YYYY-MM-DD" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "update_allocation_status",
|
|
description:
|
|
"Change the status of an existing allocation. Can reactivate cancelled allocations, confirm proposed ones, etc. Requires manageAllocations permission. Always confirm with the user before calling.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
allocationId: { type: "string", description: "Allocation ID" },
|
|
resourceName: {
|
|
type: "string",
|
|
description: "Resource name (partial match, used if no allocationId)",
|
|
},
|
|
projectCode: {
|
|
type: "string",
|
|
description: "Project short code (partial match, used if no allocationId)",
|
|
},
|
|
startDate: {
|
|
type: "string",
|
|
description: "Start date filter YYYY-MM-DD (used if no allocationId)",
|
|
},
|
|
newStatus: {
|
|
type: "string",
|
|
description: "New status: PROPOSED, CONFIRMED, ACTIVE, COMPLETED, CANCELLED",
|
|
},
|
|
},
|
|
required: ["newStatus"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
{
|
|
create_allocation: {
|
|
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
|
|
},
|
|
cancel_allocation: {
|
|
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
|
|
},
|
|
update_allocation_status: {
|
|
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
|
|
},
|
|
},
|
|
);
|
|
|
|
export function createAllocationPlanningExecutors(
|
|
deps: AllocationPlanningDeps,
|
|
): Record<string, ToolExecutor> {
|
|
return {
|
|
async list_allocations(
|
|
params: {
|
|
resourceId?: string;
|
|
projectId?: string;
|
|
resourceName?: string;
|
|
projectCode?: string;
|
|
status?: string;
|
|
limit?: number;
|
|
},
|
|
ctx: ToolContext,
|
|
) {
|
|
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
|
|
const status =
|
|
params.status && Object.values(AllocationStatus).includes(params.status as AllocationStatus)
|
|
? (params.status as AllocationStatus)
|
|
: undefined;
|
|
const readModel = await caller.listView({
|
|
...(params.resourceId ? { resourceId: params.resourceId } : {}),
|
|
...(params.projectId ? { projectId: params.projectId } : {}),
|
|
...(status ? { status } : {}),
|
|
});
|
|
|
|
const resourceNameQuery = params.resourceName?.trim().toLowerCase();
|
|
const projectCodeQuery = params.projectCode?.trim().toLowerCase();
|
|
const limit = Math.min(params.limit ?? 30, 50);
|
|
|
|
return readModel.assignments
|
|
.filter((assignment) => {
|
|
if (
|
|
resourceNameQuery &&
|
|
!assignment.resource?.displayName?.toLowerCase().includes(resourceNameQuery)
|
|
) {
|
|
return false;
|
|
}
|
|
if (
|
|
projectCodeQuery &&
|
|
!assignment.project?.shortCode?.toLowerCase().includes(projectCodeQuery)
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
})
|
|
.slice(0, limit)
|
|
.map((assignment) => ({
|
|
id: assignment.id,
|
|
resource: assignment.resource?.displayName ?? "Unknown",
|
|
resourceEid: assignment.resource?.eid ?? null,
|
|
project: assignment.project?.name ?? "Unknown",
|
|
projectCode: assignment.project?.shortCode ?? null,
|
|
role: assignment.role ?? assignment.roleEntity?.name ?? null,
|
|
status: assignment.status,
|
|
hoursPerDay: assignment.hoursPerDay,
|
|
dailyCost: assignment.dailyCostCents == null ? null : fmtEur(assignment.dailyCostCents),
|
|
start: deps.fmtDate(new Date(assignment.startDate)),
|
|
end: deps.fmtDate(new Date(assignment.endDate)),
|
|
}));
|
|
},
|
|
|
|
async get_budget_status(params: { projectId: string }, ctx: ToolContext) {
|
|
const project = await deps.resolveProjectIdentifier(ctx, params.projectId);
|
|
if ("error" in project) {
|
|
return project;
|
|
}
|
|
|
|
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
|
|
const budgetStatus = await caller.getBudgetStatus({ projectId: project.id });
|
|
|
|
if (budgetStatus.budgetCents <= 0) {
|
|
return {
|
|
project: budgetStatus.projectName,
|
|
code: budgetStatus.projectCode,
|
|
budget: "Not set",
|
|
note: "No budget defined for this project",
|
|
totalAllocations: budgetStatus.totalAllocations,
|
|
};
|
|
}
|
|
|
|
return {
|
|
project: budgetStatus.projectName,
|
|
code: budgetStatus.projectCode,
|
|
budget: fmtEur(budgetStatus.budgetCents),
|
|
confirmed: fmtEur(budgetStatus.confirmedCents),
|
|
proposed: fmtEur(budgetStatus.proposedCents),
|
|
allocated: fmtEur(budgetStatus.allocatedCents),
|
|
remaining: fmtEur(budgetStatus.remainingCents),
|
|
utilization: `${budgetStatus.utilizationPercent.toFixed(1)}%`,
|
|
winWeighted: fmtEur(budgetStatus.winProbabilityWeightedCents),
|
|
};
|
|
},
|
|
|
|
async create_allocation(
|
|
params: {
|
|
resourceId: string;
|
|
projectId: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
hoursPerDay: number;
|
|
role?: string;
|
|
},
|
|
ctx: ToolContext,
|
|
) {
|
|
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
|
const [resource, project] = await Promise.all([
|
|
deps.resolveResourceIdentifier(ctx, params.resourceId),
|
|
deps.resolveProjectIdentifier(ctx, params.projectId),
|
|
]);
|
|
if ("error" in resource) {
|
|
return resource;
|
|
}
|
|
if ("error" in project) {
|
|
return project;
|
|
}
|
|
|
|
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
|
|
try {
|
|
const result = await caller.ensureAssignment({
|
|
resourceId: resource.id,
|
|
projectId: project.id,
|
|
startDate: deps.parseIsoDate(params.startDate, "startDate"),
|
|
endDate: deps.parseIsoDate(params.endDate, "endDate"),
|
|
hoursPerDay: params.hoursPerDay,
|
|
...(params.role ? { role: params.role } : {}),
|
|
});
|
|
|
|
return {
|
|
__action: "invalidate",
|
|
scope: ["allocation", "timeline"],
|
|
success: true,
|
|
message: `${result.action === "reactivated" ? "Reactivated" : "Created"} allocation: ${resource.displayName} → ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`,
|
|
allocationId: result.assignment.id,
|
|
status: result.assignment.status,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
|
return {
|
|
error:
|
|
"Allocation already exists for this resource/project/dates. No new allocation created.",
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async cancel_allocation(
|
|
params: {
|
|
allocationId?: string;
|
|
resourceName?: string;
|
|
projectCode?: string;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
},
|
|
ctx: ToolContext,
|
|
) {
|
|
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
|
|
|
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
|
|
let resourceId: string | undefined;
|
|
let projectId: string | undefined;
|
|
if (!params.allocationId && params.resourceName && params.projectCode) {
|
|
const [resource, project] = await Promise.all([
|
|
deps.resolveResourceIdentifier(ctx, params.resourceName),
|
|
deps.resolveProjectIdentifier(ctx, params.projectCode),
|
|
]);
|
|
if ("error" in resource) {
|
|
return resource;
|
|
}
|
|
if ("error" in project) {
|
|
return project;
|
|
}
|
|
resourceId = resource.id;
|
|
projectId = project.id;
|
|
}
|
|
|
|
const startDate = deps.parseOptionalIsoDate(params.startDate, "startDate");
|
|
const endDate = deps.parseOptionalIsoDate(params.endDate, "endDate");
|
|
let assignment;
|
|
try {
|
|
assignment = await caller.resolveAssignment({
|
|
...(params.allocationId ? { assignmentId: params.allocationId } : {}),
|
|
...(resourceId ? { resourceId } : {}),
|
|
...(projectId ? { projectId } : {}),
|
|
...(startDate ? { startDate } : {}),
|
|
...(endDate ? { endDate } : {}),
|
|
selectionMode: "WINDOW",
|
|
excludeCancelled: true,
|
|
});
|
|
} catch (error) {
|
|
const mapped = deps.toAssistantAllocationNotFoundError(error);
|
|
if (mapped) {
|
|
return mapped;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
await caller.updateAssignment({
|
|
id: assignment.id,
|
|
data: UpdateAssignmentSchema.parse({ status: AllocationStatus.CANCELLED }),
|
|
});
|
|
} catch (error) {
|
|
const mapped = deps.toAssistantAllocationNotFoundError(error);
|
|
if (mapped) {
|
|
return mapped;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
__action: "invalidate",
|
|
scope: ["allocation", "timeline"],
|
|
success: true,
|
|
message: `Cancelled allocation: ${assignment.resource.displayName} → ${assignment.project.name} (${assignment.project.shortCode}), ${deps.fmtDate(assignment.startDate)} to ${deps.fmtDate(assignment.endDate)}`,
|
|
};
|
|
},
|
|
|
|
async update_allocation_status(
|
|
params: {
|
|
allocationId?: string;
|
|
resourceName?: string;
|
|
projectCode?: string;
|
|
startDate?: string;
|
|
newStatus: string;
|
|
},
|
|
ctx: ToolContext,
|
|
) {
|
|
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
|
|
|
const validStatuses = ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"];
|
|
if (!validStatuses.includes(params.newStatus)) {
|
|
return { error: `Invalid status: ${params.newStatus}. Valid: ${validStatuses.join(", ")}` };
|
|
}
|
|
|
|
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
|
|
let resourceId: string | undefined;
|
|
let projectId: string | undefined;
|
|
if (!params.allocationId && params.resourceName && params.projectCode) {
|
|
const [resource, project] = await Promise.all([
|
|
deps.resolveResourceIdentifier(ctx, params.resourceName),
|
|
deps.resolveProjectIdentifier(ctx, params.projectCode),
|
|
]);
|
|
if ("error" in resource) {
|
|
return resource;
|
|
}
|
|
if ("error" in project) {
|
|
return project;
|
|
}
|
|
resourceId = resource.id;
|
|
projectId = project.id;
|
|
}
|
|
|
|
const startDate = deps.parseOptionalIsoDate(params.startDate, "startDate");
|
|
let assignment;
|
|
try {
|
|
assignment = await caller.resolveAssignment({
|
|
...(params.allocationId ? { assignmentId: params.allocationId } : {}),
|
|
...(resourceId ? { resourceId } : {}),
|
|
...(projectId ? { projectId } : {}),
|
|
...(startDate ? { startDate } : {}),
|
|
selectionMode: "EXACT_START",
|
|
});
|
|
} catch (error) {
|
|
const mapped = deps.toAssistantAllocationNotFoundError(error);
|
|
if (mapped) {
|
|
return mapped;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const oldStatus = assignment.status;
|
|
try {
|
|
await caller.updateAssignment({
|
|
id: assignment.id,
|
|
data: UpdateAssignmentSchema.parse({
|
|
status: params.newStatus as AllocationStatus,
|
|
}),
|
|
});
|
|
} catch (error) {
|
|
const mapped = deps.toAssistantAllocationNotFoundError(error);
|
|
if (mapped) {
|
|
return mapped;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
__action: "invalidate",
|
|
scope: ["allocation", "timeline"],
|
|
success: true,
|
|
message: `Updated allocation status: ${assignment.resource.displayName} → ${assignment.project.name} (${assignment.project.shortCode}), ${deps.fmtDate(assignment.startDate)} to ${deps.fmtDate(assignment.endDate)}: ${oldStatus} → ${params.newStatus}`,
|
|
};
|
|
},
|
|
};
|
|
}
|