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}"`, }; }, }; }