feat(admin): complete admin module with user mgmt, KYC approval, and bulk moderation

Add missing admin backend endpoints:
- User management: list users (paginated/filterable), user detail view, update user status
- KYC approval: pending KYC queue, approve/reject KYC with comments
- Bulk moderation: approve/reject multiple listings in one request
- Domain events for KYC lifecycle (approved/rejected)
- Unit tests for all new handlers (35 tests passing)

All endpoints protected by ADMIN role guard via JwtAuthGuard + RolesGuard.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 02:27:16 +07:00
parent 60a0b3c8e1
commit 57d32fee13
36 changed files with 1198 additions and 2 deletions

View File

@@ -15,11 +15,18 @@ import { ApproveListingHandler } from './application/commands/approve-listing/ap
import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler';
import { BanUserHandler } from './application/commands/ban-user/ban-user.handler';
import { AdjustSubscriptionHandler } from './application/commands/adjust-subscription/adjust-subscription.handler';
import { UpdateUserStatusHandler } from './application/commands/update-user-status/update-user-status.handler';
import { ApproveKycHandler } from './application/commands/approve-kyc/approve-kyc.handler';
import { RejectKycHandler } from './application/commands/reject-kyc/reject-kyc.handler';
import { BulkModerateListingsHandler } from './application/commands/bulk-moderate-listings/bulk-moderate-listings.handler';
// Application — Queries
import { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler';
import { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.handler';
import { GetRevenueStatsHandler } from './application/queries/get-revenue-stats/get-revenue-stats.handler';
import { GetUsersHandler } from './application/queries/get-users/get-users.handler';
import { GetUserDetailHandler } from './application/queries/get-user-detail/get-user-detail.handler';
import { GetKycQueueHandler } from './application/queries/get-kyc-queue/get-kyc-queue.handler';
// Presentation
import { AdminController } from './presentation/controllers/admin.controller';
@@ -29,12 +36,19 @@ const CommandHandlers = [
RejectListingHandler,
BanUserHandler,
AdjustSubscriptionHandler,
UpdateUserStatusHandler,
ApproveKycHandler,
RejectKycHandler,
BulkModerateListingsHandler,
];
const QueryHandlers = [
GetModerationQueueHandler,
GetDashboardStatsHandler,
GetRevenueStatsHandler,
GetUsersHandler,
GetUserDetailHandler,
GetKycQueueHandler,
];
@Module({

View File

@@ -0,0 +1,70 @@
import { ApproveKycHandler } from '../commands/approve-kyc/approve-kyc.handler';
import { ApproveKycCommand } from '../commands/approve-kyc/approve-kyc.command';
import { type IUserRepository } from '@modules/auth/domain/repositories/user.repository';
import { UserEntity } from '@modules/auth/domain/entities/user.entity';
import { Phone } from '@modules/auth/domain/value-objects/phone.vo';
import { HashedPassword } from '@modules/auth/domain/value-objects/hashed-password.vo';
async function createUser(kycStatus = 'PENDING' as any): Promise<UserEntity> {
const phone = Phone.create('0901234567').unwrap();
const hash = await HashedPassword.fromPlain('Password123');
const passwordHash = hash.isOk ? hash.unwrap() : null;
return new UserEntity('user-1', {
email: null,
phone,
passwordHash,
fullName: 'Test User',
avatarUrl: null,
role: 'SELLER',
kycStatus,
kycData: { idNumber: '123456789' },
isActive: true,
});
}
describe('ApproveKycHandler', () => {
let handler: ApproveKycHandler;
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockUserRepo = {
findById: vi.fn(),
findByPhone: vi.fn(),
findByEmail: vi.fn(),
save: vi.fn(),
update: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
handler = new ApproveKycHandler(mockUserRepo as any, mockEventBus as any);
});
it('approves pending KYC successfully', async () => {
const user = await createUser('PENDING');
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.update.mockResolvedValue(undefined);
const command = new ApproveKycCommand('user-1', 'admin-1', 'Hồ sơ hợp lệ');
const result = await handler.execute(command);
expect(result.kycStatus).toBe('VERIFIED');
expect(result.message).toContain('duyệt thành công');
expect(mockUserRepo.update).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('throws NotFoundException when user does not exist', async () => {
mockUserRepo.findById.mockResolvedValue(null);
const command = new ApproveKycCommand('nonexistent', 'admin-1');
await expect(handler.execute(command)).rejects.toThrow('Người dùng không tồn tại');
});
it('throws when KYC is not pending', async () => {
const user = await createUser('VERIFIED');
mockUserRepo.findById.mockResolvedValue(user);
const command = new ApproveKycCommand('user-1', 'admin-1');
await expect(handler.execute(command)).rejects.toThrow(/VERIFIED/);
});
});

View File

@@ -0,0 +1,98 @@
import { BulkModerateListingsHandler } from '../commands/bulk-moderate-listings/bulk-moderate-listings.handler';
import { BulkModerateListingsCommand } from '../commands/bulk-moderate-listings/bulk-moderate-listings.command';
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
function createMockListing(id: string, status = 'PENDING_REVIEW') {
return {
id,
status,
approve: vi.fn(),
reject: vi.fn(),
setModerationScore: vi.fn(),
};
}
describe('BulkModerateListingsHandler', () => {
let handler: BulkModerateListingsHandler;
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockListingRepo = {
findById: vi.fn(),
findByIdWithProperty: vi.fn(),
save: vi.fn(),
update: vi.fn(),
search: vi.fn(),
findByStatus: vi.fn(),
findBySellerId: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
handler = new BulkModerateListingsHandler(mockListingRepo as any, mockEventBus as any);
});
it('approves multiple listings', async () => {
const listing1 = createMockListing('l1');
const listing2 = createMockListing('l2');
mockListingRepo.findById
.mockResolvedValueOnce(listing1)
.mockResolvedValueOnce(listing2);
mockListingRepo.update.mockResolvedValue(undefined);
const command = new BulkModerateListingsCommand(['l1', 'l2'], 'admin-1', 'approve');
const result = await handler.execute(command);
expect(result.succeeded).toEqual(['l1', 'l2']);
expect(result.failed).toHaveLength(0);
expect(listing1.approve).toHaveBeenCalled();
expect(listing2.approve).toHaveBeenCalled();
expect(mockEventBus.publish).toHaveBeenCalledTimes(2);
});
it('rejects multiple listings with reason', async () => {
const listing1 = createMockListing('l1');
mockListingRepo.findById.mockResolvedValueOnce(listing1);
mockListingRepo.update.mockResolvedValue(undefined);
const command = new BulkModerateListingsCommand(['l1'], 'admin-1', 'reject', 'Vi phạm chính sách');
const result = await handler.execute(command);
expect(result.succeeded).toEqual(['l1']);
expect(listing1.reject).toHaveBeenCalledWith('Vi phạm chính sách');
});
it('handles mixed success and failure', async () => {
const listing1 = createMockListing('l1');
mockListingRepo.findById
.mockResolvedValueOnce(listing1)
.mockResolvedValueOnce(null);
mockListingRepo.update.mockResolvedValue(undefined);
const command = new BulkModerateListingsCommand(['l1', 'l2'], 'admin-1', 'approve');
const result = await handler.execute(command);
expect(result.succeeded).toEqual(['l1']);
expect(result.failed).toEqual([{ listingId: 'l2', reason: 'Listing không tồn tại' }]);
});
it('skips listings not in PENDING_REVIEW status', async () => {
const listing = createMockListing('l1', 'ACTIVE');
mockListingRepo.findById.mockResolvedValueOnce(listing);
const command = new BulkModerateListingsCommand(['l1'], 'admin-1', 'approve');
const result = await handler.execute(command);
expect(result.succeeded).toHaveLength(0);
expect(result.failed).toEqual([{ listingId: 'l1', reason: 'Trạng thái hiện tại: ACTIVE' }]);
});
it('throws on empty listing ids', async () => {
const command = new BulkModerateListingsCommand([], 'admin-1', 'approve');
await expect(handler.execute(command)).rejects.toThrow('rỗng');
});
it('throws when rejecting without reason', async () => {
const command = new BulkModerateListingsCommand(['l1'], 'admin-1', 'reject');
await expect(handler.execute(command)).rejects.toThrow('Lý do');
});
});

View File

@@ -0,0 +1,43 @@
import { GetKycQueueHandler } from '../queries/get-kyc-queue/get-kyc-queue.handler';
import { GetKycQueueQuery } from '../queries/get-kyc-queue/get-kyc-queue.query';
import { type KycQueueResult } from '../../domain/repositories/admin-query.repository';
describe('GetKycQueueHandler', () => {
let handler: GetKycQueueHandler;
let mockRepo: { getKycQueue: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockRepo = {
getKycQueue: vi.fn(),
};
handler = new GetKycQueueHandler(mockRepo as any);
});
it('returns paginated KYC queue', async () => {
const expected: KycQueueResult = {
data: [
{
userId: 'u1',
fullName: 'Test User',
email: 'test@test.com',
phone: '0901234567',
role: 'SELLER',
kycStatus: 'PENDING',
kycData: { idNumber: '123456789' },
createdAt: new Date(),
},
],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
mockRepo.getKycQueue.mockResolvedValue(expected);
const query = new GetKycQueueQuery(1, 20);
const result = await handler.execute(query);
expect(result).toEqual(expected);
expect(mockRepo.getKycQueue).toHaveBeenCalledWith(1, 20);
});
});

View File

@@ -0,0 +1,49 @@
import { GetUserDetailHandler } from '../queries/get-user-detail/get-user-detail.handler';
import { GetUserDetailQuery } from '../queries/get-user-detail/get-user-detail.query';
describe('GetUserDetailHandler', () => {
let handler: GetUserDetailHandler;
let mockRepo: { getUserDetail: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockRepo = {
getUserDetail: vi.fn(),
};
handler = new GetUserDetailHandler(mockRepo as any);
});
it('returns user detail when found', async () => {
const userDetail = {
id: 'u1',
email: 'test@test.com',
phone: '0901234567',
fullName: 'Test User',
avatarUrl: null,
role: 'BUYER',
kycStatus: 'NONE',
kycData: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
listingsCount: 5,
activeListingsCount: 2,
transactionsCount: 3,
subscription: null,
recentActivity: [],
};
mockRepo.getUserDetail.mockResolvedValue(userDetail);
const query = new GetUserDetailQuery('u1');
const result = await handler.execute(query);
expect(result).toEqual(userDetail);
expect(mockRepo.getUserDetail).toHaveBeenCalledWith('u1');
});
it('throws NotFoundException when user not found', async () => {
mockRepo.getUserDetail.mockResolvedValue(null);
const query = new GetUserDetailQuery('nonexistent');
await expect(handler.execute(query)).rejects.toThrow('Người dùng không tồn tại');
});
});

View File

@@ -0,0 +1,49 @@
import { GetUsersHandler } from '../queries/get-users/get-users.handler';
import { GetUsersQuery } from '../queries/get-users/get-users.query';
import { type IAdminQueryRepository, type UserListResult } from '../../domain/repositories/admin-query.repository';
describe('GetUsersHandler', () => {
let handler: GetUsersHandler;
let mockRepo: { getUsers: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockRepo = {
getUsers: vi.fn(),
};
handler = new GetUsersHandler(mockRepo as any);
});
it('returns paginated user list', async () => {
const expected: UserListResult = {
data: [
{
id: 'u1',
email: 'test@test.com',
phone: '0901234567',
fullName: 'Test',
role: 'BUYER',
kycStatus: 'NONE',
isActive: true,
createdAt: new Date(),
},
],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
mockRepo.getUsers.mockResolvedValue(expected);
const query = new GetUsersQuery(1, 20, 'BUYER', true, 'test');
const result = await handler.execute(query);
expect(result).toEqual(expected);
expect(mockRepo.getUsers).toHaveBeenCalledWith({
page: 1,
limit: 20,
role: 'BUYER',
isActive: true,
search: 'test',
});
});
});

View File

@@ -0,0 +1,70 @@
import { RejectKycHandler } from '../commands/reject-kyc/reject-kyc.handler';
import { RejectKycCommand } from '../commands/reject-kyc/reject-kyc.command';
import { type IUserRepository } from '@modules/auth/domain/repositories/user.repository';
import { UserEntity } from '@modules/auth/domain/entities/user.entity';
import { Phone } from '@modules/auth/domain/value-objects/phone.vo';
import { HashedPassword } from '@modules/auth/domain/value-objects/hashed-password.vo';
async function createUser(kycStatus = 'PENDING' as any): Promise<UserEntity> {
const phone = Phone.create('0901234567').unwrap();
const hash = await HashedPassword.fromPlain('Password123');
const passwordHash = hash.isOk ? hash.unwrap() : null;
return new UserEntity('user-1', {
email: null,
phone,
passwordHash,
fullName: 'Test User',
avatarUrl: null,
role: 'SELLER',
kycStatus,
kycData: { idNumber: '123456789' },
isActive: true,
});
}
describe('RejectKycHandler', () => {
let handler: RejectKycHandler;
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockUserRepo = {
findById: vi.fn(),
findByPhone: vi.fn(),
findByEmail: vi.fn(),
save: vi.fn(),
update: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
handler = new RejectKycHandler(mockUserRepo as any, mockEventBus as any);
});
it('rejects pending KYC successfully', async () => {
const user = await createUser('PENDING');
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.update.mockResolvedValue(undefined);
const command = new RejectKycCommand('user-1', 'admin-1', 'Hồ sơ không hợp lệ');
const result = await handler.execute(command);
expect(result.kycStatus).toBe('REJECTED');
expect(result.message).toContain('từ chối');
expect(mockUserRepo.update).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('throws NotFoundException when user does not exist', async () => {
mockUserRepo.findById.mockResolvedValue(null);
const command = new RejectKycCommand('nonexistent', 'admin-1', 'invalid');
await expect(handler.execute(command)).rejects.toThrow('Người dùng không tồn tại');
});
it('throws when KYC is not pending', async () => {
const user = await createUser('NONE');
mockUserRepo.findById.mockResolvedValue(user);
const command = new RejectKycCommand('user-1', 'admin-1', 'invalid docs');
await expect(handler.execute(command)).rejects.toThrow(/NONE/);
});
});

View File

@@ -0,0 +1,90 @@
import { UpdateUserStatusHandler } from '../commands/update-user-status/update-user-status.handler';
import { UpdateUserStatusCommand } from '../commands/update-user-status/update-user-status.command';
import { type IUserRepository } from '@modules/auth/domain/repositories/user.repository';
import { UserEntity } from '@modules/auth/domain/entities/user.entity';
import { Phone } from '@modules/auth/domain/value-objects/phone.vo';
import { HashedPassword } from '@modules/auth/domain/value-objects/hashed-password.vo';
async function createUser(role = 'BUYER' as any, isActive = true): Promise<UserEntity> {
const phone = Phone.create('0901234567').unwrap();
const hash = await HashedPassword.fromPlain('Password123');
const passwordHash = hash.isOk ? hash.unwrap() : null;
return new UserEntity('user-1', {
email: null,
phone,
passwordHash,
fullName: 'Test User',
avatarUrl: null,
role,
kycStatus: 'NONE',
kycData: null,
isActive,
});
}
describe('UpdateUserStatusHandler', () => {
let handler: UpdateUserStatusHandler;
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockUserRepo = {
findById: vi.fn(),
findByPhone: vi.fn(),
findByEmail: vi.fn(),
save: vi.fn(),
update: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
handler = new UpdateUserStatusHandler(mockUserRepo as any, mockEventBus as any);
});
it('deactivates an active user', async () => {
const user = await createUser('BUYER', true);
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.update.mockResolvedValue(undefined);
const command = new UpdateUserStatusCommand('user-1', 'admin-1', false, 'Vi phạm chính sách');
const result = await handler.execute(command);
expect(result.isActive).toBe(false);
expect(result.message).toContain('vô hiệu hóa');
expect(mockUserRepo.update).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('activates an inactive user', async () => {
const user = await createUser('BUYER', false);
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.update.mockResolvedValue(undefined);
const command = new UpdateUserStatusCommand('user-1', 'admin-1', true, 'Resolved');
const result = await handler.execute(command);
expect(result.isActive).toBe(true);
expect(result.message).toContain('kích hoạt');
});
it('throws NotFoundException when user does not exist', async () => {
mockUserRepo.findById.mockResolvedValue(null);
const command = new UpdateUserStatusCommand('nonexistent', 'admin-1', false, 'test reason');
await expect(handler.execute(command)).rejects.toThrow('Người dùng không tồn tại');
});
it('prevents changing admin status', async () => {
const admin = await createUser('ADMIN', true);
mockUserRepo.findById.mockResolvedValue(admin);
const command = new UpdateUserStatusCommand('user-1', 'admin-1', false, 'test reason');
await expect(handler.execute(command)).rejects.toThrow(/admin/i);
});
it('throws when status already matches', async () => {
const user = await createUser('BUYER', true);
mockUserRepo.findById.mockResolvedValue(user);
const command = new UpdateUserStatusCommand('user-1', 'admin-1', true, 'test reason');
await expect(handler.execute(command)).rejects.toThrow('đang hoạt động');
});
});

View File

@@ -0,0 +1,7 @@
export class ApproveKycCommand {
constructor(
public readonly userId: string,
public readonly adminId: string,
public readonly comments?: string,
) {}
}

View File

@@ -0,0 +1,45 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException, ValidationException } from '@modules/shared';
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth/domain/repositories/user.repository';
import { KycApprovedEvent } from '../../../domain/events/kyc-approved.event';
import { ApproveKycCommand } from './approve-kyc.command';
export interface ApproveKycResult {
userId: string;
kycStatus: string;
message: string;
}
@CommandHandler(ApproveKycCommand)
export class ApproveKycHandler implements ICommandHandler<ApproveKycCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: ApproveKycCommand): Promise<ApproveKycResult> {
const user = await this.userRepo.findById(command.userId);
if (!user) {
throw new NotFoundException('Người dùng không tồn tại');
}
if (user.kycStatus !== 'PENDING') {
throw new ValidationException(
`KYC đang ở trạng thái ${user.kycStatus}, chỉ có thể duyệt KYC đang chờ`,
{ currentStatus: user.kycStatus },
);
}
user.updateKycStatus('VERIFIED');
await this.userRepo.update(user);
this.eventBus.publish(new KycApprovedEvent(user.id, command.adminId, command.comments));
return {
userId: user.id,
kycStatus: 'VERIFIED',
message: 'KYC đã được duyệt thành công',
};
}
}

View File

@@ -0,0 +1,8 @@
export class BulkModerateListingsCommand {
constructor(
public readonly listingIds: string[],
public readonly adminId: string,
public readonly action: 'approve' | 'reject',
public readonly reason?: string,
) {}
}

View File

@@ -0,0 +1,76 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { ValidationException } from '@modules/shared';
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
import { BulkModerateListingsCommand } from './bulk-moderate-listings.command';
export interface BulkModerateResult {
processed: number;
succeeded: string[];
failed: Array<{ listingId: string; reason: string }>;
}
@CommandHandler(BulkModerateListingsCommand)
export class BulkModerateListingsHandler implements ICommandHandler<BulkModerateListingsCommand> {
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: BulkModerateListingsCommand): Promise<BulkModerateResult> {
if (command.listingIds.length === 0) {
throw new ValidationException('Danh sách listing không được rỗng', {});
}
if (command.listingIds.length > 50) {
throw new ValidationException('Tối đa 50 listing mỗi lần', {
count: command.listingIds.length,
});
}
if (command.action === 'reject' && !command.reason) {
throw new ValidationException('Lý do từ chối là bắt buộc', {});
}
const succeeded: string[] = [];
const failed: Array<{ listingId: string; reason: string }> = [];
for (const listingId of command.listingIds) {
try {
const listing = await this.listingRepo.findById(listingId);
if (!listing) {
failed.push({ listingId, reason: 'Listing không tồn tại' });
continue;
}
if (listing.status !== 'PENDING_REVIEW') {
failed.push({ listingId, reason: `Trạng thái hiện tại: ${listing.status}` });
continue;
}
if (command.action === 'approve') {
listing.approve();
await this.listingRepo.update(listing);
this.eventBus.publish(new ListingApprovedEvent(listing.id, command.adminId));
} else {
listing.reject(command.reason!);
await this.listingRepo.update(listing);
this.eventBus.publish(new ListingRejectedEvent(listing.id, command.adminId, command.reason!));
}
succeeded.push(listingId);
} catch (error) {
const message = error instanceof Error ? error.message : 'Lỗi không xác định';
failed.push({ listingId, reason: message });
}
}
return {
processed: command.listingIds.length,
succeeded,
failed,
};
}
}

View File

@@ -6,3 +6,11 @@ export { BanUserCommand } from './ban-user/ban-user.command';
export { BanUserHandler } from './ban-user/ban-user.handler';
export { AdjustSubscriptionCommand } from './adjust-subscription/adjust-subscription.command';
export { AdjustSubscriptionHandler } from './adjust-subscription/adjust-subscription.handler';
export { UpdateUserStatusCommand } from './update-user-status/update-user-status.command';
export { UpdateUserStatusHandler } from './update-user-status/update-user-status.handler';
export { ApproveKycCommand } from './approve-kyc/approve-kyc.command';
export { ApproveKycHandler } from './approve-kyc/approve-kyc.handler';
export { RejectKycCommand } from './reject-kyc/reject-kyc.command';
export { RejectKycHandler } from './reject-kyc/reject-kyc.handler';
export { BulkModerateListingsCommand } from './bulk-moderate-listings/bulk-moderate-listings.command';
export { BulkModerateListingsHandler } from './bulk-moderate-listings/bulk-moderate-listings.handler';

View File

@@ -0,0 +1,7 @@
export class RejectKycCommand {
constructor(
public readonly userId: string,
public readonly adminId: string,
public readonly reason: string,
) {}
}

View File

@@ -0,0 +1,45 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException, ValidationException } from '@modules/shared';
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth/domain/repositories/user.repository';
import { KycRejectedEvent } from '../../../domain/events/kyc-rejected.event';
import { RejectKycCommand } from './reject-kyc.command';
export interface RejectKycResult {
userId: string;
kycStatus: string;
message: string;
}
@CommandHandler(RejectKycCommand)
export class RejectKycHandler implements ICommandHandler<RejectKycCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: RejectKycCommand): Promise<RejectKycResult> {
const user = await this.userRepo.findById(command.userId);
if (!user) {
throw new NotFoundException('Người dùng không tồn tại');
}
if (user.kycStatus !== 'PENDING') {
throw new ValidationException(
`KYC đang ở trạng thái ${user.kycStatus}, chỉ có thể từ chối KYC đang chờ`,
{ currentStatus: user.kycStatus },
);
}
user.updateKycStatus('REJECTED');
await this.userRepo.update(user);
this.eventBus.publish(new KycRejectedEvent(user.id, command.adminId, command.reason));
return {
userId: user.id,
kycStatus: 'REJECTED',
message: 'KYC đã bị từ chối',
};
}
}

View File

@@ -0,0 +1,8 @@
export class UpdateUserStatusCommand {
constructor(
public readonly userId: string,
public readonly adminId: string,
public readonly isActive: boolean,
public readonly reason: string,
) {}
}

View File

@@ -0,0 +1,63 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException, ValidationException } from '@modules/shared';
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth/domain/repositories/user.repository';
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
import { UpdateUserStatusCommand } from './update-user-status.command';
export interface UpdateUserStatusResult {
userId: string;
isActive: boolean;
message: string;
}
@CommandHandler(UpdateUserStatusCommand)
export class UpdateUserStatusHandler implements ICommandHandler<UpdateUserStatusCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: UpdateUserStatusCommand): Promise<UpdateUserStatusResult> {
const user = await this.userRepo.findById(command.userId);
if (!user) {
throw new NotFoundException('Người dùng không tồn tại');
}
if (user.role === 'ADMIN') {
throw new ValidationException('Không thể thay đổi trạng thái tài khoản admin', {
userId: command.userId,
});
}
if (user.isActive === command.isActive) {
const statusLabel = command.isActive ? 'đang hoạt động' : 'đã bị vô hiệu hóa';
throw new ValidationException(`Người dùng ${statusLabel}`, {
userId: command.userId,
});
}
if (command.isActive) {
user.activate();
await this.userRepo.update(user);
this.eventBus.publish(new UserUnbannedEvent(user.id, command.adminId));
return {
userId: user.id,
isActive: true,
message: 'Người dùng đã được kích hoạt',
};
}
user.deactivate();
await this.userRepo.update(user);
this.eventBus.publish(new UserBannedEvent(user.id, command.adminId, command.reason));
return {
userId: user.id,
isActive: false,
message: 'Người dùng đã bị vô hiệu hóa',
};
}
}

View File

@@ -0,0 +1,15 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type KycQueueResult } from '../../../domain/repositories/admin-query.repository';
import { GetKycQueueQuery } from './get-kyc-queue.query';
@QueryHandler(GetKycQueueQuery)
export class GetKycQueueHandler implements IQueryHandler<GetKycQueueQuery> {
constructor(
@Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
) {}
async execute(query: GetKycQueueQuery): Promise<KycQueueResult> {
return this.adminQueryRepo.getKycQueue(query.page, query.limit);
}
}

View File

@@ -0,0 +1,6 @@
export class GetKycQueueQuery {
constructor(
public readonly page: number,
public readonly limit: number,
) {}
}

View File

@@ -0,0 +1,20 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException } from '@modules/shared';
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type UserDetail } from '../../../domain/repositories/admin-query.repository';
import { GetUserDetailQuery } from './get-user-detail.query';
@QueryHandler(GetUserDetailQuery)
export class GetUserDetailHandler implements IQueryHandler<GetUserDetailQuery> {
constructor(
@Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
) {}
async execute(query: GetUserDetailQuery): Promise<UserDetail> {
const user = await this.adminQueryRepo.getUserDetail(query.userId);
if (!user) {
throw new NotFoundException('Người dùng không tồn tại');
}
return user;
}
}

View File

@@ -0,0 +1,5 @@
export class GetUserDetailQuery {
constructor(
public readonly userId: string,
) {}
}

View File

@@ -0,0 +1,21 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type UserListResult } from '../../../domain/repositories/admin-query.repository';
import { GetUsersQuery } from './get-users.query';
@QueryHandler(GetUsersQuery)
export class GetUsersHandler implements IQueryHandler<GetUsersQuery> {
constructor(
@Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
) {}
async execute(query: GetUsersQuery): Promise<UserListResult> {
return this.adminQueryRepo.getUsers({
page: query.page,
limit: query.limit,
role: query.role,
isActive: query.isActive,
search: query.search,
});
}
}

View File

@@ -0,0 +1,9 @@
export class GetUsersQuery {
constructor(
public readonly page: number,
public readonly limit: number,
public readonly role?: string,
public readonly isActive?: boolean,
public readonly search?: string,
) {}
}

View File

@@ -4,3 +4,9 @@ export { GetDashboardStatsQuery } from './get-dashboard-stats/get-dashboard-stat
export { GetDashboardStatsHandler } from './get-dashboard-stats/get-dashboard-stats.handler';
export { GetRevenueStatsQuery } from './get-revenue-stats/get-revenue-stats.query';
export { GetRevenueStatsHandler } from './get-revenue-stats/get-revenue-stats.handler';
export { GetUsersQuery } from './get-users/get-users.query';
export { GetUsersHandler } from './get-users/get-users.handler';
export { GetUserDetailQuery } from './get-user-detail/get-user-detail.query';
export { GetUserDetailHandler } from './get-user-detail/get-user-detail.handler';
export { GetKycQueueQuery } from './get-kyc-queue/get-kyc-queue.query';
export { GetKycQueueHandler } from './get-kyc-queue/get-kyc-queue.handler';

View File

@@ -3,3 +3,5 @@ export { ListingRejectedEvent } from './listing-rejected.event';
export { UserBannedEvent } from './user-banned.event';
export { UserUnbannedEvent } from './user-unbanned.event';
export { SubscriptionAdjustedEvent } from './subscription-adjusted.event';
export { KycApprovedEvent } from './kyc-approved.event';
export { KycRejectedEvent } from './kyc-rejected.event';

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class KycApprovedEvent implements DomainEvent {
readonly eventName = 'kyc.approved';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly adminId: string,
public readonly comments?: string,
) {}
}

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class KycRejectedEvent implements DomainEvent {
readonly eventName = 'kyc.rejected';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly adminId: string,
public readonly reason: string,
) {}
}

View File

@@ -60,9 +60,57 @@ export interface UserListResult {
totalPages: number;
}
export interface UserDetail {
id: string;
email: string | null;
phone: string;
fullName: string;
avatarUrl: string | null;
role: string;
kycStatus: string;
kycData: unknown;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
listingsCount: number;
activeListingsCount: number;
transactionsCount: number;
subscription: {
planTier: string;
status: string;
currentPeriodEnd: Date;
} | null;
recentActivity: Array<{
type: string;
description: string;
createdAt: Date;
}>;
}
export interface KycQueueItem {
userId: string;
fullName: string;
email: string | null;
phone: string;
role: string;
kycStatus: string;
kycData: unknown;
createdAt: Date;
}
export interface KycQueueResult {
data: KycQueueItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface IAdminQueryRepository {
getModerationQueue(page: number, limit: number): Promise<ModerationQueueResult>;
getDashboardStats(): Promise<DashboardStats>;
getRevenueStats(startDate: Date, endDate: Date, groupBy: 'day' | 'month'): Promise<RevenueStatsItem[]>;
getUsers(params: { page: number; limit: number; role?: string; isActive?: boolean; search?: string }): Promise<UserListResult>;
getUserDetail(userId: string): Promise<UserDetail | null>;
getKycQueue(page: number, limit: number): Promise<KycQueueResult>;
}

View File

@@ -6,6 +6,8 @@ import {
type DashboardStats,
type RevenueStatsItem,
type UserListResult,
type UserDetail,
type KycQueueResult,
} from '../../domain/repositories/admin-query.repository';
@Injectable()
@@ -200,4 +202,108 @@ export class PrismaAdminQueryRepository implements IAdminQueryRepository {
totalPages: Math.ceil(total / limit),
};
}
async getUserDetail(userId: string): Promise<UserDetail | null> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
subscription: {
include: { plan: { select: { tier: true } } },
},
listings: {
select: { id: true, status: true },
},
},
});
if (!user) return null;
const transactionsCount = await this.prisma.transaction.count({
where: { buyerId: userId },
});
const recentListings = await this.prisma.listing.findMany({
where: { sellerId: userId },
select: {
id: true,
status: true,
createdAt: true,
property: { select: { title: true } },
},
orderBy: { createdAt: 'desc' },
take: 10,
});
const recentActivity = recentListings.map((l) => ({
type: 'listing',
description: `${l.property.title}${l.status}`,
createdAt: l.createdAt,
}));
return {
id: user.id,
email: user.email,
phone: user.phone,
fullName: user.fullName,
avatarUrl: user.avatarUrl,
role: user.role,
kycStatus: user.kycStatus,
kycData: user.kycData,
isActive: user.isActive,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
listingsCount: user.listings.length,
activeListingsCount: user.listings.filter((l) => l.status === 'ACTIVE').length,
transactionsCount,
subscription: user.subscription
? {
planTier: user.subscription.plan.tier,
status: user.subscription.status,
currentPeriodEnd: user.subscription.currentPeriodEnd,
}
: null,
recentActivity,
};
}
async getKycQueue(page: number, limit: number): Promise<KycQueueResult> {
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
this.prisma.user.findMany({
where: { kycStatus: 'PENDING' },
select: {
id: true,
fullName: true,
email: true,
phone: true,
role: true,
kycStatus: true,
kycData: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
skip,
take: limit,
}),
this.prisma.user.count({ where: { kycStatus: 'PENDING' } }),
]);
return {
data: users.map((u) => ({
userId: u.id,
fullName: u.fullName,
email: u.email,
phone: u.phone,
role: u.role,
kycStatus: u.kycStatus,
kycData: u.kycData,
createdAt: u.createdAt,
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
}

View File

@@ -2,6 +2,8 @@ import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
@@ -17,22 +19,43 @@ import { ApproveListingCommand } from '../../application/commands/approve-listin
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command';
import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command';
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
import { RejectKycCommand } from '../../application/commands/reject-kyc/reject-kyc.command';
import { BulkModerateListingsCommand } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.command';
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
import { GetUsersQuery } from '../../application/queries/get-users/get-users.query';
import { GetUserDetailQuery } from '../../application/queries/get-user-detail/get-user-detail.query';
import { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query';
import { ApproveListingDto } from '../dto/approve-listing.dto';
import { RejectListingDto } from '../dto/reject-listing.dto';
import { BanUserDto } from '../dto/ban-user.dto';
import { AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
import { RevenueStatsDto } from '../dto/revenue-stats.dto';
import { UpdateUserStatusDto } from '../dto/update-user-status.dto';
import { ApproveKycDto } from '../dto/approve-kyc.dto';
import { RejectKycDto } from '../dto/reject-kyc.dto';
import { BulkModerateDto } from '../dto/bulk-moderate.dto';
import { type ApproveListingResult } from '../../application/commands/approve-listing/approve-listing.handler';
import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
import { type ModerationQueueResult } from '../../domain/repositories/admin-query.repository';
import { type DashboardStats, type RevenueStatsItem } from '../../domain/repositories/admin-query.repository';
import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
import { type RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
import { type BulkModerateResult } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.handler';
import {
type ModerationQueueResult,
type DashboardStats,
type RevenueStatsItem,
type UserListResult,
type UserDetail,
type KycQueueResult,
} from '../../domain/repositories/admin-query.repository';
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@@ -78,8 +101,54 @@ export class AdminController {
);
}
@Post('moderation/bulk')
async bulkModerate(
@Body() dto: BulkModerateDto,
@CurrentUser() user: JwtPayload,
): Promise<BulkModerateResult> {
return this.commandBus.execute(
new BulkModerateListingsCommand(dto.listingIds, user.sub, dto.action, dto.reason),
);
}
// ── User Management ──
@Get('users')
async getUsers(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('role') role?: string,
@Query('isActive') isActive?: string,
@Query('search') search?: string,
): Promise<UserListResult> {
return this.queryBus.execute(
new GetUsersQuery(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
role,
isActive !== undefined ? isActive === 'true' : undefined,
search,
),
);
}
@Get('users/:id')
async getUserDetail(
@Param('id') id: string,
): Promise<UserDetail> {
return this.queryBus.execute(new GetUserDetailQuery(id));
}
@Patch('users/status')
async updateUserStatus(
@Body() dto: UpdateUserStatusDto,
@CurrentUser() user: JwtPayload,
): Promise<UpdateUserStatusResult> {
return this.commandBus.execute(
new UpdateUserStatusCommand(dto.userId, user.sub, dto.isActive, dto.reason),
);
}
@Post('users/ban')
async banUser(
@Body() dto: BanUserDto,
@@ -90,6 +159,41 @@ export class AdminController {
);
}
// ── KYC ──
@Get('kyc')
async getKycQueue(
@Query('page') page?: string,
@Query('limit') limit?: string,
): Promise<KycQueueResult> {
return this.queryBus.execute(
new GetKycQueueQuery(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
),
);
}
@Post('kyc/approve')
async approveKyc(
@Body() dto: ApproveKycDto,
@CurrentUser() user: JwtPayload,
): Promise<ApproveKycResult> {
return this.commandBus.execute(
new ApproveKycCommand(dto.userId, user.sub, dto.comments),
);
}
@Post('kyc/reject')
async rejectKyc(
@Body() dto: RejectKycDto,
@CurrentUser() user: JwtPayload,
): Promise<RejectKycResult> {
return this.commandBus.execute(
new RejectKycCommand(dto.userId, user.sub, dto.reason),
);
}
// ── Subscription Management ──
@Post('subscriptions/adjust')

View File

@@ -0,0 +1,10 @@
import { IsOptional, IsString } from 'class-validator';
export class ApproveKycDto {
@IsString()
userId!: string;
@IsOptional()
@IsString()
comments?: string;
}

View File

@@ -0,0 +1,17 @@
import { IsArray, IsIn, IsOptional, IsString, ArrayMaxSize, ArrayMinSize, MinLength } from 'class-validator';
export class BulkModerateDto {
@IsArray()
@IsString({ each: true })
@ArrayMinSize(1)
@ArrayMaxSize(50)
listingIds!: string[];
@IsIn(['approve', 'reject'])
action!: 'approve' | 'reject';
@IsOptional()
@IsString()
@MinLength(5)
reason?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsOptional, IsString, IsIn } from 'class-validator';
import { Transform } from 'class-transformer';
export class GetUsersQueryDto {
@IsOptional()
@IsString()
page?: string;
@IsOptional()
@IsString()
limit?: string;
@IsOptional()
@IsIn(['BUYER', 'SELLER', 'AGENT', 'ADMIN'])
role?: string;
@IsOptional()
@IsString()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : undefined)
isActive?: string;
@IsOptional()
@IsString()
search?: string;
}

View File

@@ -3,3 +3,8 @@ export { RejectListingDto } from './reject-listing.dto';
export { BanUserDto } from './ban-user.dto';
export { AdjustSubscriptionDto } from './adjust-subscription.dto';
export { RevenueStatsDto } from './revenue-stats.dto';
export { GetUsersQueryDto } from './get-users-query.dto';
export { UpdateUserStatusDto } from './update-user-status.dto';
export { ApproveKycDto } from './approve-kyc.dto';
export { RejectKycDto } from './reject-kyc.dto';
export { BulkModerateDto } from './bulk-moderate.dto';

View File

@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class RejectKycDto {
@IsString()
userId!: string;
@IsString()
@MinLength(5)
reason!: string;
}

View File

@@ -0,0 +1,13 @@
import { IsBoolean, IsString, MinLength } from 'class-validator';
export class UpdateUserStatusDto {
@IsString()
userId!: string;
@IsBoolean()
isActive!: boolean;
@IsString()
@MinLength(5)
reason!: string;
}