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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 10:01:16 +07:00
parent a1a44ef8fb
commit d64bbe97e2
69 changed files with 3420 additions and 0 deletions

View File

@@ -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,

View File

@@ -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 {}

View File

@@ -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<typeof vi.fn> };
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();
});
});

View File

@@ -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<typeof vi.fn> };
let mockPrisma: {
review: { aggregate: ReturnType<typeof vi.fn> };
lead: { count: ReturnType<typeof vi.fn> };
listing: { count: ReturnType<typeof vi.fn> };
agent: { findUnique: ReturnType<typeof vi.fn> };
};
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);
});
});

View File

@@ -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<typeof vi.fn> };
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();
});
});
});

View File

@@ -0,0 +1,3 @@
export class RecalculateQualityScoreCommand {
constructor(public readonly agentId: string) {}
}

View File

@@ -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<RecalculateQualityScoreCommand>
{
private readonly logger = new Logger(RecalculateQualityScoreHandler.name);
constructor(
@Inject(AGENT_REPOSITORY)
private readonly agentRepo: IAgentRepository,
private readonly prisma: PrismaService,
) {}
async execute(command: RecalculateQualityScoreCommand): Promise<void> {
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})`,
);
}
}

View File

@@ -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<void> {
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<void> {
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),
);
}
}
}

View File

@@ -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<GetAgentDashboardQuery>
{
constructor(
@Inject(AGENT_REPOSITORY)
private readonly agentRepo: IAgentRepository,
) {}
async execute(query: GetAgentDashboardQuery): Promise<AgentDashboardData> {
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);
}
}

View File

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

View File

@@ -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);
});
});

View File

@@ -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<string, number>;
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<void>;
getDashboard(agentId: string): Promise<AgentDashboardData>;
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
export { AgentsModule } from './agents.module';
export {
AGENT_REPOSITORY,
type IAgentRepository,
type AgentDashboardData,
} from './domain/repositories/agent.repository';

View File

@@ -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<void> {
await this.prisma.agent.update({
where: { id: agentId },
data: { qualityScore: score },
});
}
async getDashboard(agentId: string): Promise<AgentDashboardData> {
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<string, number> = {};
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 };
}
}

View File

@@ -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<AgentDashboardData> {
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' };
}
}

View File

@@ -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<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn> };
};
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',
}),
);
});
});

View File

@@ -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<typeof vi.fn> };
let mockPrisma: {
agent: { findUnique: ReturnType<typeof vi.fn> };
};
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();
});
});

View File

@@ -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<typeof vi.fn> };
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);
});
});

View File

@@ -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<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn> };
agent: { findUnique: ReturnType<typeof vi.fn> };
};
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',
);
});
});

View File

@@ -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,
) {}
}

View File

@@ -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<CreateInquiryCommand> {
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<CreateInquiryResult> {
// 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(),
};
}
}

View File

@@ -0,0 +1,6 @@
export class MarkInquiryReadCommand {
constructor(
public readonly inquiryId: string,
public readonly agentUserId: string,
) {}
}

View File

@@ -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<MarkInquiryReadCommand> {
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<void> {
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}`);
}
}

View File

@@ -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<GetInquiriesByAgentQuery> {
constructor(
@Inject(INQUIRY_REPOSITORY) private readonly inquiryRepo: IInquiryRepository,
private readonly prisma: PrismaService,
) {}
async execute(query: GetInquiriesByAgentQuery): Promise<PaginatedResult<InquiryReadDto>> {
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,
);
}
}

View File

@@ -0,0 +1,7 @@
export class GetInquiriesByAgentQuery {
constructor(
public readonly agentUserId: string,
public readonly page: number,
public readonly limit: number,
) {}
}

View File

@@ -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<GetInquiriesByListingQuery> {
constructor(
@Inject(INQUIRY_REPOSITORY) private readonly inquiryRepo: IInquiryRepository,
) {}
async execute(query: GetInquiriesByListingQuery): Promise<PaginatedResult<InquiryReadDto>> {
return this.inquiryRepo.findByListing(
query.listingId,
query.page,
query.limit,
);
}
}

View File

@@ -0,0 +1,7 @@
export class GetInquiriesByListingQuery {
constructor(
public readonly listingId: string,
public readonly page: number,
public readonly limit: number,
) {}
}

View File

@@ -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');
});
});
});

View File

@@ -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<string> {
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),
);
}
}

View File

@@ -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,
) {}
}

View File

@@ -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,
) {}
}

View File

@@ -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;
}

View File

@@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface IInquiryRepository {
findById(id: string): Promise<InquiryEntity | null>;
save(inquiry: InquiryEntity): Promise<void>;
markAsRead(id: string): Promise<void>;
findByListing(listingId: string, page: number, limit: number): Promise<PaginatedResult<InquiryReadDto>>;
findByAgent(agentId: string, page: number, limit: number): Promise<PaginatedResult<InquiryReadDto>>;
countUnreadByAgent(agentId: string): Promise<number>;
}

View File

@@ -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';

View File

@@ -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<InquiryEntity | null> {
const inquiry = await this.prisma.inquiry.findUnique({ where: { id } });
return inquiry ? this.toDomain(inquiry) : null;
}
async save(entity: InquiryEntity): Promise<void> {
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<void> {
await this.prisma.inquiry.update({
where: { id },
data: { isRead: true },
});
}
async findByListing(
listingId: string,
page: number,
limit: number,
): Promise<PaginatedResult<InquiryReadDto>> {
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<PaginatedResult<InquiryReadDto>> {
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<number> {
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,
);
}
}

View File

@@ -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 {}

View File

@@ -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<CreateInquiryResult> {
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<PaginatedResult<InquiryReadDto>> {
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<PaginatedResult<InquiryReadDto>> {
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 };
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockPrisma: {
agent: { findUnique: ReturnType<typeof vi.fn> };
};
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();
});
});

View File

@@ -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<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockPrisma: {
agent: { findUnique: ReturnType<typeof vi.fn> };
};
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();
});
});

View File

@@ -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<typeof vi.fn> };
let mockPrisma: {
agent: { findUnique: ReturnType<typeof vi.fn> };
};
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();
});
});

View File

@@ -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<typeof vi.fn> };
let mockPrisma: {
agent: { findUnique: ReturnType<typeof vi.fn> };
};
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();
});
});

View File

@@ -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<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockPrisma: {
agent: { findUnique: ReturnType<typeof vi.fn> };
};
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',
);
});
});

View File

@@ -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,
) {}
}

View File

@@ -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<CreateLeadCommand> {
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<CreateLeadResult> {
// 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(),
};
}
}

View File

@@ -0,0 +1,6 @@
export class DeleteLeadCommand {
constructor(
public readonly leadId: string,
public readonly agentUserId: string,
) {}
}

View File

@@ -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<DeleteLeadCommand> {
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<void> {
// 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}`);
}
}

View File

@@ -0,0 +1,7 @@
export class UpdateLeadStatusCommand {
constructor(
public readonly leadId: string,
public readonly agentUserId: string,
public readonly newStatus: string,
) {}
}

View File

@@ -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<UpdateLeadStatusCommand> {
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<void> {
// 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}`);
}
}

View File

@@ -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<GetLeadStatsQuery> {
constructor(
@Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository,
private readonly prisma: PrismaService,
) {}
async execute(query: GetLeadStatsQuery): Promise<LeadStatsData> {
// 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);
}
}

View File

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

View File

@@ -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<GetLeadsByAgentQuery> {
constructor(
@Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository,
private readonly prisma: PrismaService,
) {}
async execute(query: GetLeadsByAgentQuery): Promise<PaginatedResult<LeadReadDto>> {
// 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,
);
}
}

View File

@@ -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,
) {}
}

View File

@@ -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');
});
});

View File

@@ -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<LeadStatus, LeadStatus[]> = {
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<string> {
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),
);
}
}

View File

@@ -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,
) {}
}

View File

@@ -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,
) {}
}

View File

@@ -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;
}

View File

@@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface LeadStatsData {
totalLeads: number;
byStatus: Record<string, number>;
conversionRate: number;
avgScore: number | null;
}
export interface ILeadRepository {
findById(id: string): Promise<LeadEntity | null>;
save(lead: LeadEntity): Promise<void>;
update(lead: LeadEntity): Promise<void>;
delete(id: string): Promise<void>;
findByAgent(agentId: string, status: string | null, page: number, limit: number): Promise<PaginatedResult<LeadReadDto>>;
getStatsByAgent(agentId: string): Promise<LeadStatsData>;
}

View File

@@ -0,0 +1,16 @@
import { Result, ValueObject } from '@modules/shared';
interface LeadScoreProps {
value: number;
}
export class LeadScore extends ValueObject<LeadScoreProps> {
get value(): number { return this.props.value; }
static create(value: number): Result<LeadScore, string> {
if (value < 0 || value > 100) {
return Result.err('Điểm lead phải từ 0 đến 100');
}
return Result.ok(new LeadScore({ value }));
}
}

View File

@@ -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';

View File

@@ -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<LeadEntity | null> {
const lead = await this.prisma.lead.findUnique({ where: { id } });
return lead ? this.toDomain(lead) : null;
}
async save(entity: LeadEntity): Promise<void> {
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<void> {
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<void> {
await this.prisma.lead.delete({ where: { id } });
}
async findByAgent(
agentId: string,
status: string | null,
page: number,
limit: number,
): Promise<PaginatedResult<LeadReadDto>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where: Record<string, unknown> = { 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<LeadStatsData> {
const leads = await this.prisma.lead.findMany({
where: { agentId },
select: { status: true, score: true },
});
const totalLeads = leads.length;
const byStatus: Record<string, number> = {};
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,
);
}
}

View File

@@ -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 {}

View File

@@ -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<CreateLeadResult> {
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<PaginatedResult<LeadReadDto>> {
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<LeadStatsData> {
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 };
}
}

View File

@@ -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<string, unknown>;
}

View File

@@ -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;
}

View File

@@ -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;
}