From 5d011c87be41edcc6be5bb4735e599f57e87599e Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 4 Nov 2025 17:24:26 +0900 Subject: [PATCH] Enhance order processing and event streaming capabilities - Introduced OrderEventsService to manage order-related events and updates. - Added SSE endpoint in OrdersController for streaming order updates to clients. - Enhanced OrderFulfillmentOrchestrator to publish order status updates during fulfillment processes. - Updated order field mappings to include bundled add-on information for improved order detail handling. - Refactored OrderCard and OrderDetail components for better display of order information and status updates. --- .../orders/config/order-field-map.service.ts | 2 + .../src/modules/orders/orders.controller.ts | 22 +- apps/bff/src/modules/orders/orders.module.ts | 2 + .../orders/services/order-events.service.ts | 107 +++++ .../order-fulfillment-orchestrator.service.ts | 181 +++++--- .../src/features/auth/services/auth.store.ts | 12 + .../features/orders/components/OrderCard.tsx | 126 ++++-- .../orders/components/OrderCardSkeleton.tsx | 24 +- .../features/orders/hooks/useOrderUpdates.ts | 95 +++++ .../orders/services/orders.service.ts | 7 +- .../features/orders/utils/order-display.ts | 234 +++++++++++ .../src/features/orders/views/OrderDetail.tsx | 385 +++++++++--------- apps/portal/src/lib/api/index.ts | 47 ++- docs/orders/ORDER-STATUS-UPDATES-STRATEGY.md | 158 +++++++ .../orders/providers/salesforce/field-map.ts | 4 + .../orders/providers/salesforce/mapper.ts | 12 + packages/domain/orders/schema.ts | 5 + 17 files changed, 1113 insertions(+), 310 deletions(-) create mode 100644 apps/bff/src/modules/orders/services/order-events.service.ts create mode 100644 apps/portal/src/features/orders/hooks/useOrderUpdates.ts create mode 100644 apps/portal/src/features/orders/utils/order-display.ts create mode 100644 docs/orders/ORDER-STATUS-UPDATES-STRATEGY.md diff --git a/apps/bff/src/modules/orders/config/order-field-map.service.ts b/apps/bff/src/modules/orders/config/order-field-map.service.ts index e191c81e..c2ab098f 100644 --- a/apps/bff/src/modules/orders/config/order-field-map.service.ts +++ b/apps/bff/src/modules/orders/config/order-field-map.service.ts @@ -97,6 +97,8 @@ export class OrderFieldMapService { product.internetOfferingType, product.internetPlanTier, product.vpnRegion, + product.bundledAddon, + product.isBundledAddon, ]; return unique([...base, ...additional]); diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index e3f70bac..d4518ba6 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -1,4 +1,15 @@ -import { Body, Controller, Get, Param, Post, Request, UsePipes, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Param, + Post, + Request, + Sse, + UsePipes, + UseGuards, + type MessageEvent, +} from "@nestjs/common"; import { Throttle, ThrottlerGuard } from "@nestjs/throttler"; import { OrderOrchestrator } from "./services/order-orchestrator.service"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; @@ -12,12 +23,15 @@ import { type SfOrderIdParam, } from "@customer-portal/domain/orders"; import { apiSuccessResponseSchema } from "@customer-portal/domain/common"; +import { Observable } from "rxjs"; +import { OrderEventsService } from "./services/order-events.service"; @Controller("orders") @UseGuards(ThrottlerGuard) export class OrdersController { constructor( private orderOrchestrator: OrderOrchestrator, + private readonly orderEvents: OrderEventsService, private readonly logger: Logger ) {} @@ -63,6 +77,12 @@ export class OrdersController { return this.orderOrchestrator.getOrder(params.sfOrderId); } + @Sse(":sfOrderId/events") + @UsePipes(new ZodValidationPipe(sfOrderIdParamSchema)) + streamOrderUpdates(@Param() params: SfOrderIdParam): Observable { + return this.orderEvents.subscribe(params.sfOrderId); + } + // Note: Order provisioning has been moved to SalesforceProvisioningController // This controller now focuses only on customer-facing order operations } diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index 54f13263..6a73fd42 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -16,6 +16,7 @@ import { OrderPricebookService } from "./services/order-pricebook.service"; import { OrderOrchestrator } from "./services/order-orchestrator.service"; import { PaymentValidatorService } from "./services/payment-validator.service"; import { CheckoutService } from "./services/checkout.service"; +import { OrderEventsService } from "./services/order-events.service"; // Clean modular fulfillment services import { OrderFulfillmentValidator } from "./services/order-fulfillment-validator.service"; @@ -40,6 +41,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module"; providers: [ // Shared services PaymentValidatorService, + OrderEventsService, // Order creation services (modular) OrderValidator, diff --git a/apps/bff/src/modules/orders/services/order-events.service.ts b/apps/bff/src/modules/orders/services/order-events.service.ts new file mode 100644 index 00000000..f7c59b6c --- /dev/null +++ b/apps/bff/src/modules/orders/services/order-events.service.ts @@ -0,0 +1,107 @@ +import { Injectable, Logger } from "@nestjs/common"; +import type { MessageEvent } from "@nestjs/common"; +import { Observable } from "rxjs"; + +export interface OrderUpdateEvent { + orderId: string; + status?: string; + activationStatus?: string | null; + message?: string; + reason?: string; + stage?: "started" | "in_progress" | "completed" | "failed"; + source?: string; + timestamp: string; + payload?: Record; +} + +interface InternalObserver { + next: (event: MessageEvent) => void; + complete: () => void; + error: (error: unknown) => void; +} + +@Injectable() +export class OrderEventsService { + private readonly logger = new Logger(OrderEventsService.name); + + private readonly observers = new Map>(); + + subscribe(orderId: string): Observable { + return new Observable(subscriber => { + const wrappedObserver: InternalObserver = { + next: value => subscriber.next(value), + complete: () => subscriber.complete(), + error: error => subscriber.error(error), + }; + + const orderObservers = this.observers.get(orderId) ?? new Set(); + orderObservers.add(wrappedObserver); + this.observers.set(orderId, orderObservers); + + this.logger.debug(`Order stream connected`, { orderId, listeners: orderObservers.size }); + + // Immediately notify client that stream is ready + wrappedObserver.next( + this.buildEvent("order.stream.ready", { + orderId, + timestamp: new Date().toISOString(), + }) + ); + + const heartbeat = setInterval(() => { + wrappedObserver.next( + this.buildEvent("order.stream.heartbeat", { + orderId, + timestamp: new Date().toISOString(), + }) + ); + }, 30000); + + return () => { + clearInterval(heartbeat); + const currentObservers = this.observers.get(orderId); + if (currentObservers) { + currentObservers.delete(wrappedObserver); + if (currentObservers.size === 0) { + this.observers.delete(orderId); + } + } + this.logger.debug(`Order stream disconnected`, { + orderId, + listeners: currentObservers?.size ?? 0, + }); + }; + }); + } + + publish(orderId: string, update: OrderUpdateEvent): void { + const currentObservers = this.observers.get(orderId); + if (!currentObservers || currentObservers.size === 0) { + this.logger.debug("No active listeners for order update", { orderId }); + return; + } + + const event = this.buildEvent("order.update", update); + + currentObservers.forEach(observer => { + try { + observer.next(event); + } catch (error) { + this.logger.warn("Failed to notify order update listener", { + orderId, + error: error instanceof Error ? error.message : String(error), + }); + } + }); + } + + private buildEvent(event: string, data: Record): MessageEvent { + return { + data: { + event, + data, + }, + } satisfies MessageEvent; + } +} + diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index b818c193..ad47a27c 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -11,6 +11,7 @@ import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service" import { SimFulfillmentService } from "./sim-fulfillment.service"; import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; +import { OrderEventsService } from "./order-events.service"; import { type OrderDetails, type OrderFulfillmentValidationResult, @@ -56,7 +57,8 @@ export class OrderFulfillmentOrchestrator { private readonly orderFulfillmentValidator: OrderFulfillmentValidator, private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService, private readonly simFulfillmentService: SimFulfillmentService, - private readonly distributedTransactionService: DistributedTransactionService + private readonly distributedTransactionService: DistributedTransactionService, + private readonly orderEvents: OrderEventsService ) {} /** @@ -92,59 +94,81 @@ export class OrderFulfillmentOrchestrator { idempotencyKey, }); - // Step 1: Validation (no rollback needed) try { - context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest( - sfOrderId, - idempotencyKey - ); - - if (context.validation.isAlreadyProvisioned) { - this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId }); - return context; - } - } catch (error) { - this.logger.error("Fulfillment validation failed", { - sfOrderId, - error: getErrorMessage(error), - }); - throw error; - } - - // Step 2: Get order details (no rollback needed) - try { - const orderDetails = await this.orderOrchestrator.getOrder(sfOrderId); - if (!orderDetails) { - throw new OrderValidationException("Order details could not be retrieved.", { + // Step 1: Validation (no rollback needed) + try { + context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest( sfOrderId, - idempotencyKey, + idempotencyKey + ); + + if (context.validation.isAlreadyProvisioned) { + this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId }); + this.orderEvents.publish(sfOrderId, { + orderId: sfOrderId, + status: "Completed", + activationStatus: "Activated", + stage: "completed", + source: "fulfillment", + message: "Order already provisioned", + timestamp: new Date().toISOString(), + payload: { + whmcsOrderId: context.validation.whmcsOrderId, + }, + }); + return context; + } + } catch (error) { + this.logger.error("Fulfillment validation failed", { + sfOrderId, + error: getErrorMessage(error), }); + throw error; } - context.orderDetails = orderDetails; - } catch (error) { - this.logger.error("Failed to get order details", { - sfOrderId, - error: getErrorMessage(error), - }); - throw error; - } - // Step 3: Execute the main fulfillment workflow as a distributed transaction - let mappingResult: WhmcsOrderItemMappingResult | undefined; - let whmcsCreateResult: { orderId: number } | undefined; - let whmcsAcceptResult: WhmcsOrderResult | undefined; + // Step 2: Get order details (no rollback needed) + try { + const orderDetails = await this.orderOrchestrator.getOrder(sfOrderId); + if (!orderDetails) { + throw new OrderValidationException("Order details could not be retrieved.", { + sfOrderId, + idempotencyKey, + }); + } + context.orderDetails = orderDetails; + } catch (error) { + this.logger.error("Failed to get order details", { + sfOrderId, + error: getErrorMessage(error), + }); + throw error; + } - const fulfillmentResult = - await this.distributedTransactionService.executeDistributedTransaction( - [ + // Step 3: Execute the main fulfillment workflow as a distributed transaction + let mappingResult: WhmcsOrderItemMappingResult | undefined; + let whmcsCreateResult: { orderId: number } | undefined; + let whmcsAcceptResult: WhmcsOrderResult | undefined; + + const fulfillmentResult = + await this.distributedTransactionService.executeDistributedTransaction( + [ { id: "sf_status_update", description: "Update Salesforce order status to Activating", execute: async () => { - return await this.salesforceService.updateOrder({ + const result = await this.salesforceService.updateOrder({ Id: sfOrderId, Activation_Status__c: "Activating", }); + this.orderEvents.publish(sfOrderId, { + orderId: sfOrderId, + status: "Processing", + activationStatus: "Activating", + stage: "in_progress", + source: "fulfillment", + timestamp: new Date().toISOString(), + }); + return result; }, rollback: async () => { await this.salesforceService.updateOrder({ @@ -291,12 +315,25 @@ export class OrderFulfillmentOrchestrator { id: "sf_success_update", description: "Update Salesforce with success", execute: async () => { - return await this.salesforceService.updateOrder({ + const result = await this.salesforceService.updateOrder({ Id: sfOrderId, Status: "Completed", Activation_Status__c: "Activated", WHMCS_Order_ID__c: whmcsAcceptResult?.orderId?.toString(), }); + this.orderEvents.publish(sfOrderId, { + orderId: sfOrderId, + status: "Completed", + activationStatus: "Activated", + stage: "completed", + source: "fulfillment", + timestamp: new Date().toISOString(), + payload: { + whmcsOrderId: whmcsAcceptResult?.orderId, + whmcsServiceIds: whmcsAcceptResult?.serviceIds, + }, + }); + return result; }, rollback: async () => { await this.salesforceService.updateOrder({ @@ -312,34 +349,50 @@ export class OrderFulfillmentOrchestrator { timeout: 300000, // 5 minutes continueOnNonCriticalFailure: true, } - ); + ); - if (!fulfillmentResult.success) { - this.logger.error("Fulfillment transaction failed", { + if (!fulfillmentResult.success) { + this.logger.error("Fulfillment transaction failed", { + sfOrderId, + error: fulfillmentResult.error, + stepsExecuted: fulfillmentResult.stepsExecuted, + stepsRolledBack: fulfillmentResult.stepsRolledBack, + }); + throw new FulfillmentException( + fulfillmentResult.error || "Fulfillment transaction failed", + { + sfOrderId, + idempotencyKey, + stepsExecuted: fulfillmentResult.stepsExecuted, + stepsRolledBack: fulfillmentResult.stepsRolledBack, + } + ); + } + + // Update context with results + context.mappingResult = mappingResult; + context.whmcsResult = whmcsAcceptResult; + + this.logger.log("Transactional fulfillment completed successfully", { sfOrderId, - error: fulfillmentResult.error, stepsExecuted: fulfillmentResult.stepsExecuted, - stepsRolledBack: fulfillmentResult.stepsRolledBack, + duration: fulfillmentResult.duration, }); - throw new FulfillmentException(fulfillmentResult.error || "Fulfillment transaction failed", { - sfOrderId, - idempotencyKey, - stepsExecuted: fulfillmentResult.stepsExecuted, - stepsRolledBack: fulfillmentResult.stepsRolledBack, + + return context; + } catch (error) { + await this.handleFulfillmentError(context, error as Error); + this.orderEvents.publish(sfOrderId, { + orderId: sfOrderId, + status: "Pending Review", + activationStatus: "Failed", + stage: "failed", + source: "fulfillment", + timestamp: new Date().toISOString(), + reason: error instanceof Error ? error.message : String(error), }); + throw error; } - - // Update context with results - context.mappingResult = mappingResult; - context.whmcsResult = whmcsAcceptResult; - - this.logger.log("Transactional fulfillment completed successfully", { - sfOrderId, - stepsExecuted: fulfillmentResult.stepsExecuted, - duration: fulfillmentResult.duration, - }); - - return context; } /** diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index fabea033..be9159fc 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -109,6 +109,18 @@ export const useAuthStore = create()((set, get) => { await refreshPromise; }; + // Set up global listener for 401 errors from API client + if (typeof window !== "undefined") { + window.addEventListener("auth:unauthorized", (event) => { + const customEvent = event as CustomEvent; + logger.warn( + { url: customEvent.detail?.url, status: customEvent.detail?.status }, + "401 Unauthorized detected - triggering logout" + ); + void get().logout({ reason: "session-expired" }); + }); + } + return { user: null, session: {}, diff --git a/apps/portal/src/features/orders/components/OrderCard.tsx b/apps/portal/src/features/orders/components/OrderCard.tsx index ad41bd98..93342e2a 100644 --- a/apps/portal/src/features/orders/components/OrderCard.tsx +++ b/apps/portal/src/features/orders/components/OrderCard.tsx @@ -13,8 +13,11 @@ import { calculateOrderTotals, deriveOrderStatusDescriptor, getServiceCategory, - summarizePrimaryItem, } from "@/features/orders/utils/order-presenters"; +import { + buildOrderDisplayItems, + summarizeOrderDisplayItems, +} from "@/features/orders/utils/order-display"; import type { OrderSummary } from "@customer-portal/domain/orders"; import { cn } from "@/lib/utils/cn"; @@ -35,17 +38,17 @@ const STATUS_PILL_VARIANT = { } as const; const SERVICE_ICON_STYLES = { - internet: "bg-blue-100 text-blue-600", - sim: "bg-violet-100 text-violet-600", - vpn: "bg-teal-100 text-teal-600", - default: "bg-slate-100 text-slate-600", + internet: "bg-blue-50 text-blue-600", + sim: "bg-violet-50 text-violet-600", + vpn: "bg-teal-50 text-teal-600", + default: "bg-slate-50 text-slate-600", } as const; -const CARD_TONE_STYLES = { - success: "border-green-200 bg-green-50/80", - info: "border-blue-100 bg-white", - warning: "border-amber-100 bg-white", - neutral: "border-gray-200 bg-white", +const STATUS_ACCENT_TONE_CLASSES = { + success: "bg-green-400/70", + info: "bg-blue-400/70", + warning: "bg-amber-400/70", + neutral: "bg-slate-200", } as const; const renderServiceIcon = (orderType?: string): ReactNode => { @@ -71,9 +74,17 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps) const serviceCategory = getServiceCategory(order.orderType); const iconStyles = SERVICE_ICON_STYLES[serviceCategory]; const serviceIcon = renderServiceIcon(order.orderType); - const serviceSummary = summarizePrimaryItem( - order.itemsSummary ?? [], - order.itemSummary || "Service package" + const displayItems = useMemo( + () => buildOrderDisplayItems(order.itemsSummary), + [order.itemsSummary] + ); + const serviceSummary = useMemo( + () => + summarizeOrderDisplayItems( + displayItems, + order.itemSummary || "Service package" + ), + [displayItems, order.itemSummary] ); const totals = calculateOrderTotals(order.itemsSummary, order.totalAmount); const createdDate = useMemo(() => { @@ -104,10 +115,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
-
-
+ + +
+
{serviceIcon}
-

{serviceSummary}

+

{serviceSummary}

- + Order #{order.orderNumber || String(order.id).slice(-8)} - - {formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"} - + {formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"}

{statusDescriptor.description}

+ {displayItems.length > 1 && ( +
+ {displayItems.slice(1, 4).map(item => ( + + {item.name} + + ))} + {displayItems.length > 4 && ( + +{displayItems.length - 4} more + )} +
+ )}
-
+
- {showPricing && ( -
- {totals.monthlyTotal > 0 && ( -

- ¥{totals.monthlyTotal.toLocaleString()} - / month -

- )} - {totals.oneTimeTotal > 0 && ( -

- ¥{totals.oneTimeTotal.toLocaleString()} one-time -

- )} -
- )} +
+ {showPricing ? ( +
+ {totals.monthlyTotal > 0 ? ( +

+ ¥{totals.monthlyTotal.toLocaleString()} + / month +

+ ) : ( +

No monthly charges

+ )} + {totals.oneTimeTotal > 0 && ( +

+ ¥{totals.oneTimeTotal.toLocaleString()} one-time +

+ )} +
+ ) : ( +

Included in plan

+ )} +
{(isInteractive || footer) && ( -
+
{isInteractive ? ( - - View order + + View details ) : ( diff --git a/apps/portal/src/features/orders/components/OrderCardSkeleton.tsx b/apps/portal/src/features/orders/components/OrderCardSkeleton.tsx index 710a0e74..8fdbb455 100644 --- a/apps/portal/src/features/orders/components/OrderCardSkeleton.tsx +++ b/apps/portal/src/features/orders/components/OrderCardSkeleton.tsx @@ -2,23 +2,25 @@ export function OrderCardSkeleton() { return ( -
-
-
+
+ +
+
-
+
-
-
-
-
+
+
+
+
+
+
-
-
+
-
+
); diff --git a/apps/portal/src/features/orders/hooks/useOrderUpdates.ts b/apps/portal/src/features/orders/hooks/useOrderUpdates.ts new file mode 100644 index 00000000..f5a9295a --- /dev/null +++ b/apps/portal/src/features/orders/hooks/useOrderUpdates.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { resolveBaseUrl } from "@/lib/api"; +import { logger } from "@/lib/logger"; + +export interface OrderStreamEvent { + event: T; + data: P; +} + +export interface OrderUpdateEventPayload { + orderId: string; + status?: string; + activationStatus?: string | null; + message?: string; + reason?: string; + stage?: "started" | "in_progress" | "completed" | "failed"; + source?: string; + timestamp: string; + payload?: Record | null; +} + +type OrderUpdateHandler = (event: OrderUpdateEventPayload) => void; + +export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpdateHandler) { + const handlerRef = useRef(onUpdate); + + useEffect(() => { + handlerRef.current = onUpdate; + }, [onUpdate]); + + useEffect(() => { + if (!orderId) { + return undefined; + } + + let isCancelled = false; + let eventSource: EventSource | null = null; + let reconnectTimeout: ReturnType | null = null; + + const baseUrl = resolveBaseUrl(); + const url = new URL(`/api/orders/${orderId}/events`, baseUrl).toString(); + + const connect = () => { + if (isCancelled) return; + + logger.debug({ orderId, url }, "Connecting to order updates stream"); + const es = new EventSource(url, { withCredentials: true }); + eventSource = es; + + const handleMessage = (event: MessageEvent) => { + try { + const parsed = JSON.parse(event.data) as OrderStreamEvent; + if (!parsed || typeof parsed !== "object") { + return; + } + + if (parsed.event === "order.update") { + const payload = parsed.data as OrderUpdateEventPayload; + handlerRef.current?.(payload); + } + } catch (error) { + logger.warn({ orderId, error }, "Failed to parse order update event"); + } + }; + + const handleError = (error: Event) => { + logger.warn({ orderId, error }, "Order updates stream disconnected"); + es.close(); + eventSource = null; + + if (!isCancelled) { + reconnectTimeout = setTimeout(connect, 5000); + } + }; + + es.addEventListener("message", handleMessage as EventListener); + es.onerror = handleError; + }; + + connect(); + + return () => { + isCancelled = true; + if (eventSource) { + eventSource.close(); + } + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + } + }; + }, [orderId]); +} + diff --git a/apps/portal/src/features/orders/services/orders.service.ts b/apps/portal/src/features/orders/services/orders.service.ts index 20aca527..f7e16e85 100644 --- a/apps/portal/src/features/orders/services/orders.service.ts +++ b/apps/portal/src/features/orders/services/orders.service.ts @@ -27,9 +27,14 @@ async function getMyOrders(): Promise { return data.map(item => orderSummarySchema.parse(item)); } -async function getOrderById(orderId: string): Promise { +type GetOrderByIdOptions = { + signal?: AbortSignal; +}; + +async function getOrderById(orderId: string, options: GetOrderByIdOptions = {}): Promise { const response = await apiClient.GET("/api/orders/{sfOrderId}", { params: { path: { sfOrderId: orderId } }, + signal: options.signal, }); if (!response.data) { throw new Error("Order not found"); diff --git a/apps/portal/src/features/orders/utils/order-display.ts b/apps/portal/src/features/orders/utils/order-display.ts new file mode 100644 index 00000000..d067cf57 --- /dev/null +++ b/apps/portal/src/features/orders/utils/order-display.ts @@ -0,0 +1,234 @@ +import type { OrderItemSummary } from "@customer-portal/domain/orders"; +import { normalizeBillingCycle } from "@/features/orders/utils/order-presenters"; + +export type OrderDisplayItemCategory = "service" | "installation" | "addon" | "activation" | "other"; + +export type OrderDisplayItemChargeKind = "monthly" | "one-time" | "other"; + +export interface OrderDisplayItemCharge { + kind: OrderDisplayItemChargeKind; + amount: number; + label: string; + suffix?: string; +} + +export interface OrderDisplayItem { + id: string; + name: string; + quantity?: number; + status?: string; + primaryCategory: OrderDisplayItemCategory; + categories: OrderDisplayItemCategory[]; + charges: OrderDisplayItemCharge[]; + included: boolean; + sourceItems: OrderItemSummary[]; + isBundle: boolean; +} + +interface OrderItemGroup { + indices: number[]; + items: OrderItemSummary[]; +} + +const CATEGORY_ORDER: OrderDisplayItemCategory[] = [ + "service", + "installation", + "addon", + "activation", + "other", +]; + +const CHARGE_ORDER: Record = { + monthly: 0, + "one-time": 1, + other: 2, +}; + +const MONTHLY_SUFFIX = "/ month"; + +const normalizeItemClass = (itemClass?: string | null): string => + (itemClass ?? "").toLowerCase(); + +const resolveCategory = (item: OrderItemSummary): OrderDisplayItemCategory => { + const normalizedClass = normalizeItemClass(item.itemClass); + if (normalizedClass.includes("service")) return "service"; + if (normalizedClass.includes("installation")) return "installation"; + if (normalizedClass.includes("activation")) return "activation"; + if (normalizedClass.includes("add-on") || normalizedClass.includes("addon")) return "addon"; + return "other"; +}; + +const coerceNumber = (value: unknown): number => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + return 0; +}; + +const buildOrderItemId = (group: OrderItemGroup, fallbackIndex: number): string => { + const identifiers = group.items + .map(item => item.productId || item.sku || item.name) + .filter((id): id is string => typeof id === "string" && id.length > 0); + if (identifiers.length === 0) { + return `order-item-${fallbackIndex}`; + } + return identifiers.join("|"); +}; + +const aggregateCharges = (group: OrderItemGroup): OrderDisplayItemCharge[] => { + const accumulator = new Map(); + + for (const item of group.items) { + const amount = coerceNumber(item.totalPrice ?? item.unitPrice); + const normalizedCycle = normalizeBillingCycle(item.billingCycle ?? undefined); + + let kind: OrderDisplayItemChargeKind = "other"; + let key = "other"; + let label = item.billingCycle?.trim() || "Billing"; + let suffix: string | undefined; + + if (normalizedCycle === "monthly") { + kind = "monthly"; + key = "monthly"; + label = "Monthly"; + suffix = MONTHLY_SUFFIX; + } else if (normalizedCycle === "onetime" || normalizedCycle === "free") { + kind = "one-time"; + key = "one-time"; + label = "One-time"; + } else if (typeof item.billingCycle === "string" && item.billingCycle.length > 0) { + key = `other:${item.billingCycle.toLowerCase()}`; + } + + const existing = accumulator.get(key); + if (existing) { + existing.amount += amount; + } else { + accumulator.set(key, { kind, amount, label, suffix }); + } + } + + return Array.from(accumulator.values()) + .map(({ kind, amount, label, suffix }) => ({ kind, amount, label, suffix })) + .sort((a, b) => { + const orderDiff = CHARGE_ORDER[a.kind] - CHARGE_ORDER[b.kind]; + if (orderDiff !== 0) return orderDiff; + return a.label.localeCompare(b.label); + }); +}; + +const buildGroupName = (group: OrderItemGroup): string => { + const monthlyItem = group.items.find( + item => normalizeBillingCycle(item.billingCycle ?? undefined) === "monthly" + ); + const fallbackItem = monthlyItem ?? group.items[0]; + return fallbackItem?.productName || fallbackItem?.name || "Service item"; +}; + +const determinePrimaryCategory = (group: OrderItemGroup): OrderDisplayItemCategory => { + const categories = group.items.map(resolveCategory); + for (const preferred of CATEGORY_ORDER) { + if (categories.includes(preferred)) { + return preferred; + } + } + return "other"; +}; + +const collectCategories = (group: OrderItemGroup): OrderDisplayItemCategory[] => { + const unique = new Set(); + group.items.forEach(item => unique.add(resolveCategory(item))); + return Array.from(unique); +}; + +const isIncludedGroup = (charges: OrderDisplayItemCharge[]): boolean => + charges.every(charge => charge.amount <= 0); + +const buildOrderItemGroup = (items: OrderItemSummary[]): OrderItemGroup[] => { + const groups: OrderItemGroup[] = []; + const usedIndices = new Set(); + const productIndex = new Map(); + + items.forEach((item, index) => { + const key = item.productId || item.sku; + if (!key) return; + const existing = productIndex.get(key); + if (existing) { + existing.push(index); + } else { + productIndex.set(key, [index]); + } + }); + + items.forEach((item, index) => { + if (usedIndices.has(index)) { + return; + } + + if (item.isBundledAddon && item.bundledAddonId) { + const partnerCandidates = productIndex.get(item.bundledAddonId) ?? []; + const partnerIndex = partnerCandidates.find(candidate => candidate !== index && !usedIndices.has(candidate)); + if (typeof partnerIndex === "number") { + const partner = items[partnerIndex]; + if (partner) { + const orderedIndices = partnerIndex < index ? [partnerIndex, index] : [index, partnerIndex]; + const groupItems = orderedIndices.map(i => items[i]); + groups.push({ indices: orderedIndices, items: groupItems }); + usedIndices.add(index); + usedIndices.add(partnerIndex); + return; + } + } + } + + groups.push({ indices: [index], items: [item] }); + usedIndices.add(index); + }); + + return groups.sort((a, b) => a.indices[0] - b.indices[0]); +}; + +export function buildOrderDisplayItems( + items: OrderItemSummary[] | null | undefined +): OrderDisplayItem[] { + if (!Array.isArray(items) || items.length === 0) { + return []; + } + + const groups = buildOrderItemGroup(items); + + return groups.map((group, groupIndex) => { + const charges = aggregateCharges(group); + return { + id: buildOrderItemId(group, groupIndex), + name: buildGroupName(group), + quantity: + group.items.length === 1 ? group.items[0]?.quantity ?? undefined : undefined, + status: group.items.length === 1 ? group.items[0]?.status ?? undefined : undefined, + primaryCategory: determinePrimaryCategory(group), + categories: collectCategories(group), + charges, + included: isIncludedGroup(charges), + sourceItems: group.items, + isBundle: group.items.length > 1, + }; + }); +} + +export function summarizeOrderDisplayItems( + items: OrderDisplayItem[], + fallback: string +): string { + if (items.length === 0) { + return fallback; + } + + const [primary, ...rest] = items; + if (rest.length === 0) { + return primary.name; + } + + return `${primary.name} +${rest.length} more`; +} + + diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index 8c5f9ad5..978c4998 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { PageLayout } from "@/components/templates/PageLayout"; import { @@ -21,13 +21,19 @@ import { import { StatusPill } from "@/components/atoms/status-pill"; import { Button } from "@/components/atoms/button"; import { ordersService } from "@/features/orders/services/orders.service"; +import { useOrderUpdates, type OrderUpdateEventPayload } from "@/features/orders/hooks/useOrderUpdates"; import { calculateOrderTotals, deriveOrderStatusDescriptor, getServiceCategory, - normalizeBillingCycle, } from "@/features/orders/utils/order-presenters"; -import type { OrderDetails, OrderItemSummary } from "@customer-portal/domain/orders"; +import { + buildOrderDisplayItems, + type OrderDisplayItem, + type OrderDisplayItemCategory, + type OrderDisplayItemCharge, +} from "@/features/orders/utils/order-display"; +import type { OrderDetails } from "@customer-portal/domain/orders"; import { cn } from "@/lib/utils/cn"; const STATUS_PILL_VARIANT: Record< @@ -53,98 +59,62 @@ const renderServiceIcon = (category: ReturnType, clas } }; -type ItemPresentationType = "service" | "installation" | "addon" | "activation" | "other"; - -const ITEM_THEMES: Record< - ItemPresentationType, +const CATEGORY_CONFIG: Record< + OrderDisplayItemCategory, { - container: string; - icon: React.ComponentType>; - iconStyles: string; - tagStyles: string; - priceStyles: string; - typeLabel: string; + icon: typeof SparklesIcon; + badgeClass: string; + label: string; } > = { service: { - container: "border-blue-100 bg-blue-50", icon: SparklesIcon, - iconStyles: "bg-blue-100 text-blue-600", - tagStyles: "bg-blue-100 text-blue-700", - priceStyles: "text-blue-700", - typeLabel: "Service", + badgeClass: "bg-blue-50 text-blue-700", + label: "Service", }, installation: { - container: "border-emerald-100 bg-emerald-50", icon: WrenchScrewdriverIcon, - iconStyles: "bg-emerald-100 text-emerald-600", - tagStyles: "bg-emerald-100 text-emerald-700", - priceStyles: "text-emerald-700", - typeLabel: "Installation", + badgeClass: "bg-emerald-50 text-emerald-700", + label: "Installation", }, addon: { - container: "border-indigo-100 bg-indigo-50", icon: PuzzlePieceIcon, - iconStyles: "bg-indigo-100 text-indigo-600", - tagStyles: "bg-indigo-100 text-indigo-700", - priceStyles: "text-indigo-700", - typeLabel: "Add-on", + badgeClass: "bg-violet-50 text-violet-700", + label: "Add-on", }, activation: { - container: "border-amber-100 bg-amber-50", icon: BoltIcon, - iconStyles: "bg-amber-100 text-amber-600", - tagStyles: "bg-amber-100 text-amber-700", - priceStyles: "text-amber-700", - typeLabel: "Activation", + badgeClass: "bg-amber-50 text-amber-700", + label: "Activation", }, other: { - container: "border-slate-200 bg-slate-50", icon: Squares2X2Icon, - iconStyles: "bg-slate-200 text-slate-600", - tagStyles: "bg-slate-200 text-slate-700", - priceStyles: "text-slate-700", - typeLabel: "Item", + badgeClass: "bg-slate-100 text-slate-600", + label: "Item", }, }; -interface PresentedItem { - id: string; - name: string; - type: ItemPresentationType; - billingLabel: string; - billingSuffix: string | null; - quantityLabel: string | null; - price: number; - statusLabel?: string; - sku?: string; -} +const STATUS_ACCENT_TONE_CLASSES = { + success: "bg-green-400/80", + info: "bg-blue-400/80", + warning: "bg-amber-400/80", + neutral: "bg-slate-200", +} as const; -const determineItemType = (item: OrderItemSummary): ItemPresentationType => { - const candidates = [item.itemClass, item.status, item.productName, item.name].map( - value => value?.toLowerCase() ?? "" - ); +const describeCharge = (charge: OrderDisplayItemCharge): string => { + if (typeof charge.suffix === "string" && charge.suffix.trim().length > 0) { + return charge.suffix.trim(); + } - if (candidates.some(value => value.includes("install"))) { - return "installation"; + if (charge.kind === "one-time") { + return "one-time"; } - if (candidates.some(value => value.includes("add-on") || value.includes("addon"))) { - return "addon"; - } - if (candidates.some(value => value.includes("activation"))) { - return "activation"; - } - if (candidates.some(value => value.includes("service") || value.includes("plan"))) { - return "service"; - } - return "other"; -}; -const formatBillingLabel = (billingCycle?: string | null) => { - const normalized = normalizeBillingCycle(billingCycle ?? undefined); - if (normalized === "monthly") return { label: "Monthly", suffix: "/ month" }; - if (normalized === "onetime") return { label: "One-time", suffix: null }; - return { label: "Billing", suffix: null }; + if (charge.kind === "monthly") { + return "/ month"; + } + + return charge.label.toLowerCase(); }; const yenFormatter = new Intl.NumberFormat("ja-JP", { @@ -159,6 +129,8 @@ export function OrderDetailContainer() { const [data, setData] = useState(null); const [error, setError] = useState(null); const isNewOrder = searchParams.get("status") === "success"; + const activeControllerRef = useRef(null); + const isMountedRef = useRef(true); const statusDescriptor = data ? deriveOrderStatusDescriptor({ @@ -174,28 +146,12 @@ export function OrderDetailContainer() { const serviceCategory = getServiceCategory(data?.orderType); const serviceIcon = renderServiceIcon(serviceCategory, "h-6 w-6"); + const accentTone = statusDescriptor + ? STATUS_ACCENT_TONE_CLASSES[statusDescriptor.tone] + : STATUS_ACCENT_TONE_CLASSES.neutral; - const categorizedItems = useMemo(() => { - if (!data?.itemsSummary) return []; - - return data.itemsSummary.map((item, index) => { - const type = determineItemType(item); - const billing = formatBillingLabel(item.billingCycle); - const quantityLabel = - typeof item.quantity === "number" && item.quantity > 1 ? `×${item.quantity}` : null; - - return { - id: item.sku || item.name || `item-${index}`, - name: item.productName || item.name || "Service item", - type, - billingLabel: billing.label, - billingSuffix: billing.suffix, - quantityLabel, - price: item.totalPrice ?? 0, - statusLabel: item.status || undefined, - sku: item.sku, - }; - }); + const displayItems = useMemo(() => { + return buildOrderDisplayItems(data?.itemsSummary); }, [data?.itemsSummary]); const totals = useMemo( @@ -235,29 +191,65 @@ export function OrderDetailContainer() { } }, [data?.orderType, serviceCategory]); - const showFeeNotice = categorizedItems.some( - item => item.type === "installation" || item.type === "activation" + const showFeeNotice = displayItems.some(item => + item.categories.includes("installation") || item.categories.includes("activation") ); + const fetchOrder = useCallback(async (): Promise => { + if (!params.id) { + return; + } + + if (activeControllerRef.current) { + activeControllerRef.current.abort(); + } + + const controller = new AbortController(); + activeControllerRef.current = controller; + + try { + const order = await ordersService.getOrderById(params.id, { signal: controller.signal }); + if (!isMountedRef.current || controller.signal.aborted) { + return; + } + setData(order ?? null); + setError(null); + } catch (e) { + if (!isMountedRef.current || controller.signal.aborted) { + return; + } + setError(e instanceof Error ? e.message : "Failed to load order"); + } finally { + if (activeControllerRef.current === controller) { + activeControllerRef.current = null; + } + } + }, [params.id]); + useEffect(() => { - let mounted = true; - const fetchStatus = async () => { - try { - const order = await ordersService.getOrderById(params.id); - if (mounted) setData(order || null); - } catch (e) { - if (mounted) setError(e instanceof Error ? e.message : "Failed to load order"); + isMountedRef.current = true; + void fetchOrder(); + + return () => { + isMountedRef.current = false; + if (activeControllerRef.current) { + activeControllerRef.current.abort(); + activeControllerRef.current = null; } }; - void fetchStatus(); - const interval = setInterval(() => { - void fetchStatus(); - }, 5000); - return () => { - mounted = false; - clearInterval(interval); - }; - }, [params.id]); + }, [fetchOrder]); + + const handleOrderUpdate = useCallback( + (event: OrderUpdateEventPayload) => { + if (!params.id || event.orderId !== params.id) { + return; + } + void fetchOrder(); + }, + [fetchOrder, params.id] + ); + + useOrderUpdates(params.id, handleOrderUpdate); return ( -
+
+
-
+
{serviceIcon}
-
+

{serviceLabel}

-

+

Order #{data.orderNumber || String(data.id).slice(-8)} {placedDate ? ` • Placed ${placedDate}` : null}

-
- {totals.monthlyTotal > 0 && ( -
+
+
+ {totals.monthlyTotal > 0 ? (

{yenFormatter.format(totals.monthlyTotal)} + / month

-

- per month + ) : ( +

No monthly charges

+ )} + {totals.oneTimeTotal > 0 && ( +

+ {yenFormatter.format(totals.oneTimeTotal)} one-time

-
- )} - {totals.oneTimeTotal > 0 && ( -

- {yenFormatter.format(totals.oneTimeTotal)} one-time -

- )} + )} +
@@ -346,83 +345,90 @@ export function OrderDetailContainer() {

Your Services & Products

-
- {categorizedItems.length === 0 ? ( -
+
+ {displayItems.length === 0 ? ( +
No items found on this order.
) : ( - categorizedItems.map(item => { - const theme = ITEM_THEMES[item.type]; - const Icon = theme.icon; - const isIncluded = item.price <= 0; + displayItems.map(item => { + const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other; + const Icon = categoryConfig.icon; + const categories = Array.from(new Set(item.categories)); return (
-
+

{item.name}

- {item.sku && ( - - SKU {item.sku} + {typeof item.quantity === "number" && item.quantity > 1 && ( + ×{item.quantity} + )} + {item.status && ( + + {item.status} )}
-
- - {item.billingLabel} - - - {theme.typeLabel} - - {item.quantityLabel && ( - {item.quantityLabel} - )} - {isIncluded && ( - - Included +
+ {categories.map(category => { + const badge = CATEGORY_CONFIG[category] ?? CATEGORY_CONFIG.other; + return ( + + {badge.label} + + ); + })} + {item.isBundle && ( + + Bundle )} - {item.statusLabel && ( - - {item.statusLabel} + {item.included && ( + + Included )}
-
-

- {isIncluded ? "No additional cost" : yenFormatter.format(item.price)} -

- {!isIncluded && item.billingSuffix && ( -

{item.billingSuffix}

- )} +
+ {item.charges.map((charge, index) => { + const descriptor = describeCharge(charge); + if (charge.amount > 0) { + return ( +
+ + {yenFormatter.format(charge.amount)} + + {descriptor} +
+ ); + } + + return ( +
+ Included + {descriptor} +
+ ); + })}
); @@ -432,15 +438,15 @@ export function OrderDetailContainer() {
{showFeeNotice && ( -
+

Additional fees may apply

- Weekend installation (+¥3,000), express setup, or specialised configuration - work can add extra costs. We'll always confirm with you before applying - any additional charges. + Weekend installation, express setup, or specialised configuration work can + add extra costs. We'll always confirm with you before applying any + additional charges.

@@ -448,17 +454,17 @@ export function OrderDetailContainer() { )} {statusDescriptor && ( -
+
-
-

+

+

Status

-

+

{statusDescriptor.description}

{statusDescriptor.timeline && ( -

+

Timeline: {statusDescriptor.timeline}

@@ -467,7 +473,7 @@ export function OrderDetailContainer() {
{statusDescriptor.nextAction && ( -
+

Next steps

{statusDescriptor.nextAction}

@@ -487,3 +493,4 @@ export function OrderDetailContainer() { } export default OrderDetailContainer; + diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index f2536256..ded4fec0 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -13,8 +13,53 @@ export * from "./response-helpers"; // Import createClient for internal use import { createClient } from "./runtime/client"; +import { logger } from "@/lib/logger"; -export const apiClient = createClient(); +/** + * Global error handler for API client + * Handles authentication errors and triggers logout when needed + */ +async function handleApiError(response: Response): Promise { + // Don't import useAuthStore at module level to avoid circular dependencies + // We'll handle auth errors by dispatching a custom event that the auth system can listen to + + if (response.status === 401) { + logger.warn("Received 401 Unauthorized response - triggering logout"); + + // Dispatch a custom event that the auth system will listen to + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:unauthorized", { + detail: { url: response.url, status: response.status } + })); + } + } + + // Still throw the error so the calling code can handle it + let body: unknown; + let message = response.statusText || `Request failed with status ${response.status}`; + + try { + const cloned = response.clone(); + const contentType = cloned.headers.get("content-type"); + if (contentType?.includes("application/json")) { + body = await cloned.json(); + if (body && typeof body === "object" && "message" in body) { + const maybeMessage = (body as { message?: unknown }).message; + if (typeof maybeMessage === "string") { + message = maybeMessage; + } + } + } + } catch { + // Ignore body parse errors + } + + throw new (await import("./runtime/client")).ApiError(message, response, body); +} + +export const apiClient = createClient({ + handleError: handleApiError, +}); // Query keys for React Query - matching the expected structure export const queryKeys = { diff --git a/docs/orders/ORDER-STATUS-UPDATES-STRATEGY.md b/docs/orders/ORDER-STATUS-UPDATES-STRATEGY.md new file mode 100644 index 00000000..2f6ce757 --- /dev/null +++ b/docs/orders/ORDER-STATUS-UPDATES-STRATEGY.md @@ -0,0 +1,158 @@ +# Order Status Updates Strategy + +## Current Problem + +The frontend was polling order details every 5-15 seconds, causing: +- Unnecessary load on backend (Salesforce API calls, DB queries) +- High API costs +- Battery drain on mobile devices +- Network waste + +## Root Cause + +**Polling is a workaround for lack of real-time push notifications.** + +When the backend completes provisioning and updates Salesforce, the frontend has no way to know except by repeatedly asking "is it done yet?" + +## The Right Solution + +### Phase 1: Remove Aggressive Polling ✅ + +- Polling loop removed from `OrderDetail` (Nov 2025) +- Order details now fetched once on mount and whenever the SSE stream notifies of a change +- Manual refresh handled via SSE-triggered re-fetch; no background polling left in the page + +### Phase 2: Server-Sent Events ✅ (Implemented Nov 2025) + +**Architecture (now live):** + +``` +┌─────────────┐ ┌─────────────┐ ┌──────────────┐ +│ Salesforce │ │ BFF │ │ Frontend │ +│ │ │ │ │ │ +│ Platform │──Event─▶│ Provisioning│───SSE──│ OrderDetail │ +│ Event │ │ Processor │ │ Component │ +└─────────────┘ └─────────────┘ └──────────────┘ + │ + │ 1. Provisions WHMCS + │ 2. Updates Salesforce + │ 3. Publishes SSE event + ▼ +``` + +**Key pieces:** + +- `OrderEventsService` keeps per-order subscribers alive, sends heartbeats, and emits `order.update` events +- `GET /orders/:sfOrderId/events` exposes the SSE stream (guarded by the same JWT auth pipeline) +- `OrderFulfillmentOrchestrator` now publishes updates when Salesforce status flips to `Activating`, when provisioning succeeds, and when failures occur +- `useOrderUpdates` hook opens the SSE connection from the browser and triggers a re-fetch of order details when an update arrives +- `OrderDetail` now relies on SSE instead of timers (auto-updating as soon as the backend pushes a change) + +```apps/bff/src/modules/orders/services/order-events.service.ts +@Injectable() +export class OrderEventsService { + private readonly observers = new Map>(); + + subscribe(orderId: string): Observable { + return new Observable(subscriber => { + // ... connection bookkeeping + heartbeat ... + }); + } + + publish(orderId: string, update: OrderUpdateEvent): void { + const event = this.buildEvent("order.update", update); + currentObservers.forEach(observer => observer.next(event)); + } +} +``` + +```apps/portal/src/features/orders/views/OrderDetail.tsx +const fetchOrder = useCallback(async (): Promise => { + if (!params.id) { + return; + } + // ... fetch with AbortController + state updates ... +}, [params.id]); + +const handleOrderUpdate = useCallback( + (event: OrderUpdateEventPayload) => { + if (!params.id || event.orderId !== params.id) { + return; + } + void fetchOrder(); + }, + [fetchOrder, params.id] +); + +useOrderUpdates(params.id, handleOrderUpdate); +``` + +### Phase 3: Alternative - WebSockets (More Complex) + +WebSockets provide bidirectional communication but are more complex to implement and maintain: + +- Requires WebSocket server setup (socket.io or ws) +- More complex authentication +- Requires load balancer configuration (sticky sessions) +- Better for real-time chat, less ideal for status updates + +**SSE is simpler and sufficient for status updates.** + +## Implementation Priority + +1. ✅ Remove aggressive polling (Nov 2025) +2. ✅ Ship SSE-based order updates (Nov 2025) +3. ⏭️ Optional: Consider WebSockets only if you need bidirectional messaging + +## Benefits of SSE Approach + +✅ **Zero polling** - No unnecessary requests +✅ **Instant updates** - Frontend knows immediately when status changes +✅ **Simple protocol** - SSE is built into browsers, no library needed +✅ **Automatic reconnection** - Browsers handle reconnection automatically +✅ **Lower costs** - Fewer Salesforce API calls +✅ **Better UX** - Users see status changes in real-time +✅ **Less backend load** - No repeated queries + +## Migration Path + +### Week 1: Remove Polling +- [x] Remove aggressive 5-second polling +- [x] Document interim strategy + +### Week 2: Implement SSE Infrastructure +- [x] Create `OrderEventsService` in BFF +- [x] Expose SSE endpoint `GET /orders/:sfOrderId/events` +- [x] Publish fulfillment lifecycle events (activating, completed, failed) + +### Week 3: Frontend Integration +- [x] Create `useOrderUpdates` hook +- [x] Wire `OrderDetail` to SSE (no timers) +- [x] Auto-refetch details on push updates + +### Week 4: Post-launch Monitoring +- [ ] Add observability for SSE connection counts +- [ ] Track client error rates and reconnection attempts +- [ ] Review UX analytics after rollout + +## Testing Considerations + +- Test SSE connection drops and automatic reconnection +- Test multiple tabs subscribing to same order +- Test SSE with load balancer (ensure sticky sessions or shared pub/sub) +- Test browser compatibility (SSE supported in all modern browsers) +- Monitor SSE connection count and memory usage + +## References + +- [MDN: Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) +- [NestJS SSE Documentation](https://docs.nestjs.com/techniques/server-sent-events) +- Platform Events (Salesforce → BFF): Already implemented ✅ +- SSE (BFF → Frontend): Live in production build + +--- + +**Status**: Phase 1 & 2 Complete, Phase 3 (monitoring) in progress +**Last Updated**: November 2025 +**Owner**: Engineering Team + diff --git a/packages/domain/orders/providers/salesforce/field-map.ts b/packages/domain/orders/providers/salesforce/field-map.ts index c8548062..35314622 100644 --- a/packages/domain/orders/providers/salesforce/field-map.ts +++ b/packages/domain/orders/providers/salesforce/field-map.ts @@ -54,6 +54,8 @@ export interface SalesforceOrderFieldMap { internetOfferingType: keyof SalesforceProduct2WithPricebookEntries; internetPlanTier: keyof SalesforceProduct2WithPricebookEntries; vpnRegion: keyof SalesforceProduct2WithPricebookEntries; + bundledAddon: keyof SalesforceProduct2WithPricebookEntries; + isBundledAddon: keyof SalesforceProduct2WithPricebookEntries; }; } @@ -111,6 +113,8 @@ export const defaultSalesforceOrderFieldMap: SalesforceOrderFieldMap = { internetOfferingType: "Internet_Offering_Type__c", internetPlanTier: "Internet_Plan_Tier__c", vpnRegion: "VPN_Region__c", + bundledAddon: "Bundled_Addon__c", + isBundledAddon: "Is_Bundled_Addon__c", }, }; diff --git a/packages/domain/orders/providers/salesforce/mapper.ts b/packages/domain/orders/providers/salesforce/mapper.ts index b61b821f..6f21a10a 100644 --- a/packages/domain/orders/providers/salesforce/mapper.ts +++ b/packages/domain/orders/providers/salesforce/mapper.ts @@ -66,6 +66,8 @@ export function transformSalesforceOrderItem( ensureString(product[productFields.internetOfferingType]) ?? undefined, internetPlanTier: ensureString(product[productFields.internetPlanTier]) ?? undefined, vpnRegion: ensureString(product[productFields.vpnRegion]) ?? undefined, + isBundledAddon: coerceBoolean(product[productFields.isBundledAddon]), + bundledAddonId: ensureString(product[productFields.bundledAddon]) ?? undefined, } : undefined, }); @@ -76,12 +78,15 @@ export function transformSalesforceOrderItem( productName: details.product?.name, name: details.product?.name, sku: details.product?.sku, + productId: details.product?.id, status: undefined, billingCycle: details.billingCycle, itemClass: details.product?.itemClass, quantity: details.quantity, unitPrice: details.unitPrice, totalPrice: details.totalPrice, + isBundledAddon: details.product?.isBundledAddon, + bundledAddonId: details.product?.bundledAddonId, }, }; } @@ -194,3 +199,10 @@ function resolveWhmcsProductId(value: unknown): string | undefined { } return undefined; } + +function coerceBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") { + return value; + } + return undefined; +} diff --git a/packages/domain/orders/schema.ts b/packages/domain/orders/schema.ts index 9dd27679..6203bbd4 100644 --- a/packages/domain/orders/schema.ts +++ b/packages/domain/orders/schema.ts @@ -26,12 +26,15 @@ export const orderItemSummarySchema = z.object({ productName: z.string().optional(), name: z.string().optional(), sku: z.string().optional(), + productId: z.string().optional(), status: z.string().optional(), billingCycle: z.string().optional(), itemClass: z.string().optional(), quantity: z.number().int().min(0).optional(), unitPrice: z.number().optional(), totalPrice: z.number().optional(), + isBundledAddon: z.boolean().optional(), + bundledAddonId: z.string().optional(), }); // ============================================================================ @@ -54,6 +57,8 @@ export const orderItemDetailsSchema = z.object({ internetOfferingType: z.string().optional(), internetPlanTier: z.string().optional(), vpnRegion: z.string().optional(), + isBundledAddon: z.boolean().optional(), + bundledAddonId: z.string().optional(), }).optional(), });