Assist_Design/apps/bff/src/modules/support/support-cache.service.ts

160 lines
4.5 KiB
TypeScript
Raw Normal View History

import { Injectable } from "@nestjs/common";
import { CacheService } from "@bff/infra/cache/cache.service.js";
import type { CacheBucketMetrics } from "@bff/infra/cache/cache.types.js";
import type { SupportCaseList, CaseMessageList } from "@customer-portal/domain/support";
/**
* Cache TTL configuration for support cases
*
* Unlike orders (which use CDC events), support cases use TTL-based caching
* because Salesforce Case changes don't trigger platform events in our setup.
*/
const CACHE_TTL = {
/** Case list TTL: 2 minutes - customers checking status periodically */
CASE_LIST: 120,
/** Case messages TTL: 1 minute - fresher for active conversations */
CASE_MESSAGES: 60,
} as const;
interface SupportCacheMetrics {
caseList: CacheBucketMetrics;
messages: CacheBucketMetrics;
invalidations: number;
}
/**
* Support cases cache service
*
* Uses TTL-based caching (not CDC) because:
* - Cases don't trigger platform events in our Salesforce setup
* - Customers can refresh to see updates (no real-time requirement)
* - Short TTLs (1-2 min) ensure reasonable freshness
*
* Features:
* - Request coalescing: Prevents duplicate API calls on cache miss
* - Write-through invalidation: Cache is cleared after customer adds comment
* - Metrics tracking: Monitors hits, misses, and invalidations
*/
@Injectable()
export class SupportCacheService {
private readonly metrics: SupportCacheMetrics = {
caseList: { hits: 0, misses: 0 },
messages: { hits: 0, misses: 0 },
invalidations: 0,
};
// Request coalescing: Prevents duplicate API calls
private readonly inflightRequests = new Map<string, Promise<unknown>>();
constructor(private readonly cache: CacheService) {}
/**
* Get cached case list for an account
*/
async getCaseList(
sfAccountId: string,
fetcher: () => Promise<SupportCaseList>
): Promise<SupportCaseList> {
const key = this.buildCaseListKey(sfAccountId);
return this.getOrSet("caseList", key, fetcher, CACHE_TTL.CASE_LIST);
}
/**
* Get cached messages for a case
*/
async getCaseMessages(
caseId: string,
fetcher: () => Promise<CaseMessageList>
): Promise<CaseMessageList> {
const key = this.buildMessagesKey(caseId);
return this.getOrSet("messages", key, fetcher, CACHE_TTL.CASE_MESSAGES);
}
/**
* Invalidate case list cache for an account
* Called after customer creates a new case
*/
async invalidateCaseList(sfAccountId: string): Promise<void> {
const key = this.buildCaseListKey(sfAccountId);
this.metrics.invalidations++;
await this.cache.del(key);
}
/**
* Invalidate messages cache for a case
* Called after customer adds a comment
*/
async invalidateCaseMessages(caseId: string): Promise<void> {
const key = this.buildMessagesKey(caseId);
this.metrics.invalidations++;
await this.cache.del(key);
}
/**
* Invalidate all caches for an account's cases
* Called after any write operation to ensure fresh data
*/
async invalidateAllForAccount(sfAccountId: string, caseId?: string): Promise<void> {
await this.invalidateCaseList(sfAccountId);
if (caseId) {
await this.invalidateCaseMessages(caseId);
}
}
/**
* Get cache metrics for monitoring
*/
getMetrics(): SupportCacheMetrics {
return {
caseList: { ...this.metrics.caseList },
messages: { ...this.metrics.messages },
invalidations: this.metrics.invalidations,
};
}
private async getOrSet<T>(
bucket: "caseList" | "messages",
key: string,
fetcher: () => Promise<T>,
ttlSeconds: number
): Promise<T> {
// Check Redis cache first
const cached = await this.cache.get<T>(key);
if (cached !== null) {
this.metrics[bucket].hits++;
return cached;
}
// Check for in-flight request (prevents thundering herd)
const existingRequest = this.inflightRequests.get(key);
if (existingRequest) {
return existingRequest as Promise<T>;
}
// Fetch fresh data
this.metrics[bucket].misses++;
const fetchPromise = (async () => {
try {
const fresh = await fetcher();
await this.cache.set(key, fresh, ttlSeconds);
return fresh;
} finally {
this.inflightRequests.delete(key);
}
})();
this.inflightRequests.set(key, fetchPromise);
return fetchPromise;
}
private buildCaseListKey(sfAccountId: string): string {
return `support:cases:${sfAccountId}`;
}
private buildMessagesKey(caseId: string): string {
return `support:messages:${caseId}`;
}
}