feat(assistant): extend audit and import parity
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user