Developer Docs

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 EditHistoryModule must be imported inside the NestjsQueryGraphQLModule.forFeature() imports array โ€” not at the parent module level. This is because ProductSyncService, ServiceSyncService, and BlogQueryService are 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

  1. User navigates to Admin Dashboard โ†’ Edit History tab
  2. Selects a Malet from the dropdown (auto-selects first)
  3. maletActivityFeed query fetches edits scoped to that Malet
  4. Entity type filter chips (All, Products, Services, Blog Posts, Malet) refine results
  5. Each entry is expandable to show FieldDiff components
  6. "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
Diff visualization โœ… 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

  • 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