diff --git a/.cursor/rules/sim-integrations.mdc b/.cursor/rules/sim-integrations.mdc index 309cd6a4ea6..e6e7d7b5959 100644 --- a/.cursor/rules/sim-integrations.mdc +++ b/.cursor/rules/sim-integrations.mdc @@ -210,9 +210,9 @@ export function {Service}Icon(props: SVGProps) { ``` triggers/{service}/ ├── index.ts # Export all triggers -├── webhook.ts # Webhook handler -├── utils.ts # Shared utilities -└── {event}.ts # Specific event handlers +├── utils.ts # Trigger options, setup instructions, event matching, output builders +├── webhook.ts # Generic webhook (catch-all) +└── {event}.ts # Event-specific triggers (e.g., employee_hired.ts) ``` **Register in `triggers/registry.ts`:** @@ -223,6 +223,59 @@ import { {service}WebhookTrigger } from '@/triggers/{service}' {service}_webhook: {service}WebhookTrigger, ``` +### External Subscription Lifecycle + +If the service supports programmatic webhook/subscription creation, implement auto-registration in `lib/webhooks/provider-subscriptions.ts`: + +```typescript +// Create subscription on deploy +export async function create{Service}Subscription( + webhookData: Record, + requestId: string +): Promise<{ id: string } | undefined> { + const { path, providerConfig } = webhookData + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + // Call external API to register webhook URL... + return { id: externalSubscriptionId } +} + +// Delete/disable subscription on undeploy +export async function delete{Service}Subscription( + webhook: Record, + requestId: string +): Promise { + const { externalId } = webhook.providerConfig + // Call external API to delete/disable subscription... +} +``` + +Then add branches in the dispatcher functions: + +```typescript +// In createExternalWebhookSubscription(): +} else if (provider === '{service}') { + const result = await create{Service}Subscription(webhookData, requestId) + if (result) { + updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id } + externalSubscriptionCreated = true + } +} + +// In cleanupExternalWebhook(): +} else if (webhook.provider === '{service}') { + await delete{Service}Subscription(webhook, requestId) +} +``` + +Add credential sub-blocks (API key, tenant URL, etc.) to trigger sub-blocks so the deploy flow can call the external API. Credentials are stored in `providerConfig` on the webhook DB row. + +### SOAP/XML Webhooks + +If the service sends XML/SOAP notifications, the webhook processor handles this automatically via `text/xml`, `application/xml`, `application/soap+xml` content types using `fast-xml-parser`. Use helpers from `lib/webhooks/soap-utils.ts`: + +- `extractSoapBody()` — unwraps SOAP envelope +- `stripNamespacePrefixes()` — strips `wd:`, `env:` etc. from keys + ## File Handling When integrations handle file uploads/downloads, use `UserFile` objects consistently. @@ -281,5 +334,7 @@ const file = await processor.processFileData({ - [ ] Register block in `blocks/registry.ts` - [ ] (Optional) Create triggers in `triggers/{service}/` - [ ] (Optional) Register triggers in `triggers/registry.ts` +- [ ] (If triggers with external subscriptions) Add create/delete functions in `lib/webhooks/provider-subscriptions.ts` +- [ ] (If SOAP/XML webhooks) Use `extractSoapBody()` and `stripNamespacePrefixes()` from `lib/webhooks/soap-utils.ts` - [ ] (If file uploads) Create internal API route with `downloadFileFromStorage` - [ ] (If file uploads) Use `normalizeFileInput` in block config diff --git a/CLAUDE.md b/CLAUDE.md index 004252ac6a4..8ec05703d21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -364,12 +364,23 @@ export function ServiceIcon(props: SVGProps) { ``` triggers/{service}/ ├── index.ts # Barrel export -├── webhook.ts # Webhook handler -└── {event}.ts # Event-specific handlers +├── utils.ts # Trigger options, setup instructions, event matching +├── webhook.ts # Generic webhook (catch-all) +└── {event}.ts # Event-specific triggers ``` Register in `triggers/registry.ts`. +**External Subscription Lifecycle:** If the service supports programmatic webhook registration (e.g., Ashby `webhook.create`, Workday `Put_Subscription`, Attio webhooks API), implement auto-registration so users don't need to manually configure webhooks in the external service: + +1. Add `create{Service}Subscription()` and `delete{Service}Subscription()` in `lib/webhooks/provider-subscriptions.ts` +2. Add branches for your provider in `createExternalWebhookSubscription()` and `cleanupExternalWebhook()` +3. Add credential fields (API key, OAuth token, etc.) to trigger sub-blocks so the deploy flow can call the external API +4. Store the external subscription ID in `providerConfig.externalId` for cleanup on undeploy +5. Subscriptions are created during deploy (`saveTriggerWebhooksForDeploy`) and cleaned up during undeploy (`cleanupWebhooksForWorkflow`) + +**SOAP/XML Webhooks:** If the service sends XML/SOAP notifications instead of JSON, the webhook processor (`lib/webhooks/processor.ts`) automatically handles `text/xml`, `application/xml`, and `application/soap+xml` content types via `fast-xml-parser`. Use `extractSoapBody()` and `stripNamespacePrefixes()` from `lib/webhooks/soap-utils.ts` to clean the parsed payload for event matching. + ### Integration Checklist - [ ] Look up API docs @@ -379,5 +390,6 @@ Register in `triggers/registry.ts`. - [ ] Create block in `blocks/blocks/{service}.ts` - [ ] Register block in `blocks/registry.ts` - [ ] (Optional) Create and register triggers +- [ ] (If triggers with external subscriptions) Add create/delete functions in `lib/webhooks/provider-subscriptions.ts` - [ ] (If file uploads) Create internal API route with `downloadFileFromStorage` - [ ] (If file uploads) Use `normalizeFileInput` in block config diff --git a/apps/sim/blocks/blocks/workday.ts b/apps/sim/blocks/blocks/workday.ts index b55e4e7fc0f..b8a3a4f9db7 100644 --- a/apps/sim/blocks/blocks/workday.ts +++ b/apps/sim/blocks/blocks/workday.ts @@ -1,5 +1,6 @@ import { WorkdayIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { getTrigger } from '@/triggers' export const WorkdayBlock: BlockConfig = { type: 'workday', @@ -341,7 +342,20 @@ Output: {"businessTitle": "Senior Engineer"}`, condition: { field: 'operation', value: 'terminate_worker' }, mode: 'advanced', }, + ...getTrigger('workday_employee_hired').subBlocks, + ...getTrigger('workday_employee_terminated').subBlocks, + ...getTrigger('workday_job_changed').subBlocks, + ...getTrigger('workday_webhook').subBlocks, ], + triggers: { + enabled: true, + available: [ + 'workday_employee_hired', + 'workday_employee_terminated', + 'workday_job_changed', + 'workday_webhook', + ], + }, tools: { access: [ 'workday_get_worker', diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 48604026691..3105011dc3b 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -33,6 +33,7 @@ import { validateTypeformSignature, verifyProviderWebhook, } from '@/lib/webhooks/utils.server' +import { parseXmlToJson } from '@/lib/webhooks/xml-parser' import { executeWebhookJob } from '@/background/webhook-execution' import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' import { isConfluencePayloadMatch } from '@/triggers/confluence/utils' @@ -147,6 +148,12 @@ export async function parseWebhookBody( } else { body = Object.fromEntries(formData.entries()) } + } else if ( + contentType.includes('text/xml') || + contentType.includes('application/xml') || + contentType.includes('application/soap+xml') + ) { + body = parseXmlToJson(rawBody) } else { body = JSON.parse(rawBody) } @@ -1151,6 +1158,29 @@ export async function queueWebhookExecution( } } + if (foundWebhook.provider === 'workday') { + const providerConfig = (foundWebhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + + if (triggerId && triggerId !== 'workday_webhook') { + const { isWorkdayEventMatch } = await import('@/triggers/workday/utils') + if (!isWorkdayEventMatch(triggerId, body)) { + logger.debug( + `[${options.requestId}] Workday event mismatch for trigger ${triggerId}. Skipping execution.`, + { + webhookId: foundWebhook.id, + workflowId: foundWorkflow.id, + triggerId, + } + ) + return NextResponse.json({ + status: 'skipped', + reason: 'event_type_mismatch', + }) + } + } + } + if (foundWebhook.provider === 'hubspot') { const providerConfig = (foundWebhook.providerConfig as Record) || {} const triggerId = providerConfig.triggerId as string | undefined diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index c78538883b2..757f1596d3d 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -2136,6 +2136,12 @@ export async function createExternalWebhookSubscription( updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id } externalSubscriptionCreated = true } + } else if (provider === 'workday') { + const result = await createWorkdaySubscription(webhookData, requestId) + if (result) { + updatedProviderConfig = { ...updatedProviderConfig, externalId: result.subscriptionId } + externalSubscriptionCreated = true + } } return { updatedProviderConfig, externalSubscriptionCreated } @@ -2173,6 +2179,8 @@ export async function cleanupExternalWebhook( await deleteGrainWebhook(webhook, requestId) } else if (webhook.provider === 'lemlist') { await deleteLemlistWebhook(webhook, requestId) + } else if (webhook.provider === 'workday') { + await deleteWorkdaySubscription(webhook, requestId) } } @@ -2335,3 +2343,124 @@ export async function deleteAshbyWebhook(webhook: any, requestId: string): Promi ashbyLogger.warn(`[${requestId}] Error deleting Ashby webhook (non-fatal)`, error) } } + +const workdayLogger = createLogger('WorkdaySubscription') + +const WORKDAY_TRIGGER_EVENT_TYPES: Record = { + workday_employee_hired: ['Hire_Employee'], + workday_employee_terminated: ['Terminate_Employee'], + workday_job_changed: ['Change_Job'], +} + +/** + * Creates a webhook subscription in Workday via the Put_Subscription SOAP operation. + * Uses the Integrations service (v29.0) to register our webhook URL as a push endpoint. + */ +export async function createWorkdaySubscription( + webhookData: Record, + requestId: string +): Promise<{ subscriptionId: string } | undefined> { + try { + const { path, providerConfig } = webhookData + const config = (providerConfig as Record) || {} + const { tenantUrl, tenant, username, password, integrationSystemId, triggerId } = config + + if (!tenantUrl || !tenant || !username || !password || !integrationSystemId) { + workdayLogger.error(`[${requestId}] Missing Workday credentials for subscription creation`) + return undefined + } + + const { createWorkdaySoapClient, wdRef, extractRefId } = await import('@/tools/workday/soap') + + const client = await createWorkdaySoapClient( + tenantUrl as string, + tenant as string, + 'integrations', + username as string, + password as string + ) + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const subscriptionData: Record = { + Subscriber_Reference: wdRef('Integration_System_ID', integrationSystemId as string), + Endpoint_Info_Data: { + Web_Service_API_Version_Reference: wdRef('Version', 'v29.0'), + Subscriber_URL: notificationUrl, + }, + } + + const eventTypes = WORKDAY_TRIGGER_EVENT_TYPES[triggerId as string] + if (eventTypes) { + subscriptionData.Included_Transaction_Log_Type_Reference = eventTypes.map((et) => + wdRef('Business_Process_Type', et) + ) + } else { + subscriptionData.Subscribe_to_all_Business_Processes = true + } + + const [result] = await client.Put_SubscriptionAsync({ + Subscription_Data: subscriptionData, + }) + + const subscriptionId = extractRefId(result?.Subscription_Reference) + + if (subscriptionId) { + workdayLogger.info(`[${requestId}] Workday subscription created: ${subscriptionId}`) + return { subscriptionId } + } + + workdayLogger.warn(`[${requestId}] Workday subscription created but no ID returned`) + return undefined + } catch (error) { + workdayLogger.error(`[${requestId}] Failed to create Workday subscription`, { error }) + return undefined + } +} + +/** + * Disables a Workday webhook subscription by calling Put_Subscription + * with Disable_Endpoint set to true. + */ +export async function deleteWorkdaySubscription( + webhook: Record, + requestId: string +): Promise { + try { + const providerConfig = (webhook.providerConfig as Record) || {} + const { tenantUrl, tenant, username, password, externalId, integrationSystemId } = + providerConfig + + if (!tenantUrl || !tenant || !username || !password || !externalId) { + workdayLogger.warn( + `[${requestId}] Missing credentials for Workday subscription cleanup, skipping` + ) + return + } + + const { createWorkdaySoapClient, wdRef } = await import('@/tools/workday/soap') + + const client = await createWorkdaySoapClient( + tenantUrl as string, + tenant as string, + 'integrations', + username as string, + password as string + ) + + await client.Put_SubscriptionAsync({ + Subscription_Reference: wdRef('WID', externalId as string), + Subscription_Data: { + Subscriber_Reference: wdRef('Integration_System_ID', integrationSystemId as string), + Endpoint_Info_Data: { + Web_Service_API_Version_Reference: wdRef('Version', 'v29.0'), + Disable_Endpoint: true, + }, + }, + }) + + workdayLogger.info(`[${requestId}] Workday subscription ${externalId} disabled`) + } catch (error) { + workdayLogger.warn(`[${requestId}] Error disabling Workday subscription (non-fatal)`, error) + } +} diff --git a/apps/sim/lib/webhooks/soap-utils.ts b/apps/sim/lib/webhooks/soap-utils.ts new file mode 100644 index 00000000000..f3dbe4ca18c --- /dev/null +++ b/apps/sim/lib/webhooks/soap-utils.ts @@ -0,0 +1,60 @@ +export interface ParsedSoapEnvelope { + [key: string]: unknown +} + +/** + * Extracts the SOAP body content from a parsed XML-to-JSON object. + * Strips the Envelope and Body wrappers, returning the first operation element inside. + */ +export function extractSoapBody(parsed: ParsedSoapEnvelope): Record { + const envelope = findValueByLocalName(parsed, 'Envelope') as Record | undefined + if (!envelope) return parsed + + const body = findValueByLocalName(envelope, 'Body') as Record | undefined + if (!body) return envelope + + const operationKeys = Object.keys(body).filter((k) => !k.startsWith('@_')) + if (operationKeys.length === 1) { + return body[operationKeys[0]] as Record + } + + return body +} + +/** + * Recursively strips XML namespace prefixes from all object keys. + * "wd:Event_Name" becomes "Event_Name", "env:Body" becomes "Body". + */ +export function stripNamespacePrefixes(obj: unknown): unknown { + if (obj === null || obj === undefined) return obj + if (typeof obj !== 'object') return obj + + if (Array.isArray(obj)) { + return obj.map(stripNamespacePrefixes) + } + + const result: Record = {} + for (const [key, value] of Object.entries(obj as Record)) { + const strippedKey = key.includes(':') ? key.split(':').pop()! : key + result[strippedKey] = stripNamespacePrefixes(value) + } + return result +} + +/** + * Finds a value in an object by local name (ignoring namespace prefix). + * e.g., findValueByLocalName(obj, "Envelope") matches "env:Envelope", "soap:Envelope", "Envelope" + */ +function findValueByLocalName( + obj: Record, + localName: string +): unknown | undefined { + if (obj[localName] !== undefined) return obj[localName] + + for (const key of Object.keys(obj)) { + const local = key.includes(':') ? key.split(':').pop() : key + if (local === localName) return obj[key] + } + + return undefined +} diff --git a/apps/sim/lib/webhooks/xml-parser.ts b/apps/sim/lib/webhooks/xml-parser.ts new file mode 100644 index 00000000000..825812c34fd --- /dev/null +++ b/apps/sim/lib/webhooks/xml-parser.ts @@ -0,0 +1,17 @@ +import { XMLParser } from 'fast-xml-parser' + +const xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + textNodeName: '_text', + parseAttributeValue: true, + trimValues: true, +}) + +/** + * Parses an XML string into a JSON object. + * Attributes are preserved with '@_' prefix, text content uses '_text' key. + */ +export function parseXmlToJson(xml: string): Record { + return xmlParser.parse(xml) as Record +} diff --git a/apps/sim/package.json b/apps/sim/package.json index 6148884846a..96df99d671b 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -102,6 +102,7 @@ "drizzle-orm": "^0.44.5", "encoding": "0.1.13", "entities": "6.0.1", + "fast-xml-parser": "5.5.6", "ffmpeg-static": "5.3.0", "fluent-ffmpeg": "2.1.3", "framer-motion": "^12.5.0", diff --git a/apps/sim/tools/workday/soap.ts b/apps/sim/tools/workday/soap.ts index a1f269008f8..40fd1fa5a7a 100644 --- a/apps/sim/tools/workday/soap.ts +++ b/apps/sim/tools/workday/soap.ts @@ -8,6 +8,7 @@ const WORKDAY_SERVICES = { humanResources: { name: 'Human_Resources', version: 'v45.2' }, compensation: { name: 'Compensation', version: 'v45.0' }, recruiting: { name: 'Recruiting', version: 'v45.0' }, + integrations: { name: 'Integrations', version: 'v29.0' }, } as const export type WorkdayServiceKey = keyof typeof WORKDAY_SERVICES @@ -114,6 +115,14 @@ type SoapOperationFn = ( args: Record ) => Promise<[WorkdaySoapResult, string, Record, string]> +export interface WorkdaySubscriptionResult { + Subscription_Reference?: WorkdayReference +} + +type SoapSubscriptionFn = ( + args: Record +) => Promise<[WorkdaySubscriptionResult, string, Record, string]> + export interface WorkdayClient extends soap.Client { Get_WorkersAsync: SoapOperationFn Get_OrganizationsAsync: SoapOperationFn @@ -123,6 +132,7 @@ export interface WorkdayClient extends soap.Client { Terminate_EmployeeAsync: SoapOperationFn Change_Personal_InformationAsync: SoapOperationFn Put_Onboarding_Plan_AssignmentAsync: SoapOperationFn + Put_SubscriptionAsync: SoapSubscriptionFn } /** diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index b0c61fe8c1c..b4d8e4e8d06 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -172,6 +172,12 @@ import { webflowFormSubmissionTrigger, } from '@/triggers/webflow' import { whatsappWebhookTrigger } from '@/triggers/whatsapp' +import { + workdayEmployeeHiredTrigger, + workdayEmployeeTerminatedTrigger, + workdayJobChangedTrigger, + workdayWebhookTrigger, +} from '@/triggers/workday' export const TRIGGER_REGISTRY: TriggerRegistry = { slack_webhook: slackWebhookTrigger, @@ -320,4 +326,8 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { hubspot_ticket_deleted: hubspotTicketDeletedTrigger, hubspot_ticket_property_changed: hubspotTicketPropertyChangedTrigger, imap_poller: imapPollingTrigger, + workday_employee_hired: workdayEmployeeHiredTrigger, + workday_employee_terminated: workdayEmployeeTerminatedTrigger, + workday_job_changed: workdayJobChangedTrigger, + workday_webhook: workdayWebhookTrigger, } diff --git a/apps/sim/triggers/workday/employee_hired.ts b/apps/sim/triggers/workday/employee_hired.ts new file mode 100644 index 00000000000..3a6c50ac958 --- /dev/null +++ b/apps/sim/triggers/workday/employee_hired.ts @@ -0,0 +1,27 @@ +import { WorkdayIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildEmployeeHiredOutputs, buildWorkdaySubBlocks } from '@/triggers/workday/utils' + +export const workdayEmployeeHiredTrigger: TriggerConfig = { + id: 'workday_employee_hired', + name: 'Workday Employee Hired', + provider: 'workday', + description: 'Trigger workflow when an employee is hired in Workday', + version: '1.0.0', + icon: WorkdayIcon, + + subBlocks: buildWorkdaySubBlocks({ + triggerId: 'workday_employee_hired', + eventType: 'Hire Employee', + includeDropdown: true, + }), + + outputs: buildEmployeeHiredOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + }, +} diff --git a/apps/sim/triggers/workday/employee_terminated.ts b/apps/sim/triggers/workday/employee_terminated.ts new file mode 100644 index 00000000000..672959e06c6 --- /dev/null +++ b/apps/sim/triggers/workday/employee_terminated.ts @@ -0,0 +1,26 @@ +import { WorkdayIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildEmployeeTerminatedOutputs, buildWorkdaySubBlocks } from '@/triggers/workday/utils' + +export const workdayEmployeeTerminatedTrigger: TriggerConfig = { + id: 'workday_employee_terminated', + name: 'Workday Employee Terminated', + provider: 'workday', + description: 'Trigger workflow when an employee is terminated in Workday', + version: '1.0.0', + icon: WorkdayIcon, + + subBlocks: buildWorkdaySubBlocks({ + triggerId: 'workday_employee_terminated', + eventType: 'Terminate Employee', + }), + + outputs: buildEmployeeTerminatedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + }, +} diff --git a/apps/sim/triggers/workday/index.ts b/apps/sim/triggers/workday/index.ts new file mode 100644 index 00000000000..a15866d73f1 --- /dev/null +++ b/apps/sim/triggers/workday/index.ts @@ -0,0 +1,4 @@ +export { workdayEmployeeHiredTrigger } from './employee_hired' +export { workdayEmployeeTerminatedTrigger } from './employee_terminated' +export { workdayJobChangedTrigger } from './job_changed' +export { workdayWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/workday/job_changed.ts b/apps/sim/triggers/workday/job_changed.ts new file mode 100644 index 00000000000..2c19f173294 --- /dev/null +++ b/apps/sim/triggers/workday/job_changed.ts @@ -0,0 +1,27 @@ +import { WorkdayIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildJobChangedOutputs, buildWorkdaySubBlocks } from '@/triggers/workday/utils' + +export const workdayJobChangedTrigger: TriggerConfig = { + id: 'workday_job_changed', + name: 'Workday Job Changed', + provider: 'workday', + description: + 'Trigger workflow when a job change occurs in Workday (transfer, promotion, demotion)', + version: '1.0.0', + icon: WorkdayIcon, + + subBlocks: buildWorkdaySubBlocks({ + triggerId: 'workday_job_changed', + eventType: 'Change Job', + }), + + outputs: buildJobChangedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + }, +} diff --git a/apps/sim/triggers/workday/utils.ts b/apps/sim/triggers/workday/utils.ts new file mode 100644 index 00000000000..2fefdf4ca18 --- /dev/null +++ b/apps/sim/triggers/workday/utils.ts @@ -0,0 +1,258 @@ +import { extractSoapBody, stripNamespacePrefixes } from '@/lib/webhooks/soap-utils' +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Workday SOAP notification payload types. + * Based on official docs: Notification Service v29.0 — Receive_Notification operation. + */ +export interface WorkdayObjectId { + _text?: string + '@_type'?: string + '@_parent_id'?: string + '@_parent_type'?: string +} + +export interface WorkdayObjectReference { + '@_Descriptor'?: string + ID?: WorkdayObjectId | WorkdayObjectId[] +} + +export interface WorkdayEventData { + Event_Reference: WorkdayObjectReference + Event_Name: string + Notification_Trigger: string + Event_Completion_Date: string + Event_Effective_Date?: string + Tenant_Name: string + System_ID: string + Transaction_Target_Reference?: WorkdayObjectReference | WorkdayObjectReference[] +} + +export interface WorkdayNotificationData { + Event_Data: WorkdayEventData +} + +export type WorkdayTriggerId = + | 'workday_employee_hired' + | 'workday_employee_terminated' + | 'workday_job_changed' + | 'workday_webhook' + +export const workdayTriggerOptions = [ + { label: 'Employee Hired', id: 'workday_employee_hired' }, + { label: 'Employee Terminated', id: 'workday_employee_terminated' }, + { label: 'Job Changed', id: 'workday_job_changed' }, + { label: 'Generic Webhook', id: 'workday_webhook' }, +] + +export function workdaySetupInstructions(eventType: string): string { + const instructions = [ + 'Enter your Workday ISU credentials and Integration System ID above.', + `When you deploy this workflow, a subscription for ${eventType} events will be automatically created in Workday via the Put_Subscription API.`, + 'Ensure the ISU has domain security permissions for Integration Build and Integration Process.', + 'The subscription will be automatically disabled when you undeploy the workflow.', + 'SOAP XML notifications from Workday are automatically parsed into JSON for your workflow.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +export function buildWorkdaySubBlocks(options: { + triggerId: string + eventType: string + includeDropdown?: boolean +}): SubBlockConfig[] { + const { triggerId, eventType, includeDropdown = false } = options + const blocks: SubBlockConfig[] = [] + + if (includeDropdown) { + blocks.push({ + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: workdayTriggerOptions, + value: () => triggerId, + required: true, + }) + + blocks.push({ + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + }) + } + + blocks.push( + { + id: 'tenantUrl', + title: 'Tenant URL', + type: 'short-input', + placeholder: 'https://wd2-impl-services1.workday.com', + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'tenant', + title: 'Tenant Name', + type: 'short-input', + placeholder: 'mycompany', + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'username', + title: 'ISU Username', + type: 'short-input', + placeholder: 'Integration System User username', + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'password', + title: 'ISU Password', + type: 'short-input', + placeholder: 'Integration System User password', + password: true, + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'integrationSystemId', + title: 'Integration System ID', + type: 'short-input', + placeholder: 'Workday Integration System ID', + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + description: 'The Integration System ID configured in Workday for this subscription', + } + ) + + blocks.push({ + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId, + condition: { field: 'selectedTriggerId', value: triggerId }, + }) + + blocks.push({ + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: workdaySetupInstructions(eventType), + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }) + + return blocks +} + +/** + * Extracts the typed WorkdayEventData from a raw parsed SOAP webhook body. + * Handles namespace stripping and SOAP envelope unwrapping. + */ +export function extractWorkdayEventData(body: Record): WorkdayEventData | null { + const cleaned = stripNamespacePrefixes(extractSoapBody(body)) as Record + const notificationData = cleaned.Notification_Data as WorkdayNotificationData | undefined + const eventData = (cleaned.Event_Data ?? + notificationData?.Event_Data ?? + null) as WorkdayEventData | null + + return eventData +} + +/** + * Extracts the first ID value from a WorkdayObjectReference. + */ +export function extractWorkdayRefId(ref: WorkdayObjectReference | undefined): string | null { + if (!ref?.ID) return null + const ids = Array.isArray(ref.ID) ? ref.ID : [ref.ID] + return ids[0]?._text ?? null +} + +/** + * Checks whether a parsed SOAP webhook body matches a specific Workday event type. + * Workday sends all subscribed events to the same endpoint, so we filter here. + */ +export function isWorkdayEventMatch( + triggerId: WorkdayTriggerId | string, + body: Record +): boolean { + const eventData = extractWorkdayEventData(body) + if (!eventData) return triggerId === 'workday_webhook' + + const eventName = eventData.Event_Name ?? '' + const trigger = eventData.Notification_Trigger ?? '' + + switch (triggerId) { + case 'workday_employee_hired': + return trigger.includes('Hire') || eventName.startsWith('Hire') + case 'workday_employee_terminated': + return trigger.includes('Terminat') || eventName.startsWith('Terminat') + case 'workday_job_changed': + return ( + trigger.includes('Change_Job') || + trigger.includes('Job_Change') || + eventName.startsWith('Change Job') + ) + case 'workday_webhook': + return true + default: + return true + } +} + +/** + * Core output fields present in all Workday notification payloads. + */ +function buildEventOutputs(): Record { + return { + eventName: { type: 'string', description: 'Name of the business process event' }, + eventReference: { type: 'string', description: 'Workday event reference ID' }, + notificationTrigger: { type: 'string', description: 'Trigger type identifier' }, + eventCompletionDate: { type: 'string', description: 'Event completion timestamp (ISO 8601)' }, + eventEffectiveDate: { type: 'string', description: 'Event effective date (YYYY-MM-DD)' }, + tenantName: { type: 'string', description: 'Workday tenant name' }, + systemId: { type: 'string', description: 'Integration system ID' }, + targetReference: { + type: 'string', + description: 'Reference to the affected business object (e.g., worker ID)', + }, + } +} + +export function buildEmployeeHiredOutputs(): Record { + return buildEventOutputs() +} + +export function buildEmployeeTerminatedOutputs(): Record { + return buildEventOutputs() +} + +export function buildJobChangedOutputs(): Record { + return buildEventOutputs() +} + +export function buildGenericWebhookOutputs(): Record { + return buildEventOutputs() +} diff --git a/apps/sim/triggers/workday/webhook.ts b/apps/sim/triggers/workday/webhook.ts new file mode 100644 index 00000000000..b135fd98eda --- /dev/null +++ b/apps/sim/triggers/workday/webhook.ts @@ -0,0 +1,26 @@ +import { WorkdayIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildGenericWebhookOutputs, buildWorkdaySubBlocks } from '@/triggers/workday/utils' + +export const workdayWebhookTrigger: TriggerConfig = { + id: 'workday_webhook', + name: 'Workday Webhook', + provider: 'workday', + description: 'Receive any Workday SOAP notification event', + version: '1.0.0', + icon: WorkdayIcon, + + subBlocks: buildWorkdaySubBlocks({ + triggerId: 'workday_webhook', + eventType: 'Any Business Process', + }), + + outputs: buildGenericWebhookOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + }, +} diff --git a/bun.lock b/bun.lock index 9e3efc4d447..570b5cc3d02 100644 --- a/bun.lock +++ b/bun.lock @@ -127,6 +127,7 @@ "drizzle-orm": "^0.44.5", "encoding": "0.1.13", "entities": "6.0.1", + "fast-xml-parser": "5.5.6", "ffmpeg-static": "5.3.0", "fluent-ffmpeg": "2.1.3", "framer-motion": "^12.5.0", @@ -2224,7 +2225,9 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-xml-parser": ["fast-xml-parser@5.3.5", "", { "dependencies": { "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-JeaA2Vm9ffQKp9VjvfzObuMCjUYAp5WDYhRYL5LrBPY/jUDlUtOvDfot0vKSkB9tuX885BDHjtw4fZadD95wnA=="], + "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], + + "fast-xml-parser": ["fast-xml-parser@5.5.6", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -2992,6 +2995,8 @@ "patchright-core": ["patchright-core@1.57.0", "", { "bin": { "patchright-core": "cli.js" } }, "sha512-um/9Wue7IFAa9UDLacjNgDn62ub5GJe1b1qouvYpELIF9rsFVMNhRo/rRXYajupLwp5xKJ0sSjOV6sw8/HarBQ=="], + "path-expression-matcher": ["path-expression-matcher@1.1.3", "", {}, "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -3818,6 +3823,8 @@ "@azure/communication-email/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "@azure/core-xml/fast-xml-parser": ["fast-xml-parser@5.3.5", "", { "dependencies": { "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-JeaA2Vm9ffQKp9VjvfzObuMCjUYAp5WDYhRYL5LrBPY/jUDlUtOvDfot0vKSkB9tuX885BDHjtw4fZadD95wnA=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -3826,6 +3833,8 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@better-auth/sso/fast-xml-parser": ["fast-xml-parser@5.3.5", "", { "dependencies": { "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-JeaA2Vm9ffQKp9VjvfzObuMCjUYAp5WDYhRYL5LrBPY/jUDlUtOvDfot0vKSkB9tuX885BDHjtw4fZadD95wnA=="], + "@better-auth/sso/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "@better-auth/sso/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -4228,6 +4237,8 @@ "openai/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "openapi-sampler/fast-xml-parser": ["fast-xml-parser@5.3.5", "", { "dependencies": { "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-JeaA2Vm9ffQKp9VjvfzObuMCjUYAp5WDYhRYL5LrBPY/jUDlUtOvDfot0vKSkB9tuX885BDHjtw4fZadD95wnA=="], + "ora/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],