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];