From 21af720f9074d3d06e5266fbb8d2a631d5c5314b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 17 Mar 2026 17:58:24 +0100 Subject: [PATCH] fix: validate responsiblePerson against existing resources in bot tools The create_project and update_project AI assistant tools now resolve the responsiblePerson field against active resources (case-insensitive). This ensures the name matches an existing resource so dashboard widgets like "My Projects" can correctly link projects to people. Closes #15 Co-Authored-By: claude-flow --- packages/api/src/router/assistant-tools.ts | 49 +++++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index 227368d..184cc29 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -374,6 +374,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ 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"], }, @@ -396,7 +397,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ 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" }, + 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)" }, @@ -407,6 +408,34 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, ]; +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** Resolve a responsible person name against existing resources. Returns the exact displayName or an error object. */ +async function resolveResponsiblePerson( + name: string, + db: ToolContext["db"], +): Promise<{ displayName: string } | { error: string }> { + // Exact match first (case-insensitive) + const exact = await db.resource.findFirst({ + where: { displayName: { equals: name, mode: "insensitive" }, isActive: true }, + select: { displayName: true }, + }); + if (exact) return { displayName: exact.displayName }; + + // Fuzzy: contains search + const candidates = await db.resource.findMany({ + where: { displayName: { contains: name, mode: "insensitive" }, isActive: true }, + select: { displayName: true, eid: true }, + take: 5, + }); + if (candidates.length === 1) return { displayName: candidates[0]!.displayName }; + if (candidates.length > 1) { + const list = candidates.map((c) => `${c.displayName} (${c.eid})`).join(", "); + return { error: `Multiple resources match "${name}": ${list}. Please specify the exact name.` }; + } + return { error: `No active resource found matching "${name}". The responsible person must be an existing resource.` }; +} + // ─── Tool Executors ───────────────────────────────────────────────────────── const executors = { @@ -1389,6 +1418,7 @@ 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 data: Record = {}; @@ -1397,6 +1427,13 @@ const executors = { 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.db); + if ("error" in result) return { error: result.error }; + data.responsiblePerson = result.displayName; + } + if (Object.keys(data).length === 0) return { error: "No fields to update" }; const project = await ctx.db.project.update({ @@ -1453,6 +1490,14 @@ const executors = { if (isNaN(endDate.getTime())) return { error: `Invalid endDate: ${params.endDate}` }; if (endDate < startDate) return { error: "endDate must be after startDate" }; + // Validate responsible person against existing resources + let resolvedResponsible: string | undefined; + if (params.responsiblePerson) { + const result = await resolveResponsiblePerson(params.responsiblePerson, ctx.db); + if ("error" in result) return { error: result.error }; + resolvedResponsible = result.displayName; + } + // Optional: look up blueprint by name let blueprintId: string | undefined; if (params.blueprintName) { @@ -1486,7 +1531,7 @@ const executors = { endDate, winProbability: params.winProbability ?? 100, status, - ...(params.responsiblePerson ? { responsiblePerson: params.responsiblePerson } : {}), + ...(resolvedResponsible ? { responsiblePerson: resolvedResponsible } : {}), ...(params.color ? { color: params.color } : {}), ...(blueprintId ? { blueprintId } : {}), ...(clientId ? { clientId } : {}),