# Analytics Module - Quick Reference Card ## πŸ—οΈ Architecture Stack ``` PRESENTATION (controllers) β†’ APPLICATION (CQRS) β†’ DOMAIN (entities/services) β†’ INFRASTRUCTURE (data access) ``` **Files:** - Controllers: `presentation/controllers/*.controller.ts` - DTOs: `presentation/dto/*.dto.ts` - Queries/Commands: `application/queries/*.query.ts` + `*.handler.ts` - Repositories (interface): `domain/repositories/*.ts` - Repositories (impl): `infrastructure/repositories/prisma-*.repository.ts` --- ## πŸ“ Key File Paths ``` ANALYTICS MODULE ROOT └── apps/api/src/modules/analytics/ CONTROLLERS β”œβ”€β”€ analytics.controller.ts (GET/POST /analytics/*) └── avm.controller.ts (GET/POST /avm/*) QUERY HANDLERS (14+ queries) β”œβ”€β”€ get-price-trend/ β”œβ”€β”€ get-heatmap/ β”œβ”€β”€ get-market-report/ β”œβ”€β”€ get-district-stats/ β”œβ”€β”€ get-valuation/ β”œβ”€β”€ predict-valuation/ β”œβ”€β”€ batch-valuation/ β”œβ”€β”€ industrial-valuation/ β”œβ”€β”€ get-neighborhood-score/ β”œβ”€β”€ get-nearby-pois/ β”œβ”€β”€ get-listing-ai-advice/ (Claude) β”œβ”€β”€ get-project-ai-advice/ (Claude) β”œβ”€β”€ valuation-history/ β”œβ”€β”€ valuation-comparison/ └── valuation-explanation/ REPOSITORIES (abstraction) β”œβ”€β”€ domain/repositories/market-index.repository.ts └── domain/repositories/valuation.repository.ts REPOSITORIES (Prisma impl) β”œβ”€β”€ infrastructure/repositories/prisma-market-index.repository.ts └── infrastructure/repositories/prisma-valuation.repository.ts SERVICES β”œβ”€β”€ infrastructure/services/http-avm.service.ts (β†’ Python AI) β”œβ”€β”€ infrastructure/services/prisma-avm.service.ts (fallback) β”œβ”€β”€ infrastructure/services/neighborhood-score.service.ts └── infrastructure/services/ai-service.client.ts (Claude) SHARED GUARDS & DECORATORS β”œβ”€β”€ @EndpointRateLimit({limit, windowSeconds, keyStrategy}) β”œβ”€β”€ @RequireQuota('analytics_queries') β”œβ”€β”€ @UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard) └── /modules/shared/infrastructure/guards/ ``` --- ## πŸ” Guards & Decorators Stack ```typescript @ApiBearerAuth('JWT') @EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' }) @UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard) @RequireQuota('analytics_queries') async handler() { } ``` **Order matters:** 1. `EndpointRateLimitGuard` β€” Redis sliding-window rate limit 2. `JwtAuthGuard` β€” Verify JWT token 3. `QuotaGuard` β€” Check subscription quota --- ## πŸ’Ύ Cache Patterns ### Cache-Aside Pattern ```typescript return this.cache.getOrSet( cacheKey, // Unique cache key async () => { /* loader */ }, // Function to load data if miss CacheTTL.MARKET_DATA, // TTL in seconds (1800 = 30 min) 'price_trend' // Metric label for Prometheus ); ``` ### Cache Key Building ```typescript CacheService.buildKey( CachePrefix.MARKET_TREND, // Prefix enum query.district, // Key component 1 query.city, // Key component 2 query.propertyType, // Key component 3 query.periods?.join(',') // Key component N ) // Result: "cache:market:trend:QuαΊ­n 1:Hα»“ ChΓ­ Minh:APARTMENT:2024-Q1,2024-Q2" ``` ### Useful TTLs ``` CacheTTL.MARKET_DATA = 1800 (30 min, price trends) CacheTTL.MARKET_REPORT = 900 (15 min, summaries) CacheTTL.HEATMAP = 300 (5 min, heatmaps) CacheTTL.DISTRICT_STATS = 300 (5 min, stats) CacheTTL.LISTING_DETAIL = 300 (5 min, detail pages) CacheTTL.SEARCH_RESULTS = 120 (2 min, search results) CacheTTL.REFERENCE_DATA = 86400 (24 hours, static data) ``` ### Cache Prefixes for Analytics ``` CachePrefix.MARKET_REPORT "cache:market:report" CachePrefix.MARKET_TREND "cache:market:trend" CachePrefix.MARKET_HEATMAP "cache:market:heatmap" CachePrefix.MARKET_DISTRICT "cache:market:district" CachePrefix.VALUATION "cache:valuation" ``` --- ## πŸ—ƒοΈ Prisma Models (Analytics-Related) ### Property ```prisma model Property { propertyType PropertyType // APARTMENT, HOUSE, LAND, COMMERCIAL status PropertyStatus // ACTIVE, SOLD, RENTED areaM2 Float? bedrooms Int? bathrooms Int? district String city String location geometry(Point) // PostGIS createdAt DateTime updatedAt DateTime } ``` ### Listing (with Analytics Fields) ```prisma model Listing { priceVND BigInt // Main price pricePerM2 Float? // Derived for analytics transactionType TransactionType // BUY_SELL, RENT status ListingStatus // DRAFT, ACTIVE, SOLD, etc. // AI Valuation aiPriceEstimate BigInt? aiConfidence Float? // Tracking viewCount Int saveCount Int inquiryCount Int publishedAt DateTime? createdAt DateTime updatedAt DateTime } ``` ### MarketIndex (Pre-calculated) ```prisma model MarketIndex { district String city String propertyType PropertyType period String // "2024-Q1" or "2024-04" medianPrice BigInt avgPriceM2 Float totalListings Int daysOnMarket Int inventoryLevel Int absorptionRate Float? yoyChange Float? @@unique([district, city, propertyType, period]) } ``` ### Valuation ```prisma model Valuation { propertyId String estimatedPrice BigInt confidence Float // 0-1 drivers Json? // Key price drivers comparables Json? // Similar properties explanation String? model String // "v1" or "v2" } ``` --- ## πŸ“Š CQRS Handler Pattern ### Query Class ```typescript // application/queries/get-price-trend/get-price-trend.query.ts export class GetPriceTrendQuery { constructor( public readonly district: string, public readonly city: string, public readonly propertyType: PropertyType, public readonly periods: string[], ) {} } ``` ### Handler ```typescript // application/queries/get-price-trend/get-price-trend.handler.ts @QueryHandler(GetPriceTrendQuery) export class GetPriceTrendHandler implements IQueryHandler { constructor( @Inject(MARKET_INDEX_REPOSITORY) private readonly repo: IMarketIndexRepository, private readonly cache: CacheService, private readonly logger: LoggerService, ) {} async execute(query: GetPriceTrendQuery): Promise { try { const cacheKey = CacheService.buildKey( CachePrefix.MARKET_TREND, query.district, query.city, query.propertyType, query.periods?.join(','), ); return this.cache.getOrSet( cacheKey, async () => { const trend = await this.repo.getPriceTrend( query.district, query.city, query.propertyType, query.periods, ); return { district: query.district, city: query.city, propertyType: query.propertyType, trend }; }, CacheTTL.MARKET_DATA, 'price_trend', ); } catch (error) { if (error instanceof DomainException) throw error; this.logger.error(`Failed...`, error?.stack, this.constructor.name); throw new InternalServerErrorException('...'); } } } ``` ### Controller Integration ```typescript @ApiBearerAuth('JWT') @UseGuards(JwtAuthGuard, QuotaGuard) @RequireQuota('analytics_queries') @Get('price-trend') async getPriceTrend(@Query() dto: GetPriceTrendDto): Promise { return this.queryBus.execute( new GetPriceTrendQuery(dto.district, dto.city, dto.propertyType, dto.periods) ); } ``` --- ## πŸ›‘οΈ Error Handling Pattern ```typescript async execute(query: Query): Promise { try { // Business logic return this.cache.getOrSet(cacheKey, loader, ttl, 'metric'); } catch (error) { // Re-throw domain errors as-is if (error instanceof DomainException) throw error; // Log and wrap unexpected errors this.logger.error( `Failed to process: ${error instanceof Error ? error.message : error}`, error instanceof Error ? error.stack : undefined, this.constructor.name, ); // Return user-friendly message throw new InternalServerErrorException('KhΓ΄ng thể xα»­ lΓ½ yΓͺu cαΊ§u.'); } } ``` ### Exception Hierarchy ``` DomainException (base) β”œβ”€β”€ NotFoundException β”œβ”€β”€ ValidationException β”œβ”€β”€ ConflictException β”œβ”€β”€ UnauthorizedException └── ForbiddenException ``` --- ## 🎯 Common Endpoints ```bash # Market Analytics GET /analytics/market-report?city=...&period=...&propertyType=... GET /analytics/price-trend?district=...&city=...&propertyType=...&periods=... GET /analytics/heatmap?city=...&period=... GET /analytics/district-stats?city=...&period=... # Property Valuation (AVM) GET /analytics/valuation?propertyId=... OR ?latitude=...&longitude=...&areaM2=... POST /analytics/valuation (form input) POST /analytics/valuation/batch (1-50 properties) GET /analytics/valuation/history/:id POST /analytics/valuation/compare (2-5 properties) # AVM Endpoints (alias routes) POST /avm/batch GET /avm/history/:propertyId GET /avm/compare?ids=... GET /avm/explain?valuationId=... POST /avm/industrial # Neighborhood & Location GET /analytics/neighborhoods/:district/score GET /analytics/pois/nearby?lat=...&lng=...&radius=...&limit=... # AI Advice (Claude) POST /analytics/listings/:id/ai-advice POST /analytics/projects/:id/ai-advice ``` --- ## πŸ“‹ Dependency Injection ### Module Providers ```typescript @Module({ providers: [ // Repositories (abstraction β†’ implementation) { provide: MARKET_INDEX_REPOSITORY, useClass: PrismaMarketIndexRepository }, { provide: VALUATION_REPOSITORY, useClass: PrismaValuationRepository }, // Services (fallback pattern) PrismaAVMService, { provide: AVM_SERVICE, useClass: HttpAVMService }, // Tries HTTP first // All handlers ...CommandHandlers, ...QueryHandlers, ...EventHandlers, ], exports: [ MARKET_INDEX_REPOSITORY, VALUATION_REPOSITORY, AVM_SERVICE, ], }) ``` ### Injection Pattern ```typescript constructor( @Inject(MARKET_INDEX_REPOSITORY) private readonly repo: IMarketIndexRepository, private readonly cache: CacheService, private readonly logger: LoggerService, ) {} ``` --- ## βœ… Key Conventions | Aspect | Convention | |--------|-----------| | **Query File** | `get-price-trend.query.ts` | | **Handler File** | `get-price-trend.handler.ts` | | **Class Names** | `GetPriceTrendQuery`, `GetPriceTrendHandler`, `PriceTrendDto` | | **Cache Key** | `CacheService.buildKey(CachePrefix.*, ...params)` | | **Cache TTL** | Use `CacheTTL.*` constants | | **Metric Label** | Lowercase, underscore-separated: `'price_trend'` | | **Rate Limit** | `{ limit: N, windowSeconds: 60, keyStrategy: 'user' \| 'ip' }` | | **Exception** | Catch `DomainException` separately; wrap others | | **BigInt in JSON** | Always stringify: `.toString()` | | **Shared Services** | Import from `@modules/shared` |