Audit Trail & System Log — who changed what, before/after, when

The whole audit system reduces to one idea:

A global interceptor watches every GraphQL mutation tagged with @AuditMeta({module, action}), captures the actor + the mutation result, computes a field-level diff, and fires an async audit.event. A listener writes one immutable audit_trails row per mutation — with retry, and a FAILED row as a last resort so nothing is silently lost.

Modules opt in by decorating their resolver mutations; they write no audit code themselves. There is a second, legacy "system log" (audit_logs collection, written via Mongoose pre('updateOne') hooks) that predates this system and survives only on the inventory Order and finance Transaction schemas. New work uses the audit trail; the legacy log is documented here for completeness.

Source: BE src/modules/audit-trail, src/modules/log, src/interceptors/audit.interceptor.ts, src/decorators/audit-meta.decorator.ts, src/utils/compute-diff.ts · Admin src/modules/audit-trail, src/pages/audit-trail


1. Purpose & scope

Responsible for:

  • Recording who (user id/email/role, IP, user-agent), what (module, collection, document id/code, action), before/after (field-level diff + optional full snapshots), and when (timestamp) for every opt-in mutation.
  • Persisting that record durably and asynchronously, off the request hot path, so audit failures never break a business operation.
  • Serving that history to the admin: a global filterable page, plus a reusable per-document timeline (AuditHistoryPanel).
  • Enforcing tenant/branch read scoping so a company admin or store user only sees their own slice.

Explicitly does NOT do:

  • It does not fetch the "before" state. The interceptor sets snapshotBefore = null on purpose (see §4.3). The diff is therefore computed against null, so for an UPDATE the "diff" is effectively the full after-state, not a true field delta. This is a known, deliberate limitation noted in the code comment ("snapshotBefore is not fetched here to avoid coupling interceptor to repositories").
  • It does not audit queries — only mutation operations.
  • It does not audit a mutation unless that mutation's resolver method is decorated with @AuditMeta().
  • It does not retain/purge on a schedule — there is no TTL index and no retention job (§9).
  • The legacy log module does not capture actor, snapshots, IP, or tenancy — only {collectionName, action, refId, changes, createdBy} (§7).

This is a platform capability: it is registered @Global() and wired as an APP_INTERCEPTOR, so it applies app-wide once a resolver opts in.


2. Data model

2.1 audit_trails — the audit ledger (new system)

One row per audited mutation. Immutable in practice (the resolver exposes only read queries; there is no update/delete mutation). versionKey: false, timestamps: true (so Mongoose also stamps createdAt/updatedAt).

Source: audit-trail/audit-trail.schema.ts

field type required? description
userId string Actor _id as string, or 'unknown' if no context user.
userEmail string Actor email (user.email).
userRole string Actor user.kind (a UserKindTypes value, e.g. SuperAdmin).
ipAddress string First IP from x-forwarded-for, else req.ip.
userAgent string user-agent request header.
action AuditAction (enum) CREATE / UPDATE / DELETE / STATUS_CHANGE / BULK_UPDATE. Inferred from the resolver method name.
module string Indexed. Logical module from @AuditMeta, e.g. 'payroll', 'order', 'branch'.
collectionName string Indexed. Target collection from @AuditMeta, e.g. 'invoices'.
documentId string Indexed. The affected document _id. From the mutation arg id/_id, else the result's _id.
documentCode string Human-readable doc number — result.documentCode or result.ref.
documentDate number? result.documentDate (unix ms) if present.
diff Record<string, IAuditDiffField> Field-level diff object { field: { before, after } }. Stored as a Mongo Object.
snapshotBefore Record<string, any>? Full before-document. Always null in practice (see §4.3).
snapshotAfter Record<string, any>? Full after-document. Set only when action ∈ meta.snapshots.
companyId string Indexed. Tenant id from context user.
branchId string Indexed. Active branch (user.activeBranchId ?? user.branchId).
fiscalPeriodId string? Reserved; not populated by the interceptor.
status AuditStatus (enum) default SUCCESS SUCCESS if the listener persisted on a normal pass; FAILED if all retries were exhausted.
failureReason string Error message when status = FAILED, else null.
timestamp Date When the audited op occurred (set in the interceptor at request entry).

Compound indexes (declared at the bottom of the schema):

AuditTrailSchema.index({ companyId: 1, timestamp: -1 });          // tenant feed, newest first
AuditTrailSchema.index({ documentId: 1, timestamp: -1 });         // per-document history
AuditTrailSchema.index({ module: 1, companyId: 1, timestamp: -1 });// module feed within tenant

Plus single-field index: true on module, collectionName, documentId, companyId, branchId.

// audit-trail/audit-trail.schema.ts (trimmed)
@ApSchema({ collection: 'audit_trails', timestamps: true, versionKey: false })
export class AuditTrail {
  @Prop() userId: string;
  @Prop() userEmail: string;
  @Prop() userRole: string;
  @Prop() ipAddress: string;
  @Prop() userAgent: string;

  @Prop({ type: String, enum: AuditAction }) action: AuditAction;
  @Prop({ index: true }) module: string;
  @Prop({ index: true }) collectionName: string;
  @Prop({ index: true }) documentId: string;
  @Prop() documentCode: string;
  @Prop() documentDate?: number;

  @Prop({ type: Object }) diff: Record<string, IAuditDiffField>;
  @Prop({ type: Object }) snapshotBefore?: Record<string, any>;
  @Prop({ type: Object }) snapshotAfter?: Record<string, any>;

  @Prop({ index: true }) companyId: string;
  @Prop({ index: true }) branchId: string;
  @Prop() fiscalPeriodId?: string;

  @Prop({ type: String, enum: AuditStatus, default: AuditStatus.SUCCESS }) status: AuditStatus;
  @Prop() failureReason: string;
  @Prop() timestamp: Date;
}

Note: AuditTrail does not extend BaseSchema and is not soft-deletable. It is a flat document keyed only by _id.

2.2 Enums (verbatim)

Source: audit-trail/audit-trail.interface.ts

export enum AuditAction {
  CREATE        = 'CREATE',
  UPDATE        = 'UPDATE',
  DELETE        = 'DELETE',
  STATUS_CHANGE = 'STATUS_CHANGE',
  BULK_UPDATE   = 'BULK_UPDATE',
}

export enum AuditStatus {
  SUCCESS = 'SUCCESS',
  FAILED  = 'FAILED',
}

export interface IAuditDiffField {
  before: any;
  after:  any;
}

Both enums are registered with GraphQL once in audit-trail.dto.ts:

registerEnumType(AuditAction, { name: 'AuditAction' });
registerEnumType(AuditStatus, { name: 'AuditStatus' });

2.3 The event payload contract

The interceptor and listener communicate via IAuditEventPayload (the schema fields minus status/failureReason, which the listener stamps):

// audit-trail/audit-trail.interface.ts
export interface IAuditEventPayload {
  userId: string; userEmail: string; userRole: string;
  ipAddress: string; userAgent: string;
  action: AuditAction; module: string; collectionName: string;
  documentId: string; documentCode: string; documentDate?: number;
  diff: Record<string, IAuditDiffField>;
  snapshotBefore?: Record<string, any> | null;
  snapshotAfter?: Record<string, any> | null;
  companyId: string; branchId: string; fiscalPeriodId?: string;
  timestamp: Date;
}

export interface IAuditMetaOptions {
  module: string;
  collection: string;
  /** Which action types trigger a full document snapshot */
  snapshots: AuditAction[];
}

2.4 audit_logs — the legacy system log

Separate collection, separate (older) shape. Extends BaseSchema and is soft-deletable (mongoose-delete). See §7.

Source: log/log.scheme.ts

field type description
(BaseSchema fields) _id, ref, companyId, branchId, createdBy, createdAt, …
action string Free-text, e.g. "update".
collectionName string Mongo collection name from the hook.
changes string JSON string of { path: { original, changed } }.
message string Optional.
timestamp Date Optional.

(The DTO log.dto.ts also exposes refId, status, kind, description, updates, plus a resolved user field — these are GraphQL-surface fields, several of which the schema does not populate.)


3. API surface

All audit reads are GraphQL queries on the AuditTrailGql ObjectType. There are no audit mutations — the ledger is write-only via the interceptor/listener. The whole resolver is guarded by @ApGqlAuthorize().

Source: audit-trail/audit-trail.resolver.ts, audit-trail/audit-trail.dto.ts, schema.gql

Operation Type Input Returns Auth
auditTrailPage Query AuditTrailPageInput AuditTrailPageResult @ApGqlAuthorize() + tenant/branch scoping by user.kind
auditTrailByDocument Query documentId: String! [AuditTrailGql!]! @ApGqlAuthorize() + in-memory scope filter by user.kind
logPage (legacy) Query AuditLogPageInput AuditLogPageResult @ApGqlAuthorize()
logKinds (legacy) Query [String!]!returns [] (stubbed) @ApGqlAuthorize()

AuditTrailGql (output)

extends BaseDto, so it inherits _id, key, companyId, branchId, ref, documentCode, documentDate, createdAt, updatedAt, createdBy, updatedBy, canDelete, canUpdate, canView, canPost, plus the audit-specific fields. The three change fields are JSON strings (not nested objects) for transport flexibility, and timestamp is unix epoch ms (Float):

type AuditTrailGql {
  # ...BaseDto fields...
  userId: String       userEmail: String     userRole: String
  ipAddress: String    userAgent: String
  action: AuditAction  module: String        collectionName: String
  documentId: String
  diff: String           # JSON.stringify(diff)
  snapshotBefore: String # JSON.stringify(snapshotBefore)
  snapshotAfter: String  # JSON.stringify(snapshotAfter)
  fiscalPeriodId: String
  status: AuditStatus  failureReason: String
  timestamp: Float       # unix epoch ms — "when the audited operation occurred"
}

AuditTrailPageInput (filter)

extends BasePageInput (skip, take, sortBy, sortOrder, keyword), plus:

input AuditTrailPageInput {
  skip: Float! = 0   take: Float! = 10   sortBy: String   sortOrder: SortOrder   keyword: String
  module: String         action: AuditAction
  userId: String         companyId: String        branchId: String
  documentCode: String   # matched case-insensitive ($regex, $options: 'i')
  fromDate: Float        toDate: Float            # unix ms, matched against `timestamp`
  status: AuditStatus
}

type AuditTrailPageResult { totalRecords: Float!   data: [AuditTrailGql!]! }

Filter → Mongo $match mapping (audit-trail.repository.ts → page()): every provided field maps to an exact $match except documentCode (case-insensitive regex) and fromDate/toDate (which build timestamp.$gte/$lte against Dates). Results are $sort: { timestamp: -1 }, then paginated via handlePageFacet(filter) / handlePageResult (the project's standard $facet paginator).


4. Business rules & how an audit entry is produced

This is the core of the system. An audit entry's lifecycle: interceptor captures → emits event → listener persists (with retry).

4.1 The opt-in decorator

A mutation is audited only if its resolver method carries @AuditMeta(). The decorator is a thin SetMetadata wrapper:

// decorators/audit-meta.decorator.ts
export const AUDIT_META_KEY = 'audit_meta';
export const AuditMeta = (options: IAuditMetaOptions) => SetMetadata(AUDIT_META_KEY, options);

Typical usage on a resolver (module + collection + which actions deserve a full snapshotAfter):

// e.g. branch/branch.resolver.ts
@AuditMeta({ module: 'branch', collection: 'branches', snapshots: [AuditAction.CREATE] })
@Mutation(() => Branch, { name: 'createBranch' })
async create(@Args('branch') input: CreateBranchInput) { /* ... */ }

As of writing, @AuditMeta is applied ~490 times across ~75 modules (top contributors: payroll 42, recruitment 21, order 14, attendance 14). See §6 module opt-in.

4.2 The interceptor — capture

Source: interceptors/audit.interceptor.ts. Registered globally as APP_INTERCEPTOR in app.module.ts.

Step by step (AuditInterceptor.intercept):

  1. Mutation-only gate. Build a GqlExecutionContext; if info.operation.operation !== 'mutation', pass through untouched.
  2. Opt-in gate. reflector.get(AUDIT_META_KEY, handler) — if no @AuditMeta metadata, pass through.
  3. Identify the document. documentId = args.id || args._id || null (resolved later from the result if still null).
  4. Infer the action from the handler method name (resolveAction, see §4.4).
  5. Set snapshotBefore = null (deliberate — see §4.3) and timestamp = new Date().
  6. Run the handler, then in tap:
    • next (success): deep-clone the result (JSON.parse(JSON.stringify(result))), compute diff = computeDiff(snapshotBefore /* null */, resultObj), decide needsSnapshot = meta.snapshots.includes(action), build the IAuditEventPayload (actor from contextSvc.user, IP/UA from req, tenancy from the user), and eventEmitter.emit('audit.event', payload).
      • snapshotAfter = needsSnapshot ? resultObj : null; snapshotBefore = needsSnapshot ? null : null (always null).
      • documentCode = resultObj.documentCode || resultObj.ref || ''.
    • error (failure): emit a minimal payload with diff: {}, both snapshots null, and documentCode: '', so even a failed business mutation leaves a trace. (Note: this still emits audit.event; the listener will persist it with status: SUCCESS — the FAILED status is about audit-write failure, not business-op failure.)
  7. Never throws. Both branches are wrapped in try/catch that only logs — audit logic can never propagate an error into the request.
// the success branch, trimmed to the payload assembly
const user = this.contextSvc.user;
const resultObj = result ? JSON.parse(JSON.stringify(result)) : null;
const diff = computeDiff(snapshotBefore /* null */, resultObj);
const needsSnapshot = meta.snapshots.includes(action);

const payload: IAuditEventPayload = {
  userId: user?._id?.toString() || 'unknown',
  userEmail: (user as any)?.email || '',
  userRole: (user as any)?.kind || '',
  ipAddress: req?.headers?.['x-forwarded-for']?.split(',')[0]?.trim() || req?.ip || '',
  userAgent: req?.headers?.['user-agent'] || '',
  action,
  module: meta.module,
  collectionName: meta.collection,
  documentId: documentId || resultObj?._id?.toString() || '',
  documentCode: resultObj?.documentCode || resultObj?.ref || '',
  documentDate: resultObj?.documentDate,
  diff,
  snapshotBefore: needsSnapshot ? snapshotBefore : null, // -> always null
  snapshotAfter:  needsSnapshot ? resultObj : null,
  companyId: user?.companyId?.toString() || '',
  branchId: user?.activeBranchId?.toString() || user?.branchId?.toString() || '',
  timestamp,
};
this.eventEmitter.emit('audit.event', payload);

The interceptor depends on ApContextService (resolved actor/tenant — populated by the context interceptor/middleware that runs earlier) and EventEmitter2 (registered via EventEmitterModule.forRoot() in app.module.ts).

4.3 The "before" snapshot is always null — a deliberate limitation

The interceptor never reads the pre-mutation document:

// snapshotBefore is not fetched here to avoid coupling interceptor to repositories
const snapshotBefore: Record<string, any> | null = null;

Consequences (important when reading the data):

  • snapshotBefore is always null, regardless of action or meta.snapshots.
  • diff is computeDiff(null, after) → it lists every field present on the after object (minus ignored fields) with before: null. So for an UPDATE, the diff is not a true field delta — it is the full after-state framed as "changed". This is the single biggest gotcha (see §9). The original 2026-04-10 plan envisioned listener-side before-fetch; it was not implemented.

4.4 Action inference from method name

resolveAction(handlerName) (handler name lowercased):

Rule (first match wins) Resulting AuditAction
starts with create CREATE
starts with delete DELETE
includes bulk BULK_UPDATE
includes any of status, post, approve, complete, cancel STATUS_CHANGE
otherwise UPDATE

So createBranch → CREATE, deleteBranch → DELETE, postInvoice → STATUS_CHANGE, updateBranch → UPDATE. The action is derived from the method name, not the GraphQL operation name — keep resolver method names conventional.

4.5 The diff utility

Source: utils/compute-diff.ts. Pure function, no DB access.

const IGNORED_FIELDS = new Set([
  '__v', 'updatedAt', 'updatedBy', 'createdAt', 'createdBy',
  'deletedAt', 'deletedBy', 'deleted', '_id', 'id',
]);

export function computeDiff(before, after): Record<string, IAuditDiffField> {
  // union of keys; skip IGNORED_FIELDS;
  // compare each field by safeStringify(value) — arrays & nested objects compared as whole JSON values;
  // record { before, after } only when the serialized values differ.
}
  • Arrays (e.g. order line items) and nested objects are compared as whole values via JSON serialization, not element-by-element.
  • safeStringify guards against circular references (returns "[unserializable]").
  • Audit/housekeeping fields are never reported.

4.6 The listener — durable persistence with retry

Source: audit-trail/audit-trail.listener.ts. @OnEvent('audit.event', { async: true }), so it runs off the request hot path.

  • Up to MAX_RETRIES = 3 attempts to auditSvc.log(payload, SUCCESS).
  • Backoff RETRY_DELAYS_MS = [1000, 5000, 30000]; delays before attempts 2 and 3 (the 30 000 ms entry is intentionally unused — there is no delay before giving up).
  • On success at any attempt → return.
  • If all 3 fail → persist a FAILED row: auditSvc.log(payload, FAILED, lastError.message) — so the event is never silently lost.
  • If even that write throws → logger.error only; never crash the process.
emit('audit.event')
   └─ listener: attempt 1 ──fail──▶ wait 1s ──▶ attempt 2 ──fail──▶ wait 5s ──▶ attempt 3
         │success          │success                 │success                      │
         ▼                 ▼                         ▼                       all failed
   write SUCCESS row   write SUCCESS row        write SUCCESS row     write FAILED row (failureReason)
                                                                            │ throws?
                                                                            ▼
                                                                       logger.error only

4.7 Service & repository

The service is a thin pass-through; the repository owns the Mongo access.

  • AuditTrailService.log(payload, status, failureReason?)repo.create(...).
  • AuditTrailService.page(filter)repo.page(filter) (the $match/$sort/$facet pipeline of §3).
  • AuditTrailService.getDocumentHistory(documentId)repo.getByDocumentId(documentId) (find({documentId}).sort({timestamp:-1}).lean()).
  • AuditTrailRepository.create writes { ...payload, status, failureReason: failureReason || null }.

4.8 Transactionality

The audit write is outside any business transaction by design. The interceptor only fires after the handler's Observable emits, and the listener is async/event-driven. An audit-write failure cannot roll back the business mutation, and a business mutation does not wait for the audit write. This is the intended decoupling.

4.9 State / outcome machine

status has exactly two states, set by the listener, never transitioned afterward:

            persisted on attempt 1..3
audit.event ───────────────────────────▶ SUCCESS
            │
            └─ all 3 attempts failed ───▶ FAILED (failureReason set)

5. Permissions

  • Capture side: no permission gate. Any authenticated mutation that carries @AuditMeta is audited; the actor is whoever ApContextService.user resolves to.

  • Read side: the resolver is @ApGqlAuthorize() (must be authenticated), then applies tenant/branch scoping by user.kind (UserKindTypes from user/user.schema.ts):

    user.kind Scope applied on read
    SuperAdmin Sees everything — no scoping.
    any other (e.g. Company, Admin) Forced companyId = user.companyId (cannot query other companies).
    StoreAdmin or Staff Additionally forced branchId = user.activeBranchId ?? user.branchId.

    auditTrailPage enforces scope in the $match (the resolver overwrites filter.companyId/filter.branchId before calling the service). auditTrailByDocument enforces scope in memory (filters the returned array by companyId, and by branch for store-level users).

There is no per-module/per-action CASL ability for audit reads — visibility is purely the kind-based tenant cut above. (Contrast with the general permissions & access model used elsewhere.)


6. How modules opt in

A module joins the audit trail by decorating its resolver mutations — nothing else. No imports into the module's service/repository, no schema changes.

import { AuditMeta } from 'src/decorators/audit-meta.decorator';
import { AuditAction } from 'src/modules/audit-trail/audit-trail.interface';

@AuditMeta({ module: 'order', collection: 'orders', snapshots: [AuditAction.CREATE, AuditAction.DELETE] })
@Mutation(() => Order, { name: 'createOrder' })
async create(@Args('order') input: CreateOrderInput) { /* ... */ }

Conventions observed across the codebase:

  • module is the logical domain string ('payroll', 'journal', 'stock-transfer', …); collection is the Mongo collection ('orders', 'fiscal_periods', …).
  • snapshots is the list of actions that should also store a full snapshotAfter. Common patterns: [AuditAction.CREATE] on create, [AuditAction.UPDATE] on update, [AuditAction.DELETE] on delete, [AuditAction.STATUS_CHANGE] on post/approve/cancel. (Because snapshotBefore is always null, even a DELETE snapshot captures only what the delete mutation returned.)
  • The auth resolver registers @AuditMeta({ module: 'auth', collection: 'auth', snapshots: [] } as any) to audit logins without a document.

Representative opt-ins (file → module): branch/branch.resolver.tsbranch; exchange/exchange.resolver.tsexchange; fiscal/fiscal.resolver.tsfiscal; kyc/kyc.resolver.tskyc; inventory/order/*order / stock-transfer; finance/*journal / cashbook / payment / account; payroll/*payroll; recruitment/*recruitment. The module: count table in the prep grep shows the full distribution.

The new audit trail and the legacy log can both fire for the same write — e.g. an Order updateOne triggers the legacy audit_logs hook (§7) and, if the resolver mutation is decorated, an audit_trails entry. They are independent.


7. Legacy system log — the audit_logs collection

A pre-existing, hook-based logger that the new system was meant to supersede (2026-04-19 plan "Audit Log Revamp"), but which still exists in zerp on two schemas.

Source: log/log.scheme.ts, log/log.service.ts, log/log.repository.ts, log/log.resolver.ts, inventory/order/order.logs.ts, finance/transaction/transction.logs.ts

How it captures (entirely different mechanism from the interceptor):

  1. A per-feature *.logs.ts provider registers Mongoose pre('updateOne') hooks on specific schemas in its constructor. OrderLogs wires OrderSchema, SalesInvoiceSchema, PurchaseInvoiceSchema, OrderItemSchema, PurchaseInvoiceItemSchema, SalesInvoiceItemSchema; TransactionLogs wires the finance transaction schema.
  2. Each hook calls AuditLogService.saveSchemaUpdate(this), which reads the original doc, compares it to the $set payload, and writes an AuditLog row { collectionName, action: 'update', refId, changes: JSON.stringify(changes), createdBy: contextSvc.user._id }.

Known bug in the legacy hook: saveSchemaUpdate only builds the changes diff inside if (Object.keys(updates).length === 0) — i.e. only when there are no updates. In practice the change-comparison loop never runs for a real update, so it records an empty changes object. Treat the legacy changes field as unreliable.

Read API: logPage(page: AuditLogPageInput) (paginated, sorted createdAt: -1, resolves a user field via UserService) and logKinds (returns [] — stubbed). AuditLog extends BaseSchema and is soft-deletable (mongoose-delete).

What it does not capture vs the new trail: no actor email/role, no IP/user-agent, no snapshotAfter, no action enum (free-text 'update' only), no tenant/branch scoping on read, no retry, and only updateOne (no create/delete). New work should use @AuditMeta; do not extend the legacy hooks.


8. Admin UI

Source: zerp-admin/src/modules/audit-trail/*, zerp-admin/src/pages/audit-trail/index.tsx

Page & route

  • /audit-trailpages/audit-trail/index.tsx: MainLayout (selectedKeys={['audit-trail']}) wrapping AuditTrailContextProviderAuditTrailPage. getServerSideProps runs ApGuardBuilder(session, req).isAuth() (authenticated-only; the actual company/branch cut is enforced server-side by the resolver). There is a single page — no separate company.tsx (the 2026-04-10 plan proposed one; only index.tsx exists).

Data flow (follows the zync-nextjs context standard)

component → useAuditTrailState() → context.tsx → gql/query.ts → Apollo

  • gql/query.ts exposes the single useAuditTrailQuery() hook wrapping two useLazyQuerys (AuditTrailPage, AuditTrailByDocument), both fetchPolicy: 'no-cache', over the shared AuditTrailFields fragment.
  • context.tsx is the only consumer of that hook. It owns entries, totalRecords, filter ({ page, pageSize, ...filters }) and exposes:
    • fetchPage(filter) → maps {page,pageSize} to {skip,take} via mapPageFilter (page 1 → skip 0; page N → (N-1)*pageSize; coerces fromDate/toDate to numbers), calls auditTrailPage, stores data/totalRecords.
    • fetchByDocument(documentId) → returns the per-document array (does not store it).
    • loading, byDocumentLoading.
  • Components import useAuditTrailState() only — they never touch gql/ or Apollo directly.

Components

  • page.tsx (AuditTrailPage) — filter bar (ApTextInput module, ApSelectInput action, ApSelectInput status, two ApDateInputs from/to, Search + Reset buttons, all ignoreFormik) over an ApTable. Columns: Timestamp (fmtDateTime), Module, Action (color Tag), Document Code, Actor (userEmail || userId), Status (green/red Tag). Row click opens the detail drawer. useEffect(fetchPage, [filter]) refetches on any filter change; pagination via mapTablePagination.
  • components/AuditDetailDrawer.tsx — 640px right Drawer. Descriptions block (Timestamp, Action tag, Module, Document Code, Actor, Role, IP, Status tag, and Failure Reason when present), then a Field Changes DiffTable, then Snapshots (rendered only when a before/after snapshot exists).
  • components/DiffTable.tsxJSON.parses the diff string into { field: {before, after} } rows; 3 columns Field / Before (red mono) / After (green mono); null rendered as a Tag. Graceful fallbacks for empty/unparseable diff.
  • components/SnapshotViewer.tsxCollapse panel showing pretty-printed JSON of the parsed snapshot; renders nothing if the snapshot is null/unparseable.
  • components/AuditHistoryPanel.tsx — reusable per-document timeline. Props { documentId, module }. On mount calls fetchByDocument(documentId), reverses to chronological order, renders an antd Timeline (color by action, actor + timestamp + inline DiffTable); row click opens the same AuditDetailDrawer. Intended to be embedded as a "History" tab on financial document detail pages. (Note: it must be rendered inside an AuditTrailContextProvider since it depends on useAuditTrailState.)

Action color map (used in page, drawer, panel)

CREATE → green, UPDATE → blue, DELETE → red, STATUS_CHANGE → orange, BULK_UPDATE → purple.

UI ↔︎ schema field mismatches to know

  • The admin model.ts IAuditTrail declares documentDate: number and branchId, and the fragment requests them — branchId and documentDate exist on AuditTrailGql. Good.
  • The 2026-04-10 plan referenced a storeId field and a company.tsx page; the shipped code uses branchId and a single page. Trust the code.

9. Retention

None implemented. The audit_trails schema has no TTL index and there is no cron/cleanup job. audit_logs is soft-deletable (mongoose-delete) but nothing purges it either, and the legacy repo's seed() only deletes rows missing a kind/with kind: 'AuditLog' (a one-off cleanup, not retention). Audit data grows unbounded — a retention policy (TTL on timestamp, or an archival job) is a TODO if storage becomes a concern.


10. Dependencies & integrations

  • ApContextService (src/context) — source of actor (user), companyId, activeBranchId/branchId. Must be populated before the audit interceptor runs (the context interceptor/middleware runs earlier in the chain).
  • EventEmitter2 (@nestjs/event-emitter, EventEmitterModule.forRoot() in app.module.ts) — decouples capture from persistence via the in-process audit.event event.
  • Reflector (@nestjs/core) — reads @AuditMeta metadata off the handler.
  • AuthModule — imported by AuditTrailModule (forwardRef) for @ApGqlAuthorize(); the legacy AuditLogModule also imports UserModule to resolve the user field.
  • Pagination helpers handlePageFacet/handlePageResult from src/core (shared $facet paginator).
  • No external services — no S3, mail, queue, or Redis. (The 2026-04-10 plan mentioned "Redis-backed retry"; the shipped listener uses in-memory setTimeout backoff, not Redis.)
  • Module registration: both AuditTrailModule and AuditLogModule are @Global(). AuditInterceptor is wired as APP_INTERCEPTOR in app.module.ts.

Cross-links: architecture · multi-tenancy · auth · permissions & access · workflow & approval engine


11. Gotchas & project-specific rules

  1. snapshotBefore is always null; diff is not a true delta. For UPDATE, the diff lists the full after-state with before: null. Don't read the audit diff as "fields that changed" — read it as "fields present after the change." The legacy audit_logs had a true before/after changes shape but its diff loop is bugged (§7) — so neither store gives a clean before→after delta today.
  2. Capture is opt-in. A mutation with no @AuditMeta produces no audit entry. Queries are never audited.
  3. Action is inferred from the method name. Rename a resolver method from postX/createX/deleteX and the recorded action changes. Keep names conventional (§4.4).
  4. status: FAILED means the audit write failed, not the business op. A failed business mutation still emits an audit.event (error branch) that is normally persisted as SUCCESS.
  5. Audit never blocks or breaks a request. All interceptor/listener paths swallow their own errors; an audit outage silently drops entries (best-effort, after 3 retries → FAILED row).
  6. Two systems, two collections. New trail = audit_trails (@AuditMeta + interceptor); legacy log = audit_logs (*.logs.ts Mongoose hooks on Order/Transaction only). They can both fire for one write and are independent.
  7. Read scope is kind-based and partly in-memory. auditTrailByDocument filters in JS after the query, so it still reads all matching rows from Mongo before cutting scope.
  8. Change fields are JSON strings over GraphQL. diff/snapshotBefore/snapshotAfter are JSON.stringify'd by the resolver's mapEntry and must be JSON.parsed on the client (the admin DiffTable/SnapshotViewer do this).
  9. timestamp is unix epoch ms (Float). Set in the interceptor at request entry, not by Mongo. The schema also has timestamps: true so createdAt/updatedAt exist too, but timestamp is the authoritative "when it happened."
  10. No retention. Both collections grow unbounded (§9).