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:
| Field | Type | Auto? | Rule |
|---|---|---|---|
createdAt | Date | ✅ | Mongoose timestamps: true → auto UTC |
updatedAt | Date | ✅ | Mongoose timestamps: true → auto UTC |
createdById | ObjectId | ❌ | Service sets from RequestContext.userId |
updatedById | ObjectId | ❌ | Service sets on every update |
Soft-Delete Entities (when applicable)
| Field | Type | Auto? | Rule |
|---|---|---|---|
isDeleted | boolean | ❌ | Default false; set to true on soft-delete |
deletedAt | Date | ❌ | Set to new Date() on soft-delete; null otherwise |
deletedById | ObjectId | ❌ | Set 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 ownaction+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, tenantIdImplementation
// 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
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
});
}| Field | Value |
|---|---|
createdAt | Auto (Mongoose) |
updatedAt | Auto (Mongoose) |
createdById | RequestContext.userId |
updatedById | RequestContext.userId (same as creator initially) |
isDeleted | false |
3.2 Update
async update(id: ObjectId, dto: UpdateProductDto, userId: ObjectId) {
return this.model.findByIdAndUpdate(id, {
...dto,
updatedById: userId,
// updatedAt auto-set by Mongoose timestamps
}, { new: true });
}| Field | Value |
|---|---|
updatedAt | Auto (Mongoose) |
updatedById | RequestContext.userId |
createdAt | UNCHANGED |
createdById | UNCHANGED |
3.3 Soft Delete
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 });
}| Field | Value |
|---|---|
isDeleted | true |
deletedAt | new Date() |
deletedById | RequestContext.userId |
updatedAt | Auto (Mongoose) |
updatedById | RequestContext.userId |
3.4 Hard Delete
For entities that support permanent deletion:
- No audit fields needed (document is removed).
- SHOULD log to
audit_logcollection before deletion.
4. NestJS Pipeline — Where Stamping Happens
Recommended Pattern: Service-Layer Stamping
Client → [Guard] → [Pipe/Validation] → Controller → ★ Service (stamps here) ★ → Mongoose → DBThe service layer is the canonical location for stamping because:
- Controller extracts
userIdfrom JWT (via@CurrentUser()decorator orRequestContext) - Service receives
userIdas parameter - Service applies audit fields before calling Mongoose
- This keeps controllers thin and services testable
RequestContext Pattern (for deeply nested calls)
// 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:
// 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:
- The DTO stays pure (no server-injected fields mixed with client input)
- The service is testable without HTTP request context
- Validation only covers client-supplied fields
| V1 (Express) | V2 (NestJS) |
|---|---|
addCreatedByIdToBody middleware | service.create(dto, userId) — service stamps createdById |
addUpdatedByIdToBody middleware | service.update(id, dto, userId) — service stamps updatedById |
addDeletedByToBody middleware | service.softDelete(id, userId) — service stamps deletedById |
| Body mutation before validation | No body mutation; service handles separately |
❌ FORBIDDEN Patterns
| Pattern | Why |
|---|---|
| Stamping in middleware (before validation) | Legacy Express pattern; breaks NestJS request lifecycle |
| Stamping in controller | Fat controllers; untestable |
Mongoose pre('save') hook for createdById | Hook doesn't have access to request context |
| Client-provided audit fields accepted | Security violation |
5. Schema Declaration Pattern
@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
| Field | List Endpoint | Detail Endpoint | Rule |
|---|---|---|---|
createdAt | ✅ | ✅ | Always include |
updatedAt | ❌ | ✅ | Detail only |
createdById | ❌ | ✅ | Detail only (FK) |
updatedById | ❌ | ✅ | Detail only (FK) |
isDeleted | ❌ | ❌ | Never expose (filtered at query level) |
deletedAt | ❌ | ❌ | Never expose |
deletedById | ❌ | ❌ | Never expose |
Population
Via ?include=createdBy → returns createdBySummary { _id, name, avatar } (per RELATIONSHIP_POPULATION_RULES).
7. Soft Delete Query Pattern
// 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:
| Schema | Current | Target | Type Issue |
|---|---|---|---|
voucher | createdBy: ObjectId | createdById: ObjectId | — |
voucher | updatedBy: ObjectId | updatedById: ObjectId | — |
voucher | deletedBy: ObjectId | deletedById: ObjectId | — |
user | createdBy: string | createdById: ObjectId | ⚠️ string→ObjectId |
user | updatedBy: string | updatedById: ObjectId | ⚠️ string→ObjectId |
category | createdBy: ObjectId | createdById: ObjectId | — |
category | updatedBy: ObjectId | updatedById: ObjectId | — |
product | createdBy: ObjectId | createdById: ObjectId | — |
product | updatedBy: ObjectId | updatedById: ObjectId | — |
wallet | updatedBy: ObjectId | updatedById: ObjectId | — |
Missing Audit Fields (need adding)
| Schema | Missing |
|---|---|
user | deletedById (has deletedAt + isDeleted but no deletedById) |
inventory | deletedById (has deletedAt + isDeleted but no deletedById) |
category | deletedById (has deletedAt + isDeleted but no deletedById) |
product | deletedById (has deletedAt + isDeleted but no deletedById) |
9. Implementation Tasks — Audit Stamping Infrastructure
Reference: V1 used
addCreatedByIdToBody/addUpdatedByIdToBody/addDeletedByToBodyroute middleware (fromVTBECoreLib). V2 achieves the same goal via NestJS-native patterns below.
Implementation Backlog
| Task ID | Title | Type | Priority | Estimate | Description |
|---|---|---|---|---|---|
WP-0.GOV.010 | @CurrentUser() param decorator | BE | P0 | 2h | Create @CurrentUser() decorator to extract { userId, tenantId } from JWT-validated request. Equivalent to V1 req.user._id. |
WP-0.GOV.011 | AuditStampInterceptor (global) | BE | P0 | 4h | NestJS interceptor that reads RequestContext and auto-stamps createdById/updatedById on service payloads. Configurable per-action (create/update/delete). |
WP-0.GOV.012 | RequestContextMiddleware | BE | P0 | 2h | NestJS middleware using AsyncLocalStorage to propagate { userId, tenantId } from auth guard into service layer. Registered globally in AppModule. |
WP-0.GOV.013 | StripReservedFieldsPipe | BE | P0 | 2h | Global pipe that removes createdAt, updatedAt, createdById, updatedById, deletedAt, deletedById, isDeleted, _id, tenantId from req.body before DTO validation. Safety net. |
WP-0.GOV.014 | SoftDeleteMixin helper | BE | P1 | 3h | Reusable service mixin / base class that provides softDelete(id, userId) with standard isDeleted + deletedAt + deletedById stamping. |
WP-0.GOV.015 | Audit stamping contract tests | QA | P0 | 4h | Test 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() → userIdModule Adoption Checklist
When implementing any module's CRUD endpoints, developers MUST:
- [ ] Import
@CurrentUser()decorator in controller - [ ] Pass
userIdto all service mutation methods - [ ] Service method stamps
createdById/updatedById/deletedById - [ ]
StripReservedFieldsPiperegistered globally (one-time setup) - [ ] DTOs do NOT declare audit fields
- [ ] Contract test:
POSTwithcreatedByIdin body → field is ignored, JWT user used instead
Changelog
| Date | Change |
|---|---|
| 2026-02-23 | Initial creation — mandatory fields, server-controlled rule, stamping patterns, NestJS pipeline, migration checklist |
| 2026-02-23 | Added §4.5 Legacy Express Pattern reference |
| 2026-02-23 | Added §9 Implementation Tasks — 6 tasks for audit infrastructure + mandatory usage rules per endpoint type |