mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-15 01:47:59 -05:00
Compare commits
3 Commits
staging
...
fix/webhoo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc59953a83 | ||
|
|
7f8d68206a | ||
|
|
6b776c5bb4 |
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
525
apps/sim/lib/webhooks/deploy.ts
Normal file
525
apps/sim/lib/webhooks/deploy.ts
Normal 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 }
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.',
|
||||
]
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
]
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user