Files
goodgo-platform/docs/explorations/ANALYTICS_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

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

@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

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

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"

Property

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)

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)

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

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

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

// application/queries/get-price-trend/get-price-trend.handler.ts
@QueryHandler(GetPriceTrendQuery)
export class GetPriceTrendHandler implements IQueryHandler<GetPriceTrendQuery> {
  constructor(
    @Inject(MARKET_INDEX_REPOSITORY)
    private readonly repo: IMarketIndexRepository,
    private readonly cache: CacheService,
    private readonly logger: LoggerService,
  ) {}

  async execute(query: GetPriceTrendQuery): Promise<PriceTrendDto> {
    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

@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Get('price-trend')
async getPriceTrend(@Query() dto: GetPriceTrendDto): Promise<PriceTrendDto> {
  return this.queryBus.execute(
    new GetPriceTrendQuery(dto.district, dto.city, dto.propertyType, dto.periods)
  );
}

🛡️ Error Handling Pattern

async execute(query: Query): Promise<Result> {
  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

# 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

@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

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