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,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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class MarkInquiryReadCommand {
|
||||
constructor(
|
||||
public readonly inquiryId: string,
|
||||
public readonly agentUserId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class GetInquiriesByAgentQuery {
|
||||
constructor(
|
||||
public readonly agentUserId: string,
|
||||
public readonly page: number,
|
||||
public readonly limit: number,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class GetInquiriesByListingQuery {
|
||||
constructor(
|
||||
public readonly listingId: string,
|
||||
public readonly page: number,
|
||||
public readonly limit: number,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
3
apps/api/src/modules/inquiries/index.ts
Normal file
3
apps/api/src/modules/inquiries/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
28
apps/api/src/modules/inquiries/inquiries.module.ts
Normal file
28
apps/api/src/modules/inquiries/inquiries.module.ts
Normal 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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user