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:
98
apps/api/src/modules/analytics/README.md
Normal file
98
apps/api/src/modules/analytics/README.md
Normal 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 |
|
||||
@@ -0,0 +1,113 @@
|
||||
import { type ExecutionContext, type CallHandler } from '@nestjs/common';
|
||||
import { of } from 'rxjs';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { cacheMetaStorage } from '@modules/shared';
|
||||
import { CacheMetaInterceptor, type WithCacheMeta } from '../interceptors/cache-meta.interceptor';
|
||||
|
||||
function makeContext(): ExecutionContext {
|
||||
return {} as ExecutionContext;
|
||||
}
|
||||
|
||||
function makeHandler<T>(value: T): CallHandler {
|
||||
return { handle: () => of(value) };
|
||||
}
|
||||
|
||||
describe('CacheMetaInterceptor — analytics endpoints', () => {
|
||||
let interceptor: CacheMetaInterceptor;
|
||||
|
||||
beforeEach(() => {
|
||||
interceptor = new CacheMetaInterceptor();
|
||||
});
|
||||
|
||||
it('market-report: wraps payload with cacheMeta.source=fresh when no cache was hit', async () => {
|
||||
const payload = { city: 'Hồ Chí Minh', period: '2026-Q1', districts: [] };
|
||||
const result = await lastValueFrom(
|
||||
interceptor.intercept(makeContext(), makeHandler(payload)),
|
||||
) as WithCacheMeta<typeof payload>;
|
||||
|
||||
expect(result.data).toEqual(payload);
|
||||
expect(result.cacheMeta).toMatchObject({
|
||||
source: 'fresh',
|
||||
});
|
||||
});
|
||||
|
||||
it('price-trend: wraps payload with cacheMeta.source=fresh when no cache was hit', async () => {
|
||||
const payload = { district: 'Quận 1', city: 'Hồ Chí Minh', propertyType: 'APARTMENT', trend: [] };
|
||||
const result = await lastValueFrom(
|
||||
interceptor.intercept(makeContext(), makeHandler(payload)),
|
||||
) as WithCacheMeta<typeof payload>;
|
||||
|
||||
expect(result.data).toEqual(payload);
|
||||
expect(result.cacheMeta).toMatchObject({
|
||||
source: 'fresh',
|
||||
});
|
||||
});
|
||||
|
||||
it('heatmap: wraps payload with cacheMeta.source=fresh when no cache was hit', async () => {
|
||||
const payload = { city: 'Hồ Chí Minh', period: '2026-Q1', dataPoints: [] };
|
||||
const result = await lastValueFrom(
|
||||
interceptor.intercept(makeContext(), makeHandler(payload)),
|
||||
) as WithCacheMeta<typeof payload>;
|
||||
|
||||
expect(result.data).toEqual(payload);
|
||||
expect(result.cacheMeta).toMatchObject({
|
||||
source: 'fresh',
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces cache-hit meta when store is populated by CacheService', async () => {
|
||||
const cachedAt = '2026-04-21T10:00:00.000Z';
|
||||
const nextRefreshAt = '2026-04-21T10:15:00.000Z';
|
||||
const payload = { city: 'Hồ Chí Minh', period: '2026-Q1', districts: [] };
|
||||
|
||||
// Simulate CacheService populating the store during handler execution
|
||||
const handler: CallHandler = {
|
||||
handle: () => {
|
||||
const store = cacheMetaStorage.getStore();
|
||||
if (store) {
|
||||
store.meta = { cachedAt, nextRefreshAt, source: 'cache' };
|
||||
}
|
||||
return of(payload);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await lastValueFrom(
|
||||
interceptor.intercept(makeContext(), handler),
|
||||
) as WithCacheMeta<typeof payload>;
|
||||
|
||||
expect(result.cacheMeta).toEqual({ cachedAt, nextRefreshAt, source: 'cache' });
|
||||
expect(result.data).toEqual(payload);
|
||||
});
|
||||
|
||||
it('provides null cachedAt/nextRefreshAt for fresh responses', async () => {
|
||||
const result = await lastValueFrom(
|
||||
interceptor.intercept(makeContext(), makeHandler({ ok: true })),
|
||||
) as WithCacheMeta<unknown>;
|
||||
|
||||
expect(result.cacheMeta.cachedAt).toBeNull();
|
||||
expect(result.cacheMeta.nextRefreshAt).toBeNull();
|
||||
});
|
||||
|
||||
it('does not leak meta between concurrent requests (ALS isolation)', async () => {
|
||||
const cachedAt = '2026-04-21T08:00:00.000Z';
|
||||
|
||||
const handler1: CallHandler = {
|
||||
handle: () => {
|
||||
const store = cacheMetaStorage.getStore();
|
||||
if (store) store.meta = { cachedAt, nextRefreshAt: cachedAt, source: 'cache' };
|
||||
return of({ req: 1 });
|
||||
},
|
||||
};
|
||||
const handler2: CallHandler = {
|
||||
handle: () => of({ req: 2 }),
|
||||
};
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
lastValueFrom(interceptor.intercept(makeContext(), handler1)),
|
||||
lastValueFrom(interceptor.intercept(makeContext(), handler2)),
|
||||
]) as [WithCacheMeta<unknown>, WithCacheMeta<unknown>];
|
||||
|
||||
expect(r1.cacheMeta.source).toBe('cache');
|
||||
expect(r2.cacheMeta.source).toBe('fresh');
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiParam } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '@modules/auth';
|
||||
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
|
||||
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
||||
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
|
||||
import { type BatchValuationDto as BatchValuationQueryDto } from '../../application/queries/batch-valuation/batch-valuation.handler';
|
||||
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
|
||||
import { type DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler';
|
||||
@@ -57,6 +59,7 @@ import { ValuationComparisonDto } from '../dto/valuation-comparison.dto';
|
||||
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
|
||||
|
||||
@ApiTags('analytics')
|
||||
@UseInterceptors(CacheMetaInterceptor)
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
@@ -26,9 +27,11 @@ import { AvmCompareQueryDto } from '../dto/avm-compare-query.dto';
|
||||
import { AvmExplainQueryDto } from '../dto/avm-explain-query.dto';
|
||||
import { BatchValuationDto } from '../dto/batch-valuation.dto';
|
||||
import { IndustrialValuationDto } from '../dto/industrial-valuation.dto';
|
||||
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
|
||||
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
|
||||
|
||||
@ApiTags('avm')
|
||||
@UseInterceptors(CacheMetaInterceptor)
|
||||
@Controller('avm')
|
||||
export class AvmController {
|
||||
constructor(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Injectable,
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
type NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { cacheMetaStorage, type CacheMeta } from '@modules/shared';
|
||||
|
||||
/**
|
||||
* Shape appended to every `/analytics/*` response.
|
||||
*/
|
||||
export interface WithCacheMeta<T> {
|
||||
data: T;
|
||||
cacheMeta: CacheMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* NestJS interceptor that:
|
||||
* 1. Creates an AsyncLocalStorage context for the request so CacheService
|
||||
* can populate per-request cache metadata.
|
||||
* 2. After the handler resolves, wraps the response payload with a `cacheMeta`
|
||||
* field describing freshness: `{ cachedAt, nextRefreshAt, source }`.
|
||||
*
|
||||
* Apply at controller class or individual method level:
|
||||
* ```ts
|
||||
* @UseInterceptors(CacheMetaInterceptor)
|
||||
* @Controller('analytics')
|
||||
* export class AnalyticsController { ... }
|
||||
* ```
|
||||
*
|
||||
* Responses are transformed from `T` to `{ data: T; cacheMeta: CacheMeta }`.
|
||||
* When CacheService was not called during the request (e.g. command endpoints),
|
||||
* `cacheMeta` defaults to `{ cachedAt: null, nextRefreshAt: null, source: 'fresh' }`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CacheMetaInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<WithCacheMeta<unknown>> {
|
||||
const store = { meta: null as CacheMeta | null };
|
||||
|
||||
return new Observable((subscriber) => {
|
||||
cacheMetaStorage.run(store, () => {
|
||||
next
|
||||
.handle()
|
||||
.pipe(
|
||||
map((data: unknown) => {
|
||||
const cacheMeta: CacheMeta = store.meta ?? {
|
||||
cachedAt: null,
|
||||
nextRefreshAt: null,
|
||||
source: 'fresh',
|
||||
};
|
||||
return { data, cacheMeta };
|
||||
}),
|
||||
)
|
||||
.subscribe(subscriber);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user