# Audit Logging Architecture for GoodGo Admin Module ## System Design Overview ``` ┌─────────────────────────────────────────────────────────────────────┐ │ HTTP Request (Admin Action) │ │ POST /admin/users/ban │ POST /admin/moderation/approve │ etc. │ └────────────────────────────┬──────────────────────────────────────┘ │ ▼ ┌──────────────────────┐ │ Authentication & Validation │ - JwtAuthGuard (@UseGuards) │ - RolesGuard (@Roles('ADMIN')) │ - ValidationPipe (DTO) │ - @CurrentUser() extracts JWT └──────────────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ Controller (Presentation Layer) │ │ │ │ @Post('users/ban') │ │ async banUser( │ │ @Body() dto: BanUserDto, │ ◄─── DTO validation │ @CurrentUser() user: JwtPayload │ ◄─── Admin ID here! │ ) { │ │ return this.commandBus.execute( │ │ new BanUserCommand( │ │ dto.userId, │ │ user.sub, ◄─────────────┐ │ │ dto.reason │ │ │ ) │ │ │ ); │ │ │ } │ │ └─────────────────┬───────────────┘ │ │ │ ▼ (Command) │ ┌──────────────────────────────────────┐ │ CQRS Bus - Routing │ │ (CommandBus.execute) │ └────────────────┬─────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Command Handler (Application Layer) │ │ │ │ @CommandHandler(BanUserCommand) │ │ export class BanUserHandler { │ │ async execute( │ │ command: BanUserCommand │ │ ): Promise { │ │ │ │ // 1. Business Logic │ │ const user = await │ │ this.userRepo.findById(...) │ │ user.deactivate() │ │ await this.userRepo.update(...) │ │ │ │ // 2. Publish Domain Event │ │ this.eventBus.publish( │ │ new UserBannedEvent( │ │ user.id, ◄─── Resource ID │ command.adminId, ◄─── Admin ID │ command.reason ◄─── Action context │ ) │ │ ); │ │ │ │ return { ... }; │ │ } │ │ } │ └──────────────────┬────────────────────┘ │ (Event Published) ▼ ┌──────────────────────────────────┐ │ │ Event Emitted: 'user.banned' │ │ │ { │ │ │ eventName: 'user.banned', │ │ │ aggregateId: 'usr_xyz', │ │ │ adminId: 'adm_abc', ◄────┐ │ │ │ reason: '...', │ │ │ │ occurredAt: now() │ │ │ │ } │ │ └──────────────────────────────────┘ │ │ │ ┌──────────────────┴──────────────────┐ │ │ │ │ ▼ (Event Subscribe) ▼ │ ┌──────────────────────────┐ ┌──────────────────┐ │ │ Existing Listeners │ │ Audit Listener │ │ │ │ │ │ │ │ UserBannedListener │ │ AuditLogging │ │ │ @OnEvent('user.banned') │ │ Listener │ │ │ - Deactivate listings │ │ @OnEvent(...) │ │ │ - Send notification │ │ - Extract info │ │ │ │ │ - Create record │◄─┘ └──────────────────────────┘ │ - Persist to DB │ └────────┬─────────┘ │ ▼ ┌──────────────────────────┐ │ AuditLog Repository │ │ (Infrastructure Layer) │ │ │ │ @Injectable() │ │ export class Prisma │ │ AuditLogRepository {} │ │ │ │ Methods: │ │ - create(auditLog) │ │ - findMany(filters) │ │ - findById(id) │ └──────────────┬───────────┘ │ ▼ ┌──────────────────────────┐ │ Prisma Client │ │ (Database Driver) │ └──────────────┬───────────┘ │ ▼ ┌──────────────────────────┐ │ PostgreSQL Database │ │ │ │ AuditLog Table │ │ ├─ id │ │ ├─ adminId │ │ ├─ action │ │ ├─ resourceType │ │ ├─ resourceId │ │ ├─ reason │ │ ├─ status │ │ └─ createdAt │ └──────────────────────────┘ ``` --- ## Data Flow Sequence ``` Admin Action → Controller → Command → Event → AuditListener → Repository → Database ┌───┐ POST /admin/users/ban ┌──────────┐ │ ├──────────────────────────────────▶│ Controller └───┘ {userId, reason, jwt} └──────┬───┘ │ (validate & extract admin ID) │ ┌──────▼────────┐ │ CommandBus │ └──────┬────────┘ │ ┌──────────▼──────────┐ │ BanUserCommand │ │ {userId, adminId, │ │ reason, unban} │ └──────────┬──────────┘ │ ┌──────────▼──────────────┐ │ BanUserHandler │ │ (execute business logic)│ └──────────┬──────────────┘ │ (publish event) │ ┌──────────▼────────────────┐ │ UserBannedEvent │ │ {aggregateId, adminId, │ │ reason, occurredAt} │ └──────────┬────────────────┘ │ ┌──────────────────────────┼──────────────────────────┐ │ │ │ ▼ (existing listener) ▼ (NEW - audit listener) │ ┌──────────────────────┐ ┌──────────────────────────────┐ │ │ UserBannedListener │ │ AuditLoggingListener │ │ │ - Deactivate listings│ │ - Extract event data │ │ │ - Send notification │ │ - Map to AuditLog record │ │ └──────────────────────┘ │ - Call repository.create() │ │ └──────────┬──────────────────┘ │ │ │ ┌──────────▼─────────┐ │ │ PrismaAuditLog │ │ │ Repository │ │ │ .create({...}) │ │ └──────────┬─────────┘ │ │ │ ┌──────────▼──────────┐ │ │ prisma.auditLog │ │ │ .create({ │ │ │ adminId, │ │ │ action, │ │ │ resourceType, │ │ │ resourceId, │ │ │ reason, │ │ │ status, │ │ │ createdAt │ │ │ }) │ │ └──────────┬──────────┘ │ │ │ ┌──────────▼──────────┐ │ │ PostgreSQL │ │ │ INSERT INTO auditLog│ │ │ values(...) │ │ └─────────────────────┘ │ ▼ (HTTP Response) 200 OK { userId: 'usr_xyz', isActive: false, message: '...' } ``` --- ## Prisma Schema Addition ```typescript // Add to prisma/schema.prisma // ============================================================================ // ADMIN AUDIT LOG // ============================================================================ enum AdminAction { USER_BANNED USER_UNBANNED USER_DEACTIVATED USER_STATUS_UPDATED LISTING_APPROVED LISTING_REJECTED LISTING_BULK_MODERATED KYC_APPROVED KYC_REJECTED SUBSCRIPTION_ADJUSTED } enum AuditResourceType { USER LISTING KYC SUBSCRIPTION } enum AuditStatus { SUCCESS FAILED } model AuditLog { id String @id @default(cuid()) adminId String admin User? @relation(fields: [adminId], references: [id]) action AdminAction resourceType AuditResourceType resourceId String changes Json? // {before: {...}, after: {...}} reason String? @db.Text ipAddress String? userAgent String? status AuditStatus @default(SUCCESS) statusCode Int? errorMessage String? duration Int? // milliseconds createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([adminId]) @@index([action]) @@index([resourceType]) @@index([resourceId]) @@index([createdAt]) @@index([adminId, createdAt(sort: Desc)]) @@index([action, createdAt(sort: Desc)]) @@index([resourceType, resourceId, createdAt(sort: Desc)]) } // Add relationship to User model: model User { // ... existing fields ... auditLogs AuditLog[] // New relation } ``` --- ## Repository Layer ### Domain Interface ```typescript // domain/repositories/audit-log.repository.ts export interface IAuditLogRepository { create(auditLog: CreateAuditLogDto): Promise; findMany(params: FindAuditLogsParams): Promise>; findById(id: string): Promise; findByAdminId(adminId: string, pagination: Pagination): Promise>; } export interface CreateAuditLogDto { adminId: string; action: AdminAction; resourceType: AuditResourceType; resourceId: string; changes?: Record; reason?: string; ipAddress?: string; userAgent?: string; status: AuditStatus; statusCode?: number; errorMessage?: string; duration?: number; } export interface FindAuditLogsParams { page: number; limit: number; adminId?: string; action?: AdminAction; resourceType?: AuditResourceType; resourceId?: string; startDate?: Date; endDate?: Date; } ``` ### Infrastructure Implementation ```typescript // infrastructure/repositories/prisma-audit-log.repository.ts @Injectable() export class PrismaAuditLogRepository implements IAuditLogRepository { constructor(private readonly prisma: PrismaService) {} async create(dto: CreateAuditLogDto): Promise { return this.prisma.auditLog.create({ data: { adminId: dto.adminId, action: dto.action, resourceType: dto.resourceType, resourceId: dto.resourceId, changes: dto.changes, reason: dto.reason, ipAddress: dto.ipAddress, userAgent: dto.userAgent, status: dto.status, statusCode: dto.statusCode, errorMessage: dto.errorMessage, duration: dto.duration, }, }); } async findMany(params: FindAuditLogsParams): Promise> { const skip = (params.page - 1) * params.limit; const where: Prisma.AuditLogWhereInput = { ...(params.adminId && { adminId: params.adminId }), ...(params.action && { action: params.action }), ...(params.resourceType && { resourceType: params.resourceType }), ...(params.resourceId && { resourceId: params.resourceId }), ...(params.startDate || params.endDate) && { createdAt: { ...(params.startDate && { gte: params.startDate }), ...(params.endDate && { lte: params.endDate }), }, }, }; const [data, total] = await Promise.all([ this.prisma.auditLog.findMany({ where, skip, take: params.limit, orderBy: { createdAt: 'desc' }, }), this.prisma.auditLog.count({ where }), ]); return { data, total, page: params.page, limit: params.limit, totalPages: Math.ceil(total / params.limit), }; } // ... other methods } ``` --- ## Event Listener Implementation ```typescript // application/listeners/audit-logging.listener.ts @Injectable() export class AuditLoggingListener { constructor( @Inject(AUDIT_LOG_REPOSITORY) private readonly auditRepo: IAuditLogRepository, private readonly logger: LoggerService, ) {} @OnEvent('user.banned', { async: true }) async handleUserBanned(event: UserBannedEvent): Promise { await this.createAuditLog({ adminId: event.adminId, action: AdminAction.USER_BANNED, resourceType: AuditResourceType.USER, resourceId: event.aggregateId, reason: event.reason, status: AuditStatus.SUCCESS, }); } @OnEvent('user.unbanned', { async: true }) async handleUserUnbanned(event: UserUnbannedEvent): Promise { await this.createAuditLog({ adminId: event.adminId, action: AdminAction.USER_UNBANNED, resourceType: AuditResourceType.USER, resourceId: event.aggregateId, status: AuditStatus.SUCCESS, }); } @OnEvent('listing.approved', { async: true }) async handleListingApproved(event: ListingApprovedEvent): Promise { await this.createAuditLog({ adminId: event.adminId, action: AdminAction.LISTING_APPROVED, resourceType: AuditResourceType.LISTING, resourceId: event.aggregateId, reason: event.moderationNotes, status: AuditStatus.SUCCESS, }); } @OnEvent('kyc.approved', { async: true }) async handleKycApproved(event: KycApprovedEvent): Promise { await this.createAuditLog({ adminId: event.adminId, action: AdminAction.KYC_APPROVED, resourceType: AuditResourceType.KYC, resourceId: event.aggregateId, reason: event.comments, status: AuditStatus.SUCCESS, }); } // ... more handlers ... private async createAuditLog(dto: CreateAuditLogDto): Promise { try { await this.auditRepo.create(dto); this.logger.log( `Audit logged: ${dto.action} on ${dto.resourceType}/${dto.resourceId}`, 'AuditLoggingListener', ); } catch (error) { this.logger.error( `Failed to create audit log: ${String(error)}`, error instanceof Error ? error.stack : undefined, 'AuditLoggingListener', ); // Don't re-throw to prevent interrupting the main operation } } } ``` --- ## Query Handler for Retrieval ```typescript // application/queries/get-audit-logs/get-audit-logs.handler.ts export class GetAuditLogsQuery { constructor( public readonly page: number, public readonly limit: number, public readonly adminId?: string, public readonly action?: AdminAction, public readonly resourceType?: AuditResourceType, public readonly startDate?: Date, public readonly endDate?: Date, ) {} } @QueryHandler(GetAuditLogsQuery) export class GetAuditLogsHandler implements IQueryHandler { constructor( @Inject(AUDIT_LOG_REPOSITORY) private readonly auditRepo: IAuditLogRepository, ) {} async execute(query: GetAuditLogsQuery): Promise> { return this.auditRepo.findMany({ page: query.page, limit: query.limit, adminId: query.adminId, action: query.action, resourceType: query.resourceType, startDate: query.startDate, endDate: query.endDate, }); } } ``` --- ## Controller Endpoint ```typescript // presentation/controllers/admin.controller.ts (add to existing file) @Get('audit-logs') @ApiOperation({ summary: 'Get audit logs with filters' }) @ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'limit', required: false, type: Number }) @ApiQuery({ name: 'adminId', required: false, type: String }) @ApiQuery({ name: 'action', required: false, enum: AdminAction }) @ApiQuery({ name: 'resourceType', required: false, enum: AuditResourceType }) @ApiQuery({ name: 'startDate', required: false, type: String }) @ApiQuery({ name: 'endDate', required: false, type: String }) @ApiResponse({ status: 200, description: 'Audit logs retrieved' }) async getAuditLogs( @Query() query: GetAuditLogsQueryDto, ): Promise> { return this.queryBus.execute( new GetAuditLogsQuery( query.page ?? 1, query.limit ?? 20, query.adminId, query.action, query.resourceType, query.startDate ? new Date(query.startDate) : undefined, query.endDate ? new Date(query.endDate) : undefined, ), ); } ``` --- ## DI Registration ```typescript // admin.module.ts (update existing) import { AUDIT_LOG_REPOSITORY, IAuditLogRepository } from '...'; import { PrismaAuditLogRepository } from '...'; import { AuditLoggingListener } from '...'; const QueryHandlers = [ // ... existing handlers ... GetAuditLogsHandler, // NEW ]; const EventListeners = [ UserBannedListener, UserDeactivatedListener, AuditLoggingListener, // NEW ]; @Module({ imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule], controllers: [AdminController, AdminModerationController], providers: [ // Repositories { provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository }, { provide: AUDIT_LOG_REPOSITORY, useClass: PrismaAuditLogRepository }, // NEW // CQRS ...CommandHandlers, ...QueryHandlers, // Event Listeners ...EventListeners, ], }) export class AdminModule {} ``` --- ## Testing Strategy ### Unit Test (AuditLoggingListener) ```typescript describe('AuditLoggingListener', () => { let listener: AuditLoggingListener; let auditRepo: MockRepository; beforeEach(() => { auditRepo = mockRepository(); listener = new AuditLoggingListener(auditRepo, mockLogger()); }); it('should create audit log on user.banned event', async () => { const event = new UserBannedEvent('usr_123', 'admin_456', 'Violation'); await listener.handleUserBanned(event); expect(auditRepo.create).toHaveBeenCalledWith( expect.objectContaining({ adminId: 'admin_456', action: AdminAction.USER_BANNED, resourceId: 'usr_123', reason: 'Violation', }) ); }); it('should handle repository errors gracefully', async () => { auditRepo.create.mockRejectedValueOnce(new Error('DB error')); const event = new UserBannedEvent('usr_123', 'admin_456', 'Violation'); // Should not throw await expect(listener.handleUserBanned(event)).resolves.toBeUndefined(); }); }); ``` ### Integration Test ```typescript describe('Audit Logging Integration', () => { let app: INestApplication; let prisma: PrismaService; beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AdminModule, SharedModule], }).compile(); app = module.createNestApplication(); prisma = module.get(PrismaService); }); it('should log admin action to database', async () => { const admin = await createTestAdmin(prisma); const user = await createTestUser(prisma); await app.get(CommandBus).execute( new BanUserCommand(user.id, admin.id, 'Test ban') ); const auditLog = await prisma.auditLog.findFirst({ where: { adminId: admin.id, resourceId: user.id }, }); expect(auditLog).toBeDefined(); expect(auditLog?.action).toBe(AdminAction.USER_BANNED); expect(auditLog?.reason).toBe('Test ban'); }); }); ``` --- ## Summary This architecture ensures: ✅ **Separation of Concerns** - Audit logging as separate concern via event listener ✅ **Non-Blocking** - Audit logging happens async, doesn't block main operation ✅ **Reusability** - Single listener handles all admin actions ✅ **Consistency** - Follows existing DDD/CQRS patterns ✅ **Queryability** - Full audit trail with filtering capabilities ✅ **Compliance** - Complete record of who did what and when