import { extractSalesforceObjectTypeFromPayload } from '@/lib/webhooks/providers/salesforce' import type { SubBlockConfig } from '@/blocks/types' import type { TriggerOutput } from '@/triggers/types' import { normalizeToken } from '@/triggers/utils/tokens' /** * Dropdown options for the Salesforce trigger type selector. */ export const salesforceTriggerOptions = [ { label: 'Record Created', id: 'salesforce_record_created' }, { label: 'Record Updated', id: 'salesforce_record_updated' }, { label: 'Record Deleted', id: 'salesforce_record_deleted' }, { label: 'Opportunity Stage Changed', id: 'salesforce_opportunity_stage_changed' }, { label: 'Case Status Changed', id: 'salesforce_case_status_changed' }, { label: 'Generic Webhook (All Events)', id: 'salesforce_webhook' }, ] const RECORD_CREATED = new Set([ 'record_created', 'created', 'create', 'after_insert', 'afterinsert', 'insert', ]) const RECORD_UPDATED = new Set([ 'record_updated', 'updated', 'update', 'after_update', 'afterupdate', ]) const RECORD_DELETED = new Set([ 'record_deleted', 'deleted', 'delete', 'after_delete', 'afterdelete', ]) const OPP_STAGE = new Set([ 'opportunity_stage_changed', 'stage_changed', 'stage_change', 'opportunity_stage_change', 'opportunitystagechanged', ]) const CASE_STATUS = new Set([ 'case_status_changed', 'status_changed', 'status_change', 'case_status_change', 'casestatuschanged', ]) function matchesRecordTrigger(triggerId: string, normalizedEvent: string): boolean { if (triggerId === 'salesforce_record_created') { return RECORD_CREATED.has(normalizedEvent) } if (triggerId === 'salesforce_record_updated') { return RECORD_UPDATED.has(normalizedEvent) } if (triggerId === 'salesforce_record_deleted') { return RECORD_DELETED.has(normalizedEvent) } return false } /** * Server-side filter for Salesforce Flow (JSON) payloads. * Users should send a string `eventType` (or `simEventType`) from the Flow body. * Optional `objectType` in provider config is enforced against payload when set. */ export function isSalesforceEventMatch( triggerId: string, body: Record, configuredObjectType?: string ): boolean { if (triggerId === 'salesforce_webhook') { const want = configuredObjectType?.trim() if (!want) { return true } const got = extractSalesforceObjectTypeFromPayload(body) if (!got) { return false } return normalizeToken(got) === normalizeToken(want) } const wantType = configuredObjectType?.trim() const gotType = extractSalesforceObjectTypeFromPayload(body) if (wantType) { if (!gotType) { return false } if (normalizeToken(gotType) !== normalizeToken(wantType)) { return false } } if (triggerId === 'salesforce_opportunity_stage_changed') { if (gotType && normalizeToken(gotType) !== 'opportunity') { return false } const etRaw = (typeof body.eventType === 'string' && body.eventType) || (typeof body.simEventType === 'string' && body.simEventType) || '' if (!etRaw.trim()) { return Boolean(gotType && normalizeToken(gotType) === 'opportunity') } return OPP_STAGE.has(normalizeToken(etRaw)) } if (triggerId === 'salesforce_case_status_changed') { if (gotType && normalizeToken(gotType) !== 'case') { return false } const etRaw = (typeof body.eventType === 'string' && body.eventType) || (typeof body.simEventType === 'string' && body.simEventType) || '' if (!etRaw.trim()) { return Boolean(gotType && normalizeToken(gotType) === 'case') } return CASE_STATUS.has(normalizeToken(etRaw)) } const etRaw = (typeof body.eventType === 'string' && body.eventType) || (typeof body.simEventType === 'string' && body.simEventType) || '' if (!etRaw.trim()) { return false } const normalized = normalizeToken(etRaw) return matchesRecordTrigger(triggerId, normalized) } /** * Generates HTML setup instructions for the Salesforce trigger. * Use Flow HTTP Callouts with a JSON body. Outbound Messages are SOAP/XML and are not supported. */ export function salesforceSetupInstructions(eventType: string): string { const isGeneric = eventType === 'All Events' const instructions = isGeneric ? [ 'Copy the Webhook URL above and generate a Webhook Secret (any strong random string). Paste the secret in the Webhook Secret field here.', "In Salesforce, go to Setup → Named Credentials. Create an External Credential with a custom Authentication Parameter holding your secret, then a Named Credential whose URL is the Webhook URL above, with a custom header Authorization set to {!'Bearer ' & $Credential.YourExternalCredential.YourParameter} (or a header named X-Sim-Webhook-Secret with the raw secret). When adding that custom header, check Allow Formulas in HTTP Header — if left unchecked, Salesforce sends the literal {!$Credential…} text instead of evaluating it, and authentication silently fails. Flow’s HTTP Callout action requires a Named Credential — it cannot call an arbitrary URL directly.", 'Under the External Credential, add a Permission Set and enable External Credential Principal Access for its principal, then assign that permission set to the user the Flow runs as (the Automated Process user for background/record-triggered flows). Without this the callout fails authentication even though the Named Credential is configured correctly.', 'Go to Setup → Flows and click New Flow.', 'Select Record-Triggered Flow and choose the object(s) you want to monitor.', 'Add an Action that performs an HTTP Callout using the Named Credential from step 2 — method POST, Content-Type: application/json, path / (the base URL already points at your webhook). Record-triggered flows can only make callouts on the Run Asynchronously path, so place this action there.', 'Build the request body as JSON (not SOAP/XML). Include eventType and record fields (e.g. Id, Name). Outbound Messages use SOAP and will not work with this trigger.', 'Save and Activate the Flow(s).', 'Save this trigger in Sim first so the URL is registered before you build the Named Credential and Flow.', 'Click "Save" above to activate your trigger.', ] : [ 'Copy the Webhook URL above and set a Webhook Secret. In Salesforce, create an External Credential (holding the secret) and a Named Credential whose URL is this Webhook URL, with a custom header sending the same value as Authorization: Bearer … or X-Sim-Webhook-Secret: …. If the header value uses a {!$Credential…} formula, check Allow Formulas in HTTP Header when adding it — otherwise Salesforce sends the literal formula text instead of the secret. Flow’s HTTP Callout action requires a Named Credential — it cannot call an arbitrary URL directly.', 'Under the External Credential, add a Permission Set with External Credential Principal Access enabled and assign it to the user the Flow runs as (the Automated Process user for record-triggered flows) — otherwise the callout fails authentication.', 'Go to Setup → Flows and click New Flow.', `Select Record-Triggered Flow for the right object and ${eventType} as the entry condition.`, 'Add an HTTP Callout using the Named Credential from step 1 — POST, JSON body, path /. Record-triggered flows can only make callouts on the Run Asynchronously path, so place this action there.', `Include eventType in the JSON body using a value this trigger accepts (e.g. for Record Created use record_created, created, or after_insert).`, 'If you use Object Type (Optional), you must also include matching type metadata in the JSON body (for example objectType, sobjectType, or attributes.type) or the event will be rejected.', 'Save and Activate the Flow.', 'Click "Save" above to activate your trigger.', ] return instructions .map( (instruction, index) => `
${index + 1}. ${instruction}
` ) .join('') } function salesforceWebhookSecretField(triggerId: string): SubBlockConfig { return { id: 'webhookSecret', title: 'Webhook Secret', type: 'short-input', placeholder: 'Generate a secret and paste it here', description: 'Required. Use the same value in your Salesforce HTTP Callout as Bearer token or X-Sim-Webhook-Secret.', password: true, required: true, mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, } } function salesforceObjectTypeField(triggerId: string): SubBlockConfig { return { id: 'objectType', title: 'Object Type (Optional)', type: 'short-input', placeholder: 'e.g., Account, Contact, Opportunity', description: 'When set, the payload must include matching object type metadata (for example objectType, sobjectType, or attributes.type) or the event is rejected.', mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, } } /** Secret + optional object filter (record triggers and generic webhook). */ export function buildSalesforceExtraFields(triggerId: string): SubBlockConfig[] { return [salesforceWebhookSecretField(triggerId), salesforceObjectTypeField(triggerId)] } /** Webhook secret only (Opportunity / Case specialized triggers — object is implied). */ export function buildSalesforceAuthOnlyFields(triggerId: string): SubBlockConfig[] { return [salesforceWebhookSecretField(triggerId)] } /** * Outputs for record lifecycle events (created, updated, deleted). */ export function buildSalesforceRecordOutputs(): Record { return { eventType: { type: 'string', description: 'The type of event (e.g., created, updated, deleted)', }, /** Present when the Flow JSON body uses `simEventType` instead of or in addition to `eventType`. */ simEventType: { type: 'string', description: 'Optional alias from the payload (`simEventType`). Empty when only `eventType` is sent.', }, objectType: { type: 'string', description: 'Salesforce object type (e.g., Account, Contact, Lead)', }, recordId: { type: 'string', description: 'ID of the affected record' }, timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, record: { Id: { type: 'string', description: 'Record ID' }, Name: { type: 'string', description: 'Record name' }, CreatedDate: { type: 'string', description: 'Record creation date' }, LastModifiedDate: { type: 'string', description: 'Last modification date' }, OwnerId: { type: 'string', description: 'Record owner ID (standard field when sent in the Flow body)', }, SystemModstamp: { type: 'string', description: 'System modstamp from the record (ISO 8601) when included in the payload', }, }, changedFields: { type: 'json', description: 'Fields that were changed (for update events)' }, payload: { type: 'json', description: 'Full webhook payload' }, } } /** * Outputs for opportunity stage change events. */ export function buildSalesforceOpportunityStageOutputs(): Record { return { eventType: { type: 'string', description: 'The type of event' }, simEventType: { type: 'string', description: 'Optional alias from the payload (`simEventType`). Empty when only `eventType` is sent.', }, objectType: { type: 'string', description: 'Salesforce object type (Opportunity)' }, recordId: { type: 'string', description: 'Opportunity ID' }, timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, record: { Id: { type: 'string', description: 'Opportunity ID' }, Name: { type: 'string', description: 'Opportunity name' }, StageName: { type: 'string', description: 'Current stage name' }, Amount: { type: 'string', description: 'Deal amount' }, CloseDate: { type: 'string', description: 'Expected close date' }, Probability: { type: 'string', description: 'Win probability' }, AccountId: { type: 'string', description: 'Related Account ID (standard Opportunity field)' }, OwnerId: { type: 'string', description: 'Opportunity owner ID' }, }, previousStage: { type: 'string', description: 'Previous stage name' }, newStage: { type: 'string', description: 'New stage name' }, payload: { type: 'json', description: 'Full webhook payload' }, } } /** * Outputs for case status change events. */ export function buildSalesforceCaseStatusOutputs(): Record { return { eventType: { type: 'string', description: 'The type of event' }, simEventType: { type: 'string', description: 'Optional alias from the payload (`simEventType`). Empty when only `eventType` is sent.', }, objectType: { type: 'string', description: 'Salesforce object type (Case)' }, recordId: { type: 'string', description: 'Case ID' }, timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, record: { Id: { type: 'string', description: 'Case ID' }, Subject: { type: 'string', description: 'Case subject' }, Status: { type: 'string', description: 'Current case status' }, Priority: { type: 'string', description: 'Case priority' }, CaseNumber: { type: 'string', description: 'Case number' }, AccountId: { type: 'string', description: 'Related Account ID' }, ContactId: { type: 'string', description: 'Related Contact ID' }, OwnerId: { type: 'string', description: 'Case owner ID' }, }, previousStatus: { type: 'string', description: 'Previous case status' }, newStatus: { type: 'string', description: 'New case status' }, payload: { type: 'json', description: 'Full webhook payload' }, } } /** * Outputs for the generic webhook trigger. */ export function buildSalesforceWebhookOutputs(): Record { return { eventType: { type: 'string', description: 'The type of event' }, simEventType: { type: 'string', description: 'Optional alias from the payload (`simEventType`). Empty when only `eventType` is sent.', }, objectType: { type: 'string', description: 'Salesforce object type' }, recordId: { type: 'string', description: 'ID of the affected record' }, timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, record: { type: 'json', description: 'Full record data' }, payload: { type: 'json', description: 'Full webhook payload' }, } }