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.
This commit is contained in:
barsa 2025-11-04 17:24:26 +09:00
parent 1dc8fbf36d
commit 5d011c87be
17 changed files with 1113 additions and 310 deletions

View File

@ -97,6 +97,8 @@ export class OrderFieldMapService {
product.internetOfferingType,
product.internetPlanTier,
product.vpnRegion,
product.bundledAddon,
product.isBundledAddon,
];
return unique([...base, ...additional]);

View File

@ -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<MessageEvent> {
return this.orderEvents.subscribe(params.sfOrderId);
}
// Note: Order provisioning has been moved to SalesforceProvisioningController
// This controller now focuses only on customer-facing order operations
}

View File

@ -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,

View File

@ -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<string, unknown>;
}
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<string, Set<InternalObserver>>();
subscribe(orderId: string): Observable<MessageEvent> {
return new Observable<MessageEvent>(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<InternalObserver>();
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<string, unknown>): MessageEvent {
return {
data: {
event,
data,
},
} satisfies MessageEvent;
}
}

View File

@ -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,6 +94,7 @@ export class OrderFulfillmentOrchestrator {
idempotencyKey,
});
try {
// Step 1: Validation (no rollback needed)
try {
context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest(
@ -101,6 +104,18 @@ export class OrderFulfillmentOrchestrator {
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) {
@ -141,10 +156,19 @@ export class OrderFulfillmentOrchestrator {
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({
@ -321,12 +358,15 @@ export class OrderFulfillmentOrchestrator {
stepsExecuted: fulfillmentResult.stepsExecuted,
stepsRolledBack: fulfillmentResult.stepsRolledBack,
});
throw new FulfillmentException(fulfillmentResult.error || "Fulfillment transaction failed", {
throw new FulfillmentException(
fulfillmentResult.error || "Fulfillment transaction failed",
{
sfOrderId,
idempotencyKey,
stepsExecuted: fulfillmentResult.stepsExecuted,
stepsRolledBack: fulfillmentResult.stepsRolledBack,
});
}
);
}
// Update context with results
@ -340,6 +380,19 @@ export class OrderFulfillmentOrchestrator {
});
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;
}
}
/**

View File

@ -109,6 +109,18 @@ export const useAuthStore = create<AuthState>()((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: {},

View File

@ -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 ?? [],
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)
<article
key={String(order.id)}
className={cn(
"group relative overflow-hidden rounded-2xl border bg-white p-5 shadow-sm transition-colors focus-visible:outline-none sm:p-6",
"group relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-7 shadow-sm transition-all focus-visible:outline-none",
isInteractive &&
"cursor-pointer hover:border-blue-300 focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100",
CARD_TONE_STYLES[statusDescriptor.tone],
"cursor-pointer hover:border-blue-200 hover:shadow-md focus-within:border-blue-300 focus-within:ring-2 focus-within:ring-blue-100",
className
)}
onClick={onClick}
@ -115,53 +125,83 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
role={isInteractive ? "button" : undefined}
tabIndex={isInteractive ? 0 : undefined}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<span
className={cn(
"pointer-events-none absolute inset-x-4 top-4 h-1 rounded-full",
STATUS_ACCENT_TONE_CLASSES[statusDescriptor.tone]
)}
aria-hidden
/>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-4">
<div
className={cn("flex h-12 w-12 items-center justify-center rounded-xl", iconStyles)}
className={cn(
"flex h-12 w-12 items-center justify-center rounded-xl border border-transparent",
iconStyles
)}
>
{serviceIcon}
</div>
<div className="min-w-0 space-y-2">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-2">{serviceSummary}</h3>
<h3 className="text-xl font-semibold text-gray-900 line-clamp-2">{serviceSummary}</h3>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
<span className="font-medium uppercase tracking-wide text-gray-400">
<span className="font-medium uppercase tracking-[0.14em] text-gray-400">
Order #{order.orderNumber || String(order.id).slice(-8)}
</span>
<span className="hidden text-gray-300 sm:inline"></span>
<span>
{formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"}
</span>
<span>{formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"}</span>
</div>
<p className="text-sm text-gray-600 line-clamp-2">{statusDescriptor.description}</p>
{displayItems.length > 1 && (
<div className="flex flex-wrap gap-2 pt-1 text-xs text-gray-500">
{displayItems.slice(1, 4).map(item => (
<span
key={item.id}
className="rounded-full bg-slate-100 px-2 py-0.5 font-medium text-slate-600"
>
{item.name}
</span>
))}
{displayItems.length > 4 && (
<span className="text-gray-400">+{displayItems.length - 4} more</span>
)}
</div>
)}
</div>
</div>
<div className="flex flex-col items-end gap-2">
<div className="flex flex-col items-end gap-3 text-right">
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
{showPricing && (
<div className="text-right">
{totals.monthlyTotal > 0 && (
<p className="text-lg font-semibold text-gray-900">
<div className="text-sm text-gray-500">
{showPricing ? (
<div className="space-y-1">
{totals.monthlyTotal > 0 ? (
<p className="text-2xl font-semibold text-gray-900">
¥{totals.monthlyTotal.toLocaleString()}
<span className="ml-1 text-xs font-medium text-gray-500">/ month</span>
</p>
) : (
<p className="text-sm font-semibold text-gray-500">No monthly charges</p>
)}
{totals.oneTimeTotal > 0 && (
<p className="text-xs font-semibold text-orange-600">
<p className="text-xs font-medium text-gray-500">
¥{totals.oneTimeTotal.toLocaleString()} one-time
</p>
)}
</div>
) : (
<p className="text-sm font-semibold text-gray-500">Included in plan</p>
)}
</div>
</div>
</div>
{(isInteractive || footer) && (
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-blue-600">
{isInteractive ? (
<span className="flex items-center gap-2 font-medium text-blue-600">
View order
<span className="flex items-center gap-2 font-medium">
View details
<ChevronRightIcon className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
</span>
) : (

View File

@ -2,23 +2,25 @@
export function OrderCardSkeleton() {
return (
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm sm:p-6">
<div className="animate-pulse space-y-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-7 shadow-sm">
<span className="pointer-events-none absolute inset-x-6 top-6 h-1 rounded-full bg-slate-200" aria-hidden />
<div className="animate-pulse space-y-6">
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-1 items-start gap-4">
<div className="h-12 w-12 rounded-xl bg-gray-100" />
<div className="h-12 w-12 rounded-xl bg-slate-100" />
<div className="flex-1 space-y-3">
<div className="h-5 w-3/4 rounded bg-gray-200" />
<div className="flex flex-wrap gap-3">
<div className="h-3 w-28 rounded bg-gray-200" />
<div className="h-3 w-24 rounded bg-gray-200" />
</div>
<div className="h-3 w-5/6 rounded bg-gray-200" />
<div className="h-6 w-3/4 rounded bg-slate-200" />
<div className="h-3 w-5/6 rounded bg-slate-200" />
<div className="flex flex-wrap gap-2">
<div className="h-4 w-24 rounded-full bg-slate-200" />
<div className="h-4 w-20 rounded-full bg-slate-200" />
<div className="h-4 w-16 rounded-full bg-slate-100" />
</div>
</div>
<div className="h-6 w-24 rounded-full bg-gray-200" />
</div>
<div className="h-4 w-32 rounded bg-gray-200" />
<div className="h-6 w-24 rounded-full bg-slate-200" />
</div>
<div className="h-4 w-32 rounded bg-slate-200" />
</div>
</div>
);

View File

@ -0,0 +1,95 @@
"use client";
import { useEffect, useRef } from "react";
import { resolveBaseUrl } from "@/lib/api";
import { logger } from "@/lib/logger";
export interface OrderStreamEvent<T extends string = string, P = unknown> {
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<string, unknown> | null;
}
type OrderUpdateHandler = (event: OrderUpdateEventPayload) => void;
export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpdateHandler) {
const handlerRef = useRef<OrderUpdateHandler>(onUpdate);
useEffect(() => {
handlerRef.current = onUpdate;
}, [onUpdate]);
useEffect(() => {
if (!orderId) {
return undefined;
}
let isCancelled = false;
let eventSource: EventSource | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | 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<string>) => {
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]);
}

View File

@ -27,9 +27,14 @@ async function getMyOrders(): Promise<OrderSummary[]> {
return data.map(item => orderSummarySchema.parse(item));
}
async function getOrderById(orderId: string): Promise<OrderDetails> {
type GetOrderByIdOptions = {
signal?: AbortSignal;
};
async function getOrderById(orderId: string, options: GetOrderByIdOptions = {}): Promise<OrderDetails> {
const response = await apiClient.GET("/api/orders/{sfOrderId}", {
params: { path: { sfOrderId: orderId } },
signal: options.signal,
});
if (!response.data) {
throw new Error("Order not found");

View File

@ -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<OrderDisplayItemChargeKind, number> = {
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<string, OrderDisplayItemCharge & { kind: OrderDisplayItemChargeKind }>();
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<OrderDisplayItemCategory>();
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<number>();
const productIndex = new Map<string, number[]>();
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`;
}

View File

@ -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<typeof getServiceCategory>, clas
}
};
type ItemPresentationType = "service" | "installation" | "addon" | "activation" | "other";
const ITEM_THEMES: Record<
ItemPresentationType,
const CATEGORY_CONFIG: Record<
OrderDisplayItemCategory,
{
container: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
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<OrderDetails | null>(null);
const [error, setError] = useState<string | null>(null);
const isNewOrder = searchParams.get("status") === "success";
const activeControllerRef = useRef<AbortController | null>(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<PresentedItem[]>(() => {
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<OrderDisplayItem[]>(() => {
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")
);
useEffect(() => {
let mounted = true;
const fetchStatus = async () => {
const fetchOrder = useCallback(async (): Promise<void> => {
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);
if (mounted) setData(order || null);
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 (mounted) setError(e instanceof Error ? e.message : "Failed to load order");
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(() => {
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 (
<PageLayout
@ -308,121 +300,135 @@ export function OrderDetailContainer() {
{data ? (
<>
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-8 shadow-sm sm:px-8">
<span
className={cn(
"pointer-events-none absolute inset-x-6 top-6 h-1 rounded-full",
accentTone
)}
aria-hidden
/>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
<div className="flex flex-1 items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-50 text-blue-600">
{serviceIcon}
</div>
<div>
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-gray-900">{serviceLabel}</h2>
<p className="mt-1 text-sm text-gray-500">
<p className="text-sm text-gray-500">
Order #{data.orderNumber || String(data.id).slice(-8)}
{placedDate ? ` • Placed ${placedDate}` : null}
</p>
</div>
</div>
<div className="text-right">
{totals.monthlyTotal > 0 && (
<div>
<div className="text-right text-sm text-gray-500">
<div className="space-y-1">
{totals.monthlyTotal > 0 ? (
<p className="text-2xl font-semibold text-gray-900">
{yenFormatter.format(totals.monthlyTotal)}
<span className="ml-1 text-xs font-medium text-gray-500">/ month</span>
</p>
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">
per month
</p>
</div>
) : (
<p className="text-sm font-semibold text-gray-500">No monthly charges</p>
)}
{totals.oneTimeTotal > 0 && (
<p className="mt-2 text-sm font-semibold text-orange-600">
<p className="text-xs font-medium text-gray-500">
{yenFormatter.format(totals.oneTimeTotal)} one-time
</p>
)}
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Your Services &amp; Products
</p>
<div className="mt-3 space-y-3">
{categorizedItems.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-200 bg-gray-50 px-4 py-6 text-center text-sm text-gray-500">
<div className="mt-4 space-y-3">
{displayItems.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-gray-500">
No items found on this order.
</div>
) : (
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 (
<div
key={item.id}
className={cn(
"flex flex-col gap-3 rounded-2xl border px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6",
theme.container
)}
className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 px-4 py-4 shadow-sm sm:flex-row sm:items-center sm:justify-between sm:gap-6"
>
<div className="flex flex-1 items-start gap-3">
<div
className={cn(
"flex h-11 w-11 items-center justify-center rounded-xl",
theme.iconStyles
)}
>
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-baseline gap-2">
<p className="text-base font-semibold text-gray-900">{item.name}</p>
{item.sku && (
<span className="text-xs font-medium text-gray-400">
SKU {item.sku}
{typeof item.quantity === "number" && item.quantity > 1 && (
<span className="text-xs font-medium text-gray-500">×{item.quantity}</span>
)}
{item.status && (
<span className="text-[11px] uppercase tracking-wide text-gray-400">
{item.status}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="font-semibold text-gray-500">
{item.billingLabel}
</span>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
{categories.map(category => {
const badge = CATEGORY_CONFIG[category] ?? CATEGORY_CONFIG.other;
return (
<span
key={`${item.id}-${category}`}
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
theme.tagStyles
badge.badgeClass
)}
>
{theme.typeLabel}
{badge.label}
</span>
);
})}
{item.isBundle && (
<span className="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700">
Bundle
</span>
{item.quantityLabel && (
<span className="text-gray-400">{item.quantityLabel}</span>
)}
{isIncluded && (
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[11px] font-medium text-gray-500">
{item.included && (
<span className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
Included
</span>
)}
{item.statusLabel && (
<span className="text-[11px] font-medium text-gray-400">
{item.statusLabel}
</div>
</div>
</div>
<div className="flex flex-col items-start gap-1 text-sm text-gray-600 sm:items-end">
{item.charges.map((charge, index) => {
const descriptor = describeCharge(charge);
if (charge.amount > 0) {
return (
<div key={`${item.id}-charge-${index}`} className="flex items-baseline gap-1">
<span className="text-base font-semibold text-gray-900">
{yenFormatter.format(charge.amount)}
</span>
)}
<span className="text-xs font-medium text-gray-500">{descriptor}</span>
</div>
</div>
</div>
<div className="text-left sm:text-right">
<p
className={cn(
"text-base font-semibold",
isIncluded ? "text-gray-500" : theme.priceStyles
)}
);
}
return (
<div
key={`${item.id}-charge-${index}`}
className="flex items-baseline gap-1 text-sm font-medium text-gray-500"
>
{isIncluded ? "No additional cost" : yenFormatter.format(item.price)}
</p>
{!isIncluded && item.billingSuffix && (
<p className="text-xs text-gray-500">{item.billingSuffix}</p>
)}
Included
<span className="text-xs text-gray-400">{descriptor}</span>
</div>
);
})}
</div>
</div>
);
@ -432,15 +438,15 @@ export function OrderDetailContainer() {
</div>
{showFeeNotice && (
<div className="mt-2 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-amber-900">
<div className="rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-amber-900">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-500" />
<div className="space-y-1">
<p className="text-sm font-semibold">Additional fees may apply</p>
<p className="text-xs leading-relaxed">
Weekend installation (+¥3,000), express setup, or specialised configuration
work can add extra costs. We&apos;ll always confirm with you before applying
any additional charges.
Weekend installation, express setup, or specialised configuration work can
add extra costs. We&apos;ll always confirm with you before applying any
additional charges.
</p>
</div>
</div>
@ -448,17 +454,17 @@ export function OrderDetailContainer() {
)}
{statusDescriptor && (
<div className="mt-3 rounded-2xl border border-gray-100 bg-gray-50 p-5">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Status
</p>
<p className="mt-1 text-lg font-semibold text-gray-900">
<p className="text-lg font-semibold text-gray-900">
{statusDescriptor.description}
</p>
{statusDescriptor.timeline && (
<p className="mt-2 text-sm text-gray-600">
<p className="text-sm text-gray-600">
<span className="font-medium text-gray-700">Timeline: </span>
{statusDescriptor.timeline}
</p>
@ -467,7 +473,7 @@ export function OrderDetailContainer() {
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
</div>
{statusDescriptor.nextAction && (
<div className="mt-4 rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3">
<div className="mt-4 rounded-2xl border border-blue-100 bg-white p-4">
<p className="text-sm font-semibold text-blue-900">Next steps</p>
<p className="mt-1 text-sm text-blue-800">{statusDescriptor.nextAction}</p>
</div>
@ -487,3 +493,4 @@ export function OrderDetailContainer() {
}
export default OrderDetailContainer;

View File

@ -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<void> {
// 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 = {

View File

@ -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<string, Set<InternalObserver>>();
subscribe(orderId: string): Observable<MessageEvent> {
return new Observable<MessageEvent>(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<void> => {
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

View File

@ -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",
},
};

View File

@ -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;
}

View File

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