refactor(api): extract assistant project slice
This commit is contained in:
@@ -6,7 +6,6 @@
|
||||
import { Prisma, ImportBatchStatus, StagedRecordStatus, DispoStagedRecordType, VacationType } from "@capakraken/db";
|
||||
import {
|
||||
CreateAssignmentSchema,
|
||||
CreateProjectSchema,
|
||||
CreateResourceSchema,
|
||||
AllocationStatus,
|
||||
EstimateStatus,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
COMMENT_ENTITY_TYPE_VALUES,
|
||||
PermissionKey,
|
||||
SystemRole,
|
||||
UpdateProjectSchema,
|
||||
UpdateResourceSchema,
|
||||
} from "@capakraken/shared";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
@@ -110,7 +108,18 @@ import {
|
||||
estimateMutationToolDefinitions,
|
||||
estimateReadToolDefinitions,
|
||||
} from "./assistant-tools/estimates.js";
|
||||
import type { ToolContext, ToolDef, ToolExecutor } from "./assistant-tools/shared.js";
|
||||
import {
|
||||
createProjectExecutors,
|
||||
projectMutationToolDefinitions,
|
||||
projectReadToolDefinitions,
|
||||
} from "./assistant-tools/projects.js";
|
||||
import {
|
||||
withToolAccess,
|
||||
type ToolAccessRequirements,
|
||||
type ToolContext,
|
||||
type ToolDef,
|
||||
type ToolExecutor,
|
||||
} from "./assistant-tools/shared.js";
|
||||
import { getCommentToolEntityDescription, getCommentToolScopeSentence } from "../lib/comment-entity-registry.js";
|
||||
|
||||
export type { ToolContext } from "./assistant-tools/shared.js";
|
||||
@@ -366,6 +375,90 @@ function resolveHolidayPeriod(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const CONTROLLER_ASSISTANT_ROLES = [
|
||||
SystemRole.ADMIN,
|
||||
SystemRole.MANAGER,
|
||||
SystemRole.CONTROLLER,
|
||||
] as const;
|
||||
|
||||
const MANAGER_ASSISTANT_ROLES = [
|
||||
SystemRole.ADMIN,
|
||||
SystemRole.MANAGER,
|
||||
] as const;
|
||||
|
||||
const ADMIN_ASSISTANT_ROLES = [SystemRole.ADMIN] as const;
|
||||
|
||||
const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial<Record<string, ToolAccessRequirements>> = {
|
||||
search_resources: { requiresResourceOverview: true },
|
||||
search_projects: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
get_project: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
list_clients: { requiresPlanningRead: true },
|
||||
list_org_units: { requiresResourceOverview: true },
|
||||
update_resource: { requiredPermissions: [PermissionKey.MANAGE_RESOURCES] },
|
||||
update_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
|
||||
create_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
|
||||
create_resource: { requiredPermissions: [PermissionKey.MANAGE_RESOURCES] },
|
||||
deactivate_resource: { requiredPermissions: [PermissionKey.MANAGE_RESOURCES] },
|
||||
approve_vacation: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
||||
reject_vacation: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
||||
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: {
|
||||
allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES],
|
||||
requiresCostView: true,
|
||||
},
|
||||
resolve_rate: {
|
||||
allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES],
|
||||
requiresCostView: true,
|
||||
},
|
||||
get_country: { requiresResourceOverview: true },
|
||||
get_dashboard_detail: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
delete_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
|
||||
generate_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
|
||||
remove_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
|
||||
detect_anomalies: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
get_skill_gaps: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
get_project_health: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
get_budget_forecast: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
get_insights_summary: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
run_report: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
lookup_rate: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
simulate_scenario: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
generate_project_narrative: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
query_change_history: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
get_entity_timeline: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
export_resources_csv: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
export_projects_csv: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||
import_csv_data: {
|
||||
requiredPermissions: [PermissionKey.IMPORT_DATA],
|
||||
allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES],
|
||||
},
|
||||
list_dispo_import_batches: { allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES] },
|
||||
get_dispo_import_batch: { allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES] },
|
||||
stage_dispo_import_batch: { allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES] },
|
||||
validate_dispo_import_batch: { allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES] },
|
||||
cancel_dispo_import_batch: { allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES] },
|
||||
list_dispo_staged_resources: { allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES] },
|
||||
list_dispo_staged_projects: { allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES] },
|
||||
list_dispo_staged_assignments: { allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES] },
|
||||
list_dispo_staged_vacations: { allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES] },
|
||||
list_dispo_staged_unresolved_records: { allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES] },
|
||||
resolve_dispo_staged_record: { allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES] },
|
||||
commit_dispo_import_batch: { allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES] },
|
||||
};
|
||||
|
||||
const ASSISTANT_VACATION_REQUEST_TYPES = [
|
||||
VacationType.ANNUAL,
|
||||
VacationType.SICK,
|
||||
@@ -1925,7 +2018,7 @@ function sanitizeWebhookList<T extends { secret?: string | null }>(webhooks: T[]
|
||||
|
||||
// ─── Tool Definitions ───────────────────────────────────────────────────────
|
||||
|
||||
export const TOOL_DEFINITIONS: ToolDef[] = [
|
||||
export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
|
||||
// ── READ TOOLS ──
|
||||
{
|
||||
type: "function",
|
||||
@@ -1960,35 +2053,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_projects",
|
||||
description: "Search for projects by name, short code, status, or client.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search term (matches name, shortCode)" },
|
||||
status: { type: "string", description: "Filter by status: DRAFT, ACTIVE, ON_HOLD, COMPLETED, CANCELLED" },
|
||||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_project",
|
||||
description: "Get detailed information about a single project by ID or short code, including top allocations.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
identifier: { type: "string", description: "Project ID or short code (e.g. Z033T593)" },
|
||||
},
|
||||
required: ["identifier"],
|
||||
},
|
||||
},
|
||||
},
|
||||
...projectReadToolDefinitions,
|
||||
...advancedTimelineToolDefinitions,
|
||||
...allocationPlanningReadToolDefinitions,
|
||||
...vacationHolidayReadToolDefinitions,
|
||||
@@ -2128,51 +2193,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_project",
|
||||
description: "Update a project's details. Requires manageProjects permission. Always confirm with the user before calling this.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Project ID, short code, or project name" },
|
||||
name: { type: "string", description: "New project name" },
|
||||
budgetCents: { type: "integer", description: "New budget in cents (e.g. 10000000 = 100,000 EUR)" },
|
||||
winProbability: { type: "integer", description: "Win probability 0-100" },
|
||||
status: { type: "string", description: "New status: DRAFT, ACTIVE, ON_HOLD, COMPLETED, CANCELLED" },
|
||||
responsiblePerson: { type: "string", description: "Name of the responsible person. Must match an existing resource's display name (case-insensitive search)." },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_project",
|
||||
description: "Create a new project. Requires manageProjects permission. Always confirm with the user before calling this. The project is created in DRAFT status by default.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
shortCode: { type: "string", description: "Unique project code, uppercase alphanumeric with hyphens/underscores (e.g. 'PROJ-001')" },
|
||||
name: { type: "string", description: "Project name" },
|
||||
orderType: { type: "string", description: "Order type: BD, CHARGEABLE, INTERNAL, OVERHEAD" },
|
||||
allocationType: { type: "string", description: "Allocation type: INT or EXT. Default: INT" },
|
||||
budgetCents: { type: "integer", description: "Budget in cents (e.g. 10000000 = 100,000 EUR)" },
|
||||
startDate: { type: "string", description: "Start date (YYYY-MM-DD)" },
|
||||
endDate: { type: "string", description: "End date (YYYY-MM-DD)" },
|
||||
winProbability: { type: "integer", description: "Win probability 0-100. Default: 100" },
|
||||
status: { type: "string", description: "Initial status: DRAFT, ACTIVE, ON_HOLD. Default: DRAFT" },
|
||||
responsiblePerson: { type: "string", description: "Name of the responsible person. Must match an existing resource's display name (case-insensitive search)." },
|
||||
color: { type: "string", description: "Hex color (e.g. '#3b82f6')" },
|
||||
blueprintName: { type: "string", description: "Blueprint name to look up and attach (partial match)" },
|
||||
clientName: { type: "string", description: "Client name to look up and attach (partial match)" },
|
||||
},
|
||||
required: ["shortCode", "name", "orderType", "budgetCents", "startDate", "endDate", "responsiblePerson"],
|
||||
},
|
||||
},
|
||||
},
|
||||
...projectMutationToolDefinitions,
|
||||
|
||||
// ── RESOURCE MANAGEMENT ──
|
||||
{
|
||||
@@ -2567,56 +2588,9 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// ── PROJECT MANAGEMENT ──
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_project",
|
||||
description: "Delete a project. Only DRAFT projects can be deleted. Requires manageProjects permission. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "Project ID or short code" },
|
||||
},
|
||||
required: ["projectId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── ORG UNIT MANAGEMENT ──
|
||||
...orgUnitMutationToolDefinitions,
|
||||
|
||||
// ── COVER ART ──
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "generate_project_cover",
|
||||
description: "Generate an AI cover art image for a project. Uses the configured image provider (DALL-E or Google Gemini). The image will be stored as the project's cover. Requires manageProjects permission.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "Project ID" },
|
||||
prompt: { type: "string", description: "Optional custom prompt for the AI image generation (e.g. 'futuristic car in neon cityscape'). If not provided, a default automotive/CGI prompt is used based on the project name." },
|
||||
},
|
||||
required: ["projectId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "remove_project_cover",
|
||||
description: "Remove the cover art image from a project. Requires manageProjects permission.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "Project ID" },
|
||||
},
|
||||
required: ["projectId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── TASK MANAGEMENT ──
|
||||
...notificationTaskToolDefinitions,
|
||||
|
||||
@@ -3108,7 +3082,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
...settingsAdminToolDefinitions,
|
||||
];
|
||||
], LEGACY_MONOLITHIC_TOOL_ACCESS);
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3156,32 +3130,20 @@ const executors = {
|
||||
return caller.getByIdentifierDetail({ identifier: params.identifier });
|
||||
},
|
||||
|
||||
async search_projects(params: { query?: string; status?: string; limit?: number }, ctx: ToolContext) {
|
||||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||||
return caller.searchSummariesDetail({
|
||||
search: params.query,
|
||||
status: params.status as import("@capakraken/shared").ProjectStatus | undefined,
|
||||
limit: Math.min(params.limit ?? 20, 50),
|
||||
});
|
||||
},
|
||||
|
||||
async get_project(params: { identifier: string }, ctx: ToolContext) {
|
||||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||||
let project;
|
||||
try {
|
||||
project = await caller.getByIdentifierDetail({ identifier: params.identifier });
|
||||
} catch (error) {
|
||||
const mapped = toAssistantNotFoundError(
|
||||
error,
|
||||
`Project not found: ${params.identifier}`,
|
||||
);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return project;
|
||||
},
|
||||
...createProjectExecutors({
|
||||
assertPermission,
|
||||
createProjectCaller,
|
||||
createBlueprintCaller,
|
||||
createClientCaller,
|
||||
createScopedCallerContext,
|
||||
resolveProjectIdentifier,
|
||||
resolveResponsiblePerson,
|
||||
resolveEntityOrAssistantError,
|
||||
toAssistantNotFoundError,
|
||||
toAssistantProjectMutationError,
|
||||
toAssistantProjectCreationError,
|
||||
toAssistantProjectNotFoundError,
|
||||
}),
|
||||
|
||||
...createAdvancedTimelineExecutors({
|
||||
assertPermission,
|
||||
@@ -3433,136 +3395,6 @@ const executors = {
|
||||
};
|
||||
},
|
||||
|
||||
async update_project(params: {
|
||||
id: string; name?: string; budgetCents?: number;
|
||||
winProbability?: number; status?: string;
|
||||
responsiblePerson?: string;
|
||||
}, ctx: ToolContext) {
|
||||
assertPermission(ctx, "manageProjects" as PermissionKey);
|
||||
const project = await resolveProjectIdentifier(ctx, params.id);
|
||||
if ("error" in project) return project;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (params.name !== undefined) data.name = params.name;
|
||||
if (params.budgetCents !== undefined) data.budgetCents = params.budgetCents;
|
||||
if (params.winProbability !== undefined) data.winProbability = params.winProbability;
|
||||
if (params.status !== undefined) data.status = params.status;
|
||||
|
||||
// Validate responsible person against existing resources
|
||||
if (params.responsiblePerson !== undefined) {
|
||||
const result = await resolveResponsiblePerson(params.responsiblePerson, ctx);
|
||||
if ("error" in result) return { error: result.error };
|
||||
data.responsiblePerson = result.displayName;
|
||||
}
|
||||
|
||||
const parsedData = UpdateProjectSchema.parse(data);
|
||||
const updatedFields = Object.keys(parsedData);
|
||||
if (updatedFields.length === 0) return { error: "No fields to update" };
|
||||
|
||||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||||
let updated;
|
||||
try {
|
||||
updated = await caller.update({ id: project.id, data: parsedData });
|
||||
} catch (error) {
|
||||
const mapped = toAssistantProjectMutationError(error);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["project"],
|
||||
success: true,
|
||||
message: `Updated project ${updated.name} (${updated.shortCode})`,
|
||||
updatedFields,
|
||||
};
|
||||
},
|
||||
|
||||
async create_project(params: {
|
||||
shortCode: string; name: string; orderType: string;
|
||||
allocationType?: string; budgetCents: number;
|
||||
startDate: string; endDate: string;
|
||||
winProbability?: number; status?: string;
|
||||
responsiblePerson?: string; color?: string;
|
||||
blueprintName?: string; clientName?: string;
|
||||
}, ctx: ToolContext) {
|
||||
assertPermission(ctx, "manageProjects" as PermissionKey);
|
||||
if (!params.responsiblePerson?.trim()) {
|
||||
return { error: "responsiblePerson is required to create a project." };
|
||||
}
|
||||
|
||||
// Validate responsible person against existing resources
|
||||
const responsible = await resolveResponsiblePerson(params.responsiblePerson, ctx);
|
||||
if ("error" in responsible) return { error: responsible.error };
|
||||
|
||||
const blueprintCaller = createBlueprintCaller(createScopedCallerContext(ctx));
|
||||
const clientCaller = createClientCaller(createScopedCallerContext(ctx));
|
||||
|
||||
let blueprintId: string | undefined;
|
||||
if (params.blueprintName) {
|
||||
const blueprint = await resolveEntityOrAssistantError(
|
||||
() => blueprintCaller.resolveByIdentifier({ identifier: params.blueprintName! }),
|
||||
`Blueprint not found: "${params.blueprintName}"`,
|
||||
);
|
||||
if ("error" in blueprint) {
|
||||
return blueprint;
|
||||
}
|
||||
blueprintId = blueprint.id;
|
||||
}
|
||||
|
||||
let clientId: string | undefined;
|
||||
if (params.clientName) {
|
||||
const client = await resolveEntityOrAssistantError(
|
||||
() => clientCaller.resolveByIdentifier({ identifier: params.clientName! }),
|
||||
`Client not found: "${params.clientName}"`,
|
||||
);
|
||||
if ("error" in client) {
|
||||
return client;
|
||||
}
|
||||
clientId = client.id;
|
||||
}
|
||||
|
||||
const input = CreateProjectSchema.parse({
|
||||
shortCode: params.shortCode,
|
||||
name: params.name,
|
||||
orderType: params.orderType,
|
||||
allocationType: params.allocationType ?? "INT",
|
||||
budgetCents: params.budgetCents,
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
winProbability: params.winProbability ?? 100,
|
||||
status: params.status ?? "DRAFT",
|
||||
responsiblePerson: responsible.displayName,
|
||||
...(params.color ? { color: params.color } : {}),
|
||||
...(blueprintId ? { blueprintId } : {}),
|
||||
...(clientId ? { clientId } : {}),
|
||||
staffingReqs: [],
|
||||
dynamicFields: {},
|
||||
});
|
||||
|
||||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||||
let project;
|
||||
try {
|
||||
project = await caller.create(input);
|
||||
} catch (error) {
|
||||
const mapped = toAssistantProjectCreationError(error, input.shortCode);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["project"],
|
||||
success: true,
|
||||
message: `Created project: ${project.name} (${project.shortCode}), budget ${fmtEur(params.budgetCents)}, ${params.startDate} to ${params.endDate}, status: ${project.status}`,
|
||||
projectId: project.id,
|
||||
shortCode: project.shortCode,
|
||||
};
|
||||
},
|
||||
|
||||
// ── RESOURCE MANAGEMENT ──
|
||||
|
||||
async create_resource(params: {
|
||||
@@ -4235,66 +4067,7 @@ const executors = {
|
||||
return caller.getDetail({ ...(params.section ? { section: params.section } : {}) });
|
||||
},
|
||||
|
||||
// ── PROJECT MANAGEMENT ──
|
||||
|
||||
async delete_project(params: { projectId: string }, ctx: ToolContext) {
|
||||
assertPermission(ctx, "manageProjects" as PermissionKey);
|
||||
const project = await resolveProjectIdentifier(ctx, params.projectId);
|
||||
if ("error" in project) return project;
|
||||
|
||||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||||
try {
|
||||
await caller.delete({ id: project.id });
|
||||
} catch (error) {
|
||||
const mapped = toAssistantProjectNotFoundError(error, params.projectId);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["project"],
|
||||
success: true,
|
||||
message: `Deleted project: ${project.name} (${project.shortCode})`,
|
||||
};
|
||||
},
|
||||
|
||||
// ── ORG UNIT MANAGEMENT ──
|
||||
// ─── Cover Art ───────────────────────────────────────────────────────────
|
||||
|
||||
async generate_project_cover(params: { projectId: string; prompt?: string }, ctx: ToolContext) {
|
||||
assertPermission(ctx, "manageProjects" as PermissionKey);
|
||||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||||
const project = await resolveProjectIdentifier(ctx, params.projectId);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
const { coverImageUrl } = await caller.generateCover({
|
||||
projectId: project.id,
|
||||
...(params.prompt !== undefined ? { prompt: params.prompt } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["project"],
|
||||
success: true,
|
||||
message: `Generated cover art for project "${project.name}"`,
|
||||
coverImageUrl: coverImageUrl.slice(0, 100) + "...[truncated]",
|
||||
};
|
||||
},
|
||||
|
||||
async remove_project_cover(params: { projectId: string }, ctx: ToolContext) {
|
||||
assertPermission(ctx, "manageProjects" as PermissionKey);
|
||||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||||
const project = await resolveProjectIdentifier(ctx, params.projectId);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
await caller.removeCover({ projectId: project.id });
|
||||
|
||||
return { __action: "invalidate", scope: ["project"], success: true, message: `Removed cover art from project "${project.name}"` };
|
||||
},
|
||||
// ── INSIGHTS & ANOMALIES ──────────────────────────────────────────────────
|
||||
|
||||
async detect_anomalies(_params: Record<string, never>, ctx: ToolContext) {
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
import { CreateProjectSchema, PermissionKey, type ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
|
||||
import { fmtEur } from "../../lib/format-utils.js";
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
|
||||
|
||||
type AssistantToolErrorResult = { error: string };
|
||||
|
||||
type ResolvedProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
};
|
||||
|
||||
type ResolvedReference = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type ProjectSummaryRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
};
|
||||
|
||||
type ProjectRecord = ProjectSummaryRecord & {
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type ParsedCreateProjectInput = ReturnType<typeof CreateProjectSchema.parse>;
|
||||
type ParsedUpdateProjectInput = ReturnType<typeof UpdateProjectSchema.parse>;
|
||||
|
||||
type ResponsiblePersonResolution =
|
||||
| {
|
||||
status: "resolved";
|
||||
displayName: string;
|
||||
}
|
||||
| {
|
||||
status: "ambiguous" | "missing";
|
||||
message: string;
|
||||
};
|
||||
|
||||
type ProjectToolsDeps = {
|
||||
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
|
||||
createProjectCaller: (ctx: TRPCContext) => {
|
||||
searchSummariesDetail: (params: {
|
||||
search?: string | undefined;
|
||||
status?: ProjectStatus | undefined;
|
||||
limit: number;
|
||||
}) => Promise<unknown>;
|
||||
getByIdentifierDetail: (params: { identifier: string }) => Promise<unknown>;
|
||||
update: (params: {
|
||||
id: string;
|
||||
data: ParsedUpdateProjectInput;
|
||||
}) => Promise<ProjectSummaryRecord>;
|
||||
create: (params: ParsedCreateProjectInput) => Promise<ProjectRecord>;
|
||||
delete: (params: { id: string }) => Promise<unknown>;
|
||||
generateCover: (params: {
|
||||
projectId: string;
|
||||
prompt?: string;
|
||||
}) => Promise<{ coverImageUrl: string }>;
|
||||
removeCover: (params: { projectId: string }) => Promise<unknown>;
|
||||
};
|
||||
createBlueprintCaller: (ctx: TRPCContext) => {
|
||||
resolveByIdentifier: (params: {
|
||||
identifier: string;
|
||||
}) => Promise<ResolvedReference>;
|
||||
};
|
||||
createClientCaller: (ctx: TRPCContext) => {
|
||||
resolveByIdentifier: (params: {
|
||||
identifier: string;
|
||||
}) => Promise<ResolvedReference>;
|
||||
};
|
||||
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
|
||||
resolveProjectIdentifier: (
|
||||
ctx: ToolContext,
|
||||
identifier: string,
|
||||
) => Promise<ResolvedProject | AssistantToolErrorResult>;
|
||||
resolveResponsiblePerson: (
|
||||
name: string,
|
||||
ctx: ToolContext,
|
||||
) => Promise<{ displayName: string } | { error: string }>;
|
||||
resolveEntityOrAssistantError: <T>(
|
||||
resolve: () => Promise<T>,
|
||||
notFoundMessage: string,
|
||||
) => Promise<T | AssistantToolErrorResult>;
|
||||
toAssistantNotFoundError: (
|
||||
error: unknown,
|
||||
message: string,
|
||||
) => AssistantToolErrorResult | null;
|
||||
toAssistantProjectMutationError: (
|
||||
error: unknown,
|
||||
) => AssistantToolErrorResult | null;
|
||||
toAssistantProjectCreationError: (
|
||||
error: unknown,
|
||||
shortCode: string,
|
||||
) => AssistantToolErrorResult | null;
|
||||
toAssistantProjectNotFoundError: (
|
||||
error: unknown,
|
||||
identifier: string,
|
||||
) => AssistantToolErrorResult | null;
|
||||
};
|
||||
|
||||
async function resolveOptionalReferenceId(
|
||||
identifier: string | undefined,
|
||||
notFoundMessage: string,
|
||||
resolveReference: () => Promise<ResolvedReference>,
|
||||
deps: ProjectToolsDeps,
|
||||
): Promise<string | AssistantToolErrorResult | undefined> {
|
||||
if (!identifier) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolved = await deps.resolveEntityOrAssistantError(
|
||||
resolveReference,
|
||||
notFoundMessage,
|
||||
);
|
||||
if ("error" in resolved) {
|
||||
return resolved;
|
||||
}
|
||||
return resolved.id;
|
||||
}
|
||||
|
||||
export const projectReadToolDefinitions: ToolDef[] = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_projects",
|
||||
description: "Search for projects by name, short code, status, or client.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search term (matches name, shortCode)" },
|
||||
status: { type: "string", description: "Filter by status: DRAFT, ACTIVE, ON_HOLD, COMPLETED, CANCELLED" },
|
||||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_project",
|
||||
description: "Get detailed information about a single project by ID or short code, including top allocations.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
identifier: { type: "string", description: "Project ID or short code (e.g. Z033T593)" },
|
||||
},
|
||||
required: ["identifier"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const projectMutationToolDefinitions: ToolDef[] = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_project",
|
||||
description: "Update a project's details. Requires manageProjects permission. Always confirm with the user before calling this.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Project ID, short code, or project name" },
|
||||
name: { type: "string", description: "New project name" },
|
||||
budgetCents: { type: "integer", description: "New budget in cents (e.g. 10000000 = 100,000 EUR)" },
|
||||
winProbability: { type: "integer", description: "Win probability 0-100" },
|
||||
status: { type: "string", description: "New status: DRAFT, ACTIVE, ON_HOLD, COMPLETED, CANCELLED" },
|
||||
responsiblePerson: { type: "string", description: "Name of the responsible person. Must match an existing resource's display name (case-insensitive search)." },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_project",
|
||||
description: "Create a new project. Requires manageProjects permission. Always confirm with the user before calling this. The project is created in DRAFT status by default.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
shortCode: { type: "string", description: "Unique project code, uppercase alphanumeric with hyphens/underscores (e.g. 'PROJ-001')" },
|
||||
name: { type: "string", description: "Project name" },
|
||||
orderType: { type: "string", description: "Order type: BD, CHARGEABLE, INTERNAL, OVERHEAD" },
|
||||
allocationType: { type: "string", description: "Allocation type: INT or EXT. Default: INT" },
|
||||
budgetCents: { type: "integer", description: "Budget in cents (e.g. 10000000 = 100,000 EUR)" },
|
||||
startDate: { type: "string", description: "Start date (YYYY-MM-DD)" },
|
||||
endDate: { type: "string", description: "End date (YYYY-MM-DD)" },
|
||||
winProbability: { type: "integer", description: "Win probability 0-100. Default: 100" },
|
||||
status: { type: "string", description: "Initial status: DRAFT, ACTIVE, ON_HOLD. Default: DRAFT" },
|
||||
responsiblePerson: { type: "string", description: "Name of the responsible person. Must match an existing resource's display name (case-insensitive search)." },
|
||||
color: { type: "string", description: "Hex color (e.g. '#3b82f6')" },
|
||||
blueprintName: { type: "string", description: "Blueprint name to look up and attach (partial match)" },
|
||||
clientName: { type: "string", description: "Client name to look up and attach (partial match)" },
|
||||
},
|
||||
required: ["shortCode", "name", "orderType", "budgetCents", "startDate", "endDate", "responsiblePerson"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_project",
|
||||
description: "Delete a project. Only DRAFT projects can be deleted. Requires manageProjects permission. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "Project ID or short code" },
|
||||
},
|
||||
required: ["projectId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "generate_project_cover",
|
||||
description: "Generate an AI cover art image for a project. Uses the configured image provider (DALL-E or Google Gemini). The image will be stored as the project's cover. Requires manageProjects permission.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "Project ID" },
|
||||
prompt: { type: "string", description: "Optional custom prompt for the AI image generation (e.g. 'futuristic car in neon cityscape'). If not provided, a default automotive/CGI prompt is used based on the project name." },
|
||||
},
|
||||
required: ["projectId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "remove_project_cover",
|
||||
description: "Remove the cover art image from a project. Requires manageProjects permission.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "Project ID" },
|
||||
},
|
||||
required: ["projectId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function createProjectExecutors(
|
||||
deps: ProjectToolsDeps,
|
||||
): Record<string, ToolExecutor> {
|
||||
return {
|
||||
async search_projects(
|
||||
params: { query?: string; status?: string; limit?: number },
|
||||
ctx: ToolContext,
|
||||
) {
|
||||
const caller = deps.createProjectCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.searchSummariesDetail({
|
||||
...(params.query !== undefined ? { search: params.query } : {}),
|
||||
...(params.status !== undefined ? { status: params.status as ProjectStatus } : {}),
|
||||
limit: Math.min(params.limit ?? 20, 50),
|
||||
});
|
||||
},
|
||||
|
||||
async get_project(params: { identifier: string }, ctx: ToolContext) {
|
||||
const caller = deps.createProjectCaller(deps.createScopedCallerContext(ctx));
|
||||
try {
|
||||
return await caller.getByIdentifierDetail({ identifier: params.identifier });
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantNotFoundError(
|
||||
error,
|
||||
`Project not found: ${params.identifier}`,
|
||||
);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async update_project(
|
||||
params: {
|
||||
id: string;
|
||||
name?: string;
|
||||
budgetCents?: number;
|
||||
winProbability?: number;
|
||||
status?: string;
|
||||
responsiblePerson?: string;
|
||||
},
|
||||
ctx: ToolContext,
|
||||
) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
const project = await deps.resolveProjectIdentifier(ctx, params.id);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (params.name !== undefined) data.name = params.name;
|
||||
if (params.budgetCents !== undefined) data.budgetCents = params.budgetCents;
|
||||
if (params.winProbability !== undefined) data.winProbability = params.winProbability;
|
||||
if (params.status !== undefined) data.status = params.status;
|
||||
|
||||
if (params.responsiblePerson !== undefined) {
|
||||
const result = await deps.resolveResponsiblePerson(params.responsiblePerson, ctx);
|
||||
if ("error" in result) {
|
||||
return { error: result.error };
|
||||
}
|
||||
data.responsiblePerson = result.displayName;
|
||||
}
|
||||
|
||||
const parsedData = UpdateProjectSchema.parse(data);
|
||||
const updatedFields = Object.keys(parsedData);
|
||||
if (updatedFields.length === 0) {
|
||||
return { error: "No fields to update" };
|
||||
}
|
||||
|
||||
const caller = deps.createProjectCaller(deps.createScopedCallerContext(ctx));
|
||||
let updated;
|
||||
try {
|
||||
updated = await caller.update({ id: project.id, data: parsedData });
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantProjectMutationError(error);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["project"],
|
||||
success: true,
|
||||
message: `Updated project ${updated.name} (${updated.shortCode})`,
|
||||
updatedFields,
|
||||
};
|
||||
},
|
||||
|
||||
async create_project(
|
||||
params: {
|
||||
shortCode: string;
|
||||
name: string;
|
||||
orderType: string;
|
||||
allocationType?: string;
|
||||
budgetCents: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
winProbability?: number;
|
||||
status?: string;
|
||||
responsiblePerson?: string;
|
||||
color?: string;
|
||||
blueprintName?: string;
|
||||
clientName?: string;
|
||||
},
|
||||
ctx: ToolContext,
|
||||
) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
if (!params.responsiblePerson?.trim()) {
|
||||
return { error: "responsiblePerson is required to create a project." };
|
||||
}
|
||||
|
||||
const responsible = await deps.resolveResponsiblePerson(params.responsiblePerson, ctx);
|
||||
if ("error" in responsible) {
|
||||
return { error: responsible.error };
|
||||
}
|
||||
|
||||
const scopedContext = deps.createScopedCallerContext(ctx);
|
||||
const blueprintCaller = deps.createBlueprintCaller(scopedContext);
|
||||
const clientCaller = deps.createClientCaller(scopedContext);
|
||||
|
||||
const blueprintId = await resolveOptionalReferenceId(
|
||||
params.blueprintName,
|
||||
`Blueprint not found: "${params.blueprintName}"`,
|
||||
() => blueprintCaller.resolveByIdentifier({ identifier: params.blueprintName! }),
|
||||
deps,
|
||||
);
|
||||
if (blueprintId && typeof blueprintId !== "string") {
|
||||
return blueprintId;
|
||||
}
|
||||
|
||||
const clientId = await resolveOptionalReferenceId(
|
||||
params.clientName,
|
||||
`Client not found: "${params.clientName}"`,
|
||||
() => clientCaller.resolveByIdentifier({ identifier: params.clientName! }),
|
||||
deps,
|
||||
);
|
||||
if (clientId && typeof clientId !== "string") {
|
||||
return clientId;
|
||||
}
|
||||
|
||||
const input = CreateProjectSchema.parse({
|
||||
shortCode: params.shortCode,
|
||||
name: params.name,
|
||||
orderType: params.orderType,
|
||||
allocationType: params.allocationType ?? "INT",
|
||||
budgetCents: params.budgetCents,
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
winProbability: params.winProbability ?? 100,
|
||||
status: params.status ?? "DRAFT",
|
||||
responsiblePerson: responsible.displayName,
|
||||
...(params.color ? { color: params.color } : {}),
|
||||
...(typeof blueprintId === "string" ? { blueprintId } : {}),
|
||||
...(typeof clientId === "string" ? { clientId } : {}),
|
||||
staffingReqs: [],
|
||||
dynamicFields: {},
|
||||
});
|
||||
|
||||
const caller = deps.createProjectCaller(scopedContext);
|
||||
let project;
|
||||
try {
|
||||
project = await caller.create(input);
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantProjectCreationError(error, input.shortCode);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["project"],
|
||||
success: true,
|
||||
message: `Created project: ${project.name} (${project.shortCode}), budget ${fmtEur(params.budgetCents)}, ${params.startDate} to ${params.endDate}, status: ${project.status}`,
|
||||
projectId: project.id,
|
||||
shortCode: project.shortCode,
|
||||
};
|
||||
},
|
||||
|
||||
async delete_project(params: { projectId: string }, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
const project = await deps.resolveProjectIdentifier(ctx, params.projectId);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const caller = deps.createProjectCaller(deps.createScopedCallerContext(ctx));
|
||||
try {
|
||||
await caller.delete({ id: project.id });
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantProjectNotFoundError(error, params.projectId);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["project"],
|
||||
success: true,
|
||||
message: `Deleted project: ${project.name} (${project.shortCode})`,
|
||||
};
|
||||
},
|
||||
|
||||
async generate_project_cover(
|
||||
params: { projectId: string; prompt?: string },
|
||||
ctx: ToolContext,
|
||||
) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
const caller = deps.createProjectCaller(deps.createScopedCallerContext(ctx));
|
||||
const project = await deps.resolveProjectIdentifier(ctx, params.projectId);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const { coverImageUrl } = await caller.generateCover({
|
||||
projectId: project.id,
|
||||
...(params.prompt !== undefined ? { prompt: params.prompt } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["project"],
|
||||
success: true,
|
||||
message: `Generated cover art for project "${project.name}"`,
|
||||
coverImageUrl: coverImageUrl.slice(0, 100) + "...[truncated]",
|
||||
};
|
||||
},
|
||||
|
||||
async remove_project_cover(params: { projectId: string }, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
const caller = deps.createProjectCaller(deps.createScopedCallerContext(ctx));
|
||||
const project = await deps.resolveProjectIdentifier(ctx, params.projectId);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
await caller.removeCover({ projectId: project.id });
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["project"],
|
||||
success: true,
|
||||
message: `Removed cover art from project "${project.name}"`,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user