# 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 ```typescript { 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 ```typescript { 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 ```typescript { 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 ```typescript { 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:** ```sql 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:** ```typescript 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** ```typescript 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:** ```json { "__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