diff --git a/apps/docs/content/docs/en/integrations/jotform.mdx b/apps/docs/content/docs/en/integrations/jotform.mdx index 91171bf5bb0..cc03a74c049 100644 --- a/apps/docs/content/docs/en/integrations/jotform.mdx +++ b/apps/docs/content/docs/en/integrations/jotform.mdx @@ -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 | + diff --git a/apps/sim/blocks/blocks/jotform.test.ts b/apps/sim/blocks/blocks/jotform.test.ts index 0c7ca2c680c..31144f416fc 100644 --- a/apps/sim/blocks/blocks/jotform.test.ts +++ b/apps/sim/blocks/blocks/jotform.test.ts @@ -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) }) diff --git a/apps/sim/blocks/blocks/jotform.ts b/apps/sim/blocks/blocks/jotform.ts index 1ebaf9f26d2..d22a56dfcc7 100644 --- a/apps/sim/blocks/blocks/jotform.ts +++ b/apps/sim/blocks/blocks/jotform.ts @@ -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 = { diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 86b060fa685..b46ecfb17ec 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -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", diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index f454b8682d9..3904473599b 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -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) + }) +}) diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index a7a56b03f58..06933871349 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -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> { + const formData = await new Response(rawBody, { + headers: { 'content-type': contentType }, + }).formData() + + const fields: Record = {} + 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) } diff --git a/apps/sim/lib/webhooks/providers/jotform.test.ts b/apps/sim/lib/webhooks/providers/jotform.test.ts new file mode 100644 index 00000000000..099c68a5ea1 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/jotform.test.ts @@ -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 }) => + 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) { + 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 = {}) { + 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> + 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).rawRequest).toBeNull() + expect((result.input as Record).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/) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/jotform.ts b/apps/sim/lib/webhooks/providers/jotform.ts new file mode 100644 index 00000000000..bdad7809d73 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/jotform.ts @@ -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 +): 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 { + 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, + formId: string, + notificationUrl: string +): Promise { + 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 { + 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 { + 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 { + 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 + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 085280c9d55..cf2548611b8 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -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 = { intercom: intercomHandler, instantly: instantlyHandler, jira: jiraHandler, + jotform: jotformHandler, jsm: jsmHandler, lemlist: lemlistHandler, linear: linearHandler, diff --git a/apps/sim/triggers/jotform/index.ts b/apps/sim/triggers/jotform/index.ts new file mode 100644 index 00000000000..898766e6f2e --- /dev/null +++ b/apps/sim/triggers/jotform/index.ts @@ -0,0 +1 @@ +export { jotformWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/jotform/webhook.ts b/apps/sim/triggers/jotform/webhook.ts new file mode 100644 index 00000000000..433676616d1 --- /dev/null +++ b/apps/sim/triggers/jotform/webhook.ts @@ -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 https://www.jotform.com/myaccount/api 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. https://www.jotform.com/build/231504059977966 gives 231504059977966', + 'Sim registers the webhook on that form when you deploy the workflow, and removes it again when you undeploy', + 'Note: Only submissions made through the form fire this trigger. Submissions created through the API do not.', + ] + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .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', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index c9c55455f44..32f016cc03b 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -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,