Files
goodgo-platform/docs/explorations/LISTINGS_QUICK_REFERENCE.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

8.7 KiB
Raw Permalink Blame History

Listings Module - Quick Reference Guide

1 GET /listings/:id Flow

HTTP GET /listings/:id
    ↓
ListingsController.getListing()
    ↓
QueryBus.execute(GetListingQuery)
    ↓
GetListingHandler.execute()
    ├─ CacheService.getOrSet(key, loader, 300s, 'listing')
    │  ├─ Cache Hit? → Return cached ListingDetailData
    │  ├─ Cache Miss? → Call loader()
    │  │   ├─ listingRepo.findByIdWithProperty(id)
    │  │   ├─ Extract geometry (PostGIS)
    │  │   └─ Return ListingDetailData
    │  └─ Not Found? → Throw ListingNotFoundSignal (don't cache)
    ├─ Handle errors
    └─ Return ListingDetailData | null
    ↓
Controller maps null → 404 NotFoundException
    ↓
200 OK + JSON ListingDetailData

Cache Key Format: cache:listing:{listingId}
Cache TTL: 300 seconds (5 minutes)
Cache Miss Behavior: Calls loader which queries DB + PostGIS


2 ListingDetailData Structure

{
  id: string (UUID),
  status: 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' | 'RESERVED' | 'SOLD' | 'RENTED' | 'EXPIRED' | 'REJECTED',
  transactionType: 'SALE' | 'RENT',
  priceVND: string,
  pricePerM2: number | null,
  rentPriceMonthly: string | null,
  commissionPct: number | null,
  
  // Engagement
  viewCount: number,
  saveCount: number,
  inquiryCount: number,               DENORMALIZED (updated by event handlers)
  
  // Featured/Publication
  isFeatured: boolean,
  featuredUntil: ISO 8601 | null,
  publishedAt: ISO 8601 | null,
  createdAt: ISO 8601,
  
  // Property nested object
  property: {
    id, propertyType, title, description, address, ward, district, city,
    latitude, longitude,
    areaM2, usableAreaM2,
    bedrooms, bathrooms, floors, floor, totalFloors, direction, yearBuilt,
    legalStatus, amenities, nearbyPOIs, metroDistanceM, projectName,
    furnishing, propertyCondition, balconyDirection, maintenanceFeeVND,
    parkingSlots, viewType[], petFriendly, suitableFor[], whyThisLocation,
    media: [{ id, url, type, order, caption }, ...]  // up to 10
  },
  
  // Seller & Agent
  seller: { id, fullName, phone },
  agent: { id, userId, agency } | null
}

3 AVM Service Integration

Call Path

Controller/Handler
    ↓
HttpAVMService.estimateValue(AVMParams)
    ├─ Try: estimateViaAi(params)
    │   ├─ If params.useV2: estimateViaAiV2(...)
    │   │   └─ aiClient.predictV2(AiPredictV2Request)
    │   │       → HTTP POST to Python AI service
    │   └─ Else: aiClient.predict(AiPredictRequest)
    │       → HTTP POST to Python AI service
    ├─ Catch: fallback.estimateValue(params)
    │   └─ PrismaAVMService (PostGIS-based comparables)
    └─ Return ValuationResult

Input Params

  • propertyId (optional) — if set, fetch from DB; else use inline values
  • areaM2, district, city, propertyType, bedrooms, bathrooms, floors
  • V2: distanceToHospitalKm, distanceToParkKm, floodZoneRisk, hasElevator, hasParking

Output

{
  estimatedPrice: string (VND),
  confidence: number (0-1),
  pricePerM2: number (VND/m²),
  comparables: [
    { propertyId, address, district, priceVND, pricePerM2, areaM2, propertyType, distanceMeters, soldAt },
    ...
  ],
  modelVersion: 'ai-service-v1.0' | 'ai-service-v2'
}

Batch Processing

  • Max concurrency: 5 requests
  • Processes in chunks of 5 with Promise.allSettled()

4 Agent Quality Score

Formula

QualityScore = 
  (avgRating / 5 * 100) * 0.40      [Review Rating: 40%]
  + MAX(0, 100 - (responseTime/3600)*100) * 0.30   [Response Time: 30%]
  + (conversionRate * 100) * 0.20   [Lead Conversion: 20%]
  + (activeListingRatio * 100) * 0.10 [Listing Activity: 10%]

Result: rounded to 1 decimal place

Storage

  • Table: Agent (in agents module)
  • Field: qualityScore: number
  • Update Trigger: Review events, inquiry conversion events

Inputs Required

{
  avgRating: number,          // 0-5
  totalReviews: number,
  responseTimeAvg: number | null,  // seconds (null → default 50)
  conversionRate: number,     // 0-1
  activeListingRatio: number  // 0-1
}

5 Inquiries Tracking

Create Flow

CreateInquiryHandler
  ├─ Validate listing exists
  ├─ InquiryEntity.createNew(...)
  ├─ inquiryRepo.save(inquiry)
  ├─ eventBus.publish(InquiryCreatedEvent)
  │   event {
  │     eventName: 'inquiry.received',
  │     aggregateId: inquiryId,
  │     listingId, userId
  │   }
  └─ Return { id, listingId, createdAt }

Inquiry Fields

{
  id: string (CUID),
  listingId: string,
  listingTitle: string,
  userId: string (inquirer),
  userName: string,
  userPhone: string,
  message: string (sanitized HTML),
  phone: string | null (alternate contact),
  isRead: boolean,
  createdAt: ISO 8601
}

inquiryCount Update

  • Location: Listing.inquiryCount field (denormalized counter)
  • Trigger: InquiryCreatedEvent published
  • Update: Event listener increments count and persists to DB
  • Display: Included in ListingDetailData response

6 Similar Listings Algorithm

Query: findSimilarListingsQuery(listingId, limit)

Match Criteria:

  1. Same propertyType
  2. Same district
  3. Price within ±10% (priceVND * 0.9 ... 1.1)
  4. Area within ±20% (areaM2 * 0.8 ... 1.2)
  5. Status = ACTIVE
  6. Exclude source listing itself

Algorithm:

SELECT listings WHERE
  id != :id AND
  status = 'ACTIVE' AND
  property.propertyType = source.propertyType AND
  property.district = source.district AND
  listing.priceVND BETWEEN (sourcePriceVND * 0.9) AND (sourcePriceVND * 1.1) AND
  property.areaM2 BETWEEN (sourceArea * 0.8) AND (sourceArea * 1.2)
ORDER BY ABS(listing.priceVND - sourcePriceVND) ASC
LIMIT limit

Output:

ListingSimilarItem[] = [
  { id, title, priceVND, areaM2, district, thumbnailUrl, publishedAt },
  ...
]

HTTP Endpoint:

GET /listings/:id/similar?limit=5

7 Redis Caching Patterns

Cache Service Methods

getOrSet(key, loader, ttlSeconds, resource)

1. If Redis unavailable → call loader(), return result, increment degradation counter
2. Try: get from Redis
   - Hit → increment hit counter, return value
   - Miss → increment miss counter, call loader()
3. Cache result for ttlSeconds
4. Return result

Cache Invalidation

cache.invalidate(key)                    // Delete single key
cache.invalidateByPrefix(prefix)         // Delete all keys matching prefix:*

Listing Detail Cache

Key: cache:listing:{listingId}
TTL: 300 seconds
Envelope:

{
  "__v": { ListingDetailData },
  "cachedAt": "2026-04-21T12:00:00Z",
  "ttlSeconds": 300
}

Other Cache Prefixes

  • cache:search (120s)
  • cache:geo_search
  • cache:valuation (for AVM results)
  • cache:agent:listings
  • cache:market:* (report, trend, heatmap, district, snapshot)
  • cache:user:* (profile, quota)
  • cache:plan:list (3600s)

Metrics

  • cache_hit_total (by resource)
  • cache_miss_total (by resource)
  • cache_degradation_total (by resource + operation: skip_unavailable, read_error, write_error)

🔑 Key File Paths

Component Path
Listings Controller apps/api/src/modules/listings/presentation/controllers/listings.controller.ts
GET Handler apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts
Listing Read Queries apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts
AVM Service apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts
AI Client apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts
Quality Score apps/api/src/modules/agents/domain/services/quality-score.service.ts
Inquiry Handler apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts
Cache Service apps/api/src/modules/shared/infrastructure/cache.service.ts
DTOs apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts

🎯 Important Behaviors

Cache invalidation on null: Listing not found is NOT cached; next request will find newly-created listing
AVM fallback: If AI service down, uses PostGIS-based comparables
Batch rate limiting: 5 concurrent AVM requests max
inquiryCount denormalization: Updated via event handlers for read performance
Similar listings rule-based: No ML, simple ±10% price & ±20% area match
Agent quality recalculation: Triggered by review/inquiry events
Redis graceful degradation: System works offline; metrics track all failures