Files
goodgo-platform/docs/explorations/from-desktop/02_quick_reference.md
Ho Ngoc Hai 08b96f9c2d 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>
2026-04-21 16:29:24 +07:00

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.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