From ecb217cf5efd1ad3595f4eb41c5f1f006050338c Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 21 Apr 2026 05:20:39 +0700 Subject: [PATCH] feat(analytics): add Redis 24h cache to neighborhood score endpoint (TEC-3072) The GET /neighborhoods/:district/score handler was missing Redis caching. Adds NEIGHBORHOOD_SCORE CachePrefix + CacheTTL (24h) and wires CacheService.getOrSet into GetNeighborhoodScoreHandler. Updates handler tests to cover cache behavior. Co-Authored-By: Paperclip --- .../get-neighborhood-score.handler.spec.ts | 24 ++++++++++++++++++- .../get-neighborhood-score.handler.ts | 23 ++++++++++++++---- .../shared/infrastructure/cache.service.ts | 4 ++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/api/src/modules/analytics/application/__tests__/get-neighborhood-score.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-neighborhood-score.handler.spec.ts index 7647cd5..43d40f4 100644 --- a/apps/api/src/modules/analytics/application/__tests__/get-neighborhood-score.handler.spec.ts +++ b/apps/api/src/modules/analytics/application/__tests__/get-neighborhood-score.handler.spec.ts @@ -1,4 +1,5 @@ import { type INeighborhoodScoreService, type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service'; +import { type CacheService } from '@modules/shared/infrastructure/cache.service'; import { GetNeighborhoodScoreHandler } from '../queries/get-neighborhood-score/get-neighborhood-score.handler'; import { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query'; @@ -19,13 +20,21 @@ const sampleScore: NeighborhoodScoreResult = { describe('GetNeighborhoodScoreHandler', () => { let handler: GetNeighborhoodScoreHandler; let mockService: { [K in keyof INeighborhoodScoreService]: ReturnType }; + let mockCache: { getOrSet: ReturnType }; beforeEach(() => { mockService = { getScore: vi.fn(), calculateAndSave: vi.fn(), }; - handler = new GetNeighborhoodScoreHandler(mockService as any); + // Bypass cache: call the loader directly + mockCache = { + getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()), + }; + handler = new GetNeighborhoodScoreHandler( + mockService as any, + mockCache as unknown as CacheService, + ); }); it('returns cached score when available', async () => { @@ -48,4 +57,17 @@ describe('GetNeighborhoodScoreHandler', () => { expect(mockService.getScore).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh'); expect(mockService.calculateAndSave).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh'); }); + + it('uses CacheService.getOrSet with 24h TTL', async () => { + mockService.getScore.mockResolvedValue(sampleScore); + + await handler.execute(new GetNeighborhoodScoreQuery('Quận 1', 'Hồ Chí Minh')); + + expect(mockCache.getOrSet).toHaveBeenCalledWith( + expect.stringContaining('neighborhood_score'), + expect.any(Function), + 86400, + 'neighborhood-score', + ); + }); }); diff --git a/apps/api/src/modules/analytics/application/queries/get-neighborhood-score/get-neighborhood-score.handler.ts b/apps/api/src/modules/analytics/application/queries/get-neighborhood-score/get-neighborhood-score.handler.ts index 3db2811..ab3e18e 100644 --- a/apps/api/src/modules/analytics/application/queries/get-neighborhood-score/get-neighborhood-score.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-neighborhood-score/get-neighborhood-score.handler.ts @@ -1,5 +1,6 @@ import { Inject } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; import { NEIGHBORHOOD_SCORE_SERVICE, type INeighborhoodScoreService, @@ -12,13 +13,27 @@ export class GetNeighborhoodScoreHandler implements IQueryHandler { - // Return cached score if available, otherwise calculate - const existing = await this.scoreService.getScore(query.district, query.city); - if (existing) return existing; + const cacheKey = CacheService.buildKey( + CachePrefix.NEIGHBORHOOD_SCORE, + query.district, + query.city, + ); - return this.scoreService.calculateAndSave(query.district, query.city); + return this.cache.getOrSet( + cacheKey, + async () => { + // Return cached DB score if available, otherwise calculate + const existing = await this.scoreService.getScore(query.district, query.city); + if (existing) return existing; + + return this.scoreService.calculateAndSave(query.district, query.city); + }, + CacheTTL.NEIGHBORHOOD_SCORE, + 'neighborhood-score', + ); } } diff --git a/apps/api/src/modules/shared/infrastructure/cache.service.ts b/apps/api/src/modules/shared/infrastructure/cache.service.ts index 7a23c6c..be1018c 100644 --- a/apps/api/src/modules/shared/infrastructure/cache.service.ts +++ b/apps/api/src/modules/shared/infrastructure/cache.service.ts @@ -45,6 +45,8 @@ export const CacheTTL = { MARKET_HISTORY: 21600, // 6 hours /** AVM valuation estimate per listing — long TTL, model outputs are stable within a day */ VALUATION_LISTING: 86400, // 24 h + /** [TEC-3072] Neighborhood score — 24h TTL, POI data changes infrequently */ + NEIGHBORHOOD_SCORE: 86400, // 24 h } as const; export enum CachePrefix { @@ -67,6 +69,8 @@ export enum CachePrefix { TRENDING_AREAS = 'cache:analytics:trending_areas', PRICE_MOVERS = 'cache:analytics:price_movers', MARKET_HISTORY = 'cache:analytics:market_history', + /** [TEC-3072] Neighborhood score per district */ + NEIGHBORHOOD_SCORE = 'cache:analytics:neighborhood_score', } @Injectable()