feat(assistant): extend audit and import parity

This commit is contained in:
2026-03-29 12:56:29 +02:00
parent 47e4d701ff
commit 00b936fa1f
6 changed files with 699 additions and 86 deletions
+23 -10
View File
@@ -70,6 +70,13 @@ Es gibt aktuell vier Permission-/Scope-Ebenen:
- `get_project_timeline_context` - `get_project_timeline_context`
- `preview_project_shift` - `preview_project_shift`
- basiert bereits auf denselben Timeline-Readmodels/Shift-Preview-Helfern wie die UI - 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 - Estimates: nur Suche, Detail und Anlegen, aber kein voller Lifecycle
- Reports: `run_report` ist flexibel, deckt aber nicht die spezialisierten Report-/Analyse-Readmodels ab - Reports: `run_report` ist flexibel, deckt aber nicht die spezialisierten Report-/Analyse-Readmodels ab
- Chargeability / Transparenz: - Chargeability / Transparenz:
@@ -77,7 +84,10 @@ Es gibt aktuell vier Permission-/Scope-Ebenen:
- `get_resource_computation_graph` - `get_resource_computation_graph`
- `get_project_computation_graph` - `get_project_computation_graph`
- damit sind die wichtigsten tiefen Herleitungen fuer Chargeability, SAH, Feiertagsabzuege und Projektkalkulation jetzt auch im Assistant verfuegbar - 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 - Notification/Tasking: Kernfaelle vorhanden, aber keine volle Reminder-/Task-/Notification-Paritaet
- Country-/Location-Stammdaten: nur lesend und auch dort nur flach - Country-/Location-Stammdaten: nur lesend und auch dort nur flach
- Insights: Summary-Ebene vorhanden, Drilldowns fehlen - Insights: Summary-Ebene vorhanden, Drilldowns fehlen
@@ -220,12 +230,18 @@ Konsequenz:
Aktuell im Assistant vorhanden: Aktuell im Assistant vorhanden:
- vereinfachte History-Suche (`query_change_history`) - vereinfachte History-Suche (`query_change_history`) jetzt auf Basis von `auditLogRouter.list`
- Entity-History (`get_entity_timeline`) - 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: 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 ### Admin- und Systemkonfiguration
@@ -311,23 +327,20 @@ Fehlend:
### Komplett fehlende Router-Paritaet ### Komplett fehlende Router-Paritaet
- `importExport`
- `chargeabilityReport`
- `computationGraph`
- `settings` - `settings`
- `systemRoleConfig` - `systemRoleConfig`
- `webhook` - `webhook`
- `dispo`
### Deutlich unvollstaendige Router-Paritaet ### 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` - `vacation`
- `estimate` - `estimate`
- `notification` - `notification`
- `user` - `user`
- `country` - `country`
- `auditLog`
- `insights` - `insights`
- `scenario` - `scenario`
- `resource` - `resource`
@@ -222,9 +222,25 @@ describe("assistant router tool gating", () => {
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.USER); ], 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_chargeability_report");
expect(controllerNames).toContain("get_resource_computation_graph"); expect(controllerNames).toContain("get_resource_computation_graph");
expect(controllerNames).toContain("get_project_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_chargeability_report");
expect(userNames).not.toContain("get_resource_computation_graph"); expect(userNames).not.toContain("get_resource_computation_graph");
expect(userNames).not.toContain("get_project_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"); 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", () => { it("keeps holiday calendar mutation tools admin-only while leaving read tools available", () => {
const adminNames = getToolNames([], SystemRole.ADMIN); const adminNames = getToolNames([], SystemRole.ADMIN);
const managerNames = getToolNames([], SystemRole.MANAGER); const managerNames = getToolNames([], SystemRole.MANAGER);
@@ -492,6 +526,18 @@ describe("assistant router tool gating", () => {
expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects"); expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects");
expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role"); expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role");
expect(toolDescriptions.get("create_holiday_calendar_entry")).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("controller/manager/admin");
expect(toolDescriptions.get("get_chargeability_report")).toContain("viewCosts"); expect(toolDescriptions.get("get_chargeability_report")).toContain("viewCosts");
expect(toolDescriptions.get("get_resource_computation_graph")).toContain("useAssistantAdvancedTools"); expect(toolDescriptions.get("get_resource_computation_graph")).toContain("useAssistantAdvancedTools");
@@ -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<typeof import("@capakraken/application")>();
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<string, unknown>,
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"),
}),
);
});
});
@@ -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<typeof import("@capakraken/application")>();
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<string, unknown>,
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"),
}),
);
});
});
+362 -76
View File
@@ -3,7 +3,7 @@
* Each tool has a JSON schema (for the AI) and an execute function (for the server). * 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 { checkDuplicateAssignment } from "@capakraken/engine/allocation";
import { computeBudgetStatus } from "@capakraken/engine"; import { computeBudgetStatus } from "@capakraken/engine";
import { import {
@@ -52,12 +52,16 @@ import {
} from "../sse/event-bus.js"; } from "../sse/event-bus.js";
import { logger } from "../lib/logger.js"; import { logger } from "../lib/logger.js";
import { createCallerFactory, type TRPCContext } from "../trpc.js"; import { createCallerFactory, type TRPCContext } from "../trpc.js";
import { auditLogRouter } from "./audit-log.js";
import { chargeabilityReportRouter } from "./chargeability-report.js"; import { chargeabilityReportRouter } from "./chargeability-report.js";
import { computationGraphRouter } from "./computation-graph.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) ────── // ─── Mutation tool set for audit logging (EGAI 4.1.3.1 / IAAI 3.6.26) ──────
export const MUTATION_TOOLS = new Set([ export const MUTATION_TOOLS = new Set([
"import_csv_data",
"create_allocation", "cancel_allocation", "update_allocation_status", "create_allocation", "cancel_allocation", "update_allocation_status",
"update_timeline_allocation_inline", "apply_timeline_project_shift", "update_timeline_allocation_inline", "apply_timeline_project_shift",
"quick_assign_timeline_resource", "batch_quick_assign_timeline_resources", "quick_assign_timeline_resource", "batch_quick_assign_timeline_resources",
@@ -122,6 +126,9 @@ type ToolExecutor = (params: any, ctx: ToolContext) => Promise<unknown>;
const createChargeabilityReportCaller = createCallerFactory(chargeabilityReportRouter); const createChargeabilityReportCaller = createCallerFactory(chargeabilityReportRouter);
const createComputationGraphCaller = createCallerFactory(computationGraphRouter); const createComputationGraphCaller = createCallerFactory(computationGraphRouter);
const createTimelineCaller = createCallerFactory(timelineRouter); const createTimelineCaller = createCallerFactory(timelineRouter);
const createAuditLogCaller = createCallerFactory(auditLogRouter);
const createImportExportCaller = createCallerFactory(importExportRouter);
const createDispoCaller = createCallerFactory(dispoRouter);
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── 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< function filterGraphData<
TNode extends { id: string; domain: string }, TNode extends { id: string; domain: string },
TLink extends { source: string; target: string }, TLink extends { source: string; target: string },
@@ -2623,7 +2681,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function", type: "function",
function: { function: {
name: "query_change_history", 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: { parameters: {
type: "object", type: "object",
properties: { properties: {
@@ -2641,7 +2699,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function", type: "function",
function: { function: {
name: "get_entity_timeline", 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: { parameters: {
type: "object", type: "object",
properties: { 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", type: "function",
function: { function: {
@@ -8591,48 +8788,31 @@ const executors = {
}, ctx: ToolContext) { }, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 20, 50); const limit = Math.min(params.limit ?? 20, 50);
const daysBack = params.daysBack ?? 7; const daysBack = params.daysBack ?? 7;
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() - daysBack); startDate.setDate(startDate.getDate() - daysBack);
const where: Record<string, unknown> = { const caller = createAuditLogCaller(createScopedCallerContext(ctx));
createdAt: { gte: startDate }, 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; return {
if (params.action) where.action = params.action; filters: {
if (params.userId) where.userId = params.userId; entityType: params.entityType ?? null,
userId: params.userId ?? null,
if (params.search) { action: params.action ?? null,
where.OR = [ search: params.search ?? null,
{ entityName: { contains: params.search, mode: "insensitive" } }, daysBack,
{ 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 } },
}, },
orderBy: { createdAt: "desc" }, itemCount: result.items.length,
take: limit, nextCursor: result.nextCursor ?? null,
}); items: result.items.map(formatAuditListEntry),
};
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")}`;
}, },
async get_entity_timeline(params: { async get_entity_timeline(params: {
@@ -8641,50 +8821,156 @@ const executors = {
limit?: number; limit?: number;
}, ctx: ToolContext) { }, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 50, 200); const limit = Math.min(params.limit ?? 50, 200);
const caller = createAuditLogCaller(createScopedCallerContext(ctx));
const entries = await ctx.db.auditLog.findMany({ const entries = await caller.getByEntity({
where: { entityType: params.entityType,
entityType: params.entityType, entityId: params.entityId,
entityId: params.entityId, limit,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: limit,
}); });
if (entries.length === 0) { return {
return `No change history found for ${params.entityType} ${params.entityId}.`; 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<string, never>, 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) => { async export_projects_csv(_params: Record<string, never>, ctx: ToolContext) {
const who = e.user?.name ?? e.user?.email ?? "System"; const caller = createImportExportCaller(createScopedCallerContext(ctx));
const when = e.createdAt.toISOString().slice(0, 16).replace("T", " "); const csv = await caller.exportProjectsCSV();
const summary = e.summary ?? e.action; return {
const source = e.source ? ` (via ${e.source})` : ""; format: "csv",
lineCount: csv.length === 0 ? 0 : csv.split("\n").length,
csv,
};
},
// Include changed fields summary for UPDATE actions async import_csv_data(params: {
const changes = e.changes as Record<string, unknown> | null; entityType: "resources" | "projects" | "allocations";
const diff = changes?.diff as Record<string, { old: unknown; new: unknown }> | undefined; rows: Array<Record<string, string>>;
let diffSummary = ""; dryRun?: boolean;
if (diff && Object.keys(diff).length > 0) { }, ctx: ToolContext) {
const fields = Object.entries(diff) assertPermission(ctx, PermissionKey.IMPORT_DATA);
.slice(0, 3) const caller = createImportExportCaller(createScopedCallerContext(ctx));
.map(([k, v]) => `${k}: ${JSON.stringify(v.old)}${JSON.stringify(v.new)}`) return caller.importCSV({
.join("; "); entityType: params.entityType,
diffSummary = `\n Changed: ${fields}`; rows: params.rows,
if (Object.keys(diff).length > 3) { dryRun: params.dryRun ?? true,
diffSummary += ` (+${Object.keys(diff).length - 3} more)`; });
} },
}
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) { async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) {
+12
View File
@@ -108,6 +108,7 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
create_estimate: "manageProjects", create_estimate: "manageProjects",
generate_project_cover: "manageProjects", generate_project_cover: "manageProjects",
remove_project_cover: "manageProjects", remove_project_cover: "manageProjects",
import_csv_data: PermissionKey.IMPORT_DATA,
// Allocation management // Allocation management
create_allocation: "manageAllocations", create_allocation: "manageAllocations",
cancel_allocation: "manageAllocations", cancel_allocation: "manageAllocations",
@@ -146,6 +147,14 @@ const COST_TOOLS = new Set([
/** Tools that follow controllerProcedure access rules in the main API. */ /** Tools that follow controllerProcedure access rules in the main API. */
const CONTROLLER_ONLY_TOOLS = new Set([ 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_chargeability_report",
"get_resource_computation_graph", "get_resource_computation_graph",
"get_project_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. */ /** Tools that follow managerProcedure access rules in the main API. */
const MANAGER_ONLY_TOOLS = new Set([ const MANAGER_ONLY_TOOLS = new Set([
"import_csv_data",
"update_timeline_allocation_inline", "update_timeline_allocation_inline",
"apply_timeline_project_shift", "apply_timeline_project_shift",
"quick_assign_timeline_resource", "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. */ /** Tools that are intentionally limited to ADMIN because the backing routers are admin-only today. */
const ADMIN_ONLY_TOOLS = new Set([ const ADMIN_ONLY_TOOLS = new Set([
"list_dispo_import_batches",
"get_dispo_import_batch",
"create_country", "create_country",
"update_country", "update_country",
"create_metro_city", "create_metro_city",