Files
goodgo-platform/docs/explorations/EXPLORATION_SUMMARY_LISTINGS.md
Ho Ngoc Hai 08b96f9c2d docs: consolidate exploration & audit reports under docs/ (TEC-3094)
- Move 8 stray .md (+5 .txt) from ~/Desktop into docs/explorations/from-desktop/
- Reorganize 27 .md/.txt at workspace root:
  - audit reports -> docs/audits/
  - exploration reports -> docs/explorations/
  - design system -> docs/design-system/
- Keep only README/CHANGELOG/CONTRIBUTING/CLAUDE at repo root
- Refresh docs/README.md as canonical index with links to all groups
- Note: pre-existing docs/audits/AUDIT_INDEX.md and AUDIT_SUMMARY.md were
  overwritten by the newer root-level versions during the move

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 16:29:24 +07:00

12 KiB

Listings Module Exploration - Summary

Exploration Date: April 21, 2026
Scope: Complete understanding of listings, properties, AVM, agents, inquiries, and caching

📋 Documents Generated

This exploration has produced 3 comprehensive reference documents:

  1. LISTINGS_MODULE_EXPLORATION.md (965 lines)

    • Detailed, section-by-section breakdown of all 7 components
    • Code snippets from actual source files
    • Full interface definitions and data flow diagrams
  2. LISTINGS_QUICK_REFERENCE.md (Quick lookup)

    • Visual flow diagrams using ASCII art
    • 1-page reference for each major component
    • Key formulas, algorithms, and cache configuration
  3. LISTINGS_DATA_SCHEMA.md (Database focus)

    • Table definitions in SQL
    • Foreign key relationships
    • Index strategy and query patterns
    • Denormalization strategy & eventual consistency model

🎯 Key Findings

1. GET /listings/:id Architecture

  • Handler: ListingsController.getListing()GetListingQueryGetListingHandler
  • Cache: Redis cache-aside, 300s TTL, envelope-based storage with metadata
  • Not-found behavior: Uses internal signal to avoid caching null; allows new listings to be discoverable immediately
  • Response: ListingDetailData with nested property, seller, agent info

2. Response DTO (ListingDetailData)

  • Contains full property details (40+ fields)
  • Includes engagement metrics: viewCount, saveCount, inquiryCount (denormalized)
  • Includes featured status: isFeatured, featuredUntil (computed at runtime)
  • Media: Up to 10 images/videos with order & captions
  • Seller & agent info with contact details

3. AVM Service Integration

  • Primary: Python AI microservice (v1 or v2 model)
  • Fallback: PostGIS-based comparables if AI service down
  • Batch concurrency: Max 5 concurrent requests
  • Input: Property attributes (area, district, bedrooms, etc.)
  • Output: Estimated price + confidence + comparables + model version

4. Agent Quality Score

  • Formula: Weighted average of 4 metrics (40% reviews, 30% response time, 20% conversion, 10% listing activity)
  • Storage: Agent.qualityScore field (denormalized aggregate)
  • Update trigger: Review and inquiry events
  • Result: Score rounded to 1 decimal, displayed in agent profiles

5. Inquiries Tracking

  • Creation: InquiryEntity persisted, InquiryCreatedEvent published
  • Denormalization: Event listener increments Listing.inquiryCount
  • Display: inquiryCount returned in ListingDetailData
  • Data: Inquirer, message (HTML-sanitized), optional phone, read status, timestamp

6. Similar Listings Algorithm

  • Rule-based matcher (not ML-based)
  • Criteria: Same property type & district, price ±10%, area ±20%, ACTIVE status
  • Sorting: By price delta (closest comparable first)
  • Performance: Fetches 3x limit, sorts by delta, returns top N

7. Redis Caching

  • Pattern: Cache-aside with envelope metadata
  • TTLs: 300s (listing detail), 120s (search), 60s (quota), 86400s (reference data)
  • Graceful degradation: System works offline; metrics track all failures
  • Invalidation: Single key or prefix-based (SCAN-based for prefix)
  • Metrics: Hit/miss/degradation counters by resource

🗂️ File Organization

Listings Module (apps/api/src/modules/listings/)

listings/
├── presentation/
│   ├── controllers/listings.controller.ts       ← HTTP handler
│   └── dto/                                     ← Request/response DTOs
├── application/
│   ├── queries/get-listing/                     ← Query handler + cache logic
│   └── commands/                                ← Create, update, delete
├── domain/
│   ├── entities/listing.entity.ts               ← Domain model
│   ├── repositories/listing-read.dto.ts         ← Response DTOs
│   └── events/                                  ← Domain events
└── infrastructure/
    ├── repositories/listing-read.queries.ts    ← SQL queries (including similar listings)
    └── services/

Analytics Module (apps/api/src/modules/analytics/)

analytics/
├── domain/services/avm-service.ts               ← AVM interface
└── infrastructure/services/
    ├── http-avm.service.ts                      ← Primary implementation
    ├── ai-service.client.ts                     ← AI microservice HTTP client
    └── prisma-avm.service.ts                    ← Fallback (PostGIS)

Agents Module (apps/api/src/modules/agents/)

agents/
├── domain/services/quality-score.service.ts     ← Calculator (pure domain)
├── application/commands/recalculate-quality-score/
└── infrastructure/repositories/agent-profile.queries.ts

Inquiries Module (apps/api/src/modules/inquiries/)

inquiries/
├── application/commands/create-inquiry/         ← Creates + publishes event
├── domain/entities/inquiry.entity.ts
└── domain/events/inquiry-created.event.ts

Cache Service (apps/api/src/modules/shared/)

shared/
└── infrastructure/cache.service.ts              ← Cache-aside implementation

🔗 Data Flow Diagram

Client HTTP Request: GET /listings/{id}
    ↓
ListingsController.getListing(id)
    ↓
GetListingHandler.execute(GetListingQuery)
    ├─ CacheService.getOrSet(cache:listing:{id}, loader, 300s)
    │  ├─ Redis hit? → return ListingDetailData
    │  ├─ Redis miss → call loader()
    │  │   ├─ prisma.listing.findUnique({id}, include: {...})
    │  │   ├─ Extract PostGIS geometry (lat/lng)
    │  │   ├─ Map to ListingDetailData
    │  │   └─ Store in Redis (envelope + metadata)
    │  └─ Not found? → throw ListingNotFoundSignal (don't cache)
    ├─ Exception handling
    └─ Return ListingDetailData | null
    ↓
Controller: null → throw NotFoundException (404)
    ↓
Response: 200 OK + JSON(ListingDetailData)

📊 Component Dependencies

ListingsController
├─ CommandBus (for mutations)
└─ QueryBus (for queries)
    ├─ GetListingHandler
    │   ├─ ListingRepository
    │   ├─ CacheService
    │   └─ LoggerService
    ├─ GetSimilarListingsHandler
    │   └─ ListingRepository
    │       └─ Prisma (for SQL queries)
    └─ SearchListingsHandler
        └─ ListingRepository
            ├─ Prisma
            └─ PostGIS (geometry extraction)

AgentsModule
├─ QualityScoreCalculator (pure domain service)
├─ RecalculateQualityScoreHandler
│   └─ Triggered by ReviewCreatedEvent, InquiryCreatedEvent
└─ AgentProfileQueries
    ├─ Prisma (fetch agent + reviews + listings)
    └─ ReviewAggregation

InquiriesModule
├─ CreateInquiryHandler
│   ├─ InquiryRepository
│   ├─ EventBus
│   └─ InquiryCreatedEvent (published)
└─ ListingModule receives event
    └─ Increments Listing.inquiryCount

AnalyticsModule (AVM)
├─ HttpAVMService
│   ├─ IAiServiceClient (HTTP to Python service)
│   ├─ PrismaAVMService (fallback)
│   └─ Prometheus metrics
└─ Used by: Listing valuation, Comparable search

SharedModule (Cache)
├─ CacheService
│   ├─ RedisService
│   └─ Metrics (hit/miss/degradation counters)
└─ Used by: GetListingHandler, SearchListingsHandler, etc.

🛠️ Important Implementation Details

Cache Envelope Format

{
  "__v": { /* actual data */ },
  "cachedAt": "2026-04-21T12:00:00Z",
  "ttlSeconds": 300
}
  • Allows frontend to display cache freshness
  • Backward-compatible with legacy plain-JSON entries
  • Metadata stored in async storage (cacheMetaStorage)

Denormalization Pattern

Event Publish (InquiryCreatedEvent)
    ↓
Event Listener (in Listings or Inquiry module)
    ├─ Count inquiries for listing: 
    │   await prisma.inquiry.count({ where: { listingId } })
    ├─ Update listing counter:
    │   await prisma.listing.update({
    │       where: { id: listingId },
    │       data: { inquiryCount: count }
    │   })
    └─ Cache invalidation (optional): cache.invalidate(cache:listing:{id})
  • Eventual consistency (may lag seconds)
  • Avoids expensive COUNT() on every read
  • Event-driven guarantees eventual correctness

AVM Fallback Chain

estimateValue(params)
    ├─ Try: aiClient.predict(AiPredictRequest)
    │        → HTTP POST to Python service
    │        → Return AI-based ValuationResult
    └─ Catch (any error):
        └─ fallback.estimateValue(params)
            → PostGIS spatial query
            → Find comparable sales nearby
            → Calculate median price
            → Return comparables-based ValuationResult

Quality Score Formula

Score = 
  (avgRating / 5) * 100 * 0.40 +
  MAX(0, 100 - (responseTimeAvg / 3600) * 100) * 0.30 +
  (conversionRate * 100) * 0.20 +
  (activeListingRatio * 100) * 0.10

Rounded to 1 decimal place

⚠️ Potential Issues & Edge Cases

  1. Cache invalidation on null: Not caching null results is smart but requires careful coordination—ensure all listing creation/updates invalidate appropriately.

  2. PostGIS geometry extraction: Always requires raw SQL queries ($queryRaw) for lat/lng extraction. Prisma's ORM doesn't map PostGIS types directly.

  3. Denormalized inquiryCount: May lag behind actual count if event listener fails. Should have reconciliation job.

  4. AVM service downtime: Falls back gracefully, but confidence score from fallback service may be lower. Frontend should handle confidence thresholds.

  5. Similar listings boundary cases: Price ±10% is exact mathematical boundary—no tolerance. Two listings with 10.1% price difference won't be marked as similar.

  6. Agent quality score with no reviews: Defaults to 50 (neutral). May want business logic to penalize agents with 0 reviews.

  7. Cache stampede potential: If Redis is slow, multiple concurrent requests could trigger parallel loader calls. Implement request coalescing if needed.


  1. Verify inquiryCount event listener: Check listings module for event handler that increments inquiryCount on InquiryCreatedEvent.

  2. Audit cache invalidation: Ensure all listing mutations (status change, price update, media upload) invalidate cache appropriately.

  3. Test AVM fallback: Simulate Python service downtime; verify graceful fallback to PostGIS.

  4. Performance review: Profile queries with EXPLAIN ANALYZE:

    • Listing detail with media fetch
    • Similar listings (price/area bounds)
    • Inquiry count aggregation
    • Agent quality score recalculation
  5. Document cache TTL rationale: Why 300s for listing detail but only 120s for search? May need adjustment based on data freshness requirements.

  6. Implement reconciliation job: Periodic job to recount inquiries per listing, detect denormalization drift.


📖 References

All source files are organized in the 3 generated documents:

  • Detailed exploration: LISTINGS_MODULE_EXPLORATION.md
  • Quick reference: LISTINGS_QUICK_REFERENCE.md
  • Database schema: LISTINGS_DATA_SCHEMA.md

Key base paths:

  • Listings module: apps/api/src/modules/listings/
  • Analytics (AVM): apps/api/src/modules/analytics/
  • Agents: apps/api/src/modules/agents/
  • Inquiries: apps/api/src/modules/inquiries/
  • Cache service: apps/api/src/modules/shared/infrastructure/cache.service.ts

End of Exploration Summary