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 asyncaudit.event. A listener writes one immutableaudit_trailsrow per mutation — with retry, and aFAILEDrow 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_logscollection, written via Mongoosepre('updateOne')hooks) that predates this system and survives only on the inventoryOrderand financeTransactionschemas. 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 = nullon purpose (see §4.3). The diff is therefore computed againstnull, so for anUPDATEthe "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
mutationoperations. - 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
logmodule 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 tenantPlus 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:
AuditTraildoes not extendBaseSchemaand 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):
- Mutation-only gate. Build a
GqlExecutionContext; ifinfo.operation.operation !== 'mutation', pass through untouched. - Opt-in gate.
reflector.get(AUDIT_META_KEY, handler)— if no@AuditMetametadata, pass through. - Identify the document.
documentId = args.id || args._id || null(resolved later from the result if still null). - Infer the action from the handler method name (
resolveAction, see §4.4). - Set
snapshotBefore = null(deliberate — see §4.3) andtimestamp = new Date(). - Run the handler, then in
tap:next(success): deep-clone the result (JSON.parse(JSON.stringify(result))), computediff = computeDiff(snapshotBefore /* null */, resultObj), decideneedsSnapshot = meta.snapshots.includes(action), build theIAuditEventPayload(actor fromcontextSvc.user, IP/UA fromreq, tenancy from the user), andeventEmitter.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 withdiff: {}, both snapshotsnull, anddocumentCode: '', so even a failed business mutation leaves a trace. (Note: this still emitsaudit.event; the listener will persist it withstatus: SUCCESS— theFAILEDstatus is about audit-write failure, not business-op failure.)
- 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) andEventEmitter2(registered viaEventEmitterModule.forRoot()inapp.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):
snapshotBeforeis alwaysnull, regardless of action ormeta.snapshots.diffiscomputeDiff(null, after)→ it lists every field present on the after object (minus ignored fields) withbefore: null. So for anUPDATE, thediffis not a true field delta — it is the full after-state framed as "changed". This is the single biggest gotcha (see §9). The original2026-04-10plan 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.
safeStringifyguards 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 = 3attempts toauditSvc.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
FAILEDrow:auditSvc.log(payload, FAILED, lastError.message)— so the event is never silently lost. - If even that write throws →
logger.erroronly; 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/$facetpipeline of §3).AuditTrailService.getDocumentHistory(documentId)→repo.getByDocumentId(documentId)(find({documentId}).sort({timestamp:-1}).lean()).AuditTrailRepository.createwrites{ ...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
@AuditMetais audited; the actor is whoeverApContextService.userresolves to.Read side: the resolver is
@ApGqlAuthorize()(must be authenticated), then applies tenant/branch scoping byuser.kind(UserKindTypesfromuser/user.schema.ts):user.kindScope applied on read SuperAdminSees everything — no scoping. any other (e.g. Company,Admin)Forced companyId = user.companyId(cannot query other companies).StoreAdminorStaffAdditionally forced branchId = user.activeBranchId ?? user.branchId.auditTrailPageenforces scope in the$match(the resolver overwritesfilter.companyId/filter.branchIdbefore calling the service).auditTrailByDocumentenforces scope in memory (filters the returned array bycompanyId, 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:
moduleis the logical domain string ('payroll','journal','stock-transfer', …);collectionis the Mongo collection ('orders','fiscal_periods', …).snapshotsis the list of actions that should also store a fullsnapshotAfter. Common patterns:[AuditAction.CREATE]on create,[AuditAction.UPDATE]on update,[AuditAction.DELETE]on delete,[AuditAction.STATUS_CHANGE]on post/approve/cancel. (BecausesnapshotBeforeis always null, even aDELETEsnapshot captures only what the delete mutation returned.)- The
authresolver registers@AuditMeta({ module: 'auth', collection: 'auth', snapshots: [] } as any)to audit logins without a document.
Representative opt-ins (file → module): branch/branch.resolver.ts → branch; exchange/exchange.resolver.ts → exchange; fiscal/fiscal.resolver.ts → fiscal; kyc/kyc.resolver.ts → kyc; 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):
- A per-feature
*.logs.tsprovider registers Mongoosepre('updateOne')hooks on specific schemas in its constructor.OrderLogswiresOrderSchema,SalesInvoiceSchema,PurchaseInvoiceSchema,OrderItemSchema,PurchaseInvoiceItemSchema,SalesInvoiceItemSchema;TransactionLogswires the finance transaction schema. - Each hook calls
AuditLogService.saveSchemaUpdate(this), which reads the original doc, compares it to the$setpayload, and writes anAuditLogrow{ collectionName, action: 'update', refId, changes: JSON.stringify(changes), createdBy: contextSvc.user._id }.
Known bug in the legacy hook:
saveSchemaUpdateonly builds thechangesdiff insideif (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 emptychangesobject. Treat the legacychangesfield 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-trail→pages/audit-trail/index.tsx:MainLayout(selectedKeys={['audit-trail']}) wrappingAuditTrailContextProvider→AuditTrailPage.getServerSidePropsrunsApGuardBuilder(session, req).isAuth()(authenticated-only; the actual company/branch cut is enforced server-side by the resolver). There is a single page — no separatecompany.tsx(the2026-04-10plan proposed one; onlyindex.tsxexists).
Data flow (follows the zync-nextjs context standard)
component → useAuditTrailState() → context.tsx → gql/query.ts → Apollo
gql/query.tsexposes the singleuseAuditTrailQuery()hook wrapping twouseLazyQuerys (AuditTrailPage,AuditTrailByDocument), bothfetchPolicy: 'no-cache', over the sharedAuditTrailFieldsfragment.context.tsxis the only consumer of that hook. It ownsentries,totalRecords,filter({ page, pageSize, ...filters }) and exposes:fetchPage(filter)→ maps{page,pageSize}to{skip,take}viamapPageFilter(page 1 → skip 0; page N →(N-1)*pageSize; coercesfromDate/toDateto numbers), callsauditTrailPage, storesdata/totalRecords.fetchByDocument(documentId)→ returns the per-document array (does not store it).loading,byDocumentLoading.
- Components import
useAuditTrailState()only — they never touchgql/or Apollo directly.
Components
page.tsx(AuditTrailPage) — filter bar (ApTextInputmodule,ApSelectInputaction,ApSelectInputstatus, twoApDateInputs from/to, Search + Reset buttons, allignoreFormik) over anApTable. Columns: Timestamp (fmtDateTime), Module, Action (colorTag), Document Code, Actor (userEmail || userId), Status (green/redTag). Row click opens the detail drawer.useEffect(fetchPage, [filter])refetches on any filter change; pagination viamapTablePagination.components/AuditDetailDrawer.tsx— 640px rightDrawer.Descriptionsblock (Timestamp, Action tag, Module, Document Code, Actor, Role, IP, Status tag, and Failure Reason when present), then a Field ChangesDiffTable, then Snapshots (rendered only when a before/after snapshot exists).components/DiffTable.tsx—JSON.parses thediffstring into{ field: {before, after} }rows; 3 columns Field / Before (red mono) / After (green mono);nullrendered as aTag. Graceful fallbacks for empty/unparseable diff.components/SnapshotViewer.tsx—Collapsepanel 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 callsfetchByDocument(documentId), reverses to chronological order, renders an antdTimeline(color by action, actor + timestamp + inlineDiffTable); row click opens the sameAuditDetailDrawer. Intended to be embedded as a "History" tab on financial document detail pages. (Note: it must be rendered inside anAuditTrailContextProvidersince it depends onuseAuditTrailState.)
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.tsIAuditTraildeclaresdocumentDate: numberandbranchId, and the fragment requests them —branchIdanddocumentDateexist onAuditTrailGql. Good. - The
2026-04-10plan referenced astoreIdfield and acompany.tsxpage; the shipped code usesbranchIdand 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()inapp.module.ts) — decouples capture from persistence via the in-processaudit.eventevent.Reflector(@nestjs/core) — reads@AuditMetametadata off the handler.AuthModule— imported byAuditTrailModule(forwardRef) for@ApGqlAuthorize(); the legacyAuditLogModulealso importsUserModuleto resolve theuserfield.- Pagination helpers
handlePageFacet/handlePageResultfromsrc/core(shared$facetpaginator). - No external services — no S3, mail, queue, or Redis. (The
2026-04-10plan mentioned "Redis-backed retry"; the shipped listener uses in-memorysetTimeoutbackoff, not Redis.) - Module registration: both
AuditTrailModuleandAuditLogModuleare@Global().AuditInterceptoris wired asAPP_INTERCEPTORinapp.module.ts.
Cross-links: architecture · multi-tenancy · auth · permissions & access · workflow & approval engine
11. Gotchas & project-specific rules
snapshotBeforeis alwaysnull;diffis not a true delta. ForUPDATE, the diff lists the full after-state withbefore: null. Don't read the auditdiffas "fields that changed" — read it as "fields present after the change." The legacyaudit_logshad a true before/afterchangesshape but its diff loop is bugged (§7) — so neither store gives a clean before→after delta today.- Capture is opt-in. A mutation with no
@AuditMetaproduces no audit entry. Queries are never audited. - Action is inferred from the method name. Rename a resolver method from
postX/createX/deleteXand the recordedactionchanges. Keep names conventional (§4.4). status: FAILEDmeans the audit write failed, not the business op. A failed business mutation still emits anaudit.event(error branch) that is normally persisted asSUCCESS.- 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 →
FAILEDrow). - Two systems, two collections. New trail =
audit_trails(@AuditMeta+ interceptor); legacy log =audit_logs(*.logs.tsMongoose hooks onOrder/Transactiononly). They can both fire for one write and are independent. - Read scope is
kind-based and partly in-memory.auditTrailByDocumentfilters in JS after the query, so it still reads all matching rows from Mongo before cutting scope. - Change fields are JSON strings over GraphQL.
diff/snapshotBefore/snapshotAfterareJSON.stringify'd by the resolver'smapEntryand must beJSON.parsed on the client (the adminDiffTable/SnapshotViewerdo this). timestampis unix epoch ms (Float). Set in the interceptor at request entry, not by Mongo. The schema also hastimestamps: truesocreatedAt/updatedAtexist too, buttimestampis the authoritative "when it happened."- No retention. Both collections grow unbounded (§9).