Files
CapaKraken/packages/api/src/router/assistant-tools/projects.ts
T

497 lines
17 KiB
TypeScript

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