- 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>
37 KiB
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,@Postreceive DTOs - DTOs use
class-validatorfor 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:
- Client calls endpoint
- Query handler calls
cache.getOrSet(key, loader, ttl, resource) CacheService.getOrSet():- Tries to get from Redis
- If HIT: returns cached value, increments
cache_hit_totalmetric - If MISS or Redis down: calls
loader()function, stores result in Redis, incrementscache_miss_total
- 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()callsloader()directly- Increments
cache_degradation_totalmetric - 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
-
Query Handler Pattern:
- Use
@Cacheabledecorator ORcache.getOrSet()method - Always wrap in try-catch, throw
InternalServerErrorExceptionon error - Return DTO with
cachedAt/nextRefreshAtset to null (interceptor fills them)
- Use
-
Cache Keys:
- Use
CacheService.buildKey(prefix, ...parts) - Make keys deterministic (same params = same key)
- Lowercase strings, replace spaces with underscores
- Use
-
Caching TTLs:
- Dashboard tiles: 300s (5 min)
- Heavy aggregations: 1800s (30 min)
- Historical data: 3600s+ (1 hour+)
- Dynamic predictions: NO CACHE (TTL = 0)
-
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
- All endpoints use
-
Response Format:
- With
@UseInterceptors(CacheMetaInterceptor):{ data: T; cacheMeta: ... } - Without interceptor: plain
T
- With
-
Errors:
- Validation errors: Caught by class-validator
- Business logic: Throw
DomainException - System errors: Throw
InternalServerErrorExceptionwith 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);
});
});