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