# 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.ts` or `avm.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