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