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:
@@ -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({
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export class ApproveKycCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly comments?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export class RejectKycCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly reason: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class UpdateUserStatusCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly isActive: boolean,
|
||||
public readonly reason: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetKycQueueQuery {
|
||||
constructor(
|
||||
public readonly page: number,
|
||||
public readonly limit: number,
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class GetUserDetailQuery {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class ApproveKycDto {
|
||||
@IsString()
|
||||
userId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
comments?: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RejectKycDto {
|
||||
@IsString()
|
||||
userId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user