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,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}`);
}
}