Edit History Audit Trail โ Developer Guide
Overview
The Edit History Audit Trail provides field-level change tracking across five core entity types: Products, Services, Blog Posts, Malets, and Organizations. Every time a Malet Owner or admin updates an entity, the system records precisely which fields changed, their previous and new values, and who made the change. For tracking role-level access changes, see the Custom RBAC audit events and Organization audit feed.
| Behavior | Description |
|---|---|
| Field-level diff | Only fields that actually changed are recorded โ not the entire document. |
| Tracked fields whitelist | Each entity defines which fields are audited, avoiding noise from internal timestamps or computed values. |
| Fire-and-forget | Audit logging is asynchronous and never blocks or fails the parent mutation. |
| Cross-entity feed | The maletActivityFeed query aggregates edits across all entity types scoped to a single Malet. |
| Indefinite retention | Records are kept forever by default. A TTL index can be added with a single MongoDB command. |
Architecture
graph TD
A["ProductSyncService.updateOne()"] --> E["EditHistoryService.logEdit()"]
B["ServiceSyncService.updateOne()"] --> E
C["BlogQueryService.updateOne()"] --> E
D["OrganizationResolver.updateOrganization()"] --> E
E --> F["computeDiff(before, after, trackedFields)"]
F -->|"changes detected"| G["MongoDB: edit_history collection"]
F -->|"no changes"| H["Skip (return null)"]
I["productEditHistory query"] --> J["EditHistoryService.getHistory()"]
K["maletActivityFeed query"] --> L["EditHistoryService.getHistoryByMalet()"]
J --> G
L --> G
style G fill:#6366f1,color:#fff
style E fill:#8b5cf6,color:#fff
style H fill:#64748b,color:#fff
Design Decisions
| Decision | Rationale |
|---|---|
| Shared collection | All subgraphs write to a single edit_history MongoDB collection via EditHistoryModule. Zero cross-service latency โ no event bus or dedicated audit microservice needed. |
@Directive('@shareable') |
EditHistory and FieldChange types are defined in libs/common and exposed through multiple subgraphs. The @shareable directive tells the federation gateway this is intentional. |
| JSON.stringify deep compare | Handles primitives, arrays, and nested objects uniformly without external diff libraries. |
Fire-and-forget .catch() |
Audit logging failures are logged as warnings but never propagate to the caller. A failed audit should never block a Malet Owner's update. |
| Tracked field whitelists | Internal fields like _id, updatedAt, __v, and computed fields are excluded to keep the audit trail meaningful. |
| No Schedule tracking | Schedules are excluded from initial scope โ they are slated for a P2 consolidation into the Service entity as an embedded sub-document. |
Schema
EditHistory Entity
The shared entity lives in libs/common and is registered with Typegoose in every subgraph that imports EditHistoryModule:
// libs/common/src/entities/edit-history.entity.ts
@ObjectType()
@Directive('@shareable')
@modelOptions({
schemaOptions: { timestamps: true, collection: 'edit_history' }
})
@index({ entityType: 1, entityId: 1, createdAt: -1 })
@index({ maletId: 1, createdAt: -1 })
@index({ actorId: 1, createdAt: -1 })
export class EditHistory {
id: string; // nanoid
entityType: EditHistoryEntityType; // PRODUCT | SERVICE | MALET | BLOG_POST | ORGANIZATION
entityId: string; // ID of the entity that was edited
maletId?: string; // Scoping for cross-entity queries
actorId: string; // User who made the change
changes: FieldChange[]; // Array of individual field diffs
summary?: string; // Auto-generated (e.g. "Updated name, basePrice")
createdAt: Date;
updatedAt: Date;
}
FieldChange Embedded Type
@ObjectType()
@Directive('@shareable')
export class FieldChange {
fieldName: string; // e.g. "name", "basePrice", "tags"
oldValue?: unknown; // JSON-encoded previous value
newValue?: unknown; // JSON-encoded current value
}
MongoDB Indexes
| Index | Purpose |
|---|---|
{ entityType, entityId, createdAt: -1 } |
Fast per-entity history lookups |
{ maletId, createdAt: -1 } |
Cross-entity activity feed scoped to a Malet |
{ actorId, createdAt: -1 } |
"What did this user change?" queries |
Tracked Fields by Entity
Each entity defines a whitelist constant that controls which fields trigger audit entries:
| Entity | Tracked Fields |
|---|---|
| Product | name, slug, description, basePrice, currency, status, featuredImage, tags, categoryId, isAgeRestricted, minimumAge |
| Service | name, slug, description, basePrice, currency, status, featuredImage, tags, categoryId, duration, location |
| Blog Post | title, slug, excerpt, body, html, coverImage, status, tags, seo, isMemberOnly, requiredTier |
| Organization | name, slug, description, category, logoUrl, bannerUrl, website, settings |
Changes to any field not in this list (e.g. updatedAt, totalInventory, __v) are silently ignored.
GraphQL API
Per-Entity History Queries
Each subgraph exposes its own history query. All follow the same pattern with limit/offset pagination.
Products
query {
productEditHistory(productId: "prod-abc", limit: 20, offset: 0) {
id
entityType
entityId
actorId
summary
createdAt
changes {
fieldName
oldValue
newValue
}
}
}
Services
query {
serviceEditHistory(serviceId: "svc-xyz", limit: 20) {
id
actorId
summary
createdAt
changes {
fieldName
oldValue
newValue
}
}
}
Blog Posts
query {
blogPostEditHistory(blogId: "blog-123", limit: 10) {
id
actorId
summary
createdAt
changes {
fieldName
oldValue
newValue
}
}
}
Organizations
query {
organizationEditHistory(organizationId: "org-456", limit: 50) {
id
actorId
summary
createdAt
changes {
fieldName
oldValue
newValue
}
}
}
Malets
query {
maletEditHistory(maletId: "malet-789", limit: 20) {
id
actorId
summary
createdAt
changes {
fieldName
oldValue
newValue
}
}
}
Cross-Entity Activity Feed
The maletActivityFeed query aggregates edit history across all entity types belonging to a Malet. This powers the "Recent Changes" dashboard for Malet Owners.
query {
# All edits across a Malet
maletActivityFeed(maletId: "malet-789", limit: 50) {
id
entityType # PRODUCT, SERVICE, BLOG_POST, etc.
entityId
actorId
summary
createdAt
changes {
fieldName
oldValue
newValue
}
}
}
# Filter to a specific entity type
query {
maletActivityFeed(maletId: "malet-789", entityType: PRODUCT, limit: 20) {
id
entityType
entityId
summary
createdAt
}
}
Service Layer
Diff Computation
The EditHistoryService.computeDiff() method compares two object snapshots using JSON serialization for deep equality:
// libs/common/src/entities/edit-history.service.ts
computeDiff(
before: Record<string, unknown>,
after: Record<string, unknown>,
trackedFields: string[],
): FieldChange[] {
const changes: FieldChange[] = [];
for (const field of trackedFields) {
const oldStr = JSON.stringify(before[field] ?? null);
const newStr = JSON.stringify(after[field] ?? null);
if (oldStr !== newStr) {
changes.push({
fieldName: field,
oldValue: before[field] ?? null,
newValue: after[field] ?? null,
});
}
}
return changes;
}
Integration Pattern
Each subgraph follows the same before-snapshot pattern in its updateOne method:
// Example: apps/products/src/product/product.service.ts
async updateOne(id, update, opts) {
// 1. Snapshot BEFORE state
const before = await this.model.findOne({ id }).lean().exec();
// 2. Perform update
const product = await super.updateOne(id, update, opts);
// 3. Log edit history (fire-and-forget)
if (before) {
this.editHistoryService
.logEdit({
entityType: EditHistoryEntityType.PRODUCT,
entityId: id,
maletId: before.maletId,
actorId: opts?.actorId || 'system',
before,
after: product.toObject(),
trackedFields: PRODUCT_TRACKED_FIELDS,
})
.catch((err) =>
this.logger.warn(`Failed to log edit history: ${err.message}`),
);
}
return product;
}
Key detail: The
EditHistoryModulemust be imported inside theNestjsQueryGraphQLModule.forFeature()imports array โ not at the parent module level. This is becauseProductSyncService,ServiceSyncService, andBlogQueryServiceare instantiated within the NestjsQuery child module context and cannot see providers from the parent.
Module Configuration
NestjsQuery Child Context (Products, Services, Blogs)
// apps/products/src/product/product.module.ts
NestjsQueryGraphQLModule.forFeature({
imports: [
NestjsQueryTypegooseModule.forFeature([Product]),
// โ MUST be here, not in parent module imports
EditHistoryModule,
],
resolvers: [{ ... }],
})
Standard Module (Malets, Organizations)
// apps/malets/src/malet/malet.module.ts
@Module({
imports: [
// ... other imports
EditHistoryModule, // Standard position โ works fine
],
providers: [
MaletHistoryResolver,
// ...
],
})
GraphQL Federation Composition
The EditHistory and FieldChange types are defined in libs/common and exposed via GraphQL in every subgraph that imports EditHistoryModule. Without the @shareable directive, the federation gateway rejects the supergraph composition because the same type appears in multiple subgraphs.
@ObjectType()
@Directive('@shareable') // โ Required for Federation 2
export class EditHistory { ... }
@ObjectType()
@Directive('@shareable') // โ Required for Federation 2
export class FieldChange { ... }
This follows the same pattern used by other shared types in the codebase: Money, Currency, Pricing, etc.
Affected File Map
libs/common/src/entities/
โโโ edit-history.entity.ts # EditHistory + FieldChange + EditHistoryEntityType enum
โโโ edit-history.service.ts # logEdit(), computeDiff(), getHistory(), getHistoryByMalet()
โโโ edit-history.module.ts # Typegoose registration + service export
โโโ edit-history.service.spec.ts # 15 unit tests
โโโ index.ts # Barrel exports
apps/products/src/product/
โโโ product.module.ts # +EditHistoryModule in forFeature imports
โโโ product.service.ts # +before-snapshot + logEdit in updateOne
โโโ product.service.spec.ts # +EditHistoryService mock
โโโ product-history.resolver.ts # NEW: productEditHistory query
apps/services/src/service/
โโโ service.module.ts # +EditHistoryModule in forFeature imports
โโโ service.service.ts # +before-snapshot + logEdit in updateOne
โโโ service.service.spec.ts # +EditHistoryService mock
โโโ service-history.resolver.ts # NEW: serviceEditHistory query
apps/blogs/src/blog/
โโโ blog.module.ts # +EditHistoryModule in forFeature imports
โโโ blog.query-service.ts # +before-snapshot + logEdit in updateOne
โโโ blog.query-service.spec.ts # +EditHistoryService mock
โโโ blog-history.resolver.ts # NEW: blogPostEditHistory query
apps/malets/src/malet/
โโโ malet.module.ts # +EditHistoryModule + MaletHistoryResolver
โโโ malet-history.resolver.ts # NEW: maletEditHistory + maletActivityFeed queries
apps/organizations/src/
โโโ organization/organization.module.ts # +EditHistoryModule + OrgHistoryResolver
โโโ organization/organization.resolver.ts # +before-snapshot + logEdit in updateOrganization
โโโ organization/organization.resolver.spec.ts # +EditHistoryService mock
โโโ audit/org-history.resolver.ts # NEW: organizationEditHistory query
Testing
Unit Tests
# EditHistoryService (15 tests โ diff, log, query)
npm run test -- libs/common/src/entities/edit-history
# All affected subgraphs (381 tests across 49 suites)
npm run test -- libs/common apps/products/src apps/services/src apps/blogs/src apps/malets/src apps/organizations/src
Key Test Coverage
| Area | Tests |
|---|---|
EditHistoryService.computeDiff |
Detects changed fields, ignores untracked fields, handles null/undefined transitions, deep-compares arrays and objects |
EditHistoryService.logEdit |
Creates record when changes exist, returns null when no tracked fields changed, generates summary from field names |
EditHistoryService.getHistory |
Queries by entityType + entityId with pagination |
EditHistoryService.getHistoryByMalet |
Cross-entity query by maletId, optional entityType filter |
EditHistoryService.getHistoryByActor |
Actor-scoped query with pagination |
| Product/Service/Blog/Org tests | All existing tests updated with EditHistoryService mock provider |
Retention Policy
The system currently uses indefinite retention โ no records are automatically purged. If storage becomes a concern, a TTL-based purge can be enabled with a single MongoDB command:
# Add TTL index to auto-delete records older than 90 days
db.edit_history.createIndex(
{ "createdAt": 1 },
{ expireAfterSeconds: 7776000 } # 90 days
)
This requires **zero code changes** and **zero downtime**. The TTL index runs as a background MongoDB process.
Frontend Implementation
The Admin Dashboard Edit History tab now renders real edit history data via the maletActivityFeed GraphQL query, replacing the previous placeholder data.
Components
| Component | Purpose |
|---|---|
src/lib/components/admin/EditHistoryViewer.svelte |
Timeline grouped by date with expandable entries showing field-level diffs |
src/lib/components/admin/FieldDiff.svelte |
Before/after comparison with color-coded changes and complex value rendering |
src/lib/components/admin/ActivityFeedTimeline.svelte |
Cross-entity feed with entity type filter chips, powered by maletActivityFeed |
src/lib/queries/adminTrash.ts |
GraphQL queries for all edit history endpoints + TypeScript types |
src/lib/utils/adminUtils.ts โ entityTypeLabel/Color() |
Label/color mapping for entity type badges |
src/lib/utils/adminUtils.ts โ fieldNameLabel() |
camelCase โ Title Case conversion for field names |
src/lib/utils/adminUtils.ts โ formatFieldValue() |
Type-aware value rendering (strings, arrays, booleans, objects) |
User Flow
- User navigates to Admin Dashboard โ Edit History tab
- Selects a Malet from the dropdown (auto-selects first)
maletActivityFeedquery fetches edits scoped to that Malet- Entity type filter chips (All, Products, Services, Blog Posts, Malet) refine results
- Each entry is expandable to show
FieldDiffcomponents - "Load More" button for pagination (offset-based, 30 per page)
Design Decisions
| Decision | Rationale |
|---|---|
| Malet-scoped feed | maletActivityFeed is the most useful view โ shows all changes for a given Malet. |
| Expandable entries | Minimizes visual noise โ users expand only entries they care about. |
| Color-coded diffs | Red strikethrough for old values, green for new values, amber for complex objects. |
formatFieldValue() util |
Handles all JSON types uniformly: booleans as Yes/No, arrays as chips, etc. |
Future Enhancements
| Enhancement | Description |
|---|---|
| Malet edit tracking | Intercept NestjsQuery auto-generated updateOneMalet to log Malet-level field changes |
| Schedule consolidation | When Schedules are embedded into Services (P2), schedule changes will automatically appear in Service edit history |
| Revert mutation | Allow Malet Owners to revert a specific edit by re-applying the oldValue from a history entry |
โ
Implemented โ FieldDiff.svelte + EditHistoryViewer.svelte with color-coded before/after comparisons |
|
| Webhook dispatch | Emit edit history events to external systems for compliance or SIEM integration |
| Actor enrichment | Resolve actorId to display name via GraphQL Federation @extends on the User type |
Related
- Soft-Delete & Trash System โ Companion data lifecycle feature โ soft-deleted records are excluded from list queries but retained for audit history
- Custom RBAC โ Custom role lifecycle events (created/updated/deleted/assigned/removed) are logged via the Organization Audit Trail
- Organization & Malet Management โ Frontend activity feed showing team actions and setting changes
- Organizations & Permissions โ Organization-level audit events and permission infrastructure
- SIEM Event Streaming โ Stream Organization Audit Trail events to external SIEM platforms โ complementary to field-level edit history for compliance
- Professional Services & Client Portals โ Document vault access logging provides a parallel compliance trail alongside field-level edit history
- Advanced Audit Logging UI โ Frontend Audit Log panel with filtering, CSV/JSON export, and SIEM webhook management