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

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