From 8d8a9f7c784ed5519f5106c5db2a7b4fe96bdffd Mon Sep 17 00:00:00 2001 From: Faruk AYDIN Date: Thu, 24 Nov 2022 00:33:06 +0100 Subject: [PATCH 01/14] feat: Implement Typeform authentication --- .../src/apps/typeform/assets/favicon.svg | 4 ++ .../apps/typeform/auth/generate-auth-url.ts | 21 ++++++++ .../backend/src/apps/typeform/auth/index.ts | 48 +++++++++++++++++++ .../apps/typeform/auth/is-still-verified.ts | 9 ++++ .../src/apps/typeform/auth/refresh-token.ts | 24 ++++++++++ .../apps/typeform/auth/verify-credentials.ts | 46 ++++++++++++++++++ .../apps/typeform/common/add-auth-header.ts | 12 +++++ .../src/apps/typeform/common/auth-scope.ts | 12 +++++ packages/backend/src/apps/typeform/index.ts | 16 +++++++ 9 files changed, 192 insertions(+) create mode 100644 packages/backend/src/apps/typeform/assets/favicon.svg create mode 100644 packages/backend/src/apps/typeform/auth/generate-auth-url.ts create mode 100644 packages/backend/src/apps/typeform/auth/index.ts create mode 100644 packages/backend/src/apps/typeform/auth/is-still-verified.ts create mode 100644 packages/backend/src/apps/typeform/auth/refresh-token.ts create mode 100644 packages/backend/src/apps/typeform/auth/verify-credentials.ts create mode 100644 packages/backend/src/apps/typeform/common/add-auth-header.ts create mode 100644 packages/backend/src/apps/typeform/common/auth-scope.ts create mode 100644 packages/backend/src/apps/typeform/index.ts diff --git a/packages/backend/src/apps/typeform/assets/favicon.svg b/packages/backend/src/apps/typeform/assets/favicon.svg new file mode 100644 index 00000000..f0fabb1c --- /dev/null +++ b/packages/backend/src/apps/typeform/assets/favicon.svg @@ -0,0 +1,4 @@ + + Typeform + + diff --git a/packages/backend/src/apps/typeform/auth/generate-auth-url.ts b/packages/backend/src/apps/typeform/auth/generate-auth-url.ts new file mode 100644 index 00000000..14ca84b8 --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/generate-auth-url.ts @@ -0,0 +1,21 @@ +import { IField, IGlobalVariable } from '@automatisch/types'; +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope'; + +export default async function generateAuthUrl($: IGlobalVariable) { + const oauthRedirectUrl = $.app.auth.fields.find( + (field: IField) => field.key == 'oAuthRedirectUrl' + ).value; + + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId as string, + redirect_uri: oauthRedirectUrl as string, + scope: authScope.join(' '), + }); + + const url = `${$.app.apiBaseUrl}/oauth/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/typeform/auth/index.ts b/packages/backend/src/apps/typeform/auth/index.ts new file mode 100644 index 00000000..4bc4dc66 --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/index.ts @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url'; +import verifyCredentials from './verify-credentials'; +import isStillVerified from './is-still-verified'; +import refreshToken from './refresh-token'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string' as const, + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/typeform/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Typeform OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string' as const, + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string' as const, + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/typeform/auth/is-still-verified.ts b/packages/backend/src/apps/typeform/auth/is-still-verified.ts new file mode 100644 index 00000000..4fb0c3e3 --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/is-still-verified.ts @@ -0,0 +1,9 @@ +import { IGlobalVariable } from '@automatisch/types'; + +const isStillVerified = async ($: IGlobalVariable) => { + await $.http.get('/me'); + + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/typeform/auth/refresh-token.ts b/packages/backend/src/apps/typeform/auth/refresh-token.ts new file mode 100644 index 00000000..7da4e118 --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/refresh-token.ts @@ -0,0 +1,24 @@ +import { IGlobalVariable } from '@automatisch/types'; +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope'; + +const refreshToken = async ($: IGlobalVariable) => { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: $.auth.data.clientId as string, + client_secret: $.auth.data.clientSecret as string, + refresh_token: $.auth.data.refreshToken as string, + scope: authScope.join(' '), + }); + + const { data } = await $.http.post('/oauth/token', params.toString()); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + tokenType: data.token_type, + refreshToken: data.refresh_token, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/typeform/auth/verify-credentials.ts b/packages/backend/src/apps/typeform/auth/verify-credentials.ts new file mode 100644 index 00000000..2d1e55ea --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/verify-credentials.ts @@ -0,0 +1,46 @@ +import { IField, IGlobalVariable } from '@automatisch/types'; +import { URLSearchParams } from 'url'; + +const verifyCredentials = async ($: IGlobalVariable) => { + const oauthRedirectUrl = $.app.auth.fields.find( + (field: IField) => field.key == 'oAuthRedirectUrl' + ).value; + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code: $.auth.data.code as string, + client_id: $.auth.data.clientId as string, + client_secret: $.auth.data.clientSecret as string, + redirect_uri: oauthRedirectUrl as string, + }); + + const { data: verifiedCredentials } = await $.http.post( + '/oauth/token', + params.toString() + ); + + const { + access_token: accessToken, + expires_in: expiresIn, + token_type: tokenType, + refresh_token: refreshToken, + } = verifiedCredentials; + + const { data: user } = await $.http.get('/me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + await $.auth.set({ + accessToken, + expiresIn, + tokenType, + userId: user.user_id, + screenName: user.alias, + email: user.email, + refreshToken, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/typeform/common/add-auth-header.ts b/packages/backend/src/apps/typeform/common/add-auth-header.ts new file mode 100644 index 00000000..d650c915 --- /dev/null +++ b/packages/backend/src/apps/typeform/common/add-auth-header.ts @@ -0,0 +1,12 @@ +import { TBeforeRequest } from '@automatisch/types'; + +const addAuthHeader: TBeforeRequest = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + const authorizationHeader = `Bearer ${$.auth.data.accessToken}`; + requestConfig.headers.Authorization = authorizationHeader; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/typeform/common/auth-scope.ts b/packages/backend/src/apps/typeform/common/auth-scope.ts new file mode 100644 index 00000000..7b3154c8 --- /dev/null +++ b/packages/backend/src/apps/typeform/common/auth-scope.ts @@ -0,0 +1,12 @@ +const authScope: string[] = [ + 'forms:read', + 'forms:write', + 'webhooks:read', + 'webhooks:write', + 'responses:read', + 'accounts:read', + 'workspaces:read', + 'offline', +]; + +export default authScope; diff --git a/packages/backend/src/apps/typeform/index.ts b/packages/backend/src/apps/typeform/index.ts new file mode 100644 index 00000000..34e5bac7 --- /dev/null +++ b/packages/backend/src/apps/typeform/index.ts @@ -0,0 +1,16 @@ +import defineApp from '../../helpers/define-app'; +import addAuthHeader from './common/add-auth-header'; +import auth from './auth'; + +export default defineApp({ + name: 'Typeform', + key: 'typeform', + iconUrl: '{BASE_URL}/apps/typeform/assets/favicon.svg', + authDocUrl: 'https://automatisch.io/docs/apps/typeform/connection', + supportsConnections: true, + baseUrl: 'https://typeform.com', + apiBaseUrl: 'https://api.typeform.com', + primaryColor: '000000', + beforeRequest: [addAuthHeader], + auth, +}); From 397926f99426c58f20c8e0f7ffde48416f607396 Mon Sep 17 00:00:00 2001 From: Faruk AYDIN Date: Thu, 24 Nov 2022 17:07:18 +0100 Subject: [PATCH 02/14] feat: Add listForms dynamic data to Typeform app --- .../src/apps/typeform/dynamic-data/index.ts | 3 ++ .../typeform/dynamic-data/list-forms/index.ts | 25 +++++++++++++++ packages/backend/src/apps/typeform/index.ts | 4 +++ .../src/apps/typeform/triggers/index.ts | 3 ++ .../apps/typeform/triggers/new-entry/index.ts | 32 +++++++++++++++++++ 5 files changed, 67 insertions(+) create mode 100644 packages/backend/src/apps/typeform/dynamic-data/index.ts create mode 100644 packages/backend/src/apps/typeform/dynamic-data/list-forms/index.ts create mode 100644 packages/backend/src/apps/typeform/triggers/index.ts create mode 100644 packages/backend/src/apps/typeform/triggers/new-entry/index.ts diff --git a/packages/backend/src/apps/typeform/dynamic-data/index.ts b/packages/backend/src/apps/typeform/dynamic-data/index.ts new file mode 100644 index 00000000..cb6cd053 --- /dev/null +++ b/packages/backend/src/apps/typeform/dynamic-data/index.ts @@ -0,0 +1,3 @@ +import listForms from './list-forms'; + +export default [listForms]; diff --git a/packages/backend/src/apps/typeform/dynamic-data/list-forms/index.ts b/packages/backend/src/apps/typeform/dynamic-data/list-forms/index.ts new file mode 100644 index 00000000..409ec1a3 --- /dev/null +++ b/packages/backend/src/apps/typeform/dynamic-data/list-forms/index.ts @@ -0,0 +1,25 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; + +export default { + name: 'List forms', + key: 'listForms', + + async run($: IGlobalVariable) { + const forms: { + data: IJSONObject[]; + } = { + data: [], + }; + + const response = await $.http.get('/forms'); + + forms.data = response.data.items.map((form: IJSONObject) => { + return { + value: form.id, + name: form.title, + }; + }); + + return forms; + }, +}; diff --git a/packages/backend/src/apps/typeform/index.ts b/packages/backend/src/apps/typeform/index.ts index 34e5bac7..060589c8 100644 --- a/packages/backend/src/apps/typeform/index.ts +++ b/packages/backend/src/apps/typeform/index.ts @@ -1,6 +1,8 @@ import defineApp from '../../helpers/define-app'; import addAuthHeader from './common/add-auth-header'; import auth from './auth'; +import triggers from './triggers'; +import dynamicData from './dynamic-data'; export default defineApp({ name: 'Typeform', @@ -13,4 +15,6 @@ export default defineApp({ primaryColor: '000000', beforeRequest: [addAuthHeader], auth, + triggers, + dynamicData, }); diff --git a/packages/backend/src/apps/typeform/triggers/index.ts b/packages/backend/src/apps/typeform/triggers/index.ts new file mode 100644 index 00000000..c49828b1 --- /dev/null +++ b/packages/backend/src/apps/typeform/triggers/index.ts @@ -0,0 +1,3 @@ +import newEntry from './new-entry'; + +export default [newEntry]; diff --git a/packages/backend/src/apps/typeform/triggers/new-entry/index.ts b/packages/backend/src/apps/typeform/triggers/new-entry/index.ts new file mode 100644 index 00000000..1d4f167f --- /dev/null +++ b/packages/backend/src/apps/typeform/triggers/new-entry/index.ts @@ -0,0 +1,32 @@ +import defineTrigger from '../../../../helpers/define-trigger'; + +export default defineTrigger({ + name: 'New entry', + key: 'newEntry', + pollInterval: 15, + description: 'Triggers when a new form submitted.', + arguments: [ + { + label: 'Form', + key: 'form', + type: 'dropdown' as const, + required: true, + description: 'Pick a form to receive submissions.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listForms', + }, + ], + }, + }, + ], + + async run($) { + // await getUserTweets($, { currentUser: true }); + }, +}); From d83e8dabf81bd8a4034adaea7e1db00687a3f928 Mon Sep 17 00:00:00 2001 From: Faruk AYDIN Date: Mon, 28 Nov 2022 23:30:03 +0100 Subject: [PATCH 03/14] feat: Implement webhook logic along with new entry typeform trigger --- packages/backend/.env-example | 1 + packages/backend/src/app.ts | 13 ++- .../backend/src/apps/typeform/auth/index.ts | 2 + .../src/apps/typeform/auth/verify-webhook.ts | 20 +++++ .../apps/typeform/triggers/new-entry/index.ts | 84 ++++++++++++++++++- packages/backend/src/config/app.ts | 4 + .../src/controllers/webhooks/create.ts | 55 ++++++++++++ .../graphql/mutations/update-flow-status.ts | 13 ++- .../backend/src/helpers/global-variable.ts | 23 ++++- packages/backend/src/routes/index.ts | 10 +++ packages/backend/src/routes/webhooks.ts | 8 ++ packages/backend/src/services/flow.ts | 8 +- packages/types/index.d.ts | 17 +++- 13 files changed, 246 insertions(+), 12 deletions(-) create mode 100644 packages/backend/src/apps/typeform/auth/verify-webhook.ts create mode 100644 packages/backend/src/controllers/webhooks/create.ts create mode 100644 packages/backend/src/routes/index.ts create mode 100644 packages/backend/src/routes/webhooks.ts diff --git a/packages/backend/.env-example b/packages/backend/.env-example index 53505520..924bd16c 100644 --- a/packages/backend/.env-example +++ b/packages/backend/.env-example @@ -2,6 +2,7 @@ HOST=localhost PROTOCOL=http PORT=3000 WEB_APP_URL=http://localhost:3001 +WEBHOOK_URL=http://localhost:3000 APP_ENV=development POSTGRES_DATABASE=automatisch_development POSTGRES_PORT=5432 diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index e078652a..7eec9c96 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -2,7 +2,6 @@ import createError from 'http-errors'; import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import corsOptions from './config/cors-options'; -import graphQLInstance from './helpers/graphql-instance'; import morgan from './helpers/morgan'; import appAssetsHandler from './helpers/app-assets-handler'; import webUIHandler from './helpers/web-ui-handler'; @@ -13,6 +12,8 @@ import { serverAdapter, } from './helpers/create-bull-board-handler'; import injectBullBoardHandler from './helpers/inject-bull-board-handler'; +import router from './routes'; +import { IRequest } from '@automatisch/types'; createBullBoardHandler(serverAdapter); @@ -23,10 +24,16 @@ injectBullBoardHandler(app, serverAdapter); appAssetsHandler(app); app.use(morgan); -app.use(express.json()); +app.use( + express.json({ + verify: (req, res, buf) => { + (req as IRequest).rawBody = buf; + }, + }) +); app.use(express.urlencoded({ extended: false })); app.use(cors(corsOptions)); -app.use('/graphql', graphQLInstance); +app.use('/', router); webUIHandler(app); diff --git a/packages/backend/src/apps/typeform/auth/index.ts b/packages/backend/src/apps/typeform/auth/index.ts index 4bc4dc66..0aa070f2 100644 --- a/packages/backend/src/apps/typeform/auth/index.ts +++ b/packages/backend/src/apps/typeform/auth/index.ts @@ -2,6 +2,7 @@ import generateAuthUrl from './generate-auth-url'; import verifyCredentials from './verify-credentials'; import isStillVerified from './is-still-verified'; import refreshToken from './refresh-token'; +import verifyWebhook from './verify-webhook'; export default { fields: [ @@ -45,4 +46,5 @@ export default { verifyCredentials, isStillVerified, refreshToken, + verifyWebhook, }; diff --git a/packages/backend/src/apps/typeform/auth/verify-webhook.ts b/packages/backend/src/apps/typeform/auth/verify-webhook.ts new file mode 100644 index 00000000..f5ba0d46 --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/verify-webhook.ts @@ -0,0 +1,20 @@ +import crypto from 'crypto'; +import { IGlobalVariable } from '@automatisch/types'; +import appConfig from '../../../config/app'; + +const verifyWebhook = async ($: IGlobalVariable) => { + const signature = $.request.headers['typeform-signature'] as string; + const isValid = verifySignature(signature, $.request.rawBody.toString()); + + return isValid; +}; + +const verifySignature = function (receivedSignature: string, payload: string) { + const hash = crypto + .createHmac('sha256', appConfig.appSecretKey) + .update(payload) + .digest('base64'); + return receivedSignature === `sha256=${hash}`; +}; + +export default verifyWebhook; diff --git a/packages/backend/src/apps/typeform/triggers/new-entry/index.ts b/packages/backend/src/apps/typeform/triggers/new-entry/index.ts index 1d4f167f..0c46a7d7 100644 --- a/packages/backend/src/apps/typeform/triggers/new-entry/index.ts +++ b/packages/backend/src/apps/typeform/triggers/new-entry/index.ts @@ -1,14 +1,16 @@ +import { IJSONObject } from '@automatisch/types'; +import appConfig from '../../../../config/app'; import defineTrigger from '../../../../helpers/define-trigger'; export default defineTrigger({ name: 'New entry', key: 'newEntry', - pollInterval: 15, + type: 'webhook', description: 'Triggers when a new form submitted.', arguments: [ { label: 'Form', - key: 'form', + key: 'formId', type: 'dropdown' as const, required: true, description: 'Pick a form to receive submissions.', @@ -26,7 +28,81 @@ export default defineTrigger({ }, ], - async run($) { - // await getUserTweets($, { currentUser: true }); + async testRun($) { + const createApiResponse = await $.http.get( + `/forms/${$.step.parameters.formId}` + ); + + const responsesApiResponse = await $.http.get( + `/forms/${$.step.parameters.formId}/responses` + ); + + const lastResponse = responsesApiResponse.data.items[0]; + + const computedResponseItem = { + event_type: 'form_response', + form_response: { + form_id: $.step.parameters.formId, + token: lastResponse.token, + landed_at: lastResponse.landed_at, + submitted_at: lastResponse.submitted_at, + definion: { + id: $.step.parameters.formId, + title: createApiResponse.data.title, + fields: createApiResponse.data?.fields?.map((field: IJSONObject) => ({ + id: field.id, + ref: field.ref, + type: field.type, + title: field.title, + properties: {}, + choices: ( + (field?.properties as IJSONObject)?.choices as IJSONObject[] + )?.map((choice) => ({ + id: choice.id, + label: choice.label, + })), + })), + }, + answers: lastResponse.answers?.map((answer: IJSONObject) => ({ + type: answer.type, + choice: { + label: (answer?.choice as IJSONObject)?.label, + }, + field: { + id: (answer.field as IJSONObject).id, + ref: (answer.field as IJSONObject).ref, + type: (answer.field as IJSONObject).type, + }, + })), + }, + }; + + const dataItem = { + raw: computedResponseItem, + meta: { + internalId: computedResponseItem.form_response.token, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const subscriptionPayload = { + enabled: true, + url: $.webhookUrl, + secret: appConfig.appSecretKey, + }; + + await $.http.put( + `/forms/${$.step.parameters.formId}/webhooks/${$.flow.id}`, + subscriptionPayload + ); + }, + + async unregisterHook($) { + await $.http.delete( + `/forms/${$.step.parameters.formId}/webhooks/${$.flow.id}` + ); }, }); diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index 6d41956b..7403421e 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -6,6 +6,7 @@ type AppConfig = { protocol: string; port: string; webAppUrl: string; + webhookUrl: string; appEnv: string; isDev: boolean; postgresDatabase: string; @@ -37,6 +38,8 @@ const serveWebAppSeparately = process.env.SERVE_WEB_APP_SEPARATELY === 'true' ? true : false; let webAppUrl = `${protocol}://${host}:${port}`; +const webhookUrl = process.env.WEBHOOK_URL || webAppUrl; + if (serveWebAppSeparately) { webAppUrl = process.env.WEB_APP_URL || 'http://localhost:3001'; } @@ -73,6 +76,7 @@ const appConfig: AppConfig = { bullMQDashboardPassword: process.env.BULLMQ_DASHBOARD_PASSWORD, baseUrl, webAppUrl, + webhookUrl, telemetryEnabled: process.env.TELEMETRY_ENABLED === 'false' ? false : true, }; diff --git a/packages/backend/src/controllers/webhooks/create.ts b/packages/backend/src/controllers/webhooks/create.ts new file mode 100644 index 00000000..a56f73e8 --- /dev/null +++ b/packages/backend/src/controllers/webhooks/create.ts @@ -0,0 +1,55 @@ +import { Request, Response } from 'express'; + +import { ITriggerItem } from '@automatisch/types'; +import Flow from '../../models/flow'; +import triggerQueue from '../../queues/trigger'; +import globalVariable from '../../helpers/global-variable'; + +export default async (request: Request, response: Response) => { + const flow = await Flow.query() + .findById(request.params.flowId) + .throwIfNotFound(); + + if (!flow.active) { + return response.send(404); + } + + const triggerStep = await flow.getTriggerStep(); + const app = await triggerStep.getApp(); + + if (app.auth.verifyWebhook) { + const $ = await globalVariable({ + flow, + connection: await triggerStep.$relatedQuery('connection'), + app, + step: triggerStep, + testRun: false, + request, + }); + + const verified = await app.auth.verifyWebhook($); + + if (!verified) { + return response.sendStatus(401); + } + } + + const triggerItem: ITriggerItem = { + raw: request.body, + meta: { + internalId: request.body.form_response.token, + }, + }; + + const jobName = `${triggerStep.id}-${triggerItem.meta.internalId}`; + + const jobPayload = { + flowId: flow.id, + stepId: triggerStep.id, + triggerItem, + }; + + await triggerQueue.add(jobName, jobPayload); + + return response.sendStatus(200); +}; diff --git a/packages/backend/src/graphql/mutations/update-flow-status.ts b/packages/backend/src/graphql/mutations/update-flow-status.ts index 10e2f9f5..ccc6ac3d 100644 --- a/packages/backend/src/graphql/mutations/update-flow-status.ts +++ b/packages/backend/src/graphql/mutations/update-flow-status.ts @@ -1,6 +1,7 @@ import Context from '../../types/express/context'; import flowQueue from '../../queues/flow'; import { REMOVE_AFTER_30_DAYS_OR_150_JOBS, REMOVE_AFTER_7_DAYS_OR_50_JOBS } from '../../helpers/remove-job-configuration'; +import globalVariable from '../../helpers/global-variable'; type Params = { input: { @@ -50,12 +51,22 @@ const updateFlowStatus = async ( jobName, { flowId: flow.id }, { - repeat: repeatOptions, + repeat: trigger.type === 'webhook' ? null : repeatOptions, jobId: flow.id, removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS } ); + } else if (!flow.active && trigger.type === 'webhook') { + const $ = await globalVariable({ + flow, + connection: await triggerStep.$relatedQuery('connection'), + app: await triggerStep.getApp(), + step: triggerStep, + testRun: false, + }); + + await trigger.unregisterHook($); } else { const repeatableJobs = await flowQueue.getRepeatableJobs(); const job = repeatableJobs.find((job) => job.id === flow.id); diff --git a/packages/backend/src/helpers/global-variable.ts b/packages/backend/src/helpers/global-variable.ts index 9fbb4446..de9a09e8 100644 --- a/packages/backend/src/helpers/global-variable.ts +++ b/packages/backend/src/helpers/global-variable.ts @@ -3,12 +3,14 @@ import Connection from '../models/connection'; import Flow from '../models/flow'; import Step from '../models/step'; import Execution from '../models/execution'; +import appConfig from '../config/app'; import { IJSONObject, IApp, IGlobalVariable, ITriggerItem, IActionItem, + IRequest, } from '@automatisch/types'; import EarlyExitError from '../errors/early-exit'; @@ -19,12 +21,21 @@ type GlobalVariableOptions = { step?: Step; execution?: Execution; testRun?: boolean; + request?: IRequest; }; const globalVariable = async ( options: GlobalVariableOptions ): Promise => { - const { connection, app, flow, step, execution, testRun = false } = options; + const { + connection, + app, + flow, + step, + execution, + request, + testRun = false, + } = options; const lastInternalId = testRun ? undefined : await flow?.lastInternalId(); const nextStep = await step?.getNextStep(); @@ -95,12 +106,22 @@ const globalVariable = async ( }, }; + if (request) { + $.request = request; + } + $.http = createHttpClient({ $, baseURL: app.apiBaseUrl, beforeRequest: app.beforeRequest, }); + if (flow) { + const webhookUrl = appConfig.webhookUrl + '/webhooks/' + flow.id; + + $.webhookUrl = webhookUrl; + } + const lastInternalIds = testRun || (flow && step.isAction) ? [] : await flow?.lastInternalIds(2000); diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts new file mode 100644 index 00000000..1cb26316 --- /dev/null +++ b/packages/backend/src/routes/index.ts @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import graphQLInstance from '../helpers/graphql-instance'; +import webhooksRouter from './webhooks'; + +const router = Router(); + +router.use('/graphql', graphQLInstance); +router.use('/webhooks', webhooksRouter); + +export default router; diff --git a/packages/backend/src/routes/webhooks.ts b/packages/backend/src/routes/webhooks.ts new file mode 100644 index 00000000..c08eced3 --- /dev/null +++ b/packages/backend/src/routes/webhooks.ts @@ -0,0 +1,8 @@ +import createAction from '../controllers/webhooks/create'; +import { Router } from 'express'; + +const router = Router(); + +router.post('/:flowId', createAction); + +export default router; diff --git a/packages/backend/src/services/flow.ts b/packages/backend/src/services/flow.ts index f6a256fe..d76d9be9 100644 --- a/packages/backend/src/services/flow.ts +++ b/packages/backend/src/services/flow.ts @@ -23,7 +23,13 @@ export const processFlow = async (options: ProcessFlowOptions) => { }); try { - await triggerCommand.run($); + if (triggerCommand.type === 'webhook' && !flow.active) { + await triggerCommand.testRun($); + } else if (triggerCommand.type === 'webhook' && flow.active) { + await triggerCommand.registerHook($); + } else { + await triggerCommand.run($); + } } catch (error) { if (error instanceof EarlyExitError === false) { if (error instanceof HttpError) { diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index df2521dd..0b2fb1f8 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -1,5 +1,6 @@ import type { AxiosInstance, AxiosRequestConfig } from 'axios'; export type IHttpClient = AxiosInstance; +import type { Request } from 'express'; // Type definitions for automatisch @@ -182,6 +183,7 @@ export interface IAuth { verifyCredentials($: IGlobalVariable): Promise; isStillVerified($: IGlobalVariable): Promise; refreshToken?($: IGlobalVariable): Promise; + verifyWebhook?($: IGlobalVariable): Promise; isRefreshTokenRequested?: boolean; fields: IField[]; authenticationSteps?: IAuthenticationStep[]; @@ -210,10 +212,14 @@ export interface ITriggerItem { export interface IBaseTrigger { name: string; key: string; + type?: 'webhook' | 'polling'; pollInterval?: number; description: string; getInterval?(parameters: IStep['parameters']): string; - run($: IGlobalVariable): Promise; + run?($: IGlobalVariable): Promise; + testRun?($: IGlobalVariable): Promise; + registerHook?($: IGlobalVariable): Promise; + unregisterHook?($: IGlobalVariable): Promise; sort?(item: ITriggerItem, nextItem: ITriggerItem): number; } @@ -238,7 +244,7 @@ export interface IBaseAction { name: string; key: string; description: string; - run($: IGlobalVariable): Promise; + run?($: IGlobalVariable): Promise; } export interface IRawAction extends IBaseAction { @@ -274,6 +280,7 @@ export type IGlobalVariable = { }; app: IApp; http?: IHttpClient; + request?: IRequest; flow?: { id: string; lastInternalId: string; @@ -293,6 +300,7 @@ export type IGlobalVariable = { id: string; testRun: boolean; }; + webhookUrl?: string; triggerOutput?: ITriggerOutput; actionOutput?: IActionOutput; pushTriggerItem?: (triggerItem: ITriggerItem) => void; @@ -308,3 +316,8 @@ declare module 'axios' { additionalProperties?: Record; } } + +export interface IRequest extends Request { + rawBody?: Buffer; +} + From 0f4f36c654476b13b014ca108403730cee7a34b1 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 30 Nov 2022 02:10:32 +0100 Subject: [PATCH 04/14] refactor: name webhook controller handler --- .../src/controllers/webhooks/{create.ts => handler.ts} | 10 +++++----- packages/backend/src/routes/webhooks.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) rename packages/backend/src/controllers/webhooks/{create.ts => handler.ts} (81%) diff --git a/packages/backend/src/controllers/webhooks/create.ts b/packages/backend/src/controllers/webhooks/handler.ts similarity index 81% rename from packages/backend/src/controllers/webhooks/create.ts rename to packages/backend/src/controllers/webhooks/handler.ts index a56f73e8..f79839f6 100644 --- a/packages/backend/src/controllers/webhooks/create.ts +++ b/packages/backend/src/controllers/webhooks/handler.ts @@ -1,11 +1,12 @@ -import { Request, Response } from 'express'; +import { Response } from 'express'; +import bcrypt from 'bcrypt'; +import { IRequest, ITriggerItem } from '@automatisch/types'; -import { ITriggerItem } from '@automatisch/types'; import Flow from '../../models/flow'; import triggerQueue from '../../queues/trigger'; import globalVariable from '../../helpers/global-variable'; -export default async (request: Request, response: Response) => { +export default async (request: IRequest, response: Response) => { const flow = await Flow.query() .findById(request.params.flowId) .throwIfNotFound(); @@ -23,7 +24,6 @@ export default async (request: Request, response: Response) => { connection: await triggerStep.$relatedQuery('connection'), app, step: triggerStep, - testRun: false, request, }); @@ -37,7 +37,7 @@ export default async (request: Request, response: Response) => { const triggerItem: ITriggerItem = { raw: request.body, meta: { - internalId: request.body.form_response.token, + internalId: await bcrypt.hash(request.rawBody, 1), }, }; diff --git a/packages/backend/src/routes/webhooks.ts b/packages/backend/src/routes/webhooks.ts index c08eced3..c26f4add 100644 --- a/packages/backend/src/routes/webhooks.ts +++ b/packages/backend/src/routes/webhooks.ts @@ -1,8 +1,8 @@ -import createAction from '../controllers/webhooks/create'; import { Router } from 'express'; +import webhookHandler from '../controllers/webhooks/handler'; const router = Router(); -router.post('/:flowId', createAction); +router.post('/:flowId', webhookHandler); export default router; From 82e3b40e7ce69b3248ba5d1a8be65d647627e600 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 30 Nov 2022 02:11:07 +0100 Subject: [PATCH 05/14] refactor: remove redundant function signature --- packages/backend/src/app.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 7eec9c96..a42a1009 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,5 +1,5 @@ import createError from 'http-errors'; -import express, { Request, Response, NextFunction } from 'express'; +import express from 'express'; import cors from 'cors'; import corsOptions from './config/cors-options'; import morgan from './helpers/morgan'; @@ -38,7 +38,7 @@ app.use('/', router); webUIHandler(app); // catch 404 and forward to error handler -app.use(function (req: Request, res: Response, next: NextFunction) { +app.use(function (req, res, next) { next(createError(404)); }); From b5436fe7fa054182124ec9a777fff079f1a685f1 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 30 Nov 2022 02:11:30 +0100 Subject: [PATCH 06/14] chore: add comments in update flow status --- packages/backend/src/graphql/mutations/update-flow-status.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend/src/graphql/mutations/update-flow-status.ts b/packages/backend/src/graphql/mutations/update-flow-status.ts index ccc6ac3d..83f05750 100644 --- a/packages/backend/src/graphql/mutations/update-flow-status.ts +++ b/packages/backend/src/graphql/mutations/update-flow-status.ts @@ -41,6 +41,7 @@ const updateFlowStatus = async ( }; if (flow.active) { + // add the flow job in the queue. flow = await flow.$query().patchAndFetch({ published_at: new Date().toISOString(), }); @@ -51,6 +52,7 @@ const updateFlowStatus = async ( jobName, { flowId: flow.id }, { + // do not repeat webhook job for immediate webhook registration repeat: trigger.type === 'webhook' ? null : repeatOptions, jobId: flow.id, removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, @@ -58,6 +60,7 @@ const updateFlowStatus = async ( } ); } else if (!flow.active && trigger.type === 'webhook') { + // unregister webhook from the application const $ = await globalVariable({ flow, connection: await triggerStep.$relatedQuery('connection'), @@ -68,6 +71,7 @@ const updateFlowStatus = async ( await trigger.unregisterHook($); } else { + // remove the job out of the queue const repeatableJobs = await flowQueue.getRepeatableJobs(); const job = repeatableJobs.find((job) => job.id === flow.id); From d2a6c45fd6cc060128a34cd5aed5324d4577b6a1 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 30 Nov 2022 02:11:56 +0100 Subject: [PATCH 07/14] refactor: simplify computed webhook event --- .../apps/typeform/triggers/new-entry/index.ts | 45 +++++-------------- 1 file changed, 11 insertions(+), 34 deletions(-) diff --git a/packages/backend/src/apps/typeform/triggers/new-entry/index.ts b/packages/backend/src/apps/typeform/triggers/new-entry/index.ts index 0c46a7d7..0642775c 100644 --- a/packages/backend/src/apps/typeform/triggers/new-entry/index.ts +++ b/packages/backend/src/apps/typeform/triggers/new-entry/index.ts @@ -1,4 +1,3 @@ -import { IJSONObject } from '@automatisch/types'; import appConfig from '../../../../config/app'; import defineTrigger from '../../../../helpers/define-trigger'; @@ -29,58 +28,36 @@ export default defineTrigger({ ], async testRun($) { - const createApiResponse = await $.http.get( + const { data: form } = await $.http.get( `/forms/${$.step.parameters.formId}` ); - const responsesApiResponse = await $.http.get( + const { data: responses } = await $.http.get( `/forms/${$.step.parameters.formId}/responses` ); - const lastResponse = responsesApiResponse.data.items[0]; + const lastResponse = responses.items[0]; - const computedResponseItem = { + const computedWebhookEvent = { event_type: 'form_response', form_response: { - form_id: $.step.parameters.formId, + form_id: form.id, token: lastResponse.token, landed_at: lastResponse.landed_at, submitted_at: lastResponse.submitted_at, - definion: { + definition: { id: $.step.parameters.formId, - title: createApiResponse.data.title, - fields: createApiResponse.data?.fields?.map((field: IJSONObject) => ({ - id: field.id, - ref: field.ref, - type: field.type, - title: field.title, - properties: {}, - choices: ( - (field?.properties as IJSONObject)?.choices as IJSONObject[] - )?.map((choice) => ({ - id: choice.id, - label: choice.label, - })), - })), + title: form.title, + fields: form?.fields, }, - answers: lastResponse.answers?.map((answer: IJSONObject) => ({ - type: answer.type, - choice: { - label: (answer?.choice as IJSONObject)?.label, - }, - field: { - id: (answer.field as IJSONObject).id, - ref: (answer.field as IJSONObject).ref, - type: (answer.field as IJSONObject).type, - }, - })), + answers: lastResponse.answers, }, }; const dataItem = { - raw: computedResponseItem, + raw: computedWebhookEvent, meta: { - internalId: computedResponseItem.form_response.token, + internalId: computedWebhookEvent.form_response.token, }, }; From 945b777c3cd28ce75cae1510644c9c3543d1bf2f Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 30 Nov 2022 02:12:14 +0100 Subject: [PATCH 08/14] fix(typeform): correct app color --- packages/backend/src/apps/typeform/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/apps/typeform/index.ts b/packages/backend/src/apps/typeform/index.ts index 060589c8..f0f6ffcb 100644 --- a/packages/backend/src/apps/typeform/index.ts +++ b/packages/backend/src/apps/typeform/index.ts @@ -12,7 +12,7 @@ export default defineApp({ supportsConnections: true, baseUrl: 'https://typeform.com', apiBaseUrl: 'https://api.typeform.com', - primaryColor: '000000', + primaryColor: '262627', beforeRequest: [addAuthHeader], auth, triggers, From 2f06cda82bd08d89102d36f3381bf4fee1717084 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 30 Nov 2022 16:57:35 +0100 Subject: [PATCH 09/14] fix: remove circular serialization in GQL errors --- packages/backend/src/helpers/graphql-instance.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/helpers/graphql-instance.ts b/packages/backend/src/helpers/graphql-instance.ts index d65fd83c..dac70079 100644 --- a/packages/backend/src/helpers/graphql-instance.ts +++ b/packages/backend/src/helpers/graphql-instance.ts @@ -1,12 +1,13 @@ -import { graphqlHTTP } from 'express-graphql'; -import logger from '../helpers/logger'; -import { applyMiddleware } from 'graphql-middleware'; -import authentication from '../helpers/authentication'; import { join } from 'path'; +import { graphqlHTTP } from 'express-graphql'; import { loadSchemaSync } from '@graphql-tools/load'; import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; import { addResolversToSchema } from '@graphql-tools/schema'; +import { applyMiddleware } from 'graphql-middleware'; +import logger from '../helpers/logger'; +import authentication from '../helpers/authentication'; import resolvers from '../graphql/resolvers'; +import HttpError from '../errors/http'; const schema = loadSchemaSync(join(__dirname, '../graphql/schema.graphql'), { loaders: [new GraphQLFileLoader()], @@ -23,6 +24,10 @@ const graphQLInstance = graphqlHTTP({ customFormatErrorFn: (error) => { logger.error(error.path + ' : ' + error.message + '\n' + error.stack); + if (error.originalError instanceof HttpError) { + delete (error.originalError as HttpError).response; + } + return error.originalError; }, }); From 2564d7d976c228b17082f8f5d63589a89f8a1cd9 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 30 Nov 2022 19:31:38 +0100 Subject: [PATCH 10/14] fix(typeform/new-entry): early exit when no response --- .../backend/src/apps/typeform/triggers/new-entry/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend/src/apps/typeform/triggers/new-entry/index.ts b/packages/backend/src/apps/typeform/triggers/new-entry/index.ts index 0642775c..5d718439 100644 --- a/packages/backend/src/apps/typeform/triggers/new-entry/index.ts +++ b/packages/backend/src/apps/typeform/triggers/new-entry/index.ts @@ -38,6 +38,10 @@ export default defineTrigger({ const lastResponse = responses.items[0]; + if (!lastResponse) { + return; + } + const computedWebhookEvent = { event_type: 'form_response', form_response: { From 05ce3edb805a45c27c0009fa1ae4d2df7676d806 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 30 Nov 2022 19:32:58 +0100 Subject: [PATCH 11/14] fix(webhook): respond with 404 for non-webhook flows --- packages/backend/src/controllers/webhooks/handler.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/backend/src/controllers/webhooks/handler.ts b/packages/backend/src/controllers/webhooks/handler.ts index f79839f6..17c15339 100644 --- a/packages/backend/src/controllers/webhooks/handler.ts +++ b/packages/backend/src/controllers/webhooks/handler.ts @@ -16,6 +16,12 @@ export default async (request: IRequest, response: Response) => { } const triggerStep = await flow.getTriggerStep(); + const triggerCommand = await triggerStep.getTriggerCommand(); + + if (triggerCommand.type !== 'webhook') { + return response.send(404); + } + const app = await triggerStep.getApp(); if (app.auth.verifyWebhook) { From 77a7df1cff2de9dc013bb2e1158c05ac7a9ce846 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 30 Nov 2022 19:33:30 +0100 Subject: [PATCH 12/14] feat(webhook): process trigger in controller --- .../src/controllers/webhooks/handler.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/controllers/webhooks/handler.ts b/packages/backend/src/controllers/webhooks/handler.ts index 17c15339..72329adc 100644 --- a/packages/backend/src/controllers/webhooks/handler.ts +++ b/packages/backend/src/controllers/webhooks/handler.ts @@ -3,8 +3,10 @@ import bcrypt from 'bcrypt'; import { IRequest, ITriggerItem } from '@automatisch/types'; import Flow from '../../models/flow'; -import triggerQueue from '../../queues/trigger'; +import { processTrigger } from '../../services/trigger'; +import actionQueue from '../../queues/action'; import globalVariable from '../../helpers/global-variable'; +import { REMOVE_AFTER_30_DAYS_OR_150_JOBS, REMOVE_AFTER_7_DAYS_OR_50_JOBS } from '../../helpers/remove-job-configuration'; export default async (request: IRequest, response: Response) => { const flow = await Flow.query() @@ -47,15 +49,27 @@ export default async (request: IRequest, response: Response) => { }, }; - const jobName = `${triggerStep.id}-${triggerItem.meta.internalId}`; - - const jobPayload = { + const { flowId, executionId } = await processTrigger({ flowId: flow.id, stepId: triggerStep.id, triggerItem, + }); + + const nextStep = await triggerStep.getNextStep(); + const jobName = `${executionId}-${nextStep.id}`; + + const jobPayload = { + flowId, + executionId, + stepId: nextStep.id, }; - await triggerQueue.add(jobName, jobPayload); + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + } + + await actionQueue.add(jobName, jobPayload, jobOptions); return response.sendStatus(200); }; From 687c6a09bcd951ccf4c186d4ef8fb8938d0e213b Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 30 Nov 2022 19:33:57 +0100 Subject: [PATCH 13/14] feat(webhook): register in mutation calls --- .../graphql/mutations/update-flow-status.ts | 54 ++++++++++--------- packages/backend/src/services/flow.ts | 2 - 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/backend/src/graphql/mutations/update-flow-status.ts b/packages/backend/src/graphql/mutations/update-flow-status.ts index 83f05750..1cb80d5a 100644 --- a/packages/backend/src/graphql/mutations/update-flow-status.ts +++ b/packages/backend/src/graphql/mutations/update-flow-status.ts @@ -40,27 +40,7 @@ const updateFlowStatus = async ( pattern: interval || EVERY_15_MINUTES_CRON, }; - if (flow.active) { - // add the flow job in the queue. - flow = await flow.$query().patchAndFetch({ - published_at: new Date().toISOString(), - }); - - const jobName = `${JOB_NAME}-${flow.id}`; - - await flowQueue.add( - jobName, - { flowId: flow.id }, - { - // do not repeat webhook job for immediate webhook registration - repeat: trigger.type === 'webhook' ? null : repeatOptions, - jobId: flow.id, - removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, - removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS - } - ); - } else if (!flow.active && trigger.type === 'webhook') { - // unregister webhook from the application + if (trigger.type === 'webhook') { const $ = await globalVariable({ flow, connection: await triggerStep.$relatedQuery('connection'), @@ -69,13 +49,35 @@ const updateFlowStatus = async ( testRun: false, }); - await trigger.unregisterHook($); + if (flow.active) { + await trigger.registerHook($); + } else { + await trigger.unregisterHook($); + } } else { - // remove the job out of the queue - const repeatableJobs = await flowQueue.getRepeatableJobs(); - const job = repeatableJobs.find((job) => job.id === flow.id); + if (flow.active) { + flow = await flow.$query().patchAndFetch({ + published_at: new Date().toISOString(), + }); - await flowQueue.removeRepeatableByKey(job.key); + const jobName = `${JOB_NAME}-${flow.id}`; + + await flowQueue.add( + jobName, + { flowId: flow.id }, + { + repeat: repeatOptions, + jobId: flow.id, + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS + } + ); + } else { + const repeatableJobs = await flowQueue.getRepeatableJobs(); + const job = repeatableJobs.find((job) => job.id === flow.id); + + await flowQueue.removeRepeatableByKey(job.key); + } } return flow; diff --git a/packages/backend/src/services/flow.ts b/packages/backend/src/services/flow.ts index d76d9be9..862066f7 100644 --- a/packages/backend/src/services/flow.ts +++ b/packages/backend/src/services/flow.ts @@ -25,8 +25,6 @@ export const processFlow = async (options: ProcessFlowOptions) => { try { if (triggerCommand.type === 'webhook' && !flow.active) { await triggerCommand.testRun($); - } else if (triggerCommand.type === 'webhook' && flow.active) { - await triggerCommand.registerHook($); } else { await triggerCommand.run($); } From bf62ebd20a9c5a58059bc6804ef614ffc9e74673 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 30 Nov 2022 19:38:57 +0100 Subject: [PATCH 14/14] chore(typeform): add empty type definition file --- packages/backend/src/apps/typeform/index.d.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/backend/src/apps/typeform/index.d.ts diff --git a/packages/backend/src/apps/typeform/index.d.ts b/packages/backend/src/apps/typeform/index.d.ts new file mode 100644 index 00000000..e69de29b