import { createLogger } from '@sim/logger' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, EventFilterContext, FormatInputContext, FormatInputResult, SubscriptionContext, SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Webflow') export const webflowHandler: WebhookProviderHandler = { async createSubscription({ webhook: webhookRecord, workflow, userId, requestId, }: SubscriptionContext): Promise { try { const { path, providerConfig } = webhookRecord as Record const config = (providerConfig as Record) || {} const { siteId, triggerId, collectionId, formName, credentialId } = config as { siteId?: string triggerId?: string collectionId?: string formName?: string credentialId?: string } if (!siteId) { logger.warn(`[${requestId}] Missing siteId for Webflow webhook creation.`, { webhookId: webhookRecord.id, }) throw new Error('Site ID is required to create Webflow webhook') } const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) if (!siteIdValidation.isValid) { throw new Error(siteIdValidation.error) } if (!triggerId) { logger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, { webhookId: webhookRecord.id, }) throw new Error('Trigger type is required to create Webflow webhook') } const credentialOwner = credentialId ? await getCredentialOwner(credentialId, requestId) : null const accessToken = credentialId ? credentialOwner ? await refreshAccessTokenIfNeeded( credentialOwner.accountId, credentialOwner.userId, requestId ) : null : 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}` const triggerTypeMap: Record = { webflow_collection_item_created: 'collection_item_created', webflow_collection_item_changed: 'collection_item_changed', webflow_collection_item_deleted: 'collection_item_deleted', webflow_form_submission: 'form_submission', } const webflowTriggerType = triggerTypeMap[triggerId] if (!webflowTriggerType) { logger.warn(`[${requestId}] Invalid triggerId for Webflow: ${triggerId}`, { webhookId: webhookRecord.id, }) throw new Error(`Invalid Webflow trigger type: ${triggerId}`) } const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks` const requestBody: Record = { triggerType: webflowTriggerType, url: notificationUrl, } if (formName && webflowTriggerType === 'form_submission') { requestBody.filter = { name: formName, } } 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 ${webhookRecord.id}. Status: ${webflowResponse.status}`, { message: errorMessage, response: responseBody } ) throw new Error(errorMessage) } logger.info( `[${requestId}] Successfully created webhook in Webflow for webhook ${webhookRecord.id}.`, { webflowWebhookId: responseBody.id || responseBody._id, } ) return { providerConfigUpdates: { externalId: responseBody.id || responseBody._id } } } catch (error: unknown) { const err = error as Error logger.error( `[${requestId}] Exception during Webflow webhook creation for webhook ${webhookRecord.id}.`, { message: err.message, stack: err.stack, } ) throw error } }, async deleteSubscription({ webhook: webhookRecord, workflow, requestId, strict, }: DeleteSubscriptionContext): Promise { try { const config = getProviderConfig(webhookRecord) const siteId = config.siteId as string | undefined const externalId = config.externalId as string | undefined if (!siteId) { logger.warn( `[${requestId}] Missing siteId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup` ) if (strict) throw new Error('Missing Webflow siteId for webhook deletion') return } if (!externalId) { logger.warn( `[${requestId}] Missing externalId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup` ) if (strict) throw new Error('Missing Webflow externalId for webhook deletion') return } const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) if (!siteIdValidation.isValid) { logger.warn(`[${requestId}] Invalid Webflow site ID format, skipping deletion`, { webhookId: webhookRecord.id, siteId: siteId.substring(0, 30), }) if (strict) throw new Error('Invalid Webflow siteId for webhook deletion') return } const webhookIdValidation = validateAlphanumericId(externalId, 'webhookId', 100) if (!webhookIdValidation.isValid) { logger.warn(`[${requestId}] Invalid Webflow webhook ID format, skipping deletion`, { webhookId: webhookRecord.id, externalId: externalId.substring(0, 30), }) if (strict) throw new Error('Invalid Webflow webhook ID for deletion') return } const credentialId = config.credentialId as string | undefined if (!credentialId) { logger.warn( `[${requestId}] Missing credentialId for Webflow webhook deletion ${webhookRecord.id}` ) if (strict) throw new Error('Missing Webflow credentialId for webhook deletion') return } const credentialOwner = await getCredentialOwner(credentialId, requestId) const accessToken = credentialOwner ? await refreshAccessTokenIfNeeded( credentialOwner.accountId, credentialOwner.userId, requestId ) : null if (!accessToken) { const message = `[${requestId}] Could not retrieve Webflow access token. Cannot delete webhook.` logger.warn(message, { webhookId: webhookRecord.id }) if (strict) throw new Error(message) 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(() => ({})) logger.warn( `[${requestId}] Failed to delete Webflow webhook (non-fatal): ${webflowResponse.status}`, { response: responseBody } ) if (strict) { throw new Error(`Failed to delete Webflow webhook: ${webflowResponse.status}`) } } else { logger.info(`[${requestId}] Successfully deleted Webflow webhook ${externalId}`) } } catch (error) { logger.warn(`[${requestId}] Error deleting Webflow webhook (non-fatal)`, error) if (strict) throw error } }, async formatInput({ body, webhook }: FormatInputContext): Promise { const b = body as Record const providerConfig = (webhook.providerConfig as Record) || {} const triggerId = providerConfig.triggerId as string | undefined if (triggerId === 'webflow_form_submission') { return { input: { siteId: b?.siteId || '', formId: b?.formId || '', name: b?.name || '', id: b?.id || '', submittedAt: b?.submittedAt || '', data: b?.data || {}, schema: b?.schema || {}, formElementId: b?.formElementId || '', }, } } const { _cid, _id, ...itemFields } = b || ({} as Record) return { input: { siteId: b?.siteId || '', collectionId: (_cid || b?.collectionId || '') as string, payload: { id: (_id || '') as string, cmsLocaleId: (itemFields as Record)?.cmsLocaleId || '', lastPublished: (itemFields as Record)?.lastPublished || (itemFields as Record)?.['last-published'] || '', lastUpdated: (itemFields as Record)?.lastUpdated || (itemFields as Record)?.['last-updated'] || '', createdOn: (itemFields as Record)?.createdOn || (itemFields as Record)?.['created-on'] || '', isArchived: (itemFields as Record)?.isArchived || (itemFields as Record)?._archived || false, isDraft: (itemFields as Record)?.isDraft || (itemFields as Record)?._draft || false, fieldData: itemFields, }, }, } }, shouldSkipEvent({ webhook, body, requestId, providerConfig }: EventFilterContext) { const configuredCollectionId = providerConfig.collectionId as string | undefined if (configuredCollectionId) { const obj = body as Record const payload = obj.payload as Record | undefined const payloadCollectionId = (payload?.collectionId ?? obj.collectionId) as string | undefined if (payloadCollectionId && payloadCollectionId !== configuredCollectionId) { logger.info( `[${requestId}] Webflow collection '${payloadCollectionId}' doesn't match configured collection '${configuredCollectionId}' for webhook ${webhook.id as string}, skipping` ) return true } } return false }, }