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>
23 KiB
Inquiries Module - Complete Exploration
GoodGo Platform Backend API
Module Location: apps/api/src/modules/inquiries/
Total Lines of Code: 1,212 lines
Date Explored: April 11, 2026
📋 TABLE OF CONTENTS
- Directory Structure
- Complete File Listing
- Module Architecture
- Key Classes & Handlers
- DDD Layer Analysis
- Test Files Summary
📁 DIRECTORY STRUCTURE
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
📄 COMPLETE FILE LISTING
APPLICATION LAYER (8 files)
Commands (4 files)
| File Path | Type | Description |
|---|---|---|
application/commands/create-inquiry/create-inquiry.command.ts |
Command | DTO for creating inquiry - contains userId, listingId, message, phone |
application/commands/create-inquiry/create-inquiry.handler.ts |
Command Handler | Executes CreateInquiryCommand; validates listing exists, creates entity, publishes event |
application/commands/mark-inquiry-read/mark-inquiry-read.command.ts |
Command | DTO for marking inquiry as read - contains inquiryId, agentUserId |
application/commands/mark-inquiry-read/mark-inquiry-read.handler.ts |
Command Handler | Executes MarkInquiryReadCommand; validates permissions, marks inquiry read, publishes event |
Queries (4 files)
| File Path | Type | Description |
|---|---|---|
application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query.ts |
Query | DTO for listing agent's inquiries - contains agentUserId, page, limit |
application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler.ts |
Query Handler | Resolves agent by userId, delegates to repository findByAgent |
application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query.ts |
Query | DTO for listing inquiries by listing - contains listingId, page, limit |
application/queries/get-inquiries-by-listing/get-inquiries-by-listing.handler.ts |
Query Handler | Delegates directly to repository findByListing |
Application Tests (4 files)
| File Path | Type | Test Count | Coverage |
|---|---|---|---|
application/__tests__/create-inquiry.handler.spec.ts |
Jest | 4 tests | Happy path, missing listing, domain event publishing |
application/__tests__/mark-inquiry-read.handler.spec.ts |
Jest | 5 tests | Happy path, missing inquiry, missing listing, forbidden access, agent not found |
application/__tests__/get-inquiries-by-listing.handler.spec.ts |
Jest | 2 tests | Paginated results, empty results |
application/__tests__/get-inquiries-by-agent.handler.spec.ts |
Jest | 2 tests | Paginated results, agent not found |
DOMAIN LAYER (6 files)
Entities (1 file)
| File Path | Type | Description |
|---|---|---|
domain/entities/inquiry.entity.ts |
Aggregate Root | Core domain entity with id, listingId, userId, message, phone, isRead; methods: createNew(), markAsRead() |
Events (2 files)
| File Path | Type | Description |
|---|---|---|
domain/events/inquiry-created.event.ts |
Domain Event | Published when inquiry is created - contains aggregateId, listingId, userId |
domain/events/inquiry-read.event.ts |
Domain Event | Published when inquiry is marked read - contains aggregateId, listingId, userId |
Repositories (2 files)
| File Path | Type | Description |
|---|---|---|
domain/repositories/inquiry.repository.ts |
Interface + Symbol | IInquiryRepository interface with 6 methods; INQUIRY_REPOSITORY symbol for DI; PaginatedResult interface |
domain/repositories/inquiry-read.dto.ts |
Interface | Read DTO for queries - includes listing title, user details, timestamps |
Domain Tests (1 file)
| File Path | Type | Test Count | Coverage |
|---|---|---|---|
domain/__tests__/inquiry-domain.spec.ts |
Jest | 5 tests | Entity creation, null phone, domain events, markAsRead |
INFRASTRUCTURE LAYER (1 file)
Repository Implementation
| File Path | Type | Description |
|---|---|---|
infrastructure/repositories/prisma-inquiry.repository.ts |
Service | Implements IInquiryRepository using Prisma; 6 methods: findById, save, markAsRead, findByListing, findByAgent, countUnreadByAgent |
PRESENTATION LAYER (5 files)
Controller (1 file)
| File Path | Type | Routes | Auth |
|---|---|---|---|
presentation/controllers/inquiries.controller.ts |
NestJS Controller | POST /, GET /listing/:id, GET /agent/me, PATCH /:id/read | JWT + RBAC |
Endpoints:
POST /inquiries- Create inquiry (BUYER)GET /inquiries/listing/:listingId- Get inquiries by listingGET /inquiries/agent/me- Get inquiries for logged-in agent (AGENT only)PATCH /inquiries/:id/read- Mark inquiry as read (AGENT only)
Data Transfer Objects (2 files)
| File Path | Type | Properties | Validation |
|---|---|---|---|
presentation/dto/create-inquiry.dto.ts |
DTO | listingId, message, phone? | Required string, max 2000 char message |
presentation/dto/list-inquiries.dto.ts |
DTO | page?, limit? | Int, min 1, max 100, defaults 1/20 |
Presentation Tests (1 file)
| File Path | Type | Test Count | Coverage |
|---|---|---|---|
presentation/__tests__/inquiries.controller.spec.ts |
Jest | 6 tests | All 4 endpoints, null phone handling, pagination defaults |
MODULE FILES (2 files)
| File Path | Type | Description |
|---|---|---|
inquiries.module.ts |
NestJS Module | Exports: InquiriesController; Providers: PrismaInquiryRepository, 2 command handlers, 2 query handlers |
index.ts |
Barrel Export | Exports: InquiriesModule, INQUIRY_REPOSITORY symbol, IInquiryRepository interface, InquiryEntity |
🏗️ MODULE ARCHITECTURE
Design Pattern: 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 │
└──────────────────────┘
Data Flow
Creating Inquiry:
POST /inquiries (CreateInquiryDto)
→ InquiriesController.createInquiry()
→ CommandBus.execute(CreateInquiryCommand)
→ CreateInquiryHandler.execute()
- Validate listing exists (Prisma)
- Create InquiryEntity
- Save to repository (Prisma)
- Publish InquiryCreatedEvent via EventBus
→ Return { id, listingId, createdAt }
Marking as Read:
PATCH /inquiries/:id/read (agent only)
→ InquiriesController.markAsRead()
→ CommandBus.execute(MarkInquiryReadCommand)
→ MarkInquiryReadHandler.execute()
- Find inquiry entity
- Verify agent is listing owner
- Call entity.markAsRead()
- Update in repository
- Publish InquiryReadEvent via EventBus
→ Return { success: true }
Getting Inquiries:
GET /inquiries/listing/:id or /agent/me (with pagination)
→ InquiriesController.getByListing() or getMyInquiries()
→ QueryBus.execute(GetInquiriesByListingQuery or GetInquiriesByAgentQuery)
→ Handler delegates to repository
→ Repository.findByListing() or findByAgent()
- Queries Prisma with joins
- Maps to InquiryReadDto
- Returns PaginatedResult
→ Return paginated data
🔑 KEY CLASSES & HANDLERS
Domain Entity: InquiryEntity
export class InquiryEntity extends AggregateRoot<string> {
// Properties
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
→ Creates new inquiry with isRead=false
→ Emits InquiryCreatedEvent
// Business Logic
markAsRead(): void
→ Sets isRead to true
→ Emits InquiryReadEvent
}
Command Handlers
CreateInquiryHandler
class CreateInquiryHandler implements ICommandHandler<CreateInquiryCommand> {
async execute(command: CreateInquiryCommand): Promise<CreateInquiryResult> {
// 1. Validate listing exists
const listing = await prisma.listing.findUnique(...)
if (!listing) throw NotFoundException
// 2. Create entity with factory
const inquiry = InquiryEntity.createNew(...)
// 3. Persist to database
await inquiryRepo.save(inquiry)
// 4. Publish 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. Load aggregate root
const inquiry = await inquiryRepo.findById(...)
if (!inquiry) throw NotFoundException
// 2. Verify authorization
const listing = await prisma.listing.findUnique(...)
const agent = await prisma.agent.findUnique(...)
if (agent.id !== listing.agentId) throw ForbiddenException
// 3. Update domain state
inquiry.markAsRead()
// 4. Persist state
await inquiryRepo.markAsRead(...)
// 5. Publish events
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. Resolve agent ID from user ID
const agent = await prisma.agent.findUnique({ where: { userId } })
if (!agent) throw NotFoundException
// 2. Delegate to repository
return this.inquiryRepo.findByAgent(agent.id, page, limit)
}
}
Repository Implementation
PrismaInquiryRepository
Methods:
findById(id)- Single inquiry lookupsave(entity)- Create inquirymarkAsRead(id)- Update isRead flagfindByListing(listingId, page, limit)- Paginated search with joinsfindByAgent(agentId, page, limit)- Paginated search via listing agentcountUnreadByAgent(agentId)- Unread count aggregation
Prisma Relations Used:
inquiry.listing→ property (for title)inquiry.user→ fullName, phonelisting.agentId→ agent filter- Pagination: skip/take with orderBy descending
🎯 DDD LAYER STRUCTURE
DOMAIN LAYER (domain/)
Purpose: Pure business logic, independent of frameworks
Contains:
-
Entities -
inquiry.entity.ts(Aggregate Root)- Pure TypeScript class
- Encapsulates business rules (isRead flag, field validation)
- Factory method for creation
- Methods for state transitions
- Domain events collection
-
Events -
inquiry-created.event.ts,inquiry-read.event.ts- Record significant business occurrences
- Used for event sourcing & audit trails
- Plain data classes
-
Repositories -
inquiry.repository.ts,inquiry-read.dto.ts- Interface defines contract (dependency inversion)
- Symbol for DI token
- Read DTO separate from write entity
- Pagination result interface
Isolation:
- Zero NestJS dependencies
- Zero database dependencies
- Zero external service dependencies
APPLICATION LAYER (application/)
Purpose: Use cases & coordination
Contains:
-
Commands - Mutable operations
CreateInquiryCommand- Input DTOMarkInquiryReadCommand- Input DTO- Handlers orchestrate domain operations
-
Queries - Immutable reads
GetInquiriesByListingQueryGetInquiriesByAgentQuery- Handlers delegate to repository
Responsibilities:
- Validate preconditions (listing exists, agent authorized)
- Coordinate domain entity operations
- Publish domain events
- Handle cross-cutting concerns (logging, etc.)
NestJS Integration:
@CommandHandler()decorator@QueryHandler()decorator- Dependency injection via constructor
INFRASTRUCTURE LAYER (infrastructure/)
Purpose: Database & persistence details
Contains:
- Repositories -
prisma-inquiry.repository.ts- Implements domain repository interface
- Uses Prisma client for queries
- Maps database records ↔ domain entities
- Handles pagination logic
Responsibilities:
- Query building
- Result mapping
- Pagination calculation
- Join relationships
- Database-specific optimizations
Isolation:
- Swappable implementations (could use TypeORM, MongoDB, etc.)
- Domain code unaffected by database changes
PRESENTATION LAYER (presentation/)
Purpose: HTTP interface & I/O
Contains:
-
Controllers -
inquiries.controller.ts- NestJS
@Controllerdecorator - HTTP route handlers
- Dispatch to CQRS bus
- Return HTTP responses
- NestJS
-
DTOs -
create-inquiry.dto.ts,list-inquiries.dto.ts- Input validation (class-validator)
- Swagger documentation (@ApiProperty)
- Separate from domain entities
Responsibilities:
- Route handling
- Request validation
- Authentication/Authorization
- Response formatting
- API documentation
NestJS Integration:
- Decorators:
@Post,@Get,@Patch - Guards:
JwtAuthGuard,RolesGuard - Middleware:
@CurrentUser,@Roles
📊 TEST FILES SUMMARY
Test Statistics
| Layer | File | Tests | Type |
|---|---|---|---|
| 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 |
| TOTAL | 6 files | 24 tests | Jest + Vitest |
Test Coverage by File
Domain Tests (inquiry-domain.spec.ts) - 5 tests
✓ InquiryEntity.createNew() with phone
✓ InquiryEntity.createNew() with null phone
✓ createNew() emits InquiryCreatedEvent
✓ markAsRead() sets flag to true
✓ markAsRead() emits InquiryReadEvent
CreateInquiryHandler Tests - 4 tests
✓ Creates inquiry successfully
✓ Throws NotFoundException for missing listing
✓ Publishes domain events after saving
MarkInquiryReadHandler Tests - 5 tests
✓ Marks inquiry as read successfully
✓ Throws NotFoundException when inquiry not found
✓ Throws NotFoundException when listing not found
✓ Throws ForbiddenException when user not agent
✓ Throws ForbiddenException when agent not found
GetInquiriesByListingHandler Tests - 2 tests
✓ Returns paginated results
✓ Returns empty data when no inquiries
GetInquiriesByAgentHandler Tests - 2 tests
✓ Returns paginated results
✓ Throws NotFoundException for non-agent user
InquiriesController Tests - 6 tests
✓ POST creates inquiry with command dispatch
✓ POST passes null phone when not provided
✓ GET /listing dispatches query with defaults
✓ GET /listing passes custom pagination
✓ GET /agent/me dispatches agent query
✓ PATCH marks inquiry and returns success
🔍 SUMMARY STATISTICS
| Metric | Count |
|---|---|
| Total Files | 25 |
| Source Files (.ts, excluding tests) | 19 |
| Test Files | 6 |
| Total Lines of Code | 1,212 |
| Commands | 2 |
| Queries | 2 |
| Command Handlers | 2 |
| Query Handlers | 2 |
| Domain Events | 2 |
| HTTP Endpoints | 4 |
| DTOs | 2 |
| Interfaces | 3 (IInquiryRepository, PaginatedResult, InquiryReadDto) |
| Test Suites | 6 |
| Test Cases | 24 |
🛠️ KEY DEPENDENCIES
External Packages:
@nestjs/common- Framework@nestjs/cqrs- CQRS bus@paralleldrive/cuid2- ID generation@prisma/client- ORMclass-validator- DTO validation@nestjs/swagger- API documentation
Internal Modules:
@modules/shared- AggregateRoot, DomainEvent, exceptions@modules/auth- JwtPayload, JwtAuthGuard, RolesGuard
🔐 Security & Authorization
Authentication:
- All endpoints require
@UseGuards(JwtAuthGuard) - JWT token extracted via
@CurrentUser()decorator
Authorization:
GET /agent/me→@Roles('AGENT')enforcedPATCH /:id/read→@Roles('AGENT')enforced- MarkInquiryReadHandler verifies:
- Inquiry exists
- Listing exists
- User is registered as agent
- Agent owns the listing
📝 API CONTRACTS
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: (no body)
Response: { success: boolean }
Status: 200 OK | 401 Unauthorized | 403 Forbidden | 404 Not Found
🎓 ARCHITECTURAL INSIGHTS
Strengths
- Clean Architecture - Clear separation of concerns across layers
- CQRS Pattern - Separate read/write paths enable scalability
- Domain-Driven Design - Business logic in entities, not anemic models
- Event-Driven - Domain events enable audit trails and event sourcing
- Testability - Each layer independently testable with mocks
- Type Safety - Full TypeScript with interfaces and strict types
- DI Framework - NestJS provides dependency injection out of box
Design Decisions
-
InquiryEntity as Aggregate Root
- Encapsulates inquiry business rules
- Controls state transitions via methods (createNew, markAsRead)
- Collects domain events
-
Repository Pattern
- Interface in domain, implementation in infrastructure
- Allows swapping data sources without affecting business logic
-
Separate Read/Write DTOs
CreateInquiryDto(input) vsInquiryReadDto(output)- Enables flexible API evolution
-
CQRS Handlers
- Commands handle mutations with authorization
- Queries handle reads with filtering
- Both independent, can be optimized separately
-
Pagination Interface
- Consistent pagination across all list endpoints
- Page + limit model with calculated totalPages
End of Exploration Report