# 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:**
```ts
// .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:**
```ts
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:**
```ts
@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**
```ts
@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**
```ts
@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`)
```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**
```ts
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

```ts
// 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)

```ts
// 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):**
```ts
// Just let Redis expire the key after TTL
await cache.getOrSet(key, loader, 300, 'resource');  // 5 min
```

**2. Prefix-based (manual invalidation):**
```ts
// 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):**
```ts
// 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):**
```ts
// get-district-stats.query.ts
export class GetDistrictStatsQuery {
  constructor(public readonly city: string, public readonly period: string) {}
}
```

**Handler (with decorator caching):**
```ts
// 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:**
```ts
// Handler also exports response type
export interface DistrictStatsDto {
  city: string;
  period: string;
  districts: DistrictStatsResult[];  // From repository interface
}
```

**Controller:**
```ts
@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:**
```ts
export class GetMarketSnapshotDto {
  @ApiPropertyOptional() city?: string;
  @ApiPropertyOptional({ enum: PropertyType }) propertyType?: PropertyType;
}
```

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

**Handler (manual caching):**
```ts
@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:**
```ts
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):**
```json
{
  "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):**
```ts
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:**
```ts
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):**
```ts
@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:**
```ts
@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
```prisma
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
```prisma
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
```prisma
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
```prisma
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`)

```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`)

```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`)

```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`)

```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:
```ts
// 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`

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

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

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

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

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

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

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

```ts
// 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);
  });
});
```

---

