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..a42a1009 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,8 +1,7 @@ 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 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,15 +24,21 @@ 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); // 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)); }); 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..0aa070f2 --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/index.ts @@ -0,0 +1,50 @@ +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: [ + { + 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, + verifyWebhook, +}; 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/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/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/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.d.ts b/packages/backend/src/apps/typeform/index.d.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/backend/src/apps/typeform/index.ts b/packages/backend/src/apps/typeform/index.ts new file mode 100644 index 00000000..f0f6ffcb --- /dev/null +++ b/packages/backend/src/apps/typeform/index.ts @@ -0,0 +1,20 @@ +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', + 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: '262627', + 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..5d718439 --- /dev/null +++ b/packages/backend/src/apps/typeform/triggers/new-entry/index.ts @@ -0,0 +1,89 @@ +import appConfig from '../../../../config/app'; +import defineTrigger from '../../../../helpers/define-trigger'; + +export default defineTrigger({ + name: 'New entry', + key: 'newEntry', + type: 'webhook', + description: 'Triggers when a new form submitted.', + arguments: [ + { + label: 'Form', + key: 'formId', + 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 testRun($) { + const { data: form } = await $.http.get( + `/forms/${$.step.parameters.formId}` + ); + + const { data: responses } = await $.http.get( + `/forms/${$.step.parameters.formId}/responses` + ); + + const lastResponse = responses.items[0]; + + if (!lastResponse) { + return; + } + + const computedWebhookEvent = { + event_type: 'form_response', + form_response: { + form_id: form.id, + token: lastResponse.token, + landed_at: lastResponse.landed_at, + submitted_at: lastResponse.submitted_at, + definition: { + id: $.step.parameters.formId, + title: form.title, + fields: form?.fields, + }, + answers: lastResponse.answers, + }, + }; + + const dataItem = { + raw: computedWebhookEvent, + meta: { + internalId: computedWebhookEvent.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/handler.ts b/packages/backend/src/controllers/webhooks/handler.ts new file mode 100644 index 00000000..72329adc --- /dev/null +++ b/packages/backend/src/controllers/webhooks/handler.ts @@ -0,0 +1,75 @@ +import { Response } from 'express'; +import bcrypt from 'bcrypt'; +import { IRequest, ITriggerItem } from '@automatisch/types'; + +import Flow from '../../models/flow'; +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() + .findById(request.params.flowId) + .throwIfNotFound(); + + if (!flow.active) { + return response.send(404); + } + + 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) { + const $ = await globalVariable({ + flow, + connection: await triggerStep.$relatedQuery('connection'), + app, + step: triggerStep, + request, + }); + + const verified = await app.auth.verifyWebhook($); + + if (!verified) { + return response.sendStatus(401); + } + } + + const triggerItem: ITriggerItem = { + raw: request.body, + meta: { + internalId: await bcrypt.hash(request.rawBody, 1), + }, + }; + + 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, + }; + + 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); +}; diff --git a/packages/backend/src/graphql/mutations/update-flow-status.ts b/packages/backend/src/graphql/mutations/update-flow-status.ts index 10e2f9f5..1cb80d5a 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: { @@ -39,28 +40,44 @@ const updateFlowStatus = async ( pattern: interval || EVERY_15_MINUTES_CRON, }; - if (flow.active) { - flow = await flow.$query().patchAndFetch({ - published_at: new Date().toISOString(), + if (trigger.type === 'webhook') { + const $ = await globalVariable({ + flow, + connection: await triggerStep.$relatedQuery('connection'), + app: await triggerStep.getApp(), + step: triggerStep, + testRun: false, }); - 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 - } - ); + if (flow.active) { + await trigger.registerHook($); + } else { + await trigger.unregisterHook($); + } } else { - 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/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/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; }, }); 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..c26f4add --- /dev/null +++ b/packages/backend/src/routes/webhooks.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import webhookHandler from '../controllers/webhooks/handler'; + +const router = Router(); + +router.post('/:flowId', webhookHandler); + +export default router; diff --git a/packages/backend/src/services/flow.ts b/packages/backend/src/services/flow.ts index f6a256fe..862066f7 100644 --- a/packages/backend/src/services/flow.ts +++ b/packages/backend/src/services/flow.ts @@ -23,7 +23,11 @@ export const processFlow = async (options: ProcessFlowOptions) => { }); try { - await triggerCommand.run($); + if (triggerCommand.type === 'webhook' && !flow.active) { + await triggerCommand.testRun($); + } 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; +} +