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

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