301 lines
9.6 KiB
Markdown
301 lines
9.6 KiB
Markdown
|
|
# 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}/provision
|
||
|
|
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)
|
||
|
|
|
||
|
|
```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!
|