Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

342 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>,
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 <strong>Webhook URL</strong> above and generate a <strong>Webhook Secret</strong> (any strong random string). Paste the secret in the <strong>Webhook Secret</strong> field here.',
"In Salesforce, go to <strong>Setup → Named Credentials</strong>. Create an <strong>External Credential</strong> with a custom Authentication Parameter holding your secret, then a <strong>Named Credential</strong> whose URL is the Webhook URL above, with a custom header <code>Authorization</code> set to <code>{!'Bearer ' & $Credential.YourExternalCredential.YourParameter}</code> (or a header named <code>X-Sim-Webhook-Secret</code> with the raw secret). When adding that custom header, check <strong>Allow Formulas in HTTP Header</strong> — if left unchecked, Salesforce sends the literal <code>{!$Credential…}</code> text instead of evaluating it, and authentication silently fails. Flows HTTP Callout action requires a Named Credential — it cannot call an arbitrary URL directly.",
'Under the External Credential, add a <strong>Permission Set</strong> and enable <strong>External Credential Principal Access</strong> for its principal, then assign that permission set to the user the Flow runs as (the <strong>Automated Process</strong> user for background/record-triggered flows). Without this the callout fails authentication even though the Named Credential is configured correctly.',
'Go to <strong>Setup → Flows</strong> and click <strong>New Flow</strong>.',
'Select <strong>Record-Triggered Flow</strong> and choose the object(s) you want to monitor.',
'Add an <strong>Action</strong> that performs an <strong>HTTP Callout</strong> using the Named Credential from step 2 — method <strong>POST</strong>, <code>Content-Type: application/json</code>, path <code>/</code> (the base URL already points at your webhook). Record-triggered flows can only make callouts on the <strong>Run Asynchronously</strong> path, so place this action there.',
'Build the request body as <strong>JSON</strong> (not SOAP/XML). Include <code>eventType</code> and record fields (e.g. <code>Id</code>, <code>Name</code>). Outbound Messages use SOAP and will not work with this trigger.',
'Save and <strong>Activate</strong> the Flow(s).',
'<strong>Save this trigger in Sim first</strong> so the URL is registered before you build the Named Credential and Flow.',
'Click <strong>"Save"</strong> above to activate your trigger.',
]
: [
'Copy the <strong>Webhook URL</strong> above and set a <strong>Webhook Secret</strong>. In Salesforce, create an <strong>External Credential</strong> (holding the secret) and a <strong>Named Credential</strong> whose URL is this Webhook URL, with a custom header sending the same value as <code>Authorization: Bearer …</code> or <code>X-Sim-Webhook-Secret: …</code>. If the header value uses a <code>{!$Credential…}</code> formula, check <strong>Allow Formulas in HTTP Header</strong> when adding it — otherwise Salesforce sends the literal formula text instead of the secret. Flows HTTP Callout action requires a Named Credential — it cannot call an arbitrary URL directly.',
'Under the External Credential, add a <strong>Permission Set</strong> with <strong>External Credential Principal Access</strong> enabled and assign it to the user the Flow runs as (the <strong>Automated Process</strong> user for record-triggered flows) — otherwise the callout fails authentication.',
'Go to <strong>Setup → Flows</strong> and click <strong>New Flow</strong>.',
`Select <strong>Record-Triggered Flow</strong> for the right object and <strong>${eventType}</strong> as the entry condition.`,
'Add an <strong>HTTP Callout</strong> using the Named Credential from step 1 — <strong>POST</strong>, JSON body, path <code>/</code>. Record-triggered flows can only make callouts on the <strong>Run Asynchronously</strong> path, so place this action there.',
`Include <code>eventType</code> in the JSON body using a value this trigger accepts (e.g. for Record Created use <code>record_created</code>, <code>created</code>, or <code>after_insert</code>).`,
'If you use <strong>Object Type (Optional)</strong>, you must also include matching type metadata in the JSON body (for example <code>objectType</code>, <code>sobjectType</code>, or <code>attributes.type</code>) or the event will be rejected.',
'Save and <strong>Activate</strong> the Flow.',
'Click <strong>"Save"</strong> above to activate your trigger.',
]
return instructions
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.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<string, TriggerOutput> {
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<string, TriggerOutput> {
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<string, TriggerOutput> {
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<string, TriggerOutput> {
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' },
}
}