From a41f2bf47eddfd4a0011726218b852db48797cb6 Mon Sep 17 00:00:00 2001
From: barsa
Date: Mon, 5 Jan 2026 15:11:56 +0900
Subject: [PATCH] Enhance BFF Modules and Update Domain Schemas
- Added new modules for SIM management, internet management, and call history to the BFF, improving service organization and modularity.
- Updated environment validation schema to reflect changes in Salesforce event channels, ensuring accurate configuration.
- Refactored router configuration to include new subscription-related modules, enhancing API routing clarity.
- Cleaned up Salesforce integration by removing unused service files and optimizing event handling logic.
- Improved support service by adding cache invalidation logic for case comments, ensuring real-time updates for users.
- Updated domain schemas to remove deprecated fields and enhance validation for residence card verification, promoting data integrity.
- Enhanced utility functions in the portal for better address formatting and confirmation prompts, improving user experience.
---
apps/bff/src/app.module.ts | 6 +
apps/bff/src/core/config/env.validation.ts | 6 +-
apps/bff/src/core/config/router.config.ts | 6 +
.../salesforce/constants/field-maps.ts | 2 -
.../events/account-events.subscriber.ts | 128 ++++
.../events/case-events.subscriber.ts | 98 +++
.../events/catalog-cdc.subscriber.ts | 188 ++++++
.../salesforce/events/events.module.ts | 36 +-
.../integrations/salesforce/events/index.ts | 23 +
.../salesforce/events/order-cdc.subscriber.ts | 556 +++++-------------
.../events/services-cdc.subscriber.ts | 421 -------------
.../salesforce/events/shared/index.ts | 22 +
.../events/shared/pubsub.service.ts | 150 +++++
.../salesforce/events/shared/pubsub.types.ts | 43 ++
.../salesforce/events/shared/pubsub.utils.ts | 131 +++++
.../opportunity-resolution.service.ts | 10 +-
.../services/services-cache.service.ts | 34 +-
.../modules/support/support-cache.service.ts | 159 +++++
.../bff/src/modules/support/support.module.ts | 5 +-
.../src/modules/support/support.service.ts | 52 +-
.../verification/residence-card.service.ts | 147 +----
.../verification/verification.module.ts | 5 +-
.../account/views/ProfileContainer.tsx | 28 +-
.../billing/components/PaymentMethodCard.tsx | 47 +-
.../PaymentMethodCard/PaymentMethodCard.tsx | 181 ------
.../checkout/api/checkout-params.api.ts | 8 +-
.../src/features/checkout/api/checkout.api.ts | 27 +-
.../components/AccountCheckoutContainer.tsx | 86 +--
.../checkout/components/CheckoutEntry.tsx | 60 +-
.../components/CheckoutStatusBanners.tsx | 10 +-
apps/portal/src/features/checkout/index.ts | 3 -
.../checkout/stores/checkout.store.ts | 25 +-
.../src/features/checkout/stores/index.ts | 2 +-
.../src/features/checkout/utils/index.ts | 1 -
.../features/orders/components/OrderCard.tsx | 23 +-
.../orders/components/OrderServiceIcon.tsx | 32 +
.../src/features/orders/components/index.ts | 1 +
.../src/features/orders/views/OrderDetail.tsx | 20 +-
.../InternetEligibilityRequestSubmitted.tsx | 11 +-
.../features/services/views/InternetPlans.tsx | 16 +-
.../support/utils/case-presenters.tsx | 20 +-
.../support/views/SupportCaseDetailView.tsx | 203 +++++--
.../ResidenceCardVerificationSettingsView.tsx | 26 +-
apps/portal/src/shared/utils/address.ts | 17 +
apps/portal/src/shared/utils/confirm.ts | 9 +
apps/portal/src/shared/utils/index.ts | 8 +
.../utils/payment-methods.ts} | 45 +-
.../dashboard-and-notifications.md | 79 ++-
.../eligibility-and-verification.md | 45 +-
docs/how-it-works/support-cases.md | 100 +++-
.../salesforce/platform-events.md | 268 +++++++++
packages/domain/customer/schema.ts | 3 -
packages/domain/orders/utils.ts | 16 +
packages/domain/support/contract.ts | 30 +-
.../support/providers/salesforce/mapper.ts | 147 ++++-
.../support/providers/salesforce/raw.types.ts | 17 +-
packages/domain/support/schema.ts | 15 +-
57 files changed, 2273 insertions(+), 1584 deletions(-)
create mode 100644 apps/bff/src/integrations/salesforce/events/account-events.subscriber.ts
create mode 100644 apps/bff/src/integrations/salesforce/events/case-events.subscriber.ts
create mode 100644 apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts
create mode 100644 apps/bff/src/integrations/salesforce/events/index.ts
delete mode 100644 apps/bff/src/integrations/salesforce/events/services-cdc.subscriber.ts
create mode 100644 apps/bff/src/integrations/salesforce/events/shared/index.ts
create mode 100644 apps/bff/src/integrations/salesforce/events/shared/pubsub.service.ts
create mode 100644 apps/bff/src/integrations/salesforce/events/shared/pubsub.types.ts
create mode 100644 apps/bff/src/integrations/salesforce/events/shared/pubsub.utils.ts
create mode 100644 apps/bff/src/modules/support/support-cache.service.ts
delete mode 100644 apps/portal/src/features/billing/components/PaymentMethodCard/PaymentMethodCard.tsx
delete mode 100644 apps/portal/src/features/checkout/utils/index.ts
create mode 100644 apps/portal/src/features/orders/components/OrderServiceIcon.tsx
create mode 100644 apps/portal/src/shared/utils/address.ts
create mode 100644 apps/portal/src/shared/utils/confirm.ts
rename apps/portal/src/{features/checkout/utils/checkout-ui-utils.ts => shared/utils/payment-methods.ts} (62%)
create mode 100644 docs/integrations/salesforce/platform-events.md
diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts
index 2e468e39..18bda10c 100644
--- a/apps/bff/src/app.module.ts
+++ b/apps/bff/src/app.module.ts
@@ -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,
diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts
index e129505d..c5262208 100644
--- a/apps/bff/src/core/config/env.validation.ts
+++ b/apps/bff/src/core/config/env.validation.ts
@@ -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"),
diff --git a/apps/bff/src/core/config/router.config.ts b/apps/bff/src/core/config/router.config.ts
index 441f0f7a..a94a46b8 100644
--- a/apps/bff/src/core/config/router.config.ts
+++ b/apps/bff/src/core/config/router.config.ts
@@ -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 },
diff --git a/apps/bff/src/integrations/salesforce/constants/field-maps.ts b/apps/bff/src/integrations/salesforce/constants/field-maps.ts
index cdc4b2fb..5a1a2af6 100644
--- a/apps/bff/src/integrations/salesforce/constants/field-maps.ts
+++ b/apps/bff/src/integrations/salesforce/constants/field-maps.ts
@@ -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
diff --git a/apps/bff/src/integrations/salesforce/events/account-events.subscriber.ts b/apps/bff/src/integrations/salesforce/events/account-events.subscriber.ts
new file mode 100644
index 00000000..473afadf
--- /dev/null
+++ b/apps/bff/src/integrations/salesforce/events/account-events.subscriber.ts
@@ -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 {
+ const channel = this.config.get("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 {
+ 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,
+ });
+ }
+ }
+}
diff --git a/apps/bff/src/integrations/salesforce/events/case-events.subscriber.ts b/apps/bff/src/integrations/salesforce/events/case-events.subscriber.ts
new file mode 100644
index 00000000..e88d1073
--- /dev/null
+++ b/apps/bff/src/integrations/salesforce/events/case-events.subscriber.ts
@@ -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 {
+ const channel = this.config.get("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 {
+ 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(),
+ });
+ }
+ }
+}
diff --git a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts
new file mode 100644
index 00000000..ebc16f85
--- /dev/null
+++ b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts
@@ -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 {
+ 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 {
+ const channel =
+ this.config.get("SF_CATALOG_PRODUCT_CDC_CHANNEL")?.trim() ||
+ "/data/Product2ChangeEvent";
+
+ await this.pubSubClient.subscribe(
+ channel,
+ this.handleProductEvent.bind(this, channel),
+ "product-cdc"
+ );
+ }
+
+ private async subscribeToPricebookCdc(): Promise {
+ const channel =
+ this.config.get("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 {
+ 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 {
+ 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("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 {
+ try {
+ await this.catalogCache.invalidateAllServices();
+ } catch (error) {
+ this.logger.warn("Failed to invalidate services caches", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+}
diff --git a/apps/bff/src/integrations/salesforce/events/events.module.ts b/apps/bff/src/integrations/salesforce/events/events.module.ts
index bb217301..af1c147e 100644
--- a/apps/bff/src/integrations/salesforce/events/events.module.ts
+++ b/apps/bff/src/integrations/salesforce/events/events.module.ts
@@ -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 {}
diff --git a/apps/bff/src/integrations/salesforce/events/index.ts b/apps/bff/src/integrations/salesforce/events/index.ts
new file mode 100644
index 00000000..8d8adb08
--- /dev/null
+++ b/apps/bff/src/integrations/salesforce/events/index.ts
@@ -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";
diff --git a/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts
index 4c2b6b87..ec9247d6 100644
--- a/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts
+++ b/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts
@@ -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;
-
-interface PubSubClient {
- connect(): Promise;
- subscribe(topic: string, cb: PubSubCallback, numRequested?: number): Promise;
- close(): Promise;
-}
-
-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
*/
+
+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 for cache invalidation */
+const INTERNAL_ORDER_ITEM_FIELDS = new Set(["WHMCS_Service_ID__c"]);
+
+/** Statuses that trigger provisioning */
+const PROVISION_TRIGGER_STATUSES = new Set(["Approved", "Reactivate"]);
+
@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([
- "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"]);
-
- // Statuses that trigger provisioning
- private readonly PROVISION_TRIGGER_STATUSES = new Set(["Approved", "Reactivate"]);
-
+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 {
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 {
+ const channel =
this.config.get("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 {
+ const channel =
this.config.get("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"
- );
-
- 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 {
- 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),
- });
- }
- }
-
- private async ensureClient(): Promise {
- 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("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 {
- this.logger.log("Attempting Salesforce CDC subscription", {
+ await this.pubSubClient.subscribe(
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;
- }
+ this.handleOrderItemEvent.bind(this, channel),
+ "order-item-cdc"
+ );
}
- private loadPubSubCtor(): PubSubCtor {
- if (this.pubSubCtor) {
- return this.pubSubCtor;
- }
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Order CDC Handler
+ // ─────────────────────────────────────────────────────────────────────────────
- 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("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 {
- 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",
- {
- channel,
- orderId,
- changedFields: Array.from(changedFields),
- }
- );
+ 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,
orderId: string
): Promise {
- 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",
- {
- orderId,
- activationStatus,
- status,
- }
- );
+ 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 {
- 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 | undefined,
+ header: ReturnType
+ ): 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): 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 | undefined): Set {
- if (!payload) return new Set();
+ private async invalidateOrderCaches(orderId: string, accountId?: string): Promise {
+ 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),
- ]);
- }
-
- private isDataCallback(callbackType: string): boolean {
- const normalized = String(callbackType || "").toLowerCase();
- return normalized === "data" || normalized === "event";
- }
-
- private extractPayload(data: unknown): Record | 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;
- }
-
- return data as Record;
- }
-
- private extractStringField(
- payload: Record | 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;
- }
+ if (accountId) {
+ await this.ordersCache.invalidateAccountOrders(accountId);
}
- }
- return undefined;
- }
- private extractChangeEventHeader(payload: Record):
- | {
- changedFields?: unknown;
- recordIds?: unknown;
- entityName?: unknown;
- changeType?: unknown;
+ const timestamp = new Date().toISOString();
+
+ if (accountId) {
+ this.realtime.publish(`account:sf:${accountId}`, "orders.changed", { timestamp });
}
- | undefined {
- const header = payload["ChangeEventHeader"];
- if (header && typeof header === "object") {
- return header as {
- changedFields?: unknown;
- recordIds?: unknown;
- entityName?: unknown;
- changeType?: unknown;
- };
+
+ 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),
+ });
}
- return undefined;
}
}
diff --git a/apps/bff/src/integrations/salesforce/events/services-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/services-cdc.subscriber.ts
deleted file mode 100644
index 655d42bb..00000000
--- a/apps/bff/src/integrations/salesforce/events/services-cdc.subscriber.ts
+++ /dev/null
@@ -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;
-
-interface PubSubClient {
- connect(): Promise;
- subscribe(topic: string, cb: PubSubCallback, numRequested?: number): Promise;
- close(): Promise;
-}
-
-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 {
- // 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("SF_CATALOG_PRODUCT_CDC_CHANNEL")?.trim() ||
- "/data/Product2ChangeEvent";
- const pricebookChannel =
- this.config.get("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("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 {
- 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 {
- 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("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 {
- 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("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 {
- 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 {
- if (!this.isDataCallback(callbackType)) return;
- const payload = this.extractPayload(data);
- const pricebookId = this.extractStringField(payload, ["PricebookId", "Pricebook2Id"]);
-
- const portalPricebookId = this.config.get("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 {
- 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 {
- 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 | 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;
- }
-
- return data as Record;
- }
-
- private extractStringField(
- payload: Record | 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 | 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
- ): { recordIds?: unknown; changedFields?: unknown } | undefined {
- const header = payload["ChangeEventHeader"];
- if (header && typeof header === "object") {
- return header as { recordIds?: unknown; changedFields?: unknown };
- }
- return undefined;
- }
-}
diff --git a/apps/bff/src/integrations/salesforce/events/shared/index.ts b/apps/bff/src/integrations/salesforce/events/shared/index.ts
new file mode 100644
index 00000000..d73617c5
--- /dev/null
+++ b/apps/bff/src/integrations/salesforce/events/shared/index.ts
@@ -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";
diff --git a/apps/bff/src/integrations/salesforce/events/shared/pubsub.service.ts b/apps/bff/src/integrations/salesforce/events/shared/pubsub.service.ts
new file mode 100644
index 00000000..86bf755f
--- /dev/null
+++ b/apps/bff/src/integrations/salesforce/events/shared/pubsub.service.ts
@@ -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("SF_PUBSUB_NUM_REQUESTED"),
+ 25,
+ (msg, ctx) => this.logger.warn(msg, ctx)
+ );
+ }
+
+ async onModuleDestroy(): Promise {
+ 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 {
+ 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("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 {
+ 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 {
+ 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;
+ }
+}
diff --git a/apps/bff/src/integrations/salesforce/events/shared/pubsub.types.ts b/apps/bff/src/integrations/salesforce/events/shared/pubsub.types.ts
new file mode 100644
index 00000000..e07d4b01
--- /dev/null
+++ b/apps/bff/src/integrations/salesforce/events/shared/pubsub.types.ts
@@ -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;
+
+/**
+ * Pub/Sub client interface
+ */
+export interface PubSubClient {
+ connect(): Promise;
+ subscribe(topic: string, cb: PubSubCallback, numRequested?: number): Promise;
+ close(): Promise;
+}
+
+/**
+ * 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;
+}
diff --git a/apps/bff/src/integrations/salesforce/events/shared/pubsub.utils.ts b/apps/bff/src/integrations/salesforce/events/shared/pubsub.utils.ts
new file mode 100644
index 00000000..1d2538f3
--- /dev/null
+++ b/apps/bff/src/integrations/salesforce/events/shared/pubsub.utils.ts
@@ -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 | 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;
+ }
+
+ return data as Record;
+}
+
+/**
+ * Extract a string field from payload, trying multiple possible field names
+ */
+export function extractStringField(
+ payload: Record | 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
+): 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 | 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 | undefined): Set {
+ 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) => 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;
+}
diff --git a/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts
index efcad846..56dbdd82 100644
--- a/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts
+++ b/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts
@@ -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}`);
}
}
}
diff --git a/apps/bff/src/modules/services/services/services-cache.service.ts b/apps/bff/src/modules/services/services/services-cache.service.ts
index 9102de9a..b88d3dfc 100644
--- a/apps/bff/src/modules/services/services/services-cache.service.ts
+++ b/apps/bff/src/modules/services/services/services-cache.service.ts
@@ -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("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 {
+ 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(accountId: string, fetchFn: () => Promise): Promise {
+ 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(
- bucket: "services" | "static" | "volatile" | "eligibility",
+ bucket: "services" | "static" | "volatile" | "eligibility" | "verification",
key: string,
ttlSeconds: number | null,
fetchFn: () => Promise,
diff --git a/apps/bff/src/modules/support/support-cache.service.ts b/apps/bff/src/modules/support/support-cache.service.ts
new file mode 100644
index 00000000..0d34fd79
--- /dev/null
+++ b/apps/bff/src/modules/support/support-cache.service.ts
@@ -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>();
+
+ constructor(private readonly cache: CacheService) {}
+
+ /**
+ * Get cached case list for an account
+ */
+ async getCaseList(
+ sfAccountId: string,
+ fetcher: () => Promise
+ ): Promise {
+ const key = this.buildCaseListKey(sfAccountId);
+ return this.getOrSet("caseList", key, fetcher, CACHE_TTL.CASE_LIST);
+ }
+
+ /**
+ * Get cached messages for a case
+ */
+ async getCaseMessages(
+ caseId: string,
+ fetcher: () => Promise
+ ): Promise {
+ const key = this.buildMessagesKey(caseId);
+ return this.getOrSet("messages", key, fetcher, CACHE_TTL.CASE_MESSAGES);
+ }
+
+ /**
+ * Invalidate case list cache for an account
+ * Called after customer creates a new case
+ */
+ async invalidateCaseList(sfAccountId: string): Promise {
+ const key = this.buildCaseListKey(sfAccountId);
+ this.metrics.invalidations++;
+ await this.cache.del(key);
+ }
+
+ /**
+ * Invalidate messages cache for a case
+ * Called after customer adds a comment
+ */
+ async invalidateCaseMessages(caseId: string): Promise {
+ const key = this.buildMessagesKey(caseId);
+ this.metrics.invalidations++;
+ await this.cache.del(key);
+ }
+
+ /**
+ * Invalidate all caches for an account's cases
+ * Called after any write operation to ensure fresh data
+ */
+ async invalidateAllForAccount(sfAccountId: string, caseId?: string): Promise {
+ await this.invalidateCaseList(sfAccountId);
+ if (caseId) {
+ await this.invalidateCaseMessages(caseId);
+ }
+ }
+
+ /**
+ * Get cache metrics for monitoring
+ */
+ getMetrics(): SupportCacheMetrics {
+ return {
+ caseList: { ...this.metrics.caseList },
+ messages: { ...this.metrics.messages },
+ invalidations: this.metrics.invalidations,
+ };
+ }
+
+ private async getOrSet(
+ bucket: "caseList" | "messages",
+ key: string,
+ fetcher: () => Promise,
+ ttlSeconds: number
+ ): Promise {
+ // Check Redis cache first
+ const cached = await this.cache.get(key);
+
+ if (cached !== null) {
+ this.metrics[bucket].hits++;
+ return cached;
+ }
+
+ // Check for in-flight request (prevents thundering herd)
+ const existingRequest = this.inflightRequests.get(key);
+ if (existingRequest) {
+ return existingRequest as Promise;
+ }
+
+ // Fetch fresh data
+ this.metrics[bucket].misses++;
+
+ const fetchPromise = (async () => {
+ try {
+ const fresh = await fetcher();
+ await this.cache.set(key, fresh, ttlSeconds);
+ return fresh;
+ } finally {
+ this.inflightRequests.delete(key);
+ }
+ })();
+
+ this.inflightRequests.set(key, fetchPromise);
+ return fetchPromise;
+ }
+
+ private buildCaseListKey(sfAccountId: string): string {
+ return `support:cases:${sfAccountId}`;
+ }
+
+ private buildMessagesKey(caseId: string): string {
+ return `support:messages:${caseId}`;
+ }
+}
diff --git a/apps/bff/src/modules/support/support.module.ts b/apps/bff/src/modules/support/support.module.ts
index c5ae4df6..be9cdc22 100644
--- a/apps/bff/src/modules/support/support.module.ts
+++ b/apps/bff/src/modules/support/support.module.ts
@@ -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 {}
diff --git a/apps/bff/src/modules/support/support.service.ts b/apps/bff/src/modules/support/support.service.ts
index f89fee60..709234fd 100644
--- a/apps/bff/src/modules/support/support.service.ts
+++ b/apps/bff/src/modules/support/support.service.ts
@@ -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 {
const accountId = await this.getAccountIdForUser(userId);
try {
- // SalesforceCaseService now returns SupportCase[] directly using domain mappers
- const cases = await this.caseService.getCasesForAccount(accountId);
+ // 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);
- const summary = this.buildSummary(filteredCases);
+ // 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 { 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 {
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,
diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts
index 29f238b2..120055fd 100644
--- a/apps/bff/src/modules/verification/residence-card.service.ts
+++ b/apps/bff/src/modules/verification/residence-card.service.ts
@@ -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 {
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>;
- const version = (versionRes.records?.[0] as Record | 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;
- }
- }
}
diff --git a/apps/bff/src/modules/verification/verification.module.ts b/apps/bff/src/modules/verification/verification.module.ts
index 7b704b31..ad12a656 100644
--- a/apps/bff/src/modules/verification/verification.module.ts
+++ b/apps/bff/src/modules/verification/verification.module.ts
@@ -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],
diff --git a/apps/portal/src/features/account/views/ProfileContainer.tsx b/apps/portal/src/features/account/views/ProfileContainer.tsx
index cc2b9f27..05802661 100644
--- a/apps/portal/src/features/account/views/ProfileContainer.tsx
+++ b/apps/portal/src/features/account/views/ProfileContainer.tsx
@@ -537,22 +537,15 @@ export default function ProfileContainer() {
Your residence card has been submitted. We'll verify it before activating SIM
service.
- {(verificationQuery.data?.filename || verificationQuery.data?.submittedAt) && (
+ {verificationQuery.data?.submittedAt && (
- Submitted document
+ Submission status
+
+
+ Submitted on{" "}
+ {formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })}
- {verificationQuery.data?.filename && (
-
- {verificationQuery.data.filename}
-
- )}
- {verificationQuery.data?.submittedAt && (
-
- Submitted on{" "}
- {formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })}
-
- )}
)}
@@ -579,18 +572,11 @@ export default function ProfileContainer() {
)}
- {(verificationQuery.data?.filename ||
- verificationQuery.data?.submittedAt ||
- verificationQuery.data?.reviewedAt) && (
+ {(verificationQuery.data?.submittedAt || verificationQuery.data?.reviewedAt) && (
Latest submission
- {verificationQuery.data?.filename && (
-
- {verificationQuery.data.filename}
-
- )}
{verificationQuery.data?.submittedAt && (
Submitted on{" "}
diff --git a/apps/portal/src/features/billing/components/PaymentMethodCard.tsx b/apps/portal/src/features/billing/components/PaymentMethodCard.tsx
index 5f7feee2..2cd789b9 100644
--- a/apps/portal/src/features/billing/components/PaymentMethodCard.tsx
+++ b/apps/portal/src/features/billing/components/PaymentMethodCard.tsx
@@ -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);
diff --git a/apps/portal/src/features/billing/components/PaymentMethodCard/PaymentMethodCard.tsx b/apps/portal/src/features/billing/components/PaymentMethodCard/PaymentMethodCard.tsx
deleted file mode 100644
index a697688a..00000000
--- a/apps/portal/src/features/billing/components/PaymentMethodCard/PaymentMethodCard.tsx
+++ /dev/null
@@ -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
{
- paymentMethod: PaymentMethod;
- showActions?: boolean;
- compact?: boolean;
-}
-
-const getPaymentMethodIcon = (type: PaymentMethod["type"]) => {
- switch (type) {
- case "CreditCard":
- case "RemoteCreditCard":
- return ;
- case "BankAccount":
- return ;
- case "RemoteBankAccount":
- return ;
- case "Manual":
- default:
- return ;
- }
-};
-
-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(
- ({ 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 (
-
-
{bankName || "Bank Account"}
-
- {cardLastFour && •••• {cardLastFour}}
-
-
- );
- }
-
- // Credit Card
- return (
-
-
- {cardType || "Credit Card"}
- {cardLastFour && •••• {cardLastFour}}
-
-
- {formatExpiryDate(expiryDate) && Expires {formatExpiryDate(expiryDate)}}
- {gatewayName && • {gatewayName}}
-
-
- );
- };
-
- return (
-
-
-
- {/* Icon */}
-
- {getPaymentMethodIcon(type)}
-
-
- {/* Details */}
-
- {renderPaymentMethodDetails()}
-
- {/* Description if different from generated details */}
- {description && !description.includes(cardLastFour || "") && (
-
{description}
- )}
-
- {/* Default badge */}
- {isDefault && (
-
- }>
- Default (Active)
-
-
- )}
-
-
-
- {/* Actions */}
- {showActions && (
-
-
-
- )}
-
-
- {/* Gateway info for compact view */}
- {compact && gatewayName && (
-
via {gatewayName}
- )}
-
- );
- }
-);
-
-PaymentMethodCard.displayName = "PaymentMethodCard";
-
-export { PaymentMethodCard };
-export type { PaymentMethodCardProps };
diff --git a/apps/portal/src/features/checkout/api/checkout-params.api.ts b/apps/portal/src/features/checkout/api/checkout-params.api.ts
index 93a1ae82..ad19b5e7 100644
--- a/apps/portal/src/features/checkout/api/checkout-params.api.ts
+++ b/apps/portal/src/features/checkout/api/checkout-params.api.ts
@@ -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 {
diff --git a/apps/portal/src/features/checkout/api/checkout.api.ts b/apps/portal/src/features/checkout/api/checkout.api.ts
index 09e61f93..d7b54d15 100644
--- a/apps/portal/src/features/checkout/api/checkout.api.ts
+++ b/apps/portal/src/features/checkout/api/checkout.api.ts
@@ -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("/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 {
@@ -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);
},
/**
diff --git a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx
index 1a285222..b5b4983f 100644
--- a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx
+++ b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx
@@ -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(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.
- {residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? (
+ {residenceCardQuery.data?.submittedAt || residenceCardQuery.data?.reviewedAt ? (
- Submitted document
+ Verification status
- {residenceCardQuery.data?.filename ? (
-
- {residenceCardQuery.data.filename}
- {typeof residenceCardQuery.data.sizeBytes === "number" &&
- residenceCardQuery.data.sizeBytes > 0 ? (
-
- {" "}
- ·{" "}
- {Math.round((residenceCardQuery.data.sizeBytes / 1024 / 1024) * 10) /
- 10}
- {" MB"}
-
- ) : null}
-
- ) : null}
{formatDateTime(residenceCardQuery.data?.submittedAt) ? (
@@ -556,32 +523,13 @@ export function AccountCheckoutContainer() {
We’ll verify your residence card before activating SIM service.
- {residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? (
+ {residenceCardQuery.data?.submittedAt ? (
- Submitted document
+ Submission status
- {residenceCardQuery.data?.filename ? (
-
- {residenceCardQuery.data.filename}
- {typeof residenceCardQuery.data.sizeBytes === "number" &&
- residenceCardQuery.data.sizeBytes > 0 ? (
-
- {" "}
- ·{" "}
- {Math.round((residenceCardQuery.data.sizeBytes / 1024 / 1024) * 10) /
- 10}
- {" MB"}
-
- ) : null}
-
- ) : null}
- {formatDateTime(residenceCardQuery.data?.submittedAt) ? (
-
- Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
-
- ) : null}
+ Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
) : null}
diff --git a/apps/portal/src/features/checkout/components/CheckoutEntry.tsx b/apps/portal/src/features/checkout/components/CheckoutEntry.tsx
index d4ff0f0a..82f21e39 100644
--- a/apps/portal/src/features/checkout/components/CheckoutEntry.tsx
+++ b/apps/portal/src/features/checkout/components/CheckoutEntry.tsx
@@ -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
;
}
- // 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 (
diff --git a/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx b/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx
index 0484bf29..68470f38 100644
--- a/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx
+++ b/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx
@@ -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,11 +91,10 @@ 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({
address: userAddress ?? undefined,
diff --git a/apps/portal/src/features/checkout/index.ts b/apps/portal/src/features/checkout/index.ts
index 22570355..552a7859 100644
--- a/apps/portal/src/features/checkout/index.ts
+++ b/apps/portal/src/features/checkout/index.ts
@@ -14,6 +14,3 @@ export * from "./components";
// Constants
export * from "./constants";
-
-// Utilities
-export * from "./utils";
diff --git a/apps/portal/src/features/checkout/stores/checkout.store.ts b/apps/portal/src/features/checkout/stores/checkout.store.ts
index a14208f7..a21cacd7 100644
--- a/apps/portal/src/features/checkout/stores/checkout.store.ts
+++ b/apps/portal/src/features/checkout/stores/checkout.store.ts
@@ -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
()(
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()(
}
)
);
-
-/**
- * Hook to check if cart has items
- */
-export function useHasCartItem(): boolean {
- return useCheckoutStore(state => state.cartItem !== null);
-}
diff --git a/apps/portal/src/features/checkout/stores/index.ts b/apps/portal/src/features/checkout/stores/index.ts
index f01cd670..a0933a03 100644
--- a/apps/portal/src/features/checkout/stores/index.ts
+++ b/apps/portal/src/features/checkout/stores/index.ts
@@ -1 +1 @@
-export { useCheckoutStore, useHasCartItem } from "./checkout.store";
+export { useCheckoutStore } from "./checkout.store";
diff --git a/apps/portal/src/features/checkout/utils/index.ts b/apps/portal/src/features/checkout/utils/index.ts
deleted file mode 100644
index bead2369..00000000
--- a/apps/portal/src/features/checkout/utils/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./checkout-ui-utils";
diff --git a/apps/portal/src/features/orders/components/OrderCard.tsx b/apps/portal/src/features/orders/components/OrderCard.tsx
index 8a30f3cb..d84be1dc 100644
--- a/apps/portal/src/features/orders/components/OrderCard.tsx
+++ b/apps/portal/src/features/orders/components/OrderCard.tsx
@@ -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 ;
- case "sim":
- return ;
- case "vpn":
- return ;
- default:
- return ;
- }
-};
-
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 = ;
const displayItems = useMemo(
() => buildOrderDisplayItems(order.itemsSummary),
[order.itemsSummary]
diff --git a/apps/portal/src/features/orders/components/OrderServiceIcon.tsx b/apps/portal/src/features/orders/components/OrderServiceIcon.tsx
new file mode 100644
index 00000000..b80ed6d2
--- /dev/null
+++ b/apps/portal/src/features/orders/components/OrderServiceIcon.tsx
@@ -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;
+ className?: string;
+}
+
+export function OrderServiceIcon({
+ orderType,
+ category,
+ className = "h-6 w-6",
+}: OrderServiceIconProps) {
+ const resolvedCategory = category ?? getServiceCategory(orderType);
+
+ switch (resolvedCategory) {
+ case "internet":
+ return ;
+ case "sim":
+ return ;
+ case "vpn":
+ return ;
+ default:
+ return ;
+ }
+}
diff --git a/apps/portal/src/features/orders/components/index.ts b/apps/portal/src/features/orders/components/index.ts
index f9a51e02..eb4a2b1a 100644
--- a/apps/portal/src/features/orders/components/index.ts
+++ b/apps/portal/src/features/orders/components/index.ts
@@ -1,2 +1,3 @@
export { OrderCard } from "./OrderCard";
export { OrderCardSkeleton } from "./OrderCardSkeleton";
+export { OrderServiceIcon } from "./OrderServiceIcon";
diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx
index b7e467c0..9a2e020b 100644
--- a/apps/portal/src/features/orders/views/OrderDetail.tsx
+++ b/apps/portal/src/features/orders/views/OrderDetail.tsx
@@ -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, className: string) => {
- switch (category) {
- case "internet":
- return ;
- case "sim":
- return ;
- case "vpn":
- return ;
- default:
- return ;
- }
-};
-
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 = ;
const displayItems = useMemo(() => {
return buildOrderDisplayItems(data?.itemsSummary);
diff --git a/apps/portal/src/features/services/views/InternetEligibilityRequestSubmitted.tsx b/apps/portal/src/features/services/views/InternetEligibilityRequestSubmitted.tsx
index 58a65330..c457644c 100644
--- a/apps/portal/src/features/services/views/InternetEligibilityRequestSubmitted.tsx
+++ b/apps/portal/src/features/services/views/InternetEligibilityRequestSubmitted.tsx
@@ -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;
diff --git a/apps/portal/src/features/services/views/InternetPlans.tsx b/apps/portal/src/features/services/views/InternetPlans.tsx
index a08124b9..acdb11c1 100644
--- a/apps/portal/src/features/services/views/InternetPlans.tsx
+++ b/apps/portal/src/features/services/views/InternetPlans.tsx
@@ -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("idle");
const [autoRequestId, setAutoRequestId] = useState(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);
diff --git a/apps/portal/src/features/support/utils/case-presenters.tsx b/apps/portal/src/features/support/utils/case-presenters.tsx
index 5a6615bc..4202068b 100644
--- a/apps/portal/src/features/support/utils/case-presenters.tsx
+++ b/apps/portal/src/features/support/utils/case-presenters.tsx
@@ -26,30 +26,26 @@ const ICON_SIZE_CLASSES: Record = {
/**
* Status to icon mapping
+ * Uses customer-friendly status values (internal statuses are mapped before reaching here)
*/
const STATUS_ICON_MAP: Record ReactNode> = {
- [SUPPORT_CASE_STATUS.RESOLVED]: cls => ,
- [SUPPORT_CASE_STATUS.CLOSED]: cls => ,
- [SUPPORT_CASE_STATUS.VPN_PENDING]: cls => ,
- [SUPPORT_CASE_STATUS.PENDING]: cls => ,
+ [SUPPORT_CASE_STATUS.NEW]: cls => ,
[SUPPORT_CASE_STATUS.IN_PROGRESS]: cls => ,
- [SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: cls => (
+ [SUPPORT_CASE_STATUS.AWAITING_CUSTOMER]: cls => (
),
- [SUPPORT_CASE_STATUS.NEW]: cls => ,
+ [SUPPORT_CASE_STATUS.CLOSED]: cls => ,
};
/**
* Status to variant mapping
+ * Uses customer-friendly status values (internal statuses are mapped before reaching here)
*/
const STATUS_VARIANT_MAP: Record = {
- [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",
};
/**
diff --git a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx
index 0e8628e5..fe4fe08b 100644
--- a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx
+++ b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx
@@ -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 = 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,49 +234,11 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
-
- {messagesLoading ? (
-
-
-
- ) : messagesData?.messages && messagesData.messages.length > 0 ? (
-
- {/* Original Description as first message */}
-
-
- {/* Conversation messages */}
- {messagesData.messages.map(message => (
-
- ))}
-
- ) : (
-
- {/* Show description as the only message if no conversation yet */}
-
-
- No replies yet. Our team will respond shortly.
-
-
- )}
-
+
{/* Reply Form */}
{!isCaseClosed && (
@@ -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 (
+
+
+
+ );
+ }
+
+ const hasReplies = (messagesData?.messages?.length ?? 0) > 0;
+
+ return (
+
+
+ {messageGroups.map(group => (
+
+ {/* Date separator */}
+
+
+
+ {group.dateLabel}
+
+
+
+
+ {/* Messages for this date */}
+
+ {group.messages.map(message => (
+
+ ))}
+
+
+ ))}
+
+ {/* No replies yet message */}
+ {!hasReplies && (
+
+ No replies yet. Our team will respond shortly.
+
+ )}
+
+
+ );
+}
+
/**
* 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 (
@@ -309,6 +415,15 @@ function MessageBubble({ message }: { message: CaseMessage }) {
{message.author.name}
•
{formatIsoRelative(message.createdAt)}
+ {hasAttachment && (
+ <>
+
•
+
+
+ Attachment
+
+ >
+ )}
{/* Message body */}
diff --git a/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx b/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx
index 70c3fc64..260eac1e 100644
--- a/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx
+++ b/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx
@@ -95,21 +95,14 @@ export function ResidenceCardVerificationSettingsView() {
We'll verify your residence card before activating SIM service.
- {(residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt) && (
+ {residenceCardQuery.data?.submittedAt && (
- Submitted document
+ Submission status
+
+
+ Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
- {residenceCardQuery.data?.filename && (
-
- {residenceCardQuery.data.filename}
-
- )}
- {formatDateTime(residenceCardQuery.data?.submittedAt) && (
-
- Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
-
- )}
)}
@@ -140,18 +133,11 @@ export function ResidenceCardVerificationSettingsView() {
- {(residenceCardQuery.data?.filename ||
- residenceCardQuery.data?.submittedAt ||
- residenceCardQuery.data?.reviewedAt) && (
+ {(residenceCardQuery.data?.submittedAt || residenceCardQuery.data?.reviewedAt) && (
Latest submission
- {residenceCardQuery.data?.filename && (
-
- {residenceCardQuery.data.filename}
-
- )}
{formatDateTime(residenceCardQuery.data?.submittedAt) && (
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
diff --git a/apps/portal/src/shared/utils/address.ts b/apps/portal/src/shared/utils/address.ts
new file mode 100644
index 00000000..0e975871
--- /dev/null
+++ b/apps/portal/src/shared/utils/address.ts
@@ -0,0 +1,17 @@
+import type { Address } from "@customer-portal/domain/customer";
+
+export function formatAddressLabel(address?: Partial
| 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(", ");
+}
diff --git a/apps/portal/src/shared/utils/confirm.ts b/apps/portal/src/shared/utils/confirm.ts
new file mode 100644
index 00000000..0f5a1e35
--- /dev/null
+++ b/apps/portal/src/shared/utils/confirm.ts
@@ -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}`);
+}
diff --git a/apps/portal/src/shared/utils/index.ts b/apps/portal/src/shared/utils/index.ts
index e4c8f793..1f7b0511 100644
--- a/apps/portal/src/shared/utils/index.ts
+++ b/apps/portal/src/shared/utils/index.ts
@@ -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";
diff --git a/apps/portal/src/features/checkout/utils/checkout-ui-utils.ts b/apps/portal/src/shared/utils/payment-methods.ts
similarity index 62%
rename from apps/portal/src/features/checkout/utils/checkout-ui-utils.ts
rename to apps/portal/src/shared/utils/payment-methods.ts
index 85e4a87c..8618c7c2 100644
--- a/apps/portal/src/features/checkout/utils/checkout-ui-utils.ts
+++ b/apps/portal/src/shared/utils/payment-methods.ts
@@ -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;
+}
diff --git a/docs/how-it-works/dashboard-and-notifications.md b/docs/how-it-works/dashboard-and-notifications.md
index ebbd904b..efb16a82 100644
--- a/docs/how-it-works/dashboard-and-notifications.md
+++ b/docs/how-it-works/dashboard-and-notifications.md
@@ -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
diff --git a/docs/how-it-works/eligibility-and-verification.md b/docs/how-it-works/eligibility-and-verification.md
index 3577b19f..45c84374 100644
--- a/docs/how-it-works/eligibility-and-verification.md
+++ b/docs/how-it-works/eligibility-and-verification.md
@@ -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
diff --git a/docs/how-it-works/support-cases.md b/docs/how-it-works/support-cases.md
index 4611fa91..08b37e1a 100644
--- a/docs/how-it-works/support-cases.md
+++ b/docs/how-it-works/support-cases.md
@@ -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 customer’s 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.
diff --git a/docs/integrations/salesforce/platform-events.md b/docs/integrations/salesforce/platform-events.md
new file mode 100644
index 00000000..055bf91c
--- /dev/null
+++ b/docs/integrations/salesforce/platform-events.md
@@ -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) |
diff --git a/packages/domain/customer/schema.ts b/packages/domain/customer/schema.ts
index 0a3bed99..cd192544 100644
--- a/packages/domain/customer/schema.ts
+++ b/packages/domain/customer/schema.ts
@@ -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(),
diff --git a/packages/domain/orders/utils.ts b/packages/domain/orders/utils.ts
index 4e3722f5..1fc3a9d2 100644
--- a/packages/domain/orders/utils.ts
+++ b/packages/domain/orders/utils.ts
@@ -7,6 +7,22 @@ import {
} from "./schema.js";
import { ORDER_TYPE, type CheckoutCart, type OrderTypeValue } from "./contract.js";
+export type CheckoutOrderTypeValue = Extract;
+
+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);
diff --git a/packages/domain/support/contract.ts b/packages/domain/support/contract.ts
index 5a34260c..f79f431c 100644
--- a/packages/domain/support/contract.ts
+++ b/packages/domain/support/contract.ts
@@ -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;
/**
diff --git a/packages/domain/support/providers/salesforce/mapper.ts b/packages/domain/support/providers/salesforce/mapper.ts
index 10b93300..2b26b179 100644
--- a/packages/domain/support/providers/salesforce/mapper.ts
+++ b/packages/domain/support/providers/salesforce/mapper.ts
@@ -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 ... \nwrote:" or "On ... <\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 wrote:" that may span lines
+ /On\s+\d{1,2}[^]*?wrote:\s*/gi,
+
+ // Japanese: "にが書きました:"
+ /\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: " 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 , 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,
});
}
diff --git a/packages/domain/support/providers/salesforce/raw.types.ts b/packages/domain/support/providers/salesforce/raw.types.ts
index baf58bec..8e91d157 100644
--- a/packages/domain/support/providers/salesforce/raw.types.ts
+++ b/packages/domain/support/providers/salesforce/raw.types.ts
@@ -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 = {
[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",
};
diff --git a/packages/domain/support/schema.ts b/packages/domain/support/schema.ts
index 80844867..e7ecd95b 100644
--- a/packages/domain/support/schema.ts
+++ b/packages/domain/support/schema.ts
@@ -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(),
});
/**