# 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 { constructor( private readonly prisma: PrismaService, private readonly cache: CacheService, private readonly logger: LoggerService, ) {} async execute(query: GetMarketSnapshotQuery): Promise { // 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; getDistrictStats(city: string, period: string): Promise; getHeatmap(city: string, period: string): Promise; getPriceTrend(...): Promise; } 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 { 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 { 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 { 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 { constructor( private readonly prisma: PrismaService, private readonly cache: CacheService, private readonly logger: LoggerService, ) {} async execute(query: GetMarketSnapshotQuery): Promise { 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 { // 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: , // 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 { @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 { 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 { 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 { async execute(query: GetMarketSnapshotQuery): Promise { 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 { // Expensive computation: 7+ parallel queries const [activeAgg, medianResult, newListings24h, ...] = await Promise.all([ this.prisma.listing.aggregate({ ... }), this.prisma.$queryRaw(`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 { constructor( @Inject(AVM_SERVICE) private readonly avmService: IAVMService, private readonly logger: LoggerService, ) {} async execute(query: PredictValuationQuery): Promise { // 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 { 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( key: string, loader: () => Promise, ttlSeconds: number, resource: string, ): Promise /** Invalidate single key */ async invalidate(key: string): Promise /** Invalidate all keys matching prefix (e.g., "cache:market:district:*") */ async invalidateByPrefix(prefix: string): Promise /** 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 { ... } */ 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> } export interface WithCacheMeta { 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 { 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 { 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 { 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; let logger: jest.Mocked; beforeEach(() => { prisma = createMock(); logger = createMock(); 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); }); }); ``` ---