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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 01:11:40 +07:00
parent 62f4f001b6
commit 05651ba4c3
10 changed files with 119 additions and 11 deletions

View File

@@ -105,7 +105,11 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
this.eventBus.publish(event);
}
await this.cache.invalidateByPrefix(CachePrefix.SEARCH);
await Promise.all([
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
this.cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT),
this.cache.invalidateByPrefix(CachePrefix.MARKET_REPORT),
]);
// Duplicate detection — flag but never block creation
let duplicateWarnings: DuplicateWarning[] = [];

View File

@@ -63,7 +63,7 @@ describe('GeoSearchHandler', () => {
expect(mockCache.getOrSet).toHaveBeenCalledWith(
expect.stringContaining('cache:geo_search:'),
expect.any(Function),
60,
120,
'geo_search',
);
});

View File

@@ -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);
});
});
});

View File

@@ -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()

View File

@@ -7,6 +7,7 @@ describe('CheckQuotaHandler', () => {
let handler: CheckQuotaHandler;
let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
let mockPrisma: any;
let mockCache: { getOrSet: ReturnType<typeof vi.fn>; invalidate: ReturnType<typeof vi.fn>; invalidateByPrefix: ReturnType<typeof vi.fn> };
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<unknown>) => 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',
);
});
});

View File

@@ -16,6 +16,7 @@ describe('MeterUsageHandler', () => {
let handler: MeterUsageHandler;
let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
let mockPrisma: any;
let mockCache: { getOrSet: ReturnType<typeof vi.fn>; invalidate: ReturnType<typeof vi.fn>; invalidateByPrefix: ReturnType<typeof vi.fn> };
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);

View File

@@ -17,6 +17,7 @@ describe('UpgradeSubscriptionHandler', () => {
let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
let mockPrisma: any;
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockCache: { getOrSet: ReturnType<typeof vi.fn>; invalidate: ReturnType<typeof vi.fn>; invalidateByPrefix: ReturnType<typeof vi.fn> };
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);

View File

@@ -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<MeterUsageCommand> {
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepo: ISubscriptionRepository,
private readonly prisma: PrismaService,
private readonly cache: CacheService,
) {}
async execute(command: MeterUsageCommand): Promise<MeterUsageResult> {
@@ -68,6 +70,11 @@ export class MeterUsageHandler implements ICommandHandler<MeterUsageCommand> {
});
}
// 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}`,
);

View File

@@ -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<UpgradeSubscr
private readonly subscriptionRepo: ISubscriptionRepository,
private readonly prisma: PrismaService,
private readonly eventBus: EventBus,
private readonly cache: CacheService,
) {}
async execute(command: UpgradeSubscriptionCommand): Promise<UpgradeSubscriptionResult> {
@@ -70,6 +72,11 @@ export class UpgradeSubscriptionHandler implements ICommandHandler<UpgradeSubscr
this.eventBus.publish(event);
}
// Invalidate all cached quota entries for this user (limits change with plan)
await this.cache.invalidateByPrefix(
CacheService.buildKey(CachePrefix.USER_QUOTA, command.userId),
);
this.logger.log(
`Subscription upgraded: id=${subscription.id}, ${previousTier}${command.newPlanTier}`,
);

View File

@@ -2,6 +2,7 @@ import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { type Plan } from '@prisma/client';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import {
SUBSCRIPTION_REPOSITORY,
@@ -30,10 +31,22 @@ export class CheckQuotaHandler implements IQueryHandler<CheckQuotaQuery> {
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepo: ISubscriptionRepository,
private readonly prisma: PrismaService,
private readonly cache: CacheService,
) {}
async execute(query: CheckQuotaQuery): Promise<QuotaCheckResult> {
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<QuotaCheckResult> {
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<CheckQuotaQuery> {
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<CheckQuotaQuery> {
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<QuotaCheckResult> {
const planField = METRIC_TO_PLAN_FIELD[metric];