feat(analytics): add cacheMeta to all /analytics/* and /avm/* responses (TEC-3056)

- Add CacheMetaStore (AsyncLocalStorage) in shared/infrastructure so
  cache metadata can propagate across async call stacks per-request
- Extend CacheService.getOrSet to store { __v, cachedAt, ttlSeconds }
  envelopes in Redis; reads back envelope to compute nextRefreshAt.
  Legacy plain-JSON entries are served transparently (cachedAt: null)
- Add CacheMetaInterceptor that wraps every analytics response as
  { data: T, cacheMeta: { cachedAt, nextRefreshAt, source } } using
  the per-request ALS store populated by CacheService
- Apply @UseInterceptors(CacheMetaInterceptor) on both
  AnalyticsController and AvmController (class-level)
- Update cache.service.spec.ts to expect envelope format on write
- Add cache-meta.interceptor.spec.ts with 6 tests covering market-report,
  price-trend, heatmap endpoints, cache-hit path, and ALS isolation
- Add analytics module README documenting the pattern for future devs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 02:18:28 +07:00
parent 641e91f4d4
commit a70db64da1
9 changed files with 359 additions and 6 deletions

View File

@@ -0,0 +1,98 @@
# Analytics Module
Vietnamese real estate analytics endpoints: market reports, price trends, heatmaps, district stats, AVM (property valuation), neighborhood scores, POIs, AI-powered listing/project advice.
---
## Cache Metadata Pattern
All `/analytics/*` and `/avm/*` responses are **automatically wrapped** by `CacheMetaInterceptor` with a `cacheMeta` field that tells the frontend how fresh the data is.
### Response shape
```json
{
"data": { /* original payload */ },
"cacheMeta": {
"cachedAt": "2026-04-21T10:00:00.000Z",
"nextRefreshAt": "2026-04-21T10:15:00.000Z",
"source": "cache"
}
}
```
| Field | Type | Description |
|---|---|---|
| `cachedAt` | `string \| null` | ISO-8601 timestamp when the cache entry was written. `null` for legacy entries or when Redis is unavailable. |
| `nextRefreshAt` | `string \| null` | ISO-8601 timestamp when the entry will expire. Computed as `cachedAt + ttlSeconds`. `null` when `cachedAt` is null. |
| `source` | `"cache" \| "fresh"` | `"cache"` = data served from Redis; `"fresh"` = freshly fetched from DB/AI. |
### Frontend usage
Use `cacheMeta` to show a "Cập nhật lúc..." badge or tooltip:
```tsx
const label = cacheMeta.cachedAt
? `Cập nhật lúc ${new Date(cacheMeta.cachedAt).toLocaleTimeString('vi-VN')}`
: 'Dữ liệu mới nhất';
```
### How it works (for backend devs)
Three components cooperate:
1. **`CacheMetaStore`** (`shared/infrastructure/cache-meta.store.ts`)
An `AsyncLocalStorage<{ meta: CacheMeta | null }>` that lives for the duration of a single HTTP request. Provides request isolation so concurrent requests never share metadata.
2. **`CacheService.getOrSet`** (`shared/infrastructure/cache.service.ts`)
Cache entries are now stored as JSON envelopes `{ __v: data, cachedAt, ttlSeconds }`.
On each call, `getOrSet` writes the resolved metadata into the ALS store:
- **Cache hit** → reads `cachedAt`/`ttlSeconds` from the stored envelope, computes `nextRefreshAt`, writes `source: "cache"`.
- **Cache miss / fresh** → writes `cachedAt = now`, computes `nextRefreshAt`, writes `source: "fresh"`.
- **Redis unavailable** → writes `{ cachedAt: null, nextRefreshAt: null, source: "fresh" }`.
3. **`CacheMetaInterceptor`** (`analytics/presentation/interceptors/cache-meta.interceptor.ts`)
Applied at controller class level via `@UseInterceptors(CacheMetaInterceptor)`.
Wraps each response with the ALS-sourced `cacheMeta` after the handler resolves.
### Adding the pattern to a new controller
```ts
import { UseInterceptors } from '@nestjs/common';
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
@UseInterceptors(CacheMetaInterceptor)
@Controller('my-endpoint')
export class MyController { ... }
```
No other changes needed — `CacheService.getOrSet` handles metadata population automatically.
### Legacy cache entries
Entries written by previous versions of `CacheService` (plain JSON, no `__v` envelope) are still served correctly. `cacheMeta` will have `cachedAt: null` and `nextRefreshAt: null` for these entries.
---
## Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/analytics/market-report` | JWT + Quota | Market report per city/period |
| GET | `/analytics/price-trend` | JWT + Quota | Price trend per district |
| GET | `/analytics/heatmap` | JWT + Quota | Price heatmap |
| GET | `/analytics/district-stats` | JWT + Quota | District statistics |
| GET | `/analytics/valuation` | JWT + Quota | AVM property valuation |
| POST | `/analytics/valuation` | JWT + Quota + Rate limit | AVM from manual input |
| POST | `/analytics/valuation/batch` | JWT + Quota + Rate limit | Batch AVM (up to 50) |
| GET | `/analytics/valuation/history/:propertyId` | JWT + Quota | Valuation history |
| POST | `/analytics/valuation/compare` | JWT + Quota + Rate limit | Side-by-side comparison |
| GET | `/analytics/neighborhoods/:district/score` | Public | Neighborhood score |
| GET | `/analytics/pois/nearby` | Public | Nearby POIs |
| POST | `/analytics/listings/:id/ai-advice` | JWT | Claude AI advice for listing |
| POST | `/analytics/projects/:id/ai-advice` | JWT | Claude AI advice for project |
| POST | `/avm/batch` | JWT + Quota + Rate limit | AVM controller batch |
| GET | `/avm/history/:propertyId` | JWT + Quota | AVM controller history |
| GET | `/avm/compare` | JWT + Quota + Rate limit | AVM controller compare |
| GET | `/avm/explain` | JWT + Quota | Valuation explanation |
| POST | `/avm/industrial` | JWT + Quota + Rate limit | Industrial rent estimate |