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:
parent
44fd16e89f
commit
cdfad9d036
360
apps/bff/src/infra/cache/README.md
vendored
Normal file
360
apps/bff/src/infra/cache/README.md
vendored
Normal 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)
|
||||||
|
|
||||||
9
apps/bff/src/infra/cache/cache.module.ts
vendored
9
apps/bff/src/infra/cache/cache.module.ts
vendored
@ -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";
|
||||||
|
|||||||
63
apps/bff/src/infra/cache/cache.service.ts
vendored
63
apps/bff/src/infra/cache/cache.service.ts
vendored
@ -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
46
apps/bff/src/infra/cache/cache.types.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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" />
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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";
|
|
||||||
|
|||||||
@ -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());
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user