mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 13:00:04 +08:00
feat(jotform): trigger a workflow on every new form submission (#6802)
* feat(jotform): trigger a workflow on every new form submission
Jotform's only webhook event is a new submission, so the block gets one
trigger. Deploying it registers the callback on the form through the API
and undeploying removes it again.
Two things about this provider needed handling:
Jotform posts submissions as multipart/form-data, which the shared webhook
body parser did not read — the delivery died as a 400 before any handler
saw it. The parser now flattens a multipart body the same way it already
flattens a urlencoded one, reducing an uploaded part to its filename so a
stray file cannot inflate the execution input.
The form's webhooks are identified by their position in the form's webhook
map, so an id captured at registration goes stale the moment any other
webhook on that form is removed. Nothing persists it; cleanup re-resolves
the id by matching the callback URL. Registration checks the same way,
because Jotform answers a rejected request with the unchanged list rather
than an error.
Answers are exposed as the parsed `rawRequest` rather than re-keyed by
question label — the labels are not unique, and the payload shape is only
documented as the raw q{qid}_{slug} map.
The trigger's region field is named `apiRegion` so it does not collide
with the block's own advanced-mode `region`.
* fix(jotform): make webhook registration idempotent and URL matching tolerant
Validated the trigger against Jotform's API reference and a captured
delivery (zulip's multipart fixture), which confirmed every mapped field —
formID, submissionID, formTitle, username, ip, type, pretty, rawRequest —
and turned up three things worth correcting.
Jotform keeps a form's webhooks as a plain list and does not treat the URL
as a key, so posting one it already holds leaves the form delivering every
submission twice. Registration now consults the list first and only posts
when the URL is absent. The documented POST sample returns the new entry as
"0", renumbering the rest, which is further reason nothing persists an id.
URL matching no longer lets a trailing slash decide the outcome. Jotform
stores the URL verbatim in every sample seen, but an exact match failing
would hard-fail deploy, and Pipedream's client normalizes the same way.
The rawRequest description claimed the field holds the submitted answers.
A real payload also carries slug, buildDate, submitSource and
jsExecutionTracker, and a file answer appears under the bare slugified
label as upload URLs rather than under a q{qid}_ key — which is also why
filtering to q-prefixed keys would silently drop file answers.
* 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.
This commit is contained in:
@@ -1174,3 +1174,34 @@ Update account settings such as name, email, website, company, industry, or time
|
||||
| ↳ `usage` | string | URL of the monthly usage endpoint |
|
||||
|
||||
|
||||
|
||||
## Triggers
|
||||
|
||||
A **Trigger** is a block that starts a workflow when an event happens in this service.
|
||||
|
||||
### Jotform Webhook
|
||||
|
||||
Trigger workflow when a Jotform form receives a new submission
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The form to watch. It is the numeric segment of the form URL, and the List Forms operation returns it. |
|
||||
| `apiKey` | string | Yes | Used to register the webhook on the form automatically. Create one under Account Settings > API. |
|
||||
| `apiRegion` | string | No | Data residency region the API key belongs to. A key only works on the host that issued it. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `formId` | string | ID of the form that was submitted |
|
||||
| `submissionId` | string | ID of the new submission |
|
||||
| `formTitle` | string | Title of the form at the time of submission |
|
||||
| `username` | string | Jotform account username that owns the form |
|
||||
| `ip` | string | IP address the submission came from |
|
||||
| `submissionType` | string | How the submission was made, e.g. WEB |
|
||||
| `pretty` | string | Human-readable summary of the answers, as comma-separated "Question Label:Answer" pairs. Unanswered questions are left out. |
|
||||
| `rawRequest` | json | The submitted form body. Answers are keyed q\{questionId\}_\{slugifiedLabel\} and hold a string, or an object for a multi-part question such as name or address. A file answer instead appears under the plain slugified label as an array of upload URLs, with the chosen filenames under temp_upload. The body also carries form-internal fields such as slug, buildDate, submitSource, and jsExecutionTracker. |
|
||||
| `raw` | json | Complete original webhook payload from Jotform |
|
||||
|
||||
|
||||
@@ -26,17 +26,19 @@ describe('JotformBlock', () => {
|
||||
)
|
||||
})
|
||||
|
||||
/* Trigger-mode subBlocks are supplied by the trigger config and carry their own
|
||||
state, so neither the input declarations nor the tool-mode id space covers them. */
|
||||
const toolSubBlocks = JotformBlock.subBlocks.filter((subBlock) => subBlock.mode !== 'trigger')
|
||||
|
||||
it('declares an input for every subblock', () => {
|
||||
const inputIds = new Set(Object.keys(JotformBlock.inputs))
|
||||
const missing = JotformBlock.subBlocks
|
||||
.map((subBlock) => subBlock.id)
|
||||
.filter((id) => !inputIds.has(id))
|
||||
const missing = toolSubBlocks.map((subBlock) => subBlock.id).filter((id) => !inputIds.has(id))
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('gives every subblock a unique id', () => {
|
||||
const ids = JotformBlock.subBlocks.map((subBlock) => subBlock.id)
|
||||
const ids = toolSubBlocks.map((subBlock) => subBlock.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { JotformIcon } from '@/components/icons'
|
||||
import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types'
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
/**
|
||||
* Operations that address a form by ID. Kept as one list because seventeen of the
|
||||
@@ -669,6 +670,8 @@ export const JotformBlock: BlockConfig = {
|
||||
condition: { field: 'operation', value: 'get_history' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
|
||||
...getTrigger('jotform_webhook').subBlocks,
|
||||
],
|
||||
|
||||
tools: {
|
||||
@@ -949,6 +952,11 @@ export const JotformBlock: BlockConfig = {
|
||||
description: 'Confirmation text returned by a delete operation',
|
||||
},
|
||||
},
|
||||
|
||||
triggers: {
|
||||
enabled: true,
|
||||
available: ['jotform_webhook'],
|
||||
},
|
||||
}
|
||||
|
||||
export const JotformBlockMeta = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"updatedAt": "2026-08-17",
|
||||
"updatedAt": "2026-08-18",
|
||||
"integrations": [
|
||||
{
|
||||
"type": "onepassword",
|
||||
@@ -11532,8 +11532,14 @@
|
||||
}
|
||||
],
|
||||
"operationCount": 43,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"triggers": [
|
||||
{
|
||||
"id": "jotform_webhook",
|
||||
"name": "Jotform Webhook",
|
||||
"description": "Trigger workflow when a Jotform form receives a new submission"
|
||||
}
|
||||
],
|
||||
"triggerCount": 1,
|
||||
"authType": "api-key",
|
||||
"category": "tools",
|
||||
"integrationType": "productivity",
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
workflowsPersistenceUtilsMock,
|
||||
workflowsPersistenceUtilsMockFns,
|
||||
} from '@sim/testing'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ADMISSION_ERROR_CODE,
|
||||
@@ -136,6 +136,7 @@ import {
|
||||
dispatchResolvedWebhookTarget,
|
||||
findAllWebhooksForPath,
|
||||
handleWebhookEventFilter,
|
||||
parseWebhookBody,
|
||||
processPolledWebhookEvent,
|
||||
} from '@/lib/webhooks/processor'
|
||||
|
||||
@@ -678,3 +679,59 @@ describe('polled webhook reservation ownership', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWebhookBody', () => {
|
||||
const parse = async (request: NextRequest) => {
|
||||
const result = await parseWebhookBody(request, 'req-1')
|
||||
if (result instanceof Response) throw new Error(`unexpected ${result.status} response`)
|
||||
return result
|
||||
}
|
||||
|
||||
it('flattens a multipart body, as Jotform posts submissions', async () => {
|
||||
const form = new FormData()
|
||||
form.set('formID', '231504059977966')
|
||||
form.set('submissionID', '5678')
|
||||
form.set('rawRequest', '{"q4_email":"bart@example.com"}')
|
||||
|
||||
const result = await parse(
|
||||
new NextRequest('http://localhost:3000/api/webhooks/trigger/jotform-path', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.body).toEqual({
|
||||
formID: '231504059977966',
|
||||
submissionID: '5678',
|
||||
rawRequest: '{"q4_email":"bart@example.com"}',
|
||||
})
|
||||
})
|
||||
|
||||
it('reduces an uploaded multipart part to its filename', async () => {
|
||||
const form = new FormData()
|
||||
form.set('attachment', new File(['file bytes'], 'receipt.pdf', { type: 'application/pdf' }))
|
||||
|
||||
const result = await parse(
|
||||
new NextRequest('http://localhost:3000/api/webhooks/trigger/jotform-path', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.body).toEqual({ attachment: 'receipt.pdf' })
|
||||
})
|
||||
|
||||
it('still rejects a body that matches no supported content type', async () => {
|
||||
const response = await parseWebhookBody(
|
||||
new NextRequest('http://localhost:3000/api/webhooks/trigger/jotform-path', {
|
||||
method: 'POST',
|
||||
body: 'not json',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
'req-1'
|
||||
)
|
||||
|
||||
expect(response).toBeInstanceOf(Response)
|
||||
expect((response as Response).status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,6 +77,31 @@ export interface WebhookPreprocessingResult {
|
||||
|
||||
const WEBHOOK_BODY_LABEL = 'Webhook request body'
|
||||
|
||||
/**
|
||||
* Flattens a `multipart/form-data` body into the plain object shape provider handlers
|
||||
* already receive from JSON and urlencoded bodies. Jotform posts submissions this way,
|
||||
* and every field it sends is text.
|
||||
*
|
||||
* Parsing the decoded body rather than the original bytes is safe here because the parts
|
||||
* are delimited by an ASCII boundary and an uploaded part is reduced to its filename —
|
||||
* its bytes are never read, so re-encoding cannot corrupt anything we keep. Discarding
|
||||
* them also stops a stray upload from inflating the execution input.
|
||||
*/
|
||||
async function parseMultipartBody(
|
||||
rawBody: string,
|
||||
contentType: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const formData = await new Response(rawBody, {
|
||||
headers: { 'content-type': contentType },
|
||||
}).formData()
|
||||
|
||||
const fields: Record<string, unknown> = {}
|
||||
for (const [key, value] of formData.entries()) {
|
||||
fields[key] = typeof value === 'string' ? value : value.name
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
export async function parseWebhookBody(
|
||||
request: NextRequest,
|
||||
requestId: string
|
||||
@@ -121,6 +146,8 @@ export async function parseWebhookBody(
|
||||
} else {
|
||||
body = Object.fromEntries(formData.entries())
|
||||
}
|
||||
} else if (contentType.includes('multipart/form-data')) {
|
||||
body = await parseMultipartBody(rawBody, contentType)
|
||||
} else {
|
||||
body = JSON.parse(rawBody)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
|
||||
|
||||
const WEBHOOK_ID = 'webhook-uuid-1234'
|
||||
const NOTIFICATION_URL = 'https://app.example.com/api/webhooks/trigger/jotform-path'
|
||||
|
||||
vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({
|
||||
getProviderConfig: (webhook: { providerConfig?: Record<string, unknown> }) =>
|
||||
webhook.providerConfig || {},
|
||||
getNotificationUrl: (webhook: { path?: string | null }) =>
|
||||
`https://app.example.com/api/webhooks/trigger/${webhook.path ?? 'jotform-path'}`,
|
||||
}))
|
||||
|
||||
import { jotformHandler } from '@/lib/webhooks/providers/jotform'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
function createContext(providerConfig: Record<string, unknown>) {
|
||||
return {
|
||||
webhook: { id: WEBHOOK_ID, workflowId: 'wf-1', path: 'jotform-path', providerConfig },
|
||||
workflow: {},
|
||||
userId: 'user-1',
|
||||
requestId: 'req-1',
|
||||
} as never
|
||||
}
|
||||
|
||||
/** Jotform wraps every response in an envelope and reports failures inside it. */
|
||||
function envelope(content: unknown, overrides: Record<string, unknown> = {}) {
|
||||
return new Response(JSON.stringify({ responseCode: 200, content, ...overrides }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('jotformHandler formatInput', () => {
|
||||
it('maps the multipart fields and parses rawRequest', async () => {
|
||||
const result = await jotformHandler.formatInput!({
|
||||
body: {
|
||||
formID: '231504059977966',
|
||||
submissionID: '5678',
|
||||
formTitle: 'Contact Us',
|
||||
username: 'acme',
|
||||
ip: '198.51.100.4',
|
||||
type: 'WEB',
|
||||
pretty: 'Name:Bart Simpson, Email:bart@example.com',
|
||||
rawRequest: '{"q3_name":{"first":"Bart","last":"Simpson"},"q4_email":"bart@example.com"}',
|
||||
},
|
||||
} as never)
|
||||
|
||||
expect(result.input).toEqual({
|
||||
formId: '231504059977966',
|
||||
submissionId: '5678',
|
||||
formTitle: 'Contact Us',
|
||||
username: 'acme',
|
||||
ip: '198.51.100.4',
|
||||
submissionType: 'WEB',
|
||||
pretty: 'Name:Bart Simpson, Email:bart@example.com',
|
||||
rawRequest: {
|
||||
q3_name: { first: 'Bart', last: 'Simpson' },
|
||||
q4_email: 'bart@example.com',
|
||||
},
|
||||
raw: expect.objectContaining({ submissionID: '5678' }),
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Field names and shapes taken from a captured Jotform delivery: answers sit under
|
||||
* q{qid}_{slug}, a file answer lands under the bare slug as upload URLs, and the body
|
||||
* carries form-internal keys alongside them.
|
||||
*/
|
||||
it('carries a real payload through unflattened', async () => {
|
||||
const result = await jotformHandler.formatInput!({
|
||||
body: {
|
||||
formID: '243231271343446',
|
||||
submissionID: '6084250982513018472',
|
||||
formTitle: 'Tutor Appointment Form',
|
||||
username: 'UserNiloth',
|
||||
type: 'WEB',
|
||||
ip: '27.51.18.17',
|
||||
pretty: "Student's Name:Niloth P, Grade:12",
|
||||
rawRequest: JSON.stringify({
|
||||
slug: 'submit/243231271343446',
|
||||
jsExecutionTracker: 'build-date-1732271172685=>init-started',
|
||||
q3_studentsName: { first: 'Niloth', last: 'P' },
|
||||
q35_grade: '12',
|
||||
temp_upload: { q6_reports: ['Report Card.pdf#jotformfs-e4f4'] },
|
||||
reports: ['https://www.jotform.com/uploads/UserNiloth/Report%20Card.pdf'],
|
||||
}),
|
||||
},
|
||||
} as never)
|
||||
|
||||
const input = result.input as Record<string, Record<string, unknown>>
|
||||
expect(input.submissionType).toBe('WEB')
|
||||
expect(input.rawRequest.q3_studentsName).toEqual({ first: 'Niloth', last: 'P' })
|
||||
expect(input.rawRequest.reports).toEqual([
|
||||
'https://www.jotform.com/uploads/UserNiloth/Report%20Card.pdf',
|
||||
])
|
||||
expect(input.rawRequest.slug).toBe('submit/243231271343446')
|
||||
})
|
||||
|
||||
it('keeps the submission when rawRequest is not valid JSON', async () => {
|
||||
const result = await jotformHandler.formatInput!({
|
||||
body: { submissionID: '5678', rawRequest: 'not json' },
|
||||
} as never)
|
||||
|
||||
expect((result.input as Record<string, unknown>).rawRequest).toBeNull()
|
||||
expect((result.input as Record<string, unknown>).submissionId).toBe('5678')
|
||||
})
|
||||
})
|
||||
|
||||
describe('jotformHandler extractIdempotencyId', () => {
|
||||
it('keys on the submission id', () => {
|
||||
expect(jotformHandler.extractIdempotencyId!({ submissionID: '5678' })).toBe('submission:5678')
|
||||
})
|
||||
|
||||
it('returns null without a submission id', () => {
|
||||
expect(jotformHandler.extractIdempotencyId!({ formID: '1' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('jotformHandler createSubscription', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
it('registers the notification URL on the form', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(envelope({}))
|
||||
.mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL }))
|
||||
|
||||
await jotformHandler.createSubscription!(
|
||||
createContext({ formId: '231504059977966', apiKey: 'jf-key' })
|
||||
)
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[1]
|
||||
expect(url).toBe('https://api.jotform.com/form/231504059977966/webhooks')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers.APIKEY).toBe('jf-key')
|
||||
expect(init.body).toBe(`webhookURL=${encodeURIComponent(NOTIFICATION_URL)}`)
|
||||
})
|
||||
|
||||
it('does not post again when the form already carries the URL', async () => {
|
||||
fetchMock.mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL }))
|
||||
|
||||
await jotformHandler.createSubscription!(createContext({ formId: '1', apiKey: 'jf-key' }))
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0][1].method).toBe('GET')
|
||||
})
|
||||
|
||||
it('treats a stored URL that differs only by a trailing slash as the same webhook', async () => {
|
||||
fetchMock.mockResolvedValueOnce(envelope({ '0': `${NOTIFICATION_URL}/` }))
|
||||
|
||||
await jotformHandler.createSubscription!(createContext({ formId: '1', apiKey: 'jf-key' }))
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses the host that issued the key', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(envelope({}))
|
||||
.mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL }))
|
||||
|
||||
await jotformHandler.createSubscription!(
|
||||
createContext({ formId: '1', apiKey: 'jf-key', apiRegion: 'eu' })
|
||||
)
|
||||
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('https://eu-api.jotform.com/form/1/webhooks')
|
||||
})
|
||||
|
||||
it('fails when the URL is missing from the returned webhook list', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(envelope({}))
|
||||
.mockResolvedValueOnce(envelope({ '0': 'https://elsewhere.example.com/hook' }))
|
||||
|
||||
await expect(
|
||||
jotformHandler.createSubscription!(createContext({ formId: '1', apiKey: 'jf-key' }))
|
||||
).rejects.toThrow(/did not register the webhook URL/)
|
||||
})
|
||||
|
||||
it('fails on a 200 body that carries a non-2xx responseCode', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
envelope(null, { responseCode: '401', message: 'Invalid API Key' })
|
||||
)
|
||||
|
||||
await expect(
|
||||
jotformHandler.createSubscription!(createContext({ formId: '1', apiKey: 'bad-key' }))
|
||||
).rejects.toThrow(/Invalid API Key/)
|
||||
})
|
||||
|
||||
it('fails before calling Jotform when the API key is missing', async () => {
|
||||
await expect(
|
||||
jotformHandler.createSubscription!(createContext({ formId: '1' }))
|
||||
).rejects.toThrow(/API Key is required/)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('jotformHandler deleteSubscription', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
it('resolves the webhook id by URL before deleting it', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
envelope({ '0': 'https://elsewhere.example.com/hook', '1': NOTIFICATION_URL })
|
||||
)
|
||||
.mockResolvedValueOnce(envelope({ '0': 'https://elsewhere.example.com/hook' }))
|
||||
|
||||
await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' }))
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('https://api.jotform.com/form/1/webhooks/1')
|
||||
expect(fetchMock.mock.calls[1][1].method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('does not delete anything when the form no longer carries our URL', async () => {
|
||||
fetchMock.mockResolvedValueOnce(envelope({ '0': 'https://elsewhere.example.com/hook' }))
|
||||
|
||||
await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' }))
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* Redeploying prepares the replacement row before the retired row is cleaned up, and the
|
||||
* workflow keeps its path, so both rows name one callback on one form. Without the guard
|
||||
* the retired row's cleanup deletes the callback the live row is now relying on and the
|
||||
* trigger goes silent.
|
||||
*/
|
||||
it('leaves the callback alone while an active deployment is served by it', async () => {
|
||||
queueTableRows(schemaMock.webhook, [{ path: 'jotform-path', providerConfig: { formId: '1' } }])
|
||||
|
||||
await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' }))
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still deletes when the active deployment points at a different form', async () => {
|
||||
queueTableRows(schemaMock.webhook, [
|
||||
{ path: 'jotform-path', providerConfig: { formId: '999' } },
|
||||
])
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL }))
|
||||
.mockResolvedValueOnce(envelope({}))
|
||||
|
||||
await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' }))
|
||||
|
||||
expect(fetchMock.mock.calls[1][1].method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('still deletes when the active deployment is served by a different path', async () => {
|
||||
queueTableRows(schemaMock.webhook, [{ path: 'other-path', providerConfig: { formId: '1' } }])
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(envelope({ '0': NOTIFICATION_URL }))
|
||||
.mockResolvedValueOnce(envelope({}))
|
||||
|
||||
await jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' }))
|
||||
|
||||
expect(fetchMock.mock.calls[1][1].method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('swallows a failed cleanup unless the caller is strict', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('network down'))
|
||||
|
||||
await expect(
|
||||
jotformHandler.deleteSubscription!(createContext({ formId: '1', apiKey: 'jf-key' }))
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
await expect(
|
||||
jotformHandler.deleteSubscription!({
|
||||
...(createContext({ formId: '1', apiKey: 'jf-key' }) as object),
|
||||
strict: true,
|
||||
} as never)
|
||||
).rejects.toThrow(/network down/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
import { db, webhook, workflowDeploymentVersion } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import { and, eq, isNull, ne } from 'drizzle-orm'
|
||||
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
|
||||
import type {
|
||||
DeleteSubscriptionContext,
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
SubscriptionContext,
|
||||
SubscriptionResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
import { normalizeWebhooks } from '@/tools/jotform/normalize'
|
||||
import {
|
||||
buildJotformHeaders,
|
||||
buildJotformUrl,
|
||||
jotformFormHeaders,
|
||||
parseJotformResponse,
|
||||
toFormBody,
|
||||
toStringOrNull,
|
||||
} from '@/tools/jotform/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:Jotform')
|
||||
|
||||
interface JotformSubscriptionCredentials {
|
||||
formId: string
|
||||
apiKey: string
|
||||
region?: string
|
||||
}
|
||||
|
||||
function readCredentials(
|
||||
webhook: Record<string, unknown>
|
||||
): JotformSubscriptionCredentials | { error: string } {
|
||||
const config = getProviderConfig(webhook)
|
||||
const formId = toStringOrNull(config.formId)?.trim()
|
||||
const apiKey = toStringOrNull(config.apiKey)?.trim()
|
||||
|
||||
if (!formId) return { error: 'Form ID is required to register a Jotform webhook.' }
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error:
|
||||
'API Key is required to register a Jotform webhook. Create one under Account Settings > API with Full Access.',
|
||||
}
|
||||
}
|
||||
|
||||
return { formId, apiKey, region: toStringOrNull(config.apiRegion)?.trim() || undefined }
|
||||
}
|
||||
|
||||
/** Compares callback URLs without letting a trailing slash decide the outcome. */
|
||||
function sameUrl(a: string, b: string): boolean {
|
||||
return a.replace(/\/+$/, '') === b.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Jotform identifies a form's webhooks by their position in the form's webhook map, and
|
||||
* adding one renumbers the rest — the documented POST sample returns the new entry as
|
||||
* `"0"`. An id captured at registration is therefore stale as soon as the form's webhooks
|
||||
* change at all. Nothing persists it; every path re-resolves it by matching the URL.
|
||||
*/
|
||||
function findWebhookIdByUrl(content: unknown, notificationUrl: string): string | null {
|
||||
return normalizeWebhooks(content).find((entry) => sameUrl(entry.url, notificationUrl))?.id ?? null
|
||||
}
|
||||
|
||||
async function listWebhookIdForUrl(
|
||||
credentials: JotformSubscriptionCredentials,
|
||||
notificationUrl: string
|
||||
): Promise<string | null> {
|
||||
const response = await fetch(
|
||||
buildJotformUrl(
|
||||
credentials,
|
||||
`form/${encodeURIComponent(credentials.formId)}/webhooks`
|
||||
).toString(),
|
||||
{ method: 'GET', headers: buildJotformHeaders(credentials.apiKey) }
|
||||
)
|
||||
const envelope = await parseJotformResponse(response, 'Jotform List Webhooks')
|
||||
return findWebhookIdByUrl(envelope.content, notificationUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether another webhook row belonging to an active deployment is served by the
|
||||
* same Jotform callback — the same form and the same URL.
|
||||
*
|
||||
* Redeploying a trigger prepares the replacement row alongside the live one, and a workflow
|
||||
* keeps its webhook path across deployments, so both rows resolve to one callback on one
|
||||
* form. Registration adopts the callback already there instead of posting a duplicate, which
|
||||
* leaves the retired row's cleanup pointing at the callback the new row now depends on.
|
||||
* Teardown is skipped in that case, exactly as the Telegram handler skips `deleteWebhook`
|
||||
* while an active deployment still uses the same bot.
|
||||
*/
|
||||
async function activeDeploymentSharesJotformCallback(
|
||||
webhookRecord: Record<string, unknown>,
|
||||
formId: string,
|
||||
notificationUrl: string
|
||||
): Promise<boolean> {
|
||||
const workflowId = webhookRecord.workflowId
|
||||
const webhookId = webhookRecord.id
|
||||
if (typeof workflowId !== 'string' || typeof webhookId !== 'string') return false
|
||||
|
||||
const activeWebhooks = await db
|
||||
.select({ path: webhook.path, providerConfig: webhook.providerConfig })
|
||||
.from(webhook)
|
||||
.innerJoin(
|
||||
workflowDeploymentVersion,
|
||||
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(webhook.workflowId, workflowId),
|
||||
ne(webhook.id, webhookId),
|
||||
eq(webhook.provider, 'jotform'),
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
eq(workflowDeploymentVersion.isActive, true),
|
||||
isNull(webhook.archivedAt)
|
||||
)
|
||||
)
|
||||
|
||||
return activeWebhooks.some((activeWebhook) => {
|
||||
const config = getProviderConfig({ providerConfig: activeWebhook.providerConfig })
|
||||
return (
|
||||
toStringOrNull(config.formId)?.trim() === formId &&
|
||||
sameUrl(getNotificationUrl({ path: activeWebhook.path }), notificationUrl)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const jotformHandler: WebhookProviderHandler = {
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const payload = isRecordLike(body) ? body : {}
|
||||
|
||||
/* Jotform posts `rawRequest` as a JSON string inside a multipart body. A form whose
|
||||
answers fail to parse is still worth executing on, so a bad string degrades to null
|
||||
rather than dropping the submission. */
|
||||
let rawRequest: unknown = null
|
||||
const rawRequestString = toStringOrNull(payload.rawRequest)
|
||||
if (rawRequestString) {
|
||||
try {
|
||||
rawRequest = JSON.parse(rawRequestString)
|
||||
} catch (error) {
|
||||
logger.warn('Jotform rawRequest was not valid JSON', { error: getErrorMessage(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
input: {
|
||||
formId: toStringOrNull(payload.formID) ?? '',
|
||||
submissionId: toStringOrNull(payload.submissionID) ?? '',
|
||||
formTitle: toStringOrNull(payload.formTitle) ?? '',
|
||||
username: toStringOrNull(payload.username) ?? '',
|
||||
ip: toStringOrNull(payload.ip) ?? '',
|
||||
submissionType: toStringOrNull(payload.type) ?? '',
|
||||
pretty: toStringOrNull(payload.pretty) ?? '',
|
||||
rawRequest,
|
||||
raw: payload,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
extractIdempotencyId(body: unknown): string | null {
|
||||
if (!isRecordLike(body)) return null
|
||||
const submissionId = toStringOrNull(body.submissionID)
|
||||
return submissionId ? `submission:${submissionId}` : null
|
||||
},
|
||||
|
||||
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
|
||||
const credentials = readCredentials(ctx.webhook)
|
||||
if ('error' in credentials) {
|
||||
logger.warn(`[${ctx.requestId}] ${credentials.error}`, { webhookId: ctx.webhook.id })
|
||||
throw new Error(credentials.error)
|
||||
}
|
||||
|
||||
const notificationUrl = getNotificationUrl(ctx.webhook)
|
||||
|
||||
try {
|
||||
/* Jotform keeps a plain list and does not treat the URL as a key, so posting one it
|
||||
already holds is not a no-op — it would leave the form delivering every submission
|
||||
twice. Redeploys re-run this, so the existing list decides whether to post. */
|
||||
if (await listWebhookIdForUrl(credentials, notificationUrl)) {
|
||||
logger.info(`[${ctx.requestId}] Jotform webhook was already registered`, {
|
||||
webhookId: ctx.webhook.id,
|
||||
formId: credentials.formId,
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildJotformUrl(
|
||||
credentials,
|
||||
`form/${encodeURIComponent(credentials.formId)}/webhooks`
|
||||
).toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: jotformFormHeaders(credentials.apiKey),
|
||||
body: toFormBody({ webhookURL: notificationUrl }),
|
||||
}
|
||||
)
|
||||
|
||||
/* Jotform answers a rejected registration with the unchanged webhook list rather
|
||||
than an error, so the response is only a success if our URL is in it. */
|
||||
const envelope = await parseJotformResponse(response, 'Jotform Create Webhook')
|
||||
if (!findWebhookIdByUrl(envelope.content, notificationUrl)) {
|
||||
throw new Error(
|
||||
'Jotform accepted the request but did not register the webhook URL on the form. Verify the form ID and that the API key has Full Access.'
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${ctx.requestId}] Registered Jotform webhook`, {
|
||||
webhookId: ctx.webhook.id,
|
||||
formId: credentials.formId,
|
||||
})
|
||||
|
||||
return {}
|
||||
} catch (error) {
|
||||
logger.error(`[${ctx.requestId}] Failed to register Jotform webhook`, {
|
||||
webhookId: ctx.webhook.id,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
throw new Error(getErrorMessage(error, 'Failed to register the Jotform webhook.'))
|
||||
}
|
||||
},
|
||||
|
||||
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
|
||||
const credentials = readCredentials(ctx.webhook)
|
||||
if ('error' in credentials) {
|
||||
logger.warn(
|
||||
`[${ctx.requestId}] Missing Jotform credentials, skipping webhook cleanup for ${ctx.webhook.id}`
|
||||
)
|
||||
if (ctx.strict) throw new Error(credentials.error)
|
||||
return
|
||||
}
|
||||
|
||||
const notificationUrl = getNotificationUrl(ctx.webhook)
|
||||
|
||||
try {
|
||||
if (
|
||||
await activeDeploymentSharesJotformCallback(
|
||||
ctx.webhook,
|
||||
credentials.formId,
|
||||
notificationUrl
|
||||
)
|
||||
) {
|
||||
logger.info(
|
||||
`[${ctx.requestId}] Skipping Jotform webhook deletion because an active deployment is served by the same callback`,
|
||||
{ webhookId: ctx.webhook.id }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const webhookId = await listWebhookIdForUrl(credentials, notificationUrl)
|
||||
|
||||
if (!webhookId) {
|
||||
logger.info(
|
||||
`[${ctx.requestId}] Jotform webhook was already absent from form ${credentials.formId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildJotformUrl(
|
||||
credentials,
|
||||
`form/${encodeURIComponent(credentials.formId)}/webhooks/${encodeURIComponent(webhookId)}`
|
||||
).toString(),
|
||||
{ method: 'DELETE', headers: buildJotformHeaders(credentials.apiKey) }
|
||||
)
|
||||
|
||||
await parseJotformResponse(response, 'Jotform Delete Webhook')
|
||||
logger.info(`[${ctx.requestId}] Deleted Jotform webhook from form ${credentials.formId}`)
|
||||
} catch (error) {
|
||||
logger.warn(`[${ctx.requestId}] Error deleting Jotform webhook (non-fatal)`, {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
if (ctx.strict) throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { incidentioHandler } from '@/lib/webhooks/providers/incidentio'
|
||||
import { instantlyHandler } from '@/lib/webhooks/providers/instantly'
|
||||
import { intercomHandler } from '@/lib/webhooks/providers/intercom'
|
||||
import { jiraHandler } from '@/lib/webhooks/providers/jira'
|
||||
import { jotformHandler } from '@/lib/webhooks/providers/jotform'
|
||||
import { jsmHandler } from '@/lib/webhooks/providers/jsm'
|
||||
import { lemlistHandler } from '@/lib/webhooks/providers/lemlist'
|
||||
import { linearHandler } from '@/lib/webhooks/providers/linear'
|
||||
@@ -90,6 +91,7 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
|
||||
intercom: intercomHandler,
|
||||
instantly: instantlyHandler,
|
||||
jira: jiraHandler,
|
||||
jotform: jotformHandler,
|
||||
jsm: jsmHandler,
|
||||
lemlist: lemlistHandler,
|
||||
linear: linearHandler,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { jotformWebhookTrigger } from './webhook'
|
||||
@@ -0,0 +1,129 @@
|
||||
import { JotformIcon } from '@/components/icons'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
export const jotformWebhookTrigger: TriggerConfig = {
|
||||
id: 'jotform_webhook',
|
||||
name: 'Jotform Webhook',
|
||||
provider: 'jotform',
|
||||
description: 'Trigger workflow when a Jotform form receives a new submission',
|
||||
version: '1.0.0',
|
||||
icon: JotformIcon,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'webhookUrlDisplay',
|
||||
title: 'Webhook URL',
|
||||
type: 'short-input',
|
||||
readOnly: true,
|
||||
showCopyButton: true,
|
||||
useWebhookUrl: true,
|
||||
placeholder: 'Webhook URL will be generated',
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'formId',
|
||||
title: 'Form ID',
|
||||
canvasNoun: 'a form',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., 231504059977966',
|
||||
description:
|
||||
'The form to watch. It is the numeric segment of the form URL, and the List Forms operation returns it.',
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Jotform API key',
|
||||
description:
|
||||
'Used to register the webhook on the form automatically. Create one under Account Settings > API.',
|
||||
password: true,
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
/* Named apart from the block's own `region` field: the two live in the same block
|
||||
and a shared id would collide across tool mode and trigger mode. */
|
||||
id: 'apiRegion',
|
||||
title: 'Region',
|
||||
type: 'dropdown',
|
||||
description:
|
||||
'Data residency region the API key belongs to. A key only works on the host that issued it.',
|
||||
options: [
|
||||
{ label: 'US (api.jotform.com)', id: 'us' },
|
||||
{ label: 'EU (eu-api.jotform.com)', id: 'eu' },
|
||||
{ label: 'HIPAA (hipaa-api.jotform.com)', id: 'hipaa' },
|
||||
],
|
||||
value: () => 'us',
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'triggerInstructions',
|
||||
title: 'Setup Instructions',
|
||||
hideFromPreview: true,
|
||||
type: 'text',
|
||||
defaultValue: [
|
||||
'Create an API key at <a href="https://www.jotform.com/myaccount/api" target="_blank" rel="noopener noreferrer">https://www.jotform.com/myaccount/api</a> and give it Full Access, since read-only keys cannot add webhooks',
|
||||
'Pick the region your account is on. EU and HIPAA accounts are served by different hosts, and a key is rejected by every host but its own',
|
||||
'Copy the Form ID from the form URL, e.g. <code>https://www.jotform.com/build/231504059977966</code> gives <code>231504059977966</code>',
|
||||
'Sim registers the webhook on that form when you deploy the workflow, and removes it again when you undeploy',
|
||||
'<strong>Note:</strong> Only submissions made through the form fire this trigger. Submissions created through the API do not.',
|
||||
]
|
||||
.map(
|
||||
(instruction, index) =>
|
||||
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
|
||||
)
|
||||
.join(''),
|
||||
mode: 'trigger',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: {
|
||||
formId: {
|
||||
type: 'string',
|
||||
description: 'ID of the form that was submitted',
|
||||
},
|
||||
submissionId: {
|
||||
type: 'string',
|
||||
description: 'ID of the new submission',
|
||||
},
|
||||
formTitle: {
|
||||
type: 'string',
|
||||
description: 'Title of the form at the time of submission',
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
description: 'Jotform account username that owns the form',
|
||||
},
|
||||
ip: {
|
||||
type: 'string',
|
||||
description: 'IP address the submission came from',
|
||||
},
|
||||
submissionType: {
|
||||
type: 'string',
|
||||
description: 'How the submission was made, e.g. WEB',
|
||||
},
|
||||
pretty: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Human-readable summary of the answers, as comma-separated "Question Label:Answer" pairs. Unanswered questions are left out.',
|
||||
},
|
||||
rawRequest: {
|
||||
type: 'json',
|
||||
description:
|
||||
'The submitted form body. Answers are keyed q{questionId}_{slugifiedLabel} and hold a string, or an object for a multi-part question such as name or address. A file answer instead appears under the plain slugified label as an array of upload URLs, with the chosen filenames under temp_upload. The body also carries form-internal fields such as slug, buildDate, submitSource, and jsExecutionTracker.',
|
||||
},
|
||||
raw: {
|
||||
type: 'json',
|
||||
description: 'Complete original webhook payload from Jotform',
|
||||
},
|
||||
},
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -266,6 +266,7 @@ import {
|
||||
jiraWorklogDeletedTrigger,
|
||||
jiraWorklogUpdatedTrigger,
|
||||
} from '@/triggers/jira'
|
||||
import { jotformWebhookTrigger } from '@/triggers/jotform'
|
||||
import {
|
||||
jsmRequestCommentedTrigger,
|
||||
jsmRequestCreatedTrigger,
|
||||
@@ -661,6 +662,7 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
|
||||
jira_sprint_closed: jiraSprintClosedTrigger,
|
||||
jira_project_created: jiraProjectCreatedTrigger,
|
||||
jira_version_released: jiraVersionReleasedTrigger,
|
||||
jotform_webhook: jotformWebhookTrigger,
|
||||
jsm_request_created: jsmRequestCreatedTrigger,
|
||||
jsm_request_updated: jsmRequestUpdatedTrigger,
|
||||
jsm_request_commented: jsmRequestCommentedTrigger,
|
||||
|
||||
Reference in New Issue
Block a user