diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/cacheable.decorator.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/cacheable.decorator.spec.ts new file mode 100644 index 0000000..f4c5480 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/__tests__/cacheable.decorator.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CachePrefix, CacheService, CacheTTL } from '../cache.service'; +import { Cacheable } from '../decorators/cacheable.decorator'; + +describe('Cacheable decorator', () => { + let mockCacheService: { getOrSet: ReturnType }; + + beforeEach(() => { + mockCacheService = { + getOrSet: vi.fn(), + }; + }); + + it('should call cacheService.getOrSet with correct key and TTL', async () => { + class TestHandler { + cacheService = mockCacheService; + + @Cacheable({ + prefix: CachePrefix.LISTING, + ttl: CacheTTL.LISTING_DETAIL, + resource: 'listing', + keyFrom: (query: unknown) => [(query as { id: string }).id], + }) + async execute(query: { id: string }): Promise { + return `listing-${query.id}`; + } + } + + mockCacheService.getOrSet.mockResolvedValue('cached-result'); + const handler = new TestHandler(); + const result = await handler.execute({ id: 'abc123' }); + + expect(result).toBe('cached-result'); + expect(mockCacheService.getOrSet).toHaveBeenCalledWith( + 'cache:listing:abc123', + expect.any(Function), + 300, + 'listing', + ); + }); + + it('should use first argument as default key when keyFrom is not provided', async () => { + class TestHandler { + cacheService = mockCacheService; + + @Cacheable({ + prefix: CachePrefix.PLAN_LIST, + ttl: CacheTTL.PLAN_LIST, + resource: 'plan', + }) + async execute(query: string): Promise { + return `plan-${query}`; + } + } + + mockCacheService.getOrSet.mockResolvedValue('cached-plan'); + const handler = new TestHandler(); + await handler.execute('PREMIUM'); + + expect(mockCacheService.getOrSet).toHaveBeenCalledWith( + 'cache:plan:list:premium', + expect.any(Function), + 3600, + 'plan', + ); + }); + + it('should execute the original method as the loader function', async () => { + class TestHandler { + cacheService = mockCacheService; + + @Cacheable({ + prefix: CachePrefix.LISTING, + ttl: CacheTTL.LISTING_DETAIL, + resource: 'listing', + keyFrom: (query: unknown) => [(query as { id: string }).id], + }) + async execute(_query: { id: string }): Promise { + return 'original-result'; + } + } + + mockCacheService.getOrSet.mockImplementation( + async (_key: string, loader: () => Promise) => loader(), + ); + const handler = new TestHandler(); + const result = await handler.execute({ id: 'xyz' }); + + expect(result).toBe('original-result'); + }); + + it('should handle multiple key parts from keyFrom', async () => { + class TestHandler { + cacheService = mockCacheService; + + @Cacheable({ + prefix: CachePrefix.SEARCH, + ttl: CacheTTL.SEARCH_RESULTS, + resource: 'search', + keyFrom: (query: unknown) => { + const q = query as { district: string; type: string }; + return [q.district, q.type]; + }, + }) + async execute(_query: { district: string; type: string }): Promise { + return []; + } + } + + mockCacheService.getOrSet.mockResolvedValue([]); + const handler = new TestHandler(); + await handler.execute({ district: 'Quận 1', type: 'APARTMENT' }); + + expect(mockCacheService.getOrSet).toHaveBeenCalledWith( + 'cache:search:quận_1:apartment', + expect.any(Function), + 120, + 'search', + ); + }); +}); diff --git a/apps/api/src/modules/shared/infrastructure/cache.service.ts b/apps/api/src/modules/shared/infrastructure/cache.service.ts index 0d20c64..8e81fd5 100644 --- a/apps/api/src/modules/shared/infrastructure/cache.service.ts +++ b/apps/api/src/modules/shared/infrastructure/cache.service.ts @@ -24,6 +24,10 @@ export const CacheTTL = { USER_PROFILE: 600, // 10 min /** User quota — very short TTL, invalidated on usage metering and plan changes */ USER_QUOTA: 60, // 1 min + /** Subscription plan list — long TTL, rarely changes */ + PLAN_LIST: 3600, // 1 hour + /** Reference data (districts, wards) — very long TTL, static data */ + REFERENCE_DATA: 86400, // 24 hours } as const; export enum CachePrefix { @@ -37,6 +41,8 @@ export enum CachePrefix { USER_PROFILE = 'cache:user:profile', USER_QUOTA = 'cache:user:quota', VALUATION = 'cache:valuation', + PLAN_LIST = 'cache:plan:list', + REFERENCE = 'cache:reference', } @Injectable() diff --git a/apps/api/src/modules/shared/infrastructure/decorators/cacheable.decorator.ts b/apps/api/src/modules/shared/infrastructure/decorators/cacheable.decorator.ts new file mode 100644 index 0000000..ed997fe --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/decorators/cacheable.decorator.ts @@ -0,0 +1,56 @@ +import { type CachePrefix, CacheService, type CacheTTL } from '../cache.service'; + +/** + * Metadata key for @Cacheable decorator options. + */ +export const CACHEABLE_METADATA = Symbol('CACHEABLE_METADATA'); + +export interface CacheableOptions { + /** Cache key prefix — use CachePrefix enum values. */ + prefix: CachePrefix; + /** TTL in seconds — use CacheTTL constants. */ + ttl: (typeof CacheTTL)[keyof typeof CacheTTL]; + /** Resource label for Prometheus metrics. */ + resource: string; + /** + * Extract cache key parts from method arguments. + * Return an array of strings that will be joined with the prefix. + * Defaults to using the first argument's string representation. + */ + keyFrom?: (...args: unknown[]) => (string | number | undefined)[]; +} + +/** + * Declarative cache-aside decorator for query handler `execute()` methods. + * + * Usage: + * ```ts + * @Cacheable({ prefix: CachePrefix.LISTING, ttl: CacheTTL.LISTING_DETAIL, resource: 'listing' }) + * async execute(query: GetListingQuery): Promise { ... } + * ``` + * + * Requires the class to have a `cacheService: CacheService` property + * (injected via constructor). + */ +export function Cacheable(options: CacheableOptions): MethodDecorator { + return function (_target: object, _propertyKey: string | symbol, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value as (...args: unknown[]) => Promise; + + descriptor.value = async function (this: { cacheService: CacheService }, ...args: unknown[]) { + const keyParts = options.keyFrom + ? options.keyFrom(...args) + : [String(args[0] ?? 'default')]; + + const cacheKey = CacheService.buildKey(options.prefix, ...keyParts); + + return this.cacheService.getOrSet( + cacheKey, + () => originalMethod.apply(this, args), + options.ttl, + options.resource, + ); + }; + + return descriptor; + }; +} diff --git a/apps/api/src/modules/shared/infrastructure/index.ts b/apps/api/src/modules/shared/infrastructure/index.ts index 897f980..109d042 100644 --- a/apps/api/src/modules/shared/infrastructure/index.ts +++ b/apps/api/src/modules/shared/infrastructure/index.ts @@ -1,3 +1,4 @@ +export { Cacheable, type CacheableOptions } from './decorators/cacheable.decorator'; export { PrismaService } from './prisma.service'; export { RedisService } from './redis.service'; export { CacheService, CachePrefix, CacheTTL } from './cache.service'; diff --git a/apps/api/src/modules/subscriptions/application/__tests__/get-plan.handler.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/get-plan.handler.spec.ts index e7c44b3..1de6ee8 100644 --- a/apps/api/src/modules/subscriptions/application/__tests__/get-plan.handler.spec.ts +++ b/apps/api/src/modules/subscriptions/application/__tests__/get-plan.handler.spec.ts @@ -17,6 +17,8 @@ describe('GetPlanHandler', () => { isActive: true, }; + let mockCacheService: any; + beforeEach(() => { mockPrisma = { plan: { @@ -25,7 +27,11 @@ describe('GetPlanHandler', () => { }, }; - handler = new GetPlanHandler(mockPrisma); + mockCacheService = { + getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()), + }; + + handler = new GetPlanHandler(mockPrisma, mockCacheService); }); it('returns a single plan by tier', async () => { diff --git a/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts b/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts index 351cceb..3324c0f 100644 --- a/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts +++ b/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts @@ -1,6 +1,6 @@ import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { type Plan } from '@prisma/client'; -import { NotFoundException, type PrismaService } from '@modules/shared'; +import { CacheService, CachePrefix, CacheTTL, NotFoundException, type PrismaService } from '@modules/shared'; import { GetPlanQuery } from './get-plan.query'; export interface PlanDto { @@ -17,28 +17,41 @@ export interface PlanDto { @QueryHandler(GetPlanQuery) export class GetPlanHandler implements IQueryHandler { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly cacheService: CacheService, + ) {} async execute(query: GetPlanQuery): Promise { if (query.planTier) { - let plan; - try { - plan = await this.prisma.plan.findFirst({ - where: { tier: query.planTier, isActive: true }, - }); - } catch { - throw new NotFoundException('Plan', query.planTier); - } - if (!plan) throw new NotFoundException('Plan', query.planTier); - return this.toDto(plan); + const cacheKey = CacheService.buildKey(CachePrefix.PLAN_LIST, query.planTier); + return this.cacheService.getOrSet( + cacheKey, + async () => { + const plan = await this.prisma.plan.findFirst({ + where: { tier: query.planTier, isActive: true }, + }); + if (!plan) throw new NotFoundException('Plan', query.planTier); + return this.toDto(plan); + }, + CacheTTL.PLAN_LIST, + 'plan', + ); } - const plans = await this.prisma.plan.findMany({ - where: { isActive: true }, - orderBy: { priceMonthlyVND: 'asc' }, - }); - - return plans.map((p) => this.toDto(p)); + const cacheKey = CacheService.buildKey(CachePrefix.PLAN_LIST, 'all'); + return this.cacheService.getOrSet( + cacheKey, + async () => { + const plans = await this.prisma.plan.findMany({ + where: { isActive: true }, + orderBy: { priceMonthlyVND: 'asc' }, + }); + return plans.map((p) => this.toDto(p)); + }, + CacheTTL.PLAN_LIST, + 'plan', + ); } private toDto(plan: Plan): PlanDto {