This commit is contained in:
tema 2026-01-05 15:59:09 +09:00
commit 2b001809c3
57 changed files with 2273 additions and 1584 deletions

View File

@ -34,6 +34,9 @@ import { ServicesModule } from "@bff/modules/services/services.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { BillingModule } from "@bff/modules/billing/billing.module.js";
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
import { SimManagementModule } from "@bff/modules/subscriptions/sim-management/sim-management.module.js";
import { InternetManagementModule } from "@bff/modules/subscriptions/internet-management/internet-management.module.js";
import { CallHistoryModule } from "@bff/modules/subscriptions/call-history/call-history.module.js";
import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
import { SupportModule } from "@bff/modules/support/support.module.js";
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
@ -88,6 +91,9 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
OrdersModule,
BillingModule,
SubscriptionsModule,
SimManagementModule,
InternetManagementModule,
CallHistoryModule,
CurrencyModule,
SupportModule,
RealtimeApiModule,

View File

@ -111,16 +111,16 @@ export const envSchema = z.object({
// Default ON: the portal relies on Salesforce events for real-time cache invalidation.
SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("true"),
SF_CATALOG_EVENT_CHANNEL: z.string().default("/event/Product_and_Pricebook_Change__e"),
SF_ACCOUNT_EVENT_CHANNEL: z.string().default("/event/Account_Internet_Eligibility_Update__e"),
SF_ACCOUNT_EVENT_CHANNEL: z.string().default("/event/Account_Eligibility_Update__e"),
SF_CASE_EVENT_CHANNEL: z.string().default("/event/Case_Status_Update__e"),
SF_EVENTS_REPLAY: z.enum(["LATEST", "ALL"]).default("LATEST"),
SF_PUBSUB_NUM_REQUESTED: z.string().default("25"),
SF_PUBSUB_QUEUE_MAX: z.string().default("100"),
SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"),
// CDC-specific channels (using /data/ prefix for Change Data Capture)
// CDC channels (using /data/ prefix for Change Data Capture)
SF_CATALOG_PRODUCT_CDC_CHANNEL: z.string().default("/data/Product2ChangeEvent"),
SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL: z.string().default("/data/PricebookEntryChangeEvent"),
SF_ACCOUNT_ELIGIBILITY_CHANNEL: z.string().optional(),
SF_ORDER_CDC_CHANNEL: z.string().default("/data/OrderChangeEvent"),
SF_ORDER_ITEM_CDC_CHANNEL: z.string().default("/data/OrderItemChangeEvent"),

View File

@ -7,6 +7,9 @@ import { ServicesModule } from "@bff/modules/services/services.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { BillingModule } from "@bff/modules/billing/billing.module.js";
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
import { SimManagementModule } from "@bff/modules/subscriptions/sim-management/sim-management.module.js";
import { InternetManagementModule } from "@bff/modules/subscriptions/internet-management/internet-management.module.js";
import { CallHistoryModule } from "@bff/modules/subscriptions/call-history/call-history.module.js";
import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
import { SecurityModule } from "@bff/core/security/security.module.js";
import { SupportModule } from "@bff/modules/support/support.module.js";
@ -26,6 +29,9 @@ export const apiRoutes: Routes = [
{ path: "", module: OrdersModule },
{ path: "", module: BillingModule },
{ path: "", module: SubscriptionsModule },
{ path: "", module: SimManagementModule },
{ path: "", module: InternetManagementModule },
{ path: "", module: CallHistoryModule },
{ path: "", module: CurrencyModule },
{ path: "", module: SupportModule },
{ path: "", module: SecurityModule },

View File

@ -39,8 +39,6 @@ export const ACCOUNT_FIELDS = {
status: "Internet_Eligibility_Status__c",
requestedAt: "Internet_Eligibility_Request_Date_Time__c",
checkedAt: "Internet_Eligibility_Checked_Date_Time__c",
notes: "Internet_Eligibility_Notes__c",
caseId: "Internet_Eligibility_Case_Id__c",
},
// ID verification

View File

@ -0,0 +1,128 @@
/**
* Salesforce Account Platform Events Subscriber
*
* Handles real-time Account Platform Events for:
* - Eligibility status changes
* - Verification status changes
*
* When events are received:
* 1. Invalidate Redis caches (eligibility + verification)
* 2. Send SSE to connected portal clients
* 3. Create in-app notifications (for final status changes)
*
* @see docs/integrations/salesforce/platform-events.md
*/
import { Injectable, Inject, Optional } from "@nestjs/common";
import type { OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import {
PubSubClientService,
isDataCallback,
extractPayload,
extractStringField,
} from "./shared/index.js";
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
import { AccountNotificationHandler } from "@bff/modules/notifications/account-cdc-listener.service.js";
@Injectable()
export class AccountEventsSubscriber implements OnModuleInit {
constructor(
private readonly config: ConfigService,
private readonly pubSubClient: PubSubClientService,
private readonly servicesCache: ServicesCacheService,
private readonly realtime: RealtimeService,
@Inject(Logger) private readonly logger: Logger,
@Optional() private readonly accountNotificationHandler?: AccountNotificationHandler
) {}
async onModuleInit(): Promise<void> {
const channel = this.config.get<string>("SF_ACCOUNT_EVENT_CHANNEL")?.trim();
if (!channel) {
this.logger.warn("SF_ACCOUNT_EVENT_CHANNEL not configured; skipping account events");
return;
}
try {
await this.pubSubClient.subscribe(
channel,
this.handleAccountEvent.bind(this, channel),
"account-platform-event"
);
} catch (error) {
this.logger.warn("Failed to subscribe to Account Platform Events", {
channel,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Handle Account Platform Events (eligibility + verification updates)
*
* Salesforce Flow fires this event when these fields change:
* - Internet_Eligibility__c, Internet_Eligibility_Status__c
* - Id_Verification_Status__c, Id_Verification_Rejection_Message__c
*
* Platform Event fields:
* - Account_Id__c (required)
* - Eligibility_Status__c (only if changed)
* - Verification_Status__c (only if changed)
* - Rejection_Message__c (when rejected)
*
* Actions:
* - ALWAYS invalidate both caches
* - ALWAYS send SSE to portal
* - Create notification only for final states (eligible/verified/rejected)
*/
private async handleAccountEvent(
channel: string,
subscription: { topicName?: string },
callbackType: string,
data: unknown
): Promise<void> {
if (!isDataCallback(callbackType)) return;
const payload = extractPayload(data);
const accountId = extractStringField(payload, ["Account_Id__c"]);
if (!accountId) {
this.logger.warn("Account event missing Account_Id__c", { channel });
return;
}
const eligibilityStatus = extractStringField(payload, ["Eligibility_Status__c"]);
const verificationStatus = extractStringField(payload, ["Verification_Status__c"]);
const rejectionMessage = extractStringField(payload, ["Rejection_Message__c"]);
this.logger.log("Account platform event received", {
channel,
accountIdTail: accountId.slice(-4),
hasEligibilityStatus: eligibilityStatus !== undefined,
hasVerificationStatus: verificationStatus !== undefined,
});
// ALWAYS invalidate caches
await this.servicesCache.invalidateEligibility(accountId);
await this.servicesCache.invalidateVerification(accountId);
// ALWAYS notify portal to refetch
this.realtime.publish(`account:sf:${accountId}`, "account.updated", {
timestamp: new Date().toISOString(),
});
// Create notifications for status changes (handler filters to final states)
if (this.accountNotificationHandler && (eligibilityStatus || verificationStatus)) {
void this.accountNotificationHandler.processAccountEvent({
accountId,
eligibilityStatus,
eligibilityValue: undefined,
verificationStatus,
verificationRejectionMessage: rejectionMessage,
});
}
}
}

View File

@ -0,0 +1,98 @@
/**
* Salesforce Case Platform Events Subscriber
*
* Handles real-time Case Platform Events for:
* - Case status updates
*
* Uses POKE approach: invalidate cache + SSE portal refetches
*
* @see docs/integrations/salesforce/platform-events.md
*/
import { Injectable, Inject } from "@nestjs/common";
import type { OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import {
PubSubClientService,
isDataCallback,
extractPayload,
extractStringField,
} from "./shared/index.js";
import { SupportCacheService } from "@bff/modules/support/support-cache.service.js";
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
@Injectable()
export class CaseEventsSubscriber implements OnModuleInit {
constructor(
private readonly config: ConfigService,
private readonly pubSubClient: PubSubClientService,
private readonly supportCache: SupportCacheService,
private readonly realtime: RealtimeService,
@Inject(Logger) private readonly logger: Logger
) {}
async onModuleInit(): Promise<void> {
const channel = this.config.get<string>("SF_CASE_EVENT_CHANNEL")?.trim();
if (!channel) {
this.logger.debug("SF_CASE_EVENT_CHANNEL not configured; skipping case events");
return;
}
try {
await this.pubSubClient.subscribe(
channel,
this.handleCaseEvent.bind(this, channel),
"case-platform-event"
);
} catch (error) {
this.logger.warn("Failed to subscribe to Case Platform Events", {
channel,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Handle Case Platform Events (Case_Status_Update__e)
*
* Required fields:
* - Case_Id__c
* - Account_Id__c
*/
private async handleCaseEvent(
channel: string,
subscription: { topicName?: string },
callbackType: string,
data: unknown
): Promise<void> {
if (!isDataCallback(callbackType)) return;
const payload = extractPayload(data);
const caseId = extractStringField(payload, ["Case_Id__c"]);
const accountId = extractStringField(payload, ["Account_Id__c"]);
if (!caseId) {
this.logger.warn("Case event missing Case_Id__c", { channel });
return;
}
this.logger.log("Case platform event received", {
channel,
caseIdTail: caseId.slice(-4),
accountIdTail: accountId?.slice(-4),
});
await this.supportCache.invalidateCaseMessages(caseId);
if (accountId) {
await this.supportCache.invalidateCaseList(accountId);
this.realtime.publish(`account:sf:${accountId}`, "support.case.changed", {
caseId,
timestamp: new Date().toISOString(),
});
}
}
}

View File

@ -0,0 +1,188 @@
/**
* Salesforce Catalog CDC (Change Data Capture) Subscriber
*
* Handles real-time Product2 and PricebookEntry changes from Salesforce.
*
* When catalog data changes:
* 1. Invalidate relevant Redis caches
* 2. Send SSE to connected portal clients to refetch
*
* @see docs/integrations/salesforce/platform-events.md
*/
import { Injectable, Inject } from "@nestjs/common";
import type { OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import {
PubSubClientService,
isDataCallback,
extractPayload,
extractStringField,
extractRecordIds,
} from "./shared/index.js";
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
@Injectable()
export class CatalogCdcSubscriber implements OnModuleInit {
constructor(
private readonly config: ConfigService,
private readonly pubSubClient: PubSubClientService,
private readonly catalogCache: ServicesCacheService,
private readonly realtime: RealtimeService,
@Inject(Logger) private readonly logger: Logger
) {}
async onModuleInit(): Promise<void> {
const enabled = this.config.get("SF_EVENTS_ENABLED", "true") === "true";
if (!enabled) {
this.logger.debug("Catalog CDC disabled (SF_EVENTS_ENABLED=false)");
return;
}
try {
await this.subscribeToProductCdc();
await this.subscribeToPricebookCdc();
} catch (error) {
this.logger.warn("Failed to initialize Catalog CDC subscriber", {
error: error instanceof Error ? error.message : String(error),
});
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Subscription Setup
// ─────────────────────────────────────────────────────────────────────────────
private async subscribeToProductCdc(): Promise<void> {
const channel =
this.config.get<string>("SF_CATALOG_PRODUCT_CDC_CHANNEL")?.trim() ||
"/data/Product2ChangeEvent";
await this.pubSubClient.subscribe(
channel,
this.handleProductEvent.bind(this, channel),
"product-cdc"
);
}
private async subscribeToPricebookCdc(): Promise<void> {
const channel =
this.config.get<string>("SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL")?.trim() ||
"/data/PricebookEntryChangeEvent";
await this.pubSubClient.subscribe(
channel,
this.handlePricebookEvent.bind(this, channel),
"pricebook-cdc"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// CDC Handlers
// ─────────────────────────────────────────────────────────────────────────────
private async handleProductEvent(
channel: string,
subscription: { topicName?: string },
callbackType: string,
data: unknown
): Promise<void> {
if (!isDataCallback(callbackType)) return;
const payload = extractPayload(data);
const productIds = extractRecordIds(payload);
this.logger.log("Product2 CDC event received", {
channel,
topicName: subscription.topicName,
productIds,
});
const invalidated = await this.catalogCache.invalidateProducts(productIds);
if (!invalidated) {
this.logger.debug(
"No catalog entries linked to product IDs; falling back to full invalidation",
{
channel,
productIds,
}
);
await this.invalidateAllServices();
this.realtime.publish("global:services", "services.changed", {
reason: "product.cdc.fallback_full_invalidation",
timestamp: new Date().toISOString(),
});
return;
}
this.realtime.publish("global:services", "services.changed", {
reason: "product.cdc",
timestamp: new Date().toISOString(),
});
}
private async handlePricebookEvent(
channel: string,
subscription: { topicName?: string },
callbackType: string,
data: unknown
): Promise<void> {
if (!isDataCallback(callbackType)) return;
const payload = extractPayload(data);
const pricebookId = extractStringField(payload, ["PricebookId", "Pricebook2Id"]);
// Ignore events for non-portal pricebooks
const portalPricebookId = this.config.get<string>("PORTAL_PRICEBOOK_ID");
if (portalPricebookId && pricebookId && pricebookId !== portalPricebookId) {
this.logger.debug("Ignoring pricebook event for non-portal pricebook", {
channel,
pricebookId,
});
return;
}
const productId = extractStringField(payload, ["Product2Id", "ProductId"]);
this.logger.log("PricebookEntry CDC event received", {
channel,
pricebookId,
productId,
});
const invalidated = await this.catalogCache.invalidateProducts(productId ? [productId] : []);
if (!invalidated) {
this.logger.debug("No catalog entries linked to pricebook product; full invalidation", {
channel,
pricebookId,
productId,
});
await this.invalidateAllServices();
this.realtime.publish("global:services", "services.changed", {
reason: "pricebook.cdc.fallback_full_invalidation",
timestamp: new Date().toISOString(),
});
return;
}
this.realtime.publish("global:services", "services.changed", {
reason: "pricebook.cdc",
timestamp: new Date().toISOString(),
});
}
private async invalidateAllServices(): Promise<void> {
try {
await this.catalogCache.invalidateAllServices();
} catch (error) {
this.logger.warn("Failed to invalidate services caches", {
error: error instanceof Error ? error.message : String(error),
});
}
}
}

View File

@ -1,10 +1,33 @@
/**
* Salesforce Events Module
*
* Provides real-time event handling for Salesforce Platform Events and CDC.
*
* Platform Events (/event/ channels):
* - AccountEventsSubscriber: Eligibility/verification status updates
* - CaseEventsSubscriber: Support case status updates
*
* CDC - Change Data Capture (/data/ channels):
* - CatalogCdcSubscriber: Product2/PricebookEntry changes
* - OrderCdcSubscriber: Order/OrderItem changes
*
* Shared:
* - PubSubClientService: Salesforce Pub/Sub API client management
*
* @see docs/integrations/salesforce/platform-events.md
*/
import { Module, forwardRef } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
import { ServicesCdcSubscriber } from "./services-cdc.subscriber.js";
import { SupportModule } from "@bff/modules/support/support.module.js";
import { PubSubClientService } from "./shared/index.js";
import { AccountEventsSubscriber } from "./account-events.subscriber.js";
import { CaseEventsSubscriber } from "./case-events.subscriber.js";
import { CatalogCdcSubscriber } from "./catalog-cdc.subscriber.js";
import { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
@Module({
@ -14,10 +37,17 @@ import { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
forwardRef(() => OrdersModule),
forwardRef(() => ServicesModule),
forwardRef(() => NotificationsModule),
forwardRef(() => SupportModule),
],
providers: [
ServicesCdcSubscriber, // CDC for services cache invalidation + notifications
OrderCdcSubscriber, // CDC for order cache invalidation
PubSubClientService,
// Platform Events
AccountEventsSubscriber,
CaseEventsSubscriber,
// CDC
CatalogCdcSubscriber,
OrderCdcSubscriber,
],
exports: [PubSubClientService],
})
export class SalesforceEventsModule {}

View File

@ -0,0 +1,23 @@
/**
* Salesforce Events - Public API
*/
// Module
export { SalesforceEventsModule } from "./events.module.js";
// Platform Events
export { AccountEventsSubscriber } from "./account-events.subscriber.js";
export { CaseEventsSubscriber } from "./case-events.subscriber.js";
// CDC
export { CatalogCdcSubscriber } from "./catalog-cdc.subscriber.js";
export { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
// Shared
export {
PubSubClientService,
type PubSubCallback,
type PubSubClient,
type PubSubClientConstructor,
type ChangeEventHeader,
} from "./shared/index.js";

View File

@ -1,371 +1,198 @@
import { Injectable, Inject } from "@nestjs/common";
import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
import { SalesforceConnection } from "../services/salesforce-connection.service.js";
import { OrdersCacheService } from "@bff/modules/orders/services/orders-cache.service.js";
import { ProvisioningQueueService } from "@bff/modules/orders/queue/provisioning.queue.js";
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
type PubSubCallback = (
subscription: { topicName?: string },
callbackType: string,
data: unknown
) => void | Promise<void>;
interface PubSubClient {
connect(): Promise<void>;
subscribe(topic: string, cb: PubSubCallback, numRequested?: number): Promise<void>;
close(): Promise<void>;
}
type PubSubCtor = new (opts: {
authType: string;
accessToken: string;
instanceUrl: string;
pubSubEndpoint: string;
}) => PubSubClient;
/**
* CDC Subscriber for Order changes
* Salesforce Order CDC (Change Data Capture) Subscriber
*
* Handles real-time Order and OrderItem changes from Salesforce.
*
* Strategy:
* 1. Trigger provisioning when Salesforce sets Activation_Status__c to "Activating"
* 2. Only invalidate cache for customer-facing field changes, NOT internal system fields
*
* CUSTOMER-FACING FIELDS (invalidate cache):
* - Status (Draft, Pending Review, Completed, Cancelled)
* - TotalAmount
* - BillingAddress, BillingCity, etc.
* - Customer-visible custom fields
* Customer-facing fields (invalidate cache):
* - Status, TotalAmount, BillingAddress, etc.
*
* INTERNAL SYSTEM FIELDS (ignore - updated by fulfillment):
* - Activation_Status__c (Activating, Activated, Failed)
* - WHMCS_Order_ID__c
* - Activation_Error_Code__c
* - Activation_Error_Message__c
* - Activation_Last_Attempt_At__c
* - WHMCS_Service_ID__c (on OrderItem)
* Internal system fields (ignore):
* - Activation_Status__c, WHMCS_Order_ID__c, Activation_Error_*
*
* WHY: The fulfillment flow already invalidates cache when it completes.
* Why: Fulfillment flow already invalidates cache on completion.
* CDC should only catch external changes made by admins in Salesforce UI.
*
* @see docs/integrations/salesforce/platform-events.md
*/
@Injectable()
export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
private client: PubSubClient | null = null;
private pubSubCtor: PubSubCtor | null = null;
private orderChannel: string | null = null;
private orderItemChannel: string | null = null;
private readonly numRequested: number;
// Internal fields that are updated by fulfillment process - ignore these
private readonly INTERNAL_FIELDS = new Set([
import { Injectable, Inject } from "@nestjs/common";
import type { OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import {
PubSubClientService,
isDataCallback,
extractPayload,
extractStringField,
extractChangeEventHeader,
extractChangedFields,
} from "./shared/index.js";
import { OrdersCacheService } from "@bff/modules/orders/services/orders-cache.service.js";
import { ProvisioningQueueService } from "@bff/modules/orders/queue/provisioning.queue.js";
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
/** Fields updated by internal fulfillment process - ignore for cache invalidation */
const INTERNAL_ORDER_FIELDS = new Set([
"Activation_Status__c",
"WHMCS_Order_ID__c",
"Activation_Error_Code__c",
"Activation_Error_Message__c",
"Activation_Last_Attempt_At__c",
"ActivatedDate",
]);
]);
// Internal OrderItem fields - ignore these
private readonly INTERNAL_ORDER_ITEM_FIELDS = new Set(["WHMCS_Service_ID__c"]);
/** Internal OrderItem fields - ignore for cache invalidation */
const INTERNAL_ORDER_ITEM_FIELDS = new Set(["WHMCS_Service_ID__c"]);
// Statuses that trigger provisioning
private readonly PROVISION_TRIGGER_STATUSES = new Set(["Approved", "Reactivate"]);
/** Statuses that trigger provisioning */
const PROVISION_TRIGGER_STATUSES = new Set(["Approved", "Reactivate"]);
@Injectable()
export class OrderCdcSubscriber implements OnModuleInit {
constructor(
private readonly config: ConfigService,
private readonly sfConnection: SalesforceConnection,
private readonly pubSubClient: PubSubClientService,
private readonly ordersCache: OrdersCacheService,
private readonly provisioningQueue: ProvisioningQueueService,
private readonly realtime: RealtimeService,
@Inject(Logger) private readonly logger: Logger
) {
this.numRequested = this.resolveNumRequested();
}
) {}
async onModuleInit(): Promise<void> {
const enabled = this.config.get("SF_EVENTS_ENABLED", "false") === "true";
if (!enabled) {
this.logger.debug("Salesforce CDC for orders is disabled (SF_EVENTS_ENABLED=false)");
this.logger.debug("Order CDC disabled (SF_EVENTS_ENABLED=false)");
return;
}
const orderChannel =
try {
await this.subscribeToOrderCdc();
await this.subscribeToOrderItemCdc();
} catch (error) {
this.logger.warn("Failed to initialize Order CDC subscriber", {
error: error instanceof Error ? error.message : String(error),
});
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Subscription Setup
// ─────────────────────────────────────────────────────────────────────────────
private async subscribeToOrderCdc(): Promise<void> {
const channel =
this.config.get<string>("SF_ORDER_CDC_CHANNEL")?.trim() || "/data/OrderChangeEvent";
const orderItemChannel =
await this.pubSubClient.subscribe(
channel,
this.handleOrderEvent.bind(this, channel),
"order-cdc"
);
}
private async subscribeToOrderItemCdc(): Promise<void> {
const channel =
this.config.get<string>("SF_ORDER_ITEM_CDC_CHANNEL")?.trim() || "/data/OrderItemChangeEvent";
this.logger.log("Initializing Salesforce Order CDC subscriber", {
orderChannel,
orderItemChannel,
});
try {
const client = await this.ensureClient();
this.orderChannel = orderChannel;
await this.subscribeWithDiagnostics(
client,
orderChannel,
this.handleOrderEvent.bind(this, orderChannel),
"order"
await this.pubSubClient.subscribe(
channel,
this.handleOrderItemEvent.bind(this, channel),
"order-item-cdc"
);
this.orderItemChannel = orderItemChannel;
await this.subscribeWithDiagnostics(
client,
orderItemChannel,
this.handleOrderItemEvent.bind(this, orderItemChannel),
"order_item"
);
} catch (error) {
this.logger.warn("Failed to initialize order CDC subscriber", {
error: error instanceof Error ? error.message : String(error),
});
}
}
async onModuleDestroy(): Promise<void> {
if (!this.client) return;
try {
await this.client.close();
} catch (error) {
this.logger.warn("Failed to close Order CDC subscriber cleanly", {
error: error instanceof Error ? error.message : String(error),
});
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Order CDC Handler
// ─────────────────────────────────────────────────────────────────────────────
private async ensureClient(): Promise<PubSubClient> {
if (this.client) {
return this.client;
}
const ctor = this.loadPubSubCtor();
await this.sfConnection.connect();
const accessToken = this.sfConnection.getAccessToken();
const instanceUrl = this.sfConnection.getInstanceUrl();
if (!accessToken || !instanceUrl) {
throw new Error("Salesforce access token or instance URL missing for CDC subscriber");
}
const pubSubEndpoint =
this.config.get<string>("SF_PUBSUB_ENDPOINT") || "api.pubsub.salesforce.com:7443";
const client = new ctor({
authType: "user-supplied",
accessToken,
instanceUrl,
pubSubEndpoint,
});
await client.connect();
this.client = client;
return client;
}
private async subscribeWithDiagnostics(
client: PubSubClient,
channel: string,
handler: PubSubCallback,
label: string
): Promise<void> {
this.logger.log("Attempting Salesforce CDC subscription", {
channel,
label,
numRequested: this.numRequested,
});
try {
await client.subscribe(channel, handler, this.numRequested);
this.logger.log("Successfully subscribed to Salesforce CDC channel", {
channel,
label,
numRequested: this.numRequested,
});
} catch (error) {
this.logger.error("Salesforce CDC subscription failed", {
channel,
label,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
private loadPubSubCtor(): PubSubCtor {
if (this.pubSubCtor) {
return this.pubSubCtor;
}
const maybeCtor = (PubSubApiClientPkg as unknown as PubSubCtor) ?? null;
const maybeDefault = (PubSubApiClientPkg as { default?: PubSubCtor }).default ?? null;
const ctor = typeof maybeCtor === "function" ? maybeCtor : maybeDefault;
if (!ctor) {
throw new Error("Failed to load Salesforce Pub/Sub client constructor");
}
this.pubSubCtor = ctor;
return this.pubSubCtor;
}
private resolveNumRequested(): number {
const raw = this.config.get<string>("SF_PUBSUB_NUM_REQUESTED") ?? "25";
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
this.logger.warn("Invalid SF_PUBSUB_NUM_REQUESTED value; defaulting to 25", {
rawValue: raw,
});
return 25;
}
return parsed;
}
/**
* Handle Order CDC events
* Only invalidate cache if customer-facing fields changed
*/
private async handleOrderEvent(
channel: string,
subscription: { topicName?: string },
callbackType: string,
data: unknown
): Promise<void> {
if (!this.isDataCallback(callbackType)) return;
if (!isDataCallback(callbackType)) return;
const payload = this.extractPayload(data);
const header = payload ? this.extractChangeEventHeader(payload) : undefined;
const entityName =
this.extractStringField(payload, ["entityName"]) ||
(typeof header?.entityName === "string" ? header.entityName : undefined);
const changeType =
this.extractStringField(payload, ["changeType"]) ||
(typeof header?.changeType === "string" ? header.changeType : undefined);
const changedFields = this.extractChangedFields(payload);
const payload = extractPayload(data);
const header = payload ? extractChangeEventHeader(payload) : undefined;
const changedFields = extractChangedFields(payload);
// Extract Order ID
let orderId = this.extractStringField(payload, ["Id", "OrderId"]);
if (!orderId && header && Array.isArray(header.recordIds) && header.recordIds.length > 0) {
const firstId = header.recordIds.find(
(value): value is string => typeof value === "string" && value.trim().length > 0
);
if (firstId) {
orderId = firstId.trim();
}
}
const accountId = this.extractStringField(payload, ["AccountId"]);
const orderId = this.extractOrderId(payload, header);
const accountId = extractStringField(payload, ["AccountId"]);
if (!orderId) {
this.logger.warn("Order CDC event missing Order ID; skipping", {
channel,
entityName,
changeType,
});
this.logger.warn("Order CDC event missing Order ID; skipping", { channel });
return;
}
// 1. CHECK FOR PROVISIONING TRIGGER (Activation status change)
// Check for provisioning trigger (Activation_Status__c change)
if (payload && changedFields.has("Activation_Status__c")) {
await this.handleActivationStatusChange(payload, orderId);
}
// 2. CACHE INVALIDATION (existing logic)
// Filter: Only invalidate if customer-facing fields changed
// Cache invalidation - only for customer-facing field changes
const hasCustomerFacingChange = this.hasCustomerFacingChanges(changedFields);
if (!hasCustomerFacingChange) {
this.logger.debug(
"Order CDC event contains only internal field changes; skipping cache invalidation",
{
this.logger.debug("Order CDC: only internal field changes; skipping cache invalidation", {
channel,
orderId,
changedFields: Array.from(changedFields),
}
);
});
return;
}
this.logger.log("Order CDC event received with customer-facing changes, invalidating cache", {
this.logger.log("Order CDC: invalidating cache for customer-facing changes", {
channel,
orderId,
accountId,
changedFields: Array.from(changedFields),
});
try {
// Invalidate specific order cache
await this.ordersCache.invalidateOrder(orderId);
// Invalidate account's order list cache
if (accountId) {
await this.ordersCache.invalidateAccountOrders(accountId);
}
// Notify portals for instant refresh (account-scoped + order-scoped)
const timestamp = new Date().toISOString();
if (accountId) {
this.realtime.publish(`account:sf:${accountId}`, "orders.changed", {
timestamp,
});
}
this.realtime.publish(`orders:sf:${orderId}`, "order.cdc.changed", {
timestamp,
});
} catch (error) {
this.logger.warn("Failed to invalidate order cache from CDC event", {
orderId,
accountId,
error: error instanceof Error ? error.message : String(error),
});
}
await this.invalidateOrderCaches(orderId, accountId);
}
/**
* Handle Activation_Status__c changes and trigger provisioning when Salesforce moves an order to "Activating"
* Handle Activation_Status__c changes - trigger provisioning when status is "Activating"
*/
private async handleActivationStatusChange(
payload: Record<string, unknown>,
orderId: string
): Promise<void> {
const activationStatus = this.extractStringField(payload, ["Activation_Status__c"]);
const status = this.extractStringField(payload, ["Status"]);
const whmcsOrderId = this.extractStringField(payload, ["WHMCS_Order_ID__c"]);
const activationStatus = extractStringField(payload, ["Activation_Status__c"]);
const status = extractStringField(payload, ["Status"]);
const whmcsOrderId = extractStringField(payload, ["WHMCS_Order_ID__c"]);
if (activationStatus !== "Activating") {
this.logger.debug("Activation status changed but not to Activating; skipping provisioning", {
this.logger.debug("Activation status not 'Activating'; skipping provisioning", {
orderId,
activationStatus,
});
return;
}
if (status && !this.PROVISION_TRIGGER_STATUSES.has(status)) {
this.logger.debug(
"Activation status set to Activating but order status is not a provisioning trigger",
{
if (status && !PROVISION_TRIGGER_STATUSES.has(status)) {
this.logger.debug("Order status not a provisioning trigger", {
orderId,
activationStatus,
status,
}
);
});
return;
}
if (whmcsOrderId) {
this.logger.log("Order already has WHMCS Order ID, skipping provisioning", {
this.logger.log("Order already has WHMCS Order ID; skipping provisioning", {
orderId,
whmcsOrderId,
});
return;
}
this.logger.log("Order activation moved to Activating via CDC, enqueuing fulfillment", {
this.logger.log("Enqueuing provisioning for order activation via CDC", {
orderId,
activationStatus,
status,
@ -380,46 +207,42 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
this.logger.log("Successfully enqueued provisioning job from activation change", {
orderId,
activationStatus,
status,
});
} catch (error) {
this.logger.error("Failed to enqueue provisioning job from activation change", {
this.logger.error("Failed to enqueue provisioning job from CDC", {
orderId,
activationStatus,
status,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Handle OrderItem CDC events
* Only invalidate if customer-facing fields changed
*/
// ─────────────────────────────────────────────────────────────────────────────
// OrderItem CDC Handler
// ─────────────────────────────────────────────────────────────────────────────
private async handleOrderItemEvent(
channel: string,
subscription: { topicName?: string },
callbackType: string,
data: unknown
): Promise<void> {
if (!this.isDataCallback(callbackType)) return;
if (!isDataCallback(callbackType)) return;
const payload = this.extractPayload(data);
const changedFields = this.extractChangedFields(payload);
const payload = extractPayload(data);
const changedFields = extractChangedFields(payload);
const orderId = extractStringField(payload, ["OrderId"]);
const accountId = extractStringField(payload, ["AccountId"]);
const orderId = this.extractStringField(payload, ["OrderId"]);
const accountId = this.extractStringField(payload, ["AccountId"]);
if (!orderId) {
this.logger.warn("OrderItem CDC event missing OrderId; skipping", { channel });
return;
}
// Filter: Only invalidate if customer-facing fields changed
const hasCustomerFacingChange = this.hasCustomerFacingOrderItemChanges(changedFields);
if (!hasCustomerFacingChange) {
this.logger.debug("OrderItem CDC event contains only internal field changes; skipping", {
this.logger.debug("OrderItem CDC: only internal field changes; skipping", {
channel,
orderId,
changedFields: Array.from(changedFields),
@ -427,30 +250,35 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
return;
}
this.logger.log("OrderItem CDC event received, invalidating order cache", {
this.logger.log("OrderItem CDC: invalidating order cache", {
channel,
orderId,
changedFields: Array.from(changedFields),
});
try {
await this.ordersCache.invalidateOrder(orderId);
await this.invalidateOrderCaches(orderId, accountId);
}
const timestamp = new Date().toISOString();
if (accountId) {
this.realtime.publish(`account:sf:${accountId}`, "orders.changed", {
timestamp,
});
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
private extractOrderId(
payload: Record<string, unknown> | undefined,
header: ReturnType<typeof extractChangeEventHeader>
): string | undefined {
let orderId = extractStringField(payload, ["Id", "OrderId"]);
if (!orderId && header && Array.isArray(header.recordIds) && header.recordIds.length > 0) {
const firstId = header.recordIds.find(
(value): value is string => typeof value === "string" && value.trim().length > 0
);
if (firstId) {
orderId = firstId.trim();
}
this.realtime.publish(`orders:sf:${orderId}`, "orderitem.cdc.changed", {
timestamp,
});
} catch (error) {
this.logger.warn("Failed to invalidate order cache from OrderItem CDC event", {
orderId,
error: error instanceof Error ? error.message : String(error),
});
}
return orderId;
}
/**
@ -463,108 +291,46 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
return true;
}
// Remove internal fields from changed fields
const customerFacingChanges = Array.from(changedFields).filter(
field => !this.INTERNAL_FIELDS.has(field)
field => !INTERNAL_ORDER_FIELDS.has(field)
);
return customerFacingChanges.length > 0;
}
/**
* Check if changed OrderItem fields include customer-facing fields
*/
private hasCustomerFacingOrderItemChanges(changedFields: Set<string>): boolean {
if (changedFields.size === 0) {
return true; // Safe default
}
const customerFacingChanges = Array.from(changedFields).filter(
field => !this.INTERNAL_ORDER_ITEM_FIELDS.has(field)
field => !INTERNAL_ORDER_ITEM_FIELDS.has(field)
);
return customerFacingChanges.length > 0;
}
/**
* Extract changed field names from CDC payload
* CDC payload includes changeOrigin with changedFields array
*/
private extractChangedFields(payload: Record<string, unknown> | undefined): Set<string> {
if (!payload) return new Set();
private async invalidateOrderCaches(orderId: string, accountId?: string): Promise<void> {
try {
await this.ordersCache.invalidateOrder(orderId);
const header = this.extractChangeEventHeader(payload);
const headerChangedFields = Array.isArray(header?.changedFields)
? (header?.changedFields as unknown[]).filter(
(field): field is string => typeof field === "string" && field.length > 0
)
: [];
// CDC provides changed fields in different formats depending on API version
// Try to extract from common locations
const changedFieldsArray =
(payload.changedFields as string[] | undefined) ||
(payload.changeOrigin as { changedFields?: string[] })?.changedFields ||
[];
return new Set([
...headerChangedFields,
...changedFieldsArray.filter(field => typeof field === "string" && field.length > 0),
]);
if (accountId) {
await this.ordersCache.invalidateAccountOrders(accountId);
}
private isDataCallback(callbackType: string): boolean {
const normalized = String(callbackType || "").toLowerCase();
return normalized === "data" || normalized === "event";
const timestamp = new Date().toISOString();
if (accountId) {
this.realtime.publish(`account:sf:${accountId}`, "orders.changed", { timestamp });
}
private extractPayload(data: unknown): Record<string, unknown> | undefined {
if (!data || typeof data !== "object") {
return undefined;
this.realtime.publish(`orders:sf:${orderId}`, "order.cdc.changed", { timestamp });
} catch (error) {
this.logger.warn("Failed to invalidate order cache from CDC event", {
orderId,
accountId,
error: error instanceof Error ? error.message : String(error),
});
}
const candidate = data as { payload?: unknown };
if (candidate.payload && typeof candidate.payload === "object") {
return candidate.payload as Record<string, unknown>;
}
return data as Record<string, unknown>;
}
private extractStringField(
payload: Record<string, unknown> | undefined,
fieldNames: string[]
): string | undefined {
if (!payload) return undefined;
for (const field of fieldNames) {
const value = payload[field];
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
}
return undefined;
}
private extractChangeEventHeader(payload: Record<string, unknown>):
| {
changedFields?: unknown;
recordIds?: unknown;
entityName?: unknown;
changeType?: unknown;
}
| undefined {
const header = payload["ChangeEventHeader"];
if (header && typeof header === "object") {
return header as {
changedFields?: unknown;
recordIds?: unknown;
entityName?: unknown;
changeType?: unknown;
};
}
return undefined;
}
}

View File

@ -1,421 +0,0 @@
import { Injectable, Inject, Optional } from "@nestjs/common";
import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
import { SalesforceConnection } from "../services/salesforce-connection.service.js";
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
import { AccountNotificationHandler } from "@bff/modules/notifications/account-cdc-listener.service.js";
type PubSubCallback = (
subscription: { topicName?: string },
callbackType: string,
data: unknown
) => void | Promise<void>;
interface PubSubClient {
connect(): Promise<void>;
subscribe(topic: string, cb: PubSubCallback, numRequested?: number): Promise<void>;
close(): Promise<void>;
}
type PubSubCtor = new (opts: {
authType: string;
accessToken: string;
instanceUrl: string;
pubSubEndpoint: string;
}) => PubSubClient;
@Injectable()
export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
private client: PubSubClient | null = null;
private pubSubCtor: PubSubCtor | null = null;
private productChannel: string | null = null;
private pricebookChannel: string | null = null;
private accountChannel: string | null = null;
private readonly numRequested: number;
constructor(
private readonly config: ConfigService,
private readonly sfConnection: SalesforceConnection,
private readonly catalogCache: ServicesCacheService,
private readonly realtime: RealtimeService,
@Inject(Logger) private readonly logger: Logger,
@Optional() private readonly accountNotificationHandler?: AccountNotificationHandler
) {
this.numRequested = this.resolveNumRequested();
}
async onModuleInit(): Promise<void> {
// Catalog CDC subscriptions can be optionally disabled (high-volume).
// Account eligibility updates are expected to be delivered via a Platform Event
// and should always be subscribed (best UX + explicit payload).
const cdcEnabled = this.config.get("SF_EVENTS_ENABLED", "true") === "true";
const productChannel =
this.config.get<string>("SF_CATALOG_PRODUCT_CDC_CHANNEL")?.trim() ||
"/data/Product2ChangeEvent";
const pricebookChannel =
this.config.get<string>("SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL")?.trim() ||
"/data/PricebookEntryChangeEvent";
// Always use Platform Event for eligibility updates.
// Default is set in env schema: /event/Account_Internet_Eligibility_Update__e
const accountChannel = this.config.get<string>("SF_ACCOUNT_EVENT_CHANNEL")!.trim();
try {
const client = await this.ensureClient();
if (cdcEnabled) {
this.productChannel = productChannel;
await client.subscribe(
productChannel,
this.handleProductEvent.bind(this, productChannel),
this.numRequested
);
this.logger.log("Subscribed to Product2 CDC channel", { productChannel });
this.pricebookChannel = pricebookChannel;
await client.subscribe(
pricebookChannel,
this.handlePricebookEvent.bind(this, pricebookChannel),
this.numRequested
);
this.logger.log("Subscribed to PricebookEntry CDC channel", { pricebookChannel });
} else {
this.logger.debug("Catalog CDC subscriptions disabled (SF_EVENTS_ENABLED=false)");
}
this.accountChannel = accountChannel;
await client.subscribe(
accountChannel,
this.handleAccountEvent.bind(this, accountChannel),
this.numRequested
);
this.logger.log("Subscribed to account eligibility platform event channel", {
accountChannel,
});
} catch (error) {
this.logger.warn("Failed to initialize catalog CDC subscriber", {
error: error instanceof Error ? error.message : String(error),
});
}
}
async onModuleDestroy(): Promise<void> {
if (!this.client) return;
try {
await this.client.close();
} catch (error) {
this.logger.warn("Failed to close Salesforce CDC subscriber cleanly", {
error: error instanceof Error ? error.message : String(error),
});
}
}
private async ensureClient(): Promise<PubSubClient> {
if (this.client) {
return this.client;
}
const ctor = await this.loadPubSubCtor();
await this.sfConnection.connect();
const accessToken = this.sfConnection.getAccessToken();
const instanceUrl = this.sfConnection.getInstanceUrl();
if (!accessToken || !instanceUrl) {
throw new Error("Salesforce access token or instance URL missing for CDC subscriber");
}
const pubSubEndpoint =
this.config.get<string>("SF_PUBSUB_ENDPOINT") || "api.pubsub.salesforce.com:7443";
const client = new ctor({
authType: "user-supplied",
accessToken,
instanceUrl,
pubSubEndpoint,
});
await client.connect();
this.client = client;
return client;
}
private loadPubSubCtor(): Promise<PubSubCtor> {
if (!this.pubSubCtor) {
const maybeCtor = (PubSubApiClientPkg as unknown as PubSubCtor) ?? null;
const maybeDefault = (PubSubApiClientPkg as { default?: PubSubCtor }).default ?? null;
const ctor = typeof maybeCtor === "function" ? maybeCtor : maybeDefault;
if (!ctor) {
throw new Error("Failed to load Salesforce Pub/Sub client constructor");
}
this.pubSubCtor = ctor;
}
return Promise.resolve(this.pubSubCtor);
}
private resolveNumRequested(): number {
const raw = this.config.get<string>("SF_PUBSUB_NUM_REQUESTED") ?? "25";
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
this.logger.warn("Invalid SF_PUBSUB_NUM_REQUESTED value; defaulting to 25", {
rawValue: raw,
});
return 25;
}
return parsed;
}
private async handleProductEvent(
channel: string,
subscription: { topicName?: string },
callbackType: string,
data: unknown
): Promise<void> {
if (!this.isDataCallback(callbackType)) return;
const payload = this.extractPayload(data);
const productIds = this.extractRecordIds(payload);
this.logger.log("Product2 CDC event received", {
channel,
topicName: subscription.topicName,
productIds,
});
const invalidated = await this.catalogCache.invalidateProducts(productIds);
if (!invalidated) {
this.logger.debug(
"No catalog cache entries were linked to product IDs; falling back to full invalidation",
{
channel,
productIds,
}
);
await this.invalidateAllServices();
// Full invalidation already implies all clients should refetch services
this.realtime.publish("global:services", "services.changed", {
reason: "product.cdc.fallback_full_invalidation",
timestamp: new Date().toISOString(),
});
return;
}
// Product changes can affect catalog results for all users
this.realtime.publish("global:services", "services.changed", {
reason: "product.cdc",
timestamp: new Date().toISOString(),
});
}
private async handlePricebookEvent(
channel: string,
subscription: { topicName?: string },
callbackType: string,
data: unknown
): Promise<void> {
if (!this.isDataCallback(callbackType)) return;
const payload = this.extractPayload(data);
const pricebookId = this.extractStringField(payload, ["PricebookId", "Pricebook2Id"]);
const portalPricebookId = this.config.get<string>("PORTAL_PRICEBOOK_ID");
if (portalPricebookId && pricebookId && pricebookId !== portalPricebookId) {
this.logger.debug("Ignoring pricebook event for non-portal pricebook", {
channel,
pricebookId,
});
return;
}
const productId = this.extractStringField(payload, ["Product2Id", "ProductId"]);
this.logger.log("PricebookEntry CDC event received", {
channel,
pricebookId,
productId,
});
const invalidated = await this.catalogCache.invalidateProducts(productId ? [productId] : []);
if (!invalidated) {
this.logger.debug(
"No catalog cache entries mapped to product from pricebook event; performing full invalidation",
{
channel,
pricebookId,
productId,
}
);
await this.invalidateAllServices();
this.realtime.publish("global:services", "services.changed", {
reason: "pricebook.cdc.fallback_full_invalidation",
timestamp: new Date().toISOString(),
});
return;
}
this.realtime.publish("global:services", "services.changed", {
reason: "pricebook.cdc",
timestamp: new Date().toISOString(),
});
}
private async handleAccountEvent(
channel: string,
subscription: { topicName?: string },
callbackType: string,
data: unknown
): Promise<void> {
if (!this.isDataCallback(callbackType)) return;
const payload = this.extractPayload(data);
const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]);
const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]);
const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]);
const notes = this.extractStringField(payload, ["Internet_Eligibility_Notes__c"]);
const caseId = this.extractStringField(payload, ["Internet_Eligibility_Case_Id__c"]);
const requestedAt = this.extractStringField(payload, [
"Internet_Eligibility_Request_Date_Time__c",
]);
const checkedAt = this.extractStringField(payload, [
"Internet_Eligibility_Checked_Date_Time__c",
]);
const requestId = caseId;
// Also extract ID verification fields for notifications
const verificationStatus = this.extractStringField(payload, ["Id_Verification_Status__c"]);
const verificationRejection = this.extractStringField(payload, [
"Id_Verification_Rejection_Message__c",
]);
if (!accountId) {
this.logger.warn("Account eligibility event missing AccountId", {
channel,
payload,
});
return;
}
this.logger.log("Account eligibility event received", {
channel,
accountIdTail: accountId.slice(-4),
});
await this.catalogCache.invalidateEligibility(accountId);
const hasDetails = Boolean(
status || eligibility || requestedAt || checkedAt || requestId || notes
);
if (hasDetails) {
await this.catalogCache.setEligibilityDetails(accountId, {
status: this.mapEligibilityStatus(status, eligibility),
eligibility: eligibility ?? null,
requestId: requestId ?? null,
requestedAt: requestedAt ?? null,
checkedAt: checkedAt ?? null,
notes: notes ?? null,
});
}
// Notify connected portals immediately (multi-instance safe via Redis pub/sub)
this.realtime.publish(`account:sf:${accountId}`, "services.eligibility.changed", {
timestamp: new Date().toISOString(),
});
// Create in-app notifications for eligibility/verification status changes
if (this.accountNotificationHandler && (status || verificationStatus)) {
void this.accountNotificationHandler.processAccountEvent({
accountId,
eligibilityStatus: status,
eligibilityValue: eligibility,
verificationStatus,
verificationRejectionMessage: verificationRejection,
});
}
}
private mapEligibilityStatus(
statusRaw: string | undefined,
eligibilityRaw: string | undefined
): "not_requested" | "pending" | "eligible" | "ineligible" {
const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : "";
const eligibility = typeof eligibilityRaw === "string" ? eligibilityRaw.trim() : "";
if (normalizedStatus === "pending" || normalizedStatus === "checking") return "pending";
if (normalizedStatus === "eligible") return "eligible";
if (normalizedStatus === "ineligible" || normalizedStatus === "not available")
return "ineligible";
if (eligibility.length > 0) return "eligible";
return "not_requested";
}
private async invalidateAllServices(): Promise<void> {
try {
await this.catalogCache.invalidateAllServices();
} catch (error) {
this.logger.warn("Failed to invalidate services caches", {
error: error instanceof Error ? error.message : String(error),
});
}
}
private isDataCallback(callbackType: string): boolean {
const normalized = String(callbackType || "").toLowerCase();
return normalized === "data" || normalized === "event";
}
private extractPayload(data: unknown): Record<string, unknown> | undefined {
if (!data || typeof data !== "object") {
return undefined;
}
const candidate = data as { payload?: unknown };
if (candidate.payload && typeof candidate.payload === "object") {
return candidate.payload as Record<string, unknown>;
}
return data as Record<string, unknown>;
}
private extractStringField(
payload: Record<string, unknown> | undefined,
fieldNames: string[]
): string | undefined {
if (!payload) return undefined;
for (const field of fieldNames) {
const value = payload[field];
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
}
return undefined;
}
private extractRecordIds(payload: Record<string, unknown> | undefined): string[] {
if (!payload) {
return [];
}
const header = this.extractChangeEventHeader(payload);
const ids = header?.recordIds ?? [];
if (Array.isArray(ids)) {
return ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0);
}
return [];
}
private extractChangeEventHeader(
payload: Record<string, unknown>
): { recordIds?: unknown; changedFields?: unknown } | undefined {
const header = payload["ChangeEventHeader"];
if (header && typeof header === "object") {
return header as { recordIds?: unknown; changedFields?: unknown };
}
return undefined;
}
}

View File

@ -0,0 +1,22 @@
/**
* Shared Pub/Sub utilities for Salesforce event subscribers
*/
export { PubSubClientService } from "./pubsub.service.js";
export {
isDataCallback,
extractPayload,
extractStringField,
extractChangeEventHeader,
extractRecordIds,
extractChangedFields,
parseNumRequested,
} from "./pubsub.utils.js";
export type {
PubSubCallback,
PubSubClient,
PubSubClientConstructor,
ChangeEventHeader,
} from "./pubsub.types.js";

View File

@ -0,0 +1,150 @@
/**
* Salesforce Pub/Sub Client Service
*
* Provides shared client initialization and connection management for both
* CDC (Change Data Capture) and Platform Event subscribers.
*
* This service handles:
* - Client instantiation with proper auth
* - Connection lifecycle management
* - Graceful shutdown
*/
import { Injectable, Inject } from "@nestjs/common";
import type { OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
import { SalesforceConnection } from "../../services/salesforce-connection.service.js";
import type { PubSubClient, PubSubClientConstructor, PubSubCallback } from "./pubsub.types.js";
import { parseNumRequested } from "./pubsub.utils.js";
@Injectable()
export class PubSubClientService implements OnModuleDestroy {
private client: PubSubClient | null = null;
private pubSubCtor: PubSubClientConstructor | null = null;
private readonly numRequested: number;
constructor(
private readonly config: ConfigService,
private readonly sfConnection: SalesforceConnection,
@Inject(Logger) private readonly logger: Logger
) {
this.numRequested = parseNumRequested(
this.config.get<string>("SF_PUBSUB_NUM_REQUESTED"),
25,
(msg, ctx) => this.logger.warn(msg, ctx)
);
}
async onModuleDestroy(): Promise<void> {
await this.close();
}
/**
* Get the default number of events to request per subscription
*/
getNumRequested(): number {
return this.numRequested;
}
/**
* Ensure a connected Pub/Sub client is available
*/
async ensureClient(): Promise<PubSubClient> {
if (this.client) {
return this.client;
}
const ctor = this.loadPubSubCtor();
await this.sfConnection.connect();
const accessToken = this.sfConnection.getAccessToken();
const instanceUrl = this.sfConnection.getInstanceUrl();
if (!accessToken || !instanceUrl) {
throw new Error("Salesforce access token or instance URL missing for Pub/Sub client");
}
const pubSubEndpoint =
this.config.get<string>("SF_PUBSUB_ENDPOINT") || "api.pubsub.salesforce.com:7443";
const client = new ctor({
authType: "user-supplied",
accessToken,
instanceUrl,
pubSubEndpoint,
});
await client.connect();
this.client = client;
this.logger.log("Salesforce Pub/Sub client connected", { pubSubEndpoint });
return client;
}
/**
* Subscribe to a channel with diagnostic logging
*/
async subscribe(channel: string, handler: PubSubCallback, label: string): Promise<void> {
this.logger.log("Subscribing to Salesforce channel", {
channel,
label,
numRequested: this.numRequested,
});
try {
const client = await this.ensureClient();
await client.subscribe(channel, handler, this.numRequested);
this.logger.log("Successfully subscribed to Salesforce channel", {
channel,
label,
});
} catch (error) {
this.logger.error("Failed to subscribe to Salesforce channel", {
channel,
label,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
/**
* Close the Pub/Sub client connection
*/
async close(): Promise<void> {
if (!this.client) return;
try {
await this.client.close();
this.logger.debug("Salesforce Pub/Sub client closed");
} catch (error) {
this.logger.warn("Failed to close Salesforce Pub/Sub client cleanly", {
error: error instanceof Error ? error.message : String(error),
});
} finally {
this.client = null;
}
}
private loadPubSubCtor(): PubSubClientConstructor {
if (this.pubSubCtor) {
return this.pubSubCtor;
}
const maybeCtor = (PubSubApiClientPkg as unknown as PubSubClientConstructor) ?? null;
const maybeDefault =
(PubSubApiClientPkg as { default?: PubSubClientConstructor }).default ?? null;
const ctor = typeof maybeCtor === "function" ? maybeCtor : maybeDefault;
if (!ctor) {
throw new Error("Failed to load Salesforce Pub/Sub client constructor");
}
this.pubSubCtor = ctor;
return this.pubSubCtor;
}
}

View File

@ -0,0 +1,43 @@
/**
* Shared types for Salesforce Pub/Sub API client
*
* These types are used by both CDC (Change Data Capture) and Platform Event subscribers.
*/
/**
* Callback signature for Pub/Sub event subscriptions
*/
export type PubSubCallback = (
subscription: { topicName?: string },
callbackType: string,
data: unknown
) => void | Promise<void>;
/**
* Pub/Sub client interface
*/
export interface PubSubClient {
connect(): Promise<void>;
subscribe(topic: string, cb: PubSubCallback, numRequested?: number): Promise<void>;
close(): Promise<void>;
}
/**
* Constructor type for Pub/Sub client
*/
export type PubSubClientConstructor = new (opts: {
authType: string;
accessToken: string;
instanceUrl: string;
pubSubEndpoint: string;
}) => PubSubClient;
/**
* CDC ChangeEventHeader structure
*/
export interface ChangeEventHeader {
recordIds?: unknown;
changedFields?: unknown;
entityName?: unknown;
changeType?: unknown;
}

View File

@ -0,0 +1,131 @@
/**
* Shared utility functions for Salesforce Pub/Sub subscribers
*
* Provides common payload extraction and validation logic used by both
* CDC and Platform Event subscribers.
*/
import type { ChangeEventHeader } from "./pubsub.types.js";
/**
* Check if a callback type represents actual event data
*/
export function isDataCallback(callbackType: string): boolean {
const normalized = String(callbackType || "").toLowerCase();
return normalized === "data" || normalized === "event";
}
/**
* Extract payload from Pub/Sub event data
*/
export function extractPayload(data: unknown): Record<string, unknown> | undefined {
if (!data || typeof data !== "object") {
return undefined;
}
const candidate = data as { payload?: unknown };
if (candidate.payload && typeof candidate.payload === "object") {
return candidate.payload as Record<string, unknown>;
}
return data as Record<string, unknown>;
}
/**
* Extract a string field from payload, trying multiple possible field names
*/
export function extractStringField(
payload: Record<string, unknown> | undefined,
fieldNames: string[]
): string | undefined {
if (!payload) return undefined;
for (const field of fieldNames) {
const value = payload[field];
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
}
return undefined;
}
/**
* Extract ChangeEventHeader from CDC payload
*/
export function extractChangeEventHeader(
payload: Record<string, unknown>
): ChangeEventHeader | undefined {
const header = payload["ChangeEventHeader"];
if (header && typeof header === "object") {
return header as ChangeEventHeader;
}
return undefined;
}
/**
* Extract record IDs from CDC payload
*/
export function extractRecordIds(payload: Record<string, unknown> | undefined): string[] {
if (!payload) {
return [];
}
const header = extractChangeEventHeader(payload);
const ids = header?.recordIds ?? [];
if (Array.isArray(ids)) {
return ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0);
}
return [];
}
/**
* Extract changed field names from CDC payload
*/
export function extractChangedFields(payload: Record<string, unknown> | undefined): Set<string> {
if (!payload) return new Set();
const header = extractChangeEventHeader(payload);
const headerChangedFields = Array.isArray(header?.changedFields)
? (header?.changedFields as unknown[]).filter(
(field): field is string => typeof field === "string" && field.length > 0
)
: [];
// CDC provides changed fields in different formats depending on API version
const changedFieldsArray =
(payload.changedFields as string[] | undefined) ||
(payload.changeOrigin as { changedFields?: string[] })?.changedFields ||
[];
return new Set([
...headerChangedFields,
...changedFieldsArray.filter(field => typeof field === "string" && field.length > 0),
]);
}
/**
* Parse numRequested config value with validation
*/
export function parseNumRequested(
rawValue: string | undefined,
defaultValue = 25,
onWarn?: (message: string, context: Record<string, unknown>) => void
): number {
const raw = rawValue ?? String(defaultValue);
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
onWarn?.("Invalid SF_PUBSUB_NUM_REQUESTED value; using default", {
rawValue: raw,
defaultValue,
});
return defaultValue;
}
return parsed;
}

View File

@ -3,7 +3,7 @@ import { Logger } from "nestjs-pino";
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
import { SalesforceOpportunityService } from "./salesforce-opportunity.service.js";
import { assertSalesforceId } from "../utils/soql.util.js";
import type { OrderTypeValue } from "@customer-portal/domain/orders";
import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders";
import {
APPLICATION_STAGE,
OPPORTUNITY_PRODUCT_TYPE,
@ -131,14 +131,14 @@ export class OpportunityResolutionService {
private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue {
switch (orderType) {
case "Internet":
case ORDER_TYPE.INTERNET:
return OPPORTUNITY_PRODUCT_TYPE.INTERNET;
case "SIM":
case ORDER_TYPE.SIM:
return OPPORTUNITY_PRODUCT_TYPE.SIM;
case "VPN":
case ORDER_TYPE.VPN:
return OPPORTUNITY_PRODUCT_TYPE.VPN;
default:
return OPPORTUNITY_PRODUCT_TYPE.SIM;
throw new Error(`Unsupported order type: ${orderType}`);
}
}
}

View File

@ -8,6 +8,7 @@ export interface ServicesCacheSnapshot {
static: CacheBucketMetrics;
volatile: CacheBucketMetrics;
eligibility: CacheBucketMetrics;
verification: CacheBucketMetrics;
invalidations: number;
}
@ -49,6 +50,7 @@ export class ServicesCacheService {
private readonly SERVICES_TTL: number | null;
private readonly STATIC_TTL: number | null;
private readonly ELIGIBILITY_TTL: number | null;
private readonly VERIFICATION_TTL: number | null;
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
private readonly metrics: ServicesCacheSnapshot = {
@ -56,6 +58,7 @@ export class ServicesCacheService {
static: { hits: 0, misses: 0 },
volatile: { hits: 0, misses: 0 },
eligibility: { hits: 0, misses: 0 },
verification: { hits: 0, misses: 0 },
invalidations: 0,
};
@ -70,10 +73,11 @@ export class ServicesCacheService {
const raw = this.config.get<number>("SERVICES_CACHE_SAFETY_TTL_SECONDS", 60 * 60 * 12);
const ttl = typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null;
// Apply to CDC-driven buckets (catalog + static + eligibility)
// Apply to CDC-driven buckets (catalog + static + eligibility + verification)
this.SERVICES_TTL = ttl;
this.STATIC_TTL = ttl;
this.ELIGIBILITY_TTL = ttl;
this.VERIFICATION_TTL = ttl;
}
/**
@ -121,6 +125,10 @@ export class ServicesCacheService {
return `services:eligibility:${accountId}`;
}
buildVerificationKey(accountId: string): string {
return `services:verification:${accountId}`;
}
/**
* Invalidate catalog cache by pattern
*/
@ -139,6 +147,25 @@ export class ServicesCacheService {
await this.cache.del(this.buildEligibilityKey("", accountId));
}
/**
* Invalidate verification cache for an account
*/
async invalidateVerification(accountId: string): Promise<void> {
if (!accountId) return;
this.metrics.invalidations++;
await this.cache.del(this.buildVerificationKey(accountId));
}
/**
* Get or fetch verification data (event-driven cache with safety TTL)
*/
async getCachedVerification<T>(accountId: string, fetchFn: () => Promise<T>): Promise<T> {
const key = this.buildVerificationKey(accountId);
return this.getOrSet("verification", key, this.VERIFICATION_TTL, fetchFn, {
allowNull: true,
});
}
/**
* Invalidate all catalog cache entries
*/
@ -174,6 +201,7 @@ export class ServicesCacheService {
static: { ...this.metrics.static },
volatile: { ...this.metrics.volatile },
eligibility: { ...this.metrics.eligibility },
verification: { ...this.metrics.verification },
invalidations: this.metrics.invalidations,
};
}
@ -189,10 +217,8 @@ export class ServicesCacheService {
const payload = {
status: eligibility ? "eligible" : "not_requested",
eligibility: typeof eligibility === "string" ? eligibility : null,
requestId: null,
requestedAt: null,
checkedAt: null,
notes: null,
};
if (this.ELIGIBILITY_TTL === null) {
await this.cache.set(key, payload);
@ -215,7 +241,7 @@ export class ServicesCacheService {
}
private async getOrSet<T>(
bucket: "services" | "static" | "volatile" | "eligibility",
bucket: "services" | "static" | "volatile" | "eligibility" | "verification",
key: string,
ttlSeconds: number | null,
fetchFn: () => Promise<T>,

View File

@ -0,0 +1,159 @@
import { Injectable } from "@nestjs/common";
import { CacheService } from "@bff/infra/cache/cache.service.js";
import type { CacheBucketMetrics } from "@bff/infra/cache/cache.types.js";
import type { SupportCaseList, CaseMessageList } from "@customer-portal/domain/support";
/**
* Cache TTL configuration for support cases
*
* Unlike orders (which use CDC events), support cases use TTL-based caching
* because Salesforce Case changes don't trigger platform events in our setup.
*/
const CACHE_TTL = {
/** Case list TTL: 2 minutes - customers checking status periodically */
CASE_LIST: 120,
/** Case messages TTL: 1 minute - fresher for active conversations */
CASE_MESSAGES: 60,
} as const;
interface SupportCacheMetrics {
caseList: CacheBucketMetrics;
messages: CacheBucketMetrics;
invalidations: number;
}
/**
* Support cases cache service
*
* Uses TTL-based caching (not CDC) because:
* - Cases don't trigger platform events in our Salesforce setup
* - Customers can refresh to see updates (no real-time requirement)
* - Short TTLs (1-2 min) ensure reasonable freshness
*
* Features:
* - Request coalescing: Prevents duplicate API calls on cache miss
* - Write-through invalidation: Cache is cleared after customer adds comment
* - Metrics tracking: Monitors hits, misses, and invalidations
*/
@Injectable()
export class SupportCacheService {
private readonly metrics: SupportCacheMetrics = {
caseList: { hits: 0, misses: 0 },
messages: { hits: 0, misses: 0 },
invalidations: 0,
};
// Request coalescing: Prevents duplicate API calls
private readonly inflightRequests = new Map<string, Promise<unknown>>();
constructor(private readonly cache: CacheService) {}
/**
* Get cached case list for an account
*/
async getCaseList(
sfAccountId: string,
fetcher: () => Promise<SupportCaseList>
): Promise<SupportCaseList> {
const key = this.buildCaseListKey(sfAccountId);
return this.getOrSet("caseList", key, fetcher, CACHE_TTL.CASE_LIST);
}
/**
* Get cached messages for a case
*/
async getCaseMessages(
caseId: string,
fetcher: () => Promise<CaseMessageList>
): Promise<CaseMessageList> {
const key = this.buildMessagesKey(caseId);
return this.getOrSet("messages", key, fetcher, CACHE_TTL.CASE_MESSAGES);
}
/**
* Invalidate case list cache for an account
* Called after customer creates a new case
*/
async invalidateCaseList(sfAccountId: string): Promise<void> {
const key = this.buildCaseListKey(sfAccountId);
this.metrics.invalidations++;
await this.cache.del(key);
}
/**
* Invalidate messages cache for a case
* Called after customer adds a comment
*/
async invalidateCaseMessages(caseId: string): Promise<void> {
const key = this.buildMessagesKey(caseId);
this.metrics.invalidations++;
await this.cache.del(key);
}
/**
* Invalidate all caches for an account's cases
* Called after any write operation to ensure fresh data
*/
async invalidateAllForAccount(sfAccountId: string, caseId?: string): Promise<void> {
await this.invalidateCaseList(sfAccountId);
if (caseId) {
await this.invalidateCaseMessages(caseId);
}
}
/**
* Get cache metrics for monitoring
*/
getMetrics(): SupportCacheMetrics {
return {
caseList: { ...this.metrics.caseList },
messages: { ...this.metrics.messages },
invalidations: this.metrics.invalidations,
};
}
private async getOrSet<T>(
bucket: "caseList" | "messages",
key: string,
fetcher: () => Promise<T>,
ttlSeconds: number
): Promise<T> {
// Check Redis cache first
const cached = await this.cache.get<T>(key);
if (cached !== null) {
this.metrics[bucket].hits++;
return cached;
}
// Check for in-flight request (prevents thundering herd)
const existingRequest = this.inflightRequests.get(key);
if (existingRequest) {
return existingRequest as Promise<T>;
}
// Fetch fresh data
this.metrics[bucket].misses++;
const fetchPromise = (async () => {
try {
const fresh = await fetcher();
await this.cache.set(key, fresh, ttlSeconds);
return fresh;
} finally {
this.inflightRequests.delete(key);
}
})();
this.inflightRequests.set(key, fetchPromise);
return fetchPromise;
}
private buildCaseListKey(sfAccountId: string): string {
return `support:cases:${sfAccountId}`;
}
private buildMessagesKey(caseId: string): string {
return `support:messages:${caseId}`;
}
}

View File

@ -1,13 +1,14 @@
import { Module } from "@nestjs/common";
import { SupportController } from "./support.controller.js";
import { SupportService } from "./support.service.js";
import { SupportCacheService } from "./support-cache.service.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
@Module({
imports: [SalesforceModule, MappingsModule],
controllers: [SupportController],
providers: [SupportService],
exports: [SupportService],
providers: [SupportService, SupportCacheService],
exports: [SupportService, SupportCacheService],
})
export class SupportModule {}

View File

@ -16,29 +16,25 @@ import {
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SupportCacheService } from "./support-cache.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { hashEmailForLogs } from "./support.logging.js";
/**
* Status values that indicate an open/active case
* (Display values after mapping from Salesforce Japanese API names)
* (Display values after mapping from Salesforce API names)
*/
const OPEN_STATUSES: string[] = [
SUPPORT_CASE_STATUS.NEW,
SUPPORT_CASE_STATUS.IN_PROGRESS,
SUPPORT_CASE_STATUS.AWAITING_APPROVAL,
SUPPORT_CASE_STATUS.AWAITING_CUSTOMER,
];
/**
* Status values that indicate a resolved/closed case
* (Display values after mapping from Salesforce Japanese API names)
* (Display values after mapping from Salesforce API names)
*/
const RESOLVED_STATUSES: string[] = [
SUPPORT_CASE_STATUS.VPN_PENDING,
SUPPORT_CASE_STATUS.PENDING,
SUPPORT_CASE_STATUS.RESOLVED,
SUPPORT_CASE_STATUS.CLOSED,
];
const RESOLVED_STATUSES: string[] = [SUPPORT_CASE_STATUS.CLOSED];
/**
* Priority values that indicate high priority
@ -50,23 +46,35 @@ export class SupportService {
constructor(
private readonly caseService: SalesforceCaseService,
private readonly mappingsService: MappingsService,
private readonly cacheService: SupportCacheService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* List cases for a user with optional filters
*
* Uses Redis caching with 2-minute TTL to reduce Salesforce API calls.
* Cache is invalidated when customer creates a new case.
*/
async listCases(userId: string, filters?: SupportCaseFilter): Promise<SupportCaseList> {
const accountId = await this.getAccountIdForUser(userId);
try {
// SalesforceCaseService now returns SupportCase[] directly using domain mappers
// Use cache with TTL (no CDC events for cases)
const caseList = await this.cacheService.getCaseList(accountId, async () => {
const cases = await this.caseService.getCasesForAccount(accountId);
const summary = this.buildSummary(cases);
return { cases, summary };
});
const filteredCases = this.applyFilters(cases, filters);
// Apply filters after cache (filters are user-specific, cache is account-level)
if (filters && Object.keys(filters).length > 0) {
const filteredCases = this.applyFilters(caseList.cases, filters);
const summary = this.buildSummary(filteredCases);
return { cases: filteredCases, summary };
}
return caseList;
} catch (error) {
this.logger.error("Failed to list support cases", {
userId,
@ -106,6 +114,8 @@ export class SupportService {
/**
* Create a new support case
*
* Invalidates case list cache after successful creation.
*/
async createCase(userId: string, request: CreateCaseRequest): Promise<CreateCaseResponse> {
const accountId = await this.getAccountIdForUser(userId);
@ -119,6 +129,9 @@ export class SupportService {
origin: SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT,
});
// Invalidate cache so new case appears immediately
await this.cacheService.invalidateCaseList(accountId);
this.logger.log("Support case created", {
userId,
caseId: result.id,
@ -176,6 +189,8 @@ export class SupportService {
* Get all messages for a case (conversation view)
*
* Returns a unified timeline of EmailMessages and public CaseComments.
* Uses Redis caching with 1-minute TTL for active conversations.
* Cache is invalidated when customer adds a comment.
*
* @param userId - Portal user ID
* @param caseId - Salesforce Case ID
@ -189,7 +204,10 @@ export class SupportService {
const accountId = await this.getAccountIdForUser(userId);
try {
const messages = await this.caseService.getCaseMessages(caseId, accountId, customerEmail);
// Use cache with short TTL for messages (fresher for active conversations)
const messages = await this.cacheService.getCaseMessages(caseId, async () => {
return this.caseService.getCaseMessages(caseId, accountId, customerEmail);
});
return messages;
} catch (error) {
@ -209,6 +227,7 @@ export class SupportService {
* Add a comment to a case (customer reply via portal)
*
* Creates a public CaseComment visible to both customer and agents.
* Invalidates messages cache after successful comment so it appears immediately.
*/
async addCaseComment(
userId: string,
@ -220,6 +239,9 @@ export class SupportService {
try {
const result = await this.caseService.addCaseComment(caseId, accountId, request.body);
// Invalidate caches so new comment appears immediately
await this.cacheService.invalidateAllForAccount(accountId, caseId);
this.logger.log("Case comment added", {
userId,
caseId,

View File

@ -4,6 +4,7 @@ import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import {
assertSalesforceId,
@ -21,22 +22,13 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
import { basename, extname } from "node:path";
function mapFileTypeToMime(fileType?: string | null): string | null {
const normalized = String(fileType || "")
.trim()
.toLowerCase();
if (normalized === "pdf") return "application/pdf";
if (normalized === "png") return "image/png";
if (normalized === "jpg" || normalized === "jpeg") return "image/jpeg";
return null;
}
@Injectable()
export class ResidenceCardService {
constructor(
private readonly sf: SalesforceConnection,
private readonly mappings: MappingsService,
private readonly caseService: SalesforceCaseService,
private readonly servicesCache: ServicesCacheService,
private readonly config: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -49,15 +41,20 @@ export class ResidenceCardService {
if (!sfAccountId) {
return residenceCardVerificationSchema.parse({
status: "not_submitted",
filename: null,
mimeType: null,
sizeBytes: null,
submittedAt: null,
reviewedAt: null,
reviewerNotes: null,
});
}
return this.servicesCache.getCachedVerification(sfAccountId, async () => {
return this.fetchVerificationFromSalesforce(sfAccountId);
});
}
private async fetchVerificationFromSalesforce(
sfAccountId: string
): Promise<ResidenceCardVerification> {
const fields = this.getAccountFieldNames();
const soql = `
SELECT Id, ${fields.status}, ${fields.submittedAt}, ${fields.verifiedAt}, ${fields.note}, ${fields.rejectionMessage}
@ -100,44 +97,11 @@ export class ResidenceCardService {
? noteRaw.trim()
: null;
const fileMeta =
status === "not_submitted"
? null
: await this.getLatestIdVerificationFileMetadata(sfAccountId);
const payload = {
return residenceCardVerificationSchema.parse({
status,
filename: fileMeta?.filename ?? null,
mimeType: fileMeta?.mimeType ?? null,
sizeBytes: typeof fileMeta?.sizeBytes === "number" ? fileMeta.sizeBytes : null,
submittedAt: submittedAt ?? fileMeta?.submittedAt ?? null,
submittedAt,
reviewedAt,
reviewerNotes,
};
const parsed = residenceCardVerificationSchema.safeParse(payload);
if (parsed.success) return parsed.data;
this.logger.warn(
{ userId, err: parsed.error.message },
"Invalid residence card verification payload from Salesforce; returning safe fallback"
);
const fallback = residenceCardVerificationSchema.safeParse({
...payload,
submittedAt: null,
reviewedAt: null,
});
if (fallback.success) return fallback.data;
return residenceCardVerificationSchema.parse({
status: "not_submitted",
filename: null,
mimeType: null,
sizeBytes: null,
submittedAt: null,
reviewedAt: null,
reviewerNotes: null,
});
}
@ -240,6 +204,9 @@ export class ResidenceCardService {
[fields.note]: null,
});
// Invalidate cache so next fetch gets fresh data
await this.servicesCache.invalidateVerification(sfAccountId);
return this.getStatusForUser(params.userId);
}
@ -277,88 +244,4 @@ export class ResidenceCardService {
),
};
}
/**
* Get the latest ID verification file metadata from Cases linked to the Account.
*
* Files are attached to ID verification Cases (not the Account directly).
* We find the most recent Case with the ID verification subject and get its file.
*/
private async getLatestIdVerificationFileMetadata(accountId: string): Promise<{
filename: string | null;
mimeType: string | null;
sizeBytes: number | null;
submittedAt: string | null;
} | null> {
try {
// Find the most recent ID verification case for this account
const caseSoql = `
SELECT Id
FROM Case
WHERE AccountId = '${accountId}'
AND Origin = '${SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION}'
AND Subject LIKE '%ID verification%'
ORDER BY CreatedDate DESC
LIMIT 1
`;
const caseRes = (await this.sf.query(caseSoql, {
label: "verification:residence_card:latest_case",
})) as SalesforceResponse<{ Id?: string }>;
const caseId = caseRes.records?.[0]?.Id;
if (!caseId) return null;
// Get files linked to that case
const linkSoql = `
SELECT ContentDocumentId
FROM ContentDocumentLink
WHERE LinkedEntityId = '${caseId}'
ORDER BY SystemModstamp DESC
LIMIT 1
`;
const linkRes = (await this.sf.query(linkSoql, {
label: "verification:residence_card:latest_link",
})) as SalesforceResponse<{ ContentDocumentId?: string }>;
const documentId = linkRes.records?.[0]?.ContentDocumentId;
if (!documentId) return null;
const versionSoql = `
SELECT Title, FileExtension, FileType, ContentSize, CreatedDate
FROM ContentVersion
WHERE ContentDocumentId = '${documentId}'
ORDER BY CreatedDate DESC
LIMIT 1
`;
const versionRes = (await this.sf.query(versionSoql, {
label: "verification:residence_card:latest_version",
})) as SalesforceResponse<Record<string, unknown>>;
const version = (versionRes.records?.[0] as Record<string, unknown> | undefined) ?? undefined;
if (!version) return null;
const title = typeof version.Title === "string" ? version.Title.trim() : "";
const ext = typeof version.FileExtension === "string" ? version.FileExtension.trim() : "";
const fileType = typeof version.FileType === "string" ? version.FileType.trim() : "";
const sizeBytes = typeof version.ContentSize === "number" ? version.ContentSize : null;
const createdDateRaw = version.CreatedDate;
const submittedAt = normalizeSalesforceDateTimeToIsoUtc(createdDateRaw);
const filename = title
? ext && !title.toLowerCase().endsWith(`.${ext.toLowerCase()}`)
? `${title}.${ext}`
: title
: null;
return {
filename,
mimeType: mapFileTypeToMime(fileType) ?? mapFileTypeToMime(ext) ?? null,
sizeBytes,
submittedAt,
};
} catch (error) {
this.logger.warn("Failed to load ID verification file metadata from Salesforce", {
accountIdTail: accountId.slice(-4),
error: extractErrorMessage(error),
});
return null;
}
}
}

View File

@ -1,12 +1,13 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { ResidenceCardController } from "./residence-card.controller.js";
import { ResidenceCardService } from "./residence-card.service.js";
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js";
@Module({
imports: [IntegrationsModule, MappingsModule, CoreConfigModule],
imports: [IntegrationsModule, MappingsModule, CoreConfigModule, forwardRef(() => ServicesModule)],
controllers: [ResidenceCardController],
providers: [ResidenceCardService],
exports: [ResidenceCardService],

View File

@ -537,22 +537,15 @@ export default function ProfileContainer() {
Your residence card has been submitted. We&apos;ll verify it before activating SIM
service.
</AlertBanner>
{(verificationQuery.data?.filename || verificationQuery.data?.submittedAt) && (
{verificationQuery.data?.submittedAt && (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Submitted document
Submission status
</div>
{verificationQuery.data?.filename && (
<div className="mt-1 text-sm font-medium text-foreground">
{verificationQuery.data.filename}
</div>
)}
{verificationQuery.data?.submittedAt && (
<div className="mt-1 text-xs text-muted-foreground">
Submitted on{" "}
{formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })}
</div>
)}
</div>
)}
</div>
@ -579,18 +572,11 @@ export default function ProfileContainer() {
</p>
)}
{(verificationQuery.data?.filename ||
verificationQuery.data?.submittedAt ||
verificationQuery.data?.reviewedAt) && (
{(verificationQuery.data?.submittedAt || verificationQuery.data?.reviewedAt) && (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Latest submission
</div>
{verificationQuery.data?.filename && (
<div className="mt-1 text-sm font-medium text-foreground">
{verificationQuery.data.filename}
</div>
)}
{verificationQuery.data?.submittedAt && (
<div className="mt-1 text-xs text-muted-foreground">
Submitted on{" "}

View File

@ -7,7 +7,12 @@ import {
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import type { PaymentMethod } from "@customer-portal/domain/payments";
import { cn } from "@/shared/utils";
import {
cn,
getPaymentMethodBrandLabel,
getPaymentMethodCardDisplay,
normalizeExpiryLabel,
} from "@/shared/utils";
import type { ReactNode } from "react";
interface PaymentMethodCardProps {
@ -55,44 +60,12 @@ const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
);
};
const isCreditCard = (type: PaymentMethod["type"]) =>
type === "CreditCard" || type === "RemoteCreditCard";
const isBankAccount = (type: PaymentMethod["type"]) =>
type === "BankAccount" || type === "RemoteBankAccount";
const formatCardDisplay = (method: PaymentMethod) => {
if (method.cardLastFour) {
return `***** ${method.cardLastFour}`;
}
// Fallback based on type
if (isCreditCard(method.type)) {
return method.cardType ? `${method.cardType.toUpperCase()} Card` : "Credit Card";
}
if (isBankAccount(method.type)) {
return method.bankName || "Bank Account";
}
return method.description || "Payment Method";
};
const formatCardBrand = (method: PaymentMethod) => {
if (isCreditCard(method.type) && method.cardType) {
return method.cardType.toUpperCase();
}
if (isBankAccount(method.type) && method.bankName) {
return method.bankName;
}
return null;
};
const formatExpiry = (expiryDate?: string) => {
if (!expiryDate) return null;
return `Expires ${expiryDate}`;
const normalized = normalizeExpiryLabel(expiryDate);
return normalized ? `Expires ${normalized}` : null;
};
export function PaymentMethodCard({
@ -101,8 +74,8 @@ export function PaymentMethodCard({
showActions = false,
actionSlot,
}: PaymentMethodCardProps) {
const cardDisplay = formatCardDisplay(paymentMethod);
const cardBrand = formatCardBrand(paymentMethod);
const cardDisplay = getPaymentMethodCardDisplay(paymentMethod);
const cardBrand = getPaymentMethodBrandLabel(paymentMethod);
const expiry = formatExpiry(paymentMethod.expiryDate);
const icon = getMethodIcon(paymentMethod.type, paymentMethod.cardType);

View File

@ -1,181 +0,0 @@
"use client";
import { forwardRef } from "react";
import {
CreditCardIcon,
BanknotesIcon,
CheckCircleIcon,
EllipsisVerticalIcon,
ArrowPathIcon,
} from "@heroicons/react/24/outline";
import { Badge } from "@/components/atoms/badge";
import type { PaymentMethod } from "@customer-portal/domain/payments";
import { cn } from "@/shared/utils";
interface PaymentMethodCardProps extends React.HTMLAttributes<HTMLDivElement> {
paymentMethod: PaymentMethod;
showActions?: boolean;
compact?: boolean;
}
const getPaymentMethodIcon = (type: PaymentMethod["type"]) => {
switch (type) {
case "CreditCard":
case "RemoteCreditCard":
return <CreditCardIcon className="h-5 w-5" />;
case "BankAccount":
return <BanknotesIcon className="h-5 w-5" />;
case "RemoteBankAccount":
return <ArrowPathIcon className="h-5 w-5" />;
case "Manual":
default:
return <CreditCardIcon className="h-5 w-5" />;
}
};
const getCardBrandColor = (cardBrand?: string) => {
switch (cardBrand?.toLowerCase()) {
case "visa":
return "text-info";
case "mastercard":
return "text-danger";
case "amex":
case "american express":
return "text-success";
case "discover":
return "text-warning";
default:
return "text-muted-foreground";
}
};
const PaymentMethodCard = forwardRef<HTMLDivElement, PaymentMethodCardProps>(
({ paymentMethod, showActions = true, compact = false, className, ...props }, ref) => {
const {
type,
description,
gatewayName,
isDefault,
expiryDate,
bankName,
cardType,
cardLastFour,
} = paymentMethod;
const formatExpiryDate = (expiry?: string) => {
if (!expiry) return null;
// Handle different expiry formats (MM/YY, MM/YYYY, MMYY, etc.)
const cleaned = expiry.replace(/\D/g, "");
if (cleaned.length >= 4) {
const month = cleaned.substring(0, 2);
const year = cleaned.substring(2, 4);
return `${month}/${year}`;
}
return expiry;
};
const renderPaymentMethodDetails = () => {
if (type === "BankAccount" || type === "RemoteBankAccount") {
return (
<div className="space-y-1">
<div className="font-medium text-foreground">{bankName || "Bank Account"}</div>
<div className="text-sm text-muted-foreground">
{cardLastFour && <span> {cardLastFour}</span>}
</div>
</div>
);
}
// Credit Card
return (
<div className="space-y-1">
<div className="font-medium text-foreground">
{cardType || "Credit Card"}
{cardLastFour && <span className="ml-2"> {cardLastFour}</span>}
</div>
<div className="text-sm text-muted-foreground">
{formatExpiryDate(expiryDate) && <span>Expires {formatExpiryDate(expiryDate)}</span>}
{gatewayName && <span className="ml-2"> {gatewayName}</span>}
</div>
</div>
);
};
return (
<div
ref={ref}
className={cn(
"bg-card border border-border rounded-lg transition-all duration-200",
"hover:border-border-muted hover:shadow-sm",
isDefault && "ring-2 ring-primary border-primary/30",
compact ? "p-3" : "p-4",
className
)}
{...props}
>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1 min-w-0">
{/* Icon */}
<div
className={cn(
"flex-shrink-0 rounded-lg flex items-center justify-center",
compact ? "w-8 h-8 bg-muted" : "w-10 h-10 bg-muted",
type === "CreditCard" || type === "RemoteCreditCard"
? getCardBrandColor(cardType)
: "text-muted-foreground"
)}
>
{getPaymentMethodIcon(type)}
</div>
{/* Details */}
<div className="flex-1 min-w-0">
{renderPaymentMethodDetails()}
{/* Description if different from generated details */}
{description && !description.includes(cardLastFour || "") && (
<div className="text-sm text-muted-foreground mt-1 truncate">{description}</div>
)}
{/* Default badge */}
{isDefault && (
<div className="mt-2">
<Badge variant="success" size="sm" icon={<CheckCircleIcon className="h-3 w-3" />}>
Default (Active)
</Badge>
</div>
)}
</div>
</div>
{/* Actions */}
{showActions && (
<div className="flex-shrink-0 ml-2">
<button
type="button"
className="p-1 text-muted-foreground hover:text-foreground rounded-md hover:bg-muted"
onClick={e => {
e.stopPropagation();
// Payment method actions (edit, delete) are handled via dedicated modal flows
// Future enhancement: Add dropdown menu for quick actions
}}
>
<EllipsisVerticalIcon className="h-5 w-5" />
</button>
</div>
)}
</div>
{/* Gateway info for compact view */}
{compact && gatewayName && (
<div className="mt-2 text-xs text-muted-foreground">via {gatewayName}</div>
)}
</div>
);
}
);
PaymentMethodCard.displayName = "PaymentMethodCard";
export { PaymentMethodCard };
export type { PaymentMethodCardProps };

View File

@ -41,13 +41,7 @@ export class CheckoutParamsService {
return normalized;
}
// Handle legacy/edge cases not covered by normalization
if (typeParam.toLowerCase() === "other") {
return ORDER_TYPE.OTHER;
}
// Default fallback
return ORDER_TYPE.INTERNET;
throw new Error(`Unsupported order type: ${typeParam}`);
}
private static coalescePlanReference(selections: OrderSelections): string | null {

View File

@ -1,20 +1,13 @@
import { apiClient, getDataOrThrow } from "@/core/api";
import type {
CheckoutCart,
OrderConfigurations,
OrderSelections,
OrderTypeValue,
import {
checkoutSessionResponseSchema,
type CheckoutCart,
type CheckoutSessionResponse,
type OrderConfigurations,
type OrderSelections,
type OrderTypeValue,
} from "@customer-portal/domain/orders";
type CheckoutCartSummary = { items: CheckoutCart["items"]; totals: CheckoutCart["totals"] };
type CheckoutSessionResponse = {
sessionId: string;
expiresAt: string;
orderType: OrderTypeValue;
cart: CheckoutCartSummary;
};
export const checkoutService = {
/**
* Build checkout cart from order type and selections
@ -43,7 +36,8 @@ export const checkoutService = {
const response = await apiClient.POST<CheckoutSessionResponse>("/api/checkout/session", {
body: { orderType, selections, configuration },
});
return getDataOrThrow(response, "Failed to create checkout session");
const data = getDataOrThrow(response, "Failed to create checkout session");
return checkoutSessionResponseSchema.parse(data);
},
async getSession(sessionId: string): Promise<CheckoutSessionResponse> {
@ -53,7 +47,8 @@ export const checkoutService = {
params: { path: { sessionId } },
}
);
return getDataOrThrow(response, "Failed to load checkout session");
const data = getDataOrThrow(response, "Failed to load checkout session");
return checkoutSessionResponseSchema.parse(data);
},
/**

View File

@ -15,6 +15,8 @@ import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
import { ordersService } from "@/features/orders/api/orders.api";
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
import { billingService } from "@/features/billing/api/billing.api";
import { openSsoLink } from "@/features/billing/utils/sso";
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants";
import { useInternetEligibility } from "@/features/services/hooks/useInternetEligibility";
@ -24,11 +26,12 @@ import {
useSubmitResidenceCard,
} from "@/features/verification/hooks/useResidenceCardVerification";
import { useAuthSession } from "@/features/auth/stores/auth.store";
import { apiClient } from "@/core/api";
import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders";
import { ssoLinkResponseSchema } from "@customer-portal/domain/auth";
import { buildPaymentMethodDisplay } from "../utils/checkout-ui-utils";
import {
ORDER_TYPE,
type OrderTypeValue,
toOrderTypeValueFromCheckout,
} from "@customer-portal/domain/orders";
import { buildPaymentMethodDisplay, formatAddressLabel } from "@/shared/utils";
import { CheckoutStatusBanners } from "./CheckoutStatusBanners";
export function AccountCheckoutContainer() {
@ -46,17 +49,7 @@ export function AccountCheckoutContainer() {
const paymentToastTimeoutRef = useRef<number | null>(null);
const orderType: OrderTypeValue | null = useMemo(() => {
if (!cartItem?.orderType) return null;
switch (cartItem.orderType) {
case "Internet":
return ORDER_TYPE.INTERNET;
case "SIM":
return ORDER_TYPE.SIM;
case "VPN":
return ORDER_TYPE.VPN;
default:
return null;
}
return toOrderTypeValueFromCheckout(cartItem?.orderType);
}, [cartItem?.orderType]);
const isInternetOrder = orderType === ORDER_TYPE.INTERNET;
@ -125,15 +118,7 @@ export function AccountCheckoutContainer() {
user?.address?.postcode &&
(user?.address?.country || user?.address?.countryCode)
);
const addressLabel = useMemo(() => {
const a = user?.address;
if (!a) return "";
return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode]
.filter(Boolean)
.map(part => String(part).trim())
.filter(part => part.length > 0)
.join(", ");
}, [user?.address]);
const addressLabel = useMemo(() => formatAddressLabel(user?.address), [user?.address]);
const residenceCardQuery = useResidenceCardVerification();
const submitResidenceCard = useSubmitResidenceCard();
@ -232,14 +217,11 @@ export function AccountCheckoutContainer() {
setOpeningPaymentPortal(true);
try {
const response = await apiClient.POST("/api/auth/sso-link", {
body: { destination: "index.php?rp=/account/paymentmethods" },
});
const data = ssoLinkResponseSchema.parse(response.data);
const data = await billingService.createPaymentMethodsSsoLink();
if (!data.url) {
throw new Error("No payment portal URL returned");
}
window.open(data.url, "_blank", "noopener,noreferrer");
openSsoLink(data.url, { newTab: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to open the payment portal";
showPaymentToast(message, "error");
@ -442,26 +424,11 @@ export function AccountCheckoutContainer() {
Your identity verification is complete.
</AlertBanner>
{residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? (
{residenceCardQuery.data?.submittedAt || residenceCardQuery.data?.reviewedAt ? (
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Submitted document
Verification status
</div>
{residenceCardQuery.data?.filename ? (
<div className="mt-1 text-sm font-medium text-foreground">
{residenceCardQuery.data.filename}
{typeof residenceCardQuery.data.sizeBytes === "number" &&
residenceCardQuery.data.sizeBytes > 0 ? (
<span className="text-xs text-muted-foreground">
{" "}
·{" "}
{Math.round((residenceCardQuery.data.sizeBytes / 1024 / 1024) * 10) /
10}
{" MB"}
</span>
) : null}
</div>
) : null}
<div className="mt-1 text-xs text-muted-foreground space-y-0.5">
{formatDateTime(residenceCardQuery.data?.submittedAt) ? (
<div>
@ -556,33 +523,14 @@ export function AccountCheckoutContainer() {
Well verify your residence card before activating SIM service.
</AlertBanner>
{residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? (
{residenceCardQuery.data?.submittedAt ? (
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Submitted document
Submission status
</div>
{residenceCardQuery.data?.filename ? (
<div className="mt-1 text-sm font-medium text-foreground">
{residenceCardQuery.data.filename}
{typeof residenceCardQuery.data.sizeBytes === "number" &&
residenceCardQuery.data.sizeBytes > 0 ? (
<span className="text-xs text-muted-foreground">
{" "}
·{" "}
{Math.round((residenceCardQuery.data.sizeBytes / 1024 / 1024) * 10) /
10}
{" MB"}
</span>
) : null}
</div>
) : null}
<div className="mt-1 text-xs text-muted-foreground">
{formatDateTime(residenceCardQuery.data?.submittedAt) ? (
<div>
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
</div>
) : null}
</div>
</div>
) : null}

View File

@ -3,9 +3,9 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { usePathname, useSearchParams, useRouter } from "next/navigation";
import type { CartItem, OrderType as CheckoutOrderType } from "@customer-portal/domain/checkout";
import type { CheckoutCart, OrderTypeValue } from "@customer-portal/domain/orders";
import { ORDER_TYPE } from "@customer-portal/domain/orders";
import type { CartItem } from "@customer-portal/domain/checkout";
import type { CheckoutCartSummary, OrderTypeValue } from "@customer-portal/domain/orders";
import { toCheckoutOrderType } from "@customer-portal/domain/orders";
import { checkoutService } from "@/features/checkout/api/checkout.api";
import { CheckoutParamsService } from "@/features/checkout/api/checkout-params.api";
import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
@ -15,31 +15,21 @@ import { Button } from "@/components/atoms/button";
import { Spinner } from "@/components/atoms";
import { EmptyCartRedirect } from "@/features/checkout/components/EmptyCartRedirect";
import { useAuthSession, useAuthStore } from "@/features/auth/stores/auth.store";
import { logger } from "@/core/logger";
const signatureFromSearchParams = (params: URLSearchParams): string => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
return entries.map(([key, value]) => `${key}=${value}`).join("&");
};
const mapOrderTypeToCheckout = (orderType: OrderTypeValue): CheckoutOrderType => {
switch (orderType) {
case ORDER_TYPE.SIM:
return "SIM";
case ORDER_TYPE.VPN:
return "VPN";
case ORDER_TYPE.INTERNET:
case ORDER_TYPE.OTHER:
default:
return "Internet";
}
};
type CheckoutCartSummary = { items: CheckoutCart["items"]; totals: CheckoutCart["totals"] };
const cartItemFromCheckoutCart = (
cart: CheckoutCartSummary,
orderType: OrderTypeValue
): CartItem => {
const checkoutOrderType = toCheckoutOrderType(orderType);
if (!checkoutOrderType) {
throw new Error(`Unsupported order type: ${orderType}`);
}
const planItem = cart.items.find(item => item.itemType === "plan") ?? cart.items[0];
const planSku = planItem?.sku;
if (!planSku) {
@ -50,7 +40,7 @@ const cartItemFromCheckoutCart = (
);
return {
orderType: mapOrderTypeToCheckout(orderType),
orderType: checkoutOrderType,
planSku,
planName: planItem?.name ?? planSku,
addonSkus,
@ -107,6 +97,11 @@ export function CheckoutEntry() {
void (async () => {
try {
const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
if (snapshot.warnings.length > 0) {
logger.warn("Checkout params normalization warnings", {
warnings: snapshot.warnings,
});
}
if (!snapshot.planReference) {
throw new Error("No plan selected. Please go back and select a plan.");
}
@ -171,6 +166,22 @@ export function CheckoutEntry() {
};
}, [checkoutSessionId, clear, isCartStale, paramsKey, setCartItem, setCheckoutSession]);
// Redirect unauthenticated users to login
// Cart data is preserved in localStorage, so they can continue after logging in
const shouldRedirectToLogin = !isAuthenticated && hasCheckedAuth;
useEffect(() => {
if (!shouldRedirectToLogin) return;
const currentUrl = pathname + (paramsKey ? `?${paramsKey}` : "");
const returnTo = encodeURIComponent(
pathname.startsWith("/account")
? currentUrl
: `/account/order${paramsKey ? `?${paramsKey}` : ""}`
);
router.replace(`/auth/login?returnTo=${returnTo}`);
}, [shouldRedirectToLogin, pathname, paramsKey, router]);
const shouldWaitForCart =
(Boolean(paramsKey) && (!cartItem || cartParamsSignature !== signature)) ||
(!paramsKey && Boolean(checkoutSessionId) && !cartItem);
@ -211,16 +222,7 @@ export function CheckoutEntry() {
return <EmptyCartRedirect />;
}
// Redirect unauthenticated users to login
// Cart data is preserved in localStorage, so they can continue after logging in
if (!isAuthenticated && hasCheckedAuth) {
const currentUrl = pathname + (paramsKey ? `?${paramsKey}` : "");
const returnTo = encodeURIComponent(
pathname.startsWith("/account")
? currentUrl
: `/account/order${paramsKey ? `?${paramsKey}` : ""}`
);
router.replace(`/auth/login?returnTo=${returnTo}`);
if (shouldRedirectToLogin) {
return (
<div className="max-w-2xl mx-auto py-12">
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)] text-center">

View File

@ -1,6 +1,7 @@
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import type { Address } from "@customer-portal/domain/customer";
import { confirmWithAddress } from "@/shared/utils";
interface CheckoutStatusBannersProps {
activeInternetWarning: string | null;
@ -90,10 +91,9 @@ export function CheckoutStatusBanners({
loadingText="Requesting…"
onClick={() =>
void (async () => {
const confirmed =
typeof window === "undefined" ||
window.confirm(
`Request an eligibility review for this address?\n\n${addressLabel}`
const confirmed = confirmWithAddress(
"Request an eligibility review for this address?",
addressLabel
);
if (!confirmed) return;
eligibilityRequest.mutate({

View File

@ -14,6 +14,3 @@ export * from "./components";
// Constants
export * from "./constants";
// Utilities
export * from "./utils";

View File

@ -25,7 +25,6 @@ interface CheckoutActions {
setCartItem: (item: CartItem) => void;
setCartItemFromParams: (item: CartItem, signature: string) => void;
setCheckoutSession: (session: { id: string; expiresAt: string }) => void;
clearCart: () => void;
// Reset
clear: () => void;
@ -87,21 +86,18 @@ export const useCheckoutStore = create<CheckoutStore>()(
checkoutSessionExpiresAt: session.expiresAt,
}),
clearCart: () =>
set({
cartItem: null,
cartParamsSignature: null,
checkoutSessionId: null,
checkoutSessionExpiresAt: null,
cartUpdatedAt: null,
}),
// Reset
clear: () => set(initialState),
// Cart recovery - check if cart is stale (default 24 hours)
isCartStale: (maxAgeMs = 24 * 60 * 60 * 1000) => {
const { cartUpdatedAt } = get();
const { cartUpdatedAt, checkoutSessionExpiresAt } = get();
if (checkoutSessionExpiresAt) {
const expiresAt = new Date(checkoutSessionExpiresAt).getTime();
if (!Number.isNaN(expiresAt) && Date.now() >= expiresAt) {
return true;
}
}
if (!cartUpdatedAt) return false;
return Date.now() - cartUpdatedAt > maxAgeMs;
},
@ -150,10 +146,3 @@ export const useCheckoutStore = create<CheckoutStore>()(
}
)
);
/**
* Hook to check if cart has items
*/
export function useHasCartItem(): boolean {
return useCheckoutStore(state => state.cartItem !== null);
}

View File

@ -1 +1 @@
export { useCheckoutStore, useHasCartItem } from "./checkout.store";
export { useCheckoutStore } from "./checkout.store";

View File

@ -1 +0,0 @@
export * from "./checkout-ui-utils";

View File

@ -2,18 +2,13 @@
import { ReactNode, useMemo, type KeyboardEvent } from "react";
import { StatusPill } from "@/components/atoms/status-pill";
import {
WifiIcon,
DevicePhoneMobileIcon,
LockClosedIcon,
CubeIcon,
} from "@heroicons/react/24/outline";
import {
calculateOrderTotals,
deriveOrderStatusDescriptor,
getServiceCategory,
} from "@/features/orders/utils/order-presenters";
import { buildOrderDisplayItems } from "@/features/orders/utils/order-display";
import { OrderServiceIcon } from "@/features/orders/components/OrderServiceIcon";
import type { OrderSummary } from "@customer-portal/domain/orders";
import { cn } from "@/shared/utils";
@ -40,20 +35,6 @@ const SERVICE_ICON_STYLES = {
default: "bg-muted text-muted-foreground border border-border",
} as const;
const renderServiceIcon = (orderType?: string): ReactNode => {
const category = getServiceCategory(orderType);
switch (category) {
case "internet":
return <WifiIcon className="h-6 w-6" />;
case "sim":
return <DevicePhoneMobileIcon className="h-6 w-6" />;
case "vpn":
return <LockClosedIcon className="h-6 w-6" />;
default:
return <CubeIcon className="h-6 w-6" />;
}
};
export function OrderCard({ order, onClick, footer, className }: OrderCardProps) {
const statusDescriptor = deriveOrderStatusDescriptor({
status: order.status,
@ -62,7 +43,7 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
const statusVariant = STATUS_PILL_VARIANT[statusDescriptor.tone];
const serviceCategory = getServiceCategory(order.orderType);
const iconStyles = SERVICE_ICON_STYLES[serviceCategory];
const serviceIcon = renderServiceIcon(order.orderType);
const serviceIcon = <OrderServiceIcon orderType={order.orderType} />;
const displayItems = useMemo(
() => buildOrderDisplayItems(order.itemsSummary),
[order.itemsSummary]

View File

@ -0,0 +1,32 @@
import {
WifiIcon,
DevicePhoneMobileIcon,
LockClosedIcon,
CubeIcon,
} from "@heroicons/react/24/outline";
import { getServiceCategory } from "@/features/orders/utils/order-presenters";
interface OrderServiceIconProps {
orderType?: string;
category?: ReturnType<typeof getServiceCategory>;
className?: string;
}
export function OrderServiceIcon({
orderType,
category,
className = "h-6 w-6",
}: OrderServiceIconProps) {
const resolvedCategory = category ?? getServiceCategory(orderType);
switch (resolvedCategory) {
case "internet":
return <WifiIcon className={className} />;
case "sim":
return <DevicePhoneMobileIcon className={className} />;
case "vpn":
return <LockClosedIcon className={className} />;
default:
return <CubeIcon className={className} />;
}
}

View File

@ -1,2 +1,3 @@
export { OrderCard } from "./OrderCard";
export { OrderCardSkeleton } from "./OrderCardSkeleton";
export { OrderServiceIcon } from "./OrderServiceIcon";

View File

@ -6,10 +6,6 @@ import { PageLayout } from "@/components/templates/PageLayout";
import {
ClipboardDocumentCheckIcon,
CheckCircleIcon,
WifiIcon,
DevicePhoneMobileIcon,
LockClosedIcon,
CubeIcon,
SparklesIcon,
WrenchScrewdriverIcon,
PuzzlePieceIcon,
@ -32,6 +28,7 @@ import {
type OrderDisplayItemCategory,
type OrderDisplayItemCharge,
} from "@/features/orders/utils/order-display";
import { OrderServiceIcon } from "@/features/orders/components/OrderServiceIcon";
import type { OrderDetails, OrderUpdateEventPayload } from "@customer-portal/domain/orders";
import { Formatting } from "@customer-portal/domain/toolkit";
import { cn, formatIsoDate } from "@/shared/utils";
@ -46,19 +43,6 @@ const STATUS_PILL_VARIANT: Record<
neutral: "neutral",
};
const renderServiceIcon = (category: ReturnType<typeof getServiceCategory>, className: string) => {
switch (category) {
case "internet":
return <WifiIcon className={className} />;
case "sim":
return <DevicePhoneMobileIcon className={className} />;
case "vpn":
return <LockClosedIcon className={className} />;
default:
return <CubeIcon className={className} />;
}
};
const CATEGORY_CONFIG: Record<
OrderDisplayItemCategory,
{
@ -159,7 +143,7 @@ export function OrderDetailContainer() {
: STATUS_PILL_VARIANT.neutral;
const serviceCategory = getServiceCategory(data?.orderType);
const serviceIcon = renderServiceIcon(serviceCategory, "h-6 w-6");
const serviceIcon = <OrderServiceIcon category={serviceCategory} className="h-6 w-6" />;
const displayItems = useMemo<OrderDisplayItem[]>(() => {
return buildOrderDisplayItems(data?.itemsSummary);

View File

@ -9,6 +9,7 @@ import { ServicesBackLink } from "@/features/services/components/base/ServicesBa
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { useInternetEligibility } from "@/features/services/hooks";
import { useAuthSession } from "@/features/auth/stores/auth.store";
import { formatAddressLabel } from "@/shared/utils";
export function InternetEligibilityRequestSubmittedView() {
const servicesBasePath = useServicesBasePath();
@ -18,15 +19,7 @@ export function InternetEligibilityRequestSubmittedView() {
const { user } = useAuthSession();
const eligibilityQuery = useInternetEligibility();
const addressLabel = useMemo(() => {
const a = user?.address;
if (!a) return "";
return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode]
.filter(Boolean)
.map(part => String(part).trim())
.filter(part => part.length > 0)
.join(", ");
}, [user?.address]);
const addressLabel = useMemo(() => formatAddressLabel(user?.address), [user?.address]);
const requestId = requestIdFromQuery ?? eligibilityQuery.data?.requestId ?? null;
const status = eligibilityQuery.data?.status;

View File

@ -27,7 +27,7 @@ import {
useRequestInternetEligibilityCheck,
} from "@/features/services/hooks";
import { useAuthSession } from "@/features/auth/stores/auth.store";
import { cn, formatIsoDate } from "@/shared/utils";
import { cn, confirmWithAddress, formatAddressLabel, formatIsoDate } from "@/shared/utils";
type AutoRequestStatus = "idle" | "submitting" | "submitted" | "failed" | "missing_address";
@ -344,15 +344,7 @@ export function InternetPlansContainer() {
const autoPlanSku = searchParams?.get("planSku");
const [autoRequestStatus, setAutoRequestStatus] = useState<AutoRequestStatus>("idle");
const [autoRequestId, setAutoRequestId] = useState<string | null>(null);
const addressLabel = useMemo(() => {
const a = user?.address;
if (!a) return "";
return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode]
.filter(Boolean)
.map(part => String(part).trim())
.filter(part => part.length > 0)
.join(", ");
}, [user?.address]);
const addressLabel = useMemo(() => formatAddressLabel(user?.address), [user?.address]);
const eligibility = useMemo(() => {
if (!isEligible) return null;
@ -397,9 +389,7 @@ export function InternetPlansContainer() {
}
// Trigger eligibility check
const confirmed =
typeof window === "undefined" ||
window.confirm(`Request availability check for:\n\n${addressLabel}`);
const confirmed = confirmWithAddress("Request availability check for:", addressLabel);
if (!confirmed) return;
setAutoRequestId(null);

View File

@ -26,30 +26,26 @@ const ICON_SIZE_CLASSES: Record<IconSize, string> = {
/**
* Status to icon mapping
* Uses customer-friendly status values (internal statuses are mapped before reaching here)
*/
const STATUS_ICON_MAP: Record<string, (className: string) => ReactNode> = {
[SUPPORT_CASE_STATUS.RESOLVED]: cls => <CheckCircleIcon className={`${cls} text-success`} />,
[SUPPORT_CASE_STATUS.CLOSED]: cls => <CheckCircleIcon className={`${cls} text-success`} />,
[SUPPORT_CASE_STATUS.VPN_PENDING]: cls => <CheckCircleIcon className={`${cls} text-success`} />,
[SUPPORT_CASE_STATUS.PENDING]: cls => <CheckCircleIcon className={`${cls} text-success`} />,
[SUPPORT_CASE_STATUS.NEW]: cls => <SparklesIcon className={`${cls} text-primary`} />,
[SUPPORT_CASE_STATUS.IN_PROGRESS]: cls => <ClockIcon className={`${cls} text-info`} />,
[SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: cls => (
[SUPPORT_CASE_STATUS.AWAITING_CUSTOMER]: cls => (
<ExclamationTriangleIcon className={`${cls} text-warning`} />
),
[SUPPORT_CASE_STATUS.NEW]: cls => <SparklesIcon className={`${cls} text-primary`} />,
[SUPPORT_CASE_STATUS.CLOSED]: cls => <CheckCircleIcon className={`${cls} text-success`} />,
};
/**
* Status to variant mapping
* Uses customer-friendly status values (internal statuses are mapped before reaching here)
*/
const STATUS_VARIANT_MAP: Record<string, CaseStatusVariant> = {
[SUPPORT_CASE_STATUS.RESOLVED]: "success",
[SUPPORT_CASE_STATUS.CLOSED]: "success",
[SUPPORT_CASE_STATUS.VPN_PENDING]: "success",
[SUPPORT_CASE_STATUS.PENDING]: "success",
[SUPPORT_CASE_STATUS.IN_PROGRESS]: "info",
[SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: "warning",
[SUPPORT_CASE_STATUS.NEW]: "purple",
[SUPPORT_CASE_STATUS.IN_PROGRESS]: "info",
[SUPPORT_CASE_STATUS.AWAITING_CUSTOMER]: "warning",
[SUPPORT_CASE_STATUS.CLOSED]: "success",
};
/**

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useMemo } from "react";
import {
CalendarIcon,
ClockIcon,
@ -9,6 +9,7 @@ import {
PaperAirplaneIcon,
EnvelopeIcon,
ChatBubbleLeftIcon,
PaperClipIcon,
} from "@heroicons/react/24/outline";
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
import { PageLayout } from "@/components/templates/PageLayout";
@ -26,6 +27,64 @@ import { formatIsoDate, formatIsoRelative } from "@/shared/utils";
import type { CaseMessage } from "@customer-portal/domain/support";
import { CLOSED_STATUSES } from "@customer-portal/domain/support";
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Group messages by date for better readability in long conversations
*/
interface MessageGroup {
date: string;
dateLabel: string;
messages: CaseMessage[];
}
function groupMessagesByDate(messages: CaseMessage[]): MessageGroup[] {
const groups: Map<string, CaseMessage[]> = new Map();
for (const message of messages) {
const date = new Date(message.createdAt);
const dateKey = date.toISOString().split("T")[0]; // YYYY-MM-DD
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push(message);
}
// Convert to array and add labels
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const todayKey = today.toISOString().split("T")[0];
const yesterdayKey = yesterday.toISOString().split("T")[0];
return Array.from(groups.entries()).map(([dateKey, msgs]) => {
let dateLabel: string;
if (dateKey === todayKey) {
dateLabel = "Today";
} else if (dateKey === yesterdayKey) {
dateLabel = "Yesterday";
} else {
// Format as "Mon, Dec 30"
const date = new Date(dateKey + "T00:00:00");
dateLabel = date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
}
return {
date: dateKey,
dateLabel,
messages: msgs,
};
});
}
interface SupportCaseDetailViewProps {
caseId: string;
}
@ -175,50 +234,12 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
</button>
</div>
<div className="p-5">
{messagesLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
</div>
) : messagesData?.messages && messagesData.messages.length > 0 ? (
<div className="space-y-4">
{/* Original Description as first message */}
<MessageBubble
message={{
id: "description",
type: "comment",
body: supportCase.description,
author: { name: "You", email: null, isCustomer: true },
createdAt: supportCase.createdAt,
direction: null,
}}
<ConversationThread
supportCase={supportCase}
messagesData={messagesData}
messagesLoading={messagesLoading}
/>
{/* Conversation messages */}
{messagesData.messages.map(message => (
<MessageBubble key={message.id} message={message} />
))}
</div>
) : (
<div className="space-y-4">
{/* Show description as the only message if no conversation yet */}
<MessageBubble
message={{
id: "description",
type: "comment",
body: supportCase.description,
author: { name: "You", email: null, isCustomer: true },
createdAt: supportCase.createdAt,
direction: null,
}}
/>
<p className="text-center text-sm text-muted-foreground py-4">
No replies yet. Our team will respond shortly.
</p>
</div>
)}
</div>
{/* Reply Form */}
{!isCaseClosed && (
<div className="px-5 py-4 border-t border-border bg-muted/20">
@ -279,12 +300,97 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
);
}
// ============================================================================
// Conversation Components
// ============================================================================
interface ConversationThreadProps {
supportCase: {
description: string;
createdAt: string;
};
messagesData: { messages: CaseMessage[] } | undefined;
messagesLoading: boolean;
}
/**
* Conversation thread with date grouping
*/
function ConversationThread({
supportCase,
messagesData,
messagesLoading,
}: ConversationThreadProps) {
// Create initial description message
const descriptionMessage: CaseMessage = useMemo(
() => ({
id: "description",
type: "comment",
body: supportCase.description,
author: { name: "You", email: null, isCustomer: true },
createdAt: supportCase.createdAt,
direction: null,
}),
[supportCase.description, supportCase.createdAt]
);
// Combine description with messages and group by date
const messageGroups = useMemo(() => {
const allMessages = [descriptionMessage, ...(messagesData?.messages ?? [])];
return groupMessagesByDate(allMessages);
}, [descriptionMessage, messagesData?.messages]);
if (messagesLoading) {
return (
<div className="p-5 flex items-center justify-center py-8">
<Spinner size="md" />
</div>
);
}
const hasReplies = (messagesData?.messages?.length ?? 0) > 0;
return (
<div className="p-5">
<div className="space-y-6">
{messageGroups.map(group => (
<div key={group.date}>
{/* Date separator */}
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-border" />
<span className="text-xs font-medium text-muted-foreground px-2">
{group.dateLabel}
</span>
<div className="flex-1 h-px bg-border" />
</div>
{/* Messages for this date */}
<div className="space-y-4">
{group.messages.map(message => (
<MessageBubble key={message.id} message={message} />
))}
</div>
</div>
))}
{/* No replies yet message */}
{!hasReplies && (
<p className="text-center text-sm text-muted-foreground py-4">
No replies yet. Our team will respond shortly.
</p>
)}
</div>
</div>
);
}
/**
* Message bubble component for displaying individual messages
*/
function MessageBubble({ message }: { message: CaseMessage }) {
const isCustomer = message.author.isCustomer;
const isEmail = message.type === "email";
const hasAttachment = message.hasAttachment === true;
return (
<div className={`flex ${isCustomer ? "justify-end" : "justify-start"}`}>
@ -309,6 +415,15 @@ function MessageBubble({ message }: { message: CaseMessage }) {
<span className="font-medium">{message.author.name}</span>
<span></span>
<span>{formatIsoRelative(message.createdAt)}</span>
{hasAttachment && (
<>
<span></span>
<span className="inline-flex items-center gap-1">
<PaperClipIcon className="h-3 w-3" />
Attachment
</span>
</>
)}
</div>
{/* Message body */}

View File

@ -95,21 +95,14 @@ export function ResidenceCardVerificationSettingsView() {
We&apos;ll verify your residence card before activating SIM service.
</AlertBanner>
{(residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt) && (
{residenceCardQuery.data?.submittedAt && (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Submitted document
Submission status
</div>
{residenceCardQuery.data?.filename && (
<div className="mt-1 text-sm font-medium text-foreground">
{residenceCardQuery.data.filename}
</div>
)}
{formatDateTime(residenceCardQuery.data?.submittedAt) && (
<div className="mt-1 text-xs text-muted-foreground">
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
</div>
)}
</div>
)}
@ -140,18 +133,11 @@ export function ResidenceCardVerificationSettingsView() {
</div>
</AlertBanner>
{(residenceCardQuery.data?.filename ||
residenceCardQuery.data?.submittedAt ||
residenceCardQuery.data?.reviewedAt) && (
{(residenceCardQuery.data?.submittedAt || residenceCardQuery.data?.reviewedAt) && (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Latest submission
</div>
{residenceCardQuery.data?.filename && (
<div className="mt-1 text-sm font-medium text-foreground">
{residenceCardQuery.data.filename}
</div>
)}
{formatDateTime(residenceCardQuery.data?.submittedAt) && (
<div className="mt-1 text-xs text-muted-foreground">
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}

View File

@ -0,0 +1,17 @@
import type { Address } from "@customer-portal/domain/customer";
export function formatAddressLabel(address?: Partial<Address> | null): string {
if (!address) return "";
return [
address.address1,
address.address2,
address.city,
address.state,
address.postcode,
address.country || address.countryCode,
]
.filter(Boolean)
.map(part => String(part).trim())
.filter(part => part.length > 0)
.join(", ");
}

View File

@ -0,0 +1,9 @@
export function confirmWithAddress(message: string, addressLabel?: string): boolean {
if (typeof window === "undefined") {
return true;
}
const label = addressLabel?.trim();
const suffix = label ? `\n\n${label}` : "";
return window.confirm(`${message}${suffix}`);
}

View File

@ -1,4 +1,6 @@
export { cn } from "./cn";
export { formatAddressLabel } from "./address";
export { confirmWithAddress } from "./confirm";
export {
formatIsoDate,
formatIsoRelative,
@ -19,3 +21,9 @@ export {
type ParsedError,
type ErrorCodeType,
} from "./error-handling";
export {
buildPaymentMethodDisplay,
getPaymentMethodBrandLabel,
getPaymentMethodCardDisplay,
normalizeExpiryLabel,
} from "./payment-methods";

View File

@ -1,5 +1,16 @@
import type { PaymentMethod } from "@customer-portal/domain/payments";
const isCreditCard = (type: PaymentMethod["type"]) =>
type === "CreditCard" || type === "RemoteCreditCard";
const isBankAccount = (type: PaymentMethod["type"]) =>
type === "BankAccount" || type === "RemoteBankAccount";
const getTrimmedLastFour = (method: PaymentMethod): string | null => {
const lastFour = typeof method.cardLastFour === "string" ? method.cardLastFour.trim() : "";
return lastFour.length > 0 ? lastFour.slice(-4) : null;
};
export function buildPaymentMethodDisplay(method: PaymentMethod): {
title: string;
subtitle?: string;
@ -11,10 +22,7 @@ export function buildPaymentMethodDisplay(method: PaymentMethod): {
method.gatewayName?.trim() ||
"Saved payment method";
const trimmedLastFour =
typeof method.cardLastFour === "string" && method.cardLastFour.trim().length > 0
? method.cardLastFour.trim().slice(-4)
: null;
const trimmedLastFour = getTrimmedLastFour(method);
const headline =
trimmedLastFour && method.type?.toLowerCase().includes("card")
@ -79,3 +87,32 @@ export function normalizeExpiryLabel(expiry?: string | null): string | null {
return value;
}
export function getPaymentMethodCardDisplay(method: PaymentMethod): string {
const trimmedLastFour = getTrimmedLastFour(method);
if (trimmedLastFour) {
return `***** ${trimmedLastFour}`;
}
if (isCreditCard(method.type)) {
return method.cardType ? `${method.cardType.toUpperCase()} Card` : "Credit Card";
}
if (isBankAccount(method.type)) {
return method.bankName || "Bank Account";
}
return method.description || "Payment Method";
}
export function getPaymentMethodBrandLabel(method: PaymentMethod): string | null {
if (isCreditCard(method.type) && method.cardType) {
return method.cardType.toUpperCase();
}
if (isBankAccount(method.type) && method.bankName) {
return method.bankName;
}
return null;
}

View File

@ -20,42 +20,79 @@ The response includes:
Portal UI maps task `type` → icon locally; everything else (priority, copy, links) is computed server-side.
## In-app notifications
## In-app Notifications
In-app notifications are stored in Postgres and fetched via the Notifications API. Notifications use domain templates in:
- `packages/domain/notifications/schema.ts`
### Where notifications are created
### All Notification Types
- **Eligibility / Verification**:
- Triggered from Salesforce events (Account fields change).
- Created by the Salesforce events handlers.
| Type | Title | Created By | Trigger |
| ------------------------- | ------------------------------------------ | ---------------------- | ------------------------------- |
| `ELIGIBILITY_ELIGIBLE` | "Good news! Internet service is available" | Platform Event | Eligibility status → Eligible |
| `ELIGIBILITY_INELIGIBLE` | "Internet service not available" | Platform Event | Eligibility status → Ineligible |
| `VERIFICATION_VERIFIED` | "ID verification complete" | Platform Event | Verification status → Verified |
| `VERIFICATION_REJECTED` | "ID verification requires attention" | Platform Event | Verification status → Rejected |
| `ORDER_APPROVED` | "Order approved" | Fulfillment flow | Order approved in Salesforce |
| `ORDER_ACTIVATED` | "Service activated" | Fulfillment flow | WHMCS provisioning complete |
| `ORDER_FAILED` | "Order requires attention" | Fulfillment flow | Fulfillment error |
| `CANCELLATION_SCHEDULED` | "Cancellation scheduled" | Cancellation flow | Customer requests cancellation |
| `CANCELLATION_COMPLETE` | "Service cancelled" | _(Not implemented)_ | — |
| `PAYMENT_METHOD_EXPIRING` | "Payment method expiring soon" | _(Not implemented)_ | — |
| `INVOICE_DUE` | "Invoice due" | Dashboard status check | Invoice due within 7 days |
| `SYSTEM_ANNOUNCEMENT` | "System announcement" | _(Not implemented)_ | — |
- **Orders**:
- **Approved / Activated / Failed** notifications are created during the fulfillment workflow:
- `apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts`
- The notification `sourceId` uses the Salesforce Order Id to prevent duplicates during retries.
### Eligibility & Verification Notifications
- **Cancellations**:
- A “Cancellation scheduled” notification is created when the cancellation request is submitted:
- Internet: `apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts`
- SIM: `apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts`
These are triggered by Salesforce Platform Events:
- **Invoice due**:
- Created opportunistically when the dashboard status is requested (`GET /api/me/status`) if an invoice is due within 7 days (or overdue).
1. **Salesforce Flow** fires Platform Event when `Internet_Eligibility_Status__c` or `Id_Verification_Status__c` changes
2. **BFF subscriber** receives the event and extracts status fields
3. **Notification handler** creates notification only for final states:
- Eligibility: `Eligible` or `Ineligible` (not `Pending`)
- Verification: `Verified` or `Rejected` (not `Submitted`)
### Dedupe behavior
**Important:** The Salesforce Flow must use `ISCHANGED()` to only include status fields when they actually changed. Otherwise, notifications would be created on every account update.
See `docs/integrations/salesforce/platform-events.md` for Platform Event configuration.
### Order Notifications
Created during the fulfillment workflow in:
- `apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts`
The notification `sourceId` uses the Salesforce Order ID to prevent duplicates during retries.
### Cancellation Notifications
Created when cancellation request is submitted:
- Internet: `apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts`
- SIM: `apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts`
### Invoice Due Notifications
Created opportunistically when dashboard status is requested (`GET /api/me/status`) if an invoice is due within 7 days or overdue.
### Dedupe Behavior
Notifications dedupe is enforced in:
- `apps/bff/src/modules/notifications/notifications.service.ts`
Rules:
- For most types: dedupe is **type + sourceId within 1 hour**.
- For “reminder-style” types (invoice due, payment method expiring, system announcement): dedupe is **type + sourceId within 24 hours**.
| Type Category | Dedupe Window | Logic |
| -------------------------------------------- | ------------- | ------------------------ |
| Most types | 1 hour | Same `type` + `sourceId` |
| Invoice due, payment expiring, announcements | 24 hours | Same `type` + `sourceId` |
### Action URLs
Notification templates use **authenticated Portal routes** (e.g. `/account/orders`, `/account/services`, `/account/billing/*`) so clicks always land in the correct shell.
Notification templates use authenticated Portal routes:
- `/account/orders` - Order notifications
- `/account/services` - Activation/cancellation notifications
- `/account/services/internet` - Eligibility notifications
- `/account/settings/verification` - Verification notifications
- `/account/billing/invoices` - Invoice notifications

View File

@ -28,17 +28,42 @@ This guide describes how eligibility and verification work in the customer porta
7. Salesforce Flow sends email notification to customer
8. Customer returns and sees eligible plans
### Caching & Rate Limiting (Security + Load)
### Caching & Real-Time Updates
- **BFF cache (Redis)**:
- Internet catalog data is cached in Redis (CDC-driven invalidation, no TTL) so repeated portal hits **do not repeatedly query Salesforce**.
- Eligibility details are cached per Salesforce Account ID and are invalidated/updated when Salesforce emits Account change events.
- **Portal cache (React Query)**:
- The portal caches service catalog responses in-memory, scoped by auth state, and will refetch when stale.
- On logout, the portal clears cached queries to avoid cross-user leakage on shared devices.
- **Rate limiting**:
- Public catalog endpoints are rate-limited per IP + User-Agent to prevent abuse.
- `POST /api/services/internet/eligibility-request` is authenticated and rate-limited, and the BFF is idempotent when a request is already pending (no duplicate Cases created).
#### Redis Caching (BFF)
| Cache Key | Content | Invalidation |
| ----------------------------------- | --------------------------- | -------------- |
| `services:eligibility:{accountId}` | Eligibility status & value | Platform Event |
| `services:verification:{accountId}` | Verification status & dates | Platform Event |
Both caches use CDC-driven invalidation with a safety TTL (12 hours default). When Salesforce sends a Platform Event, the BFF:
1. Invalidates both caches (eligibility + verification)
2. Sends SSE `account.updated` to connected portals
3. Portal refetches fresh data
#### Platform Event Integration
See `docs/integrations/salesforce/platform-events.md` for full details.
**Account Update Event** fires when any of these fields change:
- `Internet_Eligibility__c`, `Internet_Eligibility_Status__c`
- `Id_Verification_Status__c`, `Id_Verification_Rejection_Message__c`
The Flow should use `ISCHANGED()` to only include status fields when they actually changed (not on every field change).
#### Portal Cache (React Query)
- Service catalog responses are cached in-memory, scoped by auth state
- SSE events trigger automatic refetch
- On logout, cached queries are cleared
#### Rate Limiting
- Public catalog endpoints: rate-limited per IP + User-Agent
- `POST /api/services/internet/eligibility-request`: authenticated, rate-limited, idempotent
### Subscription Type Detection

View File

@ -4,8 +4,8 @@ How the portal surfaces and creates support cases for customers.
## Data Source & Scope
- Cases are read and written directly in Salesforce. Origin is set to “Portal Website.”
- The portal only shows cases for the customers mapped Salesforce Account to avoid leakage across customers.
- Cases are read and written directly in Salesforce. Origin is set to "Portal Support."
- The portal only shows cases for the customer's mapped Salesforce Account to avoid leakage across customers.
## Creating a Case
@ -15,10 +15,98 @@ How the portal surfaces and creates support cases for customers.
## Viewing Cases
- We read live from Salesforce (no caching) to ensure status, priority, and comments are up to date.
- The portal summarizes open vs. resolved counts and highlights high-priority cases based on Salesforce status/priority values.
- Cases are fetched from Salesforce with Redis caching (see Caching section below).
- The portal summarizes open vs. closed counts and highlights high-priority cases.
## Real-Time Updates via Platform Events
Support cases use Platform Events for real-time cache invalidation:
**Platform Event:** `Case_Status_Update__e`
| Field | API Name | Description |
| ---------- | --------------- | --------------------- |
| Account ID | `Account_Id__c` | Salesforce Account ID |
| Case ID | `Case_Id__c` | Salesforce Case ID |
**Flow Trigger:** Record update on Case when Status changes (and Origin = "Portal Support")
**BFF Behavior:**
1. Invalidates `support:cases:{accountId}` cache
2. Invalidates `support:messages:{caseId}` cache
3. Sends SSE `support.case.changed` to connected portals
4. Portal refetches case data automatically
See `docs/integrations/salesforce/platform-events.md` for full Platform Event documentation.
## Caching Strategy
We use Redis TTL-based caching to reduce Salesforce API calls:
| Cache Key | TTL | Invalidated On |
| --------------------------- | --------- | -------------- |
| `support:cases:{accountId}` | 2 minutes | Case created |
| `support:messages:{caseId}` | 1 minute | Comment added |
Features:
- **Request coalescing**: Prevents thundering herd on cache miss
- **Write-through invalidation**: Cache cleared after customer writes
- **Metrics tracking**: Hits, misses, and invalidations are tracked
## Customer-Friendly Status Mapping
Salesforce uses internal workflow statuses that may not be meaningful to customers. We map them to simplified, customer-friendly labels:
| Salesforce Status (API) | Portal Display | Meaning |
| ----------------------- | ----------------- | ------------------------------------- |
| 新規 (New) | New | New case, not yet reviewed |
| 対応中 (In Progress) | In Progress | Support is working on it |
| Awaiting Approval | In Progress | Internal workflow (hidden) |
| VPN Pending | In Progress | Internal workflow (hidden) |
| Pending | In Progress | Internal workflow (hidden) |
| 完了済み (Replied) | Awaiting Customer | Support replied, waiting for customer |
| Closed | Closed | Case is closed |
**Rationale:**
- Internal workflow statuses are hidden from customers and shown as "In Progress"
- "Replied/完了済み" means support has responded and is waiting for the customer
- Only 4 statuses visible to customers: New, In Progress, Awaiting Customer, Closed
## Case Conversation (Messages)
The case detail view shows a unified conversation timeline composed of:
1. **EmailMessages** - Email exchanges attached to the case
2. **CaseComments** - Portal comments added by customer or agent
### Features
- **Date grouping**: Messages are grouped by date (Today, Yesterday, Mon Dec 30, etc.)
- **Attachment indicators**: Messages with attachments show a paperclip icon
- **Clean email bodies**: Quoted reply chains are stripped (see below)
### Email Body Cleaning
Emails often contain quoted reply chains that pollute the conversation view. We automatically clean email bodies to show only the latest reply by stripping:
**Single-line patterns:**
- Lines starting with `>` (quoted text)
- `From:`, `To:`, `Subject:`, `Sent:` headers
- Japanese equivalents (送信者:, 件名:, etc.)
**Multi-line patterns:**
- "On Mon, Dec 29, 2025 at 18:43 ... wrote:" (Gmail style, spans multiple lines)
- "-------- Original Message --------" blocks
- Forwarded message headers
This ensures each message bubble shows only the new content, not the entire email history.
## If something goes wrong
- Salesforce unavailable: we show “support system unavailable, please try again later.”
- Case not found or belongs to another account: we respond with “case not found” to avoid leaking information.
- Salesforce unavailable: we show "support system unavailable, please try again later."
- Case not found or belongs to another account: we respond with "case not found" to avoid leaking information.

View File

@ -0,0 +1,268 @@
# Salesforce Platform Events & CDC
This guide documents all Platform Events and CDC (Change Data Capture) channels used for real-time communication between Salesforce and the Customer Portal.
## Overview
The BFF subscribes to Salesforce events using the Pub/Sub API and reacts by:
1. **Invalidating caches** - Ensuring fresh data on next request
2. **Sending SSE events** - Notifying connected portal clients to refetch
3. **Creating in-app notifications** - For significant status changes
## Event Types
| Type | Channel Prefix | Purpose |
| ------------------- | -------------- | ------------------------------------ |
| **Platform Events** | `/event/` | Custom events from Salesforce Flows |
| **CDC** | `/data/` | Standard object change notifications |
## Architecture
```
Salesforce Record Change
┌─────────────────────────────────────────┐
│ Platform Events: Record-Triggered Flow │
│ CDC: Automatic change tracking │
└─────────────────────────────────────────┘
BFF Pub/Sub Subscriber
┌────────────────────────────────────────┐
│ 1. Invalidate Redis cache │
│ 2. Send SSE to connected portal │
│ 3. Create notification (if applicable) │
└────────────────────────────────────────┘
```
---
## Platform Events
### 1. Account Update Event
**Purpose:** Notify portal when eligibility or verification status changes.
**Channel:** `SF_ACCOUNT_EVENT_CHANNEL` (default: `/event/Account_Eligibility_Update__e`)
#### Fields
| Field | API Name | Type | Required | When to Include |
| ------------------- | ------------------------ | --------- | --------- | --------------------------------------------------- |
| Account ID | `Account_Id__c` | Text(255) | ✅ Always | Always |
| Eligibility Status | `Eligibility_Status__c` | Text(255) | Optional | Only if `ISCHANGED(Internet_Eligibility_Status__c)` |
| Verification Status | `Verification_Status__c` | Text(255) | Optional | Only if `ISCHANGED(Id_Verification_Status__c)` |
| Rejection Message | `Rejection_Message__c` | Text(255) | Optional | Only if Verification_Status\_\_c = "Rejected" |
#### Salesforce Flow Logic
**Trigger:** Record update on Account when ANY of these fields change:
- `Internet_Eligibility__c`
- `Internet_Eligibility_Status__c`
- `Id_Verification_Status__c`
- `Id_Verification_Rejection_Message__c`
**Event Payload:**
```
Account_Id__c = {!$Record.Id}
IF ISCHANGED({!$Record.Internet_Eligibility_Status__c}) THEN
Eligibility_Status__c = {!$Record.Internet_Eligibility_Status__c}
END IF
IF ISCHANGED({!$Record.Id_Verification_Status__c}) THEN
Verification_Status__c = {!$Record.Id_Verification_Status__c}
IF {!$Record.Id_Verification_Status__c} = "Rejected" THEN
Rejection_Message__c = {!$Record.Id_Verification_Rejection_Message__c}
END IF
END IF
```
#### BFF Behavior
| Scenario | Cache Action | SSE Event | Notification |
| -------------------------------------- | --------------- | -------------------- | ------------------------- |
| Eligibility value changes (not status) | Invalidate both | ✅ `account.updated` | ❌ |
| Eligibility status → Pending | Invalidate both | ✅ `account.updated` | ❌ |
| Eligibility status → Eligible | Invalidate both | ✅ `account.updated` | ✅ ELIGIBILITY_ELIGIBLE |
| Eligibility status → Ineligible | Invalidate both | ✅ `account.updated` | ✅ ELIGIBILITY_INELIGIBLE |
| Verification status → Submitted | Invalidate both | ✅ `account.updated` | ❌ |
| Verification status → Verified | Invalidate both | ✅ `account.updated` | ✅ VERIFICATION_VERIFIED |
| Verification status → Rejected | Invalidate both | ✅ `account.updated` | ✅ VERIFICATION_REJECTED |
| Rejection message changes only | Invalidate both | ✅ `account.updated` | ❌ |
---
### 2. Case Status Update Event
**Purpose:** Notify portal when a support case status changes.
**Channel:** `SF_CASE_EVENT_CHANNEL` (default: `/event/Case_Status_Update__e`)
#### Fields
| Field | API Name | Type | Required |
| ---------- | --------------- | --------- | --------- |
| Account ID | `Account_Id__c` | Text(255) | ✅ Always |
| Case ID | `Case_Id__c` | Text(255) | ✅ Always |
#### Salesforce Flow Logic
**Trigger:** Record update on Case when Status changes (and Origin = "Portal Support")
**Event Payload:**
```
Account_Id__c = {!$Record.AccountId}
Case_Id__c = {!$Record.Id}
```
#### BFF Behavior
| Action | Description |
| -------------------------------------- | ------------------------- |
| Invalidate `support:cases:{accountId}` | Clear case list cache |
| Invalidate `support:messages:{caseId}` | Clear case messages cache |
| SSE `support.case.changed` | Notify portal to refetch |
---
### 3. Order Provision Requested Event
**Purpose:** Trigger order fulfillment when Salesforce Order is approved.
**Channel:** `/event/OrderProvisionRequested__e`
See `docs/how-it-works/order-fulfillment.md` for details.
---
## CDC (Change Data Capture)
### 1. Product2 CDC
**Purpose:** Detect product catalog changes.
**Channel:** `SF_CATALOG_PRODUCT_CDC_CHANNEL` (default: `/data/Product2ChangeEvent`)
#### BFF Behavior
| Action | Description |
| -------------------------- | --------------------------------- |
| Invalidate product cache | Clear affected products |
| Fallback full invalidation | If no specific products found |
| SSE `services.changed` | Notify portals to refetch catalog |
---
### 2. PricebookEntry CDC
**Purpose:** Detect pricing changes.
**Channel:** `SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL` (default: `/data/PricebookEntryChangeEvent`)
#### BFF Behavior
| Action | Description |
| ------------------------ | ------------------------------------- |
| Filter by pricebook | Only process portal pricebook changes |
| Invalidate product cache | Clear affected products |
| SSE `services.changed` | Notify portals to refetch catalog |
---
### 3. Order CDC
**Purpose:** Detect order changes + trigger provisioning.
**Channel:** `SF_ORDER_CDC_CHANNEL` (default: `/data/OrderChangeEvent`)
#### BFF Behavior
| Scenario | Action |
| ------------------------------------- | ------------------------------ |
| `Activation_Status__c` → "Activating" | Enqueue provisioning job |
| Customer-facing field changes | Invalidate order cache + SSE |
| Internal field changes only | Ignore (no cache invalidation) |
**Internal fields (ignored):** `Activation_Status__c`, `WHMCS_Order_ID__c`, `Activation_Error_*`
---
### 4. OrderItem CDC
**Purpose:** Detect order line item changes.
**Channel:** `SF_ORDER_ITEM_CDC_CHANNEL` (default: `/data/OrderItemChangeEvent`)
#### BFF Behavior
| Scenario | Action |
| ----------------------------- | ------------------------------ |
| Customer-facing field changes | Invalidate order cache + SSE |
| Internal field changes only | Ignore (no cache invalidation) |
**Internal fields (ignored):** `WHMCS_Service_ID__c`
---
## Environment Variables
```bash
# Platform Events
SF_ACCOUNT_EVENT_CHANNEL=/event/Account_Eligibility_Update__e
SF_CASE_EVENT_CHANNEL=/event/Case_Status_Update__e
# Catalog CDC
SF_CATALOG_PRODUCT_CDC_CHANNEL=/data/Product2ChangeEvent
SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL=/data/PricebookEntryChangeEvent
# Order CDC
SF_ORDER_CDC_CHANNEL=/data/OrderChangeEvent
SF_ORDER_ITEM_CDC_CHANNEL=/data/OrderItemChangeEvent
# Enable/disable all CDC subscriptions (Platform Events always enabled)
SF_EVENTS_ENABLED=true
```
---
## BFF Implementation
### Subscribers
| Subscriber | Events | Channel | File |
| ------------------------- | ------------------------ | --------- | ------------------------------ |
| `AccountEventsSubscriber` | Account | `/event/` | `account-events.subscriber.ts` |
| `CaseEventsSubscriber` | Case | `/event/` | `case-events.subscriber.ts` |
| `CatalogCdcSubscriber` | Product2, PricebookEntry | `/data/` | `catalog-cdc.subscriber.ts` |
| `OrderCdcSubscriber` | Order, OrderItem | `/data/` | `order-cdc.subscriber.ts` |
### Shared Utilities (`shared/`)
| File | Purpose |
| ------------------- | ------------------------------------ |
| `pubsub.service.ts` | Salesforce Pub/Sub client management |
| `pubsub.utils.ts` | Payload extraction helpers |
| `pubsub.types.ts` | TypeScript types |
| `index.ts` | Public exports |
---
## Testing
1. **Salesforce Workbench:** Use "REST Explorer" to publish test events
2. **BFF Logs:** Watch for event received messages
3. **Redis:** Verify cache keys are deleted after event
## Troubleshooting
| Issue | Cause | Solution |
| ------------------------ | ------------------------- | ----------------------------------------- |
| Events not received | Pub/Sub connection failed | Check BFF logs for connection errors |
| Cache not invalidated | Wrong ID in payload | Verify Flow is sending correct ID |
| Notification not created | Status not in final state | Only final states trigger notifications |
| Duplicate notifications | Same event re-delivered | Dedupe logic handles this (1 hour window) |

View File

@ -406,9 +406,6 @@ export const residenceCardVerificationStatusSchema = z.enum([
export const residenceCardVerificationSchema = z.object({
status: residenceCardVerificationStatusSchema,
filename: z.string().nullable(),
mimeType: z.string().nullable(),
sizeBytes: z.number().int().nonnegative().nullable(),
submittedAt: z.string().datetime().nullable(),
reviewedAt: z.string().datetime().nullable(),
reviewerNotes: z.string().nullable(),

View File

@ -7,6 +7,22 @@ import {
} from "./schema.js";
import { ORDER_TYPE, type CheckoutCart, type OrderTypeValue } from "./contract.js";
export type CheckoutOrderTypeValue = Extract<OrderTypeValue, "Internet" | "SIM" | "VPN">;
export function isCheckoutOrderType(value: unknown): value is CheckoutOrderTypeValue {
return value === ORDER_TYPE.INTERNET || value === ORDER_TYPE.SIM || value === ORDER_TYPE.VPN;
}
export function toCheckoutOrderType(orderType: OrderTypeValue): CheckoutOrderTypeValue | null {
return isCheckoutOrderType(orderType) ? orderType : null;
}
export function toOrderTypeValueFromCheckout(
orderType: string | null | undefined
): OrderTypeValue | null {
return isCheckoutOrderType(orderType) ? orderType : null;
}
export function buildOrderConfigurations(selections: OrderSelections): OrderConfigurations {
const normalizedSelections = orderSelectionsSchema.parse(selections);

View File

@ -9,39 +9,29 @@
*/
/**
* Portal display status values
* Mapped from Salesforce Japanese API names:
* Portal display status values (customer-friendly)
*
* Mapped from Salesforce API names:
* - New
* - In Progress
* - Awaiting Approval Awaiting Approval
* - VPN Pending VPN Pending
* - Pending Pending
* - Resolved
* - , Awaiting Approval, VPN Pending, Pending In Progress (internal workflow hidden)
* - (Replied) Awaiting Customer (support replied, waiting for customer response)
* - Closed Closed
*/
export const SUPPORT_CASE_STATUS = {
NEW: "New",
IN_PROGRESS: "In Progress",
AWAITING_APPROVAL: "Awaiting Approval",
VPN_PENDING: "VPN Pending",
PENDING: "Pending",
RESOLVED: "Resolved",
AWAITING_CUSTOMER: "Awaiting Customer", // Support has replied, waiting for customer
CLOSED: "Closed",
} as const;
/** Statuses that indicate a case is closed */
export const CLOSED_STATUSES = [
SUPPORT_CASE_STATUS.VPN_PENDING,
SUPPORT_CASE_STATUS.PENDING,
SUPPORT_CASE_STATUS.RESOLVED,
SUPPORT_CASE_STATUS.CLOSED,
] as const;
/** Statuses that indicate a case is closed (for UI logic - disables reply form) */
export const CLOSED_STATUSES = [SUPPORT_CASE_STATUS.CLOSED] as const;
/** Statuses that indicate a case is open */
/** Statuses that indicate a case is open (for UI logic) */
export const OPEN_STATUSES = [
SUPPORT_CASE_STATUS.NEW,
SUPPORT_CASE_STATUS.IN_PROGRESS,
SUPPORT_CASE_STATUS.AWAITING_APPROVAL,
SUPPORT_CASE_STATUS.AWAITING_CUSTOMER,
] as const;
/**

View File

@ -43,6 +43,143 @@ function nowIsoString(): string {
return new Date().toISOString();
}
// ============================================================================
// Email Body Cleaning
// ============================================================================
/**
* Multi-line patterns to remove from email body FIRST (before line-by-line processing).
* These patterns can span multiple lines and should be removed entirely.
*
* Order matters - more specific patterns should come first.
*/
const MULTILINE_QUOTE_PATTERNS: RegExp[] = [
// Gmail multi-line: "On <date> ... <email>\nwrote:" or "On <date> ... <name> <\nemail> wrote:"
// This handles cases where the email address or "wrote:" wraps to next line
/On\s+(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)[^]*?wrote:\s*/gi,
// Generic "On <date> <name> wrote:" that may span lines
/On\s+\d{1,2}[^]*?wrote:\s*/gi,
// Japanese: "<date>に<name>が書きました:"
/\d{4}[年/-]\d{1,2}[月/-]\d{1,2}[日]?[^]*?書きました[:]\s*/g,
// "---- Original Message ----" and everything after
/-{2,}\s*(?:Original\s*Message|Forwarded|転送|元のメッセージ)[^]*/gi,
// "___" Outlook separator and everything after
/_{3,}[^]*/g,
// "From: <email>" header block (usually indicates quoted content start)
// Only match if it's followed by typical email header patterns
/\nFrom:\s*[^\n]+\n(?:Sent|To|Date|Subject|Cc):[^]*/gi,
];
/**
* Single-line patterns that indicate the start of quoted content.
* Once matched, all remaining lines are discarded.
*/
const QUOTE_START_LINE_PATTERNS: RegExp[] = [
// "> quoted text" at start of line
/^>/,
// "From:" at start of line (email header)
/^From:\s+.+/i,
// "送信者:" (Japanese: Sender)
/^送信者[:]\s*.+/,
// "Sent:" header
/^Sent:\s+.+/i,
// Date/Time headers appearing mid-email
/^(Date|日時|送信日時)[:]\s+.+/i,
// "Subject:" appearing mid-email
/^(Subject|件名)[:]\s+.+/i,
// "To:" appearing mid-email (not at very start)
/^(To|宛先)[:]\s+.+/i,
// Lines that look like "wrote:" endings we might have missed
/^\s*wrote:\s*$/i,
/^\s*書きました[:]?\s*$/,
// Email in angle brackets followed by wrote (continuation line)
/^[^<]*<[^>]+>\s*wrote:\s*$/i,
];
/**
* Patterns for lines to skip (metadata) but continue processing
*/
const SKIP_LINE_PATTERNS: RegExp[] = [
/^(Cc|CC|Bcc|BCC)[:]\s*.*/i,
/^(Reply-To|返信先)[:]\s*.*/i,
];
/**
* Clean email body by removing quoted/forwarded content.
*
* This function strips out:
* - Quoted replies (lines starting with ">")
* - "On <date>, <name> wrote:" blocks (including multi-line Gmail format)
* - "From: / To: / Subject:" quoted headers
* - "-------- Original Message --------" separators
* - Japanese equivalents of the above
*
* @param body - Raw email text body
* @returns Cleaned email body with only the latest reply content
*/
export function cleanEmailBody(body: string): string {
if (!body) return "";
let cleaned = body;
// Step 1: Apply multi-line pattern removal first
for (const pattern of MULTILINE_QUOTE_PATTERNS) {
cleaned = cleaned.replace(pattern, "");
}
// Step 2: Process line by line for remaining patterns
const lines = cleaned.split(/\r?\n/);
const cleanLines: string[] = [];
let foundQuoteStart = false;
for (const line of lines) {
if (foundQuoteStart) {
// Already in quoted section, skip all remaining lines
continue;
}
const trimmedLine = line.trim();
// Check if this line starts quoted content
const isQuoteStart = QUOTE_START_LINE_PATTERNS.some(pattern => pattern.test(trimmedLine));
if (isQuoteStart) {
foundQuoteStart = true;
continue;
}
// Skip metadata lines but continue processing
const isSkipLine = SKIP_LINE_PATTERNS.some(pattern => pattern.test(trimmedLine));
if (isSkipLine) {
continue;
}
cleanLines.push(line);
}
// Step 3: Clean up the result
let result = cleanLines.join("\n");
// Remove excessive trailing whitespace/newlines
result = result.replace(/\s+$/, "");
// Remove leading blank lines
result = result.replace(/^\s*\n+/, "");
// If we stripped everything, return original (edge case)
if (!result.trim()) {
// Try to extract at least something useful from original
const firstParagraph = body.split(/\n\s*\n/)[0]?.trim();
return firstParagraph || body.substring(0, 500);
}
return result;
}
// ============================================================================
// Transform Functions
// ============================================================================
@ -216,6 +353,9 @@ export function buildEmailMessagesForCaseQuery(caseId: string): string {
/**
* Transform a Salesforce EmailMessage to a unified CaseMessage.
*
* Cleans the email body to show only the latest reply, stripping out
* quoted content and previous email chains for a cleaner conversation view.
*
* @param email - Raw Salesforce EmailMessage
* @param customerEmail - Customer's email address for comparison
*/
@ -233,10 +373,14 @@ export function transformEmailMessageToCaseMessage(
isIncoming ||
(customerEmail ? fromEmail?.toLowerCase() === customerEmail.toLowerCase() : false);
// Get the raw email body and clean it (strip quoted content)
const rawBody = ensureString(email.TextBody) ?? ensureString(email.HtmlBody) ?? "";
const cleanedBody = cleanEmailBody(rawBody);
return caseMessageSchema.parse({
id: email.Id,
type: "email",
body: ensureString(email.TextBody) ?? ensureString(email.HtmlBody) ?? "",
body: cleanedBody,
author: {
name: fromName,
email: fromEmail ?? null,
@ -244,6 +388,7 @@ export function transformEmailMessageToCaseMessage(
},
createdAt: ensureString(email.MessageDate) ?? ensureString(email.CreatedDate) ?? nowIsoString(),
direction: isIncoming ? "inbound" : "outbound",
hasAttachment: email.HasAttachment === true,
});
}

View File

@ -343,15 +343,22 @@ export type SalesforceCasePriority =
// ============================================================================
/**
* Map Salesforce status API names to portal display labels
* Map Salesforce status API names to customer-friendly portal display labels.
*
* Mapping logic:
* - Internal workflow statuses (Awaiting Approval, VPN Pending, Pending) "In Progress"
* - (Replied) "Awaiting Customer" (support replied, waiting for customer)
* - Closed "Closed"
*/
export const STATUS_DISPLAY_LABELS: Record<string, string> = {
[SALESFORCE_CASE_STATUS.NEW]: "New",
[SALESFORCE_CASE_STATUS.IN_PROGRESS]: "In Progress",
[SALESFORCE_CASE_STATUS.AWAITING_APPROVAL]: "Awaiting Approval",
[SALESFORCE_CASE_STATUS.VPN_PENDING]: "VPN Pending",
[SALESFORCE_CASE_STATUS.PENDING]: "Pending",
[SALESFORCE_CASE_STATUS.REPLIED]: "Resolved",
// Internal workflow statuses - show as "In Progress" to customer
[SALESFORCE_CASE_STATUS.AWAITING_APPROVAL]: "In Progress",
[SALESFORCE_CASE_STATUS.VPN_PENDING]: "In Progress",
[SALESFORCE_CASE_STATUS.PENDING]: "In Progress",
// Replied = support has responded, waiting for customer
[SALESFORCE_CASE_STATUS.REPLIED]: "Awaiting Customer",
[SALESFORCE_CASE_STATUS.CLOSED]: "Closed",
};

View File

@ -2,15 +2,18 @@ import { z } from "zod";
import { SUPPORT_CASE_STATUS, SUPPORT_CASE_PRIORITY, SUPPORT_CASE_CATEGORY } from "./contract.js";
/**
* Portal status values (mapped from Salesforce Japanese API names)
* Portal status values - customer-friendly statuses only
*
* Internal Salesforce statuses are mapped to these customer-facing values:
* - "新規" New
* - "対応中", "Awaiting Approval", "VPN Pending", "Pending" In Progress
* - "完了済み" (Replied) Awaiting Customer
* - "Closed" Closed
*/
const supportCaseStatusValues = [
SUPPORT_CASE_STATUS.NEW,
SUPPORT_CASE_STATUS.IN_PROGRESS,
SUPPORT_CASE_STATUS.AWAITING_APPROVAL,
SUPPORT_CASE_STATUS.VPN_PENDING,
SUPPORT_CASE_STATUS.PENDING,
SUPPORT_CASE_STATUS.RESOLVED,
SUPPORT_CASE_STATUS.AWAITING_CUSTOMER,
SUPPORT_CASE_STATUS.CLOSED,
] as const;
@ -136,6 +139,8 @@ export const caseMessageSchema = z.object({
createdAt: z.string(),
/** For emails: inbound (customer→agent) or outbound (agent→customer) */
direction: caseMessageDirectionSchema.nullable(),
/** Whether the message has attachments (for emails) */
hasAttachment: z.boolean().optional(),
});
/**