feat(assistant): extend audit and import parity
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
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<string, unknown> = {
|
||||
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: {
|
||||
const caller = createAuditLogCaller(createScopedCallerContext(ctx));
|
||||
const entries = await caller.getByEntity({
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
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<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) => {
|
||||
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<string, never>, 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<string, unknown> | null;
|
||||
const diff = changes?.diff as Record<string, { old: unknown; new: unknown }> | 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<Record<string, string>>;
|
||||
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) {
|
||||
|
||||
@@ -108,6 +108,7 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user