- 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>
410 lines
11 KiB
Markdown
410 lines
11 KiB
Markdown
# 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
|
|
|
|
```typescript
|
|
@ApiBearerAuth('JWT')
|
|
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
|
|
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
|
|
@RequireQuota('analytics_queries')
|
|
async handler() { }
|
|
```
|
|
|
|
**Order matters:**
|
|
1. `EndpointRateLimitGuard` — Redis sliding-window rate limit
|
|
2. `JwtAuthGuard` — Verify JWT token
|
|
3. `QuotaGuard` — Check subscription quota
|
|
|
|
---
|
|
|
|
## 💾 Cache Patterns
|
|
|
|
### Cache-Aside Pattern
|
|
```typescript
|
|
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
|
|
```typescript
|
|
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
|
|
```prisma
|
|
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)
|
|
```prisma
|
|
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)
|
|
```prisma
|
|
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
|
|
```prisma
|
|
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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
@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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```bash
|
|
# 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
|
|
```typescript
|
|
@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
|
|
```typescript
|
|
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` |
|
|
|