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:
@@ -81,10 +81,19 @@ export class PartnersService {
|
||||
}
|
||||
|
||||
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
|
||||
.findOneAndUpdate({ slug }, dto, { new: true, runValidators: true })
|
||||
.exec()
|
||||
if (!partner) throw new NotFoundException(`Partner "${slug}" not found`)
|
||||
|
||||
const diff = buildPartnerDiff(before, partner, dto)
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'partner.updated',
|
||||
@@ -92,16 +101,7 @@ export class PartnersService {
|
||||
resourceId: String(partner._id),
|
||||
resourceName: partner.name,
|
||||
partnerSlug: partner.slug,
|
||||
metadata: {
|
||||
// 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,
|
||||
),
|
||||
},
|
||||
metadata: { diff },
|
||||
},
|
||||
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