From d9726d496144f4fcec9344d794496cec8260540e Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 9 Apr 2026 09:42:45 +0700 Subject: [PATCH] feat(admin): add user-banned listener and improve moderation handlers Add event listener for user-banned events with spec. Improve KYC approval/ rejection, listing moderation, and user status handlers with proper dependency injection and ConfigService usage. Co-Authored-By: Paperclip --- apps/api/src/modules/admin/admin.module.ts | 4 + .../__tests__/user-banned.listener.spec.ts | 82 +++++++++++++++++++ .../approve-kyc/approve-kyc.handler.ts | 2 +- .../approve-listing.handler.ts | 2 +- .../commands/ban-user/ban-user.handler.ts | 2 +- .../bulk-moderate-listings.handler.ts | 2 +- .../commands/reject-kyc/reject-kyc.handler.ts | 2 +- .../reject-listing/reject-listing.handler.ts | 2 +- .../update-user-status.handler.ts | 2 +- .../listeners/user-banned.listener.ts | 52 ++++++++++++ .../prisma-admin-query.repository.ts | 2 +- .../controllers/admin.controller.ts | 6 +- 12 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/modules/admin/application/__tests__/user-banned.listener.spec.ts create mode 100644 apps/api/src/modules/admin/application/listeners/user-banned.listener.ts diff --git a/apps/api/src/modules/admin/admin.module.ts b/apps/api/src/modules/admin/admin.module.ts index 681cd96..38b0fae 100644 --- a/apps/api/src/modules/admin/admin.module.ts +++ b/apps/api/src/modules/admin/admin.module.ts @@ -11,6 +11,7 @@ import { BulkModerateListingsHandler } from './application/commands/bulk-moderat import { RejectKycHandler } from './application/commands/reject-kyc/reject-kyc.handler'; import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler'; import { UpdateUserStatusHandler } from './application/commands/update-user-status/update-user-status.handler'; +import { UserBannedListener } from './application/listeners/user-banned.listener'; import { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.handler'; import { GetKycQueueHandler } from './application/queries/get-kyc-queue/get-kyc-queue.handler'; import { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler'; @@ -51,6 +52,9 @@ const QueryHandlers = [ // CQRS ...CommandHandlers, ...QueryHandlers, + + // Event Listeners + UserBannedListener, ], }) export class AdminModule {} diff --git a/apps/api/src/modules/admin/application/__tests__/user-banned.listener.spec.ts b/apps/api/src/modules/admin/application/__tests__/user-banned.listener.spec.ts new file mode 100644 index 0000000..67a62e0 --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/user-banned.listener.spec.ts @@ -0,0 +1,82 @@ +import { UserBannedListener } from '../listeners/user-banned.listener'; + +describe('UserBannedListener', () => { + let listener: UserBannedListener; + let mockCommandBus: { execute: ReturnType }; + let mockPrisma: { + listing: { updateMany: ReturnType }; + user: { findUnique: ReturnType }; + }; + let mockLogger: { log: ReturnType; warn: ReturnType }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) }; + mockPrisma = { + listing: { updateMany: vi.fn().mockResolvedValue({ count: 3 }) }, + user: { findUnique: vi.fn() }, + }; + mockLogger = { log: vi.fn(), warn: vi.fn() }; + + listener = new UserBannedListener( + mockCommandBus as any, + mockPrisma as any, + mockLogger as any, + ); + }); + + it('deactivates all user listings when banned', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' }); + + await listener.handle({ + aggregateId: 'user-1', + adminId: 'admin-1', + reason: 'Vi phạm chính sách', + eventName: 'user.banned', + occurredAt: new Date(), + }); + + expect(mockPrisma.listing.updateMany).toHaveBeenCalledWith({ + where: { + sellerId: 'user-1', + status: { in: ['ACTIVE', 'PENDING_REVIEW', 'DRAFT'] }, + }, + data: { status: 'CANCELLED' }, + }); + }); + + it('notifies banned user via email', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' }); + + await listener.handle({ + aggregateId: 'user-1', + adminId: 'admin-1', + reason: 'Spam', + eventName: 'user.banned', + occurredAt: new Date(), + }); + + expect(mockCommandBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + channel: 'EMAIL', + templateKey: 'user.banned', + templateData: { reason: 'Spam' }, + }), + ); + }); + + it('skips email notification when user has no email', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 'user-1', email: null }); + + await listener.handle({ + aggregateId: 'user-1', + adminId: 'admin-1', + reason: 'Vi phạm', + eventName: 'user.banned', + occurredAt: new Date(), + }); + + expect(mockPrisma.listing.updateMany).toHaveBeenCalled(); + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/admin/application/commands/approve-kyc/approve-kyc.handler.ts b/apps/api/src/modules/admin/application/commands/approve-kyc/approve-kyc.handler.ts index f162676..b4ed5c7 100644 --- a/apps/api/src/modules/admin/application/commands/approve-kyc/approve-kyc.handler.ts +++ b/apps/api/src/modules/admin/application/commands/approve-kyc/approve-kyc.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { USER_REPOSITORY, type IUserRepository } from '@modules/auth/domain/repositories/user.repository'; +import { USER_REPOSITORY, type IUserRepository } from '@modules/auth'; import { NotFoundException, ValidationException } from '@modules/shared'; import { KycApprovedEvent } from '../../../domain/events/kyc-approved.event'; import { ApproveKycCommand } from './approve-kyc.command'; diff --git a/apps/api/src/modules/admin/application/commands/approve-listing/approve-listing.handler.ts b/apps/api/src/modules/admin/application/commands/approve-listing/approve-listing.handler.ts index ff7c6c8..ac1096d 100644 --- a/apps/api/src/modules/admin/application/commands/approve-listing/approve-listing.handler.ts +++ b/apps/api/src/modules/admin/application/commands/approve-listing/approve-listing.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; +import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings'; import { NotFoundException, ValidationException } from '@modules/shared'; import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event'; import { ApproveListingCommand } from './approve-listing.command'; diff --git a/apps/api/src/modules/admin/application/commands/ban-user/ban-user.handler.ts b/apps/api/src/modules/admin/application/commands/ban-user/ban-user.handler.ts index 34c3cca..0f75010 100644 --- a/apps/api/src/modules/admin/application/commands/ban-user/ban-user.handler.ts +++ b/apps/api/src/modules/admin/application/commands/ban-user/ban-user.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { USER_REPOSITORY, type IUserRepository } from '@modules/auth/domain/repositories/user.repository'; +import { USER_REPOSITORY, type IUserRepository } from '@modules/auth'; import { NotFoundException, ValidationException } from '@modules/shared'; import { UserBannedEvent } from '../../../domain/events/user-banned.event'; import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event'; diff --git a/apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.handler.ts b/apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.handler.ts index be2667b..dc72cca 100644 --- a/apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.handler.ts +++ b/apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; +import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings'; import { ValidationException } from '@modules/shared'; import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event'; import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event'; diff --git a/apps/api/src/modules/admin/application/commands/reject-kyc/reject-kyc.handler.ts b/apps/api/src/modules/admin/application/commands/reject-kyc/reject-kyc.handler.ts index cb53f48..b402944 100644 --- a/apps/api/src/modules/admin/application/commands/reject-kyc/reject-kyc.handler.ts +++ b/apps/api/src/modules/admin/application/commands/reject-kyc/reject-kyc.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { USER_REPOSITORY, type IUserRepository } from '@modules/auth/domain/repositories/user.repository'; +import { USER_REPOSITORY, type IUserRepository } from '@modules/auth'; import { NotFoundException, ValidationException } from '@modules/shared'; import { KycRejectedEvent } from '../../../domain/events/kyc-rejected.event'; import { RejectKycCommand } from './reject-kyc.command'; diff --git a/apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.handler.ts b/apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.handler.ts index 83e8484..eb706bd 100644 --- a/apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.handler.ts +++ b/apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; +import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings'; import { NotFoundException, ValidationException } from '@modules/shared'; import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event'; import { RejectListingCommand } from './reject-listing.command'; diff --git a/apps/api/src/modules/admin/application/commands/update-user-status/update-user-status.handler.ts b/apps/api/src/modules/admin/application/commands/update-user-status/update-user-status.handler.ts index b8b4b4f..0dd02fb 100644 --- a/apps/api/src/modules/admin/application/commands/update-user-status/update-user-status.handler.ts +++ b/apps/api/src/modules/admin/application/commands/update-user-status/update-user-status.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { USER_REPOSITORY, type IUserRepository } from '@modules/auth/domain/repositories/user.repository'; +import { USER_REPOSITORY, type IUserRepository } from '@modules/auth'; import { NotFoundException, ValidationException } from '@modules/shared'; import { UserBannedEvent } from '../../../domain/events/user-banned.event'; import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event'; diff --git a/apps/api/src/modules/admin/application/listeners/user-banned.listener.ts b/apps/api/src/modules/admin/application/listeners/user-banned.listener.ts new file mode 100644 index 0000000..5ff4485 --- /dev/null +++ b/apps/api/src/modules/admin/application/listeners/user-banned.listener.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { type CommandBus } from '@nestjs/cqrs'; +import { OnEvent } from '@nestjs/event-emitter'; +import { SendNotificationCommand } from '@modules/notifications'; +import { type LoggerService, type PrismaService } from '@modules/shared'; +import { type UserBannedEvent } from '../../domain/events/user-banned.event'; + +@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'); + + // Deactivate all active listings belonging to the banned user + const deactivated = await this.prisma.listing.updateMany({ + where: { + sellerId: event.aggregateId, + status: { in: ['ACTIVE', 'PENDING_REVIEW', 'DRAFT'] }, + }, + data: { status: 'CANCELLED' }, + }); + + this.logger.log( + `Deactivated ${deactivated.count} listings for banned user ${event.aggregateId}`, + 'UserBannedListener', + ); + + // Notify the banned user via email + const user = await this.prisma.user.findUnique({ + where: { id: event.aggregateId }, + select: { id: true, email: true }, + }); + + if (user?.email) { + await this.commandBus.execute( + new SendNotificationCommand( + user.id, + 'EMAIL', + 'user.banned', + { reason: event.reason }, + user.email, + ), + ); + } + } +} diff --git a/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts b/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts index 2b0ab94..42919c3 100644 --- a/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts +++ b/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { type Prisma, type UserRole } from '@prisma/client'; -import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type PrismaService } from '@modules/shared'; import { type IAdminQueryRepository, type ModerationQueueResult, diff --git a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts index 226a282..dcd6fbf 100644 --- a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts +++ b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts @@ -10,11 +10,7 @@ import { } from '@nestjs/common'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger'; -import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service'; -import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator'; -import { Roles } from '@modules/auth/presentation/decorators/roles.decorator'; -import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard'; -import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard'; +import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command'; import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler'; import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';