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,
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user