Skip to content

Commit c50c11e

Browse files
committed
fix(jotform): keep the callback when an active deployment still needs it
Redeploying prepares the replacement webhook row alongside the live one and a workflow keeps its path across deployments, so both rows resolve to a single callback on a single form. Registration adopts the callback already present instead of posting a duplicate, which left the retired row's cleanup deleting the one the new row had just adopted — the trigger went silent after a redeploy that changed the trigger config. Teardown now skips when another webhook row belonging to an active deployment resolves to the same form and callback URL, matching how the Telegram handler skips deleteWebhook while an active deployment still uses the same bot. A genuine undeploy has no such row and still cleans up.
1 parent b154f11 commit c50c11e

2 files changed

Lines changed: 108 additions & 2 deletions

File tree

apps/sim/lib/webhooks/providers/jotform.test.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
45
import { beforeEach, describe, expect, it, vi } from 'vitest'
56

7+
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
8+
69
const WEBHOOK_ID = 'webhook-uuid-1234'
710
const NOTIFICATION_URL = 'https://app.example.com/api/webhooks/trigger/jotform-path'
811

912
vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({
1013
getProviderConfig: (webhook: { providerConfig?: Record<string, unknown> }) =>
1114
webhook.providerConfig || {},
12-
getNotificationUrl: () => NOTIFICATION_URL,
15+
getNotificationUrl: (webhook: { path?: string | null }) =>
16+
`https://app.example.com/api/webhooks/trigger/${webhook.path ?? 'jotform-path'}`,
1317
}))
1418

1519
import { jotformHandler } from '@/lib/webhooks/providers/jotform'
@@ -18,7 +22,7 @@ const fetchMock = vi.fn()
1822

1923
function createContext(providerConfig: Record<string, unknown>) {
2024
return {
21-
webhook: { id: WEBHOOK_ID, path: 'jotform-path', providerConfig },
25+
webhook: { id: WEBHOOK_ID, workflowId: 'wf-1', path: 'jotform-path', providerConfig },
2226
workflow: {},
2327
userId: 'user-1',
2428
requestId: 'req-1',
@@ -201,6 +205,7 @@ describe('jotformHandler createSubscription', () => {
201205
describe('jotformHandler deleteSubscription', () => {
202206
beforeEach(() => {
203207
vi.clearAllMocks()
208+
resetDbChainMock()
204209
vi.stubGlobal('fetch', fetchMock)
205210
})
206211

@@ -226,6 +231,44 @@ describe('jotformHandler deleteSubscription', () => {
226231
expect(fetchMock).toHaveBeenCalledTimes(1)
227232
})
228233

234+
/**
235+
* Redeploying prepares the replacement row before the retired row is cleaned up, and the
236+
* workflow keeps its path, so both rows name one callback on one form. Without the guard
237+
* the retired row's cleanup deletes the callback the live row is now relying on and the
238+
* trigger goes silent.
239+
*/
240+
it('leaves the callback alone while an active deployment is served by it', async () => {
241+
queueTableRows(schemaMock.webhook, [{ path: 'jotform-path', providerConfig: { formId: '1' } }])
242+
243+
await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' }))
244+
245+
expect(fetchMock).not.toHaveBeenCalled()
246+
})
247+
248+
it('still deletes when the active deployment points at a different form', async () => {
249+
queueTableRows(schemaMock.webhook, [
250+
{ path: 'jotform-path', providerConfig: { formId: '999' } },
251+
])
252+
fetchMock
253+
.mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL }))
254+
.mockResolvedValueOnce(envelope({}))
255+
256+
await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' }))
257+
258+
expect(fetchMock.mock.calls[1][1].method).toBe('DELETE')
259+
})
260+
261+
it('still deletes when the active deployment is served by a different path', async () => {
262+
queueTableRows(schemaMock.webhook, [{ path: 'other-path', providerConfig: { formId: '1' } }])
263+
fetchMock
264+
.mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL }))
265+
.mockResolvedValueOnce(envelope({}))
266+
267+
await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' }))
268+
269+
expect(fetchMock.mock.calls[1][1].method).toBe('DELETE')
270+
})
271+
229272
it('swallows a failed cleanup unless the caller is strict', async () => {
230273
fetchMock.mockRejectedValue(new Error('network down'))
231274

apps/sim/lib/webhooks/providers/jotform.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { db, webhook, workflowDeploymentVersion } from '@sim/db'
12
import { createLogger } from '@sim/logger'
23
import { getErrorMessage } from '@sim/utils/errors'
34
import { isRecordLike } from '@sim/utils/object'
5+
import { and, eq, isNull, ne } from 'drizzle-orm'
46
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
57
import type {
68
DeleteSubscriptionContext,
@@ -76,6 +78,53 @@ async function listWebhookIdForUrl(
7678
return findWebhookIdByUrl(envelope.content, notificationUrl)
7779
}
7880

81+
/**
82+
* Reports whether another webhook row belonging to an active deployment is served by the
83+
* same Jotform callback — the same form and the same URL.
84+
*
85+
* Redeploying a trigger prepares the replacement row alongside the live one, and a workflow
86+
* keeps its webhook path across deployments, so both rows resolve to one callback on one
87+
* form. Registration adopts the callback already there instead of posting a duplicate, which
88+
* leaves the retired row's cleanup pointing at the callback the new row now depends on.
89+
* Teardown is skipped in that case, exactly as the Telegram handler skips `deleteWebhook`
90+
* while an active deployment still uses the same bot.
91+
*/
92+
async function activeDeploymentSharesJotformCallback(
93+
webhookRecord: Record<string, unknown>,
94+
formId: string,
95+
notificationUrl: string
96+
): Promise<boolean> {
97+
const workflowId = webhookRecord.workflowId
98+
const webhookId = webhookRecord.id
99+
if (typeof workflowId !== 'string' || typeof webhookId !== 'string') return false
100+
101+
const activeWebhooks = await db
102+
.select({ path: webhook.path, providerConfig: webhook.providerConfig })
103+
.from(webhook)
104+
.innerJoin(
105+
workflowDeploymentVersion,
106+
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id)
107+
)
108+
.where(
109+
and(
110+
eq(webhook.workflowId, workflowId),
111+
ne(webhook.id, webhookId),
112+
eq(webhook.provider, 'jotform'),
113+
eq(workflowDeploymentVersion.workflowId, workflowId),
114+
eq(workflowDeploymentVersion.isActive, true),
115+
isNull(webhook.archivedAt)
116+
)
117+
)
118+
119+
return activeWebhooks.some((activeWebhook) => {
120+
const config = getProviderConfig({ providerConfig: activeWebhook.providerConfig })
121+
return (
122+
toStringOrNull(config.formId)?.trim() === formId &&
123+
sameUrl(getNotificationUrl({ path: activeWebhook.path }), notificationUrl)
124+
)
125+
})
126+
}
127+
79128
export const jotformHandler: WebhookProviderHandler = {
80129
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
81130
const payload = isRecordLike(body) ? body : {}
@@ -184,6 +233,20 @@ export const jotformHandler: WebhookProviderHandler = {
184233
const notificationUrl = getNotificationUrl(ctx.webhook)
185234

186235
try {
236+
if (
237+
await activeDeploymentSharesJotformCallback(
238+
ctx.webhook,
239+
credentials.formId,
240+
notificationUrl
241+
)
242+
) {
243+
logger.info(
244+
`[${ctx.requestId}] Skipping Jotform webhook deletion because an active deployment is served by the same callback`,
245+
{ webhookId: ctx.webhook.id }
246+
)
247+
return
248+
}
249+
187250
const webhookId = await listWebhookIdForUrl(credentials, notificationUrl)
188251

189252
if (!webhookId) {

0 commit comments

Comments
 (0)