- 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>
11 KiB
11 KiB
Analytics Module - Quick Reference Card
🏗️ Architecture Stack
PRESENTATION (controllers) → APPLICATION (CQRS) → DOMAIN (entities/services) → INFRASTRUCTURE (data access)
Files:
- Controllers:
presentation/controllers/*.controller.ts - DTOs:
presentation/dto/*.dto.ts - Queries/Commands:
application/queries/*.query.ts+*.handler.ts - Repositories (interface):
domain/repositories/*.ts - Repositories (impl):
infrastructure/repositories/prisma-*.repository.ts
📍 Key File Paths
ANALYTICS MODULE ROOT
└── apps/api/src/modules/analytics/
CONTROLLERS
├── analytics.controller.ts (GET/POST /analytics/*)
└── avm.controller.ts (GET/POST /avm/*)
QUERY HANDLERS (14+ queries)
├── get-price-trend/
├── get-heatmap/
├── get-market-report/
├── get-district-stats/
├── get-valuation/
├── predict-valuation/
├── batch-valuation/
├── industrial-valuation/
├── get-neighborhood-score/
├── get-nearby-pois/
├── get-listing-ai-advice/ (Claude)
├── get-project-ai-advice/ (Claude)
├── valuation-history/
├── valuation-comparison/
└── valuation-explanation/
REPOSITORIES (abstraction)
├── domain/repositories/market-index.repository.ts
└── domain/repositories/valuation.repository.ts
REPOSITORIES (Prisma impl)
├── infrastructure/repositories/prisma-market-index.repository.ts
└── infrastructure/repositories/prisma-valuation.repository.ts
SERVICES
├── infrastructure/services/http-avm.service.ts (→ Python AI)
├── infrastructure/services/prisma-avm.service.ts (fallback)
├── infrastructure/services/neighborhood-score.service.ts
└── infrastructure/services/ai-service.client.ts (Claude)
SHARED GUARDS & DECORATORS
├── @EndpointRateLimit({limit, windowSeconds, keyStrategy})
├── @RequireQuota('analytics_queries')
├── @UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
└── /modules/shared/infrastructure/guards/
🔐 Guards & Decorators Stack
@ApiBearerAuth('JWT')
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
async handler() { }
Order matters:
EndpointRateLimitGuard— Redis sliding-window rate limitJwtAuthGuard— Verify JWT tokenQuotaGuard— Check subscription quota
💾 Cache Patterns
Cache-Aside Pattern
return this.cache.getOrSet(
cacheKey, // Unique cache key
async () => { /* loader */ }, // Function to load data if miss
CacheTTL.MARKET_DATA, // TTL in seconds (1800 = 30 min)
'price_trend' // Metric label for Prometheus
);
Cache Key Building
CacheService.buildKey(
CachePrefix.MARKET_TREND, // Prefix enum
query.district, // Key component 1
query.city, // Key component 2
query.propertyType, // Key component 3
query.periods?.join(',') // Key component N
)
// Result: "cache:market:trend:Quận 1:Hồ Chí Minh:APARTMENT:2024-Q1,2024-Q2"
Useful TTLs
CacheTTL.MARKET_DATA = 1800 (30 min, price trends)
CacheTTL.MARKET_REPORT = 900 (15 min, summaries)
CacheTTL.HEATMAP = 300 (5 min, heatmaps)
CacheTTL.DISTRICT_STATS = 300 (5 min, stats)
CacheTTL.LISTING_DETAIL = 300 (5 min, detail pages)
CacheTTL.SEARCH_RESULTS = 120 (2 min, search results)
CacheTTL.REFERENCE_DATA = 86400 (24 hours, static data)
Cache Prefixes for Analytics
CachePrefix.MARKET_REPORT "cache:market:report"
CachePrefix.MARKET_TREND "cache:market:trend"
CachePrefix.MARKET_HEATMAP "cache:market:heatmap"
CachePrefix.MARKET_DISTRICT "cache:market:district"
CachePrefix.VALUATION "cache:valuation"
🗃️ Prisma Models (Analytics-Related)
Property
model Property {
propertyType PropertyType // APARTMENT, HOUSE, LAND, COMMERCIAL
status PropertyStatus // ACTIVE, SOLD, RENTED
areaM2 Float?
bedrooms Int?
bathrooms Int?
district String
city String
location geometry(Point) // PostGIS
createdAt DateTime
updatedAt DateTime
}
Listing (with Analytics Fields)
model Listing {
priceVND BigInt // Main price
pricePerM2 Float? // Derived for analytics
transactionType TransactionType // BUY_SELL, RENT
status ListingStatus // DRAFT, ACTIVE, SOLD, etc.
// AI Valuation
aiPriceEstimate BigInt?
aiConfidence Float?
// Tracking
viewCount Int
saveCount Int
inquiryCount Int
publishedAt DateTime?
createdAt DateTime
updatedAt DateTime
}
MarketIndex (Pre-calculated)
model MarketIndex {
district String
city String
propertyType PropertyType
period String // "2024-Q1" or "2024-04"
medianPrice BigInt
avgPriceM2 Float
totalListings Int
daysOnMarket Int
inventoryLevel Int
absorptionRate Float?
yoyChange Float?
@@unique([district, city, propertyType, period])
}
Valuation
model Valuation {
propertyId String
estimatedPrice BigInt
confidence Float // 0-1
drivers Json? // Key price drivers
comparables Json? // Similar properties
explanation String?
model String // "v1" or "v2"
}
📊 CQRS Handler Pattern
Query Class
// application/queries/get-price-trend/get-price-trend.query.ts
export class GetPriceTrendQuery {
constructor(
public readonly district: string,
public readonly city: string,
public readonly propertyType: PropertyType,
public readonly periods: string[],
) {}
}
Handler
// application/queries/get-price-trend/get-price-trend.handler.ts
@QueryHandler(GetPriceTrendQuery)
export class GetPriceTrendHandler implements IQueryHandler<GetPriceTrendQuery> {
constructor(
@Inject(MARKET_INDEX_REPOSITORY)
private readonly repo: IMarketIndexRepository,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(query: GetPriceTrendQuery): Promise<PriceTrendDto> {
try {
const cacheKey = CacheService.buildKey(
CachePrefix.MARKET_TREND,
query.district,
query.city,
query.propertyType,
query.periods?.join(','),
);
return this.cache.getOrSet(
cacheKey,
async () => {
const trend = await this.repo.getPriceTrend(
query.district,
query.city,
query.propertyType,
query.periods,
);
return {
district: query.district,
city: query.city,
propertyType: query.propertyType,
trend
};
},
CacheTTL.MARKET_DATA,
'price_trend',
);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(`Failed...`, error?.stack, this.constructor.name);
throw new InternalServerErrorException('...');
}
}
}
Controller Integration
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Get('price-trend')
async getPriceTrend(@Query() dto: GetPriceTrendDto): Promise<PriceTrendDto> {
return this.queryBus.execute(
new GetPriceTrendQuery(dto.district, dto.city, dto.propertyType, dto.periods)
);
}
🛡️ Error Handling Pattern
async execute(query: Query): Promise<Result> {
try {
// Business logic
return this.cache.getOrSet(cacheKey, loader, ttl, 'metric');
} catch (error) {
// Re-throw domain errors as-is
if (error instanceof DomainException) throw error;
// Log and wrap unexpected errors
this.logger.error(
`Failed to process: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
// Return user-friendly message
throw new InternalServerErrorException('Không thể xử lý yêu cầu.');
}
}
Exception Hierarchy
DomainException (base)
├── NotFoundException
├── ValidationException
├── ConflictException
├── UnauthorizedException
└── ForbiddenException
🎯 Common Endpoints
# Market Analytics
GET /analytics/market-report?city=...&period=...&propertyType=...
GET /analytics/price-trend?district=...&city=...&propertyType=...&periods=...
GET /analytics/heatmap?city=...&period=...
GET /analytics/district-stats?city=...&period=...
# Property Valuation (AVM)
GET /analytics/valuation?propertyId=... OR ?latitude=...&longitude=...&areaM2=...
POST /analytics/valuation (form input)
POST /analytics/valuation/batch (1-50 properties)
GET /analytics/valuation/history/:id
POST /analytics/valuation/compare (2-5 properties)
# AVM Endpoints (alias routes)
POST /avm/batch
GET /avm/history/:propertyId
GET /avm/compare?ids=...
GET /avm/explain?valuationId=...
POST /avm/industrial
# Neighborhood & Location
GET /analytics/neighborhoods/:district/score
GET /analytics/pois/nearby?lat=...&lng=...&radius=...&limit=...
# AI Advice (Claude)
POST /analytics/listings/:id/ai-advice
POST /analytics/projects/:id/ai-advice
📋 Dependency Injection
Module Providers
@Module({
providers: [
// Repositories (abstraction → implementation)
{ provide: MARKET_INDEX_REPOSITORY, useClass: PrismaMarketIndexRepository },
{ provide: VALUATION_REPOSITORY, useClass: PrismaValuationRepository },
// Services (fallback pattern)
PrismaAVMService,
{ provide: AVM_SERVICE, useClass: HttpAVMService }, // Tries HTTP first
// All handlers
...CommandHandlers,
...QueryHandlers,
...EventHandlers,
],
exports: [
MARKET_INDEX_REPOSITORY,
VALUATION_REPOSITORY,
AVM_SERVICE,
],
})
Injection Pattern
constructor(
@Inject(MARKET_INDEX_REPOSITORY)
private readonly repo: IMarketIndexRepository,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
✅ Key Conventions
| Aspect | Convention |
|---|---|
| Query File | get-price-trend.query.ts |
| Handler File | get-price-trend.handler.ts |
| Class Names | GetPriceTrendQuery, GetPriceTrendHandler, PriceTrendDto |
| Cache Key | CacheService.buildKey(CachePrefix.*, ...params) |
| Cache TTL | Use CacheTTL.* constants |
| Metric Label | Lowercase, underscore-separated: 'price_trend' |
| Rate Limit | { limit: N, windowSeconds: 60, keyStrategy: 'user' | 'ip' } |
| Exception | Catch DomainException separately; wrap others |
| BigInt in JSON | Always stringify: .toString() |
| Shared Services | Import from @modules/shared |