- Updated PLESK_DEPLOYMENT.md to include new Salesforce credentials and webhook security configurations. - Refactored order fulfillment controller to streamline the process and improve readability. - Introduced EnhancedWebhookSignatureGuard for improved HMAC signature validation and nonce management. - Updated various documentation files to reflect changes in endpoint naming from `/provision` to `/fulfill` for clarity and consistency. - Enhanced Redis integration for nonce storage to prevent replay attacks. - Removed deprecated WebhookSignatureGuard in favor of the new enhanced guard.
10 KiB
10 KiB
Simple Salesforce-to-Portal Communication Guide
The Simple Flow (No Reverse Webhooks Needed!)
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}/fulfill
4. Portal BFF → Provisions in WHMCS → DIRECTLY updates Salesforce Order (via existing SF API)
5. Customer → Polls Portal for status updates
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)
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();
// Use the single canonical path '/fulfill'
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/fulfill');
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
// apps/bff/src/orders/controllers/order-fulfillment.controller.ts
@Post(':sfOrderId/fulfill')
@UseGuards(EnhancedWebhookSignatureGuard) // Your existing guard
@ApiOperation({ summary: "Fulfill 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.orderFulfillmentService.fulfillOrder(sfOrderId, payload, idempotencyKey);
}
3. Production Env Notes (Plesk)
- Backend reads environment from the Plesk env file, not from repo
.env:compose-plesk.yaml→env_file: /var/www/vhosts/.../env/portal-backend.env
- Mount secrets inside the container at
/app/secretsand set:SF_PRIVATE_KEY_PATH=/app/secrets/sf-private.keySF_LOGIN_URL,SF_CLIENT_ID,SF_USERNAME,SF_WEBHOOK_SECRET
- The backend validates the private key path to be under
./secrets(dev) or/app/secrets(prod).
Order Orchestrator (Direct Salesforce Updates)
// 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
// 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)
// 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)
# 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
<!-- 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!