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(), }); /**