Files
goodgo-platform/docs/explorations/from-desktop/01_analytics_architecture_guide.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

37 KiB
Raw Permalink Blame History

GoodGo Analytics Module — Architecture Guide

📋 Overview

The analytics module follows Domain-Driven Design (DDD) with CQRS (Command Query Responsibility Segregation) pattern. It's organized into clear layers:

apps/api/src/modules/analytics/
├── presentation/          # Controllers, DTOs, Interceptors
├── application/           # Query/Command Handlers (CQRS)
├── domain/               # Business logic, Entities, Repositories (abstractions)
└── infrastructure/       # Implementations (Prisma repos, Services)

1 DIRECTORY STRUCTURE & DDD LAYERS

Presentation Layer (/presentation)

presentation/
├── controllers/
│   ├── analytics.controller.ts    # Main analytics endpoints
│   └── avm.controller.ts          # Valuation-specific endpoints
├── dto/                            # Request/Response DTOs
│   ├── get-market-snapshot.dto.ts
│   ├── predict-valuation.dto.ts
│   ├── batch-valuation.dto.ts
│   └── ... (15+ DTOs)
└── interceptors/
    └── cache-meta.interceptor.ts   # Adds cache metadata to responses

Key Pattern:

  • Controllers inject QueryBus (from @nestjs/cqrs)
  • Methods decorated with @Get, @Post receive DTOs
  • DTOs use class-validator for validation
  • Controllers use guards: JwtAuthGuard, QuotaGuard, EndpointRateLimitGuard
  • Apply @UseInterceptors(CacheMetaInterceptor) to enable cache metadata

Application Layer (/application)

Queries (Read operations)

application/queries/
├── get-market-snapshot/
│   ├── get-market-snapshot.query.ts      # Query class (plain data holder)
│   └── get-market-snapshot.handler.ts    # @QueryHandler implementation
├── get-district-stats/
├── get-price-trend/
├── get-valuation/
├── predict-valuation/
├── batch-valuation/
├── valuation-history/
├── valuation-comparison/
├── valuation-explanation/
└── ... (15+ query types)

Query Pattern:

// .query.ts — Plain data class
export class GetMarketSnapshotQuery {
  constructor(
    public readonly city: string,
    public readonly propertyType?: PropertyType,
  ) {}
}

// .handler.ts — QueryHandler
@QueryHandler(GetMarketSnapshotQuery)
export class GetMarketSnapshotHandler implements IQueryHandler<GetMarketSnapshotQuery> {
  constructor(
    private readonly prisma: PrismaService,
    private readonly cache: CacheService,
    private readonly logger: LoggerService,
  ) {}

  async execute(query: GetMarketSnapshotQuery): Promise<MarketSnapshotDto> {
    // Two patterns:
    // 1. Manual cache.getOrSet()
    // 2. @Cacheable decorator (for simpler cases)
  }
}

Commands (Write operations)

application/commands/
├── track-event/
├── generate-report/
└── update-market-index/

Event Handlers

application/event-handlers/
└── listing-created-moderation.handler.ts

Domain Layer (/domain)

Entities

domain/entities/
├── market-index.entity.ts      # Business logic for market data
└── valuation.entity.ts         # Valuation domain entity

Repositories (Abstractions)

domain/repositories/
├── market-index.repository.ts  # Export: MARKET_INDEX_REPOSITORY symbol
├── valuation.repository.ts     # Export: VALUATION_REPOSITORY symbol
└── Define interfaces like:
    - IMarketIndexRepository
    - IValuationRepository
    - Result DTOs: MarketReportResult, HeatmapDataPoint, etc.

Example:

export const MARKET_INDEX_REPOSITORY = Symbol('MARKET_INDEX_REPOSITORY');

export interface IMarketIndexRepository {
  findById(id: string): Promise<MarketIndexEntity | null>;
  getDistrictStats(city: string, period: string): Promise<DistrictStatsResult[]>;
  getHeatmap(city: string, period: string): Promise<HeatmapDataPoint[]>;
  getPriceTrend(...): Promise<PriceTrendPoint[]>;
}

export interface DistrictStatsResult {
  district: string;
  city: string;
  propertyType: PropertyType;
  medianPrice: string;  // Stored as string to handle BigInt
  avgPriceM2: number;
  totalListings: number;
  daysOnMarket: number;
  inventoryLevel: number;
  absorptionRate: number | null;
  yoyChange: number | null;
}

Services (Domain logic)

domain/services/
├── avm-service.ts                    # AVM abstraction
├── neighborhood-score.service.ts     # Scoring interface
└── domain/events/
    └── market-index-updated.event.ts

Infrastructure Layer (/infrastructure)

Repositories (Implementations)

infrastructure/repositories/
├── prisma-market-index.repository.ts
└── prisma-valuation.repository.ts

Example Pattern:

@Injectable()
export class PrismaMarketIndexRepository implements IMarketIndexRepository {
  constructor(private readonly prisma: PrismaService) {}

  async getDistrictStats(city: string, period: string): Promise<DistrictStatsResult[]> {
    const records = await this.prisma.marketIndex.findMany({
      where: { city, period },
      orderBy: { district: 'asc' },
    });
    
    return records.map(r => ({
      district: r.district,
      medianPrice: r.medianPrice.toString(),  // Convert BigInt
      avgPriceM2: r.avgPriceM2,
      // ...
    }));
  }
}

Services (Implementations)

infrastructure/services/
├── http-avm.service.ts                # Python AI service proxy
├── prisma-avm.service.ts              # Fallback ML model
├── http-neighborhood-score.service.ts # HTTP proxy
├── prisma-neighborhood-score.service.ts
├── ai-service.client.ts               # Claude API client
├── market-index-cron.service.ts       # Background job

2 EXISTING CONTROLLERS, SERVICES, DTOs

Controllers

AnalyticsController (presentation/controllers/analytics.controller.ts)

  • GET /analytics/market-report — Market report by city/period/type
  • GET /analytics/market-snapshot — Dashboard snapshot (active count, prices, trends)
  • GET /analytics/price-trend — Price trends by district/city
  • GET /analytics/heatmap — Heatmap data for city
  • GET /analytics/district-stats — Stats by district
  • GET /analytics/valuation — Valuation by propertyId OR (lat, lng, areaM2)
  • POST /analytics/valuation — Valuation with full property details (AVM v1/v2)
  • POST /analytics/valuation/batch — Batch valuation (1-50 properties)
  • GET /analytics/valuation/history/:propertyId — Valuation history (time-series)
  • POST /analytics/valuation/compare — Compare 2-5 properties
  • GET /analytics/neighborhoods/:district/score — Neighborhood score
  • GET /analytics/pois/nearby — Public endpoint for nearby POIs
  • POST /analytics/listings/:id/ai-advice — Claude AI analysis
  • POST /analytics/projects/:id/ai-advice — AI project analysis

AvmController (presentation/controllers/avm.controller.ts)

  • POST /avm/batch — Batch valuation
  • GET /avm/history/:propertyId — Valuation history
  • GET /avm/compare?ids=prop-1,prop-2 — Compare properties
  • GET /avm/explain?valuationId=... — Valuation drivers & explanation
  • POST /avm/industrial — Industrial property rent estimation

Query Handlers

Pattern 1: Using @Cacheable Decorator

@QueryHandler(GetDistrictStatsQuery)
export class GetDistrictStatsHandler implements IQueryHandler<GetDistrictStatsQuery> {
  constructor(
    @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
    private readonly cacheService: CacheService,
    private readonly logger: LoggerService,
  ) {}

  @Cacheable({
    prefix: CachePrefix.MARKET_DISTRICT,
    ttl: CacheTTL.DISTRICT_STATS,
    resource: 'district_stats',
    keyFrom: (query: unknown) => {
      const q = query as GetDistrictStatsQuery;
      return [q.city, q.period];  // Cache key parts
    },
  })
  async execute(query: GetDistrictStatsQuery): Promise<DistrictStatsDto> {
    const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
    return { city: query.city, period: query.period, districts };
  }
}

Pattern 2: Using cache.getOrSet() Manually

@QueryHandler(GetMarketSnapshotQuery)
export class GetMarketSnapshotHandler implements IQueryHandler<GetMarketSnapshotQuery> {
  constructor(
    private readonly prisma: PrismaService,
    private readonly cache: CacheService,
    private readonly logger: LoggerService,
  ) {}

  async execute(query: GetMarketSnapshotQuery): Promise<MarketSnapshotDto> {
    const cacheKey = CacheService.buildKey(
      CachePrefix.MARKET_SNAPSHOT,
      query.city,
      query.propertyType,
    );

    return await this.cache.getOrSet(
      cacheKey,
      () => this.computeSnapshot(query.city, query.propertyType),
      CacheTTL.MARKET_SNAPSHOT,
      'market_snapshot',  // Prometheus metric label
    );
  }

  private async computeSnapshot(city: string, propertyType?: PropertyType): Promise<MarketSnapshotDto> {
    // Heavy computation: parallel DB queries, raw SQL, aggregations
    // Returns computed DTO
  }
}

3 CACHING IN THE MODULE

Cache Hierarchy

CacheTTL Constants (apps/api/src/modules/shared/infrastructure/cache.service.ts)

export const 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)
  MARKET_SNAPSHOT: 300,          // 5 min (dashboard)
  TRENDING_AREAS: 1800,          // 30 min
  // ... 20+ other TTLs
};

CachePrefix Enum

export enum CachePrefix {
  LISTING = 'cache:listing',
  SEARCH = 'cache:search',
  MARKET_REPORT = 'cache:market:report',
  MARKET_TREND = 'cache:market:trend',
  MARKET_HEATMAP = 'cache:market:heatmap',
  MARKET_DISTRICT = 'cache:market:district',
  MARKET_SNAPSHOT = 'cache:analytics:market_snapshot',
  VALUATION = 'cache:valuation',
  TRENDING_AREAS = 'cache:analytics:trending_areas',
  // ... 10+ other prefixes
}

Redis Pattern: Cache-Aside

How it works:

  1. Client calls endpoint
  2. Query handler calls cache.getOrSet(key, loader, ttl, resource)
  3. CacheService.getOrSet():
    • Tries to get from Redis
    • If HIT: returns cached value, increments cache_hit_total metric
    • If MISS or Redis down: calls loader() function, stores result in Redis, increments cache_miss_total
  4. Metrics tracked: cache_hit_total, cache_miss_total, cache_degradation_total

Cache Key Building

// Deterministic key: `prefix:param1:param2:param3`
const cacheKey = CacheService.buildKey(
  CachePrefix.MARKET_DISTRICT,
  city,
  period
);
// Result: "cache:market:district:ho_chi_minh:2024-q1"

Cache Envelope (Metadata Storage)

// Entry stored in Redis:
{
  __v: <actual_data>,           // Wrapped value
  cachedAt: "2024-04-21T10:30:00Z",
  ttlSeconds: 300
}

Why?CacheMetaInterceptor extracts cachedAt and calculates nextRefreshAt for the client.

Cache Invalidation

Two strategies:

1. TTL-based (automatic):

// Just let Redis expire the key after TTL
await cache.getOrSet(key, loader, 300, 'resource');  // 5 min

2. Prefix-based (manual invalidation):

// When listing is updated, invalidate all district stats for that city
await cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT);

// Scans with Redis SCAN (non-blocking)
// Deletes all keys matching "cache:market:district:*"

Graceful Degradation

If Redis is unavailable:

  • cache.getOrSet() calls loader() directly
  • Increments cache_degradation_total metric
  • Frontend notified via response header (if using CacheMetaInterceptor)

4 ENDPOINT PATTERNS & QUERY HANDLERS

Pattern 1: Simple Cached Query (District Stats)

DTO (Request):

// get-district-stats.dto.ts
export class GetDistrictStatsDto {
  @ApiProperty({ description: 'City name' })
  @IsString()
  city!: string;

  @ApiProperty({ description: 'Period like 2024-Q1' })
  @IsString()
  period!: string;
}

Query (CQRS):

// get-district-stats.query.ts
export class GetDistrictStatsQuery {
  constructor(public readonly city: string, public readonly period: string) {}
}

Handler (with decorator caching):

// get-district-stats.handler.ts
@QueryHandler(GetDistrictStatsQuery)
export class GetDistrictStatsHandler implements IQueryHandler<GetDistrictStatsQuery> {
  @Cacheable({
    prefix: CachePrefix.MARKET_DISTRICT,
    ttl: CacheTTL.DISTRICT_STATS,
    resource: 'district_stats',
    keyFrom: (query) => [(query as GetDistrictStatsQuery).city, (query as GetDistrictStatsQuery).period],
  })
  async execute(query: GetDistrictStatsQuery): Promise<DistrictStatsDto> {
    const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
    return { city: query.city, period: query.period, districts };
  }
}

Response DTO:

// Handler also exports response type
export interface DistrictStatsDto {
  city: string;
  period: string;
  districts: DistrictStatsResult[];  // From repository interface
}

Controller:

@Get('district-stats')
@ApiOperation({ summary: 'Get statistics by district' })
async getDistrictStats(@Query() dto: GetDistrictStatsDto): Promise<DistrictStatsDto> {
  return this.queryBus.execute(new GetDistrictStatsQuery(dto.city, dto.period));
}

Pattern 2: Complex Computed Query (Market Snapshot)

DTO:

export class GetMarketSnapshotDto {
  @ApiPropertyOptional() city?: string;
  @ApiPropertyOptional({ enum: PropertyType }) propertyType?: PropertyType;
}

Query:

export class GetMarketSnapshotQuery {
  constructor(public readonly city: string, public readonly propertyType?: PropertyType) {}
}

Handler (manual caching):

@QueryHandler(GetMarketSnapshotQuery)
export class GetMarketSnapshotHandler implements IQueryHandler<GetMarketSnapshotQuery> {
  async execute(query: GetMarketSnapshotQuery): Promise<MarketSnapshotDto> {
    const cacheKey = CacheService.buildKey(
      CachePrefix.MARKET_SNAPSHOT,
      query.city,
      query.propertyType,
    );

    return await this.cache.getOrSet(
      cacheKey,
      () => this.computeSnapshot(query.city, query.propertyType),
      CacheTTL.MARKET_SNAPSHOT,
      'market_snapshot',
    );
  }

  private async computeSnapshot(city: string, propertyType?: PropertyType): Promise<MarketSnapshotDto> {
    // Expensive computation: 7+ parallel queries
    const [activeAgg, medianResult, newListings24h, ...] = await Promise.all([
      this.prisma.listing.aggregate({ ... }),
      this.prisma.$queryRaw<any>(`SELECT PERCENTILE_CONT...`),
      // ... raw SQL queries
    ]);

    // Aggregate results
    return {
      city,
      propertyType,
      activeCount: activeAgg._count,
      avgPrice: Number(activeAgg._avg.priceVND ?? 0),
      medianPrice: Number(medianResult[0]?.median ?? 0),
      priceChangePct: { d1: ..., d7: ..., d30: ... },
      // ...
      cachedAt: null,       // Filled by interceptor
      nextRefreshAt: null,  // Filled by interceptor
    };
  }
}

Response DTO:

export interface MarketSnapshotDto {
  city: string;
  propertyType?: PropertyType;
  activeCount: number;
  avgPrice: number;
  medianPrice: number;
  priceChangePct: { d1: number; d7: number; d30: number };
  avgPricePerM2: number;
  daysOnMarket: number;
  newListings24h: number;
  cachedAt: string | null;      // Set by interceptor
  nextRefreshAt: string | null;  // Set by interceptor
}

Response structure (after CacheMetaInterceptor):

{
  "data": {
    "city": "Hồ Chí Minh",
    "activeCount": 2500,
    "avgPrice": 3500000000,
    ...
  },
  "cacheMeta": {
    "cachedAt": "2024-04-21T10:30:00Z",
    "nextRefreshAt": "2024-04-21T10:35:00Z",
    "source": "cache"
  }
}

Pattern 3: POST with Body (Prediction/Valuation)

DTO (Request):

export class PredictValuationDto {
  @ApiProperty({ enum: PropertyType })
  @IsEnum(PropertyType)
  propertyType!: PropertyType;

  @ApiProperty({ description: 'Area in m²' })
  @IsNumber()
  @Min(1)
  area!: number;

  @ApiProperty() district!: string;
  @ApiProperty() city!: string;

  @ApiPropertyOptional() bedrooms?: number;
  @ApiPropertyOptional() bathrooms?: number;
  // ... 20+ optional fields for v2 model

  @ApiPropertyOptional({ description: 'Use AVM v2 ensemble' })
  @IsBoolean()
  useV2?: boolean;
}

Query:

export class PredictValuationQuery {
  constructor(
    public readonly propertyId: string | null,
    public readonly propertyType: PropertyType,
    public readonly area: number,
    // ... all 20+ fields
  ) {}
}

Handler (NO caching for predictions):

@QueryHandler(PredictValuationQuery)
export class PredictValuationHandler implements IQueryHandler<PredictValuationQuery> {
  constructor(
    @Inject(AVM_SERVICE) private readonly avmService: IAVMService,
    private readonly logger: LoggerService,
  ) {}

  async execute(query: PredictValuationQuery): Promise<PredictValuationDto> {
    // Call AI service (Python or Prisma fallback)
    return this.avmService.predict({
      propertyType: query.propertyType,
      area: query.area,
      district: query.district,
      useV2: query.useV2,
      // ... all parameters
    });
  }
}

Controller:

@Post('valuation')
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
async predictValuation(@Body() dto: PredictValuationDto): Promise<PredictValuationDto> {
  return this.queryBus.execute(
    new PredictValuationQuery(
      null,
      dto.propertyType,
      dto.area,
      dto.district,
      dto.city,
      // ... pass all fields
      dto.useV2,
    ),
  );
}

5 PRISMA SCHEMA

Property Model

model Property {
  id                   String
  propertyType         PropertyType  // APARTMENT, VILLA, TOWNHOUSE, LAND, OFFICE, SHOPHOUSE
  title                String
  description          String
  address              String
  ward                 String
  district             String
  city                 String
  addressNormalized    String?       // Used for duplicate detection
  location             Unsupported("geometry(Point, 4326)")  // PostGIS point
  areaM2               Float
  usableAreaM2         Float?
  bedrooms             Int?
  bathrooms            Int?
  floors               Int?
  direction            Direction?    // NORTH, SOUTH, ...
  yearBuilt            Int?
  legalStatus          String?
  amenities            Json?
  nearbyPOIs           Json?
  metroDistanceM       Float?
  projectDevelopmentId String?       // FK to ProjectDevelopment
  furnishing           Furnishing?   // FULLY_FURNISHED, BASIC_FURNISHED, UNFURNISHED
  propertyCondition    PropertyCondition?  // NEW, LIKE_NEW, RENOVATED, USED
  maintenanceFeeVND    BigInt?
  parkingSlots         Int?
  viewType             String[]
  petFriendly          Boolean?
  suitableFor          String[]
  whyThisLocation      String?
  createdAt            DateTime
  updatedAt            DateTime

  listings   Listing[]
  valuations Valuation[]
  media      PropertyMedia[]

  @@index([propertyType])
  @@index([district, city])
  @@index([location], type: Gist)        // PostGIS spatial index
  @@index([district, city, propertyType])
}

Listing Model

model Listing {
  id               String
  propertyId       String           // FK
  property         Property         // eager load or lazy
  agentId          String?
  agent            Agent?
  sellerId         String           // FK to User
  seller           User
  transactionType  TransactionType  // SALE, RENT
  status           ListingStatus    // ACTIVE, SOLD, EXPIRED, ...
  priceVND         BigInt           // CHECK > 0
  pricePerM2       Float?
  rentPriceMonthly BigInt?
  commissionPct    Float?
  aiPriceEstimate  BigInt?
  aiConfidence     Float?
  moderationScore  Float?
  viewCount        Int
  saveCount        Int
  inquiryCount     Int
  featuredUntil    DateTime?
  expiresAt        DateTime?
  publishedAt      DateTime?
  createdAt        DateTime
  updatedAt        DateTime

  transactions   Transaction[]
  inquiries      Inquiry[]
  priceHistories PriceHistory[]
  savedByUsers   SavedListing[]

  @@index([status])
  @@index([transactionType])
  @@index([sellerId, status, publishedAt(sort: Desc)])
  @@index([status, publishedAt(sort: Desc)])
  @@index([status, createdAt(sort: Desc)])
}

MarketIndex Model

model MarketIndex {
  id             String
  district       String           // "Quận 1"
  city           String           // "Hồ Chí Minh"
  propertyType   PropertyType     // APARTMENT, VILLA, ...
  period         String           // "2024-Q1"
  medianPrice    BigInt           // Median price in VND
  avgPriceM2     Float            // Average price per m²
  totalListings  Int
  daysOnMarket   Float            // Average days on market
  inventoryLevel Int              // Months of supply
  absorptionRate Float?           // (Sales / Inventory)
  yoyChange      Float?           // Year-over-year change %
  createdAt      DateTime

  @@unique([district, city, propertyType, period])
  @@index([city, period])
}

Valuation Model

model Valuation {
  id               String
  propertyId       String
  property         Property
  valuationDate    DateTime
  estimatedPrice   BigInt
  confidence       Float          // 0-1 score
  method           String         // "AVM_v1", "AVM_v2", "MANUAL"
  features         Json           // Inputs used (bedrooms, area, etc.)
  comparables      Json?          // Comparable properties
  explainers       Json?          // Feature importance / drivers
  createdAt        DateTime

  @@index([propertyId, valuationDate(sort: Desc)])
}

6 SHARED MODULE — CACHE & UTILITIES

Cache Service (apps/api/src/modules/shared/infrastructure/cache.service.ts)

@Injectable()
export class CacheService implements OnModuleInit {
  /**
   * Cache-aside pattern: get from Redis or call loader function.
   * 
   * @param key — Redis key (e.g., "cache:market:district:ho_chi_minh:2024-q1")
   * @param loader — Async function to compute value if not cached
   * @param ttlSeconds — How long to keep in Redis
   * @param resource — Prometheus metric label (e.g., "district_stats")
   * @returns — Cached or freshly computed value
   * 
   * When Redis is down:
   * - Calls loader() directly (graceful degradation)
   * - Increments cache_degradation_total metric
   */
  async getOrSet<T>(
    key: string,
    loader: () => Promise<T>,
    ttlSeconds: number,
    resource: string,
  ): Promise<T>

  /** Invalidate single key */
  async invalidate(key: string): Promise<void>

  /** Invalidate all keys matching prefix (e.g., "cache:market:district:*") */
  async invalidateByPrefix(prefix: string): Promise<void>

  /** Build deterministic cache key */
  static buildKey(prefix: CachePrefix, ...parts: (string | number | undefined)[]): string
}

Cacheable Decorator (apps/api/src/modules/shared/infrastructure/decorators/cacheable.decorator.ts)

/**
 * Declarative caching decorator for query handlers.
 * 
 * Usage:
 * @Cacheable({
 *   prefix: CachePrefix.MARKET_DISTRICT,
 *   ttl: CacheTTL.DISTRICT_STATS,
 *   resource: 'district_stats',
 *   keyFrom: (query) => [query.city, query.period]
 * })
 * async execute(query: GetDistrictStatsQuery): Promise<DistrictStatsDto> { ... }
 */
export interface CacheableOptions {
  prefix: CachePrefix;
  ttl: (typeof CacheTTL)[keyof typeof CacheTTL];
  resource: string;
  keyFrom?: (...args: unknown[]) => (string | number | undefined)[];  // Extract cache key parts
}

export function Cacheable(options: CacheableOptions): MethodDecorator

Cache Meta Interceptor (analytics/presentation/interceptors/cache-meta.interceptor.ts)

/**
 * Wraps all responses with cache freshness metadata.
 * 
 * Applied at controller class level:
 * @UseInterceptors(CacheMetaInterceptor)
 * @Controller('analytics')
 * 
 * Transforms: T => { data: T; cacheMeta: { cachedAt, nextRefreshAt, source } }
 */
@Injectable()
export class CacheMetaInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<WithCacheMeta<unknown>>
}

export interface WithCacheMeta<T> {
  data: T;
  cacheMeta: {
    cachedAt: string | null;      // ISO 8601 timestamp
    nextRefreshAt: string | null;  // When cache expires
    source: 'cache' | 'fresh';     // Was it cached?
  };
}

Cache Meta Storage (apps/api/src/modules/shared/infrastructure/cache-meta.store.ts)

// AsyncLocalStorage for tracking cache metadata per-request
export const cacheMetaStorage = new AsyncLocalStorage<{ meta: CacheMeta | null }>();

// Stores: { cachedAt: string | null, nextRefreshAt: string | null, source: 'cache' | 'fresh' }

Shared Module Exports (apps/api/src/modules/shared/index.ts)

Key exports for analytics module:

// Cache
export { CacheService, CacheTTL, CachePrefix } from './infrastructure/cache.service';
export { Cacheable, CACHEABLE_METADATA } from './infrastructure/decorators/cacheable.decorator';
export { cacheMetaStorage } from './infrastructure/cache-meta.store';

// Services
export { PrismaService } from './infrastructure/prisma.service';
export { RedisService } from './infrastructure/redis.service';
export { LoggerService } from './infrastructure/logger.service';

// Errors
export { DomainException } from './domain/exceptions/domain.exception';

// Guards
export { EndpointRateLimit, EndpointRateLimitGuard } from './infrastructure/guards/...';
export { JwtAuthGuard } from '@modules/auth';

7 HOW TO ADD A NEW GET ENDPOINT

Example: Add GET /analytics/trending-areas endpoint

Step 1: Create Request DTO

File: apps/api/src/modules/analytics/presentation/dto/get-trending-areas.dto.ts

import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsEnum, Min, Max, Type } from 'class-validator';
import { PropertyType } from '@prisma/client';

export class GetTrendingAreasDto {
  @ApiPropertyOptional({ description: 'City name', example: 'Hồ Chí Minh' })
  @IsOptional()
  @IsString()
  city?: string = 'Hồ Chí Minh';

  @ApiPropertyOptional({ enum: PropertyType, description: 'Filter by property type' })
  @IsOptional()
  @IsEnum(PropertyType)
  propertyType?: PropertyType;

  @ApiPropertyOptional({ description: 'Top N trending areas', example: 10, minimum: 1, maximum: 50 })
  @IsOptional()
  @Type(() => Number)
  @Min(1)
  @Max(50)
  limit?: number = 10;

  @ApiPropertyOptional({ description: 'Period to analyze', example: '7d' })
  @IsOptional()
  @IsString()
  period?: '7d' | '30d' | '90d' = '7d';
}

Step 2: Create Query Class

File: apps/api/src/modules/analytics/application/queries/get-trending-areas/get-trending-areas.query.ts

import { PropertyType } from '@prisma/client';

export class GetTrendingAreasQuery {
  constructor(
    public readonly city: string,
    public readonly propertyType: PropertyType | undefined,
    public readonly limit: number,
    public readonly period: '7d' | '30d' | '90d',
  ) {}
}

Step 3: Create Response DTO

Part of handler file: apps/api/src/modules/analytics/application/queries/get-trending-areas/get-trending-areas.handler.ts

export interface TrendingAreaDto {
  district: string;
  priceChange: number;           // % change
  viewsChange: number;           // % change in views
  newListings: number;
  avgPrice: number;
  trend: 'UP' | 'DOWN' | 'STABLE';
}

export interface GetTrendingAreasDto {
  city: string;
  period: string;
  areas: TrendingAreaDto[];
  cachedAt: string | null;
  nextRefreshAt: string | null;
}

Step 4: Create Query Handler

File: apps/api/src/modules/analytics/application/queries/get-trending-areas/get-trending-areas.handler.ts

import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import {
  Cacheable,
  CachePrefix,
  CacheTTL,
  DomainException,
  LoggerService,
  PrismaService,
} from '@modules/shared';
import { type PropertyType, ListingStatus, Prisma } from '@prisma/client';
import { GetTrendingAreasQuery } from './get-trending-areas.query';

export interface TrendingAreaDto {
  district: string;
  priceChange: number;
  viewsChange: number;
  newListings: number;
  avgPrice: number;
  trend: 'UP' | 'DOWN' | 'STABLE';
}

export interface GetTrendingAreasDto {
  city: string;
  period: string;
  areas: TrendingAreaDto[];
  cachedAt: string | null;
  nextRefreshAt: string | null;
}

@QueryHandler(GetTrendingAreasQuery)
export class GetTrendingAreasHandler implements IQueryHandler<GetTrendingAreasQuery> {
  constructor(
    private readonly prisma: PrismaService,
    private readonly logger: LoggerService,
  ) {}

  @Cacheable({
    prefix: CachePrefix.TRENDING_AREAS,
    ttl: CacheTTL.TRENDING_AREAS,
    resource: 'trending_areas',
    keyFrom: (query: unknown) => {
      const q = query as GetTrendingAreasQuery;
      return [q.city, q.propertyType, q.period, q.limit];
    },
  })
  async execute(query: GetTrendingAreasQuery): Promise<GetTrendingAreasDto> {
    try {
      const periodDays = query.period === '7d' ? 7 : query.period === '30d' ? 30 : 90;
      const now = new Date();
      const periodStart = new Date(now.getTime() - periodDays * 24 * 60 * 60 * 1000);
      const previousPeriodStart = new Date(periodStart.getTime() - periodDays * 24 * 60 * 60 * 1000);

      // Fetch current period stats by district
      const currentPeriodStats = await this.prisma.$queryRaw<
        { district: string; avg_price: number; count: number; views: number }[]
      >`
        SELECT 
          p.district,
          AVG(l."priceVND")::float AS avg_price,
          COUNT(*)::int AS count,
          SUM(l."viewCount")::int AS views
        FROM "Listing" l
        JOIN "Property" p ON p.id = l."propertyId"
        WHERE l.status = 'ACTIVE'
          AND LOWER(p.city) = LOWER(${query.city})
          ${query.propertyType ? Prisma.sql`AND p."propertyType" = ${query.propertyType}::"PropertyType"` : Prisma.empty}
          AND l."publishedAt" >= ${periodStart}
        GROUP BY p.district
      `;

      // Fetch previous period stats
      const previousPeriodStats = await this.prisma.$queryRaw<
        { district: string; avg_price: number; count: number; views: number }[]
      >`
        SELECT 
          p.district,
          AVG(l."priceVND")::float AS avg_price,
          COUNT(*)::int AS count,
          SUM(l."viewCount")::int AS views
        FROM "Listing" l
        JOIN "Property" p ON p.id = l."propertyId"
        WHERE l.status = 'ACTIVE'
          AND LOWER(p.city) = LOWER(${query.city})
          ${query.propertyType ? Prisma.sql`AND p."propertyType" = ${query.propertyType}::"PropertyType"` : Prisma.empty}
          AND l."publishedAt" >= ${previousPeriodStart}
          AND l."publishedAt" < ${periodStart}
        GROUP BY p.district
      `;

      // Calculate trends
      const prevMap = new Map(previousPeriodStats.map(s => [s.district, s]));
      const areas: TrendingAreaDto[] = currentPeriodStats
        .map(curr => {
          const prev = prevMap.get(curr.district);
          const priceChange = prev ? ((curr.avg_price - prev.avg_price) / prev.avg_price) * 100 : 0;
          const viewsChange = prev ? ((curr.views - prev.views) / prev.views) * 100 : 0;

          return {
            district: curr.district,
            priceChange: Math.round(priceChange * 10) / 10,
            viewsChange: Math.round(viewsChange * 10) / 10,
            newListings: curr.count,
            avgPrice: Math.round(curr.avg_price),
            trend: priceChange > 2 ? 'UP' : priceChange < -2 ? 'DOWN' : 'STABLE',
          };
        })
        .sort((a, b) => Math.abs(b.priceChange) - Math.abs(a.priceChange))
        .slice(0, query.limit);

      return {
        city: query.city,
        period: query.period,
        areas,
        cachedAt: null,       // Filled by CacheMetaInterceptor
        nextRefreshAt: null,  // Filled by CacheMetaInterceptor
      };
    } catch (error) {
      if (error instanceof DomainException) throw error;
      this.logger.error(
        `Failed to get trending areas: ${error instanceof Error ? error.message : error}`,
        error instanceof Error ? error.stack : undefined,
        this.constructor.name,
      );
      throw new InternalServerErrorException('Không thể truy vấn khu vực trending. Vui lòng thử lại sau.');
    }
  }
}

Step 5: Register Handler in Module

File: apps/api/src/modules/analytics/analytics.module.ts

import { GetTrendingAreasHandler } from './application/queries/get-trending-areas/get-trending-areas.handler';

const QueryHandlers = [
  // ... existing handlers
  GetTrendingAreasHandler,  // Add here
];

@Module({
  imports: [CqrsModule, ListingsModule, AdminModule, ProjectsModule],
  controllers: [AnalyticsController, AvmController],
  providers: [
    // ...
    ...QueryHandlers,
  ],
  exports: [...],
})
export class AnalyticsModule {}

Step 6: Add Controller Method

File: apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts

import { GetTrendingAreasQuery } from '../../application/queries/get-trending-areas/get-trending-areas.query';
import { type GetTrendingAreasDto } from '../../application/queries/get-trending-areas/get-trending-areas.handler';
import { GetTrendingAreasDto as GetTrendingAreasDtoRequest } from '../dto/get-trending-areas.dto';

// In AnalyticsController class:

@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Get('trending-areas')
@ApiOperation({
  summary: 'Get trending districts with highest price/view changes',
  description: 'Returns top N districts by price trend, comparison to previous period',
})
@ApiResponse({ status: 200, description: 'Trending areas retrieved' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
async getTrendingAreas(@Query() dto: GetTrendingAreasDtoRequest): Promise<GetTrendingAreasDto> {
  return this.queryBus.execute(
    new GetTrendingAreasQuery(
      dto.city || 'Hồ Chí Minh',
      dto.propertyType,
      dto.limit || 10,
      dto.period || '7d',
    ),
  );
}

Step 7: Update DTOs Index Export

File: apps/api/src/modules/analytics/presentation/dto/index.ts

export * from './get-trending-areas.dto';

Summary: Files to Create/Modify

File Action
presentation/dto/get-trending-areas.dto.ts Create
application/queries/get-trending-areas/get-trending-areas.query.ts Create
application/queries/get-trending-areas/get-trending-areas.handler.ts Create
analytics.module.ts Modify — add handler
presentation/controllers/analytics.controller.ts Modify — add method
presentation/dto/index.ts Modify — export

📌 KEY CONVENTIONS TO FOLLOW

  1. Query Handler Pattern:

    • Use @Cacheable decorator OR cache.getOrSet() method
    • Always wrap in try-catch, throw InternalServerErrorException on error
    • Return DTO with cachedAt/nextRefreshAt set to null (interceptor fills them)
  2. Cache Keys:

    • Use CacheService.buildKey(prefix, ...parts)
    • Make keys deterministic (same params = same key)
    • Lowercase strings, replace spaces with underscores
  3. Caching TTLs:

    • Dashboard tiles: 300s (5 min)
    • Heavy aggregations: 1800s (30 min)
    • Historical data: 3600s+ (1 hour+)
    • Dynamic predictions: NO CACHE (TTL = 0)
  4. Guards & Decorators:

    • All endpoints use @UseGuards(JwtAuthGuard, QuotaGuard)
    • POST with heavy load use @EndpointRateLimit({ limit: 10, windowSeconds: 60 })
    • Use @RequireQuota('analytics_queries') to meter usage
  5. Response Format:

    • With @UseInterceptors(CacheMetaInterceptor): { data: T; cacheMeta: ... }
    • Without interceptor: plain T
  6. Errors:

    • Validation errors: Caught by class-validator
    • Business logic: Throw DomainException
    • System errors: Throw InternalServerErrorException with i18n message

🎯 TESTING PATTERNS

// Query handler test
describe('GetTrendingAreasHandler', () => {
  let handler: GetTrendingAreasHandler;
  let prisma: jest.Mocked<PrismaService>;
  let logger: jest.Mocked<LoggerService>;

  beforeEach(() => {
    prisma = createMock<PrismaService>();
    logger = createMock<LoggerService>();
    handler = new GetTrendingAreasHandler(prisma, logger);
  });

  it('should return trending areas from cache on hit', async () => {
    const query = new GetTrendingAreasQuery('Hồ Chí Minh', undefined, 10, '7d');
    const result = await handler.execute(query);

    expect(result.areas).toBeDefined();
    expect(result.areas.length).toBeLessThanOrEqual(10);
  });
});