feat: Activity History system — full audit coverage, UI, AI tools
Infrastructure (Phase 1): - AuditLog schema: add source, entityName, summary fields + index - createAuditEntry() helper: auto-diff, auto-summary, fire-and-forget - auditLog query router: list, getByEntity, getTimeline, getActivitySummary Audit Coverage (Phase 2 — 14 routers, 50+ mutations): - vacation: create, approve, reject, cancel, batch ops (8 mutations) - user: create, updateRole, setPermissions, resetPermissions (5 mutations) - entitlement: set, bulkSet (3 mutations) - client: create, update, delete, batchUpdateSortOrder - org-unit: create, update, deactivate - country: create, update, createCity, updateCity, deleteCity - management-level: createGroup, updateGroup, createLevel, updateLevel, deleteLevel - settings: updateSystemSettings (sensitive fields sanitized), testSmtp - blueprint: create, update, updateRolePresets, delete, batchDelete, setGlobal - rate-card: create, update, deactivate, addLine, updateLine, deleteLine, replaceLines - calculation-rules: create, update, delete - effort-rule: create, update, delete - experience-multiplier: create, update, delete - utilization-category: create, update Admin UI (Phase 3): - /admin/activity-log page with global searchable timeline - Filters: entity type, action, user, date range, text search - Expandable before/after diff view per entry - Summary cards showing top entity types by change count - EntityHistory reusable component for entity detail pages - Sidebar nav link with clock icon AI Assistant (Phase 4): - query_change_history tool: "Who changed project X?" - get_entity_timeline tool: "What happened to resource Y?" Regression: 283 engine + 37 staffing tests pass. TypeScript clean. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import type { PrismaClient, Prisma } from "@planarchy/db";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
type AuditAction = "CREATE" | "UPDATE" | "DELETE" | "SHIFT" | "IMPORT";
|
||||
type AuditSource = "ui" | "api" | "ai" | "import" | "cron";
|
||||
|
||||
interface CreateAuditEntryParams {
|
||||
db: PrismaClient;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
entityName?: string;
|
||||
action: AuditAction;
|
||||
userId?: string;
|
||||
before?: Record<string, unknown>;
|
||||
after?: Record<string, unknown>;
|
||||
source?: AuditSource;
|
||||
summary?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const INTERNAL_FIELDS = new Set(["id", "createdAt", "updatedAt"]);
|
||||
|
||||
/**
|
||||
* Compare two snapshots and return only the changed fields.
|
||||
* Skips internal fields (id, createdAt, updatedAt).
|
||||
* Uses JSON.stringify for nested object comparison.
|
||||
*/
|
||||
export function computeDiff(
|
||||
before: Record<string, unknown>,
|
||||
after: Record<string, unknown>,
|
||||
): Record<string, { old: unknown; new: unknown }> {
|
||||
const diff: Record<string, { old: unknown; new: unknown }> = {};
|
||||
|
||||
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (INTERNAL_FIELDS.has(key)) continue;
|
||||
|
||||
const oldVal = before[key];
|
||||
const newVal = after[key];
|
||||
|
||||
// Compare by JSON serialization to handle nested objects/arrays
|
||||
const oldStr = JSON.stringify(oldVal) ?? "undefined";
|
||||
const newStr = JSON.stringify(newVal) ?? "undefined";
|
||||
|
||||
if (oldStr !== newStr) {
|
||||
diff[key] = { old: oldVal, new: newVal };
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-generate a human-readable summary from the action and diff.
|
||||
*/
|
||||
export function generateSummary(
|
||||
action: string,
|
||||
entityType: string,
|
||||
diff?: Record<string, { old: unknown; new: unknown }>,
|
||||
): string {
|
||||
switch (action) {
|
||||
case "CREATE":
|
||||
return `Created ${entityType}`;
|
||||
case "DELETE":
|
||||
return `Deleted ${entityType}`;
|
||||
case "SHIFT":
|
||||
return `Shifted ${entityType}`;
|
||||
case "IMPORT":
|
||||
return `Imported ${entityType}`;
|
||||
case "UPDATE": {
|
||||
if (!diff || Object.keys(diff).length === 0) {
|
||||
return `Updated ${entityType}`;
|
||||
}
|
||||
const fields = Object.keys(diff);
|
||||
if (fields.length <= 3) {
|
||||
return `Updated ${fields.join(", ")}`;
|
||||
}
|
||||
return `Updated ${fields.slice(0, 3).join(", ")} and ${fields.length - 3} more`;
|
||||
}
|
||||
default:
|
||||
return `${action} ${entityType}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an audit log entry. Fire-and-forget — errors are logged but never thrown.
|
||||
*
|
||||
* If both `before` and `after` are provided, a diff is computed automatically.
|
||||
* If no `summary` is given, one is generated from the action and diff.
|
||||
*/
|
||||
export async function createAuditEntry(params: CreateAuditEntryParams): Promise<void> {
|
||||
try {
|
||||
const { db, entityType, entityId, entityName, action, userId, before, after, source, metadata } = params;
|
||||
|
||||
// Compute diff if both snapshots are available
|
||||
const diff = before && after ? computeDiff(before, after) : undefined;
|
||||
|
||||
// Skip UPDATE entries where nothing actually changed
|
||||
if (action === "UPDATE" && diff && Object.keys(diff).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-generate summary if not provided
|
||||
const summary = params.summary ?? generateSummary(action, entityType, diff);
|
||||
|
||||
// Build the changes JSONB payload
|
||||
const changes: Record<string, unknown> = {};
|
||||
if (before) changes.before = before;
|
||||
if (after) changes.after = after;
|
||||
if (diff) changes.diff = diff;
|
||||
if (metadata) changes.metadata = metadata;
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
entityType,
|
||||
entityId,
|
||||
action,
|
||||
userId: userId ?? null,
|
||||
changes: changes as unknown as Prisma.InputJsonValue,
|
||||
source: source ?? null,
|
||||
entityName: entityName ?? null,
|
||||
summary,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Fire-and-forget: log but never propagate
|
||||
logger.error({ err: error, entityType: params.entityType, entityId: params.entityId }, "Failed to create audit entry");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user