From 00b936fa1f672793ceaa36f8d4a78ddbdfa417b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 29 Mar 2026 12:56:29 +0200 Subject: [PATCH] feat(assistant): extend audit and import parity --- docs/assistant-capability-gap-analysis.md | 33 +- .../src/__tests__/assistant-router.test.ts | 46 ++ .../__tests__/assistant-tools-audit.test.ts | 129 ++++++ .../assistant-tools-import-export.test.ts | 127 +++++ packages/api/src/router/assistant-tools.ts | 438 +++++++++++++++--- packages/api/src/router/assistant.ts | 12 + 6 files changed, 699 insertions(+), 86 deletions(-) create mode 100644 packages/api/src/__tests__/assistant-tools-audit.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-import-export.test.ts diff --git a/docs/assistant-capability-gap-analysis.md b/docs/assistant-capability-gap-analysis.md index 16c455b..bbec0f4 100644 --- a/docs/assistant-capability-gap-analysis.md +++ b/docs/assistant-capability-gap-analysis.md @@ -70,6 +70,13 @@ Es gibt aktuell vier Permission-/Scope-Ebenen: - `get_project_timeline_context` - `preview_project_shift` - basiert bereits auf denselben Timeline-Readmodels/Shift-Preview-Helfern wie die UI +- Import/Export / Dispo: + - `export_resources_csv` + - `export_projects_csv` + - `import_csv_data` + - `list_dispo_import_batches` + - `get_dispo_import_batch` + - damit sind CSV-Export, CSV-Import und die Batch-Uebersicht der Dispo-Importe jetzt ueber echte Router-Pfade verfuegbar - Estimates: nur Suche, Detail und Anlegen, aber kein voller Lifecycle - Reports: `run_report` ist flexibel, deckt aber nicht die spezialisierten Report-/Analyse-Readmodels ab - Chargeability / Transparenz: @@ -77,7 +84,10 @@ Es gibt aktuell vier Permission-/Scope-Ebenen: - `get_resource_computation_graph` - `get_project_computation_graph` - damit sind die wichtigsten tiefen Herleitungen fuer Chargeability, SAH, Feiertagsabzuege und Projektkalkulation jetzt auch im Assistant verfuegbar -- Audit/History: nur einfache History-Abfragen, keine volle Audit-API +- Audit/History: + - vereinfachte History-Abfragen + - echte Audit-API fuer Liste, Detail, Entity-History, Timeline und Activity Summary + - Governance-Workbench ausserhalb des Chats bleibt offen - Notification/Tasking: Kernfaelle vorhanden, aber keine volle Reminder-/Task-/Notification-Paritaet - Country-/Location-Stammdaten: nur lesend und auch dort nur flach - Insights: Summary-Ebene vorhanden, Drilldowns fehlen @@ -220,12 +230,18 @@ Konsequenz: Aktuell im Assistant vorhanden: -- vereinfachte History-Suche (`query_change_history`) -- Entity-History (`get_entity_timeline`) +- vereinfachte History-Suche (`query_change_history`) jetzt auf Basis von `auditLogRouter.list` +- Entity-History (`get_entity_timeline`) jetzt auf Basis von `auditLogRouter.getByEntity` +- vollstaendige Audit-Readmodel-Paritaet: + - `list_audit_log_entries` + - `get_audit_log_entry` + - `get_audit_log_timeline` + - `get_audit_activity_summary` Fehlend: -- die vollstaendige Governance-/Revisionstiefe der Audit-Oberflaeche +- dedizierte Governance-/Approval-Oberflaechen ausserhalb des Chats +- eine eigenstaendige Revisions-Workbench fuer offene Freigaben und operative Nachverfolgung ### Admin- und Systemkonfiguration @@ -311,23 +327,20 @@ Fehlend: ### Komplett fehlende Router-Paritaet -- `importExport` -- `chargeabilityReport` -- `computationGraph` - `settings` - `systemRoleConfig` - `webhook` -- `dispo` ### Deutlich unvollstaendige Router-Paritaet -- `timeline` (read-only Kernfaelle vorhanden, Write-Paritaet fehlt) +- `importExport` +- `dispo` +- `timeline` (Kern-Readmodels und wichtigste Write-Paritaet vorhanden, Spezial-Workflows fehlen) - `vacation` - `estimate` - `notification` - `user` - `country` -- `auditLog` - `insights` - `scenario` - `resource` diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts index b1b378f..729b864 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -222,9 +222,25 @@ describe("assistant router tool gating", () => { PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, ], SystemRole.USER); + expect(controllerNames).toContain("query_change_history"); + expect(controllerNames).toContain("get_entity_timeline"); + expect(controllerNames).toContain("export_resources_csv"); + expect(controllerNames).toContain("export_projects_csv"); + expect(controllerNames).toContain("list_audit_log_entries"); + expect(controllerNames).toContain("get_audit_log_entry"); + expect(controllerNames).toContain("get_audit_log_timeline"); + expect(controllerNames).toContain("get_audit_activity_summary"); expect(controllerNames).toContain("get_chargeability_report"); expect(controllerNames).toContain("get_resource_computation_graph"); expect(controllerNames).toContain("get_project_computation_graph"); + expect(userNames).not.toContain("query_change_history"); + expect(userNames).not.toContain("get_entity_timeline"); + expect(userNames).not.toContain("export_resources_csv"); + expect(userNames).not.toContain("export_projects_csv"); + expect(userNames).not.toContain("list_audit_log_entries"); + expect(userNames).not.toContain("get_audit_log_entry"); + expect(userNames).not.toContain("get_audit_log_timeline"); + expect(userNames).not.toContain("get_audit_activity_summary"); expect(userNames).not.toContain("get_chargeability_report"); expect(userNames).not.toContain("get_resource_computation_graph"); expect(userNames).not.toContain("get_project_computation_graph"); @@ -257,6 +273,24 @@ describe("assistant router tool gating", () => { expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource"); }); + it("keeps import/dispo parity tools aligned to router roles and permissions", () => { + const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER); + const controllerNames = getToolNames([], SystemRole.CONTROLLER); + const adminNames = getToolNames([], SystemRole.ADMIN); + const userNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.USER); + + expect(managerNames).toContain("import_csv_data"); + expect(controllerNames).toContain("export_resources_csv"); + expect(controllerNames).toContain("export_projects_csv"); + expect(adminNames).toContain("list_dispo_import_batches"); + expect(adminNames).toContain("get_dispo_import_batch"); + expect(userNames).not.toContain("import_csv_data"); + expect(userNames).not.toContain("export_resources_csv"); + expect(userNames).not.toContain("export_projects_csv"); + expect(userNames).not.toContain("list_dispo_import_batches"); + expect(userNames).not.toContain("get_dispo_import_batch"); + }); + it("keeps holiday calendar mutation tools admin-only while leaving read tools available", () => { const adminNames = getToolNames([], SystemRole.ADMIN); const managerNames = getToolNames([], SystemRole.MANAGER); @@ -492,6 +526,18 @@ describe("assistant router tool gating", () => { expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects"); expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role"); expect(toolDescriptions.get("create_holiday_calendar_entry")).toContain("Admin role"); + expect(toolDescriptions.get("query_change_history")).toContain("Controller/manager/admin"); + expect(toolDescriptions.get("get_entity_timeline")).toContain("Controller/manager/admin"); + expect(toolDescriptions.get("export_resources_csv")).toContain("Controller/manager/admin"); + expect(toolDescriptions.get("export_projects_csv")).toContain("Controller/manager/admin"); + expect(toolDescriptions.get("import_csv_data")).toContain("importData"); + expect(toolDescriptions.get("import_csv_data")).toContain("manager/admin"); + expect(toolDescriptions.get("list_dispo_import_batches")).toContain("Admin role"); + expect(toolDescriptions.get("get_dispo_import_batch")).toContain("Admin role"); + expect(toolDescriptions.get("list_audit_log_entries")).toContain("Controller/manager/admin"); + expect(toolDescriptions.get("get_audit_log_entry")).toContain("Controller/manager/admin"); + expect(toolDescriptions.get("get_audit_log_timeline")).toContain("Controller/manager/admin"); + expect(toolDescriptions.get("get_audit_activity_summary")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("get_chargeability_report")).toContain("controller/manager/admin"); expect(toolDescriptions.get("get_chargeability_report")).toContain("viewCosts"); expect(toolDescriptions.get("get_resource_computation_graph")).toContain("useAssistantAdvancedTools"); diff --git a/packages/api/src/__tests__/assistant-tools-audit.test.ts b/packages/api/src/__tests__/assistant-tools-audit.test.ts new file mode 100644 index 0000000..62c7522 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-audit.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool, type ToolContext } from "../router/assistant-tools.js"; + +function createToolContext( + db: Record, + userRole: SystemRole = SystemRole.CONTROLLER, +): ToolContext { + return { + db: db as ToolContext["db"], + userId: "user_1", + userRole, + permissions: new Set(), + session: { + user: { email: "assistant@example.com", name: "Assistant User", image: null }, + expires: "2026-03-29T00:00:00.000Z", + }, + dbUser: { + id: "user_1", + systemRole: userRole, + permissionOverrides: null, + }, + roleDefaults: null, + }; +} + +describe("assistant audit tools", () => { + it("lists audit entries through the real audit router path", async () => { + const ctx = createToolContext({ + auditLog: { + findMany: vi.fn().mockResolvedValue([ + { + id: "audit_1", + entityType: "Project", + entityId: "project_1", + entityName: "Gelddruckmaschine", + action: "UPDATE", + userId: "user_1", + source: "ui", + summary: "Updated project dates", + createdAt: new Date("2026-03-28T10:00:00.000Z"), + user: { + id: "user_1", + name: "Larissa", + email: "larissa@example.com", + }, + }, + ]), + }, + }); + + const result = await executeTool( + "list_audit_log_entries", + JSON.stringify({ + entityType: "Project", + search: "Gelddruckmaschine", + limit: 10, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + filters: { + entityType: "Project", + entityId: null, + userId: null, + action: null, + source: null, + startDate: null, + endDate: null, + search: "Gelddruckmaschine", + }, + itemCount: 1, + nextCursor: null, + items: [ + { + id: "audit_1", + entityType: "Project", + entityId: "project_1", + entityName: "Gelddruckmaschine", + action: "UPDATE", + userId: "user_1", + source: "ui", + summary: "Updated project dates", + createdAt: "2026-03-28T10:00:00.000Z", + user: { + id: "user_1", + name: "Larissa", + email: "larissa@example.com", + }, + }, + ], + }); + }); + + it("enforces controller access for audit tools via the backing router", async () => { + const ctx = createToolContext( + { + auditLog: { + findMany: vi.fn(), + }, + }, + SystemRole.USER, + ); + + const result = await executeTool( + "query_change_history", + JSON.stringify({ entityType: "Project" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + error: expect.stringContaining("Controller access required"), + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-import-export.test.ts b/packages/api/src/__tests__/assistant-tools-import-export.test.ts new file mode 100644 index 0000000..3687597 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-import-export.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool, type ToolContext } from "../router/assistant-tools.js"; + +function createToolContext( + db: Record, + options?: { + permissions?: PermissionKey[]; + userRole?: SystemRole; + }, +): ToolContext { + const userRole = options?.userRole ?? SystemRole.ADMIN; + return { + db: db as ToolContext["db"], + userId: "user_1", + userRole, + permissions: new Set(options?.permissions ?? []), + session: { + user: { email: "assistant@example.com", name: "Assistant User", image: null }, + expires: "2026-03-29T00:00:00.000Z", + }, + dbUser: { + id: "user_1", + systemRole: userRole, + permissionOverrides: null, + }, + roleDefaults: null, + }; +} + +describe("assistant import/export and dispo tools", () => { + it("exports resources CSV through the real import/export router path", async () => { + const ctx = createToolContext( + { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + eid: "EMP-001", + displayName: "Carol Danvers", + email: "carol@example.com", + chapter: "Delivery", + lcrCents: 8000, + ucrCents: 12000, + currency: "EUR", + chargeabilityTarget: 0.8, + dynamicFields: {}, + }, + ]), + }, + blueprint: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool("export_resources_csv", "{}", ctx); + + expect(JSON.parse(result.content)).toEqual({ + format: "csv", + lineCount: 2, + csv: "eid,displayName,email,chapter,lcrCents,ucrCents,currency,chargeabilityTarget\nEMP-001,Carol Danvers,carol@example.com,Delivery,8000,12000,EUR,0.8", + }); + }); + + it("requires importData permission for CSV imports", async () => { + const ctx = createToolContext( + { + auditLog: { create: vi.fn() }, + }, + { + userRole: SystemRole.MANAGER, + permissions: [], + }, + ); + + const result = await executeTool( + "import_csv_data", + JSON.stringify({ + entityType: "resources", + rows: [{ eid: "EMP-001", displayName: "Carol Danvers" }], + dryRun: true, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + error: expect.stringContaining(PermissionKey.IMPORT_DATA), + }), + ); + }); + + it("enforces admin access for dispo batch inspection via the backing router", async () => { + const ctx = createToolContext( + { + importBatch: { + findUnique: vi.fn(), + }, + }, + { userRole: SystemRole.MANAGER }, + ); + + const result = await executeTool( + "get_dispo_import_batch", + JSON.stringify({ id: "batch_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + error: expect.stringContaining("Admin role required"), + }), + ); + }); +}); diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index 4b52b5e..b35bed9 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -3,7 +3,7 @@ * Each tool has a JSON schema (for the AI) and an execute function (for the server). */ -import { prisma, Prisma } from "@capakraken/db"; +import { prisma, Prisma, ImportBatchStatus } from "@capakraken/db"; import { checkDuplicateAssignment } from "@capakraken/engine/allocation"; import { computeBudgetStatus } from "@capakraken/engine"; import { @@ -52,12 +52,16 @@ import { } from "../sse/event-bus.js"; import { logger } from "../lib/logger.js"; import { createCallerFactory, type TRPCContext } from "../trpc.js"; +import { auditLogRouter } from "./audit-log.js"; import { chargeabilityReportRouter } from "./chargeability-report.js"; import { computationGraphRouter } from "./computation-graph.js"; +import { dispoRouter } from "./dispo.js"; +import { importExportRouter } from "./import-export.js"; // ─── Mutation tool set for audit logging (EGAI 4.1.3.1 / IAAI 3.6.26) ────── export const MUTATION_TOOLS = new Set([ + "import_csv_data", "create_allocation", "cancel_allocation", "update_allocation_status", "update_timeline_allocation_inline", "apply_timeline_project_shift", "quick_assign_timeline_resource", "batch_quick_assign_timeline_resources", @@ -122,6 +126,9 @@ type ToolExecutor = (params: any, ctx: ToolContext) => Promise; const createChargeabilityReportCaller = createCallerFactory(chargeabilityReportRouter); const createComputationGraphCaller = createCallerFactory(computationGraphRouter); const createTimelineCaller = createCallerFactory(timelineRouter); +const createAuditLogCaller = createCallerFactory(auditLogRouter); +const createImportExportCaller = createCallerFactory(importExportRouter); +const createDispoCaller = createCallerFactory(dispoRouter); // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -729,6 +736,57 @@ function createScopedCallerContext(ctx: ToolContext): TRPCContext { }; } +function formatAuditListEntry(entry: { + id: string; + entityType: string; + entityId: string; + entityName?: string | null; + action: string; + userId?: string | null; + source?: string | null; + summary?: string | null; + createdAt: Date; + user?: { id: string; name: string | null; email: string | null } | null; +}) { + return { + id: entry.id, + entityType: entry.entityType, + entityId: entry.entityId, + entityName: entry.entityName ?? null, + action: entry.action, + userId: entry.userId ?? null, + source: entry.source ?? null, + summary: entry.summary ?? null, + createdAt: entry.createdAt.toISOString(), + user: entry.user + ? { + id: entry.user.id, + name: entry.user.name, + email: entry.user.email, + } + : null, + }; +} + +function formatAuditDetailEntry(entry: { + id: string; + entityType: string; + entityId: string; + entityName?: string | null; + action: string; + userId?: string | null; + source?: string | null; + summary?: string | null; + createdAt: Date; + changes?: unknown; + user?: { id: string; name: string | null; email: string | null } | null; +}) { + return { + ...formatAuditListEntry(entry), + changes: entry.changes ?? null, + }; +} + function filterGraphData< TNode extends { id: string; domain: string }, TLink extends { source: string; target: string }, @@ -2623,7 +2681,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "query_change_history", - description: "Search the activity history for changes to projects, resources, allocations, vacations, or any entity. Can filter by entity type, entity name, user, date range, or action type.", + description: "Search the audit history for changes to projects, resources, allocations, vacations, or any entity. Reuses the real audit log list API. Controller/manager/admin roles only.", parameters: { type: "object", properties: { @@ -2641,7 +2699,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "get_entity_timeline", - description: "Get the complete change history for a specific entity (project, resource, etc). Shows who made what changes and when.", + description: "Get the audit history for a specific entity (project, resource, etc.) via the real audit API. Controller/manager/admin roles only.", parameters: { type: "object", properties: { @@ -2653,6 +2711,145 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, }, }, + { + type: "function", + function: { + name: "export_resources_csv", + description: "Export the current active resource list as CSV via the real import/export router. Controller/manager/admin roles only.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + { + type: "function", + function: { + name: "export_projects_csv", + description: "Export the current project list as CSV via the real import/export router. Controller/manager/admin roles only.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + { + type: "function", + function: { + name: "import_csv_data", + description: "Import CSV-style row data for resources, projects, or allocations via the real import/export router. Requires manager/admin, importData permission, and confirmation.", + parameters: { + type: "object", + properties: { + entityType: { type: "string", enum: ["resources", "projects", "allocations"], description: "Import target entity type." }, + rows: { + type: "array", + description: "CSV rows already parsed to key/value objects.", + items: { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + dryRun: { type: "boolean", description: "Validate only without persisting changes. Default: true." }, + }, + required: ["entityType", "rows"], + }, + }, + }, + { + type: "function", + function: { + name: "list_dispo_import_batches", + description: "List Dispo import batches with pagination and optional status filter via the real dispo router. Admin role required.", + parameters: { + type: "object", + properties: { + status: { type: "string", description: "Optional batch status filter." }, + limit: { type: "integer", description: "Max results. Default: 50, max: 200." }, + cursor: { type: "string", description: "Optional pagination cursor." }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "get_dispo_import_batch", + description: "Get one Dispo import batch including staged record counters via the real dispo router. Admin role required.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Import batch ID." }, + }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "list_audit_log_entries", + description: "List audit log entries with full audit-router filters and cursor pagination. Controller/manager/admin roles only.", + parameters: { + type: "object", + properties: { + entityType: { type: "string", description: "Optional entity type filter." }, + entityId: { type: "string", description: "Optional entity ID filter." }, + userId: { type: "string", description: "Optional user ID filter." }, + action: { type: "string", description: "Optional action filter such as CREATE, UPDATE, DELETE, SHIFT, IMPORT." }, + source: { type: "string", description: "Optional source filter such as ui or assistant." }, + startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." }, + endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." }, + search: { type: "string", description: "Optional case-insensitive search across entity name, summary, and entity type." }, + limit: { type: "integer", description: "Max results. Default: 50, max: 100." }, + cursor: { type: "string", description: "Optional pagination cursor (last seen audit entry ID)." }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "get_audit_log_entry", + description: "Get one audit log entry including the full changes payload. Controller/manager/admin roles only.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Audit log entry ID." }, + }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "get_audit_log_timeline", + description: "Get audit log entries grouped by day for a time window. Controller/manager/admin roles only.", + parameters: { + type: "object", + properties: { + startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." }, + endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." }, + limit: { type: "integer", description: "Max entries. Default: 200, max: 500." }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "get_audit_activity_summary", + description: "Get audit activity totals by entity type, action, and user for a date range. Controller/manager/admin roles only.", + parameters: { + type: "object", + properties: { + startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." }, + endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." }, + }, + }, + }, + }, { type: "function", function: { @@ -8591,48 +8788,31 @@ const executors = { }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 20, 50); const daysBack = params.daysBack ?? 7; - const startDate = new Date(); startDate.setDate(startDate.getDate() - daysBack); - const where: Record = { - createdAt: { gte: startDate }, - }; + const caller = createAuditLogCaller(createScopedCallerContext(ctx)); + const result = await caller.list({ + ...(params.entityType ? { entityType: params.entityType } : {}), + ...(params.userId ? { userId: params.userId } : {}), + ...(params.action ? { action: params.action } : {}), + ...(params.search ? { search: params.search } : {}), + startDate, + limit, + }); - if (params.entityType) where.entityType = params.entityType; - if (params.action) where.action = params.action; - if (params.userId) where.userId = params.userId; - - if (params.search) { - where.OR = [ - { entityName: { contains: params.search, mode: "insensitive" } }, - { summary: { contains: params.search, mode: "insensitive" } }, - { entityType: { contains: params.search, mode: "insensitive" } }, - ]; - } - - const entries = await ctx.db.auditLog.findMany({ - where, - include: { - user: { select: { id: true, name: true, email: true } }, + return { + filters: { + entityType: params.entityType ?? null, + userId: params.userId ?? null, + action: params.action ?? null, + search: params.search ?? null, + daysBack, }, - orderBy: { createdAt: "desc" }, - take: limit, - }); - - if (entries.length === 0) { - return `No changes found in the last ${daysBack} days matching your criteria.`; - } - - const lines = entries.map((e) => { - const who = e.user?.name ?? e.user?.email ?? "System"; - const when = e.createdAt.toISOString().slice(0, 16).replace("T", " "); - const name = e.entityName ? ` "${e.entityName}"` : ""; - const summary = e.summary ? ` — ${e.summary}` : ""; - return `[${when}] ${who}: ${e.action} ${e.entityType}${name}${summary}`; - }); - - return `Found ${entries.length} changes (last ${daysBack} days):\n\n${lines.join("\n")}`; + itemCount: result.items.length, + nextCursor: result.nextCursor ?? null, + items: result.items.map(formatAuditListEntry), + }; }, async get_entity_timeline(params: { @@ -8641,50 +8821,156 @@ const executors = { limit?: number; }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 50, 200); - - const entries = await ctx.db.auditLog.findMany({ - where: { - entityType: params.entityType, - entityId: params.entityId, - }, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - orderBy: { createdAt: "desc" }, - take: limit, + const caller = createAuditLogCaller(createScopedCallerContext(ctx)); + const entries = await caller.getByEntity({ + entityType: params.entityType, + entityId: params.entityId, + limit, }); - if (entries.length === 0) { - return `No change history found for ${params.entityType} ${params.entityId}.`; - } + return { + entityType: params.entityType, + entityId: params.entityId, + entityName: entries[0]?.entityName ?? null, + itemCount: entries.length, + items: entries.map(formatAuditDetailEntry), + }; + }, - const entityName = entries[0]?.entityName ?? params.entityId; + async export_resources_csv(_params: Record, ctx: ToolContext) { + const caller = createImportExportCaller(createScopedCallerContext(ctx)); + const csv = await caller.exportResourcesCSV(); + return { + format: "csv", + lineCount: csv.length === 0 ? 0 : csv.split("\n").length, + csv, + }; + }, - const lines = entries.map((e) => { - const who = e.user?.name ?? e.user?.email ?? "System"; - const when = e.createdAt.toISOString().slice(0, 16).replace("T", " "); - const summary = e.summary ?? e.action; - const source = e.source ? ` (via ${e.source})` : ""; + async export_projects_csv(_params: Record, ctx: ToolContext) { + const caller = createImportExportCaller(createScopedCallerContext(ctx)); + const csv = await caller.exportProjectsCSV(); + return { + format: "csv", + lineCount: csv.length === 0 ? 0 : csv.split("\n").length, + csv, + }; + }, - // Include changed fields summary for UPDATE actions - const changes = e.changes as Record | null; - const diff = changes?.diff as Record | undefined; - let diffSummary = ""; - if (diff && Object.keys(diff).length > 0) { - const fields = Object.entries(diff) - .slice(0, 3) - .map(([k, v]) => `${k}: ${JSON.stringify(v.old)} → ${JSON.stringify(v.new)}`) - .join("; "); - diffSummary = `\n Changed: ${fields}`; - if (Object.keys(diff).length > 3) { - diffSummary += ` (+${Object.keys(diff).length - 3} more)`; - } - } + async import_csv_data(params: { + entityType: "resources" | "projects" | "allocations"; + rows: Array>; + dryRun?: boolean; + }, ctx: ToolContext) { + assertPermission(ctx, PermissionKey.IMPORT_DATA); + const caller = createImportExportCaller(createScopedCallerContext(ctx)); + return caller.importCSV({ + entityType: params.entityType, + rows: params.rows, + dryRun: params.dryRun ?? true, + }); + }, - return `[${when}] ${who}${source}: ${summary}${diffSummary}`; + async list_dispo_import_batches(params: { + status?: ImportBatchStatus; + limit?: number; + cursor?: string; + }, ctx: ToolContext) { + const caller = createDispoCaller(createScopedCallerContext(ctx)); + return caller.listImportBatches({ + ...(params.status ? { status: params.status } : {}), + ...(params.cursor ? { cursor: params.cursor } : {}), + ...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}), + }); + }, + + async get_dispo_import_batch(params: { + id: string; + }, ctx: ToolContext) { + const caller = createDispoCaller(createScopedCallerContext(ctx)); + return caller.getImportBatch({ id: params.id }); + }, + + async list_audit_log_entries(params: { + entityType?: string; + entityId?: string; + userId?: string; + action?: string; + source?: string; + startDate?: string; + endDate?: string; + search?: string; + limit?: number; + cursor?: string; + }, ctx: ToolContext) { + const caller = createAuditLogCaller(createScopedCallerContext(ctx)); + const result = await caller.list({ + ...(params.entityType ? { entityType: params.entityType } : {}), + ...(params.entityId ? { entityId: params.entityId } : {}), + ...(params.userId ? { userId: params.userId } : {}), + ...(params.action ? { action: params.action } : {}), + ...(params.source ? { source: params.source } : {}), + ...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}), + ...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}), + ...(params.search ? { search: params.search } : {}), + ...(params.cursor ? { cursor: params.cursor } : {}), + ...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 100) } : {}), }); - return `Change history for ${params.entityType} "${entityName}" (${entries.length} entries):\n\n${lines.join("\n")}`; + return { + filters: { + entityType: params.entityType ?? null, + entityId: params.entityId ?? null, + userId: params.userId ?? null, + action: params.action ?? null, + source: params.source ?? null, + startDate: params.startDate ?? null, + endDate: params.endDate ?? null, + search: params.search ?? null, + }, + itemCount: result.items.length, + nextCursor: result.nextCursor ?? null, + items: result.items.map(formatAuditListEntry), + }; + }, + + async get_audit_log_entry(params: { + id: string; + }, ctx: ToolContext) { + const caller = createAuditLogCaller(createScopedCallerContext(ctx)); + const entry = await caller.getById({ id: params.id }); + return formatAuditDetailEntry(entry); + }, + + async get_audit_log_timeline(params: { + startDate?: string; + endDate?: string; + limit?: number; + }, ctx: ToolContext) { + const caller = createAuditLogCaller(createScopedCallerContext(ctx)); + const timeline = await caller.getTimeline({ + ...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}), + ...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}), + ...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 500) } : {}), + }); + + return Object.fromEntries( + Object.entries(timeline).map(([dateKey, entries]) => [ + dateKey, + entries.map(formatAuditDetailEntry), + ]), + ); + }, + + async get_audit_activity_summary(params: { + startDate?: string; + endDate?: string; + }, ctx: ToolContext) { + const caller = createAuditLogCaller(createScopedCallerContext(ctx)); + return caller.getActivitySummary({ + ...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}), + ...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}), + }); }, async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) { diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index a8ff8b3..7f2d11e 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -108,6 +108,7 @@ const TOOL_PERMISSION_MAP: Record = { create_estimate: "manageProjects", generate_project_cover: "manageProjects", remove_project_cover: "manageProjects", + import_csv_data: PermissionKey.IMPORT_DATA, // Allocation management create_allocation: "manageAllocations", cancel_allocation: "manageAllocations", @@ -146,6 +147,14 @@ const COST_TOOLS = new Set([ /** Tools that follow controllerProcedure access rules in the main API. */ const CONTROLLER_ONLY_TOOLS = new Set([ + "query_change_history", + "get_entity_timeline", + "export_resources_csv", + "export_projects_csv", + "list_audit_log_entries", + "get_audit_log_entry", + "get_audit_log_timeline", + "get_audit_activity_summary", "get_chargeability_report", "get_resource_computation_graph", "get_project_computation_graph", @@ -153,6 +162,7 @@ const CONTROLLER_ONLY_TOOLS = new Set([ /** Tools that follow managerProcedure access rules in the main API. */ const MANAGER_ONLY_TOOLS = new Set([ + "import_csv_data", "update_timeline_allocation_inline", "apply_timeline_project_shift", "quick_assign_timeline_resource", @@ -162,6 +172,8 @@ const MANAGER_ONLY_TOOLS = new Set([ /** Tools that are intentionally limited to ADMIN because the backing routers are admin-only today. */ const ADMIN_ONLY_TOOLS = new Set([ + "list_dispo_import_batches", + "get_dispo_import_batch", "create_country", "update_country", "create_metro_city",