- 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>
11 KiB
11 KiB
Quick Reference: Analytics Module Architecture
🏗️ Layer Stack (DDD + CQRS)
┌─────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ ┌──────────────────────────────────────────────────────────────┐│
│ │ @Controller('analytics') / @Controller('avm') ││
│ │ ├─ @Get endpoints (call QueryBus) ││
│ │ ├─ @Post endpoints (call QueryBus or CommandBus) ││
│ │ └─ Guards: JwtAuthGuard, QuotaGuard, EndpointRateLimitGuard ││
│ ├─ DTOs: RequestDto, ResponseDto (validation) ││
│ └─ Interceptors: CacheMetaInterceptor (wraps response) ││
└─────────────────────────────────────────────────────────────────┘
↓ QueryBus.execute()
┌─────────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER (CQRS) │
│ ┌──────────────────────────────────────────────────────────────┐│
│ │ @QueryHandler(SomeQuery) ││
│ │ ├─ Receives Query class instance ││
│ │ ├─ Injects dependencies (Prisma, Cache, Logger) ││
│ │ ├─ Caching: @Cacheable decorator OR cache.getOrSet() ││
│ │ └─ Returns ResponseDto ││
│ └─ Handlers indexed in analytics.module.ts ││
└─────────────────────────────────────────────────────────────────┘
↓ Injected repos/services
┌─────────────────────────────────────────────────────────────────┐
│ DOMAIN LAYER (Business Logic) │
│ ├─ Entities: MarketIndexEntity, ValuationEntity │
│ ├─ Repository Interfaces: IMarketIndexRepository, etc. │
│ ├─ Result DTOs: MarketReportResult, DistrictStatsResult │
│ └─ Services: IAVMService, INeighborhoodScoreService │
└─────────────────────────────────────────────────────────────────┘
↓ Injected implementation
┌─────────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER │
│ ├─ Repositories: PrismaMarketIndexRepository (implements iface) │
│ ├─ Services: HttpAVMService, PrismaAVMService │
│ └─ External: Prisma ORM, PostgreSQL, Redis, HTTP clients │
└─────────────────────────────────────────────────────────────────┘
📂 File Structure Quick Map
apps/api/src/modules/analytics/
├── presentation/
│ ├── controllers/
│ │ ├── analytics.controller.ts (14 GET/POST endpoints)
│ │ └── avm.controller.ts (5 GET/POST endpoints)
│ ├── dto/ (15+ DTO files)
│ │ ├── get-market-snapshot.dto.ts
│ │ ├── predict-valuation.dto.ts
│ │ └── ...
│ └── interceptors/
│ └── cache-meta.interceptor.ts (wraps response)
│
├── application/
│ ├── queries/ (15+ query types)
│ │ ├── get-market-snapshot/
│ │ │ ├── .query.ts (Q: GetMarketSnapshotQuery)
│ │ │ └── .handler.ts (@QueryHandler + cache logic)
│ │ ├── get-district-stats/
│ │ ├── get-price-trend/
│ │ ├── predict-valuation/
│ │ └── ...
│ ├── commands/ (3 command types)
│ ├── event-handlers/ (1 event handler)
│ └── queries/_shared/ (shared utilities)
│
├── domain/
│ ├── entities/
│ │ ├── market-index.entity.ts
│ │ └── valuation.entity.ts
│ ├── repositories/ (interfaces only)
│ │ ├── market-index.repository.ts
│ │ │ └── IMarketIndexRepository interface
│ │ └── valuation.repository.ts
│ ├── services/ (interfaces only)
│ │ ├── avm-service.ts
│ │ └── neighborhood-score.service.ts
│ └── events/
│ └── market-index-updated.event.ts
│
├── infrastructure/
│ ├── repositories/
│ │ ├── prisma-market-index.repository.ts (implements)
│ │ └── prisma-valuation.repository.ts (implements)
│ └── services/
│ ├── http-avm.service.ts (calls Python AI)
│ ├── prisma-avm.service.ts (fallback)
│ ├── http-neighborhood-score.service.ts
│ ├── prisma-neighborhood-score.service.ts
│ ├── ai-service.client.ts (Claude API)
│ └── market-index-cron.service.ts
│
├── analytics.module.ts (NestJS module, registers all)
├── index.ts (exports)
└── README.md
🔄 Request Flow Example
HTTP GET /analytics/market-snapshot?city=Ho Chi Minh
1. AnalyticsController.getMarketSnapshot(@Query dto)
└─ Validates DTO (class-validator)
└─ Calls queryBus.execute(new GetMarketSnapshotQuery(...))
2. QueryBus routes to GetMarketSnapshotHandler
└─ Handler caches key: "cache:analytics:market_snapshot:ho_chi_minh"
└─ Calls cache.getOrSet(key, computeSnapshot, 300s, 'market_snapshot')
3. CacheService.getOrSet():
├─ IF Redis HIT: return cached value, increment cache_hit_total
└─ IF MISS: call computeSnapshot(), store in Redis, increment cache_miss_total
4. GetMarketSnapshotHandler.computeSnapshot():
├─ Parallel queries:
│ ├─ listing.aggregate() → count, avg price
│ ├─ $queryRaw PERCENTILE_CONT → median
│ ├─ $queryRaw AVG(EXTRACT...) → days on market
│ └─ computePriceChangePct (3x for 1d/7d/30d)
└─ Returns MarketSnapshotDto
5. CacheMetaInterceptor wraps response:
└─ Transforms: MarketSnapshotDto
└─ Into: { data: MarketSnapshotDto, cacheMeta: { cachedAt, nextRefreshAt, source } }
6. HTTP 200 with wrapped response
💾 Caching Strategy
When to Cache
✅ Dashboard tiles → 300s TTL (5 min)
✅ Aggregations (district stats) → 300s TTL
✅ Market reports → 900s TTL (15 min)
✅ Historical trends → 1800s TTL (30 min)
❌ AI predictions → NO CACHE (always fresh)
❌ User-specific data → NO CACHE (personalized)
Cache Prefix Patterns
Prefix Example Key
────────────────────────────────────────────────────────
MARKET_SNAPSHOT cache:analytics:market_snapshot:ho_chi_minh
MARKET_DISTRICT cache:market:district:ho_chi_minh:2024_q1
MARKET_TREND cache:market:trend:q1:ho_chi_minh:apartment:...
TRENDING_AREAS cache:analytics:trending_areas:ho_chi_minh:...
VALUATION cache:valuation:prop_123
Cache Invalidation
Automatic: Redis TTL expires
Manual: cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT)
Scans "cache:market:district:*" and deletes all matches
🛡️ Decorators & Guards
┌─ @ApiBearerAuth('JWT') ← Swagger annotation
├─ @UseGuards(JwtAuthGuard) ← Requires JWT token
├─ @UseGuards(QuotaGuard) ← Checks subscription quota
├─ @RequireQuota('analytics_queries') ← Meters usage
├─ @EndpointRateLimit({ limit: 10, windowSeconds: 60 })
├─ @UseGuards(EndpointRateLimitGuard) ← Rate limiter
├─ @UseInterceptors(CacheMetaInterceptor) ← Wraps response
└─ @ApiOperation({ summary: '...' })
@ApiResponse({ status: 200, description: '...' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
(Swagger documentation)
📊 Prisma Schema Snapshot
Property
├─ id, propertyType (APARTMENT, VILLA, ...)
├─ address, ward, district, city
├─ location (PostGIS geometry Point)
├─ areaM2, bedrooms, bathrooms, floors
├─ yearBuilt, furnishing, condition
├─ createdAt, updatedAt
└─ Indexes: [propertyType], [district, city], [location (Gist)]
Listing
├─ id, propertyId (FK), sellerId (FK)
├─ transactionType (SALE, RENT)
├─ status (ACTIVE, SOLD, EXPIRED, ...)
├─ priceVND (BigInt, CHECK > 0)
├─ pricePerM2, aiPriceEstimate, aiConfidence
├─ publishedAt, expiresAt, createdAt
└─ Indexes: [status], [sellerId, status], [status, publishedAt]
MarketIndex
├─ district, city, propertyType, period (2024-Q1)
├─ medianPrice (BigInt), avgPriceM2
├─ totalListings, daysOnMarket
├─ inventoryLevel, absorptionRate, yoyChange
└─ Unique: [district, city, propertyType, period]
🎯 Response Structure
WITH CacheMetaInterceptor (@UseInterceptors):
{
"data": {
"city": "Hồ Chí Minh",
"activeCount": 2500,
...
},
"cacheMeta": {
"cachedAt": "2024-04-21T10:30:00Z",
"nextRefreshAt": "2024-04-21T10:35:00Z",
"source": "cache"
}
}
WITHOUT interceptor (plain DTO):
{
"city": "Hồ Chí Minh",
"activeCount": 2500,
...
}
🚀 Adding Endpoint: 7-Step Checklist
- Create Request DTO:
presentation/dto/get-*.dto.ts - Create Query:
application/queries/get-*/get-*.query.ts - Create Handler:
application/queries/get-*/get-*.handler.ts- @QueryHandler decorator
- @Cacheable or cache.getOrSet()
- Try-catch with logger
- Update module: Add handler to QueryHandlers array
- Add controller method:
analytics.controller.tsoravm.controller.ts- @Get or @Post
- Guards & decorators (@UseGuards, @RequireQuota, etc.)
- Swagger annotations (@ApiOperation, @ApiResponse)
- Export DTOs from
presentation/dto/index.ts - Test: Query handler test, integration test