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
321 lines
10 KiB
TypeScript
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)}`
|
|
},
|
|
}
|