From 7aa32f8a5c3ea282db3c815ea197d60201b90b9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 11:51:59 +0200 Subject: [PATCH] test(api): harden assistant tool error handling --- .../assistant-tools-advanced.test.ts | 243 +- .../__tests__/assistant-tools-audit.test.ts | 2 +- .../__tests__/assistant-tools-country.test.ts | 99 + .../assistant-tools-holidays.test.ts | 125 + .../assistant-tools-import-export.test.ts | 4804 ++++++++++++++++- packages/api/src/router/assistant-tools.ts | 2906 ++++++++-- packages/api/src/router/notification.ts | 7 + packages/api/src/router/user.ts | 93 +- 8 files changed, 7898 insertions(+), 381 deletions(-) diff --git a/packages/api/src/__tests__/assistant-tools-advanced.test.ts b/packages/api/src/__tests__/assistant-tools-advanced.test.ts index 701b6a9..bafde9e 100644 --- a/packages/api/src/__tests__/assistant-tools-advanced.test.ts +++ b/packages/api/src/__tests__/assistant-tools-advanced.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; vi.mock("@capakraken/application", async (importOriginal) => { const actual = await importOriginal(); @@ -893,6 +894,75 @@ describe("assistant advanced tools and scoping", () => { expect(db.assignment.create).toHaveBeenCalled(); }); + it("returns a stable conflict error for quick-assign timeline mutations", async () => { + const db = { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Gelddruckmaschine", + shortCode: "GDM", + status: "ACTIVE", + responsiblePerson: null, + }), + }, + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "resource_1", + eid: "E-001", + displayName: "Alice", + lcrCents: 5000, + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + saturday: 0, + sunday: 0, + }, + }), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn().mockRejectedValue( + new TRPCError({ + code: "CONFLICT", + message: "Resource is already assigned to this project with overlapping dates", + }), + ), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + auditLog: { + create: vi.fn().mockResolvedValue({}), + }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), + }; + const ctx = createToolContext( + db, + [PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS], + SystemRole.MANAGER, + ); + + const result = await executeTool( + "quick_assign_timeline_resource", + JSON.stringify({ + resourceIdentifier: "resource_1", + projectIdentifier: "project_1", + startDate: "2026-03-16", + endDate: "2026-03-20", + hoursPerDay: 8, + }), + ctx, + ); + + expect(result.action).toBeUndefined(); + expect(JSON.parse(result.content)).toEqual({ + error: "Resource is already assigned to this project with overlapping dates", + }); + }); + it("batch quick-assigns timeline resources through the real timeline router mutation", async () => { const db = { project: { @@ -975,6 +1045,60 @@ describe("assistant advanced tools and scoping", () => { expect(db.assignment.create).toHaveBeenCalledTimes(2); }); + it("returns a structured batch assignment resolver error for an unknown resource", async () => { + const db = { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + shortCode: "GDM", + name: "Gelddruckmaschine", + status: "ACTIVE", + responsiblePerson: null, + startDate: new Date("2026-03-16"), + endDate: new Date("2026-03-20"), + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + resource: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + }, + assignment: { + create: vi.fn(), + }, + }; + const ctx = createToolContext( + db, + [PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS], + SystemRole.MANAGER, + ); + + const result = await executeTool( + "batch_quick_assign_timeline_resources", + JSON.stringify({ + assignments: [ + { + resourceIdentifier: "missing_resource", + projectIdentifier: "project_1", + startDate: "2026-03-16", + endDate: "2026-03-20", + hoursPerDay: 8, + }, + ], + }), + ctx, + ); + + expect(result.action).toBeUndefined(); + expect(JSON.parse(result.content)).toEqual({ + error: "assignments[0].resourceIdentifier: Resource not found: missing_resource", + field: "assignments[0].resourceIdentifier", + index: 0, + }); + expect(db.assignment.create).not.toHaveBeenCalled(); + }); + it("applies timeline project shifts through the real timeline router mutation", async () => { const { listAssignmentBookings } = await import("@capakraken/application"); vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]); @@ -1046,6 +1170,56 @@ describe("assistant advanced tools and scoping", () => { ); }); + it("returns a stable project-not-found error if the timeline shift target disappears mid-mutation", async () => { + const { listAssignmentBookings } = await import("@capakraken/application"); + vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]); + + const db = { + project: { + findUnique: vi + .fn() + .mockResolvedValueOnce({ + id: "project_1", + name: "Gelddruckmaschine", + shortCode: "GDM", + status: "ACTIVE", + responsiblePerson: null, + budgetCents: 100000, + winProbability: 100, + startDate: new Date("2026-03-16"), + endDate: new Date("2026-03-20"), + }) + .mockResolvedValueOnce(null), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + const ctx = createToolContext( + db, + [PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS], + SystemRole.MANAGER, + ); + + const result = await executeTool( + "apply_timeline_project_shift", + JSON.stringify({ + projectIdentifier: "project_1", + newStartDate: "2026-03-23", + newEndDate: "2026-03-27", + }), + ctx, + ); + + expect(result.action).toBeUndefined(); + expect(JSON.parse(result.content)).toEqual({ + error: "Project not found with the given criteria.", + }); + }); + it("batch-shifts timeline allocations through the real timeline router mutation", async () => { const existingAssignment = { id: "assignment_1", @@ -1133,6 +1307,73 @@ describe("assistant advanced tools and scoping", () => { ); }); + it("returns a stable allocation-not-found error for inline timeline updates", async () => { + const db = { + allocation: { + findUnique: vi.fn().mockResolvedValue(null), + }, + demandRequirement: { + findUnique: vi.fn().mockResolvedValue(null), + }, + assignment: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + const ctx = createToolContext( + db, + [PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS], + SystemRole.MANAGER, + ); + + const result = await executeTool( + "update_timeline_allocation_inline", + JSON.stringify({ + allocationId: "assignment_missing", + hoursPerDay: 6, + }), + ctx, + ); + + expect(result.action).toBeUndefined(); + expect(JSON.parse(result.content)).toEqual({ + error: "Allocation not found with the given criteria.", + }); + }); + + it("returns a stable allocation-not-found error for batch timeline shifts without matches", async () => { + const db = { + allocation: { + findUnique: vi.fn().mockResolvedValue(null), + }, + demandRequirement: { + findUnique: vi.fn().mockResolvedValue(null), + }, + assignment: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + const ctx = createToolContext( + db, + [PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS], + SystemRole.MANAGER, + ); + + const result = await executeTool( + "batch_shift_timeline_allocations", + JSON.stringify({ + allocationIds: ["assignment_missing"], + daysDelta: 2, + mode: "move", + }), + ctx, + ); + + expect(result.action).toBeUndefined(); + expect(JSON.parse(result.content)).toEqual({ + error: "Allocation not found with the given criteria.", + }); + }); + it("returns the chargeability report readmodel through the assistant", async () => { const { listAssignmentBookings } = await import("@capakraken/application"); vi.mocked(listAssignmentBookings).mockResolvedValue([ @@ -1494,7 +1735,7 @@ describe("assistant advanced tools and scoping", () => { expect(JSON.parse(result.content)).toEqual( expect.objectContaining({ - error: expect.stringContaining("Admin role required"), + error: "You do not have permission to perform this action.", }), ); expect(findMany).not.toHaveBeenCalled(); diff --git a/packages/api/src/__tests__/assistant-tools-audit.test.ts b/packages/api/src/__tests__/assistant-tools-audit.test.ts index 62c7522..d890690 100644 --- a/packages/api/src/__tests__/assistant-tools-audit.test.ts +++ b/packages/api/src/__tests__/assistant-tools-audit.test.ts @@ -122,7 +122,7 @@ describe("assistant audit tools", () => { expect(JSON.parse(result.content)).toEqual( expect.objectContaining({ - error: expect.stringContaining("Controller access required"), + error: "You do not have permission to perform this action.", }), ); }); diff --git a/packages/api/src/__tests__/assistant-tools-country.test.ts b/packages/api/src/__tests__/assistant-tools-country.test.ts index 02f7fa0..0da8849 100644 --- a/packages/api/src/__tests__/assistant-tools-country.test.ts +++ b/packages/api/src/__tests__/assistant-tools-country.test.ts @@ -129,6 +129,25 @@ describe("assistant country tools", () => { }); }); + it("returns a stable error when a country cannot be resolved", async () => { + const ctx = createToolContext({ + country: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + }); + + const result = await executeTool( + "get_country", + JSON.stringify({ identifier: "Atlantis" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Country not found with the given criteria.", + }); + }); + it("creates a country for admin users and returns an invalidation action", async () => { const ctx = createToolContext({ country: { @@ -162,6 +181,46 @@ describe("assistant country tools", () => { }); }); + it("returns a stable error when creating a country with a duplicate code", async () => { + const ctx = createToolContext({ + country: { + findUnique: vi.fn().mockResolvedValue({ + id: "country_es_existing", + code: "ES", + name: "Existing Spain", + }), + }, + }); + + const result = await executeTool( + "create_country", + JSON.stringify({ code: "ES", name: "Spain", dailyWorkingHours: 8 }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "A country with this code already exists.", + }); + }); + + it("returns a stable error when updating a missing country", async () => { + const ctx = createToolContext({ + country: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }); + + const result = await executeTool( + "update_country", + JSON.stringify({ id: "country_missing", data: { name: "Atlantis" } }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Country not found with the given criteria.", + }); + }); + it("refuses country mutations for non-admin users", async () => { const ctx = createToolContext({ country: {} }, [], SystemRole.MANAGER); @@ -203,4 +262,44 @@ describe("assistant country tools", () => { message: "Deleted metro city: Hamburg", }); }); + + it("returns a stable error when updating a missing metro city", async () => { + const ctx = createToolContext({ + metroCity: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }); + + const result = await executeTool( + "update_metro_city", + JSON.stringify({ id: "city_missing", data: { name: "Hamburg-Mitte" } }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Metro city not found with the given criteria.", + }); + }); + + it("returns a stable error when deleting a metro city that is still assigned", async () => { + const ctx = createToolContext({ + metroCity: { + findUnique: vi.fn().mockResolvedValue({ + id: "city_ham", + name: "Hamburg", + _count: { resources: 3 }, + }), + }, + }); + + const result = await executeTool( + "delete_metro_city", + JSON.stringify({ id: "city_ham" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Metro city cannot be deleted while it is still assigned to resources.", + }); + }); }); diff --git a/packages/api/src/__tests__/assistant-tools-holidays.test.ts b/packages/api/src/__tests__/assistant-tools-holidays.test.ts index a5e49ba..d79c6a7 100644 --- a/packages/api/src/__tests__/assistant-tools-holidays.test.ts +++ b/packages/api/src/__tests__/assistant-tools-holidays.test.ts @@ -320,6 +320,100 @@ describe("assistant holiday tools", () => { ); }); + it("returns stable assistant errors for holiday calendar and entry mutations", async () => { + const cases = [ + { + name: "invalid holiday calendar scope", + toolName: "create_holiday_calendar", + db: { + country: { + findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }), + }, + holidayCalendar: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + payload: { + name: "Ungueltiger Kalender", + scopeType: "STATE", + countryId: "country_de", + }, + expected: "Holiday calendar scope is invalid.", + }, + { + name: "duplicate holiday calendar scope", + toolName: "create_holiday_calendar", + db: { + country: { + findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }), + }, + holidayCalendar: { + findFirst: vi.fn().mockResolvedValue({ id: "cal_existing" }), + }, + }, + payload: { + name: "Bayern Feiertage", + scopeType: "STATE", + countryId: "country_de", + stateCode: "BY", + }, + expected: "A holiday calendar for this scope already exists.", + }, + { + name: "holiday calendar not found on delete", + toolName: "delete_holiday_calendar", + db: { + holidayCalendar: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + payload: { id: "cal_missing" }, + expected: "Holiday calendar not found with the given criteria.", + }, + { + name: "holiday calendar entry not found on delete", + toolName: "delete_holiday_calendar_entry", + db: { + holidayCalendarEntry: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + payload: { id: "entry_missing" }, + expected: "Holiday calendar entry not found with the given criteria.", + }, + { + name: "duplicate holiday calendar entry date", + toolName: "create_holiday_calendar_entry", + db: { + holidayCalendar: { + findUnique: vi.fn().mockResolvedValue({ id: "cal_by", name: "Bayern Feiertage" }), + }, + holidayCalendarEntry: { + findFirst: vi.fn().mockResolvedValue({ id: "entry_existing" }), + }, + }, + payload: { + holidayCalendarId: "cal_by", + date: "2026-01-06", + name: "Heilige Drei Koenige", + }, + expected: "A holiday entry for this calendar and date already exists.", + }, + ] as const; + + for (const testCase of cases) { + const result = await executeTool( + testCase.toolName, + JSON.stringify(testCase.payload), + createToolContext(testCase.db), + ); + + expect(JSON.parse(result.content)).toEqual({ + error: testCase.expected, + }); + } + }); + it("calculates chargeability with regional holidays excluded from booked and available hours", async () => { const resourceRecord = { id: "res_1", @@ -763,6 +857,37 @@ describe("assistant holiday tools", () => { ); }); + it("returns a stable assistant error when staffing suggestions receive an invalid optional start date", async () => { + const db = { + project: { + findUnique: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + name: "Holiday Project", + shortCode: "HP", + status: "ACTIVE", + responsiblePerson: null, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-16T00:00:00.000Z"), + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + }; + const ctx = createToolContext(db); + + const result = await executeTool( + "get_staffing_suggestions", + JSON.stringify({ projectId: "project_1", startDate: "2026-99-01" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid startDate: 2026-99-01", + }); + }); + it("uses holiday-aware assignment hours for assistant shoring ratio", async () => { const db = { project: { diff --git a/packages/api/src/__tests__/assistant-tools-import-export.test.ts b/packages/api/src/__tests__/assistant-tools-import-export.test.ts index bb09de0..698d74d 100644 --- a/packages/api/src/__tests__/assistant-tools-import-export.test.ts +++ b/packages/api/src/__tests__/assistant-tools-import-export.test.ts @@ -1,13 +1,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { PermissionKey, SystemRole } from "@capakraken/shared"; -import { VacationType } from "@capakraken/db"; +import { VacationStatus, VacationType } from "@capakraken/db"; +import { TRPCError } from "@trpc/server"; import { apiRateLimiter } from "../middleware/rate-limit.js"; +const { totpValidateMock } = vi.hoisted(() => ({ + totpValidateMock: vi.fn(), +})); + vi.mock("@capakraken/application", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + approveEstimateVersion: vi.fn(), + cloneEstimate: vi.fn(), countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + createEstimateExport: vi.fn(), + createEstimatePlanningHandoff: vi.fn(), + createEstimateRevision: vi.fn(), loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()), getDashboardDemand: vi.fn().mockResolvedValue([]), getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), @@ -23,6 +33,8 @@ vi.mock("@capakraken/application", async (importOriginal) => { getDashboardTopValueResources: vi.fn().mockResolvedValue([]), getEstimateById: vi.fn(), listAssignmentBookings: vi.fn().mockResolvedValue([]), + submitEstimateVersion: vi.fn(), + updateEstimateDraft: vi.fn(), }; }); @@ -63,8 +75,39 @@ vi.mock("../ai-client.js", async (importOriginal) => { }; }); +vi.mock("otpauth", () => { + class Secret { + base32: string; + + constructor() { + this.base32 = "MOCKSECRET"; + } + + static fromBase32(value: string) { + return value; + } + } + + class TOTP { + validate(args: { token: string; window: number }) { + return totpValidateMock(args); + } + + toString() { + return "otpauth://mock"; + } + } + + return { Secret, TOTP }; +}); + import { + approveEstimateVersion, + cloneEstimate, countPlanningEntries, + createEstimateExport, + createEstimatePlanningHandoff, + createEstimateRevision, getDashboardDemand, getDashboardOverview, getDashboardProjectHealth, @@ -73,6 +116,8 @@ import { getDashboardTopValueResources, getEstimateById, listAssignmentBookings, + submitEstimateVersion, + updateEstimateDraft, } from "@capakraken/application"; import { executeTool, type ToolContext } from "../router/assistant-tools.js"; @@ -106,8 +151,16 @@ describe("assistant import/export and dispo tools", () => { beforeEach(() => { vi.clearAllMocks(); apiRateLimiter.reset(); + totpValidateMock.mockReset(); + vi.mocked(approveEstimateVersion).mockReset(); + vi.mocked(cloneEstimate).mockReset(); vi.mocked(countPlanningEntries).mockResolvedValue({ countsByRoleId: new Map() }); + vi.mocked(createEstimateExport).mockReset(); + vi.mocked(createEstimatePlanningHandoff).mockReset(); + vi.mocked(createEstimateRevision).mockReset(); vi.mocked(getEstimateById).mockReset(); + vi.mocked(submitEstimateVersion).mockReset(); + vi.mocked(updateEstimateDraft).mockReset(); }); it("exports resources CSV through the real import/export router path", async () => { @@ -179,7 +232,7 @@ describe("assistant import/export and dispo tools", () => { findUnique: vi.fn(), }, }, - { userRole: SystemRole.MANAGER }, + { userRole: SystemRole.USER }, ); const result = await executeTool( @@ -190,11 +243,32 @@ describe("assistant import/export and dispo tools", () => { expect(JSON.parse(result.content)).toEqual( expect.objectContaining({ - error: expect.stringContaining("Admin role required"), + error: "You do not have permission to perform this action.", }), ); }); + it("returns a stable assistant error for a missing dispo import batch", async () => { + const ctx = createToolContext( + { + importBatch: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "get_dispo_import_batch", + JSON.stringify({ id: "batch_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Import batch not found with the given criteria.", + }); + }); + it("surfaces protected AI configuration checks to non-admin users", async () => { const ctx = createToolContext( { @@ -262,6 +336,157 @@ describe("assistant import/export and dispo tools", () => { expect(JSON.parse(getResult.content)).not.toHaveProperty("secret"); }); + it("returns stable assistant errors for missing webhooks", async () => { + const commands = [ + ["get_webhook", { id: "wh_missing" }], + ["update_webhook", { id: "wh_missing", data: { name: "Renamed" } }], + ["delete_webhook", { id: "wh_missing" }], + ["test_webhook", { id: "wh_missing" }], + ] as const; + + for (const [toolName, payload] of commands) { + const ctx = createToolContext( + { + webhook: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool(toolName, JSON.stringify(payload), ctx); + + expect(JSON.parse(result.content)).toEqual({ + error: "Webhook not found with the given criteria.", + }); + } + }); + + it("returns a stable assistant error for invalid webhook creation input", async () => { + const ctx = createToolContext( + { + webhook: { + create: vi.fn(), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_webhook", + JSON.stringify({ + name: "Primary", + url: "not-a-url", + events: ["project.updated"], + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Webhook input is invalid.", + }); + expect(ctx.db.webhook.create).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error for invalid webhook update input", async () => { + const ctx = createToolContext( + { + webhook: { + findUnique: vi.fn().mockResolvedValue({ + id: "wh_1", + name: "Primary", + url: "https://example.com/hook", + secret: null, + events: ["project.updated"], + isActive: true, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + }), + update: vi.fn(), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "update_webhook", + JSON.stringify({ + id: "wh_1", + data: { + url: "not-a-url", + }, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Webhook update input is invalid.", + }); + expect(ctx.db.webhook.update).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when a webhook disappears during update", async () => { + const ctx = createToolContext( + { + webhook: { + findUnique: vi.fn().mockResolvedValue({ + id: "wh_1", + name: "Primary", + url: "https://example.com/hook", + secret: null, + events: ["project.updated"], + isActive: true, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + }), + update: vi.fn().mockRejectedValue({ + code: "P2025", + message: "Record to update not found.", + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "update_webhook", + JSON.stringify({ + id: "wh_1", + data: { + name: "Renamed", + }, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Webhook not found with the given criteria.", + }); + }); + + it("returns stable assistant errors for missing audit log entries", async () => { + const ctx = createToolContext( + { + auditLog: { + findUniqueOrThrow: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Audit log entry not found" }), + ), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "get_audit_log_entry", + JSON.stringify({ id: "audit_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Audit log entry not found with the given criteria.", + }); + }); + it("forwards staged dispo resource queries through the real dispo router path", async () => { const ctx = createToolContext( { @@ -431,6 +656,208 @@ describe("assistant import/export and dispo tools", () => { ); }); + it("returns a stable assistant error when marking a missing notification as read", async () => { + const update = vi.fn().mockRejectedValue({ + code: "P2025", + message: "No record was found for an update.", + }); + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + update, + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "mark_notification_read", + JSON.stringify({ notificationId: "notif_missing" }), + ctx, + ); + + expect(update).toHaveBeenCalledWith({ + where: { id: "notif_missing", userId: "user_1" }, + data: expect.objectContaining({ + readAt: expect.any(Date), + }), + }); + expect(JSON.parse(result.content)).toEqual({ + error: "Notification not found with the given criteria.", + }); + }); + + it("creates a notification through the notification router", async () => { + const db = { + notification: { + create: vi.fn().mockResolvedValue({ id: "notification_1", userId: "user_2" }), + findUnique: vi.fn().mockResolvedValue({ + id: "notification_1", + title: "Need review", + userId: "user_2", + category: "TASK", + }), + }, + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_2", email: "user2@example.com", name: "User Two" }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const result = await executeTool( + "create_notification", + JSON.stringify({ + userId: "user_2", + type: "TASK_CREATED", + title: "Need review", + category: "TASK", + taskStatus: "OPEN", + dueDate: "2026-04-02T09:30:00.000Z", + channel: "in_app", + }), + ctx, + ); + + expect(db.notification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user_2", + type: "TASK_CREATED", + title: "Need review", + category: "TASK", + taskStatus: "OPEN", + dueDate: new Date("2026-04-02T09:30:00.000Z"), + senderId: "user_1", + channel: "in_app", + }), + }); + expect(db.notification.findUnique).toHaveBeenCalledWith({ + where: { id: "notification_1" }, + }); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + notificationId: "notification_1", + message: 'Created notification "Need review".', + }), + ); + expect(result.action).toEqual({ + type: "invalidate", + scope: ["notification"], + }); + }); + + it("returns a stable assistant error when notification dueDate is invalid", async () => { + const ctx = createToolContext({}, { userRole: SystemRole.MANAGER }); + + const result = await executeTool( + "create_notification", + JSON.stringify({ + userId: "user_2", + type: "TASK_CREATED", + title: "Need review", + dueDate: "not-a-datetime", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid dueDate: not-a-datetime", + }); + }); + + it("returns a stable assistant error when notification recipient user is missing", async () => { + const ctx = createToolContext( + { + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_userId_fkey" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "create_notification", + JSON.stringify({ + userId: "user_missing", + type: "TASK_CREATED", + title: "Need review", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Notification recipient user not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when notification assignee user is missing", async () => { + const ctx = createToolContext( + { + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_assigneeId_fkey" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "create_notification", + JSON.stringify({ + userId: "user_2", + assigneeId: "user_missing", + type: "TASK_CREATED", + title: "Need review", + category: "TASK", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Assignee user not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when notification sender user is missing", async () => { + const ctx = createToolContext( + { + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_senderId_fkey" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "create_notification", + JSON.stringify({ + userId: "user_2", + senderId: "user_missing", + type: "TASK_CREATED", + title: "Need review", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Sender user not found with the given criteria.", + }); + }); + it("routes audit timeline reads through the real audit router detail path", async () => { const ctx = createToolContext({ auditLog: { @@ -599,6 +1026,30 @@ describe("assistant import/export and dispo tools", () => { ); }); + it("returns a stable assistant error for a missing task detail", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "get_task_detail", + JSON.stringify({ taskId: "task_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task not found with the given criteria.", + }); + }); + it("routes task action execution through the real notification router path", async () => { const db = { user: { @@ -675,6 +1126,438 @@ describe("assistant import/export and dispo tools", () => { ); }); + it("returns a stable assistant error when updating a missing task", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "update_task_status", + JSON.stringify({ taskId: "task_missing", status: "DONE" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when executing a missing task action", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when executing an already completed task action", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "approve_vacation:vac_1", + taskStatus: "DONE", + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task is already completed.", + }); + }); + + it("returns a stable assistant error when a task has no executable action", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: null, + taskStatus: "OPEN", + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task has no executable action.", + }); + }); + + it("returns a stable assistant error when a task action format is invalid", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "not-a-valid-task-action", + taskStatus: "OPEN", + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task action is invalid and cannot be executed.", + }); + }); + + it("returns a stable assistant error when executing a task action without permission", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "approve_vacation:vac_1", + taskStatus: "OPEN", + }), + }, + }, + { userRole: SystemRole.USER }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "You do not have permission to execute this task action.", + }); + }); + + it("returns a stable assistant error when a vacation task action target disappears", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "approve_vacation:vac_missing", + taskStatus: "OPEN", + }), + }, + vacation: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when a vacation task action is no longer pending", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "approve_vacation:vac_1", + taskStatus: "OPEN", + }), + }, + vacation: { + findUnique: vi.fn().mockResolvedValue({ + id: "vac_1", + status: VacationStatus.APPROVED, + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation is not pending and cannot be approved or rejected via this task action.", + }); + }); + + it("returns a stable assistant error when an assignment task action target disappears", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "confirm_assignment:asg_missing", + taskStatus: "OPEN", + }), + }, + assignment: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Assignment not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when an assignment is already confirmed", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "confirm_assignment:asg_1", + taskStatus: "OPEN", + }), + }, + assignment: { + findUnique: vi.fn().mockResolvedValue({ + id: "asg_1", + status: "CONFIRMED", + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Assignment is already confirmed.", + }); + }); + + it("creates a task for a user through the notification router", async () => { + const db = { + notification: { + create: vi.fn().mockResolvedValue({ id: "task_2", userId: "user_2" }), + findUnique: vi.fn().mockResolvedValue({ + id: "task_2", + title: "Follow up", + category: "TASK", + taskStatus: "OPEN", + }), + }, + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_2", email: "user2@example.com", name: "User Two" }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const result = await executeTool( + "create_task_for_user", + JSON.stringify({ + userId: "user_2", + title: "Follow up", + dueDate: "2026-04-03T11:00:00.000Z", + channel: "in_app", + }), + ctx, + ); + + expect(db.notification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user_2", + type: "TASK_CREATED", + category: "TASK", + taskStatus: "OPEN", + title: "Follow up", + dueDate: new Date("2026-04-03T11:00:00.000Z"), + senderId: "user_1", + channel: "in_app", + }), + }); + expect(db.notification.findUnique).toHaveBeenCalledWith({ + where: { id: "task_2" }, + }); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + taskId: "task_2", + message: 'Created task "Follow up" for user_2.', + }), + ); + }); + + it("returns a stable assistant error when task dueDate is invalid", async () => { + const ctx = createToolContext({}, { userRole: SystemRole.MANAGER }); + + const result = await executeTool( + "create_task_for_user", + JSON.stringify({ + userId: "user_2", + title: "Follow up", + dueDate: "not-a-datetime", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid dueDate: not-a-datetime", + }); + }); + + it("returns a stable assistant error when task recipient user is missing", async () => { + const ctx = createToolContext( + { + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_userId_fkey" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "create_task_for_user", + JSON.stringify({ + userId: "user_missing", + title: "Follow up", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Task recipient user not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when task sender user is missing", async () => { + const ctx = createToolContext( + { + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_senderId_fkey" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "create_task_for_user", + JSON.stringify({ + userId: "user_2", + title: "Follow up", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Sender user not found with the given criteria.", + }); + }); + it("creates, lists, updates, and deletes reminders through the notification router", async () => { const db = { user: { @@ -776,6 +1659,544 @@ describe("assistant import/export and dispo tools", () => { ); }); + it("returns a stable assistant error when reminder creation receives an invalid datetime", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_reminder", + JSON.stringify({ + title: "Submit report", + remindAt: "not-a-datetime", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid remindAt: not-a-datetime", + }); + }); + + it("returns a stable assistant error when reminder creation loses its authenticated user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_userId_fkey" }, + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_reminder", + JSON.stringify({ + title: "Submit report", + remindAt: "2026-04-01T09:00:00.000Z", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Authenticated user not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when reminder creation is rejected by the backing router", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + create: vi.fn(), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + ctx.db.notification.create.mockRejectedValue( + new TRPCError({ + code: "BAD_REQUEST", + message: "Reminder payload is invalid", + }), + ); + + const result = await executeTool( + "create_reminder", + JSON.stringify({ + title: "Submit report", + remindAt: "2026-04-01T09:00:00.000Z", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Reminder input is invalid.", + }); + }); + + it("returns stable assistant errors for reminder validation edge cases", async () => { + const cases = [ + { + payload: { + title: " ", + remindAt: "2026-04-01T09:00:00.000Z", + }, + expected: "Reminder title is required.", + }, + { + payload: { + title: "x".repeat(201), + remindAt: "2026-04-01T09:00:00.000Z", + }, + expected: "Reminder title must be at most 200 characters.", + }, + { + payload: { + title: "Submit report", + body: "x".repeat(2001), + remindAt: "2026-04-01T09:00:00.000Z", + }, + expected: "Reminder body must be at most 2000 characters.", + }, + { + payload: { + title: "Submit report", + remindAt: "2026-04-01T09:00:00.000Z", + recurrence: "yearly", + }, + expected: "Invalid recurrence: yearly. Valid values: daily, weekly, monthly.", + }, + ] as const; + + for (const testCase of cases) { + const ctx = createToolContext({}, { userRole: SystemRole.ADMIN }); + const result = await executeTool( + "create_reminder", + JSON.stringify(testCase.payload), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ error: testCase.expected }); + } + }); + + it("returns a stable assistant error when updating a missing reminder", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "update_reminder", + JSON.stringify({ id: "rem_missing", title: "Updated" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Reminder not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when deleting a missing reminder", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "delete_reminder", + JSON.stringify({ id: "rem_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Reminder not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when assigning a non-task notification", async () => { + const ctx = createToolContext( + { + notification: { + findUnique: vi.fn().mockResolvedValue({ + id: "notification_1", + category: "NOTIFICATION", + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "assign_task", + JSON.stringify({ id: "notification_1", assigneeId: "user_2" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Only tasks and approvals can be assigned.", + }); + }); + + it("returns a stable assistant error when assigning a task to a missing assignee", async () => { + const ctx = createToolContext( + { + notification: { + findUnique: vi.fn().mockResolvedValue({ + id: "task_1", + category: "TASK", + }), + update: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_assigneeId_fkey" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "assign_task", + JSON.stringify({ id: "task_1", assigneeId: "user_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Assignee user not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when deleting a missing notification", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "delete_notification", + JSON.stringify({ id: "notification_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Notification not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when deleting a task created by another user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "notification_task", + category: "TASK", + senderId: "user_2", + }), + delete: vi.fn(), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "delete_notification", + JSON.stringify({ id: "notification_task" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Tasks created by other users cannot be deleted.", + }); + expect(ctx.db.notification.delete).not.toHaveBeenCalled(); + }); + + it("creates and sends a broadcast through the notification router", async () => { + const db = { + notificationBroadcast: { + create: vi.fn().mockResolvedValue({ + id: "broadcast_1", + title: "Office update", + targetType: "user", + createdAt: new Date("2026-03-30T09:00:00.000Z"), + }), + update: vi.fn().mockResolvedValue({ + id: "broadcast_1", + recipientCount: 1, + }), + }, + notification: { + create: vi.fn().mockResolvedValue({ id: "notification_2", userId: "user_2" }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const result = await executeTool( + "send_broadcast", + JSON.stringify({ + title: "Office update", + body: "New schedule", + targetType: "user", + targetValue: "user_2", + category: "TASK", + priority: "HIGH", + channel: "in_app", + dueDate: "2026-04-04T10:00:00.000Z", + }), + ctx, + ); + + expect(db.notificationBroadcast.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + senderId: "user_1", + title: "Office update", + body: "New schedule", + category: "TASK", + priority: "HIGH", + channel: "in_app", + targetType: "user", + targetValue: "user_2", + }), + }); + expect(db.notification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user_2", + type: "BROADCAST_TASK", + title: "Office update", + body: "New schedule", + category: "TASK", + priority: "HIGH", + channel: "in_app", + sourceId: "broadcast_1", + senderId: "user_1", + taskStatus: "OPEN", + dueDate: new Date("2026-04-04T10:00:00.000Z"), + }), + }); + expect(db.notificationBroadcast.update).toHaveBeenCalledWith({ + where: { id: "broadcast_1" }, + data: expect.objectContaining({ + sentAt: expect.any(Date), + recipientCount: 1, + }), + }); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + broadcastId: "broadcast_1", + recipientCount: 1, + message: 'Broadcast "Office update" created.', + }), + ); + expect(result.action).toEqual({ + type: "invalidate", + scope: ["notification"], + }); + }); + + it("creates a scheduled broadcast without immediate recipient fan-out", async () => { + const db = { + notificationBroadcast: { + create: vi.fn().mockResolvedValue({ + id: "broadcast_future", + title: "Planned update", + targetType: "all", + scheduledAt: new Date("2026-04-10T08:00:00.000Z"), + }), + update: vi.fn(), + }, + notification: { + create: vi.fn(), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const result = await executeTool( + "send_broadcast", + JSON.stringify({ + title: "Planned update", + targetType: "all", + scheduledAt: "2026-04-10T08:00:00.000Z", + }), + ctx, + ); + + expect(db.notificationBroadcast.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + senderId: "user_1", + title: "Planned update", + targetType: "all", + scheduledAt: new Date("2026-04-10T08:00:00.000Z"), + }), + }); + expect(db.notification.create).not.toHaveBeenCalled(); + expect(db.notificationBroadcast.update).not.toHaveBeenCalled(); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + broadcastId: "broadcast_future", + recipientCount: 0, + message: 'Broadcast "Planned update" created.', + }), + ); + }); + + it("returns a stable assistant error when broadcast scheduledAt is invalid", async () => { + const ctx = createToolContext({}, { userRole: SystemRole.MANAGER }); + + const result = await executeTool( + "send_broadcast", + JSON.stringify({ + title: "Office update", + targetType: "all", + scheduledAt: "not-a-datetime", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid scheduledAt: not-a-datetime", + }); + }); + + it("returns a stable assistant error when broadcast recipient user is missing", async () => { + const ctx = createToolContext( + { + notificationBroadcast: { + create: vi.fn().mockResolvedValue({ + id: "broadcast_missing_user", + title: "Office update", + targetType: "user", + }), + }, + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_userId_fkey" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "send_broadcast", + JSON.stringify({ + title: "Office update", + targetType: "user", + targetValue: "user_missing", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Broadcast recipient user not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when broadcast sender user is missing", async () => { + const ctx = createToolContext( + { + notificationBroadcast: { + create: vi.fn().mockResolvedValue({ + id: "broadcast_missing_sender", + title: "Office update", + targetType: "user", + }), + }, + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_senderId_fkey" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "send_broadcast", + JSON.stringify({ + title: "Office update", + targetType: "user", + targetValue: "user_2", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Sender user not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when a broadcast target resolves to no recipients", async () => { + const ctx = createToolContext( + { + notificationBroadcast: { + create: vi.fn().mockResolvedValue({ + id: "broadcast_empty", + title: "Office update", + targetType: "user", + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "send_broadcast", + JSON.stringify({ + title: "Office update", + targetType: "user", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "No recipients matched the broadcast target.", + }); + }); + it("reads broadcast details through the real notification router and rejects plain users", async () => { const db = { notificationBroadcast: { @@ -815,11 +2236,32 @@ describe("assistant import/export and dispo tools", () => { ); expect(JSON.parse(deniedResult.content)).toEqual( expect.objectContaining({ - error: expect.stringContaining("Manager or Admin role required"), + error: "You do not have permission to perform this action.", }), ); }); + it("returns a stable assistant error for a missing broadcast", async () => { + const ctx = createToolContext( + { + notificationBroadcast: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "get_broadcast_detail", + JSON.stringify({ id: "broadcast_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Broadcast not found with the given criteria.", + }); + }); + it("lists users only for admins through the real user router", async () => { const db = { user: { @@ -858,11 +2300,25 @@ describe("assistant import/export and dispo tools", () => { ]); expect(JSON.parse(deniedResult.content)).toEqual( expect.objectContaining({ - error: expect.stringContaining("Admin role required"), + error: "You do not have permission to perform this action.", }), ); }); + it("returns a stable assistant error when authenticated assistant context is missing", async () => { + const ctx = { + ...createToolContext({}, { userRole: SystemRole.ADMIN }), + session: null, + dbUser: null, + } as unknown as ToolContext; + + const result = await executeTool("list_users", "{}", ctx); + + expect(JSON.parse(result.content)).toEqual({ + error: "Authenticated assistant context is required for this tool.", + }); + }); + it("reads estimate details through the real estimate router and rejects plain users", async () => { vi.mocked(getEstimateById).mockResolvedValue({ id: "est_1", @@ -967,7 +2423,7 @@ describe("assistant import/export and dispo tools", () => { ); expect(JSON.parse(deniedResult.content)).toEqual( expect.objectContaining({ - error: expect.stringContaining("Controller access required"), + error: "You do not have permission to perform this action.", }), ); }); @@ -1100,6 +2556,515 @@ describe("assistant import/export and dispo tools", () => { ); }); + it("returns a stable error when estimate details are requested for a missing estimate", async () => { + vi.mocked(getEstimateById).mockResolvedValueOnce(null as never); + + const ctx = createToolContext({}, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }); + + const result = await executeTool( + "get_estimate_detail", + JSON.stringify({ estimateId: "missing_estimate" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Estimate not found with the given criteria.", + }); + }); + + it("returns a stable error when estimate versions are requested for a missing estimate", async () => { + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "list_estimate_versions", + JSON.stringify({ estimateId: "missing_estimate" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Estimate not found with the given criteria.", + }); + }); + + it("returns a stable error when an estimate version snapshot is missing", async () => { + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }); + + const result = await executeTool( + "get_estimate_version_snapshot", + JSON.stringify({ estimateId: "missing_estimate", versionId: "missing_version" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Estimate version not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when the selected project disappears during estimate creation", async () => { + const ctx = createToolContext( + { + project: { + findUnique: vi.fn() + .mockResolvedValueOnce({ id: "project_1" }) + .mockResolvedValueOnce({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + status: "DRAFT", + startDate: new Date("2026-01-01T00:00:00.000Z"), + endDate: new Date("2026-12-31T00:00:00.000Z"), + orderType: "TIME_MATERIAL", + allocationType: "INT", + winProbability: 100, + budgetCents: 100000, + responsiblePerson: "Peter Parker", + }), + }, + estimate: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Estimate_projectId_fkey" }, + }), + }, + }, + { + userRole: SystemRole.MANAGER, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_estimate", + JSON.stringify({ name: "Delivery Estimate", projectId: "project_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Project not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when a referenced role disappears during estimate creation", async () => { + const ctx = createToolContext( + { + estimate: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "EstimateDemandLine_roleId_fkey" }, + }), + }, + rateCardLine: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.MANAGER, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_estimate", + JSON.stringify({ + name: "Delivery Estimate", + demandLines: [ + { + roleId: "role_1", + lineType: "LABOR", + name: "Design", + hours: 40, + costRateCents: 0, + billRateCents: 0, + currency: "EUR", + costTotalCents: 0, + priceTotalCents: 0, + monthlySpread: {}, + staffingAttributes: {}, + metadata: {}, + }, + ], + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Role not found with the given criteria.", + }); + }); + + it("returns stable assistant errors for estimate mutation tools backed by estimate application use-cases", async () => { + const cases = [ + { + name: "clone_estimate missing source estimate", + toolName: "clone_estimate", + payload: { sourceEstimateId: "est_missing" }, + permission: PermissionKey.MANAGE_PROJECTS, + setup: () => vi.mocked(cloneEstimate).mockRejectedValueOnce(new Error("Source estimate not found")), + expected: "Estimate not found with the given criteria.", + }, + { + name: "clone_estimate without source versions", + toolName: "clone_estimate", + payload: { sourceEstimateId: "est_empty" }, + permission: PermissionKey.MANAGE_PROJECTS, + setup: () => vi.mocked(cloneEstimate).mockRejectedValueOnce(new Error("Source estimate has no versions")), + expected: "Source estimate has no versions and cannot be cloned.", + }, + { + name: "update_estimate_draft missing estimate", + toolName: "update_estimate_draft", + payload: { + id: "est_missing", + baseCurrency: "EUR", + assumptions: [], + scopeItems: [], + demandLines: [], + resourceSnapshots: [], + metrics: [], + }, + permission: PermissionKey.MANAGE_PROJECTS, + db: { + estimate: { + findUnique: vi.fn().mockResolvedValue({ projectId: null }), + }, + }, + setup: () => vi.mocked(updateEstimateDraft).mockRejectedValueOnce(new Error("Estimate not found")), + expected: "Estimate not found with the given criteria.", + }, + { + name: "update_estimate_draft without working version", + toolName: "update_estimate_draft", + payload: { + id: "est_locked", + baseCurrency: "EUR", + assumptions: [], + scopeItems: [], + demandLines: [], + resourceSnapshots: [], + metrics: [], + }, + permission: PermissionKey.MANAGE_PROJECTS, + db: { + estimate: { + findUnique: vi.fn().mockResolvedValue({ projectId: null }), + }, + }, + setup: () => vi.mocked(updateEstimateDraft).mockRejectedValueOnce(new Error("Estimate has no working version")), + expected: "Estimate has no working version.", + }, + { + name: "submit_estimate_version missing version", + toolName: "submit_estimate_version", + payload: { estimateId: "est_1", versionId: "ver_missing" }, + permission: PermissionKey.MANAGE_PROJECTS, + setup: () => vi.mocked(submitEstimateVersion).mockRejectedValueOnce(new Error("Estimate version not found")), + expected: "Estimate version not found with the given criteria.", + }, + { + name: "submit_estimate_version without working version", + toolName: "submit_estimate_version", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_PROJECTS, + setup: () => vi.mocked(submitEstimateVersion).mockRejectedValueOnce(new Error("Estimate has no working version")), + expected: "Estimate has no working version.", + }, + { + name: "submit_estimate_version wrong source status", + toolName: "submit_estimate_version", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_PROJECTS, + setup: () => vi.mocked(submitEstimateVersion).mockRejectedValueOnce(new Error("Only working versions can be submitted")), + expected: "Only working versions can be submitted.", + }, + { + name: "approve_estimate_version missing version", + toolName: "approve_estimate_version", + payload: { estimateId: "est_1", versionId: "ver_missing" }, + permission: PermissionKey.MANAGE_PROJECTS, + setup: () => vi.mocked(approveEstimateVersion).mockRejectedValueOnce(new Error("Estimate version not found")), + expected: "Estimate version not found with the given criteria.", + }, + { + name: "approve_estimate_version without submitted version", + toolName: "approve_estimate_version", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_PROJECTS, + setup: () => vi.mocked(approveEstimateVersion).mockRejectedValueOnce(new Error("Estimate has no submitted version")), + expected: "Estimate has no submitted version.", + }, + { + name: "approve_estimate_version wrong source status", + toolName: "approve_estimate_version", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_PROJECTS, + setup: () => vi.mocked(approveEstimateVersion).mockRejectedValueOnce(new Error("Only submitted versions can be approved")), + expected: "Only submitted versions can be approved.", + }, + { + name: "create_estimate_revision with existing working version", + toolName: "create_estimate_revision", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_PROJECTS, + setup: () => vi.mocked(createEstimateRevision).mockRejectedValueOnce(new Error("Estimate already has a working version")), + expected: "Estimate already has a working version.", + }, + { + name: "create_estimate_revision with unlocked source version", + toolName: "create_estimate_revision", + payload: { estimateId: "est_1", sourceVersionId: "ver_1" }, + permission: PermissionKey.MANAGE_PROJECTS, + setup: () => vi.mocked(createEstimateRevision).mockRejectedValueOnce(new Error("Source version must be locked before creating a revision")), + expected: "Source version must be locked before creating a revision.", + }, + { + name: "create_estimate_revision without locked source version", + toolName: "create_estimate_revision", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_PROJECTS, + setup: () => vi.mocked(createEstimateRevision).mockRejectedValueOnce(new Error("Estimate has no locked version to revise")), + expected: "Estimate has no locked version to revise.", + }, + { + name: "create_estimate_export missing version", + toolName: "create_estimate_export", + payload: { estimateId: "est_1", versionId: "ver_missing", format: "XLSX" }, + permission: PermissionKey.MANAGE_PROJECTS, + setup: () => vi.mocked(createEstimateExport).mockRejectedValueOnce(new Error("Estimate version not found")), + expected: "Estimate version not found with the given criteria.", + }, + { + name: "create_estimate_planning_handoff with missing linked project", + toolName: "create_estimate_planning_handoff", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_ALLOCATIONS, + setup: () => vi.mocked(createEstimatePlanningHandoff).mockRejectedValueOnce(new Error("Linked project not found")), + expected: "Project not found with the given criteria.", + }, + { + name: "create_estimate_planning_handoff without approved version", + toolName: "create_estimate_planning_handoff", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_ALLOCATIONS, + setup: () => vi.mocked(createEstimatePlanningHandoff).mockRejectedValueOnce(new Error("Estimate has no approved version")), + expected: "Estimate has no approved version.", + }, + { + name: "create_estimate_planning_handoff duplicate", + toolName: "create_estimate_planning_handoff", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_ALLOCATIONS, + setup: () => vi.mocked(createEstimatePlanningHandoff).mockRejectedValueOnce(new Error("Planning handoff already exists for this approved version")), + expected: "Planning handoff already exists for this approved version.", + }, + { + name: "create_estimate_planning_handoff without working days", + toolName: "create_estimate_planning_handoff", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_ALLOCATIONS, + setup: () => vi.mocked(createEstimatePlanningHandoff).mockRejectedValueOnce(new Error("Project window has no working days for demand line dl_1")), + expected: "The linked project window has no working days for at least one demand line.", + }, + { + name: "create_estimate_planning_handoff requires approved version", + toolName: "create_estimate_planning_handoff", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_ALLOCATIONS, + setup: () => vi.mocked(createEstimatePlanningHandoff).mockRejectedValueOnce(new Error("Only approved versions can be handed off to planning")), + expected: "Only approved versions can be handed off to planning.", + }, + { + name: "create_estimate_planning_handoff requires linked project", + toolName: "create_estimate_planning_handoff", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_ALLOCATIONS, + setup: () => vi.mocked(createEstimatePlanningHandoff).mockRejectedValueOnce(new Error("Estimate must be linked to a project before planning handoff")), + expected: "Estimate must be linked to a project before planning handoff.", + }, + { + name: "create_estimate_planning_handoff requires valid linked project dates", + toolName: "create_estimate_planning_handoff", + payload: { estimateId: "est_1" }, + permission: PermissionKey.MANAGE_ALLOCATIONS, + setup: () => vi.mocked(createEstimatePlanningHandoff).mockRejectedValueOnce(new Error("Linked project has an invalid date range")), + expected: "The linked project has an invalid date range for planning handoff.", + }, + ] as const; + + for (const testCase of cases) { + testCase.setup(); + const ctx = createToolContext(testCase.db ?? {}, { + userRole: SystemRole.MANAGER, + permissions: [testCase.permission], + }); + + const result = await executeTool( + testCase.toolName, + JSON.stringify(testCase.payload), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ error: testCase.expected }); + } + }); + + it("returns stable assistant errors for estimate phasing and commercial terms edge cases", async () => { + vi.mocked(getEstimateById).mockResolvedValueOnce(null as never); + + const missingEstimateCtx = createToolContext({}, { + userRole: SystemRole.MANAGER, + permissions: [PermissionKey.MANAGE_PROJECTS], + }); + const missingEstimateResult = await executeTool( + "generate_estimate_weekly_phasing", + JSON.stringify({ + estimateId: "est_missing", + startDate: "2026-04-01", + endDate: "2026-04-30", + }), + missingEstimateCtx, + ); + expect(JSON.parse(missingEstimateResult.content)).toEqual({ + error: "Estimate not found with the given criteria.", + }); + + vi.mocked(getEstimateById).mockResolvedValueOnce({ + id: "est_1", + name: "Estimate One", + versions: [], + } as Awaited>); + const noWorkingVersionResult = await executeTool( + "generate_estimate_weekly_phasing", + JSON.stringify({ + estimateId: "est_1", + startDate: "2026-04-01", + endDate: "2026-04-30", + }), + missingEstimateCtx, + ); + expect(JSON.parse(noWorkingVersionResult.content)).toEqual({ + error: "Estimate has no working version.", + }); + + vi.mocked(getEstimateById).mockResolvedValueOnce({ + id: "est_2", + name: "Estimate Two", + versions: [ + { + id: "ver_1", + status: "WORKING", + demandLines: [ + { + id: "line_missing", + hours: 40, + metadata: {}, + }, + ], + }, + ], + } as Awaited>); + const missingDemandLineCtx = createToolContext( + { + estimateDemandLine: { + update: vi.fn().mockRejectedValue({ + code: "P2025", + message: "Record not found", + meta: { modelName: "EstimateDemandLine" }, + }), + }, + }, + { + userRole: SystemRole.MANAGER, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + const missingDemandLineResult = await executeTool( + "generate_estimate_weekly_phasing", + JSON.stringify({ + estimateId: "est_2", + startDate: "2026-04-01", + endDate: "2026-04-30", + }), + missingDemandLineCtx, + ); + expect(JSON.parse(missingDemandLineResult.content)).toEqual({ + error: "Estimate demand line not found with the given criteria.", + }); + + const commercialTermsCases = [ + { + db: { + estimate: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + expected: "Estimate version not found with the given criteria.", + }, + { + db: { + estimate: { + findUnique: vi.fn().mockResolvedValue({ + id: "est_1", + versions: [{ id: "ver_1", status: "APPROVED" }], + }), + }, + }, + expected: "Commercial terms can only be edited on working versions.", + }, + ] as const; + + for (const testCase of commercialTermsCases) { + const ctx = createToolContext(testCase.db, { + userRole: SystemRole.MANAGER, + permissions: [PermissionKey.MANAGE_PROJECTS], + }); + const result = await executeTool( + "update_estimate_commercial_terms", + JSON.stringify({ + estimateId: "est_1", + terms: {}, + }), + ctx, + ); + expect(JSON.parse(result.content)).toEqual({ error: testCase.expected }); + } + + const invalidTermsCtx = createToolContext({}, { + userRole: SystemRole.MANAGER, + permissions: [PermissionKey.MANAGE_PROJECTS], + }); + const invalidTermsResult = await executeTool( + "update_estimate_commercial_terms", + JSON.stringify({ + estimateId: "est_1", + terms: { + contingencyPercent: -1, + }, + }), + invalidTermsCtx, + ); + expect(JSON.parse(invalidTermsResult.content)).toEqual({ + error: "Commercial terms input is invalid.", + }); + }); + it("reads countries through the real country router identifier path", async () => { const db = { country: { @@ -1694,7 +3659,7 @@ describe("assistant import/export and dispo tools", () => { findMany: vi.fn().mockResolvedValue([]), }, }; - const ctx = createToolContext(db, { userRole: SystemRole.MANAGER }); + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); const balanceResult = await executeTool( "get_vacation_balance", @@ -1830,7 +3795,7 @@ describe("assistant import/export and dispo tools", () => { findMany: vi.fn().mockResolvedValue([]), }, }; - const ctx = createToolContext(db, { userRole: SystemRole.MANAGER }); + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); const result = await executeTool( "get_entitlement_summary", @@ -1914,6 +3879,414 @@ describe("assistant import/export and dispo tools", () => { }); }); + it("returns a stable assistant error when vacation approval cannot resolve the request", async () => { + const ctx = createToolContext( + { + vacation: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" }), + ), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "approve_vacation", + JSON.stringify({ vacationId: "vac_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when vacation approval violates lifecycle preconditions", async () => { + const ctx = createToolContext( + { + vacation: { + findUnique: vi + .fn() + .mockResolvedValue({ + id: "vac_approved", + resource: { displayName: "Alice Example" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "approve_vacation", + JSON.stringify({ vacationId: "vac_approved" }), + { + ...ctx, + db: { + ...ctx.db, + vacation: { + ...((ctx.db as Record).vacation as Record), + findUnique: vi + .fn() + .mockResolvedValueOnce({ + id: "vac_approved", + resource: { displayName: "Alice Example" }, + }) + .mockResolvedValueOnce({ + id: "vac_approved", + resource: { displayName: "Alice Example" }, + }), + update: vi.fn().mockRejectedValue( + new TRPCError({ + code: "BAD_REQUEST", + message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved", + }), + ), + }, + } as ToolContext["db"], + }, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation cannot be approved in its current status.", + }); + }); + + it("returns a stable assistant error when vacation rejection cannot resolve the request", async () => { + const ctx = createToolContext( + { + vacation: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" }), + ), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "reject_vacation", + JSON.stringify({ vacationId: "vac_missing", reason: "Capacity freeze" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when vacation rejection violates lifecycle preconditions", async () => { + const ctx = createToolContext( + { + vacation: { + findUnique: vi + .fn() + .mockResolvedValue({ + id: "vac_approved", + resource: { displayName: "Alice Example" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "reject_vacation", + JSON.stringify({ vacationId: "vac_approved", reason: "Capacity freeze" }), + { + ...ctx, + db: { + ...ctx.db, + vacation: { + ...((ctx.db as Record).vacation as Record), + findUnique: vi + .fn() + .mockResolvedValueOnce({ + id: "vac_approved", + resource: { displayName: "Alice Example" }, + }) + .mockResolvedValueOnce({ + id: "vac_approved", + resource: { displayName: "Alice Example" }, + }), + update: vi.fn().mockRejectedValue( + new TRPCError({ + code: "BAD_REQUEST", + message: "Only PENDING vacations can be rejected", + }), + ), + }, + } as ToolContext["db"], + }, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation cannot be rejected in its current status.", + }); + }); + + it("returns a stable assistant error when vacation cancellation cannot resolve the request", async () => { + const ctx = createToolContext( + { + vacation: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" }), + ), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "cancel_vacation", + JSON.stringify({ vacationId: "vac_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when vacation cancellation violates lifecycle preconditions", async () => { + const ctx = createToolContext( + { + vacation: { + findUnique: vi + .fn() + .mockResolvedValue({ + id: "vac_cancelled", + requestedById: "user_1", + resource: { displayName: "Alice Example", userId: "user_1" }, + }), + }, + }, + { userRole: SystemRole.USER, permissions: [] }, + ); + + const result = await executeTool( + "cancel_vacation", + JSON.stringify({ vacationId: "vac_cancelled" }), + { + ...ctx, + db: { + ...ctx.db, + vacation: { + ...((ctx.db as Record).vacation as Record), + findUnique: vi + .fn() + .mockResolvedValueOnce({ + id: "vac_cancelled", + requestedById: "user_1", + resource: { displayName: "Alice Example", userId: "user_1" }, + }) + .mockResolvedValueOnce({ + id: "vac_cancelled", + status: VacationStatus.CANCELLED, + resource: { displayName: "Alice Example" }, + }), + update: vi.fn().mockRejectedValue( + new TRPCError({ + code: "BAD_REQUEST", + message: "Already cancelled", + }), + ), + }, + } as ToolContext["db"], + }, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Vacation cannot be cancelled in its current status.", + }); + }); + + it("returns a stable assistant error when vacation creation receives an invalid end date", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Alice Example", + chapter: "Delivery", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_vacation", + JSON.stringify({ + resourceId: "EMP-001", + type: "ANNUAL", + startDate: "2026-09-07", + endDate: "2026-09-99", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid endDate: 2026-09-99", + }); + }); + + it("returns a stable assistant error when vacation creation overlaps an existing request", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Alice Example", + chapter: "Delivery", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "MANAGER" }), + }, + vacation: { + findFirst: vi.fn().mockResolvedValue({ + id: "vac_existing", + resourceId: "res_1", + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "create_vacation", + JSON.stringify({ + resourceId: "EMP-001", + type: "ANNUAL", + startDate: "2026-09-07", + endDate: "2026-09-09", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Overlapping vacation already exists for this resource in the selected period", + }); + }); + + it("returns a stable assistant error when a user tries to create vacation for another resource", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Alice Example", + chapter: "Delivery", + isActive: true, + }) + .mockResolvedValueOnce({ + userId: "user_2", + }), + findFirst: vi.fn().mockResolvedValue({ + id: "res_1", + }), + }, + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }), + }, + }, + { userRole: SystemRole.USER }, + ); + + const result = await executeTool( + "create_vacation", + JSON.stringify({ + resourceId: "EMP-001", + type: "ANNUAL", + startDate: "2026-09-07", + endDate: "2026-09-09", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "You can only create vacation requests for your own resource.", + }); + }); + + it("returns a stable assistant error when the entitlement resource disappears before saving", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Alice Example", + chapter: "Delivery", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "VacationEntitlement_resourceId_fkey" }, + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "set_entitlement", + JSON.stringify({ resourceId: "EMP-001", year: 2027, entitledDays: 32 }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Resource not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when entitlement carryover is passed manually", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn(), + findFirst: vi.fn(), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "set_entitlement", + JSON.stringify({ + resourceId: "EMP-001", + year: 2027, + entitledDays: 32, + carryoverDays: 3, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Manual carryoverDays is not supported here. Carryover is computed automatically from prior-year balances.", + }); + expect(ctx.db.resource.findUnique).not.toHaveBeenCalled(); + expect(ctx.db.resource.findFirst).not.toHaveBeenCalled(); + }); + it("routes comment listing, creation, and resolution through the real comment router path", async () => { const commentFindUnique = vi.fn().mockResolvedValue({ id: "comment_1", @@ -2073,6 +4446,193 @@ describe("assistant import/export and dispo tools", () => { }); }); + it("returns a stable assistant error when creating a comment with an empty body", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn(), + }, + comment: { + create: vi.fn(), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "create_comment", + JSON.stringify({ + entityType: "estimate", + entityId: "est_1", + body: "", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Comment body is required.", + }); + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + expect(ctx.db.comment.create).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when creating a comment with a body that is too long", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn(), + }, + comment: { + create: vi.fn(), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "create_comment", + JSON.stringify({ + entityType: "estimate", + entityId: "est_1", + body: "x".repeat(10_001), + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Comment body must be at most 10000 characters.", + }); + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + expect(ctx.db.comment.create).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when the comment author disappears during creation", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + comment: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Comment_authorId_fkey" }, + }), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "create_comment", + JSON.stringify({ + entityType: "estimate", + entityId: "est_1", + body: "Please review this estimate.", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Comment author not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when a mentioned user disappears during comment creation", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + comment: { + create: vi.fn().mockResolvedValue({ + id: "comment_created", + body: "Hello @[Peter Parker](user_missing)", + resolved: false, + createdAt: new Date("2026-03-29T11:00:00.000Z"), + author: { id: "user_1", name: "Assistant User", email: "assistant@example.com", image: null }, + }), + }, + notification: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_userId_fkey" }, + }), + }, + auditLog: { + create: vi.fn(), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "create_comment", + JSON.stringify({ + entityType: "estimate", + entityId: "est_1", + body: "Hello @[Peter Parker](user_missing)", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Mentioned user not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when resolving a missing comment", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + comment: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "resolve_comment", + JSON.stringify({ commentId: "comment_missing", resolved: true }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Comment not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when a non-author resolves a comment", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + comment: { + findUnique: vi.fn().mockResolvedValue({ + id: "comment_1", + authorId: "user_2", + }), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "resolve_comment", + JSON.stringify({ commentId: "comment_1", resolved: true }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Only the comment author or an admin can resolve comments.", + }); + }); + it("routes taxonomy and rule read tools through their backing routers", async () => { const db = { managementLevelGroup: { @@ -2320,7 +4880,7 @@ describe("assistant import/export and dispo tools", () => { }), }, }; - const ctx = createToolContext(db, { userRole: SystemRole.MANAGER }); + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); const listResult = await executeTool( "list_holiday_calendars", @@ -2453,6 +5013,26 @@ describe("assistant import/export and dispo tools", () => { })); }); + it("returns a stable error when a holiday calendar cannot be found by identifier", async () => { + const db = { + holidayCalendar: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const result = await executeTool( + "get_holiday_calendar", + JSON.stringify({ identifier: "Missing Calendar" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Holiday calendar not found: Missing Calendar", + })); + }); + it("routes role, client, and org unit reads through their backing routers", async () => { const db = { role: { @@ -2681,6 +5261,28 @@ describe("assistant import/export and dispo tools", () => { ]); }); + it("returns a stable assistant error when a blueprint cannot be resolved for read access", async () => { + const ctx = createToolContext( + { + blueprint: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "get_blueprint", + JSON.stringify({ identifier: "Missing Blueprint" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Blueprint not found: Missing Blueprint", + }); + }); + it("routes project health reads through the dashboard router path", async () => { vi.mocked(getDashboardProjectHealth).mockResolvedValue([ { @@ -3321,6 +5923,32 @@ describe("assistant import/export and dispo tools", () => { }); }); + it("returns a generic assistant error for internal project lookup failures", async () => { + const ctx = createToolContext( + { + project: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "database connection pool exhausted", + }), + ), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool( + "get_project", + JSON.stringify({ identifier: "GDM" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "The tool could not complete due to an internal error.", + }); + }); + it("routes resource search and detail reads through the resource router path", async () => { const db = { resource: { @@ -4453,6 +7081,31 @@ describe("assistant import/export and dispo tools", () => { }); }); + it("returns a stable assistant error when rate resolution receives an invalid date", async () => { + const ctx = createToolContext( + { + rateCard: { + findMany: vi.fn(), + findUnique: vi.fn(), + }, + }, + { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }, + ); + + const result = await executeTool( + "resolve_rate", + JSON.stringify({ roleName: "Pipeline TD", date: "2026-99-01" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid date: 2026-99-01", + }); + }); + it("creates, updates, and deletes holiday calendars and entries through the real holiday router path", async () => { const db = { country: { @@ -4651,6 +7304,106 @@ describe("assistant import/export and dispo tools", () => { ); }); + it("returns a stable error when a holiday calendar scope already exists", async () => { + const ctx = createToolContext( + { + country: { + findUnique: vi.fn().mockResolvedValue({ id: "country_de", name: "Germany" }), + }, + holidayCalendar: { + findFirst: vi.fn().mockResolvedValue({ id: "cal_existing" }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_holiday_calendar", + JSON.stringify({ name: "Germany National", scopeType: "COUNTRY", countryId: "country_de" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "A holiday calendar for this scope already exists.", + })); + }); + + it("returns a stable error when a holiday entry calendar cannot be found", async () => { + const ctx = createToolContext( + { + holidayCalendar: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_holiday_calendar_entry", + JSON.stringify({ + holidayCalendarId: "cal_missing", + date: "2026-01-01", + name: "New Year", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Holiday calendar not found with the given criteria.", + })); + }); + + it("returns a stable error when a holiday entry date conflicts", async () => { + const ctx = createToolContext( + { + holidayCalendarEntry: { + findUnique: vi.fn().mockResolvedValue({ + id: "entry_1", + name: "New Year", + date: new Date("2026-01-01T00:00:00.000Z"), + holidayCalendarId: "cal_de", + }), + findFirst: vi.fn().mockResolvedValue({ id: "entry_conflict" }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "update_holiday_calendar_entry", + JSON.stringify({ + id: "entry_1", + data: { date: "2026-01-02" }, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "A holiday entry for this calendar and date already exists.", + })); + }); + + it("returns a stable error when deleting a missing holiday calendar entry", async () => { + const ctx = createToolContext( + { + holidayCalendarEntry: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "delete_holiday_calendar_entry", + JSON.stringify({ id: "entry_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Holiday calendar entry not found with the given criteria.", + })); + }); + it("returns the expected assistant payloads for role, client, and org unit mutations", async () => { const db = { role: { @@ -4717,6 +7470,7 @@ describe("assistant import/export and dispo tools", () => { isActive: true, parentId: null, tags: [], + _count: { projects: 0, children: 0 }, }; } @@ -4739,6 +7493,16 @@ describe("assistant import/export and dispo tools", () => { tags: ["auto", "priority"], isActive: true, }), + delete: vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme Mobility", + code: "ACM", + parentId: null, + sortOrder: 3, + tags: ["auto", "priority"], + isActive: true, + _count: { projects: 0, children: 0 }, + }), }, orgUnit: { findUnique: vi.fn(async ({ @@ -4814,6 +7578,11 @@ describe("assistant import/export and dispo tools", () => { JSON.stringify({ id: "client_1", name: "Acme Mobility", code: "ACM", sortOrder: 3, tags: ["auto", "priority"] }), ctx, ); + const deleteClientResult = await executeTool( + "delete_client", + JSON.stringify({ id: "client_1" }), + ctx, + ); const createOrgUnitResult = await executeTool( "create_org_unit", JSON.stringify({ name: "Delivery", shortName: "DEL", level: 5, sortOrder: 1 }), @@ -4845,6 +7614,10 @@ describe("assistant import/export and dispo tools", () => { success: true, message: "Updated client: Acme Mobility", })); + expect(JSON.parse(deleteClientResult.content)).toEqual(expect.objectContaining({ + success: true, + message: "Deleted client: Acme", + })); expect(JSON.parse(createOrgUnitResult.content)).toEqual(expect.objectContaining({ success: true, message: "Created org unit: Delivery", @@ -4855,6 +7628,984 @@ describe("assistant import/export and dispo tools", () => { })); }); + it("returns a stable error when creating a duplicate role", async () => { + const ctx = createToolContext( + { + role: { + findUnique: vi.fn().mockResolvedValue({ id: "role_existing", name: "CG Artist" }), + }, + }, + { + userRole: SystemRole.MANAGER, + permissions: [PermissionKey.MANAGE_ROLES], + }, + ); + + const result = await executeTool( + "create_role", + JSON.stringify({ name: "CG Artist" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "A role with this name already exists.", + })); + }); + + it("returns a stable error when updating a missing role", async () => { + const ctx = createToolContext( + { + role: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { + userRole: SystemRole.MANAGER, + permissions: [PermissionKey.MANAGE_ROLES], + }, + ); + + const result = await executeTool( + "update_role", + JSON.stringify({ id: "role_missing", name: "Senior CG Artist" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Role not found with the given criteria.", + })); + }); + + it("returns a stable error when deleting an assigned role", async () => { + const roleRecord = { + id: "role_1", + name: "Senior CG Artist", + description: "Pipeline lead", + color: "#222222", + isActive: true, + _count: { resourceRoles: 1 }, + resourceRoles: [], + }; + const ctx = createToolContext( + { + role: { + findUnique: vi.fn() + .mockResolvedValueOnce(roleRecord) + .mockResolvedValueOnce(roleRecord), + }, + demandRequirement: { + groupBy: vi.fn().mockResolvedValue([]), + }, + assignment: { + groupBy: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.MANAGER, + permissions: [PermissionKey.MANAGE_ROLES], + }, + ); + + const result = await executeTool( + "delete_role", + JSON.stringify({ id: "role_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Role cannot be deleted while it is still assigned. Deactivate it instead.", + })); + }); + + it("returns a stable error when creating a client with a duplicate code", async () => { + const ctx = createToolContext( + { + client: { + findUnique: vi.fn(async ({ + where, + }: { + where: { id?: string; code?: string }; + }) => { + if (where.code === "ACM") { + return { id: "client_existing", code: "ACM", name: "Existing Client" }; + } + return null; + }), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "create_client", + JSON.stringify({ name: "Acme Mobility", code: "ACM" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "A client with this code already exists.", + })); + }); + + it("returns a stable error when creating a client with a missing parent", async () => { + const ctx = createToolContext( + { + client: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "create_client", + JSON.stringify({ name: "Acme Mobility", parentId: "client_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Parent client not found with the given criteria.", + })); + }); + + it("returns a stable error when updating a missing client", async () => { + const ctx = createToolContext( + { + client: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "update_client", + JSON.stringify({ id: "client_missing", name: "Acme Mobility" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Client not found with the given criteria.", + })); + }); + + it("returns a stable error when deleting a missing client", async () => { + const ctx = createToolContext( + { + client: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "delete_client", + JSON.stringify({ id: "client_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Client not found with the given criteria.", + })); + }); + + it("returns a stable error when deleting a client that still has projects", async () => { + const clientRecord = { + id: "client_1", + name: "Acme Mobility", + code: "ACM", + parentId: null, + isActive: true, + sortOrder: 3, + tags: ["auto", "priority"], + _count: { projects: 2, children: 0 }, + }; + const ctx = createToolContext( + { + client: { + findUnique: vi.fn() + .mockResolvedValueOnce(clientRecord) + .mockResolvedValueOnce(clientRecord), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "delete_client", + JSON.stringify({ id: "client_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Client cannot be deleted while it still has projects. Deactivate it instead.", + })); + }); + + it("returns a stable error when deleting a client that still has child clients", async () => { + const clientRecord = { + id: "client_1", + name: "Acme Mobility", + code: "ACM", + parentId: null, + isActive: true, + sortOrder: 3, + tags: ["auto", "priority"], + _count: { projects: 0, children: 2 }, + }; + const ctx = createToolContext( + { + client: { + findUnique: vi.fn() + .mockResolvedValueOnce(clientRecord) + .mockResolvedValueOnce(clientRecord), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "delete_client", + JSON.stringify({ id: "client_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Client cannot be deleted while it still has child clients. Remove or reassign them first.", + })); + }); + + it("returns a stable error when creating an org unit with a missing parent", async () => { + const ctx = createToolContext( + { + orgUnit: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_org_unit", + JSON.stringify({ name: "Delivery", level: 6, parentId: "ou_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Parent org unit not found with the given criteria.", + })); + }); + + it("returns a stable error when creating an org unit with an invalid child level", async () => { + const ctx = createToolContext( + { + orgUnit: { + findUnique: vi.fn().mockResolvedValue({ + id: "ou_parent", + name: "Delivery", + shortName: "DEL", + level: 6, + parentId: null, + sortOrder: 1, + isActive: true, + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_org_unit", + JSON.stringify({ name: "Delivery Germany", level: 5, parentId: "ou_parent" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Org unit level must be greater than the parent org unit level.", + })); + }); + + it("returns a stable error when updating a missing org unit", async () => { + const ctx = createToolContext( + { + orgUnit: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "update_org_unit", + JSON.stringify({ id: "ou_missing", name: "Delivery Europe" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Org unit not found with the given criteria.", + })); + }); + + it("returns a stable error when creating a country with a duplicate code", async () => { + const ctx = createToolContext( + { + country: { + findUnique: vi.fn(async ({ + where, + }: { + where: { id?: string; code?: string }; + }) => { + if (where.code === "DE") { + return { id: "country_existing", code: "DE", name: "Germany" }; + } + return null; + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_country", + JSON.stringify({ code: "DE", name: "Germany" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "A country with this code already exists.", + })); + }); + + it("returns a stable error when updating a missing country", async () => { + const ctx = createToolContext( + { + country: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "update_country", + JSON.stringify({ id: "country_missing", data: { name: "Germany Updated" } }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Country not found with the given criteria.", + })); + }); + + it("returns a stable error when creating a metro city for a missing country", async () => { + const ctx = createToolContext( + { + country: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_metro_city", + JSON.stringify({ countryId: "country_missing", name: "Munich" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Country not found with the given criteria.", + })); + }); + + it("returns a stable error when updating a missing metro city", async () => { + const ctx = createToolContext( + { + metroCity: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "update_metro_city", + JSON.stringify({ id: "city_missing", data: { name: "Muenchen" } }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Metro city not found with the given criteria.", + })); + }); + + it("returns a stable error when deleting an assigned metro city", async () => { + const ctx = createToolContext( + { + metroCity: { + findUnique: vi.fn().mockResolvedValue({ + id: "city_muc", + name: "Munich", + countryId: "country_de", + _count: { resources: 2 }, + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "delete_metro_city", + JSON.stringify({ id: "city_muc" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Metro city cannot be deleted while it is still assigned to resources.", + })); + }); + + it("returns a stable error when creating a user with a duplicate email", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ + id: "user_existing", + email: "peter.parker@example.com", + name: "Peter Parker", + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_user", + JSON.stringify({ + email: "peter.parker@example.com", + name: "Peter Parker", + password: "secret123", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User with this email already exists.", + })); + }); + + it("returns a stable error when creating a user without a name", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn(), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_user", + JSON.stringify({ + email: "miles.morales@example.com", + name: "", + password: "secret123", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Name is required.", + })); + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns a stable error when creating a user with a password that is too short", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn(), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "create_user", + JSON.stringify({ + email: "miles.morales@example.com", + name: "Miles Morales", + password: "short", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Password must be at least 8 characters.", + })); + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns a stable error when resetting the password of a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "set_user_password", + JSON.stringify({ userId: "user_missing", password: "secret123" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when resetting a password that is too short", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn(), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "set_user_password", + JSON.stringify({ userId: "user_1", password: "short" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Password must be at least 8 characters.", + })); + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns a stable error when updating the role of a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "update_user_role", + JSON.stringify({ id: "user_missing", systemRole: SystemRole.MANAGER }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when renaming a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "update_user_name", + JSON.stringify({ id: "user_missing", name: "Miles Morales" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when renaming a user without a name", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn(), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "update_user_name", + JSON.stringify({ id: "user_1", name: "" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Name is required.", + })); + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns a stable error when renaming a user with a name that is too long", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn(), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "update_user_name", + JSON.stringify({ id: "user_1", name: "x".repeat(201) }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Name must be at most 200 characters.", + })); + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns a stable error when linking a missing user to a resource", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_missing", resourceId: "res_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when linking a user to a missing resource", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + resource: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_1", resourceId: "res_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Resource not found with the given criteria.", + })); + }); + + it("returns a stable error when linking a user to a resource that disappears during persistence", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + resource: { + findUnique: vi.fn().mockResolvedValue({ id: "res_1" }), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + update: vi.fn().mockRejectedValue({ + code: "P2025", + message: "No record was found for an update.", + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_1", resourceId: "res_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Resource not found with the given criteria.", + })); + }); + + it("returns a stable error when linking a user after the user disappears during persistence", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + resource: { + findUnique: vi.fn().mockResolvedValue({ id: "res_1" }), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + update: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Resource_userId_fkey" }, + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_1", resourceId: "res_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when linking a user after the user disappears and prisma reports an array target", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + resource: { + findUnique: vi.fn().mockResolvedValue({ id: "res_1" }), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + update: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { target: ["Resource_userId_fkey"] }, + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_1", resourceId: "res_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when setting permissions for a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "set_user_permissions", + JSON.stringify({ userId: "user_missing", overrides: { granted: ["manageProjects"] } }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when resetting permissions for a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "reset_user_permissions", + JSON.stringify({ userId: "user_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when reading effective permissions for a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "get_effective_user_permissions", + JSON.stringify({ userId: "user_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when disabling TOTP for a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "disable_user_totp", + JSON.stringify({ userId: "user_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when enabling TOTP without a generated secret", async () => { + const ctx = createToolContext( + { + user: { + findUniqueOrThrow: vi.fn().mockResolvedValue({ + id: "user_1", + name: "Assistant User", + email: "assistant@example.com", + totpSecret: null, + totpEnabled: false, + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "verify_and_enable_totp", + JSON.stringify({ token: "123456" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "No TOTP secret generated. Call generate_totp_secret first.", + }); + }); + + it("returns a stable error when enabling TOTP for a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUniqueOrThrow: vi.fn().mockRejectedValue( + new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }), + ), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "verify_and_enable_totp", + JSON.stringify({ token: "123456" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "User not found with the given criteria.", + }); + }); + + it("returns a stable error when enabling TOTP that is already enabled", async () => { + const ctx = createToolContext( + { + user: { + findUniqueOrThrow: vi.fn().mockResolvedValue({ + id: "user_1", + name: "Assistant User", + email: "assistant@example.com", + totpSecret: "MOCKSECRET", + totpEnabled: true, + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "verify_and_enable_totp", + JSON.stringify({ token: "123456" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "TOTP is already enabled.", + }); + }); + + it("returns a stable error when a provided TOTP token is invalid", async () => { + totpValidateMock.mockReturnValue(null); + + const update = vi.fn(); + const ctx = createToolContext( + { + user: { + findUniqueOrThrow: vi.fn().mockResolvedValue({ + id: "user_1", + name: "Assistant User", + email: "assistant@example.com", + totpSecret: "MOCKSECRET", + totpEnabled: false, + }), + update, + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "verify_and_enable_totp", + JSON.stringify({ token: "123456" }), + ctx, + ); + + expect(update).not.toHaveBeenCalled(); + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid TOTP token.", + }); + }); + it("routes resource creation through the real resource router path and writes an audit log", async () => { const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); const resourceFindFirst = vi.fn().mockResolvedValue(null); @@ -5054,6 +8805,197 @@ describe("assistant import/export and dispo tools", () => { expect(projectCreate).not.toHaveBeenCalled(); }); + it("returns a generic assistant error when blueprint resolution fails internally during project creation", async () => { + const projectCreate = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue({ + displayName: "Peter Parker", + }), + findMany: vi.fn().mockResolvedValue([]), + }, + project: { + findUnique: vi.fn().mockResolvedValue(null), + create: projectCreate, + }, + blueprint: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "blueprint resolver connection exhausted", + }), + ), + }, + client: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + auditLog: { + create: vi.fn(), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_project", + JSON.stringify({ + shortCode: "PROJ-500", + name: "Blueprint Failure Project", + orderType: "CHARGEABLE", + budgetCents: 150000, + startDate: "2026-05-01", + endDate: "2026-06-30", + responsiblePerson: "Peter Parker", + blueprintName: "Consulting Blueprint", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "The tool could not complete due to an internal error.", + }); + expect(projectCreate).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when creating a duplicate project short code", async () => { + const projectCreate = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue({ + displayName: "Peter Parker", + }), + findMany: vi.fn().mockResolvedValue([]), + }, + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_existing", + shortCode: "PROJ-1", + name: "Existing Project", + }), + create: projectCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_project", + JSON.stringify({ + shortCode: "PROJ-1", + name: "Duplicate Project", + orderType: "CHARGEABLE", + budgetCents: 150000, + startDate: "2026-05-01", + endDate: "2026-06-30", + responsiblePerson: "Peter Parker", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: 'A project with short code "PROJ-1" already exists.', + }); + expect(projectCreate).not.toHaveBeenCalled(); + }); + + it("requires a responsible person before creating a project", async () => { + const projectCreate = vi.fn(); + const resourceFindFirst = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: resourceFindFirst, + findMany: vi.fn().mockResolvedValue([]), + }, + project: { + findUnique: vi.fn().mockResolvedValue(null), + create: projectCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_project", + JSON.stringify({ + shortCode: "PROJ-NO-RP", + name: "Missing Responsible Person", + orderType: "CHARGEABLE", + budgetCents: 150000, + startDate: "2026-05-01", + endDate: "2026-06-30", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "responsiblePerson is required to create a project.", + }); + expect(resourceFindFirst).not.toHaveBeenCalled(); + expect(projectCreate).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when the project blueprint disappears before creation", async () => { + const projectCreate = vi.fn(); + const blueprintFindUnique = vi.fn() + .mockResolvedValueOnce({ id: "bp_1", name: "Consulting Blueprint", target: "PROJECT", isActive: true }) + .mockResolvedValueOnce(null); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue({ + displayName: "Peter Parker", + }), + findMany: vi.fn().mockResolvedValue([]), + }, + project: { + findUnique: vi.fn().mockResolvedValue(null), + create: projectCreate, + }, + blueprint: { + findUnique: blueprintFindUnique, + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_project", + JSON.stringify({ + shortCode: "PROJ-BP", + name: "Blueprint Race Project", + orderType: "CHARGEABLE", + budgetCents: 150000, + startDate: "2026-05-01", + endDate: "2026-06-30", + responsiblePerson: "Peter Parker", + blueprintName: "Consulting Blueprint", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Blueprint not found with the given criteria.", + }); + expect(projectCreate).not.toHaveBeenCalled(); + }); + it("routes resource updates through the real resource router path and writes an audit log", async () => { const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); const resourceFindUnique = vi.fn() @@ -5110,6 +9052,78 @@ describe("assistant import/export and dispo tools", () => { expect(auditCreate).toHaveBeenCalledTimes(1); }); + it("returns a stable assistant error when the resource disappears during update", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Carol Danvers", + }) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Carol Danvers", + dynamicFields: {}, + blueprintId: null, + }), + update: vi.fn().mockRejectedValue({ + code: "P2025", + message: "Record to update not found.", + }), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "update_resource", + JSON.stringify({ id: "res_1", displayName: "Captain Marvel" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Resource not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when the resource disappears during deactivation", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "res_1", + eid: "EMP-001", + displayName: "Carol Danvers", + }), + update: vi.fn().mockRejectedValue({ + code: "P2025", + message: "Record to update not found.", + }), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "deactivate_resource", + JSON.stringify({ identifier: "res_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Resource not found with the given criteria.", + }); + }); + it("returns a stable assistant error when the role cannot be resolved during resource creation", async () => { const resourceCreate = vi.fn(); const ctx = createToolContext( @@ -5153,6 +9167,184 @@ describe("assistant import/export and dispo tools", () => { expect(resourceCreate).not.toHaveBeenCalled(); }); + it("returns a generic assistant error when role resolution fails internally during resource creation", async () => { + const resourceCreate = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue(null), + create: resourceCreate, + }, + role: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "role resolver connection exhausted", + }), + ), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "create_resource", + JSON.stringify({ + eid: "EMP-500", + displayName: "Role Failure", + email: "role-failure@example.com", + lcrCents: 8000, + roleName: "Designer", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "The tool could not complete due to an internal error.", + }); + expect(resourceCreate).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when creating a duplicate resource", async () => { + const resourceCreate = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue({ + id: "resource_existing", + eid: "EMP-001", + email: "carol@example.com", + }), + create: resourceCreate, + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "create_resource", + JSON.stringify({ + eid: "EMP-001", + displayName: "Carol Danvers", + email: "carol@example.com", + lcrCents: 8000, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "A resource with this EID or email already exists.", + }); + expect(resourceCreate).not.toHaveBeenCalled(); + }); + + it("requires an email address before creating a resource", async () => { + const resourceCreate = vi.fn(); + const roleFindUnique = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue(null), + create: resourceCreate, + }, + role: { + findUnique: roleFindUnique, + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "create_resource", + JSON.stringify({ + eid: "EMP-NO-MAIL", + displayName: "Missing Email", + lcrCents: 8000, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "email is required to create a resource.", + }); + expect(roleFindUnique).not.toHaveBeenCalled(); + expect(resourceCreate).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when the selected resource role disappears before creation", async () => { + const resourceCreate = vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Resource_roleId_fkey" }, + }); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue(null), + create: resourceCreate, + }, + role: { + findUnique: vi.fn().mockResolvedValue({ id: "role_1", name: "Designer" }), + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "create_resource", + JSON.stringify({ + eid: "EMP-002", + displayName: "Carol Danvers", + email: "carol@example.com", + lcrCents: 8000, + roleName: "Designer", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Role not found with the given criteria.", + }); + }); + it("routes project updates through the real project router path and resolves short codes before updating", async () => { const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); const projectFindUnique = vi.fn() @@ -5229,6 +9421,55 @@ describe("assistant import/export and dispo tools", () => { expect(auditCreate).toHaveBeenCalledTimes(1); }); + it("returns a stable assistant error when the project disappears during update", async () => { + const ctx = createToolContext( + { + project: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + status: "DRAFT", + responsiblePerson: "Peter Parker", + }) + .mockResolvedValueOnce({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + status: "DRAFT", + responsiblePerson: "Peter Parker", + dynamicFields: {}, + blueprintId: null, + }), + findFirst: vi.fn().mockResolvedValue(null), + update: vi.fn().mockRejectedValue({ + code: "P2025", + message: "Record to update not found.", + }), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "update_project", + JSON.stringify({ id: "PROJ-1", name: "Project One Reloaded" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Project not found with the given criteria.", + }); + }); + it("enforces admin-only project deletion through the real project router path", async () => { const ctx = createToolContext( { @@ -5258,7 +9499,7 @@ describe("assistant import/export and dispo tools", () => { ); expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ - error: expect.stringContaining("Admin role required"), + error: "You do not have permission to perform this action.", })); }); @@ -5315,6 +9556,48 @@ describe("assistant import/export and dispo tools", () => { expect(auditCreate).toHaveBeenCalledTimes(1); }); + it("returns a stable error when a project disappears before deletion completes", async () => { + const ctx = createToolContext( + { + project: { + findUnique: vi.fn() + .mockResolvedValueOnce({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + }) + .mockResolvedValueOnce({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + }), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Project not found" }), + ), + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "delete_project", + JSON.stringify({ projectId: "project_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Project not found: project_1", + })); + }); + it("routes demand creation through the real allocation router path and writes an audit log", async () => { const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); const notificationCreate = vi.fn().mockResolvedValue({ id: "task_1" }); @@ -5631,6 +9914,263 @@ describe("assistant import/export and dispo tools", () => { }); }); + it("returns a stable assistant error when demand filling cannot resolve the demand", async () => { + const ctx = createToolContext( + { + demandRequirement: { + findUnique: vi.fn().mockResolvedValue(null), + }, + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "resource_1", + displayName: "Carol Danvers", + eid: "EMP-001", + chapter: "Delivery", + isActive: true, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "fill_demand", + JSON.stringify({ demandId: "demand_missing", resourceId: "resource_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Demand not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when demand filling violates lifecycle preconditions", async () => { + const ctx = createToolContext( + { + demandRequirement: { + findUnique: vi.fn().mockResolvedValue({ + id: "demand_1", + projectId: "project_1", + startDate: new Date("2026-05-01T00:00:00.000Z"), + endDate: new Date("2026-05-15T00:00:00.000Z"), + hoursPerDay: 6, + role: "Designer", + roleId: "role_1", + headcount: 1, + status: "COMPLETED", + metadata: {}, + }), + }, + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "resource_1", + displayName: "Carol Danvers", + eid: "EMP-001", + chapter: "Delivery", + isActive: true, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "fill_demand", + JSON.stringify({ demandId: "demand_1", resourceId: "resource_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Demand cannot be filled in its current status.", + }); + }); + + it("returns a generic assistant error when role resolution fails internally during demand creation", async () => { + const demandCreate = vi.fn(); + const ctx = createToolContext( + { + project: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + role: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "role resolver connection exhausted", + }), + ), + }, + demandRequirement: { + create: demandCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "create_demand", + JSON.stringify({ + projectId: "PROJ-1", + roleName: "Designer", + headcount: 2, + hoursPerDay: 6, + startDate: "2026-05-01", + endDate: "2026-05-15", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "The tool could not complete due to an internal error.", + }); + expect(demandCreate).not.toHaveBeenCalled(); + }); + + it("returns a stable assistant error when the demand project disappears before creation", async () => { + const tx = { + project: { + findUnique: vi.fn().mockResolvedValue(null), + }, + demandRequirement: { + create: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + }; + const ctx = createToolContext( + { + project: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + role: { + findUnique: vi.fn().mockResolvedValue({ id: "role_1", name: "Designer" }), + findFirst: vi.fn().mockResolvedValue(null), + }, + $transaction: vi.fn().mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)), + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "create_demand", + JSON.stringify({ + projectId: "PROJ-1", + roleName: "Designer", + headcount: 2, + hoursPerDay: 6, + startDate: "2026-05-01", + endDate: "2026-05-15", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Project not found with the given criteria.", + }); + }); + + it("returns a stable assistant error when the selected demand role disappears before creation", async () => { + const tx = { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + budgetCents: 0, + }), + }, + demandRequirement: { + create: vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "DemandRequirement_roleId_fkey" }, + }), + }, + auditLog: { + create: vi.fn(), + }, + }; + const ctx = createToolContext( + { + project: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + role: { + findUnique: vi.fn().mockResolvedValue({ id: "role_1", name: "Designer" }), + findFirst: vi.fn().mockResolvedValue(null), + }, + $transaction: vi.fn().mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)), + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "create_demand", + JSON.stringify({ + projectId: "PROJ-1", + roleName: "Designer", + headcount: 2, + hoursPerDay: 6, + startDate: "2026-05-01", + endDate: "2026-05-15", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Role not found with the given criteria.", + }); + }); + it("routes allocation creation through the real allocation router path", async () => { const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); const assignmentCreate = vi.fn().mockResolvedValue({ @@ -5979,6 +10519,155 @@ describe("assistant import/export and dispo tools", () => { }); }); + it("returns a stable assistant error when allocation cancellation cannot resolve an assignment", async () => { + const ctx = createToolContext( + { + assignment: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" }), + ), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "cancel_allocation", + JSON.stringify({ allocationId: "assignment_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Allocation not found with the given criteria.", + }); + }); + + it("returns stable assistant errors when allocation cancellation fails during the update step", async () => { + const assignment = { + id: "assignment_1", + resourceId: "resource_1", + projectId: "project_1", + demandRequirementId: null, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Designer", + roleId: null, + dailyCostCents: 42000, + status: "PROPOSED", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { id: "resource_1", displayName: "Carol Danvers", eid: "EMP-001", lcrCents: 7000 }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: null, + demandRequirement: null, + }; + const errors = [ + new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" }), + { + code: "P2025", + message: "Record not found", + meta: { modelName: "Assignment" }, + }, + ] as const; + + for (const error of errors) { + const tx = { + assignment: { + findUnique: vi.fn().mockResolvedValue(assignment), + update: vi.fn().mockRejectedValue(error), + }, + auditLog: { + create: vi.fn(), + }, + }; + const ctx = createToolContext( + { + assignment: { + findUnique: vi.fn().mockResolvedValue(assignment), + }, + webhook: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: vi.fn().mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)), + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "cancel_allocation", + JSON.stringify({ allocationId: "assignment_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Allocation not found with the given criteria.", + }); + } + }); + + it("returns a stable assistant error when allocation creation receives an invalid start date", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "resource_1", + displayName: "Carol Danvers", + eid: "EMP-001", + chapter: "Delivery", + isActive: true, + fte: 1, + lcrCents: 7000, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + project: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "create_allocation", + JSON.stringify({ + resourceId: "resource_1", + projectId: "project_1", + startDate: "2026-13-01", + endDate: "2026-06-05", + hoursPerDay: 6, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid startDate: 2026-13-01", + }); + }); + it("routes allocation status updates through the real allocation router path", async () => { const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); const assignmentUpdate = vi.fn().mockResolvedValue({ @@ -6102,6 +10791,101 @@ describe("assistant import/export and dispo tools", () => { expect(auditCreate).toHaveBeenCalledTimes(1); }); + it("returns a stable assistant error when allocation status update cannot resolve an assignment", async () => { + const ctx = createToolContext( + { + assignment: { + findUnique: vi.fn().mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" }), + ), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "update_allocation_status", + JSON.stringify({ allocationId: "assignment_missing", newStatus: "ACTIVE" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Allocation not found with the given criteria.", + }); + }); + + it("returns stable assistant errors when allocation status updates fail during the update step", async () => { + const assignment = { + id: "assignment_1", + resourceId: "resource_1", + projectId: "project_1", + demandRequirementId: null, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Designer", + roleId: null, + dailyCostCents: 42000, + status: "PROPOSED", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { id: "resource_1", displayName: "Carol Danvers", eid: "EMP-001", lcrCents: 7000 }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: null, + demandRequirement: null, + }; + const errors = [ + new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" }), + { + code: "P2025", + message: "Record not found", + meta: { modelName: "Assignment" }, + }, + ] as const; + + for (const error of errors) { + const tx = { + assignment: { + findUnique: vi.fn().mockResolvedValue(assignment), + update: vi.fn().mockRejectedValue(error), + }, + auditLog: { + create: vi.fn(), + }, + }; + const ctx = createToolContext( + { + assignment: { + findUnique: vi.fn().mockResolvedValue(assignment), + }, + webhook: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: vi.fn().mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)), + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "update_allocation_status", + JSON.stringify({ allocationId: "assignment_1", newStatus: "ACTIVE" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Allocation not found with the given criteria.", + }); + } + }); + it("routes project cover generation through the real project router path", async () => { const projectFindUnique = vi.fn() .mockResolvedValueOnce({ diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index 4a18e6e..067d687 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -36,7 +36,9 @@ import { } 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 { isAiConfigured } from "../ai-client.js"; import { timelineRouter } from "./timeline.js"; import { logger } from "../lib/logger.js"; import { createCallerFactory, type TRPCContext } from "../trpc.js"; @@ -102,7 +104,7 @@ export const MUTATION_TOOLS = new Set([ "set_entitlement", "create_demand", "fill_demand", "generate_project_cover", "remove_project_cover", "create_role", "update_role", "delete_role", - "create_client", "update_client", + "create_client", "update_client", "delete_client", "create_org_unit", "update_org_unit", "create_country", "update_country", "create_metro_city", "update_metro_city", "delete_metro_city", @@ -206,15 +208,22 @@ function fmtDate(d: Date | null | undefined): string | null { return d ? d.toISOString().slice(0, 10) : null; } +class AssistantVisibleError extends Error { + constructor(message: string) { + super(message); + this.name = "AssistantVisibleError"; + } +} + function assertPermission(ctx: ToolContext, perm: PermissionKey): void { if (!ctx.permissions.has(perm)) { - throw new Error(`Permission denied: you need the "${perm}" permission to perform this action.`); + 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 Error("Admin role required to perform this action."); + throw new AssistantVisibleError("Admin role required to perform this action."); } } @@ -316,19 +325,19 @@ function resolveHolidayPeriod(input: { }): { year: number | null; periodStart: Date; periodEnd: Date } { if (input.periodStart || input.periodEnd) { if (!input.periodStart || !input.periodEnd) { - throw new Error("periodStart and periodEnd must both be provided when using a custom holiday range."); + 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 Error(`Invalid periodStart: ${input.periodStart}`); + throw new AssistantVisibleError(`Invalid periodStart: ${input.periodStart}`); } if (Number.isNaN(periodEnd.getTime())) { - throw new Error(`Invalid periodEnd: ${input.periodEnd}`); + throw new AssistantVisibleError(`Invalid periodEnd: ${input.periodEnd}`); } if (periodEnd < periodStart) { - throw new Error("periodEnd must be on or after periodStart."); + throw new AssistantVisibleError("periodEnd must be on or after periodStart."); } return { year: null, periodStart, periodEnd }; @@ -351,29 +360,64 @@ const ASSISTANT_VACATION_REQUEST_TYPES = [ function parseAssistantVacationRequestType(input: string): VacationType { const normalized = input.trim().toUpperCase(); if (normalized === VacationType.PUBLIC_HOLIDAY) { - throw new Error("PUBLIC_HOLIDAY requests cannot be created manually. Manage public holidays through holiday calendars instead."); + 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 Error(`Invalid vacation type: ${input}. Valid types: ${ASSISTANT_VACATION_REQUEST_TYPES.join(", ")}.`); + 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 Error(`Invalid ${fieldName}: ${value}`); + 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, @@ -385,6 +429,1305 @@ function toAssistantNotFoundError( 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 { + const allocationNotFound = toAssistantAllocationNotFoundError(error); + if (allocationNotFound && (context === "updateInline" || context === "batchShift")) { + return allocationNotFound; + } + + 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 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 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; + } + + const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); + if (errorText.includes("project")) { + return { error: "Project not found with the given criteria." }; + } + 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." }; + } + + if (prismaError.code === "P2003") { + 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." }; + } + } + + if (prismaError.code === "P2025") { + switch (action) { + case "generateWeeklyPhasing": + return { error: "Estimate demand line not found with the given criteria." }; + case "updateCommercialTerms": + case "submitVersion": + case "approveVersion": + case "createRevision": + case "createExport": + return { error: "Estimate version not found with the given criteria." }; + default: + return { error: "Estimate not found with the given criteria." }; + } + } + + return null; +} + +function toAssistantUserMutationError( + error: unknown, + action: "create" | "update" | "password" = "update", +): AssistantToolErrorResult | null { + if (error instanceof TRPCError && error.code === "CONFLICT" && action === "create") { + return { error: "User with this email already exists." }; + } + + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return { error: "User not found with the given criteria." }; + } + + if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + const validationIssues = getTrpcValidationIssues(error); + for (const issue of validationIssues) { + const field = issue.path[0]; + if (field === "password" && issue.code === "too_small") { + return { error: "Password must be at least 8 characters." }; + } + + if (field === "name" && issue.code === "too_small") { + return { error: "Name is required." }; + } + + if (field === "name" && issue.code === "too_big") { + return { error: "Name must be at most 200 characters." }; + } + } + + if (error.message.includes("Password must be at least 8 characters")) { + return { error: "Password must be at least 8 characters." }; + } + + if (error.message.includes("Name is required")) { + return { error: "Name is required." }; + } + } + + const prismaError = getPrismaRequestErrorMetadata(error); + if (prismaError?.code === "P2025") { + return { error: "User not found with the given criteria." }; + } + + return null; +} + +function getTrpcValidationIssues(error: TRPCError): Array<{ + code?: string; + path: string[]; +}> { + if (error.cause instanceof ZodError) { + return error.cause.issues.map((issue) => ({ + code: issue.code, + path: issue.path.map((segment) => String(segment)), + })); + } + + try { + const parsed = JSON.parse(error.message); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter((issue): issue is { code?: unknown; path?: unknown } => issue !== null && typeof issue === "object") + .map((issue) => ( + typeof issue.code === "string" + ? { + code: issue.code, + path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [], + } + : { + path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [], + } + )); + } catch { + return []; + } +} + +function toAssistantUserResourceLinkError( + error: unknown, +): AssistantToolErrorResult | null { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + if (error.message.includes("Resource")) { + return { error: "Resource not found with the given criteria." }; + } + return { error: "User not found with the given criteria." }; + } + + const prismaError = getPrismaRequestErrorMetadata(error); + if (!prismaError) { + return null; + } + + const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); + const pointsToUser = + errorText.includes("userid") + || errorText.includes("user_id") + || errorText.includes(" user "); + const pointsToResource = + errorText.includes("resourceid") + || errorText.includes("resource_id") + || errorText.includes(" resource "); + + if (prismaError.code === "P2025") { + return { error: "Resource not found with the given criteria." }; + } + + if (prismaError.code === "P2003") { + if (pointsToUser) { + return { error: "User not found with the given criteria." }; + } + if (pointsToResource || errorText.includes("resource")) { + return { error: "Resource not found with the given criteria." }; + } + return { error: "User not found with the given criteria." }; + } + + return null; +} + +function toAssistantTotpEnableError( + error: unknown, +): AssistantToolErrorResult | null { + if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + if (error.message.includes("No TOTP secret generated")) { + return { error: "No TOTP secret generated. Call generate_totp_secret first." }; + } + if (error.message.includes("already enabled")) { + return { error: "TOTP is already enabled." }; + } + if (error.message.includes("Invalid TOTP token")) { + return { error: "Invalid TOTP token." }; + } + } + + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return { error: "User not found with the given criteria." }; + } + + const prismaError = getPrismaRequestErrorMetadata(error); + if (prismaError?.code === "P2025") { + return { error: "User not found with the given criteria." }; + } + + return null; +} + +function toAssistantWebhookNotFoundError( + error: unknown, +): AssistantToolErrorResult | null { + return toAssistantNotFoundError( + error, + "Webhook not found with the given criteria.", + ); +} + +function toAssistantWebhookMutationError( + error: unknown, + action: "create" | "update" = "update", +): AssistantToolErrorResult | null { + const notFound = toAssistantWebhookNotFoundError(error); + if (notFound) { + return notFound; + } + + if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + return { + error: action === "create" + ? "Webhook input is invalid." + : "Webhook update input is invalid.", + }; + } + + const prismaError = getPrismaRequestErrorMetadata(error); + if (prismaError?.code === "P2025") { + return { error: "Webhook not found with the given criteria." }; + } + + return null; +} + +function toAssistantAuditLogEntryNotFoundError( + error: unknown, +): AssistantToolErrorResult | null { + return toAssistantNotFoundError( + error, + "Audit log entry not found with the given criteria.", + ); +} + +function toAssistantTaskNotFoundError( + error: unknown, +): AssistantToolErrorResult | null { + return toAssistantNotFoundError( + error, + "Task not found with the given criteria.", + ); +} + +function toAssistantTaskActionError( + error: unknown, +): AssistantToolErrorResult | null { + const notFound = toAssistantTaskNotFoundError(error); + if (notFound) { + return notFound; + } + + if (error instanceof TRPCError && error.code === "PRECONDITION_FAILED") { + if (error.message.includes("no executable action")) { + return { error: "Task has no executable action." }; + } + if (error.message.includes("already completed")) { + return { error: "Task is already completed." }; + } + } + + if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + if (error.message.includes("Invalid taskAction format") || error.message.includes("Unknown action")) { + return { error: "Task action is invalid and cannot be executed." }; + } + if (error.message === "Vacation not found") { + return { error: "Vacation not found with the given criteria." }; + } + if (error.message.startsWith("Vacation is ") && error.message.includes(", not PENDING")) { + return { error: "Vacation is not pending and cannot be approved or rejected via this task action." }; + } + if (error.message === "Assignment not found") { + return { error: "Assignment not found with the given criteria." }; + } + if (error.message === "Assignment is already CONFIRMED") { + return { error: "Assignment is already confirmed." }; + } + return { error: error.message }; + } + + if (error instanceof TRPCError && error.code === "FORBIDDEN") { + return { error: "You do not have permission to execute this task action." }; + } + + return null; +} + +function toAssistantTaskAssignmentError( + error: unknown, +): AssistantToolErrorResult | null { + const notFound = toAssistantTaskNotFoundError(error); + if (notFound) { + return notFound; + } + + const prismaError = getPrismaRequestErrorMetadata(error); + if (prismaError && (prismaError.code === "P2003" || prismaError.code === "P2025")) { + const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); + if (errorText.includes("assignee")) { + return { error: "Assignee user not found with the given criteria." }; + } + } + + if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + return { error: "Only tasks and approvals can be assigned." }; + } + + return null; +} + +function toAssistantBroadcastNotFoundError( + error: unknown, +): AssistantToolErrorResult | null { + return toAssistantNotFoundError( + error, + "Broadcast not found with the given criteria.", + ); +} + +function toAssistantDispoImportBatchNotFoundError( + error: unknown, +): AssistantToolErrorResult | null { + return toAssistantNotFoundError( + error, + "Import batch not found with the given criteria.", + ); +} + +function toAssistantReminderNotFoundError( + error: unknown, +): AssistantToolErrorResult | null { + return toAssistantNotFoundError( + error, + "Reminder not found with the given criteria.", + ); +} + +function toAssistantNotificationNotFoundError( + error: unknown, +): AssistantToolErrorResult | null { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return { error: "Notification not found with the given criteria." }; + } + + const prismaError = getPrismaRequestErrorMetadata(error); + if (prismaError?.code === "P2025") { + return { error: "Notification not found with the given criteria." }; + } + + return null; +} + +function toAssistantNotificationReadError( + error: unknown, +): AssistantToolErrorResult | null { + return toAssistantNotificationNotFoundError(error); +} + +function toAssistantNotificationDeletionError( + error: unknown, +): AssistantToolErrorResult | null { + const notFound = toAssistantNotificationNotFoundError(error); + if (notFound) { + return notFound; + } + + if (error instanceof TRPCError && error.code === "FORBIDDEN") { + return { error: "Tasks created by other users cannot be deleted." }; + } + + return null; +} + +function toAssistantReminderCreationError( + error: unknown, +): AssistantToolErrorResult | null { + if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + return { error: "Reminder input is invalid." }; + } + + const prismaError = getPrismaRequestErrorMetadata(error); + if (!prismaError) { + return null; + } + + if (prismaError.code !== "P2003" && prismaError.code !== "P2025") { + return null; + } + + const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); + if (errorText.includes("user")) { + return { error: "Authenticated user not found with the given criteria." }; + } + + return null; +} + +function toAssistantCommentResolveError( + error: unknown, +): AssistantToolErrorResult | null { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return { error: "Comment not found with the given criteria." }; + } + + if (error instanceof TRPCError && error.code === "FORBIDDEN") { + return { error: "Only the comment author or an admin can resolve comments." }; + } + + return null; +} + +function toAssistantCommentCreationError( + error: unknown, +): AssistantToolErrorResult | null { + if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + if (error.message.includes("at least 1 character")) { + return { error: "Comment body is required." }; + } + + if (error.message.includes("at most 10000")) { + return { error: "Comment body must be at most 10000 characters." }; + } + } + + const prismaError = getPrismaRequestErrorMetadata(error); + if (!prismaError) { + return null; + } + + if (prismaError.code !== "P2003" && prismaError.code !== "P2025") { + return null; + } + + const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); + if (errorText.includes("author") || errorText.includes("sender")) { + return { error: "Comment author not found with the given criteria." }; + } + + if (errorText.includes("user") || errorText.includes("recipient")) { + return { error: "Mentioned user not found with the given criteria." }; + } + + return null; +} + +function getPrismaRequestErrorMetadata(error: unknown): { + code: string; + message: string; + metaText: string; +} | null { + const collectMetaStrings = (value: unknown): string[] => { + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + return value.flatMap((entry) => collectMetaStrings(entry)); + } + if (value && typeof value === "object") { + return Object.values(value).flatMap((entry) => collectMetaStrings(entry)); + } + return []; + }; + + const queue: unknown[] = [error]; + const visited = new Set(); + + while (queue.length > 0) { + const current = queue.shift(); + if (current === undefined || current === null || visited.has(current)) { + continue; + } + visited.add(current); + + if (current instanceof Prisma.PrismaClientKnownRequestError) { + const metaText = Object.values(current.meta ?? {}) + .flatMap((value) => collectMetaStrings(value)) + .join(" "); + return { + code: current.code, + message: current.message, + metaText, + }; + } + + if (typeof current !== "object") { + continue; + } + + const candidate = current as { + code?: unknown; + message?: unknown; + meta?: Record; + cause?: unknown; + }; + + if (typeof candidate.code === "string" && /^P\d{4}$/.test(candidate.code)) { + const metaText = Object.values(candidate.meta ?? {}) + .flatMap((value) => collectMetaStrings(value)) + .join(" "); + + return { + code: candidate.code, + message: typeof candidate.message === "string" ? candidate.message : "", + metaText, + }; + } + + if ("cause" in candidate) { + queue.push(candidate.cause); + } + } + + return null; +} + +function toAssistantNotificationCreationError( + error: unknown, + context: "notification" | "task" | "broadcast", +): AssistantToolErrorResult | null { + if ( + context === "broadcast" + && error instanceof TRPCError + && error.code === "BAD_REQUEST" + && error.message === "No recipients matched the broadcast target." + ) { + return { error: "No recipients matched the broadcast target." }; + } + + 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 === "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 }; + } + + if (error instanceof TRPCError) { + if (error.code === "INTERNAL_SERVER_ERROR") { + return { + error: "The tool could not complete due to an internal error.", + }; + } + + if (error.code === "UNAUTHORIZED") { + return { + error: "Authentication is required to use this tool.", + }; + } + + if (error.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, @@ -396,6 +1739,9 @@ async function resolveEntityOrAssistantError( if (mapped) { return mapped; } + if (error instanceof TRPCError && error.code === "INTERNAL_SERVER_ERROR") { + return normalizeAssistantExecutionError(error); + } throw error; } } @@ -424,7 +1770,7 @@ async function resolveResourceIdentifier( function createScopedCallerContext(ctx: ToolContext): TRPCContext { if (!ctx.session?.user || !ctx.dbUser) { - throw new Error("Authenticated assistant context is required for this tool."); + throw new AssistantVisibleError("Authenticated assistant context is required for this tool."); } return { @@ -1936,6 +3282,20 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, }, }, + { + type: "function", + function: { + name: "delete_client", + description: "Delete a client. Requires admin role. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Client ID" }, + }, + required: ["id"], + }, + }, + }, // ── ADMIN / CONFIG READ TOOLS ── { @@ -3646,8 +5006,15 @@ const executors = { let project; try { project = await caller.getByIdentifierDetail({ identifier: params.identifier }); - } catch { - return { error: `Project not found: ${params.identifier}` }; + } catch (error) { + const mapped = toAssistantNotFoundError( + error, + `Project not found: ${params.identifier}`, + ); + if (mapped) { + return mapped; + } + throw error; } return project; }, @@ -3771,14 +5138,23 @@ const executors = { assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); const caller = createTimelineCaller(createScopedCallerContext(ctx)); - const updated = await caller.updateAllocationInline({ - allocationId: params.allocationId, - ...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}), - ...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}), - ...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}), - ...(params.includeSaturday !== undefined ? { includeSaturday: params.includeSaturday } : {}), - ...(params.role !== undefined ? { role: params.role } : {}), - }); + let updated; + try { + updated = await caller.updateAllocationInline({ + allocationId: params.allocationId, + ...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}), + ...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}), + ...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}), + ...(params.includeSaturday !== undefined ? { includeSaturday: params.includeSaturday } : {}), + ...(params.role !== undefined ? { role: params.role } : {}), + }); + } catch (error) { + const mapped = toAssistantTimelineMutationError(error, "updateInline"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -3814,11 +5190,20 @@ const executors = { const newStartDate = parseIsoDate(params.newStartDate, "newStartDate"); const newEndDate = parseIsoDate(params.newEndDate, "newEndDate"); const caller = createTimelineCaller(createScopedCallerContext(ctx)); - const result = await caller.applyShift({ - projectId: project.id, - newStartDate, - newEndDate, - }); + let result; + try { + result = await caller.applyShift({ + projectId: project.id, + newStartDate, + newEndDate, + }); + } catch (error) { + const mapped = toAssistantTimelineMutationError(error, "applyShift"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -3859,16 +5244,25 @@ const executors = { } const caller = createTimelineCaller(createScopedCallerContext(ctx)); - const allocation = await caller.quickAssign({ - resourceId: resource.id, - projectId: project.id, - startDate: parseIsoDate(params.startDate, "startDate"), - endDate: parseIsoDate(params.endDate, "endDate"), - ...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}), - ...(params.role !== undefined ? { role: params.role } : {}), - ...(params.roleId !== undefined ? { roleId: params.roleId } : {}), - ...(params.status !== undefined ? { status: params.status } : {}), - }); + let allocation; + try { + allocation = await caller.quickAssign({ + resourceId: resource.id, + projectId: project.id, + startDate: parseIsoDate(params.startDate, "startDate"), + endDate: parseIsoDate(params.endDate, "endDate"), + ...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}), + ...(params.role !== undefined ? { role: params.role } : {}), + ...(params.roleId !== undefined ? { roleId: params.roleId } : {}), + ...(params.status !== undefined ? { status: params.status } : {}), + }); + } catch (error) { + const mapped = toAssistantTimelineMutationError(error, "quickAssign"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -3908,10 +5302,10 @@ const executors = { resolveProjectIdentifier(ctx, assignment.projectIdentifier), ]); if ("error" in resource) { - throw new Error(`assignments[${index}].resourceIdentifier: ${resource.error}`); + return toAssistantIndexedFieldError(index, "resourceIdentifier", resource.error); } if ("error" in project) { - throw new Error(`assignments[${index}].projectIdentifier: ${project.error}`); + return toAssistantIndexedFieldError(index, "projectIdentifier", project.error); } return { resourceId: resource.id, @@ -3924,10 +5318,29 @@ const executors = { }; })); + const resolutionError = resolvedAssignments.find( + (assignment): assignment is AssistantIndexedFieldErrorResult => isAssistantToolErrorResult(assignment), + ); + if (resolutionError) { + return resolutionError; + } + const validAssignments = resolvedAssignments.filter( + (assignment): assignment is BatchQuickAssignmentInput => !isAssistantToolErrorResult(assignment), + ); + const caller = createTimelineCaller(createScopedCallerContext(ctx)); - const result = await caller.batchQuickAssign({ - assignments: resolvedAssignments, - }); + let result; + try { + result = await caller.batchQuickAssign({ + assignments: validAssignments, + }); + } catch (error) { + const mapped = toAssistantTimelineMutationError(error, "quickAssign"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -3947,11 +5360,20 @@ const executors = { assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); const caller = createTimelineCaller(createScopedCallerContext(ctx)); - const result = await caller.batchShiftAllocations({ - allocationIds: params.allocationIds, - daysDelta: params.daysDelta, - ...(params.mode !== undefined ? { mode: params.mode } : {}), - }); + let result; + try { + result = await caller.batchShiftAllocations({ + allocationIds: params.allocationIds, + daysDelta: params.daysDelta, + ...(params.mode !== undefined ? { mode: params.mode } : {}), + }); + } catch (error) { + const mapped = toAssistantTimelineMutationError(error, "batchShift"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -4172,7 +5594,11 @@ const executors = { identifier: string; }, ctx: ToolContext) { const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - return caller.getCalendarByIdentifierDetail({ identifier: params.identifier.trim() }); + const identifier = params.identifier.trim(); + return resolveEntityOrAssistantError( + () => caller.getCalendarByIdentifierDetail({ identifier }), + `Holiday calendar not found: ${identifier}`, + ); }, async preview_resolved_holiday_calendar(params: { @@ -4197,7 +5623,16 @@ const executors = { }, ctx: ToolContext) { assertAdminRole(ctx); const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - const created = await caller.createCalendar(CreateHolidayCalendarSchema.parse(params)); + let created; + try { + created = await caller.createCalendar(CreateHolidayCalendarSchema.parse(params)); + } catch (error) { + const mapped = toAssistantHolidayCalendarMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -4224,7 +5659,16 @@ const executors = { id: params.id, data: UpdateHolidayCalendarSchema.parse(params.data), }; - const updated = await caller.updateCalendar(input); + let updated; + try { + updated = await caller.updateCalendar(input); + } catch (error) { + const mapped = toAssistantHolidayCalendarMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -4240,7 +5684,16 @@ const executors = { }, ctx: ToolContext) { assertAdminRole(ctx); const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - const deleted = await caller.deleteCalendar({ id: params.id }); + let deleted; + try { + deleted = await caller.deleteCalendar({ id: params.id }); + } catch (error) { + const mapped = toAssistantHolidayCalendarNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -4259,7 +5712,16 @@ const executors = { }, ctx: ToolContext) { assertAdminRole(ctx); const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - const created = await caller.createEntry(CreateHolidayCalendarEntrySchema.parse(params)); + let created; + try { + created = await caller.createEntry(CreateHolidayCalendarEntrySchema.parse(params)); + } catch (error) { + const mapped = toAssistantHolidayEntryMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -4285,7 +5747,16 @@ const executors = { id: params.id, data: UpdateHolidayCalendarEntrySchema.parse(params.data), }; - const updated = await caller.updateEntry(input); + let updated; + try { + updated = await caller.updateEntry(input); + } catch (error) { + const mapped = toAssistantHolidayEntryMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -4301,7 +5772,16 @@ const executors = { }, ctx: ToolContext) { assertAdminRole(ctx); const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - const deleted = await caller.deleteEntry({ id: params.id }); + let deleted; + try { + deleted = await caller.deleteEntry({ id: params.id }); + } catch (error) { + const mapped = toAssistantHolidayEntryNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -4549,8 +6029,8 @@ const executors = { const result = await caller.ensureAssignment({ resourceId: resource.id, projectId: project.id, - startDate: new Date(`${params.startDate}T00:00:00.000Z`), - endDate: new Date(`${params.endDate}T00:00:00.000Z`), + startDate: parseIsoDate(params.startDate, "startDate"), + endDate: parseIsoDate(params.endDate, "endDate"), hoursPerDay: params.hoursPerDay, ...(params.role ? { role: params.role } : {}), }); @@ -4596,25 +6076,39 @@ const executors = { projectId = project.id; } + const startDate = parseOptionalIsoDate(params.startDate, "startDate"); + const endDate = parseOptionalIsoDate(params.endDate, "endDate"); let assignment; try { assignment = await caller.resolveAssignment({ ...(params.allocationId ? { assignmentId: params.allocationId } : {}), ...(resourceId ? { resourceId } : {}), ...(projectId ? { projectId } : {}), - ...(params.startDate ? { startDate: new Date(`${params.startDate}T00:00:00.000Z`) } : {}), - ...(params.endDate ? { endDate: new Date(`${params.endDate}T00:00:00.000Z`) } : {}), + ...(startDate ? { startDate } : {}), + ...(endDate ? { endDate } : {}), selectionMode: "WINDOW", excludeCancelled: true, }); - } catch { - return { error: "Allocation not found with the given criteria." }; + } catch (error) { + const mapped = toAssistantAllocationNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; } - await caller.updateAssignment({ - id: assignment.id, - data: UpdateAssignmentSchema.parse({ status: AllocationStatus.CANCELLED }), - }); + try { + await caller.updateAssignment({ + id: assignment.id, + data: UpdateAssignmentSchema.parse({ status: AllocationStatus.CANCELLED }), + }); + } catch (error) { + const mapped = toAssistantAllocationNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -4655,26 +6149,39 @@ const executors = { projectId = project.id; } + const startDate = parseOptionalIsoDate(params.startDate, "startDate"); let assignment; try { assignment = await caller.resolveAssignment({ ...(params.allocationId ? { assignmentId: params.allocationId } : {}), ...(resourceId ? { resourceId } : {}), ...(projectId ? { projectId } : {}), - ...(params.startDate ? { startDate: new Date(`${params.startDate}T00:00:00.000Z`) } : {}), + ...(startDate ? { startDate } : {}), selectionMode: "EXACT_START", }); - } catch { - return { error: "Allocation not found with the given criteria." }; + } catch (error) { + const mapped = toAssistantAllocationNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; } const oldStatus = assignment.status; - await caller.updateAssignment({ - id: assignment.id, - data: UpdateAssignmentSchema.parse({ - status: params.newStatus as AllocationStatus, - }), - }); + try { + await caller.updateAssignment({ + id: assignment.id, + data: UpdateAssignmentSchema.parse({ + status: params.newStatus as AllocationStatus, + }), + }); + } catch (error) { + const mapped = toAssistantAllocationNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -4704,7 +6211,16 @@ const executors = { const updatedFields = Object.keys(data); if (updatedFields.length === 0) return { error: "No fields to update" }; - const updated = await caller.update({ id: resource.id, data }); + let updated; + try { + updated = await caller.update({ id: resource.id, data }); + } catch (error) { + const mapped = toAssistantResourceMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["resource"], @@ -4741,7 +6257,16 @@ const executors = { if (updatedFields.length === 0) return { error: "No fields to update" }; const caller = createProjectCaller(createScopedCallerContext(ctx)); - const updated = await caller.update({ id: project.id, data: parsedData }); + let updated; + try { + updated = await caller.update({ id: project.id, data: parsedData }); + } catch (error) { + const mapped = toAssistantProjectMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["project"], @@ -4814,7 +6339,16 @@ const executors = { }); const caller = createProjectCaller(createScopedCallerContext(ctx)); - const project = await caller.create(input); + let project; + try { + project = await caller.create(input); + } catch (error) { + const mapped = toAssistantProjectCreationError(error, input.shortCode); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -4900,7 +6434,16 @@ const executors = { const input = CreateResourceSchema.parse(data); const caller = createResourceCaller(createScopedCallerContext(ctx)); - const resource = await caller.create(input); + let resource; + try { + resource = await caller.create(input); + } catch (error) { + const mapped = toAssistantResourceCreationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -4917,8 +6460,15 @@ const executors = { if ("error" in resource) return resource; const caller = createResourceCaller(createScopedCallerContext(ctx)); - await caller.deactivate({ id: resource.id }); - if (!resource) return { error: `Resource not found: ${params.identifier}` }; + try { + await caller.deactivate({ id: resource.id }); + } catch (error) { + const mapped = toAssistantResourceMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["resource"], @@ -4939,15 +6489,24 @@ const executors = { const caller = createVacationCaller(createScopedCallerContext(ctx)); const type = parseAssistantVacationRequestType(params.type); - const vacation = await caller.create({ - resourceId: resource.id, - type, - startDate: new Date(params.startDate), - endDate: new Date(params.endDate), - ...(params.isHalfDay !== undefined ? { isHalfDay: params.isHalfDay } : {}), - ...(params.halfDayPart !== undefined ? { halfDayPart: params.halfDayPart as "MORNING" | "AFTERNOON" } : {}), - ...(params.note !== undefined ? { note: params.note } : {}), - }); + let vacation; + try { + vacation = await caller.create({ + resourceId: resource.id, + type, + startDate: parseIsoDate(params.startDate, "startDate"), + endDate: parseIsoDate(params.endDate, "endDate"), + ...(params.isHalfDay !== undefined ? { isHalfDay: params.isHalfDay } : {}), + ...(params.halfDayPart !== undefined ? { halfDayPart: params.halfDayPart as "MORNING" | "AFTERNOON" } : {}), + ...(params.note !== undefined ? { note: params.note } : {}), + }); + } catch (error) { + const mapped = toAssistantVacationCreationError(error); + if (mapped) { + return mapped; + } + throw error; + } const effectiveDays = "effectiveDays" in vacation && typeof vacation.effectiveDays === "number" ? vacation.effectiveDays : null; @@ -4964,8 +6523,27 @@ const executors = { async approve_vacation(params: { vacationId: string }, ctx: ToolContext) { const caller = createVacationCaller(createScopedCallerContext(ctx)); - const existing = await caller.getById({ id: params.vacationId }); - const approved = await caller.approve({ id: params.vacationId }); + let existing; + try { + existing = await caller.getById({ id: params.vacationId }); + } catch (error) { + const mapped = toAssistantVacationMutationError(error, "approve"); + if (mapped) { + return mapped; + } + throw error; + } + + let approved; + try { + approved = await caller.approve({ id: params.vacationId }); + } catch (error) { + const mapped = toAssistantVacationMutationError(error, "approve"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["vacation"], @@ -4978,11 +6556,30 @@ const executors = { async reject_vacation(params: { vacationId: string; reason?: string }, ctx: ToolContext) { const caller = createVacationCaller(createScopedCallerContext(ctx)); - const existing = await caller.getById({ id: params.vacationId }); - const rejected = await caller.reject({ - id: params.vacationId, - ...(params.reason !== undefined ? { rejectionReason: params.reason } : {}), - }); + let existing; + try { + existing = await caller.getById({ id: params.vacationId }); + } catch (error) { + const mapped = toAssistantVacationMutationError(error, "reject"); + if (mapped) { + return mapped; + } + throw error; + } + + let rejected; + try { + rejected = await caller.reject({ + id: params.vacationId, + ...(params.reason !== undefined ? { rejectionReason: params.reason } : {}), + }); + } catch (error) { + const mapped = toAssistantVacationMutationError(error, "reject"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["vacation"], @@ -4994,8 +6591,27 @@ const executors = { async cancel_vacation(params: { vacationId: string }, ctx: ToolContext) { const caller = createVacationCaller(createScopedCallerContext(ctx)); - const existing = await caller.getById({ id: params.vacationId }); - const cancelled = await caller.cancel({ id: params.vacationId }); + let existing; + try { + existing = await caller.getById({ id: params.vacationId }); + } catch (error) { + const mapped = toAssistantVacationMutationError(error, "cancel"); + if (mapped) { + return mapped; + } + throw error; + } + + let cancelled; + try { + cancelled = await caller.cancel({ id: params.vacationId }); + } catch (error) { + const mapped = toAssistantVacationMutationError(error, "cancel"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["vacation"], @@ -5032,8 +6648,8 @@ const executors = { const caller = createVacationCaller(createScopedCallerContext(ctx)); return caller.getTeamOverlapDetail({ resourceId: resource.id, - startDate: new Date(params.startDate), - endDate: new Date(params.endDate), + startDate: parseIsoDate(params.startDate, "startDate"), + endDate: parseIsoDate(params.endDate, "endDate"), }); }, @@ -5061,11 +6677,20 @@ const executors = { if ("error" in resource) return resource; const caller = createEntitlementCaller(createScopedCallerContext(ctx)); - const entitlement = await caller.set({ - resourceId: resource.id, - year: params.year, - entitledDays: params.entitledDays, - }); + let entitlement; + try { + entitlement = await caller.set({ + resourceId: resource.id, + year: params.year, + entitledDays: params.entitledDays, + }); + } catch (error) { + const mapped = toAssistantEntitlementMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -5128,15 +6753,24 @@ const executors = { } const caller = createAllocationCaller(createScopedCallerContext(ctx)); - const demand = await caller.createDemand({ - projectId: project.id, - roleId: role.id, - role: role.name, - headcount: params.headcount ?? 1, - hoursPerDay: params.hoursPerDay, - startDate: parseIsoDate(params.startDate, "startDate"), - endDate: parseIsoDate(params.endDate, "endDate"), - }); + let demand; + try { + demand = await caller.createDemand({ + projectId: project.id, + roleId: role.id, + role: role.name, + headcount: params.headcount ?? 1, + hoursPerDay: params.hoursPerDay, + startDate: parseIsoDate(params.startDate, "startDate"), + endDate: parseIsoDate(params.endDate, "endDate"), + }); + } catch (error) { + const mapped = toAssistantDemandCreationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -5153,10 +6787,19 @@ const executors = { const resource = await resolveResourceIdentifier(ctx, params.resourceId); if ("error" in resource) return resource; - const result = await allocationCaller.assignResourceToDemand({ - demandRequirementId: params.demandId, - resourceId: resource.id, - }); + let result; + try { + result = await allocationCaller.assignResourceToDemand({ + demandRequirementId: params.demandId, + resourceId: resource.id, + }); + } catch (error) { + const mapped = toAssistantDemandFillError(error); + if (mapped) { + return mapped; + } + throw error; + } const roleName = result.demandRequirement.roleEntity?.name ?? result.demandRequirement.role ?? null; return { @@ -5179,8 +6822,8 @@ const executors = { const caller = createAllocationCaller(createScopedCallerContext(ctx)); return caller.getResourceAvailabilitySummary({ resourceId: resource.id, - startDate: new Date(`${params.startDate}T00:00:00.000Z`), - endDate: new Date(`${params.endDate}T00:00:00.000Z`), + startDate: parseIsoDate(params.startDate, "startDate"), + endDate: parseIsoDate(params.endDate, "endDate"), }); }, @@ -5194,11 +6837,13 @@ const executors = { } const caller = createStaffingCaller(createScopedCallerContext(ctx)); + const startDate = parseOptionalIsoDate(params.startDate, "startDate"); + const endDate = parseOptionalIsoDate(params.endDate, "endDate"); return caller.getProjectStaffingSuggestions({ projectId: project.id, ...(params.roleName ? { roleName: params.roleName } : {}), - ...(params.startDate ? { startDate: new Date(params.startDate) } : {}), - ...(params.endDate ? { endDate: new Date(params.endDate) } : {}), + ...(startDate ? { startDate } : {}), + ...(endDate ? { endDate } : {}), ...(params.limit ? { limit: params.limit } : {}), }); }, @@ -5210,8 +6855,8 @@ const executors = { }, ctx: ToolContext) { const caller = createStaffingCaller(createScopedCallerContext(ctx)); return caller.searchCapacity({ - startDate: new Date(params.startDate), - endDate: new Date(params.endDate), + startDate: parseIsoDate(params.startDate, "startDate"), + endDate: parseIsoDate(params.endDate, "endDate"), minHoursPerDay: params.minHoursPerDay ?? 4, ...(params.roleName ? { roleName: params.roleName } : {}), ...(params.chapter ? { chapter: params.chapter } : {}), @@ -5233,7 +6878,13 @@ const executors = { async get_blueprint(params: { identifier: string }, ctx: ToolContext) { const caller = createBlueprintCaller(createScopedCallerContext(ctx)); - const bp = await caller.getByIdentifier({ identifier: params.identifier }); + const bp = await resolveEntityOrAssistantError( + () => caller.getByIdentifier({ identifier: params.identifier }), + `Blueprint not found: ${params.identifier}`, + ); + if ("error" in bp) { + return bp; + } return { id: bp.id, name: bp.name, @@ -5261,6 +6912,7 @@ const executors = { async resolve_rate(params: { resourceId?: string; roleName?: string; date?: string }, ctx: ToolContext) { const caller = createRateCardCaller(createScopedCallerContext(ctx)); + const date = parseOptionalIsoDate(params.date, "date"); if (params.resourceId) { const resource = await resolveResourceIdentifier(ctx, params.resourceId); if ("error" in resource) { @@ -5268,13 +6920,13 @@ const executors = { } return caller.resolveBestRate({ resourceId: resource.id, - ...(params.date ? { date: new Date(params.date) } : {}), + ...(date ? { date } : {}), }); } return caller.resolveBestRate({ ...(params.roleName ? { roleName: params.roleName } : {}), - ...(params.date ? { date: new Date(params.date) } : {}), + ...(date ? { date } : {}), }); }, @@ -5283,12 +6935,28 @@ const executors = { async get_estimate_detail(params: { estimateId: string }, ctx: ToolContext) { assertPermission(ctx, PermissionKey.VIEW_COSTS); const caller = createEstimateCaller(createScopedCallerContext(ctx)); - return caller.getById({ id: params.estimateId }); + try { + return await caller.getById({ id: params.estimateId }); + } catch (error) { + const mapped = toAssistantEstimateNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } }, async list_estimate_versions(params: { estimateId: string }, ctx: ToolContext) { const caller = createEstimateCaller(createScopedCallerContext(ctx)); - return caller.listVersions({ estimateId: params.estimateId }); + try { + return await caller.listVersions({ estimateId: params.estimateId }); + } catch (error) { + const mapped = toAssistantEstimateNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } }, async get_estimate_version_snapshot(params: { @@ -5297,10 +6965,18 @@ const executors = { }, ctx: ToolContext) { assertPermission(ctx, PermissionKey.VIEW_COSTS); const caller = createEstimateCaller(createScopedCallerContext(ctx)); - return caller.getVersionSnapshot({ - estimateId: params.estimateId, - ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), - }); + try { + return await caller.getVersionSnapshot({ + estimateId: params.estimateId, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), + }); + } catch (error) { + const mapped = toAssistantEstimateNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } }, async create_estimate(params: { @@ -5328,20 +7004,29 @@ const executors = { projectId = project.id; } - const estimate = await caller.create({ - name: params.name, - ...(projectId ? { projectId } : {}), - ...(params.opportunityId !== undefined ? { opportunityId: params.opportunityId } : {}), - ...(params.baseCurrency !== undefined ? { baseCurrency: params.baseCurrency } : {}), - ...(params.status !== undefined ? { status: params.status } : {}), - ...(params.versionLabel !== undefined ? { versionLabel: params.versionLabel } : {}), - ...(params.versionNotes !== undefined ? { versionNotes: params.versionNotes } : {}), - ...(params.assumptions !== undefined ? { assumptions: params.assumptions } : {}), - ...(params.scopeItems !== undefined ? { scopeItems: params.scopeItems } : {}), - ...(params.demandLines !== undefined ? { demandLines: params.demandLines } : {}), - ...(params.resourceSnapshots !== undefined ? { resourceSnapshots: params.resourceSnapshots } : {}), - ...(params.metrics !== undefined ? { metrics: params.metrics } : {}), - }); + let estimate; + try { + estimate = await caller.create({ + name: params.name, + ...(projectId ? { projectId } : {}), + ...(params.opportunityId !== undefined ? { opportunityId: params.opportunityId } : {}), + ...(params.baseCurrency !== undefined ? { baseCurrency: params.baseCurrency } : {}), + ...(params.status !== undefined ? { status: params.status } : {}), + ...(params.versionLabel !== undefined ? { versionLabel: params.versionLabel } : {}), + ...(params.versionNotes !== undefined ? { versionNotes: params.versionNotes } : {}), + ...(params.assumptions !== undefined ? { assumptions: params.assumptions } : {}), + ...(params.scopeItems !== undefined ? { scopeItems: params.scopeItems } : {}), + ...(params.demandLines !== undefined ? { demandLines: params.demandLines } : {}), + ...(params.resourceSnapshots !== undefined ? { resourceSnapshots: params.resourceSnapshots } : {}), + ...(params.metrics !== undefined ? { metrics: params.metrics } : {}), + }); + } catch (error) { + const mapped = toAssistantEstimateCreationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -5369,11 +7054,20 @@ const executors = { projectId = project.id; } - const estimate = await caller.clone({ - sourceEstimateId: params.sourceEstimateId, - ...(params.name !== undefined ? { name: params.name } : {}), - ...(projectId ? { projectId } : {}), - }); + let estimate; + try { + estimate = await caller.clone({ + sourceEstimateId: params.sourceEstimateId, + ...(params.name !== undefined ? { name: params.name } : {}), + ...(projectId ? { projectId } : {}), + }); + } catch (error) { + const mapped = toAssistantEstimateMutationError(error, "clone"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -5411,21 +7105,30 @@ const executors = { projectId = project.id; } - const estimate = await caller.updateDraft({ - id: params.id, - ...(projectId ? { projectId } : {}), - ...(params.name !== undefined ? { name: params.name } : {}), - ...(params.opportunityId !== undefined ? { opportunityId: params.opportunityId } : {}), - ...(params.baseCurrency !== undefined ? { baseCurrency: params.baseCurrency } : {}), - ...(params.status !== undefined ? { status: params.status } : {}), - ...(params.versionLabel !== undefined ? { versionLabel: params.versionLabel } : {}), - ...(params.versionNotes !== undefined ? { versionNotes: params.versionNotes } : {}), - ...(params.assumptions !== undefined ? { assumptions: params.assumptions } : {}), - ...(params.scopeItems !== undefined ? { scopeItems: params.scopeItems } : {}), - ...(params.demandLines !== undefined ? { demandLines: params.demandLines } : {}), - ...(params.resourceSnapshots !== undefined ? { resourceSnapshots: params.resourceSnapshots } : {}), - ...(params.metrics !== undefined ? { metrics: params.metrics } : {}), - }); + let estimate; + try { + estimate = await caller.updateDraft({ + id: params.id, + ...(projectId ? { projectId } : {}), + ...(params.name !== undefined ? { name: params.name } : {}), + ...(params.opportunityId !== undefined ? { opportunityId: params.opportunityId } : {}), + ...(params.baseCurrency !== undefined ? { baseCurrency: params.baseCurrency } : {}), + ...(params.status !== undefined ? { status: params.status } : {}), + ...(params.versionLabel !== undefined ? { versionLabel: params.versionLabel } : {}), + ...(params.versionNotes !== undefined ? { versionNotes: params.versionNotes } : {}), + ...(params.assumptions !== undefined ? { assumptions: params.assumptions } : {}), + ...(params.scopeItems !== undefined ? { scopeItems: params.scopeItems } : {}), + ...(params.demandLines !== undefined ? { demandLines: params.demandLines } : {}), + ...(params.resourceSnapshots !== undefined ? { resourceSnapshots: params.resourceSnapshots } : {}), + ...(params.metrics !== undefined ? { metrics: params.metrics } : {}), + }); + } catch (error) { + const mapped = toAssistantEstimateMutationError(error, "updateDraft"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -5442,10 +7145,19 @@ const executors = { versionId?: string; }, ctx: ToolContext) { const caller = createEstimateCaller(createScopedCallerContext(ctx)); - const estimate = await caller.submitVersion({ - estimateId: params.estimateId, - ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), - }); + let estimate; + try { + estimate = await caller.submitVersion({ + estimateId: params.estimateId, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), + }); + } catch (error) { + const mapped = toAssistantEstimateMutationError(error, "submitVersion"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["estimate"], @@ -5461,10 +7173,19 @@ const executors = { versionId?: string; }, ctx: ToolContext) { const caller = createEstimateCaller(createScopedCallerContext(ctx)); - const estimate = await caller.approveVersion({ - estimateId: params.estimateId, - ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), - }); + let estimate; + try { + estimate = await caller.approveVersion({ + estimateId: params.estimateId, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), + }); + } catch (error) { + const mapped = toAssistantEstimateMutationError(error, "approveVersion"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["estimate"], @@ -5482,12 +7203,21 @@ const executors = { notes?: string; }, ctx: ToolContext) { const caller = createEstimateCaller(createScopedCallerContext(ctx)); - const estimate = await caller.createRevision({ - estimateId: params.estimateId, - ...(params.sourceVersionId !== undefined ? { sourceVersionId: params.sourceVersionId } : {}), - ...(params.label !== undefined ? { label: params.label } : {}), - ...(params.notes !== undefined ? { notes: params.notes } : {}), - }); + let estimate; + try { + estimate = await caller.createRevision({ + estimateId: params.estimateId, + ...(params.sourceVersionId !== undefined ? { sourceVersionId: params.sourceVersionId } : {}), + ...(params.label !== undefined ? { label: params.label } : {}), + ...(params.notes !== undefined ? { notes: params.notes } : {}), + }); + } catch (error) { + const mapped = toAssistantEstimateMutationError(error, "createRevision"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["estimate"], @@ -5504,11 +7234,20 @@ const executors = { format: EstimateExportFormat; }, ctx: ToolContext) { const caller = createEstimateCaller(createScopedCallerContext(ctx)); - const estimate = await caller.createExport({ - estimateId: params.estimateId, - format: params.format, - ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), - }); + let estimate; + try { + estimate = await caller.createExport({ + estimateId: params.estimateId, + format: params.format, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), + }); + } catch (error) { + const mapped = toAssistantEstimateMutationError(error, "createExport"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["estimate"], @@ -5524,10 +7263,19 @@ const executors = { versionId?: string; }, ctx: ToolContext) { const caller = createEstimateCaller(createScopedCallerContext(ctx)); - const result = await caller.createPlanningHandoff({ - estimateId: params.estimateId, - ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), - }); + let result; + try { + result = await caller.createPlanningHandoff({ + estimateId: params.estimateId, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), + }); + } catch (error) { + const mapped = toAssistantEstimateMutationError(error, "createPlanningHandoff"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["estimate", "allocation", "timeline"], @@ -5544,12 +7292,21 @@ const executors = { pattern?: "even" | "front_loaded" | "back_loaded" | "custom"; }, ctx: ToolContext) { const caller = createEstimateCaller(createScopedCallerContext(ctx)); - const result = await caller.generateWeeklyPhasing({ - estimateId: params.estimateId, - startDate: params.startDate, - endDate: params.endDate, - ...(params.pattern !== undefined ? { pattern: params.pattern } : {}), - }); + let result; + try { + result = await caller.generateWeeklyPhasing({ + estimateId: params.estimateId, + startDate: params.startDate, + endDate: params.endDate, + ...(params.pattern !== undefined ? { pattern: params.pattern } : {}), + }); + } catch (error) { + const mapped = toAssistantEstimateMutationError(error, "generateWeeklyPhasing"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["estimate"], @@ -5583,11 +7340,20 @@ const executors = { terms: Record; }, ctx: ToolContext) { const caller = createEstimateCaller(createScopedCallerContext(ctx)); - const result = await caller.updateCommercialTerms({ - estimateId: params.estimateId, - terms: params.terms, - ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), - }); + let result; + try { + result = await caller.updateCommercialTerms({ + estimateId: params.estimateId, + terms: params.terms, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), + }); + } catch (error) { + const mapped = toAssistantEstimateMutationError(error, "updateCommercialTerms"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["estimate"], @@ -5605,7 +7371,16 @@ const executors = { color?: string; }, ctx: ToolContext) { const caller = createRoleCaller(createScopedCallerContext(ctx)); - const role = await caller.create(CreateRoleSchema.parse(params)); + let role; + try { + role = await caller.create(CreateRoleSchema.parse(params)); + } catch (error) { + const mapped = toAssistantRoleMutationError(error, "create"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["role"], success: true, message: `Created role: ${role.name}`, roleId: role.id, role }; }, @@ -5624,14 +7399,32 @@ const executors = { ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), }); if (Object.keys(data).length === 0) return { error: "No fields to update" }; - const role = await caller.update({ id: params.id, data }); + let role; + try { + role = await caller.update({ id: params.id, data }); + } catch (error) { + const mapped = toAssistantRoleMutationError(error, "update"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["role"], success: true, message: `Updated role: ${role.name}`, roleId: role.id, role }; }, async delete_role(params: { id: string }, ctx: ToolContext) { const caller = createRoleCaller(createScopedCallerContext(ctx)); - const role = await caller.getById({ id: params.id }); - await caller.delete({ id: params.id }); + let role; + try { + role = await caller.getById({ id: params.id }); + await caller.delete({ id: params.id }); + } catch (error) { + const mapped = toAssistantRoleMutationError(error, "delete"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["role"], success: true, message: `Deleted role: ${role.name}` }; }, @@ -5645,7 +7438,16 @@ const executors = { tags?: string[]; }, ctx: ToolContext) { const caller = createClientCaller(createScopedCallerContext(ctx)); - const client = await caller.create(CreateClientSchema.parse(params)); + let client; + try { + client = await caller.create(CreateClientSchema.parse(params)); + } catch (error) { + const mapped = toAssistantClientMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["client"], success: true, message: `Created client: ${client.name}`, clientId: client.id, client }; }, @@ -5668,10 +7470,35 @@ const executors = { ...(params.tags !== undefined ? { tags: params.tags } : {}), }); if (Object.keys(data).length === 0) return { error: "No fields to update" }; - const client = await caller.update({ id: params.id, data }); + let client; + try { + client = await caller.update({ id: params.id, data }); + } catch (error) { + const mapped = toAssistantClientMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["client"], success: true, message: `Updated client: ${client.name}`, clientId: client.id, client }; }, + async delete_client(params: { id: string }, ctx: ToolContext) { + const caller = createClientCaller(createScopedCallerContext(ctx)); + let client; + try { + client = await caller.getById({ id: params.id }); + await caller.delete({ id: params.id }); + } catch (error) { + const mapped = toAssistantClientMutationError(error, "delete"); + if (mapped) { + return mapped; + } + throw error; + } + return { __action: "invalidate", scope: ["client"], success: true, message: `Deleted client: ${client.name}` }; + }, + // ── ADMIN / CONFIG ── async list_countries(params: { includeInactive?: boolean; search?: string }, ctx: ToolContext) { @@ -5696,7 +7523,16 @@ const executors = { async get_country(params: { identifier: string }, ctx: ToolContext) { const caller = createCountryCaller(createScopedCallerContext(ctx)); - const country = await caller.getByIdentifier({ identifier: params.identifier }); + let country; + try { + country = await caller.getByIdentifier({ identifier: params.identifier }); + } catch (error) { + const mapped = toAssistantCountryNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } return formatCountry(country); }, @@ -5708,7 +7544,16 @@ const executors = { }, ctx: ToolContext) { assertAdminRole(ctx); const caller = createCountryCaller(createScopedCallerContext(ctx)); - const created = await caller.create(CreateCountrySchema.parse(params)); + let created; + try { + created = await caller.create(CreateCountrySchema.parse(params)); + } catch (error) { + const mapped = toAssistantCountryMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -5735,7 +7580,16 @@ const executors = { id: params.id, data: UpdateCountrySchema.parse(params.data), }; - const updated = await caller.update(input); + let updated; + try { + updated = await caller.update(input); + } catch (error) { + const mapped = toAssistantCountryMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -5749,7 +7603,16 @@ const executors = { async create_metro_city(params: { countryId: string; name: string }, ctx: ToolContext) { assertAdminRole(ctx); const caller = createCountryCaller(createScopedCallerContext(ctx)); - const created = await caller.createCity(CreateMetroCitySchema.parse(params)); + let created; + try { + created = await caller.createCity(CreateMetroCitySchema.parse(params)); + } catch (error) { + const mapped = toAssistantMetroCityMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -5767,7 +7630,16 @@ const executors = { id: params.id, data: UpdateMetroCitySchema.parse(params.data), }; - const updated = await caller.updateCity(input); + let updated; + try { + updated = await caller.updateCity(input); + } catch (error) { + const mapped = toAssistantMetroCityMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -5781,7 +7653,16 @@ const executors = { async delete_metro_city(params: { id: string }, ctx: ToolContext) { assertAdminRole(ctx); const caller = createCountryCaller(createScopedCallerContext(ctx)); - const deleted = await caller.deleteCity({ id: params.id }); + let deleted; + try { + deleted = await caller.deleteCity({ id: params.id }); + } catch (error) { + const mapped = toAssistantMetroCityMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -5978,7 +7859,16 @@ const executors = { async verify_and_enable_totp(params: { token: string }, ctx: ToolContext) { const caller = createUserCaller(createScopedCallerContext(ctx)); - const result = await caller.verifyAndEnableTotp({ token: params.token }); + let result; + try { + result = await caller.verifyAndEnableTotp({ token: params.token }); + } catch (error) { + const mapped = toAssistantTotpEnableError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["user"], @@ -6005,12 +7895,21 @@ const executors = { password: string; }, ctx: ToolContext) { const caller = createUserCaller(createScopedCallerContext(ctx)); - const user = await caller.create({ - email: params.email, - name: params.name, - password: params.password, - ...(params.systemRole !== undefined ? { systemRole: params.systemRole } : {}), - }); + let user; + try { + user = await caller.create({ + email: params.email, + name: params.name, + password: params.password, + ...(params.systemRole !== undefined ? { systemRole: params.systemRole } : {}), + }); + } catch (error) { + const mapped = toAssistantUserMutationError(error, "create"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["user", "resource"], @@ -6023,7 +7922,16 @@ const executors = { async set_user_password(params: { userId: string; password: string }, ctx: ToolContext) { const caller = createUserCaller(createScopedCallerContext(ctx)); - const result = await caller.setPassword(params); + let result; + try { + result = await caller.setPassword(params); + } catch (error) { + const mapped = toAssistantUserMutationError(error, "password"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["user"], @@ -6034,7 +7942,16 @@ const executors = { async update_user_role(params: { id: string; systemRole: SystemRole }, ctx: ToolContext) { const caller = createUserCaller(createScopedCallerContext(ctx)); - const user = await caller.updateRole(params); + let user; + try { + user = await caller.updateRole(params); + } catch (error) { + const mapped = toAssistantUserMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["user"], @@ -6047,7 +7964,16 @@ const executors = { async update_user_name(params: { id: string; name: string }, ctx: ToolContext) { const caller = createUserCaller(createScopedCallerContext(ctx)); - const user = await caller.updateName(params); + let user; + try { + user = await caller.updateName(params); + } catch (error) { + const mapped = toAssistantUserMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["user"], @@ -6060,10 +7986,19 @@ const executors = { async link_user_resource(params: { userId: string; resourceId?: string | null }, ctx: ToolContext) { const caller = createUserCaller(createScopedCallerContext(ctx)); - const result = await caller.linkResource({ - userId: params.userId, - resourceId: params.resourceId ?? null, - }); + let result; + try { + result = await caller.linkResource({ + userId: params.userId, + resourceId: params.resourceId ?? null, + }); + } catch (error) { + const mapped = toAssistantUserResourceLinkError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["user", "resource"], @@ -6093,10 +8028,19 @@ const executors = { } | null; }, ctx: ToolContext) { const caller = createUserCaller(createScopedCallerContext(ctx)); - const user = await caller.setPermissions({ - userId: params.userId, - overrides: params.overrides ?? null, - }); + let user; + try { + user = await caller.setPermissions({ + userId: params.userId, + overrides: params.overrides ?? null, + }); + } catch (error) { + const mapped = toAssistantUserMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["user"], @@ -6109,7 +8053,16 @@ const executors = { async reset_user_permissions(params: { userId: string }, ctx: ToolContext) { const caller = createUserCaller(createScopedCallerContext(ctx)); - const user = await caller.resetPermissions(params); + let user; + try { + user = await caller.resetPermissions(params); + } catch (error) { + const mapped = toAssistantUserMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["user"], @@ -6122,12 +8075,29 @@ const executors = { async get_effective_user_permissions(params: { userId: string }, ctx: ToolContext) { const caller = createUserCaller(createScopedCallerContext(ctx)); - return caller.getEffectivePermissions(params); + try { + return await caller.getEffectivePermissions(params); + } catch (error) { + const mapped = toAssistantUserMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } }, async disable_user_totp(params: { userId: string }, ctx: ToolContext) { const caller = createUserCaller(createScopedCallerContext(ctx)); - const result = await caller.disableTotp(params); + let result; + try { + result = await caller.disableTotp(params); + } catch (error) { + const mapped = toAssistantUserMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["user"], @@ -6147,9 +8117,17 @@ const executors = { async mark_notification_read(params: { notificationId?: string }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - await caller.markRead({ - ...(params.notificationId !== undefined ? { id: params.notificationId } : {}), - }); + try { + await caller.markRead({ + ...(params.notificationId !== undefined ? { id: params.notificationId } : {}), + }); + } catch (error) { + const mapped = toAssistantNotificationReadError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["notification"], @@ -6182,23 +8160,33 @@ const executors = { senderId?: string; }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - const notification = await caller.create({ - userId: params.userId, - type: params.type, - title: params.title, - ...(params.body !== undefined ? { body: params.body } : {}), - ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), - ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), - ...(params.category !== undefined ? { category: params.category } : {}), - ...(params.priority !== undefined ? { priority: params.priority } : {}), - ...(params.link !== undefined ? { link: params.link } : {}), - ...(params.taskStatus !== undefined ? { taskStatus: params.taskStatus } : {}), - ...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}), - ...(params.assigneeId !== undefined ? { assigneeId: params.assigneeId } : {}), - ...(params.dueDate !== undefined ? { dueDate: new Date(params.dueDate) } : {}), - ...(params.channel !== undefined ? { channel: params.channel } : {}), - ...(params.senderId !== undefined ? { senderId: params.senderId } : {}), - }); + const dueDate = parseOptionalDateTime(params.dueDate, "dueDate"); + let notification; + try { + notification = await caller.create({ + userId: params.userId, + type: params.type, + title: params.title, + ...(params.body !== undefined ? { body: params.body } : {}), + ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), + ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), + ...(params.category !== undefined ? { category: params.category } : {}), + ...(params.priority !== undefined ? { priority: params.priority } : {}), + ...(params.link !== undefined ? { link: params.link } : {}), + ...(params.taskStatus !== undefined ? { taskStatus: params.taskStatus } : {}), + ...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}), + ...(params.assigneeId !== undefined ? { assigneeId: params.assigneeId } : {}), + ...(dueDate ? { dueDate } : {}), + ...(params.channel !== undefined ? { channel: params.channel } : {}), + ...(params.senderId !== undefined ? { senderId: params.senderId } : {}), + }); + } catch (error) { + const mapped = toAssistantNotificationCreationError(error, "notification"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["notification"], @@ -6224,7 +8212,15 @@ const executors = { if ("error" in project) return project; const caller = createProjectCaller(createScopedCallerContext(ctx)); - await caller.delete({ id: project.id }); + try { + await caller.delete({ id: project.id }); + } catch (error) { + const mapped = toAssistantProjectNotFoundError(error, params.projectId); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["project"], @@ -6243,7 +8239,16 @@ const executors = { sortOrder?: number; }, ctx: ToolContext) { const caller = createOrgUnitCaller(createScopedCallerContext(ctx)); - const ou = await caller.create(CreateOrgUnitSchema.parse(params)); + let ou; + try { + ou = await caller.create(CreateOrgUnitSchema.parse(params)); + } catch (error) { + const mapped = toAssistantOrgUnitMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Created org unit: ${ou.name}`, orgUnitId: ou.id, orgUnit: ou }; }, @@ -6264,7 +8269,16 @@ const executors = { ...(params.parentId !== undefined ? { parentId: params.parentId } : {}), }); if (Object.keys(data).length === 0) return { error: "No fields to update" }; - const ou = await caller.update({ id: params.id, data }); + let ou; + try { + ou = await caller.update({ id: params.id, data }); + } catch (error) { + const mapped = toAssistantOrgUnitMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Updated org unit: ${ou.name}`, orgUnitId: ou.id, orgUnit: ou }; }, @@ -6325,15 +8339,32 @@ const executors = { async get_task_detail(params: { taskId: string }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - return caller.getTaskDetail({ id: params.taskId }); + try { + return await caller.getTaskDetail({ id: params.taskId }); + } catch (error) { + const mapped = toAssistantTaskNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } }, async update_task_status(params: { taskId: string; status: string }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - const task = await caller.updateTaskStatus({ - id: params.taskId, - status: params.status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED", - }); + let task; + try { + task = await caller.updateTaskStatus({ + id: params.taskId, + status: params.status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED", + }); + } catch (error) { + const mapped = toAssistantTaskNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["notification"], @@ -6345,7 +8376,16 @@ const executors = { async execute_task_action(params: { taskId: string }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - const result = await caller.executeTaskAction({ id: params.taskId }); + let result; + try { + result = await caller.executeTaskAction({ id: params.taskId }); + } catch (error) { + const mapped = toAssistantTaskActionError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -6366,15 +8406,42 @@ const executors = { link?: string; }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - const reminder = await caller.createReminder({ - title: params.title, - remindAt: new Date(params.remindAt), - ...(params.body !== undefined ? { body: params.body } : {}), - ...(params.recurrence !== undefined ? { recurrence: params.recurrence as "daily" | "weekly" | "monthly" } : {}), - ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), - ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), - ...(params.link !== undefined ? { link: params.link } : {}), - }); + if (!params.title.trim()) { + return { error: "Reminder title is required." }; + } + if (params.title.length > 200) { + return { error: "Reminder title must be at most 200 characters." }; + } + if (params.body !== undefined && params.body.length > 2000) { + return { error: "Reminder body must be at most 2000 characters." }; + } + if ( + params.recurrence !== undefined + && !["daily", "weekly", "monthly"].includes(params.recurrence) + ) { + return { + error: `Invalid recurrence: ${params.recurrence}. Valid values: daily, weekly, monthly.`, + }; + } + const remindAt = parseDateTime(params.remindAt, "remindAt"); + let reminder; + try { + reminder = await caller.createReminder({ + title: params.title, + remindAt, + ...(params.body !== undefined ? { body: params.body } : {}), + ...(params.recurrence !== undefined ? { recurrence: params.recurrence as "daily" | "weekly" | "monthly" } : {}), + ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), + ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), + ...(params.link !== undefined ? { link: params.link } : {}), + }); + } catch (error) { + const mapped = toAssistantReminderCreationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["notification"], @@ -6400,13 +8467,23 @@ const executors = { recurrence?: "daily" | "weekly" | "monthly" | null; }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - const reminder = await caller.updateReminder({ - id: params.id, - ...(params.title !== undefined ? { title: params.title } : {}), - ...(params.body !== undefined ? { body: params.body } : {}), - ...(params.remindAt !== undefined ? { remindAt: new Date(params.remindAt) } : {}), - ...(params.recurrence !== undefined ? { recurrence: params.recurrence } : {}), - }); + const remindAt = parseOptionalDateTime(params.remindAt, "remindAt"); + let reminder; + try { + reminder = await caller.updateReminder({ + id: params.id, + ...(params.title !== undefined ? { title: params.title } : {}), + ...(params.body !== undefined ? { body: params.body } : {}), + ...(remindAt ? { remindAt } : {}), + ...(params.recurrence !== undefined ? { recurrence: params.recurrence } : {}), + }); + } catch (error) { + const mapped = toAssistantReminderNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["notification"], @@ -6419,7 +8496,15 @@ const executors = { async delete_reminder(params: { id: string }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - await caller.deleteReminder({ id: params.id }); + try { + await caller.deleteReminder({ id: params.id }); + } catch (error) { + const mapped = toAssistantReminderNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["notification"], @@ -6442,18 +8527,28 @@ const executors = { channel?: "in_app" | "email" | "both"; }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - const task = await caller.createTask({ - userId: params.userId, - title: params.title, - ...(params.body !== undefined ? { body: params.body } : {}), - ...(params.priority !== undefined ? { priority: params.priority as "LOW" | "NORMAL" | "HIGH" | "URGENT" } : {}), - ...(params.dueDate !== undefined ? { dueDate: new Date(params.dueDate) } : {}), - ...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}), - ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), - ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), - ...(params.link !== undefined ? { link: params.link } : {}), - ...(params.channel !== undefined ? { channel: params.channel } : {}), - }); + const dueDate = parseOptionalDateTime(params.dueDate, "dueDate"); + let task; + try { + task = await caller.createTask({ + userId: params.userId, + title: params.title, + ...(params.body !== undefined ? { body: params.body } : {}), + ...(params.priority !== undefined ? { priority: params.priority as "LOW" | "NORMAL" | "HIGH" | "URGENT" } : {}), + ...(dueDate ? { dueDate } : {}), + ...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}), + ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), + ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), + ...(params.link !== undefined ? { link: params.link } : {}), + ...(params.channel !== undefined ? { channel: params.channel } : {}), + }); + } catch (error) { + const mapped = toAssistantNotificationCreationError(error, "task"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["notification"], @@ -6466,7 +8561,16 @@ const executors = { async assign_task(params: { id: string; assigneeId: string }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - const task = await caller.assignTask(params); + let task; + try { + task = await caller.assignTask(params); + } catch (error) { + const mapped = toAssistantTaskAssignmentError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["notification"], @@ -6491,19 +8595,30 @@ const executors = { dueDate?: string; }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - const broadcast = await caller.createBroadcast({ - title: params.title, - targetType: params.targetType as "user" | "role" | "project" | "orgUnit" | "all", - ...(params.body !== undefined ? { body: params.body } : {}), - ...(params.link !== undefined ? { link: params.link } : {}), - ...(params.category !== undefined ? { category: params.category } : {}), - ...(params.priority !== undefined ? { priority: params.priority as "LOW" | "NORMAL" | "HIGH" | "URGENT" } : {}), - ...(params.channel !== undefined ? { channel: params.channel as "in_app" | "email" | "both" } : {}), - ...(params.targetValue !== undefined ? { targetValue: params.targetValue } : {}), - ...(params.scheduledAt !== undefined ? { scheduledAt: new Date(params.scheduledAt) } : {}), - ...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}), - ...(params.dueDate !== undefined ? { dueDate: new Date(params.dueDate) } : {}), - }); + const scheduledAt = parseOptionalDateTime(params.scheduledAt, "scheduledAt"); + const dueDate = parseOptionalDateTime(params.dueDate, "dueDate"); + let broadcast; + try { + broadcast = await caller.createBroadcast({ + title: params.title, + targetType: params.targetType as "user" | "role" | "project" | "orgUnit" | "all", + ...(params.body !== undefined ? { body: params.body } : {}), + ...(params.link !== undefined ? { link: params.link } : {}), + ...(params.category !== undefined ? { category: params.category } : {}), + ...(params.priority !== undefined ? { priority: params.priority as "LOW" | "NORMAL" | "HIGH" | "URGENT" } : {}), + ...(params.channel !== undefined ? { channel: params.channel as "in_app" | "email" | "both" } : {}), + ...(params.targetValue !== undefined ? { targetValue: params.targetValue } : {}), + ...(scheduledAt ? { scheduledAt } : {}), + ...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}), + ...(dueDate ? { dueDate } : {}), + }); + } catch (error) { + const mapped = toAssistantNotificationCreationError(error, "broadcast"); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -6525,12 +8640,28 @@ const executors = { async get_broadcast_detail(params: { id: string }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - return caller.getBroadcastById({ id: params.id }); + try { + return await caller.getBroadcastById({ id: params.id }); + } catch (error) { + const mapped = toAssistantBroadcastNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } }, async delete_notification(params: { id: string }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); - await caller.delete({ id: params.id }); + try { + await caller.delete({ id: params.id }); + } catch (error) { + const mapped = toAssistantNotificationDeletionError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", scope: ["notification"], @@ -6711,12 +8842,29 @@ const executors = { entityId: string; body: string; }, ctx: ToolContext) { + if (params.body.length === 0) { + return { error: "Comment body is required." }; + } + + if (params.body.length > 10_000) { + return { error: "Comment body must be at most 10000 characters." }; + } + const caller = createCommentCaller(createScopedCallerContext(ctx)); - const comment = await caller.create({ - entityType: params.entityType, - entityId: params.entityId, - body: params.body, - }); + let comment; + try { + comment = await caller.create({ + entityType: params.entityType, + entityId: params.entityId, + body: params.body, + }); + } catch (error) { + const mapped = toAssistantCommentCreationError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -6733,10 +8881,19 @@ const executors = { resolved?: boolean; }, ctx: ToolContext) { const caller = createCommentCaller(createScopedCallerContext(ctx)); - const updated = await caller.resolve({ - id: params.commentId, - resolved: params.resolved !== false, - }); + let updated; + try { + updated = await caller.resolve({ + id: params.commentId, + resolved: params.resolved !== false, + }); + } catch (error) { + const mapped = toAssistantCommentResolveError(error); + if (mapped) { + return mapped; + } + throw error; + } return { __action: "invalidate", @@ -6850,7 +9007,15 @@ const executors = { id: string; }, ctx: ToolContext) { const caller = createDispoCaller(createScopedCallerContext(ctx)); - return caller.getImportBatch({ id: params.id }); + try { + return await caller.getImportBatch({ id: params.id }); + } catch (error) { + const mapped = toAssistantDispoImportBatchNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } }, async stage_dispo_import_batch(params: { @@ -7066,8 +9231,16 @@ const executors = { }, async get_ai_configured(_params: Record, ctx: ToolContext) { - const caller = createSettingsCaller(createScopedCallerContext(ctx)); - return caller.getAiConfigured(); + const settings = await ctx.db.systemSettings.findUnique({ + where: { id: "singleton" }, + select: { + aiProvider: true, + azureOpenAiEndpoint: true, + azureOpenAiDeployment: true, + azureOpenAiApiKey: true, + }, + }); + return { configured: isAiConfigured(settings) }; }, async list_system_role_configs(_params: Record, ctx: ToolContext) { @@ -7096,7 +9269,16 @@ const executors = { id: string; }, ctx: ToolContext) { const caller = createWebhookCaller(createScopedCallerContext(ctx)); - const webhook = await caller.getById({ id: params.id }); + let webhook; + try { + webhook = await caller.getById({ id: params.id }); + } catch (error) { + const mapped = toAssistantWebhookNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } return sanitizeWebhook(webhook); }, @@ -7108,13 +9290,22 @@ const executors = { isActive?: boolean; }, ctx: ToolContext) { const caller = createWebhookCaller(createScopedCallerContext(ctx)); - const webhook = await caller.create({ - name: params.name, - url: params.url, - events: params.events as [string, ...string[]], - ...(params.secret !== undefined ? { secret: params.secret } : {}), - ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), - }); + let webhook; + try { + webhook = await caller.create({ + name: params.name, + url: params.url, + events: params.events as [string, ...string[]], + ...(params.secret !== undefined ? { secret: params.secret } : {}), + ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), + }); + } catch (error) { + const mapped = toAssistantWebhookMutationError(error, "create"); + if (mapped) { + return mapped; + } + throw error; + } return sanitizeWebhook(webhook); }, @@ -7129,16 +9320,25 @@ const executors = { }; }, ctx: ToolContext) { const caller = createWebhookCaller(createScopedCallerContext(ctx)); - const webhook = await caller.update({ - id: params.id, - data: { - ...(params.data.name !== undefined ? { name: params.data.name } : {}), - ...(params.data.url !== undefined ? { url: params.data.url } : {}), - ...(params.data.secret !== undefined ? { secret: params.data.secret } : {}), - ...(params.data.events !== undefined ? { events: params.data.events as [string, ...string[]] } : {}), - ...(params.data.isActive !== undefined ? { isActive: params.data.isActive } : {}), - }, - }); + let webhook; + try { + webhook = await caller.update({ + id: params.id, + data: { + ...(params.data.name !== undefined ? { name: params.data.name } : {}), + ...(params.data.url !== undefined ? { url: params.data.url } : {}), + ...(params.data.secret !== undefined ? { secret: params.data.secret } : {}), + ...(params.data.events !== undefined ? { events: params.data.events as [string, ...string[]] } : {}), + ...(params.data.isActive !== undefined ? { isActive: params.data.isActive } : {}), + }, + }); + } catch (error) { + const mapped = toAssistantWebhookMutationError(error, "update"); + if (mapped) { + return mapped; + } + throw error; + } return sanitizeWebhook(webhook); }, @@ -7146,7 +9346,15 @@ const executors = { id: string; }, ctx: ToolContext) { const caller = createWebhookCaller(createScopedCallerContext(ctx)); - await caller.delete({ id: params.id }); + try { + await caller.delete({ id: params.id }); + } catch (error) { + const mapped = toAssistantWebhookNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } return { ok: true, id: params.id }; }, @@ -7154,7 +9362,15 @@ const executors = { id: string; }, ctx: ToolContext) { const caller = createWebhookCaller(createScopedCallerContext(ctx)); - return caller.test({ id: params.id }); + try { + return await caller.test({ id: params.id }); + } catch (error) { + const mapped = toAssistantWebhookNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } }, async list_audit_log_entries(params: { @@ -7204,7 +9420,15 @@ const executors = { id: string; }, ctx: ToolContext) { const caller = createAuditLogCaller(createScopedCallerContext(ctx)); - return caller.getByIdDetail({ id: params.id }); + try { + return await caller.getByIdDetail({ id: params.id }); + } catch (error) { + const mapped = toAssistantAuditLogEntryNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } }, async get_audit_log_timeline(params: { @@ -7328,7 +9552,7 @@ export async function executeTool( ...(typeof result === "string" ? {} : { data: result }), }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const normalizedError = normalizeAssistantExecutionError(err); logger.error( { tool: name, @@ -7338,6 +9562,6 @@ export async function executeTool( }, "AI assistant tool execution failed", ); - return { content: JSON.stringify({ error: msg }) }; + return { content: JSON.stringify(normalizedError), data: normalizedError }; } } diff --git a/packages/api/src/router/notification.ts b/packages/api/src/router/notification.ts index 9611714..09ab7db 100644 --- a/packages/api/src/router/notification.ts +++ b/packages/api/src/router/notification.ts @@ -619,6 +619,13 @@ export const notificationRouter = createTRPCRouter({ senderId, ); + if (recipientIds.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No recipients matched the broadcast target.", + }); + } + // 4. Create individual notifications for each recipient const isTask = input.category === "TASK" || input.category === "APPROVAL"; diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 9066e19..ac4073d 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -135,10 +135,13 @@ export const userRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const user = await ctx.db.user.findUniqueOrThrow({ - where: { id: input.userId }, - select: { id: true, name: true, email: true }, - }); + const user = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { id: true, name: true, email: true }, + }), + "User", + ); const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); @@ -170,10 +173,13 @@ export const userRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const before = await ctx.db.user.findUniqueOrThrow({ - where: { id: input.id }, - select: { id: true, name: true, email: true, systemRole: true }, - }); + const before = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: input.id }, + select: { id: true, name: true, email: true, systemRole: true }, + }), + "User", + ); const updated = await ctx.db.user.update({ where: { id: input.id }, @@ -205,10 +211,13 @@ export const userRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const before = await ctx.db.user.findUniqueOrThrow({ - where: { id: input.id }, - select: { id: true, name: true, email: true }, - }); + const before = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: input.id }, + select: { id: true, name: true, email: true }, + }), + "User", + ); const updated = await ctx.db.user.update({ where: { id: input.id }, @@ -237,7 +246,23 @@ export const userRouter = createTRPCRouter({ linkResource: adminProcedure .input(z.object({ userId: z.string(), resourceId: z.string().nullable() })) .mutation(async ({ ctx, input }) => { + await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { id: true }, + }), + "User", + ); + if (input.resourceId) { + await findUniqueOrThrow( + ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { id: true }, + }), + "Resource", + ); + // Unlink any resource previously linked to this user await ctx.db.resource.updateMany({ where: { userId: input.userId }, @@ -345,10 +370,13 @@ export const userRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const before = await ctx.db.user.findUniqueOrThrow({ - where: { id: input.userId }, - select: { id: true, name: true, email: true, permissionOverrides: true }, - }); + const before = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { id: true, name: true, email: true, permissionOverrides: true }, + }), + "User", + ); const user = await ctx.db.user.update({ where: { id: input.userId }, @@ -376,10 +404,13 @@ export const userRouter = createTRPCRouter({ resetPermissions: adminProcedure .input(z.object({ userId: z.string() })) .mutation(async ({ ctx, input }) => { - const before = await ctx.db.user.findUniqueOrThrow({ - where: { id: input.userId }, - select: { id: true, name: true, email: true, permissionOverrides: true }, - }); + const before = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { id: true, name: true, email: true, permissionOverrides: true }, + }), + "User", + ); const updated = await ctx.db.user.update({ where: { id: input.userId }, @@ -453,10 +484,13 @@ export const userRouter = createTRPCRouter({ getEffectivePermissions: adminProcedure .input(z.object({ userId: z.string() })) .query(async ({ ctx, input }) => { - const user = await ctx.db.user.findUniqueOrThrow({ - where: { id: input.userId }, - select: { systemRole: true, permissionOverrides: true }, - }); + const user = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { systemRole: true, permissionOverrides: true }, + }), + "User", + ); const permissions = resolvePermissions( user.systemRole as SystemRole, user.permissionOverrides as PermissionOverrides | null, @@ -547,10 +581,13 @@ export const userRouter = createTRPCRouter({ disableTotp: adminProcedure .input(z.object({ userId: z.string() })) .mutation(async ({ ctx, input }) => { - const user = await ctx.db.user.findUniqueOrThrow({ - where: { id: input.userId }, - select: { id: true, name: true, email: true, totpEnabled: true }, - }); + const user = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { id: true, name: true, email: true, totpEnabled: true }, + }), + "User", + ); await ctx.db.user.update({ where: { id: input.userId },