Enhance BFF Modules and Update Domain Schemas
- Added new modules for SIM management, internet management, and call history to the BFF, improving service organization and modularity. - Updated environment validation schema to reflect changes in Salesforce event channels, ensuring accurate configuration. - Refactored router configuration to include new subscription-related modules, enhancing API routing clarity. - Cleaned up Salesforce integration by removing unused service files and optimizing event handling logic. - Improved support service by adding cache invalidation logic for case comments, ensuring real-time updates for users. - Updated domain schemas to remove deprecated fields and enhance validation for residence card verification, promoting data integrity. - Enhanced utility functions in the portal for better address formatting and confirmation prompts, improving user experience.
This commit is contained in:
parent
a699080610
commit
a41f2bf47e
@ -34,6 +34,9 @@ import { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
|
||||
import { BillingModule } from "@bff/modules/billing/billing.module.js";
|
||||
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
|
||||
import { SimManagementModule } from "@bff/modules/subscriptions/sim-management/sim-management.module.js";
|
||||
import { InternetManagementModule } from "@bff/modules/subscriptions/internet-management/internet-management.module.js";
|
||||
import { CallHistoryModule } from "@bff/modules/subscriptions/call-history/call-history.module.js";
|
||||
import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
|
||||
import { SupportModule } from "@bff/modules/support/support.module.js";
|
||||
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
|
||||
@ -88,6 +91,9 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
|
||||
OrdersModule,
|
||||
BillingModule,
|
||||
SubscriptionsModule,
|
||||
SimManagementModule,
|
||||
InternetManagementModule,
|
||||
CallHistoryModule,
|
||||
CurrencyModule,
|
||||
SupportModule,
|
||||
RealtimeApiModule,
|
||||
|
||||
@ -111,16 +111,16 @@ export const envSchema = z.object({
|
||||
// Default ON: the portal relies on Salesforce events for real-time cache invalidation.
|
||||
SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("true"),
|
||||
SF_CATALOG_EVENT_CHANNEL: z.string().default("/event/Product_and_Pricebook_Change__e"),
|
||||
SF_ACCOUNT_EVENT_CHANNEL: z.string().default("/event/Account_Internet_Eligibility_Update__e"),
|
||||
SF_ACCOUNT_EVENT_CHANNEL: z.string().default("/event/Account_Eligibility_Update__e"),
|
||||
SF_CASE_EVENT_CHANNEL: z.string().default("/event/Case_Status_Update__e"),
|
||||
SF_EVENTS_REPLAY: z.enum(["LATEST", "ALL"]).default("LATEST"),
|
||||
SF_PUBSUB_NUM_REQUESTED: z.string().default("25"),
|
||||
SF_PUBSUB_QUEUE_MAX: z.string().default("100"),
|
||||
SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"),
|
||||
|
||||
// CDC-specific channels (using /data/ prefix for Change Data Capture)
|
||||
// CDC channels (using /data/ prefix for Change Data Capture)
|
||||
SF_CATALOG_PRODUCT_CDC_CHANNEL: z.string().default("/data/Product2ChangeEvent"),
|
||||
SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL: z.string().default("/data/PricebookEntryChangeEvent"),
|
||||
SF_ACCOUNT_ELIGIBILITY_CHANNEL: z.string().optional(),
|
||||
SF_ORDER_CDC_CHANNEL: z.string().default("/data/OrderChangeEvent"),
|
||||
SF_ORDER_ITEM_CDC_CHANNEL: z.string().default("/data/OrderItemChangeEvent"),
|
||||
|
||||
|
||||
@ -7,6 +7,9 @@ import { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
|
||||
import { BillingModule } from "@bff/modules/billing/billing.module.js";
|
||||
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
|
||||
import { SimManagementModule } from "@bff/modules/subscriptions/sim-management/sim-management.module.js";
|
||||
import { InternetManagementModule } from "@bff/modules/subscriptions/internet-management/internet-management.module.js";
|
||||
import { CallHistoryModule } from "@bff/modules/subscriptions/call-history/call-history.module.js";
|
||||
import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
|
||||
import { SecurityModule } from "@bff/core/security/security.module.js";
|
||||
import { SupportModule } from "@bff/modules/support/support.module.js";
|
||||
@ -26,6 +29,9 @@ export const apiRoutes: Routes = [
|
||||
{ path: "", module: OrdersModule },
|
||||
{ path: "", module: BillingModule },
|
||||
{ path: "", module: SubscriptionsModule },
|
||||
{ path: "", module: SimManagementModule },
|
||||
{ path: "", module: InternetManagementModule },
|
||||
{ path: "", module: CallHistoryModule },
|
||||
{ path: "", module: CurrencyModule },
|
||||
{ path: "", module: SupportModule },
|
||||
{ path: "", module: SecurityModule },
|
||||
|
||||
@ -39,8 +39,6 @@ export const ACCOUNT_FIELDS = {
|
||||
status: "Internet_Eligibility_Status__c",
|
||||
requestedAt: "Internet_Eligibility_Request_Date_Time__c",
|
||||
checkedAt: "Internet_Eligibility_Checked_Date_Time__c",
|
||||
notes: "Internet_Eligibility_Notes__c",
|
||||
caseId: "Internet_Eligibility_Case_Id__c",
|
||||
},
|
||||
|
||||
// ID verification
|
||||
|
||||
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Salesforce Account Platform Events Subscriber
|
||||
*
|
||||
* Handles real-time Account Platform Events for:
|
||||
* - Eligibility status changes
|
||||
* - Verification status changes
|
||||
*
|
||||
* When events are received:
|
||||
* 1. Invalidate Redis caches (eligibility + verification)
|
||||
* 2. Send SSE to connected portal clients
|
||||
* 3. Create in-app notifications (for final status changes)
|
||||
*
|
||||
* @see docs/integrations/salesforce/platform-events.md
|
||||
*/
|
||||
|
||||
import { Injectable, Inject, Optional } from "@nestjs/common";
|
||||
import type { OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
PubSubClientService,
|
||||
isDataCallback,
|
||||
extractPayload,
|
||||
extractStringField,
|
||||
} from "./shared/index.js";
|
||||
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
|
||||
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||
import { AccountNotificationHandler } from "@bff/modules/notifications/account-cdc-listener.service.js";
|
||||
|
||||
@Injectable()
|
||||
export class AccountEventsSubscriber implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly pubSubClient: PubSubClientService,
|
||||
private readonly servicesCache: ServicesCacheService,
|
||||
private readonly realtime: RealtimeService,
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
@Optional() private readonly accountNotificationHandler?: AccountNotificationHandler
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const channel = this.config.get<string>("SF_ACCOUNT_EVENT_CHANNEL")?.trim();
|
||||
|
||||
if (!channel) {
|
||||
this.logger.warn("SF_ACCOUNT_EVENT_CHANNEL not configured; skipping account events");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.pubSubClient.subscribe(
|
||||
channel,
|
||||
this.handleAccountEvent.bind(this, channel),
|
||||
"account-platform-event"
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to subscribe to Account Platform Events", {
|
||||
channel,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Account Platform Events (eligibility + verification updates)
|
||||
*
|
||||
* Salesforce Flow fires this event when these fields change:
|
||||
* - Internet_Eligibility__c, Internet_Eligibility_Status__c
|
||||
* - Id_Verification_Status__c, Id_Verification_Rejection_Message__c
|
||||
*
|
||||
* Platform Event fields:
|
||||
* - Account_Id__c (required)
|
||||
* - Eligibility_Status__c (only if changed)
|
||||
* - Verification_Status__c (only if changed)
|
||||
* - Rejection_Message__c (when rejected)
|
||||
*
|
||||
* Actions:
|
||||
* - ALWAYS invalidate both caches
|
||||
* - ALWAYS send SSE to portal
|
||||
* - Create notification only for final states (eligible/verified/rejected)
|
||||
*/
|
||||
private async handleAccountEvent(
|
||||
channel: string,
|
||||
subscription: { topicName?: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
): Promise<void> {
|
||||
if (!isDataCallback(callbackType)) return;
|
||||
|
||||
const payload = extractPayload(data);
|
||||
const accountId = extractStringField(payload, ["Account_Id__c"]);
|
||||
|
||||
if (!accountId) {
|
||||
this.logger.warn("Account event missing Account_Id__c", { channel });
|
||||
return;
|
||||
}
|
||||
|
||||
const eligibilityStatus = extractStringField(payload, ["Eligibility_Status__c"]);
|
||||
const verificationStatus = extractStringField(payload, ["Verification_Status__c"]);
|
||||
const rejectionMessage = extractStringField(payload, ["Rejection_Message__c"]);
|
||||
|
||||
this.logger.log("Account platform event received", {
|
||||
channel,
|
||||
accountIdTail: accountId.slice(-4),
|
||||
hasEligibilityStatus: eligibilityStatus !== undefined,
|
||||
hasVerificationStatus: verificationStatus !== undefined,
|
||||
});
|
||||
|
||||
// ALWAYS invalidate caches
|
||||
await this.servicesCache.invalidateEligibility(accountId);
|
||||
await this.servicesCache.invalidateVerification(accountId);
|
||||
|
||||
// ALWAYS notify portal to refetch
|
||||
this.realtime.publish(`account:sf:${accountId}`, "account.updated", {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Create notifications for status changes (handler filters to final states)
|
||||
if (this.accountNotificationHandler && (eligibilityStatus || verificationStatus)) {
|
||||
void this.accountNotificationHandler.processAccountEvent({
|
||||
accountId,
|
||||
eligibilityStatus,
|
||||
eligibilityValue: undefined,
|
||||
verificationStatus,
|
||||
verificationRejectionMessage: rejectionMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Salesforce Case Platform Events Subscriber
|
||||
*
|
||||
* Handles real-time Case Platform Events for:
|
||||
* - Case status updates
|
||||
*
|
||||
* Uses POKE approach: invalidate cache + SSE → portal refetches
|
||||
*
|
||||
* @see docs/integrations/salesforce/platform-events.md
|
||||
*/
|
||||
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import type { OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
PubSubClientService,
|
||||
isDataCallback,
|
||||
extractPayload,
|
||||
extractStringField,
|
||||
} from "./shared/index.js";
|
||||
import { SupportCacheService } from "@bff/modules/support/support-cache.service.js";
|
||||
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||
|
||||
@Injectable()
|
||||
export class CaseEventsSubscriber implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly pubSubClient: PubSubClientService,
|
||||
private readonly supportCache: SupportCacheService,
|
||||
private readonly realtime: RealtimeService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const channel = this.config.get<string>("SF_CASE_EVENT_CHANNEL")?.trim();
|
||||
|
||||
if (!channel) {
|
||||
this.logger.debug("SF_CASE_EVENT_CHANNEL not configured; skipping case events");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.pubSubClient.subscribe(
|
||||
channel,
|
||||
this.handleCaseEvent.bind(this, channel),
|
||||
"case-platform-event"
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to subscribe to Case Platform Events", {
|
||||
channel,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Case Platform Events (Case_Status_Update__e)
|
||||
*
|
||||
* Required fields:
|
||||
* - Case_Id__c
|
||||
* - Account_Id__c
|
||||
*/
|
||||
private async handleCaseEvent(
|
||||
channel: string,
|
||||
subscription: { topicName?: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
): Promise<void> {
|
||||
if (!isDataCallback(callbackType)) return;
|
||||
|
||||
const payload = extractPayload(data);
|
||||
const caseId = extractStringField(payload, ["Case_Id__c"]);
|
||||
const accountId = extractStringField(payload, ["Account_Id__c"]);
|
||||
|
||||
if (!caseId) {
|
||||
this.logger.warn("Case event missing Case_Id__c", { channel });
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log("Case platform event received", {
|
||||
channel,
|
||||
caseIdTail: caseId.slice(-4),
|
||||
accountIdTail: accountId?.slice(-4),
|
||||
});
|
||||
|
||||
await this.supportCache.invalidateCaseMessages(caseId);
|
||||
|
||||
if (accountId) {
|
||||
await this.supportCache.invalidateCaseList(accountId);
|
||||
|
||||
this.realtime.publish(`account:sf:${accountId}`, "support.case.changed", {
|
||||
caseId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Salesforce Catalog CDC (Change Data Capture) Subscriber
|
||||
*
|
||||
* Handles real-time Product2 and PricebookEntry changes from Salesforce.
|
||||
*
|
||||
* When catalog data changes:
|
||||
* 1. Invalidate relevant Redis caches
|
||||
* 2. Send SSE to connected portal clients to refetch
|
||||
*
|
||||
* @see docs/integrations/salesforce/platform-events.md
|
||||
*/
|
||||
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import type { OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
PubSubClientService,
|
||||
isDataCallback,
|
||||
extractPayload,
|
||||
extractStringField,
|
||||
extractRecordIds,
|
||||
} from "./shared/index.js";
|
||||
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
|
||||
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||
|
||||
@Injectable()
|
||||
export class CatalogCdcSubscriber implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly pubSubClient: PubSubClientService,
|
||||
private readonly catalogCache: ServicesCacheService,
|
||||
private readonly realtime: RealtimeService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const enabled = this.config.get("SF_EVENTS_ENABLED", "true") === "true";
|
||||
|
||||
if (!enabled) {
|
||||
this.logger.debug("Catalog CDC disabled (SF_EVENTS_ENABLED=false)");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.subscribeToProductCdc();
|
||||
await this.subscribeToPricebookCdc();
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to initialize Catalog CDC subscriber", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Subscription Setup
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async subscribeToProductCdc(): Promise<void> {
|
||||
const channel =
|
||||
this.config.get<string>("SF_CATALOG_PRODUCT_CDC_CHANNEL")?.trim() ||
|
||||
"/data/Product2ChangeEvent";
|
||||
|
||||
await this.pubSubClient.subscribe(
|
||||
channel,
|
||||
this.handleProductEvent.bind(this, channel),
|
||||
"product-cdc"
|
||||
);
|
||||
}
|
||||
|
||||
private async subscribeToPricebookCdc(): Promise<void> {
|
||||
const channel =
|
||||
this.config.get<string>("SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL")?.trim() ||
|
||||
"/data/PricebookEntryChangeEvent";
|
||||
|
||||
await this.pubSubClient.subscribe(
|
||||
channel,
|
||||
this.handlePricebookEvent.bind(this, channel),
|
||||
"pricebook-cdc"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CDC Handlers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async handleProductEvent(
|
||||
channel: string,
|
||||
subscription: { topicName?: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
): Promise<void> {
|
||||
if (!isDataCallback(callbackType)) return;
|
||||
|
||||
const payload = extractPayload(data);
|
||||
const productIds = extractRecordIds(payload);
|
||||
|
||||
this.logger.log("Product2 CDC event received", {
|
||||
channel,
|
||||
topicName: subscription.topicName,
|
||||
productIds,
|
||||
});
|
||||
|
||||
const invalidated = await this.catalogCache.invalidateProducts(productIds);
|
||||
|
||||
if (!invalidated) {
|
||||
this.logger.debug(
|
||||
"No catalog entries linked to product IDs; falling back to full invalidation",
|
||||
{
|
||||
channel,
|
||||
productIds,
|
||||
}
|
||||
);
|
||||
await this.invalidateAllServices();
|
||||
this.realtime.publish("global:services", "services.changed", {
|
||||
reason: "product.cdc.fallback_full_invalidation",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.realtime.publish("global:services", "services.changed", {
|
||||
reason: "product.cdc",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private async handlePricebookEvent(
|
||||
channel: string,
|
||||
subscription: { topicName?: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
): Promise<void> {
|
||||
if (!isDataCallback(callbackType)) return;
|
||||
|
||||
const payload = extractPayload(data);
|
||||
const pricebookId = extractStringField(payload, ["PricebookId", "Pricebook2Id"]);
|
||||
|
||||
// Ignore events for non-portal pricebooks
|
||||
const portalPricebookId = this.config.get<string>("PORTAL_PRICEBOOK_ID");
|
||||
if (portalPricebookId && pricebookId && pricebookId !== portalPricebookId) {
|
||||
this.logger.debug("Ignoring pricebook event for non-portal pricebook", {
|
||||
channel,
|
||||
pricebookId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const productId = extractStringField(payload, ["Product2Id", "ProductId"]);
|
||||
|
||||
this.logger.log("PricebookEntry CDC event received", {
|
||||
channel,
|
||||
pricebookId,
|
||||
productId,
|
||||
});
|
||||
|
||||
const invalidated = await this.catalogCache.invalidateProducts(productId ? [productId] : []);
|
||||
|
||||
if (!invalidated) {
|
||||
this.logger.debug("No catalog entries linked to pricebook product; full invalidation", {
|
||||
channel,
|
||||
pricebookId,
|
||||
productId,
|
||||
});
|
||||
await this.invalidateAllServices();
|
||||
this.realtime.publish("global:services", "services.changed", {
|
||||
reason: "pricebook.cdc.fallback_full_invalidation",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.realtime.publish("global:services", "services.changed", {
|
||||
reason: "pricebook.cdc",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private async invalidateAllServices(): Promise<void> {
|
||||
try {
|
||||
await this.catalogCache.invalidateAllServices();
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to invalidate services caches", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,33 @@
|
||||
/**
|
||||
* Salesforce Events Module
|
||||
*
|
||||
* Provides real-time event handling for Salesforce Platform Events and CDC.
|
||||
*
|
||||
* Platform Events (/event/ channels):
|
||||
* - AccountEventsSubscriber: Eligibility/verification status updates
|
||||
* - CaseEventsSubscriber: Support case status updates
|
||||
*
|
||||
* CDC - Change Data Capture (/data/ channels):
|
||||
* - CatalogCdcSubscriber: Product2/PricebookEntry changes
|
||||
* - OrderCdcSubscriber: Order/OrderItem changes
|
||||
*
|
||||
* Shared:
|
||||
* - PubSubClientService: Salesforce Pub/Sub API client management
|
||||
*
|
||||
* @see docs/integrations/salesforce/platform-events.md
|
||||
*/
|
||||
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
|
||||
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
|
||||
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
import { ServicesCdcSubscriber } from "./services-cdc.subscriber.js";
|
||||
import { SupportModule } from "@bff/modules/support/support.module.js";
|
||||
import { PubSubClientService } from "./shared/index.js";
|
||||
import { AccountEventsSubscriber } from "./account-events.subscriber.js";
|
||||
import { CaseEventsSubscriber } from "./case-events.subscriber.js";
|
||||
import { CatalogCdcSubscriber } from "./catalog-cdc.subscriber.js";
|
||||
import { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
|
||||
|
||||
@Module({
|
||||
@ -14,10 +37,17 @@ import { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
|
||||
forwardRef(() => OrdersModule),
|
||||
forwardRef(() => ServicesModule),
|
||||
forwardRef(() => NotificationsModule),
|
||||
forwardRef(() => SupportModule),
|
||||
],
|
||||
providers: [
|
||||
ServicesCdcSubscriber, // CDC for services cache invalidation + notifications
|
||||
OrderCdcSubscriber, // CDC for order cache invalidation
|
||||
PubSubClientService,
|
||||
// Platform Events
|
||||
AccountEventsSubscriber,
|
||||
CaseEventsSubscriber,
|
||||
// CDC
|
||||
CatalogCdcSubscriber,
|
||||
OrderCdcSubscriber,
|
||||
],
|
||||
exports: [PubSubClientService],
|
||||
})
|
||||
export class SalesforceEventsModule {}
|
||||
|
||||
23
apps/bff/src/integrations/salesforce/events/index.ts
Normal file
23
apps/bff/src/integrations/salesforce/events/index.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Salesforce Events - Public API
|
||||
*/
|
||||
|
||||
// Module
|
||||
export { SalesforceEventsModule } from "./events.module.js";
|
||||
|
||||
// Platform Events
|
||||
export { AccountEventsSubscriber } from "./account-events.subscriber.js";
|
||||
export { CaseEventsSubscriber } from "./case-events.subscriber.js";
|
||||
|
||||
// CDC
|
||||
export { CatalogCdcSubscriber } from "./catalog-cdc.subscriber.js";
|
||||
export { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
|
||||
|
||||
// Shared
|
||||
export {
|
||||
PubSubClientService,
|
||||
type PubSubCallback,
|
||||
type PubSubClient,
|
||||
type PubSubClientConstructor,
|
||||
type ChangeEventHeader,
|
||||
} from "./shared/index.js";
|
||||
@ -1,371 +1,198 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
|
||||
import { SalesforceConnection } from "../services/salesforce-connection.service.js";
|
||||
import { OrdersCacheService } from "@bff/modules/orders/services/orders-cache.service.js";
|
||||
import { ProvisioningQueueService } from "@bff/modules/orders/queue/provisioning.queue.js";
|
||||
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||
|
||||
type PubSubCallback = (
|
||||
subscription: { topicName?: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
) => void | Promise<void>;
|
||||
|
||||
interface PubSubClient {
|
||||
connect(): Promise<void>;
|
||||
subscribe(topic: string, cb: PubSubCallback, numRequested?: number): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
type PubSubCtor = new (opts: {
|
||||
authType: string;
|
||||
accessToken: string;
|
||||
instanceUrl: string;
|
||||
pubSubEndpoint: string;
|
||||
}) => PubSubClient;
|
||||
|
||||
/**
|
||||
* CDC Subscriber for Order changes
|
||||
* Salesforce Order CDC (Change Data Capture) Subscriber
|
||||
*
|
||||
* Handles real-time Order and OrderItem changes from Salesforce.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Trigger provisioning when Salesforce sets Activation_Status__c to "Activating"
|
||||
* 2. Only invalidate cache for customer-facing field changes, NOT internal system fields
|
||||
*
|
||||
* CUSTOMER-FACING FIELDS (invalidate cache):
|
||||
* - Status (Draft, Pending Review, Completed, Cancelled)
|
||||
* - TotalAmount
|
||||
* - BillingAddress, BillingCity, etc.
|
||||
* - Customer-visible custom fields
|
||||
* Customer-facing fields (invalidate cache):
|
||||
* - Status, TotalAmount, BillingAddress, etc.
|
||||
*
|
||||
* INTERNAL SYSTEM FIELDS (ignore - updated by fulfillment):
|
||||
* - Activation_Status__c (Activating, Activated, Failed)
|
||||
* - WHMCS_Order_ID__c
|
||||
* - Activation_Error_Code__c
|
||||
* - Activation_Error_Message__c
|
||||
* - Activation_Last_Attempt_At__c
|
||||
* - WHMCS_Service_ID__c (on OrderItem)
|
||||
* Internal system fields (ignore):
|
||||
* - Activation_Status__c, WHMCS_Order_ID__c, Activation_Error_*
|
||||
*
|
||||
* WHY: The fulfillment flow already invalidates cache when it completes.
|
||||
* Why: Fulfillment flow already invalidates cache on completion.
|
||||
* CDC should only catch external changes made by admins in Salesforce UI.
|
||||
*
|
||||
* @see docs/integrations/salesforce/platform-events.md
|
||||
*/
|
||||
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import type { OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
PubSubClientService,
|
||||
isDataCallback,
|
||||
extractPayload,
|
||||
extractStringField,
|
||||
extractChangeEventHeader,
|
||||
extractChangedFields,
|
||||
} from "./shared/index.js";
|
||||
import { OrdersCacheService } from "@bff/modules/orders/services/orders-cache.service.js";
|
||||
import { ProvisioningQueueService } from "@bff/modules/orders/queue/provisioning.queue.js";
|
||||
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||
|
||||
/** Fields updated by internal fulfillment process - ignore for cache invalidation */
|
||||
const INTERNAL_ORDER_FIELDS = new Set([
|
||||
"Activation_Status__c",
|
||||
"WHMCS_Order_ID__c",
|
||||
"Activation_Error_Code__c",
|
||||
"Activation_Error_Message__c",
|
||||
"Activation_Last_Attempt_At__c",
|
||||
"ActivatedDate",
|
||||
]);
|
||||
|
||||
/** Internal OrderItem fields - ignore for cache invalidation */
|
||||
const INTERNAL_ORDER_ITEM_FIELDS = new Set(["WHMCS_Service_ID__c"]);
|
||||
|
||||
/** Statuses that trigger provisioning */
|
||||
const PROVISION_TRIGGER_STATUSES = new Set(["Approved", "Reactivate"]);
|
||||
|
||||
@Injectable()
|
||||
export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
private client: PubSubClient | null = null;
|
||||
private pubSubCtor: PubSubCtor | null = null;
|
||||
private orderChannel: string | null = null;
|
||||
private orderItemChannel: string | null = null;
|
||||
private readonly numRequested: number;
|
||||
|
||||
// Internal fields that are updated by fulfillment process - ignore these
|
||||
private readonly INTERNAL_FIELDS = new Set([
|
||||
"Activation_Status__c",
|
||||
"WHMCS_Order_ID__c",
|
||||
"Activation_Error_Code__c",
|
||||
"Activation_Error_Message__c",
|
||||
"Activation_Last_Attempt_At__c",
|
||||
"ActivatedDate",
|
||||
]);
|
||||
|
||||
// Internal OrderItem fields - ignore these
|
||||
private readonly INTERNAL_ORDER_ITEM_FIELDS = new Set(["WHMCS_Service_ID__c"]);
|
||||
|
||||
// Statuses that trigger provisioning
|
||||
private readonly PROVISION_TRIGGER_STATUSES = new Set(["Approved", "Reactivate"]);
|
||||
|
||||
export class OrderCdcSubscriber implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly sfConnection: SalesforceConnection,
|
||||
private readonly pubSubClient: PubSubClientService,
|
||||
private readonly ordersCache: OrdersCacheService,
|
||||
private readonly provisioningQueue: ProvisioningQueueService,
|
||||
private readonly realtime: RealtimeService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {
|
||||
this.numRequested = this.resolveNumRequested();
|
||||
}
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const enabled = this.config.get("SF_EVENTS_ENABLED", "false") === "true";
|
||||
if (!enabled) {
|
||||
this.logger.debug("Salesforce CDC for orders is disabled (SF_EVENTS_ENABLED=false)");
|
||||
this.logger.debug("Order CDC disabled (SF_EVENTS_ENABLED=false)");
|
||||
return;
|
||||
}
|
||||
|
||||
const orderChannel =
|
||||
try {
|
||||
await this.subscribeToOrderCdc();
|
||||
await this.subscribeToOrderItemCdc();
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to initialize Order CDC subscriber", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Subscription Setup
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async subscribeToOrderCdc(): Promise<void> {
|
||||
const channel =
|
||||
this.config.get<string>("SF_ORDER_CDC_CHANNEL")?.trim() || "/data/OrderChangeEvent";
|
||||
const orderItemChannel =
|
||||
|
||||
await this.pubSubClient.subscribe(
|
||||
channel,
|
||||
this.handleOrderEvent.bind(this, channel),
|
||||
"order-cdc"
|
||||
);
|
||||
}
|
||||
|
||||
private async subscribeToOrderItemCdc(): Promise<void> {
|
||||
const channel =
|
||||
this.config.get<string>("SF_ORDER_ITEM_CDC_CHANNEL")?.trim() || "/data/OrderItemChangeEvent";
|
||||
|
||||
this.logger.log("Initializing Salesforce Order CDC subscriber", {
|
||||
orderChannel,
|
||||
orderItemChannel,
|
||||
});
|
||||
|
||||
try {
|
||||
const client = await this.ensureClient();
|
||||
|
||||
this.orderChannel = orderChannel;
|
||||
await this.subscribeWithDiagnostics(
|
||||
client,
|
||||
orderChannel,
|
||||
this.handleOrderEvent.bind(this, orderChannel),
|
||||
"order"
|
||||
);
|
||||
|
||||
this.orderItemChannel = orderItemChannel;
|
||||
await this.subscribeWithDiagnostics(
|
||||
client,
|
||||
orderItemChannel,
|
||||
this.handleOrderItemEvent.bind(this, orderItemChannel),
|
||||
"order_item"
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to initialize order CDC subscriber", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
try {
|
||||
await this.client.close();
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to close Order CDC subscriber cleanly", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureClient(): Promise<PubSubClient> {
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
const ctor = this.loadPubSubCtor();
|
||||
|
||||
await this.sfConnection.connect();
|
||||
const accessToken = this.sfConnection.getAccessToken();
|
||||
const instanceUrl = this.sfConnection.getInstanceUrl();
|
||||
|
||||
if (!accessToken || !instanceUrl) {
|
||||
throw new Error("Salesforce access token or instance URL missing for CDC subscriber");
|
||||
}
|
||||
|
||||
const pubSubEndpoint =
|
||||
this.config.get<string>("SF_PUBSUB_ENDPOINT") || "api.pubsub.salesforce.com:7443";
|
||||
|
||||
const client = new ctor({
|
||||
authType: "user-supplied",
|
||||
accessToken,
|
||||
instanceUrl,
|
||||
pubSubEndpoint,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
this.client = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
private async subscribeWithDiagnostics(
|
||||
client: PubSubClient,
|
||||
channel: string,
|
||||
handler: PubSubCallback,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
this.logger.log("Attempting Salesforce CDC subscription", {
|
||||
await this.pubSubClient.subscribe(
|
||||
channel,
|
||||
label,
|
||||
numRequested: this.numRequested,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.subscribe(channel, handler, this.numRequested);
|
||||
this.logger.log("Successfully subscribed to Salesforce CDC channel", {
|
||||
channel,
|
||||
label,
|
||||
numRequested: this.numRequested,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("Salesforce CDC subscription failed", {
|
||||
channel,
|
||||
label,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
this.handleOrderItemEvent.bind(this, channel),
|
||||
"order-item-cdc"
|
||||
);
|
||||
}
|
||||
|
||||
private loadPubSubCtor(): PubSubCtor {
|
||||
if (this.pubSubCtor) {
|
||||
return this.pubSubCtor;
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Order CDC Handler
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const maybeCtor = (PubSubApiClientPkg as unknown as PubSubCtor) ?? null;
|
||||
const maybeDefault = (PubSubApiClientPkg as { default?: PubSubCtor }).default ?? null;
|
||||
const ctor = typeof maybeCtor === "function" ? maybeCtor : maybeDefault;
|
||||
|
||||
if (!ctor) {
|
||||
throw new Error("Failed to load Salesforce Pub/Sub client constructor");
|
||||
}
|
||||
|
||||
this.pubSubCtor = ctor;
|
||||
return this.pubSubCtor;
|
||||
}
|
||||
|
||||
private resolveNumRequested(): number {
|
||||
const raw = this.config.get<string>("SF_PUBSUB_NUM_REQUESTED") ?? "25";
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
this.logger.warn("Invalid SF_PUBSUB_NUM_REQUESTED value; defaulting to 25", {
|
||||
rawValue: raw,
|
||||
});
|
||||
return 25;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Order CDC events
|
||||
* Only invalidate cache if customer-facing fields changed
|
||||
*/
|
||||
private async handleOrderEvent(
|
||||
channel: string,
|
||||
subscription: { topicName?: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
): Promise<void> {
|
||||
if (!this.isDataCallback(callbackType)) return;
|
||||
if (!isDataCallback(callbackType)) return;
|
||||
|
||||
const payload = this.extractPayload(data);
|
||||
const header = payload ? this.extractChangeEventHeader(payload) : undefined;
|
||||
const entityName =
|
||||
this.extractStringField(payload, ["entityName"]) ||
|
||||
(typeof header?.entityName === "string" ? header.entityName : undefined);
|
||||
const changeType =
|
||||
this.extractStringField(payload, ["changeType"]) ||
|
||||
(typeof header?.changeType === "string" ? header.changeType : undefined);
|
||||
const changedFields = this.extractChangedFields(payload);
|
||||
const payload = extractPayload(data);
|
||||
const header = payload ? extractChangeEventHeader(payload) : undefined;
|
||||
const changedFields = extractChangedFields(payload);
|
||||
|
||||
// Extract Order ID
|
||||
let orderId = this.extractStringField(payload, ["Id", "OrderId"]);
|
||||
if (!orderId && header && Array.isArray(header.recordIds) && header.recordIds.length > 0) {
|
||||
const firstId = header.recordIds.find(
|
||||
(value): value is string => typeof value === "string" && value.trim().length > 0
|
||||
);
|
||||
if (firstId) {
|
||||
orderId = firstId.trim();
|
||||
}
|
||||
}
|
||||
const accountId = this.extractStringField(payload, ["AccountId"]);
|
||||
const orderId = this.extractOrderId(payload, header);
|
||||
const accountId = extractStringField(payload, ["AccountId"]);
|
||||
|
||||
if (!orderId) {
|
||||
this.logger.warn("Order CDC event missing Order ID; skipping", {
|
||||
channel,
|
||||
entityName,
|
||||
changeType,
|
||||
});
|
||||
this.logger.warn("Order CDC event missing Order ID; skipping", { channel });
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. CHECK FOR PROVISIONING TRIGGER (Activation status change)
|
||||
// Check for provisioning trigger (Activation_Status__c change)
|
||||
if (payload && changedFields.has("Activation_Status__c")) {
|
||||
await this.handleActivationStatusChange(payload, orderId);
|
||||
}
|
||||
|
||||
// 2. CACHE INVALIDATION (existing logic)
|
||||
// Filter: Only invalidate if customer-facing fields changed
|
||||
// Cache invalidation - only for customer-facing field changes
|
||||
const hasCustomerFacingChange = this.hasCustomerFacingChanges(changedFields);
|
||||
|
||||
if (!hasCustomerFacingChange) {
|
||||
this.logger.debug(
|
||||
"Order CDC event contains only internal field changes; skipping cache invalidation",
|
||||
{
|
||||
channel,
|
||||
orderId,
|
||||
changedFields: Array.from(changedFields),
|
||||
}
|
||||
);
|
||||
this.logger.debug("Order CDC: only internal field changes; skipping cache invalidation", {
|
||||
channel,
|
||||
orderId,
|
||||
changedFields: Array.from(changedFields),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log("Order CDC event received with customer-facing changes, invalidating cache", {
|
||||
this.logger.log("Order CDC: invalidating cache for customer-facing changes", {
|
||||
channel,
|
||||
orderId,
|
||||
accountId,
|
||||
changedFields: Array.from(changedFields),
|
||||
});
|
||||
|
||||
try {
|
||||
// Invalidate specific order cache
|
||||
await this.ordersCache.invalidateOrder(orderId);
|
||||
|
||||
// Invalidate account's order list cache
|
||||
if (accountId) {
|
||||
await this.ordersCache.invalidateAccountOrders(accountId);
|
||||
}
|
||||
|
||||
// Notify portals for instant refresh (account-scoped + order-scoped)
|
||||
const timestamp = new Date().toISOString();
|
||||
if (accountId) {
|
||||
this.realtime.publish(`account:sf:${accountId}`, "orders.changed", {
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
this.realtime.publish(`orders:sf:${orderId}`, "order.cdc.changed", {
|
||||
timestamp,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to invalidate order cache from CDC event", {
|
||||
orderId,
|
||||
accountId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
await this.invalidateOrderCaches(orderId, accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Activation_Status__c changes and trigger provisioning when Salesforce moves an order to "Activating"
|
||||
* Handle Activation_Status__c changes - trigger provisioning when status is "Activating"
|
||||
*/
|
||||
private async handleActivationStatusChange(
|
||||
payload: Record<string, unknown>,
|
||||
orderId: string
|
||||
): Promise<void> {
|
||||
const activationStatus = this.extractStringField(payload, ["Activation_Status__c"]);
|
||||
const status = this.extractStringField(payload, ["Status"]);
|
||||
const whmcsOrderId = this.extractStringField(payload, ["WHMCS_Order_ID__c"]);
|
||||
const activationStatus = extractStringField(payload, ["Activation_Status__c"]);
|
||||
const status = extractStringField(payload, ["Status"]);
|
||||
const whmcsOrderId = extractStringField(payload, ["WHMCS_Order_ID__c"]);
|
||||
|
||||
if (activationStatus !== "Activating") {
|
||||
this.logger.debug("Activation status changed but not to Activating; skipping provisioning", {
|
||||
this.logger.debug("Activation status not 'Activating'; skipping provisioning", {
|
||||
orderId,
|
||||
activationStatus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (status && !this.PROVISION_TRIGGER_STATUSES.has(status)) {
|
||||
this.logger.debug(
|
||||
"Activation status set to Activating but order status is not a provisioning trigger",
|
||||
{
|
||||
orderId,
|
||||
activationStatus,
|
||||
status,
|
||||
}
|
||||
);
|
||||
if (status && !PROVISION_TRIGGER_STATUSES.has(status)) {
|
||||
this.logger.debug("Order status not a provisioning trigger", {
|
||||
orderId,
|
||||
activationStatus,
|
||||
status,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (whmcsOrderId) {
|
||||
this.logger.log("Order already has WHMCS Order ID, skipping provisioning", {
|
||||
this.logger.log("Order already has WHMCS Order ID; skipping provisioning", {
|
||||
orderId,
|
||||
whmcsOrderId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log("Order activation moved to Activating via CDC, enqueuing fulfillment", {
|
||||
this.logger.log("Enqueuing provisioning for order activation via CDC", {
|
||||
orderId,
|
||||
activationStatus,
|
||||
status,
|
||||
@ -380,46 +207,42 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
this.logger.log("Successfully enqueued provisioning job from activation change", {
|
||||
orderId,
|
||||
activationStatus,
|
||||
status,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to enqueue provisioning job from activation change", {
|
||||
this.logger.error("Failed to enqueue provisioning job from CDC", {
|
||||
orderId,
|
||||
activationStatus,
|
||||
status,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OrderItem CDC events
|
||||
* Only invalidate if customer-facing fields changed
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// OrderItem CDC Handler
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async handleOrderItemEvent(
|
||||
channel: string,
|
||||
subscription: { topicName?: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
): Promise<void> {
|
||||
if (!this.isDataCallback(callbackType)) return;
|
||||
if (!isDataCallback(callbackType)) return;
|
||||
|
||||
const payload = this.extractPayload(data);
|
||||
const changedFields = this.extractChangedFields(payload);
|
||||
const payload = extractPayload(data);
|
||||
const changedFields = extractChangedFields(payload);
|
||||
|
||||
const orderId = extractStringField(payload, ["OrderId"]);
|
||||
const accountId = extractStringField(payload, ["AccountId"]);
|
||||
|
||||
const orderId = this.extractStringField(payload, ["OrderId"]);
|
||||
const accountId = this.extractStringField(payload, ["AccountId"]);
|
||||
if (!orderId) {
|
||||
this.logger.warn("OrderItem CDC event missing OrderId; skipping", { channel });
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter: Only invalidate if customer-facing fields changed
|
||||
const hasCustomerFacingChange = this.hasCustomerFacingOrderItemChanges(changedFields);
|
||||
|
||||
if (!hasCustomerFacingChange) {
|
||||
this.logger.debug("OrderItem CDC event contains only internal field changes; skipping", {
|
||||
this.logger.debug("OrderItem CDC: only internal field changes; skipping", {
|
||||
channel,
|
||||
orderId,
|
||||
changedFields: Array.from(changedFields),
|
||||
@ -427,30 +250,35 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log("OrderItem CDC event received, invalidating order cache", {
|
||||
this.logger.log("OrderItem CDC: invalidating order cache", {
|
||||
channel,
|
||||
orderId,
|
||||
changedFields: Array.from(changedFields),
|
||||
});
|
||||
|
||||
try {
|
||||
await this.ordersCache.invalidateOrder(orderId);
|
||||
await this.invalidateOrderCaches(orderId, accountId);
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
if (accountId) {
|
||||
this.realtime.publish(`account:sf:${accountId}`, "orders.changed", {
|
||||
timestamp,
|
||||
});
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private extractOrderId(
|
||||
payload: Record<string, unknown> | undefined,
|
||||
header: ReturnType<typeof extractChangeEventHeader>
|
||||
): string | undefined {
|
||||
let orderId = extractStringField(payload, ["Id", "OrderId"]);
|
||||
|
||||
if (!orderId && header && Array.isArray(header.recordIds) && header.recordIds.length > 0) {
|
||||
const firstId = header.recordIds.find(
|
||||
(value): value is string => typeof value === "string" && value.trim().length > 0
|
||||
);
|
||||
if (firstId) {
|
||||
orderId = firstId.trim();
|
||||
}
|
||||
this.realtime.publish(`orders:sf:${orderId}`, "orderitem.cdc.changed", {
|
||||
timestamp,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to invalidate order cache from OrderItem CDC event", {
|
||||
orderId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return orderId;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -463,108 +291,46 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove internal fields from changed fields
|
||||
const customerFacingChanges = Array.from(changedFields).filter(
|
||||
field => !this.INTERNAL_FIELDS.has(field)
|
||||
field => !INTERNAL_ORDER_FIELDS.has(field)
|
||||
);
|
||||
|
||||
return customerFacingChanges.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if changed OrderItem fields include customer-facing fields
|
||||
*/
|
||||
private hasCustomerFacingOrderItemChanges(changedFields: Set<string>): boolean {
|
||||
if (changedFields.size === 0) {
|
||||
return true; // Safe default
|
||||
}
|
||||
|
||||
const customerFacingChanges = Array.from(changedFields).filter(
|
||||
field => !this.INTERNAL_ORDER_ITEM_FIELDS.has(field)
|
||||
field => !INTERNAL_ORDER_ITEM_FIELDS.has(field)
|
||||
);
|
||||
|
||||
return customerFacingChanges.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract changed field names from CDC payload
|
||||
* CDC payload includes changeOrigin with changedFields array
|
||||
*/
|
||||
private extractChangedFields(payload: Record<string, unknown> | undefined): Set<string> {
|
||||
if (!payload) return new Set();
|
||||
private async invalidateOrderCaches(orderId: string, accountId?: string): Promise<void> {
|
||||
try {
|
||||
await this.ordersCache.invalidateOrder(orderId);
|
||||
|
||||
const header = this.extractChangeEventHeader(payload);
|
||||
const headerChangedFields = Array.isArray(header?.changedFields)
|
||||
? (header?.changedFields as unknown[]).filter(
|
||||
(field): field is string => typeof field === "string" && field.length > 0
|
||||
)
|
||||
: [];
|
||||
|
||||
// CDC provides changed fields in different formats depending on API version
|
||||
// Try to extract from common locations
|
||||
const changedFieldsArray =
|
||||
(payload.changedFields as string[] | undefined) ||
|
||||
(payload.changeOrigin as { changedFields?: string[] })?.changedFields ||
|
||||
[];
|
||||
|
||||
return new Set([
|
||||
...headerChangedFields,
|
||||
...changedFieldsArray.filter(field => typeof field === "string" && field.length > 0),
|
||||
]);
|
||||
}
|
||||
|
||||
private isDataCallback(callbackType: string): boolean {
|
||||
const normalized = String(callbackType || "").toLowerCase();
|
||||
return normalized === "data" || normalized === "event";
|
||||
}
|
||||
|
||||
private extractPayload(data: unknown): Record<string, unknown> | undefined {
|
||||
if (!data || typeof data !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidate = data as { payload?: unknown };
|
||||
if (candidate.payload && typeof candidate.payload === "object") {
|
||||
return candidate.payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private extractStringField(
|
||||
payload: Record<string, unknown> | undefined,
|
||||
fieldNames: string[]
|
||||
): string | undefined {
|
||||
if (!payload) return undefined;
|
||||
for (const field of fieldNames) {
|
||||
const value = payload[field];
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
if (accountId) {
|
||||
await this.ordersCache.invalidateAccountOrders(accountId);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractChangeEventHeader(payload: Record<string, unknown>):
|
||||
| {
|
||||
changedFields?: unknown;
|
||||
recordIds?: unknown;
|
||||
entityName?: unknown;
|
||||
changeType?: unknown;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
if (accountId) {
|
||||
this.realtime.publish(`account:sf:${accountId}`, "orders.changed", { timestamp });
|
||||
}
|
||||
| undefined {
|
||||
const header = payload["ChangeEventHeader"];
|
||||
if (header && typeof header === "object") {
|
||||
return header as {
|
||||
changedFields?: unknown;
|
||||
recordIds?: unknown;
|
||||
entityName?: unknown;
|
||||
changeType?: unknown;
|
||||
};
|
||||
|
||||
this.realtime.publish(`orders:sf:${orderId}`, "order.cdc.changed", { timestamp });
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to invalidate order cache from CDC event", {
|
||||
orderId,
|
||||
accountId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,421 +0,0 @@
|
||||
import { Injectable, Inject, Optional } from "@nestjs/common";
|
||||
import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
|
||||
import { SalesforceConnection } from "../services/salesforce-connection.service.js";
|
||||
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
|
||||
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||
import { AccountNotificationHandler } from "@bff/modules/notifications/account-cdc-listener.service.js";
|
||||
|
||||
type PubSubCallback = (
|
||||
subscription: { topicName?: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
) => void | Promise<void>;
|
||||
|
||||
interface PubSubClient {
|
||||
connect(): Promise<void>;
|
||||
subscribe(topic: string, cb: PubSubCallback, numRequested?: number): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
type PubSubCtor = new (opts: {
|
||||
authType: string;
|
||||
accessToken: string;
|
||||
instanceUrl: string;
|
||||
pubSubEndpoint: string;
|
||||
}) => PubSubClient;
|
||||
|
||||
@Injectable()
|
||||
export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
private client: PubSubClient | null = null;
|
||||
private pubSubCtor: PubSubCtor | null = null;
|
||||
private productChannel: string | null = null;
|
||||
private pricebookChannel: string | null = null;
|
||||
private accountChannel: string | null = null;
|
||||
private readonly numRequested: number;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly sfConnection: SalesforceConnection,
|
||||
private readonly catalogCache: ServicesCacheService,
|
||||
private readonly realtime: RealtimeService,
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
@Optional() private readonly accountNotificationHandler?: AccountNotificationHandler
|
||||
) {
|
||||
this.numRequested = this.resolveNumRequested();
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// Catalog CDC subscriptions can be optionally disabled (high-volume).
|
||||
// Account eligibility updates are expected to be delivered via a Platform Event
|
||||
// and should always be subscribed (best UX + explicit payload).
|
||||
const cdcEnabled = this.config.get("SF_EVENTS_ENABLED", "true") === "true";
|
||||
|
||||
const productChannel =
|
||||
this.config.get<string>("SF_CATALOG_PRODUCT_CDC_CHANNEL")?.trim() ||
|
||||
"/data/Product2ChangeEvent";
|
||||
const pricebookChannel =
|
||||
this.config.get<string>("SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL")?.trim() ||
|
||||
"/data/PricebookEntryChangeEvent";
|
||||
// Always use Platform Event for eligibility updates.
|
||||
// Default is set in env schema: /event/Account_Internet_Eligibility_Update__e
|
||||
const accountChannel = this.config.get<string>("SF_ACCOUNT_EVENT_CHANNEL")!.trim();
|
||||
|
||||
try {
|
||||
const client = await this.ensureClient();
|
||||
|
||||
if (cdcEnabled) {
|
||||
this.productChannel = productChannel;
|
||||
await client.subscribe(
|
||||
productChannel,
|
||||
this.handleProductEvent.bind(this, productChannel),
|
||||
this.numRequested
|
||||
);
|
||||
this.logger.log("Subscribed to Product2 CDC channel", { productChannel });
|
||||
|
||||
this.pricebookChannel = pricebookChannel;
|
||||
await client.subscribe(
|
||||
pricebookChannel,
|
||||
this.handlePricebookEvent.bind(this, pricebookChannel),
|
||||
this.numRequested
|
||||
);
|
||||
this.logger.log("Subscribed to PricebookEntry CDC channel", { pricebookChannel });
|
||||
} else {
|
||||
this.logger.debug("Catalog CDC subscriptions disabled (SF_EVENTS_ENABLED=false)");
|
||||
}
|
||||
|
||||
this.accountChannel = accountChannel;
|
||||
await client.subscribe(
|
||||
accountChannel,
|
||||
this.handleAccountEvent.bind(this, accountChannel),
|
||||
this.numRequested
|
||||
);
|
||||
this.logger.log("Subscribed to account eligibility platform event channel", {
|
||||
accountChannel,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to initialize catalog CDC subscriber", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
try {
|
||||
await this.client.close();
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to close Salesforce CDC subscriber cleanly", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureClient(): Promise<PubSubClient> {
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
const ctor = await this.loadPubSubCtor();
|
||||
|
||||
await this.sfConnection.connect();
|
||||
const accessToken = this.sfConnection.getAccessToken();
|
||||
const instanceUrl = this.sfConnection.getInstanceUrl();
|
||||
|
||||
if (!accessToken || !instanceUrl) {
|
||||
throw new Error("Salesforce access token or instance URL missing for CDC subscriber");
|
||||
}
|
||||
|
||||
const pubSubEndpoint =
|
||||
this.config.get<string>("SF_PUBSUB_ENDPOINT") || "api.pubsub.salesforce.com:7443";
|
||||
|
||||
const client = new ctor({
|
||||
authType: "user-supplied",
|
||||
accessToken,
|
||||
instanceUrl,
|
||||
pubSubEndpoint,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
this.client = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
private loadPubSubCtor(): Promise<PubSubCtor> {
|
||||
if (!this.pubSubCtor) {
|
||||
const maybeCtor = (PubSubApiClientPkg as unknown as PubSubCtor) ?? null;
|
||||
const maybeDefault = (PubSubApiClientPkg as { default?: PubSubCtor }).default ?? null;
|
||||
const ctor = typeof maybeCtor === "function" ? maybeCtor : maybeDefault;
|
||||
if (!ctor) {
|
||||
throw new Error("Failed to load Salesforce Pub/Sub client constructor");
|
||||
}
|
||||
this.pubSubCtor = ctor;
|
||||
}
|
||||
return Promise.resolve(this.pubSubCtor);
|
||||
}
|
||||
|
||||
private resolveNumRequested(): number {
|
||||
const raw = this.config.get<string>("SF_PUBSUB_NUM_REQUESTED") ?? "25";
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
this.logger.warn("Invalid SF_PUBSUB_NUM_REQUESTED value; defaulting to 25", {
|
||||
rawValue: raw,
|
||||
});
|
||||
return 25;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private async handleProductEvent(
|
||||
channel: string,
|
||||
subscription: { topicName?: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
): Promise<void> {
|
||||
if (!this.isDataCallback(callbackType)) return;
|
||||
const payload = this.extractPayload(data);
|
||||
const productIds = this.extractRecordIds(payload);
|
||||
|
||||
this.logger.log("Product2 CDC event received", {
|
||||
channel,
|
||||
topicName: subscription.topicName,
|
||||
productIds,
|
||||
});
|
||||
|
||||
const invalidated = await this.catalogCache.invalidateProducts(productIds);
|
||||
|
||||
if (!invalidated) {
|
||||
this.logger.debug(
|
||||
"No catalog cache entries were linked to product IDs; falling back to full invalidation",
|
||||
{
|
||||
channel,
|
||||
productIds,
|
||||
}
|
||||
);
|
||||
await this.invalidateAllServices();
|
||||
// Full invalidation already implies all clients should refetch services
|
||||
this.realtime.publish("global:services", "services.changed", {
|
||||
reason: "product.cdc.fallback_full_invalidation",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Product changes can affect catalog results for all users
|
||||
this.realtime.publish("global:services", "services.changed", {
|
||||
reason: "product.cdc",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private async handlePricebookEvent(
|
||||
channel: string,
|
||||
subscription: { topicName?: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
): Promise<void> {
|
||||
if (!this.isDataCallback(callbackType)) return;
|
||||
const payload = this.extractPayload(data);
|
||||
const pricebookId = this.extractStringField(payload, ["PricebookId", "Pricebook2Id"]);
|
||||
|
||||
const portalPricebookId = this.config.get<string>("PORTAL_PRICEBOOK_ID");
|
||||
if (portalPricebookId && pricebookId && pricebookId !== portalPricebookId) {
|
||||
this.logger.debug("Ignoring pricebook event for non-portal pricebook", {
|
||||
channel,
|
||||
pricebookId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const productId = this.extractStringField(payload, ["Product2Id", "ProductId"]);
|
||||
|
||||
this.logger.log("PricebookEntry CDC event received", {
|
||||
channel,
|
||||
pricebookId,
|
||||
productId,
|
||||
});
|
||||
|
||||
const invalidated = await this.catalogCache.invalidateProducts(productId ? [productId] : []);
|
||||
|
||||
if (!invalidated) {
|
||||
this.logger.debug(
|
||||
"No catalog cache entries mapped to product from pricebook event; performing full invalidation",
|
||||
{
|
||||
channel,
|
||||
pricebookId,
|
||||
productId,
|
||||
}
|
||||
);
|
||||
await this.invalidateAllServices();
|
||||
this.realtime.publish("global:services", "services.changed", {
|
||||
reason: "pricebook.cdc.fallback_full_invalidation",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.realtime.publish("global:services", "services.changed", {
|
||||
reason: "pricebook.cdc",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private async handleAccountEvent(
|
||||
channel: string,
|
||||
subscription: { topicName?: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
): Promise<void> {
|
||||
if (!this.isDataCallback(callbackType)) return;
|
||||
const payload = this.extractPayload(data);
|
||||
const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]);
|
||||
const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]);
|
||||
const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]);
|
||||
const notes = this.extractStringField(payload, ["Internet_Eligibility_Notes__c"]);
|
||||
const caseId = this.extractStringField(payload, ["Internet_Eligibility_Case_Id__c"]);
|
||||
const requestedAt = this.extractStringField(payload, [
|
||||
"Internet_Eligibility_Request_Date_Time__c",
|
||||
]);
|
||||
const checkedAt = this.extractStringField(payload, [
|
||||
"Internet_Eligibility_Checked_Date_Time__c",
|
||||
]);
|
||||
|
||||
const requestId = caseId;
|
||||
|
||||
// Also extract ID verification fields for notifications
|
||||
const verificationStatus = this.extractStringField(payload, ["Id_Verification_Status__c"]);
|
||||
const verificationRejection = this.extractStringField(payload, [
|
||||
"Id_Verification_Rejection_Message__c",
|
||||
]);
|
||||
|
||||
if (!accountId) {
|
||||
this.logger.warn("Account eligibility event missing AccountId", {
|
||||
channel,
|
||||
payload,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log("Account eligibility event received", {
|
||||
channel,
|
||||
accountIdTail: accountId.slice(-4),
|
||||
});
|
||||
|
||||
await this.catalogCache.invalidateEligibility(accountId);
|
||||
const hasDetails = Boolean(
|
||||
status || eligibility || requestedAt || checkedAt || requestId || notes
|
||||
);
|
||||
if (hasDetails) {
|
||||
await this.catalogCache.setEligibilityDetails(accountId, {
|
||||
status: this.mapEligibilityStatus(status, eligibility),
|
||||
eligibility: eligibility ?? null,
|
||||
requestId: requestId ?? null,
|
||||
requestedAt: requestedAt ?? null,
|
||||
checkedAt: checkedAt ?? null,
|
||||
notes: notes ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Notify connected portals immediately (multi-instance safe via Redis pub/sub)
|
||||
this.realtime.publish(`account:sf:${accountId}`, "services.eligibility.changed", {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Create in-app notifications for eligibility/verification status changes
|
||||
if (this.accountNotificationHandler && (status || verificationStatus)) {
|
||||
void this.accountNotificationHandler.processAccountEvent({
|
||||
accountId,
|
||||
eligibilityStatus: status,
|
||||
eligibilityValue: eligibility,
|
||||
verificationStatus,
|
||||
verificationRejectionMessage: verificationRejection,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private mapEligibilityStatus(
|
||||
statusRaw: string | undefined,
|
||||
eligibilityRaw: string | undefined
|
||||
): "not_requested" | "pending" | "eligible" | "ineligible" {
|
||||
const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : "";
|
||||
const eligibility = typeof eligibilityRaw === "string" ? eligibilityRaw.trim() : "";
|
||||
|
||||
if (normalizedStatus === "pending" || normalizedStatus === "checking") return "pending";
|
||||
if (normalizedStatus === "eligible") return "eligible";
|
||||
if (normalizedStatus === "ineligible" || normalizedStatus === "not available")
|
||||
return "ineligible";
|
||||
if (eligibility.length > 0) return "eligible";
|
||||
return "not_requested";
|
||||
}
|
||||
|
||||
private async invalidateAllServices(): Promise<void> {
|
||||
try {
|
||||
await this.catalogCache.invalidateAllServices();
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to invalidate services caches", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private isDataCallback(callbackType: string): boolean {
|
||||
const normalized = String(callbackType || "").toLowerCase();
|
||||
return normalized === "data" || normalized === "event";
|
||||
}
|
||||
|
||||
private extractPayload(data: unknown): Record<string, unknown> | undefined {
|
||||
if (!data || typeof data !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidate = data as { payload?: unknown };
|
||||
if (candidate.payload && typeof candidate.payload === "object") {
|
||||
return candidate.payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private extractStringField(
|
||||
payload: Record<string, unknown> | undefined,
|
||||
fieldNames: string[]
|
||||
): string | undefined {
|
||||
if (!payload) return undefined;
|
||||
for (const field of fieldNames) {
|
||||
const value = payload[field];
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractRecordIds(payload: Record<string, unknown> | undefined): string[] {
|
||||
if (!payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const header = this.extractChangeEventHeader(payload);
|
||||
const ids = header?.recordIds ?? [];
|
||||
if (Array.isArray(ids)) {
|
||||
return ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private extractChangeEventHeader(
|
||||
payload: Record<string, unknown>
|
||||
): { recordIds?: unknown; changedFields?: unknown } | undefined {
|
||||
const header = payload["ChangeEventHeader"];
|
||||
if (header && typeof header === "object") {
|
||||
return header as { recordIds?: unknown; changedFields?: unknown };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
22
apps/bff/src/integrations/salesforce/events/shared/index.ts
Normal file
22
apps/bff/src/integrations/salesforce/events/shared/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared Pub/Sub utilities for Salesforce event subscribers
|
||||
*/
|
||||
|
||||
export { PubSubClientService } from "./pubsub.service.js";
|
||||
|
||||
export {
|
||||
isDataCallback,
|
||||
extractPayload,
|
||||
extractStringField,
|
||||
extractChangeEventHeader,
|
||||
extractRecordIds,
|
||||
extractChangedFields,
|
||||
parseNumRequested,
|
||||
} from "./pubsub.utils.js";
|
||||
|
||||
export type {
|
||||
PubSubCallback,
|
||||
PubSubClient,
|
||||
PubSubClientConstructor,
|
||||
ChangeEventHeader,
|
||||
} from "./pubsub.types.js";
|
||||
@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Salesforce Pub/Sub Client Service
|
||||
*
|
||||
* Provides shared client initialization and connection management for both
|
||||
* CDC (Change Data Capture) and Platform Event subscribers.
|
||||
*
|
||||
* This service handles:
|
||||
* - Client instantiation with proper auth
|
||||
* - Connection lifecycle management
|
||||
* - Graceful shutdown
|
||||
*/
|
||||
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import type { OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
|
||||
import { SalesforceConnection } from "../../services/salesforce-connection.service.js";
|
||||
import type { PubSubClient, PubSubClientConstructor, PubSubCallback } from "./pubsub.types.js";
|
||||
import { parseNumRequested } from "./pubsub.utils.js";
|
||||
|
||||
@Injectable()
|
||||
export class PubSubClientService implements OnModuleDestroy {
|
||||
private client: PubSubClient | null = null;
|
||||
private pubSubCtor: PubSubClientConstructor | null = null;
|
||||
private readonly numRequested: number;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly sfConnection: SalesforceConnection,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {
|
||||
this.numRequested = parseNumRequested(
|
||||
this.config.get<string>("SF_PUBSUB_NUM_REQUESTED"),
|
||||
25,
|
||||
(msg, ctx) => this.logger.warn(msg, ctx)
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default number of events to request per subscription
|
||||
*/
|
||||
getNumRequested(): number {
|
||||
return this.numRequested;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a connected Pub/Sub client is available
|
||||
*/
|
||||
async ensureClient(): Promise<PubSubClient> {
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
const ctor = this.loadPubSubCtor();
|
||||
|
||||
await this.sfConnection.connect();
|
||||
const accessToken = this.sfConnection.getAccessToken();
|
||||
const instanceUrl = this.sfConnection.getInstanceUrl();
|
||||
|
||||
if (!accessToken || !instanceUrl) {
|
||||
throw new Error("Salesforce access token or instance URL missing for Pub/Sub client");
|
||||
}
|
||||
|
||||
const pubSubEndpoint =
|
||||
this.config.get<string>("SF_PUBSUB_ENDPOINT") || "api.pubsub.salesforce.com:7443";
|
||||
|
||||
const client = new ctor({
|
||||
authType: "user-supplied",
|
||||
accessToken,
|
||||
instanceUrl,
|
||||
pubSubEndpoint,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
this.client = client;
|
||||
|
||||
this.logger.log("Salesforce Pub/Sub client connected", { pubSubEndpoint });
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a channel with diagnostic logging
|
||||
*/
|
||||
async subscribe(channel: string, handler: PubSubCallback, label: string): Promise<void> {
|
||||
this.logger.log("Subscribing to Salesforce channel", {
|
||||
channel,
|
||||
label,
|
||||
numRequested: this.numRequested,
|
||||
});
|
||||
|
||||
try {
|
||||
const client = await this.ensureClient();
|
||||
await client.subscribe(channel, handler, this.numRequested);
|
||||
|
||||
this.logger.log("Successfully subscribed to Salesforce channel", {
|
||||
channel,
|
||||
label,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to subscribe to Salesforce channel", {
|
||||
channel,
|
||||
label,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the Pub/Sub client connection
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
|
||||
try {
|
||||
await this.client.close();
|
||||
this.logger.debug("Salesforce Pub/Sub client closed");
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to close Salesforce Pub/Sub client cleanly", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
|
||||
private loadPubSubCtor(): PubSubClientConstructor {
|
||||
if (this.pubSubCtor) {
|
||||
return this.pubSubCtor;
|
||||
}
|
||||
|
||||
const maybeCtor = (PubSubApiClientPkg as unknown as PubSubClientConstructor) ?? null;
|
||||
const maybeDefault =
|
||||
(PubSubApiClientPkg as { default?: PubSubClientConstructor }).default ?? null;
|
||||
const ctor = typeof maybeCtor === "function" ? maybeCtor : maybeDefault;
|
||||
|
||||
if (!ctor) {
|
||||
throw new Error("Failed to load Salesforce Pub/Sub client constructor");
|
||||
}
|
||||
|
||||
this.pubSubCtor = ctor;
|
||||
return this.pubSubCtor;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Shared types for Salesforce Pub/Sub API client
|
||||
*
|
||||
* These types are used by both CDC (Change Data Capture) and Platform Event subscribers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Callback signature for Pub/Sub event subscriptions
|
||||
*/
|
||||
export type PubSubCallback = (
|
||||
subscription: { topicName?: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Pub/Sub client interface
|
||||
*/
|
||||
export interface PubSubClient {
|
||||
connect(): Promise<void>;
|
||||
subscribe(topic: string, cb: PubSubCallback, numRequested?: number): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor type for Pub/Sub client
|
||||
*/
|
||||
export type PubSubClientConstructor = new (opts: {
|
||||
authType: string;
|
||||
accessToken: string;
|
||||
instanceUrl: string;
|
||||
pubSubEndpoint: string;
|
||||
}) => PubSubClient;
|
||||
|
||||
/**
|
||||
* CDC ChangeEventHeader structure
|
||||
*/
|
||||
export interface ChangeEventHeader {
|
||||
recordIds?: unknown;
|
||||
changedFields?: unknown;
|
||||
entityName?: unknown;
|
||||
changeType?: unknown;
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Shared utility functions for Salesforce Pub/Sub subscribers
|
||||
*
|
||||
* Provides common payload extraction and validation logic used by both
|
||||
* CDC and Platform Event subscribers.
|
||||
*/
|
||||
|
||||
import type { ChangeEventHeader } from "./pubsub.types.js";
|
||||
|
||||
/**
|
||||
* Check if a callback type represents actual event data
|
||||
*/
|
||||
export function isDataCallback(callbackType: string): boolean {
|
||||
const normalized = String(callbackType || "").toLowerCase();
|
||||
return normalized === "data" || normalized === "event";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract payload from Pub/Sub event data
|
||||
*/
|
||||
export function extractPayload(data: unknown): Record<string, unknown> | undefined {
|
||||
if (!data || typeof data !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidate = data as { payload?: unknown };
|
||||
if (candidate.payload && typeof candidate.payload === "object") {
|
||||
return candidate.payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a string field from payload, trying multiple possible field names
|
||||
*/
|
||||
export function extractStringField(
|
||||
payload: Record<string, unknown> | undefined,
|
||||
fieldNames: string[]
|
||||
): string | undefined {
|
||||
if (!payload) return undefined;
|
||||
|
||||
for (const field of fieldNames) {
|
||||
const value = payload[field];
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ChangeEventHeader from CDC payload
|
||||
*/
|
||||
export function extractChangeEventHeader(
|
||||
payload: Record<string, unknown>
|
||||
): ChangeEventHeader | undefined {
|
||||
const header = payload["ChangeEventHeader"];
|
||||
if (header && typeof header === "object") {
|
||||
return header as ChangeEventHeader;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract record IDs from CDC payload
|
||||
*/
|
||||
export function extractRecordIds(payload: Record<string, unknown> | undefined): string[] {
|
||||
if (!payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const header = extractChangeEventHeader(payload);
|
||||
const ids = header?.recordIds ?? [];
|
||||
|
||||
if (Array.isArray(ids)) {
|
||||
return ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract changed field names from CDC payload
|
||||
*/
|
||||
export function extractChangedFields(payload: Record<string, unknown> | undefined): Set<string> {
|
||||
if (!payload) return new Set();
|
||||
|
||||
const header = extractChangeEventHeader(payload);
|
||||
const headerChangedFields = Array.isArray(header?.changedFields)
|
||||
? (header?.changedFields as unknown[]).filter(
|
||||
(field): field is string => typeof field === "string" && field.length > 0
|
||||
)
|
||||
: [];
|
||||
|
||||
// CDC provides changed fields in different formats depending on API version
|
||||
const changedFieldsArray =
|
||||
(payload.changedFields as string[] | undefined) ||
|
||||
(payload.changeOrigin as { changedFields?: string[] })?.changedFields ||
|
||||
[];
|
||||
|
||||
return new Set([
|
||||
...headerChangedFields,
|
||||
...changedFieldsArray.filter(field => typeof field === "string" && field.length > 0),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse numRequested config value with validation
|
||||
*/
|
||||
export function parseNumRequested(
|
||||
rawValue: string | undefined,
|
||||
defaultValue = 25,
|
||||
onWarn?: (message: string, context: Record<string, unknown>) => void
|
||||
): number {
|
||||
const raw = rawValue ?? String(defaultValue);
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
onWarn?.("Invalid SF_PUBSUB_NUM_REQUESTED value; using default", {
|
||||
rawValue: raw,
|
||||
defaultValue,
|
||||
});
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
@ -3,7 +3,7 @@ import { Logger } from "nestjs-pino";
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { SalesforceOpportunityService } from "./salesforce-opportunity.service.js";
|
||||
import { assertSalesforceId } from "../utils/soql.util.js";
|
||||
import type { OrderTypeValue } from "@customer-portal/domain/orders";
|
||||
import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders";
|
||||
import {
|
||||
APPLICATION_STAGE,
|
||||
OPPORTUNITY_PRODUCT_TYPE,
|
||||
@ -131,14 +131,14 @@ export class OpportunityResolutionService {
|
||||
|
||||
private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue {
|
||||
switch (orderType) {
|
||||
case "Internet":
|
||||
case ORDER_TYPE.INTERNET:
|
||||
return OPPORTUNITY_PRODUCT_TYPE.INTERNET;
|
||||
case "SIM":
|
||||
case ORDER_TYPE.SIM:
|
||||
return OPPORTUNITY_PRODUCT_TYPE.SIM;
|
||||
case "VPN":
|
||||
case ORDER_TYPE.VPN:
|
||||
return OPPORTUNITY_PRODUCT_TYPE.VPN;
|
||||
default:
|
||||
return OPPORTUNITY_PRODUCT_TYPE.SIM;
|
||||
throw new Error(`Unsupported order type: ${orderType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ export interface ServicesCacheSnapshot {
|
||||
static: CacheBucketMetrics;
|
||||
volatile: CacheBucketMetrics;
|
||||
eligibility: CacheBucketMetrics;
|
||||
verification: CacheBucketMetrics;
|
||||
invalidations: number;
|
||||
}
|
||||
|
||||
@ -49,6 +50,7 @@ export class ServicesCacheService {
|
||||
private readonly SERVICES_TTL: number | null;
|
||||
private readonly STATIC_TTL: number | null;
|
||||
private readonly ELIGIBILITY_TTL: number | null;
|
||||
private readonly VERIFICATION_TTL: number | null;
|
||||
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
|
||||
|
||||
private readonly metrics: ServicesCacheSnapshot = {
|
||||
@ -56,6 +58,7 @@ export class ServicesCacheService {
|
||||
static: { hits: 0, misses: 0 },
|
||||
volatile: { hits: 0, misses: 0 },
|
||||
eligibility: { hits: 0, misses: 0 },
|
||||
verification: { hits: 0, misses: 0 },
|
||||
invalidations: 0,
|
||||
};
|
||||
|
||||
@ -70,10 +73,11 @@ export class ServicesCacheService {
|
||||
const raw = this.config.get<number>("SERVICES_CACHE_SAFETY_TTL_SECONDS", 60 * 60 * 12);
|
||||
const ttl = typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null;
|
||||
|
||||
// Apply to CDC-driven buckets (catalog + static + eligibility)
|
||||
// Apply to CDC-driven buckets (catalog + static + eligibility + verification)
|
||||
this.SERVICES_TTL = ttl;
|
||||
this.STATIC_TTL = ttl;
|
||||
this.ELIGIBILITY_TTL = ttl;
|
||||
this.VERIFICATION_TTL = ttl;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -121,6 +125,10 @@ export class ServicesCacheService {
|
||||
return `services:eligibility:${accountId}`;
|
||||
}
|
||||
|
||||
buildVerificationKey(accountId: string): string {
|
||||
return `services:verification:${accountId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate catalog cache by pattern
|
||||
*/
|
||||
@ -139,6 +147,25 @@ export class ServicesCacheService {
|
||||
await this.cache.del(this.buildEligibilityKey("", accountId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate verification cache for an account
|
||||
*/
|
||||
async invalidateVerification(accountId: string): Promise<void> {
|
||||
if (!accountId) return;
|
||||
this.metrics.invalidations++;
|
||||
await this.cache.del(this.buildVerificationKey(accountId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or fetch verification data (event-driven cache with safety TTL)
|
||||
*/
|
||||
async getCachedVerification<T>(accountId: string, fetchFn: () => Promise<T>): Promise<T> {
|
||||
const key = this.buildVerificationKey(accountId);
|
||||
return this.getOrSet("verification", key, this.VERIFICATION_TTL, fetchFn, {
|
||||
allowNull: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all catalog cache entries
|
||||
*/
|
||||
@ -174,6 +201,7 @@ export class ServicesCacheService {
|
||||
static: { ...this.metrics.static },
|
||||
volatile: { ...this.metrics.volatile },
|
||||
eligibility: { ...this.metrics.eligibility },
|
||||
verification: { ...this.metrics.verification },
|
||||
invalidations: this.metrics.invalidations,
|
||||
};
|
||||
}
|
||||
@ -189,10 +217,8 @@ export class ServicesCacheService {
|
||||
const payload = {
|
||||
status: eligibility ? "eligible" : "not_requested",
|
||||
eligibility: typeof eligibility === "string" ? eligibility : null,
|
||||
requestId: null,
|
||||
requestedAt: null,
|
||||
checkedAt: null,
|
||||
notes: null,
|
||||
};
|
||||
if (this.ELIGIBILITY_TTL === null) {
|
||||
await this.cache.set(key, payload);
|
||||
@ -215,7 +241,7 @@ export class ServicesCacheService {
|
||||
}
|
||||
|
||||
private async getOrSet<T>(
|
||||
bucket: "services" | "static" | "volatile" | "eligibility",
|
||||
bucket: "services" | "static" | "volatile" | "eligibility" | "verification",
|
||||
key: string,
|
||||
ttlSeconds: number | null,
|
||||
fetchFn: () => Promise<T>,
|
||||
|
||||
159
apps/bff/src/modules/support/support-cache.service.ts
Normal file
159
apps/bff/src/modules/support/support-cache.service.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { CacheService } from "@bff/infra/cache/cache.service.js";
|
||||
import type { CacheBucketMetrics } from "@bff/infra/cache/cache.types.js";
|
||||
import type { SupportCaseList, CaseMessageList } from "@customer-portal/domain/support";
|
||||
|
||||
/**
|
||||
* Cache TTL configuration for support cases
|
||||
*
|
||||
* Unlike orders (which use CDC events), support cases use TTL-based caching
|
||||
* because Salesforce Case changes don't trigger platform events in our setup.
|
||||
*/
|
||||
const CACHE_TTL = {
|
||||
/** Case list TTL: 2 minutes - customers checking status periodically */
|
||||
CASE_LIST: 120,
|
||||
/** Case messages TTL: 1 minute - fresher for active conversations */
|
||||
CASE_MESSAGES: 60,
|
||||
} as const;
|
||||
|
||||
interface SupportCacheMetrics {
|
||||
caseList: CacheBucketMetrics;
|
||||
messages: CacheBucketMetrics;
|
||||
invalidations: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Support cases cache service
|
||||
*
|
||||
* Uses TTL-based caching (not CDC) because:
|
||||
* - Cases don't trigger platform events in our Salesforce setup
|
||||
* - Customers can refresh to see updates (no real-time requirement)
|
||||
* - Short TTLs (1-2 min) ensure reasonable freshness
|
||||
*
|
||||
* Features:
|
||||
* - Request coalescing: Prevents duplicate API calls on cache miss
|
||||
* - Write-through invalidation: Cache is cleared after customer adds comment
|
||||
* - Metrics tracking: Monitors hits, misses, and invalidations
|
||||
*/
|
||||
@Injectable()
|
||||
export class SupportCacheService {
|
||||
private readonly metrics: SupportCacheMetrics = {
|
||||
caseList: { hits: 0, misses: 0 },
|
||||
messages: { hits: 0, misses: 0 },
|
||||
invalidations: 0,
|
||||
};
|
||||
|
||||
// Request coalescing: Prevents duplicate API calls
|
||||
private readonly inflightRequests = new Map<string, Promise<unknown>>();
|
||||
|
||||
constructor(private readonly cache: CacheService) {}
|
||||
|
||||
/**
|
||||
* Get cached case list for an account
|
||||
*/
|
||||
async getCaseList(
|
||||
sfAccountId: string,
|
||||
fetcher: () => Promise<SupportCaseList>
|
||||
): Promise<SupportCaseList> {
|
||||
const key = this.buildCaseListKey(sfAccountId);
|
||||
return this.getOrSet("caseList", key, fetcher, CACHE_TTL.CASE_LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached messages for a case
|
||||
*/
|
||||
async getCaseMessages(
|
||||
caseId: string,
|
||||
fetcher: () => Promise<CaseMessageList>
|
||||
): Promise<CaseMessageList> {
|
||||
const key = this.buildMessagesKey(caseId);
|
||||
return this.getOrSet("messages", key, fetcher, CACHE_TTL.CASE_MESSAGES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate case list cache for an account
|
||||
* Called after customer creates a new case
|
||||
*/
|
||||
async invalidateCaseList(sfAccountId: string): Promise<void> {
|
||||
const key = this.buildCaseListKey(sfAccountId);
|
||||
this.metrics.invalidations++;
|
||||
await this.cache.del(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate messages cache for a case
|
||||
* Called after customer adds a comment
|
||||
*/
|
||||
async invalidateCaseMessages(caseId: string): Promise<void> {
|
||||
const key = this.buildMessagesKey(caseId);
|
||||
this.metrics.invalidations++;
|
||||
await this.cache.del(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all caches for an account's cases
|
||||
* Called after any write operation to ensure fresh data
|
||||
*/
|
||||
async invalidateAllForAccount(sfAccountId: string, caseId?: string): Promise<void> {
|
||||
await this.invalidateCaseList(sfAccountId);
|
||||
if (caseId) {
|
||||
await this.invalidateCaseMessages(caseId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache metrics for monitoring
|
||||
*/
|
||||
getMetrics(): SupportCacheMetrics {
|
||||
return {
|
||||
caseList: { ...this.metrics.caseList },
|
||||
messages: { ...this.metrics.messages },
|
||||
invalidations: this.metrics.invalidations,
|
||||
};
|
||||
}
|
||||
|
||||
private async getOrSet<T>(
|
||||
bucket: "caseList" | "messages",
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
ttlSeconds: number
|
||||
): Promise<T> {
|
||||
// Check Redis cache first
|
||||
const cached = await this.cache.get<T>(key);
|
||||
|
||||
if (cached !== null) {
|
||||
this.metrics[bucket].hits++;
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Check for in-flight request (prevents thundering herd)
|
||||
const existingRequest = this.inflightRequests.get(key);
|
||||
if (existingRequest) {
|
||||
return existingRequest as Promise<T>;
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
this.metrics[bucket].misses++;
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
try {
|
||||
const fresh = await fetcher();
|
||||
await this.cache.set(key, fresh, ttlSeconds);
|
||||
return fresh;
|
||||
} finally {
|
||||
this.inflightRequests.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
this.inflightRequests.set(key, fetchPromise);
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
private buildCaseListKey(sfAccountId: string): string {
|
||||
return `support:cases:${sfAccountId}`;
|
||||
}
|
||||
|
||||
private buildMessagesKey(caseId: string): string {
|
||||
return `support:messages:${caseId}`;
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SupportController } from "./support.controller.js";
|
||||
import { SupportService } from "./support.service.js";
|
||||
import { SupportCacheService } from "./support-cache.service.js";
|
||||
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [SalesforceModule, MappingsModule],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService],
|
||||
exports: [SupportService],
|
||||
providers: [SupportService, SupportCacheService],
|
||||
exports: [SupportService, SupportCacheService],
|
||||
})
|
||||
export class SupportModule {}
|
||||
|
||||
@ -16,29 +16,25 @@ import {
|
||||
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
||||
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { SupportCacheService } from "./support-cache.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { hashEmailForLogs } from "./support.logging.js";
|
||||
|
||||
/**
|
||||
* Status values that indicate an open/active case
|
||||
* (Display values after mapping from Salesforce Japanese API names)
|
||||
* (Display values after mapping from Salesforce API names)
|
||||
*/
|
||||
const OPEN_STATUSES: string[] = [
|
||||
SUPPORT_CASE_STATUS.NEW,
|
||||
SUPPORT_CASE_STATUS.IN_PROGRESS,
|
||||
SUPPORT_CASE_STATUS.AWAITING_APPROVAL,
|
||||
SUPPORT_CASE_STATUS.AWAITING_CUSTOMER,
|
||||
];
|
||||
|
||||
/**
|
||||
* Status values that indicate a resolved/closed case
|
||||
* (Display values after mapping from Salesforce Japanese API names)
|
||||
* (Display values after mapping from Salesforce API names)
|
||||
*/
|
||||
const RESOLVED_STATUSES: string[] = [
|
||||
SUPPORT_CASE_STATUS.VPN_PENDING,
|
||||
SUPPORT_CASE_STATUS.PENDING,
|
||||
SUPPORT_CASE_STATUS.RESOLVED,
|
||||
SUPPORT_CASE_STATUS.CLOSED,
|
||||
];
|
||||
const RESOLVED_STATUSES: string[] = [SUPPORT_CASE_STATUS.CLOSED];
|
||||
|
||||
/**
|
||||
* Priority values that indicate high priority
|
||||
@ -50,23 +46,35 @@ export class SupportService {
|
||||
constructor(
|
||||
private readonly caseService: SalesforceCaseService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly cacheService: SupportCacheService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List cases for a user with optional filters
|
||||
*
|
||||
* Uses Redis caching with 2-minute TTL to reduce Salesforce API calls.
|
||||
* Cache is invalidated when customer creates a new case.
|
||||
*/
|
||||
async listCases(userId: string, filters?: SupportCaseFilter): Promise<SupportCaseList> {
|
||||
const accountId = await this.getAccountIdForUser(userId);
|
||||
|
||||
try {
|
||||
// SalesforceCaseService now returns SupportCase[] directly using domain mappers
|
||||
const cases = await this.caseService.getCasesForAccount(accountId);
|
||||
// Use cache with TTL (no CDC events for cases)
|
||||
const caseList = await this.cacheService.getCaseList(accountId, async () => {
|
||||
const cases = await this.caseService.getCasesForAccount(accountId);
|
||||
const summary = this.buildSummary(cases);
|
||||
return { cases, summary };
|
||||
});
|
||||
|
||||
const filteredCases = this.applyFilters(cases, filters);
|
||||
const summary = this.buildSummary(filteredCases);
|
||||
// Apply filters after cache (filters are user-specific, cache is account-level)
|
||||
if (filters && Object.keys(filters).length > 0) {
|
||||
const filteredCases = this.applyFilters(caseList.cases, filters);
|
||||
const summary = this.buildSummary(filteredCases);
|
||||
return { cases: filteredCases, summary };
|
||||
}
|
||||
|
||||
return { cases: filteredCases, summary };
|
||||
return caseList;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to list support cases", {
|
||||
userId,
|
||||
@ -106,6 +114,8 @@ export class SupportService {
|
||||
|
||||
/**
|
||||
* Create a new support case
|
||||
*
|
||||
* Invalidates case list cache after successful creation.
|
||||
*/
|
||||
async createCase(userId: string, request: CreateCaseRequest): Promise<CreateCaseResponse> {
|
||||
const accountId = await this.getAccountIdForUser(userId);
|
||||
@ -119,6 +129,9 @@ export class SupportService {
|
||||
origin: SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT,
|
||||
});
|
||||
|
||||
// Invalidate cache so new case appears immediately
|
||||
await this.cacheService.invalidateCaseList(accountId);
|
||||
|
||||
this.logger.log("Support case created", {
|
||||
userId,
|
||||
caseId: result.id,
|
||||
@ -176,6 +189,8 @@ export class SupportService {
|
||||
* Get all messages for a case (conversation view)
|
||||
*
|
||||
* Returns a unified timeline of EmailMessages and public CaseComments.
|
||||
* Uses Redis caching with 1-minute TTL for active conversations.
|
||||
* Cache is invalidated when customer adds a comment.
|
||||
*
|
||||
* @param userId - Portal user ID
|
||||
* @param caseId - Salesforce Case ID
|
||||
@ -189,7 +204,10 @@ export class SupportService {
|
||||
const accountId = await this.getAccountIdForUser(userId);
|
||||
|
||||
try {
|
||||
const messages = await this.caseService.getCaseMessages(caseId, accountId, customerEmail);
|
||||
// Use cache with short TTL for messages (fresher for active conversations)
|
||||
const messages = await this.cacheService.getCaseMessages(caseId, async () => {
|
||||
return this.caseService.getCaseMessages(caseId, accountId, customerEmail);
|
||||
});
|
||||
|
||||
return messages;
|
||||
} catch (error) {
|
||||
@ -209,6 +227,7 @@ export class SupportService {
|
||||
* Add a comment to a case (customer reply via portal)
|
||||
*
|
||||
* Creates a public CaseComment visible to both customer and agents.
|
||||
* Invalidates messages cache after successful comment so it appears immediately.
|
||||
*/
|
||||
async addCaseComment(
|
||||
userId: string,
|
||||
@ -220,6 +239,9 @@ export class SupportService {
|
||||
try {
|
||||
const result = await this.caseService.addCaseComment(caseId, accountId, request.body);
|
||||
|
||||
// Invalidate caches so new comment appears immediately
|
||||
await this.cacheService.invalidateAllForAccount(accountId, caseId);
|
||||
|
||||
this.logger.log("Case comment added", {
|
||||
userId,
|
||||
caseId,
|
||||
|
||||
@ -4,6 +4,7 @@ import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
|
||||
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
||||
import {
|
||||
assertSalesforceId,
|
||||
@ -21,22 +22,13 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
|
||||
import { basename, extname } from "node:path";
|
||||
|
||||
function mapFileTypeToMime(fileType?: string | null): string | null {
|
||||
const normalized = String(fileType || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "pdf") return "application/pdf";
|
||||
if (normalized === "png") return "image/png";
|
||||
if (normalized === "jpg" || normalized === "jpeg") return "image/jpeg";
|
||||
return null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ResidenceCardService {
|
||||
constructor(
|
||||
private readonly sf: SalesforceConnection,
|
||||
private readonly mappings: MappingsService,
|
||||
private readonly caseService: SalesforceCaseService,
|
||||
private readonly servicesCache: ServicesCacheService,
|
||||
private readonly config: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
@ -49,15 +41,20 @@ export class ResidenceCardService {
|
||||
if (!sfAccountId) {
|
||||
return residenceCardVerificationSchema.parse({
|
||||
status: "not_submitted",
|
||||
filename: null,
|
||||
mimeType: null,
|
||||
sizeBytes: null,
|
||||
submittedAt: null,
|
||||
reviewedAt: null,
|
||||
reviewerNotes: null,
|
||||
});
|
||||
}
|
||||
|
||||
return this.servicesCache.getCachedVerification(sfAccountId, async () => {
|
||||
return this.fetchVerificationFromSalesforce(sfAccountId);
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchVerificationFromSalesforce(
|
||||
sfAccountId: string
|
||||
): Promise<ResidenceCardVerification> {
|
||||
const fields = this.getAccountFieldNames();
|
||||
const soql = `
|
||||
SELECT Id, ${fields.status}, ${fields.submittedAt}, ${fields.verifiedAt}, ${fields.note}, ${fields.rejectionMessage}
|
||||
@ -100,44 +97,11 @@ export class ResidenceCardService {
|
||||
? noteRaw.trim()
|
||||
: null;
|
||||
|
||||
const fileMeta =
|
||||
status === "not_submitted"
|
||||
? null
|
||||
: await this.getLatestIdVerificationFileMetadata(sfAccountId);
|
||||
|
||||
const payload = {
|
||||
return residenceCardVerificationSchema.parse({
|
||||
status,
|
||||
filename: fileMeta?.filename ?? null,
|
||||
mimeType: fileMeta?.mimeType ?? null,
|
||||
sizeBytes: typeof fileMeta?.sizeBytes === "number" ? fileMeta.sizeBytes : null,
|
||||
submittedAt: submittedAt ?? fileMeta?.submittedAt ?? null,
|
||||
submittedAt,
|
||||
reviewedAt,
|
||||
reviewerNotes,
|
||||
};
|
||||
|
||||
const parsed = residenceCardVerificationSchema.safeParse(payload);
|
||||
if (parsed.success) return parsed.data;
|
||||
|
||||
this.logger.warn(
|
||||
{ userId, err: parsed.error.message },
|
||||
"Invalid residence card verification payload from Salesforce; returning safe fallback"
|
||||
);
|
||||
|
||||
const fallback = residenceCardVerificationSchema.safeParse({
|
||||
...payload,
|
||||
submittedAt: null,
|
||||
reviewedAt: null,
|
||||
});
|
||||
if (fallback.success) return fallback.data;
|
||||
|
||||
return residenceCardVerificationSchema.parse({
|
||||
status: "not_submitted",
|
||||
filename: null,
|
||||
mimeType: null,
|
||||
sizeBytes: null,
|
||||
submittedAt: null,
|
||||
reviewedAt: null,
|
||||
reviewerNotes: null,
|
||||
});
|
||||
}
|
||||
|
||||
@ -240,6 +204,9 @@ export class ResidenceCardService {
|
||||
[fields.note]: null,
|
||||
});
|
||||
|
||||
// Invalidate cache so next fetch gets fresh data
|
||||
await this.servicesCache.invalidateVerification(sfAccountId);
|
||||
|
||||
return this.getStatusForUser(params.userId);
|
||||
}
|
||||
|
||||
@ -277,88 +244,4 @@ export class ResidenceCardService {
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest ID verification file metadata from Cases linked to the Account.
|
||||
*
|
||||
* Files are attached to ID verification Cases (not the Account directly).
|
||||
* We find the most recent Case with the ID verification subject and get its file.
|
||||
*/
|
||||
private async getLatestIdVerificationFileMetadata(accountId: string): Promise<{
|
||||
filename: string | null;
|
||||
mimeType: string | null;
|
||||
sizeBytes: number | null;
|
||||
submittedAt: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
// Find the most recent ID verification case for this account
|
||||
const caseSoql = `
|
||||
SELECT Id
|
||||
FROM Case
|
||||
WHERE AccountId = '${accountId}'
|
||||
AND Origin = '${SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION}'
|
||||
AND Subject LIKE '%ID verification%'
|
||||
ORDER BY CreatedDate DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const caseRes = (await this.sf.query(caseSoql, {
|
||||
label: "verification:residence_card:latest_case",
|
||||
})) as SalesforceResponse<{ Id?: string }>;
|
||||
const caseId = caseRes.records?.[0]?.Id;
|
||||
if (!caseId) return null;
|
||||
|
||||
// Get files linked to that case
|
||||
const linkSoql = `
|
||||
SELECT ContentDocumentId
|
||||
FROM ContentDocumentLink
|
||||
WHERE LinkedEntityId = '${caseId}'
|
||||
ORDER BY SystemModstamp DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const linkRes = (await this.sf.query(linkSoql, {
|
||||
label: "verification:residence_card:latest_link",
|
||||
})) as SalesforceResponse<{ ContentDocumentId?: string }>;
|
||||
const documentId = linkRes.records?.[0]?.ContentDocumentId;
|
||||
if (!documentId) return null;
|
||||
|
||||
const versionSoql = `
|
||||
SELECT Title, FileExtension, FileType, ContentSize, CreatedDate
|
||||
FROM ContentVersion
|
||||
WHERE ContentDocumentId = '${documentId}'
|
||||
ORDER BY CreatedDate DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const versionRes = (await this.sf.query(versionSoql, {
|
||||
label: "verification:residence_card:latest_version",
|
||||
})) as SalesforceResponse<Record<string, unknown>>;
|
||||
const version = (versionRes.records?.[0] as Record<string, unknown> | undefined) ?? undefined;
|
||||
if (!version) return null;
|
||||
|
||||
const title = typeof version.Title === "string" ? version.Title.trim() : "";
|
||||
const ext = typeof version.FileExtension === "string" ? version.FileExtension.trim() : "";
|
||||
const fileType = typeof version.FileType === "string" ? version.FileType.trim() : "";
|
||||
const sizeBytes = typeof version.ContentSize === "number" ? version.ContentSize : null;
|
||||
const createdDateRaw = version.CreatedDate;
|
||||
const submittedAt = normalizeSalesforceDateTimeToIsoUtc(createdDateRaw);
|
||||
|
||||
const filename = title
|
||||
? ext && !title.toLowerCase().endsWith(`.${ext.toLowerCase()}`)
|
||||
? `${title}.${ext}`
|
||||
: title
|
||||
: null;
|
||||
|
||||
return {
|
||||
filename,
|
||||
mimeType: mapFileTypeToMime(fileType) ?? mapFileTypeToMime(ext) ?? null,
|
||||
sizeBytes,
|
||||
submittedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to load ID verification file metadata from Salesforce", {
|
||||
accountIdTail: accountId.slice(-4),
|
||||
error: extractErrorMessage(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { ResidenceCardController } from "./residence-card.controller.js";
|
||||
import { ResidenceCardService } from "./residence-card.service.js";
|
||||
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { CoreConfigModule } from "@bff/core/config/config.module.js";
|
||||
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, MappingsModule, CoreConfigModule],
|
||||
imports: [IntegrationsModule, MappingsModule, CoreConfigModule, forwardRef(() => ServicesModule)],
|
||||
controllers: [ResidenceCardController],
|
||||
providers: [ResidenceCardService],
|
||||
exports: [ResidenceCardService],
|
||||
|
||||
@ -537,22 +537,15 @@ export default function ProfileContainer() {
|
||||
Your residence card has been submitted. We'll verify it before activating SIM
|
||||
service.
|
||||
</AlertBanner>
|
||||
{(verificationQuery.data?.filename || verificationQuery.data?.submittedAt) && (
|
||||
{verificationQuery.data?.submittedAt && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Submitted document
|
||||
Submission status
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Submitted on{" "}
|
||||
{formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })}
|
||||
</div>
|
||||
{verificationQuery.data?.filename && (
|
||||
<div className="mt-1 text-sm font-medium text-foreground">
|
||||
{verificationQuery.data.filename}
|
||||
</div>
|
||||
)}
|
||||
{verificationQuery.data?.submittedAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Submitted on{" "}
|
||||
{formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -579,18 +572,11 @@ export default function ProfileContainer() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(verificationQuery.data?.filename ||
|
||||
verificationQuery.data?.submittedAt ||
|
||||
verificationQuery.data?.reviewedAt) && (
|
||||
{(verificationQuery.data?.submittedAt || verificationQuery.data?.reviewedAt) && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Latest submission
|
||||
</div>
|
||||
{verificationQuery.data?.filename && (
|
||||
<div className="mt-1 text-sm font-medium text-foreground">
|
||||
{verificationQuery.data.filename}
|
||||
</div>
|
||||
)}
|
||||
{verificationQuery.data?.submittedAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Submitted on{" "}
|
||||
|
||||
@ -7,7 +7,12 @@ import {
|
||||
CheckCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { PaymentMethod } from "@customer-portal/domain/payments";
|
||||
import { cn } from "@/shared/utils";
|
||||
import {
|
||||
cn,
|
||||
getPaymentMethodBrandLabel,
|
||||
getPaymentMethodCardDisplay,
|
||||
normalizeExpiryLabel,
|
||||
} from "@/shared/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PaymentMethodCardProps {
|
||||
@ -55,44 +60,12 @@ const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
const isCreditCard = (type: PaymentMethod["type"]) =>
|
||||
type === "CreditCard" || type === "RemoteCreditCard";
|
||||
|
||||
const isBankAccount = (type: PaymentMethod["type"]) =>
|
||||
type === "BankAccount" || type === "RemoteBankAccount";
|
||||
|
||||
const formatCardDisplay = (method: PaymentMethod) => {
|
||||
if (method.cardLastFour) {
|
||||
return `***** ${method.cardLastFour}`;
|
||||
}
|
||||
|
||||
// Fallback based on type
|
||||
if (isCreditCard(method.type)) {
|
||||
return method.cardType ? `${method.cardType.toUpperCase()} Card` : "Credit Card";
|
||||
}
|
||||
|
||||
if (isBankAccount(method.type)) {
|
||||
return method.bankName || "Bank Account";
|
||||
}
|
||||
|
||||
return method.description || "Payment Method";
|
||||
};
|
||||
|
||||
const formatCardBrand = (method: PaymentMethod) => {
|
||||
if (isCreditCard(method.type) && method.cardType) {
|
||||
return method.cardType.toUpperCase();
|
||||
}
|
||||
|
||||
if (isBankAccount(method.type) && method.bankName) {
|
||||
return method.bankName;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatExpiry = (expiryDate?: string) => {
|
||||
if (!expiryDate) return null;
|
||||
return `Expires ${expiryDate}`;
|
||||
const normalized = normalizeExpiryLabel(expiryDate);
|
||||
return normalized ? `Expires ${normalized}` : null;
|
||||
};
|
||||
|
||||
export function PaymentMethodCard({
|
||||
@ -101,8 +74,8 @@ export function PaymentMethodCard({
|
||||
showActions = false,
|
||||
actionSlot,
|
||||
}: PaymentMethodCardProps) {
|
||||
const cardDisplay = formatCardDisplay(paymentMethod);
|
||||
const cardBrand = formatCardBrand(paymentMethod);
|
||||
const cardDisplay = getPaymentMethodCardDisplay(paymentMethod);
|
||||
const cardBrand = getPaymentMethodBrandLabel(paymentMethod);
|
||||
const expiry = formatExpiry(paymentMethod.expiryDate);
|
||||
const icon = getMethodIcon(paymentMethod.type, paymentMethod.cardType);
|
||||
|
||||
|
||||
@ -1,181 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import {
|
||||
CreditCardIcon,
|
||||
BanknotesIcon,
|
||||
CheckCircleIcon,
|
||||
EllipsisVerticalIcon,
|
||||
ArrowPathIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Badge } from "@/components/atoms/badge";
|
||||
import type { PaymentMethod } from "@customer-portal/domain/payments";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
interface PaymentMethodCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
paymentMethod: PaymentMethod;
|
||||
showActions?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const getPaymentMethodIcon = (type: PaymentMethod["type"]) => {
|
||||
switch (type) {
|
||||
case "CreditCard":
|
||||
case "RemoteCreditCard":
|
||||
return <CreditCardIcon className="h-5 w-5" />;
|
||||
case "BankAccount":
|
||||
return <BanknotesIcon className="h-5 w-5" />;
|
||||
case "RemoteBankAccount":
|
||||
return <ArrowPathIcon className="h-5 w-5" />;
|
||||
case "Manual":
|
||||
default:
|
||||
return <CreditCardIcon className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getCardBrandColor = (cardBrand?: string) => {
|
||||
switch (cardBrand?.toLowerCase()) {
|
||||
case "visa":
|
||||
return "text-info";
|
||||
case "mastercard":
|
||||
return "text-danger";
|
||||
case "amex":
|
||||
case "american express":
|
||||
return "text-success";
|
||||
case "discover":
|
||||
return "text-warning";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
const PaymentMethodCard = forwardRef<HTMLDivElement, PaymentMethodCardProps>(
|
||||
({ paymentMethod, showActions = true, compact = false, className, ...props }, ref) => {
|
||||
const {
|
||||
type,
|
||||
description,
|
||||
gatewayName,
|
||||
isDefault,
|
||||
expiryDate,
|
||||
bankName,
|
||||
cardType,
|
||||
cardLastFour,
|
||||
} = paymentMethod;
|
||||
|
||||
const formatExpiryDate = (expiry?: string) => {
|
||||
if (!expiry) return null;
|
||||
// Handle different expiry formats (MM/YY, MM/YYYY, MMYY, etc.)
|
||||
const cleaned = expiry.replace(/\D/g, "");
|
||||
if (cleaned.length >= 4) {
|
||||
const month = cleaned.substring(0, 2);
|
||||
const year = cleaned.substring(2, 4);
|
||||
return `${month}/${year}`;
|
||||
}
|
||||
return expiry;
|
||||
};
|
||||
|
||||
const renderPaymentMethodDetails = () => {
|
||||
if (type === "BankAccount" || type === "RemoteBankAccount") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-foreground">{bankName || "Bank Account"}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{cardLastFour && <span> •••• {cardLastFour}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Credit Card
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-foreground">
|
||||
{cardType || "Credit Card"}
|
||||
{cardLastFour && <span className="ml-2">•••• {cardLastFour}</span>}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatExpiryDate(expiryDate) && <span>Expires {formatExpiryDate(expiryDate)}</span>}
|
||||
{gatewayName && <span className="ml-2">• {gatewayName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-card border border-border rounded-lg transition-all duration-200",
|
||||
"hover:border-border-muted hover:shadow-sm",
|
||||
isDefault && "ring-2 ring-primary border-primary/30",
|
||||
compact ? "p-3" : "p-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1 min-w-0">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 rounded-lg flex items-center justify-center",
|
||||
compact ? "w-8 h-8 bg-muted" : "w-10 h-10 bg-muted",
|
||||
type === "CreditCard" || type === "RemoteCreditCard"
|
||||
? getCardBrandColor(cardType)
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{getPaymentMethodIcon(type)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderPaymentMethodDetails()}
|
||||
|
||||
{/* Description if different from generated details */}
|
||||
{description && !description.includes(cardLastFour || "") && (
|
||||
<div className="text-sm text-muted-foreground mt-1 truncate">{description}</div>
|
||||
)}
|
||||
|
||||
{/* Default badge */}
|
||||
{isDefault && (
|
||||
<div className="mt-2">
|
||||
<Badge variant="success" size="sm" icon={<CheckCircleIcon className="h-3 w-3" />}>
|
||||
Default (Active)
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{showActions && (
|
||||
<div className="flex-shrink-0 ml-2">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded-md hover:bg-muted"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
// Payment method actions (edit, delete) are handled via dedicated modal flows
|
||||
// Future enhancement: Add dropdown menu for quick actions
|
||||
}}
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gateway info for compact view */}
|
||||
{compact && gatewayName && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">via {gatewayName}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PaymentMethodCard.displayName = "PaymentMethodCard";
|
||||
|
||||
export { PaymentMethodCard };
|
||||
export type { PaymentMethodCardProps };
|
||||
@ -41,13 +41,7 @@ export class CheckoutParamsService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Handle legacy/edge cases not covered by normalization
|
||||
if (typeParam.toLowerCase() === "other") {
|
||||
return ORDER_TYPE.OTHER;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return ORDER_TYPE.INTERNET;
|
||||
throw new Error(`Unsupported order type: ${typeParam}`);
|
||||
}
|
||||
|
||||
private static coalescePlanReference(selections: OrderSelections): string | null {
|
||||
|
||||
@ -1,20 +1,13 @@
|
||||
import { apiClient, getDataOrThrow } from "@/core/api";
|
||||
import type {
|
||||
CheckoutCart,
|
||||
OrderConfigurations,
|
||||
OrderSelections,
|
||||
OrderTypeValue,
|
||||
import {
|
||||
checkoutSessionResponseSchema,
|
||||
type CheckoutCart,
|
||||
type CheckoutSessionResponse,
|
||||
type OrderConfigurations,
|
||||
type OrderSelections,
|
||||
type OrderTypeValue,
|
||||
} from "@customer-portal/domain/orders";
|
||||
|
||||
type CheckoutCartSummary = { items: CheckoutCart["items"]; totals: CheckoutCart["totals"] };
|
||||
|
||||
type CheckoutSessionResponse = {
|
||||
sessionId: string;
|
||||
expiresAt: string;
|
||||
orderType: OrderTypeValue;
|
||||
cart: CheckoutCartSummary;
|
||||
};
|
||||
|
||||
export const checkoutService = {
|
||||
/**
|
||||
* Build checkout cart from order type and selections
|
||||
@ -43,7 +36,8 @@ export const checkoutService = {
|
||||
const response = await apiClient.POST<CheckoutSessionResponse>("/api/checkout/session", {
|
||||
body: { orderType, selections, configuration },
|
||||
});
|
||||
return getDataOrThrow(response, "Failed to create checkout session");
|
||||
const data = getDataOrThrow(response, "Failed to create checkout session");
|
||||
return checkoutSessionResponseSchema.parse(data);
|
||||
},
|
||||
|
||||
async getSession(sessionId: string): Promise<CheckoutSessionResponse> {
|
||||
@ -53,7 +47,8 @@ export const checkoutService = {
|
||||
params: { path: { sessionId } },
|
||||
}
|
||||
);
|
||||
return getDataOrThrow(response, "Failed to load checkout session");
|
||||
const data = getDataOrThrow(response, "Failed to load checkout session");
|
||||
return checkoutSessionResponseSchema.parse(data);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@ -15,6 +15,8 @@ import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
|
||||
import { ordersService } from "@/features/orders/api/orders.api";
|
||||
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
||||
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||||
import { billingService } from "@/features/billing/api/billing.api";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||
import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants";
|
||||
import { useInternetEligibility } from "@/features/services/hooks/useInternetEligibility";
|
||||
@ -24,11 +26,12 @@ import {
|
||||
useSubmitResidenceCard,
|
||||
} from "@/features/verification/hooks/useResidenceCardVerification";
|
||||
import { useAuthSession } from "@/features/auth/stores/auth.store";
|
||||
import { apiClient } from "@/core/api";
|
||||
|
||||
import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders";
|
||||
import { ssoLinkResponseSchema } from "@customer-portal/domain/auth";
|
||||
import { buildPaymentMethodDisplay } from "../utils/checkout-ui-utils";
|
||||
import {
|
||||
ORDER_TYPE,
|
||||
type OrderTypeValue,
|
||||
toOrderTypeValueFromCheckout,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { buildPaymentMethodDisplay, formatAddressLabel } from "@/shared/utils";
|
||||
import { CheckoutStatusBanners } from "./CheckoutStatusBanners";
|
||||
|
||||
export function AccountCheckoutContainer() {
|
||||
@ -46,17 +49,7 @@ export function AccountCheckoutContainer() {
|
||||
const paymentToastTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const orderType: OrderTypeValue | null = useMemo(() => {
|
||||
if (!cartItem?.orderType) return null;
|
||||
switch (cartItem.orderType) {
|
||||
case "Internet":
|
||||
return ORDER_TYPE.INTERNET;
|
||||
case "SIM":
|
||||
return ORDER_TYPE.SIM;
|
||||
case "VPN":
|
||||
return ORDER_TYPE.VPN;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return toOrderTypeValueFromCheckout(cartItem?.orderType);
|
||||
}, [cartItem?.orderType]);
|
||||
|
||||
const isInternetOrder = orderType === ORDER_TYPE.INTERNET;
|
||||
@ -125,15 +118,7 @@ export function AccountCheckoutContainer() {
|
||||
user?.address?.postcode &&
|
||||
(user?.address?.country || user?.address?.countryCode)
|
||||
);
|
||||
const addressLabel = useMemo(() => {
|
||||
const a = user?.address;
|
||||
if (!a) return "";
|
||||
return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode]
|
||||
.filter(Boolean)
|
||||
.map(part => String(part).trim())
|
||||
.filter(part => part.length > 0)
|
||||
.join(", ");
|
||||
}, [user?.address]);
|
||||
const addressLabel = useMemo(() => formatAddressLabel(user?.address), [user?.address]);
|
||||
|
||||
const residenceCardQuery = useResidenceCardVerification();
|
||||
const submitResidenceCard = useSubmitResidenceCard();
|
||||
@ -232,14 +217,11 @@ export function AccountCheckoutContainer() {
|
||||
setOpeningPaymentPortal(true);
|
||||
|
||||
try {
|
||||
const response = await apiClient.POST("/api/auth/sso-link", {
|
||||
body: { destination: "index.php?rp=/account/paymentmethods" },
|
||||
});
|
||||
const data = ssoLinkResponseSchema.parse(response.data);
|
||||
const data = await billingService.createPaymentMethodsSsoLink();
|
||||
if (!data.url) {
|
||||
throw new Error("No payment portal URL returned");
|
||||
}
|
||||
window.open(data.url, "_blank", "noopener,noreferrer");
|
||||
openSsoLink(data.url, { newTab: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to open the payment portal";
|
||||
showPaymentToast(message, "error");
|
||||
@ -442,26 +424,11 @@ export function AccountCheckoutContainer() {
|
||||
Your identity verification is complete.
|
||||
</AlertBanner>
|
||||
|
||||
{residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? (
|
||||
{residenceCardQuery.data?.submittedAt || residenceCardQuery.data?.reviewedAt ? (
|
||||
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Submitted document
|
||||
Verification status
|
||||
</div>
|
||||
{residenceCardQuery.data?.filename ? (
|
||||
<div className="mt-1 text-sm font-medium text-foreground">
|
||||
{residenceCardQuery.data.filename}
|
||||
{typeof residenceCardQuery.data.sizeBytes === "number" &&
|
||||
residenceCardQuery.data.sizeBytes > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{" "}
|
||||
·{" "}
|
||||
{Math.round((residenceCardQuery.data.sizeBytes / 1024 / 1024) * 10) /
|
||||
10}
|
||||
{" MB"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-1 text-xs text-muted-foreground space-y-0.5">
|
||||
{formatDateTime(residenceCardQuery.data?.submittedAt) ? (
|
||||
<div>
|
||||
@ -556,32 +523,13 @@ export function AccountCheckoutContainer() {
|
||||
We’ll verify your residence card before activating SIM service.
|
||||
</AlertBanner>
|
||||
|
||||
{residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? (
|
||||
{residenceCardQuery.data?.submittedAt ? (
|
||||
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Submitted document
|
||||
Submission status
|
||||
</div>
|
||||
{residenceCardQuery.data?.filename ? (
|
||||
<div className="mt-1 text-sm font-medium text-foreground">
|
||||
{residenceCardQuery.data.filename}
|
||||
{typeof residenceCardQuery.data.sizeBytes === "number" &&
|
||||
residenceCardQuery.data.sizeBytes > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{" "}
|
||||
·{" "}
|
||||
{Math.round((residenceCardQuery.data.sizeBytes / 1024 / 1024) * 10) /
|
||||
10}
|
||||
{" MB"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatDateTime(residenceCardQuery.data?.submittedAt) ? (
|
||||
<div>
|
||||
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
|
||||
</div>
|
||||
) : null}
|
||||
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams, useRouter } from "next/navigation";
|
||||
import type { CartItem, OrderType as CheckoutOrderType } from "@customer-portal/domain/checkout";
|
||||
import type { CheckoutCart, OrderTypeValue } from "@customer-portal/domain/orders";
|
||||
import { ORDER_TYPE } from "@customer-portal/domain/orders";
|
||||
import type { CartItem } from "@customer-portal/domain/checkout";
|
||||
import type { CheckoutCartSummary, OrderTypeValue } from "@customer-portal/domain/orders";
|
||||
import { toCheckoutOrderType } from "@customer-portal/domain/orders";
|
||||
import { checkoutService } from "@/features/checkout/api/checkout.api";
|
||||
import { CheckoutParamsService } from "@/features/checkout/api/checkout-params.api";
|
||||
import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
|
||||
@ -15,31 +15,21 @@ import { Button } from "@/components/atoms/button";
|
||||
import { Spinner } from "@/components/atoms";
|
||||
import { EmptyCartRedirect } from "@/features/checkout/components/EmptyCartRedirect";
|
||||
import { useAuthSession, useAuthStore } from "@/features/auth/stores/auth.store";
|
||||
import { logger } from "@/core/logger";
|
||||
|
||||
const signatureFromSearchParams = (params: URLSearchParams): string => {
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
return entries.map(([key, value]) => `${key}=${value}`).join("&");
|
||||
};
|
||||
|
||||
const mapOrderTypeToCheckout = (orderType: OrderTypeValue): CheckoutOrderType => {
|
||||
switch (orderType) {
|
||||
case ORDER_TYPE.SIM:
|
||||
return "SIM";
|
||||
case ORDER_TYPE.VPN:
|
||||
return "VPN";
|
||||
case ORDER_TYPE.INTERNET:
|
||||
case ORDER_TYPE.OTHER:
|
||||
default:
|
||||
return "Internet";
|
||||
}
|
||||
};
|
||||
|
||||
type CheckoutCartSummary = { items: CheckoutCart["items"]; totals: CheckoutCart["totals"] };
|
||||
|
||||
const cartItemFromCheckoutCart = (
|
||||
cart: CheckoutCartSummary,
|
||||
orderType: OrderTypeValue
|
||||
): CartItem => {
|
||||
const checkoutOrderType = toCheckoutOrderType(orderType);
|
||||
if (!checkoutOrderType) {
|
||||
throw new Error(`Unsupported order type: ${orderType}`);
|
||||
}
|
||||
const planItem = cart.items.find(item => item.itemType === "plan") ?? cart.items[0];
|
||||
const planSku = planItem?.sku;
|
||||
if (!planSku) {
|
||||
@ -50,7 +40,7 @@ const cartItemFromCheckoutCart = (
|
||||
);
|
||||
|
||||
return {
|
||||
orderType: mapOrderTypeToCheckout(orderType),
|
||||
orderType: checkoutOrderType,
|
||||
planSku,
|
||||
planName: planItem?.name ?? planSku,
|
||||
addonSkus,
|
||||
@ -107,6 +97,11 @@ export function CheckoutEntry() {
|
||||
void (async () => {
|
||||
try {
|
||||
const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
|
||||
if (snapshot.warnings.length > 0) {
|
||||
logger.warn("Checkout params normalization warnings", {
|
||||
warnings: snapshot.warnings,
|
||||
});
|
||||
}
|
||||
if (!snapshot.planReference) {
|
||||
throw new Error("No plan selected. Please go back and select a plan.");
|
||||
}
|
||||
@ -171,6 +166,22 @@ export function CheckoutEntry() {
|
||||
};
|
||||
}, [checkoutSessionId, clear, isCartStale, paramsKey, setCartItem, setCheckoutSession]);
|
||||
|
||||
// Redirect unauthenticated users to login
|
||||
// Cart data is preserved in localStorage, so they can continue after logging in
|
||||
const shouldRedirectToLogin = !isAuthenticated && hasCheckedAuth;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRedirectToLogin) return;
|
||||
|
||||
const currentUrl = pathname + (paramsKey ? `?${paramsKey}` : "");
|
||||
const returnTo = encodeURIComponent(
|
||||
pathname.startsWith("/account")
|
||||
? currentUrl
|
||||
: `/account/order${paramsKey ? `?${paramsKey}` : ""}`
|
||||
);
|
||||
router.replace(`/auth/login?returnTo=${returnTo}`);
|
||||
}, [shouldRedirectToLogin, pathname, paramsKey, router]);
|
||||
|
||||
const shouldWaitForCart =
|
||||
(Boolean(paramsKey) && (!cartItem || cartParamsSignature !== signature)) ||
|
||||
(!paramsKey && Boolean(checkoutSessionId) && !cartItem);
|
||||
@ -211,16 +222,7 @@ export function CheckoutEntry() {
|
||||
return <EmptyCartRedirect />;
|
||||
}
|
||||
|
||||
// Redirect unauthenticated users to login
|
||||
// Cart data is preserved in localStorage, so they can continue after logging in
|
||||
if (!isAuthenticated && hasCheckedAuth) {
|
||||
const currentUrl = pathname + (paramsKey ? `?${paramsKey}` : "");
|
||||
const returnTo = encodeURIComponent(
|
||||
pathname.startsWith("/account")
|
||||
? currentUrl
|
||||
: `/account/order${paramsKey ? `?${paramsKey}` : ""}`
|
||||
);
|
||||
router.replace(`/auth/login?returnTo=${returnTo}`);
|
||||
if (shouldRedirectToLogin) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-12">
|
||||
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)] text-center">
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
import { confirmWithAddress } from "@/shared/utils";
|
||||
|
||||
interface CheckoutStatusBannersProps {
|
||||
activeInternetWarning: string | null;
|
||||
@ -90,11 +91,10 @@ export function CheckoutStatusBanners({
|
||||
loadingText="Requesting…"
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
const confirmed =
|
||||
typeof window === "undefined" ||
|
||||
window.confirm(
|
||||
`Request an eligibility review for this address?\n\n${addressLabel}`
|
||||
);
|
||||
const confirmed = confirmWithAddress(
|
||||
"Request an eligibility review for this address?",
|
||||
addressLabel
|
||||
);
|
||||
if (!confirmed) return;
|
||||
eligibilityRequest.mutate({
|
||||
address: userAddress ?? undefined,
|
||||
|
||||
@ -14,6 +14,3 @@ export * from "./components";
|
||||
|
||||
// Constants
|
||||
export * from "./constants";
|
||||
|
||||
// Utilities
|
||||
export * from "./utils";
|
||||
|
||||
@ -25,7 +25,6 @@ interface CheckoutActions {
|
||||
setCartItem: (item: CartItem) => void;
|
||||
setCartItemFromParams: (item: CartItem, signature: string) => void;
|
||||
setCheckoutSession: (session: { id: string; expiresAt: string }) => void;
|
||||
clearCart: () => void;
|
||||
|
||||
// Reset
|
||||
clear: () => void;
|
||||
@ -87,21 +86,18 @@ export const useCheckoutStore = create<CheckoutStore>()(
|
||||
checkoutSessionExpiresAt: session.expiresAt,
|
||||
}),
|
||||
|
||||
clearCart: () =>
|
||||
set({
|
||||
cartItem: null,
|
||||
cartParamsSignature: null,
|
||||
checkoutSessionId: null,
|
||||
checkoutSessionExpiresAt: null,
|
||||
cartUpdatedAt: null,
|
||||
}),
|
||||
|
||||
// Reset
|
||||
clear: () => set(initialState),
|
||||
|
||||
// Cart recovery - check if cart is stale (default 24 hours)
|
||||
isCartStale: (maxAgeMs = 24 * 60 * 60 * 1000) => {
|
||||
const { cartUpdatedAt } = get();
|
||||
const { cartUpdatedAt, checkoutSessionExpiresAt } = get();
|
||||
if (checkoutSessionExpiresAt) {
|
||||
const expiresAt = new Date(checkoutSessionExpiresAt).getTime();
|
||||
if (!Number.isNaN(expiresAt) && Date.now() >= expiresAt) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!cartUpdatedAt) return false;
|
||||
return Date.now() - cartUpdatedAt > maxAgeMs;
|
||||
},
|
||||
@ -150,10 +146,3 @@ export const useCheckoutStore = create<CheckoutStore>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to check if cart has items
|
||||
*/
|
||||
export function useHasCartItem(): boolean {
|
||||
return useCheckoutStore(state => state.cartItem !== null);
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
export { useCheckoutStore, useHasCartItem } from "./checkout.store";
|
||||
export { useCheckoutStore } from "./checkout.store";
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./checkout-ui-utils";
|
||||
@ -2,18 +2,13 @@
|
||||
|
||||
import { ReactNode, useMemo, type KeyboardEvent } from "react";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import {
|
||||
WifiIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
LockClosedIcon,
|
||||
CubeIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
calculateOrderTotals,
|
||||
deriveOrderStatusDescriptor,
|
||||
getServiceCategory,
|
||||
} from "@/features/orders/utils/order-presenters";
|
||||
import { buildOrderDisplayItems } from "@/features/orders/utils/order-display";
|
||||
import { OrderServiceIcon } from "@/features/orders/components/OrderServiceIcon";
|
||||
import type { OrderSummary } from "@customer-portal/domain/orders";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
@ -40,20 +35,6 @@ const SERVICE_ICON_STYLES = {
|
||||
default: "bg-muted text-muted-foreground border border-border",
|
||||
} as const;
|
||||
|
||||
const renderServiceIcon = (orderType?: string): ReactNode => {
|
||||
const category = getServiceCategory(orderType);
|
||||
switch (category) {
|
||||
case "internet":
|
||||
return <WifiIcon className="h-6 w-6" />;
|
||||
case "sim":
|
||||
return <DevicePhoneMobileIcon className="h-6 w-6" />;
|
||||
case "vpn":
|
||||
return <LockClosedIcon className="h-6 w-6" />;
|
||||
default:
|
||||
return <CubeIcon className="h-6 w-6" />;
|
||||
}
|
||||
};
|
||||
|
||||
export function OrderCard({ order, onClick, footer, className }: OrderCardProps) {
|
||||
const statusDescriptor = deriveOrderStatusDescriptor({
|
||||
status: order.status,
|
||||
@ -62,7 +43,7 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
||||
const statusVariant = STATUS_PILL_VARIANT[statusDescriptor.tone];
|
||||
const serviceCategory = getServiceCategory(order.orderType);
|
||||
const iconStyles = SERVICE_ICON_STYLES[serviceCategory];
|
||||
const serviceIcon = renderServiceIcon(order.orderType);
|
||||
const serviceIcon = <OrderServiceIcon orderType={order.orderType} />;
|
||||
const displayItems = useMemo(
|
||||
() => buildOrderDisplayItems(order.itemsSummary),
|
||||
[order.itemsSummary]
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
import {
|
||||
WifiIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
LockClosedIcon,
|
||||
CubeIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { getServiceCategory } from "@/features/orders/utils/order-presenters";
|
||||
|
||||
interface OrderServiceIconProps {
|
||||
orderType?: string;
|
||||
category?: ReturnType<typeof getServiceCategory>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OrderServiceIcon({
|
||||
orderType,
|
||||
category,
|
||||
className = "h-6 w-6",
|
||||
}: OrderServiceIconProps) {
|
||||
const resolvedCategory = category ?? getServiceCategory(orderType);
|
||||
|
||||
switch (resolvedCategory) {
|
||||
case "internet":
|
||||
return <WifiIcon className={className} />;
|
||||
case "sim":
|
||||
return <DevicePhoneMobileIcon className={className} />;
|
||||
case "vpn":
|
||||
return <LockClosedIcon className={className} />;
|
||||
default:
|
||||
return <CubeIcon className={className} />;
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export { OrderCard } from "./OrderCard";
|
||||
export { OrderCardSkeleton } from "./OrderCardSkeleton";
|
||||
export { OrderServiceIcon } from "./OrderServiceIcon";
|
||||
|
||||
@ -6,10 +6,6 @@ import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import {
|
||||
ClipboardDocumentCheckIcon,
|
||||
CheckCircleIcon,
|
||||
WifiIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
LockClosedIcon,
|
||||
CubeIcon,
|
||||
SparklesIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
PuzzlePieceIcon,
|
||||
@ -32,6 +28,7 @@ import {
|
||||
type OrderDisplayItemCategory,
|
||||
type OrderDisplayItemCharge,
|
||||
} from "@/features/orders/utils/order-display";
|
||||
import { OrderServiceIcon } from "@/features/orders/components/OrderServiceIcon";
|
||||
import type { OrderDetails, OrderUpdateEventPayload } from "@customer-portal/domain/orders";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import { cn, formatIsoDate } from "@/shared/utils";
|
||||
@ -46,19 +43,6 @@ const STATUS_PILL_VARIANT: Record<
|
||||
neutral: "neutral",
|
||||
};
|
||||
|
||||
const renderServiceIcon = (category: ReturnType<typeof getServiceCategory>, className: string) => {
|
||||
switch (category) {
|
||||
case "internet":
|
||||
return <WifiIcon className={className} />;
|
||||
case "sim":
|
||||
return <DevicePhoneMobileIcon className={className} />;
|
||||
case "vpn":
|
||||
return <LockClosedIcon className={className} />;
|
||||
default:
|
||||
return <CubeIcon className={className} />;
|
||||
}
|
||||
};
|
||||
|
||||
const CATEGORY_CONFIG: Record<
|
||||
OrderDisplayItemCategory,
|
||||
{
|
||||
@ -159,7 +143,7 @@ export function OrderDetailContainer() {
|
||||
: STATUS_PILL_VARIANT.neutral;
|
||||
|
||||
const serviceCategory = getServiceCategory(data?.orderType);
|
||||
const serviceIcon = renderServiceIcon(serviceCategory, "h-6 w-6");
|
||||
const serviceIcon = <OrderServiceIcon category={serviceCategory} className="h-6 w-6" />;
|
||||
|
||||
const displayItems = useMemo<OrderDisplayItem[]>(() => {
|
||||
return buildOrderDisplayItems(data?.itemsSummary);
|
||||
|
||||
@ -9,6 +9,7 @@ import { ServicesBackLink } from "@/features/services/components/base/ServicesBa
|
||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||
import { useInternetEligibility } from "@/features/services/hooks";
|
||||
import { useAuthSession } from "@/features/auth/stores/auth.store";
|
||||
import { formatAddressLabel } from "@/shared/utils";
|
||||
|
||||
export function InternetEligibilityRequestSubmittedView() {
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
@ -18,15 +19,7 @@ export function InternetEligibilityRequestSubmittedView() {
|
||||
const { user } = useAuthSession();
|
||||
const eligibilityQuery = useInternetEligibility();
|
||||
|
||||
const addressLabel = useMemo(() => {
|
||||
const a = user?.address;
|
||||
if (!a) return "";
|
||||
return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode]
|
||||
.filter(Boolean)
|
||||
.map(part => String(part).trim())
|
||||
.filter(part => part.length > 0)
|
||||
.join(", ");
|
||||
}, [user?.address]);
|
||||
const addressLabel = useMemo(() => formatAddressLabel(user?.address), [user?.address]);
|
||||
|
||||
const requestId = requestIdFromQuery ?? eligibilityQuery.data?.requestId ?? null;
|
||||
const status = eligibilityQuery.data?.status;
|
||||
|
||||
@ -27,7 +27,7 @@ import {
|
||||
useRequestInternetEligibilityCheck,
|
||||
} from "@/features/services/hooks";
|
||||
import { useAuthSession } from "@/features/auth/stores/auth.store";
|
||||
import { cn, formatIsoDate } from "@/shared/utils";
|
||||
import { cn, confirmWithAddress, formatAddressLabel, formatIsoDate } from "@/shared/utils";
|
||||
|
||||
type AutoRequestStatus = "idle" | "submitting" | "submitted" | "failed" | "missing_address";
|
||||
|
||||
@ -344,15 +344,7 @@ export function InternetPlansContainer() {
|
||||
const autoPlanSku = searchParams?.get("planSku");
|
||||
const [autoRequestStatus, setAutoRequestStatus] = useState<AutoRequestStatus>("idle");
|
||||
const [autoRequestId, setAutoRequestId] = useState<string | null>(null);
|
||||
const addressLabel = useMemo(() => {
|
||||
const a = user?.address;
|
||||
if (!a) return "";
|
||||
return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode]
|
||||
.filter(Boolean)
|
||||
.map(part => String(part).trim())
|
||||
.filter(part => part.length > 0)
|
||||
.join(", ");
|
||||
}, [user?.address]);
|
||||
const addressLabel = useMemo(() => formatAddressLabel(user?.address), [user?.address]);
|
||||
|
||||
const eligibility = useMemo(() => {
|
||||
if (!isEligible) return null;
|
||||
@ -397,9 +389,7 @@ export function InternetPlansContainer() {
|
||||
}
|
||||
|
||||
// Trigger eligibility check
|
||||
const confirmed =
|
||||
typeof window === "undefined" ||
|
||||
window.confirm(`Request availability check for:\n\n${addressLabel}`);
|
||||
const confirmed = confirmWithAddress("Request availability check for:", addressLabel);
|
||||
if (!confirmed) return;
|
||||
|
||||
setAutoRequestId(null);
|
||||
|
||||
@ -26,30 +26,26 @@ const ICON_SIZE_CLASSES: Record<IconSize, string> = {
|
||||
|
||||
/**
|
||||
* Status to icon mapping
|
||||
* Uses customer-friendly status values (internal statuses are mapped before reaching here)
|
||||
*/
|
||||
const STATUS_ICON_MAP: Record<string, (className: string) => ReactNode> = {
|
||||
[SUPPORT_CASE_STATUS.RESOLVED]: cls => <CheckCircleIcon className={`${cls} text-success`} />,
|
||||
[SUPPORT_CASE_STATUS.CLOSED]: cls => <CheckCircleIcon className={`${cls} text-success`} />,
|
||||
[SUPPORT_CASE_STATUS.VPN_PENDING]: cls => <CheckCircleIcon className={`${cls} text-success`} />,
|
||||
[SUPPORT_CASE_STATUS.PENDING]: cls => <CheckCircleIcon className={`${cls} text-success`} />,
|
||||
[SUPPORT_CASE_STATUS.NEW]: cls => <SparklesIcon className={`${cls} text-primary`} />,
|
||||
[SUPPORT_CASE_STATUS.IN_PROGRESS]: cls => <ClockIcon className={`${cls} text-info`} />,
|
||||
[SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: cls => (
|
||||
[SUPPORT_CASE_STATUS.AWAITING_CUSTOMER]: cls => (
|
||||
<ExclamationTriangleIcon className={`${cls} text-warning`} />
|
||||
),
|
||||
[SUPPORT_CASE_STATUS.NEW]: cls => <SparklesIcon className={`${cls} text-primary`} />,
|
||||
[SUPPORT_CASE_STATUS.CLOSED]: cls => <CheckCircleIcon className={`${cls} text-success`} />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Status to variant mapping
|
||||
* Uses customer-friendly status values (internal statuses are mapped before reaching here)
|
||||
*/
|
||||
const STATUS_VARIANT_MAP: Record<string, CaseStatusVariant> = {
|
||||
[SUPPORT_CASE_STATUS.RESOLVED]: "success",
|
||||
[SUPPORT_CASE_STATUS.CLOSED]: "success",
|
||||
[SUPPORT_CASE_STATUS.VPN_PENDING]: "success",
|
||||
[SUPPORT_CASE_STATUS.PENDING]: "success",
|
||||
[SUPPORT_CASE_STATUS.IN_PROGRESS]: "info",
|
||||
[SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: "warning",
|
||||
[SUPPORT_CASE_STATUS.NEW]: "purple",
|
||||
[SUPPORT_CASE_STATUS.IN_PROGRESS]: "info",
|
||||
[SUPPORT_CASE_STATUS.AWAITING_CUSTOMER]: "warning",
|
||||
[SUPPORT_CASE_STATUS.CLOSED]: "success",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
@ -9,6 +9,7 @@ import {
|
||||
PaperAirplaneIcon,
|
||||
EnvelopeIcon,
|
||||
ChatBubbleLeftIcon,
|
||||
PaperClipIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
@ -26,6 +27,64 @@ import { formatIsoDate, formatIsoRelative } from "@/shared/utils";
|
||||
import type { CaseMessage } from "@customer-portal/domain/support";
|
||||
import { CLOSED_STATUSES } from "@customer-portal/domain/support";
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Group messages by date for better readability in long conversations
|
||||
*/
|
||||
interface MessageGroup {
|
||||
date: string;
|
||||
dateLabel: string;
|
||||
messages: CaseMessage[];
|
||||
}
|
||||
|
||||
function groupMessagesByDate(messages: CaseMessage[]): MessageGroup[] {
|
||||
const groups: Map<string, CaseMessage[]> = new Map();
|
||||
|
||||
for (const message of messages) {
|
||||
const date = new Date(message.createdAt);
|
||||
const dateKey = date.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
|
||||
if (!groups.has(dateKey)) {
|
||||
groups.set(dateKey, []);
|
||||
}
|
||||
groups.get(dateKey)!.push(message);
|
||||
}
|
||||
|
||||
// Convert to array and add labels
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const todayKey = today.toISOString().split("T")[0];
|
||||
const yesterdayKey = yesterday.toISOString().split("T")[0];
|
||||
|
||||
return Array.from(groups.entries()).map(([dateKey, msgs]) => {
|
||||
let dateLabel: string;
|
||||
if (dateKey === todayKey) {
|
||||
dateLabel = "Today";
|
||||
} else if (dateKey === yesterdayKey) {
|
||||
dateLabel = "Yesterday";
|
||||
} else {
|
||||
// Format as "Mon, Dec 30"
|
||||
const date = new Date(dateKey + "T00:00:00");
|
||||
dateLabel = date.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
date: dateKey,
|
||||
dateLabel,
|
||||
messages: msgs,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface SupportCaseDetailViewProps {
|
||||
caseId: string;
|
||||
}
|
||||
@ -175,49 +234,11 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{messagesLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
) : messagesData?.messages && messagesData.messages.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{/* Original Description as first message */}
|
||||
<MessageBubble
|
||||
message={{
|
||||
id: "description",
|
||||
type: "comment",
|
||||
body: supportCase.description,
|
||||
author: { name: "You", email: null, isCustomer: true },
|
||||
createdAt: supportCase.createdAt,
|
||||
direction: null,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Conversation messages */}
|
||||
{messagesData.messages.map(message => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Show description as the only message if no conversation yet */}
|
||||
<MessageBubble
|
||||
message={{
|
||||
id: "description",
|
||||
type: "comment",
|
||||
body: supportCase.description,
|
||||
author: { name: "You", email: null, isCustomer: true },
|
||||
createdAt: supportCase.createdAt,
|
||||
direction: null,
|
||||
}}
|
||||
/>
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
No replies yet. Our team will respond shortly.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConversationThread
|
||||
supportCase={supportCase}
|
||||
messagesData={messagesData}
|
||||
messagesLoading={messagesLoading}
|
||||
/>
|
||||
|
||||
{/* Reply Form */}
|
||||
{!isCaseClosed && (
|
||||
@ -279,12 +300,97 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conversation Components
|
||||
// ============================================================================
|
||||
|
||||
interface ConversationThreadProps {
|
||||
supportCase: {
|
||||
description: string;
|
||||
createdAt: string;
|
||||
};
|
||||
messagesData: { messages: CaseMessage[] } | undefined;
|
||||
messagesLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation thread with date grouping
|
||||
*/
|
||||
function ConversationThread({
|
||||
supportCase,
|
||||
messagesData,
|
||||
messagesLoading,
|
||||
}: ConversationThreadProps) {
|
||||
// Create initial description message
|
||||
const descriptionMessage: CaseMessage = useMemo(
|
||||
() => ({
|
||||
id: "description",
|
||||
type: "comment",
|
||||
body: supportCase.description,
|
||||
author: { name: "You", email: null, isCustomer: true },
|
||||
createdAt: supportCase.createdAt,
|
||||
direction: null,
|
||||
}),
|
||||
[supportCase.description, supportCase.createdAt]
|
||||
);
|
||||
|
||||
// Combine description with messages and group by date
|
||||
const messageGroups = useMemo(() => {
|
||||
const allMessages = [descriptionMessage, ...(messagesData?.messages ?? [])];
|
||||
return groupMessagesByDate(allMessages);
|
||||
}, [descriptionMessage, messagesData?.messages]);
|
||||
|
||||
if (messagesLoading) {
|
||||
return (
|
||||
<div className="p-5 flex items-center justify-center py-8">
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasReplies = (messagesData?.messages?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="space-y-6">
|
||||
{messageGroups.map(group => (
|
||||
<div key={group.date}>
|
||||
{/* Date separator */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs font-medium text-muted-foreground px-2">
|
||||
{group.dateLabel}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Messages for this date */}
|
||||
<div className="space-y-4">
|
||||
{group.messages.map(message => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* No replies yet message */}
|
||||
{!hasReplies && (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
No replies yet. Our team will respond shortly.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Message bubble component for displaying individual messages
|
||||
*/
|
||||
function MessageBubble({ message }: { message: CaseMessage }) {
|
||||
const isCustomer = message.author.isCustomer;
|
||||
const isEmail = message.type === "email";
|
||||
const hasAttachment = message.hasAttachment === true;
|
||||
|
||||
return (
|
||||
<div className={`flex ${isCustomer ? "justify-end" : "justify-start"}`}>
|
||||
@ -309,6 +415,15 @@ function MessageBubble({ message }: { message: CaseMessage }) {
|
||||
<span className="font-medium">{message.author.name}</span>
|
||||
<span>•</span>
|
||||
<span>{formatIsoRelative(message.createdAt)}</span>
|
||||
{hasAttachment && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<PaperClipIcon className="h-3 w-3" />
|
||||
Attachment
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message body */}
|
||||
|
||||
@ -95,21 +95,14 @@ export function ResidenceCardVerificationSettingsView() {
|
||||
We'll verify your residence card before activating SIM service.
|
||||
</AlertBanner>
|
||||
|
||||
{(residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt) && (
|
||||
{residenceCardQuery.data?.submittedAt && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Submitted document
|
||||
Submission status
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
|
||||
</div>
|
||||
{residenceCardQuery.data?.filename && (
|
||||
<div className="mt-1 text-sm font-medium text-foreground">
|
||||
{residenceCardQuery.data.filename}
|
||||
</div>
|
||||
)}
|
||||
{formatDateTime(residenceCardQuery.data?.submittedAt) && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -140,18 +133,11 @@ export function ResidenceCardVerificationSettingsView() {
|
||||
</div>
|
||||
</AlertBanner>
|
||||
|
||||
{(residenceCardQuery.data?.filename ||
|
||||
residenceCardQuery.data?.submittedAt ||
|
||||
residenceCardQuery.data?.reviewedAt) && (
|
||||
{(residenceCardQuery.data?.submittedAt || residenceCardQuery.data?.reviewedAt) && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Latest submission
|
||||
</div>
|
||||
{residenceCardQuery.data?.filename && (
|
||||
<div className="mt-1 text-sm font-medium text-foreground">
|
||||
{residenceCardQuery.data.filename}
|
||||
</div>
|
||||
)}
|
||||
{formatDateTime(residenceCardQuery.data?.submittedAt) && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
|
||||
|
||||
17
apps/portal/src/shared/utils/address.ts
Normal file
17
apps/portal/src/shared/utils/address.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
|
||||
export function formatAddressLabel(address?: Partial<Address> | null): string {
|
||||
if (!address) return "";
|
||||
return [
|
||||
address.address1,
|
||||
address.address2,
|
||||
address.city,
|
||||
address.state,
|
||||
address.postcode,
|
||||
address.country || address.countryCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map(part => String(part).trim())
|
||||
.filter(part => part.length > 0)
|
||||
.join(", ");
|
||||
}
|
||||
9
apps/portal/src/shared/utils/confirm.ts
Normal file
9
apps/portal/src/shared/utils/confirm.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function confirmWithAddress(message: string, addressLabel?: string): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const label = addressLabel?.trim();
|
||||
const suffix = label ? `\n\n${label}` : "";
|
||||
return window.confirm(`${message}${suffix}`);
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
export { cn } from "./cn";
|
||||
export { formatAddressLabel } from "./address";
|
||||
export { confirmWithAddress } from "./confirm";
|
||||
export {
|
||||
formatIsoDate,
|
||||
formatIsoRelative,
|
||||
@ -19,3 +21,9 @@ export {
|
||||
type ParsedError,
|
||||
type ErrorCodeType,
|
||||
} from "./error-handling";
|
||||
export {
|
||||
buildPaymentMethodDisplay,
|
||||
getPaymentMethodBrandLabel,
|
||||
getPaymentMethodCardDisplay,
|
||||
normalizeExpiryLabel,
|
||||
} from "./payment-methods";
|
||||
|
||||
@ -1,5 +1,16 @@
|
||||
import type { PaymentMethod } from "@customer-portal/domain/payments";
|
||||
|
||||
const isCreditCard = (type: PaymentMethod["type"]) =>
|
||||
type === "CreditCard" || type === "RemoteCreditCard";
|
||||
|
||||
const isBankAccount = (type: PaymentMethod["type"]) =>
|
||||
type === "BankAccount" || type === "RemoteBankAccount";
|
||||
|
||||
const getTrimmedLastFour = (method: PaymentMethod): string | null => {
|
||||
const lastFour = typeof method.cardLastFour === "string" ? method.cardLastFour.trim() : "";
|
||||
return lastFour.length > 0 ? lastFour.slice(-4) : null;
|
||||
};
|
||||
|
||||
export function buildPaymentMethodDisplay(method: PaymentMethod): {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
@ -11,10 +22,7 @@ export function buildPaymentMethodDisplay(method: PaymentMethod): {
|
||||
method.gatewayName?.trim() ||
|
||||
"Saved payment method";
|
||||
|
||||
const trimmedLastFour =
|
||||
typeof method.cardLastFour === "string" && method.cardLastFour.trim().length > 0
|
||||
? method.cardLastFour.trim().slice(-4)
|
||||
: null;
|
||||
const trimmedLastFour = getTrimmedLastFour(method);
|
||||
|
||||
const headline =
|
||||
trimmedLastFour && method.type?.toLowerCase().includes("card")
|
||||
@ -79,3 +87,32 @@ export function normalizeExpiryLabel(expiry?: string | null): string | null {
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getPaymentMethodCardDisplay(method: PaymentMethod): string {
|
||||
const trimmedLastFour = getTrimmedLastFour(method);
|
||||
if (trimmedLastFour) {
|
||||
return `***** ${trimmedLastFour}`;
|
||||
}
|
||||
|
||||
if (isCreditCard(method.type)) {
|
||||
return method.cardType ? `${method.cardType.toUpperCase()} Card` : "Credit Card";
|
||||
}
|
||||
|
||||
if (isBankAccount(method.type)) {
|
||||
return method.bankName || "Bank Account";
|
||||
}
|
||||
|
||||
return method.description || "Payment Method";
|
||||
}
|
||||
|
||||
export function getPaymentMethodBrandLabel(method: PaymentMethod): string | null {
|
||||
if (isCreditCard(method.type) && method.cardType) {
|
||||
return method.cardType.toUpperCase();
|
||||
}
|
||||
|
||||
if (isBankAccount(method.type) && method.bankName) {
|
||||
return method.bankName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -20,42 +20,79 @@ The response includes:
|
||||
|
||||
Portal UI maps task `type` → icon locally; everything else (priority, copy, links) is computed server-side.
|
||||
|
||||
## In-app notifications
|
||||
## In-app Notifications
|
||||
|
||||
In-app notifications are stored in Postgres and fetched via the Notifications API. Notifications use domain templates in:
|
||||
|
||||
- `packages/domain/notifications/schema.ts`
|
||||
|
||||
### Where notifications are created
|
||||
### All Notification Types
|
||||
|
||||
- **Eligibility / Verification**:
|
||||
- Triggered from Salesforce events (Account fields change).
|
||||
- Created by the Salesforce events handlers.
|
||||
| Type | Title | Created By | Trigger |
|
||||
| ------------------------- | ------------------------------------------ | ---------------------- | ------------------------------- |
|
||||
| `ELIGIBILITY_ELIGIBLE` | "Good news! Internet service is available" | Platform Event | Eligibility status → Eligible |
|
||||
| `ELIGIBILITY_INELIGIBLE` | "Internet service not available" | Platform Event | Eligibility status → Ineligible |
|
||||
| `VERIFICATION_VERIFIED` | "ID verification complete" | Platform Event | Verification status → Verified |
|
||||
| `VERIFICATION_REJECTED` | "ID verification requires attention" | Platform Event | Verification status → Rejected |
|
||||
| `ORDER_APPROVED` | "Order approved" | Fulfillment flow | Order approved in Salesforce |
|
||||
| `ORDER_ACTIVATED` | "Service activated" | Fulfillment flow | WHMCS provisioning complete |
|
||||
| `ORDER_FAILED` | "Order requires attention" | Fulfillment flow | Fulfillment error |
|
||||
| `CANCELLATION_SCHEDULED` | "Cancellation scheduled" | Cancellation flow | Customer requests cancellation |
|
||||
| `CANCELLATION_COMPLETE` | "Service cancelled" | _(Not implemented)_ | — |
|
||||
| `PAYMENT_METHOD_EXPIRING` | "Payment method expiring soon" | _(Not implemented)_ | — |
|
||||
| `INVOICE_DUE` | "Invoice due" | Dashboard status check | Invoice due within 7 days |
|
||||
| `SYSTEM_ANNOUNCEMENT` | "System announcement" | _(Not implemented)_ | — |
|
||||
|
||||
- **Orders**:
|
||||
- **Approved / Activated / Failed** notifications are created during the fulfillment workflow:
|
||||
- `apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts`
|
||||
- The notification `sourceId` uses the Salesforce Order Id to prevent duplicates during retries.
|
||||
### Eligibility & Verification Notifications
|
||||
|
||||
- **Cancellations**:
|
||||
- A “Cancellation scheduled” notification is created when the cancellation request is submitted:
|
||||
- Internet: `apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts`
|
||||
- SIM: `apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts`
|
||||
These are triggered by Salesforce Platform Events:
|
||||
|
||||
- **Invoice due**:
|
||||
- Created opportunistically when the dashboard status is requested (`GET /api/me/status`) if an invoice is due within 7 days (or overdue).
|
||||
1. **Salesforce Flow** fires Platform Event when `Internet_Eligibility_Status__c` or `Id_Verification_Status__c` changes
|
||||
2. **BFF subscriber** receives the event and extracts status fields
|
||||
3. **Notification handler** creates notification only for final states:
|
||||
- Eligibility: `Eligible` or `Ineligible` (not `Pending`)
|
||||
- Verification: `Verified` or `Rejected` (not `Submitted`)
|
||||
|
||||
### Dedupe behavior
|
||||
**Important:** The Salesforce Flow must use `ISCHANGED()` to only include status fields when they actually changed. Otherwise, notifications would be created on every account update.
|
||||
|
||||
See `docs/integrations/salesforce/platform-events.md` for Platform Event configuration.
|
||||
|
||||
### Order Notifications
|
||||
|
||||
Created during the fulfillment workflow in:
|
||||
|
||||
- `apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts`
|
||||
|
||||
The notification `sourceId` uses the Salesforce Order ID to prevent duplicates during retries.
|
||||
|
||||
### Cancellation Notifications
|
||||
|
||||
Created when cancellation request is submitted:
|
||||
|
||||
- Internet: `apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts`
|
||||
- SIM: `apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts`
|
||||
|
||||
### Invoice Due Notifications
|
||||
|
||||
Created opportunistically when dashboard status is requested (`GET /api/me/status`) if an invoice is due within 7 days or overdue.
|
||||
|
||||
### Dedupe Behavior
|
||||
|
||||
Notifications dedupe is enforced in:
|
||||
|
||||
- `apps/bff/src/modules/notifications/notifications.service.ts`
|
||||
|
||||
Rules:
|
||||
|
||||
- For most types: dedupe is **type + sourceId within 1 hour**.
|
||||
- For “reminder-style” types (invoice due, payment method expiring, system announcement): dedupe is **type + sourceId within 24 hours**.
|
||||
| Type Category | Dedupe Window | Logic |
|
||||
| -------------------------------------------- | ------------- | ------------------------ |
|
||||
| Most types | 1 hour | Same `type` + `sourceId` |
|
||||
| Invoice due, payment expiring, announcements | 24 hours | Same `type` + `sourceId` |
|
||||
|
||||
### Action URLs
|
||||
|
||||
Notification templates use **authenticated Portal routes** (e.g. `/account/orders`, `/account/services`, `/account/billing/*`) so clicks always land in the correct shell.
|
||||
Notification templates use authenticated Portal routes:
|
||||
|
||||
- `/account/orders` - Order notifications
|
||||
- `/account/services` - Activation/cancellation notifications
|
||||
- `/account/services/internet` - Eligibility notifications
|
||||
- `/account/settings/verification` - Verification notifications
|
||||
- `/account/billing/invoices` - Invoice notifications
|
||||
|
||||
@ -28,17 +28,42 @@ This guide describes how eligibility and verification work in the customer porta
|
||||
7. Salesforce Flow sends email notification to customer
|
||||
8. Customer returns and sees eligible plans
|
||||
|
||||
### Caching & Rate Limiting (Security + Load)
|
||||
### Caching & Real-Time Updates
|
||||
|
||||
- **BFF cache (Redis)**:
|
||||
- Internet catalog data is cached in Redis (CDC-driven invalidation, no TTL) so repeated portal hits **do not repeatedly query Salesforce**.
|
||||
- Eligibility details are cached per Salesforce Account ID and are invalidated/updated when Salesforce emits Account change events.
|
||||
- **Portal cache (React Query)**:
|
||||
- The portal caches service catalog responses in-memory, scoped by auth state, and will refetch when stale.
|
||||
- On logout, the portal clears cached queries to avoid cross-user leakage on shared devices.
|
||||
- **Rate limiting**:
|
||||
- Public catalog endpoints are rate-limited per IP + User-Agent to prevent abuse.
|
||||
- `POST /api/services/internet/eligibility-request` is authenticated and rate-limited, and the BFF is idempotent when a request is already pending (no duplicate Cases created).
|
||||
#### Redis Caching (BFF)
|
||||
|
||||
| Cache Key | Content | Invalidation |
|
||||
| ----------------------------------- | --------------------------- | -------------- |
|
||||
| `services:eligibility:{accountId}` | Eligibility status & value | Platform Event |
|
||||
| `services:verification:{accountId}` | Verification status & dates | Platform Event |
|
||||
|
||||
Both caches use CDC-driven invalidation with a safety TTL (12 hours default). When Salesforce sends a Platform Event, the BFF:
|
||||
|
||||
1. Invalidates both caches (eligibility + verification)
|
||||
2. Sends SSE `account.updated` to connected portals
|
||||
3. Portal refetches fresh data
|
||||
|
||||
#### Platform Event Integration
|
||||
|
||||
See `docs/integrations/salesforce/platform-events.md` for full details.
|
||||
|
||||
**Account Update Event** fires when any of these fields change:
|
||||
|
||||
- `Internet_Eligibility__c`, `Internet_Eligibility_Status__c`
|
||||
- `Id_Verification_Status__c`, `Id_Verification_Rejection_Message__c`
|
||||
|
||||
The Flow should use `ISCHANGED()` to only include status fields when they actually changed (not on every field change).
|
||||
|
||||
#### Portal Cache (React Query)
|
||||
|
||||
- Service catalog responses are cached in-memory, scoped by auth state
|
||||
- SSE events trigger automatic refetch
|
||||
- On logout, cached queries are cleared
|
||||
|
||||
#### Rate Limiting
|
||||
|
||||
- Public catalog endpoints: rate-limited per IP + User-Agent
|
||||
- `POST /api/services/internet/eligibility-request`: authenticated, rate-limited, idempotent
|
||||
|
||||
### Subscription Type Detection
|
||||
|
||||
|
||||
@ -4,8 +4,8 @@ How the portal surfaces and creates support cases for customers.
|
||||
|
||||
## Data Source & Scope
|
||||
|
||||
- Cases are read and written directly in Salesforce. Origin is set to “Portal Website.”
|
||||
- The portal only shows cases for the customer’s mapped Salesforce Account to avoid leakage across customers.
|
||||
- Cases are read and written directly in Salesforce. Origin is set to "Portal Support."
|
||||
- The portal only shows cases for the customer's mapped Salesforce Account to avoid leakage across customers.
|
||||
|
||||
## Creating a Case
|
||||
|
||||
@ -15,10 +15,98 @@ How the portal surfaces and creates support cases for customers.
|
||||
|
||||
## Viewing Cases
|
||||
|
||||
- We read live from Salesforce (no caching) to ensure status, priority, and comments are up to date.
|
||||
- The portal summarizes open vs. resolved counts and highlights high-priority cases based on Salesforce status/priority values.
|
||||
- Cases are fetched from Salesforce with Redis caching (see Caching section below).
|
||||
- The portal summarizes open vs. closed counts and highlights high-priority cases.
|
||||
|
||||
## Real-Time Updates via Platform Events
|
||||
|
||||
Support cases use Platform Events for real-time cache invalidation:
|
||||
|
||||
**Platform Event:** `Case_Status_Update__e`
|
||||
|
||||
| Field | API Name | Description |
|
||||
| ---------- | --------------- | --------------------- |
|
||||
| Account ID | `Account_Id__c` | Salesforce Account ID |
|
||||
| Case ID | `Case_Id__c` | Salesforce Case ID |
|
||||
|
||||
**Flow Trigger:** Record update on Case when Status changes (and Origin = "Portal Support")
|
||||
|
||||
**BFF Behavior:**
|
||||
|
||||
1. Invalidates `support:cases:{accountId}` cache
|
||||
2. Invalidates `support:messages:{caseId}` cache
|
||||
3. Sends SSE `support.case.changed` to connected portals
|
||||
4. Portal refetches case data automatically
|
||||
|
||||
See `docs/integrations/salesforce/platform-events.md` for full Platform Event documentation.
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
We use Redis TTL-based caching to reduce Salesforce API calls:
|
||||
|
||||
| Cache Key | TTL | Invalidated On |
|
||||
| --------------------------- | --------- | -------------- |
|
||||
| `support:cases:{accountId}` | 2 minutes | Case created |
|
||||
| `support:messages:{caseId}` | 1 minute | Comment added |
|
||||
|
||||
Features:
|
||||
|
||||
- **Request coalescing**: Prevents thundering herd on cache miss
|
||||
- **Write-through invalidation**: Cache cleared after customer writes
|
||||
- **Metrics tracking**: Hits, misses, and invalidations are tracked
|
||||
|
||||
## Customer-Friendly Status Mapping
|
||||
|
||||
Salesforce uses internal workflow statuses that may not be meaningful to customers. We map them to simplified, customer-friendly labels:
|
||||
|
||||
| Salesforce Status (API) | Portal Display | Meaning |
|
||||
| ----------------------- | ----------------- | ------------------------------------- |
|
||||
| 新規 (New) | New | New case, not yet reviewed |
|
||||
| 対応中 (In Progress) | In Progress | Support is working on it |
|
||||
| Awaiting Approval | In Progress | Internal workflow (hidden) |
|
||||
| VPN Pending | In Progress | Internal workflow (hidden) |
|
||||
| Pending | In Progress | Internal workflow (hidden) |
|
||||
| 完了済み (Replied) | Awaiting Customer | Support replied, waiting for customer |
|
||||
| Closed | Closed | Case is closed |
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- Internal workflow statuses are hidden from customers and shown as "In Progress"
|
||||
- "Replied/完了済み" means support has responded and is waiting for the customer
|
||||
- Only 4 statuses visible to customers: New, In Progress, Awaiting Customer, Closed
|
||||
|
||||
## Case Conversation (Messages)
|
||||
|
||||
The case detail view shows a unified conversation timeline composed of:
|
||||
|
||||
1. **EmailMessages** - Email exchanges attached to the case
|
||||
2. **CaseComments** - Portal comments added by customer or agent
|
||||
|
||||
### Features
|
||||
|
||||
- **Date grouping**: Messages are grouped by date (Today, Yesterday, Mon Dec 30, etc.)
|
||||
- **Attachment indicators**: Messages with attachments show a paperclip icon
|
||||
- **Clean email bodies**: Quoted reply chains are stripped (see below)
|
||||
|
||||
### Email Body Cleaning
|
||||
|
||||
Emails often contain quoted reply chains that pollute the conversation view. We automatically clean email bodies to show only the latest reply by stripping:
|
||||
|
||||
**Single-line patterns:**
|
||||
|
||||
- Lines starting with `>` (quoted text)
|
||||
- `From:`, `To:`, `Subject:`, `Sent:` headers
|
||||
- Japanese equivalents (送信者:, 件名:, etc.)
|
||||
|
||||
**Multi-line patterns:**
|
||||
|
||||
- "On Mon, Dec 29, 2025 at 18:43 ... wrote:" (Gmail style, spans multiple lines)
|
||||
- "-------- Original Message --------" blocks
|
||||
- Forwarded message headers
|
||||
|
||||
This ensures each message bubble shows only the new content, not the entire email history.
|
||||
|
||||
## If something goes wrong
|
||||
|
||||
- Salesforce unavailable: we show “support system unavailable, please try again later.”
|
||||
- Case not found or belongs to another account: we respond with “case not found” to avoid leaking information.
|
||||
- Salesforce unavailable: we show "support system unavailable, please try again later."
|
||||
- Case not found or belongs to another account: we respond with "case not found" to avoid leaking information.
|
||||
|
||||
268
docs/integrations/salesforce/platform-events.md
Normal file
268
docs/integrations/salesforce/platform-events.md
Normal file
@ -0,0 +1,268 @@
|
||||
# Salesforce Platform Events & CDC
|
||||
|
||||
This guide documents all Platform Events and CDC (Change Data Capture) channels used for real-time communication between Salesforce and the Customer Portal.
|
||||
|
||||
## Overview
|
||||
|
||||
The BFF subscribes to Salesforce events using the Pub/Sub API and reacts by:
|
||||
|
||||
1. **Invalidating caches** - Ensuring fresh data on next request
|
||||
2. **Sending SSE events** - Notifying connected portal clients to refetch
|
||||
3. **Creating in-app notifications** - For significant status changes
|
||||
|
||||
## Event Types
|
||||
|
||||
| Type | Channel Prefix | Purpose |
|
||||
| ------------------- | -------------- | ------------------------------------ |
|
||||
| **Platform Events** | `/event/` | Custom events from Salesforce Flows |
|
||||
| **CDC** | `/data/` | Standard object change notifications |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Salesforce Record Change
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Platform Events: Record-Triggered Flow │
|
||||
│ CDC: Automatic change tracking │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
BFF Pub/Sub Subscriber
|
||||
↓
|
||||
┌────────────────────────────────────────┐
|
||||
│ 1. Invalidate Redis cache │
|
||||
│ 2. Send SSE to connected portal │
|
||||
│ 3. Create notification (if applicable) │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platform Events
|
||||
|
||||
### 1. Account Update Event
|
||||
|
||||
**Purpose:** Notify portal when eligibility or verification status changes.
|
||||
|
||||
**Channel:** `SF_ACCOUNT_EVENT_CHANNEL` (default: `/event/Account_Eligibility_Update__e`)
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | API Name | Type | Required | When to Include |
|
||||
| ------------------- | ------------------------ | --------- | --------- | --------------------------------------------------- |
|
||||
| Account ID | `Account_Id__c` | Text(255) | ✅ Always | Always |
|
||||
| Eligibility Status | `Eligibility_Status__c` | Text(255) | Optional | Only if `ISCHANGED(Internet_Eligibility_Status__c)` |
|
||||
| Verification Status | `Verification_Status__c` | Text(255) | Optional | Only if `ISCHANGED(Id_Verification_Status__c)` |
|
||||
| Rejection Message | `Rejection_Message__c` | Text(255) | Optional | Only if Verification_Status\_\_c = "Rejected" |
|
||||
|
||||
#### Salesforce Flow Logic
|
||||
|
||||
**Trigger:** Record update on Account when ANY of these fields change:
|
||||
|
||||
- `Internet_Eligibility__c`
|
||||
- `Internet_Eligibility_Status__c`
|
||||
- `Id_Verification_Status__c`
|
||||
- `Id_Verification_Rejection_Message__c`
|
||||
|
||||
**Event Payload:**
|
||||
|
||||
```
|
||||
Account_Id__c = {!$Record.Id}
|
||||
|
||||
IF ISCHANGED({!$Record.Internet_Eligibility_Status__c}) THEN
|
||||
Eligibility_Status__c = {!$Record.Internet_Eligibility_Status__c}
|
||||
END IF
|
||||
|
||||
IF ISCHANGED({!$Record.Id_Verification_Status__c}) THEN
|
||||
Verification_Status__c = {!$Record.Id_Verification_Status__c}
|
||||
IF {!$Record.Id_Verification_Status__c} = "Rejected" THEN
|
||||
Rejection_Message__c = {!$Record.Id_Verification_Rejection_Message__c}
|
||||
END IF
|
||||
END IF
|
||||
```
|
||||
|
||||
#### BFF Behavior
|
||||
|
||||
| Scenario | Cache Action | SSE Event | Notification |
|
||||
| -------------------------------------- | --------------- | -------------------- | ------------------------- |
|
||||
| Eligibility value changes (not status) | Invalidate both | ✅ `account.updated` | ❌ |
|
||||
| Eligibility status → Pending | Invalidate both | ✅ `account.updated` | ❌ |
|
||||
| Eligibility status → Eligible | Invalidate both | ✅ `account.updated` | ✅ ELIGIBILITY_ELIGIBLE |
|
||||
| Eligibility status → Ineligible | Invalidate both | ✅ `account.updated` | ✅ ELIGIBILITY_INELIGIBLE |
|
||||
| Verification status → Submitted | Invalidate both | ✅ `account.updated` | ❌ |
|
||||
| Verification status → Verified | Invalidate both | ✅ `account.updated` | ✅ VERIFICATION_VERIFIED |
|
||||
| Verification status → Rejected | Invalidate both | ✅ `account.updated` | ✅ VERIFICATION_REJECTED |
|
||||
| Rejection message changes only | Invalidate both | ✅ `account.updated` | ❌ |
|
||||
|
||||
---
|
||||
|
||||
### 2. Case Status Update Event
|
||||
|
||||
**Purpose:** Notify portal when a support case status changes.
|
||||
|
||||
**Channel:** `SF_CASE_EVENT_CHANNEL` (default: `/event/Case_Status_Update__e`)
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | API Name | Type | Required |
|
||||
| ---------- | --------------- | --------- | --------- |
|
||||
| Account ID | `Account_Id__c` | Text(255) | ✅ Always |
|
||||
| Case ID | `Case_Id__c` | Text(255) | ✅ Always |
|
||||
|
||||
#### Salesforce Flow Logic
|
||||
|
||||
**Trigger:** Record update on Case when Status changes (and Origin = "Portal Support")
|
||||
|
||||
**Event Payload:**
|
||||
|
||||
```
|
||||
Account_Id__c = {!$Record.AccountId}
|
||||
Case_Id__c = {!$Record.Id}
|
||||
```
|
||||
|
||||
#### BFF Behavior
|
||||
|
||||
| Action | Description |
|
||||
| -------------------------------------- | ------------------------- |
|
||||
| Invalidate `support:cases:{accountId}` | Clear case list cache |
|
||||
| Invalidate `support:messages:{caseId}` | Clear case messages cache |
|
||||
| SSE `support.case.changed` | Notify portal to refetch |
|
||||
|
||||
---
|
||||
|
||||
### 3. Order Provision Requested Event
|
||||
|
||||
**Purpose:** Trigger order fulfillment when Salesforce Order is approved.
|
||||
|
||||
**Channel:** `/event/OrderProvisionRequested__e`
|
||||
|
||||
See `docs/how-it-works/order-fulfillment.md` for details.
|
||||
|
||||
---
|
||||
|
||||
## CDC (Change Data Capture)
|
||||
|
||||
### 1. Product2 CDC
|
||||
|
||||
**Purpose:** Detect product catalog changes.
|
||||
|
||||
**Channel:** `SF_CATALOG_PRODUCT_CDC_CHANNEL` (default: `/data/Product2ChangeEvent`)
|
||||
|
||||
#### BFF Behavior
|
||||
|
||||
| Action | Description |
|
||||
| -------------------------- | --------------------------------- |
|
||||
| Invalidate product cache | Clear affected products |
|
||||
| Fallback full invalidation | If no specific products found |
|
||||
| SSE `services.changed` | Notify portals to refetch catalog |
|
||||
|
||||
---
|
||||
|
||||
### 2. PricebookEntry CDC
|
||||
|
||||
**Purpose:** Detect pricing changes.
|
||||
|
||||
**Channel:** `SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL` (default: `/data/PricebookEntryChangeEvent`)
|
||||
|
||||
#### BFF Behavior
|
||||
|
||||
| Action | Description |
|
||||
| ------------------------ | ------------------------------------- |
|
||||
| Filter by pricebook | Only process portal pricebook changes |
|
||||
| Invalidate product cache | Clear affected products |
|
||||
| SSE `services.changed` | Notify portals to refetch catalog |
|
||||
|
||||
---
|
||||
|
||||
### 3. Order CDC
|
||||
|
||||
**Purpose:** Detect order changes + trigger provisioning.
|
||||
|
||||
**Channel:** `SF_ORDER_CDC_CHANNEL` (default: `/data/OrderChangeEvent`)
|
||||
|
||||
#### BFF Behavior
|
||||
|
||||
| Scenario | Action |
|
||||
| ------------------------------------- | ------------------------------ |
|
||||
| `Activation_Status__c` → "Activating" | Enqueue provisioning job |
|
||||
| Customer-facing field changes | Invalidate order cache + SSE |
|
||||
| Internal field changes only | Ignore (no cache invalidation) |
|
||||
|
||||
**Internal fields (ignored):** `Activation_Status__c`, `WHMCS_Order_ID__c`, `Activation_Error_*`
|
||||
|
||||
---
|
||||
|
||||
### 4. OrderItem CDC
|
||||
|
||||
**Purpose:** Detect order line item changes.
|
||||
|
||||
**Channel:** `SF_ORDER_ITEM_CDC_CHANNEL` (default: `/data/OrderItemChangeEvent`)
|
||||
|
||||
#### BFF Behavior
|
||||
|
||||
| Scenario | Action |
|
||||
| ----------------------------- | ------------------------------ |
|
||||
| Customer-facing field changes | Invalidate order cache + SSE |
|
||||
| Internal field changes only | Ignore (no cache invalidation) |
|
||||
|
||||
**Internal fields (ignored):** `WHMCS_Service_ID__c`
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Platform Events
|
||||
SF_ACCOUNT_EVENT_CHANNEL=/event/Account_Eligibility_Update__e
|
||||
SF_CASE_EVENT_CHANNEL=/event/Case_Status_Update__e
|
||||
|
||||
# Catalog CDC
|
||||
SF_CATALOG_PRODUCT_CDC_CHANNEL=/data/Product2ChangeEvent
|
||||
SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL=/data/PricebookEntryChangeEvent
|
||||
|
||||
# Order CDC
|
||||
SF_ORDER_CDC_CHANNEL=/data/OrderChangeEvent
|
||||
SF_ORDER_ITEM_CDC_CHANNEL=/data/OrderItemChangeEvent
|
||||
|
||||
# Enable/disable all CDC subscriptions (Platform Events always enabled)
|
||||
SF_EVENTS_ENABLED=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## BFF Implementation
|
||||
|
||||
### Subscribers
|
||||
|
||||
| Subscriber | Events | Channel | File |
|
||||
| ------------------------- | ------------------------ | --------- | ------------------------------ |
|
||||
| `AccountEventsSubscriber` | Account | `/event/` | `account-events.subscriber.ts` |
|
||||
| `CaseEventsSubscriber` | Case | `/event/` | `case-events.subscriber.ts` |
|
||||
| `CatalogCdcSubscriber` | Product2, PricebookEntry | `/data/` | `catalog-cdc.subscriber.ts` |
|
||||
| `OrderCdcSubscriber` | Order, OrderItem | `/data/` | `order-cdc.subscriber.ts` |
|
||||
|
||||
### Shared Utilities (`shared/`)
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------- | ------------------------------------ |
|
||||
| `pubsub.service.ts` | Salesforce Pub/Sub client management |
|
||||
| `pubsub.utils.ts` | Payload extraction helpers |
|
||||
| `pubsub.types.ts` | TypeScript types |
|
||||
| `index.ts` | Public exports |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
1. **Salesforce Workbench:** Use "REST Explorer" to publish test events
|
||||
2. **BFF Logs:** Watch for event received messages
|
||||
3. **Redis:** Verify cache keys are deleted after event
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Cause | Solution |
|
||||
| ------------------------ | ------------------------- | ----------------------------------------- |
|
||||
| Events not received | Pub/Sub connection failed | Check BFF logs for connection errors |
|
||||
| Cache not invalidated | Wrong ID in payload | Verify Flow is sending correct ID |
|
||||
| Notification not created | Status not in final state | Only final states trigger notifications |
|
||||
| Duplicate notifications | Same event re-delivered | Dedupe logic handles this (1 hour window) |
|
||||
@ -406,9 +406,6 @@ export const residenceCardVerificationStatusSchema = z.enum([
|
||||
|
||||
export const residenceCardVerificationSchema = z.object({
|
||||
status: residenceCardVerificationStatusSchema,
|
||||
filename: z.string().nullable(),
|
||||
mimeType: z.string().nullable(),
|
||||
sizeBytes: z.number().int().nonnegative().nullable(),
|
||||
submittedAt: z.string().datetime().nullable(),
|
||||
reviewedAt: z.string().datetime().nullable(),
|
||||
reviewerNotes: z.string().nullable(),
|
||||
|
||||
@ -7,6 +7,22 @@ import {
|
||||
} from "./schema.js";
|
||||
import { ORDER_TYPE, type CheckoutCart, type OrderTypeValue } from "./contract.js";
|
||||
|
||||
export type CheckoutOrderTypeValue = Extract<OrderTypeValue, "Internet" | "SIM" | "VPN">;
|
||||
|
||||
export function isCheckoutOrderType(value: unknown): value is CheckoutOrderTypeValue {
|
||||
return value === ORDER_TYPE.INTERNET || value === ORDER_TYPE.SIM || value === ORDER_TYPE.VPN;
|
||||
}
|
||||
|
||||
export function toCheckoutOrderType(orderType: OrderTypeValue): CheckoutOrderTypeValue | null {
|
||||
return isCheckoutOrderType(orderType) ? orderType : null;
|
||||
}
|
||||
|
||||
export function toOrderTypeValueFromCheckout(
|
||||
orderType: string | null | undefined
|
||||
): OrderTypeValue | null {
|
||||
return isCheckoutOrderType(orderType) ? orderType : null;
|
||||
}
|
||||
|
||||
export function buildOrderConfigurations(selections: OrderSelections): OrderConfigurations {
|
||||
const normalizedSelections = orderSelectionsSchema.parse(selections);
|
||||
|
||||
|
||||
@ -9,39 +9,29 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Portal display status values
|
||||
* Mapped from Salesforce Japanese API names:
|
||||
* Portal display status values (customer-friendly)
|
||||
*
|
||||
* Mapped from Salesforce API names:
|
||||
* - 新規 → New
|
||||
* - 対応中 → In Progress
|
||||
* - Awaiting Approval → Awaiting Approval
|
||||
* - VPN Pending → VPN Pending
|
||||
* - Pending → Pending
|
||||
* - 完了済み → Resolved
|
||||
* - 対応中, Awaiting Approval, VPN Pending, Pending → In Progress (internal workflow hidden)
|
||||
* - 完了済み (Replied) → Awaiting Customer (support replied, waiting for customer response)
|
||||
* - Closed → Closed
|
||||
*/
|
||||
export const SUPPORT_CASE_STATUS = {
|
||||
NEW: "New",
|
||||
IN_PROGRESS: "In Progress",
|
||||
AWAITING_APPROVAL: "Awaiting Approval",
|
||||
VPN_PENDING: "VPN Pending",
|
||||
PENDING: "Pending",
|
||||
RESOLVED: "Resolved",
|
||||
AWAITING_CUSTOMER: "Awaiting Customer", // Support has replied, waiting for customer
|
||||
CLOSED: "Closed",
|
||||
} as const;
|
||||
|
||||
/** Statuses that indicate a case is closed */
|
||||
export const CLOSED_STATUSES = [
|
||||
SUPPORT_CASE_STATUS.VPN_PENDING,
|
||||
SUPPORT_CASE_STATUS.PENDING,
|
||||
SUPPORT_CASE_STATUS.RESOLVED,
|
||||
SUPPORT_CASE_STATUS.CLOSED,
|
||||
] as const;
|
||||
/** Statuses that indicate a case is closed (for UI logic - disables reply form) */
|
||||
export const CLOSED_STATUSES = [SUPPORT_CASE_STATUS.CLOSED] as const;
|
||||
|
||||
/** Statuses that indicate a case is open */
|
||||
/** Statuses that indicate a case is open (for UI logic) */
|
||||
export const OPEN_STATUSES = [
|
||||
SUPPORT_CASE_STATUS.NEW,
|
||||
SUPPORT_CASE_STATUS.IN_PROGRESS,
|
||||
SUPPORT_CASE_STATUS.AWAITING_APPROVAL,
|
||||
SUPPORT_CASE_STATUS.AWAITING_CUSTOMER,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@ -43,6 +43,143 @@ function nowIsoString(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Email Body Cleaning
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Multi-line patterns to remove from email body FIRST (before line-by-line processing).
|
||||
* These patterns can span multiple lines and should be removed entirely.
|
||||
*
|
||||
* Order matters - more specific patterns should come first.
|
||||
*/
|
||||
const MULTILINE_QUOTE_PATTERNS: RegExp[] = [
|
||||
// Gmail multi-line: "On <date> ... <email>\nwrote:" or "On <date> ... <name> <\nemail> wrote:"
|
||||
// This handles cases where the email address or "wrote:" wraps to next line
|
||||
/On\s+(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)[^]*?wrote:\s*/gi,
|
||||
|
||||
// Generic "On <date> <name> wrote:" that may span lines
|
||||
/On\s+\d{1,2}[^]*?wrote:\s*/gi,
|
||||
|
||||
// Japanese: "<date>に<name>が書きました:"
|
||||
/\d{4}[年/-]\d{1,2}[月/-]\d{1,2}[日]?[^]*?書きました[::]\s*/g,
|
||||
|
||||
// "---- Original Message ----" and everything after
|
||||
/-{2,}\s*(?:Original\s*Message|Forwarded|転送|元のメッセージ)[^]*/gi,
|
||||
|
||||
// "___" Outlook separator and everything after
|
||||
/_{3,}[^]*/g,
|
||||
|
||||
// "From: <email>" header block (usually indicates quoted content start)
|
||||
// Only match if it's followed by typical email header patterns
|
||||
/\nFrom:\s*[^\n]+\n(?:Sent|To|Date|Subject|Cc):[^]*/gi,
|
||||
];
|
||||
|
||||
/**
|
||||
* Single-line patterns that indicate the start of quoted content.
|
||||
* Once matched, all remaining lines are discarded.
|
||||
*/
|
||||
const QUOTE_START_LINE_PATTERNS: RegExp[] = [
|
||||
// "> quoted text" at start of line
|
||||
/^>/,
|
||||
// "From:" at start of line (email header)
|
||||
/^From:\s+.+/i,
|
||||
// "送信者:" (Japanese: Sender)
|
||||
/^送信者[::]\s*.+/,
|
||||
// "Sent:" header
|
||||
/^Sent:\s+.+/i,
|
||||
// Date/Time headers appearing mid-email
|
||||
/^(Date|日時|送信日時)[::]\s+.+/i,
|
||||
// "Subject:" appearing mid-email
|
||||
/^(Subject|件名)[::]\s+.+/i,
|
||||
// "To:" appearing mid-email (not at very start)
|
||||
/^(To|宛先)[::]\s+.+/i,
|
||||
// Lines that look like "wrote:" endings we might have missed
|
||||
/^\s*wrote:\s*$/i,
|
||||
/^\s*書きました[::]?\s*$/,
|
||||
// Email in angle brackets followed by wrote (continuation line)
|
||||
/^[^<]*<[^>]+>\s*wrote:\s*$/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for lines to skip (metadata) but continue processing
|
||||
*/
|
||||
const SKIP_LINE_PATTERNS: RegExp[] = [
|
||||
/^(Cc|CC|Bcc|BCC)[::]\s*.*/i,
|
||||
/^(Reply-To|返信先)[::]\s*.*/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Clean email body by removing quoted/forwarded content.
|
||||
*
|
||||
* This function strips out:
|
||||
* - Quoted replies (lines starting with ">")
|
||||
* - "On <date>, <name> wrote:" blocks (including multi-line Gmail format)
|
||||
* - "From: / To: / Subject:" quoted headers
|
||||
* - "-------- Original Message --------" separators
|
||||
* - Japanese equivalents of the above
|
||||
*
|
||||
* @param body - Raw email text body
|
||||
* @returns Cleaned email body with only the latest reply content
|
||||
*/
|
||||
export function cleanEmailBody(body: string): string {
|
||||
if (!body) return "";
|
||||
|
||||
let cleaned = body;
|
||||
|
||||
// Step 1: Apply multi-line pattern removal first
|
||||
for (const pattern of MULTILINE_QUOTE_PATTERNS) {
|
||||
cleaned = cleaned.replace(pattern, "");
|
||||
}
|
||||
|
||||
// Step 2: Process line by line for remaining patterns
|
||||
const lines = cleaned.split(/\r?\n/);
|
||||
const cleanLines: string[] = [];
|
||||
let foundQuoteStart = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (foundQuoteStart) {
|
||||
// Already in quoted section, skip all remaining lines
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Check if this line starts quoted content
|
||||
const isQuoteStart = QUOTE_START_LINE_PATTERNS.some(pattern => pattern.test(trimmedLine));
|
||||
if (isQuoteStart) {
|
||||
foundQuoteStart = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip metadata lines but continue processing
|
||||
const isSkipLine = SKIP_LINE_PATTERNS.some(pattern => pattern.test(trimmedLine));
|
||||
if (isSkipLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cleanLines.push(line);
|
||||
}
|
||||
|
||||
// Step 3: Clean up the result
|
||||
let result = cleanLines.join("\n");
|
||||
|
||||
// Remove excessive trailing whitespace/newlines
|
||||
result = result.replace(/\s+$/, "");
|
||||
|
||||
// Remove leading blank lines
|
||||
result = result.replace(/^\s*\n+/, "");
|
||||
|
||||
// If we stripped everything, return original (edge case)
|
||||
if (!result.trim()) {
|
||||
// Try to extract at least something useful from original
|
||||
const firstParagraph = body.split(/\n\s*\n/)[0]?.trim();
|
||||
return firstParagraph || body.substring(0, 500);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transform Functions
|
||||
// ============================================================================
|
||||
@ -216,6 +353,9 @@ export function buildEmailMessagesForCaseQuery(caseId: string): string {
|
||||
/**
|
||||
* Transform a Salesforce EmailMessage to a unified CaseMessage.
|
||||
*
|
||||
* Cleans the email body to show only the latest reply, stripping out
|
||||
* quoted content and previous email chains for a cleaner conversation view.
|
||||
*
|
||||
* @param email - Raw Salesforce EmailMessage
|
||||
* @param customerEmail - Customer's email address for comparison
|
||||
*/
|
||||
@ -233,10 +373,14 @@ export function transformEmailMessageToCaseMessage(
|
||||
isIncoming ||
|
||||
(customerEmail ? fromEmail?.toLowerCase() === customerEmail.toLowerCase() : false);
|
||||
|
||||
// Get the raw email body and clean it (strip quoted content)
|
||||
const rawBody = ensureString(email.TextBody) ?? ensureString(email.HtmlBody) ?? "";
|
||||
const cleanedBody = cleanEmailBody(rawBody);
|
||||
|
||||
return caseMessageSchema.parse({
|
||||
id: email.Id,
|
||||
type: "email",
|
||||
body: ensureString(email.TextBody) ?? ensureString(email.HtmlBody) ?? "",
|
||||
body: cleanedBody,
|
||||
author: {
|
||||
name: fromName,
|
||||
email: fromEmail ?? null,
|
||||
@ -244,6 +388,7 @@ export function transformEmailMessageToCaseMessage(
|
||||
},
|
||||
createdAt: ensureString(email.MessageDate) ?? ensureString(email.CreatedDate) ?? nowIsoString(),
|
||||
direction: isIncoming ? "inbound" : "outbound",
|
||||
hasAttachment: email.HasAttachment === true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -343,15 +343,22 @@ export type SalesforceCasePriority =
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Map Salesforce status API names to portal display labels
|
||||
* Map Salesforce status API names to customer-friendly portal display labels.
|
||||
*
|
||||
* Mapping logic:
|
||||
* - Internal workflow statuses (Awaiting Approval, VPN Pending, Pending) → "In Progress"
|
||||
* - 完了済み (Replied) → "Awaiting Customer" (support replied, waiting for customer)
|
||||
* - Closed → "Closed"
|
||||
*/
|
||||
export const STATUS_DISPLAY_LABELS: Record<string, string> = {
|
||||
[SALESFORCE_CASE_STATUS.NEW]: "New",
|
||||
[SALESFORCE_CASE_STATUS.IN_PROGRESS]: "In Progress",
|
||||
[SALESFORCE_CASE_STATUS.AWAITING_APPROVAL]: "Awaiting Approval",
|
||||
[SALESFORCE_CASE_STATUS.VPN_PENDING]: "VPN Pending",
|
||||
[SALESFORCE_CASE_STATUS.PENDING]: "Pending",
|
||||
[SALESFORCE_CASE_STATUS.REPLIED]: "Resolved",
|
||||
// Internal workflow statuses - show as "In Progress" to customer
|
||||
[SALESFORCE_CASE_STATUS.AWAITING_APPROVAL]: "In Progress",
|
||||
[SALESFORCE_CASE_STATUS.VPN_PENDING]: "In Progress",
|
||||
[SALESFORCE_CASE_STATUS.PENDING]: "In Progress",
|
||||
// Replied = support has responded, waiting for customer
|
||||
[SALESFORCE_CASE_STATUS.REPLIED]: "Awaiting Customer",
|
||||
[SALESFORCE_CASE_STATUS.CLOSED]: "Closed",
|
||||
};
|
||||
|
||||
|
||||
@ -2,15 +2,18 @@ import { z } from "zod";
|
||||
import { SUPPORT_CASE_STATUS, SUPPORT_CASE_PRIORITY, SUPPORT_CASE_CATEGORY } from "./contract.js";
|
||||
|
||||
/**
|
||||
* Portal status values (mapped from Salesforce Japanese API names)
|
||||
* Portal status values - customer-friendly statuses only
|
||||
*
|
||||
* Internal Salesforce statuses are mapped to these customer-facing values:
|
||||
* - "新規" → New
|
||||
* - "対応中", "Awaiting Approval", "VPN Pending", "Pending" → In Progress
|
||||
* - "完了済み" (Replied) → Awaiting Customer
|
||||
* - "Closed" → Closed
|
||||
*/
|
||||
const supportCaseStatusValues = [
|
||||
SUPPORT_CASE_STATUS.NEW,
|
||||
SUPPORT_CASE_STATUS.IN_PROGRESS,
|
||||
SUPPORT_CASE_STATUS.AWAITING_APPROVAL,
|
||||
SUPPORT_CASE_STATUS.VPN_PENDING,
|
||||
SUPPORT_CASE_STATUS.PENDING,
|
||||
SUPPORT_CASE_STATUS.RESOLVED,
|
||||
SUPPORT_CASE_STATUS.AWAITING_CUSTOMER,
|
||||
SUPPORT_CASE_STATUS.CLOSED,
|
||||
] as const;
|
||||
|
||||
@ -136,6 +139,8 @@ export const caseMessageSchema = z.object({
|
||||
createdAt: z.string(),
|
||||
/** For emails: inbound (customer→agent) or outbound (agent→customer) */
|
||||
direction: caseMessageDirectionSchema.nullable(),
|
||||
/** Whether the message has attachments (for emails) */
|
||||
hasAttachment: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user