refactor(api): extract assistant project slice

This commit is contained in:
2026-03-30 22:04:28 +02:00
parent 91ab7898e9
commit 1568efab30
3 changed files with 612 additions and 342 deletions
+114 -341
View File
@@ -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}"`,
};
},
};
}