# GoodGo Analytics Module - Architecture Exploration Summary ## 1. MODULE STRUCTURE (DDD Layers) ### File Organization ``` apps/api/src/modules/analytics/ ├── analytics.module.ts # NestJS module (CQRS setup, DI) ├── index.ts # Exports ├── application/ # Application layer │ ├── commands/ # Write operations │ │ ├── generate-report/ │ │ ├── track-event/ │ │ └── update-market-index/ │ ├── queries/ # Read operations │ │ ├── batch-valuation/ │ │ ├── get-district-stats/ │ │ ├── get-heatmap/ │ │ ├── get-listing-ai-advice/ │ │ ├── get-market-report/ │ │ ├── get-nearby-pois/ │ │ ├── get-neighborhood-score/ │ │ ├── get-price-trend/ │ │ ├── get-project-ai-advice/ │ │ ├── get-valuation/ │ │ ├── industrial-valuation/ │ │ ├── predict-valuation/ │ │ ├── valuation-comparison/ │ │ ├── valuation-explanation/ │ │ ├── valuation-history/ │ │ ├── _shared/ # Shared utilities │ │ │ └── ai-json-client.ts │ ├── event-handlers/ │ │ └── listing-created-moderation.handler.ts │ └── __tests__/ ├── domain/ # Domain layer │ ├── repositories/ │ │ ├── market-index.repository.ts │ │ ├── valuation.repository.ts │ ├── services/ │ │ ├── avm-service.ts │ │ └── neighborhood-score.service.ts │ ├── entities/ │ └── aggregates/ ├── infrastructure/ # Infrastructure layer │ ├── repositories/ │ │ ├── prisma-market-index.repository.ts │ │ └── prisma-valuation.repository.ts │ ├── services/ │ │ ├── ai-service.client.ts │ │ ├── http-avm.service.ts │ │ ├── market-index-cron.service.ts │ │ ├── neighborhood-score.service.ts │ │ └── prisma-avm.service.ts │ └── __tests__/ └── presentation/ # Presentation layer ├── controllers/ │ ├── analytics.controller.ts │ ├── avm.controller.ts │ └── index.ts ├── dto/ # Request/Response DTOs │ ├── avm-compare-query.dto.ts │ ├── avm-explain-query.dto.ts │ ├── batch-valuation.dto.ts │ ├── get-district-stats.dto.ts │ ├── get-heatmap.dto.ts │ ├── get-market-report.dto.ts │ ├── get-nearby-pois.dto.ts │ ├── get-price-trend.dto.ts │ ├── get-valuation.dto.ts │ ├── industrial-valuation.dto.ts │ ├── predict-valuation.dto.ts │ ├── valuation-comparison.dto.ts │ ├── valuation-history.dto.ts │ └── index.ts ├── __tests__/ └── index.ts ``` ### Architecture Pattern: **DDD + CQRS** - **Commands**: State mutations (GenerateReportHandler, TrackEventHandler, UpdateMarketIndexHandler) - **Queries**: Read operations with cache-aside pattern - **Event Handlers**: Listen to domain events (listing-created-moderation) - **Repositories**: Abstract data access (Market Index, Valuation) - **Services**: Business logic (AVM, Neighborhood Scoring) --- ## 2. CONTROLLER & ENDPOINT STRUCTURE ### Analytics Controller (`analytics.controller.ts`) Routes: `GET/POST /analytics/*` and public endpoints **Key Endpoints:** ``` GET /analytics/market-report → GetMarketReportQuery GET /analytics/price-trend → GetPriceTrendQuery GET /analytics/heatmap → GetHeatmapQuery GET /analytics/district-stats → GetDistrictStatsQuery GET /analytics/valuation → GetValuationQuery (by propertyId or coords) POST /analytics/valuation → PredictValuationQuery (manual input form) POST /analytics/valuation/batch → BatchValuationQuery (1-50 properties) GET /analytics/valuation/history/:id → ValuationHistoryQuery POST /analytics/valuation/compare → ValuationComparisonQuery (2-5 properties) GET /analytics/neighborhoods/:district/score → GetNeighborhoodScoreQuery (no auth) GET /analytics/pois/nearby → GetNearbyPOIsQuery (no auth, public) POST /analytics/listings/:id/ai-advice → GetListingAiAdviceQuery (Claude) POST /analytics/projects/:id/ai-advice → GetProjectAiAdviceQuery (Claude) ``` ### AVM Controller (`avm.controller.ts`) Routes: `GET/POST /avm/*` **Key Endpoints:** ``` POST /avm/batch → BatchValuationQuery GET /avm/history/:propertyId → ValuationHistoryQuery GET /avm/compare → ValuationComparisonQuery (query string: ids) GET /avm/explain → ValuationExplanationQuery (valuationId) POST /avm/industrial → IndustrialValuationQuery ``` ### Guard & Decorator Stack ``` @ApiBearerAuth('JWT') // Swagger doc @UseGuards( EndpointRateLimitGuard, // Redis sliding-window rate limit JwtAuthGuard, // Verify JWT token QuotaGuard // Check user subscription quota ) @RequireQuota('analytics_queries') // Decorator: specify quota resource @EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' | 'ip' // Rate limit by authenticated user or IP }) ``` --- ## 3. QUERY/HANDLER PATTERN (CQRS Implementation) ### Query Definition (Example: `GetPriceTrendQuery`) ```typescript export class GetPriceTrendQuery { constructor( public readonly district: string, public readonly city: string, public readonly propertyType: PropertyType, // From @prisma/client public readonly periods: string[], ) {} } ``` ### Handler Pattern (Example: `GetPriceTrendHandler`) ```typescript @QueryHandler(GetPriceTrendQuery) export class GetPriceTrendHandler implements IQueryHandler { constructor( @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, private readonly cache: CacheService, // Cache-aside pattern 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.marketIndexRepo.getPriceTrend(...); return { district, city, propertyType, trend }; }, CacheTTL.MARKET_DATA, // 30 minutes 'price_trend', // Metric label ); } catch (error) { if (error instanceof DomainException) throw error; this.logger.error(...); throw new InternalServerErrorException('...'); } } } ``` **Key Pattern:** 1. Inject repository (abstraction) and cache service 2. Build cache key with CacheService.buildKey() 3. Use cache.getOrSet() for cache-aside pattern 4. Specify TTL from CacheTTL constant and metric name 5. Catch DomainException separately, rethrow or wrap --- ## 4. REDIS CACHING PATTERNS ### Cache Configuration (`CacheService`) **Cache TTLs:** ```typescript CacheTTL = { LISTING_DETAIL: 300, // 5 min SEARCH_RESULTS: 120, // 2 min DISTRICT_STATS: 300, // 5 min MARKET_REPORT: 900, // 15 min HEATMAP: 300, // 5 min MARKET_DATA: 1800, // 30 min (price trends) USER_PROFILE: 600, // 10 min USER_QUOTA: 60, // 1 min (frequently invalidated) PLAN_LIST: 3600, // 1 hour REFERENCE_DATA: 86400, // 24 hours (static districts/wards) } ``` **Cache Key Prefixes:** ```typescript enum CachePrefix { LISTING = 'cache:listing', SEARCH = 'cache:search', GEO_SEARCH = 'cache:geo_search', MARKET_REPORT = 'cache:market:report', MARKET_TREND = 'cache:market:trend', // For price trends MARKET_HEATMAP = 'cache:market:heatmap', MARKET_DISTRICT = 'cache:market:district', USER_PROFILE = 'cache:user:profile', USER_QUOTA = 'cache:user:quota', VALUATION = 'cache:valuation', PLAN_LIST = 'cache:plan:list', REFERENCE = 'cache:reference', AGENT_LISTINGS = 'cache:agent:listings', } ``` **Cache-Aside Implementation:** ```typescript async getOrSet( key: string, loader: () => Promise, ttlSeconds: number, resource: string, // Metric label ): Promise { // 1. Fast-path: if Redis unavailable, call loader directly (graceful degradation) if (!this.redis.isAvailable()) { // Metric: cache_degradation_total[resource]++ return await loader(); } // 2. Try to get from cache // 3. If miss: call loader, store in Redis, return // 4. Metrics: cache_hit_total or cache_miss_total[resource]++ } ``` **Metrics Tracked:** ``` cache_hit_total[resource] // Counter: hits by resource cache_miss_total[resource] // Counter: misses by resource cache_degradation_total[resource] // Counter: fallbacks when Redis down ``` ### Rate Limiting with Redis (Sliding Window) **Lua Script in `EndpointRateLimitGuard`:** ```lua -- Sliding-window algorithm using Redis sorted set -- KEYS[1] = rate limit key (e.g., "rate:user:123:POST:/analytics/valuation") -- ARGV[1] = now (ms timestamp) -- ARGV[2] = windowMs (duration) -- ARGV[3] = limit (max requests) -- ARGV[4] = requestId (unique) -- ARGV[5] = windowSec (TTL) -- Remove entries older than window redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs) local current = redis.call('ZCARD', key) if current < limit then redis.call('ZADD', key, now, requestId) -- Add current request redis.call('EXPIRE', key, windowSec + 1) -- Set expiry return {current + 1, 0} else -- Compute Retry-After header local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') return {current, retryAfterMs} end ``` **Rate Limit Headers Returned:** ``` X-RateLimit-Limit: 10 X-RateLimit-Remaining: 5 X-RateLimit-Reset: 1640000000 Retry-After: 12 ``` --- ## 5. PRISMA SCHEMA - PROPERTY & LISTING MODELS ### Property Model ```prisma model Property { id String @id @default(cuid()) addressNormalized String? propertyType PropertyType // APARTMENT, HOUSE, LAND, COMMERCIAL, etc. status PropertyStatus // ACTIVE, SOLD, RENTED, REMOVED areaM2 Float? usableAreaM2 Float? bedrooms Int? bathrooms Int? floors Int? floor Int? totalFloors Int? direction Direction? yearBuilt Int? legalStatus String? amenities Json? nearbyPOIs Json? metroDistanceM Float? projectName String? projectDevelopmentId String? projectDevelopment ProjectDevelopment? @relation(...) furnishing Furnishing? propertyCondition PropertyCondition? balconyDirection Direction? maintenanceFeeVND BigInt? parkingSlots Int? viewType String[] @default([]) petFriendly Boolean? suitableFor String[] @default([]) whyThisLocation String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt listings Listing[] valuations Valuation[] media PropertyMedia[] // Indexes for analytics queries @@index([propertyType]) @@index([district, city]) @@index([location], type: Gist) // PostGIS spatial index @@index([district, propertyType]) @@index([district, city, propertyType]) } ``` ### Listing Model (with Analytics Fields) ```prisma model Listing { id String @id @default(cuid()) propertyId String property Property @relation(...) agentId String? agent Agent? @relation(...) sellerId String seller User @relation(...) transactionType TransactionType // BUY_SELL, RENT status ListingStatus @default(DRAFT) priceVND BigInt // CHECK: > 0 pricePerM2 Float? // Derived for analytics rentPriceMonthly BigInt? commissionPct Float? @default(2.0) // AI Valuation fields aiPriceEstimate BigInt? // AVM estimate aiConfidence Float? moderationScore Float? moderationNotes String? // Analytics tracking viewCount Int @default(0) saveCount Int @default(0) inquiryCount Int @default(0) // Lifecycle featuredUntil DateTime? expiresAt DateTime? publishedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt transactions Transaction[] inquiries Inquiry[] orders Order[] priceHistories PriceHistory[] savedByUsers SavedListing[] @@index([status]) @@index([transactionType]) @@index([priceVND]) @@index([sellerId]) @@index([propertyId]) @@index([publishedAt]) @@index([createdAt]) @@index([status, createdAt(sort: Desc)]) // For market analytics @@index([status, transactionType, priceVND]) // For price analysis } ``` ### Price History Model ```prisma model PriceHistory { id String @id @default(cuid()) listingId String listing Listing @relation(...) oldPrice BigInt // CHECK: > 0 newPrice BigInt // CHECK: > 0 source String @default("manual_update") changedAt DateTime @default(now()) @@index([listingId, changedAt(sort: Desc)]) // For trend queries } ``` ### Market Index Model (Pre-calculated Analytics) ```prisma model MarketIndex { id String @id @default(cuid()) district String city String propertyType PropertyType period String // "2024-Q1" or "2024-04" format medianPrice BigInt avgPriceM2 Float totalListings Int daysOnMarket Int inventoryLevel Int absorptionRate Float? yoyChange Float? // Year-over-year % change createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([district, city, propertyType, period]) @@index([city, period]) @@index([period, propertyType]) } ``` ### Valuation Model ```prisma model Valuation { id String @id @default(cuid()) propertyId String property Property @relation(...) estimatedPrice BigInt confidence Float // 0-1 confidence score drivers Json? // Key price drivers comparables Json? // Similar properties used explanation String? model String // "v1" or "v2" createdAt DateTime @default(now()) @@index([propertyId, createdAt(sort: Desc)]) @@index([createdAt]) } ``` --- ## 6. SHARED GUARDS & DECORATORS ### File Locations ``` apps/api/src/modules/shared/ ├── infrastructure/ │ ├── decorators/ │ │ ├── endpoint-rate-limit.decorator.ts ← Rate limit config │ │ ├── user-rate-limit.decorator.ts │ │ └── cacheable.decorator.ts │ ├── guards/ │ │ ├── endpoint-rate-limit.guard.ts ← Sliding-window enforcement │ │ ├── user-rate-limit.guard.ts │ │ └── feature-listing-throttler.guard.ts │ ├── middleware/ │ │ ├── correlation-id.middleware.ts ← Trace ID injection │ │ ├── csrf.middleware.ts │ │ ├── request-logging.middleware.ts ← Audit logging │ │ └── sanitize-input.middleware.ts │ ├── cache.service.ts ← Cache-aside + Redis │ ├── redis.service.ts ← Redis connection pool │ ├── logger.service.ts ← Structured logging │ ├── prisma.service.ts │ ├── field-encryption.service.ts │ └── filters/ │ └── global-exception.filter.ts ← Error response standardization ├── domain/ │ ├── domain-exception.ts ← Base error class │ ├── error-codes.ts │ ├── base-entity.ts │ ├── domain-event.ts │ ├── aggregate-root.ts │ └── value-object.ts └── utils/ └── ... ``` ### Key Decorators & Guards **@EndpointRateLimit Decorator:** ```typescript interface EndpointRateLimitOptions { limit: number; // Max requests windowSeconds?: number; // Default 60 keyStrategy?: 'ip' | 'user'; // Default 'ip' adminBypass?: boolean; // Admins skip limit (default true) } // Usage: @EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' }) @UseGuards(EndpointRateLimitGuard, JwtAuthGuard) async method() {} ``` **Response Standardization (via GlobalExceptionFilter):** ```typescript interface ErrorResponseBody { statusCode: number; errorCode: ErrorCode; // Enum: NOT_FOUND, VALIDATION_FAILED, etc. message: string; details?: Record; correlationId?: string; // From CorrelationIdMiddleware timestamp: string; } ``` ### Exception Hierarchy ```typescript class DomainException extends HttpException { constructor( errorCode: ErrorCode, message: string, statusCode?: HttpStatus, details?: Record ) } // Specialized exceptions: class NotFoundException extends DomainException class ValidationException extends DomainException class ConflictException extends DomainException class UnauthorizedException extends DomainException class ForbiddenException extends DomainException ``` --- ## 7. DTO PATTERNS IN ANALYTICS ### Request DTOs (Query Parameters) ```typescript // GET /analytics/market-report?city=...&period=...&propertyType=... export class GetMarketReportDto { @IsString() city: string; @IsString() period: string; @IsEnum(PropertyType) propertyType?: PropertyType; } // POST /analytics/valuation with body export class PredictValuationDto { @IsEnum(PropertyType) propertyType: PropertyType; @IsNumber() area: number; @IsString() district: string; @IsString() city: string; @IsNumber() latitude?: number; @IsNumber() longitude?: number; @IsNumber() bedrooms?: number; @IsNumber() bathrooms?: number; @IsBoolean() hasElevator?: boolean; @IsBoolean() hasParking?: boolean; @IsBoolean() hasPool?: boolean; @IsNumber() distanceToHospitalKm?: number; @IsNumber() distanceToParkKm?: number; @IsNumber() distanceToMallKm?: number; @IsString() floodZoneRisk?: string; } ``` ### Response DTOs (Handler Return Types) ```typescript // From handler export interface PriceTrendDto { district: string; city: string; propertyType: string; trend: PriceTrendPoint[]; } export interface PriceTrendPoint { period: string; medianPrice: string; // Stringified BigInt avgPriceM2: number; totalListings: number; } // From repository export interface MarketReportResult { district: string; city: string; propertyType: PropertyType; period: string; medianPrice: string; // Stringified for JSON safety avgPriceM2: number; totalListings: number; daysOnMarket: number; inventoryLevel: number; absorptionRate: number | null; yoyChange: number | null; } ``` --- ## 8. DEPENDENCY INJECTION & MODULE EXPORTS ### Analytics Module Registration ```typescript @Module({ imports: [CqrsModule, ListingsModule, AdminModule, ProjectsModule], controllers: [AnalyticsController, AvmController], providers: [ { provide: AI_SERVICE_CLIENT, useClass: AiServiceClient }, { provide: MARKET_INDEX_REPOSITORY, useClass: PrismaMarketIndexRepository }, { provide: VALUATION_REPOSITORY, useClass: PrismaValuationRepository }, PrismaAVMService, { provide: AVM_SERVICE, useClass: HttpAVMService }, // HTTP → Python AI PrismaNeighborhoodScoreService, { provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: HttpNeighborhoodScoreService }, MarketIndexCronService, ...CommandHandlers, ...QueryHandlers, ...EventHandlers, ], exports: [ MARKET_INDEX_REPOSITORY, VALUATION_REPOSITORY, AVM_SERVICE, AI_SERVICE_CLIENT, ], }) export class AnalyticsModule {} ``` ### Shared Module (Global) ```typescript @Global() @Module({ imports: [ConfigModule.forRoot(...), EventEmitterModule.forRoot(), PrometheusModule.register(...)], providers: [ LoggerService, PrismaService, RedisService, // Redis connection pool CacheService, // Cache-aside pattern + metrics EventBusService, FieldEncryptionService, // Prometheus metrics for cache makeCounterProvider({ name: CACHE_HIT_TOTAL, labelNames: ['resource'] }), makeCounterProvider({ name: CACHE_MISS_TOTAL, labelNames: ['resource'] }), makeCounterProvider({ name: CACHE_DEGRADATION_TOTAL, labelNames: ['resource', 'operation'] }), { provide: APP_FILTER, useClass: GlobalExceptionFilter }, ], exports: [PrismaService, RedisService, CacheService, LoggerService, ...], }) export class SharedModule implements NestModule { // Middleware registration: configure(consumer: MiddlewareConsumer) { consumer .apply(CorrelationIdMiddleware, SanitizeInputMiddleware, RequestLoggingMiddleware) .forRoutes('*'); consumer .apply(CsrfMiddleware) .exclude([{ path: 'auth/login', method: RequestMethod.POST }, ...]) .forRoutes('*'); } } ``` --- ## 9. KEY PATTERNS & CONVENTIONS ### Cache Key Building ```typescript CacheService.buildKey(CachePrefix.MARKET_TREND, district, city, propertyType, periods) // → "cache:market:trend:{district}:{city}:{propertyType}:{periods}" ``` ### Error Handling Pattern ```typescript async execute(query: Query): Promise { try { const cacheKey = CacheService.buildKey(...); return this.cache.getOrSet(cacheKey, async () => { // Business logic }, CacheTTL.MARKET_DATA, 'metric_label'); } catch (error) { if (error instanceof DomainException) throw error; // Re-throw domain errors this.logger.error(`Failed to ...`, error.stack, this.constructor.name); throw new InternalServerErrorException('User-facing message'); } } ``` ### Repository Pattern (Dependency Inversion) ```typescript // domain/repositories/market-index.repository.ts export const MARKET_INDEX_REPOSITORY = Symbol('MARKET_INDEX_REPOSITORY'); export interface IMarketIndexRepository { ... } // infrastructure/repositories/prisma-market-index.repository.ts @Injectable() export class PrismaMarketIndexRepository implements IMarketIndexRepository { ... } // In handlers: @Inject(MARKET_INDEX_REPOSITORY) private readonly repo: IMarketIndexRepository ``` ### Quota & Rate Limit Stack ``` @ApiBearerAuth('JWT') @EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' }) @UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard) // Order matters! @RequireQuota('analytics_queries') // Quota resource name ``` ### Query Handler Naming - Query class: `GetPriceTrendQuery` - Handler: `GetPriceTrendHandler` (decorated with `@QueryHandler(GetPriceTrendQuery)`) - DTO: `GetPriceTrendDto` (response type) - Query class file: `get-price-trend.query.ts` - Handler file: `get-price-trend.handler.ts` --- ## 10. QUICK REFERENCE: PATHS & KEYS **Module Root:** ``` apps/api/src/modules/analytics/ ``` **Controllers:** ``` analytics.controller.ts → /analytics/* avm.controller.ts → /avm/* ``` **Cache Prefixes (for market analytics):** ``` cache:market:report → Market report data cache:market:trend → Price trends cache:market:heatmap → Heatmap data cache:market:district → District statistics cache:valuation → Property valuations ``` **Shared Module Exports:** ``` PrismaService → Database RedisService → Redis connection CacheService → Cache-aside + metrics LoggerService → Structured logging EventBusService → Event emission ``` **Key Decorators:** ``` @EndpointRateLimit({...}) → Per-endpoint sliding-window rate limit @RequireQuota('...') → Subscription quota check @UseGuards(...) → Auth, rate limit, quota guards ``` **TTLs:** ``` MARKET_DATA: 1800 → 30 minutes (price trends, historical) MARKET_REPORT: 900 → 15 minutes (report summaries) HEATMAP: 300 → 5 minutes (heatmap tiles) DISTRICT_STATS: 300 → 5 minutes (statistics) ```