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>
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:
-
GET /admin/users - List users with filters
- Query params: page, limit, role, isActive, search
- Returns: UserListResult (paginated)
-
GET /admin/users/:id - Get user details
- Returns: UserDetail (full profile + activity)
-
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)
- Body:
-
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
- Body:
-
POST /admin/subscriptions/adjust - Adjust subscription
- Body:
AdjustSubscriptionDto{userId, newPlanTier, reason} - Command:
AdjustSubscriptionCommand(userId, adminId, newPlanTier, reason)
- Body:
-
GET /admin/dashboard - Dashboard stats
- Query:
GetDashboardStatsQuery
- Query:
-
GET /admin/revenue - Revenue statistics
- Query params: startDate, endDate, groupBy (day/month)
AdminModerationController (Content Moderation)
File: presentation/controllers/admin-moderation.controller.ts
Endpoints:
-
GET /admin/moderation - Get moderation queue
- Query params: page, limit
- Returns: ModerationQueueResult
-
POST /admin/moderation/approve - Approve listing
- Body:
ApproveListingDto{listingId, moderationNotes} - Command:
ApproveListingCommand(listingId, adminId, moderationNotes) - Event:
ListingApprovedEventpublished
- Body:
-
POST /admin/moderation/reject - Reject listing
- Body:
RejectListingDto{listingId, reason} - Command:
RejectListingCommand(listingId, adminId, reason) - Event:
ListingRejectedEventpublished
- Body:
-
POST /admin/moderation/bulk - Bulk moderate
- Body:
BulkModerateDto{listingIds[], action, reason} - Command:
BulkModerateListingsCommand(...)
- Body:
-
GET /admin/kyc - Get KYC queue
- Returns: KycQueueResult (users with PENDING KYC)
-
POST /admin/kyc/approve - Approve KYC
- Body:
ApproveKycDto{userId, comments} - Command:
ApproveKycCommand(userId, adminId, comments) - Event:
KycApprovedEventpublished
- Body:
-
POST /admin/kyc/reject - Reject KYC
- Body:
RejectKycDto{userId, reason} - Command:
RejectKycCommand(userId, adminId, reason) - Event:
KycRejectedEventpublished
- Body:
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()inapp.module.ts - Mechanism:
- Commands publish events via
eventBus.publish(event) - Listeners subscribe via
@OnEvent(eventName, { async: true }) - Events are async by default (non-blocking)
- Commands publish events via
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:
JwtAuthGuard- Validates JWT tokenRolesGuard- Checks@Roles()decorator against user.role- 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 ✅
- Event-driven architecture - Commands publish events, listeners handle side effects
- Admin identity capture - All admin actions have
adminIdin commands - Logger service - Pino-based with PII redaction
- Exception handling - Global filter + DomainException hierarchy
- RBAC - @Roles('ADMIN') guard in place
- Module bootstrap - Clear DI pattern ready for audit repository injection
- DTO validation - All inputs validated via class-validator
What Needs to Be Built 🚀
- AuditLog Prisma Model - Store in database
- AuditLoggingInterceptor - Capture HTTP context (IP, timestamp, endpoint)
- AuditEvent Domain Event - Extend domain events for audit purposes
- AuditLoggingListener - Event listener that persists to AuditLog
- AuditLog Repository - CRUD operations for AuditLog
- Query Handler - Retrieve audit logs with filters (date range, admin, action type)
- Controller Endpoint - GET /admin/audit-logs for viewing audit trail
Key Integration Points
- Commands already pass
adminId→ Use in AuditLoggingListener - Domain events already published → Hook audit listener to relevant events
- HTTPContext (IP, user-agent, etc.) → Capture in interceptor
- Logger service available → Use for structured logging
- 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 operationspresentation/controllers/admin-moderation.controller.ts- Moderation & KYC
Command Handlers (Action Points)
application/commands/ban-user/ban-user.handler.tsapplication/commands/approve-listing/approve-listing.handler.tsapplication/commands/approve-kyc/approve-kyc.handler.tsapplication/commands/reject-listing/reject-listing.handler.tsapplication/commands/reject-kyc/reject-kyc.handler.tsapplication/commands/adjust-subscription/adjust-subscription.handler.tsapplication/commands/update-user-status/update-user-status.handler.tsapplication/commands/bulk-moderate-listings/bulk-moderate-listings.handler.ts
Event Listeners (Where to Hook Audit)
application/listeners/user-banned.listener.tsapplication/listeners/user-deactivated.listener.ts
Infrastructure
infrastructure/repositories/prisma-admin-query.repository.tsinfrastructure/repositories/admin-stats.queries.tsinfrastructure/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
- ✅ Schema Design - Create AuditLog model in Prisma
- ✅ Repository Pattern - Create AuditLogRepository with interface
- ✅ Audit Events - Create domain events for audit purposes
- ✅ Event Listener - Create AuditLoggingListener to persist events
- ✅ Interceptor - Capture HTTP context (optional enhancement)
- ✅ Query Handler - Create query/handler for retrieving audit logs
- ✅ Controller Endpoint - Add GET /admin/audit-logs endpoint
- ✅ Tests - Unit and integration tests
- ✅ Documentation - API docs in Swagger