Files
CapaKraken/packages/api/src/lib/audit.ts
T

136 lines
4.0 KiB
TypeScript

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<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;
const auditLog = (db as Partial<PrismaClient>).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<string, unknown> = {};
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");
}
}