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,145 @@
import { Injectable } from '@nestjs/common';
import type { Inquiry as PrismaInquiry } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { InquiryEntity } from '../../domain/entities/inquiry.entity';
import type { InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
import type { IInquiryRepository, PaginatedResult } from '../../domain/repositories/inquiry.repository';
@Injectable()
export class PrismaInquiryRepository implements IInquiryRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<InquiryEntity | null> {
const inquiry = await this.prisma.inquiry.findUnique({ where: { id } });
return inquiry ? this.toDomain(inquiry) : null;
}
async save(entity: InquiryEntity): Promise<void> {
await this.prisma.inquiry.create({
data: {
id: entity.id,
listingId: entity.listingId,
userId: entity.userId,
message: entity.message,
phone: entity.phone,
isRead: entity.isRead,
},
});
}
async markAsRead(id: string): Promise<void> {
await this.prisma.inquiry.update({
where: { id },
data: { isRead: true },
});
}
async findByListing(
listingId: string,
page: number,
limit: number,
): Promise<PaginatedResult<InquiryReadDto>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where = { listingId };
const [data, total] = await Promise.all([
this.prisma.inquiry.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: {
listing: { select: { id: true, property: { select: { title: true } } } },
user: { select: { id: true, fullName: true, phone: true } },
},
}),
this.prisma.inquiry.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
listingId: r.listingId,
listingTitle: r.listing.property.title,
userId: r.userId,
userName: r.user.fullName,
userPhone: r.user.phone,
message: r.message,
phone: r.phone,
isRead: r.isRead,
createdAt: r.createdAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async findByAgent(
agentId: string,
page: number,
limit: number,
): Promise<PaginatedResult<InquiryReadDto>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where = { listing: { agentId } };
const [data, total] = await Promise.all([
this.prisma.inquiry.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: {
listing: { select: { id: true, property: { select: { title: true } } } },
user: { select: { id: true, fullName: true, phone: true } },
},
}),
this.prisma.inquiry.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
listingId: r.listingId,
listingTitle: r.listing.property.title,
userId: r.userId,
userName: r.user.fullName,
userPhone: r.user.phone,
message: r.message,
phone: r.phone,
isRead: r.isRead,
createdAt: r.createdAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async countUnreadByAgent(agentId: string): Promise<number> {
return this.prisma.inquiry.count({
where: {
isRead: false,
listing: { agentId },
},
});
}
private toDomain(raw: PrismaInquiry): InquiryEntity {
return new InquiryEntity(
raw.id,
{
listingId: raw.listingId,
userId: raw.userId,
message: raw.message,
phone: raw.phone,
isRead: raw.isRead,
},
raw.createdAt,
);
}
}