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>(); constructor(private readonly cache: CacheService) {} /** * Get cached case list for an account */ async getCaseList( sfAccountId: string, fetcher: () => Promise ): Promise { 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 ): Promise { 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 { 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 { 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 { 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( bucket: "caseList" | "messages", key: string, fetcher: () => Promise, ttlSeconds: number ): Promise { // Check Redis cache first const cached = await this.cache.get(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; } // 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}`; } }