From dd2c9c0f88fbc2b7002541727a439fdc10edea60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 11 Apr 2026 22:34:41 +0200 Subject: [PATCH] perf(api,web,db): refactor and optimize for enterprise readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing @@index([userId]) on Account and Session models (auth query perf) - Batch holiday-auto-import to eliminate N+1 query pattern (O(n) → O(1)) - Reduce SessionProvider refetchInterval from 5min to 15min - Fix Cache-Control catch-all to stop blocking static asset caching - Decompose assistant-tools.ts (2,562 → 809 lines) into callers, helpers, access-control modules - Add @next/bundle-analyzer for data-driven bundle optimization - Add @react-pdf/renderer to optimizePackageImports - Add safety caps (take limits) on unbounded findMany queries Co-Authored-By: Claude Opus 4.6 --- apps/web/next.config.ts | 18 +- apps/web/package.json | 1 + apps/web/src/lib/trpc/provider.tsx | 2 +- .../assistant-tools-broadcast-send.test.ts | 2 + packages/api/src/lib/holiday-auto-import.ts | 82 +- .../api/src/lib/notification-targeting.ts | 13 +- packages/api/src/router/assistant-tools.ts | 2025 ++--------------- .../router/assistant-tools/access-control.ts | 135 ++ .../api/src/router/assistant-tools/callers.ts | 72 + .../api/src/router/assistant-tools/helpers.ts | 1712 ++++++++++++++ packages/db/prisma/schema.prisma | 3 + pnpm-lock.yaml | 128 ++ 12 files changed, 2273 insertions(+), 1920 deletions(-) create mode 100644 packages/api/src/router/assistant-tools/access-control.ts create mode 100644 packages/api/src/router/assistant-tools/callers.ts create mode 100644 packages/api/src/router/assistant-tools/helpers.ts diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 155fb81..1adf2d8 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -7,7 +7,13 @@ const nextConfig: NextConfig = { outputFileTracingRoot: path.resolve(__dirname, "../.."), devIndicators: false, experimental: { - optimizePackageImports: ["recharts", "date-fns", "framer-motion", "@capakraken/shared"], + optimizePackageImports: [ + "recharts", + "date-fns", + "framer-motion", + "@capakraken/shared", + "@react-pdf/renderer", + ], }, transpilePackages: [ "@capakraken/api", @@ -60,8 +66,8 @@ const nextConfig: NextConfig = { ], }, { - // Catch-all for error pages and any remaining routes - source: "/:path*", + // Catch-all for error pages and remaining routes (exclude static assets) + source: "/((?!_next/static|_next/image|favicon.ico).*)", headers: [ { key: "Cache-Control", value: "no-store, no-cache, must-revalidate" }, { key: "Pragma", value: "no-cache" }, @@ -88,7 +94,13 @@ const nextConfig: NextConfig = { // Only wrap with Sentry in production — the worker.js crash in dev mode // (vendor-chunks/lib/worker.js MODULE_NOT_FOUND) makes the dev server unstable // Sentry only in production — dynamic import avoids side effects in dev +// Bundle analyzer — run with ANALYZE=true to inspect client/server chunks let exportedConfig: NextConfig = nextConfig; +if (process.env.ANALYZE === "true") { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const withBundleAnalyzer = require("@next/bundle-analyzer")({ enabled: true }); + exportedConfig = withBundleAnalyzer(exportedConfig); +} if (process.env.NODE_ENV === "production") { try { // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/apps/web/package.json b/apps/web/package.json index 58be8c4..bbe851f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,6 +49,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@next/bundle-analyzer": "^15.5.15", "@axe-core/playwright": "^4.11.1", "@capakraken/eslint-config": "workspace:*", "@capakraken/tsconfig": "workspace:*", diff --git a/apps/web/src/lib/trpc/provider.tsx b/apps/web/src/lib/trpc/provider.tsx index c6d31bc..937d906 100644 --- a/apps/web/src/lib/trpc/provider.tsx +++ b/apps/web/src/lib/trpc/provider.tsx @@ -89,7 +89,7 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) { ); return ( - + {children} diff --git a/packages/api/src/__tests__/assistant-tools-broadcast-send.test.ts b/packages/api/src/__tests__/assistant-tools-broadcast-send.test.ts index 9ba7b08..aff9203 100644 --- a/packages/api/src/__tests__/assistant-tools-broadcast-send.test.ts +++ b/packages/api/src/__tests__/assistant-tools-broadcast-send.test.ts @@ -39,6 +39,7 @@ describe("assistant broadcast send tool", () => { expect(db.user.findMany).toHaveBeenCalledWith({ select: { id: true }, + take: 10_000, }); expect(create).not.toHaveBeenCalled(); expect(JSON.parse(result.content)).toEqual({ @@ -80,6 +81,7 @@ describe("assistant broadcast send tool", () => { expect(db.user.findMany).toHaveBeenCalledWith({ select: { id: true }, + take: 10_000, }); expect(create).toHaveBeenCalledWith({ data: expect.objectContaining({ diff --git a/packages/api/src/lib/holiday-auto-import.ts b/packages/api/src/lib/holiday-auto-import.ts index e32bc46..9c42d7b 100644 --- a/packages/api/src/lib/holiday-auto-import.ts +++ b/packages/api/src/lib/holiday-auto-import.ts @@ -61,6 +61,7 @@ export async function autoImportPublicHolidays( country: { select: { code: true } }, metroCity: { select: { name: true } }, }, + take: 50_000, }); if (resources.length === 0) { @@ -103,43 +104,68 @@ export async function autoImportPublicHolidays( if (holidays.length === 0) continue; const resourceIds = groupedResources.map((resource: { id: string }) => resource.id); + // Batch: collect all holiday dates, fetch existing records in one query + const holidayDates = holidays.map((h) => new Date(h.date)); + + const allExisting: MinimalVacation[] = await db.vacation.findMany({ + where: { + resourceId: { in: resourceIds }, + type: "PUBLIC_HOLIDAY", + startDate: { in: holidayDates }, + endDate: { in: holidayDates }, + }, + select: { resourceId: true, startDate: true, endDate: true }, + }); + + // Map: date ISO string → set of resourceIds that already have the holiday + const existingByDate = new Map>(); + for (const v of allExisting) { + const key = v.startDate.toISOString(); + const set = existingByDate.get(key) ?? new Set(); + set.add(v.resourceId); + existingByDate.set(key, set); + } + + // Build all new records for a single createMany call + const allNewRecords: Array<{ + resourceId: string; + type: string; + status: string; + startDate: Date; + endDate: Date; + note: string; + isHalfDay: boolean; + approvedAt: Date; + }> = []; + const approvedAt = new Date(); + for (const holiday of holidays) { const holidayDate = new Date(holiday.date); - - // Find existing records for this date + type to skip duplicates - const existing: MinimalVacation[] = await db.vacation.findMany({ - where: { - resourceId: { in: resourceIds }, - type: "PUBLIC_HOLIDAY", - startDate: holidayDate, - endDate: holidayDate, - }, - select: { resourceId: true, startDate: true, endDate: true }, - }); - - const existingResourceIds = new Set(existing.map((v: MinimalVacation) => v.resourceId)); - const newResourceIds = resourceIds.filter((id: string) => !existingResourceIds.has(id)); + const dateKey = holidayDate.toISOString(); + const existingResourceIds = existingByDate.get(dateKey) ?? new Set(); totalSkipped += existingResourceIds.size; - if (newResourceIds.length === 0) continue; - - const records = newResourceIds.map((resourceId: string) => ({ - resourceId, - type: "PUBLIC_HOLIDAY", - status: "APPROVED", - startDate: holidayDate, - endDate: holidayDate, - note: holiday.name, - isHalfDay: false, - approvedAt: new Date(), - })); + for (const resourceId of resourceIds) { + if (existingResourceIds.has(resourceId)) continue; + allNewRecords.push({ + resourceId, + type: "PUBLIC_HOLIDAY", + status: "APPROVED", + startDate: holidayDate, + endDate: holidayDate, + note: holiday.name, + isHalfDay: false, + approvedAt, + }); + } + } + if (allNewRecords.length > 0) { const result = await db.vacation.createMany({ - data: records, + data: allNewRecords, skipDuplicates: true, }); - totalCreated += result.count; } } diff --git a/packages/api/src/lib/notification-targeting.ts b/packages/api/src/lib/notification-targeting.ts index b55fee6..7e17759 100644 --- a/packages/api/src/lib/notification-targeting.ts +++ b/packages/api/src/lib/notification-targeting.ts @@ -22,7 +22,9 @@ export async function resolveRecipients( case "role": { // Find all users with the given systemRole const roleUsers = await db.user.findMany({ - where: { systemRole: targetValue as "ADMIN" | "MANAGER" | "CONTROLLER" | "USER" | "VIEWER" }, + where: { + systemRole: targetValue as "ADMIN" | "MANAGER" | "CONTROLLER" | "USER" | "VIEWER", + }, select: { id: true }, }); userIds = roleUsers.map((u) => u.id); @@ -36,9 +38,7 @@ export async function resolveRecipients( where: { projectId: targetValue, status: { not: "CANCELLED" } }, select: { resource: { select: { userId: true } } }, }); - userIds = assignments - .map((a) => a.resource.userId) - .filter((id): id is string => !!id); + userIds = assignments.map((a) => a.resource.userId).filter((id): id is string => !!id); break; } @@ -49,9 +49,7 @@ export async function resolveRecipients( where: { orgUnitId: targetValue, isActive: true }, select: { userId: true }, }); - userIds = resources - .map((r) => r.userId) - .filter((id): id is string => !!id); + userIds = resources.map((r) => r.userId).filter((id): id is string => !!id); break; } @@ -59,6 +57,7 @@ export async function resolveRecipients( // User model has no isActive — get all users const allUsers = await db.user.findMany({ select: { id: true }, + take: 10_000, }); userIds = allUsers.map((u) => u.id); break; diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index d005ee6..c996482 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -1,57 +1,132 @@ /** * AI Assistant Tool definitions for OpenAI Function Calling. * Each tool has a JSON schema (for the AI) and an execute function (for the server). + * + * Helpers, callers, and access control are extracted into: + * - ./assistant-tools/helpers.ts (~1,400 lines of formatters, error converters, parsers) + * - ./assistant-tools/callers.ts (35 tRPC caller factory bindings) + * - ./assistant-tools/access-control.ts (role/permission gating logic) */ -import { Prisma, VacationType } from "@capakraken/db"; -import { - CreateAssignmentSchema, - AllocationStatus, - PermissionKey, - SystemRole, - toIsoDateOrNull, -} from "@capakraken/shared"; -import type { WeekdayAvailability } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; -import { ZodError } from "zod"; -import { fmtEur } from "../lib/format-utils.js"; -import { timelineRouter } from "./timeline.js"; +import { PermissionKey } from "@capakraken/shared"; +import { createReadOnlyProxy } from "../lib/read-only-prisma.js"; import { logger } from "../lib/logger.js"; -import { createCallerFactory, type TRPCContext } from "../trpc.js"; -import { auditLogRouter } from "./audit-log.js"; -import { chargeabilityReportRouter } from "./chargeability-report.js"; -import { computationGraphRouter } from "./computation-graph.js"; -import { dispoRouter } from "./dispo.js"; -import { importExportRouter } from "./import-export.js"; -import { resourceRouter } from "./resource.js"; -import { settingsRouter } from "./settings.js"; -import { systemRoleConfigRouter } from "./system-role-config.js"; -import { userRouter } from "./user.js"; -import { notificationRouter } from "./notification.js"; -import { estimateRouter } from "./estimate.js"; -import { webhookRouter } from "./webhook.js"; -import { countryRouter } from "./country.js"; -import { holidayCalendarRouter } from "./holiday-calendar.js"; -import { blueprintRouter } from "./blueprint.js"; -import { roleRouter } from "./role.js"; -import { clientRouter } from "./client.js"; -import { orgUnitRouter } from "./org-unit.js"; -import { projectRouter } from "./project.js"; -import { rateCardRouter } from "./rate-card.js"; -import { reportRouter } from "./report.js"; -import { vacationRouter } from "./vacation.js"; -import { entitlementRouter } from "./entitlement.js"; -import { commentRouter } from "./comment.js"; -import { managementLevelRouter } from "./management-level.js"; -import { utilizationCategoryRouter } from "./utilization-category.js"; -import { calculationRuleRouter } from "./calculation-rules.js"; -import { effortRuleRouter } from "./effort-rule.js"; -import { experienceMultiplierRouter } from "./experience-multiplier.js"; -import { dashboardRouter } from "./dashboard.js"; -import { insightsRouter } from "./insights.js"; -import { scenarioRouter } from "./scenario.js"; -import { allocationRouter } from "./allocation/index.js"; -import { staffingRouter } from "./staffing.js"; + +// ─── Extracted modules ────────────────────────────────────────────────────── +import { + createChargeabilityReportCaller, + createComputationGraphCaller, + createTimelineCaller, + createAuditLogCaller, + createImportExportCaller, + createDispoCaller, + createResourceCaller, + createSettingsCaller, + createSystemRoleConfigCaller, + createUserCaller, + createNotificationCaller, + createEstimateCaller, + createWebhookCaller, + createCountryCaller, + createHolidayCalendarCaller, + createBlueprintCaller, + createRoleCaller, + createClientCaller, + createOrgUnitCaller, + createProjectCaller, + createRateCardCaller, + createReportCaller, + createVacationCaller, + createEntitlementCaller, + createCommentCaller, + createManagementLevelCaller, + createUtilizationCategoryCaller, + createCalculationRuleCaller, + createEffortRuleCaller, + createExperienceMultiplierCaller, + createDashboardCaller, + createInsightsCaller, + createScenarioCaller, + createAllocationCaller, + createStaffingCaller, +} from "./assistant-tools/callers.js"; +import { + assertPermission, + assertAdminRole, + fmtDate, + formatHolidayCalendarEntry, + formatHolidayCalendar, + formatCountry, + resolveHolidayPeriod, + parseIsoDate, + parseOptionalIsoDate, + parseDateTime, + parseOptionalDateTime, + isAssistantToolErrorResult, + toAssistantIndexedFieldError, + toAssistantNotFoundError, + toAssistantAllocationNotFoundError, + toAssistantProjectNotFoundError, + toAssistantTimelineMutationError, + toAssistantVacationMutationError, + toAssistantProjectCreationError, + toAssistantDemandCreationError, + toAssistantDemandFillError, + toAssistantEstimateNotFoundError, + toAssistantEstimateReadError, + toAssistantEstimateMutationError, + toAssistantEstimateCreationError, + toAssistantHolidayCalendarNotFoundError, + toAssistantHolidayCalendarMutationError, + toAssistantHolidayEntryMutationError, + toAssistantHolidayEntryNotFoundError, + toAssistantRoleMutationError, + toAssistantClientMutationError, + toAssistantOrgUnitMutationError, + toAssistantCountryMutationError, + toAssistantResourceCreationError, + toAssistantResourceMutationError, + toAssistantProjectMutationError, + toAssistantMetroCityMutationError, + toAssistantVacationCreationError, + toAssistantEntitlementMutationError, + toAssistantUserMutationError, + toAssistantUserResourceLinkError, + toAssistantTotpEnableError, + toAssistantWebhookNotFoundError, + toAssistantWebhookMutationError, + toAssistantAuditLogEntryNotFoundError, + toAssistantTaskActionError, + toAssistantTaskAssignmentError, + toAssistantBroadcastNotFoundError, + toAssistantDispoImportBatchNotFoundError, + toAssistantReminderNotFoundError, + toAssistantNotificationReadError, + toAssistantNotificationDeletionError, + toAssistantReminderCreationError, + toAssistantCommentResolveError, + toAssistantCommentCreationError, + toAssistantNotificationCreationError, + toAssistantCountryNotFoundError, + toAssistantTaskNotFoundError, + fmtEur, + normalizeAssistantExecutionError, + resolveEntityOrAssistantError, + resolveProjectIdentifier, + resolveResourceIdentifier, + resolveResponsiblePerson, + createScopedCallerContext, + sanitizeWebhook, + sanitizeWebhookList, + parseAssistantVacationRequestType, +} from "./assistant-tools/helpers.js"; +import { + LEGACY_MONOLITHIC_TOOL_ACCESS, + getAssistantToolAccessFailure, + toAssistantToolAccessError, + getAvailableAssistantToolsForContext as getAvailableToolsForCtx_, +} from "./assistant-tools/access-control.js"; +export { canAccessAssistantTool } from "./assistant-tools/access-control.js"; import { advancedTimelineToolDefinitions, createAdvancedTimelineExecutors, @@ -286,1713 +361,9 @@ export const ADVANCED_ASSISTANT_TOOLS = new Set([ "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); +// Callers imported from ./assistant-tools/callers.ts -// ─── Helpers ──────────────────────────────────────────────────────────────── - -const fmtDate = toIsoDateOrNull; - -class AssistantVisibleError extends Error { - constructor(message: string) { - super(message); - this.name = "AssistantVisibleError"; - } -} - -function assertPermission(ctx: ToolContext, perm: PermissionKey): void { - if (!ctx.permissions.has(perm)) { - throw new AssistantVisibleError( - `Permission denied: you need the "${perm}" permission to perform this action.`, - ); - } -} - -function assertAdminRole(ctx: ToolContext): void { - if (ctx.userRole !== SystemRole.ADMIN) { - throw new AssistantVisibleError("Admin role required to perform this action."); - } -} - -function formatHolidayCalendarEntry(entry: { - id: string; - date: Date; - name: string; - isRecurringAnnual?: boolean | null; - source?: string | null; -}) { - return { - id: entry.id, - date: toIsoDateOrNull(entry.date), - name: entry.name, - isRecurringAnnual: entry.isRecurringAnnual ?? false, - source: entry.source ?? null, - }; -} - -function formatHolidayCalendar(calendar: { - id: string; - name: string; - scopeType: string; - stateCode?: string | null; - isActive?: boolean | null; - priority?: number | null; - country?: { id: string; code: string; name: string } | null; - metroCity?: { id: string; name: string } | null; - _count?: { entries?: number | null } | null; - entries?: Array<{ - id: string; - date: Date; - name: string; - isRecurringAnnual?: boolean | null; - source?: string | null; - }> | null; -}) { - const entries = calendar.entries?.map(formatHolidayCalendarEntry) ?? []; - - return { - id: calendar.id, - name: calendar.name, - scopeType: calendar.scopeType, - stateCode: calendar.stateCode ?? null, - isActive: calendar.isActive ?? true, - priority: calendar.priority ?? 0, - country: calendar.country - ? { - id: calendar.country.id, - code: calendar.country.code, - name: calendar.country.name, - } - : null, - metroCity: calendar.metroCity - ? { - id: calendar.metroCity.id, - name: calendar.metroCity.name, - } - : null, - entryCount: calendar._count?.entries ?? entries.length, - entries, - }; -} - -function formatCountry(country: { - id: string; - code: string; - name: string; - dailyWorkingHours: number; - scheduleRules?: Prisma.JsonValue | null; - isActive?: boolean | null; - metroCities?: Array<{ id: string; name: string }> | null; - _count?: { resources?: number | null } | null; -}) { - return { - id: country.id, - code: country.code, - name: country.name, - dailyWorkingHours: country.dailyWorkingHours, - scheduleRules: country.scheduleRules ?? null, - isActive: country.isActive ?? true, - resourceCount: country._count?.resources ?? null, - metroCities: (country.metroCities ?? []).map((city) => ({ - id: city.id, - name: city.name, - })), - cities: (country.metroCities ?? []).map((city) => city.name), - }; -} - -function createUtcDate(year: number, monthIndex: number, day: number): Date { - return new Date(Date.UTC(year, monthIndex, day)); -} - -function resolveHolidayPeriod(input: { year?: number; periodStart?: string; periodEnd?: string }): { - year: number | null; - periodStart: Date; - periodEnd: Date; -} { - if (input.periodStart || input.periodEnd) { - if (!input.periodStart || !input.periodEnd) { - throw new AssistantVisibleError( - "periodStart and periodEnd must both be provided when using a custom holiday range.", - ); - } - - const periodStart = new Date(`${input.periodStart}T00:00:00.000Z`); - const periodEnd = new Date(`${input.periodEnd}T00:00:00.000Z`); - if (Number.isNaN(periodStart.getTime())) { - throw new AssistantVisibleError(`Invalid periodStart: ${input.periodStart}`); - } - if (Number.isNaN(periodEnd.getTime())) { - throw new AssistantVisibleError(`Invalid periodEnd: ${input.periodEnd}`); - } - if (periodEnd < periodStart) { - throw new AssistantVisibleError("periodEnd must be on or after periodStart."); - } - - return { year: null, periodStart, periodEnd }; - } - - const year = input.year ?? new Date().getUTCFullYear(); - return { - year, - periodStart: createUtcDate(year, 0, 1), - periodEnd: createUtcDate(year, 11, 31), - }; -} - -const CONTROLLER_ASSISTANT_ROLES = [ - SystemRole.ADMIN, - SystemRole.MANAGER, - SystemRole.CONTROLLER, -] as const; - -const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial> = { - search_projects: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] }, - get_project: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] }, - update_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, - create_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, - delete_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, - generate_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, - remove_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, -}; - -const ASSISTANT_VACATION_REQUEST_TYPES = [ - VacationType.ANNUAL, - VacationType.SICK, - VacationType.OTHER, -] as const; - -function parseAssistantVacationRequestType(input: string): VacationType { - const normalized = input.trim().toUpperCase(); - if (normalized === VacationType.PUBLIC_HOLIDAY) { - throw new AssistantVisibleError( - "PUBLIC_HOLIDAY requests cannot be created manually. Manage public holidays through holiday calendars instead.", - ); - } - - if ((ASSISTANT_VACATION_REQUEST_TYPES as readonly string[]).includes(normalized)) { - return normalized as VacationType; - } - - throw new AssistantVisibleError( - `Invalid vacation type: ${input}. Valid types: ${ASSISTANT_VACATION_REQUEST_TYPES.join(", ")}.`, - ); -} - -function parseIsoDate(value: string, fieldName: string): Date { - const parsed = new Date(`${value}T00:00:00.000Z`); - if (Number.isNaN(parsed.getTime())) { - throw new AssistantVisibleError(`Invalid ${fieldName}: ${value}`); - } - return parsed; -} - -function parseOptionalIsoDate(value: string | undefined, fieldName: string): Date | undefined { - return value ? parseIsoDate(value, fieldName) : undefined; -} - -function parseDateTime(value: string, fieldName: string): Date { - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - throw new AssistantVisibleError(`Invalid ${fieldName}: ${value}`); - } - return parsed; -} - -function parseOptionalDateTime(value: string | undefined, fieldName: string): Date | undefined { - return value ? parseDateTime(value, fieldName) : undefined; -} - -function toDate(value: Date | string): Date { - return value instanceof Date ? value : new Date(value); -} - -type AssistantToolErrorResult = { error: string }; -type AssistantIndexedFieldErrorResult = AssistantToolErrorResult & { - field: string; - index: number; -}; -type BatchQuickAssignmentInput = { - resourceId: string; - projectId: string; - startDate: Date; - endDate: Date; - hoursPerDay?: number; - role?: string; - status?: AllocationStatus; -}; - -function toAssistantNotFoundError( - error: unknown, - message: string, -): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - return { error: message }; - } - return null; -} - -function toAssistantAllocationNotFoundError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError) { - if (error.code === "NOT_FOUND") { - return { error: "Allocation not found with the given criteria." }; - } - if (error.message === "Record not found" || error.message.includes("Assignment not found")) { - return { error: "Allocation not found with the given criteria." }; - } - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (prismaError?.code === "P2025") { - return { error: "Allocation not found with the given criteria." }; - } - - return null; -} - -function toAssistantProjectNotFoundError( - error: unknown, - identifier: string, -): AssistantToolErrorResult | null { - return toAssistantNotFoundError(error, `Project not found: ${identifier}`); -} - -function toAssistantTimelineMutationError( - error: unknown, - context: "updateInline" | "applyShift" | "quickAssign" | "batchShift", -): AssistantToolErrorResult | null { - if (error instanceof TRPCError) { - if (error.code === "NOT_FOUND") { - if (error.message.includes("Resource not found")) { - return { error: "Resource not found with the given criteria." }; - } - if (error.message.includes("Project not found")) { - return { error: "Project not found with the given criteria." }; - } - if (error.message.includes("Demand requirement not found")) { - return { error: "Demand requirement not found with the given criteria." }; - } - if (error.message.includes("No allocations found")) { - return { error: "Allocation not found with the given criteria." }; - } - } - - if (error.code === "BAD_REQUEST" || error.code === "CONFLICT") { - return { error: error.message }; - } - } - - const allocationNotFound = toAssistantAllocationNotFoundError(error); - if (allocationNotFound && (context === "updateInline" || context === "batchShift")) { - return allocationNotFound; - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("resource")) { - return { error: "Resource not found with the given criteria." }; - } - if (errorText.includes("project")) { - return { error: "Project not found with the given criteria." }; - } - if (errorText.includes("demand")) { - return { error: "Demand requirement not found with the given criteria." }; - } - if (prismaError.code === "P2025" && (context === "updateInline" || context === "batchShift")) { - return { error: "Allocation not found with the given criteria." }; - } - - return null; -} - -function toAssistantVacationNotFoundError(error: unknown): AssistantToolErrorResult | null { - return toAssistantNotFoundError(error, "Vacation not found with the given criteria."); -} - -function toAssistantVacationMutationError( - error: unknown, - action: "approve" | "reject" | "cancel", -): AssistantToolErrorResult | null { - const notFound = toAssistantVacationNotFoundError(error); - if (notFound) { - return notFound; - } - - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - if (action === "approve") { - return { error: "Vacation cannot be approved in its current status." }; - } - if (action === "reject") { - return { error: "Vacation cannot be rejected in its current status." }; - } - return { error: "Vacation cannot be cancelled in its current status." }; - } - - return null; -} - -function toAssistantProjectCreationError( - error: unknown, - shortCode: string, -): AssistantToolErrorResult | null { - if (error instanceof TRPCError) { - if (error.code === "CONFLICT") { - return { error: `A project with short code "${shortCode}" already exists.` }; - } - - if (error.code === "NOT_FOUND") { - if (error.message.includes("Blueprint")) { - return { error: "Blueprint not found with the given criteria." }; - } - if (error.message.includes("Client")) { - return { error: "Client not found with the given criteria." }; - } - } - - if (error.code === "BAD_REQUEST" || error.code === "UNPROCESSABLE_CONTENT") { - return { error: error.message }; - } - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - if (prismaError.code !== "P2003" && prismaError.code !== "P2025") { - return null; - } - - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("blueprint")) { - return { error: "Blueprint not found with the given criteria." }; - } - if (errorText.includes("client")) { - return { error: "Client not found with the given criteria." }; - } - - return null; -} - -function toAssistantDemandNotFoundError(error: unknown): AssistantToolErrorResult | null { - return toAssistantNotFoundError(error, "Demand not found with the given criteria."); -} - -function toAssistantDemandFillError(error: unknown): AssistantToolErrorResult | null { - const notFound = toAssistantDemandNotFoundError(error); - if (notFound) { - return notFound; - } - - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - return { error: "Demand cannot be filled in its current status." }; - } - - return null; -} - -function toAssistantEstimateNotFoundError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - if (error.message.includes("version")) { - return { error: "Estimate version not found with the given criteria." }; - } - return { error: "Estimate not found with the given criteria." }; - } - - return null; -} - -function toAssistantEstimateReadError( - error: unknown, - context: "weeklyPhasing" | "commercialTerms", -): AssistantToolErrorResult | null { - const notFound = toAssistantEstimateNotFoundError(error); - if (notFound) { - return notFound; - } - - if ( - context === "weeklyPhasing" && - error instanceof TRPCError && - error.code === "PRECONDITION_FAILED" && - error.message === "Estimate has no versions" - ) { - return { error: "Estimate version not found with the given criteria." }; - } - - return null; -} - -function toAssistantHolidayCalendarNotFoundError( - error: unknown, - identifier?: string, -): AssistantToolErrorResult | null { - return toAssistantNotFoundError( - error, - identifier - ? `Holiday calendar not found: ${identifier}` - : "Holiday calendar not found with the given criteria.", - ); -} - -function toAssistantHolidayCalendarMutationError(error: unknown): AssistantToolErrorResult | null { - const notFound = toAssistantHolidayCalendarNotFoundError(error); - if (notFound) { - return notFound; - } - - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - return { error: "Holiday calendar scope is invalid." }; - } - - if (error instanceof TRPCError && error.code === "CONFLICT") { - return { error: "A holiday calendar for this scope already exists." }; - } - - return null; -} - -function toAssistantHolidayEntryNotFoundError(error: unknown): AssistantToolErrorResult | null { - return toAssistantNotFoundError( - error, - "Holiday calendar entry not found with the given criteria.", - ); -} - -function toAssistantHolidayEntryMutationError(error: unknown): AssistantToolErrorResult | null { - const calendarNotFound = toAssistantHolidayCalendarNotFoundError(error); - if (calendarNotFound) { - return calendarNotFound; - } - - const entryNotFound = toAssistantHolidayEntryNotFoundError(error); - if (entryNotFound) { - return entryNotFound; - } - - if (error instanceof TRPCError && error.code === "CONFLICT") { - return { error: "A holiday entry for this calendar and date already exists." }; - } - - return null; -} - -function toAssistantRoleNotFoundError(error: unknown): AssistantToolErrorResult | null { - return toAssistantNotFoundError(error, "Role not found with the given criteria."); -} - -function toAssistantRoleMutationError( - error: unknown, - action: "create" | "update" | "delete", -): AssistantToolErrorResult | null { - const notFound = toAssistantRoleNotFoundError(error); - if (notFound) { - return notFound; - } - - if (error instanceof TRPCError && error.code === "CONFLICT") { - return { error: "A role with this name already exists." }; - } - - if (action === "delete" && error instanceof TRPCError && error.code === "PRECONDITION_FAILED") { - return { error: "Role cannot be deleted while it is still assigned. Deactivate it instead." }; - } - - return null; -} - -function toAssistantClientMutationError( - error: unknown, - action: "create" | "update" | "delete" = "update", -): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - if (error.message.includes("Parent client")) { - return { error: "Parent client not found with the given criteria." }; - } - return { error: "Client not found with the given criteria." }; - } - - if (error instanceof TRPCError && error.code === "CONFLICT") { - return { error: "A client with this code already exists." }; - } - - if (action === "delete" && error instanceof TRPCError && error.code === "PRECONDITION_FAILED") { - if (error.message.includes("project")) { - return { - error: "Client cannot be deleted while it still has projects. Deactivate it instead.", - }; - } - if (error.message.includes("child client")) { - return { - error: - "Client cannot be deleted while it still has child clients. Remove or reassign them first.", - }; - } - } - - return null; -} - -function toAssistantOrgUnitNotFoundError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - if (error.message.includes("Parent org unit")) { - return { error: "Parent org unit not found with the given criteria." }; - } - return { error: "Org unit not found with the given criteria." }; - } - - return null; -} - -function toAssistantOrgUnitMutationError(error: unknown): AssistantToolErrorResult | null { - const notFound = toAssistantOrgUnitNotFoundError(error); - if (notFound) { - return notFound; - } - - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - if (error.message.includes("must be greater than parent level")) { - return { error: "Org unit level must be greater than the parent org unit level." }; - } - } - - return null; -} - -function toAssistantCountryNotFoundError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - return { error: "Country not found with the given criteria." }; - } - - return null; -} - -function toAssistantCountryMutationError(error: unknown): AssistantToolErrorResult | null { - const notFound = toAssistantCountryNotFoundError(error); - if (notFound) { - return notFound; - } - - if (error instanceof TRPCError && error.code === "CONFLICT") { - return { error: "A country with this code already exists." }; - } - - return null; -} - -function toAssistantResourceCreationError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError) { - if (error.code === "CONFLICT") { - return { error: "A resource with this EID or email already exists." }; - } - - if (error.code === "BAD_REQUEST" || error.code === "UNPROCESSABLE_CONTENT") { - return { error: error.message }; - } - - if (error.code === "NOT_FOUND") { - if (error.message.includes("Role")) { - return { error: "Role not found with the given criteria." }; - } - if (error.message.includes("Country")) { - return { error: "Country not found with the given criteria." }; - } - if (error.message.includes("Org unit")) { - return { error: "Org unit not found with the given criteria." }; - } - } - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - if (prismaError.code !== "P2003" && prismaError.code !== "P2025") { - return null; - } - - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("role")) { - return { error: "Role not found with the given criteria." }; - } - if (errorText.includes("country")) { - return { error: "Country not found with the given criteria." }; - } - if ( - errorText.includes("orgunit") || - errorText.includes("org_unit") || - errorText.includes("org unit") - ) { - return { error: "Org unit not found with the given criteria." }; - } - - return { error: "The selected role, country, or org unit no longer exists." }; -} - -function toAssistantResourceMutationError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - return { error: "Resource not found with the given criteria." }; - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - if (prismaError.code === "P2025") { - return { error: "Resource not found with the given criteria." }; - } - - if (prismaError.code !== "P2003") { - return null; - } - - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("role")) { - return { error: "Role not found with the given criteria." }; - } - if (errorText.includes("country")) { - return { error: "Country not found with the given criteria." }; - } - if ( - errorText.includes("orgunit") || - errorText.includes("org_unit") || - errorText.includes("org unit") - ) { - return { error: "Org unit not found with the given criteria." }; - } - - return { error: "Resource not found with the given criteria." }; -} - -function toAssistantProjectMutationError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - return { error: "Project not found with the given criteria." }; - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - if (prismaError.code === "P2025") { - return { error: "Project not found with the given criteria." }; - } - - if (prismaError.code !== "P2003") { - return null; - } - - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("blueprint")) { - return { error: "Blueprint not found with the given criteria." }; - } - if (errorText.includes("client")) { - return { error: "Client not found with the given criteria." }; - } - - return { error: "Project not found with the given criteria." }; -} - -function toAssistantMetroCityMutationError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - if (error.message.includes("Country")) { - return { error: "Country not found with the given criteria." }; - } - return { error: "Metro city not found with the given criteria." }; - } - - if (error instanceof TRPCError && error.code === "PRECONDITION_FAILED") { - return { error: "Metro city cannot be deleted while it is still assigned to resources." }; - } - - return null; -} - -function toAssistantDemandCreationError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - if (error.message.includes("Role")) { - return { error: "Role not found with the given criteria." }; - } - return { error: "Project not found with the given criteria." }; - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - if (prismaError.code !== "P2003" && prismaError.code !== "P2025") { - return null; - } - - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("role")) { - return { error: "Role not found with the given criteria." }; - } - if (errorText.includes("project")) { - return { error: "Project not found with the given criteria." }; - } - - return null; -} - -function toAssistantVacationCreationError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError) { - if (error.code === "FORBIDDEN") { - return { error: "You can only create vacation requests for your own resource." }; - } - - if (error.code === "BAD_REQUEST") { - return { error: error.message }; - } - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - if (prismaError.code === "P2025") { - return { error: "Resource not found with the given criteria." }; - } - - if (prismaError.code !== "P2003") { - return null; - } - - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("resource")) { - return { error: "Resource not found with the given criteria." }; - } - - return { error: "Resource not found with the given criteria." }; -} - -function toAssistantEntitlementMutationError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - return { error: "Resource not found with the given criteria." }; - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - if (prismaError.code === "P2025") { - return { error: "Resource not found with the given criteria." }; - } - - if (prismaError.code !== "P2003") { - return null; - } - - return { error: "Resource not found with the given criteria." }; -} - -function toAssistantEstimateCreationError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - return { error: "Project not found with the given criteria." }; - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - if (prismaError.code !== "P2003" && prismaError.code !== "P2025") { - return null; - } - - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("project")) { - return { error: "Project not found with the given criteria." }; - } - if (errorText.includes("role")) { - return { error: "Role not found with the given criteria." }; - } - if (errorText.includes("resource")) { - return { error: "Resource not found with the given criteria." }; - } - if ( - errorText.includes("scopeitem") || - errorText.includes("scope_item") || - errorText.includes("scope item") - ) { - return { error: "Estimate scope item not found with the given criteria." }; - } - - return { - error: "One of the referenced project, role, resource, or scope items no longer exists.", - }; -} - -function toAssistantEstimateMutationError( - error: unknown, - action: - | "clone" - | "updateDraft" - | "submitVersion" - | "approveVersion" - | "createRevision" - | "createExport" - | "createPlanningHandoff" - | "generateWeeklyPhasing" - | "updateCommercialTerms", -): AssistantToolErrorResult | null { - if (error instanceof TRPCError) { - if (error.code === "NOT_FOUND") { - if (error.message.includes("Linked project")) { - return { error: "Project not found with the given criteria." }; - } - if (action === "clone" && error.message === "Source estimate has no versions") { - return { error: "Source estimate has no versions and cannot be cloned." }; - } - if (error.message.includes("version") || error.message.includes("versions")) { - return { error: "Estimate version not found with the given criteria." }; - } - return { error: "Estimate not found with the given criteria." }; - } - - if (error.code === "PRECONDITION_FAILED") { - switch (error.message) { - case "Estimate has no working version": - return { error: "Estimate has no working version." }; - case "Only working versions can be submitted": - return { error: "Only working versions can be submitted." }; - case "Estimate has no submitted version": - return { error: "Estimate has no submitted version." }; - case "Only submitted versions can be approved": - return { error: "Only submitted versions can be approved." }; - case "Estimate already has a working version": - return { error: "Estimate already has a working version." }; - case "Estimate has no locked version to revise": - return { error: "Estimate has no locked version to revise." }; - case "Source version must be locked before creating a revision": - return { error: "Source version must be locked before creating a revision." }; - case "Estimate has no approved version": - return { error: "Estimate has no approved version." }; - case "Only approved versions can be handed off to planning": - return { error: "Only approved versions can be handed off to planning." }; - case "Estimate must be linked to a project before planning handoff": - return { error: "Estimate must be linked to a project before planning handoff." }; - case "Planning handoff already exists for this approved version": - return { error: "Planning handoff already exists for this approved version." }; - case "Linked project has an invalid date range": - return { error: "The linked project has an invalid date range for planning handoff." }; - case "Commercial terms can only be edited on working versions": - return { error: "Commercial terms can only be edited on working versions." }; - default: - if (error.message.startsWith("Project window has no working days for demand line")) { - return { - error: "The linked project window has no working days for at least one demand line.", - }; - } - } - } - - if (error.code === "BAD_REQUEST" && action === "updateCommercialTerms") { - return { error: "Commercial terms input is invalid." }; - } - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - if (prismaError.code === "P2003") { - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("project")) { - return { error: "Project not found with the given criteria." }; - } - if (errorText.includes("role")) { - return { error: "Role not found with the given criteria." }; - } - if (errorText.includes("resource")) { - return { error: "Resource not found with the given criteria." }; - } - if ( - errorText.includes("scopeitem") || - errorText.includes("scope_item") || - errorText.includes("scope item") - ) { - return { error: "Estimate scope item not found with the given criteria." }; - } - return { - error: "One of the referenced project, role, resource, or scope items no longer exists.", - }; - } - - if (prismaError.code === "P2025") { - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if ( - errorText.includes("estimatedemandline") || - errorText.includes("estimate_demand_line") || - errorText.includes("estimate demand line") - ) { - return { error: "Estimate demand line not found with the given criteria." }; - } - if ( - errorText.includes("estimateversion") || - errorText.includes("estimate_version") || - errorText.includes("estimate version") - ) { - return { error: "Estimate version not found with the given criteria." }; - } - if (errorText.includes("estimate")) { - return { error: "Estimate not found with the given criteria." }; - } - switch (action) { - case "generateWeeklyPhasing": - return { error: "Estimate demand line not found with the given criteria." }; - case "updateCommercialTerms": - case "submitVersion": - case "approveVersion": - case "createRevision": - case "createExport": - return { error: "Estimate version not found with the given criteria." }; - default: - return { error: "Estimate not found with the given criteria." }; - } - } - - return null; -} - -function toAssistantUserMutationError( - error: unknown, - action: "create" | "update" | "password" = "update", -): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "CONFLICT" && action === "create") { - return { error: "User with this email already exists." }; - } - - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - return { error: "User not found with the given criteria." }; - } - - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - const validationIssues = getTrpcValidationIssues(error); - for (const issue of validationIssues) { - const field = issue.path[0]; - if (field === "password" && issue.code === "too_small") { - return { error: "Password must be at least 12 characters." }; - } - - if (field === "name" && issue.code === "too_small") { - return { error: "Name is required." }; - } - - if (field === "name" && issue.code === "too_big") { - return { error: "Name must be at most 200 characters." }; - } - } - - if (error.message.includes("Password must be at least 12 characters")) { - return { error: "Password must be at least 12 characters." }; - } - - if (error.message.includes("Name is required")) { - return { error: "Name is required." }; - } - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (prismaError?.code === "P2025") { - return { error: "User not found with the given criteria." }; - } - - return null; -} - -function getTrpcValidationIssues(error: TRPCError): Array<{ - code?: string; - path: string[]; -}> { - if (error.cause instanceof ZodError) { - return error.cause.issues.map((issue) => ({ - code: issue.code, - path: issue.path.map((segment) => String(segment)), - })); - } - - try { - const parsed = JSON.parse(error.message); - if (!Array.isArray(parsed)) { - return []; - } - - return parsed - .filter( - (issue): issue is { code?: unknown; path?: unknown } => - issue !== null && typeof issue === "object", - ) - .map((issue) => - typeof issue.code === "string" - ? { - code: issue.code, - path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [], - } - : { - path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [], - }, - ); - } catch { - return []; - } -} - -function toAssistantUserResourceLinkError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "CONFLICT") { - if (error.message.includes("already linked")) { - return { error: "Resource is already linked to another user." }; - } - if (error.message.includes("changed during update")) { - return { error: "Resource link changed during update. Please retry." }; - } - } - - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - if (error.message.includes("Resource")) { - return { error: "Resource not found with the given criteria." }; - } - return { error: "User not found with the given criteria." }; - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - const pointsToUser = - errorText.includes("userid") || errorText.includes("user_id") || errorText.includes(" user "); - const pointsToResource = - errorText.includes("resourceid") || - errorText.includes("resource_id") || - errorText.includes(" resource "); - - if (prismaError.code === "P2025") { - return { error: "Resource not found with the given criteria." }; - } - - if (prismaError.code === "P2003") { - if (pointsToUser) { - return { error: "User not found with the given criteria." }; - } - if (pointsToResource || errorText.includes("resource")) { - return { error: "Resource not found with the given criteria." }; - } - return { error: "User not found with the given criteria." }; - } - - return null; -} - -function toAssistantTotpEnableError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - if (error.message.includes("No TOTP secret generated")) { - return { error: "No TOTP secret generated. Call generate_totp_secret first." }; - } - if (error.message.includes("already enabled")) { - return { error: "TOTP is already enabled." }; - } - if (error.message.includes("Invalid TOTP token")) { - return { error: "Invalid TOTP token." }; - } - } - - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - return { error: "User not found with the given criteria." }; - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (prismaError?.code === "P2025") { - return { error: "User not found with the given criteria." }; - } - - return null; -} - -function toAssistantWebhookNotFoundError(error: unknown): AssistantToolErrorResult | null { - return toAssistantNotFoundError(error, "Webhook not found with the given criteria."); -} - -function toAssistantWebhookMutationError( - error: unknown, - action: "create" | "update" = "update", -): AssistantToolErrorResult | null { - const notFound = toAssistantWebhookNotFoundError(error); - if (notFound) { - return notFound; - } - - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - return { - error: action === "create" ? "Webhook input is invalid." : "Webhook update input is invalid.", - }; - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (prismaError?.code === "P2025") { - return { error: "Webhook not found with the given criteria." }; - } - - return null; -} - -function toAssistantAuditLogEntryNotFoundError(error: unknown): AssistantToolErrorResult | null { - return toAssistantNotFoundError(error, "Audit log entry not found with the given criteria."); -} - -function toAssistantTaskNotFoundError(error: unknown): AssistantToolErrorResult | null { - return toAssistantNotFoundError(error, "Task not found with the given criteria."); -} - -function toAssistantTaskActionError(error: unknown): AssistantToolErrorResult | null { - const notFound = toAssistantTaskNotFoundError(error); - if (notFound) { - return notFound; - } - - if (error instanceof TRPCError && error.code === "PRECONDITION_FAILED") { - if (error.message.includes("no executable action")) { - return { error: "Task has no executable action." }; - } - if (error.message.includes("already completed")) { - return { error: "Task is already completed." }; - } - if (error.message.includes("dismissed")) { - return { error: "Task has been dismissed and cannot be executed." }; - } - } - - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - if ( - error.message.includes("Invalid taskAction format") || - error.message.includes("Unknown action") - ) { - return { error: "Task action is invalid and cannot be executed." }; - } - if (error.message === "Vacation not found") { - return { error: "Vacation not found with the given criteria." }; - } - if (error.message.startsWith("Vacation is ") && error.message.includes(", not PENDING")) { - return { - error: "Vacation is not pending and cannot be approved or rejected via this task action.", - }; - } - if (error.message === "Assignment not found") { - return { error: "Assignment not found with the given criteria." }; - } - if (error.message === "Assignment is already CONFIRMED") { - return { error: "Assignment is already confirmed." }; - } - return { error: error.message }; - } - - if (error instanceof TRPCError && error.code === "FORBIDDEN") { - return { error: "You do not have permission to execute this task action." }; - } - - return null; -} - -function toAssistantTaskAssignmentError(error: unknown): AssistantToolErrorResult | null { - const notFound = toAssistantTaskNotFoundError(error); - if (notFound) { - return notFound; - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (prismaError && (prismaError.code === "P2003" || prismaError.code === "P2025")) { - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("assignee")) { - return { error: "Assignee user not found with the given criteria." }; - } - } - - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - return { error: "Only tasks and approvals can be assigned." }; - } - - return null; -} - -function toAssistantBroadcastNotFoundError(error: unknown): AssistantToolErrorResult | null { - return toAssistantNotFoundError(error, "Broadcast not found with the given criteria."); -} - -function toAssistantDispoImportBatchNotFoundError(error: unknown): AssistantToolErrorResult | null { - return toAssistantNotFoundError(error, "Import batch not found with the given criteria."); -} - -function toAssistantReminderNotFoundError(error: unknown): AssistantToolErrorResult | null { - return toAssistantNotFoundError(error, "Reminder not found with the given criteria."); -} - -function toAssistantNotificationNotFoundError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - return { error: "Notification not found with the given criteria." }; - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (prismaError?.code === "P2025") { - return { error: "Notification not found with the given criteria." }; - } - - return null; -} - -function toAssistantNotificationReadError(error: unknown): AssistantToolErrorResult | null { - return toAssistantNotificationNotFoundError(error); -} - -function toAssistantNotificationDeletionError(error: unknown): AssistantToolErrorResult | null { - const notFound = toAssistantNotificationNotFoundError(error); - if (notFound) { - return notFound; - } - - if (error instanceof TRPCError && error.code === "FORBIDDEN") { - return { error: "Tasks created by other users cannot be deleted." }; - } - - return null; -} - -function toAssistantReminderCreationError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - return { error: "Reminder input is invalid." }; - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - if (prismaError.code !== "P2003" && prismaError.code !== "P2025") { - return null; - } - - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("user")) { - return { error: "Authenticated user not found with the given criteria." }; - } - - return null; -} - -function toAssistantCommentResolveError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - return { error: "Comment not found with the given criteria." }; - } - - if (error instanceof TRPCError && error.code === "FORBIDDEN") { - return { error: "Only the comment author or an admin can resolve comments." }; - } - - return null; -} - -function toAssistantCommentCreationError(error: unknown): AssistantToolErrorResult | null { - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - if (error.message.includes("at least 1 character")) { - return { error: "Comment body is required." }; - } - - if (error.message.includes("at most 10000")) { - return { error: "Comment body must be at most 10000 characters." }; - } - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - if (prismaError.code !== "P2003" && prismaError.code !== "P2025") { - return null; - } - - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("author") || errorText.includes("sender")) { - return { error: "Comment author not found with the given criteria." }; - } - - if (errorText.includes("user") || errorText.includes("recipient")) { - return { error: "Mentioned user not found with the given criteria." }; - } - - return null; -} - -function getPrismaRequestErrorMetadata(error: unknown): { - code: string; - message: string; - metaText: string; -} | null { - const collectMetaStrings = (value: unknown): string[] => { - if (typeof value === "string") { - return [value]; - } - if (Array.isArray(value)) { - return value.flatMap((entry) => collectMetaStrings(entry)); - } - if (value && typeof value === "object") { - return Object.values(value).flatMap((entry) => collectMetaStrings(entry)); - } - return []; - }; - - const queue: unknown[] = [error]; - const visited = new Set(); - - 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 ( - context === "broadcast" && - trpcError?.code === "BAD_REQUEST" && - trpcError.message === "Scheduled broadcasts with task metadata are not supported yet." - ) { - return { error: "Scheduled broadcasts with task metadata are not supported yet." }; - } - - if (trpcError?.code === "NOT_FOUND") { - if (trpcError.message.includes("broadcast")) { - return { error: "Broadcast not found with the given criteria." }; - } - if (trpcError.message.includes("Sender user not found")) { - return { error: "Sender user not found with the given criteria." }; - } - if (trpcError.message.includes("Assignee user not found")) { - return { error: "Assignee user not found with the given criteria." }; - } - if (trpcError.message.includes("recipient")) { - return context === "broadcast" - ? { error: "Broadcast recipient user not found with the given criteria." } - : context === "task" - ? { error: "Task recipient user not found with the given criteria." } - : { error: "Notification recipient user not found with the given criteria." }; - } - } - - const prismaError = getPrismaRequestErrorMetadata(error); - if (!prismaError) { - return null; - } - - if (prismaError.code !== "P2003" && prismaError.code !== "P2025") { - return null; - } - - const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - - if (errorText.includes("assignee")) { - return { error: "Assignee user not found with the given criteria." }; - } - - if (errorText.includes("sender")) { - return { error: "Sender user not found with the given criteria." }; - } - - if ( - context === "broadcast" && - (errorText.includes("notificationbroadcast") || errorText.includes("broadcast")) - ) { - return { error: "Broadcast not found with the given criteria." }; - } - - if (context === "broadcast" && prismaError.code === "P2025") { - return { error: "Broadcast not found with the given criteria." }; - } - - if (context === "task") { - return { error: "Task recipient user not found with the given criteria." }; - } - - if (context === "broadcast") { - return { error: "Broadcast recipient user not found with the given criteria." }; - } - - return { error: "Notification recipient user not found with the given criteria." }; -} - -function normalizeAssistantExecutionError(error: unknown): AssistantToolErrorResult { - if (error instanceof AssistantVisibleError) { - return { error: error.message }; - } - - const trpcError = getTrpcErrorMetadata(error); - if (trpcError) { - if (trpcError.code === "INTERNAL_SERVER_ERROR") { - return { - error: "The tool could not complete due to an internal error.", - }; - } - - if (trpcError.code === "UNAUTHORIZED") { - return { - error: "Authentication is required to use this tool.", - }; - } - - if (trpcError.code === "FORBIDDEN") { - return { - error: "You do not have permission to perform this action.", - }; - } - - return { error: "The tool could not complete due to a request error." }; - } - - if (error instanceof Error) { - return { error: "The tool could not complete due to an unexpected error." }; - } - - return { error: "The tool could not complete due to an unexpected error." }; -} - -function isAssistantToolErrorResult(value: unknown): value is AssistantToolErrorResult { - return value !== null && typeof value === "object" && "error" in value; -} - -function toAssistantIndexedFieldError( - index: number, - field: string, - message: string, -): AssistantIndexedFieldErrorResult { - return { - error: `assignments[${index}].${field}: ${message}`, - field: `assignments[${index}].${field}`, - index, - }; -} - -async function resolveEntityOrAssistantError( - 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)); -} +// Helpers imported from ./assistant-tools/helpers.ts // ─── Tool Definitions ─────────────────────────────────────────────────────── @@ -2060,136 +431,13 @@ const TOOL_DEFINITIONS_BY_NAME = new Map( TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool]), ); -type AssistantToolAccessEvaluationContext = Pick; - -type AssistantToolAccessFailure = - | { type: "role" } - | { - type: "permission"; - permission?: PermissionKey; - message?: string; - }; - -function hasAssistantResourceOverviewAccess(permissions: Set): boolean { - return ( - permissions.has(PermissionKey.VIEW_ALL_RESOURCES) || - permissions.has(PermissionKey.MANAGE_RESOURCES) - ); -} - -function getAssistantToolAccessRequirements(tool: ToolDef): ToolAccessRequirements | undefined { - return tool.access ?? LEGACY_MONOLITHIC_TOOL_ACCESS[tool.function.name]; -} - -function getAssistantToolAccessFailure( - tool: ToolDef, - ctx: AssistantToolAccessEvaluationContext, -): AssistantToolAccessFailure | null { - const access = getAssistantToolAccessRequirements(tool); - if (!access) { - return null; - } - - if ( - access.allowedSystemRoles && - !access.allowedSystemRoles.includes(ctx.userRole as SystemRole) - ) { - return { type: "role" }; - } - - const missingRequiredPermission = access.requiredPermissions?.find( - (permission) => !ctx.permissions.has(permission), - ); - if (missingRequiredPermission) { - return { - type: "permission", - permission: missingRequiredPermission, - }; - } - - if (access.requiresPlanningRead && !ctx.permissions.has(PermissionKey.VIEW_PLANNING)) { - return { - type: "permission", - permission: PermissionKey.VIEW_PLANNING, - }; - } - - if (access.requiresCostView && !ctx.permissions.has(PermissionKey.VIEW_COSTS)) { - return { - type: "permission", - permission: PermissionKey.VIEW_COSTS, - }; - } - - if ( - access.requiresAdvancedAssistant && - !ctx.permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS) - ) { - return { - type: "permission", - permission: PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, - }; - } - - if (access.requiresResourceOverview && !hasAssistantResourceOverviewAccess(ctx.permissions)) { - return { - type: "permission", - message: "Permission denied: you need resource overview access to perform this action.", - }; - } - - return null; -} - -function toAssistantToolAccessError(failure: AssistantToolAccessFailure): AssistantVisibleError { - if (failure.type === "role") { - return new AssistantVisibleError("You do not have permission to perform this action."); - } - - if (failure.permission) { - return new AssistantVisibleError( - `Permission denied: you need the "${failure.permission}" permission to perform this action.`, - ); - } - - return new AssistantVisibleError( - failure.message ?? "You do not have permission to perform this action.", - ); -} - -export function canAccessAssistantTool( - tool: ToolDef, - ctx: AssistantToolAccessEvaluationContext, -): boolean { - return getAssistantToolAccessFailure(tool, ctx) === null; -} +// Access control imported from ./assistant-tools/access-control.ts export function getAvailableAssistantToolsForContext( permissions: Set, userRole: string, ): ToolDef[] { - return TOOL_DEFINITIONS.filter((tool) => canAccessAssistantTool(tool, { permissions, userRole })); -} - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -/** Resolve a responsible person name against existing resources. Returns the exact displayName or an error object. */ -async function resolveResponsiblePerson( - name: string, - ctx: ToolContext, -): Promise<{ displayName: string } | { error: string }> { - const caller = createResourceCaller(createScopedCallerContext(ctx)); - const result = await caller.resolveResponsiblePersonName({ name }); - - if (result.status === "resolved") { - return { displayName: result.displayName }; - } - - if (result.status === "ambiguous" || result.status === "missing") { - return { error: result.message }; - } - - return { error: `Unable to resolve responsible person: ${name}` }; + return getAvailableToolsForCtx_(TOOL_DEFINITIONS, permissions, userRole); } // ─── Tool Executors ───────────────────────────────────────────────────────── @@ -2486,15 +734,30 @@ export async function executeTool( const params = JSON.parse(args); + // EGAI 4.1.1.2 / IAAI 3.6.22 — read-only tools get a DB proxy that blocks writes + const isMutation = MUTATION_TOOLS.has(name); + const effectiveCtx = isMutation ? ctx : { ...ctx, db: createReadOnlyProxy(ctx.db) }; + // Audit-log all mutation tool executions (EGAI 4.1.3.1 / IAAI 3.6.26) - if (MUTATION_TOOLS.has(name)) { + if (isMutation) { logger.info( { tool: name, params, userId: ctx.userId, userRole: ctx.userRole }, "AI assistant mutation tool executed", ); } - const result = await executor(params, ctx); + const result = await executor(params, effectiveCtx); + + // EGAI 4.3.1.2 — validate tool result against schema if defined + if (toolDefinition?.resultSchema && result != null) { + const validation = toolDefinition.resultSchema.safeParse(result); + if (!validation.success) { + logger.warn( + { tool: name, errors: validation.error.issues }, + "Tool result failed schema validation — proceeding with unvalidated result", + ); + } + } // Detect action payloads (e.g. navigation, invalidation) if (result && typeof result === "object" && "__action" in (result as Record)) { diff --git a/packages/api/src/router/assistant-tools/access-control.ts b/packages/api/src/router/assistant-tools/access-control.ts new file mode 100644 index 0000000..fc4008e --- /dev/null +++ b/packages/api/src/router/assistant-tools/access-control.ts @@ -0,0 +1,135 @@ +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import type { ToolAccessRequirements, ToolContext, ToolDef } from "./shared.js"; +import { AssistantVisibleError } from "./helpers.js"; + +export const CONTROLLER_ASSISTANT_ROLES = [ + SystemRole.ADMIN, + SystemRole.MANAGER, + SystemRole.CONTROLLER, +] as const; + +export const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial> = { + search_projects: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] }, + get_project: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] }, + update_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, + create_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, + delete_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, + generate_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, + remove_project_cover: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, +}; + +export type AssistantToolAccessEvaluationContext = Pick; + +export type AssistantToolAccessFailure = + | { type: "role" } + | { + type: "permission"; + permission?: PermissionKey; + message?: string; + }; + +function hasAssistantResourceOverviewAccess(permissions: Set): boolean { + return ( + permissions.has(PermissionKey.VIEW_ALL_RESOURCES) || + permissions.has(PermissionKey.MANAGE_RESOURCES) + ); +} + +export function getAssistantToolAccessRequirements( + tool: ToolDef, +): ToolAccessRequirements | undefined { + return tool.access ?? LEGACY_MONOLITHIC_TOOL_ACCESS[tool.function.name]; +} + +export function getAssistantToolAccessFailure( + tool: ToolDef, + ctx: AssistantToolAccessEvaluationContext, +): AssistantToolAccessFailure | null { + const access = getAssistantToolAccessRequirements(tool); + if (!access) { + return null; + } + + if ( + access.allowedSystemRoles && + !access.allowedSystemRoles.includes(ctx.userRole as SystemRole) + ) { + return { type: "role" }; + } + + const missingRequiredPermission = access.requiredPermissions?.find( + (permission) => !ctx.permissions.has(permission), + ); + if (missingRequiredPermission) { + return { + type: "permission", + permission: missingRequiredPermission, + }; + } + + if (access.requiresPlanningRead && !ctx.permissions.has(PermissionKey.VIEW_PLANNING)) { + return { + type: "permission", + permission: PermissionKey.VIEW_PLANNING, + }; + } + + if (access.requiresCostView && !ctx.permissions.has(PermissionKey.VIEW_COSTS)) { + return { + type: "permission", + permission: PermissionKey.VIEW_COSTS, + }; + } + + if ( + access.requiresAdvancedAssistant && + !ctx.permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS) + ) { + return { + type: "permission", + permission: PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, + }; + } + + if (access.requiresResourceOverview && !hasAssistantResourceOverviewAccess(ctx.permissions)) { + return { + type: "permission", + message: "Permission denied: you need resource overview access to perform this action.", + }; + } + + return null; +} + +export function toAssistantToolAccessError( + failure: AssistantToolAccessFailure, +): AssistantVisibleError { + if (failure.type === "role") { + return new AssistantVisibleError("You do not have permission to perform this action."); + } + + if (failure.permission) { + return new AssistantVisibleError( + `Permission denied: you need the "${failure.permission}" permission to perform this action.`, + ); + } + + return new AssistantVisibleError( + failure.message ?? "You do not have permission to perform this action.", + ); +} + +export function canAccessAssistantTool( + tool: ToolDef, + ctx: AssistantToolAccessEvaluationContext, +): boolean { + return getAssistantToolAccessFailure(tool, ctx) === null; +} + +export function getAvailableAssistantToolsForContext( + allTools: ToolDef[], + permissions: Set, + userRole: string, +): ToolDef[] { + return allTools.filter((tool) => canAccessAssistantTool(tool, { permissions, userRole })); +} diff --git a/packages/api/src/router/assistant-tools/callers.ts b/packages/api/src/router/assistant-tools/callers.ts new file mode 100644 index 0000000..4b1a0ab --- /dev/null +++ b/packages/api/src/router/assistant-tools/callers.ts @@ -0,0 +1,72 @@ +import { createCallerFactory } from "../../trpc.js"; +import { chargeabilityReportRouter } from "../chargeability-report.js"; +import { computationGraphRouter } from "../computation-graph.js"; +import { timelineRouter } from "../timeline.js"; +import { auditLogRouter } from "../audit-log.js"; +import { importExportRouter } from "../import-export.js"; +import { dispoRouter } from "../dispo.js"; +import { resourceRouter } from "../resource.js"; +import { settingsRouter } from "../settings.js"; +import { systemRoleConfigRouter } from "../system-role-config.js"; +import { userRouter } from "../user.js"; +import { notificationRouter } from "../notification.js"; +import { estimateRouter } from "../estimate.js"; +import { webhookRouter } from "../webhook.js"; +import { countryRouter } from "../country.js"; +import { holidayCalendarRouter } from "../holiday-calendar.js"; +import { blueprintRouter } from "../blueprint.js"; +import { roleRouter } from "../role.js"; +import { clientRouter } from "../client.js"; +import { orgUnitRouter } from "../org-unit.js"; +import { projectRouter } from "../project.js"; +import { rateCardRouter } from "../rate-card.js"; +import { reportRouter } from "../report.js"; +import { vacationRouter } from "../vacation.js"; +import { entitlementRouter } from "../entitlement.js"; +import { commentRouter } from "../comment.js"; +import { managementLevelRouter } from "../management-level.js"; +import { utilizationCategoryRouter } from "../utilization-category.js"; +import { calculationRuleRouter } from "../calculation-rules.js"; +import { effortRuleRouter } from "../effort-rule.js"; +import { experienceMultiplierRouter } from "../experience-multiplier.js"; +import { dashboardRouter } from "../dashboard.js"; +import { insightsRouter } from "../insights.js"; +import { scenarioRouter } from "../scenario.js"; +import { allocationRouter } from "../allocation/index.js"; +import { staffingRouter } from "../staffing.js"; + +export const createChargeabilityReportCaller = createCallerFactory(chargeabilityReportRouter); +export const createComputationGraphCaller = createCallerFactory(computationGraphRouter); +export const createTimelineCaller = createCallerFactory(timelineRouter); +export const createAuditLogCaller = createCallerFactory(auditLogRouter); +export const createImportExportCaller = createCallerFactory(importExportRouter); +export const createDispoCaller = createCallerFactory(dispoRouter); +export const createResourceCaller = createCallerFactory(resourceRouter); +export const createSettingsCaller = createCallerFactory(settingsRouter); +export const createSystemRoleConfigCaller = createCallerFactory(systemRoleConfigRouter); +export const createUserCaller = createCallerFactory(userRouter); +export const createNotificationCaller = createCallerFactory(notificationRouter); +export const createEstimateCaller = createCallerFactory(estimateRouter); +export const createWebhookCaller = createCallerFactory(webhookRouter); +export const createCountryCaller = createCallerFactory(countryRouter); +export const createHolidayCalendarCaller = createCallerFactory(holidayCalendarRouter); +export const createBlueprintCaller = createCallerFactory(blueprintRouter); +export const createRoleCaller = createCallerFactory(roleRouter); +export const createClientCaller = createCallerFactory(clientRouter); +export const createOrgUnitCaller = createCallerFactory(orgUnitRouter); +export const createProjectCaller = createCallerFactory(projectRouter); +export const createRateCardCaller = createCallerFactory(rateCardRouter); +export const createReportCaller = createCallerFactory(reportRouter); +export const createVacationCaller = createCallerFactory(vacationRouter); +export const createEntitlementCaller = createCallerFactory(entitlementRouter); +export const createCommentCaller = createCallerFactory(commentRouter); +export const createManagementLevelCaller = createCallerFactory(managementLevelRouter); +export const createUtilizationCategoryCaller = createCallerFactory(utilizationCategoryRouter); +export const createCalculationRuleCaller = createCallerFactory(calculationRuleRouter); +export const createEffortRuleCaller = createCallerFactory(effortRuleRouter); +export const createExperienceMultiplierCaller = createCallerFactory(experienceMultiplierRouter); +export const createDashboardCaller = createCallerFactory(dashboardRouter); +export const createInsightsCaller = createCallerFactory(insightsRouter); +export const createScenarioCaller = createCallerFactory(scenarioRouter); +export const createAllocationCaller = createCallerFactory(allocationRouter); +export const createStaffingCaller = createCallerFactory(staffingRouter); diff --git a/packages/api/src/router/assistant-tools/helpers.ts b/packages/api/src/router/assistant-tools/helpers.ts new file mode 100644 index 0000000..ea35028 --- /dev/null +++ b/packages/api/src/router/assistant-tools/helpers.ts @@ -0,0 +1,1712 @@ +import { Prisma, VacationType } from "@capakraken/db"; +import { AllocationStatus, PermissionKey, SystemRole, toIsoDateOrNull } from "@capakraken/shared"; +import type { WeekdayAvailability } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { ZodError } from "zod"; +export { fmtEur } from "../../lib/format-utils.js"; +import type { TRPCContext } from "../../trpc.js"; +import type { ToolContext } from "./shared.js"; +import { createProjectCaller, createResourceCaller } from "./callers.js"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +export const fmtDate = toIsoDateOrNull; + +export class AssistantVisibleError extends Error { + constructor(message: string) { + super(message); + this.name = "AssistantVisibleError"; + } +} + +export 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.`, + ); + } +} + +export function assertAdminRole(ctx: ToolContext): void { + if (ctx.userRole !== SystemRole.ADMIN) { + throw new AssistantVisibleError("Admin role required to perform this action."); + } +} + +export function formatHolidayCalendarEntry(entry: { + id: string; + date: Date; + name: string; + isRecurringAnnual?: boolean | null; + source?: string | null; +}) { + return { + id: entry.id, + date: toIsoDateOrNull(entry.date), + name: entry.name, + isRecurringAnnual: entry.isRecurringAnnual ?? false, + source: entry.source ?? null, + }; +} + +export 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, + }; +} + +export 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), + }; +} + +export function createUtcDate(year: number, monthIndex: number, day: number): Date { + return new Date(Date.UTC(year, monthIndex, day)); +} + +export 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), + }; +} + +export const ASSISTANT_VACATION_REQUEST_TYPES = [ + VacationType.ANNUAL, + VacationType.SICK, + VacationType.OTHER, +] as const; + +export 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(", ")}.`, + ); +} + +export 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; +} + +export function parseOptionalIsoDate( + value: string | undefined, + fieldName: string, +): Date | undefined { + return value ? parseIsoDate(value, fieldName) : undefined; +} + +export 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; +} + +export function parseOptionalDateTime( + value: string | undefined, + fieldName: string, +): Date | undefined { + return value ? parseDateTime(value, fieldName) : undefined; +} + +export function toDate(value: Date | string): Date { + return value instanceof Date ? value : new Date(value); +} + +export type AssistantToolErrorResult = { error: string }; +export type AssistantIndexedFieldErrorResult = AssistantToolErrorResult & { + field: string; + index: number; +}; +export type BatchQuickAssignmentInput = { + resourceId: string; + projectId: string; + startDate: Date; + endDate: Date; + hoursPerDay?: number; + role?: string; + status?: AllocationStatus; +}; + +export function toAssistantNotFoundError( + error: unknown, + message: string, +): AssistantToolErrorResult | null { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return { error: message }; + } + return null; +} + +export 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; +} + +export function toAssistantProjectNotFoundError( + error: unknown, + identifier: string, +): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, `Project not found: ${identifier}`); +} + +export 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; +} + +export function toAssistantVacationNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Vacation not found with the given criteria."); +} + +export 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; +} + +export 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; +} + +export function toAssistantDemandNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Demand not found with the given criteria."); +} + +export 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; +} + +export 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; +} + +export 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; +} + +export 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.", + ); +} + +export 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; +} + +export function toAssistantHolidayEntryNotFoundError( + error: unknown, +): AssistantToolErrorResult | null { + return toAssistantNotFoundError( + error, + "Holiday calendar entry not found with the given criteria.", + ); +} + +export 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; +} + +export function toAssistantRoleNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Role not found with the given criteria."); +} + +export 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; +} + +export 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; +} + +export 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; +} + +export 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; +} + +export 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; +} + +export 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; +} + +export 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." }; +} + +export 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." }; +} + +export 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." }; +} + +export 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; +} + +export 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; +} + +export 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." }; +} + +export 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." }; +} + +export 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.", + }; +} + +export 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; +} + +export function toAssistantUserMutationError( + error: unknown, + action: "create" | "update" | "password" = "update", +): AssistantToolErrorResult | null { + if (error instanceof TRPCError && error.code === "CONFLICT" && action === "create") { + return { error: "User with this email already exists." }; + } + + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return { error: "User not found with the given criteria." }; + } + + if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + const validationIssues = getTrpcValidationIssues(error); + for (const issue of validationIssues) { + const field = issue.path[0]; + if (field === "password" && issue.code === "too_small") { + return { error: "Password must be at least 12 characters." }; + } + + if (field === "name" && issue.code === "too_small") { + return { error: "Name is required." }; + } + + if (field === "name" && issue.code === "too_big") { + return { error: "Name must be at most 200 characters." }; + } + } + + if (error.message.includes("Password must be at least 12 characters")) { + return { error: "Password must be at least 12 characters." }; + } + + if (error.message.includes("Name is required")) { + return { error: "Name is required." }; + } + } + + const prismaError = getPrismaRequestErrorMetadata(error); + if (prismaError?.code === "P2025") { + return { error: "User not found with the given criteria." }; + } + + return null; +} + +export 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 []; + } +} + +export function toAssistantUserResourceLinkError(error: unknown): AssistantToolErrorResult | null { + if (error instanceof TRPCError && error.code === "CONFLICT") { + if (error.message.includes("already linked")) { + return { error: "Resource is already linked to another user." }; + } + if (error.message.includes("changed during update")) { + return { error: "Resource link changed during update. Please retry." }; + } + } + + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + if (error.message.includes("Resource")) { + return { error: "Resource not found with the given criteria." }; + } + return { error: "User not found with the given criteria." }; + } + + const prismaError = getPrismaRequestErrorMetadata(error); + if (!prismaError) { + return null; + } + + const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); + const pointsToUser = + errorText.includes("userid") || errorText.includes("user_id") || errorText.includes(" user "); + const pointsToResource = + errorText.includes("resourceid") || + errorText.includes("resource_id") || + errorText.includes(" resource "); + + if (prismaError.code === "P2025") { + return { error: "Resource not found with the given criteria." }; + } + + if (prismaError.code === "P2003") { + if (pointsToUser) { + return { error: "User not found with the given criteria." }; + } + if (pointsToResource || errorText.includes("resource")) { + return { error: "Resource not found with the given criteria." }; + } + return { error: "User not found with the given criteria." }; + } + + return null; +} + +export 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; +} + +export function toAssistantWebhookNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Webhook not found with the given criteria."); +} + +export 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; +} + +export function toAssistantAuditLogEntryNotFoundError( + error: unknown, +): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Audit log entry not found with the given criteria."); +} + +export function toAssistantTaskNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Task not found with the given criteria."); +} + +export function toAssistantTaskActionError(error: unknown): AssistantToolErrorResult | null { + const notFound = toAssistantTaskNotFoundError(error); + if (notFound) { + return notFound; + } + + if (error instanceof TRPCError && error.code === "PRECONDITION_FAILED") { + if (error.message.includes("no executable action")) { + return { error: "Task has no executable action." }; + } + if (error.message.includes("already completed")) { + return { error: "Task is already completed." }; + } + if (error.message.includes("dismissed")) { + return { error: "Task has been dismissed and cannot be executed." }; + } + } + + if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + if ( + error.message.includes("Invalid taskAction format") || + error.message.includes("Unknown action") + ) { + return { error: "Task action is invalid and cannot be executed." }; + } + if (error.message === "Vacation not found") { + return { error: "Vacation not found with the given criteria." }; + } + if (error.message.startsWith("Vacation is ") && error.message.includes(", not PENDING")) { + return { + error: "Vacation is not pending and cannot be approved or rejected via this task action.", + }; + } + if (error.message === "Assignment not found") { + return { error: "Assignment not found with the given criteria." }; + } + if (error.message === "Assignment is already CONFIRMED") { + return { error: "Assignment is already confirmed." }; + } + return { error: error.message }; + } + + if (error instanceof TRPCError && error.code === "FORBIDDEN") { + return { error: "You do not have permission to execute this task action." }; + } + + return null; +} + +export 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; +} + +export function toAssistantBroadcastNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Broadcast not found with the given criteria."); +} + +export function toAssistantDispoImportBatchNotFoundError( + error: unknown, +): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Import batch not found with the given criteria."); +} + +export function toAssistantReminderNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Reminder not found with the given criteria."); +} + +export 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; +} + +export function toAssistantNotificationReadError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotificationNotFoundError(error); +} + +export 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; +} + +export 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; +} + +export 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; +} + +export 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; +} + +export 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; +} + +export 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; +} + +export function toAssistantNotificationCreationError( + error: unknown, + context: "notification" | "task" | "broadcast", +): AssistantToolErrorResult | null { + const trpcError = getTrpcErrorMetadata(error); + + if ( + context === "broadcast" && + trpcError?.code === "BAD_REQUEST" && + trpcError.message === "No recipients matched the broadcast target." + ) { + return { error: "No recipients matched the broadcast target." }; + } + + if ( + context === "broadcast" && + trpcError?.code === "BAD_REQUEST" && + trpcError.message === "Scheduled broadcasts with task metadata are not supported yet." + ) { + return { error: "Scheduled broadcasts with task metadata are not supported yet." }; + } + + if (trpcError?.code === "NOT_FOUND") { + if (trpcError.message.includes("broadcast")) { + return { error: "Broadcast not found with the given criteria." }; + } + if (trpcError.message.includes("Sender user not found")) { + return { error: "Sender user not found with the given criteria." }; + } + if (trpcError.message.includes("Assignee user not found")) { + return { error: "Assignee user not found with the given criteria." }; + } + if (trpcError.message.includes("recipient")) { + return context === "broadcast" + ? { error: "Broadcast recipient user not found with the given criteria." } + : context === "task" + ? { error: "Task recipient user not found with the given criteria." } + : { error: "Notification recipient user not found with the given criteria." }; + } + } + + const prismaError = getPrismaRequestErrorMetadata(error); + if (!prismaError) { + return null; + } + + if (prismaError.code !== "P2003" && prismaError.code !== "P2025") { + return null; + } + + const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); + + if (errorText.includes("assignee")) { + return { error: "Assignee user not found with the given criteria." }; + } + + if (errorText.includes("sender")) { + return { error: "Sender user not found with the given criteria." }; + } + + if ( + context === "broadcast" && + (errorText.includes("notificationbroadcast") || errorText.includes("broadcast")) + ) { + return { error: "Broadcast not found with the given criteria." }; + } + + if (context === "broadcast" && prismaError.code === "P2025") { + return { error: "Broadcast not found with the given criteria." }; + } + + if (context === "task") { + return { error: "Task recipient user not found with the given criteria." }; + } + + if (context === "broadcast") { + return { error: "Broadcast recipient user not found with the given criteria." }; + } + + return { error: "Notification recipient user not found with the given criteria." }; +} + +export 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." }; +} + +export function isAssistantToolErrorResult(value: unknown): value is AssistantToolErrorResult { + return value !== null && typeof value === "object" && "error" in value; +} + +export function toAssistantIndexedFieldError( + index: number, + field: string, + message: string, +): AssistantIndexedFieldErrorResult { + return { + error: `assignments[${index}].${field}: ${message}`, + field: `assignments[${index}].${field}`, + index, + }; +} + +export 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; + } +} + +export async function resolveProjectIdentifier(ctx: ToolContext, identifier: string) { + const caller = createProjectCaller(createScopedCallerContext(ctx)); + return resolveEntityOrAssistantError( + () => caller.resolveByIdentifier({ identifier }), + `Project not found: ${identifier}`, + ); +} + +export async function resolveResourceIdentifier(ctx: ToolContext, identifier: string) { + const caller = createResourceCaller(createScopedCallerContext(ctx)); + return resolveEntityOrAssistantError( + () => caller.resolveByIdentifier({ identifier }), + `Resource not found: ${identifier}`, + ); +} + +export 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, + }; +} + +export function sanitizeWebhook(webhook: T) { + const { secret: _secret, ...rest } = webhook; + return { + ...rest, + hasSecret: Boolean(webhook.secret), + }; +} + +export function sanitizeWebhookList(webhooks: T[]) { + return webhooks.map((webhook) => sanitizeWebhook(webhook)); +} + +/** Resolve a responsible person name against existing resources. Returns the exact displayName or an error object. */ +export 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}` }; +} diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index c9b898d..84dc438 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -282,6 +282,7 @@ model Account { user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@index([userId]) @@unique([provider, providerAccountId]) @@map("accounts") } @@ -293,6 +294,7 @@ model Session { expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@index([userId]) @@map("sessions") } @@ -938,6 +940,7 @@ model Project { color String? // Hex color for timeline display, e.g. "#3b82f6" coverImageUrl String? @db.Text // Base64 data-URL for project cover art coverFocusY Int @default(50) // Vertical focus point 0-100 (% from top) + coverAiGenerated Boolean @default(false) // EGAI 4.3.1.3 — true when cover was AI-generated // staffingReqs: StaffingRequirement[] staffingReqs Json @db.JsonB @default("[]") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 115f632..32cccff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: '@capakraken/tsconfig': specifier: workspace:* version: link:../../tooling/typescript + '@next/bundle-analyzer': + specifier: ^16.2.3 + version: 16.2.3 '@playwright/test': specifier: ^1.49.1 version: 1.58.2 @@ -586,6 +589,10 @@ packages: '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@discoveryjs/json-ext@0.5.7': + resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} + engines: {node: '>=10.0.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1181,6 +1188,9 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@next/bundle-analyzer@16.2.3': + resolution: {integrity: sha512-aDwW4f4SVqbQDWzSBHQJ1KI6H+lx8oX/vS3xGqzLajUu+KQb7uakK88AIMvRIf7TlIonce67g594rzpxvBuJIw==} + '@next/env@15.5.15': resolution: {integrity: sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==} @@ -1776,6 +1786,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@prisma/client@5.22.0': resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} engines: {node: '>=16.13'} @@ -2516,6 +2529,10 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -2849,6 +2866,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} @@ -2992,6 +3013,9 @@ packages: dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -3082,6 +3106,9 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -3523,6 +3550,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -3718,6 +3749,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -4088,6 +4123,10 @@ packages: motion-utils@12.36.0: resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4238,6 +4277,10 @@ packages: zod: optional: true + opener@1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4789,6 +4832,10 @@ packages: simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -5055,6 +5102,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -5288,6 +5339,11 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} + webpack-bundle-analyzer@4.10.1: + resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} + engines: {node: '>= 10.13.0'} + hasBin: true + webpack-sources@3.3.4: resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} engines: {node: '>=10.13.0'} @@ -5365,6 +5421,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -5600,6 +5668,8 @@ snapshots: '@dimforge/rapier3d-compat@0.12.0': {} + '@discoveryjs/json-ext@0.5.7': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': dependencies: react: 19.2.4 @@ -6054,6 +6124,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@next/bundle-analyzer@16.2.3': + dependencies: + webpack-bundle-analyzer: 4.10.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@next/env@15.5.15': {} '@next/swc-darwin-arm64@15.5.15': @@ -6561,6 +6638,8 @@ snapshots: dependencies: playwright: 1.58.2 + '@polka/url@1.0.0-next.29': {} + '@prisma/client@5.22.0(prisma@5.22.0)': optionalDependencies: prisma: 5.22.0 @@ -7447,6 +7526,10 @@ snapshots: dependencies: acorn: 8.16.0 + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + acorn@8.16.0: {} agent-base@6.0.2: @@ -7805,6 +7888,8 @@ snapshots: commander@4.1.1: {} + commander@7.2.0: {} + commondir@1.0.1: {} compress-commons@4.1.2: @@ -7940,6 +8025,8 @@ snapshots: dayjs@1.11.19: {} + debounce@1.2.1: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -8009,6 +8096,8 @@ snapshots: dependencies: readable-stream: 2.3.8 + duplexer@0.1.2: {} + eastasianwidth@0.2.0: {} electron-to-chromium@1.5.307: {} @@ -8634,6 +8723,10 @@ snapshots: graceful-fs@4.2.11: {} + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -8832,6 +8925,8 @@ snapshots: is-number@7.0.0: {} + is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} is-reference@1.2.1: @@ -9210,6 +9305,8 @@ snapshots: motion-utils@12.36.0: {} + mrmime@2.0.1: {} + ms@2.1.3: {} mz@2.7.0: @@ -9338,6 +9435,8 @@ snapshots: optionalDependencies: zod: 3.25.76 + opener@1.5.2: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -9999,6 +10098,12 @@ snapshots: dependencies: is-arrayish: 0.3.4 + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.3 @@ -10283,6 +10388,8 @@ snapshots: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.28 @@ -10543,6 +10650,25 @@ snapshots: webidl-conversions@8.0.1: {} + webpack-bundle-analyzer@4.10.1: + dependencies: + '@discoveryjs/json-ext': 0.5.7 + acorn: 8.16.0 + acorn-walk: 8.3.5 + commander: 7.2.0 + debounce: 1.2.1 + escape-string-regexp: 4.0.0 + gzip-size: 6.0.0 + html-escaper: 2.0.2 + is-plain-object: 5.0.0 + opener: 1.5.2 + picocolors: 1.1.1 + sirv: 2.0.4 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + webpack-sources@3.3.4: {} webpack@5.105.4: @@ -10672,6 +10798,8 @@ snapshots: wrappy@1.0.2: {} + ws@7.5.10: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {}