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