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:
@@ -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[] = [];
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('GeoSearchHandler', () => {
|
||||
expect(mockCache.getOrSet).toHaveBeenCalledWith(
|
||||
expect.stringContaining('cache:geo_search:'),
|
||||
expect.any(Function),
|
||||
60,
|
||||
120,
|
||||
'geo_search',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user