From cdfad9d03699518f878652f6bd3761150b675ecd Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 18 Nov 2025 18:18:25 +0900 Subject: [PATCH] Enhance caching infrastructure and improve SIM management features - Updated CacheModule and CacheService with detailed documentation and new methods for better cache management, including pattern deletion and memory usage tracking. - Refactored CatalogCacheService and OrdersCacheService to utilize CDC-driven cache invalidation, improving data freshness and reducing unnecessary API calls. - Introduced SIM plan options and updated related components to leverage new domain utilities for better plan management and user experience. - Enhanced error handling and validation in TopUpModal for improved user feedback during SIM top-up operations. - Removed obsolete plan formatting utilities to streamline codebase and improve maintainability. --- apps/bff/src/infra/cache/README.md | 360 ++++++++++++++++++ apps/bff/src/infra/cache/cache.module.ts | 9 + apps/bff/src/infra/cache/cache.service.ts | 63 +++ apps/bff/src/infra/cache/cache.types.ts | 46 +++ .../catalog/services/catalog-cache.service.ts | 179 ++++----- .../orders/services/orders-cache.service.ts | 111 ++---- .../components/UpcomingPaymentBanner.tsx | 3 +- .../components/ChangePlanModal.tsx | 28 +- .../components/SimDetailsCard.tsx | 4 +- .../components/SimFeatureToggles.tsx | 81 ++-- .../sim-management/components/TopUpModal.tsx | 16 +- .../src/features/sim-management/utils/plan.ts | 20 - .../subscriptions/views/SimChangePlan.tsx | 25 +- apps/portal/src/lib/utils/index.ts | 1 - apps/portal/src/lib/utils/plan.ts | 10 - packages/domain/sim/contract.ts | 15 + packages/domain/sim/helpers.ts | 70 +++- packages/domain/sim/index.ts | 15 +- 18 files changed, 788 insertions(+), 268 deletions(-) create mode 100644 apps/bff/src/infra/cache/README.md create mode 100644 apps/bff/src/infra/cache/cache.types.ts delete mode 100644 apps/portal/src/features/sim-management/utils/plan.ts delete mode 100644 apps/portal/src/lib/utils/plan.ts diff --git a/apps/bff/src/infra/cache/README.md b/apps/bff/src/infra/cache/README.md new file mode 100644 index 00000000..f469e043 --- /dev/null +++ b/apps/bff/src/infra/cache/README.md @@ -0,0 +1,360 @@ +# Cache Infrastructure + +Redis-backed caching system with CDC (Change Data Capture) event-driven invalidation. + +## Architecture + +### Core Components + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ (Orders, Catalog, Invoices, etc. - Domain Logic) │ +└───────────────────────────┬─────────────────────────────────┘ + │ +┌───────────────────────────▼─────────────────────────────────┐ +│ Domain-Specific Cache Services │ +│ - OrdersCacheService (CDC-driven, no TTL) │ +│ - CatalogCacheService (CDC-driven, no TTL) │ +│ - WhmcsCacheService (TTL-based) │ +│ │ +│ Features: │ +│ • Request coalescing (prevents thundering herd) │ +│ • Metrics tracking (hits/misses/invalidations) │ +│ • Dependency tracking (granular invalidation) │ +└───────────────────────────┬─────────────────────────────────┘ + │ +┌───────────────────────────▼─────────────────────────────────┐ +│ CacheService │ +│ (Low-level Redis operations) │ +│ │ +│ • get/set with JSON serialization │ +│ • Pattern-based operations (scan/delete/count) │ +│ • Memory usage tracking │ +│ • Automatic error handling │ +└───────────────────────────┬─────────────────────────────────┘ + │ +┌───────────────────────────▼─────────────────────────────────┐ +│ Redis (ioredis) │ +│ In-memory data store │ +└─────────────────────────────────────────────────────────────┘ +``` + +### CDC Event-Driven Invalidation + +``` +┌─────────────────┐ ┌──────────────────┐ +│ Salesforce │ CDC │ CDC Subscriber │ +│ (Data Source) │───────▶ │ (order-cdc, │ +│ │ Events │ catalog-cdc) │ +└─────────────────┘ └─────────┬────────┘ + │ + │ Invalidate + ▼ + ┌──────────────────┐ + │ Cache Service │ + │ (Redis) │ + └──────────────────┘ +``` + +## Cache Strategies + +### 1. CDC-Driven (Orders, Catalog) + +**No TTL** - Cache persists indefinitely until CDC event triggers invalidation. + +**Pros:** +- Real-time invalidation when data changes +- Zero stale data for customer-visible fields +- Optimal for frequently read, infrequently changed data + +**Example:** +```typescript +@Injectable() +export class OrdersCacheService { + // No TTL = CDC-only invalidation + async getOrderSummaries( + sfAccountId: string, + fetcher: () => Promise + ): Promise { + const key = this.buildAccountKey(sfAccountId); + return this.getOrSet("summaries", key, fetcher, false); + } +} +``` + +### 2. TTL-Based (WHMCS, External APIs) + +**Fixed TTL** - Cache expires after a set duration. + +**Pros:** +- Simple, predictable behavior +- Good for external systems without CDC +- Automatic cleanup of stale data + +**Example:** +```typescript +@Injectable() +export class WhmcsCacheService { + private readonly INVOICES_TTL = 90; // 90 seconds + + async getInvoicesList(...): Promise { + return this.cacheService.getOrSet(key, fetcher, this.INVOICES_TTL); + } +} +``` + +### 3. Hybrid (Volatile Data) + +**Short TTL + CDC** - Combines both approaches. + +**Use case:** Frequently changing data that also needs CDC invalidation. + +```typescript +private readonly VOLATILE_TTL = 60; // 1 minute +``` + +## Key Features + +### Request Coalescing + +Prevents "thundering herd" problem when cache is invalidated and multiple requests arrive simultaneously. + +```typescript +// When 100 users request data after CDC invalidation: +// ❌ Without coalescing: 100 API calls +// ✅ With coalescing: 1 API call, shared by all 100 users + +private readonly inflightRequests = new Map>(); + +// Check if another request is already fetching this data +const existingRequest = this.inflightRequests.get(key); +if (existingRequest) { + return existingRequest; // Wait for and share the existing request +} +``` + +### Dependency Tracking (Catalog Only) + +Enables granular invalidation by tracking relationships between cached data and source entities. + +```typescript +// When Product A changes: +// ✅ Invalidate only catalog entries that depend on Product A +// ❌ Don't invalidate unrelated Product B entries + +await catalogCache.invalidateProducts(["Product_A_ID"]); +``` + +### Metrics Tracking + +All cache services track performance metrics: + +```typescript +{ + catalog: { hits: 1250, misses: 48 }, + static: { hits: 890, misses: 12 }, + volatile: { hits: 450, misses: 120 }, + invalidations: 15 +} +``` + +Access via health endpoints: +- `GET /health/catalog/cache` +- `GET /health` + +## Creating a New Cache Service + +### Step 1: Import Shared Types + +```typescript +import { CacheService } from "@bff/infra/cache/cache.service"; +import type { CacheBucketMetrics } from "@bff/infra/cache/cache.types"; +``` + +### Step 2: Define Service Structure + +```typescript +@Injectable() +export class MyDomainCacheService { + private readonly metrics = { + data: { hits: 0, misses: 0 }, + invalidations: 0, + }; + + // Request coalescing map + private readonly inflightRequests = new Map>(); + + constructor(private readonly cache: CacheService) {} +} +``` + +### Step 3: Implement Get/Set Pattern + +```typescript +async getMyData(id: string, fetcher: () => Promise): Promise { + const key = `mydomain:${id}`; + + // Check cache + const cached = await this.cache.get(key); + if (cached) { + this.metrics.data.hits++; + return cached; + } + + // Check for in-flight request + const existing = this.inflightRequests.get(key); + if (existing) return existing as Promise; + + // Fetch fresh data + this.metrics.data.misses++; + const fetchPromise = (async () => { + try { + const fresh = await fetcher(); + await this.cache.set(key, fresh); // No TTL = CDC-driven + return fresh; + } finally { + this.inflightRequests.delete(key); + } + })(); + + this.inflightRequests.set(key, fetchPromise); + return fetchPromise; +} +``` + +### Step 4: Implement Invalidation + +```typescript +async invalidateMyData(id: string): Promise { + this.metrics.invalidations++; + await this.cache.del(`mydomain:${id}`); +} +``` + +### Step 5: Wire Up CDC Subscriber (if needed) + +```typescript +@Injectable() +export class MyDomainCdcSubscriber implements OnModuleInit { + constructor(private readonly myCache: MyDomainCacheService) {} + + private async handleEvent(data: unknown): Promise { + const id = extractId(data); + await this.myCache.invalidateMyData(id); + } +} +``` + +## Cache Key Conventions + +Use colon-separated hierarchical keys: + +``` +domain:type:identifier[:subkey] +``` + +Examples: +- `orders:account:001xx000003EgI1AAK` +- `orders:detail:80122000000D4UGAA0` +- `catalog:internet:acc_001:jp` +- `catalog:deps:product:01t22000003xABCAA2` +- `mapping:userId:user_12345` + +## Configuration + +### Environment Variables + +```env +# Redis connection +REDIS_URL=redis://localhost:6379 + +# Production (Docker Compose) +REDIS_URL=redis://cache:6379/0 +``` + +### Redis Module + +Location: `/infra/redis/redis.module.ts` + +Provides global `REDIS_CLIENT` using ioredis. + +## Monitoring + +### Health Checks + +```bash +# Overall system health (includes Redis check) +GET /health + +# Catalog cache metrics +GET /health/catalog/cache +``` + +### Response Format + +```json +{ + "timestamp": "2025-11-18T10:30:00.000Z", + "metrics": { + "catalog": { "hits": 1250, "misses": 48 }, + "static": { "hits": 890, "misses": 12 }, + "invalidations": 15 + }, + "ttl": { + "catalogSeconds": null, + "staticSeconds": null, + "volatileSeconds": 60 + } +} +``` + +## Best Practices + +### ✅ DO + +- Use CDC invalidation for Salesforce data +- Implement request coalescing for all cache services +- Track metrics (hits/misses/invalidations) +- Use structured, hierarchical cache keys +- Document TTL strategy in comments +- Add JSDoc comments to public methods + +### ❌ DON'T + +- Mix TTL and CDC strategies without clear reasoning +- Cache sensitive data without encryption +- Use cache as primary data store +- Forget to clean up in-flight requests (use `finally` block) +- Use overly generic cache keys (risk of collision) + +## Troubleshooting + +### High Cache Miss Rate + +1. Check CDC subscriber health: `GET /health/sf-events` +2. Verify invalidation patterns match expected keys +3. Review TTL settings (if applicable) + +### Stale Data Issues + +1. Confirm CDC events are being received +2. Check invalidation logic in CDC subscribers +3. Verify cache key construction matches between set/invalidate + +### Memory Issues + +```typescript +// Check memory usage +const usage = await cacheService.memoryUsageByPattern("orders:*"); +const count = await cacheService.countByPattern("orders:*"); + +console.log(`${count} keys using ${usage} bytes`); +``` + +## Related Documentation + +- [Salesforce CDC Events](../../integrations/salesforce/events/README.md) +- [Order Fulfillment Flow](../../modules/orders/docs/FULFILLMENT.md) +- [Redis Configuration](../redis/README.md) + diff --git a/apps/bff/src/infra/cache/cache.module.ts b/apps/bff/src/infra/cache/cache.module.ts index 586efeb5..37c6748b 100644 --- a/apps/bff/src/infra/cache/cache.module.ts +++ b/apps/bff/src/infra/cache/cache.module.ts @@ -1,9 +1,18 @@ import { Global, Module } from "@nestjs/common"; import { CacheService } from "./cache.service"; +/** + * Global cache module + * + * Provides Redis-backed caching infrastructure for the entire application. + * Exports CacheService for use in domain-specific cache services. + */ @Global() @Module({ providers: [CacheService], exports: [CacheService], }) export class CacheModule {} + +// Export shared types for domain-specific cache services +export * from "./cache.types"; diff --git a/apps/bff/src/infra/cache/cache.service.ts b/apps/bff/src/infra/cache/cache.service.ts index 47fd8b3a..b099874f 100644 --- a/apps/bff/src/infra/cache/cache.service.ts +++ b/apps/bff/src/infra/cache/cache.service.ts @@ -2,6 +2,20 @@ import { Inject, Injectable } from "@nestjs/common"; import Redis from "ioredis"; import { Logger } from "nestjs-pino"; +/** + * Core cache service + * + * Provides Redis-backed caching infrastructure for the entire application. + * This is a low-level service - domain-specific cache services should wrap this + * to provide higher-level caching patterns (CDC invalidation, request coalescing, etc). + * + * Features: + * - JSON serialization/deserialization + * - TTL support (optional) + * - Pattern-based operations (scan, delete, count) + * - Memory usage tracking + * - Automatic error handling and logging + */ @Injectable() export class CacheService { constructor( @@ -9,6 +23,10 @@ export class CacheService { @Inject(Logger) private readonly logger: Logger ) {} + /** + * Get a cached value by key + * Returns null if key doesn't exist or value cannot be parsed + */ async get(key: string): Promise { const value = await this.redis.get(key); if (!value) { @@ -24,6 +42,12 @@ export class CacheService { } } + /** + * Set a cached value + * @param key Cache key + * @param value Value to cache (will be JSON serialized) + * @param ttlSeconds Optional TTL in seconds (null = no expiry) + */ async set(key: string, value: unknown, ttlSeconds?: number): Promise { const serialized = JSON.stringify(value); if (ttlSeconds !== undefined) { @@ -40,10 +64,18 @@ export class CacheService { await this.redis.set(key, serialized); } + /** + * Delete a single cache key + */ async del(key: string): Promise { await this.redis.del(key); } + /** + * Delete all keys matching a pattern + * Uses SCAN for safe operation on large datasets + * @param pattern Redis pattern (e.g., "orders:*", "catalog:product:*") + */ async delPattern(pattern: string): Promise { const pipeline = this.redis.pipeline(); let pending = 0; @@ -69,6 +101,10 @@ export class CacheService { await flush(); } + /** + * Count keys matching a pattern + * @param pattern Redis pattern (e.g., "orders:*") + */ async countByPattern(pattern: string): Promise { let total = 0; await this.scanPattern(pattern, keys => { @@ -77,6 +113,11 @@ export class CacheService { return total; } + /** + * Get total memory usage of keys matching a pattern + * @param pattern Redis pattern (e.g., "orders:*") + * @returns Total memory usage in bytes + */ async memoryUsageByPattern(pattern: string): Promise { let total = 0; await this.scanPattern(pattern, async keys => { @@ -101,14 +142,30 @@ export class CacheService { return total; } + /** + * Check if a key exists + */ async exists(key: string): Promise { return (await this.redis.exists(key)) === 1; } + /** + * Build a structured cache key + * @param prefix Key prefix (e.g., "orders", "catalog") + * @param userId User/account identifier + * @param parts Additional key parts + * @returns Colon-separated key (e.g., "orders:user123:summary") + */ buildKey(prefix: string, userId: string, ...parts: string[]): string { return [prefix, userId, ...parts].join(":"); } + /** + * Get or set pattern: Fetch from cache, or call fetcher and cache result + * @param key Cache key + * @param fetcher Function to fetch fresh data on cache miss + * @param ttlSeconds TTL in seconds (default: 300) + */ async getOrSet(key: string, fetcher: () => Promise, ttlSeconds: number = 300): Promise { const cached = await this.get(key); if (cached !== null) { @@ -120,6 +177,12 @@ export class CacheService { return fresh; } + /** + * Scan keys matching a pattern and invoke callback for each batch + * Uses cursor-based iteration for safe operation on large datasets + * @param pattern Redis pattern + * @param onKeys Callback invoked for each batch of matching keys + */ private async scanPattern( pattern: string, onKeys: (keys: string[]) => Promise | void diff --git a/apps/bff/src/infra/cache/cache.types.ts b/apps/bff/src/infra/cache/cache.types.ts new file mode 100644 index 00000000..cc1daa15 --- /dev/null +++ b/apps/bff/src/infra/cache/cache.types.ts @@ -0,0 +1,46 @@ +/** + * Shared cache types and interfaces + * + * These types provide consistency across all domain-specific cache services. + */ + +/** + * Cache bucket metrics for hit/miss tracking + */ +export interface CacheBucketMetrics { + hits: number; + misses: number; +} + +/** + * Cache dependencies for tracking relationships between cached data + * Used for granular invalidation (e.g., invalidate all catalog entries that depend on Product X) + */ +export interface CacheDependencies { + productIds?: string[]; + accountIds?: string[]; + [key: string]: string[] | undefined; +} + +/** + * Options for cache operations + */ +export interface CacheOptions { + /** + * Whether to cache null values + * @default false + */ + allowNull?: boolean; + + /** + * TTL in seconds (null = no expiry, CDC-driven invalidation only) + * @default null + */ + ttl?: number | null; + + /** + * Callback to resolve cache dependencies for granular invalidation + */ + resolveDependencies?: (value: T) => CacheDependencies | Promise | undefined; +} + diff --git a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts index f1d22801..587e8ea8 100644 --- a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts +++ b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts @@ -1,10 +1,6 @@ import { Injectable } from "@nestjs/common"; import { CacheService } from "@bff/infra/cache/cache.service"; - -export interface CacheBucketMetrics { - hits: number; - misses: number; -} +import type { CacheBucketMetrics, CacheDependencies, CacheOptions } from "@bff/infra/cache/cache.types"; export interface CatalogCacheSnapshot { catalog: CacheBucketMetrics; @@ -14,30 +10,37 @@ export interface CatalogCacheSnapshot { invalidations: number; } -interface CacheDependencies { - productIds?: string[]; -} - -interface WrappedCatalogValue { - value: T | null; - __catalogCache: true; - dependencies?: CacheDependencies; +export interface CatalogCacheOptions { + allowNull?: boolean; + resolveDependencies?: ( + value: T + ) => CacheDependencies | Promise | undefined; } /** - * Catalog-specific caching service - * - * Implements intelligent caching for catalog data with appropriate TTLs - * to reduce load on Salesforce APIs while maintaining data freshness. + * Catalog cache service + * + * Uses CDC (Change Data Capture) for real-time cache invalidation with + * product dependency tracking for granular invalidation. + * + * Features: + * - CDC-driven invalidation: No TTL, cache persists until CDC event + * - Product dependency tracking: Granular invalidation by product IDs + * - Request coalescing: Prevents thundering herd on cache miss + * - Metrics tracking: Monitors hits, misses, and invalidations + * + * Cache buckets: + * - catalog: Product catalog data (CDC-driven) + * - static: Static reference data (CDC-driven) + * - eligibility: Account eligibility data (CDC-driven) + * - volatile: Frequently changing data (60s TTL) */ @Injectable() export class CatalogCacheService { - // Hybrid approach: CDC for real-time invalidation + TTL for backup cleanup - // Primary: CDC events invalidate cache when data changes (real-time) - // Backup: TTL expires unused cache entries (memory management) - private readonly CATALOG_TTL: number | null = null; // CDC-driven invalidation - private readonly STATIC_TTL: number | null = null; // CDC-driven invalidation - private readonly ELIGIBILITY_TTL: number | null = null; // CDC-driven invalidation + // CDC-driven invalidation: null TTL means cache persists until explicit invalidation + private readonly CATALOG_TTL: number | null = null; + private readonly STATIC_TTL: number | null = null; + private readonly ELIGIBILITY_TTL: number | null = null; private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL private readonly metrics: CatalogCacheSnapshot = { @@ -48,15 +51,14 @@ export class CatalogCacheService { invalidations: 0, }; - // Request coalescing: Prevent duplicate fetches for the same key - // When 100 users request catalog after CDC invalidation, only 1 Salesforce API call is made - // All 100 users wait for and share the same result + // Request coalescing: Prevents duplicate API calls when multiple users + // request the same data after CDC invalidation private readonly inflightRequests = new Map>(); constructor(private readonly cache: CacheService) {} /** - * Get or fetch catalog data (long-lived cache, event-driven invalidation) + * Get or fetch catalog data (CDC-driven cache, no TTL) */ async getCachedCatalog( key: string, @@ -67,21 +69,21 @@ export class CatalogCacheService { } /** - * Get or fetch static catalog data (long-lived cache) + * Get or fetch static catalog data (CDC-driven cache, no TTL) */ async getCachedStatic(key: string, fetchFn: () => Promise): Promise { return this.getOrSet("static", key, this.STATIC_TTL, fetchFn); } /** - * Get or fetch volatile catalog data with 1-minute TTL + * Get or fetch volatile catalog data (60s TTL) */ async getCachedVolatile(key: string, fetchFn: () => Promise): Promise { return this.getOrSet("volatile", key, this.VOLATILE_TTL, fetchFn); } /** - * Get or fetch eligibility data (long-lived cache) + * Get or fetch eligibility data (CDC-driven cache, no TTL) */ async getCachedEligibility(key: string, fetchFn: () => Promise): Promise { return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, { @@ -110,7 +112,7 @@ export class CatalogCacheService { } /** - * Invalidate eligibility caches for a specific account across all catalog types + * Invalidate eligibility caches for an account */ async invalidateEligibility(accountId: string): Promise { if (!accountId) return; @@ -119,7 +121,7 @@ export class CatalogCacheService { } /** - * Invalidate all catalog cache + * Invalidate all catalog cache entries */ async invalidateAllCatalogs(): Promise { this.metrics.invalidations++; @@ -127,6 +129,9 @@ export class CatalogCacheService { await this.flushProductDependencyIndex(); } + /** + * Get TTL configuration for monitoring + */ getTtlConfiguration(): { catalogSeconds: number | null; eligibilitySeconds: number | null; @@ -141,6 +146,9 @@ export class CatalogCacheService { }; } + /** + * Get cache metrics for monitoring + */ getMetrics(): CatalogCacheSnapshot { return { catalog: { ...this.metrics.catalog }, @@ -151,6 +159,9 @@ export class CatalogCacheService { }; } + /** + * Set eligibility value for an account + */ async setEligibilityValue( accountId: string, eligibility: string | null | undefined @@ -161,9 +172,9 @@ export class CatalogCacheService { ? { Id: accountId, Internet_Eligibility__c: eligibility } : null; if (this.ELIGIBILITY_TTL === null) { - await this.cache.set(key, this.wrapCachedValue(payload)); + await this.cache.set(key, payload); } else { - await this.cache.set(key, this.wrapCachedValue(payload), this.ELIGIBILITY_TTL); + await this.cache.set(key, payload, this.ELIGIBILITY_TTL); } } @@ -175,27 +186,24 @@ export class CatalogCacheService { options?: CatalogCacheOptions ): Promise { const allowNull = options?.allowNull ?? false; - // 1. Check Redis cache first (fastest path) - const cached = await this.cache.get(key); - const unwrapped = this.unwrapCachedValue(cached); + + // Check Redis cache first + const cached = await this.cache.get(key); - if (unwrapped.hit) { - if (allowNull || unwrapped.value !== null) { + if (cached !== null) { + if (allowNull || cached !== null) { this.metrics[bucket].hits++; - return unwrapped.value as T; + return cached; } } - // 2. Check if another request is already fetching this data (request coalescing) - // This prevents 100 simultaneous users from making 100 Salesforce API calls - // after CDC invalidates the cache - they all share 1 request instead + // Check for in-flight request (prevents thundering herd) const existingRequest = this.inflightRequests.get(key); if (existingRequest) { - // Wait for the in-flight request to complete and return its result return existingRequest as Promise; } - // 3. No cache hit and no in-flight request - fetch fresh data + // Fetch fresh data this.metrics[bucket].misses++; const fetchPromise = (async () => { @@ -206,34 +214,39 @@ export class CatalogCacheService { ? await options.resolveDependencies(fresh) : undefined; - if (unwrapped.dependencies) { - await this.unlinkDependenciesForKey(key, unwrapped.dependencies); + // Clean up old dependencies before storing new value + const existingDeps = await this.getDependencies(key); + if (existingDeps) { + await this.unlinkDependenciesForKey(key, existingDeps); } - // Store in Redis for future requests + // Store value in Redis if (ttlSeconds === null) { - await this.cache.set(key, this.wrapCachedValue(valueToStore, dependencies)); + await this.cache.set(key, valueToStore); } else { - await this.cache.set(key, this.wrapCachedValue(valueToStore, dependencies), ttlSeconds); + await this.cache.set(key, valueToStore, ttlSeconds); } + // Store and link dependencies separately if (dependencies) { + await this.storeDependencies(key, dependencies); await this.linkDependencies(key, dependencies); } return fresh; } finally { - // Clean up: Remove from in-flight map when done (success or failure) this.inflightRequests.delete(key); } })(); - // 4. Store the promise so concurrent requests can share it this.inflightRequests.set(key, fetchPromise); - return fetchPromise; } + /** + * Invalidate catalog entries by product IDs + * Returns true if any entries were invalidated, false if no matches found + */ async invalidateProducts(productIds: string[]): Promise { const uniqueIds = Array.from(new Set((productIds ?? []).filter(Boolean))); if (uniqueIds.length === 0) { @@ -257,52 +270,42 @@ export class CatalogCacheService { } for (const key of keysToInvalidate) { - const cached = await this.cache.get(key); - const unwrapped = this.unwrapCachedValue(cached); - if (unwrapped.dependencies) { - await this.unlinkDependenciesForKey(key, unwrapped.dependencies); + const deps = await this.getDependencies(key); + if (deps) { + await this.unlinkDependenciesForKey(key, deps); } await this.cache.del(key); + await this.cache.del(this.buildDependencyMetaKey(key)); this.metrics.invalidations++; } return true; } - private unwrapCachedValue(cached: unknown): { - hit: boolean; - value: T | null; - dependencies?: CacheDependencies; - } { - if (cached === null || cached === undefined) { - return { hit: false, value: null }; + /** + * Store dependencies metadata for a cache key + */ + private async storeDependencies(key: string, dependencies: CacheDependencies): Promise { + const normalized = this.normalizeDependencies(dependencies); + if (normalized) { + const metaKey = this.buildDependencyMetaKey(key); + await this.cache.set(metaKey, normalized); } - - if ( - typeof cached === "object" && - cached !== null && - Object.prototype.hasOwnProperty.call(cached, "__catalogCache") - ) { - const wrapper = cached as WrappedCatalogValue; - return { - hit: true, - value: wrapper.value ?? null, - dependencies: wrapper.dependencies, - }; - } - - return { hit: true, value: (cached as T) ?? null }; } - private wrapCachedValue( - value: T | null, - dependencies?: CacheDependencies - ): WrappedCatalogValue { - return { - value: value ?? null, - __catalogCache: true, - dependencies: dependencies && this.normalizeDependencies(dependencies), - }; + /** + * Get dependencies metadata for a cache key + */ + private async getDependencies(key: string): Promise { + const metaKey = this.buildDependencyMetaKey(key); + return await this.cache.get(metaKey); + } + + /** + * Build key for storing dependency metadata + */ + private buildDependencyMetaKey(cacheKey: string): string { + return `${cacheKey}:deps`; } private normalizeDependencies(dependencies: CacheDependencies): CacheDependencies | undefined { diff --git a/apps/bff/src/modules/orders/services/orders-cache.service.ts b/apps/bff/src/modules/orders/services/orders-cache.service.ts index 23c99d74..576c9974 100644 --- a/apps/bff/src/modules/orders/services/orders-cache.service.ts +++ b/apps/bff/src/modules/orders/services/orders-cache.service.ts @@ -1,78 +1,82 @@ import { Injectable } from "@nestjs/common"; import { CacheService } from "@bff/infra/cache/cache.service"; +import type { CacheBucketMetrics } from "@bff/infra/cache/cache.types"; import type { OrderDetails, OrderSummary } from "@customer-portal/domain/orders"; -interface CacheBucketMetrics { - hits: number; - misses: number; -} - interface OrdersCacheMetrics { summaries: CacheBucketMetrics; details: CacheBucketMetrics; invalidations: number; } +/** + * Orders cache service + * + * Uses CDC (Change Data Capture) for real-time cache invalidation. + * No TTL - cache entries persist until explicitly invalidated by CDC events. + * + * Features: + * - Request coalescing: Prevents thundering herd on cache miss + * - Granular invalidation: Account-level and order-level invalidation + * - Metrics tracking: Monitors hits, misses, and invalidations + */ @Injectable() export class OrdersCacheService { - // Hybrid approach: CDC for real-time invalidation + TTL for backup cleanup - // Primary: CDC events invalidate cache when customer-facing fields change - // Backup: TTL expires unused cache entries (memory management) - private readonly SUMMARY_TTL_SECONDS = 3600; // 1 hour - order lists - private readonly DETAIL_TTL_SECONDS = 7200; // 2 hours - individual orders - private readonly metrics: OrdersCacheMetrics = { summaries: { hits: 0, misses: 0 }, details: { hits: 0, misses: 0 }, invalidations: 0, }; - // Request coalescing: Prevent duplicate fetches for the same key - // When multiple users request the same order after CDC invalidation, only 1 Salesforce API call is made + // Request coalescing: Prevents duplicate API calls when multiple users + // request the same data after CDC invalidation private readonly inflightRequests = new Map>(); constructor(private readonly cache: CacheService) {} + /** + * Get order summaries for an account (CDC-driven cache) + */ async getOrderSummaries( sfAccountId: string, fetcher: () => Promise ): Promise { const key = this.buildAccountKey(sfAccountId); - return this.getOrSet( - "summaries", - key, - this.SUMMARY_TTL_SECONDS, - fetcher, - false - ); + return this.getOrSet("summaries", key, fetcher, false); } + /** + * Get order details by ID (CDC-driven cache) + */ async getOrderDetails( orderId: string, fetcher: () => Promise ): Promise { const key = this.buildOrderKey(orderId); - return this.getOrSet( - "details", - key, - this.DETAIL_TTL_SECONDS, - fetcher, - true - ); + return this.getOrSet("details", key, fetcher, true); } + /** + * Invalidate all cached orders for an account + */ async invalidateAccountOrders(sfAccountId: string): Promise { const key = this.buildAccountKey(sfAccountId); this.metrics.invalidations++; await this.cache.del(key); } + /** + * Invalidate cached order details + */ async invalidateOrder(orderId: string): Promise { const key = this.buildOrderKey(orderId); this.metrics.invalidations++; await this.cache.del(key); } + /** + * Get cache metrics for monitoring + */ getMetrics(): OrdersCacheMetrics { return { summaries: { ...this.metrics.summaries }, @@ -84,78 +88,43 @@ export class OrdersCacheService { private async getOrSet( bucket: keyof Pick, key: string, - ttlSeconds: number | null, fetcher: () => Promise, allowNull: boolean ): Promise { - // 1. Check Redis cache first (fastest path) - const cached = await this.cache.get(key); - const unwrapped = this.unwrapCachedValue(cached); - - if (unwrapped.hit) { - if (allowNull || unwrapped.value !== null) { + // Check Redis cache first + const cached = await this.cache.get(key); + + if (cached !== null) { + if (allowNull || cached !== null) { this.metrics[bucket].hits++; - return unwrapped.value as T; + return cached; } } - // 2. Check if another request is already fetching this data (request coalescing) - // This prevents duplicate Salesforce API calls after CDC invalidates the cache + // Check for in-flight request (prevents thundering herd) const existingRequest = this.inflightRequests.get(key); if (existingRequest) { - // Wait for the in-flight request to complete and return its result return existingRequest as Promise; } - // 3. No cache hit and no in-flight request - fetch fresh data + // Fetch fresh data this.metrics[bucket].misses++; const fetchPromise = (async () => { try { const fresh = await fetcher(); const valueToStore = allowNull ? (fresh ?? null) : fresh; - - // Store in Redis for future requests - if (ttlSeconds === null) { - await this.cache.set(key, this.wrapCachedValue(valueToStore)); - } else { - await this.cache.set(key, this.wrapCachedValue(valueToStore), ttlSeconds); - } - + await this.cache.set(key, valueToStore); return fresh; } finally { - // Clean up: Remove from in-flight map when done (success or failure) this.inflightRequests.delete(key); } })(); - // 4. Store the promise so concurrent requests can share it this.inflightRequests.set(key, fetchPromise); - return fetchPromise; } - private unwrapCachedValue(cached: unknown): { hit: boolean; value: T | null } { - if (cached === null || cached === undefined) { - return { hit: false, value: null }; - } - - if ( - typeof cached === "object" && - cached !== null && - Object.prototype.hasOwnProperty.call(cached, "__ordersCache") - ) { - const wrapper = cached as { value: T | null }; - return { hit: true, value: wrapper.value ?? null }; - } - - return { hit: true, value: (cached as T) ?? null }; - } - - private wrapCachedValue(value: T | null): { value: T | null; __ordersCache: true } { - return { value: value ?? null, __ordersCache: true }; - } - private buildAccountKey(sfAccountId: string): string { return `orders:account:${sfAccountId}`; } diff --git a/apps/portal/src/features/dashboard/components/UpcomingPaymentBanner.tsx b/apps/portal/src/features/dashboard/components/UpcomingPaymentBanner.tsx index 7cd1e6ef..1f2d9e81 100644 --- a/apps/portal/src/features/dashboard/components/UpcomingPaymentBanner.tsx +++ b/apps/portal/src/features/dashboard/components/UpcomingPaymentBanner.tsx @@ -5,9 +5,10 @@ import { CalendarDaysIcon, ChevronRightIcon } from "@heroicons/react/24/outline" import { format, formatDistanceToNow } from "date-fns"; import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; +import type { NextInvoice } from "@customer-portal/domain/dashboard"; interface UpcomingPaymentBannerProps { - invoice: { id: number; amount: number; currency?: string; dueDate: string }; + invoice: NextInvoice; onPay?: (invoiceId: number) => void; loading?: boolean; } diff --git a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx index fe22b142..cb8f1ead 100644 --- a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx +++ b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx @@ -3,6 +3,11 @@ import React, { useState } from "react"; import { apiClient } from "@/lib/api"; import { XMarkIcon } from "@heroicons/react/24/outline"; +import { + SIM_PLAN_OPTIONS, + type SimPlanCode, + getSimPlanLabel, +} from "@customer-portal/domain/sim"; interface ChangePlanModalProps { subscriptionId: number; @@ -19,20 +24,9 @@ export function ChangePlanModal({ onSuccess, onError, }: ChangePlanModalProps) { - const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const; - type PlanCode = (typeof PLAN_CODES)[number]; - const PLAN_LABELS: Record = { - PASI_5G: "5GB", - PASI_10G: "10GB", - PASI_25G: "25GB", - PASI_50G: "50GB", - }; + const allowedPlans = SIM_PLAN_OPTIONS.filter(option => option.code !== currentPlanCode); - const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter( - code => code !== (currentPlanCode || "") - ); - - const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>(""); + const [newPlanCode, setNewPlanCode] = useState<"" | SimPlanCode>(""); const [loading, setLoading] = useState(false); const submit = async () => { @@ -84,13 +78,13 @@ export function ChangePlanModal({ diff --git a/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx b/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx index 174c499d..2cea8308 100644 --- a/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx +++ b/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { formatPlanShort } from "@/lib/utils"; +import { formatSimPlanShort } from "@customer-portal/domain/sim"; import { DevicePhoneMobileIcon, CheckCircleIcon, @@ -106,7 +106,7 @@ export function SimDetailsCard({ return ; } - const planName = simDetails.planName || formatPlanShort(simDetails.planCode) || "SIM Plan"; + const planName = simDetails.planName || formatSimPlanShort(simDetails.planCode) || "SIM Plan"; const statusIcon = STATUS_ICON_MAP[simDetails.status] ?? ( ); diff --git a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx index 62e0e553..1b9f44cd 100644 --- a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx +++ b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx @@ -2,6 +2,10 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { apiClient } from "@/lib/api"; +import { + buildSimFeaturesUpdatePayload, + type SimFeatureToggleSnapshot, +} from "@customer-portal/domain/sim"; interface SimFeatureTogglesProps { subscriptionId: number; @@ -23,42 +27,42 @@ export function SimFeatureToggles({ embedded = false, }: SimFeatureTogglesProps) { // Initial values - const initial = useMemo( + const initial = useMemo( () => ({ - vm: !!voiceMailEnabled, - cw: !!callWaitingEnabled, - ir: !!internationalRoamingEnabled, - nt: networkType === "5G" ? "5G" : "4G", + voiceMailEnabled: !!voiceMailEnabled, + callWaitingEnabled: !!callWaitingEnabled, + internationalRoamingEnabled: !!internationalRoamingEnabled, + networkType: networkType === "5G" ? "5G" : "4G", }), [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType] ); // Working values - const [vm, setVm] = useState(initial.vm); - const [cw, setCw] = useState(initial.cw); - const [ir, setIr] = useState(initial.ir); - const [nt, setNt] = useState<"4G" | "5G">(initial.nt as "4G" | "5G"); + const [vm, setVm] = useState(initial.voiceMailEnabled); + const [cw, setCw] = useState(initial.callWaitingEnabled); + const [ir, setIr] = useState(initial.internationalRoamingEnabled); + const [nt, setNt] = useState<"4G" | "5G">(initial.networkType); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const successTimerRef = useRef(null); useEffect(() => { - setVm(initial.vm); - setCw(initial.cw); - setIr(initial.ir); - setNt(initial.nt as "4G" | "5G"); - }, [initial.vm, initial.cw, initial.ir, initial.nt]); + setVm(initial.voiceMailEnabled); + setCw(initial.callWaitingEnabled); + setIr(initial.internationalRoamingEnabled); + setNt(initial.networkType); + }, [initial]); const reset = () => { if (successTimerRef.current) { clearTimeout(successTimerRef.current); successTimerRef.current = null; } - setVm(initial.vm); - setCw(initial.cw); - setIr(initial.ir); - setNt(initial.nt as "4G" | "5G"); + setVm(initial.voiceMailEnabled); + setCw(initial.callWaitingEnabled); + setIr(initial.internationalRoamingEnabled); + setNt(initial.networkType); setError(null); setSuccess(null); }; @@ -68,22 +72,21 @@ export function SimFeatureToggles({ setError(null); setSuccess(null); try { - const featurePayload: { - voiceMailEnabled?: boolean; - callWaitingEnabled?: boolean; - internationalRoamingEnabled?: boolean; - networkType?: "4G" | "5G"; - } = {}; - if (vm !== initial.vm) featurePayload.voiceMailEnabled = vm; - if (cw !== initial.cw) featurePayload.callWaitingEnabled = cw; - if (ir !== initial.ir) featurePayload.internationalRoamingEnabled = ir; - if (nt !== initial.nt) featurePayload.networkType = nt; + const featurePayload = buildSimFeaturesUpdatePayload(initial, { + voiceMailEnabled: vm, + callWaitingEnabled: cw, + internationalRoamingEnabled: ir, + networkType: nt, + }); - if (Object.keys(featurePayload).length > 0) { + if (featurePayload) { await apiClient.POST("/api/subscriptions/{id}/sim/features", { params: { path: { id: subscriptionId } }, body: featurePayload, }); + } else { + setSuccess("No changes detected"); + return; } setSuccess("Changes submitted successfully"); @@ -146,8 +149,10 @@ export function SimFeatureToggles({
Current: - - {initial.vm ? "Enabled" : "Disabled"} + + {initial.voiceMailEnabled ? "Enabled" : "Disabled"}
@@ -190,8 +195,10 @@ export function SimFeatureToggles({
Current: - - {initial.cw ? "Enabled" : "Disabled"} + + {initial.callWaitingEnabled ? "Enabled" : "Disabled"}
@@ -234,8 +241,10 @@ export function SimFeatureToggles({
Current: - - {initial.ir ? "Enabled" : "Disabled"} + + {initial.internationalRoamingEnabled ? "Enabled" : "Disabled"}
@@ -278,7 +287,7 @@ export function SimFeatureToggles({
Current: - {initial.nt} + {initial.networkType}
setNewPlanCode(e.target.value as PlanCode)} + onChange={e => setNewPlanCode(e.target.value as SimPlanCode)} className="w-full px-3 py-2 border border-gray-300 rounded-md" > - {options.map(code => ( - ))} diff --git a/apps/portal/src/lib/utils/index.ts b/apps/portal/src/lib/utils/index.ts index 8c0a96fd..e084d70d 100644 --- a/apps/portal/src/lib/utils/index.ts +++ b/apps/portal/src/lib/utils/index.ts @@ -1,3 +1,2 @@ export { cn } from "./cn"; export { toUserMessage } from "./error-display"; -export { formatPlanShort } from "./plan"; diff --git a/apps/portal/src/lib/utils/plan.ts b/apps/portal/src/lib/utils/plan.ts deleted file mode 100644 index 8c535db5..00000000 --- a/apps/portal/src/lib/utils/plan.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Plan formatting utilities - */ - -export function formatPlanShort(planCode?: string): string { - if (!planCode) return "Unknown Plan"; - - // Convert plan codes to readable format - return planCode.replace(/[-_]/g, " ").replace(/\b\w/g, l => l.toUpperCase()); -} diff --git a/packages/domain/sim/contract.ts b/packages/domain/sim/contract.ts index 1d8706f6..c00061c0 100644 --- a/packages/domain/sim/contract.ts +++ b/packages/domain/sim/contract.ts @@ -27,6 +27,21 @@ export const SIM_TYPE = { ESIM: "esim", } as const; +// ============================================================================ +// SIM Plan Codes (frontend-safe subset; authoritative list lives in WHMCS) +// ============================================================================ + +export const SIM_PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const; + +export type SimPlanCode = (typeof SIM_PLAN_CODES)[number]; + +export const SIM_PLAN_LABELS: Record = { + PASI_5G: "5GB", + PASI_10G: "10GB", + PASI_25G: "25GB", + PASI_50G: "50GB", +}; + // ============================================================================ // Re-export Types from Schema (Schema-First Approach) // ============================================================================ diff --git a/packages/domain/sim/helpers.ts b/packages/domain/sim/helpers.ts index 2deef754..9f4b97c6 100644 --- a/packages/domain/sim/helpers.ts +++ b/packages/domain/sim/helpers.ts @@ -1,5 +1,5 @@ -import { SIM_STATUS } from "./contract"; -import type { SimStatus } from "./schema"; +import { SIM_PLAN_CODES, SIM_PLAN_LABELS, SIM_STATUS } from "./contract"; +import type { SimStatus, SimFeaturesUpdateRequest } from "./schema"; export function canManageActiveSim(status: SimStatus): boolean { return status === SIM_STATUS.ACTIVE; @@ -16,3 +16,69 @@ export function canCancelSim(status: SimStatus): boolean { export function canTopUpSim(status: SimStatus): boolean { return canManageActiveSim(status); } + +export function formatSimPlanShort(planCode?: string): string { + if (!planCode) return "—"; + const normalized = planCode.trim(); + const matchPrimary = normalized.match(/(?:^|[_-])(\d+(?:\.\d+)?)\s*G(?:B)?\b/i); + if (matchPrimary?.[1]) { + return `${matchPrimary[1]}G`; + } + const matchFallback = normalized.match(/(\d+(?:\.\d+)?)\s*G(?:B)?\b/i); + if (matchFallback?.[1]) { + return `${matchFallback[1]}G`; + } + return normalized; +} + +export type SimPlanOption = { + code: (typeof SIM_PLAN_CODES)[number]; + label: string; + shortLabel: string; +}; + +export const SIM_PLAN_OPTIONS: SimPlanOption[] = SIM_PLAN_CODES.map(code => ({ + code, + label: SIM_PLAN_LABELS[code], + shortLabel: formatSimPlanShort(code), +})); + +export function getSimPlanLabel(planCode?: string): string { + if (!planCode) return "Unknown plan"; + if (planCode in SIM_PLAN_LABELS) { + return SIM_PLAN_LABELS[planCode as keyof typeof SIM_PLAN_LABELS]; + } + return formatSimPlanShort(planCode); +} + +export type SimFeatureToggleSnapshot = { + voiceMailEnabled: boolean; + callWaitingEnabled: boolean; + internationalRoamingEnabled: boolean; + networkType: "4G" | "5G"; +}; + +export function buildSimFeaturesUpdatePayload( + current: SimFeatureToggleSnapshot, + next: SimFeatureToggleSnapshot +): SimFeaturesUpdateRequest | null { + const payload: SimFeaturesUpdateRequest = {}; + + if (current.voiceMailEnabled !== next.voiceMailEnabled) { + payload.voiceMailEnabled = next.voiceMailEnabled; + } + + if (current.callWaitingEnabled !== next.callWaitingEnabled) { + payload.callWaitingEnabled = next.callWaitingEnabled; + } + + if (current.internationalRoamingEnabled !== next.internationalRoamingEnabled) { + payload.internationalRoamingEnabled = next.internationalRoamingEnabled; + } + + if (current.networkType !== next.networkType) { + payload.networkType = next.networkType; + } + + return Object.keys(payload).length > 0 ? payload : null; +} diff --git a/packages/domain/sim/index.ts b/packages/domain/sim/index.ts index 15443025..62dd227b 100644 --- a/packages/domain/sim/index.ts +++ b/packages/domain/sim/index.ts @@ -7,7 +7,7 @@ */ // Constants -export { SIM_STATUS, SIM_TYPE } from "./contract"; +export { SIM_STATUS, SIM_TYPE, SIM_PLAN_CODES, SIM_PLAN_LABELS } from "./contract"; // Schemas (includes derived types) export * from "./schema"; @@ -15,7 +15,16 @@ export * from "./lifecycle"; // Validation functions export * from "./validation"; -export * from "./helpers"; +export { + canManageActiveSim, + canReissueEsim, + canCancelSim, + canTopUpSim, + formatSimPlanShort, + SIM_PLAN_OPTIONS, + getSimPlanLabel, + buildSimFeaturesUpdatePayload, +} from "./helpers"; // Re-export types for convenience export type { @@ -47,6 +56,8 @@ export type { SimTopUpPricingPreviewRequest, SimTopUpPricingPreviewResponse, } from './schema'; +export type { SimPlanCode } from "./contract"; +export type { SimPlanOption, SimFeatureToggleSnapshot } from "./helpers"; // Provider adapters export * as Providers from "./providers/index";