24 KiB
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
- Cấu Trúc Thư Mục
- Danh Sách File Đầy Đủ
- Kiến Trúc Module
- Các Lớp & Handler Chính
- Phân Tích Tầng DDD
- Tóm Tắt File Test
📁 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 APPLICATION (8 file)
Commands (4 file)
| Đường Dẫn File | Loại | Mô Tả |
|---|---|---|
application/commands/create-inquiry/create-inquiry.command.ts |
Command | DTO để tạo inquiry - 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 inquiry đã đọ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 hạn, đánh dấu inquiry đã đọc, phát sự kiệ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ê inquiry của agent - chứa agentUserId, page, limit |
application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler.ts |
Query Handler | Tra cứu agent 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ê inquiry 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 Application (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 domain event |
application/__tests__/mark-inquiry-read.handler.spec.ts |
Jest | 5 test | Happy path, inquiry không tồn tại, listing không tồn tại, truy cập bị từ chối, agent không tồn tại |
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, agent không tồn tại |
TẦNG DOMAIN (6 file)
Entities (1 file)
| Đường Dẫn File | Loại | Mô Tả |
|---|---|---|
domain/entities/inquiry.entity.ts |
Aggregate Root | Entity domain cốt lõi với id, listingId, userId, message, phone, isRead; các phương thức: createNew(), markAsRead() |
Events (2 file)
| Đường Dẫn File | Loại | Mô Tả |
|---|---|---|
domain/events/inquiry-created.event.ts |
Domain Event | Phát khi inquiry được tạo - chứa aggregateId, listingId, userId |
domain/events/inquiry-read.event.ts |
Domain Event | Phát khi inquiry được đánh dấu đã đọc - chứa aggregateId, listingId, userId |
Repositories (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 queries - bao gồm tiêu đề listing, thông tin người dùng, timestamps |
Test Tầng 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, domain events, markAsRead |
TẦNG INFRASTRUCTURE (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 dùng Prisma; 6 phương thức: findById, save, markAsRead, findByListing, findByAgent, countUnreadByAgent |
TẦNG PRESENTATION (5 file)
Controller (1 file)
| Đường Dẫn File | Loại | Routes | Auth |
|---|---|---|---|
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 inquiry (BUYER)GET /inquiries/listing/:listingId- Lấy inquiry theo listingGET /inquiries/agent/me- Lấy inquiry của agent đang đăng nhập (chỉ AGENT)PATCH /inquiries/:id/read- Đánh dấu inquiry đã đọc (chỉ AGENT)
Data Transfer Objects (2 file)
| Đường Dẫn File | Loại | Thuộc Tính | Validation |
|---|---|---|---|
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, tối thiểu 1, tối đa 100, mặc định 1/20 |
Test Tầng Presentation (1 file)
| Đường Dẫn File | Loại | Số Test | Phạm Vi |
|---|---|---|---|
presentation/__tests__/inquiries.controller.spec.ts |
Jest | 6 test | Cả 4 endpoint, xử lý phone null, giá trị mặc định phân trang |
FILE MODULE (2 file)
| Đường Dẫn File | Loại | Mô Tả |
|---|---|---|
inquiries.module.ts |
NestJS Module | Exports: InquiriesController; Providers: PrismaInquiryRepository, 2 command handler, 2 query handler |
index.ts |
Barrel Export | Exports: 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 Inquiry:
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 agent 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 Inquiry:
GET /inquiries/listing/:id hoặc /agent/me (có phân trang)
→ InquiriesController.getByListing() hoặc getMyInquiries()
→ QueryBus.execute(GetInquiriesByListingQuery hoặc GetInquiriesByAgentQuery)
→ Handler ủy quyền cho repository
→ Repository.findByListing() hoặc findByAgent()
- Truy vấn Prisma với join
- Ánh xạ sang InquiryReadDto
- Trả về PaginatedResult
→ Trả về dữ liệu phân trang
🔑 CÁC LỚP & HANDLER CHÍNH
Domain Entity: InquiryEntity
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 inquiry mới với isRead=false
→ Phát InquiryCreatedEvent
// Business Logic
markAsRead(): void
→ Đặt isRead thành true
→ Phát InquiryReadEvent
}
Command Handlers
CreateInquiryHandler
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 qua factory
const inquiry = InquiryEntity.createNew(...)
// 3. Lưu vào database
await inquiryRepo.save(inquiry)
// 4. Phát domain events
const events = inquiry.clearDomainEvents()
events.forEach(e => eventBus.publish(e))
return { id, listingId, createdAt }
}
}
MarkInquiryReadHandler
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 quyền hạ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
class GetInquiriesByListingHandler implements IQueryHandler<GetInquiriesByListingQuery> {
async execute(query: GetInquiriesByListingQuery): Promise<PaginatedResult<InquiryReadDto>> {
return this.inquiryRepo.findByListing(
query.listingId,
query.page,
query.limit
)
}
}
GetInquiriesByAgentHandler
class GetInquiriesByAgentHandler implements IQueryHandler<GetInquiriesByAgentQuery> {
async execute(query: GetInquiriesByAgentQuery): Promise<PaginatedResult<InquiryReadDto>> {
// 1. Tra cứu 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:
findById(id)- Tra cứu inquiry đơn lẻsave(entity)- Tạo inquirymarkAsRead(id)- Cập nhật cờ isReadfindByListing(listingId, page, limit)- Tìm kiếm phân trang với joinfindByAgent(agentId, page, limit)- Tìm kiếm phân trang qua agent của listingcountUnreadByAgent(agentId)- Tổng hợp số lượng chưa đọc
Quan Hệ Prisma Sử Dụng:
inquiry.listing→ property (lấy tiêu đề)inquiry.user→ fullName, phonelisting.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)- Lớp TypeScript thuần túy
- Đóng gói các quy tắc nghiệp vụ (cờ isRead, xác thực trường)
- Factory method để tạo mới
- Các phương thức chuyển đổi trạng thái
- Tập hợp domain events
-
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
- Lớp 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 cho DI token
- Read DTO tách biệt với write entity
- Interface kết quả phân trang
Sự Cô Lập:
- Không có dependency NestJS
- Không có dependency database
- Không có dependency dịch vụ bên ngoài
TẦNG APPLICATION (application/)
Mục Đích: Use case và điều phối
Bao Gồm:
-
Commands - Các thao tác thay đổi dữ liệu
CreateInquiryCommand- Input DTOMarkInquiryReadCommand- Input DTO- Các handler điều phối thao tác domain
-
Queries - Các đọc bất biến
GetInquiriesByListingQueryGetInquiriesByAgentQuery- Các handler ủy quyền cho repository
Trách Nhiệm:
- Xác thực điều kiện tiên quyết (listing tồn tại, agent được phép)
- Điều phối các thao tác domain entity
- Phát domain events
- 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 INFRASTRUCTURE (infrastructure/)
Mục Đích: Chi tiết database và persistence
Bao Gồm:
- Repositories -
prisma-inquiry.repository.ts- Triển khai interface repository của domain
- Dùng Prisma client cho các truy vấn
- Ánh xạ bản ghi database ↔ domain entity
- Xử lý logic phân trang
Trách Nhiệm:
- Xây dựng truy vấn
- Ánh xạ kết quả
- Tính toán phân trang
- Quan hệ join
- Tối ưu hóa riêng cho database
Sự Cô Lập:
- Triển khai có thể hoán đổi (có thể dùng TypeORM, MongoDB, v.v.)
- Code domain không bị ảnh hưởng khi thay đổi database
TẦNG PRESENTATION (presentation/)
Mục Đích: Giao diện HTTP và I/O
Bao Gồm:
-
Controllers -
inquiries.controller.ts- Decorator NestJS
@Controller - Các handler route HTTP
- Phân phối đến CQRS bus
- Trả về HTTP response
- Decorator NestJS
-
DTOs -
create-inquiry.dto.ts,list-inquiries.dto.ts- Xác thực đầ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
- Xác thực request
- Xác thực danh tính/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 | 6 file | 24 test | Jest + Vitest |
Phạm Vi Test Theo File
Test Domain (inquiry-domain.spec.ts) - 5 test
✓ InquiryEntity.createNew() có 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 inquiry thành công
✓ Ném NotFoundException khi listing không tồn tại
✓ Phát domain events sau khi lưu
Test MarkInquiryReadHandler - 5 test
✓ Đánh dấu inquiry đã đọc thành công
✓ Ném NotFoundException khi không tìm thấy inquiry
✓ Ném NotFoundException khi không tìm thấy listing
✓ Ném ForbiddenException khi người dùng không phải agent
✓ Ném ForbiddenException khi không tìm thấy agent
Test GetInquiriesByListingHandler - 2 test
✓ Trả về kết quả phân trang
✓ Trả về dữ liệu rỗng khi không có inquiry
Test GetInquiriesByAgentHandler - 2 test
✓ Trả về kết quả phân trang
✓ Ném NotFoundException cho người dùng không phải agent
Test InquiriesController - 6 test
✓ POST tạo inquiry với command dispatch
✓ POST truyền phone null khi không cung cấp
✓ GET /listing phân phối query với giá trị mặc định
✓ GET /listing truyền phân trang tùy chỉnh
✓ GET /agent/me phân phối agent query
✓ PATCH đánh dấu inquiry 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, trừ test) | 19 |
| File Test | 6 |
| Tổng Số Dòng Code | 1.212 |
| Commands | 2 |
| Queries | 2 |
| Command Handlers | 2 |
| Query Handlers | 2 |
| Domain Events | 2 |
| HTTP Endpoint | 4 |
| DTOs | 2 |
| Interfaces | 3 (IInquiryRepository, PaginatedResult, InquiryReadDto) |
| Test Suite | 6 |
| Test Case | 24 |
🛠️ CÁC DEPENDENCY CHÍNH
Gói Bên Ngoài:
@nestjs/common- Framework@nestjs/cqrs- CQRS bus@paralleldrive/cuid2- Tạo ID@prisma/client- ORMclass-validator- Xác thực DTO@nestjs/swagger- Tài liệu API
Module Nội Bộ:
@modules/shared- AggregateRoot, DomainEvent, exceptions@modules/auth- JwtPayload, JwtAuthGuard, RolesGuard
🔐 Bảo Mật & Phân Quyền
Xác Thực Danh Tính:
- 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→ áp dụng@Roles('AGENT')PATCH /:id/read→ áp dụng@Roles('AGENT')- MarkInquiryReadHandler xác minh:
- Inquiry tồn tại
- Listing tồn tại
- Người dùng đã đăng ký là agent
- Agent 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 KIẾN TRÚC
Điểm Mạnh
- Kiến Trúc Sạch - Phân tách mối quan tâm rõ ràng giữa các tầng
- Mẫu CQRS - Đường đọc/ghi riêng biệt giúp tăng khả năng mở rộng
- Thiết Kế Hướng Domain - Logic nghiệp vụ nằm trong entity, không phải mô hình thiếu máu
- Hướng Sự Kiện - Domain events cho phép audit trail và event sourcing
- Khả Năng Kiểm Thử - Mỗi tầng có thể kiểm thử độc lập với mock
- An Toàn Kiểu - TypeScript đầy đủ với interface và kiểu chặt chẽ
- Framework DI - NestJS cung cấp dependency injection sẵn có
Quyết Định Thiết Kế
-
InquiryEntity là Aggregate Root
- Đóng gói quy tắc nghiệp vụ inquiry
- Kiểm soát chuyển đổi trạng thái qua phương thức (createNew, markAsRead)
- Thu thập domain events
-
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ụ
-
DTO Đọc/Ghi Riêng Biệt
CreateInquiryDto(đầu vào) so vớiInquiryReadDto(đầu ra)- Cho phép tiến hóa API linh hoạt
-
CQRS Handlers
- Commands xử lý thay đổi dữ liệu với phân quyền
- Queries xử lý đọc với lọc
- Cả hai độc lập, có thể tối ưu riêng biệt
-
Interface Phân Trang
- Phân trang nhất quán trên tất 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á