From 57d32fee130cefb0d0993ce01f11c63bd7153981 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 02:27:16 +0700 Subject: [PATCH] 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 --- apps/api/src/modules/admin/admin.module.ts | 14 +++ .../__tests__/approve-kyc.handler.spec.ts | 70 ++++++++++++ .../bulk-moderate-listings.handler.spec.ts | 98 ++++++++++++++++ .../__tests__/get-kyc-queue.handler.spec.ts | 43 +++++++ .../__tests__/get-user-detail.handler.spec.ts | 49 ++++++++ .../__tests__/get-users.handler.spec.ts | 49 ++++++++ .../__tests__/reject-kyc.handler.spec.ts | 70 ++++++++++++ .../update-user-status.handler.spec.ts | 90 +++++++++++++++ .../approve-kyc/approve-kyc.command.ts | 7 ++ .../approve-kyc/approve-kyc.handler.ts | 45 ++++++++ .../bulk-moderate-listings.command.ts | 8 ++ .../bulk-moderate-listings.handler.ts | 76 ++++++++++++ .../admin/application/commands/index.ts | 8 ++ .../commands/reject-kyc/reject-kyc.command.ts | 7 ++ .../commands/reject-kyc/reject-kyc.handler.ts | 45 ++++++++ .../update-user-status.command.ts | 8 ++ .../update-user-status.handler.ts | 63 ++++++++++ .../get-kyc-queue/get-kyc-queue.handler.ts | 15 +++ .../get-kyc-queue/get-kyc-queue.query.ts | 6 + .../get-user-detail.handler.ts | 20 ++++ .../get-user-detail/get-user-detail.query.ts | 5 + .../queries/get-users/get-users.handler.ts | 21 ++++ .../queries/get-users/get-users.query.ts | 9 ++ .../admin/application/queries/index.ts | 6 + .../src/modules/admin/domain/events/index.ts | 2 + .../admin/domain/events/kyc-approved.event.ts | 12 ++ .../admin/domain/events/kyc-rejected.event.ts | 12 ++ .../repositories/admin-query.repository.ts | 48 ++++++++ .../prisma-admin-query.repository.ts | 106 +++++++++++++++++ .../controllers/admin.controller.ts | 108 +++++++++++++++++- .../admin/presentation/dto/approve-kyc.dto.ts | 10 ++ .../presentation/dto/bulk-moderate.dto.ts | 17 +++ .../presentation/dto/get-users-query.dto.ts | 25 ++++ .../modules/admin/presentation/dto/index.ts | 5 + .../admin/presentation/dto/reject-kyc.dto.ts | 10 ++ .../dto/update-user-status.dto.ts | 13 +++ 36 files changed, 1198 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/modules/admin/application/__tests__/approve-kyc.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/__tests__/bulk-moderate-listings.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/__tests__/get-kyc-queue.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/__tests__/get-user-detail.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/__tests__/get-users.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/__tests__/reject-kyc.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/__tests__/update-user-status.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/commands/approve-kyc/approve-kyc.command.ts create mode 100644 apps/api/src/modules/admin/application/commands/approve-kyc/approve-kyc.handler.ts create mode 100644 apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.command.ts create mode 100644 apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.handler.ts create mode 100644 apps/api/src/modules/admin/application/commands/reject-kyc/reject-kyc.command.ts create mode 100644 apps/api/src/modules/admin/application/commands/reject-kyc/reject-kyc.handler.ts create mode 100644 apps/api/src/modules/admin/application/commands/update-user-status/update-user-status.command.ts create mode 100644 apps/api/src/modules/admin/application/commands/update-user-status/update-user-status.handler.ts create mode 100644 apps/api/src/modules/admin/application/queries/get-kyc-queue/get-kyc-queue.handler.ts create mode 100644 apps/api/src/modules/admin/application/queries/get-kyc-queue/get-kyc-queue.query.ts create mode 100644 apps/api/src/modules/admin/application/queries/get-user-detail/get-user-detail.handler.ts create mode 100644 apps/api/src/modules/admin/application/queries/get-user-detail/get-user-detail.query.ts create mode 100644 apps/api/src/modules/admin/application/queries/get-users/get-users.handler.ts create mode 100644 apps/api/src/modules/admin/application/queries/get-users/get-users.query.ts create mode 100644 apps/api/src/modules/admin/domain/events/kyc-approved.event.ts create mode 100644 apps/api/src/modules/admin/domain/events/kyc-rejected.event.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/approve-kyc.dto.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/bulk-moderate.dto.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/get-users-query.dto.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/reject-kyc.dto.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/update-user-status.dto.ts diff --git a/apps/api/src/modules/admin/admin.module.ts b/apps/api/src/modules/admin/admin.module.ts index 0cf652e..7b62954 100644 --- a/apps/api/src/modules/admin/admin.module.ts +++ b/apps/api/src/modules/admin/admin.module.ts @@ -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({ diff --git a/apps/api/src/modules/admin/application/__tests__/approve-kyc.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/approve-kyc.handler.spec.ts new file mode 100644 index 0000000..dcfbf46 --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/approve-kyc.handler.spec.ts @@ -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 { + 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 }; + let mockEventBus: { publish: ReturnType }; + + 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/); + }); +}); diff --git a/apps/api/src/modules/admin/application/__tests__/bulk-moderate-listings.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/bulk-moderate-listings.handler.spec.ts new file mode 100644 index 0000000..d23ddfb --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/bulk-moderate-listings.handler.spec.ts @@ -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 }; + let mockEventBus: { publish: ReturnType }; + + 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'); + }); +}); diff --git a/apps/api/src/modules/admin/application/__tests__/get-kyc-queue.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/get-kyc-queue.handler.spec.ts new file mode 100644 index 0000000..2ef7a0b --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/get-kyc-queue.handler.spec.ts @@ -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 }; + + 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); + }); +}); diff --git a/apps/api/src/modules/admin/application/__tests__/get-user-detail.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/get-user-detail.handler.spec.ts new file mode 100644 index 0000000..408317d --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/get-user-detail.handler.spec.ts @@ -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 }; + + 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'); + }); +}); diff --git a/apps/api/src/modules/admin/application/__tests__/get-users.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/get-users.handler.spec.ts new file mode 100644 index 0000000..c700b56 --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/get-users.handler.spec.ts @@ -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 }; + + 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', + }); + }); +}); diff --git a/apps/api/src/modules/admin/application/__tests__/reject-kyc.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/reject-kyc.handler.spec.ts new file mode 100644 index 0000000..9807c1d --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/reject-kyc.handler.spec.ts @@ -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 { + 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 }; + let mockEventBus: { publish: ReturnType }; + + 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/); + }); +}); diff --git a/apps/api/src/modules/admin/application/__tests__/update-user-status.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/update-user-status.handler.spec.ts new file mode 100644 index 0000000..28b5e39 --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/update-user-status.handler.spec.ts @@ -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 { + 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 }; + let mockEventBus: { publish: ReturnType }; + + 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'); + }); +}); diff --git a/apps/api/src/modules/admin/application/commands/approve-kyc/approve-kyc.command.ts b/apps/api/src/modules/admin/application/commands/approve-kyc/approve-kyc.command.ts new file mode 100644 index 0000000..2e512b7 --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/approve-kyc/approve-kyc.command.ts @@ -0,0 +1,7 @@ +export class ApproveKycCommand { + constructor( + public readonly userId: string, + public readonly adminId: string, + public readonly comments?: string, + ) {} +} diff --git a/apps/api/src/modules/admin/application/commands/approve-kyc/approve-kyc.handler.ts b/apps/api/src/modules/admin/application/commands/approve-kyc/approve-kyc.handler.ts new file mode 100644 index 0000000..35c4c6a --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/approve-kyc/approve-kyc.handler.ts @@ -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 { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + private readonly eventBus: EventBus, + ) {} + + async execute(command: ApproveKycCommand): Promise { + 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', + }; + } +} diff --git a/apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.command.ts b/apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.command.ts new file mode 100644 index 0000000..be297fb --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.command.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.handler.ts b/apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.handler.ts new file mode 100644 index 0000000..25d5049 --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/bulk-moderate-listings/bulk-moderate-listings.handler.ts @@ -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 { + constructor( + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + private readonly eventBus: EventBus, + ) {} + + async execute(command: BulkModerateListingsCommand): Promise { + 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, + }; + } +} diff --git a/apps/api/src/modules/admin/application/commands/index.ts b/apps/api/src/modules/admin/application/commands/index.ts index 836eb53..59dbc43 100644 --- a/apps/api/src/modules/admin/application/commands/index.ts +++ b/apps/api/src/modules/admin/application/commands/index.ts @@ -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'; diff --git a/apps/api/src/modules/admin/application/commands/reject-kyc/reject-kyc.command.ts b/apps/api/src/modules/admin/application/commands/reject-kyc/reject-kyc.command.ts new file mode 100644 index 0000000..cb46e0a --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/reject-kyc/reject-kyc.command.ts @@ -0,0 +1,7 @@ +export class RejectKycCommand { + constructor( + public readonly userId: string, + public readonly adminId: string, + public readonly reason: string, + ) {} +} diff --git a/apps/api/src/modules/admin/application/commands/reject-kyc/reject-kyc.handler.ts b/apps/api/src/modules/admin/application/commands/reject-kyc/reject-kyc.handler.ts new file mode 100644 index 0000000..bd39688 --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/reject-kyc/reject-kyc.handler.ts @@ -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 { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + private readonly eventBus: EventBus, + ) {} + + async execute(command: RejectKycCommand): Promise { + 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', + }; + } +} diff --git a/apps/api/src/modules/admin/application/commands/update-user-status/update-user-status.command.ts b/apps/api/src/modules/admin/application/commands/update-user-status/update-user-status.command.ts new file mode 100644 index 0000000..77b958c --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/update-user-status/update-user-status.command.ts @@ -0,0 +1,8 @@ +export class UpdateUserStatusCommand { + constructor( + public readonly userId: string, + public readonly adminId: string, + public readonly isActive: boolean, + public readonly reason: string, + ) {} +} diff --git a/apps/api/src/modules/admin/application/commands/update-user-status/update-user-status.handler.ts b/apps/api/src/modules/admin/application/commands/update-user-status/update-user-status.handler.ts new file mode 100644 index 0000000..69818e0 --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/update-user-status/update-user-status.handler.ts @@ -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 { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + private readonly eventBus: EventBus, + ) {} + + async execute(command: UpdateUserStatusCommand): Promise { + 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', + }; + } +} diff --git a/apps/api/src/modules/admin/application/queries/get-kyc-queue/get-kyc-queue.handler.ts b/apps/api/src/modules/admin/application/queries/get-kyc-queue/get-kyc-queue.handler.ts new file mode 100644 index 0000000..9210b49 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-kyc-queue/get-kyc-queue.handler.ts @@ -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 { + constructor( + @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository, + ) {} + + async execute(query: GetKycQueueQuery): Promise { + return this.adminQueryRepo.getKycQueue(query.page, query.limit); + } +} diff --git a/apps/api/src/modules/admin/application/queries/get-kyc-queue/get-kyc-queue.query.ts b/apps/api/src/modules/admin/application/queries/get-kyc-queue/get-kyc-queue.query.ts new file mode 100644 index 0000000..53a105d --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-kyc-queue/get-kyc-queue.query.ts @@ -0,0 +1,6 @@ +export class GetKycQueueQuery { + constructor( + public readonly page: number, + public readonly limit: number, + ) {} +} diff --git a/apps/api/src/modules/admin/application/queries/get-user-detail/get-user-detail.handler.ts b/apps/api/src/modules/admin/application/queries/get-user-detail/get-user-detail.handler.ts new file mode 100644 index 0000000..b71b34a --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-user-detail/get-user-detail.handler.ts @@ -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 { + constructor( + @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository, + ) {} + + async execute(query: GetUserDetailQuery): Promise { + 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; + } +} diff --git a/apps/api/src/modules/admin/application/queries/get-user-detail/get-user-detail.query.ts b/apps/api/src/modules/admin/application/queries/get-user-detail/get-user-detail.query.ts new file mode 100644 index 0000000..9aceb9e --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-user-detail/get-user-detail.query.ts @@ -0,0 +1,5 @@ +export class GetUserDetailQuery { + constructor( + public readonly userId: string, + ) {} +} diff --git a/apps/api/src/modules/admin/application/queries/get-users/get-users.handler.ts b/apps/api/src/modules/admin/application/queries/get-users/get-users.handler.ts new file mode 100644 index 0000000..51ff4c4 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-users/get-users.handler.ts @@ -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 { + constructor( + @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository, + ) {} + + async execute(query: GetUsersQuery): Promise { + return this.adminQueryRepo.getUsers({ + page: query.page, + limit: query.limit, + role: query.role, + isActive: query.isActive, + search: query.search, + }); + } +} diff --git a/apps/api/src/modules/admin/application/queries/get-users/get-users.query.ts b/apps/api/src/modules/admin/application/queries/get-users/get-users.query.ts new file mode 100644 index 0000000..f45ea45 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-users/get-users.query.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/admin/application/queries/index.ts b/apps/api/src/modules/admin/application/queries/index.ts index 345d404..63ac6fc 100644 --- a/apps/api/src/modules/admin/application/queries/index.ts +++ b/apps/api/src/modules/admin/application/queries/index.ts @@ -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'; diff --git a/apps/api/src/modules/admin/domain/events/index.ts b/apps/api/src/modules/admin/domain/events/index.ts index 9cfc536..3cc435d 100644 --- a/apps/api/src/modules/admin/domain/events/index.ts +++ b/apps/api/src/modules/admin/domain/events/index.ts @@ -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'; diff --git a/apps/api/src/modules/admin/domain/events/kyc-approved.event.ts b/apps/api/src/modules/admin/domain/events/kyc-approved.event.ts new file mode 100644 index 0000000..be78d99 --- /dev/null +++ b/apps/api/src/modules/admin/domain/events/kyc-approved.event.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/admin/domain/events/kyc-rejected.event.ts b/apps/api/src/modules/admin/domain/events/kyc-rejected.event.ts new file mode 100644 index 0000000..e1a645b --- /dev/null +++ b/apps/api/src/modules/admin/domain/events/kyc-rejected.event.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/admin/domain/repositories/admin-query.repository.ts b/apps/api/src/modules/admin/domain/repositories/admin-query.repository.ts index 383bb93..6598bae 100644 --- a/apps/api/src/modules/admin/domain/repositories/admin-query.repository.ts +++ b/apps/api/src/modules/admin/domain/repositories/admin-query.repository.ts @@ -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; getDashboardStats(): Promise; getRevenueStats(startDate: Date, endDate: Date, groupBy: 'day' | 'month'): Promise; getUsers(params: { page: number; limit: number; role?: string; isActive?: boolean; search?: string }): Promise; + getUserDetail(userId: string): Promise; + getKycQueue(page: number, limit: number): Promise; } diff --git a/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts b/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts index fb860b2..9023e86 100644 --- a/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts +++ b/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts @@ -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 { + 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 { + 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), + }; + } } diff --git a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts index 5176a3a..2ab70aa 100644 --- a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts +++ b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts @@ -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 { + 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 { + 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 { + return this.queryBus.execute(new GetUserDetailQuery(id)); + } + + @Patch('users/status') + async updateUserStatus( + @Body() dto: UpdateUserStatusDto, + @CurrentUser() user: JwtPayload, + ): Promise { + 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 { + 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 { + return this.commandBus.execute( + new ApproveKycCommand(dto.userId, user.sub, dto.comments), + ); + } + + @Post('kyc/reject') + async rejectKyc( + @Body() dto: RejectKycDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new RejectKycCommand(dto.userId, user.sub, dto.reason), + ); + } + // ── Subscription Management ── @Post('subscriptions/adjust') diff --git a/apps/api/src/modules/admin/presentation/dto/approve-kyc.dto.ts b/apps/api/src/modules/admin/presentation/dto/approve-kyc.dto.ts new file mode 100644 index 0000000..5c79cb0 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/approve-kyc.dto.ts @@ -0,0 +1,10 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class ApproveKycDto { + @IsString() + userId!: string; + + @IsOptional() + @IsString() + comments?: string; +} diff --git a/apps/api/src/modules/admin/presentation/dto/bulk-moderate.dto.ts b/apps/api/src/modules/admin/presentation/dto/bulk-moderate.dto.ts new file mode 100644 index 0000000..b138005 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/bulk-moderate.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/admin/presentation/dto/get-users-query.dto.ts b/apps/api/src/modules/admin/presentation/dto/get-users-query.dto.ts new file mode 100644 index 0000000..bbc1c40 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/get-users-query.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/admin/presentation/dto/index.ts b/apps/api/src/modules/admin/presentation/dto/index.ts index a9b9beb..244702d 100644 --- a/apps/api/src/modules/admin/presentation/dto/index.ts +++ b/apps/api/src/modules/admin/presentation/dto/index.ts @@ -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'; diff --git a/apps/api/src/modules/admin/presentation/dto/reject-kyc.dto.ts b/apps/api/src/modules/admin/presentation/dto/reject-kyc.dto.ts new file mode 100644 index 0000000..4abecb5 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/reject-kyc.dto.ts @@ -0,0 +1,10 @@ +import { IsString, MinLength } from 'class-validator'; + +export class RejectKycDto { + @IsString() + userId!: string; + + @IsString() + @MinLength(5) + reason!: string; +} diff --git a/apps/api/src/modules/admin/presentation/dto/update-user-status.dto.ts b/apps/api/src/modules/admin/presentation/dto/update-user-status.dto.ts new file mode 100644 index 0000000..ff59c58 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/update-user-status.dto.ts @@ -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; +}