160 lines
4.5 KiB
TypeScript
160 lines
4.5 KiB
TypeScript
|
|
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}`;
|
||
|
|
}
|
||
|
|
}
|