497 lines
17 KiB
TypeScript
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}"`,
|
|
};
|
|
},
|
|
};
|
|
}
|