feat(audit): record before/after diff for partner updates

partner.updated events previously recorded only the field names that
changed (metadata.changes). Now they record metadata.diff — a
{ field: { from, to } } map — by reading the partner before the
findOneAndUpdate and comparing serialized values. Only fields that
actually differ make it into the diff, so a save-without-changes
records an empty diff instead of every DTO key.

The operator audit row's expanded panel renders the diff as a small
inline table (field · from → to). Older audit rows that still carry
metadata.changes fall back to the original chip layout so historical
events stay readable.
This commit is contained in:
Ronni Baslund
2026-05-24 22:20:50 +02:00
parent d3376d7f4a
commit 0e0cf8d90b
2 changed files with 88 additions and 11 deletions
+54 -1
View File
@@ -92,6 +92,26 @@ function formatMetaValue(v: unknown): string {
return String(v)
}
// Compact human-readable rendering of a single side of a diff. Objects collapse
// to JSON; null / undefined / empty string render as a dim em-dash so the
// "before" side of a freshly populated field reads as clearly empty.
function formatDiffValue(v: unknown): string {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
}
// `metadata.diff` shape for partner.updated (and future *.updated actions):
// { fieldName: { from, to } }. Type-narrow safely; old audit rows that only
// carried `metadata.changes: string[]` are still rendered as plain chips by
// the fallback branch in the template.
interface DiffEntry { from: unknown; to: unknown }
function diffEntries(meta: Record<string, unknown> | undefined): [string, DiffEntry][] {
const d = meta?.diff
if (!d || typeof d !== 'object') return []
return Object.entries(d as Record<string, DiffEntry>)
}
// ── Tamper-evidence (Phase 3) + cold-storage archives (Phase 4) ────────
interface VerifyReport {
ok: boolean
@@ -273,7 +293,23 @@ function fmtRelative(iso: string | null | undefined): string {
<tr v-if="expanded.has(e._id) && hasDetails(e)" class="detail-row">
<td></td>
<td colspan="7">
<dl class="meta">
<!-- New format: { diff: { field: {from, to} } } render as a
table of before after rows so the operator can read the
change inline. -->
<table v-if="diffEntries(e.metadata).length" class="diff-table">
<tbody>
<tr v-for="[field, entry] in diffEntries(e.metadata)" :key="field">
<td class="diff-key"><Mono>{{ field }}</Mono></td>
<td class="diff-from"><Mono dim>{{ formatDiffValue(entry.from) }}</Mono></td>
<td class="diff-arrow"><Mono dim></Mono></td>
<td class="diff-to"><Mono>{{ formatDiffValue(entry.to) }}</Mono></td>
</tr>
</tbody>
</table>
<!-- Generic metadata renderer covers older events that only
have metadata.changes (string[]) plus all non-update
actions whose metadata is free-form key/values. -->
<dl v-else class="meta">
<template v-for="(value, key) in e.metadata" :key="key">
<dt><Mono dim>{{ key }}</Mono></dt>
<dd>
@@ -495,6 +531,23 @@ tr.detail-row td {
font-size: 11px;
color: var(--text);
}
/* before → after diff table inside the expanded row */
.diff-table {
width: auto;
border-collapse: collapse;
margin: 0;
}
.diff-table td {
padding: 4px 14px 4px 0;
border: 0;
font-size: 12px;
vertical-align: top;
}
.diff-key { font-weight: 500; min-width: 110px; }
.diff-from { color: var(--text-mute); max-width: 360px; word-break: break-word; }
.diff-arrow { width: 18px; text-align: center; }
.diff-to { max-width: 360px; word-break: break-word; }
.name { font-size: 12px; font-weight: 500; }
.action { font-weight: 500; }
.target { font-size: 12px; color: var(--text-dim); }