Files
goodgo-platform/docs/audits/ADMIN_AUDIT_EXPLORATION.md
Ho Ngoc Hai 59272e9321 chore(docs): consolidate 22 audit files from root into docs/audits/
Root directory had accumulated audit/exploration markdown files cluttering
the project root. Moved all audit-related files to docs/audits/ with a
README.md index, and updated cross-references in K6_LOAD_TESTING_GUIDE.md
and README_FRONTEND_DOCS.md.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 23:16:00 +07:00

24 KiB

GoodGo Platform Admin Module & Audit Logging Exploration

Overview

This document provides a comprehensive analysis of the GoodGo Platform codebase for implementing audit logging in the admin module. The exploration covers the admin module structure, existing patterns, DDD implementation, and event infrastructure.


1. ADMIN MODULE STRUCTURE

Directory Layout

apps/api/src/modules/admin/
├── admin.module.ts                              # Module bootstrap & DI configuration
├── index.ts                                     # Public exports
│
├── domain/                                      # DDD Domain Layer
│   ├── events/                                  # Domain events published by commands
│   │   ├── kyc-approved.event.ts
│   │   ├── kyc-rejected.event.ts
│   │   ├── listing-approved.event.ts
│   │   ├── listing-rejected.event.ts
│   │   ├── subscription-adjusted.event.ts
│   │   ├── user-banned.event.ts
│   │   ├── user-unbanned.event.ts
│   │   └── index.ts
│   ├── repositories/
│   │   ├── admin-query.repository.ts           # Query repository interface (read models)
│   │   └── index.ts
│   ├── __tests__/
│   │   └── admin-events.spec.ts
│   └── index.ts
│
├── application/                                 # CQRS Application Layer
│   ├── commands/                               # Command handlers (mutations)
│   │   ├── adjust-subscription/
│   │   │   ├── adjust-subscription.command.ts
│   │   │   └── adjust-subscription.handler.ts
│   │   ├── approve-kyc/
│   │   ├── approve-listing/
│   │   ├── ban-user/
│   │   ├── bulk-moderate-listings/
│   │   ├── reject-kyc/
│   │   ├── reject-listing/
│   │   ├── update-user-status/
│   │   ├── __tests__/                         # Each handler has spec
│   │   └── index.ts
│   │
│   ├── queries/                                # Query handlers (read models)
│   │   ├── get-dashboard-stats/
│   │   ├── get-kyc-queue/
│   │   ├── get-moderation-queue/
│   │   ├── get-revenue-stats/
│   │   ├── get-user-detail/
│   │   ├── get-users/
│   │   ├── __tests__/
│   │   └── index.ts
│   │
│   ├── listeners/                              # Event subscribers (side effects)
│   │   ├── user-banned.listener.ts            # Deactivates listings, sends notification
│   │   ├── user-deactivated.listener.ts
│   │   └── (called via @OnEvent decorator)
│   │
│   ├── __tests__/                             # Integration tests for handlers
│   │   └── *.spec.ts files
│   │
│   └── index.ts
│
├── infrastructure/                             # Data Access Layer
│   ├── repositories/
│   │   ├── prisma-admin-query.repository.ts   # Prisma implementation
│   │   ├── admin-stats.queries.ts             # Raw SQL/Prisma queries
│   │   ├── admin-user.queries.ts              # Raw SQL/Prisma queries
│   │   └── index.ts
│   └── index.ts
│
└── presentation/                               # HTTP Layer
    ├── controllers/
    │   ├── admin.controller.ts                # User management, subscriptions, dashboard
    │   ├── admin-moderation.controller.ts     # Moderation, KYC
    │   └── index.ts
    │
    ├── dto/                                   # Data Transfer Objects
    │   ├── adjust-subscription.dto.ts
    │   ├── approve-kyc.dto.ts
    │   ├── approve-listing.dto.ts
    │   ├── ban-user.dto.ts
    │   ├── bulk-moderate.dto.ts
    │   ├── get-users-query.dto.ts
    │   ├── reject-kyc.dto.ts
    │   ├── reject-listing.dto.ts
    │   ├── revenue-stats.dto.ts
    │   ├── update-user-status.dto.ts
    │   └── index.ts
    │
    └── index.ts

2. PRISMA SCHEMA ANALYSIS

Current State

  • Database: PostgreSQL 16 + PostGIS
  • Schema Location: prisma/schema.prisma
  • Lines: 602 lines total

User Model (Relevant to Audit)

model User {
  id           String    @id @default(cuid())
  email        String?   @unique
  phone        String    @unique
  passwordHash String?
  fullName     String
  avatarUrl    String?
  role         UserRole  @default(BUYER)  // BUYER, SELLER, AGENT, ADMIN
  kycStatus    KYCStatus @default(NONE)   // NONE, PENDING, VERIFIED, REJECTED
  kycData      Json?
  isActive     Boolean   @default(true)   // Ban flag
  deletedAt    DateTime?
  deletionScheduledAt DateTime?
  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt
  
  // Relations...
  @@index([role])
  @@index([kycStatus])
  @@index([isActive])
  @@index([deletedAt])
  @@index([createdAt])
  @@index([role, isActive, createdAt(sort: Desc)])
  @@index([kycStatus, createdAt])
}

Listing Model (Relevant to Audit)

model Listing {
  id               String          @id @default(cuid())
  propertyId       String
  agentId          String?
  sellerId         String
  status           ListingStatus   @default(DRAFT) 
    // DRAFT, PENDING_REVIEW, ACTIVE, RESERVED, SOLD, RENTED, EXPIRED, REJECTED
  moderationScore  Float?
  moderationNotes  String?
  // ... other fields
  @@index([status])
  @@index([createdAt])
}

NO EXISTING AUDIT TABLE

  • No AuditLog model found
  • No AdminAction model found
  • Opportunity to implement from scratch following project patterns

3. ADMIN CONTROLLER ACTIONS & FLOW

AdminController (User Management)

File: presentation/controllers/admin.controller.ts

Endpoints:

  1. GET /admin/users - List users with filters

    • Query params: page, limit, role, isActive, search
    • Returns: UserListResult (paginated)
  2. GET /admin/users/:id - Get user details

    • Returns: UserDetail (full profile + activity)
  3. PATCH /admin/users/status - Update user active status

    • Body: UpdateUserStatusDto {userId, isActive, reason}
    • Current user (admin) captured via @CurrentUser()
    • Command: UpdateUserStatusCommand(userId, adminId, isActive, reason)
  4. POST /admin/users/ban - Ban/unban user

    • Body: BanUserDto {userId, reason, unban?}
    • Command: BanUserCommand(userId, adminId, reason, unban)
    • Key: Admin ID is captured from JWT
  5. POST /admin/subscriptions/adjust - Adjust subscription

    • Body: AdjustSubscriptionDto {userId, newPlanTier, reason}
    • Command: AdjustSubscriptionCommand(userId, adminId, newPlanTier, reason)
  6. GET /admin/dashboard - Dashboard stats

    • Query: GetDashboardStatsQuery
  7. GET /admin/revenue - Revenue statistics

    • Query params: startDate, endDate, groupBy (day/month)

AdminModerationController (Content Moderation)

File: presentation/controllers/admin-moderation.controller.ts

Endpoints:

  1. GET /admin/moderation - Get moderation queue

    • Query params: page, limit
    • Returns: ModerationQueueResult
  2. POST /admin/moderation/approve - Approve listing

    • Body: ApproveListingDto {listingId, moderationNotes}
    • Command: ApproveListingCommand(listingId, adminId, moderationNotes)
    • Event: ListingApprovedEvent published
  3. POST /admin/moderation/reject - Reject listing

    • Body: RejectListingDto {listingId, reason}
    • Command: RejectListingCommand(listingId, adminId, reason)
    • Event: ListingRejectedEvent published
  4. POST /admin/moderation/bulk - Bulk moderate

    • Body: BulkModerateDto {listingIds[], action, reason}
    • Command: BulkModerateListingsCommand(...)
  5. GET /admin/kyc - Get KYC queue

    • Returns: KycQueueResult (users with PENDING KYC)
  6. POST /admin/kyc/approve - Approve KYC

    • Body: ApproveKycDto {userId, comments}
    • Command: ApproveKycCommand(userId, adminId, comments)
    • Event: KycApprovedEvent published
  7. POST /admin/kyc/reject - Reject KYC

    • Body: RejectKycDto {userId, reason}
    • Command: RejectKycCommand(userId, adminId, reason)
    • Event: KycRejectedEvent published

Key Pattern: Admin ID Capture

@Post('moderation/approve')
async approveListing(
  @Body() dto: ApproveListingDto,
  @CurrentUser() user: JwtPayload,  // ← Admin's identity
) {
  return this.commandBus.execute(
    new ApproveListingCommand(dto.listingId, user.sub, dto.moderationNotes)
  );
  // user.sub = admin's userId
}

4. EXISTING EVENT/LOGGING INFRASTRUCTURE

DomainEvent Interface

Location: @modules/shared

// All domain events implement DomainEvent:
export interface DomainEvent {
  readonly eventName: string;
  readonly occurredAt: Date;
  readonly aggregateId: string;  // What changed (user/listing ID)
}

Example: UserBannedEvent

export class UserBannedEvent implements DomainEvent {
  readonly eventName = 'user.banned';
  readonly occurredAt = new Date();

  constructor(
    public readonly aggregateId: string,      // userId
    public readonly adminId: string,           // ← Admin who performed action
    public readonly reason: string,
  ) {}
}

Example: ListingApprovedEvent

export class ListingApprovedEvent implements DomainEvent {
  readonly eventName = 'listing.approved';
  readonly occurredAt = new Date();

  constructor(
    public readonly aggregateId: string,         // listingId
    public readonly adminId: string,             // ← Admin who approved
    public readonly moderationNotes?: string,
  ) {}
}

Event Publishing & Listening

Pattern Used: NestJS CQRS + EventEmitter

Publishing (in Command Handlers):

@CommandHandler(BanUserCommand)
export class BanUserHandler implements ICommandHandler<BanUserCommand> {
  constructor(
    private readonly userRepo: IUserRepository,
    private readonly eventBus: EventBus,  // ← Injected
  ) {}

  async execute(command: BanUserCommand): Promise<BanUserResult> {
    // ... business logic ...
    
    this.eventBus.publish(
      new UserBannedEvent(user.id, command.adminId, command.reason)
    );
    
    return { userId: user.id, isActive: false, message: 'Người dùng đã bị ban' };
  }
}

Listening (in Event Listeners):

@Injectable()
export class UserBannedListener {
  constructor(
    private readonly commandBus: CommandBus,
    private readonly prisma: PrismaService,
    private readonly logger: LoggerService,
  ) {}

  @OnEvent('user.banned', { async: true })
  async handle(event: UserBannedEvent): Promise<void> {
    this.logger.log(
      `Handling user.banned for user ${event.aggregateId}`,
      'UserBannedListener'
    );

    // Side effects: deactivate listings, send notification, etc.
    const deactivated = await this.prisma.listing.updateMany({
      where: { sellerId: event.aggregateId, status: { in: ['ACTIVE', ...] } },
      data: { status: 'EXPIRED' },
    });

    // Send email notification
    await this.commandBus.execute(
      new SendNotificationCommand(user.id, 'EMAIL', 'user.banned', ...)
    );
  }
}

EventBus Architecture

  • Module: @nestjs/cqrs
  • Setup: CqrsModule.forRoot() in app.module.ts
  • Mechanism:
    • Commands publish events via eventBus.publish(event)
    • Listeners subscribe via @OnEvent(eventName, { async: true })
    • Events are async by default (non-blocking)

5. LOGGER SERVICE

Location: apps/api/src/modules/shared/infrastructure/logger.service.ts

Features

  • Provider: Pino (structured logging)
  • PII Redaction: Automatic masking of sensitive fields
    • Redacted paths: password, token, email, phone, kycData, creditCard, etc.
    • Censor pattern: [REDACTED]
  • Environment-aware:
    • Dev: Pretty-printed with colors
    • Prod: Structured JSON
  • Methods:
    • log(message, context)
    • error(message, trace, context)
    • warn(message, context)
    • debug(message, context)
    • verbose(message, context)
    • child(bindings) - Child logger with context binding

Redacted Fields

redact: {
  paths: [
    'password', 'passwordHash', 'token', 'accessToken', 'refreshToken',
    'secret', 'authorization', 'cookie', 'creditCard', 'cardNumber',
    'cvv', 'ssn', 'cmnd', 'cccd', 'email', 'phone', 'kycData',
    'idNumber', 'identityNumber', 'dateOfBirth', 'dob', 'address',
    'bankAccount', 'accountNumber', 'apiKey', 'privateKey', 'encryptionKey',
    'req.headers.authorization', 'req.headers.cookie',
    'user.email', 'user.phone', 'user.kycData',
    'body.password', 'body.token', 'body.email', 'body.phone',
  ],
  censor: '[REDACTED]',
}

6. EXCEPTION HANDLING & FILTERS

GlobalExceptionFilter

Location: apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  constructor(private readonly logger: LoggerService) {}

  catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const correlationId = (request.headers['x-correlation-id'] as string) ?? undefined;

    const errorResponse = this.buildErrorResponse(exception, correlationId);
    
    this.logger.error(
      `[${errorResponse.errorCode}] ${errorResponse.message}`,
      exception instanceof Error ? exception.stack : undefined,
      'GlobalExceptionFilter'
    );

    response.status(errorResponse.statusCode).json(errorResponse);
  }
}

Error Response Structure

interface ErrorResponseBody {
  statusCode: number;
  errorCode: ErrorCode;  // Enum with values like VALIDATION_FAILED, NOT_FOUND, etc.
  message: string;
  details?: Record<string, unknown>;
  correlationId?: string;
  timestamp: string;
}

Domain Exceptions

export class DomainException extends HttpException {
  constructor(
    public readonly errorCode: ErrorCode,
    message: string,
    statusCode: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
    public readonly details?: Record<string, unknown>,
  ) {
    super(message, statusCode);
  }
}

// Specific exceptions:
export class NotFoundException extends DomainException { ... }
export class ValidationException extends DomainException { ... }
export class ConflictException extends DomainException { ... }
export class UnauthorizedException extends DomainException { ... }
export class ForbiddenException extends DomainException { ... }

7. SECURITY & GUARDS

Role-Based Access Control (RBAC)

Location: apps/api/src/modules/auth/presentation/decorators/

Pattern:

@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
export class AdminController {
  // All endpoints require ADMIN role
}

Roles Guard Flow:

  1. JwtAuthGuard - Validates JWT token
  2. RolesGuard - Checks @Roles() decorator against user.role
  3. Both decorators from @modules/auth

Rate Limiting

Setup: ThrottlerModule + ThrottlerBehindProxyGuard

  • Default: 60 requests per 60 seconds per IP
  • Auth endpoints: 10 requests per 60 seconds
  • Payment callbacks: 20 requests per 60 seconds

8. DDD LAYER STRUCTURE

Architectural Layers

Presentation Layer (Controllers)
    ↓ (DTO validation)
Application Layer (Commands/Queries/Handlers/Listeners)
    ↓ (Command/Query)
Domain Layer (Events, Interfaces, Business Rules)
    ↓ (Repository calls, Event publishing)
Infrastructure Layer (Prisma, Database)

Command Handler Pattern

// 1. Command (DTO-like)
export class BanUserCommand {
  constructor(
    public readonly userId: string,
    public readonly adminId: string,
    public readonly reason: string,
    public readonly unban: boolean = false,
  ) {}
}

// 2. Handler (Business Logic + Event Publishing)
@CommandHandler(BanUserCommand)
export class BanUserHandler implements ICommandHandler<BanUserCommand> {
  constructor(
    @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: BanUserCommand): Promise<BanUserResult> {
    // Business logic
    const user = await this.userRepo.findById(command.userId);
    if (!user) throw new NotFoundException(...);
    
    user.deactivate();
    await this.userRepo.update(user);
    
    // Publish event
    this.eventBus.publish(
      new UserBannedEvent(user.id, command.adminId, command.reason)
    );
    
    return { userId: user.id, isActive: false, message: '...' };
  }
}

// 3. Event (Side-effect Trigger)
export class UserBannedEvent implements DomainEvent {
  readonly eventName = 'user.banned';
  readonly occurredAt = new Date();
  
  constructor(
    public readonly aggregateId: string,
    public readonly adminId: string,
    public readonly reason: string,
  ) {}
}

// 4. Listener (Side Effects - triggered by Event)
@Injectable()
export class UserBannedListener {
  @OnEvent('user.banned', { async: true })
  async handle(event: UserBannedEvent): Promise<void> {
    // Send notification, update related data, etc.
  }
}

Query Handler Pattern

// Query (read operation definition)
export class GetDashboardStatsQuery {}

// Handler (fetch & return data)
@QueryHandler(GetDashboardStatsQuery)
export class GetDashboardStatsHandler implements IQueryHandler<GetDashboardStatsQuery> {
  constructor(
    @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
  ) {}

  async execute(_query: GetDashboardStatsQuery): Promise<DashboardStats> {
    return this.adminQueryRepo.getDashboardStats();
  }
}

Repository Pattern

// Domain interface (no implementation details)
export interface IAdminQueryRepository {
  getModerationQueue(page: number, limit: number): Promise<ModerationQueueResult>;
  getDashboardStats(): Promise<DashboardStats>;
  // ... more methods
}

// Infrastructure implementation (Prisma-specific)
@Injectable()
export class PrismaAdminQueryRepository implements IAdminQueryRepository {
  constructor(private readonly prisma: PrismaService) {}

  async getModerationQueue(page: number, limit: number): Promise<ModerationQueueResult> {
    // Prisma queries here
  }
}

// DI Token
export const ADMIN_QUERY_REPOSITORY = Symbol('ADMIN_QUERY_REPOSITORY');

// Module registration
@Module({
  providers: [
    { provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository },
  ],
})
export class AdminModule {}

9. MODULE BOOTSTRAP

AdminModule Setup

File: apps/api/src/modules/admin/admin.module.ts

@Module({
  imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule],
  controllers: [AdminController, AdminModerationController],
  providers: [
    // Repositories
    { provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository },

    // CQRS Handlers
    ...CommandHandlers,  // 8 command handlers
    ...QueryHandlers,    // 6 query handlers

    // Event Listeners
    UserBannedListener,
    UserDeactivatedListener,
  ],
})
export class AdminModule {}

Global Setup

File: apps/api/src/app.module.ts

@Module({
  imports: [
    SentryModule.forRoot(),
    CqrsModule.forRoot(),              // ← CQRS with Event Bus
    ScheduleModule.forRoot(),
    ThrottlerModule.forRoot(...),      // ← Rate limiting
    // ... other modules including AdminModule
  ],
  providers: [
    {
      provide: APP_FILTER,
      useClass: SentryGlobalFilter,
    },
    {
      provide: APP_GUARD,
      useClass: ThrottlerBehindProxyGuard,
    },
    {
      provide: APP_INTERCEPTOR,
      useClass: HttpMetricsInterceptor,
    },
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(SanitizeInputMiddleware).forRoutes('*');
    consumer.apply(CsrfMiddleware).exclude(...).forRoutes('*');
  }
}

10. EXISTING INTERCEPTOR PATTERNS

HttpMetricsInterceptor

Location: @modules/metrics

  • Injected globally via APP_INTERCEPTOR
  • Tracks HTTP request/response metrics
  • Could serve as a template for audit logging interceptor

CSRF Middleware

Location: @modules/shared/infrastructure/middleware/csrf.middleware

  • Double-submit cookie pattern
  • Validates on state-changing methods
  • Provides model for request context enhancement

11. SUMMARY FOR AUDIT LOGGING IMPLEMENTATION

What's Already in Place

  1. Event-driven architecture - Commands publish events, listeners handle side effects
  2. Admin identity capture - All admin actions have adminId in commands
  3. Logger service - Pino-based with PII redaction
  4. Exception handling - Global filter + DomainException hierarchy
  5. RBAC - @Roles('ADMIN') guard in place
  6. Module bootstrap - Clear DI pattern ready for audit repository injection
  7. DTO validation - All inputs validated via class-validator

What Needs to Be Built 🚀

  1. AuditLog Prisma Model - Store in database
  2. AuditLoggingInterceptor - Capture HTTP context (IP, timestamp, endpoint)
  3. AuditEvent Domain Event - Extend domain events for audit purposes
  4. AuditLoggingListener - Event listener that persists to AuditLog
  5. AuditLog Repository - CRUD operations for AuditLog
  6. Query Handler - Retrieve audit logs with filters (date range, admin, action type)
  7. Controller Endpoint - GET /admin/audit-logs for viewing audit trail

Key Integration Points

  1. Commands already pass adminId → Use in AuditLoggingListener
  2. Domain events already published → Hook audit listener to relevant events
  3. HTTPContext (IP, user-agent, etc.) → Capture in interceptor
  4. Logger service available → Use for structured logging
  5. Repository pattern established → Follow for AuditLog repository
  • id (primary key)
  • adminId (who performed action)
  • adminName (for denormalization)
  • action (e.g., 'user.banned', 'listing.approved')
  • resourceType (e.g., 'user', 'listing')
  • resourceId (what was affected)
  • changes (JSON of what changed - before/after)
  • reason (from DTO if provided)
  • ipAddress (from request)
  • userAgent (from request)
  • status (success/failure)
  • statusCode (HTTP status)
  • errorMessage (if failed)
  • duration (milliseconds)
  • createdAt (timestamp)

Key Files Reference

Controllers

  • presentation/controllers/admin.controller.ts - Main admin operations
  • presentation/controllers/admin-moderation.controller.ts - Moderation & KYC

Command Handlers (Action Points)

  • application/commands/ban-user/ban-user.handler.ts
  • application/commands/approve-listing/approve-listing.handler.ts
  • application/commands/approve-kyc/approve-kyc.handler.ts
  • application/commands/reject-listing/reject-listing.handler.ts
  • application/commands/reject-kyc/reject-kyc.handler.ts
  • application/commands/adjust-subscription/adjust-subscription.handler.ts
  • application/commands/update-user-status/update-user-status.handler.ts
  • application/commands/bulk-moderate-listings/bulk-moderate-listings.handler.ts

Event Listeners (Where to Hook Audit)

  • application/listeners/user-banned.listener.ts
  • application/listeners/user-deactivated.listener.ts

Infrastructure

  • infrastructure/repositories/prisma-admin-query.repository.ts
  • infrastructure/repositories/admin-stats.queries.ts
  • infrastructure/repositories/admin-user.queries.ts

Shared Resources

  • Logger: @modules/shared/infrastructure/logger.service.ts
  • Exception Filter: @modules/shared/infrastructure/filters/global-exception.filter.ts
  • Roles Guard: @modules/auth (decorators + guard)

Prisma

  • Schema: prisma/schema.prisma (602 lines, no audit model yet)
  • Client type safety guaranteed

Next Steps

  1. Schema Design - Create AuditLog model in Prisma
  2. Repository Pattern - Create AuditLogRepository with interface
  3. Audit Events - Create domain events for audit purposes
  4. Event Listener - Create AuditLoggingListener to persist events
  5. Interceptor - Capture HTTP context (optional enhancement)
  6. Query Handler - Create query/handler for retrieving audit logs
  7. Controller Endpoint - Add GET /admin/audit-logs endpoint
  8. Tests - Unit and integration tests
  9. Documentation - API docs in Swagger