dfeb4d361e
Tests fell behind source changes: lastTotpAt replay-attack prevention, activeSession invalidation on password reset, select clauses in permission updates, UNAUTHORIZED (anti-enumeration) for disabled TOTP, and password minimum raised from 8 to 12 characters. Also fix root eslint.config.mjs to ignore packages/ (linted via turbo) and add --no-warn-ignored to lint-staged to suppress warnings for ignored files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2547 lines
79 KiB
TypeScript
2547 lines
79 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,
|
|
toIsoDateOrNull,
|
|
} 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/index.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 {
|
|
createVacationEntitlementExecutors,
|
|
vacationEntitlementToolDefinitions,
|
|
} from "./assistant-tools/vacation-entitlements.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 ────────────────────────────────────────────────────────────────
|
|
|
|
const fmtDate = toIsoDateOrNull;
|
|
|
|
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: toIsoDateOrNull(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 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] },
|
|
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 12 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 12 characters")) {
|
|
return { error: "Password must be at least 12 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 === "CONFLICT") {
|
|
if (error.message.includes("already linked")) {
|
|
return { error: "Resource is already linked to another user." };
|
|
}
|
|
if (error.message.includes("changed during update")) {
|
|
return { error: "Resource link changed during update. Please retry." };
|
|
}
|
|
}
|
|
|
|
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.message.includes("dismissed")) {
|
|
return { error: "Task has been dismissed and cannot be executed." };
|
|
}
|
|
}
|
|
|
|
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 (
|
|
context === "broadcast" &&
|
|
trpcError?.code === "BAD_REQUEST" &&
|
|
trpcError.message === "Scheduled broadcasts with task metadata are not supported yet."
|
|
) {
|
|
return { error: "Scheduled broadcasts with task metadata are not supported yet." };
|
|
}
|
|
|
|
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,
|
|
...vacationEntitlementToolDefinitions,
|
|
|
|
// ── 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,
|
|
);
|
|
|
|
const TOOL_DEFINITIONS_BY_NAME = new Map(
|
|
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool]),
|
|
);
|
|
|
|
type AssistantToolAccessEvaluationContext = Pick<ToolContext, "permissions" | "userRole">;
|
|
|
|
type AssistantToolAccessFailure =
|
|
| { type: "role" }
|
|
| {
|
|
type: "permission";
|
|
permission?: PermissionKey;
|
|
message?: string;
|
|
};
|
|
|
|
function hasAssistantResourceOverviewAccess(permissions: Set<PermissionKey>): boolean {
|
|
return (
|
|
permissions.has(PermissionKey.VIEW_ALL_RESOURCES) ||
|
|
permissions.has(PermissionKey.MANAGE_RESOURCES)
|
|
);
|
|
}
|
|
|
|
function getAssistantToolAccessRequirements(tool: ToolDef): ToolAccessRequirements | undefined {
|
|
return tool.access ?? LEGACY_MONOLITHIC_TOOL_ACCESS[tool.function.name];
|
|
}
|
|
|
|
function getAssistantToolAccessFailure(
|
|
tool: ToolDef,
|
|
ctx: AssistantToolAccessEvaluationContext,
|
|
): AssistantToolAccessFailure | null {
|
|
const access = getAssistantToolAccessRequirements(tool);
|
|
if (!access) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
access.allowedSystemRoles &&
|
|
!access.allowedSystemRoles.includes(ctx.userRole as SystemRole)
|
|
) {
|
|
return { type: "role" };
|
|
}
|
|
|
|
const missingRequiredPermission = access.requiredPermissions?.find(
|
|
(permission) => !ctx.permissions.has(permission),
|
|
);
|
|
if (missingRequiredPermission) {
|
|
return {
|
|
type: "permission",
|
|
permission: missingRequiredPermission,
|
|
};
|
|
}
|
|
|
|
if (access.requiresPlanningRead && !ctx.permissions.has(PermissionKey.VIEW_PLANNING)) {
|
|
return {
|
|
type: "permission",
|
|
permission: PermissionKey.VIEW_PLANNING,
|
|
};
|
|
}
|
|
|
|
if (access.requiresCostView && !ctx.permissions.has(PermissionKey.VIEW_COSTS)) {
|
|
return {
|
|
type: "permission",
|
|
permission: PermissionKey.VIEW_COSTS,
|
|
};
|
|
}
|
|
|
|
if (
|
|
access.requiresAdvancedAssistant &&
|
|
!ctx.permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)
|
|
) {
|
|
return {
|
|
type: "permission",
|
|
permission: PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
|
};
|
|
}
|
|
|
|
if (access.requiresResourceOverview && !hasAssistantResourceOverviewAccess(ctx.permissions)) {
|
|
return {
|
|
type: "permission",
|
|
message: "Permission denied: you need resource overview access to perform this action.",
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function toAssistantToolAccessError(failure: AssistantToolAccessFailure): AssistantVisibleError {
|
|
if (failure.type === "role") {
|
|
return new AssistantVisibleError("You do not have permission to perform this action.");
|
|
}
|
|
|
|
if (failure.permission) {
|
|
return new AssistantVisibleError(
|
|
`Permission denied: you need the "${failure.permission}" permission to perform this action.`,
|
|
);
|
|
}
|
|
|
|
return new AssistantVisibleError(
|
|
failure.message ?? "You do not have permission to perform this action.",
|
|
);
|
|
}
|
|
|
|
export function canAccessAssistantTool(
|
|
tool: ToolDef,
|
|
ctx: AssistantToolAccessEvaluationContext,
|
|
): boolean {
|
|
return getAssistantToolAccessFailure(tool, ctx) === null;
|
|
}
|
|
|
|
export function getAvailableAssistantToolsForContext(
|
|
permissions: Set<PermissionKey>,
|
|
userRole: string,
|
|
): ToolDef[] {
|
|
return TOOL_DEFINITIONS.filter((tool) => canAccessAssistantTool(tool, { permissions, userRole }));
|
|
}
|
|
|
|
// ─── 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,
|
|
}),
|
|
...createVacationEntitlementExecutors({
|
|
createVacationCaller,
|
|
createEntitlementCaller,
|
|
createScopedCallerContext,
|
|
resolveResourceIdentifier,
|
|
parseIsoDate,
|
|
fmtDate,
|
|
parseAssistantVacationRequestType,
|
|
toAssistantVacationCreationError,
|
|
toAssistantVacationMutationError,
|
|
toAssistantEntitlementMutationError,
|
|
}),
|
|
|
|
// ── 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,
|
|
toAssistantCurrentUserError: toAssistantUserMutationError,
|
|
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 toolDefinition = TOOL_DEFINITIONS_BY_NAME.get(name);
|
|
const accessFailure = toolDefinition
|
|
? getAssistantToolAccessFailure(toolDefinition, ctx)
|
|
: null;
|
|
if (accessFailure) {
|
|
throw toAssistantToolAccessError(accessFailure);
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|