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>
This commit is contained in:
306
docs/explorations/LISTINGS_QUICK_REFERENCE.md
Normal file
306
docs/explorations/LISTINGS_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user