Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 29s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m42s
Deploy / Build Web Image (push) Failing after 27s
Deploy / Build AI Services Image (push) Failing after 29s
E2E Tests / Playwright E2E (push) Failing after 43s
Deploy / Build API Image (push) Failing after 1m31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 5m35s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 3m45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — Web Image (push) Failing after 13m51s
Security Scanning / Trivy Filesystem Scan (push) Failing after 14m46s
Security Scanning / Security Gate (push) Has been cancelled
703 lines
25 KiB
Markdown
703 lines
25 KiB
Markdown
# Module Inquiries - Khám Phá Toàn Diện
|
|
|
|
**GoodGo Platform Backend API**
|
|
**Vị Trí Module:** `apps/api/src/modules/inquiries/`
|
|
**Tổng Số Dòng Code:** 1.212 dòng
|
|
**Ngày Khám Phá:** 11 tháng 4 năm 2026
|
|
|
|
---
|
|
|
|
## 📋 MỤC LỤC
|
|
|
|
1. [Cấu Trúc Thư Mục](#directory-structure)
|
|
2. [Danh Sách File Đầy Đủ](#complete-file-listing)
|
|
3. [Kiến Trúc Module](#module-architecture)
|
|
4. [Các Class & Handler Chính](#key-classes--handlers)
|
|
5. [Phân Tích Tầng DDD](#ddd-layer-analysis)
|
|
6. [Tóm Tắt File Test](#test-files-summary)
|
|
|
|
---
|
|
|
|
## 📁 CẤU TRÚC THƯ MỤC
|
|
|
|
```
|
|
apps/api/src/modules/inquiries/
|
|
├── application/
|
|
│ ├── __tests__/
|
|
│ │ ├── create-inquiry.handler.spec.ts
|
|
│ │ ├── get-inquiries-by-agent.handler.spec.ts
|
|
│ │ ├── get-inquiries-by-listing.handler.spec.ts
|
|
│ │ └── mark-inquiry-read.handler.spec.ts
|
|
│ ├── commands/
|
|
│ │ ├── create-inquiry/
|
|
│ │ │ ├── create-inquiry.command.ts
|
|
│ │ │ └── create-inquiry.handler.ts
|
|
│ │ └── mark-inquiry-read/
|
|
│ │ ├── mark-inquiry-read.command.ts
|
|
│ │ └── mark-inquiry-read.handler.ts
|
|
│ └── queries/
|
|
│ ├── get-inquiries-by-agent/
|
|
│ │ ├── get-inquiries-by-agent.handler.ts
|
|
│ │ └── get-inquiries-by-agent.query.ts
|
|
│ └── get-inquiries-by-listing/
|
|
│ ├── get-inquiries-by-listing.handler.ts
|
|
│ └── get-inquiries-by-listing.query.ts
|
|
├── domain/
|
|
│ ├── __tests__/
|
|
│ │ └── inquiry-domain.spec.ts
|
|
│ ├── entities/
|
|
│ │ └── inquiry.entity.ts
|
|
│ ├── events/
|
|
│ │ ├── inquiry-created.event.ts
|
|
│ │ └── inquiry-read.event.ts
|
|
│ └── repositories/
|
|
│ ├── inquiry.repository.ts
|
|
│ └── inquiry-read.dto.ts
|
|
├── infrastructure/
|
|
│ └── repositories/
|
|
│ └── prisma-inquiry.repository.ts
|
|
├── presentation/
|
|
│ ├── __tests__/
|
|
│ │ └── inquiries.controller.spec.ts
|
|
│ ├── controllers/
|
|
│ │ └── inquiries.controller.ts
|
|
│ └── dto/
|
|
│ ├── create-inquiry.dto.ts
|
|
│ └── list-inquiries.dto.ts
|
|
├── index.ts
|
|
└── inquiries.module.ts
|
|
```
|
|
|
|
---
|
|
|
|
## 📄 DANH SÁCH FILE ĐẦY ĐỦ
|
|
|
|
### **TẦNG ỨNG DỤNG (APPLICATION LAYER)** (8 file)
|
|
|
|
#### Lệnh (Commands) (4 file)
|
|
|
|
| Đường Dẫn File | Loại | Mô Tả |
|
|
|-----------|------|-------------|
|
|
| `application/commands/create-inquiry/create-inquiry.command.ts` | Command | DTO để tạo yêu cầu - chứa userId, listingId, message, phone |
|
|
| `application/commands/create-inquiry/create-inquiry.handler.ts` | Command Handler | Thực thi CreateInquiryCommand; xác thực listing tồn tại, tạo entity, phát sự kiện |
|
|
| `application/commands/mark-inquiry-read/mark-inquiry-read.command.ts` | Command | DTO để đánh dấu yêu cầu đã đọc - chứa inquiryId, agentUserId |
|
|
| `application/commands/mark-inquiry-read/mark-inquiry-read.handler.ts` | Command Handler | Thực thi MarkInquiryReadCommand; xác thực quyền, đánh dấu yêu cầu đã đọc, phát sự kiện |
|
|
|
|
#### Truy Vấn (Queries) (4 file)
|
|
|
|
| Đường Dẫn File | Loại | Mô Tả |
|
|
|-----------|------|-------------|
|
|
| `application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query.ts` | Query | DTO để liệt kê yêu cầu của đại lý - chứa agentUserId, page, limit |
|
|
| `application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler.ts` | Query Handler | Phân giải đại lý theo userId, ủy quyền cho repository findByAgent |
|
|
| `application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query.ts` | Query | DTO để liệt kê yêu cầu theo listing - chứa listingId, page, limit |
|
|
| `application/queries/get-inquiries-by-listing/get-inquiries-by-listing.handler.ts` | Query Handler | Ủy quyền trực tiếp cho repository findByListing |
|
|
|
|
#### Test Tầng Ứng Dụng (4 file)
|
|
|
|
| Đường Dẫn File | Loại | Số Test | Phạm Vi |
|
|
|-----------|------|-----------|----------|
|
|
| `application/__tests__/create-inquiry.handler.spec.ts` | Jest | 4 test | Happy path, listing không tồn tại, phát sự kiện domain |
|
|
| `application/__tests__/mark-inquiry-read.handler.spec.ts` | Jest | 5 test | Happy path, yêu cầu không tồn tại, listing không tồn tại, truy cập bị cấm, đại lý không tìm thấy |
|
|
| `application/__tests__/get-inquiries-by-listing.handler.spec.ts` | Jest | 2 test | Kết quả phân trang, kết quả rỗng |
|
|
| `application/__tests__/get-inquiries-by-agent.handler.spec.ts` | Jest | 2 test | Kết quả phân trang, đại lý không tìm thấy |
|
|
|
|
---
|
|
|
|
### **TẦNG DOMAIN** (6 file)
|
|
|
|
#### Thực Thể (Entities) (1 file)
|
|
|
|
| Đường Dẫn File | Loại | Mô Tả |
|
|
|-----------|------|-------------|
|
|
| `domain/entities/inquiry.entity.ts` | Aggregate Root | Thực thể domain cốt lõi với id, listingId, userId, message, phone, isRead; các phương thức: createNew(), markAsRead() |
|
|
|
|
#### Sự Kiện (Events) (2 file)
|
|
|
|
| Đường Dẫn File | Loại | Mô Tả |
|
|
|-----------|------|-------------|
|
|
| `domain/events/inquiry-created.event.ts` | Domain Event | Được phát khi yêu cầu được tạo - chứa aggregateId, listingId, userId |
|
|
| `domain/events/inquiry-read.event.ts` | Domain Event | Được phát khi yêu cầu được đánh dấu đã đọc - chứa aggregateId, listingId, userId |
|
|
|
|
#### Repository (2 file)
|
|
|
|
| Đường Dẫn File | Loại | Mô Tả |
|
|
|-----------|------|-------------|
|
|
| `domain/repositories/inquiry.repository.ts` | Interface + Symbol | Interface IInquiryRepository với 6 phương thức; symbol INQUIRY_REPOSITORY cho DI; interface PaginatedResult |
|
|
| `domain/repositories/inquiry-read.dto.ts` | Interface | Read DTO cho các truy vấn - bao gồm tiêu đề listing, thông tin người dùng, dấu thời gian |
|
|
|
|
#### Test Domain (1 file)
|
|
|
|
| Đường Dẫn File | Loại | Số Test | Phạm Vi |
|
|
|-----------|------|-----------|----------|
|
|
| `domain/__tests__/inquiry-domain.spec.ts` | Jest | 5 test | Tạo entity, phone null, sự kiện domain, markAsRead |
|
|
|
|
---
|
|
|
|
### **TẦNG CƠ SỞ HẠ TẦNG (INFRASTRUCTURE LAYER)** (1 file)
|
|
|
|
#### Triển Khai Repository
|
|
|
|
| Đường Dẫn File | Loại | Mô Tả |
|
|
|-----------|------|-------------|
|
|
| `infrastructure/repositories/prisma-inquiry.repository.ts` | Service | Triển khai IInquiryRepository sử dụng Prisma; 6 phương thức: findById, save, markAsRead, findByListing, findByAgent, countUnreadByAgent |
|
|
|
|
---
|
|
|
|
### **TẦNG TRÌNH BÀY (PRESENTATION LAYER)** (5 file)
|
|
|
|
#### Controller (1 file)
|
|
|
|
| Đường Dẫn File | Loại | Routes | Xác Thực |
|
|
|-----------|------|--------|------|
|
|
| `presentation/controllers/inquiries.controller.ts` | NestJS Controller | POST /, GET /listing/:id, GET /agent/me, PATCH /:id/read | JWT + RBAC |
|
|
|
|
**Các Endpoint:**
|
|
- `POST /inquiries` - Tạo yêu cầu (BUYER)
|
|
- `GET /inquiries/listing/:listingId` - Lấy yêu cầu theo listing
|
|
- `GET /inquiries/agent/me` - Lấy yêu cầu cho đại lý đang đăng nhập (chỉ AGENT)
|
|
- `PATCH /inquiries/:id/read` - Đánh dấu yêu cầu đã đọc (chỉ AGENT)
|
|
|
|
#### Đối Tượng Truyền Dữ Liệu - Data Transfer Objects (2 file)
|
|
|
|
| Đường Dẫn File | Loại | Thuộc Tính | Kiểm Tra Hợp Lệ |
|
|
|-----------|------|-----------|------------|
|
|
| `presentation/dto/create-inquiry.dto.ts` | DTO | listingId, message, phone? | Chuỗi bắt buộc, message tối đa 2000 ký tự |
|
|
| `presentation/dto/list-inquiries.dto.ts` | DTO | page?, limit? | Số nguyên, min 1, max 100, mặc định 1/20 |
|
|
|
|
#### Test Tầng Trình Bày (1 file)
|
|
|
|
| Đường Dẫn File | Loại | Số Test | Phạm Vi |
|
|
|-----------|------|-----------|----------|
|
|
| `presentation/__tests__/inquiries.controller.spec.ts` | Jest | 6 test | Tất cả 4 endpoint, xử lý phone null, giá trị phân trang mặc định |
|
|
|
|
---
|
|
|
|
### **FILE MODULE** (2 file)
|
|
|
|
| Đường Dẫn File | Loại | Mô Tả |
|
|
|-----------|------|-------------|
|
|
| `inquiries.module.ts` | NestJS Module | Xuất: InquiriesController; Provider: PrismaInquiryRepository, 2 command handler, 2 query handler |
|
|
| `index.ts` | Barrel Export | Xuất: InquiriesModule, symbol INQUIRY_REPOSITORY, interface IInquiryRepository, InquiryEntity |
|
|
|
|
---
|
|
|
|
## 🏗️ KIẾN TRÚC MODULE
|
|
|
|
### **Mẫu Thiết Kế: CQRS + Event Sourcing + DDD**
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ PRESENTATION LAYER │
|
|
│ Controllers + DTOs (inquiries.controller.ts) │
|
|
└────────────────────────┬────────────────────────────────────┘
|
|
│
|
|
┌────────────────┼────────────────┐
|
|
↓ ↓ ↓
|
|
┌─────────┐ ┌─────────┐ ┌─────────┐
|
|
│ Commands│ │ Queries │ │ Validation│
|
|
└────┬────┘ └────┬────┘ └─────────┘
|
|
│ │
|
|
└──────┬───────┘
|
|
↓
|
|
┌──────────────────────┐
|
|
│ APPLICATION LAYER │
|
|
│ Handlers + Services │
|
|
└──────────┬───────────┘
|
|
│
|
|
┌──────┴──────┐
|
|
↓ ↓
|
|
┌──────────┐ ┌──────────┐
|
|
│ Commands │ │ Queries │
|
|
│ Handlers │ │ Handlers │
|
|
└────┬─────┘ └────┬─────┘
|
|
│ │
|
|
└──────┬──────┘
|
|
↓
|
|
┌──────────────────────┐
|
|
│ DOMAIN LAYER │
|
|
│ Entities + Events │
|
|
│ Repository Interface │
|
|
└──────────┬───────────┘
|
|
│
|
|
↓
|
|
┌──────────────────────┐
|
|
│ INFRASTRUCTURE LAYER │
|
|
│ Prisma Repository │
|
|
│ Database Queries │
|
|
└──────────────────────┘
|
|
```
|
|
|
|
### **Luồng Dữ Liệu**
|
|
|
|
**Tạo Yêu Cầu:**
|
|
```
|
|
POST /inquiries (CreateInquiryDto)
|
|
→ InquiriesController.createInquiry()
|
|
→ CommandBus.execute(CreateInquiryCommand)
|
|
→ CreateInquiryHandler.execute()
|
|
- Xác thực listing tồn tại (Prisma)
|
|
- Tạo InquiryEntity
|
|
- Lưu vào repository (Prisma)
|
|
- Phát InquiryCreatedEvent qua EventBus
|
|
→ Trả về { id, listingId, createdAt }
|
|
```
|
|
|
|
**Đánh Dấu Đã Đọc:**
|
|
```
|
|
PATCH /inquiries/:id/read (chỉ agent)
|
|
→ InquiriesController.markAsRead()
|
|
→ CommandBus.execute(MarkInquiryReadCommand)
|
|
→ MarkInquiryReadHandler.execute()
|
|
- Tìm inquiry entity
|
|
- Xác minh đại lý là chủ sở hữu listing
|
|
- Gọi entity.markAsRead()
|
|
- Cập nhật trong repository
|
|
- Phát InquiryReadEvent qua EventBus
|
|
→ Trả về { success: true }
|
|
```
|
|
|
|
**Lấy Danh Sách Yêu Cầu:**
|
|
```
|
|
GET /inquiries/listing/:id or /agent/me (có phân trang)
|
|
→ InquiriesController.getByListing() or getMyInquiries()
|
|
→ QueryBus.execute(GetInquiriesByListingQuery or GetInquiriesByAgentQuery)
|
|
→ Handler ủy quyền cho repository
|
|
→ Repository.findByListing() or findByAgent()
|
|
- Truy vấn Prisma với join
|
|
- Map sang InquiryReadDto
|
|
- Trả về PaginatedResult
|
|
→ Trả về dữ liệu phân trang
|
|
```
|
|
|
|
---
|
|
|
|
## 🔑 CÁC CLASS & HANDLER CHÍNH
|
|
|
|
### **Domain Entity: InquiryEntity**
|
|
|
|
```typescript
|
|
export class InquiryEntity extends AggregateRoot<string> {
|
|
// Thuộc tính
|
|
private _listingId: string;
|
|
private _userId: string;
|
|
private _message: string;
|
|
private _phone: string | null;
|
|
private _isRead: boolean;
|
|
|
|
// Factory Method
|
|
static createNew(id, listingId, userId, message, phone): InquiryEntity
|
|
→ Tạo yêu cầu mới với isRead=false
|
|
→ Phát InquiryCreatedEvent
|
|
|
|
// Business Logic
|
|
markAsRead(): void
|
|
→ Đặt isRead thành true
|
|
→ Phát InquiryReadEvent
|
|
}
|
|
```
|
|
|
|
### **Command Handlers**
|
|
|
|
#### CreateInquiryHandler
|
|
```typescript
|
|
class CreateInquiryHandler implements ICommandHandler<CreateInquiryCommand> {
|
|
async execute(command: CreateInquiryCommand): Promise<CreateInquiryResult> {
|
|
// 1. Xác thực listing tồn tại
|
|
const listing = await prisma.listing.findUnique(...)
|
|
if (!listing) throw NotFoundException
|
|
|
|
// 2. Tạo entity bằng factory
|
|
const inquiry = InquiryEntity.createNew(...)
|
|
|
|
// 3. Lưu vào cơ sở dữ liệu
|
|
await inquiryRepo.save(inquiry)
|
|
|
|
// 4. Phát các sự kiện domain
|
|
const events = inquiry.clearDomainEvents()
|
|
events.forEach(e => eventBus.publish(e))
|
|
|
|
return { id, listingId, createdAt }
|
|
}
|
|
}
|
|
```
|
|
|
|
#### MarkInquiryReadHandler
|
|
```typescript
|
|
class MarkInquiryReadHandler implements ICommandHandler<MarkInquiryReadCommand> {
|
|
async execute(command: MarkInquiryReadCommand): Promise<void> {
|
|
// 1. Tải aggregate root
|
|
const inquiry = await inquiryRepo.findById(...)
|
|
if (!inquiry) throw NotFoundException
|
|
|
|
// 2. Xác minh phân quyền
|
|
const listing = await prisma.listing.findUnique(...)
|
|
const agent = await prisma.agent.findUnique(...)
|
|
if (agent.id !== listing.agentId) throw ForbiddenException
|
|
|
|
// 3. Cập nhật trạng thái domain
|
|
inquiry.markAsRead()
|
|
|
|
// 4. Lưu trạng thái
|
|
await inquiryRepo.markAsRead(...)
|
|
|
|
// 5. Phát sự kiện
|
|
const events = inquiry.clearDomainEvents()
|
|
events.forEach(e => eventBus.publish(e))
|
|
}
|
|
}
|
|
```
|
|
|
|
### **Query Handlers**
|
|
|
|
#### GetInquiriesByListingHandler
|
|
```typescript
|
|
class GetInquiriesByListingHandler implements IQueryHandler<GetInquiriesByListingQuery> {
|
|
async execute(query: GetInquiriesByListingQuery): Promise<PaginatedResult<InquiryReadDto>> {
|
|
return this.inquiryRepo.findByListing(
|
|
query.listingId,
|
|
query.page,
|
|
query.limit
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
#### GetInquiriesByAgentHandler
|
|
```typescript
|
|
class GetInquiriesByAgentHandler implements IQueryHandler<GetInquiriesByAgentQuery> {
|
|
async execute(query: GetInquiriesByAgentQuery): Promise<PaginatedResult<InquiryReadDto>> {
|
|
// 1. Phân giải agent ID từ user ID
|
|
const agent = await prisma.agent.findUnique({ where: { userId } })
|
|
if (!agent) throw NotFoundException
|
|
|
|
// 2. Ủy quyền cho repository
|
|
return this.inquiryRepo.findByAgent(agent.id, page, limit)
|
|
}
|
|
}
|
|
```
|
|
|
|
### **Triển Khai Repository**
|
|
|
|
#### PrismaInquiryRepository
|
|
|
|
**Các Phương Thức:**
|
|
1. `findById(id)` - Tra cứu một yêu cầu đơn lẻ
|
|
2. `save(entity)` - Tạo yêu cầu
|
|
3. `markAsRead(id)` - Cập nhật cờ isRead
|
|
4. `findByListing(listingId, page, limit)` - Tìm kiếm phân trang với join
|
|
5. `findByAgent(agentId, page, limit)` - Tìm kiếm phân trang qua listing agent
|
|
6. `countUnreadByAgent(agentId)` - Tổng hợp số lượng chưa đọc
|
|
|
|
**Quan Hệ Prisma Được Sử Dụng:**
|
|
- `inquiry.listing` → property (để lấy tiêu đề)
|
|
- `inquiry.user` → fullName, phone
|
|
- `listing.agentId` → lọc theo agent
|
|
- Phân trang: skip/take với orderBy giảm dần
|
|
|
|
---
|
|
|
|
## 🎯 CẤU TRÚC TẦNG DDD
|
|
|
|
### **TẦNG DOMAIN** (`domain/`)
|
|
|
|
**Mục Đích:** Logic nghiệp vụ thuần túy, độc lập với framework
|
|
|
|
**Bao Gồm:**
|
|
- **Entities** - `inquiry.entity.ts` (Aggregate Root)
|
|
- Class TypeScript thuần túy
|
|
- Đóng gói quy tắc nghiệp vụ (cờ isRead, kiểm tra hợp lệ trường dữ liệu)
|
|
- Factory method để tạo đối tượng
|
|
- Các phương thức chuyển đổi trạng thái
|
|
- Thu thập sự kiện domain
|
|
|
|
- **Events** - `inquiry-created.event.ts`, `inquiry-read.event.ts`
|
|
- Ghi lại các sự kiện nghiệp vụ quan trọng
|
|
- Dùng cho event sourcing và audit trail
|
|
- Các class dữ liệu đơn giản
|
|
|
|
- **Repositories** - `inquiry.repository.ts`, `inquiry-read.dto.ts`
|
|
- Interface định nghĩa hợp đồng (đảo ngược phụ thuộc)
|
|
- Symbol dùng cho DI token
|
|
- Read DTO tách biệt với write entity
|
|
- Interface kết quả phân trang
|
|
|
|
**Tính Cô Lập:**
|
|
- Không có phụ thuộc NestJS
|
|
- Không có phụ thuộc cơ sở dữ liệu
|
|
- Không có phụ thuộc dịch vụ bên ngoài
|
|
|
|
---
|
|
|
|
### **TẦNG ỨNG DỤNG** (`application/`)
|
|
|
|
**Mục Đích:** Các use case & điều phối
|
|
|
|
**Bao Gồm:**
|
|
- **Commands** - Các thao tác thay đổi trạng thái
|
|
- `CreateInquiryCommand` - Input DTO
|
|
- `MarkInquiryReadCommand` - Input DTO
|
|
- Các handler điều phối các thao tác domain
|
|
|
|
- **Queries** - Các thao tác đọc không thay đổi trạng thái
|
|
- `GetInquiriesByListingQuery`
|
|
- `GetInquiriesByAgentQuery`
|
|
- Các handler ủy quyền cho repository
|
|
|
|
**Trách Nhiệm:**
|
|
- Kiểm tra điều kiện tiên quyết (listing tồn tại, đại lý được phân quyền)
|
|
- Điều phối các thao tác entity domain
|
|
- Phát sự kiện domain
|
|
- Xử lý các mối quan tâm xuyên suốt (logging, v.v.)
|
|
|
|
**Tích Hợp NestJS:**
|
|
- Decorator `@CommandHandler()`
|
|
- Decorator `@QueryHandler()`
|
|
- Dependency injection qua constructor
|
|
|
|
---
|
|
|
|
### **TẦNG CƠ SỞ HẠ TẦNG** (`infrastructure/`)
|
|
|
|
**Mục Đích:** Chi tiết cơ sở dữ liệu & lưu trữ
|
|
|
|
**Bao Gồm:**
|
|
- **Repositories** - `prisma-inquiry.repository.ts`
|
|
- Triển khai interface repository domain
|
|
- Sử dụng Prisma client cho các truy vấn
|
|
- Map bản ghi cơ sở dữ liệu ↔ domain entity
|
|
- Xử lý logic phân trang
|
|
|
|
**Trách Nhiệm:**
|
|
- Xây dựng truy vấn
|
|
- Map kết quả
|
|
- Tính toán phân trang
|
|
- Quan hệ join
|
|
- Tối ưu hóa đặc thù cơ sở dữ liệu
|
|
|
|
**Tính Cô Lập:**
|
|
- Có thể hoán đổi triển khai (có thể dùng TypeORM, MongoDB, v.v.)
|
|
- Code domain không bị ảnh hưởng bởi thay đổi cơ sở dữ liệu
|
|
|
|
---
|
|
|
|
### **TẦNG TRÌNH BÀY** (`presentation/`)
|
|
|
|
**Mục Đích:** Giao diện HTTP & I/O
|
|
|
|
**Bao Gồm:**
|
|
- **Controllers** - `inquiries.controller.ts`
|
|
- Decorator `@Controller` của NestJS
|
|
- Các handler route HTTP
|
|
- Điều phối đến CQRS bus
|
|
- Trả về HTTP response
|
|
|
|
- **DTOs** - `create-inquiry.dto.ts`, `list-inquiries.dto.ts`
|
|
- Kiểm tra hợp lệ đầu vào (class-validator)
|
|
- Tài liệu Swagger (@ApiProperty)
|
|
- Tách biệt với domain entity
|
|
|
|
**Trách Nhiệm:**
|
|
- Xử lý route
|
|
- Kiểm tra hợp lệ yêu cầu
|
|
- Xác thực/Phân quyền
|
|
- Định dạng response
|
|
- Tài liệu API
|
|
|
|
**Tích Hợp NestJS:**
|
|
- Decorator: `@Post`, `@Get`, `@Patch`
|
|
- Guard: `JwtAuthGuard`, `RolesGuard`
|
|
- Middleware: `@CurrentUser`, `@Roles`
|
|
|
|
---
|
|
|
|
## 📊 TÓM TẮT FILE TEST
|
|
|
|
### **Thống Kê Test**
|
|
|
|
| Tầng | File | Số Test | Loại |
|
|
|-------|------|-------|------|
|
|
| Domain | `domain/__tests__/inquiry-domain.spec.ts` | 5 | Jest |
|
|
| Application | `application/__tests__/create-inquiry.handler.spec.ts` | 4 | Jest |
|
|
| Application | `application/__tests__/mark-inquiry-read.handler.spec.ts` | 5 | Jest |
|
|
| Application | `application/__tests__/get-inquiries-by-listing.handler.spec.ts` | 2 | Jest |
|
|
| Application | `application/__tests__/get-inquiries-by-agent.handler.spec.ts` | 2 | Jest |
|
|
| Presentation | `presentation/__tests__/inquiries.controller.spec.ts` | 6 | Jest |
|
|
| **TỔNG CỘNG** | **6 file** | **24 test** | Jest + Vitest |
|
|
|
|
### **Phạm Vi Test Theo File**
|
|
|
|
#### Test Domain (`inquiry-domain.spec.ts`) - 5 test
|
|
```
|
|
✓ InquiryEntity.createNew() với phone
|
|
✓ InquiryEntity.createNew() với phone null
|
|
✓ createNew() phát InquiryCreatedEvent
|
|
✓ markAsRead() đặt cờ thành true
|
|
✓ markAsRead() phát InquiryReadEvent
|
|
```
|
|
|
|
#### Test CreateInquiryHandler - 4 test
|
|
```
|
|
✓ Tạo yêu cầu thành công
|
|
✓ Ném NotFoundException khi listing không tồn tại
|
|
✓ Phát sự kiện domain sau khi lưu
|
|
```
|
|
|
|
#### Test MarkInquiryReadHandler - 5 test
|
|
```
|
|
✓ Đánh dấu yêu cầu đã đọc thành công
|
|
✓ Ném NotFoundException khi không tìm thấy yêu cầu
|
|
✓ Ném NotFoundException khi không tìm thấy listing
|
|
✓ Ném ForbiddenException khi người dùng không phải đại lý
|
|
✓ Ném ForbiddenException khi không tìm thấy đại lý
|
|
```
|
|
|
|
#### Test GetInquiriesByListingHandler - 2 test
|
|
```
|
|
✓ Trả về kết quả phân trang
|
|
✓ Trả về dữ liệu rỗng khi không có yêu cầu
|
|
```
|
|
|
|
#### Test GetInquiriesByAgentHandler - 2 test
|
|
```
|
|
✓ Trả về kết quả phân trang
|
|
✓ Ném NotFoundException cho người dùng không phải đại lý
|
|
```
|
|
|
|
#### Test InquiriesController - 6 test
|
|
```
|
|
✓ POST tạo yêu cầu với command dispatch
|
|
✓ POST truyền phone null khi không được cung cấp
|
|
✓ GET /listing điều phối truy vấn với giá trị mặc định
|
|
✓ GET /listing truyền phân trang tùy chỉnh
|
|
✓ GET /agent/me điều phối truy vấn agent
|
|
✓ PATCH đánh dấu yêu cầu và trả về thành công
|
|
```
|
|
|
|
---
|
|
|
|
## 🔍 THỐNG KÊ TÓM TẮT
|
|
|
|
| Chỉ Số | Số Lượng |
|
|
|--------|-------|
|
|
| **Tổng Số File** | 25 |
|
|
| **File Nguồn (.ts, không tính test)** | 19 |
|
|
| **File Test** | 6 |
|
|
| **Tổng Số Dòng Code** | 1.212 |
|
|
| **Commands** | 2 |
|
|
| **Queries** | 2 |
|
|
| **Command Handler** | 2 |
|
|
| **Query Handler** | 2 |
|
|
| **Domain Event** | 2 |
|
|
| **HTTP Endpoint** | 4 |
|
|
| **DTO** | 2 |
|
|
| **Interface** | 3 (IInquiryRepository, PaginatedResult, InquiryReadDto) |
|
|
| **Test Suite** | 6 |
|
|
| **Test Case** | 24 |
|
|
|
|
---
|
|
|
|
## 🛠️ CÁC PHỤ THUỘC CHÍNH
|
|
|
|
**Gói Bên Ngoài:**
|
|
- `@nestjs/common` - Framework
|
|
- `@nestjs/cqrs` - CQRS bus
|
|
- `@paralleldrive/cuid2` - Tạo ID
|
|
- `@prisma/client` - ORM
|
|
- `class-validator` - Kiểm tra hợp lệ DTO
|
|
- `@nestjs/swagger` - Tài liệu API
|
|
|
|
**Module Nội Bộ:**
|
|
- `@modules/shared` - AggregateRoot, DomainEvent, các ngoại lệ
|
|
- `@modules/auth` - JwtPayload, JwtAuthGuard, RolesGuard
|
|
|
|
---
|
|
|
|
## 🔐 Bảo Mật & Phân Quyền
|
|
|
|
**Xác Thực:**
|
|
- Tất cả endpoint yêu cầu `@UseGuards(JwtAuthGuard)`
|
|
- JWT token được trích xuất qua decorator `@CurrentUser()`
|
|
|
|
**Phân Quyền:**
|
|
- `GET /agent/me` → bắt buộc `@Roles('AGENT')`
|
|
- `PATCH /:id/read` → bắt buộc `@Roles('AGENT')`
|
|
- **MarkInquiryReadHandler** xác minh:
|
|
- Yêu cầu tồn tại
|
|
- Listing tồn tại
|
|
- Người dùng đã đăng ký là đại lý
|
|
- Đại lý sở hữu listing
|
|
|
|
---
|
|
|
|
## 📝 HỢP ĐỒNG API
|
|
|
|
### POST /inquiries
|
|
```
|
|
Request: { listingId, message, phone? }
|
|
Response: { id, listingId, createdAt }
|
|
Status: 201 Created | 400 Bad Request | 401 Unauthorized | 404 Not Found
|
|
```
|
|
|
|
### GET /inquiries/listing/:listingId
|
|
```
|
|
Request: Query: page?, limit?
|
|
Response: PaginatedResult<InquiryReadDto>
|
|
Status: 200 OK | 401 Unauthorized
|
|
```
|
|
|
|
### GET /inquiries/agent/me
|
|
```
|
|
Request: Query: page?, limit?
|
|
Response: PaginatedResult<InquiryReadDto>
|
|
Status: 200 OK | 401 Unauthorized | 403 Forbidden
|
|
```
|
|
|
|
### PATCH /inquiries/:id/read
|
|
```
|
|
Request: (không có body)
|
|
Response: { success: boolean }
|
|
Status: 200 OK | 401 Unauthorized | 403 Forbidden | 404 Not Found
|
|
```
|
|
|
|
---
|
|
|
|
## 🎓 NHẬN XÉT VỀ KIẾN TRÚC
|
|
|
|
### **Điểm Mạnh**
|
|
|
|
1. **Kiến Trúc Sạch** - Phân tách mối quan tâm rõ ràng giữa các tầng
|
|
2. **Mẫu CQRS** - Đường dẫn đọc/ghi riêng biệt cho phép mở rộng quy mô
|
|
3. **Domain-Driven Design** - Logic nghiệp vụ trong entity, không phải mô hình thiếu máu
|
|
4. **Hướng Sự Kiện** - Sự kiện domain cho phép audit trail và event sourcing
|
|
5. **Khả Năng Test** - Mỗi tầng có thể test độc lập với mock
|
|
6. **An Toàn Kiểu** - TypeScript đầy đủ với interface và kiểu chặt chẽ
|
|
7. **Framework DI** - NestJS cung cấp dependency injection sẵn có
|
|
|
|
### **Quyết Định Thiết Kế**
|
|
|
|
1. **InquiryEntity là Aggregate Root**
|
|
- Đóng gói quy tắc nghiệp vụ của yêu cầu
|
|
- Kiểm soát chuyển đổi trạng thái qua các phương thức (createNew, markAsRead)
|
|
- Thu thập sự kiện domain
|
|
|
|
2. **Mẫu Repository**
|
|
- Interface trong domain, triển khai trong infrastructure
|
|
- Cho phép hoán đổi nguồn dữ liệu mà không ảnh hưởng đến logic nghiệp vụ
|
|
|
|
3. **Read/Write DTO Riêng Biệt**
|
|
- `CreateInquiryDto` (đầu vào) so với `InquiryReadDto` (đầu ra)
|
|
- Cho phép phát triển API linh hoạt
|
|
|
|
4. **CQRS Handler**
|
|
- Command xử lý các thao tác thay đổi trạng thái có phân quyền
|
|
- Query xử lý các thao tác đọc có lọc
|
|
- Cả hai độc lập, có thể tối ưu hóa riêng
|
|
|
|
5. **Interface Phân Trang**
|
|
- Phân trang nhất quán trên tất cả các endpoint danh sách
|
|
- Mô hình Page + limit với totalPages được tính toán
|
|
|
|
---
|
|
|
|
**Kết Thúc Báo Cáo Khám Phá**
|