From d64bbe97e27b04135539afd5e57346d619ebaecb Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 9 Apr 2026 10:01:16 +0700 Subject: [PATCH] feat(api): add inquiries, leads, and agents modules for Agent Portal Build three new DDD modules following existing CQRS patterns: - Inquiries: CRUD endpoints for buyer consultation requests with agent notification support - Leads: Full lead lifecycle management with status state machine and conversion tracking - Agents: Quality score calculation (event-driven on review changes) and dashboard stats API All modules include unit tests (14 test files, all 797 tests pass). Co-Authored-By: Paperclip --- apps/api/src/app.module.ts | 6 + apps/api/src/modules/agents/agents.module.ts | 25 +++ .../get-agent-dashboard.handler.spec.ts | 65 ++++++ .../recalculate-quality-score.handler.spec.ts | 115 +++++++++++ .../__tests__/review-events.listener.spec.ts | 75 +++++++ .../recalculate-quality-score.command.ts | 3 + .../recalculate-quality-score.handler.ts | 84 ++++++++ .../listeners/review-events.listener.ts | 41 ++++ .../get-agent-dashboard.handler.ts | 28 +++ .../get-agent-dashboard.query.ts | 3 + .../domain/__tests__/quality-score.spec.ts | 122 +++++++++++ .../domain/repositories/agent.repository.ts | 25 +++ .../domain/services/quality-score.service.ts | 37 ++++ apps/api/src/modules/agents/index.ts | 6 + .../repositories/prisma-agent.repository.ts | 122 +++++++++++ .../controllers/agents.controller.ts | 61 ++++++ .../__tests__/create-inquiry.handler.spec.ts | 95 +++++++++ .../get-inquiries-by-agent.handler.spec.ts | 76 +++++++ .../get-inquiries-by-listing.handler.spec.ts | 68 +++++++ .../mark-inquiry-read.handler.spec.ts | 126 ++++++++++++ .../create-inquiry/create-inquiry.command.ts | 8 + .../create-inquiry/create-inquiry.handler.ts | 61 ++++++ .../mark-inquiry-read.command.ts | 6 + .../mark-inquiry-read.handler.ts | 52 +++++ .../get-inquiries-by-agent.handler.ts | 31 +++ .../get-inquiries-by-agent.query.ts | 7 + .../get-inquiries-by-listing.handler.ts | 20 ++ .../get-inquiries-by-listing.query.ts | 7 + .../domain/__tests__/inquiry-domain.spec.ts | 95 +++++++++ .../domain/entities/inquiry.entity.ts | 62 ++++++ .../domain/events/inquiry-created.event.ts | 12 ++ .../domain/events/inquiry-read.event.ts | 12 ++ .../domain/repositories/inquiry-read.dto.ts | 12 ++ .../domain/repositories/inquiry.repository.ts | 21 ++ apps/api/src/modules/inquiries/index.ts | 3 + .../repositories/prisma-inquiry.repository.ts | 145 +++++++++++++ .../src/modules/inquiries/inquiries.module.ts | 28 +++ .../controllers/inquiries.controller.ts | 120 +++++++++++ .../presentation/dto/create-inquiry.dto.ts | 20 ++ .../presentation/dto/list-inquiries.dto.ts | 20 ++ .../__tests__/create-lead.handler.spec.ts | 117 +++++++++++ .../__tests__/delete-lead.handler.spec.ts | 104 ++++++++++ .../__tests__/get-lead-stats.handler.spec.ts | 69 +++++++ .../get-leads-by-agent.handler.spec.ts | 90 +++++++++ .../update-lead-status.handler.spec.ts | 104 ++++++++++ .../create-lead/create-lead.command.ts | 11 + .../create-lead/create-lead.handler.ts | 73 +++++++ .../delete-lead/delete-lead.command.ts | 6 + .../delete-lead/delete-lead.handler.ts | 40 ++++ .../update-lead-status.command.ts | 7 + .../update-lead-status.handler.ts | 48 +++++ .../get-lead-stats/get-lead-stats.handler.ts | 25 +++ .../get-lead-stats/get-lead-stats.query.ts | 5 + .../get-leads-by-agent.handler.ts | 31 +++ .../get-leads-by-agent.query.ts | 8 + .../domain/__tests__/lead-domain.spec.ts | 190 ++++++++++++++++++ .../leads/domain/entities/lead.entity.ts | 101 ++++++++++ .../leads/domain/events/lead-created.event.ts | 11 + .../events/lead-status-changed.event.ts | 13 ++ .../domain/repositories/lead-read.dto.ts | 13 ++ .../domain/repositories/lead.repository.ts | 28 +++ .../domain/value-objects/lead-score.vo.ts | 16 ++ apps/api/src/modules/leads/index.ts | 3 + .../repositories/prisma-lead.repository.ts | 151 ++++++++++++++ apps/api/src/modules/leads/leads.module.ts | 26 +++ .../controllers/leads.controller.ts | 126 ++++++++++++ .../leads/presentation/dto/create-lead.dto.ts | 35 ++++ .../leads/presentation/dto/list-leads.dto.ts | 30 +++ .../dto/update-lead-status.dto.ts | 14 ++ 69 files changed, 3420 insertions(+) create mode 100644 apps/api/src/modules/agents/agents.module.ts create mode 100644 apps/api/src/modules/agents/application/__tests__/get-agent-dashboard.handler.spec.ts create mode 100644 apps/api/src/modules/agents/application/__tests__/recalculate-quality-score.handler.spec.ts create mode 100644 apps/api/src/modules/agents/application/__tests__/review-events.listener.spec.ts create mode 100644 apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.command.ts create mode 100644 apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.handler.ts create mode 100644 apps/api/src/modules/agents/application/listeners/review-events.listener.ts create mode 100644 apps/api/src/modules/agents/application/queries/get-agent-dashboard/get-agent-dashboard.handler.ts create mode 100644 apps/api/src/modules/agents/application/queries/get-agent-dashboard/get-agent-dashboard.query.ts create mode 100644 apps/api/src/modules/agents/domain/__tests__/quality-score.spec.ts create mode 100644 apps/api/src/modules/agents/domain/repositories/agent.repository.ts create mode 100644 apps/api/src/modules/agents/domain/services/quality-score.service.ts create mode 100644 apps/api/src/modules/agents/index.ts create mode 100644 apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts create mode 100644 apps/api/src/modules/agents/presentation/controllers/agents.controller.ts create mode 100644 apps/api/src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts create mode 100644 apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-agent.handler.spec.ts create mode 100644 apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-listing.handler.spec.ts create mode 100644 apps/api/src/modules/inquiries/application/__tests__/mark-inquiry-read.handler.spec.ts create mode 100644 apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.command.ts create mode 100644 apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts create mode 100644 apps/api/src/modules/inquiries/application/commands/mark-inquiry-read/mark-inquiry-read.command.ts create mode 100644 apps/api/src/modules/inquiries/application/commands/mark-inquiry-read/mark-inquiry-read.handler.ts create mode 100644 apps/api/src/modules/inquiries/application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler.ts create mode 100644 apps/api/src/modules/inquiries/application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query.ts create mode 100644 apps/api/src/modules/inquiries/application/queries/get-inquiries-by-listing/get-inquiries-by-listing.handler.ts create mode 100644 apps/api/src/modules/inquiries/application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query.ts create mode 100644 apps/api/src/modules/inquiries/domain/__tests__/inquiry-domain.spec.ts create mode 100644 apps/api/src/modules/inquiries/domain/entities/inquiry.entity.ts create mode 100644 apps/api/src/modules/inquiries/domain/events/inquiry-created.event.ts create mode 100644 apps/api/src/modules/inquiries/domain/events/inquiry-read.event.ts create mode 100644 apps/api/src/modules/inquiries/domain/repositories/inquiry-read.dto.ts create mode 100644 apps/api/src/modules/inquiries/domain/repositories/inquiry.repository.ts create mode 100644 apps/api/src/modules/inquiries/index.ts create mode 100644 apps/api/src/modules/inquiries/infrastructure/repositories/prisma-inquiry.repository.ts create mode 100644 apps/api/src/modules/inquiries/inquiries.module.ts create mode 100644 apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts create mode 100644 apps/api/src/modules/inquiries/presentation/dto/create-inquiry.dto.ts create mode 100644 apps/api/src/modules/inquiries/presentation/dto/list-inquiries.dto.ts create mode 100644 apps/api/src/modules/leads/application/__tests__/create-lead.handler.spec.ts create mode 100644 apps/api/src/modules/leads/application/__tests__/delete-lead.handler.spec.ts create mode 100644 apps/api/src/modules/leads/application/__tests__/get-lead-stats.handler.spec.ts create mode 100644 apps/api/src/modules/leads/application/__tests__/get-leads-by-agent.handler.spec.ts create mode 100644 apps/api/src/modules/leads/application/__tests__/update-lead-status.handler.spec.ts create mode 100644 apps/api/src/modules/leads/application/commands/create-lead/create-lead.command.ts create mode 100644 apps/api/src/modules/leads/application/commands/create-lead/create-lead.handler.ts create mode 100644 apps/api/src/modules/leads/application/commands/delete-lead/delete-lead.command.ts create mode 100644 apps/api/src/modules/leads/application/commands/delete-lead/delete-lead.handler.ts create mode 100644 apps/api/src/modules/leads/application/commands/update-lead-status/update-lead-status.command.ts create mode 100644 apps/api/src/modules/leads/application/commands/update-lead-status/update-lead-status.handler.ts create mode 100644 apps/api/src/modules/leads/application/queries/get-lead-stats/get-lead-stats.handler.ts create mode 100644 apps/api/src/modules/leads/application/queries/get-lead-stats/get-lead-stats.query.ts create mode 100644 apps/api/src/modules/leads/application/queries/get-leads-by-agent/get-leads-by-agent.handler.ts create mode 100644 apps/api/src/modules/leads/application/queries/get-leads-by-agent/get-leads-by-agent.query.ts create mode 100644 apps/api/src/modules/leads/domain/__tests__/lead-domain.spec.ts create mode 100644 apps/api/src/modules/leads/domain/entities/lead.entity.ts create mode 100644 apps/api/src/modules/leads/domain/events/lead-created.event.ts create mode 100644 apps/api/src/modules/leads/domain/events/lead-status-changed.event.ts create mode 100644 apps/api/src/modules/leads/domain/repositories/lead-read.dto.ts create mode 100644 apps/api/src/modules/leads/domain/repositories/lead.repository.ts create mode 100644 apps/api/src/modules/leads/domain/value-objects/lead-score.vo.ts create mode 100644 apps/api/src/modules/leads/index.ts create mode 100644 apps/api/src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts create mode 100644 apps/api/src/modules/leads/leads.module.ts create mode 100644 apps/api/src/modules/leads/presentation/controllers/leads.controller.ts create mode 100644 apps/api/src/modules/leads/presentation/dto/create-lead.dto.ts create mode 100644 apps/api/src/modules/leads/presentation/dto/list-leads.dto.ts create mode 100644 apps/api/src/modules/leads/presentation/dto/update-lead-status.dto.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b22c8ec..f223921 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,9 +4,12 @@ import { CqrsModule } from '@nestjs/cqrs'; import { ThrottlerModule } from '@nestjs/throttler'; import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup'; import { AdminModule } from '@modules/admin'; +import { AgentsModule } from '@modules/agents'; import { AnalyticsModule } from '@modules/analytics'; import { AuthModule } from '@modules/auth'; import { HealthModule } from '@modules/health'; +import { InquiriesModule } from '@modules/inquiries'; +import { LeadsModule } from '@modules/leads'; import { ListingsModule } from '@modules/listings'; import { McpIntegrationModule } from '@modules/mcp'; import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics'; @@ -28,6 +31,9 @@ import { AppController } from './app.controller'; SharedModule, HealthModule, AuthModule, + AgentsModule, + InquiriesModule, + LeadsModule, ListingsModule, ReviewsModule, SearchModule, diff --git a/apps/api/src/modules/agents/agents.module.ts b/apps/api/src/modules/agents/agents.module.ts new file mode 100644 index 0000000..201073c --- /dev/null +++ b/apps/api/src/modules/agents/agents.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { RecalculateQualityScoreHandler } from './application/commands/recalculate-quality-score/recalculate-quality-score.handler'; +import { ReviewEventsListener } from './application/listeners/review-events.listener'; +import { GetAgentDashboardHandler } from './application/queries/get-agent-dashboard/get-agent-dashboard.handler'; +import { AGENT_REPOSITORY } from './domain/repositories/agent.repository'; +import { PrismaAgentRepository } from './infrastructure/repositories/prisma-agent.repository'; +import { AgentsController } from './presentation/controllers/agents.controller'; + +const CommandHandlers = [RecalculateQualityScoreHandler]; + +const QueryHandlers = [GetAgentDashboardHandler]; + +@Module({ + imports: [CqrsModule], + controllers: [AgentsController], + providers: [ + { provide: AGENT_REPOSITORY, useClass: PrismaAgentRepository }, + ...CommandHandlers, + ...QueryHandlers, + ReviewEventsListener, + ], + exports: [AGENT_REPOSITORY], +}) +export class AgentsModule {} diff --git a/apps/api/src/modules/agents/application/__tests__/get-agent-dashboard.handler.spec.ts b/apps/api/src/modules/agents/application/__tests__/get-agent-dashboard.handler.spec.ts new file mode 100644 index 0000000..da6f8ad --- /dev/null +++ b/apps/api/src/modules/agents/application/__tests__/get-agent-dashboard.handler.spec.ts @@ -0,0 +1,65 @@ +import type { IAgentRepository, AgentDashboardData } from '../../domain/repositories/agent.repository'; +import { GetAgentDashboardQuery } from '../queries/get-agent-dashboard/get-agent-dashboard.query'; +import { GetAgentDashboardHandler } from '../queries/get-agent-dashboard/get-agent-dashboard.handler'; + +describe('GetAgentDashboardHandler', () => { + let handler: GetAgentDashboardHandler; + let mockAgentRepo: { [K in keyof IAgentRepository]: ReturnType }; + + beforeEach(() => { + mockAgentRepo = { + findByUserId: vi.fn(), + findById: vi.fn(), + updateQualityScore: vi.fn(), + getDashboard: vi.fn(), + }; + + handler = new GetAgentDashboardHandler(mockAgentRepo as any); + }); + + it('returns dashboard data', async () => { + mockAgentRepo.findByUserId.mockResolvedValue({ + id: 'agent-1', + userId: 'user-1', + qualityScore: 85, + }); + + const mockDashboard: AgentDashboardData = { + agentId: 'agent-1', + qualityScore: 85, + totalDeals: 12, + responseTimeAvg: 600, + isVerified: true, + totalLeads: 30, + leadsByStatus: { NEW: 5, CONTACTED: 10, QUALIFIED: 5, NEGOTIATING: 3, CONVERTED: 5, LOST: 2 }, + conversionRate: 0.167, + totalInquiries: 45, + unreadInquiries: 3, + totalListings: 15, + activeListings: 10, + avgReviewRating: 4.5, + totalReviews: 20, + }; + mockAgentRepo.getDashboard.mockResolvedValue(mockDashboard); + + const query = new GetAgentDashboardQuery('user-1'); + const result = await handler.execute(query); + + expect(result).toEqual(mockDashboard); + expect(result.agentId).toBe('agent-1'); + expect(result.qualityScore).toBe(85); + expect(mockAgentRepo.findByUserId).toHaveBeenCalledWith('user-1'); + expect(mockAgentRepo.getDashboard).toHaveBeenCalledWith('agent-1'); + }); + + it('throws NotFoundException when agent not found', async () => { + mockAgentRepo.findByUserId.mockResolvedValue(null); + + const query = new GetAgentDashboardQuery('not-an-agent'); + + await expect(handler.execute(query)).rejects.toThrow( + 'Không tìm thấy thông tin môi giới', + ); + expect(mockAgentRepo.getDashboard).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/agents/application/__tests__/recalculate-quality-score.handler.spec.ts b/apps/api/src/modules/agents/application/__tests__/recalculate-quality-score.handler.spec.ts new file mode 100644 index 0000000..ed5ddb3 --- /dev/null +++ b/apps/api/src/modules/agents/application/__tests__/recalculate-quality-score.handler.spec.ts @@ -0,0 +1,115 @@ +import type { IAgentRepository } from '../../domain/repositories/agent.repository'; +import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command'; +import { RecalculateQualityScoreHandler } from '../commands/recalculate-quality-score/recalculate-quality-score.handler'; + +describe('RecalculateQualityScoreHandler', () => { + let handler: RecalculateQualityScoreHandler; + let mockAgentRepo: { [K in keyof IAgentRepository]: ReturnType }; + let mockPrisma: { + review: { aggregate: ReturnType }; + lead: { count: ReturnType }; + listing: { count: ReturnType }; + agent: { findUnique: ReturnType }; + }; + + beforeEach(() => { + mockAgentRepo = { + findByUserId: vi.fn(), + findById: vi.fn(), + updateQualityScore: vi.fn(), + getDashboard: vi.fn(), + }; + + mockPrisma = { + review: { aggregate: vi.fn() }, + lead: { count: vi.fn() }, + listing: { count: vi.fn() }, + agent: { findUnique: vi.fn() }, + }; + + handler = new RecalculateQualityScoreHandler( + mockAgentRepo as any, + mockPrisma as any, + ); + }); + + it('recalculates quality score successfully', async () => { + mockAgentRepo.findById.mockResolvedValue({ id: 'agent-1', userId: 'user-1', qualityScore: 50 }); + mockPrisma.review.aggregate.mockResolvedValue({ + _avg: { rating: 4.5 }, + _count: { rating: 10 }, + }); + mockPrisma.lead.count + .mockResolvedValueOnce(20) // totalLeads + .mockResolvedValueOnce(5); // convertedLeads + mockPrisma.listing.count + .mockResolvedValueOnce(10) // totalListings + .mockResolvedValueOnce(7); // activeListings + mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: 900 }); // 15 min + + const command = new RecalculateQualityScoreCommand('agent-1'); + + await handler.execute(command); + + expect(mockAgentRepo.updateQualityScore).toHaveBeenCalledTimes(1); + expect(mockAgentRepo.updateQualityScore).toHaveBeenCalledWith( + 'agent-1', + expect.any(Number), + ); + + // Verify the score value is reasonable + const actualScore = mockAgentRepo.updateQualityScore.mock.calls[0][1]; + expect(actualScore).toBeGreaterThan(0); + expect(actualScore).toBeLessThanOrEqual(100); + }); + + it('skips recalculation when agent not found', async () => { + mockAgentRepo.findById.mockResolvedValue(null); + + const command = new RecalculateQualityScoreCommand('non-existent'); + + await handler.execute(command); + + expect(mockAgentRepo.updateQualityScore).not.toHaveBeenCalled(); + expect(mockPrisma.review.aggregate).not.toHaveBeenCalled(); + }); + + it('updates agent record with calculated score', async () => { + mockAgentRepo.findById.mockResolvedValue({ id: 'agent-1', userId: 'user-1', qualityScore: 0 }); + mockPrisma.review.aggregate.mockResolvedValue({ + _avg: { rating: 5 }, + _count: { rating: 20 }, + }); + mockPrisma.lead.count + .mockResolvedValueOnce(10) + .mockResolvedValueOnce(10); // 100% conversion + mockPrisma.listing.count + .mockResolvedValueOnce(5) + .mockResolvedValueOnce(5); // 100% active + mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: 0 }); // instant response + + const command = new RecalculateQualityScoreCommand('agent-1'); + + await handler.execute(command); + + expect(mockAgentRepo.updateQualityScore).toHaveBeenCalledWith('agent-1', 100); + }); + + it('handles null response time avg from agent record', async () => { + mockAgentRepo.findById.mockResolvedValue({ id: 'agent-1', userId: 'user-1', qualityScore: 0 }); + mockPrisma.review.aggregate.mockResolvedValue({ + _avg: { rating: null }, + _count: { rating: 0 }, + }); + mockPrisma.lead.count.mockResolvedValue(0); + mockPrisma.listing.count.mockResolvedValue(0); + mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: null }); + + const command = new RecalculateQualityScoreCommand('agent-1'); + + await handler.execute(command); + + // no reviews (50*0.4=20), null response (50*0.3=15), 0 conversion, 0 listing + expect(mockAgentRepo.updateQualityScore).toHaveBeenCalledWith('agent-1', 35); + }); +}); diff --git a/apps/api/src/modules/agents/application/__tests__/review-events.listener.spec.ts b/apps/api/src/modules/agents/application/__tests__/review-events.listener.spec.ts new file mode 100644 index 0000000..377a039 --- /dev/null +++ b/apps/api/src/modules/agents/application/__tests__/review-events.listener.spec.ts @@ -0,0 +1,75 @@ +import type { CommandBus } from '@nestjs/cqrs'; +import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command'; +import { ReviewEventsListener } from '../listeners/review-events.listener'; + +describe('ReviewEventsListener', () => { + let listener: ReviewEventsListener; + let mockCommandBus: { execute: ReturnType }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn() }; + listener = new ReviewEventsListener(mockCommandBus as unknown as CommandBus); + }); + + describe('onReviewCreated', () => { + it('dispatches recalculate command when targetType is AGENT', async () => { + mockCommandBus.execute.mockResolvedValue(undefined); + + await listener.onReviewCreated({ + targetType: 'AGENT', + targetId: 'agent-1', + }); + + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + expect(mockCommandBus.execute).toHaveBeenCalledWith( + expect.any(RecalculateQualityScoreCommand), + ); + + const command = mockCommandBus.execute.mock.calls[0][0] as RecalculateQualityScoreCommand; + expect(command.agentId).toBe('agent-1'); + }); + + it('ignores events when targetType is not AGENT', async () => { + await listener.onReviewCreated({ + targetType: 'LISTING', + targetId: 'listing-1', + }); + + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); + + it('ignores events when targetType is USER', async () => { + await listener.onReviewCreated({ + targetType: 'USER', + targetId: 'user-1', + }); + + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); + }); + + describe('onReviewDeleted', () => { + it('dispatches recalculate command when targetType is AGENT', async () => { + mockCommandBus.execute.mockResolvedValue(undefined); + + await listener.onReviewDeleted({ + targetType: 'AGENT', + targetId: 'agent-2', + }); + + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + + const command = mockCommandBus.execute.mock.calls[0][0] as RecalculateQualityScoreCommand; + expect(command.agentId).toBe('agent-2'); + }); + + it('ignores events when targetType is not AGENT', async () => { + await listener.onReviewDeleted({ + targetType: 'PROPERTY', + targetId: 'prop-1', + }); + + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.command.ts b/apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.command.ts new file mode 100644 index 0000000..853dada --- /dev/null +++ b/apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.command.ts @@ -0,0 +1,3 @@ +export class RecalculateQualityScoreCommand { + constructor(public readonly agentId: string) {} +} diff --git a/apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.handler.ts b/apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.handler.ts new file mode 100644 index 0000000..a4cd922 --- /dev/null +++ b/apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.handler.ts @@ -0,0 +1,84 @@ +import { Inject, Logger } from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { type PrismaService } from '@modules/shared'; +import { + AGENT_REPOSITORY, + type IAgentRepository, +} from '../../../domain/repositories/agent.repository'; +import { QualityScoreCalculator } from '../../../domain/services/quality-score.service'; +import { RecalculateQualityScoreCommand } from './recalculate-quality-score.command'; + +@CommandHandler(RecalculateQualityScoreCommand) +export class RecalculateQualityScoreHandler + implements ICommandHandler +{ + private readonly logger = new Logger(RecalculateQualityScoreHandler.name); + + constructor( + @Inject(AGENT_REPOSITORY) + private readonly agentRepo: IAgentRepository, + private readonly prisma: PrismaService, + ) {} + + async execute(command: RecalculateQualityScoreCommand): Promise { + const agent = await this.agentRepo.findById(command.agentId); + if (!agent) { + this.logger.warn(`Agent ${command.agentId} not found, skipping recalculation`); + return; + } + + // Fetch review stats for this agent + const reviewStats = await this.prisma.review.aggregate({ + where: { targetType: 'AGENT', targetId: command.agentId }, + _avg: { rating: true }, + _count: { rating: true }, + }); + + const avgRating = reviewStats._avg.rating ?? 0; + const totalReviews = reviewStats._count.rating; + + // Fetch lead conversion rate + const [totalLeads, convertedLeads] = await Promise.all([ + this.prisma.lead.count({ where: { agentId: command.agentId } }), + this.prisma.lead.count({ + where: { agentId: command.agentId, status: 'CONVERTED' }, + }), + ]); + const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0; + + // Fetch listing activity ratio + const [totalListings, activeListings] = await Promise.all([ + this.prisma.listing.count({ + where: { agentId: command.agentId }, + }), + this.prisma.listing.count({ + where: { agentId: command.agentId, status: 'ACTIVE' }, + }), + ]); + const activeListingRatio = + totalListings > 0 ? activeListings / totalListings : 0; + + // Fetch response time from agent record + const agentRecord = await this.prisma.agent.findUnique({ + where: { id: command.agentId }, + select: { responseTimeAvg: true }, + }); + + const score = QualityScoreCalculator.calculate({ + avgRating, + totalReviews, + responseTimeAvg: agentRecord?.responseTimeAvg ?? null, + conversionRate, + activeListingRatio, + }); + + await this.agentRepo.updateQualityScore(command.agentId, score); + + this.logger.log( + `Quality score recalculated for agent ${command.agentId}: ${score} ` + + `(rating=${avgRating.toFixed(2)}, reviews=${totalReviews}, ` + + `conversion=${(conversionRate * 100).toFixed(1)}%, ` + + `activeListings=${activeListings}/${totalListings})`, + ); + } +} diff --git a/apps/api/src/modules/agents/application/listeners/review-events.listener.ts b/apps/api/src/modules/agents/application/listeners/review-events.listener.ts new file mode 100644 index 0000000..04c6821 --- /dev/null +++ b/apps/api/src/modules/agents/application/listeners/review-events.listener.ts @@ -0,0 +1,41 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { type CommandBus } from '@nestjs/cqrs'; +import { OnEvent } from '@nestjs/event-emitter'; +import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command'; + +@Injectable() +export class ReviewEventsListener { + private readonly logger = new Logger(ReviewEventsListener.name); + + constructor(private readonly commandBus: CommandBus) {} + + @OnEvent('review.created', { async: true }) + async onReviewCreated(event: { + targetType: string; + targetId: string; + }): Promise { + if (event.targetType === 'AGENT') { + this.logger.log( + `Recalculating quality score for agent ${event.targetId}`, + ); + await this.commandBus.execute( + new RecalculateQualityScoreCommand(event.targetId), + ); + } + } + + @OnEvent('review.deleted', { async: true }) + async onReviewDeleted(event: { + targetType: string; + targetId: string; + }): Promise { + if (event.targetType === 'AGENT') { + this.logger.log( + `Recalculating quality score for agent ${event.targetId} after review deletion`, + ); + await this.commandBus.execute( + new RecalculateQualityScoreCommand(event.targetId), + ); + } + } +} diff --git a/apps/api/src/modules/agents/application/queries/get-agent-dashboard/get-agent-dashboard.handler.ts b/apps/api/src/modules/agents/application/queries/get-agent-dashboard/get-agent-dashboard.handler.ts new file mode 100644 index 0000000..8c6a54c --- /dev/null +++ b/apps/api/src/modules/agents/application/queries/get-agent-dashboard/get-agent-dashboard.handler.ts @@ -0,0 +1,28 @@ +import { Inject } from '@nestjs/common'; +import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { NotFoundException } from '@modules/shared'; +import { + AGENT_REPOSITORY, + type AgentDashboardData, + type IAgentRepository, +} from '../../../domain/repositories/agent.repository'; +import { GetAgentDashboardQuery } from './get-agent-dashboard.query'; + +@QueryHandler(GetAgentDashboardQuery) +export class GetAgentDashboardHandler + implements IQueryHandler +{ + constructor( + @Inject(AGENT_REPOSITORY) + private readonly agentRepo: IAgentRepository, + ) {} + + async execute(query: GetAgentDashboardQuery): Promise { + const agent = await this.agentRepo.findByUserId(query.userId); + if (!agent) { + throw new NotFoundException('Không tìm thấy thông tin môi giới'); + } + + return this.agentRepo.getDashboard(agent.id); + } +} diff --git a/apps/api/src/modules/agents/application/queries/get-agent-dashboard/get-agent-dashboard.query.ts b/apps/api/src/modules/agents/application/queries/get-agent-dashboard/get-agent-dashboard.query.ts new file mode 100644 index 0000000..e9ee005 --- /dev/null +++ b/apps/api/src/modules/agents/application/queries/get-agent-dashboard/get-agent-dashboard.query.ts @@ -0,0 +1,3 @@ +export class GetAgentDashboardQuery { + constructor(public readonly userId: string) {} +} diff --git a/apps/api/src/modules/agents/domain/__tests__/quality-score.spec.ts b/apps/api/src/modules/agents/domain/__tests__/quality-score.spec.ts new file mode 100644 index 0000000..782d40f --- /dev/null +++ b/apps/api/src/modules/agents/domain/__tests__/quality-score.spec.ts @@ -0,0 +1,122 @@ +import { QualityScoreCalculator } from '../services/quality-score.service'; + +describe('QualityScoreCalculator', () => { + it('calculates score with all inputs at maximum', () => { + const score = QualityScoreCalculator.calculate({ + avgRating: 5, + totalReviews: 10, + responseTimeAvg: 0, + conversionRate: 1, + activeListingRatio: 1, + }); + + // rating: (5/5)*100*0.4 = 40, response: 100*0.3 = 30, conversion: 100*0.2 = 20, listing: 100*0.1 = 10 + expect(score).toBe(100); + }); + + it('calculates score with mixed inputs', () => { + const score = QualityScoreCalculator.calculate({ + avgRating: 4, + totalReviews: 5, + responseTimeAvg: 1800, // 30 minutes + conversionRate: 0.3, + activeListingRatio: 0.5, + }); + + // rating: (4/5)*100*0.4 = 32 + // response: max(0, 100 - (1800/3600)*100)*0.3 = 50*0.3 = 15 + // conversion: 30*0.2 = 6 + // listing: 50*0.1 = 5 + // total = 58 + expect(score).toBe(58); + }); + + it('defaults rating score to 50 with no reviews', () => { + const score = QualityScoreCalculator.calculate({ + avgRating: 0, + totalReviews: 0, + responseTimeAvg: 0, + conversionRate: 0, + activeListingRatio: 0, + }); + + // rating: 50*0.4 = 20, response: 100*0.3 = 30, conversion: 0, listing: 0 + expect(score).toBe(50); + }); + + it('defaults response score to 50 with null response time', () => { + const score = QualityScoreCalculator.calculate({ + avgRating: 0, + totalReviews: 0, + responseTimeAvg: null, + conversionRate: 0, + activeListingRatio: 0, + }); + + // rating: 50*0.4 = 20, response: 50*0.3 = 15, conversion: 0, listing: 0 + expect(score).toBe(35); + }); + + it('clamps response score to 0 when response time >= 1 hour', () => { + const score = QualityScoreCalculator.calculate({ + avgRating: 5, + totalReviews: 1, + responseTimeAvg: 3600, // exactly 1 hour + conversionRate: 0, + activeListingRatio: 0, + }); + + // rating: 100*0.4 = 40, response: max(0, 100-100)*0.3 = 0, conversion: 0, listing: 0 + expect(score).toBe(40); + }); + + it('clamps response score to 0 when response time > 1 hour', () => { + const score = QualityScoreCalculator.calculate({ + avgRating: 5, + totalReviews: 1, + responseTimeAvg: 7200, // 2 hours + conversionRate: 0, + activeListingRatio: 0, + }); + + // rating: 100*0.4 = 40, response: max(0, 100-200)*0.3 = 0, conversion: 0, listing: 0 + expect(score).toBe(40); + }); + + it('returns score with 1 decimal precision', () => { + const score = QualityScoreCalculator.calculate({ + avgRating: 3.7, + totalReviews: 3, + responseTimeAvg: 600, // 10 min + conversionRate: 0.15, + activeListingRatio: 0.33, + }); + + // Verify it has at most 1 decimal place + const decimalPart = score.toString().split('.')[1]; + expect(!decimalPart || decimalPart.length <= 1).toBe(true); + }); + + it('score is bounded 0-100', () => { + // All zeros (except default for 0 reviews) + const minScore = QualityScoreCalculator.calculate({ + avgRating: 0, + totalReviews: 1, // force avgRating to be used as 0 + responseTimeAvg: 99999, + conversionRate: 0, + activeListingRatio: 0, + }); + + expect(minScore).toBeGreaterThanOrEqual(0); + + const maxScore = QualityScoreCalculator.calculate({ + avgRating: 5, + totalReviews: 100, + responseTimeAvg: 0, + conversionRate: 1, + activeListingRatio: 1, + }); + + expect(maxScore).toBeLessThanOrEqual(100); + }); +}); diff --git a/apps/api/src/modules/agents/domain/repositories/agent.repository.ts b/apps/api/src/modules/agents/domain/repositories/agent.repository.ts new file mode 100644 index 0000000..979a234 --- /dev/null +++ b/apps/api/src/modules/agents/domain/repositories/agent.repository.ts @@ -0,0 +1,25 @@ +export const AGENT_REPOSITORY = Symbol('AGENT_REPOSITORY'); + +export interface AgentDashboardData { + agentId: string; + qualityScore: number; + totalDeals: number; + responseTimeAvg: number | null; + isVerified: boolean; + totalLeads: number; + leadsByStatus: Record; + conversionRate: number; + totalInquiries: number; + unreadInquiries: number; + totalListings: number; + activeListings: number; + avgReviewRating: number; + totalReviews: number; +} + +export interface IAgentRepository { + findByUserId(userId: string): Promise<{ id: string; userId: string; qualityScore: number } | null>; + findById(agentId: string): Promise<{ id: string; userId: string; qualityScore: number } | null>; + updateQualityScore(agentId: string, score: number): Promise; + getDashboard(agentId: string): Promise; +} diff --git a/apps/api/src/modules/agents/domain/services/quality-score.service.ts b/apps/api/src/modules/agents/domain/services/quality-score.service.ts new file mode 100644 index 0000000..fabbf88 --- /dev/null +++ b/apps/api/src/modules/agents/domain/services/quality-score.service.ts @@ -0,0 +1,37 @@ +/** + * Pure domain service — calculates quality score from inputs. + * No infrastructure dependencies. + */ +export class QualityScoreCalculator { + /** + * Quality Score = weighted average of: + * - Review rating (40%) — avg rating normalized to 0-100 + * - Response time (30%) — inverse of avg response time, 0-100 + * - Lead conversion (20%) — conversion rate * 100 + * - Listing activity (10%) — active listings ratio * 100 + */ + static calculate(params: { + avgRating: number; // 0-5 + totalReviews: number; + responseTimeAvg: number | null; // seconds + conversionRate: number; // 0-1 + activeListingRatio: number; // 0-1 + }): number { + const ratingScore = + params.totalReviews > 0 ? (params.avgRating / 5) * 100 : 50; + const responseScore = + params.responseTimeAvg !== null + ? Math.max(0, 100 - (params.responseTimeAvg / 3600) * 100) // 1hr = 0 + : 50; + const conversionScore = params.conversionRate * 100; + const listingScore = params.activeListingRatio * 100; + + const score = + ratingScore * 0.4 + + responseScore * 0.3 + + conversionScore * 0.2 + + listingScore * 0.1; + + return Math.round(score * 10) / 10; // 1 decimal + } +} diff --git a/apps/api/src/modules/agents/index.ts b/apps/api/src/modules/agents/index.ts new file mode 100644 index 0000000..4a2aa1b --- /dev/null +++ b/apps/api/src/modules/agents/index.ts @@ -0,0 +1,6 @@ +export { AgentsModule } from './agents.module'; +export { + AGENT_REPOSITORY, + type IAgentRepository, + type AgentDashboardData, +} from './domain/repositories/agent.repository'; diff --git a/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts b/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts new file mode 100644 index 0000000..bb15cac --- /dev/null +++ b/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts @@ -0,0 +1,122 @@ +import { Injectable } from '@nestjs/common'; +import { type PrismaService } from '@modules/shared'; +import type { + AgentDashboardData, + IAgentRepository, +} from '../../domain/repositories/agent.repository'; + +@Injectable() +export class PrismaAgentRepository implements IAgentRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByUserId( + userId: string, + ): Promise<{ id: string; userId: string; qualityScore: number } | null> { + return this.prisma.agent.findUnique({ + where: { userId }, + select: { id: true, userId: true, qualityScore: true }, + }); + } + + async findById( + agentId: string, + ): Promise<{ id: string; userId: string; qualityScore: number } | null> { + return this.prisma.agent.findUnique({ + where: { id: agentId }, + select: { id: true, userId: true, qualityScore: true }, + }); + } + + async updateQualityScore(agentId: string, score: number): Promise { + await this.prisma.agent.update({ + where: { id: agentId }, + data: { qualityScore: score }, + }); + } + + async getDashboard(agentId: string): Promise { + const [agent, leads, inquiryStats, listingStats, reviewStats] = + await Promise.all([ + this.prisma.agent.findUniqueOrThrow({ + where: { id: agentId }, + select: { + id: true, + qualityScore: true, + totalDeals: true, + responseTimeAvg: true, + isVerified: true, + }, + }), + this.prisma.lead.groupBy({ + by: ['status'], + where: { agentId }, + _count: { id: true }, + }), + this.getInquiryStats(agentId), + this.getListingStats(agentId), + this.prisma.review.aggregate({ + where: { targetType: 'AGENT', targetId: agentId }, + _avg: { rating: true }, + _count: { rating: true }, + }), + ]); + + const leadsByStatus: Record = {}; + let totalLeads = 0; + let convertedLeads = 0; + + for (const group of leads) { + leadsByStatus[group.status] = group._count.id; + totalLeads += group._count.id; + if (group.status === 'CONVERTED') { + convertedLeads = group._count.id; + } + } + + const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0; + + return { + agentId: agent.id, + qualityScore: agent.qualityScore, + totalDeals: agent.totalDeals, + responseTimeAvg: agent.responseTimeAvg, + isVerified: agent.isVerified, + totalLeads, + leadsByStatus, + conversionRate: Math.round(conversionRate * 1000) / 1000, // 3 decimals + totalInquiries: inquiryStats.total, + unreadInquiries: inquiryStats.unread, + totalListings: listingStats.total, + activeListings: listingStats.active, + avgReviewRating: + Math.round((reviewStats._avg.rating ?? 0) * 10) / 10, + totalReviews: reviewStats._count.rating, + }; + } + + private async getInquiryStats( + agentId: string, + ): Promise<{ total: number; unread: number }> { + const [total, unread] = await Promise.all([ + this.prisma.inquiry.count({ + where: { listing: { agentId } }, + }), + this.prisma.inquiry.count({ + where: { listing: { agentId }, isRead: false }, + }), + ]); + return { total, unread }; + } + + private async getListingStats( + agentId: string, + ): Promise<{ total: number; active: number }> { + const [total, active] = await Promise.all([ + this.prisma.listing.count({ where: { agentId } }), + this.prisma.listing.count({ + where: { agentId, status: 'ACTIVE' }, + }), + ]); + return { total, active }; + } +} diff --git a/apps/api/src/modules/agents/presentation/controllers/agents.controller.ts b/apps/api/src/modules/agents/presentation/controllers/agents.controller.ts new file mode 100644 index 0000000..0d2eadd --- /dev/null +++ b/apps/api/src/modules/agents/presentation/controllers/agents.controller.ts @@ -0,0 +1,61 @@ +import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { + type JwtPayload, + CurrentUser, + JwtAuthGuard, + RolesGuard, + Roles, +} from '@modules/auth'; +import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command'; +import { GetAgentDashboardQuery } from '../../application/queries/get-agent-dashboard/get-agent-dashboard.query'; +import type { AgentDashboardData } from '../../domain/repositories/agent.repository'; + +@ApiTags('agents') +@Controller('agents') +export class AgentsController { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Get agent dashboard stats' }) + @ApiResponse({ status: 200, description: 'Agent dashboard data' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden — only agents' }) + @ApiResponse({ status: 404, description: 'Agent profile not found' }) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('AGENT') + @Get('me/dashboard') + async getDashboard( + @CurrentUser() user: JwtPayload, + ): Promise { + return this.queryBus.execute(new GetAgentDashboardQuery(user.sub)); + } + + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Recalculate quality score (admin/system)' }) + @ApiParam({ name: 'agentId', description: 'Agent ID' }) + @ApiResponse({ status: 201, description: 'Quality score recalculated' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden — only admins' }) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN') + @Post(':agentId/recalculate-score') + async recalculateScore( + @Param('agentId') agentId: string, + ): Promise<{ message: string }> { + await this.commandBus.execute( + new RecalculateQualityScoreCommand(agentId), + ); + return { message: 'Quality score recalculated' }; + } +} diff --git a/apps/api/src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts b/apps/api/src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts new file mode 100644 index 0000000..fc34169 --- /dev/null +++ b/apps/api/src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts @@ -0,0 +1,95 @@ +import type { EventBus } from '@nestjs/cqrs'; +import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository'; +import { CreateInquiryCommand } from '../commands/create-inquiry/create-inquiry.command'; +import { CreateInquiryHandler } from '../commands/create-inquiry/create-inquiry.handler'; + +describe('CreateInquiryHandler', () => { + let handler: CreateInquiryHandler; + let mockInquiryRepo: { [K in keyof IInquiryRepository]: ReturnType }; + let mockEventBus: { publish: ReturnType }; + let mockPrisma: { + listing: { findUnique: ReturnType }; + }; + + beforeEach(() => { + mockInquiryRepo = { + findById: vi.fn(), + save: vi.fn(), + markAsRead: vi.fn(), + findByListing: vi.fn(), + findByAgent: vi.fn(), + countUnreadByAgent: vi.fn(), + }; + + mockEventBus = { publish: vi.fn() }; + + mockPrisma = { + listing: { findUnique: vi.fn() }, + }; + + handler = new CreateInquiryHandler( + mockInquiryRepo as any, + mockEventBus as unknown as EventBus, + mockPrisma as any, + ); + }); + + it('creates an inquiry successfully', async () => { + mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' }); + mockInquiryRepo.save.mockResolvedValue(undefined); + + const command = new CreateInquiryCommand( + 'user-1', + 'listing-1', + 'Tôi muốn xem nhà', + '0901234567', + ); + + const result = await handler.execute(command); + + expect(result.id).toBeDefined(); + expect(result.listingId).toBe('listing-1'); + expect(result.createdAt).toBeDefined(); + expect(mockInquiryRepo.save).toHaveBeenCalledTimes(1); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('throws NotFoundException when listing not found', async () => { + mockPrisma.listing.findUnique.mockResolvedValue(null); + + const command = new CreateInquiryCommand( + 'user-1', + 'listing-not-exist', + 'Tôi muốn xem nhà', + null, + ); + + await expect(handler.execute(command)).rejects.toThrow( + "Listing with id 'listing-not-exist' not found", + ); + expect(mockInquiryRepo.save).not.toHaveBeenCalled(); + }); + + it('publishes domain events after saving', async () => { + mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' }); + mockInquiryRepo.save.mockResolvedValue(undefined); + + const command = new CreateInquiryCommand( + 'user-1', + 'listing-1', + 'Cho tôi hỏi giá', + null, + ); + + await handler.execute(command); + + expect(mockEventBus.publish).toHaveBeenCalledTimes(1); + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + eventName: 'inquiry.created', + listingId: 'listing-1', + userId: 'user-1', + }), + ); + }); +}); diff --git a/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-agent.handler.spec.ts b/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-agent.handler.spec.ts new file mode 100644 index 0000000..4cf90fc --- /dev/null +++ b/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-agent.handler.spec.ts @@ -0,0 +1,76 @@ +import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository'; +import { GetInquiriesByAgentQuery } from '../queries/get-inquiries-by-agent/get-inquiries-by-agent.query'; +import { GetInquiriesByAgentHandler } from '../queries/get-inquiries-by-agent/get-inquiries-by-agent.handler'; + +describe('GetInquiriesByAgentHandler', () => { + let handler: GetInquiriesByAgentHandler; + let mockInquiryRepo: { [K in keyof IInquiryRepository]: ReturnType }; + let mockPrisma: { + agent: { findUnique: ReturnType }; + }; + + beforeEach(() => { + mockInquiryRepo = { + findById: vi.fn(), + save: vi.fn(), + markAsRead: vi.fn(), + findByListing: vi.fn(), + findByAgent: vi.fn(), + countUnreadByAgent: vi.fn(), + }; + + mockPrisma = { + agent: { findUnique: vi.fn() }, + }; + + handler = new GetInquiriesByAgentHandler( + mockInquiryRepo as any, + mockPrisma as any, + ); + }); + + it('returns paginated results', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' }); + + const mockResult = { + data: [ + { + id: 'inq-1', + listingId: 'listing-1', + userId: 'user-1', + message: 'Tôi muốn xem nhà', + phone: '0901234567', + isRead: false, + createdAt: new Date(), + }, + ], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + mockInquiryRepo.findByAgent.mockResolvedValue(mockResult); + + const query = new GetInquiriesByAgentQuery('agent-user-1', 1, 20); + const result = await handler.execute(query); + + expect(result).toEqual(mockResult); + expect(result.data).toHaveLength(1); + expect(mockPrisma.agent.findUnique).toHaveBeenCalledWith({ + where: { userId: 'agent-user-1' }, + select: { id: true }, + }); + expect(mockInquiryRepo.findByAgent).toHaveBeenCalledWith('agent-1', 1, 20); + }); + + it('throws NotFoundException when agent not found for user', async () => { + mockPrisma.agent.findUnique.mockResolvedValue(null); + + const query = new GetInquiriesByAgentQuery('not-an-agent', 1, 20); + + await expect(handler.execute(query)).rejects.toThrow( + "Agent with id 'not-an-agent' not found", + ); + expect(mockInquiryRepo.findByAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-listing.handler.spec.ts b/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-listing.handler.spec.ts new file mode 100644 index 0000000..1f24d8b --- /dev/null +++ b/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-listing.handler.spec.ts @@ -0,0 +1,68 @@ +import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository'; +import { GetInquiriesByListingQuery } from '../queries/get-inquiries-by-listing/get-inquiries-by-listing.query'; +import { GetInquiriesByListingHandler } from '../queries/get-inquiries-by-listing/get-inquiries-by-listing.handler'; + +describe('GetInquiriesByListingHandler', () => { + let handler: GetInquiriesByListingHandler; + let mockInquiryRepo: { [K in keyof IInquiryRepository]: ReturnType }; + + beforeEach(() => { + mockInquiryRepo = { + findById: vi.fn(), + save: vi.fn(), + markAsRead: vi.fn(), + findByListing: vi.fn(), + findByAgent: vi.fn(), + countUnreadByAgent: vi.fn(), + }; + + handler = new GetInquiriesByListingHandler(mockInquiryRepo as any); + }); + + it('returns paginated results', async () => { + const mockResult = { + data: [ + { + id: 'inq-1', + listingId: 'listing-1', + userId: 'user-1', + message: 'Tôi muốn xem nhà', + phone: '0901234567', + isRead: false, + createdAt: new Date(), + }, + ], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + mockInquiryRepo.findByListing.mockResolvedValue(mockResult); + + const query = new GetInquiriesByListingQuery('listing-1', 1, 20); + const result = await handler.execute(query); + + expect(result).toEqual(mockResult); + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(mockInquiryRepo.findByListing).toHaveBeenCalledWith('listing-1', 1, 20); + }); + + it('returns empty data when no inquiries found', async () => { + const mockResult = { + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }; + mockInquiryRepo.findByListing.mockResolvedValue(mockResult); + + const query = new GetInquiriesByListingQuery('listing-empty', 1, 20); + const result = await handler.execute(query); + + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + }); +}); diff --git a/apps/api/src/modules/inquiries/application/__tests__/mark-inquiry-read.handler.spec.ts b/apps/api/src/modules/inquiries/application/__tests__/mark-inquiry-read.handler.spec.ts new file mode 100644 index 0000000..12e5028 --- /dev/null +++ b/apps/api/src/modules/inquiries/application/__tests__/mark-inquiry-read.handler.spec.ts @@ -0,0 +1,126 @@ +import type { EventBus } from '@nestjs/cqrs'; +import { InquiryEntity } from '../../domain/entities/inquiry.entity'; +import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository'; +import { MarkInquiryReadCommand } from '../commands/mark-inquiry-read/mark-inquiry-read.command'; +import { MarkInquiryReadHandler } from '../commands/mark-inquiry-read/mark-inquiry-read.handler'; + +describe('MarkInquiryReadHandler', () => { + let handler: MarkInquiryReadHandler; + let mockInquiryRepo: { [K in keyof IInquiryRepository]: ReturnType }; + let mockEventBus: { publish: ReturnType }; + let mockPrisma: { + listing: { findUnique: ReturnType }; + agent: { findUnique: ReturnType }; + }; + + beforeEach(() => { + mockInquiryRepo = { + findById: vi.fn(), + save: vi.fn(), + markAsRead: vi.fn(), + findByListing: vi.fn(), + findByAgent: vi.fn(), + countUnreadByAgent: vi.fn(), + }; + + mockEventBus = { publish: vi.fn() }; + + mockPrisma = { + listing: { findUnique: vi.fn() }, + agent: { findUnique: vi.fn() }, + }; + + handler = new MarkInquiryReadHandler( + mockInquiryRepo as any, + mockEventBus as unknown as EventBus, + mockPrisma as any, + ); + }); + + it('marks an inquiry as read successfully', async () => { + const inquiry = new InquiryEntity('inq-1', { + listingId: 'listing-1', + userId: 'user-1', + message: 'Tôi muốn xem nhà', + phone: null, + isRead: false, + }); + mockInquiryRepo.findById.mockResolvedValue(inquiry); + mockPrisma.listing.findUnique.mockResolvedValue({ agentId: 'agent-1' }); + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' }); + mockInquiryRepo.markAsRead.mockResolvedValue(undefined); + + const command = new MarkInquiryReadCommand('inq-1', 'agent-user-1'); + + await handler.execute(command); + + expect(mockInquiryRepo.markAsRead).toHaveBeenCalledWith('inq-1'); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('throws NotFoundException when inquiry not found', async () => { + mockInquiryRepo.findById.mockResolvedValue(null); + + const command = new MarkInquiryReadCommand('inq-not-exist', 'agent-user-1'); + + await expect(handler.execute(command)).rejects.toThrow( + "Inquiry with id 'inq-not-exist' not found", + ); + }); + + it('throws NotFoundException when listing not found', async () => { + const inquiry = new InquiryEntity('inq-1', { + listingId: 'listing-1', + userId: 'user-1', + message: 'Test', + phone: null, + isRead: false, + }); + mockInquiryRepo.findById.mockResolvedValue(inquiry); + mockPrisma.listing.findUnique.mockResolvedValue(null); + + const command = new MarkInquiryReadCommand('inq-1', 'agent-user-1'); + + await expect(handler.execute(command)).rejects.toThrow( + "Listing with id 'listing-1' not found", + ); + }); + + it('throws ForbiddenException when user is not the listing agent', async () => { + const inquiry = new InquiryEntity('inq-1', { + listingId: 'listing-1', + userId: 'user-1', + message: 'Test', + phone: null, + isRead: false, + }); + mockInquiryRepo.findById.mockResolvedValue(inquiry); + mockPrisma.listing.findUnique.mockResolvedValue({ agentId: 'agent-1' }); + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-other' }); + + const command = new MarkInquiryReadCommand('inq-1', 'agent-user-other'); + + await expect(handler.execute(command)).rejects.toThrow( + 'Bạn không có quyền đánh dấu yêu cầu tư vấn này', + ); + }); + + it('throws ForbiddenException when agent not found for user', async () => { + const inquiry = new InquiryEntity('inq-1', { + listingId: 'listing-1', + userId: 'user-1', + message: 'Test', + phone: null, + isRead: false, + }); + mockInquiryRepo.findById.mockResolvedValue(inquiry); + mockPrisma.listing.findUnique.mockResolvedValue({ agentId: 'agent-1' }); + mockPrisma.agent.findUnique.mockResolvedValue(null); + + const command = new MarkInquiryReadCommand('inq-1', 'not-an-agent'); + + await expect(handler.execute(command)).rejects.toThrow( + 'Bạn không có quyền đánh dấu yêu cầu tư vấn này', + ); + }); +}); diff --git a/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.command.ts b/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.command.ts new file mode 100644 index 0000000..81a0f20 --- /dev/null +++ b/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.command.ts @@ -0,0 +1,8 @@ +export class CreateInquiryCommand { + constructor( + public readonly userId: string, + public readonly listingId: string, + public readonly message: string, + public readonly phone: string | null, + ) {} +} diff --git a/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts b/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts new file mode 100644 index 0000000..cd0abaa --- /dev/null +++ b/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts @@ -0,0 +1,61 @@ +import { Inject, Logger } from '@nestjs/common'; +import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { createId } from '@paralleldrive/cuid2'; +import { NotFoundException } from '@modules/shared'; +import { type PrismaService } from '@modules/shared'; +import { InquiryEntity } from '../../../domain/entities/inquiry.entity'; +import { INQUIRY_REPOSITORY, type IInquiryRepository } from '../../../domain/repositories/inquiry.repository'; +import { CreateInquiryCommand } from './create-inquiry.command'; + +export interface CreateInquiryResult { + id: string; + listingId: string; + createdAt: string; +} + +@CommandHandler(CreateInquiryCommand) +export class CreateInquiryHandler implements ICommandHandler { + private readonly logger = new Logger(CreateInquiryHandler.name); + + constructor( + @Inject(INQUIRY_REPOSITORY) private readonly inquiryRepo: IInquiryRepository, + private readonly eventBus: EventBus, + private readonly prisma: PrismaService, + ) {} + + async execute(command: CreateInquiryCommand): Promise { + // Validate listing exists + const listing = await this.prisma.listing.findUnique({ + where: { id: command.listingId }, + select: { id: true }, + }); + if (!listing) { + throw new NotFoundException('Listing', command.listingId); + } + + const id = createId(); + const inquiry = InquiryEntity.createNew( + id, + command.listingId, + command.userId, + command.message, + command.phone, + ); + + await this.inquiryRepo.save(inquiry); + + // Publish domain events + const events = inquiry.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log(`Inquiry ${id} created by user ${command.userId} for listing ${command.listingId}`); + + return { + id, + listingId: command.listingId, + createdAt: inquiry.createdAt.toISOString(), + }; + } +} diff --git a/apps/api/src/modules/inquiries/application/commands/mark-inquiry-read/mark-inquiry-read.command.ts b/apps/api/src/modules/inquiries/application/commands/mark-inquiry-read/mark-inquiry-read.command.ts new file mode 100644 index 0000000..3108412 --- /dev/null +++ b/apps/api/src/modules/inquiries/application/commands/mark-inquiry-read/mark-inquiry-read.command.ts @@ -0,0 +1,6 @@ +export class MarkInquiryReadCommand { + constructor( + public readonly inquiryId: string, + public readonly agentUserId: string, + ) {} +} diff --git a/apps/api/src/modules/inquiries/application/commands/mark-inquiry-read/mark-inquiry-read.handler.ts b/apps/api/src/modules/inquiries/application/commands/mark-inquiry-read/mark-inquiry-read.handler.ts new file mode 100644 index 0000000..1457cb1 --- /dev/null +++ b/apps/api/src/modules/inquiries/application/commands/mark-inquiry-read/mark-inquiry-read.handler.ts @@ -0,0 +1,52 @@ +import { Inject, Logger } from '@nestjs/common'; +import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { ForbiddenException, NotFoundException } from '@modules/shared'; +import { type PrismaService } from '@modules/shared'; +import { INQUIRY_REPOSITORY, type IInquiryRepository } from '../../../domain/repositories/inquiry.repository'; +import { MarkInquiryReadCommand } from './mark-inquiry-read.command'; + +@CommandHandler(MarkInquiryReadCommand) +export class MarkInquiryReadHandler implements ICommandHandler { + private readonly logger = new Logger(MarkInquiryReadHandler.name); + + constructor( + @Inject(INQUIRY_REPOSITORY) private readonly inquiryRepo: IInquiryRepository, + private readonly eventBus: EventBus, + private readonly prisma: PrismaService, + ) {} + + async execute(command: MarkInquiryReadCommand): Promise { + const inquiry = await this.inquiryRepo.findById(command.inquiryId); + if (!inquiry) { + throw new NotFoundException('Inquiry', command.inquiryId); + } + + // Verify the requesting user is the listing's agent + const listing = await this.prisma.listing.findUnique({ + where: { id: inquiry.listingId }, + select: { agentId: true }, + }); + if (!listing) { + throw new NotFoundException('Listing', inquiry.listingId); + } + + const agent = await this.prisma.agent.findUnique({ + where: { userId: command.agentUserId }, + select: { id: true }, + }); + if (!agent || listing.agentId !== agent.id) { + throw new ForbiddenException('Bạn không có quyền đánh dấu yêu cầu tư vấn này'); + } + + inquiry.markAsRead(); + await this.inquiryRepo.markAsRead(command.inquiryId); + + // Publish domain events + const events = inquiry.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log(`Inquiry ${command.inquiryId} marked as read by agent ${command.agentUserId}`); + } +} diff --git a/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler.ts b/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler.ts new file mode 100644 index 0000000..b2447df --- /dev/null +++ b/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler.ts @@ -0,0 +1,31 @@ +import { Inject } from '@nestjs/common'; +import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { NotFoundException } from '@modules/shared'; +import { type PrismaService } from '@modules/shared'; +import { type InquiryReadDto } from '../../../domain/repositories/inquiry-read.dto'; +import { INQUIRY_REPOSITORY, type IInquiryRepository, type PaginatedResult } from '../../../domain/repositories/inquiry.repository'; +import { GetInquiriesByAgentQuery } from './get-inquiries-by-agent.query'; + +@QueryHandler(GetInquiriesByAgentQuery) +export class GetInquiriesByAgentHandler implements IQueryHandler { + constructor( + @Inject(INQUIRY_REPOSITORY) private readonly inquiryRepo: IInquiryRepository, + private readonly prisma: PrismaService, + ) {} + + async execute(query: GetInquiriesByAgentQuery): Promise> { + const agent = await this.prisma.agent.findUnique({ + where: { userId: query.agentUserId }, + select: { id: true }, + }); + if (!agent) { + throw new NotFoundException('Agent', query.agentUserId); + } + + return this.inquiryRepo.findByAgent( + agent.id, + query.page, + query.limit, + ); + } +} diff --git a/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query.ts b/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query.ts new file mode 100644 index 0000000..eae63ea --- /dev/null +++ b/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query.ts @@ -0,0 +1,7 @@ +export class GetInquiriesByAgentQuery { + constructor( + public readonly agentUserId: string, + public readonly page: number, + public readonly limit: number, + ) {} +} diff --git a/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-listing/get-inquiries-by-listing.handler.ts b/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-listing/get-inquiries-by-listing.handler.ts new file mode 100644 index 0000000..4d610c1 --- /dev/null +++ b/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-listing/get-inquiries-by-listing.handler.ts @@ -0,0 +1,20 @@ +import { Inject } from '@nestjs/common'; +import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { type InquiryReadDto } from '../../../domain/repositories/inquiry-read.dto'; +import { INQUIRY_REPOSITORY, type IInquiryRepository, type PaginatedResult } from '../../../domain/repositories/inquiry.repository'; +import { GetInquiriesByListingQuery } from './get-inquiries-by-listing.query'; + +@QueryHandler(GetInquiriesByListingQuery) +export class GetInquiriesByListingHandler implements IQueryHandler { + constructor( + @Inject(INQUIRY_REPOSITORY) private readonly inquiryRepo: IInquiryRepository, + ) {} + + async execute(query: GetInquiriesByListingQuery): Promise> { + return this.inquiryRepo.findByListing( + query.listingId, + query.page, + query.limit, + ); + } +} diff --git a/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query.ts b/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query.ts new file mode 100644 index 0000000..462c13e --- /dev/null +++ b/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query.ts @@ -0,0 +1,7 @@ +export class GetInquiriesByListingQuery { + constructor( + public readonly listingId: string, + public readonly page: number, + public readonly limit: number, + ) {} +} diff --git a/apps/api/src/modules/inquiries/domain/__tests__/inquiry-domain.spec.ts b/apps/api/src/modules/inquiries/domain/__tests__/inquiry-domain.spec.ts new file mode 100644 index 0000000..8c29cba --- /dev/null +++ b/apps/api/src/modules/inquiries/domain/__tests__/inquiry-domain.spec.ts @@ -0,0 +1,95 @@ +import { InquiryCreatedEvent } from '../events/inquiry-created.event'; +import { InquiryReadEvent } from '../events/inquiry-read.event'; +import { InquiryEntity } from '../entities/inquiry.entity'; + +describe('InquiryEntity', () => { + describe('createNew', () => { + it('creates an inquiry with correct properties', () => { + const inquiry = InquiryEntity.createNew( + 'inq-1', + 'listing-1', + 'user-1', + 'Tôi muốn xem nhà', + '0901234567', + ); + + expect(inquiry.id).toBe('inq-1'); + expect(inquiry.listingId).toBe('listing-1'); + expect(inquiry.userId).toBe('user-1'); + expect(inquiry.message).toBe('Tôi muốn xem nhà'); + expect(inquiry.phone).toBe('0901234567'); + expect(inquiry.isRead).toBe(false); + }); + + it('creates an inquiry with null phone', () => { + const inquiry = InquiryEntity.createNew( + 'inq-2', + 'listing-1', + 'user-1', + 'Cho tôi hỏi giá', + null, + ); + + expect(inquiry.phone).toBeNull(); + }); + + it('emits InquiryCreatedEvent', () => { + const inquiry = InquiryEntity.createNew( + 'inq-1', + 'listing-1', + 'user-1', + 'Tôi muốn xem nhà', + '0901234567', + ); + + const events = inquiry.domainEvents; + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(InquiryCreatedEvent); + + const event = events[0] as InquiryCreatedEvent; + expect(event.aggregateId).toBe('inq-1'); + expect(event.listingId).toBe('listing-1'); + expect(event.userId).toBe('user-1'); + }); + }); + + describe('markAsRead', () => { + it('sets isRead to true', () => { + const inquiry = InquiryEntity.createNew( + 'inq-1', + 'listing-1', + 'user-1', + 'Tôi muốn xem nhà', + null, + ); + + expect(inquiry.isRead).toBe(false); + inquiry.markAsRead(); + expect(inquiry.isRead).toBe(true); + }); + + it('emits InquiryReadEvent', () => { + const inquiry = InquiryEntity.createNew( + 'inq-1', + 'listing-1', + 'user-1', + 'Tôi muốn xem nhà', + null, + ); + + // Clear the creation event + inquiry.clearDomainEvents(); + + inquiry.markAsRead(); + + const events = inquiry.domainEvents; + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(InquiryReadEvent); + + const event = events[0] as InquiryReadEvent; + expect(event.aggregateId).toBe('inq-1'); + expect(event.listingId).toBe('listing-1'); + expect(event.userId).toBe('user-1'); + }); + }); +}); diff --git a/apps/api/src/modules/inquiries/domain/entities/inquiry.entity.ts b/apps/api/src/modules/inquiries/domain/entities/inquiry.entity.ts new file mode 100644 index 0000000..ef6739a --- /dev/null +++ b/apps/api/src/modules/inquiries/domain/entities/inquiry.entity.ts @@ -0,0 +1,62 @@ +import { AggregateRoot } from '@modules/shared'; +import { InquiryCreatedEvent } from '../events/inquiry-created.event'; +import { InquiryReadEvent } from '../events/inquiry-read.event'; + +export interface InquiryProps { + listingId: string; + userId: string; + message: string; + phone: string | null; + isRead: boolean; +} + +export class InquiryEntity extends AggregateRoot { + private _listingId: string; + private _userId: string; + private _message: string; + private _phone: string | null; + private _isRead: boolean; + + constructor(id: string, props: InquiryProps, createdAt?: Date) { + super(id, createdAt); + this._listingId = props.listingId; + this._userId = props.userId; + this._message = props.message; + this._phone = props.phone; + this._isRead = props.isRead; + } + + get listingId(): string { return this._listingId; } + get userId(): string { return this._userId; } + get message(): string { return this._message; } + get phone(): string | null { return this._phone; } + get isRead(): boolean { return this._isRead; } + + static createNew( + id: string, + listingId: string, + userId: string, + message: string, + phone: string | null, + ): InquiryEntity { + const inquiry = new InquiryEntity(id, { + listingId, + userId, + message, + phone, + isRead: false, + }); + + inquiry.addDomainEvent( + new InquiryCreatedEvent(id, listingId, userId), + ); + return inquiry; + } + + markAsRead(): void { + this._isRead = true; + this.addDomainEvent( + new InquiryReadEvent(this.id, this._listingId, this._userId), + ); + } +} diff --git a/apps/api/src/modules/inquiries/domain/events/inquiry-created.event.ts b/apps/api/src/modules/inquiries/domain/events/inquiry-created.event.ts new file mode 100644 index 0000000..f1d9198 --- /dev/null +++ b/apps/api/src/modules/inquiries/domain/events/inquiry-created.event.ts @@ -0,0 +1,12 @@ +import { type DomainEvent } from '@modules/shared'; + +export class InquiryCreatedEvent implements DomainEvent { + readonly eventName = 'inquiry.created'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly listingId: string, + public readonly userId: string, + ) {} +} diff --git a/apps/api/src/modules/inquiries/domain/events/inquiry-read.event.ts b/apps/api/src/modules/inquiries/domain/events/inquiry-read.event.ts new file mode 100644 index 0000000..e760b70 --- /dev/null +++ b/apps/api/src/modules/inquiries/domain/events/inquiry-read.event.ts @@ -0,0 +1,12 @@ +import { type DomainEvent } from '@modules/shared'; + +export class InquiryReadEvent implements DomainEvent { + readonly eventName = 'inquiry.read'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly listingId: string, + public readonly userId: string, + ) {} +} diff --git a/apps/api/src/modules/inquiries/domain/repositories/inquiry-read.dto.ts b/apps/api/src/modules/inquiries/domain/repositories/inquiry-read.dto.ts new file mode 100644 index 0000000..5582321 --- /dev/null +++ b/apps/api/src/modules/inquiries/domain/repositories/inquiry-read.dto.ts @@ -0,0 +1,12 @@ +export interface InquiryReadDto { + id: string; + listingId: string; + listingTitle: string; + userId: string; + userName: string; + userPhone: string; + message: string; + phone: string | null; + isRead: boolean; + createdAt: string; +} diff --git a/apps/api/src/modules/inquiries/domain/repositories/inquiry.repository.ts b/apps/api/src/modules/inquiries/domain/repositories/inquiry.repository.ts new file mode 100644 index 0000000..34468fd --- /dev/null +++ b/apps/api/src/modules/inquiries/domain/repositories/inquiry.repository.ts @@ -0,0 +1,21 @@ +import { type InquiryEntity } from '../entities/inquiry.entity'; +import { type InquiryReadDto } from './inquiry-read.dto'; + +export const INQUIRY_REPOSITORY = Symbol('INQUIRY_REPOSITORY'); + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface IInquiryRepository { + findById(id: string): Promise; + save(inquiry: InquiryEntity): Promise; + markAsRead(id: string): Promise; + findByListing(listingId: string, page: number, limit: number): Promise>; + findByAgent(agentId: string, page: number, limit: number): Promise>; + countUnreadByAgent(agentId: string): Promise; +} diff --git a/apps/api/src/modules/inquiries/index.ts b/apps/api/src/modules/inquiries/index.ts new file mode 100644 index 0000000..0d3cd17 --- /dev/null +++ b/apps/api/src/modules/inquiries/index.ts @@ -0,0 +1,3 @@ +export { InquiriesModule } from './inquiries.module'; +export { INQUIRY_REPOSITORY, type IInquiryRepository } from './domain/repositories/inquiry.repository'; +export { InquiryEntity } from './domain/entities/inquiry.entity'; diff --git a/apps/api/src/modules/inquiries/infrastructure/repositories/prisma-inquiry.repository.ts b/apps/api/src/modules/inquiries/infrastructure/repositories/prisma-inquiry.repository.ts new file mode 100644 index 0000000..1898b42 --- /dev/null +++ b/apps/api/src/modules/inquiries/infrastructure/repositories/prisma-inquiry.repository.ts @@ -0,0 +1,145 @@ +import { Injectable } from '@nestjs/common'; +import type { Inquiry as PrismaInquiry } from '@prisma/client'; +import { type PrismaService } from '@modules/shared'; +import { InquiryEntity } from '../../domain/entities/inquiry.entity'; +import type { InquiryReadDto } from '../../domain/repositories/inquiry-read.dto'; +import type { IInquiryRepository, PaginatedResult } from '../../domain/repositories/inquiry.repository'; + +@Injectable() +export class PrismaInquiryRepository implements IInquiryRepository { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + const inquiry = await this.prisma.inquiry.findUnique({ where: { id } }); + return inquiry ? this.toDomain(inquiry) : null; + } + + async save(entity: InquiryEntity): Promise { + await this.prisma.inquiry.create({ + data: { + id: entity.id, + listingId: entity.listingId, + userId: entity.userId, + message: entity.message, + phone: entity.phone, + isRead: entity.isRead, + }, + }); + } + + async markAsRead(id: string): Promise { + await this.prisma.inquiry.update({ + where: { id }, + data: { isRead: true }, + }); + } + + async findByListing( + listingId: string, + page: number, + limit: number, + ): Promise> { + const take = Math.min(limit, 100); + const skip = (page - 1) * take; + const where = { listingId }; + + const [data, total] = await Promise.all([ + this.prisma.inquiry.findMany({ + where, + skip, + take, + orderBy: { createdAt: 'desc' }, + include: { + listing: { select: { id: true, property: { select: { title: true } } } }, + user: { select: { id: true, fullName: true, phone: true } }, + }, + }), + this.prisma.inquiry.count({ where }), + ]); + + return { + data: data.map((r) => ({ + id: r.id, + listingId: r.listingId, + listingTitle: r.listing.property.title, + userId: r.userId, + userName: r.user.fullName, + userPhone: r.user.phone, + message: r.message, + phone: r.phone, + isRead: r.isRead, + createdAt: r.createdAt.toISOString(), + })), + total, + page, + limit: take, + totalPages: Math.ceil(total / take), + }; + } + + async findByAgent( + agentId: string, + page: number, + limit: number, + ): Promise> { + const take = Math.min(limit, 100); + const skip = (page - 1) * take; + const where = { listing: { agentId } }; + + const [data, total] = await Promise.all([ + this.prisma.inquiry.findMany({ + where, + skip, + take, + orderBy: { createdAt: 'desc' }, + include: { + listing: { select: { id: true, property: { select: { title: true } } } }, + user: { select: { id: true, fullName: true, phone: true } }, + }, + }), + this.prisma.inquiry.count({ where }), + ]); + + return { + data: data.map((r) => ({ + id: r.id, + listingId: r.listingId, + listingTitle: r.listing.property.title, + userId: r.userId, + userName: r.user.fullName, + userPhone: r.user.phone, + message: r.message, + phone: r.phone, + isRead: r.isRead, + createdAt: r.createdAt.toISOString(), + })), + total, + page, + limit: take, + totalPages: Math.ceil(total / take), + }; + } + + async countUnreadByAgent(agentId: string): Promise { + return this.prisma.inquiry.count({ + where: { + isRead: false, + listing: { agentId }, + }, + }); + } + + private toDomain(raw: PrismaInquiry): InquiryEntity { + return new InquiryEntity( + raw.id, + { + listingId: raw.listingId, + userId: raw.userId, + message: raw.message, + phone: raw.phone, + isRead: raw.isRead, + }, + raw.createdAt, + ); + } +} diff --git a/apps/api/src/modules/inquiries/inquiries.module.ts b/apps/api/src/modules/inquiries/inquiries.module.ts new file mode 100644 index 0000000..b10bc76 --- /dev/null +++ b/apps/api/src/modules/inquiries/inquiries.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { CreateInquiryHandler } from './application/commands/create-inquiry/create-inquiry.handler'; +import { MarkInquiryReadHandler } from './application/commands/mark-inquiry-read/mark-inquiry-read.handler'; +import { GetInquiriesByAgentHandler } from './application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler'; +import { GetInquiriesByListingHandler } from './application/queries/get-inquiries-by-listing/get-inquiries-by-listing.handler'; +import { INQUIRY_REPOSITORY } from './domain/repositories/inquiry.repository'; +import { PrismaInquiryRepository } from './infrastructure/repositories/prisma-inquiry.repository'; +import { InquiriesController } from './presentation/controllers/inquiries.controller'; + +const CommandHandlers = [CreateInquiryHandler, MarkInquiryReadHandler]; + +const QueryHandlers = [ + GetInquiriesByListingHandler, + GetInquiriesByAgentHandler, +]; + +@Module({ + imports: [CqrsModule], + controllers: [InquiriesController], + providers: [ + { provide: INQUIRY_REPOSITORY, useClass: PrismaInquiryRepository }, + ...CommandHandlers, + ...QueryHandlers, + ], + exports: [INQUIRY_REPOSITORY], +}) +export class InquiriesModule {} diff --git a/apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts b/apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts new file mode 100644 index 0000000..2d1e1a9 --- /dev/null +++ b/apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts @@ -0,0 +1,120 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; +import { CreateInquiryCommand } from '../../application/commands/create-inquiry/create-inquiry.command'; +import { type CreateInquiryResult } from '../../application/commands/create-inquiry/create-inquiry.handler'; +import { MarkInquiryReadCommand } from '../../application/commands/mark-inquiry-read/mark-inquiry-read.command'; +import { GetInquiriesByAgentQuery } from '../../application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query'; +import { GetInquiriesByListingQuery } from '../../application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query'; +import type { InquiryReadDto } from '../../domain/repositories/inquiry-read.dto'; +import type { PaginatedResult } from '../../domain/repositories/inquiry.repository'; +import type { CreateInquiryDto } from '../dto/create-inquiry.dto'; +import type { ListInquiriesDto } from '../dto/list-inquiries.dto'; + +@ApiTags('inquiries') +@Controller('inquiries') +export class InquiriesController { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Create an inquiry for a listing' }) + @ApiResponse({ status: 201, description: 'Inquiry created successfully' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Listing not found' }) + @UseGuards(JwtAuthGuard) + @Post() + async createInquiry( + @Body() dto: CreateInquiryDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new CreateInquiryCommand( + user.sub, + dto.listingId, + dto.message, + dto.phone ?? null, + ), + ); + } + + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'List inquiries by listing' }) + @ApiParam({ name: 'listingId', description: 'Listing ID' }) + @ApiResponse({ status: 200, description: 'Paginated list of inquiries' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @UseGuards(JwtAuthGuard) + @Get('listing/:listingId') + async getByListing( + @Param('listingId') listingId: string, + @Query() dto: ListInquiriesDto, + ): Promise> { + return this.queryBus.execute( + new GetInquiriesByListingQuery( + listingId, + dto.page ?? 1, + dto.limit ?? 20, + ), + ); + } + + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'List inquiries for current agent' }) + @ApiResponse({ status: 200, description: 'Paginated list of inquiries for agent' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden — not an agent' }) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('AGENT') + @Get('agent/me') + async getMyInquiries( + @CurrentUser() user: JwtPayload, + @Query() dto: ListInquiriesDto, + ): Promise> { + return this.queryBus.execute( + new GetInquiriesByAgentQuery( + user.sub, + dto.page ?? 1, + dto.limit ?? 20, + ), + ); + } + + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Mark inquiry as read' }) + @ApiParam({ name: 'id', description: 'Inquiry ID' }) + @ApiResponse({ status: 200, description: 'Inquiry marked as read' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden — not the listing agent' }) + @ApiResponse({ status: 404, description: 'Inquiry not found' }) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('AGENT') + @Patch(':id/read') + async markAsRead( + @Param('id') id: string, + @CurrentUser() user: JwtPayload, + ): Promise<{ success: boolean }> { + await this.commandBus.execute( + new MarkInquiryReadCommand(id, user.sub), + ); + return { success: true }; + } +} diff --git a/apps/api/src/modules/inquiries/presentation/dto/create-inquiry.dto.ts b/apps/api/src/modules/inquiries/presentation/dto/create-inquiry.dto.ts new file mode 100644 index 0000000..c39fb6e --- /dev/null +++ b/apps/api/src/modules/inquiries/presentation/dto/create-inquiry.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateInquiryDto { + @ApiProperty({ description: 'ID of the listing' }) + @IsString() + @IsNotEmpty() + listingId!: string; + + @ApiProperty({ description: 'Tin nhắn yêu cầu tư vấn' }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + message!: string; + + @ApiPropertyOptional({ description: 'Số điện thoại liên hệ' }) + @IsOptional() + @IsString() + phone?: string; +} diff --git a/apps/api/src/modules/inquiries/presentation/dto/list-inquiries.dto.ts b/apps/api/src/modules/inquiries/presentation/dto/list-inquiries.dto.ts new file mode 100644 index 0000000..6a85ea0 --- /dev/null +++ b/apps/api/src/modules/inquiries/presentation/dto/list-inquiries.dto.ts @@ -0,0 +1,20 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ListInquiriesDto { + @ApiPropertyOptional({ example: 1, description: 'Page number', default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page?: number; + + @ApiPropertyOptional({ example: 20, description: 'Items per page', default: 20 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + limit?: number; +} diff --git a/apps/api/src/modules/leads/application/__tests__/create-lead.handler.spec.ts b/apps/api/src/modules/leads/application/__tests__/create-lead.handler.spec.ts new file mode 100644 index 0000000..e041373 --- /dev/null +++ b/apps/api/src/modules/leads/application/__tests__/create-lead.handler.spec.ts @@ -0,0 +1,117 @@ +import type { EventBus } from '@nestjs/cqrs'; +import type { ILeadRepository } from '../../domain/repositories/lead.repository'; +import { CreateLeadCommand } from '../commands/create-lead/create-lead.command'; +import { CreateLeadHandler } from '../commands/create-lead/create-lead.handler'; + +describe('CreateLeadHandler', () => { + let handler: CreateLeadHandler; + let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType }; + let mockEventBus: { publish: ReturnType }; + let mockPrisma: { + agent: { findUnique: ReturnType }; + }; + + beforeEach(() => { + mockLeadRepo = { + findById: vi.fn(), + save: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findByAgent: vi.fn(), + getStatsByAgent: vi.fn(), + }; + + mockEventBus = { publish: vi.fn() }; + + mockPrisma = { + agent: { findUnique: vi.fn() }, + }; + + handler = new CreateLeadHandler( + mockLeadRepo as any, + mockEventBus as unknown as EventBus, + mockPrisma as any, + ); + }); + + it('creates a lead successfully', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' }); + mockLeadRepo.save.mockResolvedValue(undefined); + + const command = new CreateLeadCommand( + 'user-1', + 'Nguyễn Văn A', + '0901234567', + 'a@example.com', + 'WEBSITE', + 75, + { note: 'Interested in District 7' }, + ); + + const result = await handler.execute(command); + + expect(result.id).toBeDefined(); + expect(result.status).toBe('NEW'); + expect(result.createdAt).toBeDefined(); + expect(mockLeadRepo.save).toHaveBeenCalledTimes(1); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('creates a lead with null score', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' }); + mockLeadRepo.save.mockResolvedValue(undefined); + + const command = new CreateLeadCommand( + 'user-1', + 'Nguyễn Văn B', + '0907654321', + null, + 'REFERRAL', + null, + null, + ); + + const result = await handler.execute(command); + + expect(result.id).toBeDefined(); + expect(result.status).toBe('NEW'); + }); + + it('throws NotFoundException when agent not found', async () => { + mockPrisma.agent.findUnique.mockResolvedValue(null); + + const command = new CreateLeadCommand( + 'not-an-agent', + 'Nguyễn Văn A', + '0901234567', + null, + 'WEBSITE', + null, + null, + ); + + await expect(handler.execute(command)).rejects.toThrow( + "Agent with id 'not-an-agent' not found", + ); + expect(mockLeadRepo.save).not.toHaveBeenCalled(); + }); + + it('throws ValidationException for invalid score', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' }); + + const command = new CreateLeadCommand( + 'user-1', + 'Nguyễn Văn A', + '0901234567', + null, + 'WEBSITE', + 150, + null, + ); + + await expect(handler.execute(command)).rejects.toThrow( + 'Điểm lead phải từ 0 đến 100', + ); + expect(mockLeadRepo.save).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/leads/application/__tests__/delete-lead.handler.spec.ts b/apps/api/src/modules/leads/application/__tests__/delete-lead.handler.spec.ts new file mode 100644 index 0000000..c2833f7 --- /dev/null +++ b/apps/api/src/modules/leads/application/__tests__/delete-lead.handler.spec.ts @@ -0,0 +1,104 @@ +import type { EventBus } from '@nestjs/cqrs'; +import { LeadEntity } from '../../domain/entities/lead.entity'; +import type { ILeadRepository } from '../../domain/repositories/lead.repository'; +import { DeleteLeadCommand } from '../commands/delete-lead/delete-lead.command'; +import { DeleteLeadHandler } from '../commands/delete-lead/delete-lead.handler'; + +describe('DeleteLeadHandler', () => { + let handler: DeleteLeadHandler; + let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType }; + let mockEventBus: { publish: ReturnType }; + let mockPrisma: { + agent: { findUnique: ReturnType }; + }; + + beforeEach(() => { + mockLeadRepo = { + findById: vi.fn(), + save: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findByAgent: vi.fn(), + getStatsByAgent: vi.fn(), + }; + + mockEventBus = { publish: vi.fn() }; + + mockPrisma = { + agent: { findUnique: vi.fn() }, + }; + + handler = new DeleteLeadHandler( + mockLeadRepo as any, + mockEventBus as unknown as EventBus, + mockPrisma as any, + ); + }); + + it('deletes a lead successfully', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' }); + + const lead = new LeadEntity('lead-1', { + agentId: 'agent-1', + name: 'Nguyễn Văn A', + phone: '0901234567', + email: null, + source: 'WEBSITE', + score: null, + notes: null, + status: 'NEW', + }); + mockLeadRepo.findById.mockResolvedValue(lead); + mockLeadRepo.delete.mockResolvedValue(undefined); + + const command = new DeleteLeadCommand('lead-1', 'user-1'); + + await handler.execute(command); + + expect(mockLeadRepo.delete).toHaveBeenCalledWith('lead-1'); + }); + + it('throws NotFoundException when agent not found', async () => { + mockPrisma.agent.findUnique.mockResolvedValue(null); + + const command = new DeleteLeadCommand('lead-1', 'not-an-agent'); + + await expect(handler.execute(command)).rejects.toThrow( + "Agent with id 'not-an-agent' not found", + ); + }); + + it('throws NotFoundException when lead not found', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' }); + mockLeadRepo.findById.mockResolvedValue(null); + + const command = new DeleteLeadCommand('lead-not-exist', 'user-1'); + + await expect(handler.execute(command)).rejects.toThrow( + "Lead with id 'lead-not-exist' not found", + ); + }); + + it('throws ForbiddenException when agent does not own the lead', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-2' }); + + const lead = new LeadEntity('lead-1', { + agentId: 'agent-1', + name: 'Nguyễn Văn A', + phone: '0901234567', + email: null, + source: 'WEBSITE', + score: null, + notes: null, + status: 'NEW', + }); + mockLeadRepo.findById.mockResolvedValue(lead); + + const command = new DeleteLeadCommand('lead-1', 'user-2'); + + await expect(handler.execute(command)).rejects.toThrow( + 'Bạn chỉ có thể xóa lead của chính mình', + ); + expect(mockLeadRepo.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/leads/application/__tests__/get-lead-stats.handler.spec.ts b/apps/api/src/modules/leads/application/__tests__/get-lead-stats.handler.spec.ts new file mode 100644 index 0000000..bedbc97 --- /dev/null +++ b/apps/api/src/modules/leads/application/__tests__/get-lead-stats.handler.spec.ts @@ -0,0 +1,69 @@ +import type { ILeadRepository } from '../../domain/repositories/lead.repository'; +import { GetLeadStatsQuery } from '../queries/get-lead-stats/get-lead-stats.query'; +import { GetLeadStatsHandler } from '../queries/get-lead-stats/get-lead-stats.handler'; + +describe('GetLeadStatsHandler', () => { + let handler: GetLeadStatsHandler; + let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType }; + let mockPrisma: { + agent: { findUnique: ReturnType }; + }; + + beforeEach(() => { + mockLeadRepo = { + findById: vi.fn(), + save: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findByAgent: vi.fn(), + getStatsByAgent: vi.fn(), + }; + + mockPrisma = { + agent: { findUnique: vi.fn() }, + }; + + handler = new GetLeadStatsHandler( + mockLeadRepo as any, + mockPrisma as any, + ); + }); + + it('returns stats for the agent', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' }); + + const mockStats = { + totalLeads: 25, + byStatus: { + NEW: 5, + CONTACTED: 8, + QUALIFIED: 4, + NEGOTIATING: 3, + CONVERTED: 3, + LOST: 2, + }, + conversionRate: 0.12, + avgScore: 65.5, + }; + mockLeadRepo.getStatsByAgent.mockResolvedValue(mockStats); + + const query = new GetLeadStatsQuery('user-1'); + const result = await handler.execute(query); + + expect(result).toEqual(mockStats); + expect(result.totalLeads).toBe(25); + expect(result.conversionRate).toBe(0.12); + expect(mockLeadRepo.getStatsByAgent).toHaveBeenCalledWith('agent-1'); + }); + + it('throws NotFoundException when agent not found', async () => { + mockPrisma.agent.findUnique.mockResolvedValue(null); + + const query = new GetLeadStatsQuery('not-an-agent'); + + await expect(handler.execute(query)).rejects.toThrow( + "Agent with id 'not-an-agent' not found", + ); + expect(mockLeadRepo.getStatsByAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/leads/application/__tests__/get-leads-by-agent.handler.spec.ts b/apps/api/src/modules/leads/application/__tests__/get-leads-by-agent.handler.spec.ts new file mode 100644 index 0000000..dde23e5 --- /dev/null +++ b/apps/api/src/modules/leads/application/__tests__/get-leads-by-agent.handler.spec.ts @@ -0,0 +1,90 @@ +import type { ILeadRepository } from '../../domain/repositories/lead.repository'; +import { GetLeadsByAgentQuery } from '../queries/get-leads-by-agent/get-leads-by-agent.query'; +import { GetLeadsByAgentHandler } from '../queries/get-leads-by-agent/get-leads-by-agent.handler'; + +describe('GetLeadsByAgentHandler', () => { + let handler: GetLeadsByAgentHandler; + let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType }; + let mockPrisma: { + agent: { findUnique: ReturnType }; + }; + + beforeEach(() => { + mockLeadRepo = { + findById: vi.fn(), + save: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findByAgent: vi.fn(), + getStatsByAgent: vi.fn(), + }; + + mockPrisma = { + agent: { findUnique: vi.fn() }, + }; + + handler = new GetLeadsByAgentHandler( + mockLeadRepo as any, + mockPrisma as any, + ); + }); + + it('returns paginated results', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' }); + + const mockResult = { + data: [ + { + id: 'lead-1', + agentId: 'agent-1', + name: 'Nguyễn Văn A', + phone: '0901234567', + email: null, + source: 'WEBSITE', + score: 75, + status: 'NEW', + createdAt: new Date(), + }, + ], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + mockLeadRepo.findByAgent.mockResolvedValue(mockResult); + + const query = new GetLeadsByAgentQuery('user-1', null, 1, 20); + const result = await handler.execute(query); + + expect(result).toEqual(mockResult); + expect(result.data).toHaveLength(1); + expect(mockLeadRepo.findByAgent).toHaveBeenCalledWith('agent-1', null, 1, 20); + }); + + it('passes status filter to repository', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' }); + mockLeadRepo.findByAgent.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }); + + const query = new GetLeadsByAgentQuery('user-1', 'CONTACTED', 1, 20); + await handler.execute(query); + + expect(mockLeadRepo.findByAgent).toHaveBeenCalledWith('agent-1', 'CONTACTED', 1, 20); + }); + + it('throws NotFoundException when agent not found', async () => { + mockPrisma.agent.findUnique.mockResolvedValue(null); + + const query = new GetLeadsByAgentQuery('not-an-agent'); + + await expect(handler.execute(query)).rejects.toThrow( + "Agent with id 'not-an-agent' not found", + ); + expect(mockLeadRepo.findByAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/leads/application/__tests__/update-lead-status.handler.spec.ts b/apps/api/src/modules/leads/application/__tests__/update-lead-status.handler.spec.ts new file mode 100644 index 0000000..06d9350 --- /dev/null +++ b/apps/api/src/modules/leads/application/__tests__/update-lead-status.handler.spec.ts @@ -0,0 +1,104 @@ +import type { EventBus } from '@nestjs/cqrs'; +import { LeadEntity } from '../../domain/entities/lead.entity'; +import type { ILeadRepository } from '../../domain/repositories/lead.repository'; +import { UpdateLeadStatusCommand } from '../commands/update-lead-status/update-lead-status.command'; +import { UpdateLeadStatusHandler } from '../commands/update-lead-status/update-lead-status.handler'; + +describe('UpdateLeadStatusHandler', () => { + let handler: UpdateLeadStatusHandler; + let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType }; + let mockEventBus: { publish: ReturnType }; + let mockPrisma: { + agent: { findUnique: ReturnType }; + }; + + beforeEach(() => { + mockLeadRepo = { + findById: vi.fn(), + save: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findByAgent: vi.fn(), + getStatsByAgent: vi.fn(), + }; + + mockEventBus = { publish: vi.fn() }; + + mockPrisma = { + agent: { findUnique: vi.fn() }, + }; + + handler = new UpdateLeadStatusHandler( + mockLeadRepo as any, + mockEventBus as unknown as EventBus, + mockPrisma as any, + ); + }); + + it('updates lead status successfully', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' }); + + const lead = new LeadEntity('lead-1', { + agentId: 'agent-1', + name: 'Nguyễn Văn A', + phone: '0901234567', + email: null, + source: 'WEBSITE', + score: null, + notes: null, + status: 'NEW', + }); + mockLeadRepo.findById.mockResolvedValue(lead); + mockLeadRepo.update.mockResolvedValue(undefined); + + const command = new UpdateLeadStatusCommand('lead-1', 'user-1', 'CONTACTED'); + + await handler.execute(command); + + expect(mockLeadRepo.update).toHaveBeenCalledTimes(1); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('throws NotFoundException when agent not found', async () => { + mockPrisma.agent.findUnique.mockResolvedValue(null); + + const command = new UpdateLeadStatusCommand('lead-1', 'not-an-agent', 'CONTACTED'); + + await expect(handler.execute(command)).rejects.toThrow( + "Agent with id 'not-an-agent' not found", + ); + }); + + it('throws NotFoundException when lead not found', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' }); + mockLeadRepo.findById.mockResolvedValue(null); + + const command = new UpdateLeadStatusCommand('lead-not-exist', 'user-1', 'CONTACTED'); + + await expect(handler.execute(command)).rejects.toThrow( + "Lead with id 'lead-not-exist' not found", + ); + }); + + it('throws ForbiddenException when agent does not own the lead', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-2' }); + + const lead = new LeadEntity('lead-1', { + agentId: 'agent-1', + name: 'Nguyễn Văn A', + phone: '0901234567', + email: null, + source: 'WEBSITE', + score: null, + notes: null, + status: 'NEW', + }); + mockLeadRepo.findById.mockResolvedValue(lead); + + const command = new UpdateLeadStatusCommand('lead-1', 'user-2', 'CONTACTED'); + + await expect(handler.execute(command)).rejects.toThrow( + 'Bạn chỉ có thể cập nhật lead của chính mình', + ); + }); +}); diff --git a/apps/api/src/modules/leads/application/commands/create-lead/create-lead.command.ts b/apps/api/src/modules/leads/application/commands/create-lead/create-lead.command.ts new file mode 100644 index 0000000..f8b027b --- /dev/null +++ b/apps/api/src/modules/leads/application/commands/create-lead/create-lead.command.ts @@ -0,0 +1,11 @@ +export class CreateLeadCommand { + constructor( + public readonly agentUserId: string, + public readonly name: string, + public readonly phone: string, + public readonly email: string | null, + public readonly source: string, + public readonly score: number | null, + public readonly notes: unknown, + ) {} +} diff --git a/apps/api/src/modules/leads/application/commands/create-lead/create-lead.handler.ts b/apps/api/src/modules/leads/application/commands/create-lead/create-lead.handler.ts new file mode 100644 index 0000000..439315c --- /dev/null +++ b/apps/api/src/modules/leads/application/commands/create-lead/create-lead.handler.ts @@ -0,0 +1,73 @@ +import { Inject, Logger } from '@nestjs/common'; +import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { createId } from '@paralleldrive/cuid2'; +import { NotFoundException, ValidationException, type PrismaService } from '@modules/shared'; +import { LeadEntity } from '../../../domain/entities/lead.entity'; +import { LEAD_REPOSITORY, type ILeadRepository } from '../../../domain/repositories/lead.repository'; +import { LeadScore } from '../../../domain/value-objects/lead-score.vo'; +import { CreateLeadCommand } from './create-lead.command'; + +export interface CreateLeadResult { + id: string; + status: string; + createdAt: string; +} + +@CommandHandler(CreateLeadCommand) +export class CreateLeadHandler implements ICommandHandler { + private readonly logger = new Logger(CreateLeadHandler.name); + + constructor( + @Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository, + private readonly eventBus: EventBus, + private readonly prisma: PrismaService, + ) {} + + async execute(command: CreateLeadCommand): Promise { + // Look up agent by userId + const agent = await this.prisma.agent.findUnique({ + where: { userId: command.agentUserId }, + }); + if (!agent) { + throw new NotFoundException('Agent', command.agentUserId); + } + + // Validate score value object + let score: LeadScore | null = null; + if (command.score !== null && command.score !== undefined) { + const scoreResult = LeadScore.create(command.score); + if (scoreResult.isErr) { + throw new ValidationException(scoreResult.unwrapErr()); + } + score = scoreResult.unwrap(); + } + + const id = createId(); + const lead = LeadEntity.createNew( + id, + agent.id, + command.name, + command.phone, + command.email, + command.source, + score, + command.notes ?? null, + ); + + await this.leadRepo.save(lead); + + // Publish domain events + const events = lead.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log(`Lead ${id} created by agent ${agent.id}`); + + return { + id, + status: lead.status, + createdAt: lead.createdAt.toISOString(), + }; + } +} diff --git a/apps/api/src/modules/leads/application/commands/delete-lead/delete-lead.command.ts b/apps/api/src/modules/leads/application/commands/delete-lead/delete-lead.command.ts new file mode 100644 index 0000000..c695a48 --- /dev/null +++ b/apps/api/src/modules/leads/application/commands/delete-lead/delete-lead.command.ts @@ -0,0 +1,6 @@ +export class DeleteLeadCommand { + constructor( + public readonly leadId: string, + public readonly agentUserId: string, + ) {} +} diff --git a/apps/api/src/modules/leads/application/commands/delete-lead/delete-lead.handler.ts b/apps/api/src/modules/leads/application/commands/delete-lead/delete-lead.handler.ts new file mode 100644 index 0000000..df24547 --- /dev/null +++ b/apps/api/src/modules/leads/application/commands/delete-lead/delete-lead.handler.ts @@ -0,0 +1,40 @@ +import { Inject, Logger } from '@nestjs/common'; +import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared'; +import { LEAD_REPOSITORY, type ILeadRepository } from '../../../domain/repositories/lead.repository'; +import { DeleteLeadCommand } from './delete-lead.command'; + +@CommandHandler(DeleteLeadCommand) +export class DeleteLeadHandler implements ICommandHandler { + private readonly logger = new Logger(DeleteLeadHandler.name); + + constructor( + @Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository, + private readonly eventBus: EventBus, + private readonly prisma: PrismaService, + ) {} + + async execute(command: DeleteLeadCommand): Promise { + // Look up agent by userId + const agent = await this.prisma.agent.findUnique({ + where: { userId: command.agentUserId }, + }); + if (!agent) { + throw new NotFoundException('Agent', command.agentUserId); + } + + const lead = await this.leadRepo.findById(command.leadId); + if (!lead) { + throw new NotFoundException('Lead', command.leadId); + } + + // Verify agent ownership + if (lead.agentId !== agent.id) { + throw new ForbiddenException('Bạn chỉ có thể xóa lead của chính mình'); + } + + await this.leadRepo.delete(command.leadId); + + this.logger.log(`Lead ${command.leadId} deleted by agent ${agent.id}`); + } +} diff --git a/apps/api/src/modules/leads/application/commands/update-lead-status/update-lead-status.command.ts b/apps/api/src/modules/leads/application/commands/update-lead-status/update-lead-status.command.ts new file mode 100644 index 0000000..9a3624b --- /dev/null +++ b/apps/api/src/modules/leads/application/commands/update-lead-status/update-lead-status.command.ts @@ -0,0 +1,7 @@ +export class UpdateLeadStatusCommand { + constructor( + public readonly leadId: string, + public readonly agentUserId: string, + public readonly newStatus: string, + ) {} +} diff --git a/apps/api/src/modules/leads/application/commands/update-lead-status/update-lead-status.handler.ts b/apps/api/src/modules/leads/application/commands/update-lead-status/update-lead-status.handler.ts new file mode 100644 index 0000000..a3f867b --- /dev/null +++ b/apps/api/src/modules/leads/application/commands/update-lead-status/update-lead-status.handler.ts @@ -0,0 +1,48 @@ +import { Inject, Logger } from '@nestjs/common'; +import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared'; +import { type LeadStatus } from '../../../domain/entities/lead.entity'; +import { LEAD_REPOSITORY, type ILeadRepository } from '../../../domain/repositories/lead.repository'; +import { UpdateLeadStatusCommand } from './update-lead-status.command'; + +@CommandHandler(UpdateLeadStatusCommand) +export class UpdateLeadStatusHandler implements ICommandHandler { + private readonly logger = new Logger(UpdateLeadStatusHandler.name); + + constructor( + @Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository, + private readonly eventBus: EventBus, + private readonly prisma: PrismaService, + ) {} + + async execute(command: UpdateLeadStatusCommand): Promise { + // Look up agent by userId + const agent = await this.prisma.agent.findUnique({ + where: { userId: command.agentUserId }, + }); + if (!agent) { + throw new NotFoundException('Agent', command.agentUserId); + } + + const lead = await this.leadRepo.findById(command.leadId); + if (!lead) { + throw new NotFoundException('Lead', command.leadId); + } + + // Verify agent ownership + if (lead.agentId !== agent.id) { + throw new ForbiddenException('Bạn chỉ có thể cập nhật lead của chính mình'); + } + + lead.updateStatus(command.newStatus as LeadStatus); + await this.leadRepo.update(lead); + + // Publish domain events + const events = lead.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log(`Lead ${command.leadId} status updated to ${command.newStatus} by agent ${agent.id}`); + } +} diff --git a/apps/api/src/modules/leads/application/queries/get-lead-stats/get-lead-stats.handler.ts b/apps/api/src/modules/leads/application/queries/get-lead-stats/get-lead-stats.handler.ts new file mode 100644 index 0000000..0ff6b49 --- /dev/null +++ b/apps/api/src/modules/leads/application/queries/get-lead-stats/get-lead-stats.handler.ts @@ -0,0 +1,25 @@ +import { Inject } from '@nestjs/common'; +import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { NotFoundException, type PrismaService } from '@modules/shared'; +import { LEAD_REPOSITORY, type ILeadRepository, type LeadStatsData } from '../../../domain/repositories/lead.repository'; +import { GetLeadStatsQuery } from './get-lead-stats.query'; + +@QueryHandler(GetLeadStatsQuery) +export class GetLeadStatsHandler implements IQueryHandler { + constructor( + @Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository, + private readonly prisma: PrismaService, + ) {} + + async execute(query: GetLeadStatsQuery): Promise { + // Look up agent by userId + const agent = await this.prisma.agent.findUnique({ + where: { userId: query.agentUserId }, + }); + if (!agent) { + throw new NotFoundException('Agent', query.agentUserId); + } + + return this.leadRepo.getStatsByAgent(agent.id); + } +} diff --git a/apps/api/src/modules/leads/application/queries/get-lead-stats/get-lead-stats.query.ts b/apps/api/src/modules/leads/application/queries/get-lead-stats/get-lead-stats.query.ts new file mode 100644 index 0000000..824fb27 --- /dev/null +++ b/apps/api/src/modules/leads/application/queries/get-lead-stats/get-lead-stats.query.ts @@ -0,0 +1,5 @@ +export class GetLeadStatsQuery { + constructor( + public readonly agentUserId: string, + ) {} +} diff --git a/apps/api/src/modules/leads/application/queries/get-leads-by-agent/get-leads-by-agent.handler.ts b/apps/api/src/modules/leads/application/queries/get-leads-by-agent/get-leads-by-agent.handler.ts new file mode 100644 index 0000000..7319b69 --- /dev/null +++ b/apps/api/src/modules/leads/application/queries/get-leads-by-agent/get-leads-by-agent.handler.ts @@ -0,0 +1,31 @@ +import { Inject } from '@nestjs/common'; +import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { NotFoundException, type PrismaService } from '@modules/shared'; +import { type LeadReadDto } from '../../../domain/repositories/lead-read.dto'; +import { LEAD_REPOSITORY, type ILeadRepository, type PaginatedResult } from '../../../domain/repositories/lead.repository'; +import { GetLeadsByAgentQuery } from './get-leads-by-agent.query'; + +@QueryHandler(GetLeadsByAgentQuery) +export class GetLeadsByAgentHandler implements IQueryHandler { + constructor( + @Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository, + private readonly prisma: PrismaService, + ) {} + + async execute(query: GetLeadsByAgentQuery): Promise> { + // Look up agent by userId + const agent = await this.prisma.agent.findUnique({ + where: { userId: query.agentUserId }, + }); + if (!agent) { + throw new NotFoundException('Agent', query.agentUserId); + } + + return this.leadRepo.findByAgent( + agent.id, + query.status, + query.page, + query.limit, + ); + } +} diff --git a/apps/api/src/modules/leads/application/queries/get-leads-by-agent/get-leads-by-agent.query.ts b/apps/api/src/modules/leads/application/queries/get-leads-by-agent/get-leads-by-agent.query.ts new file mode 100644 index 0000000..84a0f83 --- /dev/null +++ b/apps/api/src/modules/leads/application/queries/get-leads-by-agent/get-leads-by-agent.query.ts @@ -0,0 +1,8 @@ +export class GetLeadsByAgentQuery { + constructor( + public readonly agentUserId: string, + public readonly status: string | null = null, + public readonly page: number = 1, + public readonly limit: number = 20, + ) {} +} diff --git a/apps/api/src/modules/leads/domain/__tests__/lead-domain.spec.ts b/apps/api/src/modules/leads/domain/__tests__/lead-domain.spec.ts new file mode 100644 index 0000000..fa936af --- /dev/null +++ b/apps/api/src/modules/leads/domain/__tests__/lead-domain.spec.ts @@ -0,0 +1,190 @@ +import { LeadEntity, type LeadStatus } from '../entities/lead.entity'; +import { LeadCreatedEvent } from '../events/lead-created.event'; +import { LeadStatusChangedEvent } from '../events/lead-status-changed.event'; +import { LeadScore } from '../value-objects/lead-score.vo'; + +describe('LeadEntity', () => { + describe('createNew', () => { + it('creates a lead with correct properties and NEW status', () => { + const lead = LeadEntity.createNew( + 'lead-1', + 'agent-1', + 'Nguyễn Văn A', + '0901234567', + 'a@example.com', + 'WEBSITE', + null, + null, + ); + + expect(lead.id).toBe('lead-1'); + expect(lead.agentId).toBe('agent-1'); + expect(lead.name).toBe('Nguyễn Văn A'); + expect(lead.phone).toBe('0901234567'); + expect(lead.email).toBe('a@example.com'); + expect(lead.source).toBe('WEBSITE'); + expect(lead.score).toBeNull(); + expect(lead.notes).toBeNull(); + expect(lead.status).toBe('NEW'); + }); + + it('emits LeadCreatedEvent', () => { + const lead = LeadEntity.createNew( + 'lead-1', + 'agent-1', + 'Nguyễn Văn A', + '0901234567', + null, + 'REFERRAL', + null, + null, + ); + + const events = lead.domainEvents; + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(LeadCreatedEvent); + + const event = events[0] as LeadCreatedEvent; + expect(event.aggregateId).toBe('lead-1'); + expect(event.agentId).toBe('agent-1'); + }); + }); + + describe('updateStatus', () => { + const createNewLead = (): LeadEntity => + LeadEntity.createNew( + 'lead-1', + 'agent-1', + 'Test', + '0901234567', + null, + 'WEBSITE', + null, + null, + ); + + it('allows NEW → CONTACTED transition', () => { + const lead = createNewLead(); + lead.clearDomainEvents(); + + lead.updateStatus('CONTACTED'); + + expect(lead.status).toBe('CONTACTED'); + const events = lead.domainEvents; + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(LeadStatusChangedEvent); + }); + + it('allows NEW → LOST transition', () => { + const lead = createNewLead(); + lead.updateStatus('LOST'); + expect(lead.status).toBe('LOST'); + }); + + it('allows CONTACTED → QUALIFIED transition', () => { + const lead = createNewLead(); + lead.updateStatus('CONTACTED'); + lead.updateStatus('QUALIFIED'); + expect(lead.status).toBe('QUALIFIED'); + }); + + it('allows QUALIFIED → NEGOTIATING transition', () => { + const lead = createNewLead(); + lead.updateStatus('CONTACTED'); + lead.updateStatus('QUALIFIED'); + lead.updateStatus('NEGOTIATING'); + expect(lead.status).toBe('NEGOTIATING'); + }); + + it('allows NEGOTIATING → CONVERTED transition', () => { + const lead = createNewLead(); + lead.updateStatus('CONTACTED'); + lead.updateStatus('QUALIFIED'); + lead.updateStatus('NEGOTIATING'); + lead.updateStatus('CONVERTED'); + expect(lead.status).toBe('CONVERTED'); + }); + + it('throws on CONVERTED → any transition', () => { + const lead = createNewLead(); + lead.updateStatus('CONTACTED'); + lead.updateStatus('QUALIFIED'); + lead.updateStatus('NEGOTIATING'); + lead.updateStatus('CONVERTED'); + + const statuses: LeadStatus[] = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'LOST']; + for (const status of statuses) { + expect(() => lead.updateStatus(status)).toThrow( + `Không thể chuyển trạng thái từ CONVERTED sang ${status}`, + ); + } + }); + + it('throws on LOST → any transition', () => { + const lead = createNewLead(); + lead.updateStatus('LOST'); + + const statuses: LeadStatus[] = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED']; + for (const status of statuses) { + expect(() => lead.updateStatus(status)).toThrow( + `Không thể chuyển trạng thái từ LOST sang ${status}`, + ); + } + }); + + it('throws on invalid transition NEW → QUALIFIED', () => { + const lead = createNewLead(); + expect(() => lead.updateStatus('QUALIFIED')).toThrow( + 'Không thể chuyển trạng thái từ NEW sang QUALIFIED', + ); + }); + + it('emits LeadStatusChangedEvent with old and new status', () => { + const lead = createNewLead(); + lead.clearDomainEvents(); + + lead.updateStatus('CONTACTED'); + + const events = lead.domainEvents; + expect(events).toHaveLength(1); + + const event = events[0] as LeadStatusChangedEvent; + expect(event.aggregateId).toBe('lead-1'); + expect(event.agentId).toBe('agent-1'); + expect(event.oldStatus).toBe('NEW'); + expect(event.newStatus).toBe('CONTACTED'); + }); + }); +}); + +describe('LeadScore', () => { + it('creates a valid score at 0', () => { + const result = LeadScore.create(0); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(0); + }); + + it('creates a valid score at 100', () => { + const result = LeadScore.create(100); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(100); + }); + + it('creates a valid score at 50', () => { + const result = LeadScore.create(50); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(50); + }); + + it('rejects negative score', () => { + const result = LeadScore.create(-1); + expect(result.isErr).toBe(true); + expect(result.unwrapErr()).toBe('Điểm lead phải từ 0 đến 100'); + }); + + it('rejects score above 100', () => { + const result = LeadScore.create(101); + expect(result.isErr).toBe(true); + expect(result.unwrapErr()).toBe('Điểm lead phải từ 0 đến 100'); + }); +}); diff --git a/apps/api/src/modules/leads/domain/entities/lead.entity.ts b/apps/api/src/modules/leads/domain/entities/lead.entity.ts new file mode 100644 index 0000000..9b79473 --- /dev/null +++ b/apps/api/src/modules/leads/domain/entities/lead.entity.ts @@ -0,0 +1,101 @@ +import { AggregateRoot, ValidationException } from '@modules/shared'; +import { LeadCreatedEvent } from '../events/lead-created.event'; +import { LeadStatusChangedEvent } from '../events/lead-status-changed.event'; +import { type LeadScore } from '../value-objects/lead-score.vo'; + +export type LeadStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'NEGOTIATING' | 'CONVERTED' | 'LOST'; + +const VALID_TRANSITIONS: Record = { + NEW: ['CONTACTED', 'LOST'], + CONTACTED: ['QUALIFIED', 'LOST'], + QUALIFIED: ['NEGOTIATING', 'LOST'], + NEGOTIATING: ['CONVERTED', 'LOST'], + CONVERTED: [], + LOST: [], +}; + +export interface LeadProps { + agentId: string; + name: string; + phone: string; + email: string | null; + source: string; + score: LeadScore | null; + notes: unknown; + status: LeadStatus; +} + +export class LeadEntity extends AggregateRoot { + private _agentId: string; + private _name: string; + private _phone: string; + private _email: string | null; + private _source: string; + private _score: LeadScore | null; + private _notes: unknown; + private _status: LeadStatus; + + constructor(id: string, props: LeadProps, createdAt?: Date, updatedAt?: Date) { + super(id, createdAt); + if (updatedAt) this.updatedAt = updatedAt; + this._agentId = props.agentId; + this._name = props.name; + this._phone = props.phone; + this._email = props.email; + this._source = props.source; + this._score = props.score; + this._notes = props.notes; + this._status = props.status; + } + + get agentId(): string { return this._agentId; } + get name(): string { return this._name; } + get phone(): string { return this._phone; } + get email(): string | null { return this._email; } + get source(): string { return this._source; } + get score(): LeadScore | null { return this._score; } + get notes(): unknown { return this._notes; } + get status(): LeadStatus { return this._status; } + + static createNew( + id: string, + agentId: string, + name: string, + phone: string, + email: string | null, + source: string, + score: LeadScore | null, + notes: unknown, + ): LeadEntity { + const lead = new LeadEntity(id, { + agentId, + name, + phone, + email, + source, + score, + notes, + status: 'NEW', + }); + + lead.addDomainEvent(new LeadCreatedEvent(id, agentId)); + return lead; + } + + updateStatus(newStatus: LeadStatus): void { + const allowed = VALID_TRANSITIONS[this._status]; + if (!allowed.includes(newStatus)) { + throw new ValidationException( + `Không thể chuyển trạng thái từ ${this._status} sang ${newStatus}`, + ); + } + + const oldStatus = this._status; + this._status = newStatus; + this.updatedAt = new Date(); + + this.addDomainEvent( + new LeadStatusChangedEvent(this.id, this._agentId, oldStatus, newStatus), + ); + } +} diff --git a/apps/api/src/modules/leads/domain/events/lead-created.event.ts b/apps/api/src/modules/leads/domain/events/lead-created.event.ts new file mode 100644 index 0000000..c202a12 --- /dev/null +++ b/apps/api/src/modules/leads/domain/events/lead-created.event.ts @@ -0,0 +1,11 @@ +import { type DomainEvent } from '@modules/shared'; + +export class LeadCreatedEvent implements DomainEvent { + readonly eventName = 'lead.created'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly agentId: string, + ) {} +} diff --git a/apps/api/src/modules/leads/domain/events/lead-status-changed.event.ts b/apps/api/src/modules/leads/domain/events/lead-status-changed.event.ts new file mode 100644 index 0000000..572a882 --- /dev/null +++ b/apps/api/src/modules/leads/domain/events/lead-status-changed.event.ts @@ -0,0 +1,13 @@ +import { type DomainEvent } from '@modules/shared'; + +export class LeadStatusChangedEvent implements DomainEvent { + readonly eventName = 'lead.status_changed'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly agentId: string, + public readonly oldStatus: string, + public readonly newStatus: string, + ) {} +} diff --git a/apps/api/src/modules/leads/domain/repositories/lead-read.dto.ts b/apps/api/src/modules/leads/domain/repositories/lead-read.dto.ts new file mode 100644 index 0000000..405644f --- /dev/null +++ b/apps/api/src/modules/leads/domain/repositories/lead-read.dto.ts @@ -0,0 +1,13 @@ +export interface LeadReadDto { + id: string; + agentId: string; + name: string; + phone: string; + email: string | null; + source: string; + score: number | null; + notes: unknown; + status: string; + createdAt: string; + updatedAt: string; +} diff --git a/apps/api/src/modules/leads/domain/repositories/lead.repository.ts b/apps/api/src/modules/leads/domain/repositories/lead.repository.ts new file mode 100644 index 0000000..b39c96a --- /dev/null +++ b/apps/api/src/modules/leads/domain/repositories/lead.repository.ts @@ -0,0 +1,28 @@ +import { type LeadEntity } from '../entities/lead.entity'; +import { type LeadReadDto } from './lead-read.dto'; + +export const LEAD_REPOSITORY = Symbol('LEAD_REPOSITORY'); + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface LeadStatsData { + totalLeads: number; + byStatus: Record; + conversionRate: number; + avgScore: number | null; +} + +export interface ILeadRepository { + findById(id: string): Promise; + save(lead: LeadEntity): Promise; + update(lead: LeadEntity): Promise; + delete(id: string): Promise; + findByAgent(agentId: string, status: string | null, page: number, limit: number): Promise>; + getStatsByAgent(agentId: string): Promise; +} diff --git a/apps/api/src/modules/leads/domain/value-objects/lead-score.vo.ts b/apps/api/src/modules/leads/domain/value-objects/lead-score.vo.ts new file mode 100644 index 0000000..4d3185e --- /dev/null +++ b/apps/api/src/modules/leads/domain/value-objects/lead-score.vo.ts @@ -0,0 +1,16 @@ +import { Result, ValueObject } from '@modules/shared'; + +interface LeadScoreProps { + value: number; +} + +export class LeadScore extends ValueObject { + get value(): number { return this.props.value; } + + static create(value: number): Result { + if (value < 0 || value > 100) { + return Result.err('Điểm lead phải từ 0 đến 100'); + } + return Result.ok(new LeadScore({ value })); + } +} diff --git a/apps/api/src/modules/leads/index.ts b/apps/api/src/modules/leads/index.ts new file mode 100644 index 0000000..cb82f9c --- /dev/null +++ b/apps/api/src/modules/leads/index.ts @@ -0,0 +1,3 @@ +export { LeadsModule } from './leads.module'; +export { LEAD_REPOSITORY, type ILeadRepository } from './domain/repositories/lead.repository'; +export { LeadEntity } from './domain/entities/lead.entity'; diff --git a/apps/api/src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts b/apps/api/src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts new file mode 100644 index 0000000..ce1e6a9 --- /dev/null +++ b/apps/api/src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts @@ -0,0 +1,151 @@ +import { Injectable } from '@nestjs/common'; +import type { Lead as PrismaLead } from '@prisma/client'; +import { type PrismaService } from '@modules/shared'; +import { LeadEntity, type LeadStatus } from '../../domain/entities/lead.entity'; +import type { LeadReadDto } from '../../domain/repositories/lead-read.dto'; +import type { ILeadRepository, LeadStatsData, PaginatedResult } from '../../domain/repositories/lead.repository'; +import { LeadScore } from '../../domain/value-objects/lead-score.vo'; + +@Injectable() +export class PrismaLeadRepository implements ILeadRepository { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + const lead = await this.prisma.lead.findUnique({ where: { id } }); + return lead ? this.toDomain(lead) : null; + } + + async save(entity: LeadEntity): Promise { + await this.prisma.lead.create({ + data: { + id: entity.id, + agentId: entity.agentId, + name: entity.name, + phone: entity.phone, + email: entity.email, + source: entity.source, + score: entity.score?.value ?? null, + notes: entity.notes as never, + status: entity.status, + }, + }); + } + + async update(entity: LeadEntity): Promise { + await this.prisma.lead.update({ + where: { id: entity.id }, + data: { + status: entity.status, + score: entity.score?.value ?? null, + notes: entity.notes as never, + }, + }); + } + + async delete(id: string): Promise { + await this.prisma.lead.delete({ where: { id } }); + } + + async findByAgent( + agentId: string, + status: string | null, + page: number, + limit: number, + ): Promise> { + const take = Math.min(limit, 100); + const skip = (page - 1) * take; + const where: Record = { agentId }; + if (status) { + where['status'] = status; + } + + const [data, total] = await Promise.all([ + this.prisma.lead.findMany({ + where, + skip, + take, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.lead.count({ where }), + ]); + + return { + data: data.map((r) => ({ + id: r.id, + agentId: r.agentId, + name: r.name, + phone: r.phone, + email: r.email, + source: r.source, + score: r.score, + notes: r.notes, + status: r.status, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + })), + total, + page, + limit: take, + totalPages: Math.ceil(total / take), + }; + } + + async getStatsByAgent(agentId: string): Promise { + const leads = await this.prisma.lead.findMany({ + where: { agentId }, + select: { status: true, score: true }, + }); + + const totalLeads = leads.length; + const byStatus: Record = {}; + + let scoreSum = 0; + let scoreCount = 0; + let convertedCount = 0; + + for (const lead of leads) { + byStatus[lead.status] = (byStatus[lead.status] ?? 0) + 1; + if (lead.score !== null) { + scoreSum += lead.score; + scoreCount++; + } + if (lead.status === 'CONVERTED') { + convertedCount++; + } + } + + return { + totalLeads, + byStatus, + conversionRate: totalLeads > 0 + ? Math.round((convertedCount / totalLeads) * 10000) / 100 + : 0, + avgScore: scoreCount > 0 + ? Math.round((scoreSum / scoreCount) * 10) / 10 + : null, + }; + } + + private toDomain(raw: PrismaLead): LeadEntity { + let score: LeadScore | null = null; + if (raw.score !== null) { + score = LeadScore.create(raw.score).unwrap(); + } + + return new LeadEntity( + raw.id, + { + agentId: raw.agentId, + name: raw.name, + phone: raw.phone, + email: raw.email, + source: raw.source, + score, + notes: raw.notes, + status: raw.status as LeadStatus, + }, + raw.createdAt, + raw.updatedAt, + ); + } +} diff --git a/apps/api/src/modules/leads/leads.module.ts b/apps/api/src/modules/leads/leads.module.ts new file mode 100644 index 0000000..af9b9dc --- /dev/null +++ b/apps/api/src/modules/leads/leads.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { CreateLeadHandler } from './application/commands/create-lead/create-lead.handler'; +import { DeleteLeadHandler } from './application/commands/delete-lead/delete-lead.handler'; +import { UpdateLeadStatusHandler } from './application/commands/update-lead-status/update-lead-status.handler'; +import { GetLeadStatsHandler } from './application/queries/get-lead-stats/get-lead-stats.handler'; +import { GetLeadsByAgentHandler } from './application/queries/get-leads-by-agent/get-leads-by-agent.handler'; +import { LEAD_REPOSITORY } from './domain/repositories/lead.repository'; +import { PrismaLeadRepository } from './infrastructure/repositories/prisma-lead.repository'; +import { LeadsController } from './presentation/controllers/leads.controller'; + +const CommandHandlers = [CreateLeadHandler, UpdateLeadStatusHandler, DeleteLeadHandler]; + +const QueryHandlers = [GetLeadsByAgentHandler, GetLeadStatsHandler]; + +@Module({ + imports: [CqrsModule], + controllers: [LeadsController], + providers: [ + { provide: LEAD_REPOSITORY, useClass: PrismaLeadRepository }, + ...CommandHandlers, + ...QueryHandlers, + ], + exports: [LEAD_REPOSITORY], +}) +export class LeadsModule {} diff --git a/apps/api/src/modules/leads/presentation/controllers/leads.controller.ts b/apps/api/src/modules/leads/presentation/controllers/leads.controller.ts new file mode 100644 index 0000000..5c3659f --- /dev/null +++ b/apps/api/src/modules/leads/presentation/controllers/leads.controller.ts @@ -0,0 +1,126 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { type JwtPayload, CurrentUser, JwtAuthGuard, RolesGuard, Roles } from '@modules/auth'; +import { CreateLeadCommand } from '../../application/commands/create-lead/create-lead.command'; +import { type CreateLeadResult } from '../../application/commands/create-lead/create-lead.handler'; +import { DeleteLeadCommand } from '../../application/commands/delete-lead/delete-lead.command'; +import { UpdateLeadStatusCommand } from '../../application/commands/update-lead-status/update-lead-status.command'; +import { GetLeadStatsQuery } from '../../application/queries/get-lead-stats/get-lead-stats.query'; +import { GetLeadsByAgentQuery } from '../../application/queries/get-leads-by-agent/get-leads-by-agent.query'; +import type { LeadReadDto } from '../../domain/repositories/lead-read.dto'; +import type { LeadStatsData, PaginatedResult } from '../../domain/repositories/lead.repository'; +import type { CreateLeadDto } from '../dto/create-lead.dto'; +import type { ListLeadsDto } from '../dto/list-leads.dto'; +import type { UpdateLeadStatusDto } from '../dto/update-lead-status.dto'; + +@ApiTags('leads') +@ApiBearerAuth('JWT') +@Controller('leads') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('AGENT') +export class LeadsController { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + @ApiOperation({ summary: 'Tạo lead mới' }) + @ApiResponse({ status: 201, description: 'Lead đã được tạo thành công' }) + @ApiResponse({ status: 400, description: 'Lỗi validation' }) + @ApiResponse({ status: 401, description: 'Chưa xác thực' }) + @ApiResponse({ status: 403, description: 'Không có quyền' }) + @Post() + async createLead( + @Body() dto: CreateLeadDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new CreateLeadCommand( + user.sub, + dto.name, + dto.phone, + dto.email ?? null, + dto.source, + dto.score ?? null, + dto.notes ?? null, + ), + ); + } + + @ApiOperation({ summary: 'Danh sách lead của agent' }) + @ApiResponse({ status: 200, description: 'Danh sách lead phân trang' }) + @Get() + async getLeads( + @Query() dto: ListLeadsDto, + @CurrentUser() user: JwtPayload, + ): Promise> { + return this.queryBus.execute( + new GetLeadsByAgentQuery( + user.sub, + dto.status ?? null, + dto.page ?? 1, + dto.limit ?? 20, + ), + ); + } + + @ApiOperation({ summary: 'Thống kê lead của agent' }) + @ApiResponse({ status: 200, description: 'Thống kê lead' }) + @Get('stats') + async getStats( + @CurrentUser() user: JwtPayload, + ): Promise { + return this.queryBus.execute( + new GetLeadStatsQuery(user.sub), + ); + } + + @ApiOperation({ summary: 'Cập nhật trạng thái lead' }) + @ApiParam({ name: 'id', description: 'Lead ID' }) + @ApiResponse({ status: 200, description: 'Trạng thái đã được cập nhật' }) + @ApiResponse({ status: 400, description: 'Chuyển trạng thái không hợp lệ' }) + @ApiResponse({ status: 403, description: 'Không có quyền' }) + @ApiResponse({ status: 404, description: 'Không tìm thấy lead' }) + @Patch(':id/status') + async updateStatus( + @Param('id') id: string, + @Body() dto: UpdateLeadStatusDto, + @CurrentUser() user: JwtPayload, + ): Promise<{ updated: boolean }> { + await this.commandBus.execute( + new UpdateLeadStatusCommand(id, user.sub, dto.status), + ); + return { updated: true }; + } + + @ApiOperation({ summary: 'Xóa lead' }) + @ApiParam({ name: 'id', description: 'Lead ID' }) + @ApiResponse({ status: 200, description: 'Lead đã được xóa' }) + @ApiResponse({ status: 403, description: 'Không có quyền' }) + @ApiResponse({ status: 404, description: 'Không tìm thấy lead' }) + @Delete(':id') + async deleteLead( + @Param('id') id: string, + @CurrentUser() user: JwtPayload, + ): Promise<{ deleted: boolean }> { + await this.commandBus.execute(new DeleteLeadCommand(id, user.sub)); + return { deleted: true }; + } +} diff --git a/apps/api/src/modules/leads/presentation/dto/create-lead.dto.ts b/apps/api/src/modules/leads/presentation/dto/create-lead.dto.ts new file mode 100644 index 0000000..8fe4044 --- /dev/null +++ b/apps/api/src/modules/leads/presentation/dto/create-lead.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class CreateLeadDto { + @ApiProperty({ example: 'Nguyễn Văn A', description: 'Tên khách hàng tiềm năng' }) + @IsString() + @IsNotEmpty() + name!: string; + + @ApiProperty({ example: '0901234567', description: 'Số điện thoại' }) + @IsString() + @IsNotEmpty() + phone!: string; + + @ApiPropertyOptional({ example: 'nguyen@example.com', description: 'Email' }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiProperty({ example: 'website', description: 'Nguồn lead' }) + @IsString() + @IsNotEmpty() + source!: string; + + @ApiPropertyOptional({ example: 75, description: 'Điểm lead (0-100)' }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + score?: number; + + @ApiPropertyOptional({ description: 'Ghi chú bổ sung' }) + @IsOptional() + notes?: Record; +} diff --git a/apps/api/src/modules/leads/presentation/dto/list-leads.dto.ts b/apps/api/src/modules/leads/presentation/dto/list-leads.dto.ts new file mode 100644 index 0000000..c8e24fb --- /dev/null +++ b/apps/api/src/modules/leads/presentation/dto/list-leads.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; + +const LEAD_STATUSES = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED', 'LOST'] as const; + +export class ListLeadsDto { + @ApiPropertyOptional({ + enum: LEAD_STATUSES, + description: 'Lọc theo trạng thái', + }) + @IsOptional() + @IsIn(LEAD_STATUSES) + status?: string; + + @ApiPropertyOptional({ example: 1, description: 'Trang', default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page?: number; + + @ApiPropertyOptional({ example: 20, description: 'Số lượng mỗi trang', default: 20 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + limit?: number; +} diff --git a/apps/api/src/modules/leads/presentation/dto/update-lead-status.dto.ts b/apps/api/src/modules/leads/presentation/dto/update-lead-status.dto.ts new file mode 100644 index 0000000..fff444d --- /dev/null +++ b/apps/api/src/modules/leads/presentation/dto/update-lead-status.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; + +const LEAD_STATUSES = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED', 'LOST'] as const; + +export class UpdateLeadStatusDto { + @ApiProperty({ + enum: LEAD_STATUSES, + description: 'Trạng thái mới của lead', + example: 'CONTACTED', + }) + @IsIn(LEAD_STATUSES) + status!: string; +}