From 05651ba4c3f3c220fdeaee5160e8f9a8b3f640ad Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 9 Apr 2026 01:11:40 +0700 Subject: [PATCH] feat(api): add Redis caching for user quota and improve cache invalidation Add 1-min TTL caching to CheckQuotaHandler (previously uncached, hitting 3 DB queries per guarded request). Add cache invalidation to MeterUsageHandler and UpgradeSubscriptionHandler so quota caches stay fresh after usage metering and plan changes. Increase search results TTL from 1min to 2min per spec. Add market cache invalidation on listing creation to keep district stats and market reports consistent. Co-Authored-By: Paperclip --- .../create-listing/create-listing.handler.ts | 6 +++- .../__tests__/geo-search.handler.spec.ts | 2 +- .../__tests__/cache.service.spec.ts | 3 +- .../shared/infrastructure/cache.service.ts | 7 +++-- .../__tests__/check-quota.handler.spec.ts | 24 +++++++++++++- .../__tests__/meter-usage.handler.spec.ts | 31 +++++++++++++++++++ .../upgrade-subscription.handler.spec.ts | 21 +++++++++++++ .../meter-usage/meter-usage.handler.ts | 7 +++++ .../upgrade-subscription.handler.ts | 7 +++++ .../check-quota/check-quota.handler.ts | 22 ++++++++++--- 10 files changed, 119 insertions(+), 11 deletions(-) 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 cda646c..651682e 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 @@ -105,7 +105,11 @@ export class CreateListingHandler implements ICommandHandler { expect(mockCache.getOrSet).toHaveBeenCalledWith( expect.stringContaining('cache:geo_search:'), expect.any(Function), - 60, + 120, 'geo_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 index ebd7d3c..3834dbd 100644 --- a/apps/api/src/modules/shared/infrastructure/__tests__/cache.service.spec.ts +++ b/apps/api/src/modules/shared/infrastructure/__tests__/cache.service.spec.ts @@ -149,9 +149,10 @@ describe('CacheService', () => { describe('CacheTTL', () => { it('should have correct TTL values', () => { expect(CacheTTL.LISTING_DETAIL).toBe(300); - expect(CacheTTL.SEARCH_RESULTS).toBe(60); + expect(CacheTTL.SEARCH_RESULTS).toBe(120); expect(CacheTTL.MARKET_DATA).toBe(1800); expect(CacheTTL.USER_PROFILE).toBe(600); + expect(CacheTTL.USER_QUOTA).toBe(60); }); }); }); diff --git a/apps/api/src/modules/shared/infrastructure/cache.service.ts b/apps/api/src/modules/shared/infrastructure/cache.service.ts index 7a6394a..af94512 100644 --- a/apps/api/src/modules/shared/infrastructure/cache.service.ts +++ b/apps/api/src/modules/shared/infrastructure/cache.service.ts @@ -10,8 +10,8 @@ export const CACHE_MISS_TOTAL = 'cache_miss_total'; export const 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 + /** Search results — short TTL, invalidated on listing mutations */ + SEARCH_RESULTS: 120, // 2 min /** District stats — moderate TTL, invalidated on listing events */ DISTRICT_STATS: 300, // 5 min /** Market report — moderate TTL, invalidated on listing events */ @@ -22,6 +22,8 @@ export const CacheTTL = { MARKET_DATA: 1800, // 30 min /** User profile — moderate TTL, invalidated on mutation */ USER_PROFILE: 600, // 10 min + /** User quota — very short TTL, invalidated on usage metering and plan changes */ + USER_QUOTA: 60, // 1 min } as const; export enum CachePrefix { @@ -33,6 +35,7 @@ export enum CachePrefix { MARKET_HEATMAP = 'cache:market:heatmap', MARKET_DISTRICT = 'cache:market:district', USER_PROFILE = 'cache:user:profile', + USER_QUOTA = 'cache:user:quota', } @Injectable() diff --git a/apps/api/src/modules/subscriptions/application/__tests__/check-quota.handler.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/check-quota.handler.spec.ts index d191598..09bb511 100644 --- a/apps/api/src/modules/subscriptions/application/__tests__/check-quota.handler.spec.ts +++ b/apps/api/src/modules/subscriptions/application/__tests__/check-quota.handler.spec.ts @@ -7,6 +7,7 @@ describe('CheckQuotaHandler', () => { let handler: CheckQuotaHandler; let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType }; let mockPrisma: any; + let mockCache: { getOrSet: ReturnType; invalidate: ReturnType; invalidateByPrefix: ReturnType }; beforeEach(() => { mockRepo = { @@ -26,7 +27,13 @@ describe('CheckQuotaHandler', () => { }, }; - handler = new CheckQuotaHandler(mockRepo as any, mockPrisma); + mockCache = { + getOrSet: vi.fn().mockImplementation(async (_key: string, fn: () => Promise) => fn()), + invalidate: vi.fn().mockResolvedValue(undefined), + invalidateByPrefix: vi.fn().mockResolvedValue(undefined), + }; + + handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any); }); it('returns quota for active subscription', async () => { @@ -115,4 +122,19 @@ describe('CheckQuotaHandler', () => { expect(result.limit).toBe(0); expect(result.allowed).toBe(false); }); + + it('uses cache with correct key and TTL', async () => { + mockRepo.findByUserId.mockResolvedValue(null); + mockPrisma.plan.findFirst.mockResolvedValue(null); + + const query = new CheckQuotaQuery('user-1', 'listings_created'); + await handler.execute(query); + + expect(mockCache.getOrSet).toHaveBeenCalledWith( + expect.stringContaining('user-1'), + expect.any(Function), + 60, + 'quota', + ); + }); }); diff --git a/apps/api/src/modules/subscriptions/application/__tests__/meter-usage.handler.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/meter-usage.handler.spec.ts index 8b34c84..b69773d 100644 --- a/apps/api/src/modules/subscriptions/application/__tests__/meter-usage.handler.spec.ts +++ b/apps/api/src/modules/subscriptions/application/__tests__/meter-usage.handler.spec.ts @@ -16,6 +16,7 @@ describe('MeterUsageHandler', () => { let handler: MeterUsageHandler; let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType }; let mockPrisma: any; + let mockCache: { getOrSet: ReturnType; invalidate: ReturnType; invalidateByPrefix: ReturnType }; beforeEach(() => { mockRepo = { @@ -33,9 +34,16 @@ describe('MeterUsageHandler', () => { }, }; + mockCache = { + getOrSet: vi.fn(), + invalidate: vi.fn().mockResolvedValue(undefined), + invalidateByPrefix: vi.fn().mockResolvedValue(undefined), + }; + handler = new MeterUsageHandler( mockRepo as any, mockPrisma, + mockCache as any, ); }); @@ -85,6 +93,29 @@ describe('MeterUsageHandler', () => { }); }); + it('invalidates quota cache after metering usage', async () => { + const subscription = createActiveSubscription(); + mockRepo.findByUserId.mockResolvedValue(subscription); + mockPrisma.usageRecord.findFirst.mockResolvedValue(null); + mockPrisma.usageRecord.create.mockResolvedValue({ + id: 'usage-1', + metric: 'listings_created', + count: 1, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }); + + const command = new MeterUsageCommand('user-1', 'listings_created', 1); + await handler.execute(command); + + expect(mockCache.invalidate).toHaveBeenCalledWith( + expect.stringContaining('user-1'), + ); + expect(mockCache.invalidate).toHaveBeenCalledWith( + expect.stringContaining('listings_created'), + ); + }); + it('throws ValidationException for zero count', async () => { const command = new MeterUsageCommand('user-1', 'listings_created', 0); diff --git a/apps/api/src/modules/subscriptions/application/__tests__/upgrade-subscription.handler.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/upgrade-subscription.handler.spec.ts index 1dd0047..96d135d 100644 --- a/apps/api/src/modules/subscriptions/application/__tests__/upgrade-subscription.handler.spec.ts +++ b/apps/api/src/modules/subscriptions/application/__tests__/upgrade-subscription.handler.spec.ts @@ -17,6 +17,7 @@ describe('UpgradeSubscriptionHandler', () => { let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType }; let mockPrisma: any; let mockEventBus: { publish: ReturnType }; + let mockCache: { getOrSet: ReturnType; invalidate: ReturnType; invalidateByPrefix: ReturnType }; beforeEach(() => { mockRepo = { @@ -34,10 +35,17 @@ describe('UpgradeSubscriptionHandler', () => { mockEventBus = { publish: vi.fn() }; + mockCache = { + getOrSet: vi.fn(), + invalidate: vi.fn().mockResolvedValue(undefined), + invalidateByPrefix: vi.fn().mockResolvedValue(undefined), + }; + handler = new UpgradeSubscriptionHandler( mockRepo as any, mockPrisma, mockEventBus as any, + mockCache as any, ); }); @@ -56,6 +64,19 @@ describe('UpgradeSubscriptionHandler', () => { expect(mockEventBus.publish).toHaveBeenCalled(); }); + it('invalidates all quota caches on upgrade', async () => { + const subscription = createActiveSubscription('FREE'); + mockRepo.findByUserId.mockResolvedValue(subscription); + mockPrisma.plan.findFirst.mockResolvedValue({ id: 'plan-2', tier: 'AGENT_PRO', isActive: true }); + + const command = new UpgradeSubscriptionCommand('user-1', 'AGENT_PRO'); + await handler.execute(command); + + expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith( + expect.stringContaining('user-1'), + ); + }); + it('allows lateral switch between AGENT_PRO and INVESTOR', async () => { const subscription = createActiveSubscription('AGENT_PRO'); mockRepo.findByUserId.mockResolvedValue(subscription); diff --git a/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts b/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts index ed0bdbc..94657d7 100644 --- a/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts +++ b/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts @@ -1,6 +1,7 @@ import { Inject, Logger } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception'; +import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service'; import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; import { SUBSCRIPTION_REPOSITORY, @@ -24,6 +25,7 @@ export class MeterUsageHandler implements ICommandHandler { @Inject(SUBSCRIPTION_REPOSITORY) private readonly subscriptionRepo: ISubscriptionRepository, private readonly prisma: PrismaService, + private readonly cache: CacheService, ) {} async execute(command: MeterUsageCommand): Promise { @@ -68,6 +70,11 @@ export class MeterUsageHandler implements ICommandHandler { }); } + // Invalidate cached quota for this user + metric + await this.cache.invalidate( + CacheService.buildKey(CachePrefix.USER_QUOTA, command.userId, command.metric), + ); + this.logger.log( `Usage metered: subscription=${subscription.id}, metric=${command.metric}, count=${command.count}`, ); diff --git a/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts b/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts index a6d2753..1e7b479 100644 --- a/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts +++ b/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts @@ -1,6 +1,7 @@ import { Inject, Logger } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception'; +import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service'; import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; import { SUBSCRIPTION_REPOSITORY, @@ -26,6 +27,7 @@ export class UpgradeSubscriptionHandler implements ICommandHandler { @@ -70,6 +72,11 @@ export class UpgradeSubscriptionHandler implements ICommandHandler { @Inject(SUBSCRIPTION_REPOSITORY) private readonly subscriptionRepo: ISubscriptionRepository, private readonly prisma: PrismaService, + private readonly cache: CacheService, ) {} async execute(query: CheckQuotaQuery): Promise { - const subscription = await this.subscriptionRepo.findByUserId(query.userId); + const cacheKey = CacheService.buildKey(CachePrefix.USER_QUOTA, query.userId, query.metric); + + return this.cache.getOrSet( + cacheKey, + () => this.loadQuota(query.userId, query.metric), + CacheTTL.USER_QUOTA, + 'quota', + ); + } + + private async loadQuota(userId: string, metric: string): Promise { + const subscription = await this.subscriptionRepo.findByUserId(userId); // No subscription = FREE tier defaults if (!subscription || !subscription.isActive()) { @@ -41,9 +54,9 @@ export class CheckQuotaHandler implements IQueryHandler { where: { tier: 'FREE', isActive: true }, }); if (!freePlan) { - return { metric: query.metric, limit: 0, used: 0, remaining: 0, allowed: false }; + return { metric, limit: 0, used: 0, remaining: 0, allowed: false }; } - return this.checkAgainstPlan(freePlan, query.metric, null, query.userId); + return this.checkAgainstPlan(freePlan, metric, null); } const plan = await this.prisma.plan.findUnique({ @@ -53,14 +66,13 @@ export class CheckQuotaHandler implements IQueryHandler { throw new NotFoundException('Plan', subscription.planId); } - return this.checkAgainstPlan(plan, query.metric, subscription.id, query.userId); + return this.checkAgainstPlan(plan, metric, subscription.id); } private async checkAgainstPlan( plan: Plan, metric: string, subscriptionId: string | null, - _userId: string, ): Promise { const planField = METRIC_TO_PLAN_FIELD[metric];