Refactor Salesforce event handling and caching mechanisms
- Removed the deprecated SF_PROVISION_EVENT_CHANNEL from environment configuration to streamline event handling. - Enhanced CatalogCdcSubscriber to utilize product IDs for cache invalidation, improving cache management during CDC events. - Updated OrderCdcSubscriber to trigger provisioning based on Activation_Status__c changes, refining order processing logic. - Improved CatalogCacheService to support dependency tracking for cached values, enabling more efficient cache invalidation. - Refactored provisioning processor to simplify job handling and removed unnecessary replay ID management, enhancing clarity and performance.
This commit is contained in:
parent
309dac630f
commit
cbaa878000
@ -85,7 +85,6 @@ export const envSchema = z.object({
|
|||||||
SF_QUEUE_LONG_RUNNING_TIMEOUT_MS: z.coerce.number().int().positive().default(600000),
|
SF_QUEUE_LONG_RUNNING_TIMEOUT_MS: z.coerce.number().int().positive().default(600000),
|
||||||
|
|
||||||
SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("false"),
|
SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||||
SF_PROVISION_EVENT_CHANNEL: z.string().default("/event/Order_Fulfilment_Requested__e"),
|
|
||||||
SF_CATALOG_EVENT_CHANNEL: z.string().default("/event/Product_and_Pricebook_Change__e"),
|
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_Internet_Eligibility_Update__e"),
|
||||||
SF_ORDER_EVENT_CHANNEL: z.string().optional(),
|
SF_ORDER_EVENT_CHANNEL: z.string().optional(),
|
||||||
|
|||||||
@ -139,11 +139,24 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
data: unknown
|
data: unknown
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.isDataCallback(callbackType)) return;
|
if (!this.isDataCallback(callbackType)) return;
|
||||||
this.logger.log("Product2 CDC event received, invalidating catalogs", {
|
const payload = this.extractPayload(data);
|
||||||
|
const productIds = this.extractRecordIds(payload);
|
||||||
|
|
||||||
|
this.logger.log("Product2 CDC event received", {
|
||||||
channel,
|
channel,
|
||||||
topicName: subscription.topicName,
|
topicName: subscription.topicName,
|
||||||
|
productIds,
|
||||||
});
|
});
|
||||||
await this.invalidateAllCatalogs();
|
|
||||||
|
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.invalidateAllCatalogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handlePricebookEvent(
|
private async handlePricebookEvent(
|
||||||
@ -165,11 +178,26 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log("PricebookEntry CDC event received, invalidating catalogs", {
|
const productId = this.extractStringField(payload, ["Product2Id", "ProductId"]);
|
||||||
|
|
||||||
|
this.logger.log("PricebookEntry CDC event received", {
|
||||||
channel,
|
channel,
|
||||||
pricebookId,
|
pricebookId,
|
||||||
|
productId,
|
||||||
});
|
});
|
||||||
await this.invalidateAllCatalogs();
|
|
||||||
|
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.invalidateAllCatalogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAccountEvent(
|
private async handleAccountEvent(
|
||||||
@ -248,5 +276,29 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
return undefined;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
export function replayKey(channel: string): string {
|
|
||||||
return `sf:pe:replay:${channel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function statusKey(channel: string): string {
|
|
||||||
return `sf:pe:status:${channel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function latestSeenKey(channel: string): string {
|
|
||||||
return `sf:pe:latestSeen:${channel}`;
|
|
||||||
}
|
|
||||||
@ -3,14 +3,12 @@ import { ConfigModule } from "@nestjs/config";
|
|||||||
import { IntegrationsModule } from "@bff/integrations/integrations.module";
|
import { IntegrationsModule } from "@bff/integrations/integrations.module";
|
||||||
import { OrdersModule } from "@bff/modules/orders/orders.module";
|
import { OrdersModule } from "@bff/modules/orders/orders.module";
|
||||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module";
|
import { CatalogModule } from "@bff/modules/catalog/catalog.module";
|
||||||
import { SalesforcePubSubSubscriber } from "./pubsub.subscriber";
|
|
||||||
import { CatalogCdcSubscriber } from "./catalog-cdc.subscriber";
|
import { CatalogCdcSubscriber } from "./catalog-cdc.subscriber";
|
||||||
import { OrderCdcSubscriber } from "./order-cdc.subscriber";
|
import { OrderCdcSubscriber } from "./order-cdc.subscriber";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule, IntegrationsModule, OrdersModule, CatalogModule],
|
imports: [ConfigModule, IntegrationsModule, OrdersModule, CatalogModule],
|
||||||
providers: [
|
providers: [
|
||||||
SalesforcePubSubSubscriber, // Platform Event for order provisioning
|
|
||||||
CatalogCdcSubscriber, // CDC for catalog cache invalidation
|
CatalogCdcSubscriber, // CDC for catalog cache invalidation
|
||||||
OrderCdcSubscriber, // CDC for order cache invalidation
|
OrderCdcSubscriber, // CDC for order cache invalidation
|
||||||
],
|
],
|
||||||
|
|||||||
@ -28,7 +28,9 @@ type PubSubCtor = new (opts: {
|
|||||||
/**
|
/**
|
||||||
* CDC Subscriber for Order changes
|
* CDC Subscriber for Order changes
|
||||||
*
|
*
|
||||||
* Strategy: Only invalidate cache for customer-facing field changes, NOT internal system fields
|
* 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):
|
* CUSTOMER-FACING FIELDS (invalidate cache):
|
||||||
* - Status (Draft, Pending Review, Completed, Cancelled)
|
* - Status (Draft, Pending Review, Completed, Cancelled)
|
||||||
@ -202,9 +204,9 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. CHECK FOR PROVISIONING TRIGGER (Status change)
|
// 1. CHECK FOR PROVISIONING TRIGGER (Activation status change)
|
||||||
if (payload && changedFields.has("Status")) {
|
if (payload && changedFields.has("Activation_Status__c")) {
|
||||||
await this.handleStatusChange(payload, orderId, changedFields);
|
await this.handleActivationStatusChange(payload, orderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. CACHE INVALIDATION (existing logic)
|
// 2. CACHE INVALIDATION (existing logic)
|
||||||
@ -245,45 +247,33 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle Status field changes and trigger provisioning if needed
|
* Handle Activation_Status__c changes and trigger provisioning when Salesforce moves an order to "Activating"
|
||||||
*/
|
*/
|
||||||
private async handleStatusChange(
|
private async handleActivationStatusChange(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
orderId: string,
|
orderId: string
|
||||||
changedFields: Set<string>
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const newStatus = this.extractStringField(payload, ["Status"]);
|
|
||||||
const activationStatus = this.extractStringField(payload, ["Activation_Status__c"]);
|
const activationStatus = this.extractStringField(payload, ["Activation_Status__c"]);
|
||||||
|
const status = this.extractStringField(payload, ["Status"]);
|
||||||
const whmcsOrderId = this.extractStringField(payload, ["WHMCS_Order_ID__c"]);
|
const whmcsOrderId = this.extractStringField(payload, ["WHMCS_Order_ID__c"]);
|
||||||
|
|
||||||
// Guard: Only provision for specific statuses
|
if (activationStatus !== "Activating") {
|
||||||
if (!newStatus || !this.PROVISION_TRIGGER_STATUSES.has(newStatus)) {
|
this.logger.debug("Activation status changed but not to Activating; skipping provisioning", {
|
||||||
this.logger.debug("Status changed but not a provision trigger", {
|
|
||||||
orderId,
|
|
||||||
newStatus,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard: Don't trigger if already provisioning
|
|
||||||
if (activationStatus === "Activating") {
|
|
||||||
this.logger.debug("Order already provisioning, skipping", {
|
|
||||||
orderId,
|
orderId,
|
||||||
activationStatus,
|
activationStatus,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: Don't trigger if already activated
|
if (status && !this.PROVISION_TRIGGER_STATUSES.has(status)) {
|
||||||
if (activationStatus === "Activated") {
|
this.logger.debug("Activation status set to Activating but order status is not a provisioning trigger", {
|
||||||
this.logger.debug("Order already activated, skipping", {
|
|
||||||
orderId,
|
orderId,
|
||||||
activationStatus,
|
activationStatus,
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: Check if WHMCS Order ID already exists (idempotency)
|
|
||||||
if (whmcsOrderId) {
|
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,
|
orderId,
|
||||||
@ -292,28 +282,29 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger provisioning
|
this.logger.log("Order activation moved to Activating via CDC, enqueuing fulfillment", {
|
||||||
this.logger.log("Order status changed to provision trigger via CDC, enqueuing fulfillment", {
|
|
||||||
orderId,
|
orderId,
|
||||||
status: newStatus,
|
|
||||||
activationStatus,
|
activationStatus,
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.provisioningQueue.enqueue({
|
await this.provisioningQueue.enqueue({
|
||||||
sfOrderId: orderId,
|
sfOrderId: orderId,
|
||||||
idempotencyKey: `cdc-status-${Date.now()}-${orderId}`,
|
idempotencyKey: `cdc-activation-${Date.now()}-${orderId}`,
|
||||||
correlationId: `cdc-order-${orderId}`,
|
correlationId: `cdc-order-${orderId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log("Successfully enqueued provisioning job from CDC Status change", {
|
this.logger.log("Successfully enqueued provisioning job from activation change", {
|
||||||
orderId,
|
orderId,
|
||||||
trigger: `Status → ${newStatus}`,
|
activationStatus,
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to enqueue provisioning job from CDC", {
|
this.logger.error("Failed to enqueue provisioning job from activation change", {
|
||||||
orderId,
|
orderId,
|
||||||
newStatus,
|
activationStatus,
|
||||||
|
status,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -408,6 +399,12 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
private extractChangedFields(payload: Record<string, unknown> | undefined): Set<string> {
|
private extractChangedFields(payload: Record<string, unknown> | undefined): Set<string> {
|
||||||
if (!payload) return new Set();
|
if (!payload) return new Set();
|
||||||
|
|
||||||
|
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
|
// CDC provides changed fields in different formats depending on API version
|
||||||
// Try to extract from common locations
|
// Try to extract from common locations
|
||||||
const changedFieldsArray =
|
const changedFieldsArray =
|
||||||
@ -415,7 +412,10 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
((payload.changeOrigin as { changedFields?: string[] })?.changedFields) ||
|
((payload.changeOrigin as { changedFields?: string[] })?.changedFields) ||
|
||||||
[];
|
[];
|
||||||
|
|
||||||
return new Set(changedFieldsArray);
|
return new Set([
|
||||||
|
...headerChangedFields,
|
||||||
|
...changedFieldsArray.filter(field => typeof field === "string" && field.length > 0),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isDataCallback(callbackType: string): boolean {
|
private isDataCallback(callbackType: string): boolean {
|
||||||
@ -452,5 +452,15 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractChangeEventHeader(
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): { changedFields?: unknown } | undefined {
|
||||||
|
const header = payload["ChangeEventHeader"];
|
||||||
|
if (header && typeof header === "object") {
|
||||||
|
return header as { changedFields?: unknown };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,366 +0,0 @@
|
|||||||
import { Injectable, OnModuleInit, OnModuleDestroy, Inject } 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";
|
|
||||||
import { ProvisioningQueueService } from "@bff/modules/orders/queue/provisioning.queue";
|
|
||||||
import { CacheService } from "@bff/infra/cache/cache.service";
|
|
||||||
import {
|
|
||||||
replayKey as sfReplayKey,
|
|
||||||
statusKey as sfStatusKey,
|
|
||||||
latestSeenKey as sfLatestSeenKey,
|
|
||||||
} from "./event-keys.util";
|
|
||||||
import type {
|
|
||||||
SalesforceOrderProvisionEvent,
|
|
||||||
SalesforcePubSubError,
|
|
||||||
SalesforcePubSubSubscription,
|
|
||||||
SalesforcePubSubCallbackType,
|
|
||||||
SalesforcePubSubUnknownData,
|
|
||||||
} from "@customer-portal/domain/orders";
|
|
||||||
|
|
||||||
type SubscribeCallback = (
|
|
||||||
subscription: SalesforcePubSubSubscription,
|
|
||||||
callbackType: SalesforcePubSubCallbackType,
|
|
||||||
data: SalesforceOrderProvisionEvent | SalesforcePubSubError | SalesforcePubSubUnknownData
|
|
||||||
) => void | Promise<void>;
|
|
||||||
|
|
||||||
interface PubSubClient {
|
|
||||||
connect(): Promise<void>;
|
|
||||||
subscribe(topic: string, cb: SubscribeCallback, numRequested?: number): Promise<void>;
|
|
||||||
subscribeFromReplayId(
|
|
||||||
topic: string,
|
|
||||||
cb: SubscribeCallback,
|
|
||||||
numRequested: number | null,
|
|
||||||
replayId: number
|
|
||||||
): Promise<void>;
|
|
||||||
subscribeFromEarliestEvent(
|
|
||||||
topic: string,
|
|
||||||
cb: SubscribeCallback,
|
|
||||||
numRequested?: number
|
|
||||||
): Promise<void>;
|
|
||||||
requestAdditionalEvents(topic: string, numRequested: number): Promise<void>;
|
|
||||||
close(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PubSubCtor = new (opts: {
|
|
||||||
authType: string;
|
|
||||||
accessToken: string;
|
|
||||||
instanceUrl: string;
|
|
||||||
pubSubEndpoint: string;
|
|
||||||
}) => PubSubClient;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy {
|
|
||||||
private client: PubSubClient | null = null;
|
|
||||||
private clientAccessToken: string | null = null;
|
|
||||||
private channel!: string;
|
|
||||||
private replayCorruptionRecovered = false;
|
|
||||||
private subscribeCallback!: SubscribeCallback;
|
|
||||||
private pubSubCtor: PubSubCtor | null = null;
|
|
||||||
private clientBuildInFlight: Promise<PubSubClient> | null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly config: ConfigService,
|
|
||||||
private readonly sfConn: SalesforceConnection,
|
|
||||||
private readonly provisioningQueue: ProvisioningQueueService,
|
|
||||||
private readonly cache: CacheService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
|
||||||
const enabled = this.config.get("SF_EVENTS_ENABLED", "false") === "true";
|
|
||||||
if (!enabled) {
|
|
||||||
this.logger.log("Salesforce Pub/Sub subscriber disabled", { enabled });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.channel = this.config.get<string>(
|
|
||||||
"SF_PROVISION_EVENT_CHANNEL",
|
|
||||||
"/event/Order_Fulfilment_Requested__e"
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.subscribeCallback = this.buildSubscribeCallback();
|
|
||||||
await this.subscribeWithPolicy(true);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error("Salesforce Pub/Sub subscription failed", {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await this.cache.set(sfStatusKey(this.channel || "/event/OrderProvisionRequested__e"), {
|
|
||||||
status: "disconnected",
|
|
||||||
since: Date.now(),
|
|
||||||
});
|
|
||||||
} catch (cacheErr) {
|
|
||||||
this.logger.warn("Failed to set SF Pub/Sub disconnected status", {
|
|
||||||
error: cacheErr instanceof Error ? cacheErr.message : String(cacheErr),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.safeCloseClient();
|
|
||||||
await this.cache.set(sfStatusKey(this.channel), {
|
|
||||||
status: "disconnected",
|
|
||||||
since: Date.now(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn("Error closing Salesforce Pub/Sub client", {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildSubscribeCallback(): SubscribeCallback {
|
|
||||||
return async (subscription, callbackType, data) => {
|
|
||||||
try {
|
|
||||||
const argTypes = [typeof subscription, typeof callbackType, typeof data];
|
|
||||||
const type = callbackType;
|
|
||||||
const typeNorm = String(type || "").toLowerCase();
|
|
||||||
const topic = subscription.topicName || this.channel;
|
|
||||||
|
|
||||||
if (typeNorm === "data" || typeNorm === "event") {
|
|
||||||
const event = data as SalesforceOrderProvisionEvent;
|
|
||||||
this.logger.debug("SF Pub/Sub data callback received", {
|
|
||||||
topic,
|
|
||||||
argTypes,
|
|
||||||
hasPayload: Boolean(event?.payload),
|
|
||||||
});
|
|
||||||
const payload = event?.payload;
|
|
||||||
|
|
||||||
const orderIdVal = payload?.["OrderId__c"] ?? payload?.["OrderId"];
|
|
||||||
const orderId = typeof orderIdVal === "string" ? orderIdVal : undefined;
|
|
||||||
if (!orderId) {
|
|
||||||
this.logger.warn("Pub/Sub event missing OrderId__c; skipping", {
|
|
||||||
argTypes,
|
|
||||||
topic,
|
|
||||||
payloadKeys: payload ? Object.keys(payload) : [],
|
|
||||||
});
|
|
||||||
const depth = await this.provisioningQueue.depth();
|
|
||||||
if (depth < this.getMaxQueueSize()) {
|
|
||||||
const activeClient = this.client;
|
|
||||||
if (activeClient) {
|
|
||||||
await activeClient.requestAdditionalEvents(topic, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const replayVal = (event as { replayId?: unknown })?.replayId;
|
|
||||||
const idempotencyKey =
|
|
||||||
typeof replayVal === "number" || typeof replayVal === "string"
|
|
||||||
? String(replayVal)
|
|
||||||
: String(Date.now());
|
|
||||||
const pubsubReplayId = typeof replayVal === "number" ? replayVal : undefined;
|
|
||||||
|
|
||||||
await this.provisioningQueue.enqueue({
|
|
||||||
sfOrderId: orderId,
|
|
||||||
idempotencyKey,
|
|
||||||
pubsubReplayId,
|
|
||||||
});
|
|
||||||
this.logger.log("Enqueued provisioning job from SF event", {
|
|
||||||
sfOrderId: orderId,
|
|
||||||
replayId: pubsubReplayId,
|
|
||||||
topic,
|
|
||||||
});
|
|
||||||
} else if (typeNorm === "lastevent") {
|
|
||||||
const depth = await this.provisioningQueue.depth();
|
|
||||||
const available = Math.max(0, this.getMaxQueueSize() - depth);
|
|
||||||
const desired = Math.max(0, Math.min(this.getNumRequested(), available));
|
|
||||||
if (desired > 0) {
|
|
||||||
const activeClient = this.client;
|
|
||||||
if (activeClient) {
|
|
||||||
await activeClient.requestAdditionalEvents(topic, desired);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (typeNorm === "grpckeepalive") {
|
|
||||||
const latestVal = (data as { latestReplayId?: unknown })?.latestReplayId;
|
|
||||||
const latest = typeof latestVal === "number" ? latestVal : undefined;
|
|
||||||
if (typeof latest === "number") {
|
|
||||||
await this.cache.set(sfLatestSeenKey(this.channel), {
|
|
||||||
id: String(latest),
|
|
||||||
at: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (typeNorm === "grpcstatus" || typeNorm === "end") {
|
|
||||||
// Informational – no action required
|
|
||||||
} else if (typeNorm === "error") {
|
|
||||||
this.logger.warn("SF Pub/Sub stream error", { topic, data });
|
|
||||||
try {
|
|
||||||
const errorData = data as SalesforcePubSubError;
|
|
||||||
const details = errorData.details || "";
|
|
||||||
const metadata = errorData.metadata || {};
|
|
||||||
const errorCodes = Array.isArray(metadata["error-code"]) ? metadata["error-code"] : [];
|
|
||||||
const hasCorruptionCode = errorCodes.some(code =>
|
|
||||||
String(code).includes("replayid.corrupted")
|
|
||||||
);
|
|
||||||
const mentionsReplayValidation = /Replay ID validation failed/i.test(details);
|
|
||||||
|
|
||||||
if (
|
|
||||||
(hasCorruptionCode || mentionsReplayValidation) &&
|
|
||||||
!this.replayCorruptionRecovered
|
|
||||||
) {
|
|
||||||
this.replayCorruptionRecovered = true;
|
|
||||||
const key = sfReplayKey(this.channel);
|
|
||||||
await this.cache.del(key);
|
|
||||||
this.logger.warn(
|
|
||||||
"Cleared invalid Salesforce Pub/Sub replay cursor; retrying subscription",
|
|
||||||
{
|
|
||||||
channel: this.channel,
|
|
||||||
key,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (recoveryErr) {
|
|
||||||
this.logger.warn("SF Pub/Sub replay corruption auto-recovery failed", {
|
|
||||||
error: recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await this.recoverFromStreamError();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const maybeEvent = data as SalesforceOrderProvisionEvent | undefined;
|
|
||||||
const hasPayload = Boolean(maybeEvent?.payload);
|
|
||||||
this.logger.debug("SF Pub/Sub callback ignored (unknown type)", {
|
|
||||||
type,
|
|
||||||
topic,
|
|
||||||
argTypes,
|
|
||||||
hasPayload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error("Pub/Sub subscribe callback failed", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getNumRequested(): number {
|
|
||||||
return Number(this.config.get("SF_PUBSUB_NUM_REQUESTED", "50")) || 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMaxQueueSize(): number {
|
|
||||||
return Number(this.config.get("SF_PUBSUB_QUEUE_MAX", "100")) || 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPubSubCtor(): PubSubCtor {
|
|
||||||
if (this.pubSubCtor) {
|
|
||||||
return this.pubSubCtor;
|
|
||||||
}
|
|
||||||
const maybeCtor =
|
|
||||||
(PubSubApiClientPkg as { default?: unknown })?.default ?? (PubSubApiClientPkg as unknown);
|
|
||||||
this.pubSubCtor = maybeCtor as PubSubCtor;
|
|
||||||
return this.pubSubCtor;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureClient(forceRefresh = false): Promise<PubSubClient> {
|
|
||||||
if (this.clientBuildInFlight && !forceRefresh) {
|
|
||||||
return this.clientBuildInFlight;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clientBuildInFlight = (async () => {
|
|
||||||
await this.sfConn.ensureConnected();
|
|
||||||
const accessToken = this.sfConn.getAccessToken();
|
|
||||||
const instanceUrl = this.sfConn.getInstanceUrl();
|
|
||||||
if (!accessToken || !instanceUrl) {
|
|
||||||
throw new Error("Salesforce access token || instance URL unavailable");
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenChanged = this.clientAccessToken !== accessToken;
|
|
||||||
|
|
||||||
if (!this.client || forceRefresh || tokenChanged) {
|
|
||||||
await this.safeCloseClient();
|
|
||||||
|
|
||||||
const endpoint = this.config.get<string>(
|
|
||||||
"SF_PUBSUB_ENDPOINT",
|
|
||||||
"api.pubsub.salesforce.com:7443"
|
|
||||||
);
|
|
||||||
const Ctor = this.getPubSubCtor();
|
|
||||||
const client = new Ctor({
|
|
||||||
authType: "user-supplied",
|
|
||||||
accessToken,
|
|
||||||
instanceUrl,
|
|
||||||
pubSubEndpoint: endpoint,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
|
||||||
this.client = client;
|
|
||||||
this.clientAccessToken = accessToken;
|
|
||||||
this.replayCorruptionRecovered = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.client;
|
|
||||||
})();
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await this.clientBuildInFlight;
|
|
||||||
} finally {
|
|
||||||
this.clientBuildInFlight = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async subscribeWithPolicy(forceClientRefresh = false): Promise<void> {
|
|
||||||
if (!this.subscribeCallback) {
|
|
||||||
throw new Error("Subscribe callback not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.cache.set(sfStatusKey(this.channel), {
|
|
||||||
status: "connecting",
|
|
||||||
since: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = await this.ensureClient(forceClientRefresh);
|
|
||||||
|
|
||||||
const replayMode = this.config.get<string>("SF_EVENTS_REPLAY", "LATEST");
|
|
||||||
const replayKey = sfReplayKey(this.channel);
|
|
||||||
const storedReplay = replayMode !== "ALL" ? await this.cache.get<string>(replayKey) : null;
|
|
||||||
const numRequested = this.getNumRequested();
|
|
||||||
|
|
||||||
if (storedReplay && replayMode !== "ALL") {
|
|
||||||
await client.subscribeFromReplayId(
|
|
||||||
this.channel,
|
|
||||||
this.subscribeCallback,
|
|
||||||
numRequested,
|
|
||||||
Number(storedReplay)
|
|
||||||
);
|
|
||||||
} else if (replayMode === "ALL") {
|
|
||||||
await client.subscribeFromEarliestEvent(this.channel, this.subscribeCallback, numRequested);
|
|
||||||
} else {
|
|
||||||
await client.subscribe(this.channel, this.subscribeCallback, numRequested);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.cache.set(sfStatusKey(this.channel), {
|
|
||||||
status: "connected",
|
|
||||||
since: Date.now(),
|
|
||||||
});
|
|
||||||
this.logger.log("Salesforce Pub/Sub subscription active", { channel: this.channel });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async recoverFromStreamError(): Promise<void> {
|
|
||||||
await this.cache.set(sfStatusKey(this.channel), {
|
|
||||||
status: "reconnecting",
|
|
||||||
since: Date.now(),
|
|
||||||
});
|
|
||||||
await this.safeCloseClient();
|
|
||||||
await this.subscribeWithPolicy(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async safeCloseClient(): Promise<void> {
|
|
||||||
if (!this.client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this.client.close();
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn("Failed to close Salesforce Pub/Sub client", {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
this.client = null;
|
|
||||||
this.clientAccessToken = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -20,6 +20,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
|
|||||||
SalesforceWriteThrottleGuard,
|
SalesforceWriteThrottleGuard,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
|
QueueModule,
|
||||||
SalesforceService,
|
SalesforceService,
|
||||||
SalesforceConnection,
|
SalesforceConnection,
|
||||||
SalesforceOrderService,
|
SalesforceOrderService,
|
||||||
|
|||||||
@ -14,6 +14,16 @@ export interface CatalogCacheSnapshot {
|
|||||||
invalidations: number;
|
invalidations: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CacheDependencies {
|
||||||
|
productIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WrappedCatalogValue<T> {
|
||||||
|
value: T | null;
|
||||||
|
__catalogCache: true;
|
||||||
|
dependencies?: CacheDependencies;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Catalog-specific caching service
|
* Catalog-specific caching service
|
||||||
*
|
*
|
||||||
@ -25,10 +35,10 @@ export class CatalogCacheService {
|
|||||||
// Hybrid approach: CDC for real-time invalidation + TTL for backup cleanup
|
// Hybrid approach: CDC for real-time invalidation + TTL for backup cleanup
|
||||||
// Primary: CDC events invalidate cache when data changes (real-time)
|
// Primary: CDC events invalidate cache when data changes (real-time)
|
||||||
// Backup: TTL expires unused cache entries (memory management)
|
// Backup: TTL expires unused cache entries (memory management)
|
||||||
private readonly CATALOG_TTL = 86400; // 24 hours - general catalog data
|
private readonly CATALOG_TTL: number | null = null; // CDC-driven invalidation
|
||||||
private readonly STATIC_TTL = 604800; // 7 days - rarely changing data
|
private readonly STATIC_TTL: number | null = null; // CDC-driven invalidation
|
||||||
private readonly ELIGIBILITY_TTL = 3600; // 1 hour - user-specific eligibility
|
private readonly ELIGIBILITY_TTL: number | null = null; // CDC-driven invalidation
|
||||||
private readonly VOLATILE_TTL = 60; // 1 minute - real-time data (availability, inventory)
|
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
|
||||||
|
|
||||||
private readonly metrics: CatalogCacheSnapshot = {
|
private readonly metrics: CatalogCacheSnapshot = {
|
||||||
catalog: { hits: 0, misses: 0 },
|
catalog: { hits: 0, misses: 0 },
|
||||||
@ -48,8 +58,12 @@ export class CatalogCacheService {
|
|||||||
/**
|
/**
|
||||||
* Get or fetch catalog data (long-lived cache, event-driven invalidation)
|
* Get or fetch catalog data (long-lived cache, event-driven invalidation)
|
||||||
*/
|
*/
|
||||||
async getCachedCatalog<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
|
async getCachedCatalog<T>(
|
||||||
return this.getOrSet("catalog", key, this.CATALOG_TTL, fetchFn);
|
key: string,
|
||||||
|
fetchFn: () => Promise<T>,
|
||||||
|
options?: CatalogCacheOptions<T>
|
||||||
|
): Promise<T> {
|
||||||
|
return this.getOrSet("catalog", key, this.CATALOG_TTL, fetchFn, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -70,7 +84,9 @@ export class CatalogCacheService {
|
|||||||
* Get or fetch eligibility data (long-lived cache)
|
* Get or fetch eligibility data (long-lived cache)
|
||||||
*/
|
*/
|
||||||
async getCachedEligibility<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
|
async getCachedEligibility<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
|
||||||
return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, true);
|
return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, {
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -90,6 +106,7 @@ export class CatalogCacheService {
|
|||||||
async invalidateCatalog(catalogType: string): Promise<void> {
|
async invalidateCatalog(catalogType: string): Promise<void> {
|
||||||
this.metrics.invalidations++;
|
this.metrics.invalidations++;
|
||||||
await this.cache.delPattern(`catalog:${catalogType}:*`);
|
await this.cache.delPattern(`catalog:${catalogType}:*`);
|
||||||
|
await this.flushProductDependencyIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -107,12 +124,13 @@ export class CatalogCacheService {
|
|||||||
async invalidateAllCatalogs(): Promise<void> {
|
async invalidateAllCatalogs(): Promise<void> {
|
||||||
this.metrics.invalidations++;
|
this.metrics.invalidations++;
|
||||||
await this.cache.delPattern("catalog:*");
|
await this.cache.delPattern("catalog:*");
|
||||||
|
await this.flushProductDependencyIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
getTtlConfiguration(): {
|
getTtlConfiguration(): {
|
||||||
catalogSeconds: number;
|
catalogSeconds: number | null;
|
||||||
eligibilitySeconds: number;
|
eligibilitySeconds: number | null;
|
||||||
staticSeconds: number;
|
staticSeconds: number | null;
|
||||||
volatileSeconds: number;
|
volatileSeconds: number;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
@ -142,7 +160,11 @@ export class CatalogCacheService {
|
|||||||
typeof eligibility === "string"
|
typeof eligibility === "string"
|
||||||
? { Id: accountId, Internet_Eligibility__c: eligibility }
|
? { Id: accountId, Internet_Eligibility__c: eligibility }
|
||||||
: null;
|
: null;
|
||||||
await this.cache.set(key, this.wrapCachedValue(payload));
|
if (this.ELIGIBILITY_TTL === null) {
|
||||||
|
await this.cache.set(key, this.wrapCachedValue(payload));
|
||||||
|
} else {
|
||||||
|
await this.cache.set(key, this.wrapCachedValue(payload), this.ELIGIBILITY_TTL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrSet<T>(
|
private async getOrSet<T>(
|
||||||
@ -150,8 +172,9 @@ export class CatalogCacheService {
|
|||||||
key: string,
|
key: string,
|
||||||
ttlSeconds: number | null,
|
ttlSeconds: number | null,
|
||||||
fetchFn: () => Promise<T>,
|
fetchFn: () => Promise<T>,
|
||||||
allowNull = false
|
options?: CatalogCacheOptions<T>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
const allowNull = options?.allowNull ?? false;
|
||||||
// 1. Check Redis cache first (fastest path)
|
// 1. Check Redis cache first (fastest path)
|
||||||
const cached = await this.cache.get<unknown>(key);
|
const cached = await this.cache.get<unknown>(key);
|
||||||
const unwrapped = this.unwrapCachedValue<T>(cached);
|
const unwrapped = this.unwrapCachedValue<T>(cached);
|
||||||
@ -179,12 +202,27 @@ export class CatalogCacheService {
|
|||||||
try {
|
try {
|
||||||
const fresh = await fetchFn();
|
const fresh = await fetchFn();
|
||||||
const valueToStore = allowNull ? (fresh ?? null) : fresh;
|
const valueToStore = allowNull ? (fresh ?? null) : fresh;
|
||||||
|
const dependencies = options?.resolveDependencies
|
||||||
|
? await options.resolveDependencies(fresh)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (unwrapped.dependencies) {
|
||||||
|
await this.unlinkDependenciesForKey(key, unwrapped.dependencies);
|
||||||
|
}
|
||||||
|
|
||||||
// Store in Redis for future requests
|
// Store in Redis for future requests
|
||||||
if (ttlSeconds === null) {
|
if (ttlSeconds === null) {
|
||||||
await this.cache.set(key, this.wrapCachedValue(valueToStore));
|
await this.cache.set(key, this.wrapCachedValue(valueToStore, dependencies));
|
||||||
} else {
|
} else {
|
||||||
await this.cache.set(key, this.wrapCachedValue(valueToStore), ttlSeconds);
|
await this.cache.set(
|
||||||
|
key,
|
||||||
|
this.wrapCachedValue(valueToStore, dependencies),
|
||||||
|
ttlSeconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dependencies) {
|
||||||
|
await this.linkDependencies(key, dependencies);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fresh;
|
return fresh;
|
||||||
@ -200,7 +238,46 @@ export class CatalogCacheService {
|
|||||||
return fetchPromise;
|
return fetchPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private unwrapCachedValue<T>(cached: unknown): { hit: boolean; value: T | null } {
|
async invalidateProducts(productIds: string[]): Promise<boolean> {
|
||||||
|
const uniqueIds = Array.from(new Set((productIds ?? []).filter(Boolean)));
|
||||||
|
if (uniqueIds.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysToInvalidate = new Set<string>();
|
||||||
|
|
||||||
|
for (const productId of uniqueIds) {
|
||||||
|
const indexKey = this.buildProductDependencyKey(productId);
|
||||||
|
const index = await this.cache.get<{ keys?: string[] }>(indexKey);
|
||||||
|
const keys = index?.keys ?? [];
|
||||||
|
keys.forEach(k => keysToInvalidate.add(k));
|
||||||
|
if (keys.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keysToInvalidate.size === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keysToInvalidate) {
|
||||||
|
const cached = await this.cache.get<unknown>(key);
|
||||||
|
const unwrapped = this.unwrapCachedValue<unknown>(cached);
|
||||||
|
if (unwrapped.dependencies) {
|
||||||
|
await this.unlinkDependenciesForKey(key, unwrapped.dependencies);
|
||||||
|
}
|
||||||
|
await this.cache.del(key);
|
||||||
|
this.metrics.invalidations++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unwrapCachedValue<T>(cached: unknown): {
|
||||||
|
hit: boolean;
|
||||||
|
value: T | null;
|
||||||
|
dependencies?: CacheDependencies;
|
||||||
|
} {
|
||||||
if (cached === null || cached === undefined) {
|
if (cached === null || cached === undefined) {
|
||||||
return { hit: false, value: null };
|
return { hit: false, value: null };
|
||||||
}
|
}
|
||||||
@ -210,14 +287,90 @@ export class CatalogCacheService {
|
|||||||
cached !== null &&
|
cached !== null &&
|
||||||
Object.prototype.hasOwnProperty.call(cached, "__catalogCache")
|
Object.prototype.hasOwnProperty.call(cached, "__catalogCache")
|
||||||
) {
|
) {
|
||||||
const wrapper = cached as { value: T | null };
|
const wrapper = cached as WrappedCatalogValue<T>;
|
||||||
return { hit: true, value: wrapper.value ?? null };
|
return {
|
||||||
|
hit: true,
|
||||||
|
value: wrapper.value ?? null,
|
||||||
|
dependencies: wrapper.dependencies,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { hit: true, value: (cached as T) ?? null };
|
return { hit: true, value: (cached as T) ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
private wrapCachedValue<T>(value: T | null): { value: T | null; __catalogCache: true } {
|
private wrapCachedValue<T>(
|
||||||
return { value: value ?? null, __catalogCache: true };
|
value: T | null,
|
||||||
|
dependencies?: CacheDependencies
|
||||||
|
): WrappedCatalogValue<T> {
|
||||||
|
return {
|
||||||
|
value: value ?? null,
|
||||||
|
__catalogCache: true,
|
||||||
|
dependencies: dependencies && this.normalizeDependencies(dependencies),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeDependencies(dependencies: CacheDependencies): CacheDependencies | undefined {
|
||||||
|
const productIds = dependencies.productIds?.filter(Boolean);
|
||||||
|
if (!productIds || productIds.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { productIds: Array.from(new Set(productIds)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async linkDependencies(key: string, dependencies: CacheDependencies): Promise<void> {
|
||||||
|
const normalized = this.normalizeDependencies(dependencies);
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.productIds) {
|
||||||
|
for (const productId of normalized.productIds) {
|
||||||
|
const indexKey = this.buildProductDependencyKey(productId);
|
||||||
|
const existing = (await this.cache.get<{ keys?: string[] }>(indexKey))?.keys ?? [];
|
||||||
|
if (!existing.includes(key)) {
|
||||||
|
existing.push(key);
|
||||||
|
}
|
||||||
|
await this.cache.set(indexKey, { keys: existing });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async unlinkDependenciesForKey(
|
||||||
|
key: string,
|
||||||
|
dependencies: CacheDependencies
|
||||||
|
): Promise<void> {
|
||||||
|
const normalized = this.normalizeDependencies(dependencies);
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.productIds) {
|
||||||
|
for (const productId of normalized.productIds) {
|
||||||
|
const indexKey = this.buildProductDependencyKey(productId);
|
||||||
|
const existing = await this.cache.get<{ keys?: string[] }>(indexKey);
|
||||||
|
if (!existing?.keys?.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextKeys = existing.keys.filter(k => k !== key);
|
||||||
|
if (nextKeys.length === 0) {
|
||||||
|
await this.cache.del(indexKey);
|
||||||
|
} else {
|
||||||
|
await this.cache.set(indexKey, { keys: nextKeys });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildProductDependencyKey(productId: string): string {
|
||||||
|
return `catalog:deps:product:${productId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushProductDependencyIndex(): Promise<void> {
|
||||||
|
await this.cache.delPattern("catalog:deps:product:*");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CatalogCacheOptions<T> {
|
||||||
|
allowNull?: boolean;
|
||||||
|
resolveDependencies?: (value: T) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined;
|
||||||
|
}
|
||||||
|
|||||||
@ -41,87 +41,113 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
async getPlans(): Promise<InternetPlanCatalogItem[]> {
|
async getPlans(): Promise<InternetPlanCatalogItem[]> {
|
||||||
const cacheKey = this.catalogCache.buildCatalogKey("internet", "plans");
|
const cacheKey = this.catalogCache.buildCatalogKey("internet", "plans");
|
||||||
|
|
||||||
return this.catalogCache.getCachedCatalog(cacheKey, async () => {
|
return this.catalogCache.getCachedCatalog(
|
||||||
const soql = this.buildCatalogServiceQuery("Internet", [
|
cacheKey,
|
||||||
"Internet_Plan_Tier__c",
|
async () => {
|
||||||
"Internet_Offering_Type__c",
|
const soql = this.buildCatalogServiceQuery("Internet", [
|
||||||
"Catalog_Order__c",
|
"Internet_Plan_Tier__c",
|
||||||
]);
|
"Internet_Offering_Type__c",
|
||||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
"Catalog_Order__c",
|
||||||
soql,
|
]);
|
||||||
"Internet Plans"
|
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||||
);
|
soql,
|
||||||
|
"Internet Plans"
|
||||||
|
);
|
||||||
|
|
||||||
return records.map(record => {
|
return records.map(record => {
|
||||||
const entry = this.extractPricebookEntry(record);
|
const entry = this.extractPricebookEntry(record);
|
||||||
const plan = CatalogProviders.Salesforce.mapInternetPlan(record, entry);
|
const plan = CatalogProviders.Salesforce.mapInternetPlan(record, entry);
|
||||||
return enrichInternetPlanMetadata(plan);
|
return enrichInternetPlanMetadata(plan);
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
resolveDependencies: plans => ({
|
||||||
|
productIds: plans.map(plan => plan.id).filter((id): id is string => Boolean(id)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
async getInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
||||||
const cacheKey = this.catalogCache.buildCatalogKey("internet", "installations");
|
const cacheKey = this.catalogCache.buildCatalogKey("internet", "installations");
|
||||||
|
|
||||||
return this.catalogCache.getCachedCatalog(cacheKey, async () => {
|
return this.catalogCache.getCachedCatalog(
|
||||||
const soql = this.buildProductQuery("Internet", "Installation", [
|
cacheKey,
|
||||||
"Billing_Cycle__c",
|
async () => {
|
||||||
"Catalog_Order__c",
|
const soql = this.buildProductQuery("Internet", "Installation", [
|
||||||
]);
|
"Billing_Cycle__c",
|
||||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
"Catalog_Order__c",
|
||||||
soql,
|
]);
|
||||||
"Internet Installations"
|
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||||
);
|
soql,
|
||||||
|
"Internet Installations"
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log(`Found ${records.length} installation records`);
|
this.logger.log(`Found ${records.length} installation records`);
|
||||||
|
|
||||||
return records
|
return records
|
||||||
.map(record => {
|
.map(record => {
|
||||||
const entry = this.extractPricebookEntry(record);
|
const entry = this.extractPricebookEntry(record);
|
||||||
const installation = CatalogProviders.Salesforce.mapInternetInstallation(record, entry);
|
const installation = CatalogProviders.Salesforce.mapInternetInstallation(record, entry);
|
||||||
return {
|
return {
|
||||||
...installation,
|
...installation,
|
||||||
catalogMetadata: {
|
catalogMetadata: {
|
||||||
...installation.catalogMetadata,
|
...installation.catalogMetadata,
|
||||||
installationTerm: inferInstallationTermFromSku(installation.sku ?? ""),
|
installationTerm: inferInstallationTermFromSku(installation.sku ?? ""),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
resolveDependencies: installations => ({
|
||||||
|
productIds: installations
|
||||||
|
.map(item => item.id)
|
||||||
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAddons(): Promise<InternetAddonCatalogItem[]> {
|
async getAddons(): Promise<InternetAddonCatalogItem[]> {
|
||||||
const cacheKey = this.catalogCache.buildCatalogKey("internet", "addons");
|
const cacheKey = this.catalogCache.buildCatalogKey("internet", "addons");
|
||||||
|
|
||||||
return this.catalogCache.getCachedCatalog(cacheKey, async () => {
|
return this.catalogCache.getCachedCatalog(
|
||||||
const soql = this.buildProductQuery("Internet", "Add-on", [
|
cacheKey,
|
||||||
"Billing_Cycle__c",
|
async () => {
|
||||||
"Catalog_Order__c",
|
const soql = this.buildProductQuery("Internet", "Add-on", [
|
||||||
"Bundled_Addon__c",
|
"Billing_Cycle__c",
|
||||||
"Is_Bundled_Addon__c",
|
"Catalog_Order__c",
|
||||||
]);
|
"Bundled_Addon__c",
|
||||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
"Is_Bundled_Addon__c",
|
||||||
soql,
|
]);
|
||||||
"Internet Add-ons"
|
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||||
);
|
soql,
|
||||||
|
"Internet Add-ons"
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log(`Found ${records.length} addon records`);
|
this.logger.log(`Found ${records.length} addon records`);
|
||||||
|
|
||||||
return records
|
return records
|
||||||
.map(record => {
|
.map(record => {
|
||||||
const entry = this.extractPricebookEntry(record);
|
const entry = this.extractPricebookEntry(record);
|
||||||
const addon = CatalogProviders.Salesforce.mapInternetAddon(record, entry);
|
const addon = CatalogProviders.Salesforce.mapInternetAddon(record, entry);
|
||||||
return {
|
return {
|
||||||
...addon,
|
...addon,
|
||||||
catalogMetadata: {
|
catalogMetadata: {
|
||||||
...addon.catalogMetadata,
|
...addon.catalogMetadata,
|
||||||
addonType: inferAddonTypeFromSku(addon.sku ?? ""),
|
addonType: inferAddonTypeFromSku(addon.sku ?? ""),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
resolveDependencies: addons => ({
|
||||||
|
productIds: addons.map(addon => addon.id).filter((id): id is string => Boolean(id)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCatalogData() {
|
async getCatalogData() {
|
||||||
|
|||||||
@ -29,64 +29,21 @@ export class SimCatalogService extends BaseCatalogService {
|
|||||||
async getPlans(): Promise<SimCatalogProduct[]> {
|
async getPlans(): Promise<SimCatalogProduct[]> {
|
||||||
const cacheKey = this.catalogCache.buildCatalogKey("sim", "plans");
|
const cacheKey = this.catalogCache.buildCatalogKey("sim", "plans");
|
||||||
|
|
||||||
return this.catalogCache.getCachedCatalog(cacheKey, async () => {
|
return this.catalogCache.getCachedCatalog(
|
||||||
const soql = this.buildCatalogServiceQuery("SIM", [
|
cacheKey,
|
||||||
"SIM_Data_Size__c",
|
async () => {
|
||||||
"SIM_Plan_Type__c",
|
const soql = this.buildCatalogServiceQuery("SIM", [
|
||||||
"SIM_Has_Family_Discount__c",
|
"SIM_Data_Size__c",
|
||||||
"Catalog_Order__c",
|
"SIM_Plan_Type__c",
|
||||||
]);
|
"SIM_Has_Family_Discount__c",
|
||||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
"Catalog_Order__c",
|
||||||
soql,
|
]);
|
||||||
"SIM Plans"
|
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||||
);
|
soql,
|
||||||
|
"SIM Plans"
|
||||||
|
);
|
||||||
|
|
||||||
return records.map(record => {
|
return records.map(record => {
|
||||||
const entry = this.extractPricebookEntry(record);
|
|
||||||
const product = CatalogProviders.Salesforce.mapSimProduct(record, entry);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...product,
|
|
||||||
description: product.description ?? product.name,
|
|
||||||
} satisfies SimCatalogProduct;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
|
||||||
const cacheKey = this.catalogCache.buildCatalogKey("sim", "activation-fees");
|
|
||||||
|
|
||||||
return this.catalogCache.getCachedCatalog(cacheKey, async () => {
|
|
||||||
const soql = this.buildProductQuery("SIM", "Activation", []);
|
|
||||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
|
||||||
soql,
|
|
||||||
"SIM Activation Fees"
|
|
||||||
);
|
|
||||||
|
|
||||||
return records.map(record => {
|
|
||||||
const entry = this.extractPricebookEntry(record);
|
|
||||||
return CatalogProviders.Salesforce.mapSimActivationFee(record, entry);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAddons(): Promise<SimCatalogProduct[]> {
|
|
||||||
const cacheKey = this.catalogCache.buildCatalogKey("sim", "addons");
|
|
||||||
|
|
||||||
return this.catalogCache.getCachedCatalog(cacheKey, async () => {
|
|
||||||
const soql = this.buildProductQuery("SIM", "Add-on", [
|
|
||||||
"Billing_Cycle__c",
|
|
||||||
"Catalog_Order__c",
|
|
||||||
"Bundled_Addon__c",
|
|
||||||
"Is_Bundled_Addon__c",
|
|
||||||
]);
|
|
||||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
|
||||||
soql,
|
|
||||||
"SIM Add-ons"
|
|
||||||
);
|
|
||||||
|
|
||||||
return records
|
|
||||||
.map(record => {
|
|
||||||
const entry = this.extractPricebookEntry(record);
|
const entry = this.extractPricebookEntry(record);
|
||||||
const product = CatalogProviders.Salesforce.mapSimProduct(record, entry);
|
const product = CatalogProviders.Salesforce.mapSimProduct(record, entry);
|
||||||
|
|
||||||
@ -94,9 +51,80 @@ export class SimCatalogService extends BaseCatalogService {
|
|||||||
...product,
|
...product,
|
||||||
description: product.description ?? product.name,
|
description: product.description ?? product.name,
|
||||||
} satisfies SimCatalogProduct;
|
} satisfies SimCatalogProduct;
|
||||||
})
|
});
|
||||||
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
},
|
||||||
});
|
{
|
||||||
|
resolveDependencies: plans => ({
|
||||||
|
productIds: plans.map(plan => plan.id).filter((id): id is string => Boolean(id)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||||
|
const cacheKey = this.catalogCache.buildCatalogKey("sim", "activation-fees");
|
||||||
|
|
||||||
|
return this.catalogCache.getCachedCatalog(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const soql = this.buildProductQuery("SIM", "Activation", []);
|
||||||
|
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||||
|
soql,
|
||||||
|
"SIM Activation Fees"
|
||||||
|
);
|
||||||
|
|
||||||
|
return records.map(record => {
|
||||||
|
const entry = this.extractPricebookEntry(record);
|
||||||
|
return CatalogProviders.Salesforce.mapSimActivationFee(record, entry);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolveDependencies: products => ({
|
||||||
|
productIds: products
|
||||||
|
.map(product => product.id)
|
||||||
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAddons(): Promise<SimCatalogProduct[]> {
|
||||||
|
const cacheKey = this.catalogCache.buildCatalogKey("sim", "addons");
|
||||||
|
|
||||||
|
return this.catalogCache.getCachedCatalog(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const soql = this.buildProductQuery("SIM", "Add-on", [
|
||||||
|
"Billing_Cycle__c",
|
||||||
|
"Catalog_Order__c",
|
||||||
|
"Bundled_Addon__c",
|
||||||
|
"Is_Bundled_Addon__c",
|
||||||
|
]);
|
||||||
|
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||||
|
soql,
|
||||||
|
"SIM Add-ons"
|
||||||
|
);
|
||||||
|
|
||||||
|
return records
|
||||||
|
.map(record => {
|
||||||
|
const entry = this.extractPricebookEntry(record);
|
||||||
|
const product = CatalogProviders.Salesforce.mapSimProduct(record, entry);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...product,
|
||||||
|
description: product.description ?? product.name,
|
||||||
|
} satisfies SimCatalogProduct;
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolveDependencies: products => ({
|
||||||
|
productIds: products
|
||||||
|
.map(product => product.id)
|
||||||
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPlansForUser(userId: string): Promise<SimCatalogProduct[]> {
|
async getPlansForUser(userId: string): Promise<SimCatalogProduct[]> {
|
||||||
|
|||||||
@ -4,10 +4,7 @@ import { Logger } from "nestjs-pino";
|
|||||||
import { OrderFulfillmentOrchestrator } from "../services/order-fulfillment-orchestrator.service";
|
import { OrderFulfillmentOrchestrator } from "../services/order-fulfillment-orchestrator.service";
|
||||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||||
import type { ProvisioningJobData } from "./provisioning.queue";
|
import type { ProvisioningJobData } from "./provisioning.queue";
|
||||||
import { CacheService } from "@bff/infra/cache/cache.service";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { QUEUE_NAMES } from "@bff/infra/queue/queue.constants";
|
import { QUEUE_NAMES } from "@bff/infra/queue/queue.constants";
|
||||||
import { replayKey as sfReplayKey } from "@bff/integrations/salesforce/events/event-keys.util";
|
|
||||||
|
|
||||||
@Processor(QUEUE_NAMES.PROVISIONING)
|
@Processor(QUEUE_NAMES.PROVISIONING)
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -15,8 +12,6 @@ export class ProvisioningProcessor extends WorkerHost {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly orchestrator: OrderFulfillmentOrchestrator,
|
private readonly orchestrator: OrderFulfillmentOrchestrator,
|
||||||
private readonly salesforceService: SalesforceService,
|
private readonly salesforceService: SalesforceService,
|
||||||
private readonly cache: CacheService,
|
|
||||||
private readonly config: ConfigService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@ -28,7 +23,6 @@ export class ProvisioningProcessor extends WorkerHost {
|
|||||||
sfOrderId,
|
sfOrderId,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
correlationId: job.data.correlationId,
|
correlationId: job.data.correlationId,
|
||||||
pubsubReplayId: job.data.pubsubReplayId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Guard: Only process if Salesforce Order is currently 'Activating'
|
// Guard: Only process if Salesforce Order is currently 'Activating'
|
||||||
@ -41,7 +35,6 @@ export class ProvisioningProcessor extends WorkerHost {
|
|||||||
sfOrderId,
|
sfOrderId,
|
||||||
currentStatus: status,
|
currentStatus: status,
|
||||||
});
|
});
|
||||||
await this.commitReplay(job);
|
|
||||||
return; // Ack + no-op to safely handle duplicate/old events
|
return; // Ack + no-op to safely handle duplicate/old events
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,36 +45,11 @@ export class ProvisioningProcessor extends WorkerHost {
|
|||||||
currentStatus: status,
|
currentStatus: status,
|
||||||
lastErrorCode,
|
lastErrorCode,
|
||||||
});
|
});
|
||||||
await this.commitReplay(job);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Execute the same orchestration used by the webhook path, but without payload validation
|
||||||
// Execute the same orchestration used by the webhook path, but without payload validation
|
await this.orchestrator.executeFulfillment(sfOrderId, {}, idempotencyKey);
|
||||||
await this.orchestrator.executeFulfillment(sfOrderId, {}, idempotencyKey);
|
this.logger.log("Provisioning job completed", { sfOrderId });
|
||||||
this.logger.log("Provisioning job completed", { sfOrderId });
|
|
||||||
} finally {
|
|
||||||
// Commit processed replay id for Pub/Sub resume (commit regardless of success to avoid replay storms)
|
|
||||||
await this.commitReplay(job);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async commitReplay(job: { data: ProvisioningJobData }): Promise<void> {
|
|
||||||
if (typeof job.data.pubsubReplayId !== "number") return;
|
|
||||||
try {
|
|
||||||
const channel = this.config.get<string>(
|
|
||||||
"SF_PROVISION_EVENT_CHANNEL",
|
|
||||||
"/event/Order_Fulfilment_Requested__e"
|
|
||||||
);
|
|
||||||
const replayKey = sfReplayKey(channel);
|
|
||||||
const prev = Number((await this.cache.get<string>(replayKey)) ?? 0);
|
|
||||||
if (job.data.pubsubReplayId > prev) {
|
|
||||||
await this.cache.set(replayKey, String(job.data.pubsubReplayId));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.logger.warn("Failed to commit Pub/Sub replay id", {
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,6 @@ export interface ProvisioningJobData {
|
|||||||
sfOrderId: string;
|
sfOrderId: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
correlationId?: string;
|
correlationId?: string;
|
||||||
pubsubReplayId?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -19,10 +18,7 @@ export class ProvisioningQueueService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async enqueue(job: ProvisioningJobData): Promise<void> {
|
async enqueue(job: ProvisioningJobData): Promise<void> {
|
||||||
const jobId =
|
const jobId = job.idempotencyKey || `sf:${job.sfOrderId}`;
|
||||||
typeof job.pubsubReplayId === "number"
|
|
||||||
? `sf:${job.sfOrderId}:replay:${job.pubsubReplayId}`
|
|
||||||
: `sf:${job.sfOrderId}`;
|
|
||||||
try {
|
try {
|
||||||
await this.queue.add("provision", job, {
|
await this.queue.add("provision", job, {
|
||||||
jobId,
|
jobId,
|
||||||
@ -35,7 +31,6 @@ export class ProvisioningQueueService {
|
|||||||
sfOrderId: job.sfOrderId,
|
sfOrderId: job.sfOrderId,
|
||||||
idempotencyKey: job.idempotencyKey,
|
idempotencyKey: job.idempotencyKey,
|
||||||
correlationId: job.correlationId,
|
correlationId: job.correlationId,
|
||||||
pubsubReplayId: job.pubsubReplayId,
|
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@ -25,11 +25,13 @@ Enqueues provisioning job
|
|||||||
```
|
```
|
||||||
Salesforce Order Status → "Approved"
|
Salesforce Order Status → "Approved"
|
||||||
↓
|
↓
|
||||||
CDC: OrderChangeEvent (automatic)
|
Salesforce Flow: set Activation_Status__c = "Activating" (clear errors)
|
||||||
|
↓
|
||||||
|
CDC: OrderChangeEvent (Activation_Status__c)
|
||||||
↓
|
↓
|
||||||
OrderCdcSubscriber receives event
|
OrderCdcSubscriber receives event
|
||||||
↓
|
↓
|
||||||
Detects: Status changed to "Approved"
|
Detects: Activation_Status__c changed to "Activating"
|
||||||
↓
|
↓
|
||||||
Enqueues provisioning job
|
Enqueues provisioning job
|
||||||
```
|
```
|
||||||
@ -41,7 +43,7 @@ Enqueues provisioning job
|
|||||||
### **OrderCdcSubscriber Now Handles:**
|
### **OrderCdcSubscriber Now Handles:**
|
||||||
|
|
||||||
1. **Order Provisioning** (NEW)
|
1. **Order Provisioning** (NEW)
|
||||||
- Detects when Status changes to "Approved" or "Reactivate"
|
- Detects when Salesforce sets `Activation_Status__c` to `"Activating"`
|
||||||
- Validates order is not already provisioning/provisioned
|
- Validates order is not already provisioning/provisioned
|
||||||
- Enqueues provisioning job
|
- Enqueues provisioning job
|
||||||
|
|
||||||
@ -52,16 +54,13 @@ Enqueues provisioning job
|
|||||||
### **Key Guards Added:**
|
### **Key Guards Added:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 1. Only trigger for specific statuses
|
// 1. Only continue when Activation_Status__c === "Activating"
|
||||||
PROVISION_TRIGGER_STATUSES = ["Approved", "Reactivate"]
|
if (activationStatus !== "Activating") return;
|
||||||
|
|
||||||
// 2. Don't trigger if already provisioning
|
// 2. (Optional) If Status is present, require Approved/Reactivate
|
||||||
if (activationStatus === "Activating") return;
|
if (status && !PROVISION_TRIGGER_STATUSES.has(status)) return;
|
||||||
|
|
||||||
// 3. Don't trigger if already activated
|
// 3. Don't trigger if already has WHMCS Order ID
|
||||||
if (activationStatus === "Activated") return;
|
|
||||||
|
|
||||||
// 4. Don't trigger if already has WHMCS Order ID
|
|
||||||
if (whmcsOrderId) return;
|
if (whmcsOrderId) return;
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -74,55 +73,54 @@ if (whmcsOrderId) return;
|
|||||||
```
|
```
|
||||||
TIME: 10:00:00 - Admin clicks "Approve" in Salesforce
|
TIME: 10:00:00 - Admin clicks "Approve" in Salesforce
|
||||||
↓
|
↓
|
||||||
Order Status: "Pending Review" → "Approved"
|
Salesforce Flow:
|
||||||
|
- Sets Activation_Status__c = "Activating"
|
||||||
|
- Clears Activation_Error_* fields
|
||||||
↓
|
↓
|
||||||
TIME: 10:00:01 - CDC event published (automatic)
|
TIME: 10:00:01 - CDC event published (automatic)
|
||||||
{
|
{
|
||||||
"Id": "801xxx",
|
"Id": "801xxx",
|
||||||
"Status": "Approved",
|
"Activation_Status__c": "Activating",
|
||||||
"Activation_Status__c": null,
|
"Activation_Error_Code__c": null,
|
||||||
"WHMCS_Order_ID__c": null,
|
"Activation_Error_Message__c": null,
|
||||||
"changedFields": ["Status"]
|
"changedFields": ["Activation_Status__c", "Activation_Error_Code__c", "Activation_Error_Message__c"]
|
||||||
}
|
}
|
||||||
↓
|
↓
|
||||||
TIME: 10:00:02 - OrderCdcSubscriber.handleOrderEvent()
|
TIME: 10:00:02 - OrderCdcSubscriber.handleOrderEvent()
|
||||||
↓
|
↓
|
||||||
Step 1: Check if Status field changed
|
Step 1: Check if Activation_Status__c changed
|
||||||
→ Yes, Status in changedFields
|
→ Yes, value is "Activating"
|
||||||
↓
|
↓
|
||||||
Step 2: handleStatusChange()
|
Step 2: handleActivationStatusChange()
|
||||||
→ newStatus = "Approved" ✅
|
→ activationStatus = "Activating" ✅
|
||||||
→ activationStatus = null ✅ (not provisioning)
|
→ status (if provided) = "Approved" ✅
|
||||||
→ whmcsOrderId = null ✅ (not provisioned)
|
→ whmcsOrderId = null ✅ (not provisioned)
|
||||||
↓
|
↓
|
||||||
Step 3: Enqueue provisioning job
|
Step 3: Enqueue provisioning job
|
||||||
provisioningQueue.enqueue({
|
provisioningQueue.enqueue({
|
||||||
sfOrderId: "801xxx",
|
sfOrderId: "801xxx",
|
||||||
idempotencyKey: "cdc-status-1699999999999-801xxx",
|
idempotencyKey: "cdc-activation-1699999999999-801xxx",
|
||||||
correlationId: "cdc-order-801xxx"
|
correlationId: "cdc-order-801xxx"
|
||||||
})
|
})
|
||||||
↓
|
↓
|
||||||
Log: "Order status changed to provision trigger via CDC"
|
Log: "Order activation moved to Activating via CDC, enqueuing fulfillment"
|
||||||
↓
|
↓
|
||||||
TIME: 10:00:03 - Provisioning processor picks up job
|
TIME: 10:00:03 - Provisioning processor picks up job
|
||||||
↓
|
↓
|
||||||
Executes fulfillment
|
Executes fulfillment (Activation_Status__c already = "Activating" so guard passes)
|
||||||
↓
|
↓
|
||||||
Updates Salesforce:
|
Updates Salesforce:
|
||||||
- Activation_Status__c: "Activating"
|
|
||||||
- Then: "Activated"
|
|
||||||
- WHMCS_Order_ID__c: "12345"
|
|
||||||
- Status: "Completed"
|
- Status: "Completed"
|
||||||
|
- Activation_Status__c: "Activated"
|
||||||
|
- WHMCS_Order_ID__c: "12345"
|
||||||
↓
|
↓
|
||||||
TIME: 10:00:05 - CDC events for status updates
|
TIME: 10:00:05 - CDC events for status updates
|
||||||
Event 1: Activation_Status__c changed
|
Event 1: Activation_Status__c changed to "Activated"
|
||||||
→ OrderCdcSubscriber checks
|
→ Internal field → Skip cache invalidation ✅
|
||||||
→ Is internal field → Skip cache invalidation ✅
|
→ Not "Activating" → Skip provisioning ✅
|
||||||
→ Status didn't change → Skip provisioning ✅
|
|
||||||
|
|
||||||
Event 2: Status → "Completed"
|
Event 2: Status → "Completed"
|
||||||
→ OrderCdcSubscriber checks
|
→ Status changed but not "Approved"/"Reactivate" → Skip provisioning ✅
|
||||||
→ Status changed but not "Approved" → Skip provisioning ✅
|
|
||||||
→ Customer-facing field → Invalidate cache ✅
|
→ Customer-facing field → Invalidate cache ✅
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
1
env/portal-backend.env.sample
vendored
1
env/portal-backend.env.sample
vendored
@ -96,7 +96,6 @@ SF_QUEUE_LONG_RUNNING_TIMEOUT_MS=600000
|
|||||||
|
|
||||||
# Salesforce Platform Events (Provisioning)
|
# Salesforce Platform Events (Provisioning)
|
||||||
SF_EVENTS_ENABLED=true
|
SF_EVENTS_ENABLED=true
|
||||||
SF_PROVISION_EVENT_CHANNEL=/event/Order_Fulfilment_Requested__e
|
|
||||||
SF_CATALOG_EVENT_CHANNEL=/event/Product_and_Pricebook_Change__e
|
SF_CATALOG_EVENT_CHANNEL=/event/Product_and_Pricebook_Change__e
|
||||||
SF_ACCOUNT_EVENT_CHANNEL=/event/Account_Internet_Eligibility_Update__e
|
SF_ACCOUNT_EVENT_CHANNEL=/event/Account_Internet_Eligibility_Update__e
|
||||||
SF_ORDER_EVENT_CHANNEL=/event/Order_Fulfilment_Requested__e
|
SF_ORDER_EVENT_CHANNEL=/event/Order_Fulfilment_Requested__e
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user