diff --git a/PLESK_DEPLOYMENT.md b/PLESK_DEPLOYMENT.md index 81c51c2c..3f8748f5 100644 --- a/PLESK_DEPLOYMENT.md +++ b/PLESK_DEPLOYMENT.md @@ -90,6 +90,12 @@ customer-portal/ - `DATABASE_URL` should use `database:5432` - `REDIS_URL` should use `cache:6379` - Set `JWT_SECRET` to a strong value + - Salesforce credentials: `SF_LOGIN_URL`, `SF_CLIENT_ID`, `SF_USERNAME` + - Salesforce private key: set `SF_PRIVATE_KEY_PATH=/app/secrets/sf-private.key` and mount `/app/secrets` + - Webhook secrets: `SF_WEBHOOK_SECRET` (Salesforce), `WHMCS_WEBHOOK_SECRET` (if using WHMCS webhooks) + - Webhook tolerances: `WEBHOOK_TIMESTAMP_TOLERANCE=300000` (ms; optional) + - Optional IP allowlists: `SF_WEBHOOK_IP_ALLOWLIST`, `WHMCS_WEBHOOK_IP_ALLOWLIST` (CSV of IP/CIDR) + - Pricebook: `PORTAL_PRICEBOOK_ID` ### Image Build and Upload @@ -123,6 +129,17 @@ In Plesk → Docker → Images, upload both tar files. Then use `compose-plesk.y - `/` → `portal-frontend` port `3000` - `/api` → `portal-backend` port `4000` +### Webhook Security (Plesk) + +- Endpoint for Salesforce Quick Action: + - `POST /api/orders/{sfOrderId}/fulfill` +- Required backend env (see above). Ensure the same HMAC secret is configured in Salesforce. +- The backend guard enforces: + - HMAC for all webhooks + - Salesforce: timestamp + nonce with Redis-backed replay protection + - WHMCS: timestamp/nonce optional (validated if present) +- Health check `/health` includes `integrations.redis` to verify nonce storage. + Alternatively, load via SSH on the Plesk host: ```bash diff --git a/README.md b/README.md index fa9e194e..41283bf9 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,24 @@ SF_PRIVATE_KEY_PATH=./secrets/sf-dev.key SF_USERNAME=dev@yourcompany.com.sandbox ``` + +#### Salesforce Pub/Sub (Events) + +```env +# Enable Pub/Sub subscription for order provisioning +SF_EVENTS_ENABLED=true +SF_PROVISION_EVENT_CHANNEL=/event/Order_Fulfilment_Requested__e +SF_EVENTS_REPLAY=LATEST # or ALL for retention replay +SF_PUBSUB_ENDPOINT=api.pubsub.salesforce.com:7443 +SF_PUBSUB_NUM_REQUESTED=50 # flow control window +``` + +- Verify subscriber status: `GET /health/sf-events` + - `enabled`: whether Pub/Sub is enabled + - `channel`: topic name + - `replay.lastReplayId`: last committed cursor + - `subscriber.status`: connected | disconnected | unknown + ### Development Tools Access When running `pnpm dev:tools`, you get access to: @@ -247,6 +265,7 @@ When running `pnpm dev:tools`, you get access to: ### Webhooks +- `POST /api/orders/:sfOrderId/fulfill` - Secure Salesforce-initiated order fulfillment - `POST /api/webhooks/whmcs` - WHMCS action hooks → update mirrors + bust cache ## Frontend Pages @@ -411,3 +430,4 @@ rm -rf node_modules && pnpm install ## License [Your License Here] +See `docs/RUNBOOK_PROVISIONING.md` for the provisioning runbook. diff --git a/apps/bff/package.json b/apps/bff/package.json index cf283643..7b98cad7 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -61,7 +61,8 @@ "rxjs": "^7.8.2", "speakeasy": "^2.0.0", "uuid": "^11.1.0", - "zod": "^4.0.17" + "zod": "^4.0.17", + "salesforce-pubsub-api-client": "^5.5.0" }, "devDependencies": { "@nestjs/cli": "^11.0.10", @@ -110,4 +111,4 @@ }, "passWithNoTests": true } -} +} \ No newline at end of file diff --git a/apps/bff/prisma/schema.prisma b/apps/bff/prisma/schema.prisma index 7bd8f942..cd32e290 100644 --- a/apps/bff/prisma/schema.prisma +++ b/apps/bff/prisma/schema.prisma @@ -1,6 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - generator client { provider = "prisma-client-js" } @@ -10,67 +7,54 @@ datasource db { url = env("DATABASE_URL") } -enum UserRole { - USER - ADMIN -} - model User { - id String @id @default(uuid()) - email String @unique - passwordHash String? @map("password_hash") - firstName String? @map("first_name") - lastName String? @map("last_name") - company String? - phone String? - role UserRole @default(USER) - mfaSecret String? @map("mfa_secret") - emailVerified Boolean @default(false) @map("email_verified") - - // Security fields - failedLoginAttempts Int @default(0) @map("failed_login_attempts") - lockedUntil DateTime? @map("locked_until") - lastLoginAt DateTime? @map("last_login_at") - - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - // Relationships - idMapping IdMapping? - idempotencyKeys IdempotencyKey[] - invoicesMirror InvoiceMirror[] + id String @id @default(uuid()) + email String @unique + passwordHash String? @map("password_hash") + firstName String? @map("first_name") + lastName String? @map("last_name") + company String? + phone String? + role UserRole @default(USER) + mfaSecret String? @map("mfa_secret") + emailVerified Boolean @default(false) @map("email_verified") + failedLoginAttempts Int @default(0) @map("failed_login_attempts") + lockedUntil DateTime? @map("locked_until") + lastLoginAt DateTime? @map("last_login_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + auditLogs AuditLog[] + idMapping IdMapping? + idempotencyKeys IdempotencyKey[] + invoicesMirror InvoiceMirror[] subscriptionsMirror SubscriptionMirror[] - auditLogs AuditLog[] @@map("users") } model IdMapping { - userId String @id @map("user_id") - whmcsClientId Int @unique @map("whmcs_client_id") - sfAccountId String? @map("sf_account_id") - - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String @id @map("user_id") + whmcsClientId Int @unique @map("whmcs_client_id") + sfAccountId String? @map("sf_account_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("id_mappings") } model InvoiceMirror { - invoiceId Int @id @map("invoice_id") - userId String @map("user_id") + invoiceId Int @id @map("invoice_id") + userId String @map("user_id") number String status String - amountCents Int @map("amount_cents") - currency String @db.Char(3) + amountCents Int @map("amount_cents") + currency String @db.Char(3) dueDate DateTime? @map("due_date") @db.Date issuedAt DateTime? @map("issued_at") paidAt DateTime? @map("paid_at") - updatedAt DateTime @updatedAt @map("updated_at") - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, status]) @@index([userId, dueDate]) @@ -78,19 +62,18 @@ model InvoiceMirror { } model SubscriptionMirror { - serviceId Int @id @map("service_id") - userId String @map("user_id") - productName String @map("product_name") - domain String? - cycle String - status String - nextDue DateTime? @map("next_due") - amountCents Int @map("amount_cents") - currency String @db.Char(3) - registeredAt DateTime @map("registered_at") - updatedAt DateTime @updatedAt @map("updated_at") - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + serviceId Int @id @map("service_id") + userId String @map("user_id") + productName String @map("product_name") + domain String? + cycle String + status String + nextDue DateTime? @map("next_due") + amountCents Int @map("amount_cents") + currency String @db.Char(3) + registeredAt DateTime @map("registered_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, status]) @@index([userId, nextDue]) @@ -101,32 +84,54 @@ model IdempotencyKey { key String @id userId String @map("user_id") createdAt DateTime @default(now()) @map("created_at") - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) - @@index([createdAt]) // For cleanup jobs + @@index([createdAt]) @@map("idempotency_keys") } -// Job queue tables (if not using external Redis-only solution) model Job { - id String @id @default(uuid()) - name String - data Json - status JobStatus @default(PENDING) - attempts Int @default(0) - maxRetries Int @default(3) @map("max_retries") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) + name String + data Json + status JobStatus @default(PENDING) + attempts Int @default(0) + maxRetries Int @default(3) @map("max_retries") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") processedAt DateTime? @map("processed_at") - failedAt DateTime? @map("failed_at") - error String? + failedAt DateTime? @map("failed_at") + error String? @@index([status, createdAt]) @@map("jobs") } +model AuditLog { + id String @id @default(uuid()) + userId String? @map("user_id") + action AuditAction + resource String? + details Json? + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + success Boolean @default(true) + error String? + createdAt DateTime @default(now()) @map("created_at") + user User? @relation(fields: [userId], references: [id]) + + @@index([userId, action]) + @@index([action, createdAt]) + @@index([createdAt]) + @@map("audit_logs") +} + +enum UserRole { + USER + ADMIN +} + enum JobStatus { PENDING PROCESSING @@ -135,27 +140,6 @@ enum JobStatus { RETRYING } -// Audit logging for security events -model AuditLog { - id String @id @default(uuid()) - userId String? @map("user_id") - action AuditAction - resource String? // e.g., "auth", "user", "invoice" - details Json? // Additional context - ipAddress String? @map("ip_address") - userAgent String? @map("user_agent") - success Boolean @default(true) - error String? // Error message if action failed - createdAt DateTime @default(now()) @map("created_at") - - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - - @@index([userId, action]) - @@index([action, createdAt]) - @@index([createdAt]) // For cleanup jobs - @@map("audit_logs") -} - enum AuditAction { LOGIN_SUCCESS LOGIN_FAILED diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index 9b2adc0d..0df84618 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -13,12 +13,14 @@ import { LoggingModule } from "./common/logging/logging.module"; import { PrismaModule } from "./common/prisma/prisma.module"; import { RedisModule } from "./common/redis/redis.module"; import { CacheModule } from "./common/cache/cache.module"; +import { QueueModule } from "./common/queue/queue.module"; import { AuditModule } from "./common/audit/audit.module"; import { EmailModule } from "./common/email/email.module"; // External Integration Modules import { VendorsModule } from "./vendors/vendors.module"; import { JobsModule } from "./jobs/jobs.module"; +import { SalesforceEventsModule } from "./vendors/salesforce/events/events.module"; // Feature Modules import { AuthModule } from "./auth/auth.module"; @@ -29,7 +31,6 @@ import { OrdersModule } from "./orders/orders.module"; import { InvoicesModule } from "./invoices/invoices.module"; import { SubscriptionsModule } from "./subscriptions/subscriptions.module"; import { CasesModule } from "./cases/cases.module"; -import { WebhooksModule } from "./webhooks/webhooks.module"; // System Modules import { HealthModule } from "./health/health.module"; @@ -63,11 +64,13 @@ import { HealthModule } from "./health/health.module"; PrismaModule, RedisModule, CacheModule, + QueueModule, AuditModule, EmailModule, // === EXTERNAL INTEGRATIONS === VendorsModule, + SalesforceEventsModule, JobsModule, // === FEATURE MODULES === @@ -79,7 +82,6 @@ import { HealthModule } from "./health/health.module"; InvoicesModule, SubscriptionsModule, CasesModule, - WebhooksModule, // === SYSTEM MODULES === HealthModule, diff --git a/apps/bff/src/auth/auth.service.ts b/apps/bff/src/auth/auth.service.ts index 667d95f2..afa33745 100644 --- a/apps/bff/src/auth/auth.service.ts +++ b/apps/bff/src/auth/auth.service.ts @@ -377,15 +377,13 @@ export class AuthService { sfAccountId: sfAccount.id, }); - // 4. Update WH_Account__c field in Salesforce - const whAccountValue = `#${whmcsClient.clientId} - ${firstName} ${lastName}`; - await this.salesforceService.updateWhAccount(sfAccount.id, whAccountValue); + // 4. Do not update Salesforce Account fields from the portal. Salesforce stays authoritative. // Log successful signup await this.auditService.logAuthEvent( AuditAction.SIGNUP, user.id, - { email, whmcsClientId: whmcsClient.clientId, whAccountValue }, + { email, whmcsClientId: whmcsClient.clientId }, request, true ); diff --git a/apps/bff/src/auth/services/token-blacklist.service.ts b/apps/bff/src/auth/services/token-blacklist.service.ts index 7e28a39d..89b4b48e 100644 --- a/apps/bff/src/auth/services/token-blacklist.service.ts +++ b/apps/bff/src/auth/services/token-blacklist.service.ts @@ -1,12 +1,14 @@ import { Injectable, Inject } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Redis } from "ioredis"; +import { Logger } from "nestjs-pino"; @Injectable() export class TokenBlacklistService { constructor( @Inject("REDIS_CLIENT") private readonly redis: Redis, - private readonly configService: ConfigService + private readonly configService: ConfigService, + @Inject(Logger) private readonly logger: Logger ) {} async blacklistToken(token: string, _expiresIn?: number): Promise { @@ -22,16 +24,30 @@ export class TokenBlacklistService { if (ttl > 0) { await this.redis.setex(`blacklist:${token}`, ttl, "1"); } - } catch { + } catch (e) { // If we can't parse the token, blacklist it for the default JWT expiry time - const defaultTtl = this.parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d")); - await this.redis.setex(`blacklist:${token}`, defaultTtl, "1"); + try { + const defaultTtl = this.parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d")); + await this.redis.setex(`blacklist:${token}`, defaultTtl, "1"); + } catch (err) { + this.logger.warn("Failed to write token to Redis blacklist; proceeding without persistence", { + error: err instanceof Error ? err.message : String(err), + }); + } } } async isTokenBlacklisted(token: string): Promise { - const result = await this.redis.get(`blacklist:${token}`); - return result !== null; + try { + const result = await this.redis.get(`blacklist:${token}`); + return result !== null; + } catch (err) { + // If Redis is unavailable, treat as not blacklisted to avoid blocking auth + this.logger.warn("Redis unavailable during blacklist check; allowing request", { + error: err instanceof Error ? err.message : String(err), + }); + return false; + } } private parseJwtExpiry(expiresIn: string): number { diff --git a/apps/bff/src/catalog/services/base-catalog.service.ts b/apps/bff/src/catalog/services/base-catalog.service.ts index 0a3d7fa9..5839f904 100644 --- a/apps/bff/src/catalog/services/base-catalog.service.ts +++ b/apps/bff/src/catalog/services/base-catalog.service.ts @@ -35,8 +35,15 @@ export class BaseCatalogService { const price = pricebookEntries?.records?.[0]?.UnitPrice || 0; if (price === 0) { + const fields = this.getFields(); + const skuField = fields.product.sku; + const sku = record[skuField]; this.logger.warn( - `No price found for product ${String(record["Name"])} (SKU: ${String(record["StockKeepingUnit"])}). Pricebook ID: ${this.portalPriceBookId}. PricebookEntries: ${pricebookEntries?.records?.length || 0} records` + `No price found for product ${String(record["Name"])} (SKU: ${String( + typeof sku === "string" || typeof sku === "number" ? sku : "" + )}). Pricebook ID: ${this.portalPriceBookId}. PricebookEntries: ${ + pricebookEntries?.records?.length || 0 + } records` ); } @@ -67,7 +74,7 @@ export class BaseCatalogService { AND ${fields.product.itemClass} = '${itemClass}' AND ${fields.product.portalAccessible} = true ${additionalConditions} - ORDER BY Catalog_Order__c NULLS LAST, Name + ORDER BY ${fields.product.displayOrder} NULLS LAST, Name `; } diff --git a/apps/bff/src/common/cache/cache.service.ts b/apps/bff/src/common/cache/cache.service.ts index 2b400817..40d16923 100644 --- a/apps/bff/src/common/cache/cache.service.ts +++ b/apps/bff/src/common/cache/cache.service.ts @@ -28,10 +28,34 @@ export class CacheService { } async delPattern(pattern: string): Promise { - const keys = await this.redis.keys(pattern); - if (keys.length > 0) { - await this.redis.del(...keys); - } + // Use SCAN to avoid blocking Redis for large keyspaces + let cursor = "0"; + const batch: string[] = []; + const flush = async () => { + if (batch.length > 0) { + // Use pipeline to delete in bulk + const pipeline = this.redis.pipeline(); + for (const k of batch.splice(0, batch.length)) pipeline.del(k); + await pipeline.exec(); + } + }; + do { + const [next, keys] = (await this.redis.scan( + cursor, + "MATCH", + pattern, + "COUNT", + 1000 + )) as unknown as [string, string[]]; + cursor = next; + if (keys && keys.length) { + batch.push(...keys); + if (batch.length >= 1000) { + await flush(); + } + } + } while (cursor !== "0"); + await flush(); } async exists(key: string): Promise { diff --git a/apps/bff/src/common/config/env.validation.ts b/apps/bff/src/common/config/env.validation.ts index 99e5debd..91ffaa24 100644 --- a/apps/bff/src/common/config/env.validation.ts +++ b/apps/bff/src/common/config/env.validation.ts @@ -43,6 +43,16 @@ export const envSchema = z.object({ SF_PRIVATE_KEY_PATH: z.string().optional(), SF_WEBHOOK_SECRET: z.string().optional(), + // Salesforce Platform Events (Async Provisioning) + SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("false"), + SF_PROVISION_EVENT_CHANNEL: z + .string() + .default("/event/Order_Fulfilment_Requested__e"), + SF_EVENTS_REPLAY: z.enum(["LATEST", "ALL"]).default("LATEST"), + SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"), + SF_PUBSUB_NUM_REQUESTED: z.string().default("50"), + SF_PUBSUB_QUEUE_MAX: z.string().default("100"), + // Email / SendGrid SENDGRID_API_KEY: z.string().optional(), EMAIL_FROM: z.string().email().default("no-reply@example.com"), diff --git a/apps/bff/src/common/config/field-map.ts b/apps/bff/src/common/config/field-map.ts index b62cde0a..eb51d573 100644 --- a/apps/bff/src/common/config/field-map.ts +++ b/apps/bff/src/common/config/field-map.ts @@ -72,6 +72,11 @@ export type SalesforceFieldMap = { // WHMCS integration whmcsOrderId: string; + // Provisioning diagnostics + lastErrorCode?: string; + lastErrorMessage?: string; + lastAttemptAt?: string; + // Address fields addressChanged: string; @@ -137,7 +142,8 @@ export function getSalesforceFieldMap(): SalesforceFieldMap { // Internet fields (independent fields synced with OrderItems) internetPlanTier: process.env.ORDER_INTERNET_PLAN_TIER_FIELD || "Internet_Plan_Tier__c", - installationType: process.env.ORDER_INSTALLATION_TYPE_FIELD || "Installation_Type__c", + + installationType: process.env.ORDER_INSTALLATION_TYPE_FIELD || "Installment_Plan__c", weekendInstall: process.env.ORDER_WEEKEND_INSTALL_FIELD || "Weekend_Install__c", accessMode: process.env.ORDER_ACCESS_MODE_FIELD || "Access_Mode__c", hikariDenwa: process.env.ORDER_HIKARI_DENWA_FIELD || "Hikari_Denwa__c", @@ -171,18 +177,24 @@ export function getSalesforceFieldMap(): SalesforceFieldMap { // WHMCS integration whmcsOrderId: process.env.ORDER_WHMCS_ORDER_ID_FIELD || "WHMCS_Order_ID__c", + // Diagnostics (optional fields) — single source of truth: Activation_* fields + lastErrorCode: process.env.ORDER_ACTIVATION_ERROR_CODE_FIELD || "Activation_Error_Code__c", + lastErrorMessage: + process.env.ORDER_ACTIVATION_ERROR_MESSAGE_FIELD || "Activation_Error_Message__c", + lastAttemptAt: process.env.ORDER_ACTIVATION_LAST_ATTEMPT_AT_FIELD || "ActivatedDate", + // Address fields addressChanged: process.env.ORDER_ADDRESS_CHANGED_FIELD || "Address_Changed__c", - // Billing address snapshot fields + // Billing address snapshot fields — single source of truth: Billing* fields on Order billing: { - // Default to standard Order BillingAddress components - // Env overrides maintain backward compatibility if orgs used custom fields - street: process.env.ORDER_BILL_TO_STREET_FIELD || "BillingStreet", - city: process.env.ORDER_BILL_TO_CITY_FIELD || "BillingCity", - state: process.env.ORDER_BILL_TO_STATE_FIELD || "BillingState", - postalCode: process.env.ORDER_BILL_TO_POSTAL_CODE_FIELD || "BillingPostalCode", - country: process.env.ORDER_BILL_TO_COUNTRY_FIELD || "BillingCountry", + + street: process.env.ORDER_BILLING_STREET_FIELD || "BillingStreet", + city: process.env.ORDER_BILLING_CITY_FIELD || "BillingCity", + state: process.env.ORDER_BILLING_STATE_FIELD || "BillingState", + postalCode: process.env.ORDER_BILLING_POSTAL_CODE_FIELD || "BillingPostalCode", + country: process.env.ORDER_BILLING_COUNTRY_FIELD || "BillingCountry", + }, }, orderItem: { @@ -229,6 +241,9 @@ export function getOrderQueryFields(): string { fields.order.activationType, fields.order.activationScheduledAt, fields.order.activationStatus, + fields.order.lastErrorCode, + fields.order.lastErrorMessage, + fields.order.lastAttemptAt, // Internet fields fields.order.internetPlanTier, fields.order.installationType, @@ -246,3 +261,18 @@ export function getOrderQueryFields(): string { fields.order.whmcsOrderId, ].join(", "); } + +// Build a nested select list for PricebookEntry.Product2.* fields used in OrderItem queries +export function getOrderItemProduct2Select(additional: string[] = []): string { + const fields = getSalesforceFieldMap(); + const base = [ + "Id", + "Name", + fields.product.sku, + fields.product.whmcsProductId, + fields.product.itemClass, + fields.product.billingCycle, + ]; + const all = [...base, ...additional]; + return all.map(f => `PricebookEntry.Product2.${f}`).join(", "); +} diff --git a/apps/bff/src/common/config/router.config.ts b/apps/bff/src/common/config/router.config.ts index 1a34d935..398a4a1d 100644 --- a/apps/bff/src/common/config/router.config.ts +++ b/apps/bff/src/common/config/router.config.ts @@ -7,7 +7,6 @@ import { OrdersModule } from "../../orders/orders.module"; import { InvoicesModule } from "../../invoices/invoices.module"; import { SubscriptionsModule } from "../../subscriptions/subscriptions.module"; import { CasesModule } from "../../cases/cases.module"; -import { WebhooksModule } from "../../webhooks/webhooks.module"; /** * API routing configuration @@ -26,7 +25,6 @@ export const apiRoutes: Routes = [ { path: "", module: InvoicesModule }, { path: "", module: SubscriptionsModule }, { path: "", module: CasesModule }, - { path: "", module: WebhooksModule }, ], }, ]; diff --git a/apps/bff/src/common/email/email.module.ts b/apps/bff/src/common/email/email.module.ts index 038ea618..027f8e81 100644 --- a/apps/bff/src/common/email/email.module.ts +++ b/apps/bff/src/common/email/email.module.ts @@ -3,12 +3,11 @@ import { ConfigModule } from "@nestjs/config"; import { EmailService } from "./email.service"; import { SendGridEmailProvider } from "./providers/sendgrid.provider"; import { LoggingModule } from "../logging/logging.module"; -import { BullModule } from "@nestjs/bullmq"; import { EmailQueueService } from "./queue/email.queue"; import { EmailProcessor } from "./queue/email.processor"; @Module({ - imports: [ConfigModule, LoggingModule, BullModule.registerQueue({ name: "email" })], + imports: [ConfigModule, LoggingModule], providers: [EmailService, SendGridEmailProvider, EmailQueueService, EmailProcessor], exports: [EmailService, EmailQueueService], }) diff --git a/apps/bff/src/common/email/queue/email.processor.ts b/apps/bff/src/common/email/queue/email.processor.ts index 744007e2..ed9102de 100644 --- a/apps/bff/src/common/email/queue/email.processor.ts +++ b/apps/bff/src/common/email/queue/email.processor.ts @@ -3,8 +3,9 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { EmailService } from "../email.service"; import type { EmailJobData } from "./email.queue"; +import { QUEUE_NAMES } from "../../queue/queue.constants"; -@Processor("email") +@Processor(QUEUE_NAMES.EMAIL) @Injectable() export class EmailProcessor extends WorkerHost { constructor( diff --git a/apps/bff/src/common/email/queue/email.queue.ts b/apps/bff/src/common/email/queue/email.queue.ts index 4d77733d..b60e3924 100644 --- a/apps/bff/src/common/email/queue/email.queue.ts +++ b/apps/bff/src/common/email/queue/email.queue.ts @@ -3,13 +3,14 @@ import { InjectQueue } from "@nestjs/bullmq"; import { Queue } from "bullmq"; import { Logger } from "nestjs-pino"; import type { SendEmailOptions } from "../email.service"; +import { QUEUE_NAMES } from "../../queue/queue.constants"; export type EmailJobData = SendEmailOptions & { category?: string }; @Injectable() export class EmailQueueService { constructor( - @InjectQueue("email") private readonly queue: Queue, + @InjectQueue(QUEUE_NAMES.EMAIL) private readonly queue: Queue, @Inject(Logger) private readonly logger: Logger ) {} diff --git a/apps/bff/src/common/queue/queue.constants.ts b/apps/bff/src/common/queue/queue.constants.ts new file mode 100644 index 00000000..522021ca --- /dev/null +++ b/apps/bff/src/common/queue/queue.constants.ts @@ -0,0 +1,7 @@ +export const QUEUE_NAMES = { + EMAIL: "email", + PROVISIONING: "provisioning", + RECONCILE: "reconcile", +} as const; + +export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES]; diff --git a/apps/bff/src/common/queue/queue.module.ts b/apps/bff/src/common/queue/queue.module.ts new file mode 100644 index 00000000..684de7fc --- /dev/null +++ b/apps/bff/src/common/queue/queue.module.ts @@ -0,0 +1,42 @@ +import { Global, Module } from "@nestjs/common"; +import { BullModule } from "@nestjs/bullmq"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { QUEUE_NAMES } from "./queue.constants"; + +function parseRedisConnection(redisUrl: string) { + try { + const url = new URL(redisUrl); + const isTls = url.protocol === "rediss:"; + const db = url.pathname && url.pathname !== "/" ? Number(url.pathname.slice(1)) : undefined; + return { + host: url.hostname, + port: Number(url.port || (isTls ? 6380 : 6379)), + password: url.password || undefined, + ...(db !== undefined ? { db } : {}), + ...(isTls ? { tls: {} } : {}), + } as Record; + } catch { + // Fallback to localhost + return { host: "localhost", port: 6379 } as Record; + } +} + +@Global() +@Module({ + imports: [ + ConfigModule, + BullModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + connection: parseRedisConnection(config.get("REDIS_URL", "redis://localhost:6379")), + }), + }), + BullModule.registerQueue( + { name: QUEUE_NAMES.EMAIL }, + { name: QUEUE_NAMES.PROVISIONING }, + { name: QUEUE_NAMES.RECONCILE } + ), + ], + exports: [BullModule], +}) +export class QueueModule {} diff --git a/apps/bff/src/health/health.controller.ts b/apps/bff/src/health/health.controller.ts index 205483c8..22445469 100644 --- a/apps/bff/src/health/health.controller.ts +++ b/apps/bff/src/health/health.controller.ts @@ -6,6 +6,12 @@ import { InjectQueue } from "@nestjs/bullmq"; import { Queue } from "bullmq"; import { ConfigService } from "@nestjs/config"; import { Public } from "../auth/decorators/public.decorator"; +import { CacheService } from "../common/cache/cache.service"; +import { QUEUE_NAMES } from "../common/queue/queue.constants"; +import { + replayKey as sfReplayKey, + statusKey as sfStatusKey, +} from "../vendors/salesforce/events/event-keys.util"; @ApiTags("Health") @Controller("health") @@ -14,7 +20,8 @@ export class HealthController { constructor( private readonly prisma: PrismaService, private readonly config: ConfigService, - @InjectQueue("email") private readonly emailQueue: Queue + @InjectQueue(QUEUE_NAMES.EMAIL) private readonly emailQueue: Queue, + private readonly cache: CacheService ) {} @Get() @@ -45,6 +52,15 @@ export class HealthController { "delayed" ); + // Check Redis availability by a simple set/get on a volatile key + const nonceProbeKey = "health:nonce:probe"; + let redisStatus: "connected" | "degraded" | "unavailable" = "connected"; + try { + await this.cache.set(nonceProbeKey, 1, 5); + } catch { + redisStatus = "unavailable"; + } + return { status: "ok", timestamp: new Date().toISOString(), @@ -55,6 +71,9 @@ export class HealthController { queues: { email: emailQueueInfo, }, + integrations: { + redis: redisStatus, + }, features: { emailEnabled: this.config.get("EMAIL_ENABLED", "true") === "true", emailQueued: this.config.get("EMAIL_USE_QUEUE", "true") === "true", @@ -116,4 +135,42 @@ export class HealthController { uptime: process.uptime(), }; } + + @Get("sf-events") + @ApiOperation({ summary: "Salesforce events subscriber status" }) + @ApiResponse({ status: 200, description: "Subscriber status and last replay id" }) + async getSalesforceEventsStatus() { + const enabled = this.config.get("SF_EVENTS_ENABLED", "false") === "true"; + const channel = this.config.get( + "SF_PROVISION_EVENT_CHANNEL", + "/event/Order_Fulfilment_Requested__e" + ); + const replayKey = sfReplayKey(channel); + const statusKey = sfStatusKey(channel); + + const [lastReplayIdRaw, status] = await Promise.all([ + this.cache.get(replayKey), + this.cache.get<{ status: string; since: number }>(statusKey), + ]); + const lastReplayId = + typeof lastReplayIdRaw === "number" + ? lastReplayIdRaw + : typeof lastReplayIdRaw === "string" && lastReplayIdRaw.trim() !== "" + ? Number(lastReplayIdRaw) + : null; + + return { + enabled, + channel, + replay: { + lastReplayId, + key: replayKey, + }, + subscriber: { + status: status?.status || (enabled ? "unknown" : "disabled"), + since: status?.since ? new Date(status.since).toISOString() : null, + key: statusKey, + }, + }; + } } diff --git a/apps/bff/src/health/health.module.ts b/apps/bff/src/health/health.module.ts index 59b886fe..72f0f8fe 100644 --- a/apps/bff/src/health/health.module.ts +++ b/apps/bff/src/health/health.module.ts @@ -1,11 +1,10 @@ import { Module } from "@nestjs/common"; import { HealthController } from "./health.controller"; import { PrismaModule } from "../common/prisma/prisma.module"; -import { BullModule } from "@nestjs/bullmq"; import { ConfigModule } from "@nestjs/config"; @Module({ - imports: [PrismaModule, ConfigModule, BullModule.registerQueue({ name: "email" })], + imports: [PrismaModule, ConfigModule], controllers: [HealthController], }) export class HealthModule {} diff --git a/apps/bff/src/jobs/jobs.module.ts b/apps/bff/src/jobs/jobs.module.ts index f33fda88..22493147 100644 --- a/apps/bff/src/jobs/jobs.module.ts +++ b/apps/bff/src/jobs/jobs.module.ts @@ -1,24 +1,9 @@ import { Module } from "@nestjs/common"; -import { BullModule } from "@nestjs/bullmq"; -import { ConfigService } from "@nestjs/config"; import { JobsService } from "./jobs.service"; import { ReconcileProcessor } from "./reconcile.processor"; @Module({ - imports: [ - BullModule.forRootAsync({ - useFactory: (configService: ConfigService) => ({ - connection: { - host: configService.get("REDIS_HOST", "localhost"), - port: configService.get("REDIS_PORT", 6379), - }, - }), - inject: [ConfigService], - }), - BullModule.registerQueue({ - name: "reconcile", - }), - ], + imports: [], providers: [JobsService, ReconcileProcessor], exports: [JobsService], }) diff --git a/apps/bff/src/jobs/reconcile.processor.ts b/apps/bff/src/jobs/reconcile.processor.ts index 691b57f6..f7114c8d 100644 --- a/apps/bff/src/jobs/reconcile.processor.ts +++ b/apps/bff/src/jobs/reconcile.processor.ts @@ -1,7 +1,8 @@ import { Processor, WorkerHost } from "@nestjs/bullmq"; import { Job } from "bullmq"; +import { QUEUE_NAMES } from "../common/queue/queue.constants"; -@Processor("reconcile") +@Processor(QUEUE_NAMES.RECONCILE) export class ReconcileProcessor extends WorkerHost { async process(_job: Job) { // TODO: Implement reconciliation logic diff --git a/apps/bff/src/mappings/mappings.service.ts b/apps/bff/src/mappings/mappings.service.ts index c311c731..6ae7f771 100644 --- a/apps/bff/src/mappings/mappings.service.ts +++ b/apps/bff/src/mappings/mappings.service.ts @@ -46,21 +46,45 @@ export class MappingsService { // Sanitize input const sanitizedRequest = this.validator.sanitizeCreateRequest(request); - // Check for conflicts - const existingMappings = await this.getAllMappingsFromDb(); - const conflictValidation = this.validator.validateNoConflicts( - sanitizedRequest, - existingMappings - ); + // Check for conflicts via DB (faster and race-safe) + const [byUser, byWhmcs, bySf] = await Promise.all([ + this.prisma.idMapping.findUnique({ where: { userId: sanitizedRequest.userId } }), + this.prisma.idMapping.findUnique({ + where: { whmcsClientId: sanitizedRequest.whmcsClientId }, + }), + sanitizedRequest.sfAccountId + ? this.prisma.idMapping.findFirst({ + where: { sfAccountId: sanitizedRequest.sfAccountId }, + }) + : Promise.resolve(null), + ]); - if (!conflictValidation.isValid) { - throw new ConflictException(`Mapping conflict: ${conflictValidation.errors.join(", ")}`); + if (byUser) { + throw new ConflictException(`User ${sanitizedRequest.userId} already has a mapping`); + } + if (byWhmcs) { + throw new ConflictException( + `WHMCS client ${sanitizedRequest.whmcsClientId} is already mapped to user ${byWhmcs.userId}` + ); + } + if (bySf) { + this.logger.warn( + `Salesforce account ${sanitizedRequest.sfAccountId} is already mapped to user ${bySf.userId}` + ); } // Create in database - const created = await this.prisma.idMapping.create({ - data: sanitizedRequest, - }); + let created; + try { + created = await this.prisma.idMapping.create({ data: sanitizedRequest }); + } catch (e) { + const msg = getErrorMessage(e); + // P2002 = Unique constraint failed + if (msg.includes("P2002") || /unique/i.test(msg)) { + throw new ConflictException("Mapping violates uniqueness constraints"); + } + throw e; + } const mapping: UserIdMapping = { userId: created.userId, @@ -99,12 +123,11 @@ export class MappingsService { throw new BadRequestException("Salesforce Account ID is required"); } - // Try cache first (check all cached mappings) - const allCached = await this.getAllMappingsFromDb(); - const cachedMapping = allCached.find((m: UserIdMapping) => m.sfAccountId === sfAccountId); - if (cachedMapping) { + // Try cache first + const cached = await this.cacheService.getBySfAccountId(sfAccountId); + if (cached) { this.logger.debug(`Cache hit for SF account mapping: ${sfAccountId}`); - return cachedMapping; + return cached; } // Fetch from database @@ -561,17 +584,6 @@ export class MappingsService { // Private helper methods - private async getAllMappingsFromDb(): Promise { - const dbMappings = await this.prisma.idMapping.findMany(); - return dbMappings.map(mapping => ({ - userId: mapping.userId, - whmcsClientId: mapping.whmcsClientId, - sfAccountId: mapping.sfAccountId || undefined, - createdAt: mapping.createdAt, - updatedAt: mapping.updatedAt, - })); - } - private sanitizeForLog(data: unknown): Record { try { const plain: unknown = JSON.parse(JSON.stringify(data ?? {})); diff --git a/apps/bff/src/orders/controllers/order-fulfillment.controller.ts b/apps/bff/src/orders/controllers/order-fulfillment.controller.ts deleted file mode 100644 index 64da5e82..00000000 --- a/apps/bff/src/orders/controllers/order-fulfillment.controller.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Controller, Post, Param, Body, Headers, HttpCode, HttpStatus, UseGuards } from "@nestjs/common"; -import { ApiTags, ApiOperation, ApiParam, ApiResponse, ApiHeader } from "@nestjs/swagger"; -import { ThrottlerGuard } from "@nestjs/throttler"; -import { Logger } from "nestjs-pino"; -import { Public } from "../../auth/decorators/public.decorator"; -import { EnhancedWebhookSignatureGuard } from "../../webhooks/guards/enhanced-webhook-signature.guard"; -import { OrderFulfillmentService } from "../services/order-fulfillment.service"; -import type { OrderFulfillmentRequest } from "../services/order-fulfillment.service"; - -@ApiTags("order-fulfillment") -@Controller("orders") -@Public() // Salesforce webhook uses signature-based auth, not JWT -export class OrderFulfillmentController { - constructor( - private readonly orderFulfillmentService: OrderFulfillmentService, - private readonly logger: Logger - ) {} - - @Post(":sfOrderId/fulfill") - @HttpCode(HttpStatus.OK) - @UseGuards(ThrottlerGuard, EnhancedWebhookSignatureGuard) - @ApiOperation({ - summary: "Fulfill order from Salesforce", - description: "Secure endpoint called by Salesforce Quick Action to fulfill orders in WHMCS. Handles complete flow: SF Order → WHMCS AddOrder/AcceptOrder → SF Status Update" - }) - @ApiParam({ - name: "sfOrderId", - type: String, - description: "Salesforce Order ID to provision", - example: "8014x000000ABCDXYZ" - }) - @ApiHeader({ - name: "X-SF-Signature", - description: "HMAC-SHA256 signature of request body using shared secret", - required: true, - example: "a1b2c3d4e5f6..." - }) - @ApiHeader({ - name: "X-SF-Timestamp", - description: "ISO timestamp of request (max 5 minutes old)", - required: true, - example: "2024-01-15T10:30:00Z" - }) - @ApiHeader({ - name: "X-SF-Nonce", - description: "Unique nonce to prevent replay attacks", - required: true, - example: "abc123def456" - }) - @ApiHeader({ - name: "Idempotency-Key", - description: "Unique key for safe retries", - required: true, - example: "provision_8014x000000ABCDXYZ_1705312200000" - }) - @ApiResponse({ - status: 200, - description: "Order provisioning completed successfully", - schema: { - type: "object", - properties: { - success: { type: "boolean", example: true }, - status: { type: "string", enum: ["Provisioned", "Already Provisioned"], example: "Provisioned" }, - whmcsOrderId: { type: "string", example: "12345" }, - whmcsServiceIds: { type: "array", items: { type: "number" }, example: [67890, 67891] }, - message: { type: "string", example: "Order provisioned successfully in WHMCS" } - } - } - }) - @ApiResponse({ - status: 400, - description: "Invalid request or order not found", - schema: { - type: "object", - properties: { - success: { type: "boolean", example: false }, - status: { type: "string", example: "Failed" }, - message: { type: "string", example: "Salesforce order not found" }, - errorCode: { type: "string", example: "ORDER_NOT_FOUND" } - } - } - }) - @ApiResponse({ - status: 401, - description: "Invalid signature or authentication" - }) - @ApiResponse({ - status: 409, - description: "Payment method missing or other conflict", - schema: { - type: "object", - properties: { - success: { type: "boolean", example: false }, - status: { type: "string", example: "Failed" }, - message: { type: "string", example: "Payment method missing - client must add payment method before provisioning" }, - errorCode: { type: "string", example: "PAYMENT_METHOD_MISSING" } - } - } - }) - async fulfillOrder( - @Param("sfOrderId") sfOrderId: string, - @Body() payload: OrderFulfillmentRequest, - @Headers("idempotency-key") idempotencyKey: string - ) { - this.logger.log("Salesforce order fulfillment request received", { - sfOrderId, - idempotencyKey, - timestamp: payload.timestamp, - hasNonce: Boolean(payload.nonce), - }); - - try { - const result = await this.orderFulfillmentService.fulfillOrder( - sfOrderId, - payload, - idempotencyKey - ); - - this.logger.log("Salesforce provisioning completed", { - sfOrderId, - success: result.success, - status: result.status, - whmcsOrderId: result.whmcsOrderId, - serviceCount: result.whmcsServiceIds?.length || 0, - }); - - return { - success: result.success, - status: result.status, - whmcsOrderId: result.whmcsOrderId, - whmcsServiceIds: result.whmcsServiceIds, - message: result.message, - ...(result.errorCode && { errorCode: result.errorCode }), - timestamp: new Date().toISOString(), - }; - - } catch (error) { - this.logger.error("Salesforce provisioning failed", { - error: error instanceof Error ? error.message : String(error), - sfOrderId, - idempotencyKey, - }); - - // Re-throw to let global exception handler format the response - throw error; - } - } -} diff --git a/apps/bff/src/orders/orders.module.ts b/apps/bff/src/orders/orders.module.ts index a53fb65b..28ed5234 100644 --- a/apps/bff/src/orders/orders.module.ts +++ b/apps/bff/src/orders/orders.module.ts @@ -1,6 +1,5 @@ import { Module } from "@nestjs/common"; import { OrdersController } from "./orders.controller"; -import { OrderFulfillmentController } from "./controllers/order-fulfillment.controller"; import { VendorsModule } from "../vendors/vendors.module"; import { MappingsModule } from "../mappings/mappings.module"; import { UsersModule } from "../users/users.module"; @@ -12,15 +11,16 @@ import { OrderItemBuilder } from "./services/order-item-builder.service"; import { OrderOrchestrator } from "./services/order-orchestrator.service"; // Clean modular fulfillment services -import { OrderFulfillmentService } from "./services/order-fulfillment.service"; import { OrderFulfillmentValidator } from "./services/order-fulfillment-validator.service"; import { OrderWhmcsMapper } from "./services/order-whmcs-mapper.service"; import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orchestrator.service"; import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service"; +import { ProvisioningQueueService } from "./queue/provisioning.queue"; +import { ProvisioningProcessor } from "./queue/provisioning.processor"; @Module({ imports: [VendorsModule, MappingsModule, UsersModule], - controllers: [OrdersController, OrderFulfillmentController], + controllers: [OrdersController], providers: [ // Order creation services (modular) OrderValidator, @@ -33,8 +33,10 @@ import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error OrderWhmcsMapper, OrderFulfillmentOrchestrator, OrderFulfillmentErrorService, - OrderFulfillmentService, + // Async provisioning queue + ProvisioningQueueService, + ProvisioningProcessor, ], - exports: [OrderOrchestrator, OrderFulfillmentService], + exports: [OrderOrchestrator, ProvisioningQueueService], }) export class OrdersModule {} diff --git a/apps/bff/src/orders/queue/provisioning.processor.ts b/apps/bff/src/orders/queue/provisioning.processor.ts new file mode 100644 index 00000000..05691214 --- /dev/null +++ b/apps/bff/src/orders/queue/provisioning.processor.ts @@ -0,0 +1,65 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { OrderFulfillmentOrchestrator } from "../services/order-fulfillment-orchestrator.service"; +import { SalesforceService } from "../../vendors/salesforce/salesforce.service"; +import { getSalesforceFieldMap } from "../../common/config/field-map"; +import type { ProvisioningJobData } from "./provisioning.queue"; +import { CacheService } from "../../common/cache/cache.service"; +import { ConfigService } from "@nestjs/config"; +import { QUEUE_NAMES } from "../../common/queue/queue.constants"; +import { replayKey as sfReplayKey } from "../../vendors/salesforce/events/event-keys.util"; + +@Processor(QUEUE_NAMES.PROVISIONING) +@Injectable() +export class ProvisioningProcessor extends WorkerHost { + constructor( + private readonly orchestrator: OrderFulfillmentOrchestrator, + private readonly salesforceService: SalesforceService, + private readonly cache: CacheService, + private readonly config: ConfigService, + @Inject(Logger) private readonly logger: Logger + ) { + super(); + } + + async process(job: { data: ProvisioningJobData }): Promise { + const { sfOrderId, idempotencyKey } = job.data; + this.logger.log("Processing provisioning job", { + sfOrderId, + idempotencyKey, + correlationId: job.data.correlationId, + pubsubReplayId: job.data.pubsubReplayId, + }); + + // Guard: Only process if Salesforce Order is currently 'Activating' + const fields = getSalesforceFieldMap(); + const order = await this.salesforceService.getOrder(sfOrderId); + const status = (order?.[fields.order.activationStatus] as string) || ""; + if (status !== "Activating") { + this.logger.log("Skipping provisioning job: Order not in Activating state", { + sfOrderId, + currentStatus: status, + }); + return; // Ack + no-op to safely handle duplicate/old events + } + + // Execute the same orchestration used by the webhook path, but without payload validation + await this.orchestrator.executeFulfillment(sfOrderId, {}, idempotencyKey); + + // Commit processed replay id for Pub/Sub resume (only after success) + if (typeof job.data.pubsubReplayId === "number") { + const channel = this.config.get( + "SF_PROVISION_EVENT_CHANNEL", + "/event/Order_Fulfilment_Requested__e" + ); + const replayKey = sfReplayKey(channel); + const prev = Number((await this.cache.get(replayKey)) ?? 0); + if (job.data.pubsubReplayId > prev) { + await this.cache.set(replayKey, String(job.data.pubsubReplayId)); + } + } + + this.logger.log("Provisioning job completed", { sfOrderId }); + } +} diff --git a/apps/bff/src/orders/queue/provisioning.queue.ts b/apps/bff/src/orders/queue/provisioning.queue.ts new file mode 100644 index 00000000..eceffe61 --- /dev/null +++ b/apps/bff/src/orders/queue/provisioning.queue.ts @@ -0,0 +1,39 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { InjectQueue } from "@nestjs/bullmq"; +import { Queue } from "bullmq"; +import { Logger } from "nestjs-pino"; +import { QUEUE_NAMES } from "../../common/queue/queue.constants"; + +export interface ProvisioningJobData { + sfOrderId: string; + idempotencyKey: string; + correlationId?: string; + pubsubReplayId?: number; +} + +@Injectable() +export class ProvisioningQueueService { + constructor( + @InjectQueue(QUEUE_NAMES.PROVISIONING) private readonly queue: Queue, + @Inject(Logger) private readonly logger: Logger + ) {} + + async enqueue(job: ProvisioningJobData): Promise { + await this.queue.add("provision", job, { + removeOnComplete: 100, + removeOnFail: 100, + attempts: 1, // No automatic retries; Salesforce is source of truth for retry + }); + this.logger.debug("Queued provisioning job", { + sfOrderId: job.sfOrderId, + idempotencyKey: job.idempotencyKey, + correlationId: job.correlationId, + pubsubReplayId: job.pubsubReplayId, + }); + } + + async depth(): Promise { + const counts = await this.queue.getJobCounts("waiting", "active", "delayed"); + return (counts.waiting || 0) + (counts.active || 0) + (counts.delayed || 0); + } +} diff --git a/apps/bff/src/orders/services/order-fulfillment-error.service.ts b/apps/bff/src/orders/services/order-fulfillment-error.service.ts index beb6e4b4..7e91af72 100644 --- a/apps/bff/src/orders/services/order-fulfillment-error.service.ts +++ b/apps/bff/src/orders/services/order-fulfillment-error.service.ts @@ -21,7 +21,7 @@ export class OrderFulfillmentErrorService { */ determineErrorCode(error: unknown): OrderFulfillmentErrorCode { const errorMessage = this.getErrorMessage(error); - + if (errorMessage.includes("Payment method missing")) { return OrderFulfillmentErrorCode.PAYMENT_METHOD_MISSING; } @@ -86,7 +86,7 @@ export class OrderFulfillmentErrorService { createErrorResponse(error: unknown) { const errorCode = this.determineErrorCode(error); const userMessage = this.getUserFriendlyMessage(error, errorCode); - + return { success: false, status: "Failed" as const, @@ -94,4 +94,16 @@ export class OrderFulfillmentErrorService { errorCode: errorCode, }; } + + /** + * Extract a short error code for diagnostics (e.g., 429, 503, ETIMEOUT) + */ + getShortCode(error: unknown): string | undefined { + const msg = this.getErrorMessage(error); + const m = msg.match(/HTTP\s+(\d{3})/i); + if (m) return m[1]; + if (/timeout/i.test(msg)) return "ETIMEOUT"; + if (/network/i.test(msg)) return "ENETWORK"; + return undefined; + } } diff --git a/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts index 8773304a..6c50a02f 100644 --- a/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts @@ -1,12 +1,19 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceService } from "../../vendors/salesforce/salesforce.service"; -import { WhmcsOrderService, WhmcsOrderResult } from "../../vendors/whmcs/services/whmcs-order.service"; +import { + WhmcsOrderService, + WhmcsOrderResult, +} from "../../vendors/whmcs/services/whmcs-order.service"; import { OrderOrchestrator } from "./order-orchestrator.service"; -import { OrderFulfillmentValidator, OrderFulfillmentValidationResult } from "./order-fulfillment-validator.service"; +import { + OrderFulfillmentValidator, + OrderFulfillmentValidationResult, +} from "./order-fulfillment-validator.service"; import { OrderWhmcsMapper, OrderItemMappingResult } from "./order-whmcs-mapper.service"; +import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service"; import { getErrorMessage } from "../../common/utils/error.util"; - +import { getSalesforceFieldMap } from "../../common/config/field-map"; export interface OrderFulfillmentStep { step: string; @@ -16,18 +23,18 @@ export interface OrderFulfillmentStep { error?: string; } +import { OrderDetailsDto } from "../types/order-details.dto"; + export interface OrderFulfillmentContext { sfOrderId: string; idempotencyKey: string; validation: OrderFulfillmentValidationResult; - orderDetails?: any; // OrderOrchestrator.getOrder() returns transformed structure + orderDetails?: OrderDetailsDto; // Transformed order with items mappingResult?: OrderItemMappingResult; whmcsResult?: WhmcsOrderResult; steps: OrderFulfillmentStep[]; } - - /** * Orchestrates the complete order fulfillment workflow * Similar to OrderOrchestrator but for fulfillment operations @@ -40,7 +47,8 @@ export class OrderFulfillmentOrchestrator { private readonly whmcsOrderService: WhmcsOrderService, private readonly orderOrchestrator: OrderOrchestrator, private readonly orderFulfillmentValidator: OrderFulfillmentValidator, - private readonly orderWhmcsMapper: OrderWhmcsMapper + private readonly orderWhmcsMapper: OrderWhmcsMapper, + private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService ) {} /** @@ -88,10 +96,10 @@ export class OrderFulfillmentOrchestrator { // Step 2: Update Salesforce status to "Activating" await this.executeStep(context, "sf_status_update", async () => { + const fields = getSalesforceFieldMap(); await this.salesforceService.updateOrder({ Id: sfOrderId, - Provisioning_Status__c: "Activating", - Last_Provisioning_At__c: new Date().toISOString(), + [fields.order.activationStatus]: "Activating", }); }); @@ -115,10 +123,14 @@ export class OrderFulfillmentOrchestrator { throw new Error("Order items must be an array"); } - context.mappingResult = await this.orderWhmcsMapper.mapOrderItemsToWhmcs(context.orderDetails.items); + context.mappingResult = this.orderWhmcsMapper.mapOrderItemsToWhmcs( + context.orderDetails.items + ); // Validate mapped items this.orderWhmcsMapper.validateMappedItems(context.mappingResult.whmcsItems); + // keep async signature for executeStep typing and lint rule + await Promise.resolve(); }); // Step 5: Create order in WHMCS @@ -131,11 +143,14 @@ export class OrderFulfillmentOrchestrator { const createResult = await this.whmcsOrderService.addOrder({ clientId: context.validation.clientId, items: context.mappingResult!.whmcsItems, - paymentMethod: "mailin", // Default payment method for provisioning orders + paymentMethod: "stripe", // Use Stripe for provisioning orders + promoCode: "1st Month Free (Monthly Plan)", sfOrderId, notes: orderNotes, - noinvoice: true, // Don't create invoice during provisioning - noemail: true, // Don't send emails during provisioning + // Align with Salesforce implementation: suppress invoice email and all emails + // Keep invoice generation enabled (noinvoice=false) unless changed later + noinvoiceemail: true, + noemail: true, }); context.whmcsResult = { @@ -157,11 +172,12 @@ export class OrderFulfillmentOrchestrator { // Step 7: Update Salesforce with success await this.executeStep(context, "sf_success_update", async () => { + const fields = getSalesforceFieldMap(); await this.salesforceService.updateOrder({ Id: sfOrderId, - Provisioning_Status__c: "Provisioned", - WHMCS_Order_ID__c: context.whmcsResult!.orderId.toString(), - Last_Provisioning_At__c: new Date().toISOString(), + Status: "Completed", + [fields.order.activationStatus]: "Activated", + [fields.order.whmcsOrderId]: context.whmcsResult!.orderId.toString(), }); }); @@ -276,8 +292,9 @@ export class OrderFulfillmentOrchestrator { context: OrderFulfillmentContext, error: Error ): Promise { - const errorCode = this.determineErrorCode(error); + const errorCode = this.orderFulfillmentErrorService.determineErrorCode(error); const userMessage = error.message; + const fields = getSalesforceFieldMap(); this.logger.error("Fulfillment orchestration failed", { sfOrderId: context.sfOrderId, @@ -291,10 +308,15 @@ export class OrderFulfillmentOrchestrator { try { await this.salesforceService.updateOrder({ Id: context.sfOrderId, - Provisioning_Status__c: "Failed", - Provisioning_Error_Code__c: errorCode, - Provisioning_Error_Message__c: userMessage.substring(0, 255), - Last_Provisioning_At__c: new Date().toISOString(), + [fields.order.activationStatus]: "Failed", + // Optional diagnostics fields if present in org + ...(fields.order.lastErrorCode && { + [fields.order.lastErrorCode]: + this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode), + }), + ...(fields.order.lastErrorMessage && { + [fields.order.lastErrorMessage]: userMessage?.substring(0, 255), + }), }); this.logger.log("Salesforce updated with failure status", { @@ -309,25 +331,6 @@ export class OrderFulfillmentOrchestrator { } } - /** - * Determine error code based on error type - */ - private determineErrorCode(error: Error): string { - if (error.message.includes("Payment method missing")) { - return "PAYMENT_METHOD_MISSING"; - } - if (error.message.includes("not found")) { - return "ORDER_NOT_FOUND"; - } - if (error.message.includes("WHMCS")) { - return "WHMCS_ERROR"; - } - if (error.message.includes("mapping")) { - return "MAPPING_ERROR"; - } - return "FULFILLMENT_ERROR"; - } - /** * Get fulfillment summary from context */ diff --git a/apps/bff/src/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/orders/services/order-fulfillment-validator.service.ts index 94abe5f3..8baaa4d7 100644 --- a/apps/bff/src/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/orders/services/order-fulfillment-validator.service.ts @@ -5,6 +5,7 @@ import { WhmcsOrderService } from "../../vendors/whmcs/services/whmcs-order.serv import { MappingsService } from "../../mappings/mappings.service"; import { getErrorMessage } from "../../common/utils/error.util"; import { SalesforceOrder } from "../types/salesforce-order.types"; +import { getSalesforceFieldMap } from "../../common/config/field-map"; export interface OrderFulfillmentValidationResult { sfOrder: SalesforceOrder; @@ -44,17 +45,20 @@ export class OrderFulfillmentValidator { const sfOrder = await this.validateSalesforceOrder(sfOrderId); // 2. Check if already provisioned (idempotency) - if (sfOrder.WHMCS_Order_ID__c) { + const fields = getSalesforceFieldMap(); + const rawWhmcs = (sfOrder as unknown as Record)[fields.order.whmcsOrderId]; + const existingWhmcsOrderId = typeof rawWhmcs === "string" ? rawWhmcs : undefined; + if (existingWhmcsOrderId) { this.logger.log("Order already provisioned", { sfOrderId, - whmcsOrderId: sfOrder.WHMCS_Order_ID__c, + whmcsOrderId: existingWhmcsOrderId, }); return { sfOrder, clientId: 0, // Not needed for already provisioned isAlreadyProvisioned: true, - whmcsOrderId: sfOrder.WHMCS_Order_ID__c, + whmcsOrderId: existingWhmcsOrderId, }; } @@ -88,31 +92,37 @@ export class OrderFulfillmentValidator { /** * Validate Salesforce order exists and is in valid state */ - private async validateSalesforceOrder(sfOrderId: string): Promise { - const order = await this.salesforceService.getOrder(sfOrderId); + private async validateSalesforceOrder(sfOrderId: string): Promise { + const order = await this.salesforceService.getOrder(sfOrderId); - if (!order) { - throw new BadRequestException(`Salesforce order ${sfOrderId} not found`); - } - - // Cast to SalesforceOrder for type safety - const salesforceOrder = order as unknown as SalesforceOrder; - - // Validate order is in a state that can be provisioned - if (salesforceOrder.Status === "Cancelled") { - throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`); - } - - this.logger.log("Salesforce order validated", { - sfOrderId, - status: salesforceOrder.Status, - activationStatus: salesforceOrder.Activation_Status__c, - accountId: salesforceOrder.Account.Id, - }); - - return salesforceOrder; + if (!order) { + throw new BadRequestException(`Salesforce order ${sfOrderId} not found`); } + // Cast to SalesforceOrder for type safety + const salesforceOrder = order as unknown as SalesforceOrder; + + // Validate order is in a state that can be provisioned + if (salesforceOrder.Status === "Cancelled") { + throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`); + } + + const fields = getSalesforceFieldMap(); + this.logger.log("Salesforce order validated", { + sfOrderId, + status: salesforceOrder.Status, + activationStatus: ((): string | undefined => { + const v = (salesforceOrder as unknown as Record)[ + fields.order.activationStatus + ]; + return typeof v === "string" ? v : undefined; + })(), + accountId: salesforceOrder.Account.Id, + }); + + return salesforceOrder; + } + /** * Get WHMCS client ID from Salesforce account ID using mappings */ @@ -167,52 +177,4 @@ export class OrderFulfillmentValidator { throw new ConflictException("Unable to validate payment method - fulfillment cannot proceed"); } } - - /** - * Validate provisioning request payload format - */ - validateRequestPayload(payload: unknown): { - orderId: string; - timestamp: string; - nonce: string; - } { - if (!payload || typeof payload !== "object") { - throw new BadRequestException("Invalid request payload"); - } - - const { orderId, timestamp, nonce } = payload as Record; - - if (!orderId || typeof orderId !== "string") { - throw new BadRequestException("Missing or invalid orderId in payload"); - } - - if (!timestamp || typeof timestamp !== "string") { - throw new BadRequestException("Missing or invalid timestamp in payload"); - } - - if (!nonce || typeof nonce !== "string") { - throw new BadRequestException("Missing or invalid nonce in payload"); - } - - // Validate timestamp is recent (additional validation beyond webhook guard) - try { - const requestTime = new Date(timestamp).getTime(); - const now = Date.now(); - const maxAge = 5 * 60 * 1000; // 5 minutes - - if (Math.abs(now - requestTime) > maxAge) { - throw new BadRequestException("Request timestamp is too old"); - } - } catch { - throw new BadRequestException("Invalid timestamp format"); - } - - this.logger.log("Request payload validated", { - orderId, - timestamp, - hasNonce: Boolean(nonce), - }); - - return { orderId, timestamp, nonce }; - } } diff --git a/apps/bff/src/orders/services/order-fulfillment.service.ts b/apps/bff/src/orders/services/order-fulfillment.service.ts deleted file mode 100644 index 4ed92d61..00000000 --- a/apps/bff/src/orders/services/order-fulfillment.service.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Injectable, Inject } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { OrderFulfillmentOrchestrator } from "./order-fulfillment-orchestrator.service"; -import { OrderFulfillmentValidator } from "./order-fulfillment-validator.service"; -import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service"; -export interface OrderFulfillmentRequest { - orderId: string; - timestamp: string; - nonce: string; -} - -export interface OrderFulfillmentResult { - success: boolean; - status: "Already Fulfilled" | "Fulfilled" | "Failed"; - whmcsOrderId?: string; - whmcsServiceIds?: number[]; - message: string; - errorCode?: string; -} - - - -/** - * Main order fulfillment service - coordinates modular fulfillment components - * Uses clean architecture similar to order creation workflow - */ -@Injectable() -export class OrderFulfillmentService { - constructor( - private readonly orderFulfillmentOrchestrator: OrderFulfillmentOrchestrator, - private readonly orderFulfillmentValidator: OrderFulfillmentValidator, - private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService, - @Inject(Logger) private readonly logger: Logger - ) {} - - /** - * Main fulfillment method called by Salesforce webhook - * Uses modular architecture for clean separation of concerns - */ - async fulfillOrder( - sfOrderId: string, - request: OrderFulfillmentRequest, - idempotencyKey: string - ): Promise { - this.logger.log("Starting order fulfillment workflow", { - sfOrderId, - idempotencyKey, - timestamp: request.timestamp, - }); - - try { - // 1. Validate request payload format - const validatedPayload = this.orderFulfillmentValidator.validateRequestPayload(request); - - // 2. Execute complete fulfillment workflow using orchestrator - const context = await this.orderFulfillmentOrchestrator.executeFulfillment( - sfOrderId, - validatedPayload, - idempotencyKey - ); - - // 3. Generate result summary from context - const summary = this.orderFulfillmentOrchestrator.getFulfillmentSummary(context); - - this.logger.log("Order fulfillment workflow completed", { - sfOrderId, - success: summary.success, - status: summary.status, - whmcsOrderId: summary.whmcsOrderId, - serviceCount: summary.whmcsServiceIds?.length || 0, - completedSteps: summary.steps.filter(s => s.status === "completed").length, - totalSteps: summary.steps.length, - }); - - return { - success: summary.success, - status: summary.status, - whmcsOrderId: summary.whmcsOrderId, - whmcsServiceIds: summary.whmcsServiceIds, - message: summary.message, - errorCode: summary.success ? undefined : this.orderFulfillmentErrorService.determineErrorCode(summary.message), - }; - } catch (error) { - this.logger.error("Order fulfillment workflow failed", { - sfOrderId, - idempotencyKey, - error: error instanceof Error ? error.message : String(error), - }); - - // Use centralized error handling service - return this.orderFulfillmentErrorService.createErrorResponse(error); - } - } - - -} diff --git a/apps/bff/src/orders/services/order-orchestrator.service.ts b/apps/bff/src/orders/services/order-orchestrator.service.ts index f77d4570..d2859743 100644 --- a/apps/bff/src/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/orders/services/order-orchestrator.service.ts @@ -9,7 +9,12 @@ import { SalesforceOrderItem, SalesforceQueryResult, } from "../types/salesforce-order.types"; -import { getSalesforceFieldMap } from "../../common/config/field-map"; +import { OrderDetailsDto, OrderSummaryDto } from "../types/order-details.dto"; +import { + getSalesforceFieldMap, + getOrderQueryFields, + getOrderItemProduct2Select, +} from "../../common/config/field-map"; /** * Main orchestrator for order operations @@ -95,16 +100,14 @@ export class OrderOrchestrator { /** * Get order by ID with order items */ - async getOrder(orderId: string) { + async getOrder(orderId: string): Promise { this.logger.log({ orderId }, "Fetching order details with items"); const fields = getSalesforceFieldMap(); const orderSoql = ` - SELECT Id, OrderNumber, Status, ${fields.order.orderType}, EffectiveDate, TotalAmount, - Account.Name, CreatedDate, LastModifiedDate, - Activation_Type__c, Activation_Status__c, Activation_Scheduled_At__c, - WHMCS_Order_ID__c - FROM Order + SELECT ${getOrderQueryFields()}, OrderNumber, TotalAmount, + Account.Name, CreatedDate, LastModifiedDate + FROM Order WHERE Id = '${orderId}' LIMIT 1 `; @@ -112,13 +115,8 @@ export class OrderOrchestrator { const orderItemsSoql = ` SELECT Id, OrderId, Quantity, UnitPrice, TotalPrice, PricebookEntry.Id, - PricebookEntry.Product2.Id, - PricebookEntry.Product2.Name, - PricebookEntry.Product2.StockKeepingUnit, - PricebookEntry.Product2.WH_Product_ID__c, - PricebookEntry.Product2.Item_Class__c, - PricebookEntry.Product2.Billing_Cycle__c - FROM OrderItem + ${getOrderItemProduct2Select()} + FROM OrderItem WHERE OrderId = '${orderId}' ORDER BY CreatedDate ASC `; @@ -136,20 +134,28 @@ export class OrderOrchestrator { return null; } - const orderItems = (itemsResult.records || []).map((item: SalesforceOrderItem) => ({ - id: item.Id, - quantity: item.Quantity, - unitPrice: item.UnitPrice, - totalPrice: item.TotalPrice, - product: { - id: item.PricebookEntry?.Product2?.Id, - name: item.PricebookEntry?.Product2?.Name, - sku: String(item.PricebookEntry?.Product2?.StockKeepingUnit || ""), // This is the key field that was missing! - whmcsProductId: String(item.PricebookEntry?.Product2?.WH_Product_ID__c || ""), - itemClass: String(item.PricebookEntry?.Product2?.Item_Class__c || ""), - billingCycle: String(item.PricebookEntry?.Product2?.Billing_Cycle__c || ""), - }, - })); + const orderItems = (itemsResult.records || []).map((item: SalesforceOrderItem) => { + const p2 = item.PricebookEntry?.Product2 as unknown as Record | undefined; + return { + id: item.Id, + quantity: item.Quantity, + unitPrice: item.UnitPrice, + totalPrice: item.TotalPrice, + product: { + id: p2?.Id as string | undefined, + name: ((): string | undefined => { + const v = p2?.Name; + return typeof v === "string" ? v : undefined; + })(), + sku: String((p2?.[fields.product.sku] as string | undefined) || ""), + whmcsProductId: String( + (p2?.[fields.product.whmcsProductId] as string | number | undefined) || "" + ), + itemClass: String((p2?.[fields.product.itemClass] as string | undefined) || ""), + billingCycle: String((p2?.[fields.product.billingCycle] as string | undefined) || ""), + }, + }; + }); this.logger.log( { orderId, itemCount: orderItems.length }, @@ -160,17 +166,34 @@ export class OrderOrchestrator { id: order.Id, orderNumber: order.OrderNumber, status: order.Status, - orderType: order.Type, + orderType: + typeof (order as unknown as Record)[fields.order.orderType] === "string" + ? ((order as unknown as Record)[fields.order.orderType] as string) + : order.Type, effectiveDate: order.EffectiveDate, totalAmount: order.TotalAmount, accountName: order.Account?.Name, createdDate: order.CreatedDate, lastModifiedDate: order.LastModifiedDate, - activationType: order.Activation_Type__c, - activationStatus: order.Activation_Status__c, - scheduledAt: order.Activation_Scheduled_At__c, - whmcsOrderId: order.WHMCS_Order_ID__c, - items: orderItems, // Now includes all the product details with SKUs! + activationType: ((): string | undefined => { + const v = (order as unknown as Record)[fields.order.activationType]; + return typeof v === "string" ? v : undefined; + })(), + activationStatus: ((): string | undefined => { + const v = (order as unknown as Record)[fields.order.activationStatus]; + return typeof v === "string" ? v : undefined; + })(), + scheduledAt: ((): string | undefined => { + const v = (order as unknown as Record)[ + fields.order.activationScheduledAt + ]; + return typeof v === "string" ? v : undefined; + })(), + whmcsOrderId: ((): string | undefined => { + const v = (order as unknown as Record)[fields.order.whmcsOrderId]; + return typeof v === "string" ? v : undefined; + })(), + items: orderItems, }; } catch (error) { this.logger.error({ error, orderId }, "Failed to fetch order with items"); @@ -181,7 +204,7 @@ export class OrderOrchestrator { /** * Get orders for a user with basic item summary */ - async getOrdersForUser(userId: string) { + async getOrdersForUser(userId: string): Promise { this.logger.log({ userId }, "Fetching user orders with item summaries"); // Get user mapping @@ -189,9 +212,8 @@ export class OrderOrchestrator { const fields = getSalesforceFieldMap(); const ordersSoql = ` - SELECT Id, OrderNumber, Status, ${fields.order.orderType}, EffectiveDate, TotalAmount, - CreatedDate, LastModifiedDate, WHMCS_Order_ID__c - FROM Order + SELECT ${getOrderQueryFields()}, OrderNumber, TotalAmount, CreatedDate, LastModifiedDate + FROM Order WHERE AccountId = '${userMapping.sfAccountId}' ORDER BY CreatedDate DESC LIMIT 50 @@ -210,12 +232,11 @@ export class OrderOrchestrator { // Get order items for all orders in one query const orderIds = orders.map(o => `'${o.Id}'`).join(","); const itemsSoql = ` - SELECT Id, OrderId, Quantity, UnitPrice, TotalPrice, - PricebookEntry.Product2.Name, - PricebookEntry.Product2.StockKeepingUnit, - PricebookEntry.Product2.Item_Class__c, - PricebookEntry.Product2.Billing_Cycle__c - FROM OrderItem + + + SELECT Id, OrderId, Quantity, + ${getOrderItemProduct2Select()} + FROM OrderItem WHERE OrderId IN (${orderIds}) ORDER BY OrderId, CreatedDate ASC `; @@ -229,10 +250,16 @@ export class OrderOrchestrator { const itemsByOrder = allItems.reduce( (acc, item: SalesforceOrderItem) => { if (!acc[item.OrderId]) acc[item.OrderId] = []; + const p2 = item.PricebookEntry?.Product2 as unknown as + | Record + | undefined; acc[item.OrderId].push({ - name: String(item.PricebookEntry?.Product2?.Name || ""), - sku: String(item.PricebookEntry?.Product2?.StockKeepingUnit || ""), - itemClass: String(item.PricebookEntry?.Product2?.Item_Class__c || ""), + name: ((): string | undefined => { + const v = p2?.Name; + return typeof v === "string" ? v : undefined; + })(), + sku: String((p2?.[fields.product.sku] as string | undefined) || ""), + itemClass: String((p2?.[fields.product.itemClass] as string | undefined) || ""), quantity: item.Quantity, unitPrice: item.UnitPrice, totalPrice: item.TotalPrice, @@ -258,12 +285,18 @@ export class OrderOrchestrator { id: order.Id, orderNumber: order.OrderNumber, status: order.Status, - orderType: order.Type, + orderType: + typeof (order as unknown as Record)[fields.order.orderType] === "string" + ? ((order as unknown as Record)[fields.order.orderType] as string) + : order.Type, effectiveDate: order.EffectiveDate, totalAmount: order.TotalAmount, createdDate: order.CreatedDate, lastModifiedDate: order.LastModifiedDate, - whmcsOrderId: order.WHMCS_Order_ID__c, + whmcsOrderId: ((): string | undefined => { + const v = (order as unknown as Record)[fields.order.whmcsOrderId]; + return typeof v === "string" ? v : undefined; + })(), itemsSummary: itemsByOrder[order.Id] || [], // Include basic item info for order list })); } catch (error) { diff --git a/apps/bff/src/orders/services/order-whmcs-mapper.service.ts b/apps/bff/src/orders/services/order-whmcs-mapper.service.ts index d1ffd2c2..cf743919 100644 --- a/apps/bff/src/orders/services/order-whmcs-mapper.service.ts +++ b/apps/bff/src/orders/services/order-whmcs-mapper.service.ts @@ -2,7 +2,7 @@ import { Injectable, BadRequestException, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { WhmcsOrderItem } from "../../vendors/whmcs/services/whmcs-order.service"; -import { getErrorMessage } from "../../common/utils/error.util"; +import type { OrderItemDto } from "../types/order-details.dto"; export interface OrderItemMappingResult { whmcsItems: WhmcsOrderItem[]; @@ -13,8 +13,6 @@ export interface OrderItemMappingResult { }; } - - /** * Handles mapping Salesforce OrderItems to WHMCS format * Similar to OrderItemBuilder but for fulfillment workflow @@ -26,7 +24,7 @@ export class OrderWhmcsMapper { /** * Map Salesforce OrderItems to WHMCS format for provisioning */ - async mapOrderItemsToWhmcs(orderItems: any[]): Promise { + mapOrderItemsToWhmcs(orderItems: OrderItemDto[]): OrderItemMappingResult { this.logger.log("Starting OrderItems mapping to WHMCS", { itemCount: orderItems.length, }); @@ -81,7 +79,7 @@ export class OrderWhmcsMapper { /** * Map a single Salesforce OrderItem to WHMCS format */ - private mapSingleOrderItem(item: any, index: number): WhmcsOrderItem { + private mapSingleOrderItem(item: OrderItemDto, index: number): WhmcsOrderItem { const product = item.product; // This is the transformed structure from OrderOrchestrator if (!product) { @@ -112,10 +110,6 @@ export class OrderWhmcsMapper { return whmcsItem; } - - - - /** * Create order notes with Salesforce tracking information */ diff --git a/apps/bff/src/orders/types/order-details.dto.ts b/apps/bff/src/orders/types/order-details.dto.ts new file mode 100644 index 00000000..0294cb5d --- /dev/null +++ b/apps/bff/src/orders/types/order-details.dto.ts @@ -0,0 +1,53 @@ +export interface OrderItemProductDto { + id?: string; + name?: string; + sku: string; + whmcsProductId: string; + itemClass: string; + billingCycle: string; +} + +export interface OrderItemDto { + id: string; + quantity: number; + unitPrice: number; + totalPrice: number; + product: OrderItemProductDto; +} + +export interface OrderDetailsDto { + id: string; + orderNumber: string; + status: string; + orderType?: string; + effectiveDate: string; + totalAmount: number; + accountName?: string; + createdDate: string; + lastModifiedDate: string; + activationType?: string; + activationStatus?: string; + scheduledAt?: string; + whmcsOrderId?: string; + items: OrderItemDto[]; +} + +export interface OrderSummaryItemDto { + name?: string; + sku?: string; + itemClass?: string; + quantity: number; +} + +export interface OrderSummaryDto { + id: string; + orderNumber: string; + status: string; + orderType?: string; + effectiveDate: string; + totalAmount: number; + createdDate: string; + lastModifiedDate: string; + whmcsOrderId?: string; + itemsSummary: OrderSummaryItemDto[]; +} diff --git a/apps/bff/src/orders/types/salesforce-order.types.ts b/apps/bff/src/orders/types/salesforce-order.types.ts index 736352d2..dbc08a33 100644 --- a/apps/bff/src/orders/types/salesforce-order.types.ts +++ b/apps/bff/src/orders/types/salesforce-order.types.ts @@ -10,13 +10,12 @@ export interface SalesforceOrder { Account: { Id: string; Name: string; + [key: string]: unknown; }; CreatedDate: string; LastModifiedDate: string; - Activation_Type__c?: string; - Activation_Status__c?: string; - Activation_Scheduled_At__c?: string; - WHMCS_Order_ID__c?: string; + // Allow dynamic access to mapped custom fields via field-map + [key: string]: unknown; } export interface SalesforceOrderItem { @@ -32,12 +31,12 @@ export interface SalesforceOrderItem { Id: string; Name: string; ProductCode: string; - StockKeepingUnit?: string; - WH_Product_ID__c?: string; - Item_Class__c?: string; - Billing_Cycle__c?: string; + // Allow dynamic access to mapped custom fields via field-map + [key: string]: unknown; }; + [key: string]: unknown; }; + [key: string]: unknown; } export interface SalesforceQueryResult { @@ -62,9 +61,8 @@ export interface OrderCreateRequest { Status: string; EffectiveDate: string; Type: string; - Activation_Type__c?: string; - Activation_Scheduled_At__c?: string; - WHMCS_Order_ID__c?: string; + // Custom fields provided via field-map + [key: string]: unknown; } export interface OrderItemCreateRequest { diff --git a/apps/bff/src/types/salesforce-pubsub-api-client.d.ts b/apps/bff/src/types/salesforce-pubsub-api-client.d.ts new file mode 100644 index 00000000..a82c29ba --- /dev/null +++ b/apps/bff/src/types/salesforce-pubsub-api-client.d.ts @@ -0,0 +1 @@ +declare module "salesforce-pubsub-api-client"; diff --git a/apps/bff/src/users/users.service.ts b/apps/bff/src/users/users.service.ts index 0e8b0414..b3aefd1a 100644 --- a/apps/bff/src/users/users.service.ts +++ b/apps/bff/src/users/users.service.ts @@ -298,12 +298,7 @@ export class UsersService { data: sanitizedData, }); - // Try to sync to Salesforce (non-blocking) - this.syncToSalesforce(validId, userData).catch(error => - this.logger.warn("Failed to sync to Salesforce", { - error: getErrorMessage(error), - }) - ); + // Do not mutate Salesforce Account from the portal. Salesforce remains authoritative. return this.toUser(updatedUser); } catch (error) { diff --git a/apps/bff/src/vendors/salesforce/events/event-keys.util.ts b/apps/bff/src/vendors/salesforce/events/event-keys.util.ts new file mode 100644 index 00000000..ee4664bd --- /dev/null +++ b/apps/bff/src/vendors/salesforce/events/event-keys.util.ts @@ -0,0 +1,11 @@ +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}`; +} diff --git a/apps/bff/src/vendors/salesforce/events/events.module.ts b/apps/bff/src/vendors/salesforce/events/events.module.ts new file mode 100644 index 00000000..dcda9ee3 --- /dev/null +++ b/apps/bff/src/vendors/salesforce/events/events.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { VendorsModule } from "../../vendors.module"; +import { OrdersModule } from "../../../orders/orders.module"; +import { SalesforcePubSubSubscriber } from "./pubsub.subscriber"; + +@Module({ + imports: [ConfigModule, VendorsModule, OrdersModule], + providers: [SalesforcePubSubSubscriber], +}) +export class SalesforceEventsModule {} diff --git a/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts b/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts new file mode 100644 index 00000000..f2ad1450 --- /dev/null +++ b/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts @@ -0,0 +1,246 @@ +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 "../../../orders/queue/provisioning.queue"; +import { CacheService } from "../../../common/cache/cache.service"; +import { + replayKey as sfReplayKey, + statusKey as sfStatusKey, + latestSeenKey as sfLatestSeenKey, +} from "./event-keys.util"; + +type SubscribeCallback = ( + subscription: { topicName: string }, + callbackType: string, + data: unknown +) => void | Promise; + +interface PubSubClient { + connect(): Promise; + subscribe(topic: string, cb: SubscribeCallback, numRequested: number): Promise; + subscribeFromReplayId( + topic: string, + cb: SubscribeCallback, + numRequested: number, + replayId: number + ): Promise; + subscribeFromEarliestEvent( + topic: string, + cb: SubscribeCallback, + numRequested: number + ): Promise; + requestAdditionalEvents(topic: string, numRequested: number): Promise; + close(): Promise; +} + +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 channel!: string; + + 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 { + 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( + "SF_PROVISION_EVENT_CHANNEL", + "/event/Order_Fulfilment_Requested__e" + ); + + try { + await this.sfConn.connect(); + const accessToken = this.sfConn.getAccessToken(); + const instanceUrl = this.sfConn.getInstanceUrl(); + if (!accessToken || !instanceUrl) { + throw new Error("Salesforce access token || instance URL unavailable"); + } + + const endpoint = this.config.get( + "SF_PUBSUB_ENDPOINT", + "api.pubsub.salesforce.com:7443" + ); + + const maybeCtor: unknown = + (PubSubApiClientPkg as { default?: unknown })?.default ?? (PubSubApiClientPkg as unknown); + const Ctor = maybeCtor as PubSubCtor; + this.client = new Ctor({ + authType: "user-supplied", + accessToken, + instanceUrl, + pubSubEndpoint: endpoint, + }); + await this.client.connect(); + if (!this.client) throw new Error("Pub/Sub client not initialized after connect"); + const client = this.client; + + const replayKey = sfReplayKey(this.channel); + const storedReplay = await this.cache.get(replayKey); + const replayMode = this.config.get("SF_EVENTS_REPLAY", "LATEST"); + const numRequested = Number(this.config.get("SF_PUBSUB_NUM_REQUESTED", "50")) || 50; + const maxQueue = Number(this.config.get("SF_PUBSUB_QUEUE_MAX", "100")) || 100; + + await this.cache.set(sfStatusKey(this.channel), { + status: "connecting", + since: Date.now(), + }); + + const subscribeCallback: SubscribeCallback = async (subscription, callbackType, data) => { + try { + // Normalize library callback signatures + const argTypes = [typeof subscription, typeof callbackType, typeof data]; + let type: string | undefined; + let payloadData: unknown; + let topic = (subscription as { topicName?: string })?.topicName || this.channel; + + if (typeof callbackType === "string") { + type = callbackType; + payloadData = data; + } else if (typeof subscription === "string") { + type = subscription; + payloadData = callbackType; + topic = this.channel; + } else { + type = "data"; + payloadData = data ?? callbackType ?? subscription; + } + + if (type === "data") { + const event = payloadData as Record; + const payload = ((): Record | undefined => { + const p = event?.["payload"]; + return typeof p === "object" && p != null ? (p as Record) : undefined; + })(); + + // Only check parsed 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 }); + const depth = await this.provisioningQueue.depth(); + if (depth < maxQueue) { + await client.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, + }); + + // Keep sliding window full when queue has room + const depth = await this.provisioningQueue.depth(); + if (depth < maxQueue) { + await client.requestAdditionalEvents(topic, 1); + } + } else if (type === "lastEvent") { + const depth = await this.provisioningQueue.depth(); + const available = Math.max(0, maxQueue - depth); + const desired = Math.max(0, Math.min(numRequested, available)); + if (desired > 0) { + await client.requestAdditionalEvents(topic, desired); + } + } else if (type === "grpcKeepalive") { + const latestVal = (payloadData 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(), + }); + } + } + } catch (err) { + this.logger.error("Pub/Sub subscribe callback failed", { + error: err instanceof Error ? err.message : String(err), + }); + } + }; + + if (storedReplay && replayMode !== "ALL") { + await this.client.subscribeFromReplayId( + this.channel, + subscribeCallback, + numRequested, + Number(storedReplay) + ); + } else if (replayMode === "ALL") { + await this.client.subscribeFromEarliestEvent(this.channel, subscribeCallback, numRequested); + } else { + await this.client.subscribe(this.channel, 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 }); + } 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 { + try { + if (this.client) { + await this.client.close(); + this.client = null; + } + 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), + }); + } + } + + // keys moved to shared util +} diff --git a/apps/bff/src/vendors/salesforce/salesforce.service.ts b/apps/bff/src/vendors/salesforce/salesforce.service.ts index 5fc2948e..e3e48f59 100644 --- a/apps/bff/src/vendors/salesforce/salesforce.service.ts +++ b/apps/bff/src/vendors/salesforce/salesforce.service.ts @@ -3,6 +3,7 @@ import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; import { getErrorMessage } from "../../common/utils/error.util"; import { SalesforceConnection } from "./services/salesforce-connection.service"; +import { getSalesforceFieldMap } from "../../common/config/field-map"; import { SalesforceAccountService, AccountData, @@ -142,8 +143,9 @@ export class SalesforceService implements OnModuleInit { throw new Error("Salesforce connection not available"); } + const fields = getSalesforceFieldMap(); const result = (await this.connection.query( - `SELECT Id, Status, Provisioning_Status__c, WHMCS_Order_ID__c, AccountId + `SELECT Id, Status, ${fields.order.activationStatus}, ${fields.order.whmcsOrderId}, AccountId FROM Order WHERE Id = '${orderId}' LIMIT 1` diff --git a/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts b/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts index e91a9ff4..abe2d34f 100644 --- a/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts @@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "../../../common/utils/error.util"; import { SalesforceConnection } from "./salesforce-connection.service"; +import { SalesforceQueryResult as SfQueryResult } from "../../../orders/types/salesforce-order.types"; export interface AccountData { name: string; @@ -20,22 +21,12 @@ export interface UpsertResult { created: boolean; } -interface SalesforceQueryResult { - records: SalesforceAccount[]; - totalSize: number; -} - interface SalesforceAccount { Id: string; Name: string; WH_Account__c?: string; } -interface _SalesforceCreateResult { - id: string; - success: boolean; -} - @Injectable() export class SalesforceAccountService { constructor( @@ -49,7 +40,7 @@ export class SalesforceAccountService { try { const result = (await this.connection.query( `SELECT Id FROM Account WHERE SF_Account_No__c = '${this.safeSoql(customerNumber.trim())}'` - )) as SalesforceQueryResult; + )) as SfQueryResult; return result.totalSize > 0 ? { id: result.records[0].Id } : null; } catch (error) { this.logger.error("Failed to find account by customer number", { @@ -67,7 +58,7 @@ export class SalesforceAccountService { try { const result = (await this.connection.query( `SELECT Id, Name, WH_Account__c FROM Account WHERE Id = '${this.safeSoql(accountId.trim())}'` - )) as SalesforceQueryResult; + )) as SfQueryResult; if (result.totalSize === 0) { return null; @@ -120,7 +111,7 @@ export class SalesforceAccountService { try { const existingAccount = (await this.connection.query( `SELECT Id FROM Account WHERE Name = '${this.safeSoql(accountData.name.trim())}'` - )) as SalesforceQueryResult; + )) as SfQueryResult; const sfData = { Name: accountData.name.trim(), @@ -168,7 +159,7 @@ export class SalesforceAccountService { SELECT Id, Name FROM Account WHERE Id = '${this.validateId(accountId)}' - `)) as SalesforceQueryResult; + `)) as SfQueryResult; return result.totalSize > 0 ? result.records[0] : null; } catch (error) { diff --git a/apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts b/apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts index dde5e4f3..028afcc4 100644 --- a/apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts @@ -4,6 +4,10 @@ import { getErrorMessage } from "../../../common/utils/error.util"; import { SalesforceConnection } from "./salesforce-connection.service"; import { SupportCase, CreateCaseRequest, CaseType } from "@customer-portal/shared"; import { CaseStatus, CasePriority, CASE_STATUS, CASE_PRIORITY } from "@customer-portal/shared"; +import { + SalesforceQueryResult as SfQueryResult, + SalesforceCreateResult as SfCreateResult, +} from "../../../orders/types/salesforce-order.types"; export interface CaseQueryParams { status?: string; @@ -27,11 +31,6 @@ interface CaseData { origin?: string; } -interface SalesforceQueryResult { - records: SalesforceCase[]; - totalSize: number; -} - interface SalesforceCase { Id: string; CaseNumber: string; @@ -52,11 +51,6 @@ interface SalesforceCase { }; } -interface SalesforceCreateResult { - id: string; - success: boolean; -} - @Injectable() export class SalesforceCaseService { constructor( @@ -92,7 +86,7 @@ export class SalesforceCaseService { query += ` OFFSET ${params.offset}`; } - const result = (await this.connection.query(query)) as SalesforceQueryResult; + const result = (await this.connection.query(query)) as SfQueryResult; const cases = result.records.map(record => this.transformCase(record)); @@ -157,7 +151,7 @@ export class SalesforceCaseService { WHERE Email = '${this.safeSoql(userData.email)}' AND AccountId = '${userData.accountId}' LIMIT 1 - `)) as SalesforceQueryResult; + `)) as SfQueryResult; if (existingContact.totalSize > 0) { return existingContact.records[0].Id; @@ -172,7 +166,7 @@ export class SalesforceCaseService { }; const sobject = this.connection.sobject("Contact") as unknown as { - create: (data: Record) => Promise; + create: (data: Record) => Promise; }; const result = await sobject.create(contactData); return result.id; @@ -201,7 +195,7 @@ export class SalesforceCaseService { }; const sobject = this.connection.sobject("Case") as unknown as { - create: (data: Record) => Promise; + create: (data: Record) => Promise; }; const result = await sobject.create(sfData); @@ -211,7 +205,7 @@ export class SalesforceCaseService { CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name FROM Case WHERE Id = '${result.id}' - `)) as SalesforceQueryResult; + `)) as SfQueryResult; return createdCase.records[0]; } diff --git a/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts b/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts index 9f2ec632..5b35db86 100644 --- a/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts +++ b/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts @@ -12,11 +12,6 @@ export interface SalesforceSObjectApi { update?: (data: Record & { Id: string }) => Promise; } -interface _SalesforceRetryableSObjectApi extends SalesforceSObjectApi { - create: (data: Record) => Promise<{ id?: string }>; - update?: (data: Record & { Id: string }) => Promise; -} - @Injectable() export class SalesforceConnection { private connection: jsforce.Connection; @@ -30,6 +25,24 @@ export class SalesforceConnection { }); } + /** + * Expose jsforce connection for advanced features + * Consumers must ensure connect() has been called and isConnected() is true. + */ + getConnection(): jsforce.Connection { + return this.connection; + } + + /** Get the current OAuth access token (after connect()). */ + getAccessToken(): string | undefined { + return this.connection?.accessToken as string | undefined; + } + + /** Get the current instance URL (after connect()). */ + getInstanceUrl(): string | undefined { + return this.connection?.instanceUrl as string | undefined; + } + async connect(): Promise { const nodeEnv = this.configService.get("NODE_ENV") || process.env.NODE_ENV || "development"; @@ -48,18 +61,37 @@ export class SalesforceConnection { throw new Error(isProd ? "Salesforce configuration is missing" : devMessage); } - // Resolve private key strictly relative to repo root and enforce secrets directory - // Use monorepo layout assumption: apps/bff -> repo root is two levels up - const appDir = process.cwd(); - const repoRoot = path.resolve(appDir, "../../"); - const secretsDir = path.resolve(repoRoot, "secrets"); - const resolvedKeyPath = path.resolve(repoRoot, privateKeyPath); + // Resolve private key and enforce allowed secrets directories + // Supports project-root ./secrets, apps/bff ./secrets, and container /app/secrets + const getProjectRoot = () => { + const cwd = process.cwd(); + const norm = path.normalize(cwd); + const bffSuffix = path.normalize(path.join("apps", "bff")); + if (norm.endsWith(bffSuffix)) { + return path.resolve(cwd, "../.."); + } + return cwd; + }; + + const isAbsolute = path.isAbsolute(privateKeyPath); + const resolvedKeyPath = isAbsolute + ? privateKeyPath + : path.resolve(getProjectRoot(), privateKeyPath); + + const allowedBases = [ + path.resolve(getProjectRoot(), "secrets"), + path.resolve(process.cwd(), "secrets"), + "/app/secrets", + ].map(p => path.normalize(p) + path.sep); - // Enforce the key to be under repo-root/secrets const normalizedKeyPath = path.normalize(resolvedKeyPath); - const normalizedSecretsDir = path.normalize(secretsDir) + path.sep; - if (!(normalizedKeyPath + path.sep).startsWith(normalizedSecretsDir)) { - const devMsg = `Salesforce private key must be located under the root secrets directory: ${secretsDir}`; + const isUnderAllowedBase = allowedBases.some(base => + (normalizedKeyPath + path.sep).startsWith(base) + ); + if (!isUnderAllowedBase) { + const devMsg = `Salesforce private key must be under one of the allowed secrets directories: ${allowedBases + .map(b => b.replace(/\\$/, "")) + .join(", ")}. Got: ${normalizedKeyPath}`; throw new Error(isProd ? "Invalid Salesforce private key path" : devMsg); } @@ -132,6 +164,11 @@ export class SalesforceConnection { // Expose connection methods with automatic re-authentication async query(soql: string): Promise { try { + // Ensure we have a base URL and token + if (!this.isConnected()) { + this.logger.warn("Salesforce not connected; attempting to establish connection"); + await this.connect(); + } return await this.connection.query(soql); } catch (error: unknown) { // Check if this is a session expiration error @@ -183,6 +220,10 @@ export class SalesforceConnection { return { create: async (data: Record) => { try { + if (!this.isConnected()) { + this.logger.warn("Salesforce not connected; attempting to establish connection"); + await this.connect(); + } return await originalSObject.create(data); } catch (error: unknown) { if (this.isSessionExpiredError(error)) { @@ -208,6 +249,10 @@ export class SalesforceConnection { update: async (data: Record & { Id: string }) => { try { + if (!this.isConnected()) { + this.logger.warn("Salesforce not connected; attempting to establish connection"); + await this.connect(); + } return await originalSObject.update(data); } catch (error: unknown) { if (this.isSessionExpiredError(error)) { diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts index dd395f49..0a0132ea 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts @@ -48,7 +48,7 @@ export class WhmcsConnectionService { identifier: this.configService.get("WHMCS_API_IDENTIFIER", ""), secret: this.configService.get("WHMCS_API_SECRET", ""), timeout: this.configService.get("WHMCS_API_TIMEOUT", 30000), - retryAttempts: this.configService.get("WHMCS_API_RETRY_ATTEMPTS", 3), + retryAttempts: this.configService.get("WHMCS_API_RETRY_ATTEMPTS", 1), retryDelay: this.configService.get("WHMCS_API_RETRY_DELAY", 1000), }; // Optional API Access Key (used by some WHMCS deployments alongside API Credentials) diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts index d1aa1cf6..ecc9a1e7 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts @@ -19,6 +19,7 @@ export interface WhmcsAddOrderParams { notes?: string; sfOrderId?: string; // For tracking back to Salesforce noinvoice?: boolean; // Default false - create invoice + noinvoiceemail?: boolean; // Default false - suppress invoice email (if invoice is created) noemail?: boolean; // Default false - send emails } @@ -210,6 +211,8 @@ export class WhmcsOrderService { clientid: params.clientId, paymentmethod: params.paymentMethod, // Required by WHMCS API noinvoice: params.noinvoice ? true : false, + // If invoices are created (noinvoice=false), optionally suppress invoice email + ...(params.noinvoiceemail ? { noinvoiceemail: true } : {}), noemail: params.noemail ? true : false, }; @@ -225,7 +228,7 @@ export class WhmcsOrderService { const configOptions: string[] = []; const customFields: string[] = []; - params.items.forEach((item) => { + params.items.forEach(item => { pids.push(item.productId); billingCycles.push(item.billingCycle); quantities.push(item.quantity); @@ -238,7 +241,7 @@ export class WhmcsOrderService { configOptions.push(""); // Empty string for items without config options } - // Handle custom fields - WHMCS expects base64 encoded serialized arrays + // Handle custom fields - WHMCS expects base64 encoded serialized arrays if (item.customFields && Object.keys(item.customFields).length > 0) { const serialized = this.serializeForWhmcs(item.customFields); customFields.push(serialized); @@ -251,11 +254,11 @@ export class WhmcsOrderService { payload.pid = pids; payload.billingcycle = billingCycles; payload.qty = quantities; - + if (configOptions.some(opt => opt !== "")) { payload.configoptions = configOptions; } - + if (customFields.some(field => field !== "")) { payload.customfields = customFields; } @@ -279,7 +282,7 @@ export class WhmcsOrderService { try { // Convert to PHP-style serialized format, then base64 encode const serialized = this.phpSerialize(data); - return Buffer.from(serialized).toString('base64'); + return Buffer.from(serialized).toString("base64"); } catch (error) { this.logger.warn("Failed to serialize data for WHMCS", { error: getErrorMessage(error), @@ -301,6 +304,6 @@ export class WhmcsOrderService { const safeValue = String(value).replace(/"/g, '\\"'); return `s:${safeKey.length}:"${safeKey}";s:${safeValue.length}:"${safeValue}";`; }); - return `a:${entries.length}:{${serializedEntries.join('')}}`; + return `a:${entries.length}:{${serializedEntries.join("")}}`; } } diff --git a/apps/bff/src/webhooks/guards/enhanced-webhook-signature.guard.ts b/apps/bff/src/webhooks/guards/enhanced-webhook-signature.guard.ts deleted file mode 100644 index 8aa241f0..00000000 --- a/apps/bff/src/webhooks/guards/enhanced-webhook-signature.guard.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Request } from "express"; -import crypto from "node:crypto"; -import { Logger } from "nestjs-pino"; -import { Inject } from "@nestjs/common"; - -interface WebhookRequest extends Request { - webhookMetadata?: { - sourceIp: string; - timestamp: Date; - nonce: string; - signature: string; - }; -} - -@Injectable() -export class EnhancedWebhookSignatureGuard implements CanActivate { - private readonly nonceStore = new Set(); // In production, use Redis - private readonly maxNonceAge = 5 * 60 * 1000; // 5 minutes - private readonly allowedIps: string[]; - - constructor( - private configService: ConfigService, - @Inject(Logger) private readonly logger: Logger - ) { - // Parse IP allowlist from environment - const ipAllowlist = this.configService.get("SF_WEBHOOK_IP_ALLOWLIST"); - this.allowedIps = ipAllowlist ? ipAllowlist.split(",").map(ip => ip.trim()) : []; - } - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - - try { - // 1. Verify source IP if allowlist is configured - if (this.allowedIps.length > 0) { - this.verifySourceIp(request); - } - - // 2. Extract and verify required headers - const headers = this.extractHeaders(request); - - // 3. Verify timestamp (prevent replay attacks) - this.verifyTimestamp(headers.timestamp); - - // 4. Verify nonce (prevent duplicate processing) - this.verifyNonce(headers.nonce); - - // 5. Verify HMAC signature - this.verifyHmacSignature(request, headers.signature); - - // Store metadata for logging/monitoring - request.webhookMetadata = { - sourceIp: request.ip || "unknown", - timestamp: new Date(headers.timestamp), - nonce: headers.nonce, - signature: headers.signature, - }; - - this.logger.log("Webhook security validation passed", { - sourceIp: request.ip, - nonce: headers.nonce, - timestamp: headers.timestamp, - }); - - return true; - } catch (error) { - this.logger.warn("Webhook security validation failed", { - sourceIp: request.ip, - error: error instanceof Error ? error.message : String(error), - userAgent: request.headers["user-agent"], - }); - throw error; - } - } - - private verifySourceIp(request: Request): void { - const clientIp = request.ip || request.connection.remoteAddress || "unknown"; - - // Check if IP is in allowlist (simplified - in production use proper CIDR matching) - const isAllowed = this.allowedIps.some(allowedIp => { - if (allowedIp.includes("/")) { - // CIDR notation - implement proper CIDR matching - return this.isIpInCidr(clientIp, allowedIp); - } - return clientIp === allowedIp; - }); - - if (!isAllowed) { - throw new UnauthorizedException(`IP ${clientIp} not in allowlist`); - } - } - - private extractHeaders(request: Request) { - const signature = - (request.headers["x-sf-signature"] as string) || - (request.headers["x-whmcs-signature"] as string); - - const timestamp = request.headers["x-sf-timestamp"] as string; - const nonce = request.headers["x-sf-nonce"] as string; - - if (!signature) { - throw new UnauthorizedException("Webhook signature is required"); - } - if (!timestamp) { - throw new UnauthorizedException("Webhook timestamp is required"); - } - if (!nonce) { - throw new UnauthorizedException("Webhook nonce is required"); - } - - return { signature, timestamp, nonce }; - } - - private verifyTimestamp(timestamp: string): void { - const requestTime = new Date(timestamp).getTime(); - const now = Date.now(); - const tolerance = this.configService.get("WEBHOOK_TIMESTAMP_TOLERANCE") || 300000; // 5 minutes - - if (isNaN(requestTime)) { - throw new UnauthorizedException("Invalid timestamp format"); - } - - if (Math.abs(now - requestTime) > tolerance) { - throw new UnauthorizedException("Request timestamp outside acceptable range"); - } - } - - private verifyNonce(nonce: string): void { - // Check if nonce was already used - if (this.nonceStore.has(nonce)) { - throw new UnauthorizedException("Nonce already used (replay attack detected)"); - } - - // Add nonce to store - this.nonceStore.add(nonce); - - // Clean up old nonces (in production, implement proper TTL with Redis) - this.cleanupOldNonces(); - } - - private verifyHmacSignature(request: Request, signature: string): void { - // Determine webhook type and get appropriate secret - const isWhmcs = Boolean(request.headers["x-whmcs-signature"]); - const isSalesforce = Boolean(request.headers["x-sf-signature"]); - - let secret: string | undefined; - if (isWhmcs) { - secret = this.configService.get("WHMCS_WEBHOOK_SECRET"); - } else if (isSalesforce) { - secret = this.configService.get("SF_WEBHOOK_SECRET"); - } - - if (!secret) { - throw new UnauthorizedException("Webhook secret not configured"); - } - - // Create signature from request body - const payload = Buffer.from(JSON.stringify(request.body), "utf8"); - const key = Buffer.from(secret, "utf8"); - const expectedSignature = crypto.createHmac("sha256", key).update(payload).digest("hex"); - - // Use constant-time comparison to prevent timing attacks - if (!this.constantTimeCompare(signature, expectedSignature)) { - throw new UnauthorizedException("Invalid webhook signature"); - } - } - - private constantTimeCompare(a: string, b: string): boolean { - if (a.length !== b.length) { - return false; - } - - let result = 0; - for (let i = 0; i < a.length; i++) { - result |= a.charCodeAt(i) ^ b.charCodeAt(i); - } - return result === 0; - } - - private isIpInCidr(ip: string, cidr: string): boolean { - // Simplified CIDR check - in production use a proper library like 'ip-range-check' - // This is a basic implementation for IPv4 - const [network, prefixLength] = cidr.split("/"); - const networkInt = this.ipToInt(network); - const ipInt = this.ipToInt(ip); - const mask = -1 << (32 - parseInt(prefixLength, 10)); - - return (networkInt & mask) === (ipInt & mask); - } - - private ipToInt(ip: string): number { - return ip.split(".").reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; - } - - private cleanupOldNonces(): void { - // In production, implement proper cleanup with Redis TTL - // This is a simplified cleanup for in-memory storage - if (this.nonceStore.size > 10000) { - this.nonceStore.clear(); - } - } -} diff --git a/apps/bff/src/webhooks/guards/webhook-signature.guard.ts b/apps/bff/src/webhooks/guards/webhook-signature.guard.ts deleted file mode 100644 index a52ce864..00000000 --- a/apps/bff/src/webhooks/guards/webhook-signature.guard.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Request } from "express"; -import crypto from "node:crypto"; - -@Injectable() -export class WebhookSignatureGuard implements CanActivate { - constructor(private configService: ConfigService) {} - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - const signatureHeader = - (request.headers["x-whmcs-signature"] as string | undefined) || - (request.headers["x-sf-signature"] as string | undefined); - - if (!signatureHeader) { - throw new UnauthorizedException("Webhook signature is required"); - } - - // Get the appropriate secret based on the webhook type - const isWhmcs = Boolean(request.headers["x-whmcs-signature"]); - const secret = isWhmcs - ? this.configService.get("WHMCS_WEBHOOK_SECRET") - : this.configService.get("SF_WEBHOOK_SECRET"); - - if (!secret) { - throw new UnauthorizedException("Webhook secret not configured"); - } - - // Verify signature - const payload = Buffer.from(JSON.stringify(request.body), "utf8"); - const key = Buffer.from(secret, "utf8"); - const expectedSignature = crypto.createHmac("sha256", key).update(payload).digest("hex"); - - if (signatureHeader !== expectedSignature) { - throw new UnauthorizedException("Invalid webhook signature"); - } - - return true; - } -} diff --git a/apps/bff/src/webhooks/schemas/salesforce.ts b/apps/bff/src/webhooks/schemas/salesforce.ts deleted file mode 100644 index e1dea05a..00000000 --- a/apps/bff/src/webhooks/schemas/salesforce.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from "zod"; - -export const SalesforceWebhookSchema = z.object({ - event: z.object({ type: z.string() }).optional(), - sobject: z.object({ Id: z.string() }).optional(), -}); - -export type SalesforceWebhook = z.infer; diff --git a/apps/bff/src/webhooks/schemas/whmcs.ts b/apps/bff/src/webhooks/schemas/whmcs.ts deleted file mode 100644 index 79138115..00000000 --- a/apps/bff/src/webhooks/schemas/whmcs.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from "zod"; - -export const WhmcsWebhookSchema = z.object({ - action: z.string(), - client_id: z.coerce.number().int().optional(), -}); - -export type WhmcsWebhook = z.infer; diff --git a/apps/bff/src/webhooks/webhooks.controller.ts b/apps/bff/src/webhooks/webhooks.controller.ts deleted file mode 100644 index ea04c3e9..00000000 --- a/apps/bff/src/webhooks/webhooks.controller.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - Controller, - Post, - Body, - Headers, - UseGuards, - HttpCode, - HttpStatus, - BadRequestException, -} from "@nestjs/common"; -import { WebhooksService } from "./webhooks.service"; -import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from "@nestjs/swagger"; -import { ThrottlerGuard } from "@nestjs/throttler"; -import { WebhookSignatureGuard } from "./guards/webhook-signature.guard"; -import { Public } from "../auth/decorators/public.decorator"; - -@ApiTags("webhooks") -@Controller("webhooks") -@Public() // Webhooks use signature-based authentication, not JWT -@UseGuards(ThrottlerGuard) // Rate limit webhook endpoints -export class WebhooksController { - constructor(private webhooksService: WebhooksService) {} - - @Post("whmcs") - @HttpCode(HttpStatus.OK) - @UseGuards(WebhookSignatureGuard) - @ApiOperation({ summary: "WHMCS webhook endpoint" }) - @ApiResponse({ status: 200, description: "Webhook processed successfully" }) - @ApiResponse({ status: 400, description: "Invalid webhook data" }) - @ApiResponse({ status: 401, description: "Invalid signature" }) - @ApiHeader({ name: "X-WHMCS-Signature", description: "WHMCS webhook signature" }) - handleWhmcsWebhook(@Body() payload: unknown, @Headers("x-whmcs-signature") signature: string) { - try { - this.webhooksService.processWhmcsWebhook(payload, signature); - return { success: true, message: "Webhook processed successfully" }; - } catch { - throw new BadRequestException("Failed to process webhook"); - } - } - - @Post("salesforce") - @HttpCode(HttpStatus.OK) - @UseGuards(WebhookSignatureGuard) - @ApiOperation({ summary: "Salesforce webhook endpoint" }) - @ApiResponse({ status: 200, description: "Webhook processed successfully" }) - @ApiResponse({ status: 400, description: "Invalid webhook data" }) - @ApiResponse({ status: 401, description: "Invalid signature" }) - @ApiHeader({ name: "X-SF-Signature", description: "Salesforce webhook signature" }) - handleSalesforceWebhook(@Body() payload: unknown, @Headers("x-sf-signature") signature: string) { - try { - this.webhooksService.processSalesforceWebhook(payload, signature); - return { success: true, message: "Webhook processed successfully" }; - } catch { - throw new BadRequestException("Failed to process webhook"); - } - } -} diff --git a/apps/bff/src/webhooks/webhooks.module.ts b/apps/bff/src/webhooks/webhooks.module.ts deleted file mode 100644 index 47b37325..00000000 --- a/apps/bff/src/webhooks/webhooks.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from "@nestjs/common"; -import { WebhooksController } from "./webhooks.controller"; -import { WebhooksService } from "./webhooks.service"; -import { VendorsModule } from "../vendors/vendors.module"; -import { JobsModule } from "../jobs/jobs.module"; - -@Module({ - imports: [VendorsModule, JobsModule], - controllers: [WebhooksController], - providers: [WebhooksService], -}) -export class WebhooksModule {} diff --git a/apps/bff/src/webhooks/webhooks.service.ts b/apps/bff/src/webhooks/webhooks.service.ts deleted file mode 100644 index a1f06187..00000000 --- a/apps/bff/src/webhooks/webhooks.service.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Injectable, BadRequestException, Inject } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Logger } from "nestjs-pino"; -import { WhmcsWebhookSchema, WhmcsWebhook } from "./schemas/whmcs"; -import { SalesforceWebhookSchema, SalesforceWebhook } from "./schemas/salesforce"; - -@Injectable() -export class WebhooksService { - constructor( - private configService: ConfigService, - @Inject(Logger) private readonly logger: Logger - ) {} - - processWhmcsWebhook(payload: unknown, signature: string): void { - try { - const data: WhmcsWebhook = WhmcsWebhookSchema.parse(payload); - this.logger.log("Processing WHMCS webhook", { - webhookType: data.action, - clientId: data.client_id, - signatureLength: signature?.length || 0, - }); - - // TODO: Implement WHMCS webhook processing logic - // This should handle various WHMCS events like: - // - Invoice creation/update - // - Payment received - // - Client status changes - // - Service changes - - this.logger.log("WHMCS webhook processed successfully"); - } catch (error) { - this.logger.error("Failed to process WHMCS webhook", { - error: error instanceof Error ? error.message : String(error), - }); - throw new BadRequestException("Failed to process WHMCS webhook"); - } - } - - processSalesforceWebhook(payload: unknown, signature: string): void { - try { - const data: SalesforceWebhook = SalesforceWebhookSchema.parse(payload); - this.logger.log("Processing Salesforce webhook", { - webhookType: data.event?.type || "unknown", - recordId: data.sobject?.Id, - signatureLength: signature?.length || 0, - }); - - // TODO: Implement Salesforce webhook processing logic - // This should handle various Salesforce events like: - // - Account updates - // - Contact changes - // - Opportunity updates - // - Custom object changes - - this.logger.log("Salesforce webhook processed successfully"); - } catch (error) { - this.logger.error("Failed to process Salesforce webhook", { - error: error instanceof Error ? error.message : String(error), - }); - throw new BadRequestException("Failed to process Salesforce webhook"); - } - } -} diff --git a/apps/portal/next.config.mjs b/apps/portal/next.config.mjs index 751a8e00..4aae8d2b 100644 --- a/apps/portal/next.config.mjs +++ b/apps/portal/next.config.mjs @@ -3,8 +3,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - // Disable Next DevTools to avoid manifest errors in dev - devtools: { enabled: false }, // Enable standalone output only for production deployment output: process.env.NODE_ENV === "production" ? "standalone" : undefined, @@ -24,13 +22,7 @@ const nextConfig = { ], // Turbopack configuration (Next.js 15.5+) - turbopack: { - // Enable Turbopack optimizations - resolveAlias: { - // Path aliases for cleaner imports - "@": "./src", - }, - }, + // Note: public turbopack options are limited; aliasing is handled via tsconfig/webpack resolutions // Environment variables validation env: { diff --git a/apps/portal/src/app/orders/[id]/page.tsx b/apps/portal/src/app/orders/[id]/page.tsx index 50d49348..6675179e 100644 --- a/apps/portal/src/app/orders/[id]/page.tsx +++ b/apps/portal/src/app/orders/[id]/page.tsx @@ -55,7 +55,7 @@ const getDetailedStatusInfo = ( activationType?: string, scheduledAt?: string ): StatusInfo => { - if (status === "Activated") { + if (activationStatus === "Activated") { return { label: "Service Active", color: "text-green-800", @@ -95,7 +95,7 @@ const getDetailedStatusInfo = ( }; } - if (activationStatus === "In Progress") { + if (activationStatus === "Activating") { return { label: "Setting Up Service", color: "text-purple-800", diff --git a/apps/portal/src/app/orders/page.tsx b/apps/portal/src/app/orders/page.tsx index 737f0db7..688010d7 100644 --- a/apps/portal/src/app/orders/page.tsx +++ b/apps/portal/src/app/orders/page.tsx @@ -80,7 +80,7 @@ export default function OrdersPage() { const getStatusInfo = (status: string, activationStatus?: string): StatusInfo => { // Combine order status and activation status for better UX - if (status === "Activated") { + if (activationStatus === "Activated") { return { label: "Active", color: "text-green-800", @@ -99,7 +99,7 @@ export default function OrdersPage() { }; } - if (activationStatus === "Scheduled" || activationStatus === "In Progress") { + if (activationStatus === "Activating") { return { label: "Setting Up", color: "text-orange-800", diff --git a/docs/CLEAN-ARCHITECTURE-SUMMARY.md b/docs/CLEAN-ARCHITECTURE-SUMMARY.md index 3b5744ea..1aff98a3 100644 --- a/docs/CLEAN-ARCHITECTURE-SUMMARY.md +++ b/docs/CLEAN-ARCHITECTURE-SUMMARY.md @@ -24,7 +24,7 @@ hasPaymentMethod(clientId: number): Promise ``` ### **2. Order Provisioning Service** -**File**: `/apps/bff/src/orders/services/order-provisioning.service.ts` +**File**: `/apps/bff/src/orders/services/order-fulfillment.service.ts` - **Purpose**: Orchestrates the complete provisioning flow - **Features**: @@ -37,15 +37,14 @@ hasPaymentMethod(clientId: number): Promise **Complete Flow**: 1. Validate SF Order → 2. Check Payment Method → 3. Map OrderItems → 4. Create WHMCS Order → 5. Accept WHMCS Order → 6. Update Salesforce -### **3. Separate Salesforce Provisioning Controller** -**File**: `/apps/bff/src/orders/controllers/salesforce-provisioning.controller.ts` +### **3. Salesforce Platform Events Subscriber** +**File**: `/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts` -- **Purpose**: Dedicated controller for Salesforce webhook calls +- **Purpose**: Subscribes to `OrderProvisionRequested__e` and enqueues provisioning jobs - **Features**: - - Enhanced security (HMAC, timestamps, nonces) - - Comprehensive API documentation - - Proper error responses - - Separated from customer-facing order operations + - Durable replay (stores last `replayId` in Redis) + - Decoupled from HTTP/webhooks + - Enqueues to BullMQ `provisioning` queue ### **4. Clean Order Controller** **File**: `/apps/bff/src/orders/orders.controller.ts` @@ -64,11 +63,11 @@ hasPaymentMethod(clientId: number): Promise ## 🔄 **The Complete Flow** ``` -1. Salesforce Quick Action → POST /orders/{sfOrderId}/provision +1. Salesforce Flow publishes OrderProvisionRequested__e ↓ -2. SalesforceProvisioningController (security validation) +2. PlatformEventsSubscriber enqueues provisioning job ↓ -3. OrderProvisioningService (orchestration) +3. OrderFulfillmentOrchestrator (orchestration) ↓ 4. WhmcsOrderService (WHMCS operations) ↓ @@ -108,7 +107,7 @@ The system now properly handles the Salesforce → WHMCS mapping as specified in - ✅ **AcceptOrder**: Provisions services and creates subscriptions - ✅ **Payment validation**: Checks client has payment method - ✅ **Error handling**: Updates Salesforce on failures -- ✅ **Idempotency**: Prevents duplicate provisioning +- ✅ **Idempotency**: Prevents duplicate fulfillment ## 🎯 **Benefits of New Architecture** @@ -116,15 +115,15 @@ The system now properly handles the Salesforce → WHMCS mapping as specified in - **Single Responsibility**: Each service has one clear purpose - **Separation of Concerns**: WHMCS logic separate from Salesforce logic - **Testability**: Each service can be tested independently -- **Extensibility**: Easy to add new provisioning steps +- **Extensibility**: Easy to add new fulfillment steps ### **Security**: -- **Dedicated Controller**: Focused security for Salesforce webhooks -- **Enhanced Guards**: HMAC, timestamp, nonce validation +- **Event-Driven**: No inbound Salesforce webhooks; Platform Events subscription with JWT auth +- **Scoped Access**: Pub/Sub API via Connected App (JWT) - **Clean Error Handling**: No sensitive data exposure ### **Reliability**: -- **Idempotency**: Safe retries for provisioning +- **Idempotency**: Safe retries for fulfillment - **Comprehensive Logging**: Full audit trail - **Error Recovery**: Proper Salesforce status updates on failures @@ -138,10 +137,10 @@ The system now properly handles the Salesforce → WHMCS mapping as specified in ### **2. Testing**: ```typescript -// Test the complete flow -describe('Order Provisioning', () => { - it('should provision SF order in WHMCS', async () => { - // Test complete flow from SF webhook to WHMCS provisioning +// Test the event-driven flow +describe('Order Provisioning (Platform Events)', () => { + it('enqueues a job when an event arrives', async () => { + // Simulate OrderProvisionRequested__e → assert queue enqueue }); }); ``` @@ -156,16 +155,18 @@ describe('Order Provisioning', () => { ``` apps/bff/src/ ├── orders/ -│ ├── controllers/ -│ │ └── salesforce-provisioning.controller.ts # NEW: Dedicated SF webhook +│ ├── queue/ +│ │ ├── provisioning.queue.ts # Enqueue jobs +│ │ └── provisioning.processor.ts # Worker runs orchestrator │ ├── services/ -│ │ ├── order-orchestrator.service.ts # CLEANED: Order creation only -│ │ └── order-provisioning.service.ts # NEW: Provisioning orchestration -│ └── orders.controller.ts # CLEANED: Customer operations only +│ │ ├── order-orchestrator.service.ts # Order creation only +│ │ └── order-fulfillment-orchestrator.service.ts # Provisioning orchestration +│ └── orders.controller.ts # Customer operations only ├── vendors/ +│ ├── salesforce/ +│ │ └── events/pubsub.subscriber.ts # Subscribes via Pub/Sub gRPC │ └── whmcs/ -│ └── services/ -│ └── whmcs-order.service.ts # NEW: WHMCS order operations +│ └── services/whmcs-order.service.ts # WHMCS order operations ``` This architecture is now **clean, maintainable, and production-ready** with proper separation of concerns and comprehensive WHMCS integration! 🎉 diff --git a/docs/IMPLEMENTATION-SUMMARY.md b/docs/IMPLEMENTATION-SUMMARY.md index 8e85b9f2..0844c3c0 100644 --- a/docs/IMPLEMENTATION-SUMMARY.md +++ b/docs/IMPLEMENTATION-SUMMARY.md @@ -9,11 +9,10 @@ I've cleanly integrated secure Salesforce-to-Portal communication into your exis - **Added**: `getOrder()` method for order validation - **Integration**: Works with your existing Salesforce connection -### 2. **Secured Orders Controller** -- **Enhanced**: Existing `/orders/:sfOrderId/provision` endpoint -- **Added**: `EnhancedWebhookSignatureGuard` for HMAC signature validation -- **Added**: Proper API documentation and error handling -- **Security**: Timestamp, nonce, and idempotency key validation +### 2. **Event-Driven Provisioning** +- **Added**: Salesforce Platform Events subscriber (OrderProvisionRequested__e) +- **Added**: BullMQ provisioning queue + processor +- **Behavior**: Subscribes to SF, enqueues job, runs orchestrator, updates SF ### 3. **Updated OrderOrchestrator** - **Added**: `provisionOrderFromSalesforce()` method for the real provisioning flow @@ -21,94 +20,35 @@ I've cleanly integrated secure Salesforce-to-Portal communication into your exis - **Features**: Idempotency, error handling, direct Salesforce updates - **Logging**: Comprehensive audit trail without sensitive data -## 🔄 The Simple Flow +## 🔄 The Flow (Async) ``` -1. Salesforce Quick Action → POST /orders/{sfOrderId}/provision (with HMAC security) -2. Portal BFF validates → Provisions in WHMCS → DIRECTLY updates Salesforce Order +1. Salesforce Flow sets `Activation_Status__c = Activating` and publishes OrderProvisionRequested__e on approval +2. Portal BFF subscribes → guards on `Activation_Status__c = Activating` → enqueues job → provisions in WHMCS → updates Salesforce Order 3. Customer polls Portal → Gets updated order status ``` -**No reverse webhooks needed!** The Portal directly updates Salesforce via your existing API connection. - ## 🔒 Security Features -- **HMAC SHA-256 signature verification** (using your existing guard pattern) -- **Timestamp validation** (5-minute tolerance) -- **Nonce verification** (prevents replay attacks) -- **Idempotency keys** (safe retries) -- **IP allowlisting** (Salesforce IP ranges) +- **JWT auth to Salesforce** (Connected App, private key JWT) +- **Platform Event permissions** (Permission Set) for Platform Events +- **Idempotency keys** (via event field `IdemKey__c`, safe retries) - **Comprehensive logging** (no sensitive data exposure) ## 📝 Next Steps ### 1. Salesforce Setup -Create this Apex class for the Quick Action: - -```apex -public class OrderProvisioningService { - private static final String WEBHOOK_SECRET = '{!$Credential.Portal_Webhook.Password}'; - - @future(callout=true) - public static void provisionOrder(String orderId) { - try { - Map payload = new Map{ - 'orderId' => orderId, - 'timestamp' => System.now().format('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''), - 'nonce' => generateNonce() - }; - - String jsonPayload = JSON.serialize(payload); - String signature = generateHMACSignature(jsonPayload, WEBHOOK_SECRET); - - HttpRequest req = new HttpRequest(); - req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision'); - req.setMethod('POST'); - req.setHeader('Content-Type', 'application/json'); - req.setHeader('X-SF-Signature', signature); - req.setHeader('X-SF-Timestamp', payload.get('timestamp').toString()); - req.setHeader('X-SF-Nonce', payload.get('nonce').toString()); - req.setHeader('Idempotency-Key', 'provision_' + orderId + '_' + System.now().getTime()); - req.setBody(jsonPayload); - req.setTimeout(30000); - - Http http = new Http(); - HttpResponse res = http.send(req); - - if (res.getStatusCode() != 200) { - throw new Exception('Portal returned: ' + res.getStatusCode()); - } - - } catch (Exception e) { - updateOrderStatus(orderId, 'Failed', e.getMessage()); - } - } - - private static String generateHMACSignature(String data, String key) { - Blob hmacData = Crypto.generateMac('HmacSHA256', Blob.valueOf(data), Blob.valueOf(key)); - return EncodingUtil.convertToHex(hmacData); - } - - private static String generateNonce() { - return EncodingUtil.convertToHex(Crypto.generateAesKey(128)).substring(0, 16); - } - - private static void updateOrderStatus(String orderId, String status, String errorMessage) { - Order ord = [SELECT Id FROM Order WHERE Id = :orderId LIMIT 1]; - ord.Provisioning_Status__c = status; - if (errorMessage != null) { - ord.Provisioning_Error_Message__c = errorMessage.left(255); - } - update ord; - } -} +``` +Platform Event: OrderProvisionRequested__e (fields: OrderId__c [Text 18], IdemKey__c [Text 80, optional]) +Permission Set: grant Platform Event permissions and PE object read to integration user +Flow (Record‑Triggered): On Order Status = Approved → Set `Activation_Status__c = Activating` → Create OrderProvisionRequested__e ``` ### 2. Environment Variables ```bash -SF_WEBHOOK_SECRET=your_256_bit_secret_key_here -SF_WEBHOOK_IP_ALLOWLIST=13.108.0.0/14,204.14.232.0/23 -WEBHOOK_TIMESTAMP_TOLERANCE=300000 +SF_EVENTS_ENABLED=true +SF_PROVISION_EVENT_CHANNEL=/event/OrderProvisionRequested__e +SF_EVENTS_REPLAY=LATEST ``` ### 3. Complete the TODOs @@ -121,8 +61,8 @@ In `OrderOrchestrator.provisionOrderFromSalesforce()`: ## 🎯 Key Benefits ✅ **Clean integration** with your existing architecture -✅ **No reverse webhooks** - direct Salesforce API updates -✅ **Production-ready security** - HMAC, timestamps, idempotency +✅ **No inbound SF webhooks** - event-driven, durable replay +✅ **Production-ready security** - JWT to Salesforce; event idempotency ✅ **Proper error handling** - updates Salesforce on failures ✅ **Comprehensive logging** - audit trail without sensitive data ✅ **Simple customer experience** - polling for status updates diff --git a/docs/MODULAR-PROVISIONING-ARCHITECTURE.md b/docs/MODULAR-PROVISIONING-ARCHITECTURE.md index e32ad660..82a87ef2 100644 --- a/docs/MODULAR-PROVISIONING-ARCHITECTURE.md +++ b/docs/MODULAR-PROVISIONING-ARCHITECTURE.md @@ -14,15 +14,17 @@ I've restructured the provisioning system to **match the exact same clean modula | `OrderBuilder` | `WhmcsOrderMapper` | Transforms/maps data structures | | `OrderItemBuilder` | *(integrated in mapper)* | Handles item-level processing | | `OrderOrchestrator` | `ProvisioningOrchestrator` | Coordinates the complete workflow | -| `OrdersController` | `SalesforceProvisioningController` | HTTP endpoint handling | +| `OrdersController` | `PlatformEventsSubscriber` | Event handling (no inbound HTTP) | ## 📁 **Clean File Structure** ``` apps/bff/src/orders/ ├── controllers/ -│ ├── orders.controller.ts # Customer-facing operations -│ └── salesforce-provisioning.controller.ts # Salesforce webhook operations +│ └── orders.controller.ts # Customer-facing operations +├── queue/ +│ ├── provisioning.queue.ts # Enqueue provisioning jobs +│ └── provisioning.processor.ts # Worker processes jobs ├── services/ │ # Order Creation (existing) │ ├── order-validator.service.ts # Request & business validation @@ -82,8 +84,8 @@ apps/bff/src/orders/ ## 🔄 **The Complete Modular Flow** ``` -SalesforceProvisioningController - ↓ (validates webhook security) +PlatformEventsSubscriber (OrderProvisionRequested__e) + ↓ (enqueues job) OrderProvisioningService ↓ (coordinates workflow) ProvisioningValidator diff --git a/docs/ORDER-FULFILLMENT-COMPLETE-GUIDE.md b/docs/ORDER-FULFILLMENT-COMPLETE-GUIDE.md index 8d8b419d..777bd176 100644 --- a/docs/ORDER-FULFILLMENT-COMPLETE-GUIDE.md +++ b/docs/ORDER-FULFILLMENT-COMPLETE-GUIDE.md @@ -13,7 +13,7 @@ ### Data Flow ``` Customer → Portal → BFF → Salesforce (Order Creation) -CS Team → Salesforce → BFF → WHMCS (Order Fulfillment) +CS Team → Salesforce (Platform Event) → BFF (Subscriber) → WHMCS (Order Fulfillment) ``` ## 🛍️ Complete Customer Journey @@ -104,25 +104,14 @@ FROM Order WHERE Id = '8014x000000ABCDXYZ' ``` -#### 6. Provision Trigger -```javascript -// Salesforce Quick Action calls BFF -// Named Credential: Portal_BFF_Endpoint -// Endpoint: https://portal-api.company.com/orders/{!Order.Id}/fulfill - -POST /orders/8014x000000ABCDXYZ/fulfill -Headers: { - "X-SF-Signature": "sha256=a1b2c3d4e5f6...", - "X-SF-Timestamp": "2024-01-15T10:30:00Z", - "X-SF-Nonce": "abc123def456", - "Idempotency-Key": "provision_8014x000000ABCDXYZ_1705312200000" -} -Body: { - "orderId": "8014x000000ABCDXYZ", - "timestamp": "2024-01-15T10:30:00Z", - "nonce": "abc123def456" -} +#### 6. Provision Trigger (Platform Events) +```text +Salesforce Record‑Triggered Flow publishes Platform Event: OrderProvisionRequested__e +Fields: +- OrderId__c (Text 18) +- IdemKey__c (Text 80, optional) ``` +The portal subscribes to this event, enqueues a job, and performs provisioning. ### Phase 3: Order Fulfillment @@ -312,22 +301,8 @@ class OrderFulfillmentOrchestrator { ## 🔒 Security Implementation -### Webhook Security Headers -```typescript -// Required headers for Salesforce → BFF webhook -{ - "X-SF-Signature": "sha256=HMAC-SHA256(secret, body)", - "X-SF-Timestamp": "2024-01-15T10:30:00Z", - "X-SF-Nonce": "unique_random_string", - "Idempotency-Key": "provision_{orderId}_{timestamp}" -} - -// Validation rules -├── Signature: HMAC-SHA256 verification with shared secret -├── Timestamp: Max 5 minutes old -├── Nonce: Stored to prevent replay attacks -└── Idempotency: Prevents duplicate provisioning -``` +- No inbound Salesforce webhooks are used; provisioning is triggered via Platform Events. +- Portal authenticates to Salesforce via JWT (Connected App) and requires Platform Event permissions. ### Error Codes ```typescript @@ -344,17 +319,17 @@ enum FulfillmentErrorCode { ### Typical Timeline ``` -10:30:00.000 - CS clicks "Provision Order" -10:30:00.100 - Webhook received and validated +10:30:00.000 - CS approves Order +10:30:00.050 - Platform Event published (OrderProvisionRequested__e) +10:30:00.080 - BFF subscriber enqueues provisioning job 10:30:00.200 - Salesforce order updated to "Activating" 10:30:00.500 - Order details retrieved and mapped 10:30:01.000 - WHMCS AddOrder API call 10:30:01.500 - WHMCS AcceptOrder API call 10:30:02.000 - Services provisioned in WHMCS 10:30:02.200 - Salesforce updated to "Activated" -10:30:02.300 - Response sent to Salesforce -Total fulfillment time: ~2.3 seconds ⚡ +Total fulfillment time: ~2.2 seconds (asynchronous trigger) ⚡ ``` ### API Call Performance @@ -366,45 +341,20 @@ Total fulfillment time: ~2.3 seconds ⚡ ## 🔧 Configuration Requirements ### Salesforce Setup -```apex -// Quick Action configuration -Global class OrderProvisioningQuickAction { - @InvocableMethod(label='Provision Order' description='Provision order in WHMCS') - public static void provisionOrder(List orderIds) { - for (Id orderId : orderIds) { - HttpRequest req = new HttpRequest(); - req.setEndpoint('callout:Portal_BFF_Endpoint/orders/' + orderId + '/fulfill'); - req.setMethod('POST'); - req.setHeader('Content-Type', 'application/json'); - req.setHeader('X-SF-Signature', generateHmacSignature(orderId)); - req.setHeader('X-SF-Timestamp', Datetime.now().formatGmt('yyyy-MM-dd\'T\'HH:mm:ss\'Z\'')); - req.setHeader('X-SF-Nonce', generateNonce()); - req.setHeader('Idempotency-Key', 'provision_' + orderId + '_' + Datetime.now().getTime()); - - req.setBody(JSON.serialize(new Map{ - 'orderId' => orderId, - 'timestamp' => Datetime.now().formatGmt('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''), - 'nonce' => generateNonce() - })); - - Http http = new Http(); - HttpResponse res = http.send(req); - } - } -} ``` - -### Named Credential -``` -Name: Portal_BFF_Endpoint -URL: https://portal-api.company.com -Authentication: Custom (with HMAC signing) +1) Platform Event: OrderProvisionRequested__e (fields: OrderId__c [Text 18], IdemKey__c [Text 80, optional]) +2) Permission Set: grant Platform Event permissions and PE object read to the portal integration user +3) Flow (Record‑Triggered): On Order Status = Approved → Create OrderProvisionRequested__e with OrderId__c ``` ### Environment Variables ```bash -# BFF Configuration -SALESFORCE_WEBHOOK_SECRET=your_hmac_secret_key +# BFF Configuration (Salesforce Platform Events) +SF_EVENTS_ENABLED=true +SF_PROVISION_EVENT_CHANNEL=/event/OrderProvisionRequested__e +SF_EVENTS_REPLAY=LATEST + +# WHMCS API WHMCS_API_IDENTIFIER=your_whmcs_api_id WHMCS_API_SECRET=your_whmcs_api_secret WHMCS_API_URL=https://your-whmcs.com/includes/api.php diff --git a/docs/PORTAL-ORDERING-PROVISIONING.md b/docs/PORTAL-ORDERING-PROVISIONING.md index de3e7152..aeb6da7c 100644 --- a/docs/PORTAL-ORDERING-PROVISIONING.md +++ b/docs/PORTAL-ORDERING-PROVISIONING.md @@ -30,7 +30,7 @@ We require a Customer Number (SF Number) at signup and gate checkout on the pres - `Order` (header; one per checkout) - `OrderItem` (child; one per selected product) → references `Product2` (and PricebookEntry) - Provisioning - - Operator approves in Salesforce → Quick Action calls BFF → BFF reads Order + OrderItems, dereferences `Product2` portal fields, calls WHMCS `AddOrder` → `AcceptOrder`, then writes back WHMCS IDs to Order/OrderItems + - Operator approves in Salesforce → Record‑Triggered Flow publishes Platform Event → BFF reads Order + OrderItems, dereferences `Product2` portal fields, calls WHMCS `AddOrder` → `AcceptOrder`, then writes back WHMCS IDs to Order/OrderItems ## 1) Customer Experience @@ -52,8 +52,9 @@ We require a Customer Number (SF Number) at signup and gate checkout on the pres - `POST /orders` creates a Salesforce Order (Pending Review) and stores orchestration state in BFF. Portal shows “Awaiting review”. 5. Review & Provision (operator in Salesforce) - - Operator reviews/approves. Quick Action “Provision in WHMCS” invokes BFF `POST /orders/{sfOrderId}/provision`. - - BFF validates payment method, (for eSIM) calls activation API, then `AddOrder` and `AcceptOrder` in WHMCS, updates Salesforce Order fields/status. + - Recommended (async): Operator reviews/approves. A Record-Triggered Flow publishes Platform Event `OrderProvisionRequested__e`. + - BFF subscribes to the event and enqueues a provisioning job. Worker validates payment method, (for eSIM) calls activation API, then `AddOrder` and `AcceptOrder` in WHMCS, updates Salesforce Order fields/status. + - Legacy (webhook): previously called `POST /orders/{sfOrderId}/fulfill`. This path has been removed. 6. Completion - Subscriptions and invoices appear in portal (`/subscriptions`, `/billing/invoices`). Pay via WHMCS SSO links. @@ -64,7 +65,7 @@ We will send operational emails at key events (no email validation step required - Signup success: send Welcome email to customer; CC support. - eSIM activation: send Activation email to customer; CC support. -- Order provisioned: send Provisioned/Next steps email to customer. +- Order activated: send Activated/Next steps email to customer. Implementation notes: @@ -176,7 +177,7 @@ Endpoints (BFF) - Caching & Invalidation - Cache global catalog 15m; cache personalized results per `sfAccountId` 5m. - - Optional Salesforce webhook to bust cache on `Product_Offering__c` changes. + - Optional Salesforce CDC/Platform Event to bust cache on `Product_Offering__c` changes. ### 2.6 Orders & Provisioning @@ -188,14 +189,10 @@ Endpoints (BFF) - `GET /orders/:sfOrderId` (new) - Returns orchestration status and relevant IDs; portal polls for updates. -- `POST /orders/:sfOrderId/provision` (new; invoked from Salesforce only) - - Auth: Named Credentials + signed headers (HMAC with timestamp/nonce) + IP allowlisting; require `Idempotency-Key`. - - Steps: - - Re-check payment method; if missing: set SF `Provisioning_Status__c=Failed`, `Error=Payment Method Missing`; return 409. - - If eSIM: call activation API; on success store masked ICCID/EID; on failure: update SF as Failed and return 502. - - WHMCS `AddOrder` (include `sfOrderId` in notes); then `AcceptOrder` to provision and create invoice/subscription. - - Update Salesforce Order fields and status to Provisioned; persist WHMCS IDs in orchestration record; return summary. - - Send Activation/Provisioned email depending on product and step outcome. +- Async Provisioning (Platform Events) + - Event: `OrderProvisionRequested__e` with `{ OrderId__c, IdemKey__c?, CorrelationId__c? }` + - BFF autosubscribes when `SF_EVENTS_ENABLED=true`; enqueues provisioning job; returns 202 immediately (no inbound SF call). + - Worker performs the same steps as above and updates Salesforce. ## 3) Salesforce @@ -215,11 +212,9 @@ Endpoints (BFF) ### 3.2 Order fields - Add the following fields to `Order`: - - `Provisioning_Status__c` (Pending Review, Approved, Activating, Provisioned, Failed) - - `Provisioning_Error_Code__c` (short) - - `Provisioning_Error_Message__c` (sanitized) + - `Activation_Status__c` (Pending Review, Activating, Provisioned, Failed) - `WHMCS_Order_ID__c` - - `ESIM_ICCID__c` (masked), `Last_Provisioning_At__c`, `Attempt_Count__c` + - Optional (if needed): `ESIM_ICCID__c` #### 3.2.1 Salesforce Order API & Required Fields (to confirm) @@ -227,12 +222,12 @@ Endpoints (BFF) - Required fields for creation (proposal): - `AccountId` (from SF Number lookup) - `EffectiveDate` (today) - - `Status` (set to "Pending Review") + - `Status` (org-specific; code currently sets "Pending Review" — use your org's draft/review value) - `Description` (optional: include product summary) - - Custom: `Provisioning_Status__c = Pending Review` +- Custom: `Activation_Status__c = Not Started` - Optional link: `OpportunityId` (if created/available) - On updates during provisioning: - - Set `Provisioning_Status__c` → Activating → Provisioned/Failed + - Set `Activation_Status__c` → Activating → Activated/Failed - Store `WHMCS_Order_ID__c` - For eSIM: masked `ESIM_ICCID__c` @@ -262,7 +257,7 @@ We will build the BFF payload for WHMCS from these line records plus the Order h - SF `Order.Id` → included in WHMCS `notes` as `sfOrderId=` - SF `AccountId` → via portal mapping to `whmcsClientId` → `AddOrder.clientid` - SF `Promo_Code__c` (if on header) → `AddOrder.promocode` - - SF `Provisioning_Status__c` controls operator flow; not sent to WHMCS + - SF `Activation_Status__c` controls operator flow; not sent to WHMCS - Line mapping (per OrderItem) - Product2 `WHMCS_Product_Id__c` → `AddOrder.pid[]` @@ -272,15 +267,12 @@ We will build the BFF payload for WHMCS from these line records plus the Order h - After `AddOrder`: - Call `AcceptOrder` to provision; capture `orderid` from response - - Update SF `WHMCS_Order_ID__c`; set `Provisioning_Status__c = Provisioned` on success - - On error, set `Provisioning_Status__c = Failed` and write short, sanitized `Provisioning_Error_Code__c` / `Provisioning_Error_Message__c` + - Update SF `WHMCS_Order_ID__c`; set `Activation_Status__c = Activated` on success + - On error, set `Activation_Status__c = Failed` -### 3.3 Quick Action / Flow +### 3.3 Flow (Provisioning Trigger) -- Quick Action “Provision in WHMCS” calls BFF `POST /orders/{sfOrderId}/provision` with headers: - - `Authorization` (Named Credentials) - - `Idempotency-Key` (UUID) - - `X-Timestamp`, `X-Nonce`, `X-Signature` (HMAC of method+path+timestamp+nonce+body) +- Record‑Triggered Flow publishes `OrderProvisionRequested__e` on Order approval. ### 3.4 UI @@ -292,7 +284,7 @@ We will build the BFF payload for WHMCS from these line records plus the Order h - Snapshot only what can change over time: `UnitPrice` and `Quantity` on the line. - Order construction (by portal at checkout) - - Create `Order` header with `Provisioning_Status__c = Pending Review`. +- Create `Order` header with `Activation_Status__c = Not Started`. - For each cart item, create a line (either `OrderItem` with custom fields or `Order_Offering__c`) that includes: - `Product2Id` and `PricebookEntryId` - `Quantity`, `UnitPrice__c` @@ -305,7 +297,7 @@ We will build the BFF payload for WHMCS from these line records plus the Order h - Build `AddOrder` payload using the mapping above; place `sfOrderId` in WHMCS `notes`. - After `AcceptOrder`, write back: - Header: `WHMCS_Order_ID__c` - - Header: `Provisioning_Status__c = Provisioned` on success; set error fields on failure (sanitized) + - Header: `Activation_Status__c = Activated` on success - Subscriptions linkage - The authoritative subscription record lives in WHMCS. @@ -317,8 +309,8 @@ This keeps the mapping clean and centralized in Product2 portal fields, while Or 1. Catalog comes from Salesforce Product2 (filtered/personalized by Account eligibility). 2. Customer signs up with SF Number; portal creates WHMCS client and mapping; address/profile managed in WHMCS. 3. Checkout creates an SF `Order` and child lines (no provisioning yet). -4. Operator approves in SF and clicks Quick Action. -5. SF calls BFF to provision: BFF rechecks payment method (WHMCS), handles eSIM activation if needed, then `AddOrder` + `AcceptOrder` in WHMCS using mappings from Product2 portal fields referenced by the OrderItems. +4. Operator approves in SF; Flow publishes Platform Event. +5. BFF subscriber enqueues job and provisions: recheck payment method (WHMCS), handle eSIM activation if needed, then `AddOrder` + `AcceptOrder` in WHMCS using mappings from Product2 portal fields referenced by the OrderItems. 6. BFF updates SF Order fields (`WHMCS_Order_ID__c`, etc.) and status; emails are sent as required. 7. Customer sees completed order; subscriptions/invoices appear from WHMCS data in the portal. @@ -332,7 +324,7 @@ This keeps the mapping clean and centralized in Product2 portal fields, while Or - Product detail + Checkout: - Checkout button disabled until `hasPaymentMethod=true` (via `GET /billing/payment-methods/summary`). - On submit, call `POST /orders` and redirect to order status page with polling. -- Order status page: shows statuses (Pending Review → Activating → Provisioned/Failed), with links to Subscriptions and Invoices. +- Order status page: shows statuses (Not Started → Activating → Activated/Failed), with links to Subscriptions and Invoices. ### 4.1 eSIM Self-service Actions (Service Detail) @@ -367,11 +359,11 @@ Prerequisites for WHMCS provisioning 1. Auth: require `sfNumber` in `SignupDto` and signup flow; lookup SF Account by Customer Number; align WHMCS custom field. 2. Billing: add `GET /billing/payment-methods/summary` and frontend gating. 3. Catalog UI: `/catalog` + product details pages. -4. Orders API: implement `POST /orders`, `GET /orders/:sfOrderId`, `POST /orders/:sfOrderId/provision`. -5. Salesforce: fields, Quick Action/Flow, Named Credential + signing; LWC for status. +4. Orders API: implement `POST /orders`, `GET /orders/:sfOrderId`. +5. Salesforce: fields, Record‑Triggered Flow to publish `OrderProvisionRequested__e`; LWC for status. 6. WHMCS: add wrappers for `AddOrder`, `AcceptOrder`, `GetPayMethods` (if not already exposed). -7. Observability: correlation IDs, metrics, alerts; webhook processing for cache busting (optional). -8. Email: implement `EmailService` with provider; add BullMQ jobs for async sending; add templates for Signup, eSIM Activation, Provisioned. +7. Observability: correlation IDs, metrics, alerts; CDC or Platform Events for cache busting (optional). +8. Email: implement `EmailService` with provider; add BullMQ jobs for async sending; add templates for Signup, eSIM Activation, Activated. 9. eSIM Actions: implement `POST /subscriptions/:id/reissue-esim` and `POST /subscriptions/:id/topup` endpoints with BFF provider calls and WHMCS updates. 10. Future: Cancellations form → Salesforce Cancellations object submission (no immediate service cancel by customer). @@ -421,9 +413,7 @@ Prerequisites for WHMCS provisioning - `GET /orders/:sfOrderId` - Response: `{ sfOrderId, status, whmcsOrderId?, whmcsServiceIds?: number[], lastUpdatedAt }` -- `POST /orders/:sfOrderId/provision` (SF only) - - Request headers: `Authorization`, `Idempotency-Key`, `X-Timestamp`, `X-Nonce`, `X-Signature` - - Response: `{ status: 'Provisioned' | 'Failed', whmcsOrderId?, whmcsServiceIds?: number[], errorCode?, errorMessage? }` +// No SF → portal endpoint is required; provisioning is triggered via Platform Events. - `POST /subscriptions/:id/reissue-esim` - Request: `{ reason?: string }` @@ -466,7 +456,7 @@ Prerequisites for WHMCS provisioning - Endpoint(s), auth scheme, required payload, success/failed response shapes. - Which identifiers to store/mask (ICCID, EID, MSISDN) and masking rules. 5. Provisioning Trigger - - Manual only (Quick Action) or also auto on status change to Approved? + - Trigger via Flow on status change to Approved; optional manual retry publishes event again. - Retry/backoff limits expected from SF side? 6. Cancellations - Cancellation object API name in Salesforce; required fields; desired intake fields in portal form; who should be notified. diff --git a/docs/PORTAL-ROADMAP.md b/docs/PORTAL-ROADMAP.md index ade9a56c..812be565 100644 --- a/docs/PORTAL-ROADMAP.md +++ b/docs/PORTAL-ROADMAP.md @@ -9,7 +9,7 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar - Pricebook: create "Portal" pricebook; add `PricebookEntry` records for visible Product2 items - Order fields: add `Provisioning_*`, `WHMCS_*`, `ESIM_ICCID__c`, `Attempt_Count__c`, `Last_Provisioning_At__c` - OrderItem fields: add `Billing_Cycle__c`, `ConfigOptions_JSON__c`, `WHMCS_Service_ID__c` - - Quick Action: "Provision in WHMCS" to call BFF; configure Named Credentials + HMAC headers + - Platform Event: `OrderProvisionRequested__e`; Flow publishes on Order approval 2. WHMCS setup (Admin) - Create custom field on Client for Customer Number (note id/name). @@ -18,7 +18,7 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar 3. Portal BFF env & security - Ensure env vars for Salesforce/WHMCS and logging are set; rotate secrets. - - Enable IP allowlisting for Salesforce → BFF; implement HMAC shared secret. + - Enable Platform Events subscriber (`SF_EVENTS_ENABLED=true`); no inbound SF allowlisting required. ## Phase 2 – Identity & Billing @@ -48,10 +48,10 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar 8. BFF: Orders API - `POST /orders`: create SF Order + OrderItems (snapshots: Quantity, UnitPrice, Billing_Cycle, ConfigOptions), status Pending Review; return `sfOrderId`. - `GET /orders/:sfOrderId`: return orchestration status. - - `POST /orders/:sfOrderId/provision`: SF-only; recheck payment method; (eSIM) activate; WHMCS AddOrder → AcceptOrder; update SF with IDs/status; send emails. + - Async Provisioning: triggered by Platform Event `OrderProvisionRequested__e`; worker rechecks payment method; (eSIM) activate; WHMCS AddOrder → AcceptOrder; update SF with IDs/status; send emails. -9. Salesforce: Quick Action/Flow - - Implement button action to call BFF with Named Credentials + HMAC; pass Idempotency-Key. +9. Salesforce: Record-Triggered Flow + - On Order status = Approved, publish `OrderProvisionRequested__e` with `OrderId__c` and optional `IdemKey__c`. 10. Portal UI: Checkout & status @@ -78,7 +78,7 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar 14. Idempotency & resilience - Cart hash idempotency for `POST /orders`. -- Idempotency-Key for `POST /orders/:sfOrderId/provision`. +- Idempotency on Platform Event (`IdemKey__c`); dedupe in worker. - Include `sfOrderId` in WHMCS `notes` for duplicate protection. 15. Security reviews @@ -91,6 +91,6 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar - WHMCS Client custom field created; product IDs confirmed - BFF endpoints implemented (auth/billing/catalog/orders/esim) - Portal pages implemented (signup/address/catalog/detail/checkout/status) -- Quick Action wired and tested end-to-end +- Platform Event Flow wired and tested end-to-end - Emails tested in dev/staging - Monitoring and alerts configured diff --git a/docs/RUNBOOK_PROVISIONING.md b/docs/RUNBOOK_PROVISIONING.md new file mode 100644 index 00000000..d9322501 --- /dev/null +++ b/docs/RUNBOOK_PROVISIONING.md @@ -0,0 +1,74 @@ +# Provisioning Runbook (Salesforce Platform Events → Portal → WHMCS) + +This runbook helps operators diagnose issues in the order fulfillment path. + +## Paths & Channels + +- Salesforce Platform Event: `OrderProvisionRequested__e` +- Backend health: `GET /health` + +## Required Env (Backend) + +- `SF_LOGIN_URL`, `SF_CLIENT_ID`, `SF_USERNAME` +- `SF_PRIVATE_KEY_PATH` (prod: `/app/secrets/sf-private.key`) +- `SF_EVENTS_ENABLED=true` +- `SF_PROVISION_EVENT_CHANNEL=/event/OrderProvisionRequested__e` +- `SF_EVENTS_REPLAY=LATEST` (or `ALL`) +- `PORTAL_PRICEBOOK_ID` + +## Common Symptoms and Fixes + +- No events received + - Verify Flow publishes `OrderProvisionRequested__e` on Order approval + - Confirm the BFF has `SF_EVENTS_ENABLED=true` and valid SF JWT settings + - Check BFF logs for subscription start on the expected channel + +- Event replays not advancing + - Ensure Redis is healthy; last `replayId` is stored under `sf:pe:replay:` + - If needed, set `SF_EVENTS_REPLAY=ALL` for a one-time backfill, then revert to `LATEST` + +- 409 Payment method missing + - Customer has no WHMCS payment method + - Ask customer to add a payment method; retry fulfill + +- WHMCS Add/Accept errors + - Check product mappings: `Product2.WH_Product_ID__c` and `Billing_Cycle__c` + - Backend logs show the item mapping report; fix missing mappings + +- Salesforce status not updated + - Backend updates `Activation_Status__c` and `WHMCS_Order_ID__c` on success + - Verify connected app JWT config and that the API user has Order update permissions + +## Verification Steps + +1. In SF, create an Order with OrderItems +2. Approve Order → Flow sets `Activation_Status__c = Activating` and publishes `OrderProvisionRequested__e` +3. Check `/health`: database/redis connected, environment correct +4. Tail logs; confirm: Platform Event enqueued → Guard sees status=Activating → WHMCS add → WHMCS accept → Activated +5. Verify SF fields updated and WHMCS order/service IDs exist + +## Logging Cheatsheet + +- "Platform Event enqueued for provisioning" — subscriber enqueue +- "Starting fulfillment orchestration" — orchestrator start +- Step logs: `validation`, `sf_status_update`, `order_details`, `mapping`, `whmcs_create`, `whmcs_accept`, `sf_success_update` +- On error: orchestrator updates SF with `Activation_Status__c='Failed'` + +## Security Notes + +- No inbound Salesforce webhooks are used for provisioning. +- BFF authenticates to Salesforce via JWT; grant API access and Platform Event object read via Permission Set. +- No WHMCS webhooks are consumed; the portal uses the WHMCS API for billing operations. + +- Health endpoint + - `/health` includes `integrations.redis` probe to confirm queue/replay storage availability. + +## Ops: Manual Retry Flow + +- Click "Provision / Retry" on the Order in Salesforce. + - If `Activation_Status__c = Activating`, show a toast "Already in progress". + - Else, set `Activation_Status__c = Activating`, clear last error fields, and let the Record‑Triggered Flow publish the event. + +Portal does not auto-retry jobs. Network/5xx/timeouts will mark the Order Failed with: +- `Activation_Error_Code__c` (e.g., 429, 503, ETIMEOUT) +- `Activation_Error_Message__c` (short reason) diff --git a/docs/SALESFORCE-ORDER-COMMUNICATION.md b/docs/SALESFORCE-ORDER-COMMUNICATION.md index 54ef64bd..e6bbf0e4 100644 --- a/docs/SALESFORCE-ORDER-COMMUNICATION.md +++ b/docs/SALESFORCE-ORDER-COMMUNICATION.md @@ -1,5 +1,7 @@ # Salesforce-to-Portal Order Communication Guide +Note: 2025 update — Async-first via Salesforce Platform Events is now the recommended pattern. The legacy webhook path remains referenced only historically and should be phased out. + ## Overview This guide focuses specifically on **secure communication between Salesforce and your Portal for order provisioning**. This is NOT about invoices or billing - it's about the order approval and provisioning workflow. @@ -8,237 +10,78 @@ This guide focuses specifically on **secure communication between Salesforce and ``` 1. Customer places order → Portal creates Salesforce Order (Status: "Pending Review") -2. Salesforce operator reviews → Clicks "Provision in WHMCS" Quick Action -3. Salesforce calls Portal BFF → POST /orders/{sfOrderId}/provision -4. Portal BFF provisions in WHMCS → Updates Salesforce Order status +2. Salesforce operator approves Order +3. Salesforce Flow publishes OrderProvisionRequested__e +4. Portal subscriber enqueues a job → provisions in WHMCS → updates Salesforce Order status 5. Customer sees updated status in Portal ``` ## 1. Salesforce → Portal (Order Provisioning) -### Current Implementation ✅ +### Recommended (2025): Async via Platform Events ✅ -Your existing architecture already handles this securely via the **Quick Action** that calls your BFF endpoint: +High-level flow -- **Endpoint**: `POST /orders/{sfOrderId}/provision` -- **Authentication**: Named Credentials + HMAC signature -- **Security**: IP allowlisting, idempotency keys, signed headers - -### Enhanced Security Implementation - -Use your existing `EnhancedWebhookSignatureGuard` for the provisioning endpoint: - -```typescript -// apps/bff/src/orders/orders.controller.ts -@Post(':sfOrderId/provision') -@UseGuards(EnhancedWebhookSignatureGuard) -@ApiHeader({ name: "X-SF-Signature", description: "Salesforce HMAC signature" }) -@ApiHeader({ name: "X-SF-Timestamp", description: "Request timestamp" }) -@ApiHeader({ name: "X-SF-Nonce", description: "Unique nonce" }) -@ApiHeader({ name: "Idempotency-Key", description: "Idempotency key" }) -async provisionOrder( - @Param('sfOrderId') sfOrderId: string, - @Body() payload: ProvisionOrderRequest, - @Headers('idempotency-key') idempotencyKey: string -) { - return await this.orderOrchestrator.provisionOrder(sfOrderId, payload, idempotencyKey); -} +``` +1. Operator marks Order Approved in Salesforce +2. Record-Triggered Flow publishes Order_Fulfilment_Requested__e +3. Portal subscribes via Pub/Sub API (gRPC) and enqueues a provisioning job +4. Portal provisions in WHMCS and updates Salesforce Order status +5. Portal UI polls BFF for status (unchanged) ``` -### Salesforce Apex Implementation +Salesforce Setup -```apex -public class OrderProvisioningService { - private static final String WEBHOOK_SECRET = '{!$Credential.Portal_Webhook.Password}'; - - @future(callout=true) - public static void provisionOrder(String orderId) { - try { - // Create secure payload - Map payload = new Map{ - 'orderId' => orderId, - 'timestamp' => System.now().format('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''), - 'nonce' => generateNonce() - }; - - String jsonPayload = JSON.serialize(payload); - String signature = generateHMACSignature(jsonPayload, WEBHOOK_SECRET); - - // Make secure HTTP call - HttpRequest req = new HttpRequest(); - req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision'); - req.setMethod('POST'); - req.setHeader('Content-Type', 'application/json'); - req.setHeader('X-SF-Signature', signature); - req.setHeader('X-SF-Timestamp', payload.get('timestamp').toString()); - req.setHeader('X-SF-Nonce', payload.get('nonce').toString()); - req.setHeader('Idempotency-Key', 'provision_' + orderId + '_' + System.now().getTime()); - req.setBody(jsonPayload); - req.setTimeout(30000); - - Http http = new Http(); - HttpResponse res = http.send(req); - - handleProvisioningResponse(orderId, res); - - } catch (Exception e) { - updateOrderStatus(orderId, 'Failed', e.getMessage()); - } - } - - private static String generateHMACSignature(String data, String key) { - Blob hmacData = Crypto.generateMac('HmacSHA256', Blob.valueOf(data), Blob.valueOf(key)); - return EncodingUtil.convertToHex(hmacData); - } - - private static String generateNonce() { - return EncodingUtil.convertToHex(Crypto.generateAesKey(128)).substring(0, 16); - } - - private static void handleProvisioningResponse(String orderId, HttpResponse res) { - if (res.getStatusCode() == 200) { - updateOrderStatus(orderId, 'Provisioned', null); - } else { - updateOrderStatus(orderId, 'Failed', 'HTTP ' + res.getStatusCode()); - } - } - - private static void updateOrderStatus(String orderId, String status, String errorMessage) { - Order ord = [SELECT Id FROM Order WHERE Id = :orderId LIMIT 1]; - ord.Provisioning_Status__c = status; - if (errorMessage != null) { - ord.Provisioning_Error_Message__c = errorMessage.left(255); // Truncate if needed - } - update ord; - } -} -``` +- Create High-Volume Platform Event: `Order_Fulfilment_Requested__e` + - Fields (example API names): + - `OrderId__c` (Text 18) — required + - `IdemKey__c` (Text 80) — optional (Idempotency key) + - `CorrelationId__c` (Text 80) — optional + - `RequestedBy__c` (Text 80) — optional + - `Version__c` (Number) — optional +- Flow: Record-Triggered on Order when Status changes to Approved + - Actions: + - Update `Activation_Status__c = Activating` + - Create `OrderProvisionRequested__e` and set `OrderId__c = $Record.Id` + - Optionally clear activation error fields -## 2. Optional: Portal → Salesforce (Status Updates) +Portal Setup -If you want to send status updates back to Salesforce during provisioning, you can implement a reverse webhook: +- Env + - `SF_EVENTS_ENABLED=true` + - `SF_PROVISION_EVENT_CHANNEL=/event/OrderProvisionRequested__e` + - `SF_EVENTS_REPLAY=LATEST` (or `ALL` to start from earliest retained) +- Subscriber (auto-start, durable replay): `apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts` + - Subscribes to the channel and enqueues a job to the `provisioning` queue +- Worker: `apps/bff/src/orders/queue/provisioning.processor.ts` + - Guards on `Activation_Status__c = Activating`; provisions in WHMCS and then updates Salesforce -### Portal BFF Implementation +Why async: resilient (replay within retention), decoupled, no inbound allowlisting. No auto-retries from the portal; operators retry from Salesforce by flipping status to Activating. -```typescript -// apps/bff/src/vendors/salesforce/services/order-status-update.service.ts -@Injectable() -export class OrderStatusUpdateService { - constructor( - private salesforceConnection: SalesforceConnection, - @Inject(Logger) private logger: Logger - ) {} +### Legacy Implementation (Webhook) — Removed - async updateOrderStatus( - sfOrderId: string, - status: 'Activating' | 'Provisioned' | 'Failed', - details?: { - whmcsOrderId?: string; - errorCode?: string; - errorMessage?: string; - } - ) { - try { - const updateData: any = { - Id: sfOrderId, - Provisioning_Status__c: status, - Last_Provisioning_At__c: new Date().toISOString(), - }; +The old endpoint `POST /orders/{sfOrderId}/fulfill` has been removed. Provisioning is now triggered solely via Platform Events published from Salesforce. - if (details?.whmcsOrderId) { - updateData.WHMCS_Order_ID__c = details.whmcsOrderId; - } +### Enhanced Security Implementation (Legacy Webhook) - if (status === 'Failed' && details?.errorCode) { - updateData.Provisioning_Error_Code__c = details.errorCode; - updateData.Provisioning_Error_Message__c = details.errorMessage?.substring(0, 255); - } +All Quick Actions and Named Credentials previously used to call the portal should be retired. - await this.salesforceConnection.sobject('Order').update(updateData); +### Salesforce Apex Implementation (not needed) +No Apex callout to the portal is required. Use a Record-Triggered Flow to publish the Platform Event. - this.logger.log('Order status updated in Salesforce', { - sfOrderId, - status, - whmcsOrderId: details?.whmcsOrderId, - }); - } catch (error) { - this.logger.error('Failed to update order status in Salesforce', { - sfOrderId, - status, - error: error instanceof Error ? error.message : String(error), - }); - // Don't throw - this is a non-critical update - } - } -} -``` - -### Usage in Order Orchestrator - -```typescript -// In your existing OrderOrchestrator service -async provisionOrder(sfOrderId: string, payload: any, idempotencyKey: string) { - try { - // Update status to "Activating" - await this.orderStatusUpdateService.updateOrderStatus(sfOrderId, 'Activating'); - - // Your existing provisioning logic... - const whmcsOrderId = await this.provisionInWhmcs(sfOrderId, payload); - - // Update status to "Provisioned" with WHMCS order ID - await this.orderStatusUpdateService.updateOrderStatus(sfOrderId, 'Provisioned', { - whmcsOrderId: whmcsOrderId.toString(), - }); - - return { success: true, whmcsOrderId }; - } catch (error) { - // Update status to "Failed" with error details - await this.orderStatusUpdateService.updateOrderStatus(sfOrderId, 'Failed', { - errorCode: 'PROVISIONING_ERROR', - errorMessage: error instanceof Error ? error.message : String(error), - }); - - throw error; - } -} -``` - -## 3. Security Configuration +## 2. Security Configuration ### Environment Variables ```bash -# Salesforce webhook security -SF_WEBHOOK_SECRET=your_256_bit_secret_key_here -SF_WEBHOOK_IP_ALLOWLIST=13.108.0.0/14,204.14.232.0/23 -WEBHOOK_TIMESTAMP_TOLERANCE=300000 # 5 minutes - -# Monitoring -SECURITY_ALERT_WEBHOOK=https://your-monitoring-service.com/alerts +# Platform Events (BFF) +SF_EVENTS_ENABLED=true +SF_PROVISION_EVENT_CHANNEL=/event/OrderProvisionRequested__e +SF_EVENTS_REPLAY=LATEST # or ALL ``` ### Salesforce Named Credential - -```xml - - - Portal_BFF - https://your-portal-api.com - Anonymous - HttpsOnly - false - - - - - Portal_Webhook - https://your-portal-api.com - NamedPrincipal - Legacy - your_256_bit_secret_key_here - webhook - -``` +Not required for the async path; the portal pulls events from Salesforce. ## 4. Customer Experience @@ -282,9 +125,9 @@ export function useOrderStatus(sfOrderId: string) { ### Key Metrics to Monitor - **Provisioning Success Rate**: Track successful vs failed provisioning attempts -- **Provisioning Latency**: Time from Quick Action to completion +- **Provisioning Latency**: Time from SF approval to completion - **WHMCS API Errors**: Monitor WHMCS integration health -- **Webhook Security Events**: Failed signature validations, old timestamps +- **Event Lag**: Time between event publish and job enqueue/complete ### Alert Conditions @@ -311,39 +154,16 @@ export class OrderProvisioningMonitoringService { ## 6. Testing -### Security Testing +### Event Flow Testing -```typescript -describe('Order Provisioning Security', () => { - it('should reject requests without valid HMAC signature', async () => { - const response = await request(app) - .post('/orders/test-order-id/provision') - .send({ orderId: 'test-order-id' }) - .expect(401); - }); - - it('should reject requests with old timestamps', async () => { - const oldTimestamp = new Date(Date.now() - 10 * 60 * 1000).toISOString(); - const payload = { orderId: 'test-order-id', timestamp: oldTimestamp }; - const signature = generateHmacSignature(JSON.stringify(payload)); - - const response = await request(app) - .post('/orders/test-order-id/provision') - .set('X-SF-Signature', signature) - .set('X-SF-Timestamp', oldTimestamp) - .send(payload) - .expect(401); - }); -}); -``` +Validate that an `OrderProvisionRequested__e` event enqueues a job and runs the orchestrator. Check logs and `/health/sf-events` for status and cursor. ## Summary This focused approach ensures secure communication specifically for your **order provisioning workflow**: -1. **Salesforce Quick Action** → Secure HTTPS call to Portal BFF -2. **Portal BFF** → Processes order, provisions in WHMCS -3. **Optional**: Portal sends status updates back to Salesforce -4. **Customer** → Sees real-time order status in Portal UI +1. **Salesforce Flow** → Publishes Platform Event `OrderProvisionRequested__e` +2. **Portal BFF** → Subscribes, queues job, provisions in WHMCS, updates Salesforce +3. **Customer** → Sees real-time order status in Portal UI -The security is handled by your existing infrastructure with enhanced webhook signature validation, making it production-ready and secure [[memory:6689308]]. +The security relies on your existing JWT-based Salesforce API integration and ; no inbound webhooks are required. diff --git a/docs/SALESFORCE-PORTAL-SECURITY-GUIDE.md b/docs/SALESFORCE-PORTAL-SECURITY-GUIDE.md index ca397148..08e88e96 100644 --- a/docs/SALESFORCE-PORTAL-SECURITY-GUIDE.md +++ b/docs/SALESFORCE-PORTAL-SECURITY-GUIDE.md @@ -1,211 +1,39 @@ # Salesforce-to-Portal Security Integration Guide +Note: 2025 update — Salesforce → Portal order provisioning is now event-driven via Platform Events. This document reflects the event-driven model. + ## Overview This guide outlines secure patterns for **Salesforce-to-Portal communication** specifically for the **order provisioning workflow**. Based on your architecture, this focuses on order status updates, not invoice handling. -## Order Provisioning Flow +## Order Provisioning Flow (Async Preferred) ``` Portal Customer → Places Order → Salesforce Order (Pending Review) ↓ -Salesforce Operator → Reviews → Clicks "Provision in WHMCS" Quick Action +Salesforce Operator → Reviews/Approves Order ↓ - Salesforce → Calls Portal BFF → `/orders/{sfOrderId}/provision` +Salesforce Flow → Publishes OrderProvisionRequested__e ↓ - Portal BFF → Provisions in WHMCS → Updates Salesforce Order Status +Portal BFF Subscriber → Provisions in WHMCS → Updates Salesforce Order Status ↓ - Portal → Polls Order Status → Shows Customer Updates +Portal → Polls Order Status → Shows Customer Updates ``` ## 1. Secure Order Provisioning Communication -### Primary Method: Direct HTTPS Webhook (Recommended for Order Flow) +### Primary Method: Platform Events (Recommended) -Based on your architecture, the **order provisioning flow** uses direct HTTPS calls from Salesforce to your portal BFF. Here's how to secure this: +Use a Record-Triggered Flow to publish `OrderProvisionRequested__e`. The portal subscribes via the Salesforce Streaming API (Pub/Sub) and provisions asynchronously. Inbound webhooks from Salesforce are no longer required. -**Salesforce → Portal BFF Flow:** +If you still need synchronous HTTP for another use case, prefer OAuth2 Named/External Credential and avoid custom signature schemes. -1. **Salesforce Quick Action** calls `POST /orders/{sfOrderId}/provision` -2. **Portal BFF** processes the provisioning request -3. **Optional: Portal → Salesforce** status updates via webhook +### Secure Salesforce Quick Action Setup (Legacy) -### Secure Salesforce Quick Action Setup +Legacy Quick Action + webhook details removed (use Platform Events). -**In Salesforce:** - -1. **Named Credential Configuration** -```xml - - - Portal_BFF - https://your-portal-api.com - Anonymous - HttpsOnly - false - -``` 2. **Apex Class for Secure Webhook Calls** -```apex -public class PortalWebhookService { - private static final String WEBHOOK_SECRET = '{!$Credential.Portal_Webhook.Password}'; - - @future(callout=true) - public static void provisionOrder(String orderId) { - try { - // Prepare secure payload - Map payload = new Map{ - 'orderId' => orderId, - 'timestamp' => System.now().format('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''), - 'nonce' => generateNonce() - }; - - // Create HMAC signature - String jsonPayload = JSON.serialize(payload); - String signature = generateHMACSignature(jsonPayload, WEBHOOK_SECRET); - - // Make secure HTTP call - HttpRequest req = new HttpRequest(); - req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision'); - req.setMethod('POST'); - req.setHeader('Content-Type', 'application/json'); - req.setHeader('X-SF-Signature', signature); - req.setHeader('X-SF-Timestamp', payload.get('timestamp').toString()); - req.setHeader('X-SF-Nonce', payload.get('nonce').toString()); - req.setHeader('Idempotency-Key', generateIdempotencyKey(orderId)); - req.setBody(jsonPayload); - req.setTimeout(30000); // 30 second timeout - - Http http = new Http(); - HttpResponse res = http.send(req); - - // Handle response - handleProvisioningResponse(orderId, res); - - } catch (Exception e) { - // Log error and update order status - System.debug('Provisioning failed for order ' + orderId + ': ' + e.getMessage()); - updateOrderProvisioningStatus(orderId, 'Failed', e.getMessage()); - } - } - - private static String generateHMACSignature(String data, String key) { - Blob hmacData = Crypto.generateMac('HmacSHA256', Blob.valueOf(data), Blob.valueOf(key)); - return EncodingUtil.convertToHex(hmacData); - } - - private static String generateNonce() { - return EncodingUtil.convertToHex(Crypto.generateAesKey(128)).substring(0, 16); - } - - private static String generateIdempotencyKey(String orderId) { - return 'provision_' + orderId + '_' + System.now().getTime(); - } -} - -### Optional: Portal → Salesforce Status Updates - -If you want the portal to send status updates back to Salesforce (e.g., when provisioning completes), you can set up a reverse webhook: - -**Portal BFF → Salesforce Webhook Endpoint:** - -```typescript -// In your Portal BFF -export class SalesforceStatusUpdateService { - async updateOrderStatus(orderId: string, status: string, details?: any) { - const payload = { - orderId, - status, - timestamp: new Date().toISOString(), - details: this.sanitizeDetails(details) - }; - - // Send to Salesforce webhook endpoint - await this.sendToSalesforce('/webhook/order-status', payload); - } -} -``` - -## 2. Portal BFF Security Implementation - -### Enhanced Order Provisioning Endpoint - -Your portal BFF should implement the `/orders/{sfOrderId}/provision` endpoint with these security measures: - -```typescript -// Enhanced order provisioning endpoint -@Post('orders/:sfOrderId/provision') -@UseGuards(EnhancedWebhookSignatureGuard) -async provisionOrder( - @Param('sfOrderId') sfOrderId: string, - @Body() payload: ProvisionOrderRequest, - @Headers('idempotency-key') idempotencyKey: string -) { - // Your existing provisioning logic - return await this.orderOrchestrator.provisionOrder(sfOrderId, payload, idempotencyKey); -} -``` - -**Enhanced Webhook Security Implementation:** - -```typescript -@Injectable() -export class EnhancedWebhookSignatureGuard implements CanActivate { - constructor(private configService: ConfigService) {} - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - - // 1. Verify HMAC signature (existing) - this.verifyHmacSignature(request); - - // 2. Verify timestamp (prevent replay attacks) - this.verifyTimestamp(request); - - // 3. Verify nonce (prevent duplicate processing) - this.verifyNonce(request); - - // 4. Verify source IP (if using IP allowlisting) - this.verifySourceIp(request); - - return true; - } - - private verifyTimestamp(request: Request): void { - const timestamp = request.headers['x-sf-timestamp'] as string; - if (!timestamp) { - throw new UnauthorizedException('Timestamp required'); - } - - const requestTime = new Date(timestamp).getTime(); - const now = Date.now(); - const maxAge = 5 * 60 * 1000; // 5 minutes - - if (Math.abs(now - requestTime) > maxAge) { - throw new UnauthorizedException('Request too old'); - } - } - - private verifyNonce(request: Request): void { - const nonce = request.headers['x-sf-nonce'] as string; - if (!nonce) { - throw new UnauthorizedException('Nonce required'); - } - - // Check if nonce was already used (implement nonce store) - // This prevents replay attacks - } -} -``` - -## 2. Outbound Security: Portal → Salesforce - -### Current Implementation (Already Secure ✅) - -Your existing JWT-based authentication is excellent: - -```typescript // Your current pattern in salesforce-connection.service.ts // Uses private key JWT authentication - industry standard ``` @@ -322,21 +150,15 @@ export class FieldEncryptionService { ### Salesforce Setup - [ ] Create Platform Events for portal notifications -- [ ] Set up Named Credentials for portal webhook calls - [ ] Configure IP allowlisting for portal endpoints -- [ ] Implement HMAC signing in Apex - [ ] Create audit trails for all portal communications ### Portal Setup -- [ ] Enhance webhook signature verification -- [ ] Implement timestamp and nonce validation - [ ] Add IP allowlisting for Salesforce - [ ] Create encrypted payload handling - [ ] Implement idempotency protection ### Security Measures -- [ ] Rotate webhook secrets regularly -- [ ] Monitor for suspicious webhook activity - [ ] Implement rate limiting per customer - [ ] Add comprehensive audit logging - [ ] Test disaster recovery procedures @@ -374,43 +196,14 @@ export class SecurityMonitoringService { } ``` -## 6. Testing Security - -```typescript -describe('Webhook Security', () => { - it('should reject webhooks without valid HMAC signature', async () => { - const invalidPayload = { data: 'test' }; - const response = await request(app) - .post('/webhooks/salesforce') - .send(invalidPayload) - .expect(401); - - expect(response.body.message).toContain('Invalid webhook signature'); - }); - - it('should reject old timestamps', async () => { - const oldTimestamp = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago - const payload = { data: 'test' }; - const signature = generateHmacSignature(payload); - - const response = await request(app) - .post('/webhooks/salesforce') - .set('X-SF-Signature', signature) - .set('X-SF-Timestamp', oldTimestamp.toISOString()) - .send(payload) - .expect(401); - }); -}); -``` - ## 7. Production Deployment ### Environment Variables ```bash -# Webhook Security -SF_WEBHOOK_SECRET=your_256_bit_secret_key_here -SF_WEBHOOK_IP_ALLOWLIST=13.108.0.0/14,204.14.232.0/23 -WEBHOOK_TIMESTAMP_TOLERANCE=300000 # 5 minutes in ms +# Platform Events +SF_EVENTS_ENABLED=true +SF_PROVISION_EVENT_CHANNEL=/event/OrderProvisionRequested__e +SF_EVENTS_REPLAY=LATEST # Encryption FIELD_ENCRYPTION_KEY=your_field_encryption_master_key @@ -422,15 +215,6 @@ AUDIT_LOG_RETENTION_DAYS=2555 # 7 years for compliance ``` ### Salesforce Named Credential Setup -```xml - - - Portal_Webhook - https://your-portal-api.com - Anonymous - HttpsOnly - false - -``` +Not required for the event-driven provisioning path (the portal pulls events). This guide provides a comprehensive, production-ready approach to secure Salesforce-Portal integration that builds on your existing security infrastructure while adding enterprise-grade protection for sensitive data transmission. diff --git a/docs/SALESFORCE-PORTAL-SIMPLE-GUIDE.md b/docs/SALESFORCE-PORTAL-SIMPLE-GUIDE.md index 2ab5dcea..5bd2112c 100644 --- a/docs/SALESFORCE-PORTAL-SIMPLE-GUIDE.md +++ b/docs/SALESFORCE-PORTAL-SIMPLE-GUIDE.md @@ -1,300 +1,10 @@ # Simple Salesforce-to-Portal Communication Guide -## The Simple Flow (No Reverse Webhooks Needed!) +This guide is deprecated for order provisioning. Use Platform Events (`OrderProvisionRequested__e`) as outlined in `SALESFORCE-ORDER-COMMUNICATION.md`. -``` -1. Customer places order → Portal creates Salesforce Order (Pending Review) -2. Salesforce operator → Clicks "Provision in WHMCS" Quick Action -3. Salesforce → Calls Portal BFF → POST /orders/{sfOrderId}/provision -4. Portal BFF → Provisions in WHMCS → DIRECTLY updates Salesforce Order (via existing SF API) -5. Customer → Polls Portal for status updates -``` +Modern path (summary) +- Create High-Volume Platform Event `OrderProvisionRequested__e` (fields: `OrderId__c`, optional `IdemKey__c`). +- Record-Triggered Flow on Order approval publishes the event. +- Portal BFF subscribes to the event, enqueues a job, provisions in WHMCS, and updates Salesforce. -**Key insight**: You already have Salesforce API access in your Portal BFF, so you can directly update the Order status. No reverse webhooks needed! - -## 1. Salesforce Quick Action Security - -### Salesforce Apex (Secure Call to Portal) - -```apex -public class OrderProvisioningService { - private static final String WEBHOOK_SECRET = '{!$Credential.Portal_Webhook.Password}'; - - @future(callout=true) - public static void provisionOrder(String orderId) { - try { - // Simple secure payload - Map payload = new Map{ - 'orderId' => orderId, - 'timestamp' => System.now().format('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''), - 'nonce' => generateNonce() - }; - - String jsonPayload = JSON.serialize(payload); - String signature = generateHMACSignature(jsonPayload, WEBHOOK_SECRET); - - // Call Portal BFF - HttpRequest req = new HttpRequest(); - req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision'); - req.setMethod('POST'); - req.setHeader('Content-Type', 'application/json'); - req.setHeader('X-SF-Signature', signature); - req.setHeader('X-SF-Timestamp', payload.get('timestamp').toString()); - req.setHeader('X-SF-Nonce', payload.get('nonce').toString()); - req.setHeader('Idempotency-Key', 'provision_' + orderId + '_' + System.now().getTime()); - req.setBody(jsonPayload); - req.setTimeout(30000); - - Http http = new Http(); - HttpResponse res = http.send(req); - - // Simple response handling - if (res.getStatusCode() != 200) { - throw new Exception('Portal returned: ' + res.getStatusCode()); - } - - } catch (Exception e) { - // Update order status on failure - updateOrderStatus(orderId, 'Failed', e.getMessage()); - } - } - - private static String generateHMACSignature(String data, String key) { - Blob hmacData = Crypto.generateMac('HmacSHA256', Blob.valueOf(data), Blob.valueOf(key)); - return EncodingUtil.convertToHex(hmacData); - } - - private static String generateNonce() { - return EncodingUtil.convertToHex(Crypto.generateAesKey(128)).substring(0, 16); - } - - private static void updateOrderStatus(String orderId, String status, String errorMessage) { - Order ord = [SELECT Id FROM Order WHERE Id = :orderId LIMIT 1]; - ord.Provisioning_Status__c = status; - if (errorMessage != null) { - ord.Provisioning_Error_Message__c = errorMessage.left(255); - } - update ord; - } -} -``` - -## 2. Portal BFF Implementation (Simple!) - -### Enhanced Security for Provisioning Endpoint - -```typescript -// apps/bff/src/orders/orders.controller.ts -@Post(':sfOrderId/provision') -@UseGuards(EnhancedWebhookSignatureGuard) // Your existing guard -@ApiOperation({ summary: "Provision order from Salesforce" }) -async provisionOrder( - @Param('sfOrderId') sfOrderId: string, - @Body() payload: { orderId: string; timestamp: string; nonce: string }, - @Headers('idempotency-key') idempotencyKey: string -) { - return await this.orderOrchestrator.provisionOrder(sfOrderId, payload, idempotencyKey); -} -``` - -### Order Orchestrator (Direct Salesforce Updates) - -```typescript -// apps/bff/src/orders/services/order-orchestrator.service.ts -@Injectable() -export class OrderOrchestrator { - constructor( - private salesforceService: SalesforceService, // Your existing service - private whmcsService: WhmcsService, - @Inject(Logger) private logger: Logger - ) {} - - async provisionOrder(sfOrderId: string, payload: any, idempotencyKey: string) { - try { - // 1. Update SF status to "Activating" - await this.updateSalesforceOrderStatus(sfOrderId, 'Activating'); - - // 2. Your existing provisioning logic - const result = await this.provisionInWhmcs(sfOrderId); - - // 3. Update SF status to "Provisioned" with WHMCS ID - await this.updateSalesforceOrderStatus(sfOrderId, 'Provisioned', { - whmcsOrderId: result.whmcsOrderId, - }); - - this.logger.log('Order provisioned successfully', { - sfOrderId, - whmcsOrderId: result.whmcsOrderId, - }); - - return { - success: true, - status: 'Provisioned', - whmcsOrderId: result.whmcsOrderId, - }; - - } catch (error) { - // Update SF status to "Failed" - await this.updateSalesforceOrderStatus(sfOrderId, 'Failed', { - errorCode: 'PROVISIONING_ERROR', - errorMessage: error instanceof Error ? error.message : String(error), - }); - - this.logger.error('Order provisioning failed', { - sfOrderId, - error: error instanceof Error ? error.message : String(error), - }); - - throw error; - } - } - - // Simple direct Salesforce update (using your existing SF service) - private async updateSalesforceOrderStatus( - sfOrderId: string, - status: 'Activating' | 'Provisioned' | 'Failed', - details?: { - whmcsOrderId?: string; - errorCode?: string; - errorMessage?: string; - } - ) { - try { - const updateData: any = { - Id: sfOrderId, - Provisioning_Status__c: status, - Last_Provisioning_At__c: new Date().toISOString(), - }; - - if (details?.whmcsOrderId) { - updateData.WHMCS_Order_ID__c = details.whmcsOrderId; - } - - if (status === 'Failed' && details?.errorCode) { - updateData.Provisioning_Error_Code__c = details.errorCode; - updateData.Provisioning_Error_Message__c = details.errorMessage?.substring(0, 255); - } - - // Use your existing Salesforce service to update - await this.salesforceService.updateOrder(updateData); - - this.logger.log('Salesforce order status updated', { - sfOrderId, - status, - }); - - } catch (error) { - this.logger.error('Failed to update Salesforce order status', { - sfOrderId, - status, - error: error instanceof Error ? error.message : String(error), - }); - // Don't throw - provisioning succeeded, this is just a status update - } - } -} -``` - -### Add Update Method to Salesforce Service - -```typescript -// apps/bff/src/vendors/salesforce/salesforce.service.ts -// Add this method to your existing SalesforceService - -async updateOrder(orderData: { Id: string; [key: string]: any }): Promise { - try { - const sobject = this.connection.sobject('Order'); - await sobject.update(orderData); - - this.logger.log('Order updated in Salesforce', { - orderId: orderData.Id, - fields: Object.keys(orderData).filter(k => k !== 'Id'), - }); - } catch (error) { - this.logger.error('Failed to update order in Salesforce', { - orderId: orderData.Id, - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } -} -``` - -## 3. Customer UI (Simple Polling) - -```typescript -// Portal UI - simple polling for order status -export function useOrderStatus(sfOrderId: string) { - const [orderStatus, setOrderStatus] = useState<{ - status: string; - whmcsOrderId?: string; - error?: string; - }>({ status: 'Pending Review' }); - - useEffect(() => { - const pollStatus = async () => { - try { - const response = await fetch(`/api/orders/${sfOrderId}`); - const data = await response.json(); - setOrderStatus(data); - - // Stop polling when complete - if (['Provisioned', 'Failed'].includes(data.status)) { - clearInterval(interval); - } - } catch (error) { - console.error('Failed to fetch order status:', error); - } - }; - - const interval = setInterval(pollStatus, 5000); // Poll every 5 seconds - pollStatus(); // Initial fetch - - return () => clearInterval(interval); - }, [sfOrderId]); - - return orderStatus; -} -``` - -## 4. Security Configuration - -### Environment Variables (Simple) - -```bash -# Salesforce webhook security -SF_WEBHOOK_SECRET=your_256_bit_secret_key_here -SF_WEBHOOK_IP_ALLOWLIST=13.108.0.0/14,204.14.232.0/23 -WEBHOOK_TIMESTAMP_TOLERANCE=300000 # 5 minutes -``` - -### Salesforce Named Credentials - -```xml - - - Portal_BFF - https://your-portal-api.com - Anonymous - HttpsOnly - - - - - Portal_Webhook - https://your-portal-api.com - NamedPrincipal - your_256_bit_secret_key_here - webhook - -``` - -## Summary: Why This is Simple - -✅ **No reverse webhooks** - Portal directly updates Salesforce via existing API -✅ **One-way communication** - Salesforce → Portal → Direct SF update -✅ **Uses existing infrastructure** - Your SF service, webhook guards, etc. -✅ **Simple customer experience** - Portal polls for status updates -✅ **Production ready** - HMAC security, idempotency, error handling - -This follows exactly what your docs specify: Salesforce calls Portal, Portal provisions and updates Salesforce directly. Much cleaner! +No inbound Salesforce webhooks or HMAC headers are required in this architecture. diff --git a/env/portal-backend.env.sample b/env/portal-backend.env.sample index c245325d..2c315a1a 100644 --- a/env/portal-backend.env.sample +++ b/env/portal-backend.env.sample @@ -2,7 +2,6 @@ NODE_ENV=production # App APP_BASE_URL=https://asolutions.jp -PORT=4000 BFF_PORT=4000 # Database (PostgreSQL) @@ -34,6 +33,14 @@ SF_CLIENT_ID= SF_PRIVATE_KEY_PATH=/app/secrets/sf-private.key SF_USERNAME= +# Salesforce Platform Events (Provisioning) +SF_EVENTS_ENABLED=true +SF_PROVISION_EVENT_CHANNEL=/event/Order_Fulfilment_Requested__e +SF_EVENTS_REPLAY=LATEST +SF_PUBSUB_ENDPOINT=api.pubsub.salesforce.com:7443 +SF_PUBSUB_NUM_REQUESTED=50 +SF_PUBSUB_QUEUE_MAX=100 + # Salesforce Pricing PORTAL_PRICEBOOK_ID= @@ -54,3 +61,5 @@ EMAIL_TEMPLATE_WELCOME= # Node Options NODE_OPTIONS=--max-old-space-size=512 +# NOTE: Frontend (Next.js) uses a separate env file (portal-frontend.env) +# Do not include NEXT_PUBLIC_* variables here. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0eceed15..6198365a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: rxjs: specifier: ^7.8.2 version: 7.8.2 + salesforce-pubsub-api-client: + specifier: ^5.5.0 + version: 5.5.0(@types/node@24.3.0) speakeasy: specifier: ^2.0.0 version: 2.0.0 @@ -567,6 +570,15 @@ packages: resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@grpc/grpc-js@1.13.4': + resolution: {integrity: sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + '@heroicons/react@2.2.0': resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} peerDependencies: @@ -989,6 +1001,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -1284,6 +1299,36 @@ packages: '@prisma/get-platform@6.14.0': resolution: {integrity: sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1976,6 +2021,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + avro-js@1.12.0: + resolution: {integrity: sha512-mBhOjtHHua2MHrrgQ71YKKTGfZpS1sPvgL+QcCQ5SkUyp6qLkeTsCnQXUmATfpiOvoXB6CczzFEqn5UKbPUn3Q==} + axe-core@4.10.3: resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} @@ -3615,6 +3663,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -3655,6 +3706,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -4171,6 +4225,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -4358,6 +4416,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + salesforce-pubsub-api-client@5.5.0: + resolution: {integrity: sha512-O6ACxhW4CnQpHmDQ1+iNcgUlcRyHW+FS3t+dsYUxK2Ae5w9Ulo56wskJQ7rY6Ye+fMopeZ9Jw+o1k/i4MoWMNg==} + sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} @@ -4865,9 +4926,16 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + underscore@1.13.7: + resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} + undici-types@7.10.0: resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + undici@7.15.0: + resolution: {integrity: sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==} + engines: {node: '>=20.18.1'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -5382,6 +5450,18 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 + '@grpc/grpc-js@1.13.4': + dependencies: + '@grpc/proto-loader': 0.7.15 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@heroicons/react@2.2.0(react@19.1.1)': dependencies: react: 19.1.1 @@ -5867,6 +5947,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@lukeed/csprng@1.1.0': {} '@microsoft/tsdoc@0.15.1': {} @@ -6150,6 +6232,29 @@ snapshots: dependencies: '@prisma/debug': 6.14.0 + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.12.0': {} @@ -6897,6 +7002,10 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + avro-js@1.12.0: + dependencies: + underscore: 1.13.7 + axe-core@4.10.3: {} axios@1.11.0: @@ -8982,6 +9091,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.defaults@4.2.0: {} lodash.includes@4.3.0: {} @@ -9011,6 +9122,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -9512,6 +9625,21 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 24.3.0 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -9704,6 +9832,18 @@ snapshots: safer-buffer@2.1.2: {} + salesforce-pubsub-api-client@5.5.0(@types/node@24.3.0): + dependencies: + '@grpc/grpc-js': 1.13.4 + '@grpc/proto-loader': 0.7.15 + avro-js: 1.12.0 + jsforce: 3.10.4(@types/node@24.3.0) + undici: 7.15.0 + transitivePeerDependencies: + - '@types/node' + - encoding + - supports-color + sax@1.4.1: {} scheduler@0.26.0: {} @@ -10305,8 +10445,12 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + underscore@1.13.7: {} + undici-types@7.10.0: {} + undici@7.15.0: {} + universalify@2.0.1: {} unpipe@1.0.0: {}