Files
goodgo-platform/apps/api/src/modules/analytics
Ho Ngoc Hai 3a9e44758c fix(web): unwrap CacheMetaInterceptor envelope + dev port migration + homepage diacritic
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>
2026-04-22 16:54:44 +07:00
..

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:

  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

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