import type { PrismaClient, Prisma } from "@capakraken/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; after?: Record; source?: AuditSource; summary?: string; metadata?: Record; } 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, after: Record, ): Record { const diff: Record = {}; 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 { 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 { try { const { db, entityType, entityId, entityName, action, userId, before, after, source, metadata } = params; const auditLog = (db as Partial).auditLog; if (!auditLog || typeof auditLog.create !== "function") { return; } // 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 = {}; if (before) changes.before = before; if (after) changes.after = after; if (diff) changes.diff = diff; if (metadata) changes.metadata = metadata; await 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"); } }