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:
@@ -92,6 +92,26 @@ function formatMetaValue(v: unknown): string {
|
|||||||
return String(v)
|
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) ────────
|
// ── Tamper-evidence (Phase 3) + cold-storage archives (Phase 4) ────────
|
||||||
interface VerifyReport {
|
interface VerifyReport {
|
||||||
ok: boolean
|
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">
|
<tr v-if="expanded.has(e._id) && hasDetails(e)" class="detail-row">
|
||||||
<td></td>
|
<td></td>
|
||||||
<td colspan="7">
|
<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">
|
<template v-for="(value, key) in e.metadata" :key="key">
|
||||||
<dt><Mono dim>{{ key }}</Mono></dt>
|
<dt><Mono dim>{{ key }}</Mono></dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -495,6 +531,23 @@ tr.detail-row td {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text);
|
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; }
|
.name { font-size: 12px; font-weight: 500; }
|
||||||
.action { font-weight: 500; }
|
.action { font-weight: 500; }
|
||||||
.target { font-size: 12px; color: var(--text-dim); }
|
.target { font-size: 12px; color: var(--text-dim); }
|
||||||
|
|||||||
@@ -81,10 +81,19 @@ export class PartnersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(slug: string, dto: UpdatePartnerDto, actor?: AuditActor): Promise<PartnerDocument> {
|
async update(slug: string, dto: UpdatePartnerDto, actor?: AuditActor): Promise<PartnerDocument> {
|
||||||
|
// Read first so we can record a before/after diff. The extra round-trip is
|
||||||
|
// cheap — partner updates are not hot-path — and gives the audit log
|
||||||
|
// enough context for an operator to reconstruct what changed without
|
||||||
|
// diffing Mongo backups.
|
||||||
|
const before = await this.partnerModel.findOne({ slug }).exec()
|
||||||
|
if (!before) throw new NotFoundException(`Partner "${slug}" not found`)
|
||||||
|
|
||||||
const partner = await this.partnerModel
|
const partner = await this.partnerModel
|
||||||
.findOneAndUpdate({ slug }, dto, { new: true, runValidators: true })
|
.findOneAndUpdate({ slug }, dto, { new: true, runValidators: true })
|
||||||
.exec()
|
.exec()
|
||||||
if (!partner) throw new NotFoundException(`Partner "${slug}" not found`)
|
if (!partner) throw new NotFoundException(`Partner "${slug}" not found`)
|
||||||
|
|
||||||
|
const diff = buildPartnerDiff(before, partner, dto)
|
||||||
void this.audit.record(
|
void this.audit.record(
|
||||||
{
|
{
|
||||||
action: 'partner.updated',
|
action: 'partner.updated',
|
||||||
@@ -92,16 +101,7 @@ export class PartnersService {
|
|||||||
resourceId: String(partner._id),
|
resourceId: String(partner._id),
|
||||||
resourceName: partner.name,
|
resourceName: partner.name,
|
||||||
partnerSlug: partner.slug,
|
partnerSlug: partner.slug,
|
||||||
metadata: {
|
metadata: { diff },
|
||||||
// class-validator's ValidationPipe(transform: true) instantiates the
|
|
||||||
// DTO with every @IsOptional() property defined (set to undefined
|
|
||||||
// when absent from the body). Filtering keeps the audit log honest —
|
|
||||||
// `changes` reflects what the operator actually sent, not the DTO
|
|
||||||
// shape.
|
|
||||||
changes: Object.keys(dto as Record<string, unknown>).filter(
|
|
||||||
(k) => (dto as Record<string, unknown>)[k] !== undefined,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
actor,
|
actor,
|
||||||
)
|
)
|
||||||
@@ -129,3 +129,27 @@ export class PartnersService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a { field: { from, to } } diff for the partner update audit event.
|
||||||
|
// Only keys actually present in the DTO body are considered (DTOs from class-
|
||||||
|
// validator's ValidationPipe carry every @IsOptional() field, so we filter on
|
||||||
|
// `undefined`), and only fields whose serialized representation actually
|
||||||
|
// differs are emitted — saving an unchanged form should not produce a row of
|
||||||
|
// no-op diffs in the audit log.
|
||||||
|
function buildPartnerDiff(
|
||||||
|
before: PartnerDocument,
|
||||||
|
after: PartnerDocument,
|
||||||
|
dto: UpdatePartnerDto,
|
||||||
|
): Record<string, { from: unknown; to: unknown }> {
|
||||||
|
const beforeObj = before.toObject({ flattenMaps: true })
|
||||||
|
const afterObj = after.toObject({ flattenMaps: true })
|
||||||
|
const diff: Record<string, { from: unknown; to: unknown }> = {}
|
||||||
|
for (const key of Object.keys(dto) as (keyof UpdatePartnerDto)[]) {
|
||||||
|
if ((dto as Record<string, unknown>)[key] === undefined) continue
|
||||||
|
const from = (beforeObj as Record<string, unknown>)[key]
|
||||||
|
const to = (afterObj as Record<string, unknown>)[key]
|
||||||
|
if (JSON.stringify(from) === JSON.stringify(to)) continue
|
||||||
|
diff[key] = { from: from ?? null, to: to ?? null }
|
||||||
|
}
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user