Files
simstudioai--sim/apps/sim/lib/webhooks/providers/salesforce.ts
T
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

321 lines
10 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import type {
AuthContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { buildFallbackDeliveryFingerprint, verifyTokenAuth } from '@/lib/webhooks/providers/utils'
export function extractSalesforceObjectTypeFromPayload(
body: Record<string, unknown>
): string | undefined {
const direct =
(typeof body.objectType === 'string' && body.objectType) ||
(typeof body.sobjectType === 'string' && body.sobjectType) ||
undefined
if (direct) {
return direct
}
const attrs = body.attributes as Record<string, unknown> | undefined
if (typeof attrs?.type === 'string') {
return attrs.type
}
const record = body.record
if (record && typeof record === 'object' && !Array.isArray(record)) {
const r = record as Record<string, unknown>
if (typeof r.sobjectType === 'string') {
return r.sobjectType
}
const ra = r.attributes as Record<string, unknown> | undefined
if (typeof ra?.type === 'string') {
return ra.type
}
}
return undefined
}
const logger = createLogger('WebhookProvider:Salesforce')
function verifySalesforceSharedSecret(request: Request, secret: string): boolean {
if (verifyTokenAuth(request, secret, 'x-sim-webhook-secret')) {
return true
}
return verifyTokenAuth(request, secret)
}
function asRecord(body: unknown): Record<string, unknown> {
return body && typeof body === 'object' && !Array.isArray(body)
? (body as Record<string, unknown>)
: {}
}
function extractRecordCore(body: Record<string, unknown>): Record<string, unknown> {
const nested = body.record
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
return { ...(nested as Record<string, unknown>) }
}
const skip = new Set([
'eventType',
'simEventType',
'changedFields',
'previousStage',
'newStage',
'previousStatus',
'newStatus',
'payload',
'record',
])
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(body)) {
if (!skip.has(k)) {
out[k] = v
}
}
return out
}
function pickTimestamp(body: Record<string, unknown>, record: Record<string, unknown>): string {
const candidates = [
body.timestamp,
body.time,
record.SystemModstamp,
record.LastModifiedDate,
record.CreatedDate,
]
for (const c of candidates) {
if (typeof c === 'string' && c.length > 0) {
return c
}
}
return ''
}
function pickRecordId(body: Record<string, unknown>, record: Record<string, unknown>): string {
const id =
(typeof body.recordId === 'string' && body.recordId) ||
(typeof record.Id === 'string' && record.Id) ||
(typeof body.Id === 'string' && body.Id) ||
''
return id
}
function pickStr(record: Record<string, unknown>, key: string): string {
const v = record[key]
return typeof v === 'string' ? v : ''
}
export const salesforceHandler: WebhookProviderHandler = {
verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret?.trim()) {
logger.warn(`[${requestId}] Salesforce webhook missing webhookSecret — rejecting`)
return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 })
}
if (!verifySalesforceSharedSecret(request, secret.trim())) {
logger.warn(`[${requestId}] Salesforce webhook secret verification failed`)
return new NextResponse('Unauthorized - Invalid webhook secret', { status: 401 })
}
return null
},
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId) {
return true
}
const { isSalesforceEventMatch } = await import('@/triggers/salesforce/utils')
const configuredObjectType = providerConfig.objectType as string | undefined
const obj = asRecord(body)
if (!isSalesforceEventMatch(triggerId, obj, configuredObjectType)) {
logger.debug(
`[${requestId}] Salesforce event mismatch for trigger ${triggerId}. Skipping execution.`,
{ webhookId: webhook.id, workflowId: workflow.id, triggerId }
)
return false
}
return true
},
async formatInput(ctx: FormatInputContext): Promise<FormatInputResult> {
const rawPc = (ctx.webhook as { providerConfig?: unknown }).providerConfig
const pc =
rawPc && typeof rawPc === 'object' && !Array.isArray(rawPc)
? (rawPc as Record<string, unknown>)
: {}
const id = typeof pc.triggerId === 'string' ? pc.triggerId : ''
const body = asRecord(ctx.body)
const record = extractRecordCore(body)
const objectType =
extractSalesforceObjectTypeFromPayload(body) ||
(typeof record.attributes === 'object' &&
record.attributes &&
typeof (record.attributes as Record<string, unknown>).type === 'string'
? String((record.attributes as Record<string, unknown>).type)
: '') ||
(typeof record.sobjectType === 'string' ? record.sobjectType : '')
const recordId = pickRecordId(body, record)
const timestamp = pickTimestamp(body, record)
const eventTypeRaw =
(typeof body.eventType === 'string' && body.eventType) ||
(typeof body.simEventType === 'string' && body.simEventType) ||
''
const simEventTypeRaw = typeof body.simEventType === 'string' ? body.simEventType : ''
if (id === 'salesforce_webhook') {
return {
input: {
eventType: eventTypeRaw || 'webhook',
objectType: objectType || '',
recordId,
timestamp,
simEventType: simEventTypeRaw,
record: Object.keys(record).length > 0 ? record : body,
payload: ctx.body,
},
}
}
if (
id === 'salesforce_record_created' ||
id === 'salesforce_record_updated' ||
id === 'salesforce_record_deleted'
) {
const changedFields = body.changedFields
return {
input: {
eventType: eventTypeRaw || id.replace('salesforce_', '').replace(/_/g, ' '),
objectType: objectType || '',
recordId,
timestamp,
simEventType: simEventTypeRaw,
record: {
Id: typeof record.Id === 'string' ? record.Id : recordId,
Name: typeof record.Name === 'string' ? record.Name : '',
CreatedDate: typeof record.CreatedDate === 'string' ? record.CreatedDate : '',
LastModifiedDate:
typeof record.LastModifiedDate === 'string' ? record.LastModifiedDate : '',
OwnerId: pickStr(record, 'OwnerId'),
SystemModstamp: pickStr(record, 'SystemModstamp'),
},
changedFields: changedFields !== undefined ? changedFields : null,
payload: ctx.body,
},
}
}
if (id === 'salesforce_opportunity_stage_changed') {
return {
input: {
eventType: eventTypeRaw || 'opportunity_stage_changed',
objectType: objectType || 'Opportunity',
recordId,
timestamp,
simEventType: simEventTypeRaw,
record: {
Id: typeof record.Id === 'string' ? record.Id : recordId,
Name: typeof record.Name === 'string' ? record.Name : '',
StageName: typeof record.StageName === 'string' ? record.StageName : '',
Amount: record.Amount !== undefined ? String(record.Amount) : '',
CloseDate: typeof record.CloseDate === 'string' ? record.CloseDate : '',
Probability: record.Probability !== undefined ? String(record.Probability) : '',
AccountId: pickStr(record, 'AccountId'),
OwnerId: pickStr(record, 'OwnerId'),
},
previousStage:
typeof body.previousStage === 'string'
? body.previousStage
: typeof body.PriorStage === 'string'
? body.PriorStage
: '',
newStage:
typeof body.newStage === 'string'
? body.newStage
: typeof record.StageName === 'string'
? record.StageName
: '',
payload: ctx.body,
},
}
}
if (id === 'salesforce_case_status_changed') {
return {
input: {
eventType: eventTypeRaw || 'case_status_changed',
objectType: objectType || 'Case',
recordId,
timestamp,
simEventType: simEventTypeRaw,
record: {
Id: typeof record.Id === 'string' ? record.Id : recordId,
Subject: typeof record.Subject === 'string' ? record.Subject : '',
Status: typeof record.Status === 'string' ? record.Status : '',
Priority: typeof record.Priority === 'string' ? record.Priority : '',
CaseNumber: typeof record.CaseNumber === 'string' ? record.CaseNumber : '',
AccountId: pickStr(record, 'AccountId'),
ContactId: pickStr(record, 'ContactId'),
OwnerId: pickStr(record, 'OwnerId'),
},
previousStatus:
typeof body.previousStatus === 'string'
? body.previousStatus
: typeof body.PriorStatus === 'string'
? body.PriorStatus
: '',
newStatus:
typeof body.newStatus === 'string'
? body.newStatus
: typeof record.Status === 'string'
? record.Status
: '',
payload: ctx.body,
},
}
}
return {
input: {
eventType: eventTypeRaw || 'webhook',
objectType: objectType || '',
recordId,
timestamp,
simEventType: simEventTypeRaw,
record: Object.keys(record).length > 0 ? record : body,
payload: ctx.body,
},
}
},
extractIdempotencyId(body: unknown): string | null {
const b = asRecord(body)
const record = extractRecordCore(b)
const id = pickRecordId(b, record)
const et =
(typeof b.eventType === 'string' && b.eventType) ||
(typeof b.simEventType === 'string' && b.simEventType) ||
''
const ts = pickTimestamp(b, record)
if (!id) {
return null
}
if (ts) {
return `salesforce:${et || 'event'}:${id}:${ts}`
}
return `salesforce:${et || 'event'}:${id}:${buildFallbackDeliveryFingerprint(b)}`
},
}