diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index c13ac1b..ab7d1da 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -35,12 +35,13 @@ - the authenticated user self-service assistant helpers now live in their own domain module, covering assignable users, dashboard preferences, favorites, column preferences, and MFA self-service without changing the assistant contract - 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 ## 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 project admin/helper clusters 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 or staffing-demand helpers that are still in the monolithic router. ## Remaining Major Themes diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index c69af8a..2c12eb8 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -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> = { + 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(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 = {}; - 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, ctx: ToolContext) { diff --git a/packages/api/src/router/assistant-tools/projects.ts b/packages/api/src/router/assistant-tools/projects.ts new file mode 100644 index 0000000..a9b66ad --- /dev/null +++ b/packages/api/src/router/assistant-tools/projects.ts @@ -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; +type ParsedUpdateProjectInput = ReturnType; + +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; + getByIdentifierDetail: (params: { identifier: string }) => Promise; + update: (params: { + id: string; + data: ParsedUpdateProjectInput; + }) => Promise; + create: (params: ParsedCreateProjectInput) => Promise; + delete: (params: { id: string }) => Promise; + generateCover: (params: { + projectId: string; + prompt?: string; + }) => Promise<{ coverImageUrl: string }>; + removeCover: (params: { projectId: string }) => Promise; + }; + createBlueprintCaller: (ctx: TRPCContext) => { + resolveByIdentifier: (params: { + identifier: string; + }) => Promise; + }; + createClientCaller: (ctx: TRPCContext) => { + resolveByIdentifier: (params: { + identifier: string; + }) => Promise; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; + resolveProjectIdentifier: ( + ctx: ToolContext, + identifier: string, + ) => Promise; + resolveResponsiblePerson: ( + name: string, + ctx: ToolContext, + ) => Promise<{ displayName: string } | { error: string }>; + resolveEntityOrAssistantError: ( + resolve: () => Promise, + notFoundMessage: string, + ) => Promise; + 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, + deps: ProjectToolsDeps, +): Promise { + 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 { + 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 = {}; + 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}"`, + }; + }, + }; +}