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,95 @@
import type { EventBus } from '@nestjs/cqrs';
import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository';
import { CreateInquiryCommand } from '../commands/create-inquiry/create-inquiry.command';
import { CreateInquiryHandler } from '../commands/create-inquiry/create-inquiry.handler';
describe('CreateInquiryHandler', () => {
let handler: CreateInquiryHandler;
let mockInquiryRepo: { [K in keyof IInquiryRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn> };
};
beforeEach(() => {
mockInquiryRepo = {
findById: vi.fn(),
save: vi.fn(),
markAsRead: vi.fn(),
findByListing: vi.fn(),
findByAgent: vi.fn(),
countUnreadByAgent: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
mockPrisma = {
listing: { findUnique: vi.fn() },
};
handler = new CreateInquiryHandler(
mockInquiryRepo as any,
mockEventBus as unknown as EventBus,
mockPrisma as any,
);
});
it('creates an inquiry successfully', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' });
mockInquiryRepo.save.mockResolvedValue(undefined);
const command = new CreateInquiryCommand(
'user-1',
'listing-1',
'Tôi muốn xem nhà',
'0901234567',
);
const result = await handler.execute(command);
expect(result.id).toBeDefined();
expect(result.listingId).toBe('listing-1');
expect(result.createdAt).toBeDefined();
expect(mockInquiryRepo.save).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('throws NotFoundException when listing not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
const command = new CreateInquiryCommand(
'user-1',
'listing-not-exist',
'Tôi muốn xem nhà',
null,
);
await expect(handler.execute(command)).rejects.toThrow(
"Listing with id 'listing-not-exist' not found",
);
expect(mockInquiryRepo.save).not.toHaveBeenCalled();
});
it('publishes domain events after saving', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' });
mockInquiryRepo.save.mockResolvedValue(undefined);
const command = new CreateInquiryCommand(
'user-1',
'listing-1',
'Cho tôi hỏi giá',
null,
);
await handler.execute(command);
expect(mockEventBus.publish).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'inquiry.created',
listingId: 'listing-1',
userId: 'user-1',
}),
);
});
});

View File

@@ -0,0 +1,76 @@
import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository';
import { GetInquiriesByAgentQuery } from '../queries/get-inquiries-by-agent/get-inquiries-by-agent.query';
import { GetInquiriesByAgentHandler } from '../queries/get-inquiries-by-agent/get-inquiries-by-agent.handler';
describe('GetInquiriesByAgentHandler', () => {
let handler: GetInquiriesByAgentHandler;
let mockInquiryRepo: { [K in keyof IInquiryRepository]: ReturnType<typeof vi.fn> };
let mockPrisma: {
agent: { findUnique: ReturnType<typeof vi.fn> };
};
beforeEach(() => {
mockInquiryRepo = {
findById: vi.fn(),
save: vi.fn(),
markAsRead: vi.fn(),
findByListing: vi.fn(),
findByAgent: vi.fn(),
countUnreadByAgent: vi.fn(),
};
mockPrisma = {
agent: { findUnique: vi.fn() },
};
handler = new GetInquiriesByAgentHandler(
mockInquiryRepo as any,
mockPrisma as any,
);
});
it('returns paginated results', async () => {
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' });
const mockResult = {
data: [
{
id: 'inq-1',
listingId: 'listing-1',
userId: 'user-1',
message: 'Tôi muốn xem nhà',
phone: '0901234567',
isRead: false,
createdAt: new Date(),
},
],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
mockInquiryRepo.findByAgent.mockResolvedValue(mockResult);
const query = new GetInquiriesByAgentQuery('agent-user-1', 1, 20);
const result = await handler.execute(query);
expect(result).toEqual(mockResult);
expect(result.data).toHaveLength(1);
expect(mockPrisma.agent.findUnique).toHaveBeenCalledWith({
where: { userId: 'agent-user-1' },
select: { id: true },
});
expect(mockInquiryRepo.findByAgent).toHaveBeenCalledWith('agent-1', 1, 20);
});
it('throws NotFoundException when agent not found for user', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(null);
const query = new GetInquiriesByAgentQuery('not-an-agent', 1, 20);
await expect(handler.execute(query)).rejects.toThrow(
"Agent with id 'not-an-agent' not found",
);
expect(mockInquiryRepo.findByAgent).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,68 @@
import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository';
import { GetInquiriesByListingQuery } from '../queries/get-inquiries-by-listing/get-inquiries-by-listing.query';
import { GetInquiriesByListingHandler } from '../queries/get-inquiries-by-listing/get-inquiries-by-listing.handler';
describe('GetInquiriesByListingHandler', () => {
let handler: GetInquiriesByListingHandler;
let mockInquiryRepo: { [K in keyof IInquiryRepository]: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockInquiryRepo = {
findById: vi.fn(),
save: vi.fn(),
markAsRead: vi.fn(),
findByListing: vi.fn(),
findByAgent: vi.fn(),
countUnreadByAgent: vi.fn(),
};
handler = new GetInquiriesByListingHandler(mockInquiryRepo as any);
});
it('returns paginated results', async () => {
const mockResult = {
data: [
{
id: 'inq-1',
listingId: 'listing-1',
userId: 'user-1',
message: 'Tôi muốn xem nhà',
phone: '0901234567',
isRead: false,
createdAt: new Date(),
},
],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
mockInquiryRepo.findByListing.mockResolvedValue(mockResult);
const query = new GetInquiriesByListingQuery('listing-1', 1, 20);
const result = await handler.execute(query);
expect(result).toEqual(mockResult);
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(mockInquiryRepo.findByListing).toHaveBeenCalledWith('listing-1', 1, 20);
});
it('returns empty data when no inquiries found', async () => {
const mockResult = {
data: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
};
mockInquiryRepo.findByListing.mockResolvedValue(mockResult);
const query = new GetInquiriesByListingQuery('listing-empty', 1, 20);
const result = await handler.execute(query);
expect(result.data).toHaveLength(0);
expect(result.total).toBe(0);
});
});

View File

@@ -0,0 +1,126 @@
import type { EventBus } from '@nestjs/cqrs';
import { InquiryEntity } from '../../domain/entities/inquiry.entity';
import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository';
import { MarkInquiryReadCommand } from '../commands/mark-inquiry-read/mark-inquiry-read.command';
import { MarkInquiryReadHandler } from '../commands/mark-inquiry-read/mark-inquiry-read.handler';
describe('MarkInquiryReadHandler', () => {
let handler: MarkInquiryReadHandler;
let mockInquiryRepo: { [K in keyof IInquiryRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn> };
agent: { findUnique: ReturnType<typeof vi.fn> };
};
beforeEach(() => {
mockInquiryRepo = {
findById: vi.fn(),
save: vi.fn(),
markAsRead: vi.fn(),
findByListing: vi.fn(),
findByAgent: vi.fn(),
countUnreadByAgent: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
mockPrisma = {
listing: { findUnique: vi.fn() },
agent: { findUnique: vi.fn() },
};
handler = new MarkInquiryReadHandler(
mockInquiryRepo as any,
mockEventBus as unknown as EventBus,
mockPrisma as any,
);
});
it('marks an inquiry as read successfully', async () => {
const inquiry = new InquiryEntity('inq-1', {
listingId: 'listing-1',
userId: 'user-1',
message: 'Tôi muốn xem nhà',
phone: null,
isRead: false,
});
mockInquiryRepo.findById.mockResolvedValue(inquiry);
mockPrisma.listing.findUnique.mockResolvedValue({ agentId: 'agent-1' });
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' });
mockInquiryRepo.markAsRead.mockResolvedValue(undefined);
const command = new MarkInquiryReadCommand('inq-1', 'agent-user-1');
await handler.execute(command);
expect(mockInquiryRepo.markAsRead).toHaveBeenCalledWith('inq-1');
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('throws NotFoundException when inquiry not found', async () => {
mockInquiryRepo.findById.mockResolvedValue(null);
const command = new MarkInquiryReadCommand('inq-not-exist', 'agent-user-1');
await expect(handler.execute(command)).rejects.toThrow(
"Inquiry with id 'inq-not-exist' not found",
);
});
it('throws NotFoundException when listing not found', async () => {
const inquiry = new InquiryEntity('inq-1', {
listingId: 'listing-1',
userId: 'user-1',
message: 'Test',
phone: null,
isRead: false,
});
mockInquiryRepo.findById.mockResolvedValue(inquiry);
mockPrisma.listing.findUnique.mockResolvedValue(null);
const command = new MarkInquiryReadCommand('inq-1', 'agent-user-1');
await expect(handler.execute(command)).rejects.toThrow(
"Listing with id 'listing-1' not found",
);
});
it('throws ForbiddenException when user is not the listing agent', async () => {
const inquiry = new InquiryEntity('inq-1', {
listingId: 'listing-1',
userId: 'user-1',
message: 'Test',
phone: null,
isRead: false,
});
mockInquiryRepo.findById.mockResolvedValue(inquiry);
mockPrisma.listing.findUnique.mockResolvedValue({ agentId: 'agent-1' });
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-other' });
const command = new MarkInquiryReadCommand('inq-1', 'agent-user-other');
await expect(handler.execute(command)).rejects.toThrow(
'Bạn không có quyền đánh dấu yêu cầu tư vấn này',
);
});
it('throws ForbiddenException when agent not found for user', async () => {
const inquiry = new InquiryEntity('inq-1', {
listingId: 'listing-1',
userId: 'user-1',
message: 'Test',
phone: null,
isRead: false,
});
mockInquiryRepo.findById.mockResolvedValue(inquiry);
mockPrisma.listing.findUnique.mockResolvedValue({ agentId: 'agent-1' });
mockPrisma.agent.findUnique.mockResolvedValue(null);
const command = new MarkInquiryReadCommand('inq-1', 'not-an-agent');
await expect(handler.execute(command)).rejects.toThrow(
'Bạn không có quyền đánh dấu yêu cầu tư vấn này',
);
});
});

View File

@@ -0,0 +1,8 @@
export class CreateInquiryCommand {
constructor(
public readonly userId: string,
public readonly listingId: string,
public readonly message: string,
public readonly phone: string | null,
) {}
}

View File

@@ -0,0 +1,61 @@
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { NotFoundException } from '@modules/shared';
import { type PrismaService } from '@modules/shared';
import { InquiryEntity } from '../../../domain/entities/inquiry.entity';
import { INQUIRY_REPOSITORY, type IInquiryRepository } from '../../../domain/repositories/inquiry.repository';
import { CreateInquiryCommand } from './create-inquiry.command';
export interface CreateInquiryResult {
id: string;
listingId: string;
createdAt: string;
}
@CommandHandler(CreateInquiryCommand)
export class CreateInquiryHandler implements ICommandHandler<CreateInquiryCommand> {
private readonly logger = new Logger(CreateInquiryHandler.name);
constructor(
@Inject(INQUIRY_REPOSITORY) private readonly inquiryRepo: IInquiryRepository,
private readonly eventBus: EventBus,
private readonly prisma: PrismaService,
) {}
async execute(command: CreateInquiryCommand): Promise<CreateInquiryResult> {
// Validate listing exists
const listing = await this.prisma.listing.findUnique({
where: { id: command.listingId },
select: { id: true },
});
if (!listing) {
throw new NotFoundException('Listing', command.listingId);
}
const id = createId();
const inquiry = InquiryEntity.createNew(
id,
command.listingId,
command.userId,
command.message,
command.phone,
);
await this.inquiryRepo.save(inquiry);
// Publish domain events
const events = inquiry.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
this.logger.log(`Inquiry ${id} created by user ${command.userId} for listing ${command.listingId}`);
return {
id,
listingId: command.listingId,
createdAt: inquiry.createdAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,6 @@
export class MarkInquiryReadCommand {
constructor(
public readonly inquiryId: string,
public readonly agentUserId: string,
) {}
}

View File

@@ -0,0 +1,52 @@
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenException, NotFoundException } from '@modules/shared';
import { type PrismaService } from '@modules/shared';
import { INQUIRY_REPOSITORY, type IInquiryRepository } from '../../../domain/repositories/inquiry.repository';
import { MarkInquiryReadCommand } from './mark-inquiry-read.command';
@CommandHandler(MarkInquiryReadCommand)
export class MarkInquiryReadHandler implements ICommandHandler<MarkInquiryReadCommand> {
private readonly logger = new Logger(MarkInquiryReadHandler.name);
constructor(
@Inject(INQUIRY_REPOSITORY) private readonly inquiryRepo: IInquiryRepository,
private readonly eventBus: EventBus,
private readonly prisma: PrismaService,
) {}
async execute(command: MarkInquiryReadCommand): Promise<void> {
const inquiry = await this.inquiryRepo.findById(command.inquiryId);
if (!inquiry) {
throw new NotFoundException('Inquiry', command.inquiryId);
}
// Verify the requesting user is the listing's agent
const listing = await this.prisma.listing.findUnique({
where: { id: inquiry.listingId },
select: { agentId: true },
});
if (!listing) {
throw new NotFoundException('Listing', inquiry.listingId);
}
const agent = await this.prisma.agent.findUnique({
where: { userId: command.agentUserId },
select: { id: true },
});
if (!agent || listing.agentId !== agent.id) {
throw new ForbiddenException('Bạn không có quyền đánh dấu yêu cầu tư vấn này');
}
inquiry.markAsRead();
await this.inquiryRepo.markAsRead(command.inquiryId);
// Publish domain events
const events = inquiry.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
this.logger.log(`Inquiry ${command.inquiryId} marked as read by agent ${command.agentUserId}`);
}
}

View File

@@ -0,0 +1,31 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared';
import { type PrismaService } from '@modules/shared';
import { type InquiryReadDto } from '../../../domain/repositories/inquiry-read.dto';
import { INQUIRY_REPOSITORY, type IInquiryRepository, type PaginatedResult } from '../../../domain/repositories/inquiry.repository';
import { GetInquiriesByAgentQuery } from './get-inquiries-by-agent.query';
@QueryHandler(GetInquiriesByAgentQuery)
export class GetInquiriesByAgentHandler implements IQueryHandler<GetInquiriesByAgentQuery> {
constructor(
@Inject(INQUIRY_REPOSITORY) private readonly inquiryRepo: IInquiryRepository,
private readonly prisma: PrismaService,
) {}
async execute(query: GetInquiriesByAgentQuery): Promise<PaginatedResult<InquiryReadDto>> {
const agent = await this.prisma.agent.findUnique({
where: { userId: query.agentUserId },
select: { id: true },
});
if (!agent) {
throw new NotFoundException('Agent', query.agentUserId);
}
return this.inquiryRepo.findByAgent(
agent.id,
query.page,
query.limit,
);
}
}

View File

@@ -0,0 +1,7 @@
export class GetInquiriesByAgentQuery {
constructor(
public readonly agentUserId: string,
public readonly page: number,
public readonly limit: number,
) {}
}

View File

@@ -0,0 +1,20 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { type InquiryReadDto } from '../../../domain/repositories/inquiry-read.dto';
import { INQUIRY_REPOSITORY, type IInquiryRepository, type PaginatedResult } from '../../../domain/repositories/inquiry.repository';
import { GetInquiriesByListingQuery } from './get-inquiries-by-listing.query';
@QueryHandler(GetInquiriesByListingQuery)
export class GetInquiriesByListingHandler implements IQueryHandler<GetInquiriesByListingQuery> {
constructor(
@Inject(INQUIRY_REPOSITORY) private readonly inquiryRepo: IInquiryRepository,
) {}
async execute(query: GetInquiriesByListingQuery): Promise<PaginatedResult<InquiryReadDto>> {
return this.inquiryRepo.findByListing(
query.listingId,
query.page,
query.limit,
);
}
}

View File

@@ -0,0 +1,7 @@
export class GetInquiriesByListingQuery {
constructor(
public readonly listingId: string,
public readonly page: number,
public readonly limit: number,
) {}
}