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
315 lines
13 KiB
TypeScript
315 lines
13 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
|
|
const logger = createLogger('SalesforceUtils')
|
|
|
|
/**
|
|
* Extracts Salesforce instance URL from ID token or uses provided instance URL
|
|
* @param idToken - The Salesforce ID token containing instance URL
|
|
* @param instanceUrl - Direct instance URL if provided
|
|
* @returns The Salesforce instance URL
|
|
* @throws Error if instance URL cannot be determined
|
|
*/
|
|
export function getInstanceUrl(idToken?: string, instanceUrl?: string): string {
|
|
if (instanceUrl) return instanceUrl
|
|
if (idToken) {
|
|
try {
|
|
const base64Url = idToken.split('.')[1]
|
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
|
const jsonPayload = decodeURIComponent(
|
|
atob(base64)
|
|
.split('')
|
|
.map((c) => `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`)
|
|
.join('')
|
|
)
|
|
const decoded = JSON.parse(jsonPayload)
|
|
if (decoded.profile) {
|
|
const match = decoded.profile.match(/^(https:\/\/[^/]+)/)
|
|
if (match) return match[1]
|
|
} else if (decoded.sub) {
|
|
const match = decoded.sub.match(/^(https:\/\/[^/]+)/)
|
|
if (match && match[1] !== 'https://login.salesforce.com') return match[1]
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to decode Salesforce idToken', { error })
|
|
}
|
|
}
|
|
throw new Error('Salesforce instance URL is required but not provided')
|
|
}
|
|
|
|
/**
|
|
* Trims a record ID and throws if it is missing or whitespace-only.
|
|
* Prevents whitespace-only IDs from collapsing into an empty URL path segment
|
|
* (e.g. `/sobjects/Account/`) and hitting Salesforce with a malformed request.
|
|
* @param value - The raw ID value from params
|
|
* @param label - Human-readable field name used in the error message
|
|
* @returns The trimmed, non-empty ID
|
|
* @throws Error if the ID is absent or whitespace-only
|
|
*/
|
|
export function requireId(value: string | undefined, label: string): string {
|
|
const trimmed = value?.trim()
|
|
if (!trimmed) {
|
|
throw new Error(`${label} is required. Please provide a valid Salesforce ${label}.`)
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
/**
|
|
* Ensures a custom field/object API name carries the required `__c` suffix.
|
|
* Salesforce metadata components created via the Tooling API must end in `__c`;
|
|
* users commonly omit it, so we append it when missing.
|
|
* @param value - The raw API name from params (e.g. "Region" or "Region__c")
|
|
* @param label - Human-readable field name used in the error message
|
|
* @returns The trimmed API name guaranteed to end with `__c`
|
|
* @throws Error if the name is absent or whitespace-only
|
|
*/
|
|
export function toCustomApiName(value: string | undefined, label: string): string {
|
|
const trimmed = value?.trim()
|
|
if (!trimmed) {
|
|
throw new Error(`${label} is required. Please provide a valid Salesforce API name.`)
|
|
}
|
|
return trimmed.endsWith('__c') ? trimmed : `${trimmed}__c`
|
|
}
|
|
|
|
/**
|
|
* Normalizes a boolean-ish param value into a real boolean.
|
|
* Tool params arrive as actual booleans from the LLM or as strings from block
|
|
* inputs; this collapses both forms and treats empty values as "unset".
|
|
* @param value - The raw param value
|
|
* @returns The boolean value, or undefined when the param was not provided
|
|
*/
|
|
export function normalizeBoolean(value: unknown): boolean | undefined {
|
|
if (value === undefined || value === null || value === '') return undefined
|
|
if (typeof value === 'boolean') return value
|
|
if (typeof value === 'string') return value.trim().toLowerCase() === 'true'
|
|
return Boolean(value)
|
|
}
|
|
|
|
/**
|
|
* Parses a comma-separated list into trimmed, non-empty entries.
|
|
* Used for picklist value sets supplied as a single delimited string.
|
|
* @param value - The raw comma-separated string
|
|
* @returns An array of trimmed values (empty when nothing parseable is present)
|
|
*/
|
|
export function parseDelimitedList(value: string | undefined): string[] {
|
|
if (!value) return []
|
|
return value
|
|
.split(',')
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0)
|
|
}
|
|
|
|
/**
|
|
* Shape of the custom field metadata inputs accepted from tool params.
|
|
* Numeric dimensions arrive as real numbers from the LLM (param `type: 'number'`)
|
|
* or as strings from block inputs, so both forms are accepted.
|
|
*/
|
|
export interface CustomFieldMetadataInput {
|
|
fieldType?: string
|
|
label?: string
|
|
length?: number | string
|
|
precision?: number | string
|
|
scale?: number | string
|
|
visibleLines?: number | string
|
|
required?: boolean | string
|
|
unique?: boolean | string
|
|
externalId?: boolean | string
|
|
defaultValue?: string
|
|
description?: string
|
|
inlineHelpText?: string
|
|
picklistValues?: string
|
|
}
|
|
|
|
/**
|
|
* Coerces a numeric-ish metadata value (number or string) into a number.
|
|
* @returns The parsed number, or undefined when unset or unparseable
|
|
*/
|
|
function toFieldNumber(value?: number | string): number | undefined {
|
|
if (value === undefined || value === null || String(value).trim() === '') return undefined
|
|
const parsed = Number(value)
|
|
return Number.isNaN(parsed) ? undefined : parsed
|
|
}
|
|
|
|
/**
|
|
* Overlays only the explicitly-provided custom field properties onto `target`,
|
|
* leaving any property the caller did not supply untouched. Shared by create
|
|
* (onto a fresh object) and update (onto the field's existing metadata), so an
|
|
* update never fabricates values for omitted properties.
|
|
* @param target - The metadata object to mutate in place
|
|
* @param params - The provided custom field metadata inputs
|
|
*/
|
|
function applyProvidedFieldMetadata(
|
|
target: Record<string, any>,
|
|
params: CustomFieldMetadataInput
|
|
): void {
|
|
if (params.fieldType?.trim()) target.type = params.fieldType.trim()
|
|
if (params.label?.trim()) target.label = params.label.trim()
|
|
|
|
const length = toFieldNumber(params.length)
|
|
if (length !== undefined) target.length = length
|
|
const precision = toFieldNumber(params.precision)
|
|
if (precision !== undefined) target.precision = precision
|
|
const scale = toFieldNumber(params.scale)
|
|
if (scale !== undefined) target.scale = scale
|
|
const visibleLines = toFieldNumber(params.visibleLines)
|
|
if (visibleLines !== undefined) target.visibleLines = visibleLines
|
|
|
|
const required = normalizeBoolean(params.required)
|
|
if (required !== undefined) target.required = required
|
|
const unique = normalizeBoolean(params.unique)
|
|
if (unique !== undefined) target.unique = unique
|
|
const externalId = normalizeBoolean(params.externalId)
|
|
if (externalId !== undefined) target.externalId = externalId
|
|
|
|
if (params.description?.trim()) target.description = params.description.trim()
|
|
if (params.inlineHelpText?.trim()) target.inlineHelpText = params.inlineHelpText.trim()
|
|
|
|
if (params.defaultValue !== undefined && String(params.defaultValue).trim() !== '') {
|
|
target.defaultValue =
|
|
target.type === 'Checkbox'
|
|
? (normalizeBoolean(params.defaultValue) ?? false)
|
|
: params.defaultValue
|
|
}
|
|
|
|
const picklistValues = parseDelimitedList(params.picklistValues)
|
|
if (picklistValues.length > 0) {
|
|
// Union with any existing values so an update adds new options without
|
|
// dropping the field's current values (or their default flags).
|
|
const existingValues: Array<Record<string, any>> = Array.isArray(
|
|
target.valueSet?.valueSetDefinition?.value
|
|
)
|
|
? target.valueSet.valueSetDefinition.value
|
|
: []
|
|
const existingFullNames = new Set(existingValues.map((entry) => entry.fullName))
|
|
const additions = picklistValues
|
|
.filter((value) => !existingFullNames.has(value))
|
|
.map((value) => ({ fullName: value, default: false, label: value }))
|
|
target.valueSet = {
|
|
...(target.valueSet ?? {}),
|
|
valueSetDefinition: {
|
|
...(target.valueSet?.valueSetDefinition ?? {}),
|
|
sorted: target.valueSet?.valueSetDefinition?.sorted ?? false,
|
|
value: [...existingValues, ...additions],
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies type-specific defaults required by Salesforce when the caller did not
|
|
* supply them, so common field types work out of the box on create.
|
|
* @param metadata - The metadata object to mutate in place (must have a `type`)
|
|
*/
|
|
function applyFieldTypeDefaults(metadata: Record<string, any>): void {
|
|
const fieldType = metadata.type
|
|
if (fieldType === 'Text' && metadata.length === undefined) {
|
|
metadata.length = 255
|
|
}
|
|
if (fieldType === 'LongTextArea' || fieldType === 'Html') {
|
|
if (metadata.length === undefined) metadata.length = 32768
|
|
if (metadata.visibleLines === undefined) metadata.visibleLines = 3
|
|
}
|
|
if (fieldType === 'MultiselectPicklist') {
|
|
if (metadata.visibleLines === undefined) metadata.visibleLines = 4
|
|
// Salesforce requires `length` (total characters across selected values) for
|
|
// multi-select picklists in addition to visibleLines.
|
|
if (metadata.length === undefined) metadata.length = 255
|
|
}
|
|
if (fieldType === 'Number' || fieldType === 'Currency' || fieldType === 'Percent') {
|
|
if (metadata.precision === undefined) metadata.precision = 18
|
|
if (metadata.scale === undefined) metadata.scale = 0
|
|
}
|
|
// Checkbox fields require a default value; Salesforce rejects them without one.
|
|
if (fieldType === 'Checkbox' && metadata.defaultValue === undefined) {
|
|
metadata.defaultValue = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds the `Metadata` object for a Tooling API CustomField create body.
|
|
* Applies type-specific defaults so common field types work without the caller
|
|
* supplying every property (e.g. Text defaults to length 255).
|
|
* @param params - The custom field metadata params
|
|
* @param fallbackLabel - Label to use when none is provided
|
|
* @returns The Salesforce CustomField Metadata object
|
|
* @throws Error if the field type is missing
|
|
* @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customfield.htm
|
|
*/
|
|
export function buildCustomFieldMetadata(
|
|
params: CustomFieldMetadataInput,
|
|
fallbackLabel: string
|
|
): Record<string, any> {
|
|
const fieldType = params.fieldType?.trim()
|
|
if (!fieldType) {
|
|
throw new Error('Field Type is required (e.g., Text, Number, Checkbox, Date, Picklist).')
|
|
}
|
|
|
|
const metadata: Record<string, any> = {
|
|
type: fieldType,
|
|
label: params.label?.trim() || fallbackLabel,
|
|
}
|
|
applyProvidedFieldMetadata(metadata, params)
|
|
applyFieldTypeDefaults(metadata)
|
|
return metadata
|
|
}
|
|
|
|
/**
|
|
* Merges caller-provided custom field changes onto a field's existing metadata
|
|
* for a Tooling API update. The Tooling API PATCH replaces the whole `Metadata`
|
|
* compound, so we start from the field's current metadata (read first) and
|
|
* overlay only what changed — never fabricating defaults or labels that would
|
|
* silently clobber unspecified properties.
|
|
* @param existing - The field's current `Metadata` object (from a GET)
|
|
* @param params - The provided custom field changes
|
|
* @returns The merged Salesforce CustomField Metadata object
|
|
* @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customfield.htm
|
|
*/
|
|
export function mergeCustomFieldMetadata(
|
|
existing: Record<string, any> | undefined,
|
|
params: CustomFieldMetadataInput
|
|
): Record<string, any> {
|
|
const metadata: Record<string, any> = { ...(existing ?? {}) }
|
|
// An attribute update never changes the field's data type — Salesforce treats
|
|
// a type change as a separate, conversion-driven operation. Keep the field's
|
|
// existing type and overlay only the other provided properties.
|
|
applyProvidedFieldMetadata(metadata, { ...params, fieldType: undefined })
|
|
return metadata
|
|
}
|
|
|
|
/**
|
|
* Extracts a descriptive error message from Salesforce API responses
|
|
* @param data - The response data from Salesforce API
|
|
* @param status - HTTP status code
|
|
* @param defaultMessage - Default message to use if no specific error found
|
|
* @returns Formatted error message
|
|
*/
|
|
export function extractErrorMessage(data: any, status: number, defaultMessage: string): string {
|
|
if (Array.isArray(data) && data[0]?.message) {
|
|
return `Salesforce API Error (${status}): ${data[0].message}${data[0].errorCode ? ` [${data[0].errorCode}]` : ''}`
|
|
}
|
|
// Tooling API metadata writes return { success: false, errors: [{ message, statusCode }] }
|
|
if (Array.isArray(data?.errors) && data.errors[0]?.message) {
|
|
const first = data.errors[0]
|
|
return `Salesforce API Error (${status}): ${first.message}${first.statusCode ? ` [${first.statusCode}]` : ''}`
|
|
}
|
|
if (data?.message) {
|
|
return `Salesforce API Error (${status}): ${data.message}`
|
|
}
|
|
if (data?.error) {
|
|
return `Salesforce API Error (${status}): ${data.error}${data.error_description ? ` - ${data.error_description}` : ''}`
|
|
}
|
|
switch (status) {
|
|
case 400:
|
|
return `Salesforce API Error (400): Bad Request - The request was malformed or missing required parameters`
|
|
case 401:
|
|
return `Salesforce API Error (401): Unauthorized - Invalid or expired access token. Please re-authenticate.`
|
|
case 403:
|
|
return `Salesforce API Error (403): Forbidden - You do not have permission to access this resource.`
|
|
case 404:
|
|
return `Salesforce API Error (404): Not Found - The requested resource does not exist or you do not have access to it.`
|
|
case 500:
|
|
return `Salesforce API Error (500): Internal Server Error - An error occurred on Salesforce's servers.`
|
|
default:
|
|
return `${defaultMessage} (HTTP ${status})`
|
|
}
|
|
}
|