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 <ruv@ruv.net>
This commit is contained in:
2026-03-17 17:58:24 +01:00
parent f0986fe721
commit 21af720f90
+47 -2
View File
@@ -374,6 +374,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
budgetCents: { type: "integer", description: "New budget in cents (e.g. 10000000 = 100,000 EUR)" }, budgetCents: { type: "integer", description: "New budget in cents (e.g. 10000000 = 100,000 EUR)" },
winProbability: { type: "integer", description: "Win probability 0-100" }, winProbability: { type: "integer", description: "Win probability 0-100" },
status: { type: "string", description: "New status: DRAFT, ACTIVE, ON_HOLD, COMPLETED, CANCELLED" }, 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"], required: ["id"],
}, },
@@ -396,7 +397,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
endDate: { type: "string", description: "End date (YYYY-MM-DD)" }, endDate: { type: "string", description: "End date (YYYY-MM-DD)" },
winProbability: { type: "integer", description: "Win probability 0-100. Default: 100" }, winProbability: { type: "integer", description: "Win probability 0-100. Default: 100" },
status: { type: "string", description: "Initial status: DRAFT, ACTIVE, ON_HOLD. Default: DRAFT" }, 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')" }, color: { type: "string", description: "Hex color (e.g. '#3b82f6')" },
blueprintName: { type: "string", description: "Blueprint name to look up and attach (partial match)" }, 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)" }, 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 ───────────────────────────────────────────────────────── // ─── Tool Executors ─────────────────────────────────────────────────────────
const executors = { const executors = {
@@ -1389,6 +1418,7 @@ const executors = {
async update_project(params: { async update_project(params: {
id: string; name?: string; budgetCents?: number; id: string; name?: string; budgetCents?: number;
winProbability?: number; status?: string; winProbability?: number; status?: string;
responsiblePerson?: string;
}, ctx: ToolContext) { }, ctx: ToolContext) {
assertPermission(ctx, "manageProjects" as PermissionKey); assertPermission(ctx, "manageProjects" as PermissionKey);
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
@@ -1397,6 +1427,13 @@ const executors = {
if (params.winProbability !== undefined) data.winProbability = params.winProbability; if (params.winProbability !== undefined) data.winProbability = params.winProbability;
if (params.status !== undefined) data.status = params.status; 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" }; if (Object.keys(data).length === 0) return { error: "No fields to update" };
const project = await ctx.db.project.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 (isNaN(endDate.getTime())) return { error: `Invalid endDate: ${params.endDate}` };
if (endDate < startDate) return { error: "endDate must be after startDate" }; 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 // Optional: look up blueprint by name
let blueprintId: string | undefined; let blueprintId: string | undefined;
if (params.blueprintName) { if (params.blueprintName) {
@@ -1486,7 +1531,7 @@ const executors = {
endDate, endDate,
winProbability: params.winProbability ?? 100, winProbability: params.winProbability ?? 100,
status, status,
...(params.responsiblePerson ? { responsiblePerson: params.responsiblePerson } : {}), ...(resolvedResponsible ? { responsiblePerson: resolvedResponsible } : {}),
...(params.color ? { color: params.color } : {}), ...(params.color ? { color: params.color } : {}),
...(blueprintId ? { blueprintId } : {}), ...(blueprintId ? { blueprintId } : {}),
...(clientId ? { clientId } : {}), ...(clientId ? { clientId } : {}),