Merge branch 'main' into Tema-v1
This commit is contained in:
commit
781e4c9b6a
@ -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
|
||||
|
||||
20
README.md
20
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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
@ -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<void> {
|
||||
@ -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<boolean> {
|
||||
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 {
|
||||
|
||||
@ -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
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
32
apps/bff/src/common/cache/cache.service.ts
vendored
32
apps/bff/src/common/cache/cache.service.ts
vendored
@ -28,10 +28,34 @@ export class CacheService {
|
||||
}
|
||||
|
||||
async delPattern(pattern: string): Promise<void> {
|
||||
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<boolean> {
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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(", ");
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<EmailJobData>,
|
||||
@InjectQueue(QUEUE_NAMES.EMAIL) private readonly queue: Queue<EmailJobData>,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
|
||||
7
apps/bff/src/common/queue/queue.constants.ts
Normal file
7
apps/bff/src/common/queue/queue.constants.ts
Normal file
@ -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];
|
||||
42
apps/bff/src/common/queue/queue.module.ts
Normal file
42
apps/bff/src/common/queue/queue.module.ts
Normal file
@ -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<string, unknown>;
|
||||
} catch {
|
||||
// Fallback to localhost
|
||||
return { host: "localhost", port: 6379 } as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
BullModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
connection: parseRedisConnection(config.get<string>("REDIS_URL", "redis://localhost:6379")),
|
||||
}),
|
||||
}),
|
||||
BullModule.registerQueue(
|
||||
{ name: QUEUE_NAMES.EMAIL },
|
||||
{ name: QUEUE_NAMES.PROVISIONING },
|
||||
{ name: QUEUE_NAMES.RECONCILE }
|
||||
),
|
||||
],
|
||||
exports: [BullModule],
|
||||
})
|
||||
export class QueueModule {}
|
||||
@ -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<string>(
|
||||
"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<unknown>(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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<UserIdMapping[]> {
|
||||
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<string, unknown> {
|
||||
try {
|
||||
const plain: unknown = JSON.parse(JSON.stringify(data ?? {}));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
65
apps/bff/src/orders/queue/provisioning.processor.ts
Normal file
65
apps/bff/src/orders/queue/provisioning.processor.ts
Normal file
@ -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<void> {
|
||||
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<string>(
|
||||
"SF_PROVISION_EVENT_CHANNEL",
|
||||
"/event/Order_Fulfilment_Requested__e"
|
||||
);
|
||||
const replayKey = sfReplayKey(channel);
|
||||
const prev = Number((await this.cache.get<string>(replayKey)) ?? 0);
|
||||
if (job.data.pubsubReplayId > prev) {
|
||||
await this.cache.set(replayKey, String(job.data.pubsubReplayId));
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log("Provisioning job completed", { sfOrderId });
|
||||
}
|
||||
}
|
||||
39
apps/bff/src/orders/queue/provisioning.queue.ts
Normal file
39
apps/bff/src/orders/queue/provisioning.queue.ts
Normal file
@ -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<ProvisioningJobData>,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async enqueue(job: ProvisioningJobData): Promise<void> {
|
||||
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<number> {
|
||||
const counts = await this.queue.getJobCounts("waiting", "active", "delayed");
|
||||
return (counts.waiting || 0) + (counts.active || 0) + (counts.delayed || 0);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<void> {
|
||||
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
|
||||
*/
|
||||
|
||||
@ -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<string, unknown>)[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<SalesforceOrder> {
|
||||
const order = await this.salesforceService.getOrder(sfOrderId);
|
||||
private async validateSalesforceOrder(sfOrderId: string): Promise<SalesforceOrder> {
|
||||
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<string, unknown>)[
|
||||
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<string, unknown>;
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<OrderFulfillmentResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -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<OrderDetailsDto | null> {
|
||||
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<string, unknown> | 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<string, unknown>)[fields.order.orderType] === "string"
|
||||
? ((order as unknown as Record<string, unknown>)[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<string, unknown>)[fields.order.activationType];
|
||||
return typeof v === "string" ? v : undefined;
|
||||
})(),
|
||||
activationStatus: ((): string | undefined => {
|
||||
const v = (order as unknown as Record<string, unknown>)[fields.order.activationStatus];
|
||||
return typeof v === "string" ? v : undefined;
|
||||
})(),
|
||||
scheduledAt: ((): string | undefined => {
|
||||
const v = (order as unknown as Record<string, unknown>)[
|
||||
fields.order.activationScheduledAt
|
||||
];
|
||||
return typeof v === "string" ? v : undefined;
|
||||
})(),
|
||||
whmcsOrderId: ((): string | undefined => {
|
||||
const v = (order as unknown as Record<string, unknown>)[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<OrderSummaryDto[]> {
|
||||
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<string, unknown>
|
||||
| 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<string, unknown>)[fields.order.orderType] === "string"
|
||||
? ((order as unknown as Record<string, unknown>)[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<string, unknown>)[fields.order.whmcsOrderId];
|
||||
return typeof v === "string" ? v : undefined;
|
||||
})(),
|
||||
itemsSummary: itemsByOrder[order.Id] || [], // Include basic item info for order list
|
||||
}));
|
||||
} catch (error) {
|
||||
|
||||
@ -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<OrderItemMappingResult> {
|
||||
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
|
||||
*/
|
||||
|
||||
53
apps/bff/src/orders/types/order-details.dto.ts
Normal file
53
apps/bff/src/orders/types/order-details.dto.ts
Normal file
@ -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[];
|
||||
}
|
||||
@ -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<T> {
|
||||
@ -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 {
|
||||
|
||||
1
apps/bff/src/types/salesforce-pubsub-api-client.d.ts
vendored
Normal file
1
apps/bff/src/types/salesforce-pubsub-api-client.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module "salesforce-pubsub-api-client";
|
||||
@ -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) {
|
||||
|
||||
11
apps/bff/src/vendors/salesforce/events/event-keys.util.ts
vendored
Normal file
11
apps/bff/src/vendors/salesforce/events/event-keys.util.ts
vendored
Normal file
@ -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}`;
|
||||
}
|
||||
11
apps/bff/src/vendors/salesforce/events/events.module.ts
vendored
Normal file
11
apps/bff/src/vendors/salesforce/events/events.module.ts
vendored
Normal file
@ -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 {}
|
||||
246
apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts
vendored
Normal file
246
apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts
vendored
Normal file
@ -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<void>;
|
||||
|
||||
interface PubSubClient {
|
||||
connect(): Promise<void>;
|
||||
subscribe(topic: string, cb: SubscribeCallback, numRequested: number): Promise<void>;
|
||||
subscribeFromReplayId(
|
||||
topic: string,
|
||||
cb: SubscribeCallback,
|
||||
numRequested: number,
|
||||
replayId: number
|
||||
): Promise<void>;
|
||||
subscribeFromEarliestEvent(
|
||||
topic: string,
|
||||
cb: SubscribeCallback,
|
||||
numRequested: number
|
||||
): Promise<void>;
|
||||
requestAdditionalEvents(topic: string, numRequested: number): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
type PubSubCtor = new (opts: {
|
||||
authType: string;
|
||||
accessToken: string;
|
||||
instanceUrl: string;
|
||||
pubSubEndpoint: string;
|
||||
}) => PubSubClient;
|
||||
|
||||
@Injectable()
|
||||
export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
private client: PubSubClient | null = null;
|
||||
private 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<void> {
|
||||
const enabled = this.config.get("SF_EVENTS_ENABLED", "false") === "true";
|
||||
if (!enabled) {
|
||||
this.logger.log("Salesforce Pub/Sub subscriber disabled", { enabled });
|
||||
return;
|
||||
}
|
||||
|
||||
this.channel = this.config.get<string>(
|
||||
"SF_PROVISION_EVENT_CHANNEL",
|
||||
"/event/Order_Fulfilment_Requested__e"
|
||||
);
|
||||
|
||||
try {
|
||||
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<string>(
|
||||
"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<string>(replayKey);
|
||||
const replayMode = this.config.get<string>("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<string, unknown>;
|
||||
const payload = ((): Record<string, unknown> | undefined => {
|
||||
const p = event?.["payload"];
|
||||
return typeof p === "object" && p != null ? (p as Record<string, unknown>) : 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<void> {
|
||||
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
|
||||
}
|
||||
@ -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`
|
||||
|
||||
@ -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<SalesforceAccount>;
|
||||
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<SalesforceAccount>;
|
||||
|
||||
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<SalesforceAccount>;
|
||||
|
||||
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<SalesforceAccount>;
|
||||
|
||||
return result.totalSize > 0 ? result.records[0] : null;
|
||||
} catch (error) {
|
||||
|
||||
@ -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<SalesforceCase>;
|
||||
|
||||
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<SalesforceCase>;
|
||||
|
||||
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<string, unknown>) => Promise<SalesforceCreateResult>;
|
||||
create: (data: Record<string, unknown>) => Promise<SfCreateResult>;
|
||||
};
|
||||
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<string, unknown>) => Promise<SalesforceCreateResult>;
|
||||
create: (data: Record<string, unknown>) => Promise<SfCreateResult>;
|
||||
};
|
||||
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<SalesforceCase>;
|
||||
|
||||
return createdCase.records[0];
|
||||
}
|
||||
|
||||
@ -12,11 +12,6 @@ export interface SalesforceSObjectApi {
|
||||
update?: (data: Record<string, unknown> & { Id: string }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface _SalesforceRetryableSObjectApi extends SalesforceSObjectApi {
|
||||
create: (data: Record<string, unknown>) => Promise<{ id?: string }>;
|
||||
update?: (data: Record<string, unknown> & { Id: string }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@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<void> {
|
||||
const nodeEnv =
|
||||
this.configService.get<string>("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<unknown> {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown> & { 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)) {
|
||||
|
||||
@ -48,7 +48,7 @@ export class WhmcsConnectionService {
|
||||
identifier: this.configService.get<string>("WHMCS_API_IDENTIFIER", ""),
|
||||
secret: this.configService.get<string>("WHMCS_API_SECRET", ""),
|
||||
timeout: this.configService.get<number>("WHMCS_API_TIMEOUT", 30000),
|
||||
retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 3),
|
||||
retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 1),
|
||||
retryDelay: this.configService.get<number>("WHMCS_API_RETRY_DELAY", 1000),
|
||||
};
|
||||
// Optional API Access Key (used by some WHMCS deployments alongside API Credentials)
|
||||
|
||||
@ -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("")}}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string>(); // 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<string>("SF_WEBHOOK_IP_ALLOWLIST");
|
||||
this.allowedIps = ipAllowlist ? ipAllowlist.split(",").map(ip => ip.trim()) : [];
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<WebhookRequest>();
|
||||
|
||||
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<number>("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<string>("WHMCS_WEBHOOK_SECRET");
|
||||
} else if (isSalesforce) {
|
||||
secret = this.configService.get<string>("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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<Request>();
|
||||
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<string>("WHMCS_WEBHOOK_SECRET")
|
||||
: this.configService.get<string>("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;
|
||||
}
|
||||
}
|
||||
@ -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<typeof SalesforceWebhookSchema>;
|
||||
@ -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<typeof WhmcsWebhookSchema>;
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -24,7 +24,7 @@ hasPaymentMethod(clientId: number): Promise<boolean>
|
||||
```
|
||||
|
||||
### **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<boolean>
|
||||
**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<boolean>
|
||||
## 🔄 **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! 🎉
|
||||
|
||||
@ -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<String, Object> payload = new Map<String, Object>{
|
||||
'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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<Id> 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<String, Object>{
|
||||
'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
|
||||
|
||||
@ -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=<Id>`
|
||||
- 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.
|
||||
|
||||
@ -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
|
||||
|
||||
74
docs/RUNBOOK_PROVISIONING.md
Normal file
74
docs/RUNBOOK_PROVISIONING.md
Normal file
@ -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:<channel>`
|
||||
- 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)
|
||||
@ -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<String, Object> payload = new Map<String, Object>{
|
||||
'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
|
||||
<!-- Named Credential: Portal_BFF -->
|
||||
<NamedCredential>
|
||||
<fullName>Portal_BFF</fullName>
|
||||
<endpoint>https://your-portal-api.com</endpoint>
|
||||
<principalType>Anonymous</principalType>
|
||||
<protocol>HttpsOnly</protocol>
|
||||
<generateAuthorizationHeader>false</generateAuthorizationHeader>
|
||||
</NamedCredential>
|
||||
|
||||
<!-- Named Credential: Portal_Webhook (for the secret) -->
|
||||
<NamedCredential>
|
||||
<fullName>Portal_Webhook</fullName>
|
||||
<endpoint>https://your-portal-api.com</endpoint>
|
||||
<principalType>NamedPrincipal</principalType>
|
||||
<namedCredentialType>Legacy</namedCredentialType>
|
||||
<password>your_256_bit_secret_key_here</password>
|
||||
<username>webhook</username>
|
||||
</NamedCredential>
|
||||
```
|
||||
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.
|
||||
|
||||
@ -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
|
||||
<!-- Named Credential: Portal_BFF -->
|
||||
<NamedCredential>
|
||||
<fullName>Portal_BFF</fullName>
|
||||
<endpoint>https://your-portal-api.com</endpoint>
|
||||
<principalType>Anonymous</principalType>
|
||||
<protocol>HttpsOnly</protocol>
|
||||
<generateAuthorizationHeader>false</generateAuthorizationHeader>
|
||||
</NamedCredential>
|
||||
```
|
||||
|
||||
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<String, Object> payload = new Map<String, Object>{
|
||||
'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<Request>();
|
||||
|
||||
// 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
|
||||
<!-- Named Credential: Portal_Webhook -->
|
||||
<NamedCredential>
|
||||
<fullName>Portal_Webhook</fullName>
|
||||
<endpoint>https://your-portal-api.com</endpoint>
|
||||
<principalType>Anonymous</principalType>
|
||||
<protocol>HttpsOnly</protocol>
|
||||
<generateAuthorizationHeader>false</generateAuthorizationHeader>
|
||||
</NamedCredential>
|
||||
```
|
||||
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.
|
||||
|
||||
@ -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<String, Object> payload = new Map<String, Object>{
|
||||
'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<void> {
|
||||
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
|
||||
<!-- For API calls -->
|
||||
<NamedCredential>
|
||||
<fullName>Portal_BFF</fullName>
|
||||
<endpoint>https://your-portal-api.com</endpoint>
|
||||
<principalType>Anonymous</principalType>
|
||||
<protocol>HttpsOnly</protocol>
|
||||
</NamedCredential>
|
||||
|
||||
<!-- For webhook secret -->
|
||||
<NamedCredential>
|
||||
<fullName>Portal_Webhook</fullName>
|
||||
<endpoint>https://your-portal-api.com</endpoint>
|
||||
<principalType>NamedPrincipal</principalType>
|
||||
<password>your_256_bit_secret_key_here</password>
|
||||
<username>webhook</username>
|
||||
</NamedCredential>
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
11
env/portal-backend.env.sample
vendored
11
env/portal-backend.env.sample
vendored
@ -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.
|
||||
|
||||
144
pnpm-lock.yaml
generated
144
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user