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

View File

@@ -0,0 +1,95 @@
import { InquiryCreatedEvent } from '../events/inquiry-created.event';
import { InquiryReadEvent } from '../events/inquiry-read.event';
import { InquiryEntity } from '../entities/inquiry.entity';
describe('InquiryEntity', () => {
describe('createNew', () => {
it('creates an inquiry with correct properties', () => {
const inquiry = InquiryEntity.createNew(
'inq-1',
'listing-1',
'user-1',
'Tôi muốn xem nhà',
'0901234567',
);
expect(inquiry.id).toBe('inq-1');
expect(inquiry.listingId).toBe('listing-1');
expect(inquiry.userId).toBe('user-1');
expect(inquiry.message).toBe('Tôi muốn xem nhà');
expect(inquiry.phone).toBe('0901234567');
expect(inquiry.isRead).toBe(false);
});
it('creates an inquiry with null phone', () => {
const inquiry = InquiryEntity.createNew(
'inq-2',
'listing-1',
'user-1',
'Cho tôi hỏi giá',
null,
);
expect(inquiry.phone).toBeNull();
});
it('emits InquiryCreatedEvent', () => {
const inquiry = InquiryEntity.createNew(
'inq-1',
'listing-1',
'user-1',
'Tôi muốn xem nhà',
'0901234567',
);
const events = inquiry.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(InquiryCreatedEvent);
const event = events[0] as InquiryCreatedEvent;
expect(event.aggregateId).toBe('inq-1');
expect(event.listingId).toBe('listing-1');
expect(event.userId).toBe('user-1');
});
});
describe('markAsRead', () => {
it('sets isRead to true', () => {
const inquiry = InquiryEntity.createNew(
'inq-1',
'listing-1',
'user-1',
'Tôi muốn xem nhà',
null,
);
expect(inquiry.isRead).toBe(false);
inquiry.markAsRead();
expect(inquiry.isRead).toBe(true);
});
it('emits InquiryReadEvent', () => {
const inquiry = InquiryEntity.createNew(
'inq-1',
'listing-1',
'user-1',
'Tôi muốn xem nhà',
null,
);
// Clear the creation event
inquiry.clearDomainEvents();
inquiry.markAsRead();
const events = inquiry.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(InquiryReadEvent);
const event = events[0] as InquiryReadEvent;
expect(event.aggregateId).toBe('inq-1');
expect(event.listingId).toBe('listing-1');
expect(event.userId).toBe('user-1');
});
});
});

View File

@@ -0,0 +1,62 @@
import { AggregateRoot } from '@modules/shared';
import { InquiryCreatedEvent } from '../events/inquiry-created.event';
import { InquiryReadEvent } from '../events/inquiry-read.event';
export interface InquiryProps {
listingId: string;
userId: string;
message: string;
phone: string | null;
isRead: boolean;
}
export class InquiryEntity extends AggregateRoot<string> {
private _listingId: string;
private _userId: string;
private _message: string;
private _phone: string | null;
private _isRead: boolean;
constructor(id: string, props: InquiryProps, createdAt?: Date) {
super(id, createdAt);
this._listingId = props.listingId;
this._userId = props.userId;
this._message = props.message;
this._phone = props.phone;
this._isRead = props.isRead;
}
get listingId(): string { return this._listingId; }
get userId(): string { return this._userId; }
get message(): string { return this._message; }
get phone(): string | null { return this._phone; }
get isRead(): boolean { return this._isRead; }
static createNew(
id: string,
listingId: string,
userId: string,
message: string,
phone: string | null,
): InquiryEntity {
const inquiry = new InquiryEntity(id, {
listingId,
userId,
message,
phone,
isRead: false,
});
inquiry.addDomainEvent(
new InquiryCreatedEvent(id, listingId, userId),
);
return inquiry;
}
markAsRead(): void {
this._isRead = true;
this.addDomainEvent(
new InquiryReadEvent(this.id, this._listingId, this._userId),
);
}
}

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class InquiryCreatedEvent implements DomainEvent {
readonly eventName = 'inquiry.created';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly listingId: string,
public readonly userId: string,
) {}
}

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class InquiryReadEvent implements DomainEvent {
readonly eventName = 'inquiry.read';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly listingId: string,
public readonly userId: string,
) {}
}

View File

@@ -0,0 +1,12 @@
export interface InquiryReadDto {
id: string;
listingId: string;
listingTitle: string;
userId: string;
userName: string;
userPhone: string;
message: string;
phone: string | null;
isRead: boolean;
createdAt: string;
}

View File

@@ -0,0 +1,21 @@
import { type InquiryEntity } from '../entities/inquiry.entity';
import { type InquiryReadDto } from './inquiry-read.dto';
export const INQUIRY_REPOSITORY = Symbol('INQUIRY_REPOSITORY');
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface IInquiryRepository {
findById(id: string): Promise<InquiryEntity | null>;
save(inquiry: InquiryEntity): Promise<void>;
markAsRead(id: string): Promise<void>;
findByListing(listingId: string, page: number, limit: number): Promise<PaginatedResult<InquiryReadDto>>;
findByAgent(agentId: string, page: number, limit: number): Promise<PaginatedResult<InquiryReadDto>>;
countUnreadByAgent(agentId: string): Promise<number>;
}

View File

@@ -0,0 +1,3 @@
export { InquiriesModule } from './inquiries.module';
export { INQUIRY_REPOSITORY, type IInquiryRepository } from './domain/repositories/inquiry.repository';
export { InquiryEntity } from './domain/entities/inquiry.entity';

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

View File

@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { CreateInquiryHandler } from './application/commands/create-inquiry/create-inquiry.handler';
import { MarkInquiryReadHandler } from './application/commands/mark-inquiry-read/mark-inquiry-read.handler';
import { GetInquiriesByAgentHandler } from './application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler';
import { GetInquiriesByListingHandler } from './application/queries/get-inquiries-by-listing/get-inquiries-by-listing.handler';
import { INQUIRY_REPOSITORY } from './domain/repositories/inquiry.repository';
import { PrismaInquiryRepository } from './infrastructure/repositories/prisma-inquiry.repository';
import { InquiriesController } from './presentation/controllers/inquiries.controller';
const CommandHandlers = [CreateInquiryHandler, MarkInquiryReadHandler];
const QueryHandlers = [
GetInquiriesByListingHandler,
GetInquiriesByAgentHandler,
];
@Module({
imports: [CqrsModule],
controllers: [InquiriesController],
providers: [
{ provide: INQUIRY_REPOSITORY, useClass: PrismaInquiryRepository },
...CommandHandlers,
...QueryHandlers,
],
exports: [INQUIRY_REPOSITORY],
})
export class InquiriesModule {}

View File

@@ -0,0 +1,120 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { CreateInquiryCommand } from '../../application/commands/create-inquiry/create-inquiry.command';
import { type CreateInquiryResult } from '../../application/commands/create-inquiry/create-inquiry.handler';
import { MarkInquiryReadCommand } from '../../application/commands/mark-inquiry-read/mark-inquiry-read.command';
import { GetInquiriesByAgentQuery } from '../../application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query';
import { GetInquiriesByListingQuery } from '../../application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query';
import type { InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
import type { PaginatedResult } from '../../domain/repositories/inquiry.repository';
import type { CreateInquiryDto } from '../dto/create-inquiry.dto';
import type { ListInquiriesDto } from '../dto/list-inquiries.dto';
@ApiTags('inquiries')
@Controller('inquiries')
export class InquiriesController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Create an inquiry for a listing' })
@ApiResponse({ status: 201, description: 'Inquiry created successfully' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Listing not found' })
@UseGuards(JwtAuthGuard)
@Post()
async createInquiry(
@Body() dto: CreateInquiryDto,
@CurrentUser() user: JwtPayload,
): Promise<CreateInquiryResult> {
return this.commandBus.execute(
new CreateInquiryCommand(
user.sub,
dto.listingId,
dto.message,
dto.phone ?? null,
),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'List inquiries by listing' })
@ApiParam({ name: 'listingId', description: 'Listing ID' })
@ApiResponse({ status: 200, description: 'Paginated list of inquiries' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Get('listing/:listingId')
async getByListing(
@Param('listingId') listingId: string,
@Query() dto: ListInquiriesDto,
): Promise<PaginatedResult<InquiryReadDto>> {
return this.queryBus.execute(
new GetInquiriesByListingQuery(
listingId,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'List inquiries for current agent' })
@ApiResponse({ status: 200, description: 'Paginated list of inquiries for agent' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — not an agent' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('AGENT')
@Get('agent/me')
async getMyInquiries(
@CurrentUser() user: JwtPayload,
@Query() dto: ListInquiriesDto,
): Promise<PaginatedResult<InquiryReadDto>> {
return this.queryBus.execute(
new GetInquiriesByAgentQuery(
user.sub,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Mark inquiry as read' })
@ApiParam({ name: 'id', description: 'Inquiry ID' })
@ApiResponse({ status: 200, description: 'Inquiry marked as read' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — not the listing agent' })
@ApiResponse({ status: 404, description: 'Inquiry not found' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('AGENT')
@Patch(':id/read')
async markAsRead(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<{ success: boolean }> {
await this.commandBus.execute(
new MarkInquiryReadCommand(id, user.sub),
);
return { success: true };
}
}

View File

@@ -0,0 +1,20 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateInquiryDto {
@ApiProperty({ description: 'ID of the listing' })
@IsString()
@IsNotEmpty()
listingId!: string;
@ApiProperty({ description: 'Tin nhắn yêu cầu tư vấn' })
@IsString()
@IsNotEmpty()
@MaxLength(2000)
message!: string;
@ApiPropertyOptional({ description: 'Số điện thoại liên hệ' })
@IsOptional()
@IsString()
phone?: string;
}

View File

@@ -0,0 +1,20 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListInquiriesDto {
@ApiPropertyOptional({ example: 1, description: 'Page number', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ example: 20, description: 'Items per page', default: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
limit?: number;
}