Compare commits

...

3 Commits

Author SHA1 Message Date
Vikhyath Mondreti
fc59953a83 remove useless instruction 2026-01-14 21:35:58 -08:00
Vikhyath Mondreti
7f8d68206a remove save configuration button 2026-01-14 21:31:07 -08:00
Vikhyath Mondreti
6b776c5bb4 fix(webhooks): lifecycle code accuracy 2026-01-14 19:37:55 -08:00
17 changed files with 1461 additions and 1470 deletions

View File

@@ -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<string, unknown>) || {}
let nextProviderConfig =
providerConfig !== undefined &&
resolvedProviderConfig &&
typeof resolvedProviderConfig === 'object'
? (resolvedProviderConfig as Record<string, unknown>)
: 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<string, unknown>
}
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<string, unknown>) || {}
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,
}
}

View File

@@ -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<string, unknown>
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<string, any>),
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<string, any>),
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<string | undefined> {
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<string | undefined> {
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<string, string[]> = {
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<string | undefined> {
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<string, string> = {
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<string, string> = {
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<string, string[]> = {
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<string, any> = {
hook_url: notificationUrl,
hook_type: hookType,
}
// Build include object based on configuration
const include: Record<string, boolean> = {}
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<string, string | undefined> = {
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<string, any> = {
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
}
}

View File

@@ -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 { 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,

View File

@@ -1,19 +1,19 @@
import { db } from '@sim/db'
import { webhook, workflow } from '@sim/db/schema'
import { webhook, workflow, workflowBlocks } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
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')
@@ -193,6 +193,59 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
deployedAt: state.deployedAt,
}
// Find blocks that were deleted or edited
const currentBlockIds = new Set(Object.keys(filteredBlocks))
const previousBlocks = await db
.select({ id: workflowBlocks.id, data: workflowBlocks.data })
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
const blocksToCleanup: string[] = []
for (const prevBlock of previousBlocks) {
if (!currentBlockIds.has(prevBlock.id)) {
// Block was deleted
blocksToCleanup.push(prevBlock.id)
} else {
// Block still exists - check if it was edited
const newBlock = filteredBlocks[prevBlock.id]
const prevData = prevBlock.data as Record<string, unknown> | null
if (prevData && JSON.stringify(prevData) !== JSON.stringify(newBlock)) {
blocksToCleanup.push(prevBlock.id)
}
}
}
if (blocksToCleanup.length > 0) {
const webhooksToCleanup = await db
.select()
.from(webhook)
.where(inArray(webhook.blockId, blocksToCleanup))
if (webhooksToCleanup.length > 0) {
logger.info(`[${requestId}] Cleaning up ${webhooksToCleanup.length} webhook(s)`, {
workflowId,
blocksEdited: blocksToCleanup.length,
})
const webhookIdsToDelete: string[] = []
for (const wh of webhooksToCleanup) {
try {
await cleanupExternalWebhook(wh, workflowData, requestId)
} catch (cleanupError) {
logger.warn(
`[${requestId}] Failed to cleanup external webhook ${wh.id} during workflow save`,
cleanupError
)
}
webhookIdsToDelete.push(wh.id)
}
if (webhookIdsToDelete.length > 0) {
await db.delete(webhook).where(inArray(webhook.id, webhookIdsToDelete))
}
}
}
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState as any)
if (!saveResult.success) {
@@ -203,8 +256,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 +341,3 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
function getSubBlockValue<T = unknown>(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<string, any>
): Promise<void> {
await syncBlockResources(workflowId, blocks, {
resourceName: 'webhook',
subBlockId: 'webhookId',
buildMetadata: buildWebhookMetadata,
applyMetadata: upsertWebhookRecord,
})
}
interface WebhookMetadata {
triggerPath: string
provider: string | null
providerConfig: Record<string, any>
}
const CREDENTIAL_SET_PREFIX = 'credentialSet:'
function buildWebhookMetadata(block: BlockState): WebhookMetadata | null {
const triggerId =
getSubBlockValue<string>(block, 'triggerId') ||
getSubBlockValue<string>(block, 'selectedTriggerId')
const triggerConfig = getSubBlockValue<Record<string, any>>(block, 'triggerConfig') || {}
const triggerCredentials = getSubBlockValue<string>(block, 'triggerCredentials')
const triggerPath = getSubBlockValue<string>(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<void> {
const providerConfig = metadata.providerConfig as Record<string, unknown>
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<string, any>,
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<T> {
resourceName: string
subBlockId: string
buildMetadata: (block: BlockState, resourceId: string) => T | null
applyMetadata: (
workflowId: string,
block: BlockState,
resourceId: string,
metadata: T
) => Promise<void>
}
async function syncBlockResources<T>(
workflowId: string,
blocks: Record<string, any>,
config: BlockResourceSyncConfig<T>
): Promise<void> {
const blockEntries = Object.values(blocks || {}).filter(Boolean) as BlockState[]
if (blockEntries.length === 0) return
for (const block of blockEntries) {
const resourceId = getSubBlockValue<string>(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,
})
}
}
}

View File

@@ -663,6 +663,12 @@ export function DeployModal({
</ModalTabsList>
<ModalBody className='min-h-0 flex-1'>
{apiDeployError && (
<div className='mb-3 rounded-[4px] border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
<div className='font-semibold'>Deployment Error</div>
<div>{apiDeployError}</div>
</div>
)}
<ModalTabsContent value='general'>
<GeneralDeploy
workflowId={workflowId}

View File

@@ -32,5 +32,4 @@ export { Table } from './table/table'
export { Text } from './text/text'
export { TimeInput } from './time-input/time-input'
export { ToolInput } from './tool-input/tool-input'
export { TriggerSave } from './trigger-save/trigger-save'
export { VariablesInput } from './variables-input/variables-input'

View File

@@ -1,348 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn/components'
import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useTriggerConfigAggregation } from '@/hooks/use-trigger-config-aggregation'
import { useWebhookManagement } from '@/hooks/use-webhook-management'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getTrigger, isTriggerValid } from '@/triggers'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
const logger = createLogger('TriggerSave')
interface TriggerSaveProps {
blockId: string
subBlockId: string
triggerId?: string
isPreview?: boolean
disabled?: boolean
}
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
export function TriggerSave({
blockId,
subBlockId,
triggerId,
isPreview = false,
disabled = false,
}: TriggerSaveProps) {
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(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<string, any> | 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<string, any> = {}
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<Record<string, any>>({})
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (saveStatus !== 'error' || !triggerDef) {
previousValuesRef.current = subscribedSubBlockValues
return
}
const hasChanges = Object.keys(subscribedSubBlockValues).some(
(key) =>
previousValuesRef.current[key] !== (subscribedSubBlockValues as Record<string, any>)[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 (
<div id={`${blockId}-${subBlockId}`}>
<div className='flex gap-2'>
<Button
variant='default'
onClick={handleSave}
disabled={disabled || isProcessing}
className={cn(
'flex-1',
saveStatus === 'saved' && '!bg-green-600 !text-white hover:!bg-green-700',
saveStatus === 'error' && '!bg-red-600 !text-white hover:!bg-red-700'
)}
>
{saveStatus === 'saving' && 'Saving...'}
{saveStatus === 'saved' && 'Saved'}
{saveStatus === 'error' && 'Error'}
{saveStatus === 'idle' && (webhookId ? 'Update Configuration' : 'Save Configuration')}
</Button>
{webhookId && (
<Button variant='default' onClick={handleDeleteClick} disabled={disabled || isProcessing}>
<Trash className='h-[14px] w-[14px]' />
</Button>
)}
</div>
{errorMessage && <p className='mt-2 text-[12px] text-[var(--text-error)]'>{errorMessage}</p>}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent size='sm'>
<ModalHeader>Delete Trigger</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete this trigger configuration? This will remove the
webhook and stop all incoming triggers.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='active' onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDeleteConfirm}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}

View File

@@ -38,7 +38,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'
@@ -854,17 +853,6 @@ function SubBlockComponent({
}
/>
)
case 'trigger-save':
return (
<TriggerSave
blockId={blockId}
subBlockId={config.id}
triggerId={config.triggerId}
isPreview={isPreview}
disabled={disabled}
/>
)
case 'messages-input':
return (
<MessagesInput

View File

@@ -71,6 +71,9 @@ export type SubBlockType =
| 'mcp-dynamic-args' // MCP dynamic arguments based on tool schema
| 'input-format' // Input structure format
| 'response-format' // Response structure format
/**
* @deprecated Legacy trigger save subblock type.
*/
| 'trigger-save' // Trigger save button with validation
| 'file-upload' // File uploader
| 'input-mapping' // Map parent variables to child workflow input schema

View File

@@ -0,0 +1,525 @@
import { db } from '@sim/db'
import { webhook } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import type { NextRequest } from 'next/server'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import {
cleanupExternalWebhook,
createExternalWebhookSubscription,
shouldRecreateExternalWebhookSubscription,
} from '@/lib/webhooks/provider-subscriptions'
import {
configureGmailPolling,
configureOutlookPolling,
syncWebhooksForCredentialSet,
} from '@/lib/webhooks/utils.server'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { getTrigger, isTriggerValid } from '@/triggers'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
const logger = createLogger('DeployWebhookSync')
const CREDENTIAL_SET_PREFIX = 'credentialSet:'
interface TriggerSaveError {
message: string
status: number
}
interface TriggerSaveResult {
success: boolean
error?: TriggerSaveError
}
interface SaveTriggerWebhooksInput {
request: NextRequest
workflowId: string
workflow: Record<string, unknown>
userId: string
blocks: Record<string, BlockState>
requestId: string
}
function getSubBlockValue(block: BlockState, subBlockId: string): unknown {
return block.subBlocks?.[subBlockId]?.value
}
function isFieldRequired(
config: SubBlockConfig,
subBlockValues: Record<string, { value?: unknown }>
): boolean {
if (!config.required) return false
if (typeof config.required === 'boolean') return config.required
const evalCond = (
cond: {
field: string
value: string | number | boolean | Array<string | number | boolean>
not?: boolean
and?: {
field: string
value: string | number | boolean | Array<string | number | boolean> | undefined
not?: boolean
}
},
values: Record<string, { value?: unknown }>
): 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<string, unknown>
missingFields: string[]
credentialId?: string
credentialSetId?: string
triggerPath: string
} {
const triggerConfigValue = getSubBlockValue(block, 'triggerConfig')
const baseConfig =
triggerConfigValue && typeof triggerConfigValue === 'object'
? (triggerConfigValue as Record<string, unknown>)
: {}
const providerConfig: Record<string, unknown> = { ...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<TriggerSaveError | null> {
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<string, unknown>
requestId: string
}): Promise<TriggerSaveError | null> {
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<string, any>,
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 upsertSingleWebhook(params: {
request: NextRequest
workflowId: string
workflow: Record<string, unknown>
userId: string
block: BlockState
provider: string
providerConfig: Record<string, unknown>
triggerPath: string
requestId: string
}): Promise<TriggerSaveError | null> {
const {
request,
workflowId,
workflow,
userId,
block,
provider,
providerConfig,
triggerPath,
requestId,
} = params
const existingWebhooks = await db
.select()
.from(webhook)
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, block.id)))
.limit(1)
const existing = existingWebhooks[0]
if (existing) {
const existingConfig = (existing.providerConfig as Record<string, unknown>) || {}
let nextProviderConfig = providerConfig
if (
shouldRecreateExternalWebhookSubscription({
previousProvider: existing.provider as string,
nextProvider: provider,
previousConfig: existingConfig,
nextConfig: nextProviderConfig,
})
) {
await cleanupExternalWebhook(existing, workflow, requestId)
const result = await createExternalWebhookSubscription(
request,
{
...existing,
provider,
path: triggerPath,
providerConfig: nextProviderConfig,
},
workflow,
userId,
requestId
)
nextProviderConfig = result.updatedProviderConfig as Record<string, unknown>
}
const finalProviderConfig = {
...nextProviderConfig,
credentialId: existingConfig.credentialId,
credentialSetId: existingConfig.credentialSetId,
userId: existingConfig.userId,
historyId: existingConfig.historyId,
lastCheckedTimestamp: existingConfig.lastCheckedTimestamp,
setupCompleted: existingConfig.setupCompleted,
externalId: nextProviderConfig.externalId ?? existingConfig.externalId,
}
await db
.update(webhook)
.set({
path: triggerPath,
provider,
providerConfig: finalProviderConfig,
isActive: true,
updatedAt: new Date(),
})
.where(eq(webhook.id, existing.id))
return null
}
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<string, unknown>
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.
*/
export async function saveTriggerWebhooksForDeploy({
request,
workflowId,
workflow,
userId,
blocks,
requestId,
}: SaveTriggerWebhooksInput): Promise<TriggerSaveResult> {
const triggerBlocks = Object.values(blocks || {}).filter(Boolean)
if (triggerBlocks.length === 0) {
return { success: true }
}
for (const block of triggerBlocks) {
const triggerId = resolveTriggerId(block)
if (!triggerId) continue
if (!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,
},
}
}
try {
const credentialSetError = await syncCredentialSetWebhooks({
workflowId,
blockId: block.id,
provider,
triggerPath,
providerConfig,
requestId,
})
if (credentialSetError) {
return { success: false, error: credentialSetError }
}
if (providerConfig.credentialSetId) {
continue
}
const upsertError = await upsertSingleWebhook({
request,
workflowId,
workflow,
userId,
block,
provider,
providerConfig,
triggerPath,
requestId,
})
if (upsertError) {
return { success: false, error: upsertError }
}
} catch (error: any) {
logger.error(`[${requestId}] Failed to save trigger config for ${block.id}`, error)
return {
success: false,
error: {
message: error?.message || 'Failed to save trigger configuration',
status: 500,
},
}
}
}
return { success: true }
}

View File

@@ -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<string, any> {
return (webhook.providerConfig as Record<string, any>) || {}
@@ -760,6 +761,775 @@ export async function deleteLemlistWebhook(webhook: any, requestId: string): Pro
}
}
export async function deleteWebflowWebhook(
webhook: any,
workflow: any,
requestId: string
): Promise<void> {
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: `Bearer ${accessToken}`,
accept: 'application/json',
},
})
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 {
webflowLogger.info(`[${requestId}] Successfully deleted Webflow webhook ${externalId}`)
}
} catch (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<string, string> = {
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<string, string[]> = {
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<string, any> = {
hook_url: notificationUrl,
hook_type: hookType,
}
const include: Record<string, boolean> = {}
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<string, string | undefined> = {
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]
lemlistLogger.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'
const requestBody: Record<string, any> = {
targetUrl: notificationUrl,
}
if (eventType) {
requestBody.type = eventType
}
if (campaignId) {
requestBody.campaignId = campaignId
}
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'
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<string | undefined> {
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<string | undefined> {
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<string, string[]> = {
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<string | undefined> {
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<string, string> = {
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<string, unknown>
externalSubscriptionCreated: boolean
}
type RecreateCheckInput = {
previousProvider: string
nextProvider: string
previousConfig: Record<string, unknown>
nextConfig: Record<string, unknown>
}
function areValuesEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (Array.isArray(a) || Array.isArray(b) || typeof a === 'object' || typeof b === 'object') {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
}
return false
}
export function shouldRecreateExternalWebhookSubscription({
previousProvider,
nextProvider,
previousConfig,
nextConfig,
}: RecreateCheckInput): boolean {
const relevantKeysByProvider: Record<string, string[]> = {
airtable: ['baseId', 'tableId', 'includeCellValues', 'includeCellValuesInFieldIds'],
calendly: ['apiKey', 'organization', 'triggerId'],
webflow: ['siteId', 'collectionId', 'formId', 'triggerId'],
typeform: ['formId', 'apiKey', 'secret', 'webhookTag'],
grain: ['apiKey', 'triggerId', 'includeHighlights', 'includeParticipants', 'includeAiSummary'],
lemlist: ['apiKey', 'triggerId', 'campaignId'],
telegram: ['botToken'],
'microsoft-teams': ['triggerId', 'chatId', 'credentialId', 'credentialSetId'],
}
if (previousProvider !== nextProvider) {
return (
Boolean(relevantKeysByProvider[previousProvider]) ||
Boolean(relevantKeysByProvider[nextProvider])
)
}
const keys = relevantKeysByProvider[nextProvider]
if (!keys) {
return false
}
return keys.some((key) => !areValuesEqual(previousConfig[key], nextConfig[key]))
}
export async function createExternalWebhookSubscription(
request: NextRequest,
webhookData: any,
workflow: any,
userId: string,
requestId: string
): Promise<ExternalSubscriptionResult> {
const provider = webhookData.provider as string
const providerConfig = (webhookData.providerConfig as Record<string, unknown>) || {}
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 +1550,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') {

View File

@@ -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',
]
/**

View File

@@ -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 <strong>Settings > Integrations > API</strong>.',
`Click <strong>"Save Configuration"</strong> to automatically create the webhook in Grain for <strong>${eventType}</strong> events.`,
'The webhook will be automatically deleted when you remove this trigger.',
]

View File

@@ -82,9 +82,8 @@ export function hubspotSetupInstructions(eventType: string, additionalNotes?: st
'<strong>Step 3: Configure OAuth Settings</strong><br/>After creating your app via CLI, configure it to add the OAuth Redirect URL: <code>https://www.sim.ai/api/auth/oauth2/callback/hubspot</code>. Then retrieve your <strong>Client ID</strong> and <strong>Client Secret</strong> from your app configuration and enter them in the fields above.',
"<strong>Step 4: Get App ID and Developer API Key</strong><br/>In your HubSpot developer account, find your <strong>App ID</strong> (shown below your app name) and your <strong>Developer API Key</strong> (in app settings). You'll need both for the next steps.",
'<strong>Step 5: Set Required Scopes</strong><br/>Configure your app to include the required OAuth scope: <code>crm.objects.contacts.read</code>',
'<strong>Step 6: Save Configuration in Sim</strong><br/>Click the <strong>"Save Configuration"</strong> button above. This will generate your unique webhook URL.',
'<strong>Step 7: Configure Webhook in HubSpot via API</strong><br/>After saving above, copy the <strong>Webhook URL</strong> and run the two curl commands below (replace <code>{YOUR_APP_ID}</code>, <code>{YOUR_DEVELOPER_API_KEY}</code>, and <code>{YOUR_WEBHOOK_URL_FROM_ABOVE}</code> with your actual values).',
"<strong>Step 8: Test Your Webhook</strong><br/>Create or modify a contact in HubSpot to trigger the webhook. Check your workflow execution logs in Sim to verify it's working.",
'<strong>Step 6: Configure Webhook in HubSpot via API</strong><br/>After saving above, copy the <strong>Webhook URL</strong> and run the two curl commands below (replace <code>{YOUR_APP_ID}</code>, <code>{YOUR_DEVELOPER_API_KEY}</code>, and <code>{YOUR_WEBHOOK_URL_FROM_ABOVE}</code> with your actual values).',
"<strong>Step 7: Test Your Webhook</strong><br/>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) {

View File

@@ -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',

View File

@@ -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 <strong>Settings > Integrations > API</strong>.',
`Click <strong>"Save Configuration"</strong> to automatically create the webhook in Lemlist for <strong>${eventType}</strong> events.`,
'The webhook will be automatically deleted when you remove this trigger.',
]

View File

@@ -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}`)