7061 lines
240 KiB
TypeScript
7061 lines
240 KiB
TypeScript
/**
|
||
* AI Assistant Tool definitions for OpenAI Function Calling.
|
||
* Each tool has a JSON schema (for the AI) and an execute function (for the server).
|
||
*/
|
||
|
||
import { Prisma, ImportBatchStatus, StagedRecordStatus, DispoStagedRecordType, VacationType } from "@capakraken/db";
|
||
import {
|
||
CreateAssignmentSchema,
|
||
type CreateEstimateInput,
|
||
CreateProjectSchema,
|
||
CreateResourceSchema,
|
||
AllocationStatus,
|
||
EstimateExportFormat,
|
||
EstimateStatus,
|
||
type CommentEntityType,
|
||
COMMENT_ENTITY_TYPE_VALUES,
|
||
PermissionKey,
|
||
SystemRole,
|
||
type UpdateEstimateDraftInput,
|
||
UpdateProjectSchema,
|
||
UpdateResourceSchema,
|
||
} from "@capakraken/shared";
|
||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||
import { TRPCError } from "@trpc/server";
|
||
import { ZodError } from "zod";
|
||
import { fmtEur } from "../lib/format-utils.js";
|
||
import { timelineRouter } from "./timeline.js";
|
||
import { logger } from "../lib/logger.js";
|
||
import { createCallerFactory, type TRPCContext } from "../trpc.js";
|
||
import { auditLogRouter } from "./audit-log.js";
|
||
import { chargeabilityReportRouter } from "./chargeability-report.js";
|
||
import { computationGraphRouter } from "./computation-graph.js";
|
||
import { dispoRouter } from "./dispo.js";
|
||
import { importExportRouter } from "./import-export.js";
|
||
import { resourceRouter } from "./resource.js";
|
||
import { settingsRouter } from "./settings.js";
|
||
import { systemRoleConfigRouter } from "./system-role-config.js";
|
||
import { userRouter } from "./user.js";
|
||
import { notificationRouter } from "./notification.js";
|
||
import { estimateRouter } from "./estimate.js";
|
||
import { webhookRouter } from "./webhook.js";
|
||
import { countryRouter } from "./country.js";
|
||
import { holidayCalendarRouter } from "./holiday-calendar.js";
|
||
import { blueprintRouter } from "./blueprint.js";
|
||
import { roleRouter } from "./role.js";
|
||
import { clientRouter } from "./client.js";
|
||
import { orgUnitRouter } from "./org-unit.js";
|
||
import { projectRouter } from "./project.js";
|
||
import { rateCardRouter } from "./rate-card.js";
|
||
import { reportRouter } from "./report.js";
|
||
import { vacationRouter } from "./vacation.js";
|
||
import { entitlementRouter } from "./entitlement.js";
|
||
import { commentRouter } from "./comment.js";
|
||
import { managementLevelRouter } from "./management-level.js";
|
||
import { utilizationCategoryRouter } from "./utilization-category.js";
|
||
import { calculationRuleRouter } from "./calculation-rules.js";
|
||
import { effortRuleRouter } from "./effort-rule.js";
|
||
import { experienceMultiplierRouter } from "./experience-multiplier.js";
|
||
import { dashboardRouter } from "./dashboard.js";
|
||
import { insightsRouter } from "./insights.js";
|
||
import { scenarioRouter } from "./scenario.js";
|
||
import { allocationRouter } from "./allocation.js";
|
||
import { staffingRouter } from "./staffing.js";
|
||
import { advancedTimelineToolDefinitions, createAdvancedTimelineExecutors } from "./assistant-tools/advanced-timeline.js";
|
||
import {
|
||
allocationPlanningMutationToolDefinitions,
|
||
allocationPlanningReadToolDefinitions,
|
||
createAllocationPlanningExecutors,
|
||
} from "./assistant-tools/allocation-planning.js";
|
||
import { settingsAdminToolDefinitions, createSettingsAdminExecutors } from "./assistant-tools/settings-admin.js";
|
||
import {
|
||
createVacationHolidayExecutors,
|
||
vacationHolidayMutationToolDefinitions,
|
||
vacationHolidayReadToolDefinitions,
|
||
} from "./assistant-tools/vacation-holidays.js";
|
||
import {
|
||
createRolesAnalyticsExecutors,
|
||
rolesAnalyticsMutationToolDefinitions,
|
||
rolesAnalyticsReadToolDefinitions,
|
||
} from "./assistant-tools/roles-analytics.js";
|
||
import {
|
||
clientMutationToolDefinitions,
|
||
createClientsOrgUnitsExecutors,
|
||
orgUnitMutationToolDefinitions,
|
||
} from "./assistant-tools/clients-org-units.js";
|
||
import {
|
||
chargeabilityComputationReadToolDefinitions,
|
||
createChargeabilityComputationExecutors,
|
||
} from "./assistant-tools/chargeability-computation.js";
|
||
import {
|
||
countryMetroAdminToolDefinitions,
|
||
createCountryMetroAdminExecutors,
|
||
} from "./assistant-tools/country-metro-admin.js";
|
||
import type { ToolContext, ToolDef, ToolExecutor } from "./assistant-tools/shared.js";
|
||
import { getCommentToolEntityDescription, getCommentToolScopeSentence } from "../lib/comment-entity-registry.js";
|
||
|
||
export type { ToolContext } from "./assistant-tools/shared.js";
|
||
|
||
// ─── Mutation tool set for audit logging (EGAI 4.1.3.1 / IAAI 3.6.26) ──────
|
||
|
||
export const MUTATION_TOOLS = new Set([
|
||
"import_csv_data",
|
||
"update_system_settings",
|
||
"clear_stored_runtime_secrets",
|
||
"test_ai_connection",
|
||
"test_smtp_connection",
|
||
"test_gemini_connection",
|
||
"update_system_role_config",
|
||
"create_webhook",
|
||
"update_webhook",
|
||
"delete_webhook",
|
||
"test_webhook",
|
||
"stage_dispo_import_batch",
|
||
"cancel_dispo_import_batch",
|
||
"resolve_dispo_staged_record",
|
||
"commit_dispo_import_batch",
|
||
"create_allocation", "cancel_allocation", "update_allocation_status",
|
||
"update_timeline_allocation_inline", "apply_timeline_project_shift",
|
||
"quick_assign_timeline_resource", "batch_quick_assign_timeline_resources",
|
||
"batch_shift_timeline_allocations",
|
||
"update_resource", "deactivate_resource", "create_resource",
|
||
"update_project", "create_project", "delete_project",
|
||
"create_vacation", "approve_vacation", "reject_vacation", "cancel_vacation",
|
||
"set_entitlement", "create_demand", "fill_demand",
|
||
"generate_project_cover", "remove_project_cover",
|
||
"create_role", "update_role", "delete_role",
|
||
"create_client", "update_client", "delete_client",
|
||
"create_org_unit", "update_org_unit",
|
||
"create_country", "update_country",
|
||
"create_metro_city", "update_metro_city", "delete_metro_city",
|
||
"create_holiday_calendar", "update_holiday_calendar", "delete_holiday_calendar",
|
||
"create_holiday_calendar_entry", "update_holiday_calendar_entry", "delete_holiday_calendar_entry",
|
||
"send_broadcast", "create_task_for_user", "create_reminder",
|
||
"update_task_status", "execute_task_action",
|
||
"create_comment", "resolve_comment", "mark_notification_read",
|
||
"save_dashboard_layout", "toggle_favorite_project",
|
||
"set_column_preferences", "generate_totp_secret", "verify_and_enable_totp",
|
||
"create_user", "set_user_password", "update_user_role", "update_user_name",
|
||
"link_user_resource", "auto_link_users_by_email", "set_user_permissions",
|
||
"reset_user_permissions", "disable_user_totp",
|
||
"create_notification", "update_reminder", "delete_reminder",
|
||
"delete_notification", "assign_task",
|
||
"clone_estimate", "update_estimate_draft", "submit_estimate_version",
|
||
"approve_estimate_version", "create_estimate_revision",
|
||
"create_estimate_export", "create_estimate_planning_handoff",
|
||
"generate_estimate_weekly_phasing", "update_estimate_commercial_terms",
|
||
]);
|
||
|
||
export const ADVANCED_ASSISTANT_TOOLS = new Set([
|
||
"find_best_project_resource",
|
||
"get_timeline_entries_view",
|
||
"get_timeline_holiday_overlays",
|
||
"get_project_timeline_context",
|
||
"preview_project_shift",
|
||
"update_timeline_allocation_inline",
|
||
"apply_timeline_project_shift",
|
||
"quick_assign_timeline_resource",
|
||
"batch_quick_assign_timeline_resources",
|
||
"batch_shift_timeline_allocations",
|
||
"get_chargeability_report",
|
||
"get_resource_computation_graph",
|
||
"get_project_computation_graph",
|
||
]);
|
||
|
||
const createChargeabilityReportCaller = createCallerFactory(chargeabilityReportRouter);
|
||
const createComputationGraphCaller = createCallerFactory(computationGraphRouter);
|
||
const createTimelineCaller = createCallerFactory(timelineRouter);
|
||
const createAuditLogCaller = createCallerFactory(auditLogRouter);
|
||
const createImportExportCaller = createCallerFactory(importExportRouter);
|
||
const createDispoCaller = createCallerFactory(dispoRouter);
|
||
const createResourceCaller = createCallerFactory(resourceRouter);
|
||
const createSettingsCaller = createCallerFactory(settingsRouter);
|
||
const createSystemRoleConfigCaller = createCallerFactory(systemRoleConfigRouter);
|
||
const createUserCaller = createCallerFactory(userRouter);
|
||
const createNotificationCaller = createCallerFactory(notificationRouter);
|
||
const createEstimateCaller = createCallerFactory(estimateRouter);
|
||
const createWebhookCaller = createCallerFactory(webhookRouter);
|
||
const createCountryCaller = createCallerFactory(countryRouter);
|
||
const createHolidayCalendarCaller = createCallerFactory(holidayCalendarRouter);
|
||
const createBlueprintCaller = createCallerFactory(blueprintRouter);
|
||
const createRoleCaller = createCallerFactory(roleRouter);
|
||
const createClientCaller = createCallerFactory(clientRouter);
|
||
const createOrgUnitCaller = createCallerFactory(orgUnitRouter);
|
||
const createProjectCaller = createCallerFactory(projectRouter);
|
||
const createRateCardCaller = createCallerFactory(rateCardRouter);
|
||
const createReportCaller = createCallerFactory(reportRouter);
|
||
const createVacationCaller = createCallerFactory(vacationRouter);
|
||
const createEntitlementCaller = createCallerFactory(entitlementRouter);
|
||
const createCommentCaller = createCallerFactory(commentRouter);
|
||
const createManagementLevelCaller = createCallerFactory(managementLevelRouter);
|
||
const createUtilizationCategoryCaller = createCallerFactory(utilizationCategoryRouter);
|
||
const createCalculationRuleCaller = createCallerFactory(calculationRuleRouter);
|
||
const createEffortRuleCaller = createCallerFactory(effortRuleRouter);
|
||
const createExperienceMultiplierCaller = createCallerFactory(experienceMultiplierRouter);
|
||
const createDashboardCaller = createCallerFactory(dashboardRouter);
|
||
const createInsightsCaller = createCallerFactory(insightsRouter);
|
||
const createScenarioCaller = createCallerFactory(scenarioRouter);
|
||
const createAllocationCaller = createCallerFactory(allocationRouter);
|
||
const createStaffingCaller = createCallerFactory(staffingRouter);
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function fmtDate(d: Date | null | undefined): string | null {
|
||
return d ? d.toISOString().slice(0, 10) : null;
|
||
}
|
||
|
||
class AssistantVisibleError extends Error {
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = "AssistantVisibleError";
|
||
}
|
||
}
|
||
|
||
function assertPermission(ctx: ToolContext, perm: PermissionKey): void {
|
||
if (!ctx.permissions.has(perm)) {
|
||
throw new AssistantVisibleError(`Permission denied: you need the "${perm}" permission to perform this action.`);
|
||
}
|
||
}
|
||
|
||
function assertAdminRole(ctx: ToolContext): void {
|
||
if (ctx.userRole !== SystemRole.ADMIN) {
|
||
throw new AssistantVisibleError("Admin role required to perform this action.");
|
||
}
|
||
}
|
||
|
||
function formatHolidayCalendarEntry(entry: {
|
||
id: string;
|
||
date: Date;
|
||
name: string;
|
||
isRecurringAnnual?: boolean | null;
|
||
source?: string | null;
|
||
}) {
|
||
return {
|
||
id: entry.id,
|
||
date: fmtDate(entry.date),
|
||
name: entry.name,
|
||
isRecurringAnnual: entry.isRecurringAnnual ?? false,
|
||
source: entry.source ?? null,
|
||
};
|
||
}
|
||
|
||
function formatHolidayCalendar(calendar: {
|
||
id: string;
|
||
name: string;
|
||
scopeType: string;
|
||
stateCode?: string | null;
|
||
isActive?: boolean | null;
|
||
priority?: number | null;
|
||
country?: { id: string; code: string; name: string } | null;
|
||
metroCity?: { id: string; name: string } | null;
|
||
_count?: { entries?: number | null } | null;
|
||
entries?: Array<{
|
||
id: string;
|
||
date: Date;
|
||
name: string;
|
||
isRecurringAnnual?: boolean | null;
|
||
source?: string | null;
|
||
}> | null;
|
||
}) {
|
||
const entries = calendar.entries?.map(formatHolidayCalendarEntry) ?? [];
|
||
|
||
return {
|
||
id: calendar.id,
|
||
name: calendar.name,
|
||
scopeType: calendar.scopeType,
|
||
stateCode: calendar.stateCode ?? null,
|
||
isActive: calendar.isActive ?? true,
|
||
priority: calendar.priority ?? 0,
|
||
country: calendar.country
|
||
? {
|
||
id: calendar.country.id,
|
||
code: calendar.country.code,
|
||
name: calendar.country.name,
|
||
}
|
||
: null,
|
||
metroCity: calendar.metroCity
|
||
? {
|
||
id: calendar.metroCity.id,
|
||
name: calendar.metroCity.name,
|
||
}
|
||
: null,
|
||
entryCount: calendar._count?.entries ?? entries.length,
|
||
entries,
|
||
};
|
||
}
|
||
|
||
function formatCountry(country: {
|
||
id: string;
|
||
code: string;
|
||
name: string;
|
||
dailyWorkingHours: number;
|
||
scheduleRules?: Prisma.JsonValue | null;
|
||
isActive?: boolean | null;
|
||
metroCities?: Array<{ id: string; name: string }> | null;
|
||
_count?: { resources?: number | null } | null;
|
||
}) {
|
||
return {
|
||
id: country.id,
|
||
code: country.code,
|
||
name: country.name,
|
||
dailyWorkingHours: country.dailyWorkingHours,
|
||
scheduleRules: country.scheduleRules ?? null,
|
||
isActive: country.isActive ?? true,
|
||
resourceCount: country._count?.resources ?? null,
|
||
metroCities: (country.metroCities ?? []).map((city) => ({
|
||
id: city.id,
|
||
name: city.name,
|
||
})),
|
||
cities: (country.metroCities ?? []).map((city) => city.name),
|
||
};
|
||
}
|
||
|
||
function createUtcDate(year: number, monthIndex: number, day: number): Date {
|
||
return new Date(Date.UTC(year, monthIndex, day));
|
||
}
|
||
|
||
function resolveHolidayPeriod(input: {
|
||
year?: number;
|
||
periodStart?: string;
|
||
periodEnd?: string;
|
||
}): { year: number | null; periodStart: Date; periodEnd: Date } {
|
||
if (input.periodStart || input.periodEnd) {
|
||
if (!input.periodStart || !input.periodEnd) {
|
||
throw new AssistantVisibleError("periodStart and periodEnd must both be provided when using a custom holiday range.");
|
||
}
|
||
|
||
const periodStart = new Date(`${input.periodStart}T00:00:00.000Z`);
|
||
const periodEnd = new Date(`${input.periodEnd}T00:00:00.000Z`);
|
||
if (Number.isNaN(periodStart.getTime())) {
|
||
throw new AssistantVisibleError(`Invalid periodStart: ${input.periodStart}`);
|
||
}
|
||
if (Number.isNaN(periodEnd.getTime())) {
|
||
throw new AssistantVisibleError(`Invalid periodEnd: ${input.periodEnd}`);
|
||
}
|
||
if (periodEnd < periodStart) {
|
||
throw new AssistantVisibleError("periodEnd must be on or after periodStart.");
|
||
}
|
||
|
||
return { year: null, periodStart, periodEnd };
|
||
}
|
||
|
||
const year = input.year ?? new Date().getUTCFullYear();
|
||
return {
|
||
year,
|
||
periodStart: createUtcDate(year, 0, 1),
|
||
periodEnd: createUtcDate(year, 11, 31),
|
||
};
|
||
}
|
||
|
||
const ASSISTANT_VACATION_REQUEST_TYPES = [
|
||
VacationType.ANNUAL,
|
||
VacationType.SICK,
|
||
VacationType.OTHER,
|
||
] as const;
|
||
|
||
function parseAssistantVacationRequestType(input: string): VacationType {
|
||
const normalized = input.trim().toUpperCase();
|
||
if (normalized === VacationType.PUBLIC_HOLIDAY) {
|
||
throw new AssistantVisibleError("PUBLIC_HOLIDAY requests cannot be created manually. Manage public holidays through holiday calendars instead.");
|
||
}
|
||
|
||
if ((ASSISTANT_VACATION_REQUEST_TYPES as readonly string[]).includes(normalized)) {
|
||
return normalized as VacationType;
|
||
}
|
||
|
||
throw new AssistantVisibleError(`Invalid vacation type: ${input}. Valid types: ${ASSISTANT_VACATION_REQUEST_TYPES.join(", ")}.`);
|
||
}
|
||
|
||
function parseIsoDate(value: string, fieldName: string): Date {
|
||
const parsed = new Date(`${value}T00:00:00.000Z`);
|
||
if (Number.isNaN(parsed.getTime())) {
|
||
throw new AssistantVisibleError(`Invalid ${fieldName}: ${value}`);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function parseOptionalIsoDate(
|
||
value: string | undefined,
|
||
fieldName: string,
|
||
): Date | undefined {
|
||
return value ? parseIsoDate(value, fieldName) : undefined;
|
||
}
|
||
|
||
function parseDateTime(value: string, fieldName: string): Date {
|
||
const parsed = new Date(value);
|
||
if (Number.isNaN(parsed.getTime())) {
|
||
throw new AssistantVisibleError(`Invalid ${fieldName}: ${value}`);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function parseOptionalDateTime(
|
||
value: string | undefined,
|
||
fieldName: string,
|
||
): Date | undefined {
|
||
return value ? parseDateTime(value, fieldName) : undefined;
|
||
}
|
||
|
||
function toDate(value: Date | string): Date {
|
||
return value instanceof Date ? value : new Date(value);
|
||
}
|
||
|
||
type AssistantToolErrorResult = { error: string };
|
||
type AssistantIndexedFieldErrorResult = AssistantToolErrorResult & {
|
||
field: string;
|
||
index: number;
|
||
};
|
||
type BatchQuickAssignmentInput = {
|
||
resourceId: string;
|
||
projectId: string;
|
||
startDate: Date;
|
||
endDate: Date;
|
||
hoursPerDay?: number;
|
||
role?: string;
|
||
status?: AllocationStatus;
|
||
};
|
||
|
||
function toAssistantNotFoundError(
|
||
error: unknown,
|
||
message: string,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: message };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function toAssistantAllocationNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError) {
|
||
if (error.code === "NOT_FOUND") {
|
||
return { error: "Allocation not found with the given criteria." };
|
||
}
|
||
if (error.message === "Record not found" || error.message.includes("Assignment not found")) {
|
||
return { error: "Allocation not found with the given criteria." };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (prismaError?.code === "P2025") {
|
||
return { error: "Allocation not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantProjectNotFoundError(
|
||
error: unknown,
|
||
identifier: string,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
`Project not found: ${identifier}`,
|
||
);
|
||
}
|
||
|
||
function toAssistantTimelineMutationError(
|
||
error: unknown,
|
||
context: "updateInline" | "applyShift" | "quickAssign" | "batchShift",
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError) {
|
||
if (error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Resource not found")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
if (error.message.includes("Project not found")) {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
if (error.message.includes("Demand requirement not found")) {
|
||
return { error: "Demand requirement not found with the given criteria." };
|
||
}
|
||
if (error.message.includes("No allocations found")) {
|
||
return { error: "Allocation not found with the given criteria." };
|
||
}
|
||
}
|
||
|
||
if (error.code === "BAD_REQUEST" || error.code === "CONFLICT") {
|
||
return { error: error.message };
|
||
}
|
||
}
|
||
|
||
const allocationNotFound = toAssistantAllocationNotFoundError(error);
|
||
if (allocationNotFound && (context === "updateInline" || context === "batchShift")) {
|
||
return allocationNotFound;
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("resource")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("project")) {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("demand")) {
|
||
return { error: "Demand requirement not found with the given criteria." };
|
||
}
|
||
if (prismaError.code === "P2025" && (context === "updateInline" || context === "batchShift")) {
|
||
return { error: "Allocation not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantVacationNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Vacation not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantVacationMutationError(
|
||
error: unknown,
|
||
action: "approve" | "reject" | "cancel",
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantVacationNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
if (action === "approve") {
|
||
return { error: "Vacation cannot be approved in its current status." };
|
||
}
|
||
if (action === "reject") {
|
||
return { error: "Vacation cannot be rejected in its current status." };
|
||
}
|
||
return { error: "Vacation cannot be cancelled in its current status." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantProjectCreationError(
|
||
error: unknown,
|
||
shortCode: string,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError) {
|
||
if (error.code === "CONFLICT") {
|
||
return { error: `A project with short code "${shortCode}" already exists.` };
|
||
}
|
||
|
||
if (error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Blueprint")) {
|
||
return { error: "Blueprint not found with the given criteria." };
|
||
}
|
||
if (error.message.includes("Client")) {
|
||
return { error: "Client not found with the given criteria." };
|
||
}
|
||
}
|
||
|
||
if (error.code === "BAD_REQUEST" || error.code === "UNPROCESSABLE_CONTENT") {
|
||
return { error: error.message };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("blueprint")) {
|
||
return { error: "Blueprint not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("client")) {
|
||
return { error: "Client not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantDemandNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Demand not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantDemandFillError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantDemandNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
return { error: "Demand cannot be filled in its current status." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantEstimateNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
if (error.message.includes("version")) {
|
||
return { error: "Estimate version not found with the given criteria." };
|
||
}
|
||
return { error: "Estimate not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantEstimateReadError(
|
||
error: unknown,
|
||
context: "weeklyPhasing" | "commercialTerms",
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantEstimateNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (
|
||
context === "weeklyPhasing"
|
||
&& error instanceof TRPCError
|
||
&& error.code === "PRECONDITION_FAILED"
|
||
&& error.message === "Estimate has no versions"
|
||
) {
|
||
return { error: "Estimate version not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantHolidayCalendarNotFoundError(
|
||
error: unknown,
|
||
identifier?: string,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
identifier
|
||
? `Holiday calendar not found: ${identifier}`
|
||
: "Holiday calendar not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantHolidayCalendarMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantHolidayCalendarNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
return { error: "Holiday calendar scope is invalid." };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||
return { error: "A holiday calendar for this scope already exists." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantHolidayEntryNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Holiday calendar entry not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantHolidayEntryMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const calendarNotFound = toAssistantHolidayCalendarNotFoundError(error);
|
||
if (calendarNotFound) {
|
||
return calendarNotFound;
|
||
}
|
||
|
||
const entryNotFound = toAssistantHolidayEntryNotFoundError(error);
|
||
if (entryNotFound) {
|
||
return entryNotFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||
return { error: "A holiday entry for this calendar and date already exists." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantRoleNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Role not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantRoleMutationError(
|
||
error: unknown,
|
||
action: "create" | "update" | "delete",
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantRoleNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||
return { error: "A role with this name already exists." };
|
||
}
|
||
|
||
if (action === "delete" && error instanceof TRPCError && error.code === "PRECONDITION_FAILED") {
|
||
return { error: "Role cannot be deleted while it is still assigned. Deactivate it instead." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantClientMutationError(
|
||
error: unknown,
|
||
action: "create" | "update" | "delete" = "update",
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Parent client")) {
|
||
return { error: "Parent client not found with the given criteria." };
|
||
}
|
||
return { error: "Client not found with the given criteria." };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||
return { error: "A client with this code already exists." };
|
||
}
|
||
|
||
if (action === "delete" && error instanceof TRPCError && error.code === "PRECONDITION_FAILED") {
|
||
if (error.message.includes("project")) {
|
||
return { error: "Client cannot be deleted while it still has projects. Deactivate it instead." };
|
||
}
|
||
if (error.message.includes("child client")) {
|
||
return { error: "Client cannot be deleted while it still has child clients. Remove or reassign them first." };
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantOrgUnitNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Parent org unit")) {
|
||
return { error: "Parent org unit not found with the given criteria." };
|
||
}
|
||
return { error: "Org unit not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantOrgUnitMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantOrgUnitNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
if (error.message.includes("must be greater than parent level")) {
|
||
return { error: "Org unit level must be greater than the parent org unit level." };
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantCountryNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Country not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantCountryMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantCountryNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||
return { error: "A country with this code already exists." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantResourceCreationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError) {
|
||
if (error.code === "CONFLICT") {
|
||
return { error: "A resource with this EID or email already exists." };
|
||
}
|
||
|
||
if (error.code === "BAD_REQUEST" || error.code === "UNPROCESSABLE_CONTENT") {
|
||
return { error: error.message };
|
||
}
|
||
|
||
if (error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
if (error.message.includes("Country")) {
|
||
return { error: "Country not found with the given criteria." };
|
||
}
|
||
if (error.message.includes("Org unit")) {
|
||
return { error: "Org unit not found with the given criteria." };
|
||
}
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("country")) {
|
||
return { error: "Country not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("orgunit") || errorText.includes("org_unit") || errorText.includes("org unit")) {
|
||
return { error: "Org unit not found with the given criteria." };
|
||
}
|
||
|
||
return { error: "The selected role, country, or org unit no longer exists." };
|
||
}
|
||
|
||
function toAssistantResourceMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code === "P2025") {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
if (prismaError.code !== "P2003") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("country")) {
|
||
return { error: "Country not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("orgunit") || errorText.includes("org_unit") || errorText.includes("org unit")) {
|
||
return { error: "Org unit not found with the given criteria." };
|
||
}
|
||
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
function toAssistantProjectMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code === "P2025") {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
|
||
if (prismaError.code !== "P2003") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("blueprint")) {
|
||
return { error: "Blueprint not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("client")) {
|
||
return { error: "Client not found with the given criteria." };
|
||
}
|
||
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
|
||
function toAssistantMetroCityMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Country")) {
|
||
return { error: "Country not found with the given criteria." };
|
||
}
|
||
return { error: "Metro city not found with the given criteria." };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "PRECONDITION_FAILED") {
|
||
return { error: "Metro city cannot be deleted while it is still assigned to resources." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantDemandCreationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("project")) {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantVacationCreationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError) {
|
||
if (error.code === "FORBIDDEN") {
|
||
return { error: "You can only create vacation requests for your own resource." };
|
||
}
|
||
|
||
if (error.code === "BAD_REQUEST") {
|
||
return { error: error.message };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code === "P2025") {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
if (prismaError.code !== "P2003") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("resource")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
function toAssistantEntitlementMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code === "P2025") {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
if (prismaError.code !== "P2003") {
|
||
return null;
|
||
}
|
||
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
function toAssistantEstimateCreationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("project")) {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("resource")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("scopeitem") || errorText.includes("scope_item") || errorText.includes("scope item")) {
|
||
return { error: "Estimate scope item not found with the given criteria." };
|
||
}
|
||
|
||
return { error: "One of the referenced project, role, resource, or scope items no longer exists." };
|
||
}
|
||
|
||
function toAssistantEstimateMutationError(
|
||
error: unknown,
|
||
action:
|
||
| "clone"
|
||
| "updateDraft"
|
||
| "submitVersion"
|
||
| "approveVersion"
|
||
| "createRevision"
|
||
| "createExport"
|
||
| "createPlanningHandoff"
|
||
| "generateWeeklyPhasing"
|
||
| "updateCommercialTerms",
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError) {
|
||
if (error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Linked project")) {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
if (action === "clone" && error.message === "Source estimate has no versions") {
|
||
return { error: "Source estimate has no versions and cannot be cloned." };
|
||
}
|
||
if (error.message.includes("version") || error.message.includes("versions")) {
|
||
return { error: "Estimate version not found with the given criteria." };
|
||
}
|
||
return { error: "Estimate not found with the given criteria." };
|
||
}
|
||
|
||
if (error.code === "PRECONDITION_FAILED") {
|
||
switch (error.message) {
|
||
case "Estimate has no working version":
|
||
return { error: "Estimate has no working version." };
|
||
case "Only working versions can be submitted":
|
||
return { error: "Only working versions can be submitted." };
|
||
case "Estimate has no submitted version":
|
||
return { error: "Estimate has no submitted version." };
|
||
case "Only submitted versions can be approved":
|
||
return { error: "Only submitted versions can be approved." };
|
||
case "Estimate already has a working version":
|
||
return { error: "Estimate already has a working version." };
|
||
case "Estimate has no locked version to revise":
|
||
return { error: "Estimate has no locked version to revise." };
|
||
case "Source version must be locked before creating a revision":
|
||
return { error: "Source version must be locked before creating a revision." };
|
||
case "Estimate has no approved version":
|
||
return { error: "Estimate has no approved version." };
|
||
case "Only approved versions can be handed off to planning":
|
||
return { error: "Only approved versions can be handed off to planning." };
|
||
case "Estimate must be linked to a project before planning handoff":
|
||
return { error: "Estimate must be linked to a project before planning handoff." };
|
||
case "Planning handoff already exists for this approved version":
|
||
return { error: "Planning handoff already exists for this approved version." };
|
||
case "Linked project has an invalid date range":
|
||
return { error: "The linked project has an invalid date range for planning handoff." };
|
||
case "Commercial terms can only be edited on working versions":
|
||
return { error: "Commercial terms can only be edited on working versions." };
|
||
default:
|
||
if (error.message.startsWith("Project window has no working days for demand line")) {
|
||
return { error: "The linked project window has no working days for at least one demand line." };
|
||
}
|
||
}
|
||
}
|
||
|
||
if (error.code === "BAD_REQUEST" && action === "updateCommercialTerms") {
|
||
return { error: "Commercial terms input is invalid." };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code === "P2003") {
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("project")) {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("resource")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("scopeitem") || errorText.includes("scope_item") || errorText.includes("scope item")) {
|
||
return { error: "Estimate scope item not found with the given criteria." };
|
||
}
|
||
return { error: "One of the referenced project, role, resource, or scope items no longer exists." };
|
||
}
|
||
|
||
if (prismaError.code === "P2025") {
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("estimatedemandline") || errorText.includes("estimate_demand_line") || errorText.includes("estimate demand line")) {
|
||
return { error: "Estimate demand line not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("estimateversion") || errorText.includes("estimate_version") || errorText.includes("estimate version")) {
|
||
return { error: "Estimate version not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("estimate")) {
|
||
return { error: "Estimate not found with the given criteria." };
|
||
}
|
||
switch (action) {
|
||
case "generateWeeklyPhasing":
|
||
return { error: "Estimate demand line not found with the given criteria." };
|
||
case "updateCommercialTerms":
|
||
case "submitVersion":
|
||
case "approveVersion":
|
||
case "createRevision":
|
||
case "createExport":
|
||
return { error: "Estimate version not found with the given criteria." };
|
||
default:
|
||
return { error: "Estimate not found with the given criteria." };
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantUserMutationError(
|
||
error: unknown,
|
||
action: "create" | "update" | "password" = "update",
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "CONFLICT" && action === "create") {
|
||
return { error: "User with this email already exists." };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
const validationIssues = getTrpcValidationIssues(error);
|
||
for (const issue of validationIssues) {
|
||
const field = issue.path[0];
|
||
if (field === "password" && issue.code === "too_small") {
|
||
return { error: "Password must be at least 8 characters." };
|
||
}
|
||
|
||
if (field === "name" && issue.code === "too_small") {
|
||
return { error: "Name is required." };
|
||
}
|
||
|
||
if (field === "name" && issue.code === "too_big") {
|
||
return { error: "Name must be at most 200 characters." };
|
||
}
|
||
}
|
||
|
||
if (error.message.includes("Password must be at least 8 characters")) {
|
||
return { error: "Password must be at least 8 characters." };
|
||
}
|
||
|
||
if (error.message.includes("Name is required")) {
|
||
return { error: "Name is required." };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (prismaError?.code === "P2025") {
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function getTrpcValidationIssues(error: TRPCError): Array<{
|
||
code?: string;
|
||
path: string[];
|
||
}> {
|
||
if (error.cause instanceof ZodError) {
|
||
return error.cause.issues.map((issue) => ({
|
||
code: issue.code,
|
||
path: issue.path.map((segment) => String(segment)),
|
||
}));
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(error.message);
|
||
if (!Array.isArray(parsed)) {
|
||
return [];
|
||
}
|
||
|
||
return parsed
|
||
.filter((issue): issue is { code?: unknown; path?: unknown } => issue !== null && typeof issue === "object")
|
||
.map((issue) => (
|
||
typeof issue.code === "string"
|
||
? {
|
||
code: issue.code,
|
||
path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [],
|
||
}
|
||
: {
|
||
path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [],
|
||
}
|
||
));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function toAssistantUserResourceLinkError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Resource")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
const pointsToUser =
|
||
errorText.includes("userid")
|
||
|| errorText.includes("user_id")
|
||
|| errorText.includes(" user ");
|
||
const pointsToResource =
|
||
errorText.includes("resourceid")
|
||
|| errorText.includes("resource_id")
|
||
|| errorText.includes(" resource ");
|
||
|
||
if (prismaError.code === "P2025") {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
if (prismaError.code === "P2003") {
|
||
if (pointsToUser) {
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
if (pointsToResource || errorText.includes("resource")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantTotpEnableError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
if (error.message.includes("No TOTP secret generated")) {
|
||
return { error: "No TOTP secret generated. Call generate_totp_secret first." };
|
||
}
|
||
if (error.message.includes("already enabled")) {
|
||
return { error: "TOTP is already enabled." };
|
||
}
|
||
if (error.message.includes("Invalid TOTP token")) {
|
||
return { error: "Invalid TOTP token." };
|
||
}
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (prismaError?.code === "P2025") {
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantWebhookNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Webhook not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantWebhookMutationError(
|
||
error: unknown,
|
||
action: "create" | "update" = "update",
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantWebhookNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
return {
|
||
error: action === "create"
|
||
? "Webhook input is invalid."
|
||
: "Webhook update input is invalid.",
|
||
};
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (prismaError?.code === "P2025") {
|
||
return { error: "Webhook not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantAuditLogEntryNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Audit log entry not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantTaskNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Task not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantTaskActionError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantTaskNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "PRECONDITION_FAILED") {
|
||
if (error.message.includes("no executable action")) {
|
||
return { error: "Task has no executable action." };
|
||
}
|
||
if (error.message.includes("already completed")) {
|
||
return { error: "Task is already completed." };
|
||
}
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
if (error.message.includes("Invalid taskAction format") || error.message.includes("Unknown action")) {
|
||
return { error: "Task action is invalid and cannot be executed." };
|
||
}
|
||
if (error.message === "Vacation not found") {
|
||
return { error: "Vacation not found with the given criteria." };
|
||
}
|
||
if (error.message.startsWith("Vacation is ") && error.message.includes(", not PENDING")) {
|
||
return { error: "Vacation is not pending and cannot be approved or rejected via this task action." };
|
||
}
|
||
if (error.message === "Assignment not found") {
|
||
return { error: "Assignment not found with the given criteria." };
|
||
}
|
||
if (error.message === "Assignment is already CONFIRMED") {
|
||
return { error: "Assignment is already confirmed." };
|
||
}
|
||
return { error: error.message };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "FORBIDDEN") {
|
||
return { error: "You do not have permission to execute this task action." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantTaskAssignmentError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantTaskNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (prismaError && (prismaError.code === "P2003" || prismaError.code === "P2025")) {
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("assignee")) {
|
||
return { error: "Assignee user not found with the given criteria." };
|
||
}
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
return { error: "Only tasks and approvals can be assigned." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantBroadcastNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Broadcast not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantDispoImportBatchNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Import batch not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantReminderNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Reminder not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantNotificationNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Notification not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (prismaError?.code === "P2025") {
|
||
return { error: "Notification not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantNotificationReadError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotificationNotFoundError(error);
|
||
}
|
||
|
||
function toAssistantNotificationDeletionError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantNotificationNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "FORBIDDEN") {
|
||
return { error: "Tasks created by other users cannot be deleted." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantReminderCreationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
return { error: "Reminder input is invalid." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("user")) {
|
||
return { error: "Authenticated user not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantCommentResolveError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Comment not found with the given criteria." };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "FORBIDDEN") {
|
||
return { error: "Only the comment author or an admin can resolve comments." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantCommentCreationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
if (error.message.includes("at least 1 character")) {
|
||
return { error: "Comment body is required." };
|
||
}
|
||
|
||
if (error.message.includes("at most 10000")) {
|
||
return { error: "Comment body must be at most 10000 characters." };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("author") || errorText.includes("sender")) {
|
||
return { error: "Comment author not found with the given criteria." };
|
||
}
|
||
|
||
if (errorText.includes("user") || errorText.includes("recipient")) {
|
||
return { error: "Mentioned user not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function getPrismaRequestErrorMetadata(error: unknown): {
|
||
code: string;
|
||
message: string;
|
||
metaText: string;
|
||
} | null {
|
||
const collectMetaStrings = (value: unknown): string[] => {
|
||
if (typeof value === "string") {
|
||
return [value];
|
||
}
|
||
if (Array.isArray(value)) {
|
||
return value.flatMap((entry) => collectMetaStrings(entry));
|
||
}
|
||
if (value && typeof value === "object") {
|
||
return Object.values(value).flatMap((entry) => collectMetaStrings(entry));
|
||
}
|
||
return [];
|
||
};
|
||
|
||
const queue: unknown[] = [error];
|
||
const visited = new Set<unknown>();
|
||
|
||
while (queue.length > 0) {
|
||
const current = queue.shift();
|
||
if (current === undefined || current === null || visited.has(current)) {
|
||
continue;
|
||
}
|
||
visited.add(current);
|
||
|
||
if (current instanceof Prisma.PrismaClientKnownRequestError) {
|
||
const metaText = Object.values(current.meta ?? {})
|
||
.flatMap((value) => collectMetaStrings(value))
|
||
.join(" ");
|
||
return {
|
||
code: current.code,
|
||
message: current.message,
|
||
metaText,
|
||
};
|
||
}
|
||
|
||
if (typeof current !== "object") {
|
||
continue;
|
||
}
|
||
|
||
const candidate = current as {
|
||
code?: unknown;
|
||
message?: unknown;
|
||
meta?: Record<string, unknown>;
|
||
cause?: unknown;
|
||
};
|
||
|
||
if (typeof candidate.code === "string" && /^P\d{4}$/.test(candidate.code)) {
|
||
const metaText = Object.values(candidate.meta ?? {})
|
||
.flatMap((value) => collectMetaStrings(value))
|
||
.join(" ");
|
||
|
||
return {
|
||
code: candidate.code,
|
||
message: typeof candidate.message === "string" ? candidate.message : "",
|
||
metaText,
|
||
};
|
||
}
|
||
|
||
if ("cause" in candidate) {
|
||
queue.push(candidate.cause);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function getTrpcErrorMetadata(error: unknown): {
|
||
code: string;
|
||
message: string;
|
||
} | null {
|
||
const queue: unknown[] = [error];
|
||
const visited = new Set<unknown>();
|
||
|
||
while (queue.length > 0) {
|
||
const current = queue.shift();
|
||
if (current === undefined || current === null || visited.has(current)) {
|
||
continue;
|
||
}
|
||
visited.add(current);
|
||
|
||
if (current instanceof TRPCError) {
|
||
return {
|
||
code: current.code,
|
||
message: current.message,
|
||
};
|
||
}
|
||
|
||
if (typeof current !== "object") {
|
||
continue;
|
||
}
|
||
|
||
const candidate = current as {
|
||
code?: unknown;
|
||
message?: unknown;
|
||
cause?: unknown;
|
||
data?: { code?: unknown };
|
||
shape?: { code?: unknown; message?: unknown; data?: { cause?: unknown } };
|
||
};
|
||
|
||
const candidateCode = typeof candidate.code === "string"
|
||
? candidate.code
|
||
: typeof candidate.data?.code === "string"
|
||
? candidate.data.code
|
||
: typeof candidate.shape?.code === "string"
|
||
? candidate.shape.code
|
||
: null;
|
||
const candidateMessage = typeof candidate.message === "string"
|
||
? candidate.message
|
||
: typeof candidate.shape?.message === "string"
|
||
? candidate.shape.message
|
||
: "";
|
||
|
||
if (candidateCode && /^[A-Z_]+$/.test(candidateCode)) {
|
||
return {
|
||
code: candidateCode,
|
||
message: candidateMessage,
|
||
};
|
||
}
|
||
|
||
if ("cause" in candidate) {
|
||
queue.push(candidate.cause);
|
||
}
|
||
if (candidate.shape?.data?.cause) {
|
||
queue.push(candidate.shape.data.cause);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantNotificationCreationError(
|
||
error: unknown,
|
||
context: "notification" | "task" | "broadcast",
|
||
): AssistantToolErrorResult | null {
|
||
const trpcError = getTrpcErrorMetadata(error);
|
||
|
||
if (
|
||
context === "broadcast"
|
||
&& trpcError?.code === "BAD_REQUEST"
|
||
&& trpcError.message === "No recipients matched the broadcast target."
|
||
) {
|
||
return { error: "No recipients matched the broadcast target." };
|
||
}
|
||
|
||
if (trpcError?.code === "NOT_FOUND") {
|
||
if (trpcError.message.includes("broadcast")) {
|
||
return { error: "Broadcast not found with the given criteria." };
|
||
}
|
||
if (trpcError.message.includes("Sender user not found")) {
|
||
return { error: "Sender user not found with the given criteria." };
|
||
}
|
||
if (trpcError.message.includes("Assignee user not found")) {
|
||
return { error: "Assignee user not found with the given criteria." };
|
||
}
|
||
if (trpcError.message.includes("recipient")) {
|
||
return context === "broadcast"
|
||
? { error: "Broadcast recipient user not found with the given criteria." }
|
||
: context === "task"
|
||
? { error: "Task recipient user not found with the given criteria." }
|
||
: { error: "Notification recipient user not found with the given criteria." };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
|
||
if (errorText.includes("assignee")) {
|
||
return { error: "Assignee user not found with the given criteria." };
|
||
}
|
||
|
||
if (errorText.includes("sender")) {
|
||
return { error: "Sender user not found with the given criteria." };
|
||
}
|
||
|
||
if (context === "broadcast" && (errorText.includes("notificationbroadcast") || errorText.includes("broadcast"))) {
|
||
return { error: "Broadcast not found with the given criteria." };
|
||
}
|
||
|
||
if (context === "broadcast" && prismaError.code === "P2025") {
|
||
return { error: "Broadcast not found with the given criteria." };
|
||
}
|
||
|
||
if (context === "task") {
|
||
return { error: "Task recipient user not found with the given criteria." };
|
||
}
|
||
|
||
if (context === "broadcast") {
|
||
return { error: "Broadcast recipient user not found with the given criteria." };
|
||
}
|
||
|
||
return { error: "Notification recipient user not found with the given criteria." };
|
||
}
|
||
|
||
function normalizeAssistantExecutionError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult {
|
||
if (error instanceof AssistantVisibleError) {
|
||
return { error: error.message };
|
||
}
|
||
|
||
const trpcError = getTrpcErrorMetadata(error);
|
||
if (trpcError) {
|
||
if (trpcError.code === "INTERNAL_SERVER_ERROR") {
|
||
return {
|
||
error: "The tool could not complete due to an internal error.",
|
||
};
|
||
}
|
||
|
||
if (trpcError.code === "UNAUTHORIZED") {
|
||
return {
|
||
error: "Authentication is required to use this tool.",
|
||
};
|
||
}
|
||
|
||
if (trpcError.code === "FORBIDDEN") {
|
||
return {
|
||
error: "You do not have permission to perform this action.",
|
||
};
|
||
}
|
||
|
||
return { error: "The tool could not complete due to a request error." };
|
||
}
|
||
|
||
if (error instanceof Error) {
|
||
return { error: "The tool could not complete due to an unexpected error." };
|
||
}
|
||
|
||
return { error: "The tool could not complete due to an unexpected error." };
|
||
}
|
||
|
||
function isAssistantToolErrorResult(
|
||
value: unknown,
|
||
): value is AssistantToolErrorResult {
|
||
return value !== null && typeof value === "object" && "error" in value;
|
||
}
|
||
|
||
function toAssistantIndexedFieldError(
|
||
index: number,
|
||
field: string,
|
||
message: string,
|
||
): AssistantIndexedFieldErrorResult {
|
||
return {
|
||
error: `assignments[${index}].${field}: ${message}`,
|
||
field: `assignments[${index}].${field}`,
|
||
index,
|
||
};
|
||
}
|
||
|
||
async function resolveEntityOrAssistantError<T>(
|
||
resolve: () => Promise<T>,
|
||
notFoundMessage: string,
|
||
): Promise<T | AssistantToolErrorResult> {
|
||
try {
|
||
return await resolve();
|
||
} catch (error) {
|
||
const mapped = toAssistantNotFoundError(error, notFoundMessage);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
if (error instanceof TRPCError && error.code === "INTERNAL_SERVER_ERROR") {
|
||
return normalizeAssistantExecutionError(error);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function resolveProjectIdentifier(
|
||
ctx: ToolContext,
|
||
identifier: string,
|
||
) {
|
||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||
return resolveEntityOrAssistantError(
|
||
() => caller.resolveByIdentifier({ identifier }),
|
||
`Project not found: ${identifier}`,
|
||
);
|
||
}
|
||
|
||
async function resolveResourceIdentifier(
|
||
ctx: ToolContext,
|
||
identifier: string,
|
||
) {
|
||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
||
return resolveEntityOrAssistantError(
|
||
() => caller.resolveByIdentifier({ identifier }),
|
||
`Resource not found: ${identifier}`,
|
||
);
|
||
}
|
||
|
||
function createScopedCallerContext(ctx: ToolContext): TRPCContext {
|
||
if (!ctx.session?.user || !ctx.dbUser) {
|
||
throw new AssistantVisibleError("Authenticated assistant context is required for this tool.");
|
||
}
|
||
|
||
return {
|
||
session: ctx.session,
|
||
db: ctx.db,
|
||
dbUser: ctx.dbUser,
|
||
roleDefaults: ctx.roleDefaults ?? null,
|
||
};
|
||
}
|
||
|
||
function sanitizeWebhook<T extends { secret?: string | null }>(webhook: T) {
|
||
const { secret: _secret, ...rest } = webhook;
|
||
return {
|
||
...rest,
|
||
hasSecret: Boolean(webhook.secret),
|
||
};
|
||
}
|
||
|
||
function sanitizeWebhookList<T extends { secret?: string | null }>(webhooks: T[]) {
|
||
return webhooks.map((webhook) => sanitizeWebhook(webhook));
|
||
}
|
||
|
||
// ─── Tool Definitions ───────────────────────────────────────────────────────
|
||
|
||
export const TOOL_DEFINITIONS: ToolDef[] = [
|
||
// ── READ TOOLS ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "search_resources",
|
||
description: "Search for resources (employees) by name, employee ID, chapter, country, metro city, org unit, or role. Resource overview access required. Returns a list of matching resources with key details.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
query: { type: "string", description: "Search term (matches displayName, eid, chapter)" },
|
||
country: { type: "string", description: "Filter by country name or code (e.g. 'Spain', 'ES', 'Deutschland', 'DE')" },
|
||
metroCity: { type: "string", description: "Filter by metro city name (e.g. 'Madrid', 'München')" },
|
||
orgUnit: { type: "string", description: "Filter by org unit name (partial match)" },
|
||
roleName: { type: "string", description: "Filter by role name (partial match)" },
|
||
isActive: { type: "boolean", description: "Filter by active status. Default: true" },
|
||
limit: { type: "integer", description: "Max results. Default: 50" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_resource",
|
||
description: "Get detailed information about a single resource by ID, employee ID (eid), or name.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
identifier: { type: "string", description: "Resource ID, employee ID (eid like EMP-001), or display name" },
|
||
},
|
||
required: ["identifier"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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"],
|
||
},
|
||
},
|
||
},
|
||
...advancedTimelineToolDefinitions,
|
||
...allocationPlanningReadToolDefinitions,
|
||
...vacationHolidayReadToolDefinitions,
|
||
...vacationHolidayMutationToolDefinitions,
|
||
...rolesAnalyticsReadToolDefinitions,
|
||
...chargeabilityComputationReadToolDefinitions,
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "search_estimates",
|
||
description: "Search for estimates (cost/effort estimates) by project or name. Returns estimate name, status, version count.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectCode: { type: "string", description: "Project short code to filter by" },
|
||
query: { type: "string", description: "Search term (matches estimate name)" },
|
||
status: { type: "string", description: "Filter by status: DRAFT, IN_REVIEW, APPROVED, ARCHIVED" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_clients",
|
||
description: "List clients/customers. Can search by name or code.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
query: { type: "string", description: "Search term (matches name or code)" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_org_units",
|
||
description: "List organizational units (departments, teams) with their hierarchy.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
level: { type: "integer", description: "Filter by org level (5, 6, or 7)" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── NAVIGATION TOOLS ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_my_timeline_entries_view",
|
||
description: "Get the caller's own self-service timeline entries view for a date range using the real timeline self-service endpoint. Returns only data for the caller's linked resource.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
|
||
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
|
||
resourceIds: { type: "array", items: { type: "string" }, description: "Optional filters are accepted but will be scoped to the caller's own linked resource." },
|
||
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to narrow the caller's own timeline view." },
|
||
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to narrow the caller's own timeline view." },
|
||
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters. Self-service scoping still applies." },
|
||
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs. Self-service scoping still applies." },
|
||
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes. Self-service scoping still applies." },
|
||
},
|
||
required: ["startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_my_timeline_holiday_overlays",
|
||
description: "Get the caller's own self-service holiday overlays for a date range using the real timeline self-service endpoint. Returns only holiday overlays for the caller's linked resource.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
|
||
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
|
||
resourceIds: { type: "array", items: { type: "string" }, description: "Optional filters are accepted but will be scoped to the caller's own linked resource." },
|
||
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to narrow the caller's own holiday overlay view." },
|
||
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to narrow the caller's own holiday overlay view." },
|
||
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters. Self-service scoping still applies." },
|
||
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs. Self-service scoping still applies." },
|
||
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes. Self-service scoping still applies." },
|
||
},
|
||
required: ["startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "navigate_to_page",
|
||
description: "Navigate the user to a specific page in CapaKraken, optionally with filters. Use this when the user wants to see data on a specific page (e.g. 'show me on the timeline', 'open the resources page').",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
page: {
|
||
type: "string",
|
||
description: "Page name: timeline, dashboard, resources, projects, allocations, staffing, estimates, vacations, my-vacations, roles, skills-analytics, chargeability, computation-graph",
|
||
},
|
||
eids: { type: "string", description: "Comma-separated employee IDs to filter (for timeline)" },
|
||
chapters: { type: "string", description: "Comma-separated chapters to filter (for timeline)" },
|
||
projectIds: { type: "string", description: "Comma-separated project IDs to filter (for timeline)" },
|
||
clientIds: { type: "string", description: "Comma-separated client IDs to filter (for timeline)" },
|
||
countryCodes: { type: "string", description: "Comma-separated country codes to filter (e.g. 'ES,DE' for Spain and Germany, for timeline)" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD (for timeline)" },
|
||
days: { type: "integer", description: "Number of days to show (for timeline)" },
|
||
},
|
||
required: ["page"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── WRITE TOOLS ──
|
||
...allocationPlanningMutationToolDefinitions,
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_resource",
|
||
description: "Update a resource's details. Requires manageResources permission. Always confirm with the user before calling this.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Resource ID, EID, or display name" },
|
||
displayName: { type: "string", description: "New display name" },
|
||
fte: { type: "number", description: "New FTE (0.0-1.0)" },
|
||
lcrCents: { type: "integer", description: "New LCR in cents (e.g. 8500 = 85.00 EUR/h)" },
|
||
chapter: { type: "string", description: "New chapter" },
|
||
chargeabilityTarget: { type: "number", description: "New chargeability target (0-100)" },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── RESOURCE MANAGEMENT ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_resource",
|
||
description: "Create a new resource (employee). Requires manageResources permission. Always confirm with the user before calling.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
eid: { type: "string", description: "Employee ID (e.g. EMP-042)" },
|
||
displayName: { type: "string", description: "Full name" },
|
||
email: { type: "string", description: "Email address" },
|
||
fte: { type: "number", description: "FTE 0.0-1.0. Default: 1" },
|
||
lcrCents: { type: "integer", description: "Loaded cost rate in cents (e.g. 8500 = 85 EUR/h)" },
|
||
ucrCents: { type: "integer", description: "Unloaded cost rate in cents" },
|
||
chapter: { type: "string", description: "Chapter/team name" },
|
||
chargeabilityTarget: { type: "number", description: "Chargeability target 0-100. Default: 80" },
|
||
roleName: { type: "string", description: "Role name (partial match)" },
|
||
countryCode: { type: "string", description: "Country code (e.g. DE, ES)" },
|
||
orgUnitName: { type: "string", description: "Org unit name (partial match)" },
|
||
postalCode: { type: "string", description: "Postal code" },
|
||
},
|
||
required: ["eid", "displayName", "email", "lcrCents"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "deactivate_resource",
|
||
description: "Deactivate a resource (soft delete). Requires manageResources permission. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
identifier: { type: "string", description: "Resource ID, eid, or name" },
|
||
},
|
||
required: ["identifier"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── VACATION MANAGEMENT ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_vacation",
|
||
description: "Create a vacation/leave request through the real vacation workflow. Any authenticated user can request leave for their own resource; manager/admin can create requests for others. Always confirm with the user.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID, EID, or display name" },
|
||
type: {
|
||
type: "string",
|
||
enum: ["ANNUAL", "SICK", "OTHER"],
|
||
description: "Vacation type. PUBLIC_HOLIDAY requests are managed through holiday calendars, not manual vacation requests.",
|
||
},
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
isHalfDay: { type: "boolean", description: "Half day? Default: false" },
|
||
halfDayPart: { type: "string", description: "MORNING or AFTERNOON (if half day)" },
|
||
note: { type: "string", description: "Optional note" },
|
||
},
|
||
required: ["resourceId", "type", "startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "approve_vacation",
|
||
description: "Approve a vacation request through the real vacation workflow. Manager or admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
vacationId: { type: "string", description: "Vacation ID" },
|
||
},
|
||
required: ["vacationId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "reject_vacation",
|
||
description: "Reject a pending vacation request through the real vacation workflow. Manager or admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
vacationId: { type: "string", description: "Vacation ID" },
|
||
reason: { type: "string", description: "Rejection reason" },
|
||
},
|
||
required: ["vacationId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "cancel_vacation",
|
||
description: "Cancel a vacation request through the real vacation workflow. Users can cancel their own requests; manager/admin can cancel any request. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
vacationId: { type: "string", description: "Vacation ID" },
|
||
},
|
||
required: ["vacationId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_pending_vacation_approvals",
|
||
description: "List vacation requests awaiting approval.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_team_vacation_overlap",
|
||
description: "Check if team members have overlapping vacations in a date range.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID to check overlap for" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
},
|
||
required: ["resourceId", "startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── ENTITLEMENT ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_entitlement_summary",
|
||
description: "Get vacation entitlement year summary for all resources or a specific resource.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
year: { type: "integer", description: "Year. Default: current year" },
|
||
resourceName: { type: "string", description: "Filter by resource name (optional)" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "set_entitlement",
|
||
description: "Set the annual vacation entitlement for a resource/year through the real entitlement workflow. Manager or admin role required. Carryover is computed automatically. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID, EID, or display name" },
|
||
year: { type: "integer", description: "Year" },
|
||
entitledDays: { type: "number", description: "Number of entitled vacation days" },
|
||
},
|
||
required: ["resourceId", "year", "entitledDays"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── DEMAND / STAFFING ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_demands",
|
||
description: "List staffing demand requirements for projects. Shows open positions that need to be filled.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Filter by project ID or short code" },
|
||
status: { type: "string", description: "Filter by status: OPEN, PARTIALLY_FILLED, FILLED, CANCELLED" },
|
||
limit: { type: "integer", description: "Max results. Default: 30" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_demand",
|
||
description: "Create a staffing demand requirement on a project. Requires manageAllocations permission. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID or short code" },
|
||
roleName: { type: "string", description: "Role name for the demand" },
|
||
headcount: { type: "integer", description: "Number of people needed. Default: 1" },
|
||
hoursPerDay: { type: "number", description: "Hours per day required" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
},
|
||
required: ["projectId", "roleName", "hoursPerDay", "startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "fill_demand",
|
||
description: "Fill/assign a resource to an open demand requirement. Requires manageAllocations permission. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
demandId: { type: "string", description: "Demand requirement ID" },
|
||
resourceId: { type: "string", description: "Resource ID or name to assign" },
|
||
},
|
||
required: ["demandId", "resourceId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "check_resource_availability",
|
||
description: "Check if a resource is available in a given date range (no conflicting allocations or vacations).",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID, eid, or name" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
},
|
||
required: ["resourceId", "startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_staffing_suggestions",
|
||
description: "Get AI-powered staffing suggestions for a project based on required skills, availability, and cost.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID or short code" },
|
||
roleName: { type: "string", description: "Role to find candidates for" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
limit: { type: "integer", description: "Max suggestions. Default: 5" },
|
||
},
|
||
required: ["projectId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "find_capacity",
|
||
description: "Find resources with available capacity in a date range.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
minHoursPerDay: { type: "number", description: "Minimum available hours/day. Default: 4" },
|
||
roleName: { type: "string", description: "Filter by role name" },
|
||
chapter: { type: "string", description: "Filter by chapter" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
required: ["startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── BLUEPRINT ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_blueprints",
|
||
description: "List available project blueprints with their field definitions.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_blueprint",
|
||
description: "Get detailed blueprint with all field definitions and role presets.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
identifier: { type: "string", description: "Blueprint ID or name (partial match)" },
|
||
},
|
||
required: ["identifier"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── RATE CARDS ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_rate_cards",
|
||
description: "List rate cards with their effective dates and line items.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
query: { type: "string", description: "Search by name" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "resolve_rate",
|
||
description: "Look up the applicable rate for a resource, role, or management level from rate cards.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID or name" },
|
||
roleName: { type: "string", description: "Role name" },
|
||
date: { type: "string", description: "Date to check rate for (YYYY-MM-DD). Default: today" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── ESTIMATES ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_estimate_detail",
|
||
description: "Get one estimate via the real estimate router, including versions, demand lines, metrics, assumptions, and linked project data. Controller/manager/admin access and viewCosts required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID" },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_estimate_versions",
|
||
description: "List estimate versions via the real estimate router, including status, timestamps, and artifact counts. Controller/manager/admin access required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID" },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_estimate_version_snapshot",
|
||
description: "Get an estimate version snapshot via the real estimate router, including totals, breakdowns, exports, and resource snapshots. Controller/manager/admin access and viewCosts required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID" },
|
||
versionId: { type: "string", description: "Optional explicit version ID. Defaults to the latest version." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_estimate",
|
||
description: "Create a new estimate via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Optional project ID." },
|
||
projectCode: { type: "string", description: "Optional project short code convenience alias." },
|
||
name: { type: "string", description: "Estimate name." },
|
||
opportunityId: { type: "string", description: "Optional opportunity/reference ID." },
|
||
baseCurrency: { type: "string", description: "Base currency, e.g. EUR." },
|
||
status: { type: "string", enum: ["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"] },
|
||
versionLabel: { type: "string", description: "Optional working version label." },
|
||
versionNotes: { type: "string", description: "Optional working version notes." },
|
||
assumptions: { type: "array", items: { type: "object" }, description: "Estimate assumptions." },
|
||
scopeItems: { type: "array", items: { type: "object" }, description: "Estimate scope items." },
|
||
demandLines: { type: "array", items: { type: "object" }, description: "Estimate demand lines." },
|
||
resourceSnapshots: { type: "array", items: { type: "object" }, description: "Resource cost snapshots." },
|
||
metrics: { type: "array", items: { type: "object" }, description: "Optional metric overrides." },
|
||
},
|
||
required: ["name"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "clone_estimate",
|
||
description: "Clone an existing estimate via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
sourceEstimateId: { type: "string", description: "Source estimate ID." },
|
||
name: { type: "string", description: "Optional cloned estimate name." },
|
||
projectId: { type: "string", description: "Optional target project ID." },
|
||
projectCode: { type: "string", description: "Optional target project short code convenience alias." },
|
||
},
|
||
required: ["sourceEstimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_estimate_draft",
|
||
description: "Update the working draft of an estimate via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Estimate ID." },
|
||
projectId: { type: "string", description: "Optional linked project ID." },
|
||
projectCode: { type: "string", description: "Optional linked project short code convenience alias." },
|
||
name: { type: "string" },
|
||
opportunityId: { type: "string" },
|
||
baseCurrency: { type: "string" },
|
||
status: { type: "string", enum: ["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"] },
|
||
versionLabel: { type: "string" },
|
||
versionNotes: { type: "string" },
|
||
assumptions: { type: "array", items: { type: "object" } },
|
||
scopeItems: { type: "array", items: { type: "object" } },
|
||
demandLines: { type: "array", items: { type: "object" } },
|
||
resourceSnapshots: { type: "array", items: { type: "object" } },
|
||
metrics: { type: "array", items: { type: "object" } },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "submit_estimate_version",
|
||
description: "Submit an estimate working version for review via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
versionId: { type: "string", description: "Optional explicit version ID." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "approve_estimate_version",
|
||
description: "Approve a submitted estimate version via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
versionId: { type: "string", description: "Optional explicit version ID." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_estimate_revision",
|
||
description: "Create a new working revision from the latest locked estimate version via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
sourceVersionId: { type: "string", description: "Optional source version ID." },
|
||
label: { type: "string", description: "Optional revision label." },
|
||
notes: { type: "string", description: "Optional revision notes." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_estimate_export",
|
||
description: "Create an estimate export artifact via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
versionId: { type: "string", description: "Optional explicit version ID." },
|
||
format: { type: "string", enum: ["XLSX", "CSV", "JSON", "SAP", "MMP"], description: "Export format." },
|
||
},
|
||
required: ["estimateId", "format"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_estimate_planning_handoff",
|
||
description: "Create planning allocations from an approved estimate version via the real estimate router. Manager/admin role and manageAllocations permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
versionId: { type: "string", description: "Optional explicit approved version ID." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "generate_estimate_weekly_phasing",
|
||
description: "Generate weekly phasing for the working estimate version via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
|
||
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
|
||
pattern: { type: "string", enum: ["even", "front_loaded", "back_loaded", "custom"], description: "Distribution pattern." },
|
||
},
|
||
required: ["estimateId", "startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_estimate_weekly_phasing",
|
||
description: "Get generated weekly phasing for an estimate via the real estimate router. Controller/manager/admin access required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_estimate_commercial_terms",
|
||
description: "Get estimate commercial terms via the real estimate router. Controller/manager/admin access required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
versionId: { type: "string", description: "Optional explicit version ID." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_estimate_commercial_terms",
|
||
description: "Update estimate commercial terms on a working version via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
versionId: { type: "string", description: "Optional explicit version ID." },
|
||
terms: { type: "object", description: "Commercial terms payload." },
|
||
},
|
||
required: ["estimateId", "terms"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── ROLES ──
|
||
...rolesAnalyticsMutationToolDefinitions,
|
||
|
||
// ── CLIENTS ──
|
||
...clientMutationToolDefinitions,
|
||
|
||
// ── ADMIN / CONFIG READ TOOLS ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_countries",
|
||
description: "List countries including working hours, schedule rules, active flag, and metro cities.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
includeInactive: { type: "boolean", description: "Include inactive countries. Default: false." },
|
||
search: { type: "string", description: "Optional country code or name search." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_country",
|
||
description: "Get one country with schedule rules, active flag, metro cities, and resource count. Accepts ID, code, or name.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
identifier: { type: "string", description: "Country ID, code, or name." },
|
||
},
|
||
required: ["identifier"],
|
||
},
|
||
},
|
||
},
|
||
...countryMetroAdminToolDefinitions,
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_management_levels",
|
||
description: "List management level groups and their levels with target percentages.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_utilization_categories",
|
||
description: "List utilization categories (cost classification for projects).",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_calculation_rules",
|
||
description: "List calculation rules for cost attribution and chargeability.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_effort_rules",
|
||
description: "List effort estimation rules with their formulas and conditions.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_experience_multipliers",
|
||
description: "List experience multipliers that adjust effort estimates based on seniority.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_users",
|
||
description: "List all system users via the admin user router, including role and MFA state. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
limit: { type: "integer", description: "Max results. Default: 50" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_assignable_users",
|
||
description: "List lightweight users available for assignment workflows. Manager or admin role required.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_current_user",
|
||
description: "Get the authenticated user's own profile, role, and permission overrides.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_dashboard_layout",
|
||
description: "Get the authenticated user's saved dashboard widget layout and last update timestamp.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "save_dashboard_layout",
|
||
description: "Save the authenticated user's dashboard layout. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
layout: {
|
||
type: "array",
|
||
description: "Dashboard layout items as stored by the user router.",
|
||
items: { type: "object" },
|
||
},
|
||
},
|
||
required: ["layout"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_favorite_project_ids",
|
||
description: "Get the authenticated user's favorite project IDs.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "toggle_favorite_project",
|
||
description: "Add or remove a project from the authenticated user's favorites. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID." },
|
||
},
|
||
required: ["projectId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_column_preferences",
|
||
description: "Get the authenticated user's saved table column preferences for all supported views.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "set_column_preferences",
|
||
description: "Update the authenticated user's table column preferences for one view. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
view: {
|
||
type: "string",
|
||
enum: ["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"],
|
||
description: "View key to update.",
|
||
},
|
||
visible: {
|
||
type: "array",
|
||
items: { type: "string" },
|
||
description: "Visible column IDs.",
|
||
},
|
||
sort: {
|
||
type: ["object", "null"],
|
||
properties: {
|
||
field: { type: "string" },
|
||
dir: { type: "string", enum: ["asc", "desc"] },
|
||
},
|
||
description: "Sort state. Use null to clear it.",
|
||
},
|
||
rowOrder: {
|
||
type: ["array", "null"],
|
||
items: { type: "string" },
|
||
description: "Optional row order. Use null to clear it.",
|
||
},
|
||
},
|
||
required: ["view"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "generate_totp_secret",
|
||
description: "Generate a new MFA TOTP secret and provisioning URI for the authenticated user. Always confirm first. The secret is sensitive.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "verify_and_enable_totp",
|
||
description: "Verify a 6-digit MFA TOTP token and enable MFA for the authenticated user. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
token: { type: "string", description: "6-digit TOTP token." },
|
||
},
|
||
required: ["token"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_mfa_status",
|
||
description: "Get the authenticated user's MFA status.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_active_user_count",
|
||
description: "Get the number of users active in the last five minutes. Admin role required.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_user",
|
||
description: "Create a new system user and auto-link a matching resource by email when possible. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
email: { type: "string", description: "User email address." },
|
||
name: { type: "string", description: "Display name." },
|
||
systemRole: { type: "string", enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"], description: "Initial system role." },
|
||
password: { type: "string", description: "Initial password, minimum 8 characters." },
|
||
},
|
||
required: ["email", "name", "password"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "set_user_password",
|
||
description: "Reset a user's password. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "User ID." },
|
||
password: { type: "string", description: "New password, minimum 8 characters." },
|
||
},
|
||
required: ["userId", "password"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_user_role",
|
||
description: "Change a user's system role. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "User ID." },
|
||
systemRole: { type: "string", enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||
},
|
||
required: ["id", "systemRole"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_user_name",
|
||
description: "Rename a user. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "User ID." },
|
||
name: { type: "string", description: "New display name." },
|
||
},
|
||
required: ["id", "name"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "link_user_resource",
|
||
description: "Link or unlink a user to a resource. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "User ID." },
|
||
resourceId: { type: ["string", "null"], description: "Resource ID or null to unlink." },
|
||
},
|
||
required: ["userId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "auto_link_users_by_email",
|
||
description: "Auto-link all users without a resource to matching resources by email. Admin role required. Always confirm first.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "set_user_permissions",
|
||
description: "Set explicit permission overrides for a user. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "User ID." },
|
||
overrides: {
|
||
type: ["object", "null"],
|
||
properties: {
|
||
granted: { type: "array", items: { type: "string" } },
|
||
denied: { type: "array", items: { type: "string" } },
|
||
chapterIds: { type: "array", items: { type: "string" } },
|
||
},
|
||
description: "Permission override object or null to clear.",
|
||
},
|
||
},
|
||
required: ["userId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "reset_user_permissions",
|
||
description: "Reset a user's permission overrides back to role defaults. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "User ID." },
|
||
},
|
||
required: ["userId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_effective_user_permissions",
|
||
description: "Get a user's resolved permissions, role, and explicit overrides. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "User ID." },
|
||
},
|
||
required: ["userId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "disable_user_totp",
|
||
description: "Disable MFA TOTP for a user as an admin override. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "User ID." },
|
||
},
|
||
required: ["userId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_notifications",
|
||
description: "List recent notifications for the current user.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
unreadOnly: { type: "boolean", description: "Only show unread. Default: false" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "mark_notification_read",
|
||
description: "Mark one notification as read, or all unread notifications when no notificationId is provided. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
notificationId: { type: "string", description: "Notification ID. Omit to mark all unread notifications as read." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_unread_notification_count",
|
||
description: "Count unread notifications for the current user.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_notification",
|
||
description: "Create a notification or task-style notification for a specific user. Manager or admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "Target user ID." },
|
||
type: { type: "string", description: "Notification type code." },
|
||
title: { type: "string", description: "Title." },
|
||
body: { type: "string", description: "Optional body text." },
|
||
entityId: { type: "string", description: "Optional linked entity ID." },
|
||
entityType: { type: "string", description: "Optional linked entity type." },
|
||
category: { type: "string", enum: ["NOTIFICATION", "REMINDER", "TASK", "APPROVAL"] },
|
||
priority: { type: "string", enum: ["LOW", "NORMAL", "HIGH", "URGENT"] },
|
||
link: { type: "string", description: "Optional deep link." },
|
||
taskStatus: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"] },
|
||
taskAction: { type: "string", description: "Optional machine-readable task action." },
|
||
assigneeId: { type: "string", description: "Optional assignee user ID." },
|
||
dueDate: { type: "string", format: "date-time", description: "Optional due date." },
|
||
channel: { type: "string", enum: ["in_app", "email", "both"] },
|
||
senderId: { type: "string", description: "Optional sender override." },
|
||
},
|
||
required: ["userId", "type", "title"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── DASHBOARD DETAIL ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_dashboard_detail",
|
||
description: "Get detailed dashboard data: peak allocation times, top-value resources, demand pipeline, chargeability overview.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
section: {
|
||
type: "string",
|
||
description: "Which section: peak_times, top_resources, demand_pipeline, chargeability_overview, or all",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── 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 ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_tasks",
|
||
description: "List tasks and approvals for the current user via the real notification router, optionally including tasks assigned to them.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
status: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"], description: "Optional status filter." },
|
||
includeAssigned: { type: "boolean", description: "Include tasks where the current user is assignee as well as owner. Default: true." },
|
||
limit: { type: "integer", description: "Max results. Default: 20." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_task_counts",
|
||
description: "Get open, in-progress, done, dismissed, and overdue task counts for the current user.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_task_detail",
|
||
description: "Get details of a specific task/notification including linked entity information.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
taskId: { type: "string", description: "Notification/task ID" },
|
||
},
|
||
required: ["taskId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_task_status",
|
||
description: "Update the status of a task. Mark as IN_PROGRESS, DONE, or DISMISSED.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
taskId: { type: "string", description: "Task/notification ID" },
|
||
status: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"], description: "New status" },
|
||
},
|
||
required: ["taskId", "status"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "execute_task_action",
|
||
description: "Execute the machine-readable action associated with a task. For example: approve a vacation, confirm an assignment, etc. The action is encoded in the task's taskAction field.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
taskId: { type: "string", description: "Task/notification ID containing the action to execute" },
|
||
},
|
||
required: ["taskId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_reminder",
|
||
description: "Create a personal reminder for the current user via the real notification router. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
title: { type: "string", description: "Reminder title" },
|
||
body: { type: "string", description: "Optional details" },
|
||
remindAt: { type: "string", format: "date-time", description: "When to remind (ISO 8601 datetime)" },
|
||
recurrence: { type: "string", enum: ["daily", "weekly", "monthly"], description: "Optional recurrence pattern" },
|
||
entityId: { type: "string", description: "Optional: linked entity ID (project, resource, etc.)" },
|
||
entityType: { type: "string", description: "Optional: entity type (project, resource, vacation, etc.)" },
|
||
link: { type: "string", description: "Optional deep link." },
|
||
},
|
||
required: ["title", "remindAt"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_reminders",
|
||
description: "List personal reminders for the current user via the real notification router.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
limit: { type: "integer", description: "Max results. Default: 20." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_reminder",
|
||
description: "Update a personal reminder via the real notification router. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Reminder notification ID." },
|
||
title: { type: "string", description: "Optional reminder title." },
|
||
body: { type: "string", description: "Optional reminder body." },
|
||
remindAt: { type: "string", format: "date-time", description: "Optional reminder timestamp." },
|
||
recurrence: { type: ["string", "null"], enum: ["daily", "weekly", "monthly", null], description: "Optional recurrence update. Use null to clear recurrence." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "delete_reminder",
|
||
description: "Delete a personal reminder via the real notification router. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Reminder notification ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_task_for_user",
|
||
description: "Create a task for a specific user via the real notification router. Manager or admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "Target user ID" },
|
||
title: { type: "string", description: "Task title" },
|
||
body: { type: "string", description: "Task description" },
|
||
priority: { type: "string", enum: ["LOW", "NORMAL", "HIGH", "URGENT"], description: "Priority (default NORMAL)" },
|
||
dueDate: { type: "string", format: "date-time", description: "Optional due date (ISO 8601)" },
|
||
taskAction: { type: "string", description: "Optional machine-readable action (format: action_name:entity_id)" },
|
||
entityId: { type: "string", description: "Optional linked entity ID" },
|
||
entityType: { type: "string", description: "Optional entity type" },
|
||
link: { type: "string", description: "Optional deep link." },
|
||
channel: { type: "string", enum: ["in_app", "email", "both"], description: "Delivery channel. Default: in_app." },
|
||
},
|
||
required: ["userId", "title"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "assign_task",
|
||
description: "Assign or reassign a task to another user via the real notification router. Manager or admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Task notification ID." },
|
||
assigneeId: { type: "string", description: "User ID to assign." },
|
||
},
|
||
required: ["id", "assigneeId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "send_broadcast",
|
||
description: "Create and send a broadcast notification via the real notification router. Manager or admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
title: { type: "string", description: "Notification title" },
|
||
body: { type: "string", description: "Notification body" },
|
||
targetType: { type: "string", enum: ["user", "role", "project", "orgUnit", "all"], description: "Target audience type" },
|
||
targetValue: { type: "string", description: "Target value: user ID, role name (ADMIN/MANAGER/CONTROLLER/USER/VIEWER), project ID, or org unit ID" },
|
||
category: { type: "string", enum: ["NOTIFICATION", "REMINDER", "TASK", "APPROVAL"], description: "Broadcast category. Default: NOTIFICATION." },
|
||
priority: { type: "string", enum: ["LOW", "NORMAL", "HIGH", "URGENT"], description: "Priority (default NORMAL)" },
|
||
channel: { type: "string", enum: ["in_app", "email", "both"], description: "Delivery channel (default in_app)" },
|
||
link: { type: "string", description: "Optional deep-link URL" },
|
||
scheduledAt: { type: "string", format: "date-time", description: "Optional scheduled send timestamp." },
|
||
taskAction: { type: "string", description: "Optional machine-readable task action for task-like broadcasts." },
|
||
dueDate: { type: "string", format: "date-time", description: "Optional due date for task-like broadcasts." },
|
||
},
|
||
required: ["title", "targetType"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_broadcasts",
|
||
description: "List notification broadcasts via the real notification router. Manager or admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
limit: { type: "integer", description: "Max results. Default: 20." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_broadcast_detail",
|
||
description: "Get one notification broadcast via the real notification router. Manager or admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Broadcast ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "delete_notification",
|
||
description: "Delete one of the current user's own notifications via the real notification router. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Notification ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── INSIGHTS & ANOMALIES ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "detect_anomalies",
|
||
description: "Detect anomalies across all active projects: budget burn rate issues, staffing gaps, utilization outliers, and timeline overruns.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_skill_gaps",
|
||
description: "Analyze skill supply vs demand across all active projects. Returns which skills are in short supply relative to demand requirements.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_project_health",
|
||
description: "Get health scores for all active projects based on budget utilization, staffing completeness, and timeline status.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_budget_forecast",
|
||
description: "Get budget utilization and burn rate per active project. Shows total budget, spent, remaining, and whether burn is ahead or behind schedule.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_insights_summary",
|
||
description: "Get a summary of anomaly counts by category (budget, staffing, timeline, utilization) plus critical count.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
|
||
// ── REPORTS & COMMENTS ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "run_report",
|
||
description: "Run a dynamic report query on resources, projects, assignments, or resource-month rows with flexible column selection and filtering.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entity: {
|
||
type: "string",
|
||
enum: ["resource", "project", "assignment", "resource_month"],
|
||
description: "Entity type to query",
|
||
},
|
||
columns: {
|
||
type: "array",
|
||
items: { type: "string" },
|
||
description: "Column keys to include (e.g. 'displayName', 'chapter', 'country.name')",
|
||
},
|
||
filters: {
|
||
type: "array",
|
||
items: {
|
||
type: "object",
|
||
properties: {
|
||
field: { type: "string", description: "Field to filter on" },
|
||
op: { type: "string", enum: ["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"], description: "Filter operator" },
|
||
value: { type: "string", description: "Filter value (string)" },
|
||
},
|
||
required: ["field", "op", "value"],
|
||
},
|
||
description: "Filters to apply",
|
||
},
|
||
periodMonth: {
|
||
type: "string",
|
||
description: "Required for resource_month reports. Format: YYYY-MM",
|
||
},
|
||
groupBy: {
|
||
type: "string",
|
||
description: "Optional scalar field used to group result rows into labeled sections.",
|
||
},
|
||
sortBy: {
|
||
type: "string",
|
||
description: "Optional scalar field used to sort rows within the grouped result.",
|
||
},
|
||
sortDir: {
|
||
type: "string",
|
||
enum: ["asc", "desc"],
|
||
description: "Sort direction for sortBy. Default: asc",
|
||
},
|
||
limit: { type: "integer", description: "Max results. Default: 50" },
|
||
},
|
||
required: ["entity", "columns"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_comments",
|
||
description: `List comments (with replies) for a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required.`,
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entityType: { type: "string", enum: [...COMMENT_ENTITY_TYPE_VALUES], description: getCommentToolEntityDescription() },
|
||
entityId: { type: "string", description: "Entity ID" },
|
||
},
|
||
required: ["entityType", "entityId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "lookup_rate",
|
||
description: "Find the best matching rate card line for given criteria (client, chapter, management level, role, seniority).",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
clientId: { type: "string", description: "Client ID to find rate card for" },
|
||
chapter: { type: "string", description: "Chapter to match" },
|
||
managementLevelId: { type: "string", description: "Management level ID to match" },
|
||
roleName: { type: "string", description: "Role name to match" },
|
||
seniority: { type: "string", description: "Seniority level to match" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── SCENARIO & AI ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "simulate_scenario",
|
||
description: "Run a read-only what-if staffing simulation for a project. Shows cost/hours/utilization impact of adding, removing, or changing resource assignments without persisting changes.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID" },
|
||
changes: {
|
||
type: "array",
|
||
items: {
|
||
type: "object",
|
||
properties: {
|
||
assignmentId: { type: "string", description: "Existing assignment ID to modify (omit for new)" },
|
||
resourceId: { type: "string", description: "Resource ID" },
|
||
roleId: { type: "string", description: "Role ID" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
hoursPerDay: { type: "number", description: "Hours per day" },
|
||
remove: { type: "boolean", description: "Set true to remove an existing assignment" },
|
||
},
|
||
required: ["startDate", "endDate", "hoursPerDay"],
|
||
},
|
||
description: "Array of staffing changes to simulate",
|
||
},
|
||
},
|
||
required: ["projectId", "changes"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "generate_project_narrative",
|
||
description: "Generate an AI-powered executive narrative for a project covering budget, staffing, timeline risk, and action items. Requires AI to be configured.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID" },
|
||
},
|
||
required: ["projectId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_comment",
|
||
description: `Add a comment to a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required. Supports @mentions. Always confirm with the user first.`,
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entityType: { type: "string", enum: [...COMMENT_ENTITY_TYPE_VALUES], description: getCommentToolEntityDescription() },
|
||
entityId: { type: "string", description: "Entity ID" },
|
||
body: { type: "string", description: "Comment body text. Use @[Name](userId) for mentions." },
|
||
},
|
||
required: ["entityType", "entityId", "body"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "resolve_comment",
|
||
description: `Mark a comment as resolved (or unresolve it) on a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required, and only the comment author or an admin can change resolution.`,
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
commentId: { type: "string", description: "Comment ID to resolve" },
|
||
resolved: { type: "boolean", description: "Set to true to resolve, false to unresolve. Default: true" },
|
||
},
|
||
required: ["commentId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "query_change_history",
|
||
description: "Search the audit history for changes to projects, resources, allocations, vacations, or any entity. Reuses the real audit log list API. Controller/manager/admin roles only.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entityType: { type: "string", description: "Filter by entity type (e.g. 'Project', 'Resource', 'Allocation', 'Vacation', 'Role', 'Estimate')" },
|
||
search: { type: "string", description: "Search in entity name or summary text" },
|
||
userId: { type: "string", description: "Filter by user ID who made the change" },
|
||
daysBack: { type: "integer", description: "How many days back to search. Default: 7" },
|
||
action: { type: "string", description: "Filter by action type: CREATE, UPDATE, DELETE, SHIFT, IMPORT" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_entity_timeline",
|
||
description: "Get the audit history for a specific entity (project, resource, etc.) via the real audit API. Controller/manager/admin roles only.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entityType: { type: "string", description: "Entity type (e.g. 'Project', 'Resource', 'Allocation')" },
|
||
entityId: { type: "string", description: "Entity ID" },
|
||
limit: { type: "integer", description: "Max results. Default: 50" },
|
||
},
|
||
required: ["entityType", "entityId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "export_resources_csv",
|
||
description: "Export the current active resource list as CSV via the real import/export router. Controller/manager/admin roles only.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "export_projects_csv",
|
||
description: "Export the current project list as CSV via the real import/export router. Controller/manager/admin roles only.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "import_csv_data",
|
||
description: "Import CSV-style row data for resources, projects, or allocations via the real import/export router. Requires manager/admin, importData permission, and confirmation.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entityType: { type: "string", enum: ["resources", "projects", "allocations"], description: "Import target entity type." },
|
||
rows: {
|
||
type: "array",
|
||
description: "CSV rows already parsed to key/value objects.",
|
||
items: {
|
||
type: "object",
|
||
additionalProperties: { type: "string" },
|
||
},
|
||
},
|
||
dryRun: { type: "boolean", description: "Validate only without persisting changes. Default: true." },
|
||
},
|
||
required: ["entityType", "rows"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_dispo_import_batches",
|
||
description: "List Dispo import batches with pagination and optional status filter via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
status: { type: "string", description: "Optional batch status filter." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
|
||
cursor: { type: "string", description: "Optional pagination cursor." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_dispo_import_batch",
|
||
description: "Get one Dispo import batch including staged record counters via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Import batch ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "stage_dispo_import_batch",
|
||
description: "Stage a Dispo import batch via the real dispo router. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
planningWorkbookPath: { type: "string", description: "Filesystem path to the planning workbook." },
|
||
referenceWorkbookPath: { type: "string", description: "Filesystem path to the reference workbook." },
|
||
chargeabilityWorkbookPath: { type: "string", description: "Filesystem path to the chargeability workbook." },
|
||
costWorkbookPath: { type: "string", description: "Optional filesystem path to the cost workbook." },
|
||
rosterWorkbookPath: { type: "string", description: "Optional filesystem path to the roster workbook." },
|
||
notes: { type: "string", description: "Optional import notes." },
|
||
},
|
||
required: ["planningWorkbookPath", "referenceWorkbookPath", "chargeabilityWorkbookPath"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "validate_dispo_import_batch",
|
||
description: "Validate a Dispo import batch readiness check via the real dispo router without committing anything. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
planningWorkbookPath: { type: "string", description: "Filesystem path to the planning workbook." },
|
||
referenceWorkbookPath: { type: "string", description: "Filesystem path to the reference workbook." },
|
||
chargeabilityWorkbookPath: { type: "string", description: "Filesystem path to the chargeability workbook." },
|
||
costWorkbookPath: { type: "string", description: "Optional filesystem path to the cost workbook." },
|
||
rosterWorkbookPath: { type: "string", description: "Optional filesystem path to the roster workbook." },
|
||
importBatchId: { type: "string", description: "Optional existing staged import batch ID." },
|
||
notes: { type: "string", description: "Optional import notes." },
|
||
},
|
||
required: ["planningWorkbookPath", "referenceWorkbookPath", "chargeabilityWorkbookPath"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "cancel_dispo_import_batch",
|
||
description: "Cancel a staged Dispo import batch via the real dispo router. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Import batch ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_dispo_staged_resources",
|
||
description: "List staged Dispo resources for one import batch via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
importBatchId: { type: "string", description: "Import batch ID." },
|
||
status: { type: "string", description: "Optional staged record status filter." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
|
||
cursor: { type: "string", description: "Optional pagination cursor." },
|
||
},
|
||
required: ["importBatchId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_dispo_staged_projects",
|
||
description: "List staged Dispo projects for one import batch via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
importBatchId: { type: "string", description: "Import batch ID." },
|
||
status: { type: "string", description: "Optional staged record status filter." },
|
||
isTbd: { type: "boolean", description: "Optional TBD-project filter." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
|
||
cursor: { type: "string", description: "Optional pagination cursor." },
|
||
},
|
||
required: ["importBatchId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_dispo_staged_assignments",
|
||
description: "List staged Dispo assignments for one import batch via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
importBatchId: { type: "string", description: "Import batch ID." },
|
||
status: { type: "string", description: "Optional staged record status filter." },
|
||
resourceExternalId: { type: "string", description: "Optional resource external ID filter." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
|
||
cursor: { type: "string", description: "Optional pagination cursor." },
|
||
},
|
||
required: ["importBatchId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_dispo_staged_vacations",
|
||
description: "List staged Dispo vacations for one import batch via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
importBatchId: { type: "string", description: "Import batch ID." },
|
||
resourceExternalId: { type: "string", description: "Optional resource external ID filter." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
|
||
cursor: { type: "string", description: "Optional pagination cursor." },
|
||
},
|
||
required: ["importBatchId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_dispo_staged_unresolved_records",
|
||
description: "List staged unresolved Dispo records for one import batch via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
importBatchId: { type: "string", description: "Import batch ID." },
|
||
recordType: { type: "string", description: "Optional unresolved record type filter." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
|
||
cursor: { type: "string", description: "Optional pagination cursor." },
|
||
},
|
||
required: ["importBatchId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "resolve_dispo_staged_record",
|
||
description: "Resolve one staged Dispo record via the real dispo router. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Staged record ID." },
|
||
recordType: { type: "string", description: "Staged record type." },
|
||
action: { type: "string", enum: ["APPROVE", "REJECT", "SKIP"], description: "Resolution action." },
|
||
},
|
||
required: ["id", "recordType", "action"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "commit_dispo_import_batch",
|
||
description: "Commit a staged Dispo import batch via the real dispo router. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
importBatchId: { type: "string", description: "Import batch ID." },
|
||
allowTbdUnresolved: { type: "boolean", description: "Allow unresolved TBD projects during commit." },
|
||
importTbdProjects: { type: "boolean", description: "Whether TBD projects should be imported." },
|
||
},
|
||
required: ["importBatchId"],
|
||
},
|
||
},
|
||
},
|
||
...settingsAdminToolDefinitions,
|
||
];
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
/** Resolve a responsible person name against existing resources. Returns the exact displayName or an error object. */
|
||
async function resolveResponsiblePerson(
|
||
name: string,
|
||
ctx: ToolContext,
|
||
): Promise<{ displayName: string } | { error: string }> {
|
||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.resolveResponsiblePersonName({ name });
|
||
|
||
if (result.status === "resolved") {
|
||
return { displayName: result.displayName };
|
||
}
|
||
|
||
if (result.status === "ambiguous" || result.status === "missing") {
|
||
return { error: result.message };
|
||
}
|
||
|
||
return { error: `Unable to resolve responsible person: ${name}` };
|
||
}
|
||
|
||
// ─── Tool Executors ─────────────────────────────────────────────────────────
|
||
|
||
const executors = {
|
||
async search_resources(params: {
|
||
query?: string; country?: string; metroCity?: string;
|
||
orgUnit?: string; roleName?: string;
|
||
isActive?: boolean; limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
||
return caller.listSummariesDetail({
|
||
search: params.query,
|
||
country: params.country,
|
||
metroCity: params.metroCity,
|
||
orgUnit: params.orgUnit,
|
||
roleName: params.roleName,
|
||
isActive: params.isActive ?? true,
|
||
limit: Math.min(params.limit ?? 50, 100),
|
||
});
|
||
},
|
||
|
||
async get_resource(params: { identifier: string }, ctx: ToolContext) {
|
||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
||
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;
|
||
},
|
||
|
||
...createAdvancedTimelineExecutors({
|
||
assertPermission,
|
||
createStaffingCaller,
|
||
createTimelineCaller,
|
||
createScopedCallerContext,
|
||
resolveProjectIdentifier,
|
||
resolveResourceIdentifier,
|
||
parseIsoDate,
|
||
fmtDate,
|
||
isAssistantToolErrorResult,
|
||
toAssistantIndexedFieldError,
|
||
toAssistantTimelineMutationError,
|
||
}),
|
||
...createAllocationPlanningExecutors({
|
||
assertPermission,
|
||
createAllocationCaller,
|
||
createTimelineCaller,
|
||
createScopedCallerContext,
|
||
resolveProjectIdentifier,
|
||
resolveResourceIdentifier,
|
||
parseIsoDate,
|
||
parseOptionalIsoDate,
|
||
fmtDate,
|
||
toAssistantAllocationNotFoundError,
|
||
}),
|
||
...createVacationHolidayExecutors({
|
||
createEntitlementCaller,
|
||
createVacationCaller,
|
||
createHolidayCalendarCaller,
|
||
createScopedCallerContext,
|
||
resolveResourceIdentifier,
|
||
resolveHolidayPeriod,
|
||
resolveEntityOrAssistantError,
|
||
assertAdminRole,
|
||
fmtDate,
|
||
formatHolidayCalendar,
|
||
formatHolidayCalendarEntry,
|
||
toAssistantHolidayCalendarMutationError,
|
||
toAssistantHolidayCalendarNotFoundError,
|
||
toAssistantHolidayEntryMutationError,
|
||
toAssistantHolidayEntryNotFoundError,
|
||
}),
|
||
...createRolesAnalyticsExecutors({
|
||
createRoleCaller,
|
||
createResourceCaller,
|
||
createDashboardCaller,
|
||
createScopedCallerContext,
|
||
resolveResourceIdentifier,
|
||
toAssistantRoleMutationError,
|
||
}),
|
||
...createClientsOrgUnitsExecutors({
|
||
createClientCaller,
|
||
createOrgUnitCaller,
|
||
createScopedCallerContext,
|
||
toAssistantClientMutationError,
|
||
toAssistantOrgUnitMutationError,
|
||
}),
|
||
...createChargeabilityComputationExecutors({
|
||
assertPermission,
|
||
createChargeabilityReportCaller,
|
||
createComputationGraphCaller,
|
||
createScopedCallerContext,
|
||
resolveResourceIdentifier,
|
||
resolveProjectIdentifier,
|
||
}),
|
||
|
||
async search_estimates(params: {
|
||
projectCode?: string; query?: string; status?: string; limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let projectId: string | undefined;
|
||
if (params.projectCode) {
|
||
const project = await resolveProjectIdentifier(ctx, params.projectCode);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
projectId = project.id;
|
||
}
|
||
|
||
return caller.list({
|
||
...(params.query ? { query: params.query } : {}),
|
||
...(params.status ? { status: params.status as EstimateStatus } : {}),
|
||
...(projectId ? { projectId } : {}),
|
||
});
|
||
},
|
||
|
||
async list_clients(params: { query?: string; limit?: number }, ctx: ToolContext) {
|
||
const limit = Math.min(params.limit ?? 20, 50);
|
||
const caller = createClientCaller(createScopedCallerContext(ctx));
|
||
const clients = await caller.list({
|
||
isActive: true,
|
||
...(params.query ? { search: params.query } : {}),
|
||
});
|
||
return clients.slice(0, limit).map((c) => ({
|
||
id: c.id,
|
||
name: c.name,
|
||
code: c.code,
|
||
projectCount: c._count.projects,
|
||
}));
|
||
},
|
||
|
||
async list_org_units(params: { level?: number }, ctx: ToolContext) {
|
||
const caller = createOrgUnitCaller(createScopedCallerContext(ctx));
|
||
const units = await caller.list({
|
||
isActive: true,
|
||
...(params.level !== undefined ? { level: params.level } : {}),
|
||
});
|
||
const details = await Promise.all(units.map((unit) => caller.getById({ id: unit.id })));
|
||
|
||
return details.map((u) => ({
|
||
id: u.id,
|
||
name: u.name,
|
||
shortName: u.shortName,
|
||
level: u.level,
|
||
parent: u.parent?.name ?? null,
|
||
resourceCount: u._count.resources,
|
||
}));
|
||
},
|
||
|
||
// ── NAVIGATION TOOLS ──
|
||
|
||
async get_my_timeline_entries_view(params: {
|
||
startDate: string;
|
||
endDate: string;
|
||
resourceIds?: string[];
|
||
projectIds?: string[];
|
||
clientIds?: string[];
|
||
chapters?: string[];
|
||
eids?: string[];
|
||
countryCodes?: string[];
|
||
}, ctx: ToolContext) {
|
||
const caller = createTimelineCaller(createScopedCallerContext(ctx));
|
||
return caller.getMyEntriesView({
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
...(params.resourceIds ? { resourceIds: params.resourceIds } : {}),
|
||
...(params.projectIds ? { projectIds: params.projectIds } : {}),
|
||
...(params.clientIds ? { clientIds: params.clientIds } : {}),
|
||
...(params.chapters ? { chapters: params.chapters } : {}),
|
||
...(params.eids ? { eids: params.eids } : {}),
|
||
...(params.countryCodes ? { countryCodes: params.countryCodes } : {}),
|
||
});
|
||
},
|
||
|
||
async get_my_timeline_holiday_overlays(params: {
|
||
startDate: string;
|
||
endDate: string;
|
||
resourceIds?: string[];
|
||
projectIds?: string[];
|
||
clientIds?: string[];
|
||
chapters?: string[];
|
||
eids?: string[];
|
||
countryCodes?: string[];
|
||
}, ctx: ToolContext) {
|
||
const caller = createTimelineCaller(createScopedCallerContext(ctx));
|
||
return caller.getMyHolidayOverlays({
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
...(params.resourceIds ? { resourceIds: params.resourceIds } : {}),
|
||
...(params.projectIds ? { projectIds: params.projectIds } : {}),
|
||
...(params.clientIds ? { clientIds: params.clientIds } : {}),
|
||
...(params.chapters ? { chapters: params.chapters } : {}),
|
||
...(params.eids ? { eids: params.eids } : {}),
|
||
...(params.countryCodes ? { countryCodes: params.countryCodes } : {}),
|
||
});
|
||
},
|
||
|
||
async navigate_to_page(params: {
|
||
page: string;
|
||
eids?: string; chapters?: string; projectIds?: string; clientIds?: string;
|
||
countryCodes?: string; startDate?: string; days?: number;
|
||
}, _ctx: ToolContext) {
|
||
const pageMap: Record<string, string> = {
|
||
timeline: "/timeline",
|
||
dashboard: "/dashboard",
|
||
resources: "/resources",
|
||
projects: "/projects",
|
||
allocations: "/allocations",
|
||
staffing: "/staffing",
|
||
estimates: "/estimates",
|
||
vacations: "/vacations",
|
||
"my-vacations": "/vacations/my",
|
||
roles: "/roles",
|
||
"skills-analytics": "/analytics/skills",
|
||
chargeability: "/reports/chargeability",
|
||
"computation-graph": "/analytics/computation-graph",
|
||
};
|
||
const path = pageMap[params.page];
|
||
if (!path) return { error: `Unknown page: ${params.page}. Available: ${Object.keys(pageMap).join(", ")}` };
|
||
|
||
// Build query params for pages that support them
|
||
const queryParts: string[] = [];
|
||
if (params.eids) queryParts.push(`eids=${encodeURIComponent(params.eids)}`);
|
||
if (params.chapters) queryParts.push(`chapters=${encodeURIComponent(params.chapters)}`);
|
||
if (params.projectIds) queryParts.push(`projectIds=${encodeURIComponent(params.projectIds)}`);
|
||
if (params.clientIds) queryParts.push(`clientIds=${encodeURIComponent(params.clientIds)}`);
|
||
if (params.countryCodes) queryParts.push(`countryCodes=${encodeURIComponent(params.countryCodes)}`);
|
||
if (params.startDate) queryParts.push(`startDate=${encodeURIComponent(params.startDate)}`);
|
||
if (params.days) queryParts.push(`days=${params.days}`);
|
||
|
||
const url = queryParts.length > 0 ? `${path}?${queryParts.join("&")}` : path;
|
||
|
||
return {
|
||
__action: "navigate",
|
||
url,
|
||
description: `Navigiere zu ${path}`,
|
||
};
|
||
},
|
||
|
||
// ── WRITE TOOLS ──
|
||
|
||
async update_resource(params: {
|
||
id: string; displayName?: string; fte?: number;
|
||
lcrCents?: number; chapter?: string; chargeabilityTarget?: number;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageResources" as PermissionKey);
|
||
const resource = await resolveResourceIdentifier(ctx, params.id);
|
||
if ("error" in resource) return resource;
|
||
|
||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
||
const data = UpdateResourceSchema.parse({
|
||
...(params.displayName !== undefined ? { displayName: params.displayName } : {}),
|
||
...(params.fte !== undefined ? { fte: params.fte } : {}),
|
||
...(params.lcrCents !== undefined ? { lcrCents: params.lcrCents } : {}),
|
||
...(params.chapter !== undefined ? { chapter: params.chapter } : {}),
|
||
...(params.chargeabilityTarget !== undefined ? { chargeabilityTarget: params.chargeabilityTarget } : {}),
|
||
});
|
||
|
||
const updatedFields = Object.keys(data);
|
||
if (updatedFields.length === 0) return { error: "No fields to update" };
|
||
|
||
let updated;
|
||
try {
|
||
updated = await caller.update({ id: resource.id, data });
|
||
} catch (error) {
|
||
const mapped = toAssistantResourceMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["resource"],
|
||
success: true,
|
||
message: `Updated resource ${updated.displayName} (${updated.eid})`,
|
||
updatedFields,
|
||
};
|
||
},
|
||
|
||
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: {
|
||
eid: string; displayName: string; email?: string;
|
||
fte?: number; lcrCents: number; ucrCents?: number;
|
||
chapter?: string; chargeabilityTarget?: number;
|
||
roleName?: string; countryCode?: string; orgUnitName?: string;
|
||
postalCode?: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageResources" as PermissionKey);
|
||
if (!params.email?.trim()) {
|
||
return { error: "email is required to create a resource." };
|
||
}
|
||
|
||
const roleCaller = createRoleCaller(createScopedCallerContext(ctx));
|
||
const countryCaller = createCountryCaller(createScopedCallerContext(ctx));
|
||
const orgUnitCaller = createOrgUnitCaller(createScopedCallerContext(ctx));
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const data: Record<string, any> = {
|
||
eid: params.eid,
|
||
displayName: params.displayName,
|
||
email: params.email,
|
||
lcrCents: params.lcrCents,
|
||
ucrCents: params.ucrCents ?? Math.round(params.lcrCents * 0.7),
|
||
chargeabilityTarget: params.chargeabilityTarget ?? 80,
|
||
currency: "EUR",
|
||
availability: {
|
||
monday: 8,
|
||
tuesday: 8,
|
||
wednesday: 8,
|
||
thursday: 8,
|
||
friday: 8,
|
||
},
|
||
skills: [],
|
||
dynamicFields: {},
|
||
...(params.fte !== undefined ? { fte: params.fte } : {}),
|
||
};
|
||
if (params.chapter) data.chapter = params.chapter;
|
||
if (params.postalCode) data.postalCode = params.postalCode;
|
||
|
||
if (params.roleName) {
|
||
const role = await resolveEntityOrAssistantError(
|
||
() => roleCaller.resolveByIdentifier({ identifier: params.roleName! }),
|
||
`Role not found: "${params.roleName}"`,
|
||
);
|
||
if ("error" in role) {
|
||
return role;
|
||
}
|
||
data.roleId = role.id;
|
||
}
|
||
if (params.countryCode) {
|
||
const country = await resolveEntityOrAssistantError(
|
||
() => countryCaller.resolveByIdentifier({ identifier: params.countryCode! }),
|
||
`Country not found: "${params.countryCode}"`,
|
||
);
|
||
if ("error" in country) {
|
||
return country;
|
||
}
|
||
data.countryId = country.id;
|
||
}
|
||
if (params.orgUnitName) {
|
||
const orgUnit = await resolveEntityOrAssistantError(
|
||
() => orgUnitCaller.resolveByIdentifier({ identifier: params.orgUnitName! }),
|
||
`Org unit not found: "${params.orgUnitName}"`,
|
||
);
|
||
if ("error" in orgUnit) {
|
||
return orgUnit;
|
||
}
|
||
data.orgUnitId = orgUnit.id;
|
||
}
|
||
|
||
const input = CreateResourceSchema.parse(data);
|
||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
||
let resource;
|
||
try {
|
||
resource = await caller.create(input);
|
||
} catch (error) {
|
||
const mapped = toAssistantResourceCreationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["resource"],
|
||
success: true,
|
||
message: `Created resource: ${resource.displayName} (${resource.eid})`,
|
||
resourceId: resource.id,
|
||
};
|
||
},
|
||
|
||
async deactivate_resource(params: { identifier: string }, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageResources" as PermissionKey);
|
||
const resource = await resolveResourceIdentifier(ctx, params.identifier);
|
||
if ("error" in resource) return resource;
|
||
|
||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
await caller.deactivate({ id: resource.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantResourceMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["resource"],
|
||
success: true,
|
||
message: `Deactivated resource: ${resource.displayName} (${resource.eid})`,
|
||
};
|
||
},
|
||
|
||
// ── VACATION MANAGEMENT ──
|
||
|
||
async create_vacation(params: {
|
||
resourceId: string; type: string;
|
||
startDate: string; endDate: string;
|
||
isHalfDay?: boolean; halfDayPart?: string; note?: string;
|
||
}, ctx: ToolContext) {
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) return resource;
|
||
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
const type = parseAssistantVacationRequestType(params.type);
|
||
let vacation;
|
||
try {
|
||
vacation = await caller.create({
|
||
resourceId: resource.id,
|
||
type,
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
...(params.isHalfDay !== undefined ? { isHalfDay: params.isHalfDay } : {}),
|
||
...(params.halfDayPart !== undefined ? { halfDayPart: params.halfDayPart as "MORNING" | "AFTERNOON" } : {}),
|
||
...(params.note !== undefined ? { note: params.note } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationCreationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
const effectiveDays = "effectiveDays" in vacation && typeof vacation.effectiveDays === "number"
|
||
? vacation.effectiveDays
|
||
: null;
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["vacation"],
|
||
success: true,
|
||
message: `Created ${type} for ${resource.displayName}: ${params.startDate} to ${params.endDate} (status: ${vacation.status}${effectiveDays !== null ? `, deducted ${effectiveDays} day(s)` : ""})`,
|
||
vacationId: vacation.id,
|
||
vacation,
|
||
};
|
||
},
|
||
|
||
async approve_vacation(params: { vacationId: string }, ctx: ToolContext) {
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
let existing;
|
||
try {
|
||
existing = await caller.getById({ id: params.vacationId });
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationMutationError(error, "approve");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
let approved;
|
||
try {
|
||
approved = await caller.approve({ id: params.vacationId });
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationMutationError(error, "approve");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["vacation"],
|
||
success: true,
|
||
warnings: approved.warnings,
|
||
vacation: approved,
|
||
message: `Approved vacation for ${existing.resource?.displayName ?? params.vacationId}`,
|
||
};
|
||
},
|
||
|
||
async reject_vacation(params: { vacationId: string; reason?: string }, ctx: ToolContext) {
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
let existing;
|
||
try {
|
||
existing = await caller.getById({ id: params.vacationId });
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationMutationError(error, "reject");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
let rejected;
|
||
try {
|
||
rejected = await caller.reject({
|
||
id: params.vacationId,
|
||
...(params.reason !== undefined ? { rejectionReason: params.reason } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationMutationError(error, "reject");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["vacation"],
|
||
success: true,
|
||
vacation: rejected,
|
||
message: `Rejected vacation for ${existing.resource?.displayName ?? params.vacationId}${params.reason ? `: ${params.reason}` : ""}`,
|
||
};
|
||
},
|
||
|
||
async cancel_vacation(params: { vacationId: string }, ctx: ToolContext) {
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
let existing;
|
||
try {
|
||
existing = await caller.getById({ id: params.vacationId });
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationMutationError(error, "cancel");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
let cancelled;
|
||
try {
|
||
cancelled = await caller.cancel({ id: params.vacationId });
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationMutationError(error, "cancel");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["vacation"],
|
||
success: true,
|
||
vacation: cancelled,
|
||
message: `Cancelled vacation for ${existing.resource?.displayName ?? params.vacationId}`,
|
||
};
|
||
},
|
||
|
||
async get_pending_vacation_approvals(params: { limit?: number }, ctx: ToolContext) {
|
||
const limit = Math.min(params.limit ?? 20, 50);
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
const vacations = await caller.getPendingApprovals();
|
||
return vacations.map((v) => ({
|
||
id: v.id,
|
||
resource: v.resource.displayName,
|
||
eid: v.resource.eid,
|
||
chapter: v.resource.chapter,
|
||
type: v.type,
|
||
start: fmtDate(v.startDate),
|
||
end: fmtDate(v.endDate),
|
||
isHalfDay: v.isHalfDay,
|
||
})).slice(0, limit);
|
||
},
|
||
|
||
async get_team_vacation_overlap(params: {
|
||
resourceId: string; startDate: string; endDate: string;
|
||
}, ctx: ToolContext) {
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) {
|
||
return resource;
|
||
}
|
||
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
return caller.getTeamOverlapDetail({
|
||
resourceId: resource.id,
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
});
|
||
},
|
||
|
||
// ── ENTITLEMENT ──
|
||
|
||
async get_entitlement_summary(params: { year?: number; resourceName?: string }, ctx: ToolContext) {
|
||
const year = params.year ?? new Date().getFullYear();
|
||
const caller = createEntitlementCaller(createScopedCallerContext(ctx));
|
||
return caller.getYearSummaryDetail({
|
||
year,
|
||
...(params.resourceName ? { resourceName: params.resourceName } : {}),
|
||
});
|
||
},
|
||
|
||
async set_entitlement(params: {
|
||
resourceId: string; year: number; entitledDays: number; carryoverDays?: number;
|
||
}, ctx: ToolContext) {
|
||
if (params.carryoverDays !== undefined) {
|
||
return {
|
||
error: "Manual carryoverDays is not supported here. Carryover is computed automatically from prior-year balances.",
|
||
};
|
||
}
|
||
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) return resource;
|
||
|
||
const caller = createEntitlementCaller(createScopedCallerContext(ctx));
|
||
let entitlement;
|
||
try {
|
||
entitlement = await caller.set({
|
||
resourceId: resource.id,
|
||
year: params.year,
|
||
entitledDays: params.entitledDays,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEntitlementMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["vacation"],
|
||
success: true,
|
||
entitlement,
|
||
message: `Set entitlement for ${resource.displayName} (${params.year}): ${params.entitledDays} days`,
|
||
};
|
||
},
|
||
|
||
// ── DEMAND / STAFFING ──
|
||
|
||
async list_demands(params: { projectId?: string; status?: string; limit?: number }, ctx: ToolContext) {
|
||
const limit = Math.min(params.limit ?? 30, 50);
|
||
const caller = createAllocationCaller(createScopedCallerContext(ctx));
|
||
const resolvedProject = params.projectId
|
||
? await resolveProjectIdentifier(ctx, params.projectId)
|
||
: null;
|
||
if (resolvedProject && "error" in resolvedProject) {
|
||
return resolvedProject;
|
||
}
|
||
const demands = await caller.listDemands({
|
||
...(resolvedProject ? { projectId: resolvedProject.id } : {}),
|
||
...(params.status ? { status: params.status as AllocationStatus } : {}),
|
||
});
|
||
|
||
return demands.map((d) => ({
|
||
id: d.id,
|
||
project: d.project.name,
|
||
projectCode: d.project.shortCode,
|
||
role: d.roleEntity?.name ?? d.role ?? "Unspecified",
|
||
status: d.status,
|
||
headcount: d.headcount,
|
||
filled: d.assignments.length,
|
||
remaining: d.headcount - d.assignments.length,
|
||
hoursPerDay: d.hoursPerDay,
|
||
start: fmtDate(d.startDate),
|
||
end: fmtDate(d.endDate),
|
||
})).slice(0, limit);
|
||
},
|
||
|
||
async create_demand(params: {
|
||
projectId: string; roleName: string; headcount?: number;
|
||
hoursPerDay: number; startDate: string; endDate: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageAllocations" as PermissionKey);
|
||
const roleCaller = createRoleCaller(createScopedCallerContext(ctx));
|
||
const [project, role] = await Promise.all([
|
||
resolveProjectIdentifier(ctx, params.projectId),
|
||
resolveEntityOrAssistantError(
|
||
() => roleCaller.resolveByIdentifier({ identifier: params.roleName }),
|
||
`Role not found: ${params.roleName}`,
|
||
),
|
||
]);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
if ("error" in role) {
|
||
return role;
|
||
}
|
||
|
||
const caller = createAllocationCaller(createScopedCallerContext(ctx));
|
||
let demand;
|
||
try {
|
||
demand = await caller.createDemand({
|
||
projectId: project.id,
|
||
roleId: role.id,
|
||
role: role.name,
|
||
headcount: params.headcount ?? 1,
|
||
hoursPerDay: params.hoursPerDay,
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantDemandCreationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["allocation"],
|
||
success: true,
|
||
message: `Created demand: ${role.name} × ${params.headcount ?? 1} for ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`,
|
||
demandId: demand.id,
|
||
};
|
||
},
|
||
|
||
async fill_demand(params: { demandId: string; resourceId: string }, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageAllocations" as PermissionKey);
|
||
const allocationCaller = createAllocationCaller(createScopedCallerContext(ctx));
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) return resource;
|
||
|
||
let result;
|
||
try {
|
||
result = await allocationCaller.assignResourceToDemand({
|
||
demandRequirementId: params.demandId,
|
||
resourceId: resource.id,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantDemandFillError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
const roleName = result.demandRequirement.roleEntity?.name ?? result.demandRequirement.role ?? null;
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["allocation", "timeline"],
|
||
success: true,
|
||
message: `Assigned ${resource.displayName} to ${roleName ?? "demand"} on ${result.demandRequirement.project.name} (${result.demandRequirement.project.shortCode})`,
|
||
assignmentId: result.assignment.id,
|
||
};
|
||
},
|
||
|
||
async check_resource_availability(params: {
|
||
resourceId: string; startDate: string; endDate: string;
|
||
}, ctx: ToolContext) {
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) {
|
||
return resource;
|
||
}
|
||
|
||
const caller = createAllocationCaller(createScopedCallerContext(ctx));
|
||
return caller.getResourceAvailabilitySummary({
|
||
resourceId: resource.id,
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
});
|
||
},
|
||
|
||
async get_staffing_suggestions(params: {
|
||
projectId: string; roleName?: string;
|
||
startDate?: string; endDate?: string; limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const project = await resolveProjectIdentifier(ctx, params.projectId);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
|
||
const caller = createStaffingCaller(createScopedCallerContext(ctx));
|
||
const startDate = parseOptionalIsoDate(params.startDate, "startDate");
|
||
const endDate = parseOptionalIsoDate(params.endDate, "endDate");
|
||
return caller.getProjectStaffingSuggestions({
|
||
projectId: project.id,
|
||
...(params.roleName ? { roleName: params.roleName } : {}),
|
||
...(startDate ? { startDate } : {}),
|
||
...(endDate ? { endDate } : {}),
|
||
...(params.limit ? { limit: params.limit } : {}),
|
||
});
|
||
},
|
||
|
||
async find_capacity(params: {
|
||
startDate: string; endDate: string;
|
||
minHoursPerDay?: number; roleName?: string;
|
||
chapter?: string; limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const caller = createStaffingCaller(createScopedCallerContext(ctx));
|
||
return caller.searchCapacity({
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
minHoursPerDay: params.minHoursPerDay ?? 4,
|
||
...(params.roleName ? { roleName: params.roleName } : {}),
|
||
...(params.chapter ? { chapter: params.chapter } : {}),
|
||
...(params.limit ? { limit: params.limit } : {}),
|
||
});
|
||
},
|
||
|
||
// ── BLUEPRINT ──
|
||
|
||
async list_blueprints(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createBlueprintCaller(createScopedCallerContext(ctx));
|
||
const blueprints = await caller.listSummaries();
|
||
return blueprints.map((b) => ({
|
||
id: b.id,
|
||
name: b.name,
|
||
projectCount: b._count.projects,
|
||
}));
|
||
},
|
||
|
||
async get_blueprint(params: { identifier: string }, ctx: ToolContext) {
|
||
const caller = createBlueprintCaller(createScopedCallerContext(ctx));
|
||
const bp = await resolveEntityOrAssistantError(
|
||
() => caller.getByIdentifier({ identifier: params.identifier }),
|
||
`Blueprint not found: ${params.identifier}`,
|
||
);
|
||
if ("error" in bp) {
|
||
return bp;
|
||
}
|
||
return {
|
||
id: bp.id,
|
||
name: bp.name,
|
||
fieldDefs: bp.fieldDefs,
|
||
rolePresets: bp.rolePresets,
|
||
};
|
||
},
|
||
|
||
// ── RATE CARDS ──
|
||
|
||
async list_rate_cards(params: { query?: string; limit?: number }, ctx: ToolContext) {
|
||
const caller = createRateCardCaller(createScopedCallerContext(ctx));
|
||
const cards = await caller.list({
|
||
isActive: true,
|
||
...(params.query ? { search: params.query } : {}),
|
||
});
|
||
return cards.map((c) => ({
|
||
id: c.id,
|
||
name: c.name,
|
||
effectiveFrom: fmtDate(c.effectiveFrom),
|
||
effectiveTo: fmtDate(c.effectiveTo),
|
||
lineCount: c._count.lines,
|
||
})).slice(0, Math.min(params.limit ?? 20, 50));
|
||
},
|
||
|
||
async resolve_rate(params: { resourceId?: string; roleName?: string; date?: string }, ctx: ToolContext) {
|
||
const caller = createRateCardCaller(createScopedCallerContext(ctx));
|
||
const date = parseOptionalIsoDate(params.date, "date");
|
||
if (params.resourceId) {
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) {
|
||
return resource;
|
||
}
|
||
return caller.resolveBestRate({
|
||
resourceId: resource.id,
|
||
...(date ? { date } : {}),
|
||
});
|
||
}
|
||
|
||
return caller.resolveBestRate({
|
||
...(params.roleName ? { roleName: params.roleName } : {}),
|
||
...(date ? { date } : {}),
|
||
});
|
||
},
|
||
|
||
// ── ESTIMATES ──
|
||
|
||
async get_estimate_detail(params: { estimateId: string }, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.VIEW_COSTS);
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getById({ id: params.estimateId });
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async list_estimate_versions(params: { estimateId: string }, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.listVersions({ estimateId: params.estimateId });
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async get_estimate_version_snapshot(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.VIEW_COSTS);
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getVersionSnapshot({
|
||
estimateId: params.estimateId,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async create_estimate(params: {
|
||
projectId?: string;
|
||
projectCode?: string;
|
||
name: string;
|
||
opportunityId?: string;
|
||
baseCurrency?: string;
|
||
status?: EstimateStatus;
|
||
versionLabel?: string;
|
||
versionNotes?: string;
|
||
assumptions?: CreateEstimateInput["assumptions"];
|
||
scopeItems?: CreateEstimateInput["scopeItems"];
|
||
demandLines?: CreateEstimateInput["demandLines"];
|
||
resourceSnapshots?: CreateEstimateInput["resourceSnapshots"];
|
||
metrics?: CreateEstimateInput["metrics"];
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let projectId = params.projectId;
|
||
if (!projectId && params.projectCode) {
|
||
const project = await resolveProjectIdentifier(ctx, params.projectCode);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
projectId = project.id;
|
||
}
|
||
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.create({
|
||
name: params.name,
|
||
...(projectId ? { projectId } : {}),
|
||
...(params.opportunityId !== undefined ? { opportunityId: params.opportunityId } : {}),
|
||
...(params.baseCurrency !== undefined ? { baseCurrency: params.baseCurrency } : {}),
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
...(params.versionLabel !== undefined ? { versionLabel: params.versionLabel } : {}),
|
||
...(params.versionNotes !== undefined ? { versionNotes: params.versionNotes } : {}),
|
||
...(params.assumptions !== undefined ? { assumptions: params.assumptions } : {}),
|
||
...(params.scopeItems !== undefined ? { scopeItems: params.scopeItems } : {}),
|
||
...(params.demandLines !== undefined ? { demandLines: params.demandLines } : {}),
|
||
...(params.resourceSnapshots !== undefined ? { resourceSnapshots: params.resourceSnapshots } : {}),
|
||
...(params.metrics !== undefined ? { metrics: params.metrics } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateCreationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Created estimate "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async clone_estimate(params: {
|
||
sourceEstimateId: string;
|
||
name?: string;
|
||
projectId?: string;
|
||
projectCode?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let projectId = params.projectId;
|
||
if (!projectId && params.projectCode) {
|
||
const project = await resolveProjectIdentifier(ctx, params.projectCode);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
projectId = project.id;
|
||
}
|
||
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.clone({
|
||
sourceEstimateId: params.sourceEstimateId,
|
||
...(params.name !== undefined ? { name: params.name } : {}),
|
||
...(projectId ? { projectId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "clone");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Cloned estimate "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async update_estimate_draft(params: {
|
||
id: string;
|
||
projectId?: string;
|
||
projectCode?: string;
|
||
name?: string;
|
||
opportunityId?: string;
|
||
baseCurrency?: string;
|
||
status?: EstimateStatus;
|
||
versionLabel?: string;
|
||
versionNotes?: string;
|
||
assumptions?: UpdateEstimateDraftInput["assumptions"];
|
||
scopeItems?: UpdateEstimateDraftInput["scopeItems"];
|
||
demandLines?: UpdateEstimateDraftInput["demandLines"];
|
||
resourceSnapshots?: UpdateEstimateDraftInput["resourceSnapshots"];
|
||
metrics?: UpdateEstimateDraftInput["metrics"];
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let projectId = params.projectId;
|
||
if (!projectId && params.projectCode) {
|
||
const project = await resolveProjectIdentifier(ctx, params.projectCode);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
projectId = project.id;
|
||
}
|
||
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.updateDraft({
|
||
id: params.id,
|
||
...(projectId ? { projectId } : {}),
|
||
...(params.name !== undefined ? { name: params.name } : {}),
|
||
...(params.opportunityId !== undefined ? { opportunityId: params.opportunityId } : {}),
|
||
...(params.baseCurrency !== undefined ? { baseCurrency: params.baseCurrency } : {}),
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
...(params.versionLabel !== undefined ? { versionLabel: params.versionLabel } : {}),
|
||
...(params.versionNotes !== undefined ? { versionNotes: params.versionNotes } : {}),
|
||
...(params.assumptions !== undefined ? { assumptions: params.assumptions } : {}),
|
||
...(params.scopeItems !== undefined ? { scopeItems: params.scopeItems } : {}),
|
||
...(params.demandLines !== undefined ? { demandLines: params.demandLines } : {}),
|
||
...(params.resourceSnapshots !== undefined ? { resourceSnapshots: params.resourceSnapshots } : {}),
|
||
...(params.metrics !== undefined ? { metrics: params.metrics } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "updateDraft");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Updated estimate draft "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async submit_estimate_version(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.submitVersion({
|
||
estimateId: params.estimateId,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "submitVersion");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Submitted estimate version for "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async approve_estimate_version(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.approveVersion({
|
||
estimateId: params.estimateId,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "approveVersion");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Approved estimate version for "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async create_estimate_revision(params: {
|
||
estimateId: string;
|
||
sourceVersionId?: string;
|
||
label?: string;
|
||
notes?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.createRevision({
|
||
estimateId: params.estimateId,
|
||
...(params.sourceVersionId !== undefined ? { sourceVersionId: params.sourceVersionId } : {}),
|
||
...(params.label !== undefined ? { label: params.label } : {}),
|
||
...(params.notes !== undefined ? { notes: params.notes } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "createRevision");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Created a new estimate revision for "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async create_estimate_export(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
format: EstimateExportFormat;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.createExport({
|
||
estimateId: params.estimateId,
|
||
format: params.format,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "createExport");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Created ${params.format} export for estimate "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async create_estimate_planning_handoff(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.createPlanningHandoff({
|
||
estimateId: params.estimateId,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "createPlanningHandoff");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate", "allocation", "timeline"],
|
||
success: true,
|
||
...result,
|
||
message: `Created planning handoff for estimate ${params.estimateId}.`,
|
||
};
|
||
},
|
||
|
||
async generate_estimate_weekly_phasing(params: {
|
||
estimateId: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
pattern?: "even" | "front_loaded" | "back_loaded" | "custom";
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.generateWeeklyPhasing({
|
||
estimateId: params.estimateId,
|
||
startDate: params.startDate,
|
||
endDate: params.endDate,
|
||
...(params.pattern !== undefined ? { pattern: params.pattern } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "generateWeeklyPhasing");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
...result,
|
||
message: `Generated weekly phasing for estimate ${params.estimateId}.`,
|
||
};
|
||
},
|
||
|
||
async get_estimate_weekly_phasing(params: {
|
||
estimateId: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getWeeklyPhasing({ estimateId: params.estimateId });
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateReadError(error, "weeklyPhasing");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async get_estimate_commercial_terms(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getCommercialTerms({
|
||
estimateId: params.estimateId,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateReadError(error, "commercialTerms");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async update_estimate_commercial_terms(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
terms: Record<string, unknown>;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.updateCommercialTerms({
|
||
estimateId: params.estimateId,
|
||
terms: params.terms,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "updateCommercialTerms");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
...result,
|
||
message: `Updated commercial terms for estimate ${params.estimateId}.`,
|
||
};
|
||
},
|
||
|
||
// ── ROLES ──
|
||
|
||
// ── CLIENTS ──
|
||
|
||
// ── ADMIN / CONFIG ──
|
||
|
||
async list_countries(params: { includeInactive?: boolean; search?: string }, ctx: ToolContext) {
|
||
const caller = createCountryCaller(createScopedCallerContext(ctx));
|
||
const countries = await caller.list(
|
||
params.includeInactive
|
||
? undefined
|
||
: { isActive: true },
|
||
);
|
||
const normalizedSearch = params.search?.trim().toLowerCase();
|
||
const filteredCountries = normalizedSearch
|
||
? countries.filter((country) =>
|
||
country.code.toLowerCase().includes(normalizedSearch)
|
||
|| country.name.toLowerCase().includes(normalizedSearch))
|
||
: countries;
|
||
|
||
return {
|
||
count: filteredCountries.length,
|
||
countries: filteredCountries.map(formatCountry),
|
||
};
|
||
},
|
||
|
||
async get_country(params: { identifier: string }, ctx: ToolContext) {
|
||
const caller = createCountryCaller(createScopedCallerContext(ctx));
|
||
let country;
|
||
try {
|
||
country = await caller.getByIdentifier({ identifier: params.identifier });
|
||
} catch (error) {
|
||
const mapped = toAssistantCountryNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return formatCountry(country);
|
||
},
|
||
...createCountryMetroAdminExecutors({
|
||
createCountryCaller,
|
||
createScopedCallerContext,
|
||
assertAdminRole,
|
||
formatCountry,
|
||
toAssistantCountryMutationError,
|
||
toAssistantMetroCityMutationError,
|
||
}),
|
||
|
||
async list_management_levels(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createManagementLevelCaller(createScopedCallerContext(ctx));
|
||
const groups = await caller.listGroups();
|
||
return groups.map((g) => ({
|
||
id: g.id,
|
||
name: g.name,
|
||
target: g.targetPercentage ? `${g.targetPercentage}%` : null,
|
||
levels: g.levels.map((l: { id: string; name: string }) => ({ id: l.id, name: l.name })),
|
||
}));
|
||
},
|
||
|
||
async list_utilization_categories(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUtilizationCategoryCaller(createScopedCallerContext(ctx));
|
||
const categories = await caller.list();
|
||
const categoriesWithCounts = await Promise.all(
|
||
categories.map(async (category) => {
|
||
const detailed = await caller.getById({ id: category.id });
|
||
return {
|
||
category,
|
||
projectCount: detailed._count.projects,
|
||
};
|
||
}),
|
||
);
|
||
return categoriesWithCounts.map(({ category, projectCount }) => ({
|
||
id: category.id,
|
||
code: category.code,
|
||
name: category.name,
|
||
description: category.description,
|
||
projectCount,
|
||
}));
|
||
},
|
||
|
||
async list_calculation_rules(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createCalculationRuleCaller(createScopedCallerContext(ctx));
|
||
const rules = await caller.list();
|
||
return rules.map((rule) => ({
|
||
id: rule.id,
|
||
name: rule.name,
|
||
description: rule.description,
|
||
isActive: rule.isActive,
|
||
triggerType: rule.triggerType,
|
||
orderType: rule.orderType,
|
||
costEffect: rule.costEffect,
|
||
costReductionPercent: rule.costReductionPercent,
|
||
chargeabilityEffect: rule.chargeabilityEffect,
|
||
priority: rule.priority,
|
||
project: rule.project
|
||
? {
|
||
id: rule.project.id,
|
||
name: rule.project.name,
|
||
shortCode: rule.project.shortCode,
|
||
}
|
||
: null,
|
||
}));
|
||
},
|
||
|
||
async list_effort_rules(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createEffortRuleCaller(createScopedCallerContext(ctx));
|
||
const ruleSets = await caller.list();
|
||
return ruleSets.flatMap((ruleSet) => ruleSet.rules.map((rule) => ({
|
||
id: rule.id,
|
||
description: rule.description,
|
||
scopeType: rule.scopeType,
|
||
discipline: rule.discipline,
|
||
chapter: rule.chapter,
|
||
unitMode: rule.unitMode,
|
||
hoursPerUnit: rule.hoursPerUnit,
|
||
sortOrder: rule.sortOrder,
|
||
ruleSet: {
|
||
name: ruleSet.name,
|
||
isDefault: ruleSet.isDefault,
|
||
},
|
||
})));
|
||
},
|
||
|
||
async list_experience_multipliers(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createExperienceMultiplierCaller(createScopedCallerContext(ctx));
|
||
const multiplierSets = await caller.list();
|
||
return multiplierSets.flatMap((multiplierSet) => multiplierSet.rules.map((rule) => ({
|
||
id: rule.id,
|
||
description: rule.description,
|
||
chapter: rule.chapter,
|
||
location: rule.location,
|
||
level: rule.level,
|
||
costMultiplier: rule.costMultiplier,
|
||
billMultiplier: rule.billMultiplier,
|
||
shoringRatio: rule.shoringRatio,
|
||
additionalEffortRatio: rule.additionalEffortRatio,
|
||
sortOrder: rule.sortOrder,
|
||
multiplierSet: {
|
||
name: multiplierSet.name,
|
||
isDefault: multiplierSet.isDefault,
|
||
},
|
||
})));
|
||
},
|
||
|
||
async list_users(params: { limit?: number }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
const users = await caller.list();
|
||
return users.slice(0, Math.min(params.limit ?? 50, 100));
|
||
},
|
||
|
||
async list_assignable_users(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.listAssignable();
|
||
},
|
||
|
||
async get_current_user(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.me();
|
||
},
|
||
|
||
async get_dashboard_layout(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.getDashboardLayout();
|
||
},
|
||
|
||
async save_dashboard_layout(params: { layout: unknown[] }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.saveDashboardLayout({ layout: params.layout });
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["dashboard"],
|
||
success: true,
|
||
...result,
|
||
message: "Saved dashboard layout.",
|
||
};
|
||
},
|
||
|
||
async get_favorite_project_ids(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.getFavoriteProjectIds();
|
||
},
|
||
|
||
async toggle_favorite_project(params: { projectId: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.toggleFavoriteProject({ projectId: params.projectId });
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["project"],
|
||
success: true,
|
||
...result,
|
||
message: result.added ? "Added project to favorites." : "Removed project from favorites.",
|
||
};
|
||
},
|
||
|
||
async get_column_preferences(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.getColumnPreferences();
|
||
},
|
||
|
||
async set_column_preferences(params: {
|
||
view: "resources" | "projects" | "allocations" | "vacations" | "roles" | "users" | "blueprints";
|
||
visible?: string[];
|
||
sort?: { field: string; dir: "asc" | "desc" } | null;
|
||
rowOrder?: string[] | null;
|
||
}, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.setColumnPreferences({
|
||
view: params.view,
|
||
...(params.visible !== undefined ? { visible: params.visible } : {}),
|
||
...(params.sort !== undefined ? { sort: params.sort } : {}),
|
||
...(params.rowOrder !== undefined ? { rowOrder: params.rowOrder } : {}),
|
||
});
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
...result,
|
||
message: `Updated column preferences for ${params.view}.`,
|
||
};
|
||
},
|
||
|
||
async generate_totp_secret(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.generateTotpSecret();
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
...result,
|
||
message: "Generated a new MFA TOTP secret.",
|
||
};
|
||
},
|
||
|
||
async verify_and_enable_totp(params: { token: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.verifyAndEnableTotp({ token: params.token });
|
||
} catch (error) {
|
||
const mapped = toAssistantTotpEnableError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
...result,
|
||
message: "Enabled MFA TOTP.",
|
||
};
|
||
},
|
||
|
||
async get_mfa_status(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.getMfaStatus();
|
||
},
|
||
|
||
async get_active_user_count(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.activeCount();
|
||
},
|
||
|
||
async create_user(params: {
|
||
email: string;
|
||
name: string;
|
||
systemRole?: SystemRole;
|
||
password: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let user;
|
||
try {
|
||
user = await caller.create({
|
||
email: params.email,
|
||
name: params.name,
|
||
password: params.password,
|
||
...(params.systemRole !== undefined ? { systemRole: params.systemRole } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error, "create");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user", "resource"],
|
||
success: true,
|
||
user,
|
||
userId: user.id,
|
||
message: `Created user ${user.name}.`,
|
||
};
|
||
},
|
||
|
||
async set_user_password(params: { userId: string; password: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.setPassword(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error, "password");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
...result,
|
||
message: `Reset password for user ${params.userId}.`,
|
||
};
|
||
},
|
||
|
||
async update_user_role(params: { id: string; systemRole: SystemRole }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let user;
|
||
try {
|
||
user = await caller.updateRole(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
user,
|
||
userId: user.id,
|
||
message: `Updated role for ${user.name} to ${user.systemRole}.`,
|
||
};
|
||
},
|
||
|
||
async update_user_name(params: { id: string; name: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let user;
|
||
try {
|
||
user = await caller.updateName(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
user,
|
||
userId: user.id,
|
||
message: `Updated user name to ${user.name}.`,
|
||
};
|
||
},
|
||
|
||
async link_user_resource(params: { userId: string; resourceId?: string | null }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.linkResource({
|
||
userId: params.userId,
|
||
resourceId: params.resourceId ?? null,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantUserResourceLinkError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user", "resource"],
|
||
...result,
|
||
message: params.resourceId ? "Linked user to resource." : "Unlinked user resource.",
|
||
};
|
||
},
|
||
|
||
async auto_link_users_by_email(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.autoLinkAllByEmail();
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user", "resource"],
|
||
success: true,
|
||
...result,
|
||
message: `Auto-linked ${result.linked} user(s) by email.`,
|
||
};
|
||
},
|
||
|
||
async set_user_permissions(params: {
|
||
userId: string;
|
||
overrides?: {
|
||
granted?: string[];
|
||
denied?: string[];
|
||
chapterIds?: string[];
|
||
} | null;
|
||
}, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let user;
|
||
try {
|
||
user = await caller.setPermissions({
|
||
userId: params.userId,
|
||
overrides: params.overrides ?? null,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
user,
|
||
userId: user.id,
|
||
message: params.overrides ? "Updated user permission overrides." : "Cleared user permission overrides.",
|
||
};
|
||
},
|
||
|
||
async reset_user_permissions(params: { userId: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let user;
|
||
try {
|
||
user = await caller.resetPermissions(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
user,
|
||
userId: user.id,
|
||
message: "Reset user permissions to role defaults.",
|
||
};
|
||
},
|
||
|
||
async get_effective_user_permissions(params: { userId: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getEffectivePermissions(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async disable_user_totp(params: { userId: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.disableTotp(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
...result,
|
||
message: `Disabled TOTP for user ${params.userId}.`,
|
||
};
|
||
},
|
||
|
||
async list_notifications(params: { unreadOnly?: boolean; limit?: number }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
return caller.list({
|
||
...(params.unreadOnly !== undefined ? { unreadOnly: params.unreadOnly } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(params.limit, 100) } : {}),
|
||
});
|
||
},
|
||
|
||
async mark_notification_read(params: { notificationId?: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
await caller.markRead({
|
||
...(params.notificationId !== undefined ? { id: params.notificationId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantNotificationReadError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
message: params.notificationId ? "Notification marked as read." : "All unread notifications marked as read.",
|
||
};
|
||
},
|
||
|
||
async get_unread_notification_count(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
const count = await caller.unreadCount();
|
||
return { count };
|
||
},
|
||
|
||
async create_notification(params: {
|
||
userId: string;
|
||
type: string;
|
||
title: string;
|
||
body?: string;
|
||
entityId?: string;
|
||
entityType?: string;
|
||
category?: "NOTIFICATION" | "REMINDER" | "TASK" | "APPROVAL";
|
||
priority?: "LOW" | "NORMAL" | "HIGH" | "URGENT";
|
||
link?: string;
|
||
taskStatus?: "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED";
|
||
taskAction?: string;
|
||
assigneeId?: string;
|
||
dueDate?: string;
|
||
channel?: "in_app" | "email" | "both";
|
||
senderId?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
const dueDate = parseOptionalDateTime(params.dueDate, "dueDate");
|
||
let notification;
|
||
try {
|
||
notification = await caller.create({
|
||
userId: params.userId,
|
||
type: params.type,
|
||
title: params.title,
|
||
...(params.body !== undefined ? { body: params.body } : {}),
|
||
...(params.entityId !== undefined ? { entityId: params.entityId } : {}),
|
||
...(params.entityType !== undefined ? { entityType: params.entityType } : {}),
|
||
...(params.category !== undefined ? { category: params.category } : {}),
|
||
...(params.priority !== undefined ? { priority: params.priority } : {}),
|
||
...(params.link !== undefined ? { link: params.link } : {}),
|
||
...(params.taskStatus !== undefined ? { taskStatus: params.taskStatus } : {}),
|
||
...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}),
|
||
...(params.assigneeId !== undefined ? { assigneeId: params.assigneeId } : {}),
|
||
...(dueDate ? { dueDate } : {}),
|
||
...(params.channel !== undefined ? { channel: params.channel } : {}),
|
||
...(params.senderId !== undefined ? { senderId: params.senderId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantNotificationCreationError(error, "notification");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
notification,
|
||
notificationId: notification?.id ?? null,
|
||
message: `Created notification "${params.title}".`,
|
||
};
|
||
},
|
||
|
||
// ── DASHBOARD DETAIL ──
|
||
|
||
async get_dashboard_detail(params: { section?: string }, ctx: ToolContext) {
|
||
const caller = createDashboardCaller(createScopedCallerContext(ctx));
|
||
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}"` };
|
||
},
|
||
|
||
// ── TASK MANAGEMENT ──
|
||
|
||
async list_tasks(params: {
|
||
status?: "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED";
|
||
includeAssigned?: boolean;
|
||
limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
return caller.listTasks({
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
...(params.includeAssigned !== undefined ? { includeAssigned: params.includeAssigned } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(params.limit, 100) } : {}),
|
||
});
|
||
},
|
||
|
||
async get_task_counts(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
return caller.taskCounts();
|
||
},
|
||
|
||
async get_task_detail(params: { taskId: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getTaskDetail({ id: params.taskId });
|
||
} catch (error) {
|
||
const mapped = toAssistantTaskNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async update_task_status(params: { taskId: string; status: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
let task;
|
||
try {
|
||
task = await caller.updateTaskStatus({
|
||
id: params.taskId,
|
||
status: params.status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED",
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantTaskNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
task,
|
||
message: `Task status updated to ${task.taskStatus ?? params.status}.`,
|
||
};
|
||
},
|
||
|
||
async execute_task_action(params: { taskId: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.executeTaskAction({ id: params.taskId });
|
||
} catch (error) {
|
||
const mapped = toAssistantTaskActionError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
task: result.task,
|
||
message: result.actionResult.message,
|
||
};
|
||
},
|
||
|
||
async create_reminder(params: {
|
||
title: string;
|
||
body?: string;
|
||
remindAt: string;
|
||
recurrence?: string;
|
||
entityId?: string;
|
||
entityType?: string;
|
||
link?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
if (!params.title.trim()) {
|
||
return { error: "Reminder title is required." };
|
||
}
|
||
if (params.title.length > 200) {
|
||
return { error: "Reminder title must be at most 200 characters." };
|
||
}
|
||
if (params.body !== undefined && params.body.length > 2000) {
|
||
return { error: "Reminder body must be at most 2000 characters." };
|
||
}
|
||
if (
|
||
params.recurrence !== undefined
|
||
&& !["daily", "weekly", "monthly"].includes(params.recurrence)
|
||
) {
|
||
return {
|
||
error: `Invalid recurrence: ${params.recurrence}. Valid values: daily, weekly, monthly.`,
|
||
};
|
||
}
|
||
const remindAt = parseDateTime(params.remindAt, "remindAt");
|
||
let reminder;
|
||
try {
|
||
reminder = await caller.createReminder({
|
||
title: params.title,
|
||
remindAt,
|
||
...(params.body !== undefined ? { body: params.body } : {}),
|
||
...(params.recurrence !== undefined ? { recurrence: params.recurrence as "daily" | "weekly" | "monthly" } : {}),
|
||
...(params.entityId !== undefined ? { entityId: params.entityId } : {}),
|
||
...(params.entityType !== undefined ? { entityType: params.entityType } : {}),
|
||
...(params.link !== undefined ? { link: params.link } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantReminderCreationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
reminder,
|
||
reminderId: reminder.id,
|
||
message: `Reminder "${params.title}" created.`,
|
||
};
|
||
},
|
||
|
||
async list_reminders(params: { limit?: number }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
return caller.listReminders({
|
||
...(params.limit !== undefined ? { limit: Math.min(params.limit, 100) } : {}),
|
||
});
|
||
},
|
||
|
||
async update_reminder(params: {
|
||
id: string;
|
||
title?: string;
|
||
body?: string;
|
||
remindAt?: string;
|
||
recurrence?: "daily" | "weekly" | "monthly" | null;
|
||
}, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
const remindAt = parseOptionalDateTime(params.remindAt, "remindAt");
|
||
let reminder;
|
||
try {
|
||
reminder = await caller.updateReminder({
|
||
id: params.id,
|
||
...(params.title !== undefined ? { title: params.title } : {}),
|
||
...(params.body !== undefined ? { body: params.body } : {}),
|
||
...(remindAt ? { remindAt } : {}),
|
||
...(params.recurrence !== undefined ? { recurrence: params.recurrence } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantReminderNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
reminder,
|
||
reminderId: reminder.id,
|
||
message: `Updated reminder ${params.id}.`,
|
||
};
|
||
},
|
||
|
||
async delete_reminder(params: { id: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
await caller.deleteReminder({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantReminderNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
id: params.id,
|
||
message: `Deleted reminder ${params.id}.`,
|
||
};
|
||
},
|
||
|
||
async create_task_for_user(params: {
|
||
userId: string;
|
||
title: string;
|
||
body?: string;
|
||
priority?: string;
|
||
dueDate?: string;
|
||
taskAction?: string;
|
||
entityId?: string;
|
||
entityType?: string;
|
||
link?: string;
|
||
channel?: "in_app" | "email" | "both";
|
||
}, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
const dueDate = parseOptionalDateTime(params.dueDate, "dueDate");
|
||
let task;
|
||
try {
|
||
task = await caller.createTask({
|
||
userId: params.userId,
|
||
title: params.title,
|
||
...(params.body !== undefined ? { body: params.body } : {}),
|
||
...(params.priority !== undefined ? { priority: params.priority as "LOW" | "NORMAL" | "HIGH" | "URGENT" } : {}),
|
||
...(dueDate ? { dueDate } : {}),
|
||
...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}),
|
||
...(params.entityId !== undefined ? { entityId: params.entityId } : {}),
|
||
...(params.entityType !== undefined ? { entityType: params.entityType } : {}),
|
||
...(params.link !== undefined ? { link: params.link } : {}),
|
||
...(params.channel !== undefined ? { channel: params.channel } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantNotificationCreationError(error, "task");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
task,
|
||
taskId: task?.id ?? null,
|
||
message: `Created task "${params.title}" for ${params.userId}.`,
|
||
};
|
||
},
|
||
|
||
async assign_task(params: { id: string; assigneeId: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
let task;
|
||
try {
|
||
task = await caller.assignTask(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantTaskAssignmentError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
task,
|
||
taskId: task.id,
|
||
message: `Assigned task ${params.id} to ${params.assigneeId}.`,
|
||
};
|
||
},
|
||
|
||
async send_broadcast(params: {
|
||
title: string;
|
||
body?: string;
|
||
targetType: string;
|
||
targetValue?: string;
|
||
category?: "NOTIFICATION" | "REMINDER" | "TASK" | "APPROVAL";
|
||
priority?: string;
|
||
channel?: string;
|
||
link?: string;
|
||
scheduledAt?: string;
|
||
taskAction?: string;
|
||
dueDate?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
const scheduledAt = parseOptionalDateTime(params.scheduledAt, "scheduledAt");
|
||
const dueDate = parseOptionalDateTime(params.dueDate, "dueDate");
|
||
let broadcast;
|
||
try {
|
||
broadcast = await caller.createBroadcast({
|
||
title: params.title,
|
||
targetType: params.targetType as "user" | "role" | "project" | "orgUnit" | "all",
|
||
...(params.body !== undefined ? { body: params.body } : {}),
|
||
...(params.link !== undefined ? { link: params.link } : {}),
|
||
...(params.category !== undefined ? { category: params.category } : {}),
|
||
...(params.priority !== undefined ? { priority: params.priority as "LOW" | "NORMAL" | "HIGH" | "URGENT" } : {}),
|
||
...(params.channel !== undefined ? { channel: params.channel as "in_app" | "email" | "both" } : {}),
|
||
...(params.targetValue !== undefined ? { targetValue: params.targetValue } : {}),
|
||
...(scheduledAt ? { scheduledAt } : {}),
|
||
...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}),
|
||
...(dueDate ? { dueDate } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantNotificationCreationError(error, "broadcast");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
broadcast,
|
||
broadcastId: broadcast.id,
|
||
recipientCount: broadcast.recipientCount ?? 0,
|
||
message: `Broadcast "${params.title}" created.`,
|
||
};
|
||
},
|
||
|
||
async list_broadcasts(params: { limit?: number }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
return caller.listBroadcasts({
|
||
...(params.limit !== undefined ? { limit: Math.min(params.limit, 50) } : {}),
|
||
});
|
||
},
|
||
|
||
async get_broadcast_detail(params: { id: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getBroadcastById({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantBroadcastNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async delete_notification(params: { id: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
await caller.delete({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantNotificationDeletionError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
id: params.id,
|
||
message: `Deleted notification ${params.id}.`,
|
||
};
|
||
},
|
||
|
||
// ── INSIGHTS & ANOMALIES ──────────────────────────────────────────────────
|
||
|
||
async detect_anomalies(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createInsightsCaller(createScopedCallerContext(ctx));
|
||
return caller.getAnomalyDetail();
|
||
},
|
||
|
||
async get_skill_gaps(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createDashboardCaller(createScopedCallerContext(ctx));
|
||
return caller.getSkillGapSummary();
|
||
},
|
||
|
||
async get_project_health(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createDashboardCaller(createScopedCallerContext(ctx));
|
||
return caller.getProjectHealthDetail();
|
||
},
|
||
|
||
async get_budget_forecast(_params: Record<string, never>, ctx: ToolContext) {
|
||
assertPermission(ctx, "viewCosts" as PermissionKey);
|
||
|
||
const caller = createDashboardCaller(createScopedCallerContext(ctx));
|
||
return caller.getBudgetForecastDetail();
|
||
},
|
||
|
||
async get_insights_summary(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createInsightsCaller(createScopedCallerContext(ctx));
|
||
return caller.getInsightsSummary();
|
||
},
|
||
|
||
async run_report(params: {
|
||
entity: string;
|
||
columns: string[];
|
||
filters?: Array<{
|
||
field: string;
|
||
op: "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
|
||
value: string;
|
||
}>;
|
||
periodMonth?: string;
|
||
groupBy?: string;
|
||
sortBy?: string;
|
||
sortDir?: "asc" | "desc";
|
||
limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const entity = params.entity as "resource" | "project" | "assignment" | "resource_month";
|
||
if (!["resource", "project", "assignment", "resource_month"].includes(entity)) {
|
||
return {
|
||
error:
|
||
`Unknown entity: ${params.entity}. Use resource, project, assignment, or resource_month.`,
|
||
};
|
||
}
|
||
const caller = createReportCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.getReportData({
|
||
entity,
|
||
columns: params.columns,
|
||
filters: params.filters ?? [],
|
||
periodMonth: params.periodMonth,
|
||
groupBy: params.groupBy,
|
||
sortBy: params.sortBy,
|
||
sortDir: params.sortDir ?? "asc",
|
||
limit: Math.min(params.limit ?? 50, 200),
|
||
offset: 0,
|
||
});
|
||
|
||
return {
|
||
rows: result.rows,
|
||
rowCount: result.rows.length,
|
||
totalCount: result.totalCount,
|
||
columns: result.columns,
|
||
groups: result.groups,
|
||
};
|
||
},
|
||
|
||
async list_comments(params: { entityType: CommentEntityType; entityId: string }, ctx: ToolContext) {
|
||
const caller = createCommentCaller(createScopedCallerContext(ctx));
|
||
const comments = await caller.list({
|
||
entityType: params.entityType,
|
||
entityId: params.entityId,
|
||
});
|
||
|
||
return comments.map((c) => ({
|
||
id: c.id,
|
||
author: c.author.name ?? c.author.email,
|
||
body: c.body,
|
||
resolved: c.resolved,
|
||
createdAt: c.createdAt.toISOString(),
|
||
replyCount: c.replies.length,
|
||
replies: c.replies.map((r) => ({
|
||
id: r.id,
|
||
author: r.author.name ?? r.author.email,
|
||
body: r.body,
|
||
resolved: r.resolved,
|
||
createdAt: r.createdAt.toISOString(),
|
||
})),
|
||
}));
|
||
},
|
||
|
||
async lookup_rate(params: {
|
||
clientId?: string;
|
||
chapter?: string;
|
||
managementLevelId?: string;
|
||
roleName?: string;
|
||
seniority?: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, "viewCosts" as PermissionKey);
|
||
const caller = createRateCardCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.lookupBestMatch(params);
|
||
|
||
return {
|
||
...(result.message ? { message: result.message } : {}),
|
||
bestMatch: result.bestMatch
|
||
? {
|
||
...result.bestMatch,
|
||
costRate: fmtEur(result.bestMatch.costRateCents),
|
||
billRate: result.bestMatch.billRateCents ? fmtEur(result.bestMatch.billRateCents) : null,
|
||
}
|
||
: null,
|
||
alternatives: result.alternatives.map((alternative) => ({
|
||
...alternative,
|
||
costRate: fmtEur(alternative.costRateCents),
|
||
billRate: alternative.billRateCents ? fmtEur(alternative.billRateCents) : null,
|
||
})),
|
||
totalCandidates: result.totalCandidates,
|
||
};
|
||
},
|
||
|
||
// ── SCENARIO & AI ─────────────────────────────────────────────────────────
|
||
|
||
async simulate_scenario(params: {
|
||
projectId: string;
|
||
changes: Array<{
|
||
assignmentId?: string;
|
||
resourceId?: string;
|
||
roleId?: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
hoursPerDay: number;
|
||
remove?: boolean;
|
||
}>;
|
||
}, ctx: ToolContext) {
|
||
const caller = createScenarioCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.simulate({
|
||
projectId: params.projectId,
|
||
changes: params.changes.map((change) => ({
|
||
...change,
|
||
startDate: new Date(change.startDate),
|
||
endDate: new Date(change.endDate),
|
||
})),
|
||
});
|
||
|
||
return {
|
||
baseline: {
|
||
...result.baseline,
|
||
totalCost: fmtEur(result.baseline.totalCostCents),
|
||
},
|
||
scenario: {
|
||
...result.scenario,
|
||
totalCost: fmtEur(result.scenario.totalCostCents),
|
||
},
|
||
delta: {
|
||
...result.delta,
|
||
cost: fmtEur(result.delta.costCents),
|
||
},
|
||
resourceImpacts: result.resourceImpacts,
|
||
warnings: result.warnings,
|
||
budgetCents: result.budgetCents,
|
||
};
|
||
},
|
||
|
||
async generate_project_narrative(params: { projectId: string }, ctx: ToolContext) {
|
||
const caller = createInsightsCaller(createScopedCallerContext(ctx));
|
||
return caller.generateProjectNarrative({ projectId: params.projectId });
|
||
},
|
||
|
||
async create_comment(params: {
|
||
entityType: CommentEntityType;
|
||
entityId: string;
|
||
body: string;
|
||
}, ctx: ToolContext) {
|
||
if (params.body.length === 0) {
|
||
return { error: "Comment body is required." };
|
||
}
|
||
|
||
if (params.body.length > 10_000) {
|
||
return { error: "Comment body must be at most 10000 characters." };
|
||
}
|
||
|
||
const caller = createCommentCaller(createScopedCallerContext(ctx));
|
||
let comment;
|
||
try {
|
||
comment = await caller.create({
|
||
entityType: params.entityType,
|
||
entityId: params.entityId,
|
||
body: params.body,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantCommentCreationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["comment"],
|
||
id: comment.id,
|
||
author: comment.author.name ?? comment.author.email,
|
||
body: comment.body,
|
||
createdAt: comment.createdAt.toISOString(),
|
||
};
|
||
},
|
||
|
||
async resolve_comment(params: {
|
||
commentId: string;
|
||
resolved?: boolean;
|
||
}, ctx: ToolContext) {
|
||
const caller = createCommentCaller(createScopedCallerContext(ctx));
|
||
let updated;
|
||
try {
|
||
updated = await caller.resolve({
|
||
id: params.commentId,
|
||
resolved: params.resolved !== false,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantCommentResolveError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["comment"],
|
||
id: updated.id,
|
||
resolved: updated.resolved,
|
||
author: updated.author.name ?? updated.author.email,
|
||
body: updated.body.slice(0, 100),
|
||
};
|
||
},
|
||
|
||
async query_change_history(params: {
|
||
entityType?: string;
|
||
search?: string;
|
||
userId?: string;
|
||
daysBack?: number;
|
||
action?: string;
|
||
limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const limit = Math.min(params.limit ?? 20, 50);
|
||
const daysBack = params.daysBack ?? 7;
|
||
const startDate = new Date();
|
||
startDate.setDate(startDate.getDate() - daysBack);
|
||
|
||
const caller = createAuditLogCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.listDetail({
|
||
...(params.entityType ? { entityType: params.entityType } : {}),
|
||
...(params.userId ? { userId: params.userId } : {}),
|
||
...(params.action ? { action: params.action } : {}),
|
||
...(params.search ? { search: params.search } : {}),
|
||
startDate,
|
||
limit,
|
||
});
|
||
|
||
return {
|
||
filters: {
|
||
entityType: params.entityType ?? null,
|
||
userId: params.userId ?? null,
|
||
action: params.action ?? null,
|
||
search: params.search ?? null,
|
||
daysBack,
|
||
},
|
||
itemCount: result.items.length,
|
||
nextCursor: result.nextCursor ?? null,
|
||
items: result.items,
|
||
};
|
||
},
|
||
|
||
async get_entity_timeline(params: {
|
||
entityType: string;
|
||
entityId: string;
|
||
limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const limit = Math.min(params.limit ?? 50, 200);
|
||
const caller = createAuditLogCaller(createScopedCallerContext(ctx));
|
||
return caller.getByEntityDetail({
|
||
entityType: params.entityType,
|
||
entityId: params.entityId,
|
||
limit,
|
||
});
|
||
},
|
||
|
||
async export_resources_csv(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createImportExportCaller(createScopedCallerContext(ctx));
|
||
const csv = await caller.exportResourcesCSV();
|
||
return {
|
||
format: "csv",
|
||
lineCount: csv.length === 0 ? 0 : csv.split("\n").length,
|
||
csv,
|
||
};
|
||
},
|
||
|
||
async export_projects_csv(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createImportExportCaller(createScopedCallerContext(ctx));
|
||
const csv = await caller.exportProjectsCSV();
|
||
return {
|
||
format: "csv",
|
||
lineCount: csv.length === 0 ? 0 : csv.split("\n").length,
|
||
csv,
|
||
};
|
||
},
|
||
|
||
async import_csv_data(params: {
|
||
entityType: "resources" | "projects" | "allocations";
|
||
rows: Array<Record<string, string>>;
|
||
dryRun?: boolean;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.IMPORT_DATA);
|
||
const caller = createImportExportCaller(createScopedCallerContext(ctx));
|
||
return caller.importCSV({
|
||
entityType: params.entityType,
|
||
rows: params.rows,
|
||
dryRun: params.dryRun ?? true,
|
||
});
|
||
},
|
||
|
||
async list_dispo_import_batches(params: {
|
||
status?: ImportBatchStatus;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.listImportBatches({
|
||
...(params.status ? { status: params.status } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}),
|
||
});
|
||
},
|
||
|
||
async get_dispo_import_batch(params: {
|
||
id: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getImportBatch({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantDispoImportBatchNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async stage_dispo_import_batch(params: {
|
||
chargeabilityWorkbookPath: string;
|
||
costWorkbookPath?: string;
|
||
notes?: string | null;
|
||
planningWorkbookPath: string;
|
||
referenceWorkbookPath: string;
|
||
rosterWorkbookPath?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.stageImportBatch({
|
||
chargeabilityWorkbookPath: params.chargeabilityWorkbookPath,
|
||
planningWorkbookPath: params.planningWorkbookPath,
|
||
referenceWorkbookPath: params.referenceWorkbookPath,
|
||
...(params.costWorkbookPath !== undefined ? { costWorkbookPath: params.costWorkbookPath } : {}),
|
||
...(params.notes !== undefined ? { notes: params.notes } : {}),
|
||
...(params.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: params.rosterWorkbookPath } : {}),
|
||
});
|
||
},
|
||
|
||
async validate_dispo_import_batch(params: {
|
||
chargeabilityWorkbookPath: string;
|
||
costWorkbookPath?: string;
|
||
importBatchId?: string;
|
||
notes?: string | null;
|
||
planningWorkbookPath: string;
|
||
referenceWorkbookPath: string;
|
||
rosterWorkbookPath?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.validateImportBatch({
|
||
chargeabilityWorkbookPath: params.chargeabilityWorkbookPath,
|
||
planningWorkbookPath: params.planningWorkbookPath,
|
||
referenceWorkbookPath: params.referenceWorkbookPath,
|
||
...(params.costWorkbookPath !== undefined ? { costWorkbookPath: params.costWorkbookPath } : {}),
|
||
...(params.importBatchId !== undefined ? { importBatchId: params.importBatchId } : {}),
|
||
...(params.notes !== undefined ? { notes: params.notes } : {}),
|
||
...(params.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: params.rosterWorkbookPath } : {}),
|
||
});
|
||
},
|
||
|
||
async cancel_dispo_import_batch(params: {
|
||
id: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.cancelImportBatch({ id: params.id });
|
||
},
|
||
|
||
async list_dispo_staged_resources(params: {
|
||
importBatchId: string;
|
||
status?: StagedRecordStatus;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.listStagedResources({
|
||
importBatchId: params.importBatchId,
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}),
|
||
});
|
||
},
|
||
|
||
async list_dispo_staged_projects(params: {
|
||
importBatchId: string;
|
||
status?: StagedRecordStatus;
|
||
isTbd?: boolean;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.listStagedProjects({
|
||
importBatchId: params.importBatchId,
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
...(params.isTbd !== undefined ? { isTbd: params.isTbd } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}),
|
||
});
|
||
},
|
||
|
||
async list_dispo_staged_assignments(params: {
|
||
importBatchId: string;
|
||
status?: StagedRecordStatus;
|
||
resourceExternalId?: string;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.listStagedAssignments({
|
||
importBatchId: params.importBatchId,
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
...(params.resourceExternalId !== undefined ? { resourceExternalId: params.resourceExternalId } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}),
|
||
});
|
||
},
|
||
|
||
async list_dispo_staged_vacations(params: {
|
||
importBatchId: string;
|
||
resourceExternalId?: string;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.listStagedVacations({
|
||
importBatchId: params.importBatchId,
|
||
...(params.resourceExternalId !== undefined ? { resourceExternalId: params.resourceExternalId } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}),
|
||
});
|
||
},
|
||
|
||
async list_dispo_staged_unresolved_records(params: {
|
||
importBatchId: string;
|
||
recordType?: DispoStagedRecordType;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.listStagedUnresolvedRecords({
|
||
importBatchId: params.importBatchId,
|
||
...(params.recordType !== undefined ? { recordType: params.recordType } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}),
|
||
});
|
||
},
|
||
|
||
async resolve_dispo_staged_record(params: {
|
||
action: "APPROVE" | "REJECT" | "SKIP";
|
||
id: string;
|
||
recordType: DispoStagedRecordType;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.resolveStagedRecord({
|
||
action: params.action,
|
||
id: params.id,
|
||
recordType: params.recordType,
|
||
});
|
||
},
|
||
|
||
async commit_dispo_import_batch(params: {
|
||
allowTbdUnresolved?: boolean;
|
||
importBatchId: string;
|
||
importTbdProjects?: boolean;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.commitImportBatch({
|
||
importBatchId: params.importBatchId,
|
||
...(params.allowTbdUnresolved !== undefined ? { allowTbdUnresolved: params.allowTbdUnresolved } : {}),
|
||
...(params.importTbdProjects !== undefined ? { importTbdProjects: params.importTbdProjects } : {}),
|
||
});
|
||
},
|
||
|
||
...createSettingsAdminExecutors({
|
||
createSettingsCaller,
|
||
createSystemRoleConfigCaller,
|
||
createWebhookCaller,
|
||
createAuditLogCaller,
|
||
createProjectCaller,
|
||
createScopedCallerContext,
|
||
parseIsoDate,
|
||
resolveProjectIdentifier,
|
||
sanitizeWebhook,
|
||
sanitizeWebhookList,
|
||
toAssistantWebhookNotFoundError,
|
||
toAssistantWebhookMutationError,
|
||
toAssistantAuditLogEntryNotFoundError,
|
||
}),
|
||
};
|
||
|
||
// ─── Executor ───────────────────────────────────────────────────────────────
|
||
|
||
export interface ToolAction {
|
||
type: string;
|
||
url?: string;
|
||
scope?: string[];
|
||
description?: string;
|
||
}
|
||
|
||
export interface ToolResult {
|
||
content: string;
|
||
action?: ToolAction;
|
||
data?: unknown;
|
||
}
|
||
|
||
export async function executeTool(
|
||
name: string,
|
||
args: string,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult> {
|
||
const executor = executors[name as keyof typeof executors];
|
||
if (!executor) return { content: JSON.stringify({ error: `Unknown tool: ${name}` }) };
|
||
|
||
try {
|
||
const params = JSON.parse(args);
|
||
|
||
// Audit-log all mutation tool executions (EGAI 4.1.3.1 / IAAI 3.6.26)
|
||
if (MUTATION_TOOLS.has(name)) {
|
||
logger.info(
|
||
{ tool: name, params, userId: ctx.userId, userRole: ctx.userRole },
|
||
"AI assistant mutation tool executed",
|
||
);
|
||
}
|
||
|
||
const result = await executor(params, ctx);
|
||
|
||
// Detect action payloads (e.g. navigation, invalidation)
|
||
if (result && typeof result === "object" && "__action" in (result as Record<string, unknown>)) {
|
||
const actionResult = result as Record<string, unknown>;
|
||
const actionType = actionResult.__action as string;
|
||
|
||
if (actionType === "navigate") {
|
||
const url = actionResult.url as string;
|
||
const desc = (actionResult.description as string | undefined) ?? url;
|
||
return {
|
||
content: JSON.stringify({ description: desc }),
|
||
data: { description: desc },
|
||
action: { type: "navigate", url, description: desc },
|
||
};
|
||
}
|
||
|
||
if (actionType === "invalidate") {
|
||
const scope = actionResult.scope as string[];
|
||
// Strip __action, scope, and large data from the result sent back to the AI
|
||
const { __action: _, scope: _s, coverImageUrl: _img, ...rest } = actionResult;
|
||
const content = JSON.stringify(rest);
|
||
return {
|
||
content: content.length > 4000 ? content.slice(0, 4000) + '..."' : content,
|
||
data: rest,
|
||
action: { type: "invalidate", scope },
|
||
};
|
||
}
|
||
}
|
||
|
||
// Cap tool result size to prevent oversized OpenAI conversation payloads
|
||
const content = typeof result === "string" ? result : JSON.stringify(result);
|
||
return {
|
||
content: content.length > 8000 ? content.slice(0, 8000) + '..."' : content,
|
||
...(typeof result === "string" ? {} : { data: result }),
|
||
};
|
||
} catch (err) {
|
||
const normalizedError = normalizeAssistantExecutionError(err);
|
||
logger.error(
|
||
{
|
||
tool: name,
|
||
userId: ctx.userId,
|
||
userRole: ctx.userRole,
|
||
error: err instanceof Error ? { message: err.message, stack: err.stack } : err,
|
||
},
|
||
"AI assistant tool execution failed",
|
||
);
|
||
return { content: JSON.stringify(normalizedError), data: normalizedError };
|
||
}
|
||
}
|