docs: consolidate exploration & audit reports under docs/ (TEC-3094)
- 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>
This commit is contained in:
409
docs/explorations/ANALYTICS_QUICK_REFERENCE.md
Normal file
409
docs/explorations/ANALYTICS_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,409 @@
|
||||
# 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` |
|
||||
|
||||
Reference in New Issue
Block a user