- 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.
311 lines
10 KiB
Markdown
311 lines
10 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}/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)
|
|
|
|
```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();
|
|
// 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
|
|
|
|
```typescript
|
|
// 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/secrets` and set:
|
|
- `SF_PRIVATE_KEY_PATH=/app/secrets/sf-private.key`
|
|
- `SF_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)
|
|
|
|
```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!
|