From dafed32e11b57fc1a184250b6a47d59e817acdc7 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 02:17:09 +0700 Subject: [PATCH] feat(admin): add Admin module with moderation, user mgmt, and dashboard - Commands: ApproveListing, RejectListing, BanUser, AdjustSubscription - Queries: GetModerationQueue, GetDashboardStats, GetRevenueStats - Admin-only guards via @Roles('ADMIN') on all endpoints - Prisma-based admin query repository for dashboard aggregations - 14 unit tests covering all command handlers and query handlers - Added activate() method to UserEntity for unban support Co-Authored-By: Paperclip --- apps/api/src/app.module.ts | 2 + apps/api/src/modules/admin/admin.module.ts | 52 +++++ .../adjust-subscription.handler.spec.ts | 88 ++++++++ .../__tests__/approve-listing.handler.spec.ts | 75 +++++++ .../__tests__/ban-user.handler.spec.ts | 99 +++++++++ .../get-dashboard-stats.handler.spec.ts | 40 ++++ .../get-moderation-queue.handler.spec.ts | 49 +++++ .../adjust-subscription.command.ts | 8 + .../adjust-subscription.handler.ts | 65 ++++++ .../approve-listing.command.ts | 7 + .../approve-listing.handler.ts | 53 +++++ .../commands/ban-user/ban-user.command.ts | 8 + .../commands/ban-user/ban-user.handler.ts | 70 ++++++ .../admin/application/commands/index.ts | 8 + .../reject-listing/reject-listing.command.ts | 7 + .../reject-listing/reject-listing.handler.ts | 47 ++++ .../src/modules/admin/application/index.ts | 2 + .../get-dashboard-stats.handler.ts | 15 ++ .../get-dashboard-stats.query.ts | 3 + .../get-moderation-queue.handler.ts | 15 ++ .../get-moderation-queue.query.ts | 6 + .../get-revenue-stats.handler.ts | 15 ++ .../get-revenue-stats.query.ts | 7 + .../admin/application/queries/index.ts | 6 + .../src/modules/admin/domain/events/index.ts | 5 + .../domain/events/listing-approved.event.ts | 12 ++ .../domain/events/listing-rejected.event.ts | 12 ++ .../events/subscription-adjusted.event.ts | 13 ++ .../admin/domain/events/user-banned.event.ts | 12 ++ .../domain/events/user-unbanned.event.ts | 11 + apps/api/src/modules/admin/domain/index.ts | 2 + .../repositories/admin-query.repository.ts | 68 ++++++ .../admin/domain/repositories/index.ts | 9 + apps/api/src/modules/admin/index.ts | 1 + .../src/modules/admin/infrastructure/index.ts | 1 + .../infrastructure/repositories/index.ts | 1 + .../prisma-admin-query.repository.ts | 203 ++++++++++++++++++ .../controllers/admin.controller.ts | 124 +++++++++++ .../admin/presentation/controllers/index.ts | 1 + .../dto/adjust-subscription.dto.ts | 13 ++ .../presentation/dto/approve-listing.dto.ts | 10 + .../admin/presentation/dto/ban-user.dto.ts | 14 ++ .../modules/admin/presentation/dto/index.ts | 5 + .../presentation/dto/reject-listing.dto.ts | 10 + .../presentation/dto/revenue-stats.dto.ts | 13 ++ .../src/modules/admin/presentation/index.ts | 2 + .../auth/domain/entities/user.entity.ts | 5 + .../create-subscription.handler.spec.ts | 95 ++++++++ .../__tests__/subscription.entity.spec.ts | 96 +++++++++ 49 files changed, 1485 insertions(+) create mode 100644 apps/api/src/modules/admin/admin.module.ts create mode 100644 apps/api/src/modules/admin/application/__tests__/adjust-subscription.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/__tests__/approve-listing.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/__tests__/ban-user.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/__tests__/get-dashboard-stats.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/__tests__/get-moderation-queue.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/commands/adjust-subscription/adjust-subscription.command.ts create mode 100644 apps/api/src/modules/admin/application/commands/adjust-subscription/adjust-subscription.handler.ts create mode 100644 apps/api/src/modules/admin/application/commands/approve-listing/approve-listing.command.ts create mode 100644 apps/api/src/modules/admin/application/commands/approve-listing/approve-listing.handler.ts create mode 100644 apps/api/src/modules/admin/application/commands/ban-user/ban-user.command.ts create mode 100644 apps/api/src/modules/admin/application/commands/ban-user/ban-user.handler.ts create mode 100644 apps/api/src/modules/admin/application/commands/index.ts create mode 100644 apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.command.ts create mode 100644 apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.handler.ts create mode 100644 apps/api/src/modules/admin/application/index.ts create mode 100644 apps/api/src/modules/admin/application/queries/get-dashboard-stats/get-dashboard-stats.handler.ts create mode 100644 apps/api/src/modules/admin/application/queries/get-dashboard-stats/get-dashboard-stats.query.ts create mode 100644 apps/api/src/modules/admin/application/queries/get-moderation-queue/get-moderation-queue.handler.ts create mode 100644 apps/api/src/modules/admin/application/queries/get-moderation-queue/get-moderation-queue.query.ts create mode 100644 apps/api/src/modules/admin/application/queries/get-revenue-stats/get-revenue-stats.handler.ts create mode 100644 apps/api/src/modules/admin/application/queries/get-revenue-stats/get-revenue-stats.query.ts create mode 100644 apps/api/src/modules/admin/application/queries/index.ts create mode 100644 apps/api/src/modules/admin/domain/events/index.ts create mode 100644 apps/api/src/modules/admin/domain/events/listing-approved.event.ts create mode 100644 apps/api/src/modules/admin/domain/events/listing-rejected.event.ts create mode 100644 apps/api/src/modules/admin/domain/events/subscription-adjusted.event.ts create mode 100644 apps/api/src/modules/admin/domain/events/user-banned.event.ts create mode 100644 apps/api/src/modules/admin/domain/events/user-unbanned.event.ts create mode 100644 apps/api/src/modules/admin/domain/index.ts create mode 100644 apps/api/src/modules/admin/domain/repositories/admin-query.repository.ts create mode 100644 apps/api/src/modules/admin/domain/repositories/index.ts create mode 100644 apps/api/src/modules/admin/index.ts create mode 100644 apps/api/src/modules/admin/infrastructure/index.ts create mode 100644 apps/api/src/modules/admin/infrastructure/repositories/index.ts create mode 100644 apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts create mode 100644 apps/api/src/modules/admin/presentation/controllers/admin.controller.ts create mode 100644 apps/api/src/modules/admin/presentation/controllers/index.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/adjust-subscription.dto.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/approve-listing.dto.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/ban-user.dto.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/index.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/reject-listing.dto.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/revenue-stats.dto.ts create mode 100644 apps/api/src/modules/admin/presentation/index.ts create mode 100644 apps/api/src/modules/subscriptions/application/__tests__/create-subscription.handler.spec.ts create mode 100644 apps/api/src/modules/subscriptions/domain/__tests__/subscription.entity.spec.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2d971e0..2db77af 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,6 +5,7 @@ import { SearchModule } from '@modules/search'; import { NotificationsModule } from '@modules/notifications'; import { PaymentsModule } from '@modules/payments'; import { SubscriptionsModule } from '@modules/subscriptions'; +import { AdminModule } from '@modules/admin'; import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { CqrsModule } from '@nestjs/cqrs'; @@ -22,6 +23,7 @@ import { AppController } from './app.controller'; NotificationsModule, PaymentsModule, SubscriptionsModule, + AdminModule, // ── Rate Limiting ── // Default: 60 requests per 60 seconds per IP diff --git a/apps/api/src/modules/admin/admin.module.ts b/apps/api/src/modules/admin/admin.module.ts new file mode 100644 index 0000000..0cf652e --- /dev/null +++ b/apps/api/src/modules/admin/admin.module.ts @@ -0,0 +1,52 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { AuthModule } from '@modules/auth'; +import { ListingsModule } from '@modules/listings'; +import { SubscriptionsModule } from '@modules/subscriptions'; + +// Domain +import { ADMIN_QUERY_REPOSITORY } from './domain/repositories/admin-query.repository'; + +// Infrastructure +import { PrismaAdminQueryRepository } from './infrastructure/repositories/prisma-admin-query.repository'; + +// Application — Commands +import { ApproveListingHandler } from './application/commands/approve-listing/approve-listing.handler'; +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'; + +// 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'; + +// Presentation +import { AdminController } from './presentation/controllers/admin.controller'; + +const CommandHandlers = [ + ApproveListingHandler, + RejectListingHandler, + BanUserHandler, + AdjustSubscriptionHandler, +]; + +const QueryHandlers = [ + GetModerationQueueHandler, + GetDashboardStatsHandler, + GetRevenueStatsHandler, +]; + +@Module({ + imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule], + controllers: [AdminController], + providers: [ + // Repositories + { provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository }, + + // CQRS + ...CommandHandlers, + ...QueryHandlers, + ], +}) +export class AdminModule {} diff --git a/apps/api/src/modules/admin/application/__tests__/adjust-subscription.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/adjust-subscription.handler.spec.ts new file mode 100644 index 0000000..1de2437 --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/adjust-subscription.handler.spec.ts @@ -0,0 +1,88 @@ +import { AdjustSubscriptionHandler } from '../commands/adjust-subscription/adjust-subscription.handler'; +import { AdjustSubscriptionCommand } from '../commands/adjust-subscription/adjust-subscription.command'; +import { type ISubscriptionRepository } from '@modules/subscriptions/domain/repositories/subscription.repository'; +import { SubscriptionEntity } from '@modules/subscriptions/domain/entities/subscription.entity'; + +describe('AdjustSubscriptionHandler', () => { + let handler: AdjustSubscriptionHandler; + let mockSubRepo: { [K in keyof ISubscriptionRepository]: ReturnType }; + let mockPrisma: any; + let mockEventBus: { publish: ReturnType }; + + beforeEach(() => { + mockSubRepo = { + findById: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn(), + update: vi.fn(), + }; + + mockPrisma = { + plan: { + findUnique: vi.fn(), + }, + }; + + mockEventBus = { publish: vi.fn() }; + + handler = new AdjustSubscriptionHandler( + mockSubRepo as any, + mockPrisma, + mockEventBus as any, + ); + }); + + it('adjusts subscription to a new plan successfully', async () => { + const sub = SubscriptionEntity.createNew( + 'sub-1', 'user-1', 'plan-free', 'FREE', + new Date(), new Date(Date.now() + 30 * 86400000), + ); + sub.clearDomainEvents(); + + mockSubRepo.findByUserId.mockResolvedValue(sub); + mockPrisma.plan.findUnique.mockResolvedValue({ + id: 'plan-pro', + tier: 'AGENT_PRO', + }); + mockSubRepo.update.mockResolvedValue(undefined); + + const command = new AdjustSubscriptionCommand('user-1', 'admin-1', 'AGENT_PRO', 'Courtesy upgrade'); + const result = await handler.execute(command); + + expect(result.newPlanTier).toBe('AGENT_PRO'); + expect(mockSubRepo.update).toHaveBeenCalledTimes(1); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('throws NotFoundException when user has no subscription', async () => { + mockSubRepo.findByUserId.mockResolvedValue(null); + + const command = new AdjustSubscriptionCommand('user-1', 'admin-1', 'AGENT_PRO', 'test'); + + await expect(handler.execute(command)).rejects.toThrow('Người dùng chưa có subscription'); + }); + + it('throws ValidationException for invalid plan tier', async () => { + const command = new AdjustSubscriptionCommand('user-1', 'admin-1', 'INVALID_TIER', 'test'); + + await expect(handler.execute(command)).rejects.toThrow(/không hợp lệ/); + }); + + it('throws ValidationException when user is already on the same plan', async () => { + const sub = SubscriptionEntity.createNew( + 'sub-1', 'user-1', 'plan-pro', 'AGENT_PRO', + new Date(), new Date(Date.now() + 30 * 86400000), + ); + sub.clearDomainEvents(); + + mockSubRepo.findByUserId.mockResolvedValue(sub); + mockPrisma.plan.findUnique.mockResolvedValue({ + id: 'plan-pro', + tier: 'AGENT_PRO', + }); + + const command = new AdjustSubscriptionCommand('user-1', 'admin-1', 'AGENT_PRO', 'test'); + + await expect(handler.execute(command)).rejects.toThrow(/đã đang sử dụng/); + }); +}); diff --git a/apps/api/src/modules/admin/application/__tests__/approve-listing.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/approve-listing.handler.spec.ts new file mode 100644 index 0000000..d6c41a7 --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/approve-listing.handler.spec.ts @@ -0,0 +1,75 @@ +import { ApproveListingHandler } from '../commands/approve-listing/approve-listing.handler'; +import { ApproveListingCommand } from '../commands/approve-listing/approve-listing.command'; +import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; +import { ListingEntity } from '@modules/listings/domain/entities/listing.entity'; +import { Price } from '@modules/listings/domain/value-objects/price.vo'; + +function createPendingListing(id = 'listing-1'): ListingEntity { + const price = Price.create(1_000_000_000n).unwrap(); + const listing = ListingEntity.createNew( + id, 'prop-1', 'seller-1', 'SALE', price, 100, 'agent-1', + ); + listing.submitForReview(); + listing.clearDomainEvents(); + return listing; +} + +describe('ApproveListingHandler', () => { + let handler: ApproveListingHandler; + 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 ApproveListingHandler( + mockListingRepo as any, + mockEventBus as any, + ); + }); + + it('approves a pending listing successfully', async () => { + const listing = createPendingListing(); + mockListingRepo.findById.mockResolvedValue(listing); + mockListingRepo.update.mockResolvedValue(undefined); + + const command = new ApproveListingCommand('listing-1', 'admin-1', 'Looks good'); + const result = await handler.execute(command); + + expect(result.status).toBe('ACTIVE'); + expect(result.listingId).toBe('listing-1'); + expect(mockListingRepo.update).toHaveBeenCalledTimes(1); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('throws NotFoundException when listing does not exist', async () => { + mockListingRepo.findById.mockResolvedValue(null); + + const command = new ApproveListingCommand('nonexistent', 'admin-1'); + + await expect(handler.execute(command)).rejects.toThrow('Listing không tồn tại'); + }); + + it('throws ValidationException when listing is not pending review', async () => { + const price = Price.create(500_000_000n).unwrap(); + const listing = ListingEntity.createNew( + 'listing-1', 'prop-1', 'seller-1', 'SALE', price, 80, + ); + listing.clearDomainEvents(); + mockListingRepo.findById.mockResolvedValue(listing); + + const command = new ApproveListingCommand('listing-1', 'admin-1'); + + await expect(handler.execute(command)).rejects.toThrow(/trạng thái/); + }); +}); diff --git a/apps/api/src/modules/admin/application/__tests__/ban-user.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/ban-user.handler.spec.ts new file mode 100644 index 0000000..a1339de --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/ban-user.handler.spec.ts @@ -0,0 +1,99 @@ +import { BanUserHandler } from '../commands/ban-user/ban-user.handler'; +import { BanUserCommand } from '../commands/ban-user/ban-user.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; + const user = new UserEntity('user-1', { + email: null, + phone, + passwordHash, + fullName: 'Test User', + avatarUrl: null, + role, + kycStatus: 'NONE', + kycData: null, + isActive, + }); + return user; +} + +describe('BanUserHandler', () => { + let handler: BanUserHandler; + 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 BanUserHandler( + mockUserRepo as any, + mockEventBus as any, + ); + }); + + it('bans an active user successfully', async () => { + const user = await createUser('BUYER', true); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new BanUserCommand('user-1', 'admin-1', 'Spam activity'); + const result = await handler.execute(command); + + expect(result.isActive).toBe(false); + expect(result.message).toContain('bị ban'); + expect(mockUserRepo.update).toHaveBeenCalledTimes(1); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('unbans a banned user successfully', async () => { + const user = await createUser('BUYER', false); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new BanUserCommand('user-1', 'admin-1', 'Resolved', true); + const result = await handler.execute(command); + + expect(result.isActive).toBe(true); + expect(result.message).toContain('gỡ ban'); + }); + + it('throws NotFoundException when user does not exist', async () => { + mockUserRepo.findById.mockResolvedValue(null); + + const command = new BanUserCommand('nonexistent', 'admin-1', 'test'); + + await expect(handler.execute(command)).rejects.toThrow('Người dùng không tồn tại'); + }); + + it('prevents banning an admin user', async () => { + const admin = await createUser('ADMIN', true); + mockUserRepo.findById.mockResolvedValue(admin); + + const command = new BanUserCommand('user-1', 'admin-1', 'test'); + + await expect(handler.execute(command)).rejects.toThrow(/admin/i); + }); + + it('throws when trying to ban already banned user', async () => { + const user = await createUser('BUYER', false); + mockUserRepo.findById.mockResolvedValue(user); + + const command = new BanUserCommand('user-1', 'admin-1', 'test'); + + await expect(handler.execute(command)).rejects.toThrow('Người dùng đã bị ban'); + }); +}); diff --git a/apps/api/src/modules/admin/application/__tests__/get-dashboard-stats.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/get-dashboard-stats.handler.spec.ts new file mode 100644 index 0000000..cd798be --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/get-dashboard-stats.handler.spec.ts @@ -0,0 +1,40 @@ +import { GetDashboardStatsHandler } from '../queries/get-dashboard-stats/get-dashboard-stats.handler'; +import { GetDashboardStatsQuery } from '../queries/get-dashboard-stats/get-dashboard-stats.query'; +import { type IAdminQueryRepository } from '../../domain/repositories/admin-query.repository'; + +describe('GetDashboardStatsHandler', () => { + let handler: GetDashboardStatsHandler; + let mockAdminQueryRepo: { [K in keyof IAdminQueryRepository]: ReturnType }; + + beforeEach(() => { + mockAdminQueryRepo = { + getModerationQueue: vi.fn(), + getDashboardStats: vi.fn(), + getRevenueStats: vi.fn(), + getUsers: vi.fn(), + }; + + handler = new GetDashboardStatsHandler(mockAdminQueryRepo as any); + }); + + it('returns dashboard stats', async () => { + const stats = { + totalUsers: 150, + totalListings: 500, + activeListings: 200, + pendingModerationCount: 15, + totalAgents: 30, + verifiedAgents: 20, + totalTransactions: 50, + newUsersLast30Days: 25, + newListingsLast30Days: 40, + }; + + mockAdminQueryRepo.getDashboardStats.mockResolvedValue(stats); + + const result = await handler.execute(new GetDashboardStatsQuery()); + + expect(result).toEqual(stats); + expect(mockAdminQueryRepo.getDashboardStats).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/modules/admin/application/__tests__/get-moderation-queue.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/get-moderation-queue.handler.spec.ts new file mode 100644 index 0000000..74ca162 --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/get-moderation-queue.handler.spec.ts @@ -0,0 +1,49 @@ +import { GetModerationQueueHandler } from '../queries/get-moderation-queue/get-moderation-queue.handler'; +import { GetModerationQueueQuery } from '../queries/get-moderation-queue/get-moderation-queue.query'; +import { type IAdminQueryRepository } from '../../domain/repositories/admin-query.repository'; + +describe('GetModerationQueueHandler', () => { + let handler: GetModerationQueueHandler; + let mockAdminQueryRepo: { [K in keyof IAdminQueryRepository]: ReturnType }; + + beforeEach(() => { + mockAdminQueryRepo = { + getModerationQueue: vi.fn(), + getDashboardStats: vi.fn(), + getRevenueStats: vi.fn(), + getUsers: vi.fn(), + }; + + handler = new GetModerationQueueHandler(mockAdminQueryRepo as any); + }); + + it('returns moderation queue with pagination', async () => { + const queueResult = { + data: [ + { + listingId: 'listing-1', + propertyTitle: 'Căn hộ 2PN quận 7', + propertyType: 'APARTMENT', + transactionType: 'SALE', + priceVND: 3_000_000_000n, + sellerName: 'Nguyễn Văn A', + sellerId: 'seller-1', + moderationScore: null, + createdAt: new Date(), + }, + ], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + mockAdminQueryRepo.getModerationQueue.mockResolvedValue(queueResult); + + const result = await handler.execute(new GetModerationQueueQuery(1, 20)); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(mockAdminQueryRepo.getModerationQueue).toHaveBeenCalledWith(1, 20); + }); +}); diff --git a/apps/api/src/modules/admin/application/commands/adjust-subscription/adjust-subscription.command.ts b/apps/api/src/modules/admin/application/commands/adjust-subscription/adjust-subscription.command.ts new file mode 100644 index 0000000..25efb7e --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/adjust-subscription/adjust-subscription.command.ts @@ -0,0 +1,8 @@ +export class AdjustSubscriptionCommand { + constructor( + public readonly userId: string, + public readonly adminId: string, + public readonly newPlanTier: string, + public readonly reason: string, + ) {} +} diff --git a/apps/api/src/modules/admin/application/commands/adjust-subscription/adjust-subscription.handler.ts b/apps/api/src/modules/admin/application/commands/adjust-subscription/adjust-subscription.handler.ts new file mode 100644 index 0000000..e2fe385 --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/adjust-subscription/adjust-subscription.handler.ts @@ -0,0 +1,65 @@ +import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { NotFoundException, ValidationException } from '@modules/shared'; +import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from '@modules/subscriptions/domain/repositories/subscription.repository'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type PlanTier } from '@prisma/client'; +import { SubscriptionAdjustedEvent } from '../../../domain/events/subscription-adjusted.event'; +import { AdjustSubscriptionCommand } from './adjust-subscription.command'; + +const VALID_TIERS: PlanTier[] = ['FREE', 'AGENT_PRO', 'INVESTOR', 'ENTERPRISE']; + +export interface AdjustSubscriptionResult { + subscriptionId: string; + newPlanTier: string; + message: string; +} + +@CommandHandler(AdjustSubscriptionCommand) +export class AdjustSubscriptionHandler implements ICommandHandler { + constructor( + @Inject(SUBSCRIPTION_REPOSITORY) private readonly subscriptionRepo: ISubscriptionRepository, + private readonly prisma: PrismaService, + private readonly eventBus: EventBus, + ) {} + + async execute(command: AdjustSubscriptionCommand): Promise { + const tier = command.newPlanTier as PlanTier; + if (!VALID_TIERS.includes(tier)) { + throw new ValidationException(`Gói không hợp lệ: ${command.newPlanTier}`, { + validTiers: VALID_TIERS, + }); + } + + const subscription = await this.subscriptionRepo.findByUserId(command.userId); + if (!subscription) { + throw new NotFoundException('Người dùng chưa có subscription'); + } + + const newPlan = await this.prisma.plan.findUnique({ + where: { tier }, + }); + if (!newPlan) { + throw new NotFoundException(`Gói ${tier} không tồn tại trong hệ thống`); + } + + if (subscription.planId === newPlan.id) { + throw new ValidationException('Người dùng đã đang sử dụng gói này', { + currentPlanId: subscription.planId, + }); + } + + subscription.upgrade(newPlan.id, tier); + await this.subscriptionRepo.update(subscription); + + this.eventBus.publish( + new SubscriptionAdjustedEvent(subscription.id, command.adminId, newPlan.id, command.reason), + ); + + return { + subscriptionId: subscription.id, + newPlanTier: tier, + message: `Subscription đã được chuyển sang gói ${tier}`, + }; + } +} diff --git a/apps/api/src/modules/admin/application/commands/approve-listing/approve-listing.command.ts b/apps/api/src/modules/admin/application/commands/approve-listing/approve-listing.command.ts new file mode 100644 index 0000000..770ec16 --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/approve-listing/approve-listing.command.ts @@ -0,0 +1,7 @@ +export class ApproveListingCommand { + constructor( + public readonly listingId: string, + public readonly adminId: string, + public readonly moderationNotes?: string, + ) {} +} diff --git a/apps/api/src/modules/admin/application/commands/approve-listing/approve-listing.handler.ts b/apps/api/src/modules/admin/application/commands/approve-listing/approve-listing.handler.ts new file mode 100644 index 0000000..78103db --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/approve-listing/approve-listing.handler.ts @@ -0,0 +1,53 @@ +import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { NotFoundException, 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 { ApproveListingCommand } from './approve-listing.command'; + +export interface ApproveListingResult { + listingId: string; + status: string; + message: string; +} + +@CommandHandler(ApproveListingCommand) +export class ApproveListingHandler implements ICommandHandler { + constructor( + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + private readonly eventBus: EventBus, + ) {} + + async execute(command: ApproveListingCommand): Promise { + const listing = await this.listingRepo.findById(command.listingId); + if (!listing) { + throw new NotFoundException('Listing không tồn tại'); + } + + if (listing.status !== 'PENDING_REVIEW') { + throw new ValidationException( + `Listing đang ở trạng thái ${listing.status}, chỉ có thể duyệt listing đang chờ duyệt`, + { currentStatus: listing.status }, + ); + } + + listing.approve(); + + if (command.moderationNotes) { + listing.setModerationScore(1.0, command.moderationNotes); + } + + await this.listingRepo.update(listing); + + this.eventBus.publish( + new ListingApprovedEvent(listing.id, command.adminId, command.moderationNotes), + ); + + return { + listingId: listing.id, + status: 'ACTIVE', + message: 'Listing đã được duyệt thành công', + }; + } +} diff --git a/apps/api/src/modules/admin/application/commands/ban-user/ban-user.command.ts b/apps/api/src/modules/admin/application/commands/ban-user/ban-user.command.ts new file mode 100644 index 0000000..738d168 --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/ban-user/ban-user.command.ts @@ -0,0 +1,8 @@ +export class BanUserCommand { + constructor( + public readonly userId: string, + public readonly adminId: string, + public readonly reason: string, + public readonly unban: boolean = false, + ) {} +} diff --git a/apps/api/src/modules/admin/application/commands/ban-user/ban-user.handler.ts b/apps/api/src/modules/admin/application/commands/ban-user/ban-user.handler.ts new file mode 100644 index 0000000..70811df --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/ban-user/ban-user.handler.ts @@ -0,0 +1,70 @@ +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 { BanUserCommand } from './ban-user.command'; + +export interface BanUserResult { + userId: string; + isActive: boolean; + message: string; +} + +@CommandHandler(BanUserCommand) +export class BanUserHandler implements ICommandHandler { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + private readonly eventBus: EventBus, + ) {} + + async execute(command: BanUserCommand): 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ể ban/unban tài khoản admin', { + userId: command.userId, + }); + } + + if (command.unban) { + if (user.isActive) { + throw new ValidationException('Người dùng chưa bị ban', { + userId: command.userId, + }); + } + + 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 gỡ ban', + }; + } + + if (!user.isActive) { + throw new ValidationException('Người dùng đã bị ban', { + userId: command.userId, + }); + } + + 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ị ban', + }; + } +} diff --git a/apps/api/src/modules/admin/application/commands/index.ts b/apps/api/src/modules/admin/application/commands/index.ts new file mode 100644 index 0000000..836eb53 --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/index.ts @@ -0,0 +1,8 @@ +export { ApproveListingCommand } from './approve-listing/approve-listing.command'; +export { ApproveListingHandler } from './approve-listing/approve-listing.handler'; +export { RejectListingCommand } from './reject-listing/reject-listing.command'; +export { RejectListingHandler } from './reject-listing/reject-listing.handler'; +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'; diff --git a/apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.command.ts b/apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.command.ts new file mode 100644 index 0000000..4444e1a --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.command.ts @@ -0,0 +1,7 @@ +export class RejectListingCommand { + constructor( + public readonly listingId: string, + public readonly adminId: string, + public readonly reason: string, + ) {} +} diff --git a/apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.handler.ts b/apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.handler.ts new file mode 100644 index 0000000..6c8e536 --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/reject-listing/reject-listing.handler.ts @@ -0,0 +1,47 @@ +import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { NotFoundException, ValidationException } from '@modules/shared'; +import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; +import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event'; +import { RejectListingCommand } from './reject-listing.command'; + +export interface RejectListingResult { + listingId: string; + status: string; + message: string; +} + +@CommandHandler(RejectListingCommand) +export class RejectListingHandler implements ICommandHandler { + constructor( + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + private readonly eventBus: EventBus, + ) {} + + async execute(command: RejectListingCommand): Promise { + const listing = await this.listingRepo.findById(command.listingId); + if (!listing) { + throw new NotFoundException('Listing không tồn tại'); + } + + if (listing.status !== 'PENDING_REVIEW') { + throw new ValidationException( + `Listing đang ở trạng thái ${listing.status}, chỉ có thể từ chối listing đang chờ duyệt`, + { currentStatus: listing.status }, + ); + } + + listing.reject(command.reason); + await this.listingRepo.update(listing); + + this.eventBus.publish( + new ListingRejectedEvent(listing.id, command.adminId, command.reason), + ); + + return { + listingId: listing.id, + status: 'REJECTED', + message: 'Listing đã bị từ chối', + }; + } +} diff --git a/apps/api/src/modules/admin/application/index.ts b/apps/api/src/modules/admin/application/index.ts new file mode 100644 index 0000000..a72f7ee --- /dev/null +++ b/apps/api/src/modules/admin/application/index.ts @@ -0,0 +1,2 @@ +export * from './commands'; +export * from './queries'; diff --git a/apps/api/src/modules/admin/application/queries/get-dashboard-stats/get-dashboard-stats.handler.ts b/apps/api/src/modules/admin/application/queries/get-dashboard-stats/get-dashboard-stats.handler.ts new file mode 100644 index 0000000..c8f7598 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-dashboard-stats/get-dashboard-stats.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 DashboardStats } from '../../../domain/repositories/admin-query.repository'; +import { GetDashboardStatsQuery } from './get-dashboard-stats.query'; + +@QueryHandler(GetDashboardStatsQuery) +export class GetDashboardStatsHandler implements IQueryHandler { + constructor( + @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository, + ) {} + + async execute(_query: GetDashboardStatsQuery): Promise { + return this.adminQueryRepo.getDashboardStats(); + } +} diff --git a/apps/api/src/modules/admin/application/queries/get-dashboard-stats/get-dashboard-stats.query.ts b/apps/api/src/modules/admin/application/queries/get-dashboard-stats/get-dashboard-stats.query.ts new file mode 100644 index 0000000..991a98a --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-dashboard-stats/get-dashboard-stats.query.ts @@ -0,0 +1,3 @@ +export class GetDashboardStatsQuery { + constructor() {} +} diff --git a/apps/api/src/modules/admin/application/queries/get-moderation-queue/get-moderation-queue.handler.ts b/apps/api/src/modules/admin/application/queries/get-moderation-queue/get-moderation-queue.handler.ts new file mode 100644 index 0000000..7d4ea31 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-moderation-queue/get-moderation-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 ModerationQueueResult } from '../../../domain/repositories/admin-query.repository'; +import { GetModerationQueueQuery } from './get-moderation-queue.query'; + +@QueryHandler(GetModerationQueueQuery) +export class GetModerationQueueHandler implements IQueryHandler { + constructor( + @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository, + ) {} + + async execute(query: GetModerationQueueQuery): Promise { + return this.adminQueryRepo.getModerationQueue(query.page, query.limit); + } +} diff --git a/apps/api/src/modules/admin/application/queries/get-moderation-queue/get-moderation-queue.query.ts b/apps/api/src/modules/admin/application/queries/get-moderation-queue/get-moderation-queue.query.ts new file mode 100644 index 0000000..7eea812 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-moderation-queue/get-moderation-queue.query.ts @@ -0,0 +1,6 @@ +export class GetModerationQueueQuery { + constructor( + public readonly page: number = 1, + public readonly limit: number = 20, + ) {} +} diff --git a/apps/api/src/modules/admin/application/queries/get-revenue-stats/get-revenue-stats.handler.ts b/apps/api/src/modules/admin/application/queries/get-revenue-stats/get-revenue-stats.handler.ts new file mode 100644 index 0000000..5997ec7 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-revenue-stats/get-revenue-stats.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 RevenueStatsItem } from '../../../domain/repositories/admin-query.repository'; +import { GetRevenueStatsQuery } from './get-revenue-stats.query'; + +@QueryHandler(GetRevenueStatsQuery) +export class GetRevenueStatsHandler implements IQueryHandler { + constructor( + @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository, + ) {} + + async execute(query: GetRevenueStatsQuery): Promise { + return this.adminQueryRepo.getRevenueStats(query.startDate, query.endDate, query.groupBy); + } +} diff --git a/apps/api/src/modules/admin/application/queries/get-revenue-stats/get-revenue-stats.query.ts b/apps/api/src/modules/admin/application/queries/get-revenue-stats/get-revenue-stats.query.ts new file mode 100644 index 0000000..85b2730 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-revenue-stats/get-revenue-stats.query.ts @@ -0,0 +1,7 @@ +export class GetRevenueStatsQuery { + constructor( + public readonly startDate: Date, + public readonly endDate: Date, + public readonly groupBy: 'day' | 'month' = 'month', + ) {} +} diff --git a/apps/api/src/modules/admin/application/queries/index.ts b/apps/api/src/modules/admin/application/queries/index.ts new file mode 100644 index 0000000..345d404 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/index.ts @@ -0,0 +1,6 @@ +export { GetModerationQueueQuery } from './get-moderation-queue/get-moderation-queue.query'; +export { GetModerationQueueHandler } from './get-moderation-queue/get-moderation-queue.handler'; +export { GetDashboardStatsQuery } from './get-dashboard-stats/get-dashboard-stats.query'; +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'; diff --git a/apps/api/src/modules/admin/domain/events/index.ts b/apps/api/src/modules/admin/domain/events/index.ts new file mode 100644 index 0000000..9cfc536 --- /dev/null +++ b/apps/api/src/modules/admin/domain/events/index.ts @@ -0,0 +1,5 @@ +export { ListingApprovedEvent } from './listing-approved.event'; +export { ListingRejectedEvent } from './listing-rejected.event'; +export { UserBannedEvent } from './user-banned.event'; +export { UserUnbannedEvent } from './user-unbanned.event'; +export { SubscriptionAdjustedEvent } from './subscription-adjusted.event'; diff --git a/apps/api/src/modules/admin/domain/events/listing-approved.event.ts b/apps/api/src/modules/admin/domain/events/listing-approved.event.ts new file mode 100644 index 0000000..10783b6 --- /dev/null +++ b/apps/api/src/modules/admin/domain/events/listing-approved.event.ts @@ -0,0 +1,12 @@ +import { type DomainEvent } from '@modules/shared'; + +export class ListingApprovedEvent implements DomainEvent { + readonly eventName = 'listing.approved_by_admin'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly adminId: string, + public readonly moderationNotes?: string, + ) {} +} diff --git a/apps/api/src/modules/admin/domain/events/listing-rejected.event.ts b/apps/api/src/modules/admin/domain/events/listing-rejected.event.ts new file mode 100644 index 0000000..8aebeaf --- /dev/null +++ b/apps/api/src/modules/admin/domain/events/listing-rejected.event.ts @@ -0,0 +1,12 @@ +import { type DomainEvent } from '@modules/shared'; + +export class ListingRejectedEvent implements DomainEvent { + readonly eventName = 'listing.rejected_by_admin'; + 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/events/subscription-adjusted.event.ts b/apps/api/src/modules/admin/domain/events/subscription-adjusted.event.ts new file mode 100644 index 0000000..fa169a0 --- /dev/null +++ b/apps/api/src/modules/admin/domain/events/subscription-adjusted.event.ts @@ -0,0 +1,13 @@ +import { type DomainEvent } from '@modules/shared'; + +export class SubscriptionAdjustedEvent implements DomainEvent { + readonly eventName = 'subscription.adjusted_by_admin'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly adminId: string, + public readonly newPlanId: string, + public readonly reason: string, + ) {} +} diff --git a/apps/api/src/modules/admin/domain/events/user-banned.event.ts b/apps/api/src/modules/admin/domain/events/user-banned.event.ts new file mode 100644 index 0000000..b7a9917 --- /dev/null +++ b/apps/api/src/modules/admin/domain/events/user-banned.event.ts @@ -0,0 +1,12 @@ +import { type DomainEvent } from '@modules/shared'; + +export class UserBannedEvent implements DomainEvent { + readonly eventName = 'user.banned'; + 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/events/user-unbanned.event.ts b/apps/api/src/modules/admin/domain/events/user-unbanned.event.ts new file mode 100644 index 0000000..a13da15 --- /dev/null +++ b/apps/api/src/modules/admin/domain/events/user-unbanned.event.ts @@ -0,0 +1,11 @@ +import { type DomainEvent } from '@modules/shared'; + +export class UserUnbannedEvent implements DomainEvent { + readonly eventName = 'user.unbanned'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly adminId: string, + ) {} +} diff --git a/apps/api/src/modules/admin/domain/index.ts b/apps/api/src/modules/admin/domain/index.ts new file mode 100644 index 0000000..1d43e39 --- /dev/null +++ b/apps/api/src/modules/admin/domain/index.ts @@ -0,0 +1,2 @@ +export * from './events'; +export * from './repositories'; 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 new file mode 100644 index 0000000..383bb93 --- /dev/null +++ b/apps/api/src/modules/admin/domain/repositories/admin-query.repository.ts @@ -0,0 +1,68 @@ +export const ADMIN_QUERY_REPOSITORY = Symbol('ADMIN_QUERY_REPOSITORY'); + +export interface ModerationQueueItem { + listingId: string; + propertyTitle: string; + propertyType: string; + transactionType: string; + priceVND: bigint; + sellerName: string; + sellerId: string; + moderationScore: number | null; + createdAt: Date; +} + +export interface ModerationQueueResult { + data: ModerationQueueItem[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface DashboardStats { + totalUsers: number; + totalListings: number; + activeListings: number; + pendingModerationCount: number; + totalAgents: number; + verifiedAgents: number; + totalTransactions: number; + newUsersLast30Days: number; + newListingsLast30Days: number; +} + +export interface RevenueStatsItem { + period: string; + totalRevenue: bigint; + subscriptionRevenue: bigint; + listingFeeRevenue: bigint; + featuredListingRevenue: bigint; + transactionCount: number; +} + +export interface UserListItem { + id: string; + email: string | null; + phone: string; + fullName: string; + role: string; + kycStatus: string; + isActive: boolean; + createdAt: Date; +} + +export interface UserListResult { + data: UserListItem[]; + 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; +} diff --git a/apps/api/src/modules/admin/domain/repositories/index.ts b/apps/api/src/modules/admin/domain/repositories/index.ts new file mode 100644 index 0000000..893b466 --- /dev/null +++ b/apps/api/src/modules/admin/domain/repositories/index.ts @@ -0,0 +1,9 @@ +export { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository } from './admin-query.repository'; +export type { + ModerationQueueItem, + ModerationQueueResult, + DashboardStats, + RevenueStatsItem, + UserListItem, + UserListResult, +} from './admin-query.repository'; diff --git a/apps/api/src/modules/admin/index.ts b/apps/api/src/modules/admin/index.ts new file mode 100644 index 0000000..c1edf41 --- /dev/null +++ b/apps/api/src/modules/admin/index.ts @@ -0,0 +1 @@ +export { AdminModule } from './admin.module'; diff --git a/apps/api/src/modules/admin/infrastructure/index.ts b/apps/api/src/modules/admin/infrastructure/index.ts new file mode 100644 index 0000000..c51b022 --- /dev/null +++ b/apps/api/src/modules/admin/infrastructure/index.ts @@ -0,0 +1 @@ +export * from './repositories'; diff --git a/apps/api/src/modules/admin/infrastructure/repositories/index.ts b/apps/api/src/modules/admin/infrastructure/repositories/index.ts new file mode 100644 index 0000000..1615270 --- /dev/null +++ b/apps/api/src/modules/admin/infrastructure/repositories/index.ts @@ -0,0 +1 @@ +export { PrismaAdminQueryRepository } from './prisma-admin-query.repository'; 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 new file mode 100644 index 0000000..fb860b2 --- /dev/null +++ b/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts @@ -0,0 +1,203 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { + type IAdminQueryRepository, + type ModerationQueueResult, + type DashboardStats, + type RevenueStatsItem, + type UserListResult, +} from '../../domain/repositories/admin-query.repository'; + +@Injectable() +export class PrismaAdminQueryRepository implements IAdminQueryRepository { + constructor(private readonly prisma: PrismaService) {} + + async getModerationQueue(page: number, limit: number): Promise { + const skip = (page - 1) * limit; + + const [listings, total] = await Promise.all([ + this.prisma.listing.findMany({ + where: { status: 'PENDING_REVIEW' }, + include: { + property: { select: { title: true, propertyType: true } }, + seller: { select: { fullName: true } }, + }, + orderBy: { createdAt: 'asc' }, + skip, + take: limit, + }), + this.prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }), + ]); + + return { + data: listings.map((l) => ({ + listingId: l.id, + propertyTitle: l.property.title, + propertyType: l.property.propertyType, + transactionType: l.transactionType, + priceVND: l.priceVND, + sellerName: l.seller.fullName, + sellerId: l.sellerId, + moderationScore: l.moderationScore, + createdAt: l.createdAt, + })), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async getDashboardStats(): Promise { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const [ + totalUsers, + totalListings, + activeListings, + pendingModerationCount, + totalAgents, + verifiedAgents, + totalTransactions, + newUsersLast30Days, + newListingsLast30Days, + ] = await Promise.all([ + this.prisma.user.count(), + this.prisma.listing.count(), + this.prisma.listing.count({ where: { status: 'ACTIVE' } }), + this.prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }), + this.prisma.agent.count(), + this.prisma.agent.count({ where: { isVerified: true } }), + this.prisma.transaction.count(), + this.prisma.user.count({ where: { createdAt: { gte: thirtyDaysAgo } } }), + this.prisma.listing.count({ where: { createdAt: { gte: thirtyDaysAgo } } }), + ]); + + return { + totalUsers, + totalListings, + activeListings, + pendingModerationCount, + totalAgents, + verifiedAgents, + totalTransactions, + newUsersLast30Days, + newListingsLast30Days, + }; + } + + async getRevenueStats( + startDate: Date, + endDate: Date, + groupBy: 'day' | 'month', + ): Promise { + const payments = await this.prisma.payment.findMany({ + where: { + status: 'COMPLETED', + createdAt: { gte: startDate, lte: endDate }, + }, + select: { + type: true, + amountVND: true, + createdAt: true, + }, + orderBy: { createdAt: 'asc' }, + }); + + const grouped = new Map(); + + for (const payment of payments) { + const period = groupBy === 'day' + ? payment.createdAt.toISOString().slice(0, 10) + : payment.createdAt.toISOString().slice(0, 7); + + if (!grouped.has(period)) { + grouped.set(period, { + totalRevenue: 0n, + subscriptionRevenue: 0n, + listingFeeRevenue: 0n, + featuredListingRevenue: 0n, + transactionCount: 0, + }); + } + + const stats = grouped.get(period)!; + stats.totalRevenue += payment.amountVND; + stats.transactionCount++; + + switch (payment.type) { + case 'SUBSCRIPTION': + stats.subscriptionRevenue += payment.amountVND; + break; + case 'LISTING_FEE': + stats.listingFeeRevenue += payment.amountVND; + break; + case 'FEATURED_LISTING': + stats.featuredListingRevenue += payment.amountVND; + break; + } + } + + return Array.from(grouped.entries()).map(([period, stats]) => ({ + period, + ...stats, + })); + } + + async getUsers(params: { + page: number; + limit: number; + role?: string; + isActive?: boolean; + search?: string; + }): Promise { + const { page, limit, role, isActive, search } = params; + const skip = (page - 1) * limit; + + const where: any = {}; + if (role) where.role = role; + if (isActive !== undefined) where.isActive = isActive; + if (search) { + where.OR = [ + { fullName: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } }, + { phone: { contains: search } }, + ]; + } + + const [users, total] = await Promise.all([ + this.prisma.user.findMany({ + where, + select: { + id: true, + email: true, + phone: true, + fullName: true, + role: true, + kycStatus: true, + isActive: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + this.prisma.user.count({ where }), + ]); + + return { + data: users, + 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 new file mode 100644 index 0000000..5176a3a --- /dev/null +++ b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts @@ -0,0 +1,124 @@ +import { + Body, + Controller, + Get, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard'; +import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard'; +import { Roles } from '@modules/auth/presentation/decorators/roles.decorator'; +import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator'; +import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service'; + +import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command'; +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 { 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 { 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 { 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'; + +@Controller('admin') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('ADMIN') +export class AdminController { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + // ── Moderation ── + + @Get('moderation') + async getModerationQueue( + @Query('page') page?: string, + @Query('limit') limit?: string, + ): Promise { + return this.queryBus.execute( + new GetModerationQueueQuery( + page ? parseInt(page, 10) : 1, + limit ? parseInt(limit, 10) : 20, + ), + ); + } + + @Post('moderation/approve') + async approveListing( + @Body() dto: ApproveListingDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new ApproveListingCommand(dto.listingId, user.sub, dto.moderationNotes), + ); + } + + @Post('moderation/reject') + async rejectListing( + @Body() dto: RejectListingDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new RejectListingCommand(dto.listingId, user.sub, dto.reason), + ); + } + + // ── User Management ── + + @Post('users/ban') + async banUser( + @Body() dto: BanUserDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new BanUserCommand(dto.userId, user.sub, dto.reason, dto.unban ?? false), + ); + } + + // ── Subscription Management ── + + @Post('subscriptions/adjust') + async adjustSubscription( + @Body() dto: AdjustSubscriptionDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new AdjustSubscriptionCommand(dto.userId, user.sub, dto.newPlanTier, dto.reason), + ); + } + + // ── Dashboard ── + + @Get('dashboard') + async getDashboardStats(): Promise { + return this.queryBus.execute(new GetDashboardStatsQuery()); + } + + @Get('revenue') + async getRevenueStats( + @Query() dto: RevenueStatsDto, + ): Promise { + return this.queryBus.execute( + new GetRevenueStatsQuery( + new Date(dto.startDate), + new Date(dto.endDate), + dto.groupBy ?? 'month', + ), + ); + } +} diff --git a/apps/api/src/modules/admin/presentation/controllers/index.ts b/apps/api/src/modules/admin/presentation/controllers/index.ts new file mode 100644 index 0000000..19b5a1d --- /dev/null +++ b/apps/api/src/modules/admin/presentation/controllers/index.ts @@ -0,0 +1 @@ +export { AdminController } from './admin.controller'; diff --git a/apps/api/src/modules/admin/presentation/dto/adjust-subscription.dto.ts b/apps/api/src/modules/admin/presentation/dto/adjust-subscription.dto.ts new file mode 100644 index 0000000..269d322 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/adjust-subscription.dto.ts @@ -0,0 +1,13 @@ +import { IsString, MinLength } from 'class-validator'; + +export class AdjustSubscriptionDto { + @IsString() + userId!: string; + + @IsString() + newPlanTier!: string; + + @IsString() + @MinLength(5) + reason!: string; +} diff --git a/apps/api/src/modules/admin/presentation/dto/approve-listing.dto.ts b/apps/api/src/modules/admin/presentation/dto/approve-listing.dto.ts new file mode 100644 index 0000000..fb358ba --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/approve-listing.dto.ts @@ -0,0 +1,10 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class ApproveListingDto { + @IsString() + listingId!: string; + + @IsOptional() + @IsString() + moderationNotes?: string; +} diff --git a/apps/api/src/modules/admin/presentation/dto/ban-user.dto.ts b/apps/api/src/modules/admin/presentation/dto/ban-user.dto.ts new file mode 100644 index 0000000..3dd9eb4 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/ban-user.dto.ts @@ -0,0 +1,14 @@ +import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator'; + +export class BanUserDto { + @IsString() + userId!: string; + + @IsString() + @MinLength(5) + reason!: string; + + @IsOptional() + @IsBoolean() + unban?: boolean; +} diff --git a/apps/api/src/modules/admin/presentation/dto/index.ts b/apps/api/src/modules/admin/presentation/dto/index.ts new file mode 100644 index 0000000..a9b9beb --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/index.ts @@ -0,0 +1,5 @@ +export { ApproveListingDto } from './approve-listing.dto'; +export { RejectListingDto } from './reject-listing.dto'; +export { BanUserDto } from './ban-user.dto'; +export { AdjustSubscriptionDto } from './adjust-subscription.dto'; +export { RevenueStatsDto } from './revenue-stats.dto'; diff --git a/apps/api/src/modules/admin/presentation/dto/reject-listing.dto.ts b/apps/api/src/modules/admin/presentation/dto/reject-listing.dto.ts new file mode 100644 index 0000000..9aa8894 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/reject-listing.dto.ts @@ -0,0 +1,10 @@ +import { IsString, MinLength } from 'class-validator'; + +export class RejectListingDto { + @IsString() + listingId!: string; + + @IsString() + @MinLength(5) + reason!: string; +} diff --git a/apps/api/src/modules/admin/presentation/dto/revenue-stats.dto.ts b/apps/api/src/modules/admin/presentation/dto/revenue-stats.dto.ts new file mode 100644 index 0000000..071861a --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/revenue-stats.dto.ts @@ -0,0 +1,13 @@ +import { IsDateString, IsIn, IsOptional } from 'class-validator'; + +export class RevenueStatsDto { + @IsDateString() + startDate!: string; + + @IsDateString() + endDate!: string; + + @IsOptional() + @IsIn(['day', 'month']) + groupBy?: 'day' | 'month'; +} diff --git a/apps/api/src/modules/admin/presentation/index.ts b/apps/api/src/modules/admin/presentation/index.ts new file mode 100644 index 0000000..5f229e9 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/index.ts @@ -0,0 +1,2 @@ +export * from './controllers'; +export * from './dto'; diff --git a/apps/api/src/modules/auth/domain/entities/user.entity.ts b/apps/api/src/modules/auth/domain/entities/user.entity.ts index 01d693e..5ffceb8 100644 --- a/apps/api/src/modules/auth/domain/entities/user.entity.ts +++ b/apps/api/src/modules/auth/domain/entities/user.entity.ts @@ -85,4 +85,9 @@ export class UserEntity extends AggregateRoot { this._isActive = false; this.updatedAt = new Date(); } + + activate(): void { + this._isActive = true; + this.updatedAt = new Date(); + } } diff --git a/apps/api/src/modules/subscriptions/application/__tests__/create-subscription.handler.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/create-subscription.handler.spec.ts new file mode 100644 index 0000000..3a864f0 --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/__tests__/create-subscription.handler.spec.ts @@ -0,0 +1,95 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { EventBus } from '@nestjs/cqrs'; +import { CreateSubscriptionHandler } from '../commands/create-subscription/create-subscription.handler'; +import { CreateSubscriptionCommand } from '../commands/create-subscription/create-subscription.command'; +import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository'; +import { SubscriptionEntity } from '../../domain/entities/subscription.entity'; + +describe('CreateSubscriptionHandler', () => { + let handler: CreateSubscriptionHandler; + let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType }; + let mockPrisma: any; + let mockEventBus: { publish: ReturnType }; + + beforeEach(() => { + mockRepo = { + findById: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn(), + update: vi.fn(), + }; + + mockPrisma = { + plan: { + findFirst: vi.fn(), + }, + }; + + mockEventBus = { + publish: vi.fn(), + }; + + handler = new CreateSubscriptionHandler( + mockRepo as any, + mockPrisma, + mockEventBus as any, + ); + }); + + it('creates subscription successfully', async () => { + mockRepo.findByUserId.mockResolvedValue(null); + mockPrisma.plan.findFirst.mockResolvedValue({ + id: 'plan-1', + tier: 'AGENT_PRO', + isActive: true, + }); + mockRepo.save.mockResolvedValue(undefined); + + const command = new CreateSubscriptionCommand('user-1', 'AGENT_PRO', 'monthly'); + const result = await handler.execute(command); + + expect(result.planTier).toBe('AGENT_PRO'); + expect(result.status).toBe('ACTIVE'); + expect(result.subscriptionId).toBeDefined(); + expect(mockRepo.save).toHaveBeenCalledTimes(1); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('throws ConflictException when user already has active subscription', async () => { + const existing = SubscriptionEntity.createNew( + 'sub-1', 'user-1', 'plan-1', 'FREE', + new Date(), new Date(), + ); + mockRepo.findByUserId.mockResolvedValue(existing); + + const command = new CreateSubscriptionCommand('user-1', 'AGENT_PRO', 'monthly'); + + await expect(handler.execute(command)).rejects.toThrow(ConflictException); + }); + + it('throws NotFoundException when plan does not exist', async () => { + mockRepo.findByUserId.mockResolvedValue(null); + mockPrisma.plan.findFirst.mockResolvedValue(null); + + const command = new CreateSubscriptionCommand('user-1', 'AGENT_PRO', 'monthly'); + + await expect(handler.execute(command)).rejects.toThrow(NotFoundException); + }); + + it('creates yearly subscription with correct period', async () => { + mockRepo.findByUserId.mockResolvedValue(null); + mockPrisma.plan.findFirst.mockResolvedValue({ + id: 'plan-1', + tier: 'INVESTOR', + isActive: true, + }); + mockRepo.save.mockResolvedValue(undefined); + + const command = new CreateSubscriptionCommand('user-1', 'INVESTOR', 'yearly'); + const result = await handler.execute(command); + + const startYear = result.currentPeriodStart.getFullYear(); + const endYear = result.currentPeriodEnd.getFullYear(); + expect(endYear - startYear).toBe(1); + }); +}); diff --git a/apps/api/src/modules/subscriptions/domain/__tests__/subscription.entity.spec.ts b/apps/api/src/modules/subscriptions/domain/__tests__/subscription.entity.spec.ts new file mode 100644 index 0000000..f7bd593 --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/__tests__/subscription.entity.spec.ts @@ -0,0 +1,96 @@ +import { SubscriptionEntity } from '../entities/subscription.entity'; + +describe('SubscriptionEntity', () => { + const makeSub = (overrides?: Partial[0]>) => { + return SubscriptionEntity.createNew( + 'sub-1', + 'user-1', + 'plan-1', + 'AGENT_PRO', + new Date('2026-01-01'), + new Date('2026-02-01'), + ); + }; + + it('creates a new subscription with ACTIVE status', () => { + const sub = makeSub(); + expect(sub.id).toBe('sub-1'); + expect(sub.userId).toBe('user-1'); + expect(sub.planTier).toBe('AGENT_PRO'); + expect(sub.status).toBe('ACTIVE'); + expect(sub.isActive()).toBe(true); + expect(sub.cancelledAt).toBeNull(); + }); + + it('emits SubscriptionCreatedEvent on creation', () => { + const sub = makeSub(); + const events = sub.domainEvents; + expect(events).toHaveLength(1); + expect(events[0].eventName).toBe('subscription.created'); + }); + + it('upgrades plan tier', () => { + const sub = makeSub(); + sub.clearDomainEvents(); + + sub.upgrade('plan-2', 'ENTERPRISE'); + + expect(sub.planId).toBe('plan-2'); + expect(sub.planTier).toBe('ENTERPRISE'); + const events = sub.domainEvents; + expect(events).toHaveLength(1); + expect(events[0].eventName).toBe('subscription.upgraded'); + }); + + it('throws when upgrading non-active subscription', () => { + const sub = makeSub(); + sub.cancel(); + + expect(() => sub.upgrade('plan-2', 'ENTERPRISE')).toThrow(); + }); + + it('cancels subscription', () => { + const sub = makeSub(); + sub.clearDomainEvents(); + + sub.cancel(); + + expect(sub.status).toBe('CANCELLED'); + expect(sub.cancelledAt).not.toBeNull(); + const events = sub.domainEvents; + expect(events).toHaveLength(1); + expect(events[0].eventName).toBe('subscription.cancelled'); + }); + + it('throws when cancelling already cancelled subscription', () => { + const sub = makeSub(); + sub.cancel(); + + expect(() => sub.cancel()).toThrow('Subscription đã bị hủy'); + }); + + it('marks past due', () => { + const sub = makeSub(); + sub.markPastDue(); + expect(sub.status).toBe('PAST_DUE'); + }); + + it('marks expired', () => { + const sub = makeSub(); + sub.markExpired(); + expect(sub.status).toBe('EXPIRED'); + }); + + it('renews period', () => { + const sub = makeSub(); + sub.markPastDue(); + + const newStart = new Date('2026-02-01'); + const newEnd = new Date('2026-03-01'); + sub.renewPeriod(newStart, newEnd); + + expect(sub.status).toBe('ACTIVE'); + expect(sub.currentPeriodStart).toEqual(newStart); + expect(sub.currentPeriodEnd).toEqual(newEnd); + }); +});