# 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 Lớp & 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 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 listing - `GET /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** ```typescript export class InquiryEntity extends AggregateRoot { // 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 ```typescript class CreateInquiryHandler implements ICommandHandler { async execute(command: CreateInquiryCommand): Promise { // 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 ```typescript class MarkInquiryReadHandler implements ICommandHandler { async execute(command: MarkInquiryReadCommand): Promise { // 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 ```typescript class GetInquiriesByListingHandler implements IQueryHandler { async execute(query: GetInquiriesByListingQuery): Promise> { return this.inquiryRepo.findByListing( query.listingId, query.page, query.limit ) } } ``` #### GetInquiriesByAgentHandler ```typescript class GetInquiriesByAgentHandler implements IQueryHandler { async execute(query: GetInquiriesByAgentQuery): Promise> { // 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:** 1. `findById(id)` - Tra cứu inquiry đơn lẻ 2. `save(entity)` - Tạo inquiry 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 agent của listing 6. `countUnreadByAgent(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, 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) - 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 DTO - `MarkInquiryReadCommand` - Input DTO - Các handler điều phối thao tác domain - **Queries** - Các đọc bất biến - `GetInquiriesByListingQuery` - `GetInquiriesByAgentQuery` - 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 - **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` - ORM - `class-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 Status: 200 OK | 401 Unauthorized ``` ### GET /inquiries/agent/me ``` Request: Query: page?, limit? Response: PaginatedResult 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** 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 đọc/ghi riêng biệt giúp tăng khả năng mở rộng 3. **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 4. **Hướng Sự Kiện** - Domain events cho phép audit trail và event sourcing 5. **Khả Năng Kiểm Thử** - Mỗi tầng có thể kiểm thử độ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ụ inquiry - Kiểm soát chuyển đổi trạng thái qua phương thức (createNew, markAsRead) - Thu thập domain events 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. **DTO Đọc/Ghi Riêng Biệt** - `CreateInquiryDto` (đầu vào) so với `InquiryReadDto` (đầu ra) - Cho phép tiến hóa API linh hoạt 4. **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 5. **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á**