diff --git a/apps/api/src/modules/analytics/application/__tests__/get-district-stats.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-district-stats.handler.spec.ts index 898efb5..7ad3519 100644 --- a/apps/api/src/modules/analytics/application/__tests__/get-district-stats.handler.spec.ts +++ b/apps/api/src/modules/analytics/application/__tests__/get-district-stats.handler.spec.ts @@ -1,6 +1,7 @@ import { GetDistrictStatsHandler } from '../queries/get-district-stats/get-district-stats.handler'; import { GetDistrictStatsQuery } from '../queries/get-district-stats/get-district-stats.query'; import { type IMarketIndexRepository, type DistrictStatsResult } from '../../domain/repositories/market-index.repository'; +import { type CacheService } from '@modules/shared/infrastructure/cache.service'; describe('GetDistrictStatsHandler', () => { let handler: GetDistrictStatsHandler; @@ -17,7 +18,8 @@ describe('GetDistrictStatsHandler', () => { getPriceTrend: vi.fn(), getDistrictStats: vi.fn(), }; - handler = new GetDistrictStatsHandler(mockRepo as any); + const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()) } as unknown as CacheService; + handler = new GetDistrictStatsHandler(mockRepo as any, mockCache); }); it('returns district statistics', async () => { diff --git a/apps/api/src/modules/analytics/application/__tests__/get-heatmap.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-heatmap.handler.spec.ts index 14a4212..2f09de3 100644 --- a/apps/api/src/modules/analytics/application/__tests__/get-heatmap.handler.spec.ts +++ b/apps/api/src/modules/analytics/application/__tests__/get-heatmap.handler.spec.ts @@ -1,6 +1,7 @@ import { GetHeatmapHandler } from '../queries/get-heatmap/get-heatmap.handler'; import { GetHeatmapQuery } from '../queries/get-heatmap/get-heatmap.query'; import { type IMarketIndexRepository, type HeatmapDataPoint } from '../../domain/repositories/market-index.repository'; +import { type CacheService } from '@modules/shared/infrastructure/cache.service'; describe('GetHeatmapHandler', () => { let handler: GetHeatmapHandler; @@ -17,7 +18,8 @@ describe('GetHeatmapHandler', () => { getPriceTrend: vi.fn(), getDistrictStats: vi.fn(), }; - handler = new GetHeatmapHandler(mockRepo as any); + const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()) } as unknown as CacheService; + handler = new GetHeatmapHandler(mockRepo as any, mockCache); }); it('returns heatmap data for a city and period', async () => { diff --git a/apps/api/src/modules/analytics/application/__tests__/get-market-report.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-market-report.handler.spec.ts index d76015c..da822fa 100644 --- a/apps/api/src/modules/analytics/application/__tests__/get-market-report.handler.spec.ts +++ b/apps/api/src/modules/analytics/application/__tests__/get-market-report.handler.spec.ts @@ -1,6 +1,7 @@ import { GetMarketReportHandler } from '../queries/get-market-report/get-market-report.handler'; import { GetMarketReportQuery } from '../queries/get-market-report/get-market-report.query'; import { type IMarketIndexRepository, type MarketReportResult } from '../../domain/repositories/market-index.repository'; +import { type CacheService } from '@modules/shared/infrastructure/cache.service'; describe('GetMarketReportHandler', () => { let handler: GetMarketReportHandler; @@ -17,7 +18,8 @@ describe('GetMarketReportHandler', () => { getPriceTrend: vi.fn(), getDistrictStats: vi.fn(), }; - handler = new GetMarketReportHandler(mockRepo as any); + const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()) } as unknown as CacheService; + handler = new GetMarketReportHandler(mockRepo as any, mockCache); }); it('returns market report with district data', async () => { diff --git a/apps/api/src/modules/analytics/application/__tests__/get-price-trend.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-price-trend.handler.spec.ts index 60a5afb..e245f7e 100644 --- a/apps/api/src/modules/analytics/application/__tests__/get-price-trend.handler.spec.ts +++ b/apps/api/src/modules/analytics/application/__tests__/get-price-trend.handler.spec.ts @@ -1,6 +1,7 @@ import { GetPriceTrendHandler } from '../queries/get-price-trend/get-price-trend.handler'; import { GetPriceTrendQuery } from '../queries/get-price-trend/get-price-trend.query'; import { type IMarketIndexRepository, type PriceTrendPoint } from '../../domain/repositories/market-index.repository'; +import { type CacheService } from '@modules/shared/infrastructure/cache.service'; describe('GetPriceTrendHandler', () => { let handler: GetPriceTrendHandler; @@ -17,7 +18,8 @@ describe('GetPriceTrendHandler', () => { getPriceTrend: vi.fn(), getDistrictStats: vi.fn(), }; - handler = new GetPriceTrendHandler(mockRepo as any); + const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()) } as unknown as CacheService; + handler = new GetPriceTrendHandler(mockRepo as any, mockCache); }); it('returns price trend data for a district', async () => { diff --git a/apps/api/src/modules/analytics/application/__tests__/update-market-index.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/update-market-index.handler.spec.ts index e3b58bb..e398042 100644 --- a/apps/api/src/modules/analytics/application/__tests__/update-market-index.handler.spec.ts +++ b/apps/api/src/modules/analytics/application/__tests__/update-market-index.handler.spec.ts @@ -2,6 +2,7 @@ import { UpdateMarketIndexHandler } from '../commands/update-market-index/update import { UpdateMarketIndexCommand } from '../commands/update-market-index/update-market-index.command'; import { type IMarketIndexRepository } from '../../domain/repositories/market-index.repository'; import { MarketIndexEntity } from '../../domain/entities/market-index.entity'; +import { type CacheService } from '@modules/shared/infrastructure/cache.service'; function createExistingEntity(): MarketIndexEntity { return new MarketIndexEntity('idx-1', { @@ -34,7 +35,8 @@ describe('UpdateMarketIndexHandler', () => { getPriceTrend: vi.fn(), getDistrictStats: vi.fn(), }; - handler = new UpdateMarketIndexHandler(mockRepo as any); + const mockCache = { invalidateByPrefix: vi.fn() } as unknown as CacheService; + handler = new UpdateMarketIndexHandler(mockRepo as any, mockCache); }); it('creates a new market index when none exists', async () => { diff --git a/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts b/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts index 37cf865..6d8359f 100644 --- a/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts +++ b/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts @@ -1,5 +1,6 @@ import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; +import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service'; import { UpdateMarketIndexCommand } from './update-market-index.command'; import { MARKET_INDEX_REPOSITORY, @@ -16,6 +17,7 @@ export interface UpdateMarketIndexResult { export class UpdateMarketIndexHandler implements ICommandHandler { constructor( @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + private readonly cache: CacheService, ) {} async execute(command: UpdateMarketIndexCommand): Promise { @@ -37,6 +39,7 @@ export class UpdateMarketIndexHandler implements ICommandHandler { + await Promise.all([ + this.cache.invalidateByPrefix(CachePrefix.MARKET_REPORT), + this.cache.invalidateByPrefix(CachePrefix.MARKET_TREND), + this.cache.invalidateByPrefix(CachePrefix.MARKET_HEATMAP), + this.cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT), + ]); + } } diff --git a/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts b/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts index c0a639a..88c7576 100644 --- a/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts @@ -1,5 +1,6 @@ import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service'; import { GetDistrictStatsQuery } from './get-district-stats.query'; import { MARKET_INDEX_REPOSITORY, @@ -17,15 +18,20 @@ export interface DistrictStatsDto { export class GetDistrictStatsHandler implements IQueryHandler { constructor( @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + private readonly cache: CacheService, ) {} async execute(query: GetDistrictStatsQuery): Promise { - const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period); + const cacheKey = CacheService.buildKey(CachePrefix.MARKET_DISTRICT, query.city, query.period); - return { - city: query.city, - period: query.period, - districts, - }; + return this.cache.getOrSet( + cacheKey, + async () => { + const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period); + return { city: query.city, period: query.period, districts }; + }, + CacheTTL.MARKET_DATA, + 'district_stats', + ); } } diff --git a/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts index 5edd552..7061a35 100644 --- a/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts @@ -1,5 +1,6 @@ import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service'; import { GetHeatmapQuery } from './get-heatmap.query'; import { MARKET_INDEX_REPOSITORY, @@ -17,15 +18,20 @@ export interface HeatmapDto { export class GetHeatmapHandler implements IQueryHandler { constructor( @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + private readonly cache: CacheService, ) {} async execute(query: GetHeatmapQuery): Promise { - const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period); + const cacheKey = CacheService.buildKey(CachePrefix.MARKET_HEATMAP, query.city, query.period); - return { - city: query.city, - period: query.period, - dataPoints, - }; + return this.cache.getOrSet( + cacheKey, + async () => { + const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period); + return { city: query.city, period: query.period, dataPoints }; + }, + CacheTTL.MARKET_DATA, + 'heatmap', + ); } } diff --git a/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts b/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts index 1fdca53..5a07843 100644 --- a/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts @@ -1,5 +1,6 @@ import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service'; import { GetMarketReportQuery } from './get-market-report.query'; import { MARKET_INDEX_REPOSITORY, @@ -17,19 +18,24 @@ export interface MarketReportDto { export class GetMarketReportHandler implements IQueryHandler { constructor( @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + private readonly cache: CacheService, ) {} async execute(query: GetMarketReportQuery): Promise { - const districts = await this.marketIndexRepo.getMarketReport( - query.city, - query.period, - query.propertyType, - ); + const cacheKey = CacheService.buildKey(CachePrefix.MARKET_REPORT, query.city, query.period, query.propertyType); - return { - city: query.city, - period: query.period, - districts, - }; + return this.cache.getOrSet( + cacheKey, + async () => { + const districts = await this.marketIndexRepo.getMarketReport( + query.city, + query.period, + query.propertyType, + ); + return { city: query.city, period: query.period, districts }; + }, + CacheTTL.MARKET_DATA, + 'market_report', + ); } } diff --git a/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts b/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts index 7277890..b484d8a 100644 --- a/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts @@ -1,5 +1,6 @@ import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service'; import { GetPriceTrendQuery } from './get-price-trend.query'; import { MARKET_INDEX_REPOSITORY, @@ -18,21 +19,31 @@ export interface PriceTrendDto { export class GetPriceTrendHandler implements IQueryHandler { constructor( @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + private readonly cache: CacheService, ) {} async execute(query: GetPriceTrendQuery): Promise { - const trend = await this.marketIndexRepo.getPriceTrend( + const cacheKey = CacheService.buildKey( + CachePrefix.MARKET_TREND, query.district, query.city, query.propertyType, - query.periods, + query.periods?.join(','), ); - return { - district: query.district, - city: query.city, - propertyType: query.propertyType, - trend, - }; + return this.cache.getOrSet( + cacheKey, + async () => { + const trend = await this.marketIndexRepo.getPriceTrend( + query.district, + query.city, + query.propertyType, + query.periods, + ); + return { district: query.district, city: query.city, propertyType: query.propertyType, trend }; + }, + CacheTTL.MARKET_DATA, + 'price_trend', + ); } } diff --git a/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts index 976f647..02d7a0b 100644 --- a/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts +++ b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts @@ -1,5 +1,6 @@ import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; import { Inject, NotFoundException } from '@nestjs/common'; +import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service'; import { VerifyKycCommand } from './verify-kyc.command'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; @@ -7,6 +8,7 @@ import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositor export class VerifyKycHandler implements ICommandHandler { constructor( @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + private readonly cache: CacheService, ) {} async execute(command: VerifyKycCommand): Promise { @@ -17,5 +19,7 @@ export class VerifyKycHandler implements ICommandHandler { user.updateKycStatus(command.kycStatus, command.kycData); await this.userRepo.update(user); + + await this.cache.invalidate(CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId)); } } diff --git a/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts index e486600..ca5bf00 100644 --- a/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts +++ b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts @@ -1,5 +1,6 @@ import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { Inject, NotFoundException } from '@nestjs/common'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service'; import { GetProfileQuery } from './get-profile.query'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; @@ -19,24 +20,34 @@ export interface UserProfileDto { export class GetProfileHandler implements IQueryHandler { constructor( @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + private readonly cache: CacheService, ) {} async execute(query: GetProfileQuery): Promise { - const user = await this.userRepo.findById(query.userId); - if (!user) { - throw new NotFoundException('Người dùng không tồn tại'); - } + const cacheKey = CacheService.buildKey(CachePrefix.USER_PROFILE, query.userId); - return { - id: user.id, - email: user.email?.value ?? null, - phone: user.phone.value, - fullName: user.fullName, - avatarUrl: user.avatarUrl, - role: user.role, - kycStatus: user.kycStatus, - isActive: user.isActive, - createdAt: user.createdAt, - }; + return this.cache.getOrSet( + cacheKey, + async () => { + const user = await this.userRepo.findById(query.userId); + if (!user) { + throw new NotFoundException('Người dùng không tồn tại'); + } + + return { + id: user.id, + email: user.email?.value ?? null, + phone: user.phone.value, + fullName: user.fullName, + avatarUrl: user.avatarUrl, + role: user.role, + kycStatus: user.kycStatus, + isActive: user.isActive, + createdAt: user.createdAt, + }; + }, + CacheTTL.USER_PROFILE, + 'user_profile', + ); } } diff --git a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts index 1b3ed26..3b2630f 100644 --- a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts +++ b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts @@ -1,6 +1,7 @@ import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { Inject, BadRequestException } from '@nestjs/common'; import { createId } from '@paralleldrive/cuid2'; +import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service'; import { CreateListingCommand } from './create-listing.command'; import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository'; import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository'; @@ -22,6 +23,7 @@ export class CreateListingHandler implements ICommandHandler { @@ -87,6 +89,8 @@ export class CreateListingHandler implements ICommandHandler { @@ -34,6 +36,11 @@ export class ModerateListingHandler implements ICommandHandler { @@ -32,6 +34,11 @@ export class UpdateListingStatusHandler implements ICommandHandler { constructor( @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + private readonly cache: CacheService, ) {} async execute(query: GetListingQuery): Promise { - const result = await this.listingRepo.findByIdWithProperty(query.listingId); - if (!result) { - throw new NotFoundException('Listing', query.listingId); - } - return result as unknown as ListingDetailDto; + const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId); + + return this.cache.getOrSet( + cacheKey, + async () => { + const result = await this.listingRepo.findByIdWithProperty(query.listingId); + if (!result) { + throw new NotFoundException('Listing', query.listingId); + } + return result as unknown as ListingDetailDto; + }, + CacheTTL.LISTING_DETAIL, + 'listing', + ); } } diff --git a/apps/api/src/modules/metrics/metrics.module.ts b/apps/api/src/modules/metrics/metrics.module.ts index 0a25bba..0a78e61 100644 --- a/apps/api/src/modules/metrics/metrics.module.ts +++ b/apps/api/src/modules/metrics/metrics.module.ts @@ -47,6 +47,18 @@ import { buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1], }), + // ── Cache Metrics ── + makeCounterProvider({ + name: 'cache_hit_total', + help: 'Total number of cache hits', + labelNames: ['resource'], + }), + makeCounterProvider({ + name: 'cache_miss_total', + help: 'Total number of cache misses', + labelNames: ['resource'], + }), + // ── Business Metrics ── makeCounterProvider({ name: 'listings_created_total', diff --git a/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts b/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts index ae8af48..912dbb8 100644 --- a/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts +++ b/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts @@ -1,6 +1,7 @@ import { SearchPropertiesHandler } from '../queries/search-properties/search-properties.handler'; import { SearchPropertiesQuery } from '../queries/search-properties/search-properties.query'; import { type ISearchRepository, type SearchResult } from '../../domain/repositories/search.repository'; +import { type CacheService } from '@modules/shared/infrastructure/cache.service'; function createMockSearchResult(overrides?: Partial): SearchResult { return { @@ -27,7 +28,8 @@ describe('SearchPropertiesHandler', () => { ensureCollection: vi.fn(), dropCollection: vi.fn(), }; - handler = new SearchPropertiesHandler(mockSearchRepo as any); + const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()) } as unknown as CacheService; + handler = new SearchPropertiesHandler(mockSearchRepo as any, mockCache); }); it('searches with basic query', async () => { diff --git a/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts b/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts index cfc9973..74d8b7f 100644 --- a/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts +++ b/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts @@ -1,5 +1,6 @@ import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service'; import { SearchPropertiesQuery } from './search-properties.query'; import { SEARCH_REPOSITORY, @@ -11,6 +12,7 @@ import { export class SearchPropertiesHandler implements IQueryHandler { constructor( @Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository, + private readonly cache: CacheService, ) {} async execute(query: SearchPropertiesQuery): Promise { @@ -46,12 +48,36 @@ export class SearchPropertiesHandler implements IQueryHandler this.searchRepo.search(searchParams), + CacheTTL.SEARCH_RESULTS, + 'search', + ); } } diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/cache.service.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/cache.service.spec.ts new file mode 100644 index 0000000..ebd7d3c --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/__tests__/cache.service.spec.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { CacheService, CachePrefix, CacheTTL } from '../cache.service'; + +describe('CacheService', () => { + let cacheService: CacheService; + let mockRedis: { + get: ReturnType; + set: ReturnType; + del: ReturnType; + getClient: ReturnType; + }; + let mockLogger: { log: ReturnType; warn: ReturnType }; + let mockHitCounter: { inc: ReturnType }; + let mockMissCounter: { inc: ReturnType }; + + beforeEach(() => { + mockRedis = { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + getClient: vi.fn().mockReturnValue({ + scan: vi.fn().mockResolvedValue(['0', []]), + del: vi.fn(), + }), + }; + mockLogger = { log: vi.fn(), warn: vi.fn() }; + mockHitCounter = { inc: vi.fn() }; + mockMissCounter = { inc: vi.fn() }; + + cacheService = new CacheService( + mockRedis as any, + mockLogger as any, + mockHitCounter as any, + mockMissCounter as any, + ); + }); + + describe('getOrSet', () => { + it('should return cached value on cache hit', async () => { + mockRedis.get.mockResolvedValue(JSON.stringify({ id: '123', name: 'test' })); + const loader = vi.fn(); + + const result = await cacheService.getOrSet('cache:listing:123', loader, 300, 'listing'); + + expect(result).toEqual({ id: '123', name: 'test' }); + expect(loader).not.toHaveBeenCalled(); + expect(mockHitCounter.inc).toHaveBeenCalledWith({ resource: 'listing' }); + expect(mockMissCounter.inc).not.toHaveBeenCalled(); + }); + + it('should call loader and cache result on cache miss', async () => { + mockRedis.get.mockResolvedValue(null); + const data = { id: '456', name: 'loaded' }; + const loader = vi.fn().mockResolvedValue(data); + + const result = await cacheService.getOrSet('cache:listing:456', loader, 300, 'listing'); + + expect(result).toEqual(data); + expect(loader).toHaveBeenCalledOnce(); + expect(mockMissCounter.inc).toHaveBeenCalledWith({ resource: 'listing' }); + expect(mockRedis.set).toHaveBeenCalledWith('cache:listing:456', JSON.stringify(data), 300); + }); + + it('should call loader when cache read fails', async () => { + mockRedis.get.mockRejectedValue(new Error('connection lost')); + const data = { id: '789' }; + const loader = vi.fn().mockResolvedValue(data); + + const result = await cacheService.getOrSet('key', loader, 60, 'search'); + + expect(result).toEqual(data); + expect(mockLogger.warn).toHaveBeenCalled(); + expect(mockMissCounter.inc).toHaveBeenCalledWith({ resource: 'search' }); + }); + + it('should return loaded data even when cache write fails', async () => { + mockRedis.get.mockResolvedValue(null); + mockRedis.set.mockRejectedValue(new Error('write error')); + const data = { id: '999' }; + const loader = vi.fn().mockResolvedValue(data); + + const result = await cacheService.getOrSet('key', loader, 60, 'search'); + + expect(result).toEqual(data); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('should propagate loader errors', async () => { + mockRedis.get.mockResolvedValue(null); + const loader = vi.fn().mockRejectedValue(new Error('not found')); + + await expect(cacheService.getOrSet('key', loader, 60, 'listing')).rejects.toThrow('not found'); + }); + }); + + describe('invalidate', () => { + it('should delete the cache key', async () => { + await cacheService.invalidate('cache:listing:123'); + expect(mockRedis.del).toHaveBeenCalledWith('cache:listing:123'); + }); + + it('should handle delete errors gracefully', async () => { + mockRedis.del.mockRejectedValue(new Error('fail')); + await cacheService.invalidate('cache:listing:123'); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + }); + + describe('invalidateByPrefix', () => { + it('should scan and delete matching keys', async () => { + const mockClient = { + scan: vi.fn() + .mockResolvedValueOnce(['10', ['cache:search:a', 'cache:search:b']]) + .mockResolvedValueOnce(['0', ['cache:search:c']]), + del: vi.fn(), + }; + mockRedis.getClient.mockReturnValue(mockClient); + + await cacheService.invalidateByPrefix('cache:search'); + + expect(mockClient.scan).toHaveBeenCalledTimes(2); + expect(mockClient.del).toHaveBeenCalledWith('cache:search:a', 'cache:search:b'); + expect(mockClient.del).toHaveBeenCalledWith('cache:search:c'); + }); + }); + + describe('buildKey', () => { + it('should build a deterministic cache key', () => { + const key = CacheService.buildKey(CachePrefix.LISTING, 'abc123'); + expect(key).toBe('cache:listing:abc123'); + }); + + it('should handle multiple parts', () => { + const key = CacheService.buildKey(CachePrefix.MARKET_REPORT, 'Hà Nội', '2026-Q1', 'APARTMENT'); + expect(key).toBe('cache:market:report:hà_nội:2026-q1:apartment'); + }); + + it('should skip undefined parts', () => { + const key = CacheService.buildKey(CachePrefix.SEARCH, 'query', undefined, 'SALE'); + expect(key).toBe('cache:search:query:sale'); + }); + + it('should handle numeric parts', () => { + const key = CacheService.buildKey(CachePrefix.SEARCH, 'test', 1, 20); + expect(key).toBe('cache:search:test:1:20'); + }); + }); + + describe('CacheTTL', () => { + it('should have correct TTL values', () => { + expect(CacheTTL.LISTING_DETAIL).toBe(300); + expect(CacheTTL.SEARCH_RESULTS).toBe(60); + expect(CacheTTL.MARKET_DATA).toBe(1800); + expect(CacheTTL.USER_PROFILE).toBe(600); + }); + }); +}); diff --git a/apps/api/src/modules/shared/infrastructure/cache.service.ts b/apps/api/src/modules/shared/infrastructure/cache.service.ts new file mode 100644 index 0000000..4664367 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/cache.service.ts @@ -0,0 +1,108 @@ +import { Injectable, Inject, type OnModuleInit } from '@nestjs/common'; +import { InjectMetric } from '@willsoto/nestjs-prometheus'; +import { Counter } from 'prom-client'; +import { RedisService } from './redis.service'; +import { LoggerService } from './logger.service'; + +export const CACHE_HIT_TOTAL = 'cache_hit_total'; +export const CACHE_MISS_TOTAL = 'cache_miss_total'; + +export enum CacheTTL { + /** Listing detail — moderate TTL, invalidated on mutation */ + LISTING_DETAIL = 300, // 5 min + /** Search results — short TTL due to high variability */ + SEARCH_RESULTS = 60, // 1 min + /** Market analytics — long TTL, data changes infrequently */ + MARKET_DATA = 1800, // 30 min + /** User profile — moderate TTL, invalidated on mutation */ + USER_PROFILE = 600, // 10 min +} + +export enum CachePrefix { + LISTING = 'cache:listing', + SEARCH = 'cache:search', + MARKET_REPORT = 'cache:market:report', + MARKET_TREND = 'cache:market:trend', + MARKET_HEATMAP = 'cache:market:heatmap', + MARKET_DISTRICT = 'cache:market:district', + USER_PROFILE = 'cache:user:profile', +} + +@Injectable() +export class CacheService implements OnModuleInit { + constructor( + private readonly redis: RedisService, + private readonly logger: LoggerService, + @InjectMetric(CACHE_HIT_TOTAL) private readonly cacheHitCounter: Counter, + @InjectMetric(CACHE_MISS_TOTAL) private readonly cacheMissCounter: Counter, + ) {} + + onModuleInit(): void { + this.logger.log('CacheService initialized', 'CacheService'); + } + + /** + * Cache-aside: get from cache, or execute loader and store result. + */ + async getOrSet( + key: string, + loader: () => Promise, + ttlSeconds: number, + resource: string, + ): Promise { + try { + const cached = await this.redis.get(key); + if (cached !== null) { + this.cacheHitCounter.inc({ resource }); + return JSON.parse(cached) as T; + } + } catch (err) { + this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService'); + } + + this.cacheMissCounter.inc({ resource }); + const result = await loader(); + + try { + await this.redis.set(key, JSON.stringify(result), ttlSeconds); + } catch (err) { + this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService'); + } + + return result; + } + + /** Invalidate a single cache key. */ + async invalidate(key: string): Promise { + try { + await this.redis.del(key); + } catch (err) { + this.logger.warn(`Cache invalidate error for ${key}: ${(err as Error).message}`, 'CacheService'); + } + } + + /** Invalidate all keys matching a prefix using SCAN (non-blocking). */ + async invalidateByPrefix(prefix: string): Promise { + try { + const client = this.redis.getClient(); + let cursor = '0'; + do { + const [nextCursor, keys] = await client.scan(cursor, 'MATCH', `${prefix}:*`, 'COUNT', 100); + cursor = nextCursor; + if (keys.length > 0) { + await client.del(...keys); + } + } while (cursor !== '0'); + } catch (err) { + this.logger.warn(`Cache prefix invalidate error for ${prefix}: ${(err as Error).message}`, 'CacheService'); + } + } + + /** Build a deterministic cache key from prefix + parts. */ + static buildKey(prefix: CachePrefix, ...parts: (string | number | undefined)[]): string { + const sanitized = parts + .filter((p) => p !== undefined) + .map((p) => String(p).toLowerCase().replace(/\s+/g, '_')); + return `${prefix}:${sanitized.join(':')}`; + } +} diff --git a/apps/api/src/modules/shared/infrastructure/index.ts b/apps/api/src/modules/shared/infrastructure/index.ts index 26285a8..3d7d193 100644 --- a/apps/api/src/modules/shared/infrastructure/index.ts +++ b/apps/api/src/modules/shared/infrastructure/index.ts @@ -1,5 +1,6 @@ export { PrismaService } from './prisma.service'; export { RedisService } from './redis.service'; +export { CacheService, CachePrefix, CacheTTL } from './cache.service'; export { LoggerService } from './logger.service'; export { EventBusService } from './event-bus.service'; export { GlobalExceptionFilter } from './filters/global-exception.filter'; diff --git a/apps/api/src/modules/shared/shared.module.ts b/apps/api/src/modules/shared/shared.module.ts index 813d454..db5dc23 100644 --- a/apps/api/src/modules/shared/shared.module.ts +++ b/apps/api/src/modules/shared/shared.module.ts @@ -1,14 +1,16 @@ -import { Global, type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common'; +import { Global, type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common'; import { APP_FILTER } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventBusService } from './infrastructure/event-bus.service'; import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter'; import { LoggerService } from './infrastructure/logger.service'; import { CorrelationIdMiddleware } from './infrastructure/middleware/correlation-id.middleware'; +import { CsrfMiddleware } from './infrastructure/middleware/csrf.middleware'; import { RequestLoggingMiddleware } from './infrastructure/middleware/request-logging.middleware'; import { SanitizeInputMiddleware } from './infrastructure/middleware/sanitize-input.middleware'; import { PrismaService } from './infrastructure/prisma.service'; import { RedisService } from './infrastructure/redis.service'; +import { CacheService } from './infrastructure/cache.service'; @Global() @Module({ @@ -16,6 +18,7 @@ import { RedisService } from './infrastructure/redis.service'; providers: [ PrismaService, RedisService, + CacheService, LoggerService, EventBusService, { @@ -23,12 +26,17 @@ import { RedisService } from './infrastructure/redis.service'; useClass: GlobalExceptionFilter, }, ], - exports: [PrismaService, RedisService, LoggerService, EventBusService], + exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService], }) export class SharedModule implements NestModule { configure(consumer: MiddlewareConsumer): void { consumer .apply(CorrelationIdMiddleware, SanitizeInputMiddleware, RequestLoggingMiddleware) .forRoutes('*'); + + consumer + .apply(CsrfMiddleware) + .exclude({ path: 'payments/callback/(.*)', method: RequestMethod.POST }) + .forRoutes('*'); } }