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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 09:42:45 +07:00
parent 36e0f49e9e
commit d9726d4961
12 changed files with 147 additions and 13 deletions

View File

@@ -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 {}

View File

@@ -0,0 +1,82 @@
import { UserBannedListener } from '../listeners/user-banned.listener';
describe('UserBannedListener', () => {
let listener: UserBannedListener;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { updateMany: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
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();
});
});

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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<void> {
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,
),
);
}
}
}

View File

@@ -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,

View File

@@ -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';