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 { RejectKycHandler } from './application/commands/reject-kyc/reject-kyc.handler';
|
||||||
import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler';
|
import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler';
|
||||||
import { UpdateUserStatusHandler } from './application/commands/update-user-status/update-user-status.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 { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.handler';
|
||||||
import { GetKycQueueHandler } from './application/queries/get-kyc-queue/get-kyc-queue.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';
|
import { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler';
|
||||||
@@ -51,6 +52,9 @@ const QueryHandlers = [
|
|||||||
// CQRS
|
// CQRS
|
||||||
...CommandHandlers,
|
...CommandHandlers,
|
||||||
...QueryHandlers,
|
...QueryHandlers,
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
UserBannedListener,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
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 { Inject } from '@nestjs/common';
|
||||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
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 { NotFoundException, ValidationException } from '@modules/shared';
|
||||||
import { KycApprovedEvent } from '../../../domain/events/kyc-approved.event';
|
import { KycApprovedEvent } from '../../../domain/events/kyc-approved.event';
|
||||||
import { ApproveKycCommand } from './approve-kyc.command';
|
import { ApproveKycCommand } from './approve-kyc.command';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
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 { NotFoundException, ValidationException } from '@modules/shared';
|
||||||
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
||||||
import { ApproveListingCommand } from './approve-listing.command';
|
import { ApproveListingCommand } from './approve-listing.command';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
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 { NotFoundException, ValidationException } from '@modules/shared';
|
||||||
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
||||||
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
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 { ValidationException } from '@modules/shared';
|
||||||
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
||||||
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
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 { NotFoundException, ValidationException } from '@modules/shared';
|
||||||
import { KycRejectedEvent } from '../../../domain/events/kyc-rejected.event';
|
import { KycRejectedEvent } from '../../../domain/events/kyc-rejected.event';
|
||||||
import { RejectKycCommand } from './reject-kyc.command';
|
import { RejectKycCommand } from './reject-kyc.command';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
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 { NotFoundException, ValidationException } from '@modules/shared';
|
||||||
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
||||||
import { RejectListingCommand } from './reject-listing.command';
|
import { RejectListingCommand } from './reject-listing.command';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
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 { NotFoundException, ValidationException } from '@modules/shared';
|
||||||
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
||||||
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.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 { Injectable } from '@nestjs/common';
|
||||||
import { type Prisma, type UserRole } from '@prisma/client';
|
import { type Prisma, type UserRole } from '@prisma/client';
|
||||||
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type IAdminQueryRepository,
|
type IAdminQueryRepository,
|
||||||
type ModerationQueueResult,
|
type ModerationQueueResult,
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||||
import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service';
|
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||||
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 { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
|
import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
|
||||||
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
|
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
|
||||||
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
|
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
|
||||||
|
|||||||
Reference in New Issue
Block a user