# 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) ```typescript 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) ```typescript 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 ```typescript @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` ```typescript // All domain events implement DomainEvent: export interface DomainEvent { readonly eventName: string; readonly occurredAt: Date; readonly aggregateId: string; // What changed (user/listing ID) } ``` ### Example: UserBannedEvent ```typescript 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 ```typescript 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): ```typescript @CommandHandler(BanUserCommand) export class BanUserHandler implements ICommandHandler { constructor( private readonly userRepo: IUserRepository, private readonly eventBus: EventBus, // ← Injected ) {} async execute(command: BanUserCommand): Promise { // ... 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): ```typescript @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 { 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 ```typescript 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` ```typescript @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(); const request = ctx.getRequest(); 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 ```typescript interface ErrorResponseBody { statusCode: number; errorCode: ErrorCode; // Enum with values like VALIDATION_FAILED, NOT_FOUND, etc. message: string; details?: Record; correlationId?: string; timestamp: string; } ``` ### Domain Exceptions ```typescript export class DomainException extends HttpException { constructor( public readonly errorCode: ErrorCode, message: string, statusCode: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR, public readonly details?: Record, ) { 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: ```typescript @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 ```typescript // 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 { constructor( @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, private readonly eventBus: EventBus, ) {} async execute(command: BanUserCommand): Promise { // 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 { // Send notification, update related data, etc. } } ``` ### Query Handler Pattern ```typescript // Query (read operation definition) export class GetDashboardStatsQuery {} // Handler (fetch & return data) @QueryHandler(GetDashboardStatsQuery) export class GetDashboardStatsHandler implements IQueryHandler { constructor( @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository, ) {} async execute(_query: GetDashboardStatsQuery): Promise { return this.adminQueryRepo.getDashboardStats(); } } ``` ### Repository Pattern ```typescript // Domain interface (no implementation details) export interface IAdminQueryRepository { getModerationQueue(page: number, limit: number): Promise; getDashboardStats(): Promise; // ... more methods } // Infrastructure implementation (Prisma-specific) @Injectable() export class PrismaAdminQueryRepository implements IAdminQueryRepository { constructor(private readonly prisma: PrismaService) {} async getModerationQueue(page: number, limit: number): Promise { // 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` ```typescript @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` ```typescript @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 ### Recommended Audit Fields - `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