Skip to content

Audit Stamping Standard — FitZalo V2

Governance ID: GOV-AUDIT | Owner: API Governance Lead Scope: All mutable entities across all modules Effective: 2026-02-23 | Status: APPROVED


1. Mandatory Audit Fields

Every mutable entity (any collection that supports create/update/delete) MUST have:

FieldTypeAuto?Rule
createdAtDateMongoose timestamps: true → auto UTC
updatedAtDateMongoose timestamps: true → auto UTC
createdByIdObjectIdService sets from RequestContext.userId
updatedByIdObjectIdService sets on every update

Soft-Delete Entities (when applicable)

FieldTypeAuto?Rule
isDeletedbooleanDefault false; set to true on soft-delete
deletedAtDateSet to new Date() on soft-delete; null otherwise
deletedByIdObjectIdSet from RequestContext.userId on soft-delete

Read-Only / System Entities (exempt)

These entities do NOT require audit fields:

  • sys_event_outbox, sys_event_inbox (infrastructure; auto-managed)
  • sys_audit_logs (audit entity itself; has its own action + userId)
  • jobs (queue infrastructure)

2. Server-Controlled Rule (CRITICAL)

Audit fields are NEVER accepted from the client.

Reserved Fields List

The following fields MUST be stripped/ignored from any client request body:

createdAt, updatedAt, deletedAt,
createdById, updatedById, deletedById,
isDeleted, _id, tenantId

Implementation

typescript
// In CreateDTO — audit fields are NOT declared (client cannot send them)
export class CreateProductDto {
  @IsString() name: string;
  @IsNumber() price: number;
  // NO createdById, NO createdAt — these are SERVICE-injected
}

// In UpdateDTO — same; audit fields excluded
export class UpdateProductDto {
  @IsOptional() @IsString() name?: string;
  // NO updatedById, NO updatedAt
}

If a client sends createdById or createdAt in the body, the service MUST ignore/overwrite it.


3. Stamping Rules by Action

3.1 Create

typescript
async create(dto: CreateProductDto, userId: ObjectId, tenantId: ObjectId) {
  return this.model.create({
    ...dto,
    tenantId,
    createdById: userId,
    updatedById: userId,  // same as creator on initial create
    isDeleted: false,
    // createdAt + updatedAt auto-set by Mongoose timestamps
  });
}
FieldValue
createdAtAuto (Mongoose)
updatedAtAuto (Mongoose)
createdByIdRequestContext.userId
updatedByIdRequestContext.userId (same as creator initially)
isDeletedfalse

3.2 Update

typescript
async update(id: ObjectId, dto: UpdateProductDto, userId: ObjectId) {
  return this.model.findByIdAndUpdate(id, {
    ...dto,
    updatedById: userId,
    // updatedAt auto-set by Mongoose timestamps
  }, { new: true });
}
FieldValue
updatedAtAuto (Mongoose)
updatedByIdRequestContext.userId
createdAtUNCHANGED
createdByIdUNCHANGED

3.3 Soft Delete

typescript
async softDelete(id: ObjectId, userId: ObjectId) {
  return this.model.findByIdAndUpdate(id, {
    isDeleted: true,
    deletedAt: new Date(),
    deletedById: userId,
    updatedById: userId,
    // updatedAt auto-set by Mongoose timestamps
  }, { new: true });
}
FieldValue
isDeletedtrue
deletedAtnew Date()
deletedByIdRequestContext.userId
updatedAtAuto (Mongoose)
updatedByIdRequestContext.userId

3.4 Hard Delete

For entities that support permanent deletion:

  • No audit fields needed (document is removed).
  • SHOULD log to audit_log collection before deletion.

4. NestJS Pipeline — Where Stamping Happens

Client → [Guard] → [Pipe/Validation] → Controller → ★ Service (stamps here) ★ → Mongoose → DB

The service layer is the canonical location for stamping because:

  1. Controller extracts userId from JWT (via @CurrentUser() decorator or RequestContext)
  2. Service receives userId as parameter
  3. Service applies audit fields before calling Mongoose
  4. This keeps controllers thin and services testable

RequestContext Pattern (for deeply nested calls)

typescript
// request-context.middleware.ts
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const store = { userId: req.user?._id, tenantId: req.user?.tenantId };
    RequestContext.run(store, () => next());
  }
}

// In service (when userId isn't passed as param)
const userId = RequestContext.get('userId');

4.5 Legacy Express Pattern (Reference — V1)

The old backend (Express/apiv2_8721) used route-level middleware to inject audit fields into req.body before validation:

typescript
// OLD_CODE — miniGame.route.ts (Express V1 pattern)
import { addCreatedByIdToBody, addUpdatedByIdToBody, addDeletedByToBody }
  from 'VTBECoreLib/middlewares/addUserToBody';

router.route('/')
  .post(auth(), addCreatedByIdToBody, validate(...), controller.createOne)
  //            ↑ middleware mutates req.body.createdById = req.user._id

router.route('/:id')
  .patch(auth(), addUpdatedByIdToBody, validate(...), controller.updateOne)
  .delete(auth(), addDeletedByToBody, validate(...), controller.deleteOne)

How it worked: Middleware reads req.user._id from auth, then writes req.body.createdById (or updatedById / deletedBy) before the validation middleware runs — so the DTO validator sees the field already present.

V2 NestJS equivalent: We do NOT use middleware injection. Instead, the service layer receives userId as a parameter from the controller (which extracts it from the JWT-authenticated request via @CurrentUser() decorator). This is cleaner because:

  1. The DTO stays pure (no server-injected fields mixed with client input)
  2. The service is testable without HTTP request context
  3. Validation only covers client-supplied fields
V1 (Express)V2 (NestJS)
addCreatedByIdToBody middlewareservice.create(dto, userId) — service stamps createdById
addUpdatedByIdToBody middlewareservice.update(id, dto, userId) — service stamps updatedById
addDeletedByToBody middlewareservice.softDelete(id, userId) — service stamps deletedById
Body mutation before validationNo body mutation; service handles separately

❌ FORBIDDEN Patterns

PatternWhy
Stamping in middleware (before validation)Legacy Express pattern; breaks NestJS request lifecycle
Stamping in controllerFat controllers; untestable
Mongoose pre('save') hook for createdByIdHook doesn't have access to request context
Client-provided audit fields acceptedSecurity violation

5. Schema Declaration Pattern

typescript
@Schema({ timestamps: true, collection: 'products' })
export class Product {
  @Prop({ type: Types.ObjectId, required: true, index: true })
  tenantId!: Types.ObjectId;

  // ... business fields ...

  // Audit: user-controlled via service stamping
  @Prop({ type: Types.ObjectId, required: true })
  createdById!: Types.ObjectId;

  @Prop({ type: Types.ObjectId })
  updatedById?: Types.ObjectId;

  // Soft delete (if applicable)
  @Prop({ default: false, index: true })
  isDeleted!: boolean;

  @Prop()
  deletedAt?: Date;

  @Prop({ type: Types.ObjectId })
  deletedById?: Types.ObjectId;

  // createdAt + updatedAt auto-managed by Mongoose { timestamps: true }
}

6. API Response — Audit Fields Visibility

FieldList EndpointDetail EndpointRule
createdAtAlways include
updatedAtDetail only
createdByIdDetail only (FK)
updatedByIdDetail only (FK)
isDeletedNever expose (filtered at query level)
deletedAtNever expose
deletedByIdNever expose

Population

Via ?include=createdBy → returns createdBySummary { _id, name, avatar } (per RELATIONSHIP_POPULATION_RULES).


7. Soft Delete Query Pattern

typescript
// All queries MUST filter soft-deleted records by default
async findAll(tenantId: ObjectId, filters: any) {
  return this.model.find({
    tenantId,
    isDeleted: false,   // MANDATORY default filter
    ...filters,
  }).lean();
}

// Only admin/system operations may include deleted records
async findAllIncludeDeleted(tenantId: ObjectId) {
  return this.model.find({ tenantId }).lean();
}

8. Migration Checklist (from D-009)

Schemas requiring rename from legacy audit field names:

SchemaCurrentTargetType Issue
vouchercreatedBy: ObjectIdcreatedById: ObjectId
voucherupdatedBy: ObjectIdupdatedById: ObjectId
voucherdeletedBy: ObjectIddeletedById: ObjectId
usercreatedBy: stringcreatedById: ObjectId⚠️ string→ObjectId
userupdatedBy: stringupdatedById: ObjectId⚠️ string→ObjectId
categorycreatedBy: ObjectIdcreatedById: ObjectId
categoryupdatedBy: ObjectIdupdatedById: ObjectId
productcreatedBy: ObjectIdcreatedById: ObjectId
productupdatedBy: ObjectIdupdatedById: ObjectId
walletupdatedBy: ObjectIdupdatedById: ObjectId

Missing Audit Fields (need adding)

SchemaMissing
userdeletedById (has deletedAt + isDeleted but no deletedById)
inventorydeletedById (has deletedAt + isDeleted but no deletedById)
categorydeletedById (has deletedAt + isDeleted but no deletedById)
productdeletedById (has deletedAt + isDeleted but no deletedById)

9. Implementation Tasks — Audit Stamping Infrastructure

Reference: V1 used addCreatedByIdToBody / addUpdatedByIdToBody / addDeletedByToBody route middleware (from VTBECoreLib). V2 achieves the same goal via NestJS-native patterns below.

Implementation Backlog

Task IDTitleTypePriorityEstimateDescription
WP-0.GOV.010@CurrentUser() param decoratorBEP02hCreate @CurrentUser() decorator to extract { userId, tenantId } from JWT-validated request. Equivalent to V1 req.user._id.
WP-0.GOV.011AuditStampInterceptor (global)BEP04hNestJS interceptor that reads RequestContext and auto-stamps createdById/updatedById on service payloads. Configurable per-action (create/update/delete).
WP-0.GOV.012RequestContextMiddlewareBEP02hNestJS middleware using AsyncLocalStorage to propagate { userId, tenantId } from auth guard into service layer. Registered globally in AppModule.
WP-0.GOV.013StripReservedFieldsPipeBEP02hGlobal pipe that removes createdAt, updatedAt, createdById, updatedById, deletedAt, deletedById, isDeleted, _id, tenantId from req.body before DTO validation. Safety net.
WP-0.GOV.014SoftDeleteMixin helperBEP13hReusable service mixin / base class that provides softDelete(id, userId) with standard isDeleted + deletedAt + deletedById stamping.
WP-0.GOV.015Audit stamping contract testsQAP04hTest that: (1) POST body with createdById is stripped, (2) response has correct createdById from JWT, (3) PATCH updates updatedById, (4) soft-delete sets all 3 fields.

Mandatory Usage Rules (per endpoint type)

Every authenticated mutation endpoint MUST use the audit infrastructure as follows:

┌────────────┬──────────────────────────────────────────────────────────────┐
│ HTTP       │ Required Audit Pattern                                      │
├────────────┼──────────────────────────────────────────────────────────────┤
│ POST       │ Controller: @CurrentUser() → service.create(dto, userId)    │
│ (create)   │ Service: stamps createdById + updatedById                   │
│            │ Guard chain: JwtGuard → TenantGuard → PermissionsGuard      │
├────────────┼──────────────────────────────────────────────────────────────┤
│ PATCH/PUT  │ Controller: @CurrentUser() → service.update(id, dto, userId)│
│ (update)   │ Service: stamps updatedById only                            │
│            │ Guard chain: JwtGuard → TenantGuard → PermissionsGuard      │
├────────────┼──────────────────────────────────────────────────────────────┤
│ DELETE     │ Controller: @CurrentUser() → service.softDelete(id, userId) │
│ (soft del) │ Service: stamps isDeleted + deletedAt + deletedById         │
│            │ Guard chain: JwtGuard → TenantGuard → PermissionsGuard      │
├────────────┼──────────────────────────────────────────────────────────────┤
│ GET        │ No audit stamping (read-only)                               │
│ (read)     │ Guard chain: JwtGuard → TenantGuard → PermissionsGuard      │
├────────────┼──────────────────────────────────────────────────────────────┤
│ Public     │ No audit stamping, no auth                                  │
│ (no auth)  │ No guard chain needed                                       │
└────────────┴──────────────────────────────────────────────────────────────┘

V1 → V2 Architecture Comparison

V1 (Express):
  auth() → addCreatedByIdToBody → validate() → controller.createOne
  │          │                       │
  │          └─ mutates req.body     └─ validates body (incl. injected fields)
  └─ sets req.user from JWT

V2 (NestJS):
  JwtGuard → TenantGuard → PermissionsGuard → StripReservedFieldsPipe → ValidationPipe → Controller → Service
  │                                             │                         │                │           │
  │                                             └─ safety net: removes    └─ validates     │           └─ stamps
  │                                                audit fields from body    client-only    │              createdById,
  └─ sets req.user from JWT                                                  DTO fields     │              updatedById
                                                                                            └─ @CurrentUser() → userId

Module Adoption Checklist

When implementing any module's CRUD endpoints, developers MUST:

  • [ ] Import @CurrentUser() decorator in controller
  • [ ] Pass userId to all service mutation methods
  • [ ] Service method stamps createdById / updatedById / deletedById
  • [ ] StripReservedFieldsPipe registered globally (one-time setup)
  • [ ] DTOs do NOT declare audit fields
  • [ ] Contract test: POST with createdById in body → field is ignored, JWT user used instead

Changelog

DateChange
2026-02-23Initial creation — mandatory fields, server-controlled rule, stamping patterns, NestJS pipeline, migration checklist
2026-02-23Added §4.5 Legacy Express Pattern reference
2026-02-23Added §9 Implementation Tasks — 6 tasks for audit infrastructure + mandatory usage rules per endpoint type

FitZalo Platform Documentation