Move 36 root-level audit/analysis documents and 7 web app audit documents into docs/audits/ directory to declutter the project root. Remove stale EXPLORATION_SUMMARY.txt. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1842 lines
55 KiB
Markdown
1842 lines
55 KiB
Markdown
# Test Coverage Analysis Report
|
|
## GoodGo Platform API — Untested Source Files
|
|
|
|
**Generated:** 2026-04-11
|
|
**Working Directory:** `/Users/velikho/Desktop/WORKING/goodgo-platform-ai/apps/api/`
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
This report catalogs **17 untested source files** across the inquiries, leads, and reviews modules, along with their full implementations. The files fall into four categories:
|
|
|
|
1. **Infrastructure Repositories** (3 files) - Prisma data access layer
|
|
2. **Domain Value Objects** (2 files) - LeadScore and Rating validation objects
|
|
3. **Presentation DTOs** (10 files) - Request/response validation classes
|
|
4. **Presentation Controllers** (2 files) - HTTP endpoint handlers
|
|
|
|
Two existing test files are provided as reference patterns demonstrating best practices for unit testing handlers and controllers.
|
|
|
|
---
|
|
|
|
# PART 1: INQUIRIES MODULE
|
|
|
|
## 1.1 Prisma Inquiry Repository
|
|
|
|
**File:** `src/modules/inquiries/infrastructure/repositories/prisma-inquiry.repository.ts`
|
|
|
|
```typescript
|
|
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, type 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,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key Methods to Test:**
|
|
- `findById()` - Returns inquiry by ID or null if not found
|
|
- `save()` - Creates new inquiry record
|
|
- `markAsRead()` - Updates isRead flag
|
|
- `findByListing()` - Paginated query by listing, includes relationships
|
|
- `findByAgent()` - Paginated query through listing agent relationship
|
|
- `countUnreadByAgent()` - Aggregation query for unread count
|
|
- `toDomain()` - Private mapper converting Prisma model to domain entity
|
|
|
|
**Test Scenarios:**
|
|
- All methods with valid inputs
|
|
- Null returns (no matching records)
|
|
- Pagination edge cases (page bounds, limit clamping)
|
|
- Data mapping accuracy (ISO dates, relationship joins)
|
|
|
|
---
|
|
|
|
## 1.2 Inquiries Controller
|
|
|
|
**File:** `src/modules/inquiries/presentation/controllers/inquiries.controller.ts`
|
|
|
|
```typescript
|
|
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 };
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key Endpoints to Test:**
|
|
- `POST /inquiries` - Create inquiry with phone optional
|
|
- `GET /inquiries/listing/:listingId` - List by listing with pagination
|
|
- `GET /inquiries/agent/me` - List for authenticated agent with AGENT role guard
|
|
- `PATCH /inquiries/:id/read` - Mark as read with role guard
|
|
|
|
**Test Scenarios:**
|
|
- Command/query bus dispatch with correct parameters
|
|
- Default pagination values (page: 1, limit: 20)
|
|
- Null phone handling (converts to null when not provided)
|
|
- Guard/decorator enforcement (JwtAuthGuard, RolesGuard, @Roles('AGENT'))
|
|
|
|
---
|
|
|
|
## 1.3 Create Inquiry DTO
|
|
|
|
**File:** `src/modules/inquiries/presentation/dto/create-inquiry.dto.ts`
|
|
|
|
```typescript
|
|
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;
|
|
}
|
|
```
|
|
|
|
**Validations:**
|
|
- `listingId` - Required string
|
|
- `message` - Required string, max 2000 characters
|
|
- `phone` - Optional string
|
|
|
|
**Test Scenarios:**
|
|
- Valid input with all fields
|
|
- Valid input without phone
|
|
- Missing required fields
|
|
- Message exceeding 2000 characters
|
|
|
|
---
|
|
|
|
## 1.4 List Inquiries DTO
|
|
|
|
**File:** `src/modules/inquiries/presentation/dto/list-inquiries.dto.ts`
|
|
|
|
```typescript
|
|
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;
|
|
}
|
|
```
|
|
|
|
**Validations:**
|
|
- `page` - Optional integer, minimum 1
|
|
- `limit` - Optional integer, min 1, max 100
|
|
|
|
**Test Scenarios:**
|
|
- Valid pagination (page: 1, limit: 20)
|
|
- Custom pagination
|
|
- Invalid: page < 1, limit < 1, limit > 100
|
|
- Type transformation (string to number)
|
|
|
|
---
|
|
|
|
# PART 2: LEADS MODULE
|
|
|
|
## 2.1 Prisma Lead Repository
|
|
|
|
**File:** `src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts`
|
|
|
|
```typescript
|
|
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, type LeadStatsData, type 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,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key Methods to Test:**
|
|
- `findById()` - Returns lead or null
|
|
- `save()` - Creates lead with optional score
|
|
- `update()` - Updates status, score, notes
|
|
- `delete()` - Deletes lead
|
|
- `findByAgent()` - Paginated with optional status filter
|
|
- `getStatsByAgent()` - Aggregates by status, calculates conversion rate and avg score
|
|
- `toDomain()` - Maps Prisma model including LeadScore VO instantiation
|
|
|
|
**Test Scenarios:**
|
|
- CRUD operations
|
|
- Optional score handling (null and valid values)
|
|
- Status filtering in paginated query
|
|
- Stats calculations (zero leads, no scores, conversion rate precision)
|
|
- Pagination edge cases
|
|
|
|
---
|
|
|
|
## 2.2 Lead Score Value Object
|
|
|
|
**File:** `src/modules/leads/domain/value-objects/lead-score.vo.ts`
|
|
|
|
```typescript
|
|
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 }));
|
|
}
|
|
}
|
|
```
|
|
|
|
**Validation Rules:**
|
|
- Value must be integer between 0 and 100 inclusive
|
|
|
|
**Test Scenarios:**
|
|
- Valid scores (0, 50, 100)
|
|
- Invalid: negative, > 100, null, string
|
|
- Error message in Vietnamese
|
|
- Value object equality (should be comparable)
|
|
|
|
---
|
|
|
|
## 2.3 Leads Controller
|
|
|
|
**File:** `src/modules/leads/presentation/controllers/leads.controller.ts`
|
|
|
|
```typescript
|
|
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, type 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 };
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key Endpoints to Test:**
|
|
- `POST /leads` - Create lead with optional email, score, notes
|
|
- `GET /leads` - List agent's leads with optional status filter
|
|
- `GET /leads/stats` - Stats aggregation
|
|
- `PATCH /leads/:id/status` - Update lead status
|
|
- `DELETE /leads/:id` - Delete lead
|
|
|
|
**Test Scenarios:**
|
|
- Class-level guard (@Roles('AGENT') applies to all methods)
|
|
- Optional field handling (email, score, notes → null)
|
|
- Status filtering (null passes through, specific status filters)
|
|
- Command/query parameter mapping
|
|
- Return types verify (updated: true, deleted: true)
|
|
|
|
---
|
|
|
|
## 2.4 Create Lead DTO
|
|
|
|
**File:** `src/modules/leads/presentation/dto/create-lead.dto.ts`
|
|
|
|
```typescript
|
|
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>;
|
|
}
|
|
```
|
|
|
|
**Validations:**
|
|
- `name` - Required string
|
|
- `phone` - Required string
|
|
- `email` - Optional, must be valid email format
|
|
- `source` - Required string
|
|
- `score` - Optional number, 0-100
|
|
- `notes` - Optional object
|
|
|
|
**Test Scenarios:**
|
|
- All required fields present
|
|
- Optional fields omitted
|
|
- Invalid email format
|
|
- Score out of range
|
|
- Invalid note type
|
|
|
|
---
|
|
|
|
## 2.5 List Leads DTO
|
|
|
|
**File:** `src/modules/leads/presentation/dto/list-leads.dto.ts`
|
|
|
|
```typescript
|
|
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;
|
|
}
|
|
```
|
|
|
|
**Validations:**
|
|
- `status` - Optional, must be one of: NEW, CONTACTED, QUALIFIED, NEGOTIATING, CONVERTED, LOST
|
|
- `page` - Optional integer, minimum 1
|
|
- `limit` - Optional integer, 1-100
|
|
|
|
**Test Scenarios:**
|
|
- Valid pagination
|
|
- Valid status values
|
|
- Invalid status
|
|
- Type transformation (string to number)
|
|
- Boundary checks
|
|
|
|
---
|
|
|
|
## 2.6 Update Lead Status DTO
|
|
|
|
**File:** `src/modules/leads/presentation/dto/update-lead-status.dto.ts`
|
|
|
|
```typescript
|
|
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;
|
|
}
|
|
```
|
|
|
|
**Validations:**
|
|
- `status` - Required, must be valid lead status
|
|
|
|
**Test Scenarios:**
|
|
- Valid status transitions
|
|
- Invalid status
|
|
- Missing status field
|
|
|
|
---
|
|
|
|
# PART 3: REVIEWS MODULE
|
|
|
|
## 3.1 Prisma Review Repository
|
|
|
|
**File:** `src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts`
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { type Review as PrismaReview } from '@prisma/client';
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
import { PrismaService } from '@modules/shared';
|
|
import { ReviewEntity } from '../../domain/entities/review.entity';
|
|
import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
|
import { type IReviewRepository, type PaginatedResult } from '../../domain/repositories/review.repository';
|
|
import { Rating } from '../../domain/value-objects/rating.vo';
|
|
|
|
@Injectable()
|
|
export class PrismaReviewRepository implements IReviewRepository {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async findById(id: string): Promise<ReviewEntity | null> {
|
|
const review = await this.prisma.review.findUnique({ where: { id } });
|
|
return review ? this.toDomain(review) : null;
|
|
}
|
|
|
|
async findByUserAndTarget(
|
|
userId: string,
|
|
targetType: string,
|
|
targetId: string,
|
|
): Promise<ReviewEntity | null> {
|
|
const review = await this.prisma.review.findFirst({
|
|
where: { userId, targetType, targetId },
|
|
});
|
|
return review ? this.toDomain(review) : null;
|
|
}
|
|
|
|
async save(entity: ReviewEntity): Promise<void> {
|
|
await this.prisma.review.create({
|
|
data: {
|
|
id: entity.id,
|
|
userId: entity.userId,
|
|
targetType: entity.targetType,
|
|
targetId: entity.targetId,
|
|
rating: entity.rating.value,
|
|
comment: entity.comment,
|
|
},
|
|
});
|
|
}
|
|
|
|
async delete(id: string): Promise<void> {
|
|
await this.prisma.review.delete({ where: { id } });
|
|
}
|
|
|
|
async findByTarget(
|
|
targetType: string,
|
|
targetId: string,
|
|
page: number,
|
|
limit: number,
|
|
): Promise<PaginatedResult<ReviewItemData>> {
|
|
const take = Math.min(limit, 100);
|
|
const skip = (page - 1) * take;
|
|
const where = { targetType, targetId };
|
|
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.review.findMany({
|
|
where,
|
|
skip,
|
|
take,
|
|
orderBy: { createdAt: 'desc' },
|
|
include: { user: { select: { id: true, fullName: true } } },
|
|
}),
|
|
this.prisma.review.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
data: data.map((r) => ({
|
|
id: r.id,
|
|
userId: r.userId,
|
|
userName: r.user.fullName,
|
|
targetType: r.targetType,
|
|
targetId: r.targetId,
|
|
rating: r.rating,
|
|
comment: r.comment,
|
|
createdAt: r.createdAt.toISOString(),
|
|
})),
|
|
total,
|
|
page,
|
|
limit: take,
|
|
totalPages: Math.ceil(total / take),
|
|
};
|
|
}
|
|
|
|
async findByUserId(
|
|
userId: string,
|
|
page: number,
|
|
limit: number,
|
|
): Promise<PaginatedResult<ReviewItemData>> {
|
|
const take = Math.min(limit, 100);
|
|
const skip = (page - 1) * take;
|
|
const where = { userId };
|
|
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.review.findMany({
|
|
where,
|
|
skip,
|
|
take,
|
|
orderBy: { createdAt: 'desc' },
|
|
include: { user: { select: { id: true, fullName: true } } },
|
|
}),
|
|
this.prisma.review.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
data: data.map((r) => ({
|
|
id: r.id,
|
|
userId: r.userId,
|
|
userName: r.user.fullName,
|
|
targetType: r.targetType,
|
|
targetId: r.targetId,
|
|
rating: r.rating,
|
|
comment: r.comment,
|
|
createdAt: r.createdAt.toISOString(),
|
|
})),
|
|
total,
|
|
page,
|
|
limit: take,
|
|
totalPages: Math.ceil(total / take),
|
|
};
|
|
}
|
|
|
|
async getStats(targetType: string, targetId: string): Promise<ReviewStatsData> {
|
|
const reviews = await this.prisma.review.findMany({
|
|
where: { targetType, targetId },
|
|
select: { rating: true },
|
|
});
|
|
|
|
const totalReviews = reviews.length;
|
|
const distribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
|
let sum = 0;
|
|
|
|
for (const r of reviews) {
|
|
sum += r.rating;
|
|
distribution[r.rating] = (distribution[r.rating] ?? 0) + 1;
|
|
}
|
|
|
|
return {
|
|
targetType,
|
|
targetId,
|
|
averageRating: totalReviews > 0 ? Math.round((sum / totalReviews) * 10) / 10 : 0,
|
|
totalReviews,
|
|
distribution,
|
|
};
|
|
}
|
|
|
|
private toDomain(raw: PrismaReview): ReviewEntity {
|
|
const rating = Rating.create(raw.rating).unwrap();
|
|
return new ReviewEntity(
|
|
raw.id,
|
|
{
|
|
userId: raw.userId,
|
|
targetType: raw.targetType,
|
|
targetId: raw.targetId,
|
|
rating,
|
|
comment: raw.comment,
|
|
},
|
|
raw.createdAt,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key Methods to Test:**
|
|
- `findById()` - Returns review or null
|
|
- `findByUserAndTarget()` - Unique constraint check
|
|
- `save()` - Creates review with optional comment
|
|
- `delete()` - Deletes review
|
|
- `findByTarget()` - Paginated list with user join
|
|
- `findByUserId()` - Paginated list by user
|
|
- `getStats()` - Calculates average rating and distribution
|
|
- `toDomain()` - Maps Prisma model including Rating VO
|
|
|
|
**Test Scenarios:**
|
|
- All CRUD operations
|
|
- Optional comment handling
|
|
- Pagination with relationships (user fullName join)
|
|
- Stats with distribution calculation (1-5 scale)
|
|
- Zero reviews edge case
|
|
- Data accuracy (ISO dates, decimal rounding)
|
|
|
|
---
|
|
|
|
## 3.2 Rating Value Object
|
|
|
|
**File:** `src/modules/reviews/domain/value-objects/rating.vo.ts`
|
|
|
|
```typescript
|
|
import { Result, ValueObject } from '@modules/shared';
|
|
|
|
interface RatingProps {
|
|
value: number;
|
|
}
|
|
|
|
export class Rating extends ValueObject<RatingProps> {
|
|
get value(): number { return this.props.value; }
|
|
|
|
static create(value: number): Result<Rating, string> {
|
|
if (!Number.isInteger(value) || value < 1 || value > 5) {
|
|
return Result.err('Đánh giá phải từ 1 đến 5 sao');
|
|
}
|
|
return Result.ok(new Rating({ value }));
|
|
}
|
|
}
|
|
```
|
|
|
|
**Validation Rules:**
|
|
- Must be integer between 1 and 5 inclusive
|
|
|
|
**Test Scenarios:**
|
|
- Valid ratings (1-5)
|
|
- Invalid: 0, 6, -1, null, float (2.5), non-integer
|
|
- Error message verification
|
|
- Value object creation and equality
|
|
|
|
---
|
|
|
|
## 3.3 Create Review DTO
|
|
|
|
**File:** `src/modules/reviews/presentation/dto/create-review.dto.ts`
|
|
|
|
```typescript
|
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
|
|
|
export class CreateReviewDto {
|
|
@ApiProperty({ example: 'agent', description: 'Target entity type (e.g. agent, property)' })
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
targetType!: string;
|
|
|
|
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
targetId!: string;
|
|
|
|
@ApiProperty({ example: 5, description: 'Rating from 1 to 5', minimum: 1, maximum: 5 })
|
|
@IsInt()
|
|
@Min(1)
|
|
@Max(5)
|
|
rating!: number;
|
|
|
|
@ApiPropertyOptional({ example: 'Dịch vụ rất tốt!', description: 'Optional review comment' })
|
|
@IsOptional()
|
|
@IsString()
|
|
@MaxLength(2000)
|
|
comment?: string;
|
|
}
|
|
```
|
|
|
|
**Validations:**
|
|
- `targetType` - Required string (e.g. "agent", "property")
|
|
- `targetId` - Required string
|
|
- `rating` - Required integer, 1-5
|
|
- `comment` - Optional string, max 2000 characters
|
|
|
|
**Test Scenarios:**
|
|
- Valid review with all fields
|
|
- Valid without comment
|
|
- Missing required fields
|
|
- Invalid rating (0, 6, 2.5)
|
|
- Comment exceeding max length
|
|
|
|
---
|
|
|
|
## 3.4 List Reviews DTOs
|
|
|
|
**File:** `src/modules/reviews/presentation/dto/list-reviews.dto.ts`
|
|
|
|
```typescript
|
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
import { Type } from 'class-transformer';
|
|
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
|
|
|
|
export class ListReviewsByTargetDto {
|
|
@ApiProperty({ example: 'agent', description: 'Target entity type' })
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
targetType!: string;
|
|
|
|
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
targetId!: string;
|
|
|
|
@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;
|
|
}
|
|
|
|
export class ReviewStatsDto {
|
|
@ApiProperty({ example: 'agent', description: 'Target entity type' })
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
targetType!: string;
|
|
|
|
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
targetId!: string;
|
|
}
|
|
```
|
|
|
|
**ListReviewsByTargetDto Validations:**
|
|
- `targetType` - Required string
|
|
- `targetId` - Required string
|
|
- `page` - Optional, min 1
|
|
- `limit` - Optional, 1-100
|
|
|
|
**ReviewStatsDto Validations:**
|
|
- `targetType` - Required string
|
|
- `targetId` - Required string
|
|
|
|
**Test Scenarios:**
|
|
- Valid pagination with required target fields
|
|
- Type transformation
|
|
- Boundary checks on pagination
|
|
|
|
---
|
|
|
|
## 3.5 Reviews Controller
|
|
|
|
**File:** `src/modules/reviews/presentation/controllers/reviews.controller.ts`
|
|
|
|
```typescript
|
|
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Param,
|
|
Post,
|
|
Query,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
|
import {
|
|
ApiTags,
|
|
ApiOperation,
|
|
ApiResponse,
|
|
ApiBearerAuth,
|
|
ApiParam,
|
|
} from '@nestjs/swagger';
|
|
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
|
import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command';
|
|
import { type CreateReviewResult } from '../../application/commands/create-review/create-review.handler';
|
|
import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';
|
|
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query';
|
|
import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query';
|
|
import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
|
|
import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
|
import { type PaginatedResult } from '../../domain/repositories/review.repository';
|
|
import { type CreateReviewDto } from '../dto/create-review.dto';
|
|
import { type ListReviewsByTargetDto, type ReviewStatsDto } from '../dto/list-reviews.dto';
|
|
|
|
@ApiTags('reviews')
|
|
@Controller('reviews')
|
|
export class ReviewsController {
|
|
constructor(
|
|
private readonly commandBus: CommandBus,
|
|
private readonly queryBus: QueryBus,
|
|
) {}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Create a review' })
|
|
@ApiResponse({ status: 201, description: 'Review created successfully' })
|
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 409, description: 'Already reviewed this target' })
|
|
@UseGuards(JwtAuthGuard)
|
|
@Post()
|
|
async createReview(
|
|
@Body() dto: CreateReviewDto,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<CreateReviewResult> {
|
|
return this.commandBus.execute(
|
|
new CreateReviewCommand(
|
|
user.sub,
|
|
dto.targetType,
|
|
dto.targetId,
|
|
dto.rating,
|
|
dto.comment ?? null,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'List reviews by target' })
|
|
@ApiResponse({ status: 200, description: 'Paginated list of reviews' })
|
|
@Get()
|
|
async getReviewsByTarget(
|
|
@Query() dto: ListReviewsByTargetDto,
|
|
): Promise<PaginatedResult<ReviewItemData>> {
|
|
return this.queryBus.execute(
|
|
new GetReviewsByTargetQuery(
|
|
dto.targetType,
|
|
dto.targetId,
|
|
dto.page ?? 1,
|
|
dto.limit ?? 20,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Get aggregate rating stats for a target' })
|
|
@ApiResponse({ status: 200, description: 'Rating statistics' })
|
|
@Get('stats')
|
|
async getStats(
|
|
@Query() dto: ReviewStatsDto,
|
|
): Promise<ReviewStatsData> {
|
|
return this.queryBus.execute(
|
|
new GetAverageRatingQuery(dto.targetType, dto.targetId),
|
|
);
|
|
}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Get reviews by authenticated user' })
|
|
@ApiResponse({ status: 200, description: 'Paginated list of user reviews' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@UseGuards(JwtAuthGuard)
|
|
@Get('me')
|
|
async getMyReviews(
|
|
@CurrentUser() user: JwtPayload,
|
|
@Query('page') page?: number,
|
|
@Query('limit') limit?: number,
|
|
): Promise<PaginatedResult<ReviewItemData>> {
|
|
return this.queryBus.execute(
|
|
new GetReviewsByUserQuery(user.sub, page ?? 1, limit ?? 20),
|
|
);
|
|
}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Delete own review' })
|
|
@ApiParam({ name: 'id', description: 'Review ID' })
|
|
@ApiResponse({ status: 200, description: 'Review deleted' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 403, description: 'Cannot delete another user\'s review' })
|
|
@ApiResponse({ status: 404, description: 'Review not found' })
|
|
@UseGuards(JwtAuthGuard)
|
|
@Delete(':id')
|
|
async deleteReview(
|
|
@Param('id') id: string,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<{ deleted: boolean }> {
|
|
await this.commandBus.execute(new DeleteReviewCommand(id, user.sub));
|
|
return { deleted: true };
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key Endpoints to Test:**
|
|
- `POST /reviews` - Create with optional comment
|
|
- `GET /reviews` - List by target (no auth required)
|
|
- `GET /reviews/stats` - Stats for target (no auth required)
|
|
- `GET /reviews/me` - List user's reviews (requires auth)
|
|
- `DELETE /reviews/:id` - Delete review (requires auth)
|
|
|
|
**Test Scenarios:**
|
|
- Command/query dispatch with correct parameters
|
|
- Default pagination (1, 20)
|
|
- Null comment handling
|
|
- Auth requirements (JWT for POST, GET /me, DELETE; no auth for GET, GET/stats)
|
|
- Return types (deleted: true)
|
|
|
|
---
|
|
|
|
# PART 4: REFERENCE TEST PATTERNS
|
|
|
|
## 4.1 Example Handler Test: Create Inquiry Handler
|
|
|
|
**File:** `src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts`
|
|
|
|
```typescript
|
|
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() },
|
|
};
|
|
|
|
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
|
|
|
handler = new CreateInquiryHandler(
|
|
mockInquiryRepo as any,
|
|
mockEventBus as unknown as EventBus,
|
|
mockPrisma as any,
|
|
mockLogger 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',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Pattern Insights:**
|
|
- Uses `vi.fn()` from Vitest for mocking
|
|
- Mock all repository methods in beforeEach
|
|
- Test happy path with all dependencies mocked
|
|
- Test error scenarios (NotFoundException)
|
|
- Verify side effects (event publishing)
|
|
- Uses exact parameter checking or matchers (`expect.objectContaining`)
|
|
|
|
---
|
|
|
|
## 4.2 Example Handler Test: Create Lead Handler
|
|
|
|
**File:** `src/modules/leads/application/__tests__/create-lead.handler.spec.ts`
|
|
|
|
```typescript
|
|
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() },
|
|
};
|
|
|
|
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
|
|
|
handler = new CreateLeadHandler(
|
|
mockLeadRepo as any,
|
|
mockEventBus as unknown as EventBus,
|
|
mockPrisma as any,
|
|
mockLogger 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();
|
|
});
|
|
});
|
|
```
|
|
|
|
**Additional Pattern Notes:**
|
|
- Tests optional parameter handling (score: null)
|
|
- Tests validation errors from value objects (LeadScore)
|
|
- Uses same mock setup structure as inquiry handler
|
|
|
|
---
|
|
|
|
## 4.3 Example Controller Test: Reviews Controller
|
|
|
|
**File:** `src/modules/reviews/presentation/__tests__/reviews.controller.spec.ts`
|
|
|
|
```typescript
|
|
import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command';
|
|
import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';
|
|
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query';
|
|
import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query';
|
|
import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
|
|
import { ReviewsController } from '../controllers/reviews.controller';
|
|
|
|
describe('ReviewsController', () => {
|
|
let controller: ReviewsController;
|
|
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
|
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
|
|
|
const mockUser = { sub: 'user-1', phone: '0901234567', role: 'BUYER' };
|
|
|
|
beforeEach(() => {
|
|
mockCommandBus = { execute: vi.fn() };
|
|
mockQueryBus = { execute: vi.fn() };
|
|
controller = new ReviewsController(mockCommandBus as any, mockQueryBus as any);
|
|
});
|
|
|
|
describe('POST /reviews — createReview', () => {
|
|
it('dispatches CreateReviewCommand with correct parameters', async () => {
|
|
const dto = { targetType: 'agent', targetId: 'agent-1', rating: 5, comment: 'Tuyệt vời!' };
|
|
const expected = { id: 'rev-1', rating: 5, targetType: 'agent', targetId: 'agent-1', createdAt: '2026-01-01T00:00:00.000Z' };
|
|
mockCommandBus.execute.mockResolvedValue(expected);
|
|
|
|
const result = await controller.createReview(dto as any, mockUser as any);
|
|
|
|
expect(mockCommandBus.execute).toHaveBeenCalledWith(
|
|
expect.any(CreateReviewCommand),
|
|
);
|
|
const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateReviewCommand;
|
|
expect(cmd.userId).toBe('user-1');
|
|
expect(cmd.targetType).toBe('agent');
|
|
expect(cmd.targetId).toBe('agent-1');
|
|
expect(cmd.rating).toBe(5);
|
|
expect(cmd.comment).toBe('Tuyệt vời!');
|
|
expect(result).toEqual(expected);
|
|
});
|
|
|
|
it('passes null comment when not provided', async () => {
|
|
const dto = { targetType: 'property', targetId: 'prop-1', rating: 3 };
|
|
mockCommandBus.execute.mockResolvedValue({ id: 'rev-2' });
|
|
|
|
await controller.createReview(dto as any, mockUser as any);
|
|
|
|
const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateReviewCommand;
|
|
expect(cmd.comment).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('GET /reviews — getReviewsByTarget', () => {
|
|
it('dispatches GetReviewsByTargetQuery with defaults', async () => {
|
|
const dto = { targetType: 'agent', targetId: 'agent-1' };
|
|
const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
|
mockQueryBus.execute.mockResolvedValue(expected);
|
|
|
|
const result = await controller.getReviewsByTarget(dto as any);
|
|
|
|
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
|
expect.any(GetReviewsByTargetQuery),
|
|
);
|
|
const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByTargetQuery;
|
|
expect(query.targetType).toBe('agent');
|
|
expect(query.targetId).toBe('agent-1');
|
|
expect(query.page).toBe(1);
|
|
expect(query.limit).toBe(20);
|
|
expect(result).toEqual(expected);
|
|
});
|
|
|
|
it('passes custom page and limit', async () => {
|
|
const dto = { targetType: 'agent', targetId: 'agent-1', page: 3, limit: 10 };
|
|
mockQueryBus.execute.mockResolvedValue({ data: [] });
|
|
|
|
await controller.getReviewsByTarget(dto as any);
|
|
|
|
const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByTargetQuery;
|
|
expect(query.page).toBe(3);
|
|
expect(query.limit).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('GET /reviews/stats — getStats', () => {
|
|
it('dispatches GetAverageRatingQuery', async () => {
|
|
const dto = { targetType: 'agent', targetId: 'agent-1' };
|
|
const expected = { targetType: 'agent', targetId: 'agent-1', averageRating: 4.5, totalReviews: 10, distribution: {} };
|
|
mockQueryBus.execute.mockResolvedValue(expected);
|
|
|
|
const result = await controller.getStats(dto as any);
|
|
|
|
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
|
expect.any(GetAverageRatingQuery),
|
|
);
|
|
const query = mockQueryBus.execute.mock.calls[0]![0] as GetAverageRatingQuery;
|
|
expect(query.targetType).toBe('agent');
|
|
expect(query.targetId).toBe('agent-1');
|
|
expect(result).toEqual(expected);
|
|
});
|
|
});
|
|
|
|
describe('GET /reviews/me — getMyReviews', () => {
|
|
it('dispatches GetReviewsByUserQuery with defaults', async () => {
|
|
const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
|
mockQueryBus.execute.mockResolvedValue(expected);
|
|
|
|
const result = await controller.getMyReviews(mockUser as any);
|
|
|
|
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
|
expect.any(GetReviewsByUserQuery),
|
|
);
|
|
const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByUserQuery;
|
|
expect(query.userId).toBe('user-1');
|
|
expect(query.page).toBe(1);
|
|
expect(query.limit).toBe(20);
|
|
expect(result).toEqual(expected);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /reviews/:id — deleteReview', () => {
|
|
it('dispatches DeleteReviewCommand and returns { deleted: true }', async () => {
|
|
mockCommandBus.execute.mockResolvedValue(undefined);
|
|
|
|
const result = await controller.deleteReview('rev-1', mockUser as any);
|
|
|
|
expect(mockCommandBus.execute).toHaveBeenCalledWith(
|
|
expect.any(DeleteReviewCommand),
|
|
);
|
|
const cmd = mockCommandBus.execute.mock.calls[0]![0] as DeleteReviewCommand;
|
|
expect(cmd.reviewId).toBe('rev-1');
|
|
expect(cmd.userId).toBe('user-1');
|
|
expect(result).toEqual({ deleted: true });
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
**Controller Test Pattern Insights:**
|
|
- Organized by endpoint using describe blocks
|
|
- Tests dispatch with correct command/query types
|
|
- Verifies individual command properties after dispatch
|
|
- Tests default parameter handling
|
|
- Tests optional field handling (comment null)
|
|
- Tests return type verification
|
|
|
|
---
|
|
|
|
# SUMMARY TABLE
|
|
|
|
| File | Module | Type | Key Tests |
|
|
|------|--------|------|-----------|
|
|
| prisma-inquiry.repository.ts | Inquiries | Repository | findById, save, markAsRead, findByListing, findByAgent, countUnreadByAgent, toDomain |
|
|
| inquiries.controller.ts | Inquiries | Controller | POST create, GET by listing, GET by agent, PATCH mark read, guard enforcement |
|
|
| create-inquiry.dto.ts | Inquiries | DTO | listingId, message (max 2000), phone (optional) |
|
|
| list-inquiries.dto.ts | Inquiries | DTO | page/limit validation, type transformation |
|
|
| prisma-lead.repository.ts | Leads | Repository | CRUD, findByAgent with status filter, getStatsByAgent aggregation |
|
|
| lead-score.vo.ts | Leads | Value Object | Range validation (0-100), error messages |
|
|
| leads.controller.ts | Leads | Controller | POST create, GET list, GET stats, PATCH status, DELETE, class-level @Roles |
|
|
| create-lead.dto.ts | Leads | DTO | name, phone, email, source, score (0-100), notes |
|
|
| list-leads.dto.ts | Leads | DTO | status enum validation, pagination |
|
|
| update-lead-status.dto.ts | Leads | DTO | status enum validation |
|
|
| prisma-review.repository.ts | Reviews | Repository | findById, findByUserAndTarget, CRUD, pagination, getStats with distribution |
|
|
| rating.vo.ts | Reviews | Value Object | Integer range (1-5), error messages |
|
|
| reviews.controller.ts | Reviews | Controller | POST create, GET by target, GET stats, GET me, DELETE, mixed auth |
|
|
| create-review.dto.ts | Reviews | DTO | targetType, targetId, rating (1-5), comment (optional, max 2000) |
|
|
| list-reviews.dto.ts | Reviews | DTO | ListReviewsByTargetDto, ReviewStatsDto, pagination |
|
|
|
|
---
|
|
|
|
# TESTING PRIORITIES
|
|
|
|
**High Priority (Critical Business Logic):**
|
|
1. Repository methods - data integrity, pagination accuracy
|
|
2. Value objects - validation, error messages
|
|
3. Controllers - command dispatch, parameter mapping
|
|
4. DTOs - validation rules, type transformation
|
|
|
|
**Medium Priority (Guard Rails):**
|
|
1. Optional field handling
|
|
2. Null/undefined coercion (→ null)
|
|
3. Pagination boundary checks
|
|
4. Aggregation calculations (avg, sum, distribution)
|
|
|
|
**Low Priority (Framework):**
|
|
1. Decorator validation (framework responsibility)
|
|
2. Error serialization
|
|
3. Swagger documentation
|
|
|