Several fixes discovered while smoke-testing the homepage under the new
port layout (web 3200 / api 3201) to avoid clashing with a sibling project:
- analytics-api: add `unwrap<T>()` helper for the `{ data, cacheMeta }`
envelope the backend CacheMetaInterceptor appends to every
`/analytics/*` response. Apply to all 9 analytics methods. Without this
`data.activeCount` (etc.) were `undefined`, crashing KpiStrip with
`TypeError: Cannot read properties of undefined (reading 'toLocaleString')`.
- public page: hard-coded `city = 'Ho Chi Minh'` returned 0 rows because
the DB stores `'Hồ Chí Minh'` and the SQL filter is case-insensitive but
not diacritic-insensitive. Use the accented spelling.
- use-analytics hooks: add `useAuthedAnalytics()` gate so unauthenticated
visitors on public routes no longer fire 401s from analytics queries.
- next.config.js CSP: add localhost:3200/3201 (http + ws) to connect-src so
the web origin can reach the relocated API. Without this fetches hit
`TypeError: Failed to fetch` on login.
- .claude/launch.json + package.json: web → 3200, api → 3201 (was 3000/3001,
conflicting with the sibling psyforge project also using 3000).
- Minor follow-ups from parallel QA work on this branch (analytics modules,
notifications gateway, auth test fixtures, trending-areas handler + DTO
+ tests, a few E2E smoke specs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
{
"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:
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:
-
CacheMetaStore(shared/infrastructure/cache-meta.store.ts)
AnAsyncLocalStorage<{ meta: CacheMeta | null }>that lives for the duration of a single HTTP request. Provides request isolation so concurrent requests never share metadata. -
CacheService.getOrSet(shared/infrastructure/cache.service.ts)
Cache entries are now stored as JSON envelopes{ __v: data, cachedAt, ttlSeconds }.
On each call,getOrSetwrites the resolved metadata into the ALS store:- Cache hit → reads
cachedAt/ttlSecondsfrom the stored envelope, computesnextRefreshAt, writessource: "cache". - Cache miss / fresh → writes
cachedAt = now, computesnextRefreshAt, writessource: "fresh". - Redis unavailable → writes
{ cachedAt: null, nextRefreshAt: null, source: "fresh" }.
- Cache hit → reads
-
CacheMetaInterceptor(analytics/presentation/interceptors/cache-meta.interceptor.ts)
Applied at controller class level via@UseInterceptors(CacheMetaInterceptor).
Wraps each response with the ALS-sourcedcacheMetaafter the handler resolves.
Adding the pattern to a new controller
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 |