feat(api): add inquiries, leads, and agents modules for Agent Portal
Build three new DDD modules following existing CQRS patterns: - Inquiries: CRUD endpoints for buyer consultation requests with agent notification support - Leads: Full lead lifecycle management with status state machine and conversion tracking - Agents: Quality score calculation (event-driven on review changes) and dashboard stats API All modules include unit tests (14 test files, all 797 tests pass). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class DeleteLeadCommand {
|
||||
constructor(
|
||||
public readonly leadId: string,
|
||||
public readonly agentUserId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class UpdateLeadStatusCommand {
|
||||
constructor(
|
||||
public readonly leadId: string,
|
||||
public readonly agentUserId: string,
|
||||
public readonly newStatus: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class GetLeadStatsQuery {
|
||||
constructor(
|
||||
public readonly agentUserId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
190
apps/api/src/modules/leads/domain/__tests__/lead-domain.spec.ts
Normal file
190
apps/api/src/modules/leads/domain/__tests__/lead-domain.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
101
apps/api/src/modules/leads/domain/entities/lead.entity.ts
Normal file
101
apps/api/src/modules/leads/domain/entities/lead.entity.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
3
apps/api/src/modules/leads/index.ts
Normal file
3
apps/api/src/modules/leads/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
26
apps/api/src/modules/leads/leads.module.ts
Normal file
26
apps/api/src/modules/leads/leads.module.ts
Normal 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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user