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:
25
apps/api/src/modules/agents/agents.module.ts
Normal file
25
apps/api/src/modules/agents/agents.module.ts
Normal 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 {}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
6
apps/api/src/modules/agents/index.ts
Normal file
6
apps/api/src/modules/agents/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { AgentsModule } from './agents.module';
|
||||
export {
|
||||
AGENT_REPOSITORY,
|
||||
type IAgentRepository,
|
||||
type AgentDashboardData,
|
||||
} from './domain/repositories/agent.repository';
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user