2764 lines
86 KiB
TypeScript
2764 lines
86 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, VacationType } from "@capakraken/db";
|
|
import {
|
|
CreateAssignmentSchema,
|
|
AllocationStatus,
|
|
PermissionKey,
|
|
SystemRole,
|
|
} 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 {
|
|
configReadmodelToolDefinitions,
|
|
createConfigReadmodelExecutors,
|
|
} from "./assistant-tools/config-readmodels.js";
|
|
import {
|
|
countryMetroAdminToolDefinitions,
|
|
createCountryMetroAdminExecutors,
|
|
} from "./assistant-tools/country-metro-admin.js";
|
|
import {
|
|
countryReadmodelToolDefinitions,
|
|
createCountryReadmodelExecutors,
|
|
} from "./assistant-tools/country-readmodels.js";
|
|
import {
|
|
createUserSelfServiceExecutors,
|
|
userSelfServiceToolDefinitions,
|
|
} from "./assistant-tools/user-self-service.js";
|
|
import {
|
|
createUserAdminExecutors,
|
|
userAdminToolDefinitions,
|
|
} from "./assistant-tools/user-admin.js";
|
|
import {
|
|
createNotificationsTasksExecutors,
|
|
notificationInboxToolDefinitions,
|
|
notificationTaskToolDefinitions,
|
|
} from "./assistant-tools/notifications-tasks.js";
|
|
import {
|
|
createEstimateExecutors,
|
|
estimateMutationToolDefinitions,
|
|
estimateReadToolDefinitions,
|
|
} from "./assistant-tools/estimates.js";
|
|
import {
|
|
createProjectExecutors,
|
|
projectMutationToolDefinitions,
|
|
projectReadToolDefinitions,
|
|
} from "./assistant-tools/projects.js";
|
|
import {
|
|
createStaffingDemandExecutors,
|
|
staffingDemandMutationToolDefinitions,
|
|
staffingDemandReadToolDefinitions,
|
|
} from "./assistant-tools/staffing-demand.js";
|
|
import {
|
|
createResourceExecutors,
|
|
resourceMutationToolDefinitions,
|
|
resourceReadToolDefinitions,
|
|
} from "./assistant-tools/resources.js";
|
|
import {
|
|
blueprintsRateCardsToolDefinitions,
|
|
createBlueprintsRateCardsExecutors,
|
|
} from "./assistant-tools/blueprints-rate-cards.js";
|
|
import {
|
|
createDashboardInsightsReportsExecutors,
|
|
dashboardInsightsReportsToolDefinitions,
|
|
} from "./assistant-tools/dashboard-insights-reports.js";
|
|
import {
|
|
createScenarioRateAnalysisExecutors,
|
|
scenarioRateAnalysisToolDefinitions,
|
|
} from "./assistant-tools/scenario-rate-analysis.js";
|
|
import {
|
|
createImportExportDispoExecutors,
|
|
importExportDispoToolDefinitions,
|
|
} from "./assistant-tools/import-export-dispo.js";
|
|
import {
|
|
commentMutationToolDefinitions,
|
|
commentReadToolDefinitions,
|
|
createCommentExecutors,
|
|
} from "./assistant-tools/comments.js";
|
|
import {
|
|
auditHistoryToolDefinitions,
|
|
createAuditHistoryExecutors,
|
|
} from "./assistant-tools/audit-history.js";
|
|
import {
|
|
createPlanningNavigationExecutors,
|
|
planningNavigationToolDefinitions,
|
|
} from "./assistant-tools/planning-navigation.js";
|
|
import {
|
|
withToolAccess,
|
|
type ToolAccessRequirements,
|
|
type ToolContext,
|
|
type ToolDef,
|
|
type ToolExecutor,
|
|
} from "./assistant-tools/shared.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 CONTROLLER_ASSISTANT_ROLES = [
|
|
SystemRole.ADMIN,
|
|
SystemRole.MANAGER,
|
|
SystemRole.CONTROLLER,
|
|
] as const;
|
|
|
|
const MANAGER_ASSISTANT_ROLES = [
|
|
SystemRole.ADMIN,
|
|
SystemRole.MANAGER,
|
|
] as const;
|
|
|
|
const ADMIN_ASSISTANT_ROLES = [SystemRole.ADMIN] as const;
|
|
|
|
const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial<Record<string, ToolAccessRequirements>> = {
|
|
search_projects: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
|
get_project: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
|
update_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
|
|
create_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
|
|
approve_vacation: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
|
reject_vacation: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
|
get_pending_vacation_approvals: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
|
get_entitlement_summary: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
|
set_entitlement: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
|
delete_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
|
|
generate_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
|
|
remove_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
|
|
};
|
|
|
|
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[] = withToolAccess([
|
|
// ── READ TOOLS ──
|
|
...resourceReadToolDefinitions,
|
|
...projectReadToolDefinitions,
|
|
...advancedTimelineToolDefinitions,
|
|
...allocationPlanningReadToolDefinitions,
|
|
...vacationHolidayReadToolDefinitions,
|
|
...vacationHolidayMutationToolDefinitions,
|
|
...rolesAnalyticsReadToolDefinitions,
|
|
...chargeabilityComputationReadToolDefinitions,
|
|
...planningNavigationToolDefinitions,
|
|
|
|
// ── WRITE TOOLS ──
|
|
...allocationPlanningMutationToolDefinitions,
|
|
...resourceMutationToolDefinitions,
|
|
...projectMutationToolDefinitions,
|
|
|
|
// ── 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 ──
|
|
...staffingDemandReadToolDefinitions,
|
|
...staffingDemandMutationToolDefinitions,
|
|
|
|
// ── BLUEPRINT ──
|
|
...blueprintsRateCardsToolDefinitions,
|
|
|
|
// ── ESTIMATES ──
|
|
...estimateReadToolDefinitions,
|
|
...estimateMutationToolDefinitions,
|
|
|
|
// ── ROLES ──
|
|
...rolesAnalyticsMutationToolDefinitions,
|
|
|
|
// ── CLIENTS ──
|
|
...clientMutationToolDefinitions,
|
|
|
|
// ── ADMIN / CONFIG READ TOOLS ──
|
|
...countryReadmodelToolDefinitions,
|
|
...countryMetroAdminToolDefinitions,
|
|
...configReadmodelToolDefinitions,
|
|
...userAdminToolDefinitions,
|
|
...userSelfServiceToolDefinitions,
|
|
...notificationInboxToolDefinitions,
|
|
...dashboardInsightsReportsToolDefinitions,
|
|
|
|
// ── ORG UNIT MANAGEMENT ──
|
|
...orgUnitMutationToolDefinitions,
|
|
|
|
// ── TASK MANAGEMENT ──
|
|
...notificationTaskToolDefinitions,
|
|
...commentReadToolDefinitions,
|
|
...scenarioRateAnalysisToolDefinitions,
|
|
...commentMutationToolDefinitions,
|
|
...auditHistoryToolDefinitions,
|
|
...importExportDispoToolDefinitions,
|
|
...settingsAdminToolDefinitions,
|
|
], LEGACY_MONOLITHIC_TOOL_ACCESS);
|
|
|
|
// ─── 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 = {
|
|
...createResourceExecutors({
|
|
assertPermission,
|
|
createResourceCaller,
|
|
createRoleCaller,
|
|
createCountryCaller,
|
|
createOrgUnitCaller,
|
|
createScopedCallerContext,
|
|
resolveResourceIdentifier,
|
|
resolveEntityOrAssistantError,
|
|
toAssistantResourceMutationError,
|
|
toAssistantResourceCreationError,
|
|
}),
|
|
|
|
...createProjectExecutors({
|
|
assertPermission,
|
|
createProjectCaller,
|
|
createBlueprintCaller,
|
|
createClientCaller,
|
|
createScopedCallerContext,
|
|
resolveProjectIdentifier,
|
|
resolveResponsiblePerson,
|
|
resolveEntityOrAssistantError,
|
|
toAssistantNotFoundError,
|
|
toAssistantProjectMutationError,
|
|
toAssistantProjectCreationError,
|
|
toAssistantProjectNotFoundError,
|
|
}),
|
|
...createStaffingDemandExecutors({
|
|
assertPermission,
|
|
createAllocationCaller,
|
|
createStaffingCaller,
|
|
createRoleCaller,
|
|
createScopedCallerContext,
|
|
resolveProjectIdentifier,
|
|
resolveResourceIdentifier,
|
|
resolveEntityOrAssistantError,
|
|
parseIsoDate,
|
|
parseOptionalIsoDate,
|
|
fmtDate,
|
|
toAssistantDemandCreationError,
|
|
toAssistantDemandFillError,
|
|
}),
|
|
|
|
...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,
|
|
}),
|
|
...createBlueprintsRateCardsExecutors({
|
|
createBlueprintCaller,
|
|
createRateCardCaller,
|
|
createScopedCallerContext,
|
|
resolveResourceIdentifier,
|
|
resolveEntityOrAssistantError,
|
|
parseOptionalIsoDate,
|
|
fmtDate,
|
|
}),
|
|
...createDashboardInsightsReportsExecutors({
|
|
assertPermission,
|
|
createDashboardCaller,
|
|
createInsightsCaller,
|
|
createReportCaller,
|
|
createScopedCallerContext,
|
|
}),
|
|
...createPlanningNavigationExecutors({
|
|
createEstimateCaller,
|
|
createClientCaller,
|
|
createOrgUnitCaller,
|
|
createTimelineCaller,
|
|
createScopedCallerContext,
|
|
resolveProjectIdentifier,
|
|
parseIsoDate,
|
|
}),
|
|
...createScenarioRateAnalysisExecutors({
|
|
assertPermission,
|
|
createRateCardCaller,
|
|
createScenarioCaller,
|
|
createInsightsCaller,
|
|
createScopedCallerContext,
|
|
fmtEur,
|
|
}),
|
|
...createCommentExecutors({
|
|
createCommentCaller,
|
|
createScopedCallerContext,
|
|
toAssistantCommentCreationError,
|
|
toAssistantCommentResolveError,
|
|
}),
|
|
...createAuditHistoryExecutors({
|
|
createAuditLogCaller,
|
|
createScopedCallerContext,
|
|
}),
|
|
|
|
// ── 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`,
|
|
};
|
|
},
|
|
|
|
// ── ESTIMATES ──
|
|
...createEstimateExecutors({
|
|
assertPermission,
|
|
createEstimateCaller,
|
|
createScopedCallerContext,
|
|
resolveProjectIdentifier,
|
|
toAssistantEstimateNotFoundError,
|
|
toAssistantEstimateReadError,
|
|
toAssistantEstimateCreationError,
|
|
toAssistantEstimateMutationError,
|
|
}),
|
|
|
|
// ── ROLES ──
|
|
|
|
// ── CLIENTS ──
|
|
|
|
// ── ADMIN / CONFIG ──
|
|
|
|
...createCountryReadmodelExecutors({
|
|
createCountryCaller,
|
|
createScopedCallerContext,
|
|
formatCountry,
|
|
toAssistantCountryNotFoundError,
|
|
}),
|
|
...createCountryMetroAdminExecutors({
|
|
createCountryCaller,
|
|
createScopedCallerContext,
|
|
assertAdminRole,
|
|
formatCountry,
|
|
toAssistantCountryMutationError,
|
|
toAssistantMetroCityMutationError,
|
|
}),
|
|
...createConfigReadmodelExecutors({
|
|
createManagementLevelCaller,
|
|
createUtilizationCategoryCaller,
|
|
createCalculationRuleCaller,
|
|
createEffortRuleCaller,
|
|
createExperienceMultiplierCaller,
|
|
createScopedCallerContext,
|
|
}),
|
|
...createUserAdminExecutors({
|
|
createUserCaller,
|
|
createScopedCallerContext,
|
|
toAssistantUserMutationError,
|
|
toAssistantUserResourceLinkError,
|
|
}),
|
|
...createUserSelfServiceExecutors({
|
|
createUserCaller,
|
|
createScopedCallerContext,
|
|
toAssistantTotpEnableError,
|
|
}),
|
|
...createNotificationsTasksExecutors({
|
|
createNotificationCaller,
|
|
createScopedCallerContext,
|
|
parseDateTime,
|
|
parseOptionalDateTime,
|
|
toAssistantTaskNotFoundError,
|
|
toAssistantTaskActionError,
|
|
toAssistantTaskAssignmentError,
|
|
toAssistantBroadcastNotFoundError,
|
|
toAssistantReminderNotFoundError,
|
|
toAssistantNotificationReadError,
|
|
toAssistantNotificationDeletionError,
|
|
toAssistantReminderCreationError,
|
|
toAssistantNotificationCreationError,
|
|
}),
|
|
|
|
...createImportExportDispoExecutors({
|
|
assertPermission,
|
|
createImportExportCaller,
|
|
createDispoCaller,
|
|
createScopedCallerContext,
|
|
toAssistantDispoImportBatchNotFoundError,
|
|
}),
|
|
|
|
...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 };
|
|
}
|
|
}
|