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:
@@ -4,9 +4,12 @@ import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup';
|
||||
import { AdminModule } from '@modules/admin';
|
||||
import { AgentsModule } from '@modules/agents';
|
||||
import { AnalyticsModule } from '@modules/analytics';
|
||||
import { AuthModule } from '@modules/auth';
|
||||
import { HealthModule } from '@modules/health';
|
||||
import { InquiriesModule } from '@modules/inquiries';
|
||||
import { LeadsModule } from '@modules/leads';
|
||||
import { ListingsModule } from '@modules/listings';
|
||||
import { McpIntegrationModule } from '@modules/mcp';
|
||||
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
||||
@@ -28,6 +31,9 @@ import { AppController } from './app.controller';
|
||||
SharedModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
AgentsModule,
|
||||
InquiriesModule,
|
||||
LeadsModule,
|
||||
ListingsModule,
|
||||
ReviewsModule,
|
||||
SearchModule,
|
||||
|
||||
25
apps/api/src/modules/agents/agents.module.ts
Normal file
25
apps/api/src/modules/agents/agents.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { RecalculateQualityScoreHandler } from './application/commands/recalculate-quality-score/recalculate-quality-score.handler';
|
||||
import { ReviewEventsListener } from './application/listeners/review-events.listener';
|
||||
import { GetAgentDashboardHandler } from './application/queries/get-agent-dashboard/get-agent-dashboard.handler';
|
||||
import { AGENT_REPOSITORY } from './domain/repositories/agent.repository';
|
||||
import { PrismaAgentRepository } from './infrastructure/repositories/prisma-agent.repository';
|
||||
import { AgentsController } from './presentation/controllers/agents.controller';
|
||||
|
||||
const CommandHandlers = [RecalculateQualityScoreHandler];
|
||||
|
||||
const QueryHandlers = [GetAgentDashboardHandler];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
controllers: [AgentsController],
|
||||
providers: [
|
||||
{ provide: AGENT_REPOSITORY, useClass: PrismaAgentRepository },
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
ReviewEventsListener,
|
||||
],
|
||||
exports: [AGENT_REPOSITORY],
|
||||
})
|
||||
export class AgentsModule {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { IAgentRepository, AgentDashboardData } from '../../domain/repositories/agent.repository';
|
||||
import { GetAgentDashboardQuery } from '../queries/get-agent-dashboard/get-agent-dashboard.query';
|
||||
import { GetAgentDashboardHandler } from '../queries/get-agent-dashboard/get-agent-dashboard.handler';
|
||||
|
||||
describe('GetAgentDashboardHandler', () => {
|
||||
let handler: GetAgentDashboardHandler;
|
||||
let mockAgentRepo: { [K in keyof IAgentRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockAgentRepo = {
|
||||
findByUserId: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
updateQualityScore: vi.fn(),
|
||||
getDashboard: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetAgentDashboardHandler(mockAgentRepo as any);
|
||||
});
|
||||
|
||||
it('returns dashboard data', async () => {
|
||||
mockAgentRepo.findByUserId.mockResolvedValue({
|
||||
id: 'agent-1',
|
||||
userId: 'user-1',
|
||||
qualityScore: 85,
|
||||
});
|
||||
|
||||
const mockDashboard: AgentDashboardData = {
|
||||
agentId: 'agent-1',
|
||||
qualityScore: 85,
|
||||
totalDeals: 12,
|
||||
responseTimeAvg: 600,
|
||||
isVerified: true,
|
||||
totalLeads: 30,
|
||||
leadsByStatus: { NEW: 5, CONTACTED: 10, QUALIFIED: 5, NEGOTIATING: 3, CONVERTED: 5, LOST: 2 },
|
||||
conversionRate: 0.167,
|
||||
totalInquiries: 45,
|
||||
unreadInquiries: 3,
|
||||
totalListings: 15,
|
||||
activeListings: 10,
|
||||
avgReviewRating: 4.5,
|
||||
totalReviews: 20,
|
||||
};
|
||||
mockAgentRepo.getDashboard.mockResolvedValue(mockDashboard);
|
||||
|
||||
const query = new GetAgentDashboardQuery('user-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toEqual(mockDashboard);
|
||||
expect(result.agentId).toBe('agent-1');
|
||||
expect(result.qualityScore).toBe(85);
|
||||
expect(mockAgentRepo.findByUserId).toHaveBeenCalledWith('user-1');
|
||||
expect(mockAgentRepo.getDashboard).toHaveBeenCalledWith('agent-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundException when agent not found', async () => {
|
||||
mockAgentRepo.findByUserId.mockResolvedValue(null);
|
||||
|
||||
const query = new GetAgentDashboardQuery('not-an-agent');
|
||||
|
||||
await expect(handler.execute(query)).rejects.toThrow(
|
||||
'Không tìm thấy thông tin môi giới',
|
||||
);
|
||||
expect(mockAgentRepo.getDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { IAgentRepository } from '../../domain/repositories/agent.repository';
|
||||
import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command';
|
||||
import { RecalculateQualityScoreHandler } from '../commands/recalculate-quality-score/recalculate-quality-score.handler';
|
||||
|
||||
describe('RecalculateQualityScoreHandler', () => {
|
||||
let handler: RecalculateQualityScoreHandler;
|
||||
let mockAgentRepo: { [K in keyof IAgentRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: {
|
||||
review: { aggregate: ReturnType<typeof vi.fn> };
|
||||
lead: { count: ReturnType<typeof vi.fn> };
|
||||
listing: { count: ReturnType<typeof vi.fn> };
|
||||
agent: { findUnique: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockAgentRepo = {
|
||||
findByUserId: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
updateQualityScore: vi.fn(),
|
||||
getDashboard: vi.fn(),
|
||||
};
|
||||
|
||||
mockPrisma = {
|
||||
review: { aggregate: vi.fn() },
|
||||
lead: { count: vi.fn() },
|
||||
listing: { count: vi.fn() },
|
||||
agent: { findUnique: vi.fn() },
|
||||
};
|
||||
|
||||
handler = new RecalculateQualityScoreHandler(
|
||||
mockAgentRepo as any,
|
||||
mockPrisma as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('recalculates quality score successfully', async () => {
|
||||
mockAgentRepo.findById.mockResolvedValue({ id: 'agent-1', userId: 'user-1', qualityScore: 50 });
|
||||
mockPrisma.review.aggregate.mockResolvedValue({
|
||||
_avg: { rating: 4.5 },
|
||||
_count: { rating: 10 },
|
||||
});
|
||||
mockPrisma.lead.count
|
||||
.mockResolvedValueOnce(20) // totalLeads
|
||||
.mockResolvedValueOnce(5); // convertedLeads
|
||||
mockPrisma.listing.count
|
||||
.mockResolvedValueOnce(10) // totalListings
|
||||
.mockResolvedValueOnce(7); // activeListings
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: 900 }); // 15 min
|
||||
|
||||
const command = new RecalculateQualityScoreCommand('agent-1');
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockAgentRepo.updateQualityScore).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentRepo.updateQualityScore).toHaveBeenCalledWith(
|
||||
'agent-1',
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
// Verify the score value is reasonable
|
||||
const actualScore = mockAgentRepo.updateQualityScore.mock.calls[0][1];
|
||||
expect(actualScore).toBeGreaterThan(0);
|
||||
expect(actualScore).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('skips recalculation when agent not found', async () => {
|
||||
mockAgentRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new RecalculateQualityScoreCommand('non-existent');
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockAgentRepo.updateQualityScore).not.toHaveBeenCalled();
|
||||
expect(mockPrisma.review.aggregate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates agent record with calculated score', async () => {
|
||||
mockAgentRepo.findById.mockResolvedValue({ id: 'agent-1', userId: 'user-1', qualityScore: 0 });
|
||||
mockPrisma.review.aggregate.mockResolvedValue({
|
||||
_avg: { rating: 5 },
|
||||
_count: { rating: 20 },
|
||||
});
|
||||
mockPrisma.lead.count
|
||||
.mockResolvedValueOnce(10)
|
||||
.mockResolvedValueOnce(10); // 100% conversion
|
||||
mockPrisma.listing.count
|
||||
.mockResolvedValueOnce(5)
|
||||
.mockResolvedValueOnce(5); // 100% active
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: 0 }); // instant response
|
||||
|
||||
const command = new RecalculateQualityScoreCommand('agent-1');
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockAgentRepo.updateQualityScore).toHaveBeenCalledWith('agent-1', 100);
|
||||
});
|
||||
|
||||
it('handles null response time avg from agent record', async () => {
|
||||
mockAgentRepo.findById.mockResolvedValue({ id: 'agent-1', userId: 'user-1', qualityScore: 0 });
|
||||
mockPrisma.review.aggregate.mockResolvedValue({
|
||||
_avg: { rating: null },
|
||||
_count: { rating: 0 },
|
||||
});
|
||||
mockPrisma.lead.count.mockResolvedValue(0);
|
||||
mockPrisma.listing.count.mockResolvedValue(0);
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: null });
|
||||
|
||||
const command = new RecalculateQualityScoreCommand('agent-1');
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
// no reviews (50*0.4=20), null response (50*0.3=15), 0 conversion, 0 listing
|
||||
expect(mockAgentRepo.updateQualityScore).toHaveBeenCalledWith('agent-1', 35);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { CommandBus } from '@nestjs/cqrs';
|
||||
import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command';
|
||||
import { ReviewEventsListener } from '../listeners/review-events.listener';
|
||||
|
||||
describe('ReviewEventsListener', () => {
|
||||
let listener: ReviewEventsListener;
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockCommandBus = { execute: vi.fn() };
|
||||
listener = new ReviewEventsListener(mockCommandBus as unknown as CommandBus);
|
||||
});
|
||||
|
||||
describe('onReviewCreated', () => {
|
||||
it('dispatches recalculate command when targetType is AGENT', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue(undefined);
|
||||
|
||||
await listener.onReviewCreated({
|
||||
targetType: 'AGENT',
|
||||
targetId: 'agent-1',
|
||||
});
|
||||
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledWith(
|
||||
expect.any(RecalculateQualityScoreCommand),
|
||||
);
|
||||
|
||||
const command = mockCommandBus.execute.mock.calls[0][0] as RecalculateQualityScoreCommand;
|
||||
expect(command.agentId).toBe('agent-1');
|
||||
});
|
||||
|
||||
it('ignores events when targetType is not AGENT', async () => {
|
||||
await listener.onReviewCreated({
|
||||
targetType: 'LISTING',
|
||||
targetId: 'listing-1',
|
||||
});
|
||||
|
||||
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores events when targetType is USER', async () => {
|
||||
await listener.onReviewCreated({
|
||||
targetType: 'USER',
|
||||
targetId: 'user-1',
|
||||
});
|
||||
|
||||
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onReviewDeleted', () => {
|
||||
it('dispatches recalculate command when targetType is AGENT', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue(undefined);
|
||||
|
||||
await listener.onReviewDeleted({
|
||||
targetType: 'AGENT',
|
||||
targetId: 'agent-2',
|
||||
});
|
||||
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||
|
||||
const command = mockCommandBus.execute.mock.calls[0][0] as RecalculateQualityScoreCommand;
|
||||
expect(command.agentId).toBe('agent-2');
|
||||
});
|
||||
|
||||
it('ignores events when targetType is not AGENT', async () => {
|
||||
await listener.onReviewDeleted({
|
||||
targetType: 'PROPERTY',
|
||||
targetId: 'prop-1',
|
||||
});
|
||||
|
||||
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export class RecalculateQualityScoreCommand {
|
||||
constructor(public readonly agentId: string) {}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
AGENT_REPOSITORY,
|
||||
type IAgentRepository,
|
||||
} from '../../../domain/repositories/agent.repository';
|
||||
import { QualityScoreCalculator } from '../../../domain/services/quality-score.service';
|
||||
import { RecalculateQualityScoreCommand } from './recalculate-quality-score.command';
|
||||
|
||||
@CommandHandler(RecalculateQualityScoreCommand)
|
||||
export class RecalculateQualityScoreHandler
|
||||
implements ICommandHandler<RecalculateQualityScoreCommand>
|
||||
{
|
||||
private readonly logger = new Logger(RecalculateQualityScoreHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(AGENT_REPOSITORY)
|
||||
private readonly agentRepo: IAgentRepository,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async execute(command: RecalculateQualityScoreCommand): Promise<void> {
|
||||
const agent = await this.agentRepo.findById(command.agentId);
|
||||
if (!agent) {
|
||||
this.logger.warn(`Agent ${command.agentId} not found, skipping recalculation`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch review stats for this agent
|
||||
const reviewStats = await this.prisma.review.aggregate({
|
||||
where: { targetType: 'AGENT', targetId: command.agentId },
|
||||
_avg: { rating: true },
|
||||
_count: { rating: true },
|
||||
});
|
||||
|
||||
const avgRating = reviewStats._avg.rating ?? 0;
|
||||
const totalReviews = reviewStats._count.rating;
|
||||
|
||||
// Fetch lead conversion rate
|
||||
const [totalLeads, convertedLeads] = await Promise.all([
|
||||
this.prisma.lead.count({ where: { agentId: command.agentId } }),
|
||||
this.prisma.lead.count({
|
||||
where: { agentId: command.agentId, status: 'CONVERTED' },
|
||||
}),
|
||||
]);
|
||||
const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0;
|
||||
|
||||
// Fetch listing activity ratio
|
||||
const [totalListings, activeListings] = await Promise.all([
|
||||
this.prisma.listing.count({
|
||||
where: { agentId: command.agentId },
|
||||
}),
|
||||
this.prisma.listing.count({
|
||||
where: { agentId: command.agentId, status: 'ACTIVE' },
|
||||
}),
|
||||
]);
|
||||
const activeListingRatio =
|
||||
totalListings > 0 ? activeListings / totalListings : 0;
|
||||
|
||||
// Fetch response time from agent record
|
||||
const agentRecord = await this.prisma.agent.findUnique({
|
||||
where: { id: command.agentId },
|
||||
select: { responseTimeAvg: true },
|
||||
});
|
||||
|
||||
const score = QualityScoreCalculator.calculate({
|
||||
avgRating,
|
||||
totalReviews,
|
||||
responseTimeAvg: agentRecord?.responseTimeAvg ?? null,
|
||||
conversionRate,
|
||||
activeListingRatio,
|
||||
});
|
||||
|
||||
await this.agentRepo.updateQualityScore(command.agentId, score);
|
||||
|
||||
this.logger.log(
|
||||
`Quality score recalculated for agent ${command.agentId}: ${score} ` +
|
||||
`(rating=${avgRating.toFixed(2)}, reviews=${totalReviews}, ` +
|
||||
`conversion=${(conversionRate * 100).toFixed(1)}%, ` +
|
||||
`activeListings=${activeListings}/${totalListings})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { type CommandBus } from '@nestjs/cqrs';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewEventsListener {
|
||||
private readonly logger = new Logger(ReviewEventsListener.name);
|
||||
|
||||
constructor(private readonly commandBus: CommandBus) {}
|
||||
|
||||
@OnEvent('review.created', { async: true })
|
||||
async onReviewCreated(event: {
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
}): Promise<void> {
|
||||
if (event.targetType === 'AGENT') {
|
||||
this.logger.log(
|
||||
`Recalculating quality score for agent ${event.targetId}`,
|
||||
);
|
||||
await this.commandBus.execute(
|
||||
new RecalculateQualityScoreCommand(event.targetId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('review.deleted', { async: true })
|
||||
async onReviewDeleted(event: {
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
}): Promise<void> {
|
||||
if (event.targetType === 'AGENT') {
|
||||
this.logger.log(
|
||||
`Recalculating quality score for agent ${event.targetId} after review deletion`,
|
||||
);
|
||||
await this.commandBus.execute(
|
||||
new RecalculateQualityScoreCommand(event.targetId),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import {
|
||||
AGENT_REPOSITORY,
|
||||
type AgentDashboardData,
|
||||
type IAgentRepository,
|
||||
} from '../../../domain/repositories/agent.repository';
|
||||
import { GetAgentDashboardQuery } from './get-agent-dashboard.query';
|
||||
|
||||
@QueryHandler(GetAgentDashboardQuery)
|
||||
export class GetAgentDashboardHandler
|
||||
implements IQueryHandler<GetAgentDashboardQuery>
|
||||
{
|
||||
constructor(
|
||||
@Inject(AGENT_REPOSITORY)
|
||||
private readonly agentRepo: IAgentRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetAgentDashboardQuery): Promise<AgentDashboardData> {
|
||||
const agent = await this.agentRepo.findByUserId(query.userId);
|
||||
if (!agent) {
|
||||
throw new NotFoundException('Không tìm thấy thông tin môi giới');
|
||||
}
|
||||
|
||||
return this.agentRepo.getDashboard(agent.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetAgentDashboardQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { QualityScoreCalculator } from '../services/quality-score.service';
|
||||
|
||||
describe('QualityScoreCalculator', () => {
|
||||
it('calculates score with all inputs at maximum', () => {
|
||||
const score = QualityScoreCalculator.calculate({
|
||||
avgRating: 5,
|
||||
totalReviews: 10,
|
||||
responseTimeAvg: 0,
|
||||
conversionRate: 1,
|
||||
activeListingRatio: 1,
|
||||
});
|
||||
|
||||
// rating: (5/5)*100*0.4 = 40, response: 100*0.3 = 30, conversion: 100*0.2 = 20, listing: 100*0.1 = 10
|
||||
expect(score).toBe(100);
|
||||
});
|
||||
|
||||
it('calculates score with mixed inputs', () => {
|
||||
const score = QualityScoreCalculator.calculate({
|
||||
avgRating: 4,
|
||||
totalReviews: 5,
|
||||
responseTimeAvg: 1800, // 30 minutes
|
||||
conversionRate: 0.3,
|
||||
activeListingRatio: 0.5,
|
||||
});
|
||||
|
||||
// rating: (4/5)*100*0.4 = 32
|
||||
// response: max(0, 100 - (1800/3600)*100)*0.3 = 50*0.3 = 15
|
||||
// conversion: 30*0.2 = 6
|
||||
// listing: 50*0.1 = 5
|
||||
// total = 58
|
||||
expect(score).toBe(58);
|
||||
});
|
||||
|
||||
it('defaults rating score to 50 with no reviews', () => {
|
||||
const score = QualityScoreCalculator.calculate({
|
||||
avgRating: 0,
|
||||
totalReviews: 0,
|
||||
responseTimeAvg: 0,
|
||||
conversionRate: 0,
|
||||
activeListingRatio: 0,
|
||||
});
|
||||
|
||||
// rating: 50*0.4 = 20, response: 100*0.3 = 30, conversion: 0, listing: 0
|
||||
expect(score).toBe(50);
|
||||
});
|
||||
|
||||
it('defaults response score to 50 with null response time', () => {
|
||||
const score = QualityScoreCalculator.calculate({
|
||||
avgRating: 0,
|
||||
totalReviews: 0,
|
||||
responseTimeAvg: null,
|
||||
conversionRate: 0,
|
||||
activeListingRatio: 0,
|
||||
});
|
||||
|
||||
// rating: 50*0.4 = 20, response: 50*0.3 = 15, conversion: 0, listing: 0
|
||||
expect(score).toBe(35);
|
||||
});
|
||||
|
||||
it('clamps response score to 0 when response time >= 1 hour', () => {
|
||||
const score = QualityScoreCalculator.calculate({
|
||||
avgRating: 5,
|
||||
totalReviews: 1,
|
||||
responseTimeAvg: 3600, // exactly 1 hour
|
||||
conversionRate: 0,
|
||||
activeListingRatio: 0,
|
||||
});
|
||||
|
||||
// rating: 100*0.4 = 40, response: max(0, 100-100)*0.3 = 0, conversion: 0, listing: 0
|
||||
expect(score).toBe(40);
|
||||
});
|
||||
|
||||
it('clamps response score to 0 when response time > 1 hour', () => {
|
||||
const score = QualityScoreCalculator.calculate({
|
||||
avgRating: 5,
|
||||
totalReviews: 1,
|
||||
responseTimeAvg: 7200, // 2 hours
|
||||
conversionRate: 0,
|
||||
activeListingRatio: 0,
|
||||
});
|
||||
|
||||
// rating: 100*0.4 = 40, response: max(0, 100-200)*0.3 = 0, conversion: 0, listing: 0
|
||||
expect(score).toBe(40);
|
||||
});
|
||||
|
||||
it('returns score with 1 decimal precision', () => {
|
||||
const score = QualityScoreCalculator.calculate({
|
||||
avgRating: 3.7,
|
||||
totalReviews: 3,
|
||||
responseTimeAvg: 600, // 10 min
|
||||
conversionRate: 0.15,
|
||||
activeListingRatio: 0.33,
|
||||
});
|
||||
|
||||
// Verify it has at most 1 decimal place
|
||||
const decimalPart = score.toString().split('.')[1];
|
||||
expect(!decimalPart || decimalPart.length <= 1).toBe(true);
|
||||
});
|
||||
|
||||
it('score is bounded 0-100', () => {
|
||||
// All zeros (except default for 0 reviews)
|
||||
const minScore = QualityScoreCalculator.calculate({
|
||||
avgRating: 0,
|
||||
totalReviews: 1, // force avgRating to be used as 0
|
||||
responseTimeAvg: 99999,
|
||||
conversionRate: 0,
|
||||
activeListingRatio: 0,
|
||||
});
|
||||
|
||||
expect(minScore).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const maxScore = QualityScoreCalculator.calculate({
|
||||
avgRating: 5,
|
||||
totalReviews: 100,
|
||||
responseTimeAvg: 0,
|
||||
conversionRate: 1,
|
||||
activeListingRatio: 1,
|
||||
});
|
||||
|
||||
expect(maxScore).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export const AGENT_REPOSITORY = Symbol('AGENT_REPOSITORY');
|
||||
|
||||
export interface AgentDashboardData {
|
||||
agentId: string;
|
||||
qualityScore: number;
|
||||
totalDeals: number;
|
||||
responseTimeAvg: number | null;
|
||||
isVerified: boolean;
|
||||
totalLeads: number;
|
||||
leadsByStatus: Record<string, number>;
|
||||
conversionRate: number;
|
||||
totalInquiries: number;
|
||||
unreadInquiries: number;
|
||||
totalListings: number;
|
||||
activeListings: number;
|
||||
avgReviewRating: number;
|
||||
totalReviews: number;
|
||||
}
|
||||
|
||||
export interface IAgentRepository {
|
||||
findByUserId(userId: string): Promise<{ id: string; userId: string; qualityScore: number } | null>;
|
||||
findById(agentId: string): Promise<{ id: string; userId: string; qualityScore: number } | null>;
|
||||
updateQualityScore(agentId: string, score: number): Promise<void>;
|
||||
getDashboard(agentId: string): Promise<AgentDashboardData>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Pure domain service — calculates quality score from inputs.
|
||||
* No infrastructure dependencies.
|
||||
*/
|
||||
export class QualityScoreCalculator {
|
||||
/**
|
||||
* Quality Score = weighted average of:
|
||||
* - Review rating (40%) — avg rating normalized to 0-100
|
||||
* - Response time (30%) — inverse of avg response time, 0-100
|
||||
* - Lead conversion (20%) — conversion rate * 100
|
||||
* - Listing activity (10%) — active listings ratio * 100
|
||||
*/
|
||||
static calculate(params: {
|
||||
avgRating: number; // 0-5
|
||||
totalReviews: number;
|
||||
responseTimeAvg: number | null; // seconds
|
||||
conversionRate: number; // 0-1
|
||||
activeListingRatio: number; // 0-1
|
||||
}): number {
|
||||
const ratingScore =
|
||||
params.totalReviews > 0 ? (params.avgRating / 5) * 100 : 50;
|
||||
const responseScore =
|
||||
params.responseTimeAvg !== null
|
||||
? Math.max(0, 100 - (params.responseTimeAvg / 3600) * 100) // 1hr = 0
|
||||
: 50;
|
||||
const conversionScore = params.conversionRate * 100;
|
||||
const listingScore = params.activeListingRatio * 100;
|
||||
|
||||
const score =
|
||||
ratingScore * 0.4 +
|
||||
responseScore * 0.3 +
|
||||
conversionScore * 0.2 +
|
||||
listingScore * 0.1;
|
||||
|
||||
return Math.round(score * 10) / 10; // 1 decimal
|
||||
}
|
||||
}
|
||||
6
apps/api/src/modules/agents/index.ts
Normal file
6
apps/api/src/modules/agents/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { AgentsModule } from './agents.module';
|
||||
export {
|
||||
AGENT_REPOSITORY,
|
||||
type IAgentRepository,
|
||||
type AgentDashboardData,
|
||||
} from './domain/repositories/agent.repository';
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import type {
|
||||
AgentDashboardData,
|
||||
IAgentRepository,
|
||||
} from '../../domain/repositories/agent.repository';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaAgentRepository implements IAgentRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
): Promise<{ id: string; userId: string; qualityScore: number } | null> {
|
||||
return this.prisma.agent.findUnique({
|
||||
where: { userId },
|
||||
select: { id: true, userId: true, qualityScore: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(
|
||||
agentId: string,
|
||||
): Promise<{ id: string; userId: string; qualityScore: number } | null> {
|
||||
return this.prisma.agent.findUnique({
|
||||
where: { id: agentId },
|
||||
select: { id: true, userId: true, qualityScore: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateQualityScore(agentId: string, score: number): Promise<void> {
|
||||
await this.prisma.agent.update({
|
||||
where: { id: agentId },
|
||||
data: { qualityScore: score },
|
||||
});
|
||||
}
|
||||
|
||||
async getDashboard(agentId: string): Promise<AgentDashboardData> {
|
||||
const [agent, leads, inquiryStats, listingStats, reviewStats] =
|
||||
await Promise.all([
|
||||
this.prisma.agent.findUniqueOrThrow({
|
||||
where: { id: agentId },
|
||||
select: {
|
||||
id: true,
|
||||
qualityScore: true,
|
||||
totalDeals: true,
|
||||
responseTimeAvg: true,
|
||||
isVerified: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.lead.groupBy({
|
||||
by: ['status'],
|
||||
where: { agentId },
|
||||
_count: { id: true },
|
||||
}),
|
||||
this.getInquiryStats(agentId),
|
||||
this.getListingStats(agentId),
|
||||
this.prisma.review.aggregate({
|
||||
where: { targetType: 'AGENT', targetId: agentId },
|
||||
_avg: { rating: true },
|
||||
_count: { rating: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const leadsByStatus: Record<string, number> = {};
|
||||
let totalLeads = 0;
|
||||
let convertedLeads = 0;
|
||||
|
||||
for (const group of leads) {
|
||||
leadsByStatus[group.status] = group._count.id;
|
||||
totalLeads += group._count.id;
|
||||
if (group.status === 'CONVERTED') {
|
||||
convertedLeads = group._count.id;
|
||||
}
|
||||
}
|
||||
|
||||
const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0;
|
||||
|
||||
return {
|
||||
agentId: agent.id,
|
||||
qualityScore: agent.qualityScore,
|
||||
totalDeals: agent.totalDeals,
|
||||
responseTimeAvg: agent.responseTimeAvg,
|
||||
isVerified: agent.isVerified,
|
||||
totalLeads,
|
||||
leadsByStatus,
|
||||
conversionRate: Math.round(conversionRate * 1000) / 1000, // 3 decimals
|
||||
totalInquiries: inquiryStats.total,
|
||||
unreadInquiries: inquiryStats.unread,
|
||||
totalListings: listingStats.total,
|
||||
activeListings: listingStats.active,
|
||||
avgReviewRating:
|
||||
Math.round((reviewStats._avg.rating ?? 0) * 10) / 10,
|
||||
totalReviews: reviewStats._count.rating,
|
||||
};
|
||||
}
|
||||
|
||||
private async getInquiryStats(
|
||||
agentId: string,
|
||||
): Promise<{ total: number; unread: number }> {
|
||||
const [total, unread] = await Promise.all([
|
||||
this.prisma.inquiry.count({
|
||||
where: { listing: { agentId } },
|
||||
}),
|
||||
this.prisma.inquiry.count({
|
||||
where: { listing: { agentId }, isRead: false },
|
||||
}),
|
||||
]);
|
||||
return { total, unread };
|
||||
}
|
||||
|
||||
private async getListingStats(
|
||||
agentId: string,
|
||||
): Promise<{ total: number; active: number }> {
|
||||
const [total, active] = await Promise.all([
|
||||
this.prisma.listing.count({ where: { agentId } }),
|
||||
this.prisma.listing.count({
|
||||
where: { agentId, status: 'ACTIVE' },
|
||||
}),
|
||||
]);
|
||||
return { total, active };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
type JwtPayload,
|
||||
CurrentUser,
|
||||
JwtAuthGuard,
|
||||
RolesGuard,
|
||||
Roles,
|
||||
} from '@modules/auth';
|
||||
import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command';
|
||||
import { GetAgentDashboardQuery } from '../../application/queries/get-agent-dashboard/get-agent-dashboard.query';
|
||||
import type { AgentDashboardData } from '../../domain/repositories/agent.repository';
|
||||
|
||||
@ApiTags('agents')
|
||||
@Controller('agents')
|
||||
export class AgentsController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Get agent dashboard stats' })
|
||||
@ApiResponse({ status: 200, description: 'Agent dashboard data' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden — only agents' })
|
||||
@ApiResponse({ status: 404, description: 'Agent profile not found' })
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('AGENT')
|
||||
@Get('me/dashboard')
|
||||
async getDashboard(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<AgentDashboardData> {
|
||||
return this.queryBus.execute(new GetAgentDashboardQuery(user.sub));
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Recalculate quality score (admin/system)' })
|
||||
@ApiParam({ name: 'agentId', description: 'Agent ID' })
|
||||
@ApiResponse({ status: 201, description: 'Quality score recalculated' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden — only admins' })
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@Post(':agentId/recalculate-score')
|
||||
async recalculateScore(
|
||||
@Param('agentId') agentId: string,
|
||||
): Promise<{ message: string }> {
|
||||
await this.commandBus.execute(
|
||||
new RecalculateQualityScoreCommand(agentId),
|
||||
);
|
||||
return { message: 'Quality score recalculated' };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { EventBus } from '@nestjs/cqrs';
|
||||
import type { ILeadRepository } from '../../domain/repositories/lead.repository';
|
||||
import { CreateLeadCommand } from '../commands/create-lead/create-lead.command';
|
||||
import { CreateLeadHandler } from '../commands/create-lead/create-lead.handler';
|
||||
|
||||
describe('CreateLeadHandler', () => {
|
||||
let handler: CreateLeadHandler;
|
||||
let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: {
|
||||
agent: { findUnique: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeadRepo = {
|
||||
findById: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findByAgent: vi.fn(),
|
||||
getStatsByAgent: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
mockPrisma = {
|
||||
agent: { findUnique: vi.fn() },
|
||||
};
|
||||
|
||||
handler = new CreateLeadHandler(
|
||||
mockLeadRepo as any,
|
||||
mockEventBus as unknown as EventBus,
|
||||
mockPrisma as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a lead successfully', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
|
||||
mockLeadRepo.save.mockResolvedValue(undefined);
|
||||
|
||||
const command = new CreateLeadCommand(
|
||||
'user-1',
|
||||
'Nguyễn Văn A',
|
||||
'0901234567',
|
||||
'a@example.com',
|
||||
'WEBSITE',
|
||||
75,
|
||||
{ note: 'Interested in District 7' },
|
||||
);
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.status).toBe('NEW');
|
||||
expect(result.createdAt).toBeDefined();
|
||||
expect(mockLeadRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a lead with null score', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
|
||||
mockLeadRepo.save.mockResolvedValue(undefined);
|
||||
|
||||
const command = new CreateLeadCommand(
|
||||
'user-1',
|
||||
'Nguyễn Văn B',
|
||||
'0907654321',
|
||||
null,
|
||||
'REFERRAL',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.status).toBe('NEW');
|
||||
});
|
||||
|
||||
it('throws NotFoundException when agent not found', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue(null);
|
||||
|
||||
const command = new CreateLeadCommand(
|
||||
'not-an-agent',
|
||||
'Nguyễn Văn A',
|
||||
'0901234567',
|
||||
null,
|
||||
'WEBSITE',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(
|
||||
"Agent with id 'not-an-agent' not found",
|
||||
);
|
||||
expect(mockLeadRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ValidationException for invalid score', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
|
||||
|
||||
const command = new CreateLeadCommand(
|
||||
'user-1',
|
||||
'Nguyễn Văn A',
|
||||
'0901234567',
|
||||
null,
|
||||
'WEBSITE',
|
||||
150,
|
||||
null,
|
||||
);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(
|
||||
'Điểm lead phải từ 0 đến 100',
|
||||
);
|
||||
expect(mockLeadRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { EventBus } from '@nestjs/cqrs';
|
||||
import { LeadEntity } from '../../domain/entities/lead.entity';
|
||||
import type { ILeadRepository } from '../../domain/repositories/lead.repository';
|
||||
import { DeleteLeadCommand } from '../commands/delete-lead/delete-lead.command';
|
||||
import { DeleteLeadHandler } from '../commands/delete-lead/delete-lead.handler';
|
||||
|
||||
describe('DeleteLeadHandler', () => {
|
||||
let handler: DeleteLeadHandler;
|
||||
let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: {
|
||||
agent: { findUnique: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeadRepo = {
|
||||
findById: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findByAgent: vi.fn(),
|
||||
getStatsByAgent: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
mockPrisma = {
|
||||
agent: { findUnique: vi.fn() },
|
||||
};
|
||||
|
||||
handler = new DeleteLeadHandler(
|
||||
mockLeadRepo as any,
|
||||
mockEventBus as unknown as EventBus,
|
||||
mockPrisma as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes a lead successfully', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' });
|
||||
|
||||
const lead = new LeadEntity('lead-1', {
|
||||
agentId: 'agent-1',
|
||||
name: 'Nguyễn Văn A',
|
||||
phone: '0901234567',
|
||||
email: null,
|
||||
source: 'WEBSITE',
|
||||
score: null,
|
||||
notes: null,
|
||||
status: 'NEW',
|
||||
});
|
||||
mockLeadRepo.findById.mockResolvedValue(lead);
|
||||
mockLeadRepo.delete.mockResolvedValue(undefined);
|
||||
|
||||
const command = new DeleteLeadCommand('lead-1', 'user-1');
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockLeadRepo.delete).toHaveBeenCalledWith('lead-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundException when agent not found', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue(null);
|
||||
|
||||
const command = new DeleteLeadCommand('lead-1', 'not-an-agent');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(
|
||||
"Agent with id 'not-an-agent' not found",
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when lead not found', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' });
|
||||
mockLeadRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new DeleteLeadCommand('lead-not-exist', 'user-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(
|
||||
"Lead with id 'lead-not-exist' not found",
|
||||
);
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when agent does not own the lead', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-2' });
|
||||
|
||||
const lead = new LeadEntity('lead-1', {
|
||||
agentId: 'agent-1',
|
||||
name: 'Nguyễn Văn A',
|
||||
phone: '0901234567',
|
||||
email: null,
|
||||
source: 'WEBSITE',
|
||||
score: null,
|
||||
notes: null,
|
||||
status: 'NEW',
|
||||
});
|
||||
mockLeadRepo.findById.mockResolvedValue(lead);
|
||||
|
||||
const command = new DeleteLeadCommand('lead-1', 'user-2');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(
|
||||
'Bạn chỉ có thể xóa lead của chính mình',
|
||||
);
|
||||
expect(mockLeadRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ILeadRepository } from '../../domain/repositories/lead.repository';
|
||||
import { GetLeadStatsQuery } from '../queries/get-lead-stats/get-lead-stats.query';
|
||||
import { GetLeadStatsHandler } from '../queries/get-lead-stats/get-lead-stats.handler';
|
||||
|
||||
describe('GetLeadStatsHandler', () => {
|
||||
let handler: GetLeadStatsHandler;
|
||||
let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: {
|
||||
agent: { findUnique: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeadRepo = {
|
||||
findById: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findByAgent: vi.fn(),
|
||||
getStatsByAgent: vi.fn(),
|
||||
};
|
||||
|
||||
mockPrisma = {
|
||||
agent: { findUnique: vi.fn() },
|
||||
};
|
||||
|
||||
handler = new GetLeadStatsHandler(
|
||||
mockLeadRepo as any,
|
||||
mockPrisma as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns stats for the agent', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' });
|
||||
|
||||
const mockStats = {
|
||||
totalLeads: 25,
|
||||
byStatus: {
|
||||
NEW: 5,
|
||||
CONTACTED: 8,
|
||||
QUALIFIED: 4,
|
||||
NEGOTIATING: 3,
|
||||
CONVERTED: 3,
|
||||
LOST: 2,
|
||||
},
|
||||
conversionRate: 0.12,
|
||||
avgScore: 65.5,
|
||||
};
|
||||
mockLeadRepo.getStatsByAgent.mockResolvedValue(mockStats);
|
||||
|
||||
const query = new GetLeadStatsQuery('user-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toEqual(mockStats);
|
||||
expect(result.totalLeads).toBe(25);
|
||||
expect(result.conversionRate).toBe(0.12);
|
||||
expect(mockLeadRepo.getStatsByAgent).toHaveBeenCalledWith('agent-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundException when agent not found', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue(null);
|
||||
|
||||
const query = new GetLeadStatsQuery('not-an-agent');
|
||||
|
||||
await expect(handler.execute(query)).rejects.toThrow(
|
||||
"Agent with id 'not-an-agent' not found",
|
||||
);
|
||||
expect(mockLeadRepo.getStatsByAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ILeadRepository } from '../../domain/repositories/lead.repository';
|
||||
import { GetLeadsByAgentQuery } from '../queries/get-leads-by-agent/get-leads-by-agent.query';
|
||||
import { GetLeadsByAgentHandler } from '../queries/get-leads-by-agent/get-leads-by-agent.handler';
|
||||
|
||||
describe('GetLeadsByAgentHandler', () => {
|
||||
let handler: GetLeadsByAgentHandler;
|
||||
let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: {
|
||||
agent: { findUnique: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeadRepo = {
|
||||
findById: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findByAgent: vi.fn(),
|
||||
getStatsByAgent: vi.fn(),
|
||||
};
|
||||
|
||||
mockPrisma = {
|
||||
agent: { findUnique: vi.fn() },
|
||||
};
|
||||
|
||||
handler = new GetLeadsByAgentHandler(
|
||||
mockLeadRepo as any,
|
||||
mockPrisma as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns paginated results', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' });
|
||||
|
||||
const mockResult = {
|
||||
data: [
|
||||
{
|
||||
id: 'lead-1',
|
||||
agentId: 'agent-1',
|
||||
name: 'Nguyễn Văn A',
|
||||
phone: '0901234567',
|
||||
email: null,
|
||||
source: 'WEBSITE',
|
||||
score: 75,
|
||||
status: 'NEW',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
};
|
||||
mockLeadRepo.findByAgent.mockResolvedValue(mockResult);
|
||||
|
||||
const query = new GetLeadsByAgentQuery('user-1', null, 1, 20);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(mockLeadRepo.findByAgent).toHaveBeenCalledWith('agent-1', null, 1, 20);
|
||||
});
|
||||
|
||||
it('passes status filter to repository', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' });
|
||||
mockLeadRepo.findByAgent.mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
const query = new GetLeadsByAgentQuery('user-1', 'CONTACTED', 1, 20);
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockLeadRepo.findByAgent).toHaveBeenCalledWith('agent-1', 'CONTACTED', 1, 20);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when agent not found', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue(null);
|
||||
|
||||
const query = new GetLeadsByAgentQuery('not-an-agent');
|
||||
|
||||
await expect(handler.execute(query)).rejects.toThrow(
|
||||
"Agent with id 'not-an-agent' not found",
|
||||
);
|
||||
expect(mockLeadRepo.findByAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { EventBus } from '@nestjs/cqrs';
|
||||
import { LeadEntity } from '../../domain/entities/lead.entity';
|
||||
import type { ILeadRepository } from '../../domain/repositories/lead.repository';
|
||||
import { UpdateLeadStatusCommand } from '../commands/update-lead-status/update-lead-status.command';
|
||||
import { UpdateLeadStatusHandler } from '../commands/update-lead-status/update-lead-status.handler';
|
||||
|
||||
describe('UpdateLeadStatusHandler', () => {
|
||||
let handler: UpdateLeadStatusHandler;
|
||||
let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: {
|
||||
agent: { findUnique: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeadRepo = {
|
||||
findById: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findByAgent: vi.fn(),
|
||||
getStatsByAgent: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
mockPrisma = {
|
||||
agent: { findUnique: vi.fn() },
|
||||
};
|
||||
|
||||
handler = new UpdateLeadStatusHandler(
|
||||
mockLeadRepo as any,
|
||||
mockEventBus as unknown as EventBus,
|
||||
mockPrisma as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates lead status successfully', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' });
|
||||
|
||||
const lead = new LeadEntity('lead-1', {
|
||||
agentId: 'agent-1',
|
||||
name: 'Nguyễn Văn A',
|
||||
phone: '0901234567',
|
||||
email: null,
|
||||
source: 'WEBSITE',
|
||||
score: null,
|
||||
notes: null,
|
||||
status: 'NEW',
|
||||
});
|
||||
mockLeadRepo.findById.mockResolvedValue(lead);
|
||||
mockLeadRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
const command = new UpdateLeadStatusCommand('lead-1', 'user-1', 'CONTACTED');
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockLeadRepo.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when agent not found', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue(null);
|
||||
|
||||
const command = new UpdateLeadStatusCommand('lead-1', 'not-an-agent', 'CONTACTED');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(
|
||||
"Agent with id 'not-an-agent' not found",
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when lead not found', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1' });
|
||||
mockLeadRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new UpdateLeadStatusCommand('lead-not-exist', 'user-1', 'CONTACTED');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(
|
||||
"Lead with id 'lead-not-exist' not found",
|
||||
);
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when agent does not own the lead', async () => {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-2' });
|
||||
|
||||
const lead = new LeadEntity('lead-1', {
|
||||
agentId: 'agent-1',
|
||||
name: 'Nguyễn Văn A',
|
||||
phone: '0901234567',
|
||||
email: null,
|
||||
source: 'WEBSITE',
|
||||
score: null,
|
||||
notes: null,
|
||||
status: 'NEW',
|
||||
});
|
||||
mockLeadRepo.findById.mockResolvedValue(lead);
|
||||
|
||||
const command = new UpdateLeadStatusCommand('lead-1', 'user-2', 'CONTACTED');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(
|
||||
'Bạn chỉ có thể cập nhật lead của chính mình',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export class CreateLeadCommand {
|
||||
constructor(
|
||||
public readonly agentUserId: string,
|
||||
public readonly name: string,
|
||||
public readonly phone: string,
|
||||
public readonly email: string | null,
|
||||
public readonly source: string,
|
||||
public readonly score: number | null,
|
||||
public readonly notes: unknown,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { NotFoundException, ValidationException, type PrismaService } from '@modules/shared';
|
||||
import { LeadEntity } from '../../../domain/entities/lead.entity';
|
||||
import { LEAD_REPOSITORY, type ILeadRepository } from '../../../domain/repositories/lead.repository';
|
||||
import { LeadScore } from '../../../domain/value-objects/lead-score.vo';
|
||||
import { CreateLeadCommand } from './create-lead.command';
|
||||
|
||||
export interface CreateLeadResult {
|
||||
id: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@CommandHandler(CreateLeadCommand)
|
||||
export class CreateLeadHandler implements ICommandHandler<CreateLeadCommand> {
|
||||
private readonly logger = new Logger(CreateLeadHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateLeadCommand): Promise<CreateLeadResult> {
|
||||
// Look up agent by userId
|
||||
const agent = await this.prisma.agent.findUnique({
|
||||
where: { userId: command.agentUserId },
|
||||
});
|
||||
if (!agent) {
|
||||
throw new NotFoundException('Agent', command.agentUserId);
|
||||
}
|
||||
|
||||
// Validate score value object
|
||||
let score: LeadScore | null = null;
|
||||
if (command.score !== null && command.score !== undefined) {
|
||||
const scoreResult = LeadScore.create(command.score);
|
||||
if (scoreResult.isErr) {
|
||||
throw new ValidationException(scoreResult.unwrapErr());
|
||||
}
|
||||
score = scoreResult.unwrap();
|
||||
}
|
||||
|
||||
const id = createId();
|
||||
const lead = LeadEntity.createNew(
|
||||
id,
|
||||
agent.id,
|
||||
command.name,
|
||||
command.phone,
|
||||
command.email,
|
||||
command.source,
|
||||
score,
|
||||
command.notes ?? null,
|
||||
);
|
||||
|
||||
await this.leadRepo.save(lead);
|
||||
|
||||
// Publish domain events
|
||||
const events = lead.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
this.logger.log(`Lead ${id} created by agent ${agent.id}`);
|
||||
|
||||
return {
|
||||
id,
|
||||
status: lead.status,
|
||||
createdAt: lead.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class DeleteLeadCommand {
|
||||
constructor(
|
||||
public readonly leadId: string,
|
||||
public readonly agentUserId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared';
|
||||
import { LEAD_REPOSITORY, type ILeadRepository } from '../../../domain/repositories/lead.repository';
|
||||
import { DeleteLeadCommand } from './delete-lead.command';
|
||||
|
||||
@CommandHandler(DeleteLeadCommand)
|
||||
export class DeleteLeadHandler implements ICommandHandler<DeleteLeadCommand> {
|
||||
private readonly logger = new Logger(DeleteLeadHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteLeadCommand): Promise<void> {
|
||||
// Look up agent by userId
|
||||
const agent = await this.prisma.agent.findUnique({
|
||||
where: { userId: command.agentUserId },
|
||||
});
|
||||
if (!agent) {
|
||||
throw new NotFoundException('Agent', command.agentUserId);
|
||||
}
|
||||
|
||||
const lead = await this.leadRepo.findById(command.leadId);
|
||||
if (!lead) {
|
||||
throw new NotFoundException('Lead', command.leadId);
|
||||
}
|
||||
|
||||
// Verify agent ownership
|
||||
if (lead.agentId !== agent.id) {
|
||||
throw new ForbiddenException('Bạn chỉ có thể xóa lead của chính mình');
|
||||
}
|
||||
|
||||
await this.leadRepo.delete(command.leadId);
|
||||
|
||||
this.logger.log(`Lead ${command.leadId} deleted by agent ${agent.id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class UpdateLeadStatusCommand {
|
||||
constructor(
|
||||
public readonly leadId: string,
|
||||
public readonly agentUserId: string,
|
||||
public readonly newStatus: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared';
|
||||
import { type LeadStatus } from '../../../domain/entities/lead.entity';
|
||||
import { LEAD_REPOSITORY, type ILeadRepository } from '../../../domain/repositories/lead.repository';
|
||||
import { UpdateLeadStatusCommand } from './update-lead-status.command';
|
||||
|
||||
@CommandHandler(UpdateLeadStatusCommand)
|
||||
export class UpdateLeadStatusHandler implements ICommandHandler<UpdateLeadStatusCommand> {
|
||||
private readonly logger = new Logger(UpdateLeadStatusHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateLeadStatusCommand): Promise<void> {
|
||||
// Look up agent by userId
|
||||
const agent = await this.prisma.agent.findUnique({
|
||||
where: { userId: command.agentUserId },
|
||||
});
|
||||
if (!agent) {
|
||||
throw new NotFoundException('Agent', command.agentUserId);
|
||||
}
|
||||
|
||||
const lead = await this.leadRepo.findById(command.leadId);
|
||||
if (!lead) {
|
||||
throw new NotFoundException('Lead', command.leadId);
|
||||
}
|
||||
|
||||
// Verify agent ownership
|
||||
if (lead.agentId !== agent.id) {
|
||||
throw new ForbiddenException('Bạn chỉ có thể cập nhật lead của chính mình');
|
||||
}
|
||||
|
||||
lead.updateStatus(command.newStatus as LeadStatus);
|
||||
await this.leadRepo.update(lead);
|
||||
|
||||
// Publish domain events
|
||||
const events = lead.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
this.logger.log(`Lead ${command.leadId} status updated to ${command.newStatus} by agent ${agent.id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException, type PrismaService } from '@modules/shared';
|
||||
import { LEAD_REPOSITORY, type ILeadRepository, type LeadStatsData } from '../../../domain/repositories/lead.repository';
|
||||
import { GetLeadStatsQuery } from './get-lead-stats.query';
|
||||
|
||||
@QueryHandler(GetLeadStatsQuery)
|
||||
export class GetLeadStatsHandler implements IQueryHandler<GetLeadStatsQuery> {
|
||||
constructor(
|
||||
@Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetLeadStatsQuery): Promise<LeadStatsData> {
|
||||
// Look up agent by userId
|
||||
const agent = await this.prisma.agent.findUnique({
|
||||
where: { userId: query.agentUserId },
|
||||
});
|
||||
if (!agent) {
|
||||
throw new NotFoundException('Agent', query.agentUserId);
|
||||
}
|
||||
|
||||
return this.leadRepo.getStatsByAgent(agent.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class GetLeadStatsQuery {
|
||||
constructor(
|
||||
public readonly agentUserId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException, type PrismaService } from '@modules/shared';
|
||||
import { type LeadReadDto } from '../../../domain/repositories/lead-read.dto';
|
||||
import { LEAD_REPOSITORY, type ILeadRepository, type PaginatedResult } from '../../../domain/repositories/lead.repository';
|
||||
import { GetLeadsByAgentQuery } from './get-leads-by-agent.query';
|
||||
|
||||
@QueryHandler(GetLeadsByAgentQuery)
|
||||
export class GetLeadsByAgentHandler implements IQueryHandler<GetLeadsByAgentQuery> {
|
||||
constructor(
|
||||
@Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetLeadsByAgentQuery): Promise<PaginatedResult<LeadReadDto>> {
|
||||
// Look up agent by userId
|
||||
const agent = await this.prisma.agent.findUnique({
|
||||
where: { userId: query.agentUserId },
|
||||
});
|
||||
if (!agent) {
|
||||
throw new NotFoundException('Agent', query.agentUserId);
|
||||
}
|
||||
|
||||
return this.leadRepo.findByAgent(
|
||||
agent.id,
|
||||
query.status,
|
||||
query.page,
|
||||
query.limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class GetLeadsByAgentQuery {
|
||||
constructor(
|
||||
public readonly agentUserId: string,
|
||||
public readonly status: string | null = null,
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
190
apps/api/src/modules/leads/domain/__tests__/lead-domain.spec.ts
Normal file
190
apps/api/src/modules/leads/domain/__tests__/lead-domain.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { LeadEntity, type LeadStatus } from '../entities/lead.entity';
|
||||
import { LeadCreatedEvent } from '../events/lead-created.event';
|
||||
import { LeadStatusChangedEvent } from '../events/lead-status-changed.event';
|
||||
import { LeadScore } from '../value-objects/lead-score.vo';
|
||||
|
||||
describe('LeadEntity', () => {
|
||||
describe('createNew', () => {
|
||||
it('creates a lead with correct properties and NEW status', () => {
|
||||
const lead = LeadEntity.createNew(
|
||||
'lead-1',
|
||||
'agent-1',
|
||||
'Nguyễn Văn A',
|
||||
'0901234567',
|
||||
'a@example.com',
|
||||
'WEBSITE',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(lead.id).toBe('lead-1');
|
||||
expect(lead.agentId).toBe('agent-1');
|
||||
expect(lead.name).toBe('Nguyễn Văn A');
|
||||
expect(lead.phone).toBe('0901234567');
|
||||
expect(lead.email).toBe('a@example.com');
|
||||
expect(lead.source).toBe('WEBSITE');
|
||||
expect(lead.score).toBeNull();
|
||||
expect(lead.notes).toBeNull();
|
||||
expect(lead.status).toBe('NEW');
|
||||
});
|
||||
|
||||
it('emits LeadCreatedEvent', () => {
|
||||
const lead = LeadEntity.createNew(
|
||||
'lead-1',
|
||||
'agent-1',
|
||||
'Nguyễn Văn A',
|
||||
'0901234567',
|
||||
null,
|
||||
'REFERRAL',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const events = lead.domainEvents;
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toBeInstanceOf(LeadCreatedEvent);
|
||||
|
||||
const event = events[0] as LeadCreatedEvent;
|
||||
expect(event.aggregateId).toBe('lead-1');
|
||||
expect(event.agentId).toBe('agent-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
const createNewLead = (): LeadEntity =>
|
||||
LeadEntity.createNew(
|
||||
'lead-1',
|
||||
'agent-1',
|
||||
'Test',
|
||||
'0901234567',
|
||||
null,
|
||||
'WEBSITE',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
it('allows NEW → CONTACTED transition', () => {
|
||||
const lead = createNewLead();
|
||||
lead.clearDomainEvents();
|
||||
|
||||
lead.updateStatus('CONTACTED');
|
||||
|
||||
expect(lead.status).toBe('CONTACTED');
|
||||
const events = lead.domainEvents;
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toBeInstanceOf(LeadStatusChangedEvent);
|
||||
});
|
||||
|
||||
it('allows NEW → LOST transition', () => {
|
||||
const lead = createNewLead();
|
||||
lead.updateStatus('LOST');
|
||||
expect(lead.status).toBe('LOST');
|
||||
});
|
||||
|
||||
it('allows CONTACTED → QUALIFIED transition', () => {
|
||||
const lead = createNewLead();
|
||||
lead.updateStatus('CONTACTED');
|
||||
lead.updateStatus('QUALIFIED');
|
||||
expect(lead.status).toBe('QUALIFIED');
|
||||
});
|
||||
|
||||
it('allows QUALIFIED → NEGOTIATING transition', () => {
|
||||
const lead = createNewLead();
|
||||
lead.updateStatus('CONTACTED');
|
||||
lead.updateStatus('QUALIFIED');
|
||||
lead.updateStatus('NEGOTIATING');
|
||||
expect(lead.status).toBe('NEGOTIATING');
|
||||
});
|
||||
|
||||
it('allows NEGOTIATING → CONVERTED transition', () => {
|
||||
const lead = createNewLead();
|
||||
lead.updateStatus('CONTACTED');
|
||||
lead.updateStatus('QUALIFIED');
|
||||
lead.updateStatus('NEGOTIATING');
|
||||
lead.updateStatus('CONVERTED');
|
||||
expect(lead.status).toBe('CONVERTED');
|
||||
});
|
||||
|
||||
it('throws on CONVERTED → any transition', () => {
|
||||
const lead = createNewLead();
|
||||
lead.updateStatus('CONTACTED');
|
||||
lead.updateStatus('QUALIFIED');
|
||||
lead.updateStatus('NEGOTIATING');
|
||||
lead.updateStatus('CONVERTED');
|
||||
|
||||
const statuses: LeadStatus[] = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'LOST'];
|
||||
for (const status of statuses) {
|
||||
expect(() => lead.updateStatus(status)).toThrow(
|
||||
`Không thể chuyển trạng thái từ CONVERTED sang ${status}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws on LOST → any transition', () => {
|
||||
const lead = createNewLead();
|
||||
lead.updateStatus('LOST');
|
||||
|
||||
const statuses: LeadStatus[] = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED'];
|
||||
for (const status of statuses) {
|
||||
expect(() => lead.updateStatus(status)).toThrow(
|
||||
`Không thể chuyển trạng thái từ LOST sang ${status}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws on invalid transition NEW → QUALIFIED', () => {
|
||||
const lead = createNewLead();
|
||||
expect(() => lead.updateStatus('QUALIFIED')).toThrow(
|
||||
'Không thể chuyển trạng thái từ NEW sang QUALIFIED',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits LeadStatusChangedEvent with old and new status', () => {
|
||||
const lead = createNewLead();
|
||||
lead.clearDomainEvents();
|
||||
|
||||
lead.updateStatus('CONTACTED');
|
||||
|
||||
const events = lead.domainEvents;
|
||||
expect(events).toHaveLength(1);
|
||||
|
||||
const event = events[0] as LeadStatusChangedEvent;
|
||||
expect(event.aggregateId).toBe('lead-1');
|
||||
expect(event.agentId).toBe('agent-1');
|
||||
expect(event.oldStatus).toBe('NEW');
|
||||
expect(event.newStatus).toBe('CONTACTED');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LeadScore', () => {
|
||||
it('creates a valid score at 0', () => {
|
||||
const result = LeadScore.create(0);
|
||||
expect(result.isOk).toBe(true);
|
||||
expect(result.unwrap().value).toBe(0);
|
||||
});
|
||||
|
||||
it('creates a valid score at 100', () => {
|
||||
const result = LeadScore.create(100);
|
||||
expect(result.isOk).toBe(true);
|
||||
expect(result.unwrap().value).toBe(100);
|
||||
});
|
||||
|
||||
it('creates a valid score at 50', () => {
|
||||
const result = LeadScore.create(50);
|
||||
expect(result.isOk).toBe(true);
|
||||
expect(result.unwrap().value).toBe(50);
|
||||
});
|
||||
|
||||
it('rejects negative score', () => {
|
||||
const result = LeadScore.create(-1);
|
||||
expect(result.isErr).toBe(true);
|
||||
expect(result.unwrapErr()).toBe('Điểm lead phải từ 0 đến 100');
|
||||
});
|
||||
|
||||
it('rejects score above 100', () => {
|
||||
const result = LeadScore.create(101);
|
||||
expect(result.isErr).toBe(true);
|
||||
expect(result.unwrapErr()).toBe('Điểm lead phải từ 0 đến 100');
|
||||
});
|
||||
});
|
||||
101
apps/api/src/modules/leads/domain/entities/lead.entity.ts
Normal file
101
apps/api/src/modules/leads/domain/entities/lead.entity.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { AggregateRoot, ValidationException } from '@modules/shared';
|
||||
import { LeadCreatedEvent } from '../events/lead-created.event';
|
||||
import { LeadStatusChangedEvent } from '../events/lead-status-changed.event';
|
||||
import { type LeadScore } from '../value-objects/lead-score.vo';
|
||||
|
||||
export type LeadStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'NEGOTIATING' | 'CONVERTED' | 'LOST';
|
||||
|
||||
const VALID_TRANSITIONS: Record<LeadStatus, LeadStatus[]> = {
|
||||
NEW: ['CONTACTED', 'LOST'],
|
||||
CONTACTED: ['QUALIFIED', 'LOST'],
|
||||
QUALIFIED: ['NEGOTIATING', 'LOST'],
|
||||
NEGOTIATING: ['CONVERTED', 'LOST'],
|
||||
CONVERTED: [],
|
||||
LOST: [],
|
||||
};
|
||||
|
||||
export interface LeadProps {
|
||||
agentId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string | null;
|
||||
source: string;
|
||||
score: LeadScore | null;
|
||||
notes: unknown;
|
||||
status: LeadStatus;
|
||||
}
|
||||
|
||||
export class LeadEntity extends AggregateRoot<string> {
|
||||
private _agentId: string;
|
||||
private _name: string;
|
||||
private _phone: string;
|
||||
private _email: string | null;
|
||||
private _source: string;
|
||||
private _score: LeadScore | null;
|
||||
private _notes: unknown;
|
||||
private _status: LeadStatus;
|
||||
|
||||
constructor(id: string, props: LeadProps, createdAt?: Date, updatedAt?: Date) {
|
||||
super(id, createdAt);
|
||||
if (updatedAt) this.updatedAt = updatedAt;
|
||||
this._agentId = props.agentId;
|
||||
this._name = props.name;
|
||||
this._phone = props.phone;
|
||||
this._email = props.email;
|
||||
this._source = props.source;
|
||||
this._score = props.score;
|
||||
this._notes = props.notes;
|
||||
this._status = props.status;
|
||||
}
|
||||
|
||||
get agentId(): string { return this._agentId; }
|
||||
get name(): string { return this._name; }
|
||||
get phone(): string { return this._phone; }
|
||||
get email(): string | null { return this._email; }
|
||||
get source(): string { return this._source; }
|
||||
get score(): LeadScore | null { return this._score; }
|
||||
get notes(): unknown { return this._notes; }
|
||||
get status(): LeadStatus { return this._status; }
|
||||
|
||||
static createNew(
|
||||
id: string,
|
||||
agentId: string,
|
||||
name: string,
|
||||
phone: string,
|
||||
email: string | null,
|
||||
source: string,
|
||||
score: LeadScore | null,
|
||||
notes: unknown,
|
||||
): LeadEntity {
|
||||
const lead = new LeadEntity(id, {
|
||||
agentId,
|
||||
name,
|
||||
phone,
|
||||
email,
|
||||
source,
|
||||
score,
|
||||
notes,
|
||||
status: 'NEW',
|
||||
});
|
||||
|
||||
lead.addDomainEvent(new LeadCreatedEvent(id, agentId));
|
||||
return lead;
|
||||
}
|
||||
|
||||
updateStatus(newStatus: LeadStatus): void {
|
||||
const allowed = VALID_TRANSITIONS[this._status];
|
||||
if (!allowed.includes(newStatus)) {
|
||||
throw new ValidationException(
|
||||
`Không thể chuyển trạng thái từ ${this._status} sang ${newStatus}`,
|
||||
);
|
||||
}
|
||||
|
||||
const oldStatus = this._status;
|
||||
this._status = newStatus;
|
||||
this.updatedAt = new Date();
|
||||
|
||||
this.addDomainEvent(
|
||||
new LeadStatusChangedEvent(this.id, this._agentId, oldStatus, newStatus),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class LeadCreatedEvent implements DomainEvent {
|
||||
readonly eventName = 'lead.created';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly agentId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class LeadStatusChangedEvent implements DomainEvent {
|
||||
readonly eventName = 'lead.status_changed';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly agentId: string,
|
||||
public readonly oldStatus: string,
|
||||
public readonly newStatus: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface LeadReadDto {
|
||||
id: string;
|
||||
agentId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string | null;
|
||||
source: string;
|
||||
score: number | null;
|
||||
notes: unknown;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type LeadEntity } from '../entities/lead.entity';
|
||||
import { type LeadReadDto } from './lead-read.dto';
|
||||
|
||||
export const LEAD_REPOSITORY = Symbol('LEAD_REPOSITORY');
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface LeadStatsData {
|
||||
totalLeads: number;
|
||||
byStatus: Record<string, number>;
|
||||
conversionRate: number;
|
||||
avgScore: number | null;
|
||||
}
|
||||
|
||||
export interface ILeadRepository {
|
||||
findById(id: string): Promise<LeadEntity | null>;
|
||||
save(lead: LeadEntity): Promise<void>;
|
||||
update(lead: LeadEntity): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
findByAgent(agentId: string, status: string | null, page: number, limit: number): Promise<PaginatedResult<LeadReadDto>>;
|
||||
getStatsByAgent(agentId: string): Promise<LeadStatsData>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Result, ValueObject } from '@modules/shared';
|
||||
|
||||
interface LeadScoreProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class LeadScore extends ValueObject<LeadScoreProps> {
|
||||
get value(): number { return this.props.value; }
|
||||
|
||||
static create(value: number): Result<LeadScore, string> {
|
||||
if (value < 0 || value > 100) {
|
||||
return Result.err('Điểm lead phải từ 0 đến 100');
|
||||
}
|
||||
return Result.ok(new LeadScore({ value }));
|
||||
}
|
||||
}
|
||||
3
apps/api/src/modules/leads/index.ts
Normal file
3
apps/api/src/modules/leads/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { LeadsModule } from './leads.module';
|
||||
export { LEAD_REPOSITORY, type ILeadRepository } from './domain/repositories/lead.repository';
|
||||
export { LeadEntity } from './domain/entities/lead.entity';
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Lead as PrismaLead } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { LeadEntity, type LeadStatus } from '../../domain/entities/lead.entity';
|
||||
import type { LeadReadDto } from '../../domain/repositories/lead-read.dto';
|
||||
import type { ILeadRepository, LeadStatsData, PaginatedResult } from '../../domain/repositories/lead.repository';
|
||||
import { LeadScore } from '../../domain/value-objects/lead-score.vo';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaLeadRepository implements ILeadRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findById(id: string): Promise<LeadEntity | null> {
|
||||
const lead = await this.prisma.lead.findUnique({ where: { id } });
|
||||
return lead ? this.toDomain(lead) : null;
|
||||
}
|
||||
|
||||
async save(entity: LeadEntity): Promise<void> {
|
||||
await this.prisma.lead.create({
|
||||
data: {
|
||||
id: entity.id,
|
||||
agentId: entity.agentId,
|
||||
name: entity.name,
|
||||
phone: entity.phone,
|
||||
email: entity.email,
|
||||
source: entity.source,
|
||||
score: entity.score?.value ?? null,
|
||||
notes: entity.notes as never,
|
||||
status: entity.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(entity: LeadEntity): Promise<void> {
|
||||
await this.prisma.lead.update({
|
||||
where: { id: entity.id },
|
||||
data: {
|
||||
status: entity.status,
|
||||
score: entity.score?.value ?? null,
|
||||
notes: entity.notes as never,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.lead.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async findByAgent(
|
||||
agentId: string,
|
||||
status: string | null,
|
||||
page: number,
|
||||
limit: number,
|
||||
): Promise<PaginatedResult<LeadReadDto>> {
|
||||
const take = Math.min(limit, 100);
|
||||
const skip = (page - 1) * take;
|
||||
const where: Record<string, unknown> = { agentId };
|
||||
if (status) {
|
||||
where['status'] = status;
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.lead.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.lead.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: data.map((r) => ({
|
||||
id: r.id,
|
||||
agentId: r.agentId,
|
||||
name: r.name,
|
||||
phone: r.phone,
|
||||
email: r.email,
|
||||
source: r.source,
|
||||
score: r.score,
|
||||
notes: r.notes,
|
||||
status: r.status,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
limit: take,
|
||||
totalPages: Math.ceil(total / take),
|
||||
};
|
||||
}
|
||||
|
||||
async getStatsByAgent(agentId: string): Promise<LeadStatsData> {
|
||||
const leads = await this.prisma.lead.findMany({
|
||||
where: { agentId },
|
||||
select: { status: true, score: true },
|
||||
});
|
||||
|
||||
const totalLeads = leads.length;
|
||||
const byStatus: Record<string, number> = {};
|
||||
|
||||
let scoreSum = 0;
|
||||
let scoreCount = 0;
|
||||
let convertedCount = 0;
|
||||
|
||||
for (const lead of leads) {
|
||||
byStatus[lead.status] = (byStatus[lead.status] ?? 0) + 1;
|
||||
if (lead.score !== null) {
|
||||
scoreSum += lead.score;
|
||||
scoreCount++;
|
||||
}
|
||||
if (lead.status === 'CONVERTED') {
|
||||
convertedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalLeads,
|
||||
byStatus,
|
||||
conversionRate: totalLeads > 0
|
||||
? Math.round((convertedCount / totalLeads) * 10000) / 100
|
||||
: 0,
|
||||
avgScore: scoreCount > 0
|
||||
? Math.round((scoreSum / scoreCount) * 10) / 10
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(raw: PrismaLead): LeadEntity {
|
||||
let score: LeadScore | null = null;
|
||||
if (raw.score !== null) {
|
||||
score = LeadScore.create(raw.score).unwrap();
|
||||
}
|
||||
|
||||
return new LeadEntity(
|
||||
raw.id,
|
||||
{
|
||||
agentId: raw.agentId,
|
||||
name: raw.name,
|
||||
phone: raw.phone,
|
||||
email: raw.email,
|
||||
source: raw.source,
|
||||
score,
|
||||
notes: raw.notes,
|
||||
status: raw.status as LeadStatus,
|
||||
},
|
||||
raw.createdAt,
|
||||
raw.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
26
apps/api/src/modules/leads/leads.module.ts
Normal file
26
apps/api/src/modules/leads/leads.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { CreateLeadHandler } from './application/commands/create-lead/create-lead.handler';
|
||||
import { DeleteLeadHandler } from './application/commands/delete-lead/delete-lead.handler';
|
||||
import { UpdateLeadStatusHandler } from './application/commands/update-lead-status/update-lead-status.handler';
|
||||
import { GetLeadStatsHandler } from './application/queries/get-lead-stats/get-lead-stats.handler';
|
||||
import { GetLeadsByAgentHandler } from './application/queries/get-leads-by-agent/get-leads-by-agent.handler';
|
||||
import { LEAD_REPOSITORY } from './domain/repositories/lead.repository';
|
||||
import { PrismaLeadRepository } from './infrastructure/repositories/prisma-lead.repository';
|
||||
import { LeadsController } from './presentation/controllers/leads.controller';
|
||||
|
||||
const CommandHandlers = [CreateLeadHandler, UpdateLeadStatusHandler, DeleteLeadHandler];
|
||||
|
||||
const QueryHandlers = [GetLeadsByAgentHandler, GetLeadStatsHandler];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
controllers: [LeadsController],
|
||||
providers: [
|
||||
{ provide: LEAD_REPOSITORY, useClass: PrismaLeadRepository },
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [LEAD_REPOSITORY],
|
||||
})
|
||||
export class LeadsModule {}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
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, JwtAuthGuard, RolesGuard, Roles } from '@modules/auth';
|
||||
import { CreateLeadCommand } from '../../application/commands/create-lead/create-lead.command';
|
||||
import { type CreateLeadResult } from '../../application/commands/create-lead/create-lead.handler';
|
||||
import { DeleteLeadCommand } from '../../application/commands/delete-lead/delete-lead.command';
|
||||
import { UpdateLeadStatusCommand } from '../../application/commands/update-lead-status/update-lead-status.command';
|
||||
import { GetLeadStatsQuery } from '../../application/queries/get-lead-stats/get-lead-stats.query';
|
||||
import { GetLeadsByAgentQuery } from '../../application/queries/get-leads-by-agent/get-leads-by-agent.query';
|
||||
import type { LeadReadDto } from '../../domain/repositories/lead-read.dto';
|
||||
import type { LeadStatsData, PaginatedResult } from '../../domain/repositories/lead.repository';
|
||||
import type { CreateLeadDto } from '../dto/create-lead.dto';
|
||||
import type { ListLeadsDto } from '../dto/list-leads.dto';
|
||||
import type { UpdateLeadStatusDto } from '../dto/update-lead-status.dto';
|
||||
|
||||
@ApiTags('leads')
|
||||
@ApiBearerAuth('JWT')
|
||||
@Controller('leads')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('AGENT')
|
||||
export class LeadsController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@ApiOperation({ summary: 'Tạo lead mới' })
|
||||
@ApiResponse({ status: 201, description: 'Lead đã được tạo thành công' })
|
||||
@ApiResponse({ status: 400, description: 'Lỗi validation' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
|
||||
@ApiResponse({ status: 403, description: 'Không có quyền' })
|
||||
@Post()
|
||||
async createLead(
|
||||
@Body() dto: CreateLeadDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<CreateLeadResult> {
|
||||
return this.commandBus.execute(
|
||||
new CreateLeadCommand(
|
||||
user.sub,
|
||||
dto.name,
|
||||
dto.phone,
|
||||
dto.email ?? null,
|
||||
dto.source,
|
||||
dto.score ?? null,
|
||||
dto.notes ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Danh sách lead của agent' })
|
||||
@ApiResponse({ status: 200, description: 'Danh sách lead phân trang' })
|
||||
@Get()
|
||||
async getLeads(
|
||||
@Query() dto: ListLeadsDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<PaginatedResult<LeadReadDto>> {
|
||||
return this.queryBus.execute(
|
||||
new GetLeadsByAgentQuery(
|
||||
user.sub,
|
||||
dto.status ?? null,
|
||||
dto.page ?? 1,
|
||||
dto.limit ?? 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Thống kê lead của agent' })
|
||||
@ApiResponse({ status: 200, description: 'Thống kê lead' })
|
||||
@Get('stats')
|
||||
async getStats(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<LeadStatsData> {
|
||||
return this.queryBus.execute(
|
||||
new GetLeadStatsQuery(user.sub),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Cập nhật trạng thái lead' })
|
||||
@ApiParam({ name: 'id', description: 'Lead ID' })
|
||||
@ApiResponse({ status: 200, description: 'Trạng thái đã được cập nhật' })
|
||||
@ApiResponse({ status: 400, description: 'Chuyển trạng thái không hợp lệ' })
|
||||
@ApiResponse({ status: 403, description: 'Không có quyền' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy lead' })
|
||||
@Patch(':id/status')
|
||||
async updateStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLeadStatusDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<{ updated: boolean }> {
|
||||
await this.commandBus.execute(
|
||||
new UpdateLeadStatusCommand(id, user.sub, dto.status),
|
||||
);
|
||||
return { updated: true };
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Xóa lead' })
|
||||
@ApiParam({ name: 'id', description: 'Lead ID' })
|
||||
@ApiResponse({ status: 200, description: 'Lead đã được xóa' })
|
||||
@ApiResponse({ status: 403, description: 'Không có quyền' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy lead' })
|
||||
@Delete(':id')
|
||||
async deleteLead(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<{ deleted: boolean }> {
|
||||
await this.commandBus.execute(new DeleteLeadCommand(id, user.sub));
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class CreateLeadDto {
|
||||
@ApiProperty({ example: 'Nguyễn Văn A', description: 'Tên khách hàng tiềm năng' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: '0901234567', description: 'Số điện thoại' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'nguyen@example.com', description: 'Email' })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiProperty({ example: 'website', description: 'Nguồn lead' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
source!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 75, description: 'Điểm lead (0-100)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
score?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Ghi chú bổ sung' })
|
||||
@IsOptional()
|
||||
notes?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
const LEAD_STATUSES = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED', 'LOST'] as const;
|
||||
|
||||
export class ListLeadsDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: LEAD_STATUSES,
|
||||
description: 'Lọc theo trạng thái',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(LEAD_STATUSES)
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, description: 'Trang', default: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 20, description: 'Số lượng mỗi trang', default: 20 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsIn } from 'class-validator';
|
||||
|
||||
const LEAD_STATUSES = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED', 'LOST'] as const;
|
||||
|
||||
export class UpdateLeadStatusDto {
|
||||
@ApiProperty({
|
||||
enum: LEAD_STATUSES,
|
||||
description: 'Trạng thái mới của lead',
|
||||
example: 'CONTACTED',
|
||||
})
|
||||
@IsIn(LEAD_STATUSES)
|
||||
status!: string;
|
||||
}
|
||||
Reference in New Issue
Block a user