diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts index a868313c0f..d76d765ab9 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts @@ -1,6 +1,8 @@ import { db, workflow } from '@sim/db' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { generateRequestId } from '@/lib/core/utils/request' +import { cleanupWebhooksForWorkflow } from '@/lib/webhooks/deploy' import { deployWorkflow, loadWorkflowFromNormalizedTables, @@ -80,10 +82,11 @@ export const POST = withAdminAuthParams(async (request, context) => export const DELETE = withAdminAuthParams(async (request, context) => { const { id: workflowId } = await context.params + const requestId = generateRequestId() try { const [workflowRecord] = await db - .select({ id: workflow.id }) + .select() .from(workflow) .where(eq(workflow.id, workflowId)) .limit(1) @@ -92,6 +95,13 @@ export const DELETE = withAdminAuthParams(async (request, context) return notFoundResponse('Workflow') } + // Clean up external webhook subscriptions before undeploying + await cleanupWebhooksForWorkflow( + workflowId, + workflowRecord as Record, + requestId + ) + const result = await undeployWorkflow({ workflowId }) if (!result.success) { return internalErrorResponse(result.error || 'Failed to undeploy workflow') diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 0cd31402df..7f10feefb5 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -7,6 +7,11 @@ import { getSession } from '@/lib/auth' import { validateInteger } from '@/lib/core/security/input-validation' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { + cleanupExternalWebhook, + createExternalWebhookSubscription, + shouldRecreateExternalWebhookSubscription, +} from '@/lib/webhooks/provider-subscriptions' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WebhookAPI') @@ -177,6 +182,46 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + const existingProviderConfig = + (webhookData.webhook.providerConfig as Record) || {} + let nextProviderConfig = + providerConfig !== undefined && + resolvedProviderConfig && + typeof resolvedProviderConfig === 'object' + ? (resolvedProviderConfig as Record) + : existingProviderConfig + const nextProvider = (provider ?? webhookData.webhook.provider) as string + + if ( + providerConfig !== undefined && + shouldRecreateExternalWebhookSubscription({ + previousProvider: webhookData.webhook.provider as string, + nextProvider, + previousConfig: existingProviderConfig, + nextConfig: nextProviderConfig, + }) + ) { + await cleanupExternalWebhook( + { ...webhookData.webhook, providerConfig: existingProviderConfig }, + webhookData.workflow, + requestId + ) + + const result = await createExternalWebhookSubscription( + request, + { + ...webhookData.webhook, + provider: nextProvider, + providerConfig: nextProviderConfig, + }, + webhookData.workflow, + session.user.id, + requestId + ) + + nextProviderConfig = result.updatedProviderConfig as Record + } + logger.debug(`[${requestId}] Updating webhook properties`, { hasPathUpdate: path !== undefined, hasProviderUpdate: provider !== undefined, @@ -188,16 +233,16 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< // Merge providerConfig to preserve credential-related fields let finalProviderConfig = webhooks[0].webhook.providerConfig if (providerConfig !== undefined) { - const existingConfig = (webhooks[0].webhook.providerConfig as Record) || {} + const existingConfig = existingProviderConfig finalProviderConfig = { - ...resolvedProviderConfig, + ...nextProviderConfig, credentialId: existingConfig.credentialId, credentialSetId: existingConfig.credentialSetId, userId: existingConfig.userId, historyId: existingConfig.historyId, lastCheckedTimestamp: existingConfig.lastCheckedTimestamp, setupCompleted: existingConfig.setupCompleted, - externalId: existingConfig.externalId, + externalId: nextProviderConfig.externalId ?? existingConfig.externalId, } } diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 4e980646b9..0ae8b65d91 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -7,9 +7,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { createExternalWebhookSubscription } from '@/lib/webhooks/provider-subscriptions' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { getOAuthToken } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhooksAPI') @@ -257,7 +256,7 @@ export async function POST(request: NextRequest) { const finalProviderConfig = providerConfig || {} const { resolveEnvVarsInObject } = await import('@/lib/webhooks/env-resolver') - const resolvedProviderConfig = await resolveEnvVarsInObject( + let resolvedProviderConfig = await resolveEnvVarsInObject( finalProviderConfig, userId, workflowRecord.workspaceId || undefined @@ -414,149 +413,33 @@ export async function POST(request: NextRequest) { } // --- End Credential Set Handling --- - // Create external subscriptions before saving to DB to prevent orphaned records - let externalSubscriptionId: string | undefined let externalSubscriptionCreated = false - - const createTempWebhookData = () => ({ + const createTempWebhookData = (providerConfigOverride = resolvedProviderConfig) => ({ id: targetWebhookId || nanoid(), path: finalPath, - providerConfig: resolvedProviderConfig, + provider, + providerConfig: providerConfigOverride, }) - if (provider === 'airtable') { - logger.info(`[${requestId}] Creating Airtable subscription before saving to database`) - try { - externalSubscriptionId = await createAirtableWebhookSubscription( - request, - userId, - createTempWebhookData(), - requestId - ) - if (externalSubscriptionId) { - resolvedProviderConfig.externalId = externalSubscriptionId - externalSubscriptionCreated = true - } - } catch (err) { - logger.error(`[${requestId}] Error creating Airtable webhook subscription`, err) - return NextResponse.json( - { - error: 'Failed to create webhook in Airtable', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } - } - - if (provider === 'calendly') { - logger.info(`[${requestId}] Creating Calendly subscription before saving to database`) - try { - externalSubscriptionId = await createCalendlyWebhookSubscription( - request, - userId, - createTempWebhookData(), - requestId - ) - if (externalSubscriptionId) { - resolvedProviderConfig.externalId = externalSubscriptionId - externalSubscriptionCreated = true - } - } catch (err) { - logger.error(`[${requestId}] Error creating Calendly webhook subscription`, err) - return NextResponse.json( - { - error: 'Failed to create webhook in Calendly', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } - } - - if (provider === 'microsoft-teams') { - const { createTeamsSubscription } = await import('@/lib/webhooks/provider-subscriptions') - logger.info(`[${requestId}] Creating Teams subscription before saving to database`) - try { - await createTeamsSubscription(request, createTempWebhookData(), workflowRecord, requestId) - externalSubscriptionCreated = true - } catch (err) { - logger.error(`[${requestId}] Error creating Teams subscription`, err) - return NextResponse.json( - { - error: 'Failed to create Teams subscription', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } - } - - if (provider === 'telegram') { - const { createTelegramWebhook } = await import('@/lib/webhooks/provider-subscriptions') - logger.info(`[${requestId}] Creating Telegram webhook before saving to database`) - try { - await createTelegramWebhook(request, createTempWebhookData(), requestId) - externalSubscriptionCreated = true - } catch (err) { - logger.error(`[${requestId}] Error creating Telegram webhook`, err) - return NextResponse.json( - { - error: 'Failed to create Telegram webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } - } - - if (provider === 'webflow') { - logger.info(`[${requestId}] Creating Webflow subscription before saving to database`) - try { - externalSubscriptionId = await createWebflowWebhookSubscription( - request, - userId, - createTempWebhookData(), - requestId - ) - if (externalSubscriptionId) { - resolvedProviderConfig.externalId = externalSubscriptionId - externalSubscriptionCreated = true - } - } catch (err) { - logger.error(`[${requestId}] Error creating Webflow webhook subscription`, err) - return NextResponse.json( - { - error: 'Failed to create webhook in Webflow', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } - } - - if (provider === 'typeform') { - const { createTypeformWebhook } = await import('@/lib/webhooks/provider-subscriptions') - logger.info(`[${requestId}] Creating Typeform webhook before saving to database`) - try { - const usedTag = await createTypeformWebhook(request, createTempWebhookData(), requestId) - - if (!resolvedProviderConfig.webhookTag) { - resolvedProviderConfig.webhookTag = usedTag - logger.info(`[${requestId}] Stored auto-generated webhook tag: ${usedTag}`) - } - - externalSubscriptionCreated = true - } catch (err) { - logger.error(`[${requestId}] Error creating Typeform webhook`, err) - return NextResponse.json( - { - error: 'Failed to create webhook in Typeform', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } + try { + const result = await createExternalWebhookSubscription( + request, + createTempWebhookData(), + workflowRecord, + userId, + requestId + ) + resolvedProviderConfig = result.updatedProviderConfig as Record + externalSubscriptionCreated = result.externalSubscriptionCreated + } catch (err) { + logger.error(`[${requestId}] Error creating external webhook subscription`, err) + return NextResponse.json( + { + error: 'Failed to create external webhook subscription', + details: err instanceof Error ? err.message : 'Unknown error', + }, + { status: 500 } + ) } // Now save to database (only if subscription succeeded or provider doesn't need external subscription) @@ -617,7 +500,11 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] DB save failed, cleaning up external subscription`, dbError) try { const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions') - await cleanupExternalWebhook(createTempWebhookData(), workflowRecord, requestId) + await cleanupExternalWebhook( + createTempWebhookData(resolvedProviderConfig), + workflowRecord, + requestId + ) } catch (cleanupError) { logger.error( `[${requestId}] Failed to cleanup external subscription after DB save failure`, @@ -741,110 +628,6 @@ export async function POST(request: NextRequest) { } // --- End RSS specific logic --- - if (savedWebhook && provider === 'grain') { - logger.info(`[${requestId}] Grain provider detected. Creating Grain webhook subscription.`) - try { - const grainResult = await createGrainWebhookSubscription( - request, - { - id: savedWebhook.id, - path: savedWebhook.path, - providerConfig: savedWebhook.providerConfig, - }, - requestId - ) - - if (grainResult) { - // Update the webhook record with the external Grain hook ID and event types for filtering - const updatedConfig = { - ...(savedWebhook.providerConfig as Record), - externalId: grainResult.id, - eventTypes: grainResult.eventTypes, - } - await db - .update(webhook) - .set({ - providerConfig: updatedConfig, - updatedAt: new Date(), - }) - .where(eq(webhook.id, savedWebhook.id)) - - savedWebhook.providerConfig = updatedConfig - logger.info(`[${requestId}] Successfully created Grain webhook`, { - grainHookId: grainResult.id, - eventTypes: grainResult.eventTypes, - webhookId: savedWebhook.id, - }) - } - } catch (err) { - logger.error( - `[${requestId}] Error creating Grain webhook subscription, rolling back webhook`, - err - ) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return NextResponse.json( - { - error: 'Failed to create webhook in Grain', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } - } - // --- End Grain specific logic --- - - // --- Lemlist specific logic --- - if (savedWebhook && provider === 'lemlist') { - logger.info( - `[${requestId}] Lemlist provider detected. Creating Lemlist webhook subscription.` - ) - try { - const lemlistResult = await createLemlistWebhookSubscription( - { - id: savedWebhook.id, - path: savedWebhook.path, - providerConfig: savedWebhook.providerConfig, - }, - requestId - ) - - if (lemlistResult) { - // Update the webhook record with the external Lemlist hook ID - const updatedConfig = { - ...(savedWebhook.providerConfig as Record), - externalId: lemlistResult.id, - } - await db - .update(webhook) - .set({ - providerConfig: updatedConfig, - updatedAt: new Date(), - }) - .where(eq(webhook.id, savedWebhook.id)) - - savedWebhook.providerConfig = updatedConfig - logger.info(`[${requestId}] Successfully created Lemlist webhook`, { - lemlistHookId: lemlistResult.id, - webhookId: savedWebhook.id, - }) - } - } catch (err) { - logger.error( - `[${requestId}] Error creating Lemlist webhook subscription, rolling back webhook`, - err - ) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return NextResponse.json( - { - error: 'Failed to create webhook in Lemlist', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } - } - // --- End Lemlist specific logic --- - if (!targetWebhookId && savedWebhook) { try { PlatformEvents.webhookCreated({ @@ -868,616 +651,3 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } - -// Helper function to create the webhook subscription in Airtable -async function createAirtableWebhookSubscription( - request: NextRequest, - userId: string, - webhookData: any, - requestId: string -): Promise { - try { - const { path, providerConfig } = webhookData - const { baseId, tableId, includeCellValuesInFieldIds } = providerConfig || {} - - if (!baseId || !tableId) { - logger.warn(`[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Base ID and Table ID are required to create Airtable webhook. Please provide valid Airtable base and table IDs.' - ) - } - - const accessToken = await getOAuthToken(userId, 'airtable') - if (!accessToken) { - logger.warn( - `[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.` - ) - throw new Error( - 'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` - - const specification: any = { - options: { - filters: { - dataTypes: ['tableData'], // Watch table data changes - recordChangeScope: tableId, // Watch only the specified table - }, - }, - } - - // Conditionally add the 'includes' field based on the config - if (includeCellValuesInFieldIds === 'all') { - specification.options.includes = { - includeCellValuesInFieldIds: 'all', - } - } - - const requestBody: any = { - notificationUrl: notificationUrl, - specification: specification, - } - - const airtableResponse = await fetch(airtableApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - // Airtable often returns 200 OK even for errors in the body, check payload - const responseBody = await airtableResponse.json() - - if (!airtableResponse.ok || responseBody.error) { - const errorMessage = - responseBody.error?.message || responseBody.error || 'Unknown Airtable API error' - const errorType = responseBody.error?.type - logger.error( - `[${requestId}] Failed to create webhook in Airtable for webhook ${webhookData.id}. Status: ${airtableResponse.status}`, - { type: errorType, message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Airtable' - if (airtableResponse.status === 404) { - userFriendlyMessage = - 'Airtable base or table not found. Please verify that the Base ID and Table ID are correct and that you have access to them.' - } else if (errorMessage && errorMessage !== 'Unknown Airtable API error') { - userFriendlyMessage = `Airtable error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - logger.info( - `[${requestId}] Successfully created webhook in Airtable for webhook ${webhookData.id}.`, - { - airtableWebhookId: responseBody.id, - } - ) - return responseBody.id - } catch (error: any) { - logger.error( - `[${requestId}] Exception during Airtable webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - // Re-throw the error so it can be caught by the outer try-catch - throw error - } -} - -// Helper function to create the webhook subscription in Calendly -async function createCalendlyWebhookSubscription( - request: NextRequest, - userId: string, - webhookData: any, - requestId: string -): Promise { - try { - const { path, providerConfig } = webhookData - const { apiKey, organization, triggerId } = providerConfig || {} - - if (!apiKey) { - logger.warn(`[${requestId}] Missing apiKey for Calendly webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Personal Access Token is required to create Calendly webhook. Please provide your Calendly Personal Access Token.' - ) - } - - if (!organization) { - logger.warn(`[${requestId}] Missing organization URI for Calendly webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Organization URI is required to create Calendly webhook. Please provide your Organization URI from the "Get Current User" operation.' - ) - } - - if (!triggerId) { - logger.warn(`[${requestId}] Missing triggerId for Calendly webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error('Trigger ID is required to create Calendly webhook') - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - // Map trigger IDs to Calendly event types - const eventTypeMap: Record = { - calendly_invitee_created: ['invitee.created'], - calendly_invitee_canceled: ['invitee.canceled'], - calendly_routing_form_submitted: ['routing_form_submission.created'], - calendly_webhook: ['invitee.created', 'invitee.canceled', 'routing_form_submission.created'], - } - - const events = eventTypeMap[triggerId] || ['invitee.created'] - - const calendlyApiUrl = 'https://api.calendly.com/webhook_subscriptions' - - const requestBody = { - url: notificationUrl, - events, - organization, - scope: 'organization', - } - - const calendlyResponse = await fetch(calendlyApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - if (!calendlyResponse.ok) { - const errorBody = await calendlyResponse.json().catch(() => ({})) - const errorMessage = errorBody.message || errorBody.title || 'Unknown Calendly API error' - logger.error( - `[${requestId}] Failed to create webhook in Calendly for webhook ${webhookData.id}. Status: ${calendlyResponse.status}`, - { response: errorBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Calendly' - if (calendlyResponse.status === 401) { - userFriendlyMessage = - 'Calendly authentication failed. Please verify your Personal Access Token is correct.' - } else if (calendlyResponse.status === 403) { - userFriendlyMessage = - 'Calendly access denied. Please ensure you have appropriate permissions and a paid Calendly subscription.' - } else if (calendlyResponse.status === 404) { - userFriendlyMessage = - 'Calendly organization not found. Please verify the Organization URI is correct.' - } else if (errorMessage && errorMessage !== 'Unknown Calendly API error') { - userFriendlyMessage = `Calendly error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const responseBody = await calendlyResponse.json() - const webhookUri = responseBody.resource?.uri - - if (!webhookUri) { - logger.error( - `[${requestId}] Calendly webhook created but no webhook URI returned for webhook ${webhookData.id}`, - { response: responseBody } - ) - throw new Error('Calendly webhook creation succeeded but no webhook URI was returned') - } - - // Extract the webhook ID from the URI (e.g., https://api.calendly.com/webhook_subscriptions/WEBHOOK_ID) - const webhookId = webhookUri.split('/').pop() - - if (!webhookId) { - logger.error(`[${requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`, { - response: responseBody, - }) - throw new Error('Failed to extract webhook ID from Calendly response') - } - - logger.info( - `[${requestId}] Successfully created webhook in Calendly for webhook ${webhookData.id}.`, - { - calendlyWebhookUri: webhookUri, - calendlyWebhookId: webhookId, - } - ) - return webhookId - } catch (error: any) { - logger.error( - `[${requestId}] Exception during Calendly webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - // Re-throw the error so it can be caught by the outer try-catch - throw error - } -} - -// Helper function to create the webhook subscription in Webflow -async function createWebflowWebhookSubscription( - request: NextRequest, - userId: string, - webhookData: any, - requestId: string -): Promise { - try { - const { path, providerConfig } = webhookData - const { siteId, triggerId, collectionId, formId } = providerConfig || {} - - if (!siteId) { - logger.warn(`[${requestId}] Missing siteId for Webflow webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error('Site ID is required to create Webflow webhook') - } - - if (!triggerId) { - logger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error('Trigger type is required to create Webflow webhook') - } - - const accessToken = await getOAuthToken(userId, 'webflow') - if (!accessToken) { - logger.warn( - `[${requestId}] Could not retrieve Webflow access token for user ${userId}. Cannot create webhook in Webflow.` - ) - throw new Error( - 'Webflow account connection required. Please connect your Webflow account in the trigger configuration and try again.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - // Map trigger IDs to Webflow trigger types - const triggerTypeMap: Record = { - webflow_collection_item_created: 'collection_item_created', - webflow_collection_item_changed: 'collection_item_changed', - webflow_collection_item_deleted: 'collection_item_deleted', - webflow_form_submission: 'form_submission', - } - - const webflowTriggerType = triggerTypeMap[triggerId] - if (!webflowTriggerType) { - logger.warn(`[${requestId}] Invalid triggerId for Webflow: ${triggerId}`, { - webhookId: webhookData.id, - }) - throw new Error(`Invalid Webflow trigger type: ${triggerId}`) - } - - const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks` - - const requestBody: any = { - triggerType: webflowTriggerType, - url: notificationUrl, - } - - // Add filter for collection-based triggers - if (collectionId && webflowTriggerType.startsWith('collection_item_')) { - requestBody.filter = { - resource_type: 'collection', - resource_id: collectionId, - } - } - - // Add filter for form submissions - if (formId && webflowTriggerType === 'form_submission') { - requestBody.filter = { - resource_type: 'form', - resource_id: formId, - } - } - - const webflowResponse = await fetch(webflowApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - accept: 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await webflowResponse.json() - - if (!webflowResponse.ok || responseBody.error) { - const errorMessage = responseBody.message || responseBody.error || 'Unknown Webflow API error' - logger.error( - `[${requestId}] Failed to create webhook in Webflow for webhook ${webhookData.id}. Status: ${webflowResponse.status}`, - { message: errorMessage, response: responseBody } - ) - throw new Error(errorMessage) - } - - logger.info( - `[${requestId}] Successfully created webhook in Webflow for webhook ${webhookData.id}.`, - { - webflowWebhookId: responseBody.id || responseBody._id, - } - ) - - return responseBody.id || responseBody._id - } catch (error: any) { - logger.error( - `[${requestId}] Exception during Webflow webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -// Helper function to create the webhook subscription in Grain -async function createGrainWebhookSubscription( - request: NextRequest, - webhookData: any, - requestId: string -): Promise<{ id: string; eventTypes: string[] } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, includeHighlights, includeParticipants, includeAiSummary } = - providerConfig || {} - - if (!apiKey) { - logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.' - ) - } - - // Map trigger IDs to Grain API hook_type (only 2 options: recording_added, upload_status) - const hookTypeMap: Record = { - grain_webhook: 'recording_added', - grain_recording_created: 'recording_added', - grain_recording_updated: 'recording_added', - grain_highlight_created: 'recording_added', - grain_highlight_updated: 'recording_added', - grain_story_created: 'recording_added', - grain_upload_status: 'upload_status', - } - - const eventTypeMap: Record = { - grain_webhook: [], - grain_recording_created: ['recording_added'], - grain_recording_updated: ['recording_updated'], - grain_highlight_created: ['highlight_created'], - grain_highlight_updated: ['highlight_updated'], - grain_story_created: ['story_created'], - grain_upload_status: ['upload_status'], - } - - const hookType = hookTypeMap[triggerId] ?? 'recording_added' - const eventTypes = eventTypeMap[triggerId] ?? [] - - if (!hookTypeMap[triggerId]) { - logger.warn( - `[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to recording_added`, - { - webhookId: webhookData.id, - } - ) - } - - logger.info(`[${requestId}] Creating Grain webhook`, { - triggerId, - hookType, - eventTypes, - webhookId: webhookData.id, - }) - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const grainApiUrl = 'https://api.grain.com/_/public-api/v2/hooks/create' - - const requestBody: Record = { - hook_url: notificationUrl, - hook_type: hookType, - } - - // Build include object based on configuration - const include: Record = {} - if (includeHighlights) { - include.highlights = true - } - if (includeParticipants) { - include.participants = true - } - if (includeAiSummary) { - include.ai_summary = true - } - if (Object.keys(include).length > 0) { - requestBody.include = include - } - - const grainResponse = await fetch(grainApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'Public-Api-Version': '2025-10-31', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await grainResponse.json() - - if (!grainResponse.ok || responseBody.error || responseBody.errors) { - logger.warn('[App] Grain response body:', responseBody) - const errorMessage = - responseBody.errors?.detail || - responseBody.error?.message || - responseBody.error || - responseBody.message || - 'Unknown Grain API error' - logger.error( - `[${requestId}] Failed to create webhook in Grain for webhook ${webhookData.id}. Status: ${grainResponse.status}`, - { message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Grain' - if (grainResponse.status === 401) { - userFriendlyMessage = - 'Invalid Grain API Key. Please verify your Personal Access Token is correct.' - } else if (grainResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure your Grain API Key has appropriate permissions.' - } else if (errorMessage && errorMessage !== 'Unknown Grain API error') { - userFriendlyMessage = `Grain error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - logger.info( - `[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`, - { - grainWebhookId: responseBody.id, - eventTypes, - } - ) - - return { id: responseBody.id, eventTypes } - } catch (error: any) { - logger.error( - `[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -// Helper function to create the webhook subscription in Lemlist -async function createLemlistWebhookSubscription( - webhookData: any, - requestId: string -): Promise<{ id: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, campaignId } = providerConfig || {} - - if (!apiKey) { - logger.warn(`[${requestId}] Missing apiKey for Lemlist webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Lemlist API Key is required. Please provide your Lemlist API Key in the trigger configuration.' - ) - } - - // Map trigger IDs to Lemlist event types - const eventTypeMap: Record = { - lemlist_email_replied: 'emailsReplied', - lemlist_linkedin_replied: 'linkedinReplied', - lemlist_interested: 'interested', - lemlist_not_interested: 'notInterested', - lemlist_email_opened: 'emailsOpened', - lemlist_email_clicked: 'emailsClicked', - lemlist_email_bounced: 'emailsBounced', - lemlist_email_sent: 'emailsSent', - lemlist_webhook: undefined, // Generic webhook - no type filter - } - - const eventType = eventTypeMap[triggerId] - - logger.info(`[${requestId}] Creating Lemlist webhook`, { - triggerId, - eventType, - hasCampaignId: !!campaignId, - webhookId: webhookData.id, - }) - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const lemlistApiUrl = 'https://api.lemlist.com/api/hooks' - - // Build request body - const requestBody: Record = { - targetUrl: notificationUrl, - } - - // Add event type if specified (omit for generic webhook to receive all events) - if (eventType) { - requestBody.type = eventType - } - - // Add campaign filter if specified - if (campaignId) { - requestBody.campaignId = campaignId - } - - // Lemlist uses Basic Auth with empty username and API key as password - const authString = Buffer.from(`:${apiKey}`).toString('base64') - - const lemlistResponse = await fetch(lemlistApiUrl, { - method: 'POST', - headers: { - Authorization: `Basic ${authString}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await lemlistResponse.json() - - if (!lemlistResponse.ok || responseBody.error) { - const errorMessage = responseBody.message || responseBody.error || 'Unknown Lemlist API error' - logger.error( - `[${requestId}] Failed to create webhook in Lemlist for webhook ${webhookData.id}. Status: ${lemlistResponse.status}`, - { message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Lemlist' - if (lemlistResponse.status === 401) { - userFriendlyMessage = 'Invalid Lemlist API Key. Please verify your API Key is correct.' - } else if (lemlistResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure your Lemlist API Key has appropriate permissions.' - } else if (errorMessage && errorMessage !== 'Unknown Lemlist API error') { - userFriendlyMessage = `Lemlist error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - logger.info( - `[${requestId}] Successfully created webhook in Lemlist for webhook ${webhookData.id}.`, - { - lemlistWebhookId: responseBody._id, - } - ) - - return { id: responseBody._id } - } catch (error: any) { - logger.error( - `[${requestId}] Exception during Lemlist webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 1ba7647955..21afa25177 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -4,6 +4,7 @@ import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' +import { cleanupWebhooksForWorkflow, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy' import { deployWorkflow, loadWorkflowFromNormalizedTables, @@ -130,6 +131,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400) } + const triggerSaveResult = await saveTriggerWebhooksForDeploy({ + request, + workflowId: id, + workflow: workflowData, + userId: actorUserId, + blocks: normalizedData.blocks, + requestId, + }) + + if (!triggerSaveResult.success) { + return createErrorResponse( + triggerSaveResult.error?.message || 'Failed to save trigger configuration', + triggerSaveResult.error?.status || 500 + ) + } + const deployResult = await deployWorkflow({ workflowId: id, deployedBy: actorUserId, @@ -202,11 +219,18 @@ export async function DELETE( try { logger.debug(`[${requestId}] Undeploying workflow: ${id}`) - const { error } = await validateWorkflowPermissions(id, requestId, 'admin') + const { error, workflow: workflowData } = await validateWorkflowPermissions( + id, + requestId, + 'admin' + ) if (error) { return createErrorResponse(error.message, error.status) } + // Clean up external webhook subscriptions before undeploying + await cleanupWebhooksForWorkflow(id, workflowData as Record, requestId) + const result = await undeployWorkflow({ workflowId: id }) if (!result.success) { return createErrorResponse(result.error || 'Failed to undeploy workflow', 500) diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 2cadeff341..7c8879430e 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { webhook, workflow } from '@sim/db/schema' +import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -13,7 +13,6 @@ import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validat import { getWorkflowAccessContext } from '@/lib/workflows/utils' import type { BlockState } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { getTrigger } from '@/triggers' const logger = createLogger('WorkflowStateAPI') @@ -203,8 +202,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ ) } - await syncWorkflowWebhooks(workflowId, workflowState.blocks) - // Extract and persist custom tools to database try { const workspaceId = workflowData.workspaceId @@ -290,213 +287,3 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } - -function getSubBlockValue(block: BlockState, subBlockId: string): T | undefined { - const value = block.subBlocks?.[subBlockId]?.value - if (value === undefined || value === null) { - return undefined - } - return value as T -} - -async function syncWorkflowWebhooks( - workflowId: string, - blocks: Record -): Promise { - await syncBlockResources(workflowId, blocks, { - resourceName: 'webhook', - subBlockId: 'webhookId', - buildMetadata: buildWebhookMetadata, - applyMetadata: upsertWebhookRecord, - }) -} - -interface WebhookMetadata { - triggerPath: string - provider: string | null - providerConfig: Record -} - -const CREDENTIAL_SET_PREFIX = 'credentialSet:' - -function buildWebhookMetadata(block: BlockState): WebhookMetadata | null { - const triggerId = - getSubBlockValue(block, 'triggerId') || - getSubBlockValue(block, 'selectedTriggerId') - const triggerConfig = getSubBlockValue>(block, 'triggerConfig') || {} - const triggerCredentials = getSubBlockValue(block, 'triggerCredentials') - const triggerPath = getSubBlockValue(block, 'triggerPath') || block.id - - const triggerDef = triggerId ? getTrigger(triggerId) : undefined - const provider = triggerDef?.provider || null - - // Handle credential sets vs individual credentials - const isCredentialSet = triggerCredentials?.startsWith(CREDENTIAL_SET_PREFIX) - const credentialSetId = isCredentialSet - ? triggerCredentials!.slice(CREDENTIAL_SET_PREFIX.length) - : undefined - const credentialId = isCredentialSet ? undefined : triggerCredentials - - const providerConfig = { - ...(typeof triggerConfig === 'object' ? triggerConfig : {}), - ...(credentialId ? { credentialId } : {}), - ...(credentialSetId ? { credentialSetId } : {}), - ...(triggerId ? { triggerId } : {}), - } - - return { - triggerPath, - provider, - providerConfig, - } -} - -async function upsertWebhookRecord( - workflowId: string, - block: BlockState, - webhookId: string, - metadata: WebhookMetadata -): Promise { - const providerConfig = metadata.providerConfig as Record - const credentialSetId = providerConfig?.credentialSetId as string | undefined - - // For credential sets, delegate to the sync function which handles fan-out - if (credentialSetId && metadata.provider) { - const { syncWebhooksForCredentialSet } = await import('@/lib/webhooks/utils.server') - const { getProviderIdFromServiceId } = await import('@/lib/oauth') - - const oauthProviderId = getProviderIdFromServiceId(metadata.provider) - const requestId = crypto.randomUUID().slice(0, 8) - - // Extract base config (without credential-specific fields) - const { - credentialId: _cId, - credentialSetId: _csId, - userId: _uId, - ...baseConfig - } = providerConfig - - try { - await syncWebhooksForCredentialSet({ - workflowId, - blockId: block.id, - provider: metadata.provider, - basePath: metadata.triggerPath, - credentialSetId, - oauthProviderId, - providerConfig: baseConfig as Record, - requestId, - }) - - logger.info('Synced credential set webhooks during workflow save', { - workflowId, - blockId: block.id, - credentialSetId, - }) - } catch (error) { - logger.error('Failed to sync credential set webhooks during workflow save', { - workflowId, - blockId: block.id, - credentialSetId, - error, - }) - } - return - } - - // For individual credentials, use the existing single webhook logic - const [existing] = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1) - - if (existing) { - const needsUpdate = - existing.blockId !== block.id || - existing.workflowId !== workflowId || - existing.path !== metadata.triggerPath - - if (needsUpdate) { - await db - .update(webhook) - .set({ - workflowId, - blockId: block.id, - path: metadata.triggerPath, - provider: metadata.provider || existing.provider, - providerConfig: Object.keys(metadata.providerConfig).length - ? metadata.providerConfig - : existing.providerConfig, - isActive: true, - updatedAt: new Date(), - }) - .where(eq(webhook.id, webhookId)) - } - return - } - - await db.insert(webhook).values({ - id: webhookId, - workflowId, - blockId: block.id, - path: metadata.triggerPath, - provider: metadata.provider, - providerConfig: metadata.providerConfig, - credentialSetId: null, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }) - - logger.info('Recreated missing webhook after workflow save', { - workflowId, - blockId: block.id, - webhookId, - }) -} - -interface BlockResourceSyncConfig { - resourceName: string - subBlockId: string - buildMetadata: (block: BlockState, resourceId: string) => T | null - applyMetadata: ( - workflowId: string, - block: BlockState, - resourceId: string, - metadata: T - ) => Promise -} - -async function syncBlockResources( - workflowId: string, - blocks: Record, - config: BlockResourceSyncConfig -): Promise { - const blockEntries = Object.values(blocks || {}).filter(Boolean) as BlockState[] - if (blockEntries.length === 0) return - - for (const block of blockEntries) { - const resourceId = getSubBlockValue(block, config.subBlockId) - if (!resourceId) continue - - const metadata = config.buildMetadata(block, resourceId) - if (!metadata) { - logger.warn(`Skipping ${config.resourceName} sync due to invalid configuration`, { - workflowId, - blockId: block.id, - resourceId, - resourceName: config.resourceName, - }) - continue - } - - try { - await config.applyMetadata(workflowId, block, resourceId, metadata) - } catch (error) { - logger.error(`Failed to sync ${config.resourceName}`, { - workflowId, - blockId: block.id, - resourceId, - resourceName: config.resourceName, - error, - }) - } - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 98a61c1d1f..a1b28e59ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -189,6 +189,7 @@ export function DeployModal({ useEffect(() => { if (open && workflowId) { setActiveTab('general') + setApiDeployError(null) fetchChatDeploymentInfo() } }, [open, workflowId, fetchChatDeploymentInfo]) @@ -507,6 +508,7 @@ export function DeployModal({ const handleCloseModal = () => { setIsSubmitting(false) setChatSubmitting(false) + setApiDeployError(null) onOpenChange(false) } @@ -663,6 +665,12 @@ export function DeployModal({ + {apiDeployError && ( +
+
Deployment Error
+
{apiDeployError}
+
+ )} ('idle') - const [errorMessage, setErrorMessage] = useState(null) - const [deleteStatus, setDeleteStatus] = useState<'idle' | 'deleting'>('idle') - const [showDeleteDialog, setShowDeleteDialog] = useState(false) - - const effectiveTriggerId = useMemo(() => { - if (triggerId && isTriggerValid(triggerId)) { - return triggerId - } - const selectedTriggerId = useSubBlockStore.getState().getValue(blockId, 'selectedTriggerId') - if (typeof selectedTriggerId === 'string' && isTriggerValid(selectedTriggerId)) { - return selectedTriggerId - } - return triggerId - }, [blockId, triggerId]) - - const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() - - const { webhookId, saveConfig, deleteConfig, isLoading } = useWebhookManagement({ - blockId, - triggerId: effectiveTriggerId, - isPreview, - useWebhookUrl: true, // to store the webhook url in the store - }) - - const triggerConfig = useSubBlockStore((state) => state.getValue(blockId, 'triggerConfig')) - const triggerCredentials = useSubBlockStore((state) => - state.getValue(blockId, 'triggerCredentials') - ) - - const triggerDef = - effectiveTriggerId && isTriggerValid(effectiveTriggerId) ? getTrigger(effectiveTriggerId) : null - - const validateRequiredFields = useCallback( - ( - configToCheck: Record | null | undefined - ): { valid: boolean; missingFields: string[] } => { - if (!triggerDef) { - return { valid: true, missingFields: [] } - } - - const missingFields: string[] = [] - - triggerDef.subBlocks - .filter( - (sb) => sb.required && sb.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(sb.id) - ) - .forEach((subBlock) => { - if (subBlock.id === 'triggerCredentials') { - if (!triggerCredentials) { - missingFields.push(subBlock.title || 'Credentials') - } - } else { - const value = configToCheck?.[subBlock.id] - if (value === undefined || value === null || value === '') { - missingFields.push(subBlock.title || subBlock.id) - } - } - }) - - return { - valid: missingFields.length === 0, - missingFields, - } - }, - [triggerDef, triggerCredentials] - ) - - const requiredSubBlockIds = useMemo(() => { - if (!triggerDef) return [] - return triggerDef.subBlocks - .filter((sb) => sb.required && sb.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(sb.id)) - .map((sb) => sb.id) - }, [triggerDef]) - - const subscribedSubBlockValues = useSubBlockStore( - useCallback( - (state) => { - if (!triggerDef) return {} - const values: Record = {} - requiredSubBlockIds.forEach((subBlockId) => { - const value = state.getValue(blockId, subBlockId) - if (value !== null && value !== undefined && value !== '') { - values[subBlockId] = value - } - }) - return values - }, - [blockId, triggerDef, requiredSubBlockIds] - ) - ) - - const previousValuesRef = useRef>({}) - const validationTimeoutRef = useRef(null) - - useEffect(() => { - if (saveStatus !== 'error' || !triggerDef) { - previousValuesRef.current = subscribedSubBlockValues - return - } - - const hasChanges = Object.keys(subscribedSubBlockValues).some( - (key) => - previousValuesRef.current[key] !== (subscribedSubBlockValues as Record)[key] - ) - - if (!hasChanges) { - return - } - - if (validationTimeoutRef.current) { - clearTimeout(validationTimeoutRef.current) - } - - validationTimeoutRef.current = setTimeout(() => { - const aggregatedConfig = useTriggerConfigAggregation(blockId, effectiveTriggerId) - - if (aggregatedConfig) { - useSubBlockStore.getState().setValue(blockId, 'triggerConfig', aggregatedConfig) - } - - const validation = validateRequiredFields(aggregatedConfig) - - if (validation.valid) { - setErrorMessage(null) - setSaveStatus('idle') - logger.debug('Error cleared after validation passed', { - blockId, - triggerId: effectiveTriggerId, - }) - } else { - setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`) - logger.debug('Error message updated', { - blockId, - triggerId: effectiveTriggerId, - missingFields: validation.missingFields, - }) - } - - previousValuesRef.current = subscribedSubBlockValues - }, 300) - - return () => { - if (validationTimeoutRef.current) { - clearTimeout(validationTimeoutRef.current) - } - } - }, [ - blockId, - effectiveTriggerId, - triggerDef, - subscribedSubBlockValues, - saveStatus, - validateRequiredFields, - ]) - - const handleSave = async () => { - if (isPreview || disabled) return - - setSaveStatus('saving') - setErrorMessage(null) - - try { - const aggregatedConfig = useTriggerConfigAggregation(blockId, effectiveTriggerId) - - if (aggregatedConfig) { - useSubBlockStore.getState().setValue(blockId, 'triggerConfig', aggregatedConfig) - logger.debug('Stored aggregated trigger config', { - blockId, - triggerId: effectiveTriggerId, - aggregatedConfig, - }) - } - - const validation = validateRequiredFields(aggregatedConfig) - if (!validation.valid) { - setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`) - setSaveStatus('error') - return - } - - const success = await saveConfig() - if (!success) { - throw new Error('Save config returned false') - } - - setSaveStatus('saved') - setErrorMessage(null) - - const savedWebhookId = useSubBlockStore.getState().getValue(blockId, 'webhookId') - const savedTriggerPath = useSubBlockStore.getState().getValue(blockId, 'triggerPath') - const savedTriggerId = useSubBlockStore.getState().getValue(blockId, 'triggerId') - const savedTriggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') - - collaborativeSetSubblockValue(blockId, 'webhookId', savedWebhookId) - collaborativeSetSubblockValue(blockId, 'triggerPath', savedTriggerPath) - collaborativeSetSubblockValue(blockId, 'triggerId', savedTriggerId) - collaborativeSetSubblockValue(blockId, 'triggerConfig', savedTriggerConfig) - - setTimeout(() => { - setSaveStatus('idle') - }, 2000) - - logger.info('Trigger configuration saved successfully', { - blockId, - triggerId: effectiveTriggerId, - hasWebhookId: !!webhookId, - }) - } catch (error: any) { - setSaveStatus('error') - setErrorMessage(error.message || 'An error occurred while saving.') - logger.error('Error saving trigger configuration', { error }) - } - } - - const handleDeleteClick = () => { - if (isPreview || disabled || !webhookId) return - setShowDeleteDialog(true) - } - - const handleDeleteConfirm = async () => { - setShowDeleteDialog(false) - setDeleteStatus('deleting') - setErrorMessage(null) - - try { - const success = await deleteConfig() - - if (success) { - setDeleteStatus('idle') - setSaveStatus('idle') - setErrorMessage(null) - - collaborativeSetSubblockValue(blockId, 'triggerPath', '') - collaborativeSetSubblockValue(blockId, 'webhookId', null) - collaborativeSetSubblockValue(blockId, 'triggerConfig', null) - - logger.info('Trigger configuration deleted successfully', { - blockId, - triggerId: effectiveTriggerId, - }) - } else { - setDeleteStatus('idle') - setErrorMessage('Failed to delete trigger configuration.') - logger.error('Failed to delete trigger configuration') - } - } catch (error: any) { - setDeleteStatus('idle') - setErrorMessage(error.message || 'An error occurred while deleting.') - logger.error('Error deleting trigger configuration', { error }) - } - } - - if (isPreview) { - return null - } - - const isProcessing = saveStatus === 'saving' || deleteStatus === 'deleting' || isLoading - - return ( -
-
- - - {webhookId && ( - - )} -
- - {errorMessage &&

{errorMessage}

} - - - - Delete Trigger - -

- Are you sure you want to delete this trigger configuration? This will remove the - webhook and stop all incoming triggers.{' '} - This action cannot be undone. -

-
- - - - -
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 9201ce8299..b3ec7fec07 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -39,7 +39,6 @@ import { Text, TimeInput, ToolInput, - TriggerSave, VariablesInput, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' @@ -867,17 +866,6 @@ function SubBlockComponent({ } /> ) - case 'trigger-save': - return ( - - ) - case 'messages-input': return ( + userId: string + blocks: Record + requestId: string +} + +function getSubBlockValue(block: BlockState, subBlockId: string): unknown { + return block.subBlocks?.[subBlockId]?.value +} + +function isFieldRequired( + config: SubBlockConfig, + subBlockValues: Record +): boolean { + if (!config.required) return false + if (typeof config.required === 'boolean') return config.required + + const evalCond = ( + cond: { + field: string + value: string | number | boolean | Array + not?: boolean + and?: { + field: string + value: string | number | boolean | Array | undefined + not?: boolean + } + }, + values: Record + ): boolean => { + const fieldValue = values[cond.field]?.value + const condValue = cond.value + + let match = Array.isArray(condValue) + ? condValue.includes(fieldValue as string | number | boolean) + : fieldValue === condValue + + if (cond.not) match = !match + + if (cond.and) { + const andFieldValue = values[cond.and.field]?.value + const andCondValue = cond.and.value + let andMatch = Array.isArray(andCondValue) + ? (andCondValue || []).includes(andFieldValue as string | number | boolean) + : andFieldValue === andCondValue + if (cond.and.not) andMatch = !andMatch + match = match && andMatch + } + + return match + } + + const condition = typeof config.required === 'function' ? config.required() : config.required + return evalCond(condition, subBlockValues) +} + +function resolveTriggerId(block: BlockState): string | undefined { + const selectedTriggerId = getSubBlockValue(block, 'selectedTriggerId') + if (typeof selectedTriggerId === 'string' && isTriggerValid(selectedTriggerId)) { + return selectedTriggerId + } + + const storedTriggerId = getSubBlockValue(block, 'triggerId') + if (typeof storedTriggerId === 'string' && isTriggerValid(storedTriggerId)) { + return storedTriggerId + } + + const blockConfig = getBlock(block.type) + if (blockConfig?.category === 'triggers' && isTriggerValid(block.type)) { + return block.type + } + + if (block.triggerMode && blockConfig?.triggers?.enabled) { + const configuredTriggerId = + typeof selectedTriggerId === 'string' ? selectedTriggerId : undefined + if (configuredTriggerId && isTriggerValid(configuredTriggerId)) { + return configuredTriggerId + } + + const available = blockConfig.triggers?.available?.[0] + if (available && isTriggerValid(available)) { + return available + } + } + + return undefined +} + +function getConfigValue(block: BlockState, subBlock: SubBlockConfig): unknown { + const fieldValue = getSubBlockValue(block, subBlock.id) + + if ( + (fieldValue === null || fieldValue === undefined || fieldValue === '') && + Boolean(subBlock.required) && + subBlock.defaultValue !== undefined + ) { + return subBlock.defaultValue + } + + return fieldValue +} + +function buildProviderConfig( + block: BlockState, + triggerId: string, + triggerDef: { subBlocks: SubBlockConfig[] } +): { + providerConfig: Record + missingFields: string[] + credentialId?: string + credentialSetId?: string + triggerPath: string +} { + const triggerConfigValue = getSubBlockValue(block, 'triggerConfig') + const baseConfig = + triggerConfigValue && typeof triggerConfigValue === 'object' + ? (triggerConfigValue as Record) + : {} + + const providerConfig: Record = { ...baseConfig } + const missingFields: string[] = [] + const subBlockValues = Object.fromEntries( + Object.entries(block.subBlocks || {}).map(([key, value]) => [key, { value: value.value }]) + ) + + triggerDef.subBlocks + .filter((subBlock) => subBlock.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(subBlock.id)) + .forEach((subBlock) => { + const valueToUse = getConfigValue(block, subBlock) + if (valueToUse !== null && valueToUse !== undefined && valueToUse !== '') { + providerConfig[subBlock.id] = valueToUse + } else if (isFieldRequired(subBlock, subBlockValues)) { + missingFields.push(subBlock.title || subBlock.id) + } + }) + + const credentialConfig = triggerDef.subBlocks.find( + (subBlock) => subBlock.id === 'triggerCredentials' + ) + const triggerCredentials = getSubBlockValue(block, 'triggerCredentials') + if ( + credentialConfig && + isFieldRequired(credentialConfig, subBlockValues) && + !triggerCredentials + ) { + missingFields.push(credentialConfig.title || 'Credentials') + } + + let credentialId: string | undefined + let credentialSetId: string | undefined + if (typeof triggerCredentials === 'string' && triggerCredentials.length > 0) { + if (triggerCredentials.startsWith(CREDENTIAL_SET_PREFIX)) { + credentialSetId = triggerCredentials.slice(CREDENTIAL_SET_PREFIX.length) + providerConfig.credentialSetId = credentialSetId + } else { + credentialId = triggerCredentials + providerConfig.credentialId = credentialId + } + } + + providerConfig.triggerId = triggerId + + const triggerPathValue = getSubBlockValue(block, 'triggerPath') + const triggerPath = + typeof triggerPathValue === 'string' && triggerPathValue.length > 0 + ? triggerPathValue + : block.id + + return { providerConfig, missingFields, credentialId, credentialSetId, triggerPath } +} + +async function configurePollingIfNeeded( + provider: string, + savedWebhook: any, + requestId: string +): Promise { + if (provider === 'gmail') { + const success = await configureGmailPolling(savedWebhook, requestId) + if (!success) { + await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) + return { + message: 'Failed to configure Gmail polling. Please check your Gmail account permissions.', + status: 500, + } + } + } + + if (provider === 'outlook') { + const success = await configureOutlookPolling(savedWebhook, requestId) + if (!success) { + await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) + return { + message: + 'Failed to configure Outlook polling. Please check your Outlook account permissions.', + status: 500, + } + } + } + + return null +} + +async function syncCredentialSetWebhooks(params: { + workflowId: string + blockId: string + provider: string + triggerPath: string + providerConfig: Record + requestId: string +}): Promise { + const { workflowId, blockId, provider, triggerPath, providerConfig, requestId } = params + + const credentialSetId = providerConfig.credentialSetId as string | undefined + if (!credentialSetId) { + return null + } + + const oauthProviderId = getProviderIdFromServiceId(provider) + + const { credentialId: _cId, credentialSetId: _csId, userId: _uId, ...baseConfig } = providerConfig + + const syncResult = await syncWebhooksForCredentialSet({ + workflowId, + blockId, + provider, + basePath: triggerPath, + credentialSetId, + oauthProviderId, + providerConfig: baseConfig as Record, + requestId, + }) + + if (syncResult.webhooks.length === 0) { + return { + message: `No valid credentials found in credential set for ${provider}. Please connect accounts and try again.`, + status: 400, + } + } + + if (provider === 'gmail' || provider === 'outlook') { + const configureFunc = provider === 'gmail' ? configureGmailPolling : configureOutlookPolling + for (const wh of syncResult.webhooks) { + if (wh.isNew) { + const rows = await db.select().from(webhook).where(eq(webhook.id, wh.id)).limit(1) + if (rows.length > 0) { + const success = await configureFunc(rows[0], requestId) + if (!success) { + await db.delete(webhook).where(eq(webhook.id, wh.id)) + return { + message: `Failed to configure ${provider} polling. Please check account permissions.`, + status: 500, + } + } + } + } + } + } + + return null +} + +async function createWebhookForBlock(params: { + request: NextRequest + workflowId: string + workflow: Record + userId: string + block: BlockState + provider: string + providerConfig: Record + triggerPath: string + requestId: string +}): Promise { + const { + request, + workflowId, + workflow, + userId, + block, + provider, + providerConfig, + triggerPath, + requestId, + } = params + + const webhookId = nanoid() + const createPayload = { + id: webhookId, + path: triggerPath, + provider, + providerConfig, + } + + const result = await createExternalWebhookSubscription( + request, + createPayload, + workflow, + userId, + requestId + ) + + const updatedProviderConfig = result.updatedProviderConfig as Record + let savedWebhook: any + + try { + const createdRows = await db + .insert(webhook) + .values({ + id: webhookId, + workflowId, + blockId: block.id, + path: triggerPath, + provider, + providerConfig: updatedProviderConfig, + credentialSetId: (updatedProviderConfig.credentialSetId as string | undefined) || null, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + savedWebhook = createdRows[0] + } catch (error) { + if (result.externalSubscriptionCreated) { + await cleanupExternalWebhook(createPayload, workflow, requestId) + } + throw error + } + + const pollingError = await configurePollingIfNeeded(provider, savedWebhook, requestId) + if (pollingError) { + return pollingError + } + + return null +} + +/** + * Saves trigger webhook configurations as part of workflow deployment. + * Uses delete + create approach for changed/deleted webhooks. + */ +export async function saveTriggerWebhooksForDeploy({ + request, + workflowId, + workflow, + userId, + blocks, + requestId, +}: SaveTriggerWebhooksInput): Promise { + const triggerBlocks = Object.values(blocks || {}).filter(Boolean) + const currentBlockIds = new Set(triggerBlocks.map((b) => b.id)) + + // 1. Get all existing webhooks for this workflow + const existingWebhooks = await db.select().from(webhook).where(eq(webhook.workflowId, workflowId)) + + const webhooksByBlockId = new Map( + existingWebhooks.filter((wh) => wh.blockId).map((wh) => [wh.blockId!, wh]) + ) + + logger.info(`[${requestId}] Starting webhook sync`, { + workflowId, + currentBlockIds: Array.from(currentBlockIds), + existingWebhookBlockIds: Array.from(webhooksByBlockId.keys()), + }) + + // 2. Determine which webhooks to delete (orphaned or config changed) + const webhooksToDelete: typeof existingWebhooks = [] + const blocksNeedingWebhook: BlockState[] = [] + + for (const block of triggerBlocks) { + const triggerId = resolveTriggerId(block) + if (!triggerId || !isTriggerValid(triggerId)) continue + + const triggerDef = getTrigger(triggerId) + const provider = triggerDef.provider + const { providerConfig, missingFields, triggerPath } = buildProviderConfig( + block, + triggerId, + triggerDef + ) + + if (missingFields.length > 0) { + return { + success: false, + error: { + message: `Missing required fields for ${triggerDef.name || triggerId}: ${missingFields.join(', ')}`, + status: 400, + }, + } + } + // Store config for later use + + ;(block as any)._webhookConfig = { provider, providerConfig, triggerPath, triggerDef } + + const existingWh = webhooksByBlockId.get(block.id) + if (!existingWh) { + // No existing webhook - needs creation + blocksNeedingWebhook.push(block) + } else { + // Check if config changed + const existingConfig = (existingWh.providerConfig as Record) || {} + if ( + shouldRecreateExternalWebhookSubscription({ + previousProvider: existingWh.provider as string, + nextProvider: provider, + previousConfig: existingConfig, + nextConfig: providerConfig, + }) + ) { + // Config changed - delete and recreate + webhooksToDelete.push(existingWh) + blocksNeedingWebhook.push(block) + logger.info(`[${requestId}] Webhook config changed for block ${block.id}, will recreate`) + } + // else: config unchanged, keep existing webhook + } + } + + // Add orphaned webhooks (block no longer exists) + for (const wh of existingWebhooks) { + if (wh.blockId && !currentBlockIds.has(wh.blockId)) { + webhooksToDelete.push(wh) + logger.info(`[${requestId}] Webhook orphaned (block deleted): ${wh.blockId}`) + } + } + + // 3. Delete webhooks that need deletion + if (webhooksToDelete.length > 0) { + logger.info(`[${requestId}] Deleting ${webhooksToDelete.length} webhook(s)`, { + webhookIds: webhooksToDelete.map((wh) => wh.id), + }) + + for (const wh of webhooksToDelete) { + try { + await cleanupExternalWebhook(wh, workflow, requestId) + } catch (cleanupError) { + logger.warn(`[${requestId}] Failed to cleanup external webhook ${wh.id}`, cleanupError) + } + } + + const idsToDelete = webhooksToDelete.map((wh) => wh.id) + await db.delete(webhook).where(inArray(webhook.id, idsToDelete)) + } + + // 4. Create webhooks for blocks that need them + for (const block of blocksNeedingWebhook) { + const config = (block as any)._webhookConfig + if (!config) continue + + const { provider, providerConfig, triggerPath } = config + + try { + // Handle credential sets + const credentialSetError = await syncCredentialSetWebhooks({ + workflowId, + blockId: block.id, + provider, + triggerPath, + providerConfig, + requestId, + }) + + if (credentialSetError) { + return { success: false, error: credentialSetError } + } + + if (providerConfig.credentialSetId) { + continue + } + + const createError = await createWebhookForBlock({ + request, + workflowId, + workflow, + userId, + block, + provider, + providerConfig, + triggerPath, + requestId, + }) + + if (createError) { + return { success: false, error: createError } + } + } catch (error: any) { + logger.error(`[${requestId}] Failed to create webhook for ${block.id}`, error) + return { + success: false, + error: { + message: error?.message || 'Failed to save trigger configuration', + status: 500, + }, + } + } + } + + // Clean up temp config + for (const block of triggerBlocks) { + ;(block as any)._webhookConfig = undefined + } + + return { success: true } +} + +/** + * Clean up all webhooks for a workflow during undeploy. + * Removes external subscriptions and deletes webhook records from the database. + */ +export async function cleanupWebhooksForWorkflow( + workflowId: string, + workflow: Record, + requestId: string +): Promise { + const existingWebhooks = await db.select().from(webhook).where(eq(webhook.workflowId, workflowId)) + + if (existingWebhooks.length === 0) { + logger.debug(`[${requestId}] No webhooks to clean up for workflow ${workflowId}`) + return + } + + logger.info(`[${requestId}] Cleaning up ${existingWebhooks.length} webhook(s) for undeploy`, { + workflowId, + webhookIds: existingWebhooks.map((wh) => wh.id), + }) + + // Clean up external subscriptions + for (const wh of existingWebhooks) { + try { + await cleanupExternalWebhook(wh, workflow, requestId) + } catch (cleanupError) { + logger.warn(`[${requestId}] Failed to cleanup external webhook ${wh.id}`, cleanupError) + // Continue with other webhooks even if one fails + } + } + + // Delete all webhook records + await db.delete(webhook).where(eq(webhook.workflowId, workflowId)) + + logger.info(`[${requestId}] Cleaned up all webhooks for workflow ${workflowId}`) +} diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 1e031d921c..9e8f729c26 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -10,6 +10,7 @@ const typeformLogger = createLogger('TypeformWebhook') const calendlyLogger = createLogger('CalendlyWebhook') const grainLogger = createLogger('GrainWebhook') const lemlistLogger = createLogger('LemlistWebhook') +const webflowLogger = createLogger('WebflowWebhook') function getProviderConfig(webhook: any): Record { return (webhook.providerConfig as Record) || {} @@ -728,38 +729,863 @@ export async function deleteLemlistWebhook(webhook: any, requestId: string): Pro return } - if (!externalId) { + const authString = Buffer.from(`:${apiKey}`).toString('base64') + + const deleteById = async (id: string) => { + const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${id}` + const lemlistResponse = await fetch(lemlistApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Basic ${authString}`, + }, + }) + + if (!lemlistResponse.ok && lemlistResponse.status !== 404) { + const responseBody = await lemlistResponse.json().catch(() => ({})) + lemlistLogger.warn( + `[${requestId}] Failed to delete Lemlist webhook (non-fatal): ${lemlistResponse.status}`, + { response: responseBody } + ) + } else { + lemlistLogger.info(`[${requestId}] Successfully deleted Lemlist webhook ${id}`) + } + } + + if (externalId) { + await deleteById(externalId) + return + } + + const notificationUrl = getNotificationUrl(webhook) + const listResponse = await fetch('https://api.lemlist.com/api/hooks', { + method: 'GET', + headers: { + Authorization: `Basic ${authString}`, + }, + }) + + if (!listResponse.ok) { lemlistLogger.warn( - `[${requestId}] Missing externalId for Lemlist webhook deletion ${webhook.id}, skipping cleanup` + `[${requestId}] Failed to list Lemlist webhooks for cleanup ${webhook.id}`, + { status: listResponse.status } ) return } - // Lemlist uses Basic Auth with empty username and API key as password - const authString = Buffer.from(`:${apiKey}`).toString('base64') - const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${externalId}` + const listBody = await listResponse.json().catch(() => null) + const hooks: Array> = Array.isArray(listBody) + ? listBody + : listBody?.hooks || listBody?.data || [] + const matches = hooks.filter((hook) => { + const targetUrl = hook?.targetUrl || hook?.target_url || hook?.url + return typeof targetUrl === 'string' && targetUrl === notificationUrl + }) - const lemlistResponse = await fetch(lemlistApiUrl, { + if (matches.length === 0) { + lemlistLogger.info(`[${requestId}] Lemlist webhook not found for cleanup ${webhook.id}`, { + notificationUrl, + }) + return + } + + for (const hook of matches) { + const hookId = hook?._id || hook?.id + if (typeof hookId === 'string' && hookId.length > 0) { + await deleteById(hookId) + } + } + } catch (error) { + lemlistLogger.warn(`[${requestId}] Error deleting Lemlist webhook (non-fatal)`, error) + } +} + +export async function deleteWebflowWebhook( + webhook: any, + workflow: any, + requestId: string +): Promise { + try { + const config = getProviderConfig(webhook) + const siteId = config.siteId as string | undefined + const externalId = config.externalId as string | undefined + + if (!siteId) { + webflowLogger.warn( + `[${requestId}] Missing siteId for Webflow webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + webflowLogger.warn( + `[${requestId}] Missing externalId for Webflow webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const accessToken = await getOAuthToken(workflow.userId, 'webflow') + if (!accessToken) { + webflowLogger.warn( + `[${requestId}] Could not retrieve Webflow access token for user ${workflow.userId}. Cannot delete webhook.`, + { webhookId: webhook.id } + ) + return + } + + const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks/${externalId}` + + const webflowResponse = await fetch(webflowApiUrl, { method: 'DELETE', headers: { - Authorization: `Basic ${authString}`, + Authorization: `Bearer ${accessToken}`, + accept: 'application/json', }, }) - if (!lemlistResponse.ok && lemlistResponse.status !== 404) { - const responseBody = await lemlistResponse.json().catch(() => ({})) - lemlistLogger.warn( - `[${requestId}] Failed to delete Lemlist webhook (non-fatal): ${lemlistResponse.status}`, + if (!webflowResponse.ok && webflowResponse.status !== 404) { + const responseBody = await webflowResponse.json().catch(() => ({})) + webflowLogger.warn( + `[${requestId}] Failed to delete Webflow webhook (non-fatal): ${webflowResponse.status}`, { response: responseBody } ) } else { - lemlistLogger.info(`[${requestId}] Successfully deleted Lemlist webhook ${externalId}`) + webflowLogger.info(`[${requestId}] Successfully deleted Webflow webhook ${externalId}`) } } catch (error) { - lemlistLogger.warn(`[${requestId}] Error deleting Lemlist webhook (non-fatal)`, error) + webflowLogger.warn(`[${requestId}] Error deleting Webflow webhook (non-fatal)`, error) + } +} + +export async function createGrainWebhookSubscription( + _request: NextRequest, + webhookData: any, + requestId: string +): Promise<{ id: string; eventTypes: string[] } | undefined> { + try { + const { path, providerConfig } = webhookData + const { apiKey, triggerId, includeHighlights, includeParticipants, includeAiSummary } = + providerConfig || {} + + if (!apiKey) { + grainLogger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, { + webhookId: webhookData.id, + }) + throw new Error( + 'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.' + ) + } + + const hookTypeMap: Record = { + grain_webhook: 'recording_added', + grain_recording_created: 'recording_added', + grain_recording_updated: 'recording_added', + grain_highlight_created: 'recording_added', + grain_highlight_updated: 'recording_added', + grain_story_created: 'recording_added', + grain_upload_status: 'upload_status', + } + + const eventTypeMap: Record = { + grain_webhook: [], + grain_recording_created: ['recording_added'], + grain_recording_updated: ['recording_updated'], + grain_highlight_created: ['highlight_created'], + grain_highlight_updated: ['highlight_updated'], + grain_story_created: ['story_created'], + grain_upload_status: ['upload_status'], + } + + const hookType = hookTypeMap[triggerId] ?? 'recording_added' + const eventTypes = eventTypeMap[triggerId] ?? [] + + if (!hookTypeMap[triggerId]) { + grainLogger.warn( + `[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to recording_added`, + { + webhookId: webhookData.id, + } + ) + } + + grainLogger.info(`[${requestId}] Creating Grain webhook`, { + triggerId, + hookType, + eventTypes, + webhookId: webhookData.id, + }) + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const grainApiUrl = 'https://api.grain.com/_/public-api/v2/hooks/create' + + const requestBody: Record = { + hook_url: notificationUrl, + hook_type: hookType, + } + + const include: Record = {} + if (includeHighlights) { + include.highlights = true + } + if (includeParticipants) { + include.participants = true + } + if (includeAiSummary) { + include.ai_summary = true + } + if (Object.keys(include).length > 0) { + requestBody.include = include + } + + const grainResponse = await fetch(grainApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'Public-Api-Version': '2025-10-31', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await grainResponse.json() + + if (!grainResponse.ok || responseBody.error || responseBody.errors) { + const errorMessage = + responseBody.errors?.detail || + responseBody.error?.message || + responseBody.error || + responseBody.message || + 'Unknown Grain API error' + grainLogger.error( + `[${requestId}] Failed to create webhook in Grain for webhook ${webhookData.id}. Status: ${grainResponse.status}`, + { message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Grain' + if (grainResponse.status === 401) { + userFriendlyMessage = + 'Invalid Grain API Key. Please verify your Personal Access Token is correct.' + } else if (grainResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure your Grain API Key has appropriate permissions.' + } else if (errorMessage && errorMessage !== 'Unknown Grain API error') { + userFriendlyMessage = `Grain error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + grainLogger.info( + `[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`, + { + grainWebhookId: responseBody.id, + eventTypes, + } + ) + + return { id: responseBody.id, eventTypes } + } catch (error: any) { + grainLogger.error( + `[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`, + { + message: error.message, + stack: error.stack, + } + ) + throw error + } +} + +export async function createLemlistWebhookSubscription( + webhookData: any, + requestId: string +): Promise<{ id: string } | undefined> { + try { + const { path, providerConfig } = webhookData + const { apiKey, triggerId, campaignId } = providerConfig || {} + + if (!apiKey) { + lemlistLogger.warn(`[${requestId}] Missing apiKey for Lemlist webhook creation.`, { + webhookId: webhookData.id, + }) + throw new Error( + 'Lemlist API Key is required. Please provide your Lemlist API Key in the trigger configuration.' + ) + } + + const eventTypeMap: Record = { + lemlist_email_replied: 'emailsReplied', + lemlist_linkedin_replied: 'linkedinReplied', + lemlist_interested: 'interested', + lemlist_not_interested: 'notInterested', + lemlist_email_opened: 'emailsOpened', + lemlist_email_clicked: 'emailsClicked', + lemlist_email_bounced: 'emailsBounced', + lemlist_email_sent: 'emailsSent', + lemlist_webhook: undefined, + } + + const eventType = eventTypeMap[triggerId] + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + const authString = Buffer.from(`:${apiKey}`).toString('base64') + + lemlistLogger.info(`[${requestId}] Creating Lemlist webhook`, { + triggerId, + eventType, + hasCampaignId: !!campaignId, + webhookId: webhookData.id, + }) + + const lemlistApiUrl = 'https://api.lemlist.com/api/hooks' + + const requestBody: Record = { + targetUrl: notificationUrl, + } + + if (eventType) { + requestBody.type = eventType + } + + if (campaignId) { + requestBody.campaignId = campaignId + } + + const lemlistResponse = await fetch(lemlistApiUrl, { + method: 'POST', + headers: { + Authorization: `Basic ${authString}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await lemlistResponse.json() + + if (!lemlistResponse.ok || responseBody.error) { + const errorMessage = responseBody.message || responseBody.error || 'Unknown Lemlist API error' + lemlistLogger.error( + `[${requestId}] Failed to create webhook in Lemlist for webhook ${webhookData.id}. Status: ${lemlistResponse.status}`, + { message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Lemlist' + if (lemlistResponse.status === 401) { + userFriendlyMessage = 'Invalid Lemlist API Key. Please verify your API Key is correct.' + } else if (lemlistResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure your Lemlist API Key has appropriate permissions.' + } else if (errorMessage && errorMessage !== 'Unknown Lemlist API error') { + userFriendlyMessage = `Lemlist error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + lemlistLogger.info( + `[${requestId}] Successfully created webhook in Lemlist for webhook ${webhookData.id}.`, + { + lemlistWebhookId: responseBody._id, + } + ) + + return { id: responseBody._id } + } catch (error: any) { + lemlistLogger.error( + `[${requestId}] Exception during Lemlist webhook creation for webhook ${webhookData.id}.`, + { + message: error.message, + stack: error.stack, + } + ) + throw error + } +} + +export async function createAirtableWebhookSubscription( + userId: string, + webhookData: any, + requestId: string +): Promise { + try { + const { path, providerConfig } = webhookData + const { baseId, tableId, includeCellValuesInFieldIds } = providerConfig || {} + + if (!baseId || !tableId) { + airtableLogger.warn( + `[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, + { + webhookId: webhookData.id, + } + ) + throw new Error( + 'Base ID and Table ID are required to create Airtable webhook. Please provide valid Airtable base and table IDs.' + ) + } + + const accessToken = await getOAuthToken(userId, 'airtable') + if (!accessToken) { + airtableLogger.warn( + `[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.` + ) + throw new Error( + 'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.' + ) + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` + + const specification: any = { + options: { + filters: { + dataTypes: ['tableData'], + recordChangeScope: tableId, + }, + }, + } + + if (includeCellValuesInFieldIds === 'all') { + specification.options.includes = { + includeCellValuesInFieldIds: 'all', + } + } + + const requestBody: any = { + notificationUrl: notificationUrl, + specification: specification, + } + + const airtableResponse = await fetch(airtableApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await airtableResponse.json() + + if (!airtableResponse.ok || responseBody.error) { + const errorMessage = + responseBody.error?.message || responseBody.error || 'Unknown Airtable API error' + const errorType = responseBody.error?.type + airtableLogger.error( + `[${requestId}] Failed to create webhook in Airtable for webhook ${webhookData.id}. Status: ${airtableResponse.status}`, + { type: errorType, message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Airtable' + if (airtableResponse.status === 404) { + userFriendlyMessage = + 'Airtable base or table not found. Please verify that the Base ID and Table ID are correct and that you have access to them.' + } else if (errorMessage && errorMessage !== 'Unknown Airtable API error') { + userFriendlyMessage = `Airtable error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + airtableLogger.info( + `[${requestId}] Successfully created webhook in Airtable for webhook ${webhookData.id}.`, + { + airtableWebhookId: responseBody.id, + } + ) + return responseBody.id + } catch (error: any) { + airtableLogger.error( + `[${requestId}] Exception during Airtable webhook creation for webhook ${webhookData.id}.`, + { + message: error.message, + stack: error.stack, + } + ) + throw error } } +export async function createCalendlyWebhookSubscription( + webhookData: any, + requestId: string +): Promise { + try { + const { path, providerConfig } = webhookData + const { apiKey, organization, triggerId } = providerConfig || {} + + if (!apiKey) { + calendlyLogger.warn(`[${requestId}] Missing apiKey for Calendly webhook creation.`, { + webhookId: webhookData.id, + }) + throw new Error( + 'Personal Access Token is required to create Calendly webhook. Please provide your Calendly Personal Access Token.' + ) + } + + if (!organization) { + calendlyLogger.warn( + `[${requestId}] Missing organization URI for Calendly webhook creation.`, + { + webhookId: webhookData.id, + } + ) + throw new Error( + 'Organization URI is required to create Calendly webhook. Please provide your Organization URI from the "Get Current User" operation.' + ) + } + + if (!triggerId) { + calendlyLogger.warn(`[${requestId}] Missing triggerId for Calendly webhook creation.`, { + webhookId: webhookData.id, + }) + throw new Error('Trigger ID is required to create Calendly webhook') + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const eventTypeMap: Record = { + calendly_invitee_created: ['invitee.created'], + calendly_invitee_canceled: ['invitee.canceled'], + calendly_routing_form_submitted: ['routing_form_submission.created'], + calendly_webhook: ['invitee.created', 'invitee.canceled', 'routing_form_submission.created'], + } + + const events = eventTypeMap[triggerId] || ['invitee.created'] + + const calendlyApiUrl = 'https://api.calendly.com/webhook_subscriptions' + + const requestBody = { + url: notificationUrl, + events, + organization, + scope: 'organization', + } + + const calendlyResponse = await fetch(calendlyApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!calendlyResponse.ok) { + const errorBody = await calendlyResponse.json().catch(() => ({})) + const errorMessage = errorBody.message || errorBody.title || 'Unknown Calendly API error' + calendlyLogger.error( + `[${requestId}] Failed to create webhook in Calendly for webhook ${webhookData.id}. Status: ${calendlyResponse.status}`, + { response: errorBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Calendly' + if (calendlyResponse.status === 401) { + userFriendlyMessage = + 'Calendly authentication failed. Please verify your Personal Access Token is correct.' + } else if (calendlyResponse.status === 403) { + userFriendlyMessage = + 'Calendly access denied. Please ensure you have appropriate permissions and a paid Calendly subscription.' + } else if (calendlyResponse.status === 404) { + userFriendlyMessage = + 'Calendly organization not found. Please verify the Organization URI is correct.' + } else if (errorMessage && errorMessage !== 'Unknown Calendly API error') { + userFriendlyMessage = `Calendly error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = await calendlyResponse.json() + const webhookUri = responseBody.resource?.uri + + if (!webhookUri) { + calendlyLogger.error( + `[${requestId}] Calendly webhook created but no webhook URI returned for webhook ${webhookData.id}`, + { response: responseBody } + ) + throw new Error('Calendly webhook creation succeeded but no webhook URI was returned') + } + + const webhookId = webhookUri.split('/').pop() + + if (!webhookId) { + calendlyLogger.error( + `[${requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`, + { + response: responseBody, + } + ) + throw new Error('Failed to extract webhook ID from Calendly response') + } + + calendlyLogger.info( + `[${requestId}] Successfully created webhook in Calendly for webhook ${webhookData.id}.`, + { + calendlyWebhookUri: webhookUri, + calendlyWebhookId: webhookId, + } + ) + return webhookId + } catch (error: any) { + calendlyLogger.error( + `[${requestId}] Exception during Calendly webhook creation for webhook ${webhookData.id}.`, + { + message: error.message, + stack: error.stack, + } + ) + throw error + } +} + +export async function createWebflowWebhookSubscription( + userId: string, + webhookData: any, + requestId: string +): Promise { + try { + const { path, providerConfig } = webhookData + const { siteId, triggerId, collectionId, formId } = providerConfig || {} + + if (!siteId) { + webflowLogger.warn(`[${requestId}] Missing siteId for Webflow webhook creation.`, { + webhookId: webhookData.id, + }) + throw new Error('Site ID is required to create Webflow webhook') + } + + if (!triggerId) { + webflowLogger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, { + webhookId: webhookData.id, + }) + throw new Error('Trigger type is required to create Webflow webhook') + } + + const accessToken = await getOAuthToken(userId, 'webflow') + if (!accessToken) { + webflowLogger.warn( + `[${requestId}] Could not retrieve Webflow access token for user ${userId}. Cannot create webhook in Webflow.` + ) + throw new Error( + 'Webflow account connection required. Please connect your Webflow account in the trigger configuration and try again.' + ) + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const triggerTypeMap: Record = { + webflow_collection_item_created: 'collection_item_created', + webflow_collection_item_changed: 'collection_item_changed', + webflow_collection_item_deleted: 'collection_item_deleted', + webflow_form_submission: 'form_submission', + } + + const webflowTriggerType = triggerTypeMap[triggerId] + if (!webflowTriggerType) { + webflowLogger.warn(`[${requestId}] Invalid triggerId for Webflow: ${triggerId}`, { + webhookId: webhookData.id, + }) + throw new Error(`Invalid Webflow trigger type: ${triggerId}`) + } + + const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks` + + const requestBody: any = { + triggerType: webflowTriggerType, + url: notificationUrl, + } + + if (collectionId && webflowTriggerType.startsWith('collection_item_')) { + requestBody.filter = { + resource_type: 'collection', + resource_id: collectionId, + } + } + + if (formId && webflowTriggerType === 'form_submission') { + requestBody.filter = { + resource_type: 'form', + resource_id: formId, + } + } + + const webflowResponse = await fetch(webflowApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await webflowResponse.json() + + if (!webflowResponse.ok || responseBody.error) { + const errorMessage = responseBody.message || responseBody.error || 'Unknown Webflow API error' + webflowLogger.error( + `[${requestId}] Failed to create webhook in Webflow for webhook ${webhookData.id}. Status: ${webflowResponse.status}`, + { message: errorMessage, response: responseBody } + ) + throw new Error(errorMessage) + } + + webflowLogger.info( + `[${requestId}] Successfully created webhook in Webflow for webhook ${webhookData.id}.`, + { + webflowWebhookId: responseBody.id || responseBody._id, + } + ) + + return responseBody.id || responseBody._id + } catch (error: any) { + webflowLogger.error( + `[${requestId}] Exception during Webflow webhook creation for webhook ${webhookData.id}.`, + { + message: error.message, + stack: error.stack, + } + ) + throw error + } +} + +type ExternalSubscriptionResult = { + updatedProviderConfig: Record + externalSubscriptionCreated: boolean +} + +type RecreateCheckInput = { + previousProvider: string + nextProvider: string + previousConfig: Record + nextConfig: Record +} + +/** Providers that create external webhook subscriptions */ +const PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS = new Set([ + 'airtable', + 'calendly', + 'webflow', + 'typeform', + 'grain', + 'lemlist', + 'telegram', + 'microsoft-teams', +]) + +/** System-managed fields that shouldn't trigger recreation */ +const SYSTEM_MANAGED_FIELDS = new Set([ + 'externalId', + 'externalSubscriptionId', + 'eventTypes', + 'webhookTag', + 'historyId', + 'lastCheckedTimestamp', + 'setupCompleted', + 'userId', +]) + +export function shouldRecreateExternalWebhookSubscription({ + previousProvider, + nextProvider, + previousConfig, + nextConfig, +}: RecreateCheckInput): boolean { + if (previousProvider !== nextProvider) { + return ( + PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS.has(previousProvider) || + PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS.has(nextProvider) + ) + } + + if (!PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS.has(nextProvider)) { + return false + } + + const allKeys = new Set([...Object.keys(previousConfig), ...Object.keys(nextConfig)]) + + for (const key of allKeys) { + if (SYSTEM_MANAGED_FIELDS.has(key)) continue + + const prevVal = previousConfig[key] + const nextVal = nextConfig[key] + + const prevStr = typeof prevVal === 'object' ? JSON.stringify(prevVal ?? null) : prevVal + const nextStr = typeof nextVal === 'object' ? JSON.stringify(nextVal ?? null) : nextVal + + if (prevStr !== nextStr) { + return true + } + } + + return false +} + +export async function createExternalWebhookSubscription( + request: NextRequest, + webhookData: any, + workflow: any, + userId: string, + requestId: string +): Promise { + const provider = webhookData.provider as string + const providerConfig = (webhookData.providerConfig as Record) || {} + let updatedProviderConfig = providerConfig + let externalSubscriptionCreated = false + + if (provider === 'airtable') { + const externalId = await createAirtableWebhookSubscription(userId, webhookData, requestId) + if (externalId) { + updatedProviderConfig = { ...updatedProviderConfig, externalId } + externalSubscriptionCreated = true + } + } else if (provider === 'calendly') { + const externalId = await createCalendlyWebhookSubscription(webhookData, requestId) + if (externalId) { + updatedProviderConfig = { ...updatedProviderConfig, externalId } + externalSubscriptionCreated = true + } + } else if (provider === 'microsoft-teams') { + await createTeamsSubscription(request, webhookData, workflow, requestId) + externalSubscriptionCreated = + (providerConfig.triggerId as string | undefined) === 'microsoftteams_chat_subscription' + } else if (provider === 'telegram') { + await createTelegramWebhook(request, webhookData, requestId) + externalSubscriptionCreated = true + } else if (provider === 'webflow') { + const externalId = await createWebflowWebhookSubscription(userId, webhookData, requestId) + if (externalId) { + updatedProviderConfig = { ...updatedProviderConfig, externalId } + externalSubscriptionCreated = true + } + } else if (provider === 'typeform') { + const usedTag = await createTypeformWebhook(request, webhookData, requestId) + if (!updatedProviderConfig.webhookTag && usedTag) { + updatedProviderConfig = { ...updatedProviderConfig, webhookTag: usedTag } + } + externalSubscriptionCreated = true + } else if (provider === 'grain') { + const result = await createGrainWebhookSubscription(request, webhookData, requestId) + if (result) { + updatedProviderConfig = { + ...updatedProviderConfig, + externalId: result.id, + eventTypes: result.eventTypes, + } + externalSubscriptionCreated = true + } + } else if (provider === 'lemlist') { + const result = await createLemlistWebhookSubscription(webhookData, requestId) + if (result) { + updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id } + externalSubscriptionCreated = true + } + } + + return { updatedProviderConfig, externalSubscriptionCreated } +} + /** * Clean up external webhook subscriptions for a webhook * Handles Airtable, Teams, Telegram, Typeform, Calendly, Grain, and Lemlist cleanup @@ -780,6 +1606,8 @@ export async function cleanupExternalWebhook( await deleteTypeformWebhook(webhook, requestId) } else if (webhook.provider === 'calendly') { await deleteCalendlyWebhook(webhook, requestId) + } else if (webhook.provider === 'webflow') { + await deleteWebflowWebhook(webhook, workflow, requestId) } else if (webhook.provider === 'grain') { await deleteGrainWebhook(webhook, requestId) } else if (webhook.provider === 'lemlist') { diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index f5960e4b59..9ce1bfbc86 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -1,6 +1,7 @@ import * as schema from '@sim/db' import { webhook, workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db' import { createLogger } from '@sim/logger' +import type { InferSelectModel } from 'drizzle-orm' import { and, eq, inArray, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' @@ -1134,7 +1135,14 @@ async function handleWorkflowOperationTx( parallelCount: Object.keys(parallels || {}).length, }) - // Delete all existing blocks (this will cascade delete edges via ON DELETE CASCADE) + // Snapshot existing webhooks before deletion to preserve them through the cycle + // (workflowBlocks has CASCADE DELETE to webhook table) + const existingWebhooks = await tx + .select() + .from(webhook) + .where(eq(webhook.workflowId, workflowId)) + + // Delete all existing blocks (this will cascade delete edges and webhooks via ON DELETE CASCADE) await tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)) // Delete all existing subflows @@ -1200,6 +1208,32 @@ async function handleWorkflowOperationTx( await tx.insert(workflowSubflows).values(parallelValues) } + // Re-insert preserved webhooks if any exist and their blocks still exist + type WebhookRecord = InferSelectModel + if (existingWebhooks.length > 0) { + const webhookInserts = existingWebhooks + .filter((wh: WebhookRecord) => !!blocks?.[wh.blockId ?? '']) + .map((wh: WebhookRecord) => ({ + id: wh.id, + workflowId: wh.workflowId, + blockId: wh.blockId, + path: wh.path, + provider: wh.provider, + providerConfig: wh.providerConfig, + credentialSetId: wh.credentialSetId, + isActive: wh.isActive, + createdAt: wh.createdAt, + updatedAt: new Date(), + })) + + if (webhookInserts.length > 0) { + await tx.insert(webhook).values(webhookInserts) + logger.debug(`Preserved ${webhookInserts.length} webhook(s) through state replacement`, { + workflowId, + }) + } + } + logger.info(`Successfully replaced workflow state for ${workflowId}`) break } diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index 354994db04..66258cbd41 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -8,24 +8,8 @@ export const SYSTEM_SUBBLOCK_IDS: string[] = [ 'triggerCredentials', // OAuth credentials subblock 'triggerInstructions', // Setup instructions text 'webhookUrlDisplay', // Webhook URL display - 'triggerSave', // Save configuration button 'samplePayload', // Example payload display 'setupScript', // Setup script code (e.g., Apps Script) - 'triggerId', // Stored trigger ID - 'selectedTriggerId', // Selected trigger from dropdown (multi-trigger blocks) -] - -/** - * Trigger-related subblock IDs whose values should be persisted and - * propagated when workflows are edited programmatically. - */ -export const TRIGGER_PERSISTED_SUBBLOCK_IDS: string[] = [ - 'triggerConfig', - 'triggerCredentials', - 'triggerId', - 'selectedTriggerId', - 'webhookId', - 'triggerPath', ] /** diff --git a/apps/sim/triggers/grain/utils.ts b/apps/sim/triggers/grain/utils.ts index 1d371f9cbb..5b4820efaa 100644 --- a/apps/sim/triggers/grain/utils.ts +++ b/apps/sim/triggers/grain/utils.ts @@ -19,7 +19,6 @@ export function grainSetupInstructions(eventType: string): string { const instructions = [ 'Enter your Grain API Key (Personal Access Token) above.', 'You can find or create your API key in Grain at Settings > Integrations > API.', - `Click "Save Configuration" to automatically create the webhook in Grain for ${eventType} events.`, 'The webhook will be automatically deleted when you remove this trigger.', ] diff --git a/apps/sim/triggers/hubspot/utils.ts b/apps/sim/triggers/hubspot/utils.ts index 09ef6fcfcc..21cd74bd22 100644 --- a/apps/sim/triggers/hubspot/utils.ts +++ b/apps/sim/triggers/hubspot/utils.ts @@ -82,9 +82,8 @@ export function hubspotSetupInstructions(eventType: string, additionalNotes?: st 'Step 3: Configure OAuth Settings
After creating your app via CLI, configure it to add the OAuth Redirect URL: https://www.sim.ai/api/auth/oauth2/callback/hubspot. Then retrieve your Client ID and Client Secret from your app configuration and enter them in the fields above.', "Step 4: Get App ID and Developer API Key
In your HubSpot developer account, find your App ID (shown below your app name) and your Developer API Key (in app settings). You'll need both for the next steps.", 'Step 5: Set Required Scopes
Configure your app to include the required OAuth scope: crm.objects.contacts.read', - 'Step 6: Save Configuration in Sim
Click the "Save Configuration" button above. This will generate your unique webhook URL.', - 'Step 7: Configure Webhook in HubSpot via API
After saving above, copy the Webhook URL and run the two curl commands below (replace {YOUR_APP_ID}, {YOUR_DEVELOPER_API_KEY}, and {YOUR_WEBHOOK_URL_FROM_ABOVE} with your actual values).', - "Step 8: Test Your Webhook
Create or modify a contact in HubSpot to trigger the webhook. Check your workflow execution logs in Sim to verify it's working.", + 'Step 6: Configure Webhook in HubSpot via API
After saving above, copy the Webhook URL and run the two curl commands below (replace {YOUR_APP_ID}, {YOUR_DEVELOPER_API_KEY}, and {YOUR_WEBHOOK_URL_FROM_ABOVE} with your actual values).', + "Step 7: Test Your Webhook
Create or modify a contact in HubSpot to trigger the webhook. Check your workflow execution logs in Sim to verify it's working.", ] if (additionalNotes) { diff --git a/apps/sim/triggers/index.ts b/apps/sim/triggers/index.ts index d4c517e9c5..d00cbf175c 100644 --- a/apps/sim/triggers/index.ts +++ b/apps/sim/triggers/index.ts @@ -14,6 +14,9 @@ export function getTrigger(triggerId: string): TriggerConfig { } const clonedTrigger = { ...trigger, subBlocks: [...trigger.subBlocks] } + clonedTrigger.subBlocks = clonedTrigger.subBlocks.filter( + (subBlock) => subBlock.id !== 'triggerSave' && subBlock.type !== 'trigger-save' + ) // Inject samplePayload for webhooks/pollers with condition if (trigger.webhook || trigger.id.includes('webhook') || trigger.id.includes('poller')) { @@ -155,16 +158,6 @@ export function buildTriggerSubBlocks(options: BuildTriggerSubBlocksOptions): Su } // Save button - blocks.push({ - id: 'triggerSave', - title: '', - type: 'trigger-save', - hideFromPreview: true, - mode: 'trigger', - triggerId: triggerId, - condition: { field: 'selectedTriggerId', value: triggerId }, - }) - // Setup instructions blocks.push({ id: 'triggerInstructions', diff --git a/apps/sim/triggers/lemlist/utils.ts b/apps/sim/triggers/lemlist/utils.ts index 6ecbcce6cf..5a8160d777 100644 --- a/apps/sim/triggers/lemlist/utils.ts +++ b/apps/sim/triggers/lemlist/utils.ts @@ -23,7 +23,6 @@ export function lemlistSetupInstructions(eventType: string): string { const instructions = [ 'Enter your Lemlist API Key above.', 'You can find your API key in Lemlist at Settings > Integrations > API.', - `Click "Save Configuration" to automatically create the webhook in Lemlist for ${eventType} events.`, 'The webhook will be automatically deleted when you remove this trigger.', ] diff --git a/apps/sim/triggers/twilio_voice/webhook.ts b/apps/sim/triggers/twilio_voice/webhook.ts index c87194b77f..45ebb5744f 100644 --- a/apps/sim/triggers/twilio_voice/webhook.ts +++ b/apps/sim/triggers/twilio_voice/webhook.ts @@ -129,7 +129,6 @@ Return ONLY the TwiML with square brackets - no explanations, no markdown, no ex 'Scroll down to the "Voice Configuration" section.', 'In the "A CALL COMES IN" field, select "Webhook" and paste the Webhook URL (from above).', 'Ensure the HTTP method is set to POST.', - 'Click "Save configuration".', 'How it works: When a call comes in, Twilio receives your TwiML response immediately and executes those instructions. Your workflow runs in the background with access to caller information, call status, and any recorded/transcribed data.', ] .map((instruction, index) => `${index + 1}. ${instruction}`)