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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export class RecalculateQualityScoreCommand {
|
||||
constructor(public readonly agentId: string) {}
|
||||
}
|
||||
@@ -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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetAgentDashboardQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
Reference in New Issue
Block a user