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.
This commit is contained in:
barsa 2025-11-18 18:18:25 +09:00
parent 44fd16e89f
commit cdfad9d036
18 changed files with 788 additions and 268 deletions

360
apps/bff/src/infra/cache/README.md vendored Normal file
View File

@ -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<OrderSummary[]>
): Promise<OrderSummary[]> {
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<InvoiceList | null> {
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<string, Promise<unknown>>();
// 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<string, Promise<unknown>>();
constructor(private readonly cache: CacheService) {}
}
```
### Step 3: Implement Get/Set Pattern
```typescript
async getMyData(id: string, fetcher: () => Promise<MyData>): Promise<MyData> {
const key = `mydomain:${id}`;
// Check cache
const cached = await this.cache.get<MyData>(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<MyData>;
// 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<void> {
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<void> {
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)

View File

@ -1,9 +1,18 @@
import { Global, Module } from "@nestjs/common"; import { Global, Module } from "@nestjs/common";
import { CacheService } from "./cache.service"; 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() @Global()
@Module({ @Module({
providers: [CacheService], providers: [CacheService],
exports: [CacheService], exports: [CacheService],
}) })
export class CacheModule {} export class CacheModule {}
// Export shared types for domain-specific cache services
export * from "./cache.types";

View File

@ -2,6 +2,20 @@ import { Inject, Injectable } from "@nestjs/common";
import Redis from "ioredis"; import Redis from "ioredis";
import { Logger } from "nestjs-pino"; 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() @Injectable()
export class CacheService { export class CacheService {
constructor( constructor(
@ -9,6 +23,10 @@ export class CacheService {
@Inject(Logger) private readonly logger: Logger @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<T>(key: string): Promise<T | null> { async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key); const value = await this.redis.get(key);
if (!value) { 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<void> { async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
const serialized = JSON.stringify(value); const serialized = JSON.stringify(value);
if (ttlSeconds !== undefined) { if (ttlSeconds !== undefined) {
@ -40,10 +64,18 @@ export class CacheService {
await this.redis.set(key, serialized); await this.redis.set(key, serialized);
} }
/**
* Delete a single cache key
*/
async del(key: string): Promise<void> { async del(key: string): Promise<void> {
await this.redis.del(key); 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<void> { async delPattern(pattern: string): Promise<void> {
const pipeline = this.redis.pipeline(); const pipeline = this.redis.pipeline();
let pending = 0; let pending = 0;
@ -69,6 +101,10 @@ export class CacheService {
await flush(); await flush();
} }
/**
* Count keys matching a pattern
* @param pattern Redis pattern (e.g., "orders:*")
*/
async countByPattern(pattern: string): Promise<number> { async countByPattern(pattern: string): Promise<number> {
let total = 0; let total = 0;
await this.scanPattern(pattern, keys => { await this.scanPattern(pattern, keys => {
@ -77,6 +113,11 @@ export class CacheService {
return total; 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<number> { async memoryUsageByPattern(pattern: string): Promise<number> {
let total = 0; let total = 0;
await this.scanPattern(pattern, async keys => { await this.scanPattern(pattern, async keys => {
@ -101,14 +142,30 @@ export class CacheService {
return total; return total;
} }
/**
* Check if a key exists
*/
async exists(key: string): Promise<boolean> { async exists(key: string): Promise<boolean> {
return (await this.redis.exists(key)) === 1; 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 { buildKey(prefix: string, userId: string, ...parts: string[]): string {
return [prefix, userId, ...parts].join(":"); 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<T>(key: string, fetcher: () => Promise<T>, ttlSeconds: number = 300): Promise<T> { async getOrSet<T>(key: string, fetcher: () => Promise<T>, ttlSeconds: number = 300): Promise<T> {
const cached = await this.get<T>(key); const cached = await this.get<T>(key);
if (cached !== null) { if (cached !== null) {
@ -120,6 +177,12 @@ export class CacheService {
return fresh; 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( private async scanPattern(
pattern: string, pattern: string,
onKeys: (keys: string[]) => Promise<void> | void onKeys: (keys: string[]) => Promise<void> | void

46
apps/bff/src/infra/cache/cache.types.ts vendored Normal file
View File

@ -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<T> {
/**
* 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<CacheDependencies | undefined> | undefined;
}

View File

@ -1,10 +1,6 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { CacheService } from "@bff/infra/cache/cache.service"; import { CacheService } from "@bff/infra/cache/cache.service";
import type { CacheBucketMetrics, CacheDependencies, CacheOptions } from "@bff/infra/cache/cache.types";
export interface CacheBucketMetrics {
hits: number;
misses: number;
}
export interface CatalogCacheSnapshot { export interface CatalogCacheSnapshot {
catalog: CacheBucketMetrics; catalog: CacheBucketMetrics;
@ -14,30 +10,37 @@ export interface CatalogCacheSnapshot {
invalidations: number; invalidations: number;
} }
interface CacheDependencies { export interface CatalogCacheOptions<T> {
productIds?: string[]; allowNull?: boolean;
} resolveDependencies?: (
value: T
interface WrappedCatalogValue<T> { ) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined;
value: T | null;
__catalogCache: true;
dependencies?: CacheDependencies;
} }
/** /**
* Catalog-specific caching service * Catalog cache service
* *
* Implements intelligent caching for catalog data with appropriate TTLs * Uses CDC (Change Data Capture) for real-time cache invalidation with
* to reduce load on Salesforce APIs while maintaining data freshness. * 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() @Injectable()
export class CatalogCacheService { export class CatalogCacheService {
// Hybrid approach: CDC for real-time invalidation + TTL for backup cleanup // CDC-driven invalidation: null TTL means cache persists until explicit invalidation
// Primary: CDC events invalidate cache when data changes (real-time) private readonly CATALOG_TTL: number | null = null;
// Backup: TTL expires unused cache entries (memory management) private readonly STATIC_TTL: number | null = null;
private readonly CATALOG_TTL: number | null = null; // CDC-driven invalidation private readonly ELIGIBILITY_TTL: number | null = null;
private readonly STATIC_TTL: number | null = null; // CDC-driven invalidation
private readonly ELIGIBILITY_TTL: number | null = null; // CDC-driven invalidation
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
private readonly metrics: CatalogCacheSnapshot = { private readonly metrics: CatalogCacheSnapshot = {
@ -48,15 +51,14 @@ export class CatalogCacheService {
invalidations: 0, invalidations: 0,
}; };
// Request coalescing: Prevent duplicate fetches for the same key // Request coalescing: Prevents duplicate API calls when multiple users
// When 100 users request catalog after CDC invalidation, only 1 Salesforce API call is made // request the same data after CDC invalidation
// All 100 users wait for and share the same result
private readonly inflightRequests = new Map<string, Promise<unknown>>(); private readonly inflightRequests = new Map<string, Promise<unknown>>();
constructor(private readonly cache: CacheService) {} 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<T>( async getCachedCatalog<T>(
key: string, 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<T>(key: string, fetchFn: () => Promise<T>): Promise<T> { async getCachedStatic<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
return this.getOrSet("static", key, this.STATIC_TTL, fetchFn); 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<T>(key: string, fetchFn: () => Promise<T>): Promise<T> { async getCachedVolatile<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
return this.getOrSet("volatile", key, this.VOLATILE_TTL, fetchFn); 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<T>(key: string, fetchFn: () => Promise<T>): Promise<T> { async getCachedEligibility<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, { 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<void> { async invalidateEligibility(accountId: string): Promise<void> {
if (!accountId) return; if (!accountId) return;
@ -119,7 +121,7 @@ export class CatalogCacheService {
} }
/** /**
* Invalidate all catalog cache * Invalidate all catalog cache entries
*/ */
async invalidateAllCatalogs(): Promise<void> { async invalidateAllCatalogs(): Promise<void> {
this.metrics.invalidations++; this.metrics.invalidations++;
@ -127,6 +129,9 @@ export class CatalogCacheService {
await this.flushProductDependencyIndex(); await this.flushProductDependencyIndex();
} }
/**
* Get TTL configuration for monitoring
*/
getTtlConfiguration(): { getTtlConfiguration(): {
catalogSeconds: number | null; catalogSeconds: number | null;
eligibilitySeconds: number | null; eligibilitySeconds: number | null;
@ -141,6 +146,9 @@ export class CatalogCacheService {
}; };
} }
/**
* Get cache metrics for monitoring
*/
getMetrics(): CatalogCacheSnapshot { getMetrics(): CatalogCacheSnapshot {
return { return {
catalog: { ...this.metrics.catalog }, catalog: { ...this.metrics.catalog },
@ -151,6 +159,9 @@ export class CatalogCacheService {
}; };
} }
/**
* Set eligibility value for an account
*/
async setEligibilityValue( async setEligibilityValue(
accountId: string, accountId: string,
eligibility: string | null | undefined eligibility: string | null | undefined
@ -161,9 +172,9 @@ export class CatalogCacheService {
? { Id: accountId, Internet_Eligibility__c: eligibility } ? { Id: accountId, Internet_Eligibility__c: eligibility }
: null; : null;
if (this.ELIGIBILITY_TTL === null) { if (this.ELIGIBILITY_TTL === null) {
await this.cache.set(key, this.wrapCachedValue(payload)); await this.cache.set(key, payload);
} else { } 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<T> options?: CatalogCacheOptions<T>
): Promise<T> { ): Promise<T> {
const allowNull = options?.allowNull ?? false; const allowNull = options?.allowNull ?? false;
// 1. Check Redis cache first (fastest path)
const cached = await this.cache.get<unknown>(key); // Check Redis cache first
const unwrapped = this.unwrapCachedValue<T>(cached); const cached = await this.cache.get<T>(key);
if (unwrapped.hit) { if (cached !== null) {
if (allowNull || unwrapped.value !== null) { if (allowNull || cached !== null) {
this.metrics[bucket].hits++; this.metrics[bucket].hits++;
return unwrapped.value as T; return cached;
} }
} }
// 2. Check if another request is already fetching this data (request coalescing) // Check for in-flight request (prevents thundering herd)
// This prevents 100 simultaneous users from making 100 Salesforce API calls
// after CDC invalidates the cache - they all share 1 request instead
const existingRequest = this.inflightRequests.get(key); const existingRequest = this.inflightRequests.get(key);
if (existingRequest) { if (existingRequest) {
// Wait for the in-flight request to complete and return its result
return existingRequest as Promise<T>; return existingRequest as Promise<T>;
} }
// 3. No cache hit and no in-flight request - fetch fresh data // Fetch fresh data
this.metrics[bucket].misses++; this.metrics[bucket].misses++;
const fetchPromise = (async () => { const fetchPromise = (async () => {
@ -206,34 +214,39 @@ export class CatalogCacheService {
? await options.resolveDependencies(fresh) ? await options.resolveDependencies(fresh)
: undefined; : undefined;
if (unwrapped.dependencies) { // Clean up old dependencies before storing new value
await this.unlinkDependenciesForKey(key, unwrapped.dependencies); 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) { if (ttlSeconds === null) {
await this.cache.set(key, this.wrapCachedValue(valueToStore, dependencies)); await this.cache.set(key, valueToStore);
} else { } 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) { if (dependencies) {
await this.storeDependencies(key, dependencies);
await this.linkDependencies(key, dependencies); await this.linkDependencies(key, dependencies);
} }
return fresh; return fresh;
} finally { } finally {
// Clean up: Remove from in-flight map when done (success or failure)
this.inflightRequests.delete(key); this.inflightRequests.delete(key);
} }
})(); })();
// 4. Store the promise so concurrent requests can share it
this.inflightRequests.set(key, fetchPromise); this.inflightRequests.set(key, fetchPromise);
return 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<boolean> { async invalidateProducts(productIds: string[]): Promise<boolean> {
const uniqueIds = Array.from(new Set((productIds ?? []).filter(Boolean))); const uniqueIds = Array.from(new Set((productIds ?? []).filter(Boolean)));
if (uniqueIds.length === 0) { if (uniqueIds.length === 0) {
@ -257,52 +270,42 @@ export class CatalogCacheService {
} }
for (const key of keysToInvalidate) { for (const key of keysToInvalidate) {
const cached = await this.cache.get<unknown>(key); const deps = await this.getDependencies(key);
const unwrapped = this.unwrapCachedValue<unknown>(cached); if (deps) {
if (unwrapped.dependencies) { await this.unlinkDependenciesForKey(key, deps);
await this.unlinkDependenciesForKey(key, unwrapped.dependencies);
} }
await this.cache.del(key); await this.cache.del(key);
await this.cache.del(this.buildDependencyMetaKey(key));
this.metrics.invalidations++; this.metrics.invalidations++;
} }
return true; return true;
} }
private unwrapCachedValue<T>(cached: unknown): { /**
hit: boolean; * Store dependencies metadata for a cache key
value: T | null; */
dependencies?: CacheDependencies; private async storeDependencies(key: string, dependencies: CacheDependencies): Promise<void> {
} { const normalized = this.normalizeDependencies(dependencies);
if (cached === null || cached === undefined) { if (normalized) {
return { hit: false, value: null }; 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<T>;
return {
hit: true,
value: wrapper.value ?? null,
dependencies: wrapper.dependencies,
};
}
return { hit: true, value: (cached as T) ?? null };
} }
private wrapCachedValue<T>( /**
value: T | null, * Get dependencies metadata for a cache key
dependencies?: CacheDependencies */
): WrappedCatalogValue<T> { private async getDependencies(key: string): Promise<CacheDependencies | null> {
return { const metaKey = this.buildDependencyMetaKey(key);
value: value ?? null, return await this.cache.get<CacheDependencies>(metaKey);
__catalogCache: true, }
dependencies: dependencies && this.normalizeDependencies(dependencies),
}; /**
* Build key for storing dependency metadata
*/
private buildDependencyMetaKey(cacheKey: string): string {
return `${cacheKey}:deps`;
} }
private normalizeDependencies(dependencies: CacheDependencies): CacheDependencies | undefined { private normalizeDependencies(dependencies: CacheDependencies): CacheDependencies | undefined {

View File

@ -1,78 +1,82 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { CacheService } from "@bff/infra/cache/cache.service"; 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"; import type { OrderDetails, OrderSummary } from "@customer-portal/domain/orders";
interface CacheBucketMetrics {
hits: number;
misses: number;
}
interface OrdersCacheMetrics { interface OrdersCacheMetrics {
summaries: CacheBucketMetrics; summaries: CacheBucketMetrics;
details: CacheBucketMetrics; details: CacheBucketMetrics;
invalidations: number; 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() @Injectable()
export class OrdersCacheService { 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 = { private readonly metrics: OrdersCacheMetrics = {
summaries: { hits: 0, misses: 0 }, summaries: { hits: 0, misses: 0 },
details: { hits: 0, misses: 0 }, details: { hits: 0, misses: 0 },
invalidations: 0, invalidations: 0,
}; };
// Request coalescing: Prevent duplicate fetches for the same key // Request coalescing: Prevents duplicate API calls when multiple users
// When multiple users request the same order after CDC invalidation, only 1 Salesforce API call is made // request the same data after CDC invalidation
private readonly inflightRequests = new Map<string, Promise<unknown>>(); private readonly inflightRequests = new Map<string, Promise<unknown>>();
constructor(private readonly cache: CacheService) {} constructor(private readonly cache: CacheService) {}
/**
* Get order summaries for an account (CDC-driven cache)
*/
async getOrderSummaries( async getOrderSummaries(
sfAccountId: string, sfAccountId: string,
fetcher: () => Promise<OrderSummary[]> fetcher: () => Promise<OrderSummary[]>
): Promise<OrderSummary[]> { ): Promise<OrderSummary[]> {
const key = this.buildAccountKey(sfAccountId); const key = this.buildAccountKey(sfAccountId);
return this.getOrSet<OrderSummary[]>( return this.getOrSet<OrderSummary[]>("summaries", key, fetcher, false);
"summaries",
key,
this.SUMMARY_TTL_SECONDS,
fetcher,
false
);
} }
/**
* Get order details by ID (CDC-driven cache)
*/
async getOrderDetails( async getOrderDetails(
orderId: string, orderId: string,
fetcher: () => Promise<OrderDetails | null> fetcher: () => Promise<OrderDetails | null>
): Promise<OrderDetails | null> { ): Promise<OrderDetails | null> {
const key = this.buildOrderKey(orderId); const key = this.buildOrderKey(orderId);
return this.getOrSet<OrderDetails | null>( return this.getOrSet<OrderDetails | null>("details", key, fetcher, true);
"details",
key,
this.DETAIL_TTL_SECONDS,
fetcher,
true
);
} }
/**
* Invalidate all cached orders for an account
*/
async invalidateAccountOrders(sfAccountId: string): Promise<void> { async invalidateAccountOrders(sfAccountId: string): Promise<void> {
const key = this.buildAccountKey(sfAccountId); const key = this.buildAccountKey(sfAccountId);
this.metrics.invalidations++; this.metrics.invalidations++;
await this.cache.del(key); await this.cache.del(key);
} }
/**
* Invalidate cached order details
*/
async invalidateOrder(orderId: string): Promise<void> { async invalidateOrder(orderId: string): Promise<void> {
const key = this.buildOrderKey(orderId); const key = this.buildOrderKey(orderId);
this.metrics.invalidations++; this.metrics.invalidations++;
await this.cache.del(key); await this.cache.del(key);
} }
/**
* Get cache metrics for monitoring
*/
getMetrics(): OrdersCacheMetrics { getMetrics(): OrdersCacheMetrics {
return { return {
summaries: { ...this.metrics.summaries }, summaries: { ...this.metrics.summaries },
@ -84,78 +88,43 @@ export class OrdersCacheService {
private async getOrSet<T>( private async getOrSet<T>(
bucket: keyof Pick<OrdersCacheMetrics, "summaries" | "details">, bucket: keyof Pick<OrdersCacheMetrics, "summaries" | "details">,
key: string, key: string,
ttlSeconds: number | null,
fetcher: () => Promise<T>, fetcher: () => Promise<T>,
allowNull: boolean allowNull: boolean
): Promise<T> { ): Promise<T> {
// 1. Check Redis cache first (fastest path) // Check Redis cache first
const cached = await this.cache.get<unknown>(key); const cached = await this.cache.get<T>(key);
const unwrapped = this.unwrapCachedValue<T>(cached);
if (cached !== null) {
if (unwrapped.hit) { if (allowNull || cached !== null) {
if (allowNull || unwrapped.value !== null) {
this.metrics[bucket].hits++; this.metrics[bucket].hits++;
return unwrapped.value as T; return cached;
} }
} }
// 2. Check if another request is already fetching this data (request coalescing) // Check for in-flight request (prevents thundering herd)
// This prevents duplicate Salesforce API calls after CDC invalidates the cache
const existingRequest = this.inflightRequests.get(key); const existingRequest = this.inflightRequests.get(key);
if (existingRequest) { if (existingRequest) {
// Wait for the in-flight request to complete and return its result
return existingRequest as Promise<T>; return existingRequest as Promise<T>;
} }
// 3. No cache hit and no in-flight request - fetch fresh data // Fetch fresh data
this.metrics[bucket].misses++; this.metrics[bucket].misses++;
const fetchPromise = (async () => { const fetchPromise = (async () => {
try { try {
const fresh = await fetcher(); const fresh = await fetcher();
const valueToStore = allowNull ? (fresh ?? null) : fresh; const valueToStore = allowNull ? (fresh ?? null) : fresh;
await this.cache.set(key, valueToStore);
// 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);
}
return fresh; return fresh;
} finally { } finally {
// Clean up: Remove from in-flight map when done (success or failure)
this.inflightRequests.delete(key); this.inflightRequests.delete(key);
} }
})(); })();
// 4. Store the promise so concurrent requests can share it
this.inflightRequests.set(key, fetchPromise); this.inflightRequests.set(key, fetchPromise);
return fetchPromise; return fetchPromise;
} }
private unwrapCachedValue<T>(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<T>(value: T | null): { value: T | null; __ordersCache: true } {
return { value: value ?? null, __ordersCache: true };
}
private buildAccountKey(sfAccountId: string): string { private buildAccountKey(sfAccountId: string): string {
return `orders:account:${sfAccountId}`; return `orders:account:${sfAccountId}`;
} }

View File

@ -5,9 +5,10 @@ import { CalendarDaysIcon, ChevronRightIcon } from "@heroicons/react/24/outline"
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
import type { NextInvoice } from "@customer-portal/domain/dashboard";
interface UpcomingPaymentBannerProps { interface UpcomingPaymentBannerProps {
invoice: { id: number; amount: number; currency?: string; dueDate: string }; invoice: NextInvoice;
onPay?: (invoiceId: number) => void; onPay?: (invoiceId: number) => void;
loading?: boolean; loading?: boolean;
} }

View File

@ -3,6 +3,11 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import {
SIM_PLAN_OPTIONS,
type SimPlanCode,
getSimPlanLabel,
} from "@customer-portal/domain/sim";
interface ChangePlanModalProps { interface ChangePlanModalProps {
subscriptionId: number; subscriptionId: number;
@ -19,20 +24,9 @@ export function ChangePlanModal({
onSuccess, onSuccess,
onError, onError,
}: ChangePlanModalProps) { }: ChangePlanModalProps) {
const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const; const allowedPlans = SIM_PLAN_OPTIONS.filter(option => option.code !== currentPlanCode);
type PlanCode = (typeof PLAN_CODES)[number];
const PLAN_LABELS: Record<PlanCode, string> = {
PASI_5G: "5GB",
PASI_10G: "10GB",
PASI_25G: "25GB",
PASI_50G: "50GB",
};
const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter( const [newPlanCode, setNewPlanCode] = useState<"" | SimPlanCode>("");
code => code !== (currentPlanCode || "")
);
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
@ -84,13 +78,13 @@ export function ChangePlanModal({
</label> </label>
<select <select
value={newPlanCode} value={newPlanCode}
onChange={e => setNewPlanCode(e.target.value as PlanCode)} onChange={e => setNewPlanCode(e.target.value as SimPlanCode)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
> >
<option value="">Choose a plan</option> <option value="">Choose a plan</option>
{allowedPlans.map(code => ( {allowedPlans.map(option => (
<option key={code} value={code}> <option key={option.code} value={option.code}>
{PLAN_LABELS[code]} {getSimPlanLabel(option.code)}
</option> </option>
))} ))}
</select> </select>

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { formatPlanShort } from "@/lib/utils"; import { formatSimPlanShort } from "@customer-portal/domain/sim";
import { import {
DevicePhoneMobileIcon, DevicePhoneMobileIcon,
CheckCircleIcon, CheckCircleIcon,
@ -106,7 +106,7 @@ export function SimDetailsCard({
return <ErrorCard embedded={embedded} message={error} />; return <ErrorCard embedded={embedded} message={error} />;
} }
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] ?? ( const statusIcon = STATUS_ICON_MAP[simDetails.status] ?? (
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" /> <DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" />
); );

View File

@ -2,6 +2,10 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import {
buildSimFeaturesUpdatePayload,
type SimFeatureToggleSnapshot,
} from "@customer-portal/domain/sim";
interface SimFeatureTogglesProps { interface SimFeatureTogglesProps {
subscriptionId: number; subscriptionId: number;
@ -23,42 +27,42 @@ export function SimFeatureToggles({
embedded = false, embedded = false,
}: SimFeatureTogglesProps) { }: SimFeatureTogglesProps) {
// Initial values // Initial values
const initial = useMemo( const initial = useMemo<SimFeatureToggleSnapshot>(
() => ({ () => ({
vm: !!voiceMailEnabled, voiceMailEnabled: !!voiceMailEnabled,
cw: !!callWaitingEnabled, callWaitingEnabled: !!callWaitingEnabled,
ir: !!internationalRoamingEnabled, internationalRoamingEnabled: !!internationalRoamingEnabled,
nt: networkType === "5G" ? "5G" : "4G", networkType: networkType === "5G" ? "5G" : "4G",
}), }),
[voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType] [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]
); );
// Working values // Working values
const [vm, setVm] = useState(initial.vm); const [vm, setVm] = useState(initial.voiceMailEnabled);
const [cw, setCw] = useState(initial.cw); const [cw, setCw] = useState(initial.callWaitingEnabled);
const [ir, setIr] = useState(initial.ir); const [ir, setIr] = useState(initial.internationalRoamingEnabled);
const [nt, setNt] = useState<"4G" | "5G">(initial.nt as "4G" | "5G"); const [nt, setNt] = useState<"4G" | "5G">(initial.networkType);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const successTimerRef = useRef<number | null>(null); const successTimerRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
setVm(initial.vm); setVm(initial.voiceMailEnabled);
setCw(initial.cw); setCw(initial.callWaitingEnabled);
setIr(initial.ir); setIr(initial.internationalRoamingEnabled);
setNt(initial.nt as "4G" | "5G"); setNt(initial.networkType);
}, [initial.vm, initial.cw, initial.ir, initial.nt]); }, [initial]);
const reset = () => { const reset = () => {
if (successTimerRef.current) { if (successTimerRef.current) {
clearTimeout(successTimerRef.current); clearTimeout(successTimerRef.current);
successTimerRef.current = null; successTimerRef.current = null;
} }
setVm(initial.vm); setVm(initial.voiceMailEnabled);
setCw(initial.cw); setCw(initial.callWaitingEnabled);
setIr(initial.ir); setIr(initial.internationalRoamingEnabled);
setNt(initial.nt as "4G" | "5G"); setNt(initial.networkType);
setError(null); setError(null);
setSuccess(null); setSuccess(null);
}; };
@ -68,22 +72,21 @@ export function SimFeatureToggles({
setError(null); setError(null);
setSuccess(null); setSuccess(null);
try { try {
const featurePayload: { const featurePayload = buildSimFeaturesUpdatePayload(initial, {
voiceMailEnabled?: boolean; voiceMailEnabled: vm,
callWaitingEnabled?: boolean; callWaitingEnabled: cw,
internationalRoamingEnabled?: boolean; internationalRoamingEnabled: ir,
networkType?: "4G" | "5G"; networkType: nt,
} = {}; });
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;
if (Object.keys(featurePayload).length > 0) { if (featurePayload) {
await apiClient.POST("/api/subscriptions/{id}/sim/features", { await apiClient.POST("/api/subscriptions/{id}/sim/features", {
params: { path: { id: subscriptionId } }, params: { path: { id: subscriptionId } },
body: featurePayload, body: featurePayload,
}); });
} else {
setSuccess("No changes detected");
return;
} }
setSuccess("Changes submitted successfully"); setSuccess("Changes submitted successfully");
@ -146,8 +149,10 @@ export function SimFeatureToggles({
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="text-sm"> <div className="text-sm">
<span className="text-gray-500">Current: </span> <span className="text-gray-500">Current: </span>
<span className={`font-medium ${initial.vm ? "text-green-600" : "text-gray-600"}`}> <span
{initial.vm ? "Enabled" : "Disabled"} className={`font-medium ${initial.voiceMailEnabled ? "text-green-600" : "text-gray-600"}`}
>
{initial.voiceMailEnabled ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
<div className="text-gray-400"></div> <div className="text-gray-400"></div>
@ -190,8 +195,10 @@ export function SimFeatureToggles({
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="text-sm"> <div className="text-sm">
<span className="text-gray-500">Current: </span> <span className="text-gray-500">Current: </span>
<span className={`font-medium ${initial.cw ? "text-green-600" : "text-gray-600"}`}> <span
{initial.cw ? "Enabled" : "Disabled"} className={`font-medium ${initial.callWaitingEnabled ? "text-green-600" : "text-gray-600"}`}
>
{initial.callWaitingEnabled ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
<div className="text-gray-400"></div> <div className="text-gray-400"></div>
@ -234,8 +241,10 @@ export function SimFeatureToggles({
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="text-sm"> <div className="text-sm">
<span className="text-gray-500">Current: </span> <span className="text-gray-500">Current: </span>
<span className={`font-medium ${initial.ir ? "text-green-600" : "text-gray-600"}`}> <span
{initial.ir ? "Enabled" : "Disabled"} className={`font-medium ${initial.internationalRoamingEnabled ? "text-green-600" : "text-gray-600"}`}
>
{initial.internationalRoamingEnabled ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
<div className="text-gray-400"></div> <div className="text-gray-400"></div>
@ -278,7 +287,7 @@ export function SimFeatureToggles({
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="text-sm"> <div className="text-sm">
<span className="text-gray-500">Current: </span> <span className="text-gray-500">Current: </span>
<span className="font-medium text-blue-600">{initial.nt}</span> <span className="font-medium text-blue-600">{initial.networkType}</span>
</div> </div>
<div className="text-gray-400"></div> <div className="text-gray-400"></div>
<select <select

View File

@ -4,7 +4,10 @@ import React, { useState, useEffect } from "react";
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import { useSimTopUpPricing } from "../hooks/useSimTopUpPricing"; import { useSimTopUpPricing } from "../hooks/useSimTopUpPricing";
import type { SimTopUpPricingPreviewResponse } from "@customer-portal/domain/sim"; import {
simTopUpRequestSchema,
type SimTopUpPricingPreviewResponse,
} from "@customer-portal/domain/sim";
interface TopUpModalProps { interface TopUpModalProps {
subscriptionId: number; subscriptionId: number;
@ -60,13 +63,18 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
setLoading(true); setLoading(true);
try { try {
const requestBody = { const validationResult = simTopUpRequestSchema.safeParse({
quotaMb: getCurrentAmountMb(), quotaMb: getCurrentAmountMb(),
}; });
if (!validationResult.success) {
onError(validationResult.error.issues[0]?.message ?? "Invalid top-up amount");
return;
}
await apiClient.POST("/api/subscriptions/{id}/sim/top-up", { await apiClient.POST("/api/subscriptions/{id}/sim/top-up", {
params: { path: { id: subscriptionId } }, params: { path: { id: subscriptionId } },
body: requestBody, body: validationResult.data,
}); });
onSuccess(); onSuccess();

View File

@ -1,20 +0,0 @@
// Generic plan code formatter for SIM plans
// Examples:
// - PASI_10G -> 10G
// - PASI_25G -> 25G
// - ANY_PREFIX_50GB -> 50G
// - Fallback: return the original code when unknown
export function formatPlanShort(planCode?: string): string {
if (!planCode) return "—";
const m = planCode.match(/(?:^|[_-])(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
if (m && m[1]) {
return `${m[1]}G`;
}
// Try extracting trailing number+G anywhere in the string
const m2 = planCode.match(/(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
if (m2 && m2[1]) {
return `${m2[1]}G`;
}
return planCode;
}

View File

@ -9,20 +9,17 @@ import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service"; import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const; import {
type PlanCode = (typeof PLAN_CODES)[number]; SIM_PLAN_OPTIONS,
const PLAN_LABELS: Record<PlanCode, string> = { type SimPlanCode,
PASI_5G: "5GB", getSimPlanLabel,
PASI_10G: "10GB", } from "@customer-portal/domain/sim";
PASI_25G: "25GB",
PASI_50G: "50GB",
};
export function SimChangePlanContainer() { export function SimChangePlanContainer() {
const params = useParams(); const params = useParams();
const subscriptionId = params.id as string; const subscriptionId = params.id as string;
const [currentPlanCode] = useState<string>(""); const [currentPlanCode] = useState<string>("");
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>(""); const [newPlanCode, setNewPlanCode] = useState<"" | SimPlanCode>("");
const [assignGlobalIp, setAssignGlobalIp] = useState(false); const [assignGlobalIp, setAssignGlobalIp] = useState(false);
const [scheduledAt, setScheduledAt] = useState(""); const [scheduledAt, setScheduledAt] = useState("");
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
@ -30,7 +27,7 @@ export function SimChangePlanContainer() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const options = useMemo( const options = useMemo(
() => (PLAN_CODES as readonly PlanCode[]).filter(c => c !== (currentPlanCode as PlanCode)), () => SIM_PLAN_OPTIONS.filter(option => option.code !== currentPlanCode),
[currentPlanCode] [currentPlanCode]
); );
@ -100,13 +97,13 @@ export function SimChangePlanContainer() {
<label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label> <label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label>
<select <select
value={newPlanCode} value={newPlanCode}
onChange={e => 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" className="w-full px-3 py-2 border border-gray-300 rounded-md"
> >
<option value="">Choose a plan</option> <option value="">Choose a plan</option>
{options.map(code => ( {options.map(option => (
<option key={code} value={code}> <option key={option.code} value={option.code}>
{PLAN_LABELS[code]} {getSimPlanLabel(option.code)}
</option> </option>
))} ))}
</select> </select>

View File

@ -1,3 +1,2 @@
export { cn } from "./cn"; export { cn } from "./cn";
export { toUserMessage } from "./error-display"; export { toUserMessage } from "./error-display";
export { formatPlanShort } from "./plan";

View File

@ -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());
}

View File

@ -27,6 +27,21 @@ export const SIM_TYPE = {
ESIM: "esim", ESIM: "esim",
} as const; } 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<SimPlanCode, string> = {
PASI_5G: "5GB",
PASI_10G: "10GB",
PASI_25G: "25GB",
PASI_50G: "50GB",
};
// ============================================================================ // ============================================================================
// Re-export Types from Schema (Schema-First Approach) // Re-export Types from Schema (Schema-First Approach)
// ============================================================================ // ============================================================================

View File

@ -1,5 +1,5 @@
import { SIM_STATUS } from "./contract"; import { SIM_PLAN_CODES, SIM_PLAN_LABELS, SIM_STATUS } from "./contract";
import type { SimStatus } from "./schema"; import type { SimStatus, SimFeaturesUpdateRequest } from "./schema";
export function canManageActiveSim(status: SimStatus): boolean { export function canManageActiveSim(status: SimStatus): boolean {
return status === SIM_STATUS.ACTIVE; return status === SIM_STATUS.ACTIVE;
@ -16,3 +16,69 @@ export function canCancelSim(status: SimStatus): boolean {
export function canTopUpSim(status: SimStatus): boolean { export function canTopUpSim(status: SimStatus): boolean {
return canManageActiveSim(status); 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;
}

View File

@ -7,7 +7,7 @@
*/ */
// Constants // 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) // Schemas (includes derived types)
export * from "./schema"; export * from "./schema";
@ -15,7 +15,16 @@ export * from "./lifecycle";
// Validation functions // Validation functions
export * from "./validation"; export * from "./validation";
export * from "./helpers"; export {
canManageActiveSim,
canReissueEsim,
canCancelSim,
canTopUpSim,
formatSimPlanShort,
SIM_PLAN_OPTIONS,
getSimPlanLabel,
buildSimFeaturesUpdatePayload,
} from "./helpers";
// Re-export types for convenience // Re-export types for convenience
export type { export type {
@ -47,6 +56,8 @@ export type {
SimTopUpPricingPreviewRequest, SimTopUpPricingPreviewRequest,
SimTopUpPricingPreviewResponse, SimTopUpPricingPreviewResponse,
} from './schema'; } from './schema';
export type { SimPlanCode } from "./contract";
export type { SimPlanOption, SimFeatureToggleSnapshot } from "./helpers";
// Provider adapters // Provider adapters
export * as Providers from "./providers/index"; export * as Providers from "./providers/index";