- 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>
8.7 KiB
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 valuesareaM2, 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.inquiryCountfield (denormalized counter) - Trigger:
InquiryCreatedEventpublished - Update: Event listener increments count and persists to DB
- Display: Included in
ListingDetailDataresponse
6️⃣ Similar Listings Algorithm
Query: findSimilarListingsQuery(listingId, limit)
Match Criteria:
- Same
propertyType - Same
district - Price within ±10% (
priceVND * 0.9 ... 1.1) - Area within ±20% (
areaM2 * 0.8 ... 1.2) - Status =
ACTIVE - 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_searchcache:valuation(for AVM results)cache:agent:listingscache: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