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:
@@ -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 {}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user