Audit Log Architecture

Overview

Two-phase audit: SQL for atomic durable writes, Elasticsearch for fast reads.

[Handler]
    │
    ├─ QueueAudit() ──► EF context (same DbContext, no SaveChanges)
    │
    └─ SaveChangesAsync() ──► SQL (entity + audit in ONE transaction)
                                        │
                              [Index Service Consumer]
                                        │
                                        └─► Elasticsearch (async, after commit)

Phase 1 — Write (Atomic, SQL)

Transmittal Service

Pattern: ITransmittalAuditQueue writes to same ApplicationDbContext — no extra roundtrip.

// All Update/Delete/Status handlers:
_auditQueue.QueueAudit(AuditEntityTypes.Transmittal, entity.Id,
    AuditEventTypes.Claimed, workspaceId, userId, new { claimedById = userId });
await _repo.UnitOfWork.SaveChangesAsync(ct);  // entity + audit in 1 tx ✅

// All Create handlers (ID unknown before save):
await using var tx = await _repo.UnitOfWork.Database.BeginTransactionAsync(ct);
await _repo.AddAsync(entity, ct);
await _repo.UnitOfWork.SaveChangesAsync(ct);        // entity gets DB-assigned ID
_auditQueue.QueueAudit(..., entity.Id, AuditEventTypes.Created, ...);
await _repo.UnitOfWork.SaveChangesAsync(ct);        // audit in same tx
await tx.CommitAsync(ct);                           // both committed ✅

Interface: ITransmittalAuditQueue.QueueAudit(entityType, entityId, eventType, workspaceId, actorUserId, payload) Impl: TransmittalAuditQueue_context.TransmittalAuditLogs.Add(log) only — no SaveChanges Table: TransmittalAuditLog in ApplicationDbContext (same DB as transmittal entities)

Core Service

Pattern: CoreAuditInterceptor (SaveChangesInterceptor, Singleton) — auto-captures all entities.

SavingChangesAsync  → scan ChangeTracker → AsyncLocal<List<PendingAuditEntry>>
SavedChangesAsync   → IDs now set → write CoreAuditLog via IAuditLogRepository

Entities tracked:

Entity Events
User Created, Updated, Deleted
Role Created, Updated, Deleted
Group Created, Updated, Deleted
Pool, State, Metadata, ContactAlias, Workspace, Dashboard, Widget, FileTemplate Created, Updated, Deleted
UserRole junction User:RoleAssigned + Role:UserAssigned / User:RoleRevoked + Role:UserRevoked
UserGroup junction User:GroupAssigned + Group:UserAssigned / User:GroupRevoked + Group:UserRevoked
UserPool junction User:PoolAssigned / User:PoolRevoked

Payload rules:


Phase 2 — Read (Fast, Elasticsearch) (planned)

After SaveChanges commits, an event triggers the Index Service to replicate audit logs to Elasticsearch.

SQL commit
    │
    └─► [MassTransit event: AuditLogWrittenEvent]
              │
              └─► Index Service Consumer
                        │
                        └─► ES index: pplus-audit-{workspaceId}

ES document shape:

{
  "id": "guid",
  "entityType": "Transmittal",
  "entityId": 123,
  "eventType": "Claimed",
  "workspaceId": 1,
  "actorUserId": 42,
  "occurredAt": "2026-06-25T10:00:00Z",
  "payload": { "claimedById": 42 }
}

Read API: GET /{entity}/{id}/logs?page=1&pageSize=20


Audit Entity Types & Event Types

AuditEntityTypes (both services)

Transmittal, Correspondence, CorrespondenceType, Property, Template
User, Role, Group, Pool, State, Metadata, ContactAlias, Workspace, Dashboard, Widget, FileTemplate

AuditEventTypes

Category Events
CRUD Created, Updated, Deleted, StatusUpdated
Transmittal lifecycle Sent, Superseded, Replied, Claimed, Released, Read, Undone
Bookmarks BookmarkSet, BookmarkRemoved, BookmarksCleared
Attachments AttachmentFlipped, AttachmentLocked, AttachmentUnlocked, AttachmentEmailed, AttachmentStampDateUpdated, StampSequenceGenerated
Approval flow ApprovalCreated, Resubmitted, Forwarded, Returned, DraftForwarded, ReturnedFromDraftForward, ApproveReview, ApproveSubmit, ApprovedAndSent
User/Role/Group RoleAssigned, RoleRevoked, GroupAssigned, GroupRevoked, PoolAssigned, PoolRevoked, UserAssigned, UserRevoked

Atomicity Matrix

Service Approach Create Update/Delete Audit failure
Transmittal ITransmittalAuditQueue (same DbContext) ✅ explicit tx ✅ single tx ❌ throws (intentional)
Core CoreAuditInterceptor (separate AuditLogDbContext) ⚠️ non-atomic ⚠️ non-atomic ✅ swallowed (warning log)

Key Constraints

  1. QueueAudit — NEVER call SaveChanges inside — handler owns the transaction
  2. For Create handlers — ALWAYS use BeginTransactionAsync wrapper
  3. Core interceptor — NEVER make atomic; per-handler code for Core = 50+ modifications
  4. EventId (Guid) — unique per audit row; prevents duplicate audit on EF retry
  5. actorUserId — nullable; null when no HTTP context (background jobs, migrations)
  6. Payload — anonymous object serialized to JSON; keep flat and minimal

Files

Microservices/Core/
  PPlus.CoreService.Infrastructure/Interceptors/CoreAuditInterceptor.cs
  PPlus.CoreService.Infrastructure/Extentions/ServiceCollectionExtensions.cs  ← AddInterceptors
  PPlus.CoreService.Application/Queues/AuditLog/AuditConst.cs                 ← AuditEntityTypes, AuditEventTypes

Microservices/Transmittal/
  PPlus.TransmittalService.Application/Interfaces/ITransmittalAuditQueue.cs
  PPlus.TransmittalService.Application/Queues/AuditLog/AuditConst.cs          ← AuditEntityTypes, AuditEventTypes
  PPlus.TransmittalService.Infrastructure/Services/TransmittalAuditQueue.cs
  PPlus.TransmittalService.Infrastructure/DbContexts/ApplicationDbContext.cs  ← TransmittalAuditLogs DbSet
  PPlus.TransmittalService.Infrastructure/Extentions/ServiceCollectionExtensions.cs ← AddScoped<ITransmittalAuditQueue>