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
+362 -76
View File
@@ -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: {
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<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) {
+12
View File
@@ -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",