/** * The audit trail: who changed what, and when. * * The diff is computed here rather than by each caller so every entry has the * same shape, and so a field can never be recorded as "changed" when only its * representation moved. */ import type { InputJsonValue } from '@/lib/generated/prisma/internal/prismaNamespace'; import { prisma } from '@/lib/db'; export type FieldChange = { before: unknown; after: unknown }; export type Diff = Record; /** * Field-by-field difference between two snapshots. * * Returns `null` when nothing moved, so a no-op save leaves no entry: a log * full of empty changes is a log nobody reads. * * Values are compared by their JSON form, which is what makes `slots` arrays * and dates compare sensibly rather than by identity. */ export function diffOf( before: Record | null, after: Record | null, ): Diff | null { const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]); const diff: Diff = {}; for (const key of keys) { const previous = before?.[key]; const next = after?.[key]; if (!sameValue(previous, next)) { diff[key] = { before: normalise(previous), after: normalise(next) }; } } return Object.keys(diff).length > 0 ? diff : null; } function sameValue(a: unknown, b: unknown): boolean { return JSON.stringify(normalise(a)) === JSON.stringify(normalise(b)); } /** Dates are compared and stored in ISO form; undefined and null are the same absence. */ function normalise(value: unknown): unknown { if (value === undefined) { return null; } if (value instanceof Date) { return value.toISOString(); } return value; } export type AuditEntry = { userEmail: string; action: string; entity: string; entityId?: string | null; diff?: Diff | null; }; /** * Records an entry. Never throws: losing the trail is bad, but refusing the * user's change because the trail could not be written is worse. */ export async function recordAudit(entry: AuditEntry): Promise { try { await prisma.auditLog.create({ data: { userEmail: entry.userEmail, action: entry.action, entity: entry.entity, entityId: entry.entityId ?? null, // Prisma's JSON input type does not accept an arbitrary record; the // diff is plain JSON by construction, so the assertion is safe. diff: entry.diff ? (entry.diff as unknown as InputJsonValue) : undefined, }, }); } catch (error) { console.error('Could not write the audit entry', entry.action, entry.entity, error); } }