From d57d45714836a5688a3fc9f1f28b3e90a401d264 Mon Sep 17 00:00:00 2001 From: ricardo Date: Sun, 26 Apr 2020 12:51:20 -0500 Subject: [PATCH] :sparkles: SurveyMonkey trigger --- packages/cli/src/Server.ts | 43 +- .../SurveyMonkeyApi.credentials.ts | 35 ++ .../nodes/SurveyMonkey/GenericFunctions.ts | 82 ++++ .../SurveyMonkey/SurveyMonkeyTrigger.node.ts | 433 ++++++++++++++++++ .../nodes/SurveyMonkey/surveyMonkey.png | Bin 0 -> 3327 bytes packages/nodes-base/package.json | 2 + packages/workflow/src/Interfaces.ts | 2 +- 7 files changed, 593 insertions(+), 4 deletions(-) create mode 100644 packages/nodes-base/credentials/SurveyMonkeyApi.credentials.ts create mode 100644 packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts create mode 100644 packages/nodes-base/nodes/SurveyMonkey/surveyMonkey.png diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index a98def2cbe..b6a07aab44 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1083,7 +1083,6 @@ class App { return returnData; })); - // Forces the execution to stop this.app.post('/rest/executions-current/:id/stop', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const executionId = req.params.id; @@ -1151,6 +1150,26 @@ class App { // Webhooks // ---------------------------------------- + // HEAD webhook requests + this.app.head(`/${this.endpointWebhook}/*`, async (req: express.Request, res: express.Response) => { + // Cut away the "/webhook/" to get the registred part of the url + const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(this.endpointWebhook.length + 2); + + let response; + try { + response = await this.activeWorkflowRunner.executeWebhook('HEAD', requestUrl, req, res); + } catch (error) { + ResponseHelper.sendErrorResponse(res, error); + return; + } + + if (response.noWebhookResponse === true) { + // Nothing else to do as the response got already sent + return; + } + + ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); + }); // GET webhook requests this.app.get(`/${this.endpointWebhook}/*`, async (req: express.Request, res: express.Response) => { @@ -1173,7 +1192,6 @@ class App { ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); }); - // POST webhook requests this.app.post(`/${this.endpointWebhook}/*`, async (req: express.Request, res: express.Response) => { // Cut away the "/webhook/" to get the registred part of the url @@ -1195,6 +1213,26 @@ class App { ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); }); + // HEAD webhook requests (test for UI) + this.app.head(`/${this.endpointWebhookTest}/*`, async (req: express.Request, res: express.Response) => { + // Cut away the "/webhook-test/" to get the registred part of the url + const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(this.endpointWebhookTest.length + 2); + + let response; + try { + response = await this.testWebhooks.callTestWebhook('HEAD', requestUrl, req, res); + } catch (error) { + ResponseHelper.sendErrorResponse(res, error); + return; + } + + if (response.noWebhookResponse === true) { + // Nothing else to do as the response got already sent + return; + } + + ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); + }); // GET webhook requests (test for UI) this.app.get(`/${this.endpointWebhookTest}/*`, async (req: express.Request, res: express.Response) => { @@ -1217,7 +1255,6 @@ class App { ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); }); - // POST webhook requests (test for UI) this.app.post(`/${this.endpointWebhookTest}/*`, async (req: express.Request, res: express.Response) => { // Cut away the "/webhook-test/" to get the registred part of the url diff --git a/packages/nodes-base/credentials/SurveyMonkeyApi.credentials.ts b/packages/nodes-base/credentials/SurveyMonkeyApi.credentials.ts new file mode 100644 index 0000000000..130690d423 --- /dev/null +++ b/packages/nodes-base/credentials/SurveyMonkeyApi.credentials.ts @@ -0,0 +1,35 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class SurveyMonkeyApi implements ICredentialType { + name = 'surveyMonkeyApi'; + displayName = 'SurveyMonkey API'; + properties = [ + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '', + description: `The access token must have the following scopes:
+ - Create/modify webhooks
+ - View webhooks
+ - View surveys
+ - View collectors
+ - View response details`, + }, + { + displayName: 'Client ID', + name: 'clientId', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Client Secret', + name: 'clientSecret', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts b/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts new file mode 100644 index 0000000000..2d2cf7995d --- /dev/null +++ b/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts @@ -0,0 +1,82 @@ +import { + OptionsWithUri, + } from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + IHookFunctions, + IWebhookFunctions +} from 'n8n-workflow'; + +export async function surveyMonkeyApiRequest(this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const credentials = this.getCredentials('surveyMonkeyApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const endpoint = 'https://api.surveymonkey.com/v3'; + + let options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `bearer ${credentials.accessToken}`, + }, + method, + body, + qs: query, + uri: uri || `${endpoint}${resource}`, + json: true + }; + if (!Object.keys(body).length) { + delete options.body; + } + if (!Object.keys(query).length) { + delete options.qs; + } + options = Object.assign({}, options, option); + try { + return await this.helpers.request!(options); + } catch (error) { + const errorMessage = error.response.body.error.message; + if (errorMessage !== undefined) { + throw new Error(`SurveyMonkey error response [${error.statusCode}]: ${errorMessage}`); + } + throw error; + } +} + +export async function surveyMonkeyRequestAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + query.page = 1; + query.per_page = 100; + let uri: string | undefined; + + do { + responseData = await surveyMonkeyApiRequest.call(this, method, endpoint, body, query, uri); + uri = responseData.links.next; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + responseData.links.next + ); + + return returnData; +} + +export function idsExist(ids: string[], surveyIds: string[]) { + for (const surveyId of surveyIds) { + if (!ids.includes(surveyId)) { + return false; + } + } + return true; +} diff --git a/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts b/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts new file mode 100644 index 0000000000..51d101697a --- /dev/null +++ b/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts @@ -0,0 +1,433 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + idsExist, + surveyMonkeyApiRequest, + surveyMonkeyRequestAllItems, +} from './GenericFunctions'; + +import { + createHmac, + } from 'crypto'; + +export class SurveyMonkeyTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'SurveyMonkey Trigger', + name: 'surveyMonkeyTrigger', + icon: 'file:surveyMonkey.png', + group: ['trigger'], + version: 1, + description: 'Starts the workflow when Survey Monkey events occure.', + defaults: { + name: 'SurveyMonkey Trigger', + color: '#53b675', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'surveyMonkeyApi', + required: true, + }, + ], + webhooks: [ + { + name: 'setup', + httpMethod: 'HEAD', + responseMode: 'onReceived', + path: 'webhook', + }, + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Event', + name: 'event', + type: 'options', + options: [ + { + name: 'Collector Created', + value: 'collector_created', + description: 'A collector is created', + }, + { + name: 'Collector Updated', + value: 'collector_updated', + description: 'A collector is updated', + }, + { + name: 'Collector Deleted', + value: 'collector_deleted', + description: 'A collector is deleted', + }, + { + name: 'Response Completed', + value: 'response_completed', + description: 'A survey response is completed', + }, + { + name: 'Response Created', + value: 'response_created', + description: 'A respondent begins a survey', + }, + { + name: 'Response Deleted', + value: 'response_deleted', + description: 'A response is deleted', + }, + { + name: 'Response Desqualified', + value: 'response_desqualified', + description: 'A survey response is disqualified ', + }, + { + name: 'Response Overquota', + value: 'response_overquota', + description: `A response is over a survey’s quota`, + }, + { + name: 'Response Updated', + value: 'response_updated', + description: 'A survey response is updated', + }, + { + name: 'Survey Created', + value: 'survey_created', + description: 'A survey is created', + }, + { + name: 'Survey Deleted', + value: 'survey_deleted', + description: 'A survey is deleted', + }, + { + name: 'Survey Updated', + value: 'survey_updated', + description: 'A survey is updated', + }, + ], + default: '', + required: true, + }, + { + displayName: 'Type', + name: 'objectType', + type: 'options', + options: [ + { + name: 'Collector', + value: 'collector', + }, + { + name: 'Survey', + value: 'survey', + }, + ], + default: '', + required: true, + }, + { + displayName: 'Survey IDs', + name: 'surveyIds', + type: 'multiOptions', + displayOptions: { + show: { + objectType: [ + 'survey', + ], + }, + hide: { + event: [ + 'survey_created', + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getSurveys', + }, + options: [], + default: [], + required: true, + }, + { + displayName: 'Survey ID', + name: 'surveyId', + type: 'options', + displayOptions: { + show: { + objectType: [ + 'collector', + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getSurveys', + }, + default: [], + required: true, + }, + { + displayName: 'Collector IDs', + name: 'collectorIds', + type: 'multiOptions', + displayOptions: { + show: { + objectType: [ + 'collector', + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getCollectors', + loadOptionsDependsOn: [ + 'surveyId', + ], + }, + options: [], + default: [], + required: true, + }, + { + displayName: 'Resolve Data', + name: 'resolveData', + type: 'boolean', + displayOptions: { + show: { + event: [ + 'response_completed', + ], + }, + }, + default: true, + description: 'By default the webhook-data only contain the IDs. If this option gets activated it
will resolve the data automatically.', + }, + ], + }; + + methods = { + loadOptions: { + // Get all the survey's collectors to display them to user so that he can + // select them easily + async getCollectors(this: ILoadOptionsFunctions): Promise { + const surveyId = this.getCurrentNodeParameter('surveyId'); + const returnData: INodePropertyOptions[] = []; + const collectors = await surveyMonkeyRequestAllItems.call(this, 'data', 'GET', `/surveys/${surveyId}/collectors`); + for (const collector of collectors) { + const collectorName = collector.name; + const collectorId = collector.id; + returnData.push({ + name: collectorName, + value: collectorId, + }); + } + return returnData; + }, + + // Get all the surveys to display them to user so that he can + // select them easily + async getSurveys(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const surveys = await surveyMonkeyRequestAllItems.call(this, 'data', 'GET', '/surveys'); + for (const survey of surveys) { + const surveyName = survey.title; + const surveyId = survey.id; + returnData.push({ + name: surveyName, + value: surveyId, + }); + } + return returnData; + }, + }, + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const objectType = this.getNodeParameter('objectType') as string; + const event = this.getNodeParameter('event') as string; + // Check all the webhooks which exist already if it is identical to the + // one that is supposed to get created. + const endpoint = '/webhooks'; + + const responseData = await surveyMonkeyRequestAllItems.call(this, 'data', 'GET', endpoint, {}); + + const webhookUrl = this.getNodeWebhookUrl('default'); + + const ids: string[] = []; + + if (objectType === 'survey') { + const surveyIds = this.getNodeParameter('surveyIds') as string[]; + ids.push.apply(ids, surveyIds); + } else if (objectType === 'collector') { + const collectorIds = this.getNodeParameter('collectorIds') as string[]; + ids.push.apply(ids, collectorIds); + } + + for (const webhook of responseData) { + const webhookDetails = await surveyMonkeyApiRequest.call(this, 'GET', `/webhooks/${webhook.id}`); + if (webhookDetails.subscription_url === webhookUrl + && idsExist(webhookDetails.object_ids as string[], ids as string[]) + && webhookDetails.event_type === event) { + // Set webhook-id to be sure that it can be deleted + const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = webhook.id as string; + return true; + } + } + + return false; + }, + + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + const event = this.getNodeParameter('event') as string; + const objectType = this.getNodeParameter('objectType') as string; + const endpoint = '/webhooks'; + const ids: string[] = []; + + if (objectType === 'survey') { + const surveyIds = this.getNodeParameter('surveyIds') as string[]; + ids.push.apply(ids, surveyIds); + } else if (objectType === 'collector') { + const collectorIds = this.getNodeParameter('collectorIds') as string[]; + ids.push.apply(ids, collectorIds); + } + + const body: IDataObject = { + name: `n8n - Webhook [${event}]`, + object_type: objectType, + object_ids: ids, + subscription_url: webhookUrl, + event_type: event, + }; + + if (objectType === 'collector' && event === 'collector_created') { + throw new Error('Type collector cannot be used with collector created event'); + } + + if (objectType === 'survey' && event === 'survey_created') { + delete body.object_type; + } + + let responseData: IDataObject = {}; + + responseData = await surveyMonkeyApiRequest.call(this, 'POST', endpoint, body); + + if (responseData.id === undefined) { + // Required data is missing so was not successful + return false; + } + + const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = responseData.id as string; + + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId !== undefined) { + + const endpoint = `/webhooks/${webhookData.webhookId}`; + + try { + await surveyMonkeyApiRequest.call(this, 'DELETE', endpoint); + } catch (e) { + return false; + } + + // Remove from the static workflow data so that it is clear + // that no webhooks are registred anymore + delete webhookData.webhookId; + } + + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const event = this.getNodeParameter('event') as string; + const objectType = this.getNodeParameter('objectType') as string; + const credentials = this.getCredentials('surveyMonkeyApi') as IDataObject; + const headerData = this.getHeaderData() as IDataObject; + const req = this.getRequestObject(); + const webhookName = this.getWebhookName(); + + if (webhookName === 'setup') { + // It is a create webhook confirmation request + return {}; + } + + if (headerData['sm-signature'] === undefined) { + return {}; + } + + return new Promise((resolve, reject) => { + const data: Buffer[] = []; + + req.on('data', (chunk) => { + data.push(chunk); + }); + + req.on('end', async () => { + + const computedSignature = createHmac('sha1', `${credentials.clientId}&${credentials.clientSecret}`).update(data.join('')).digest('base64'); + if (headerData['sm-signature'] !== computedSignature) { + // Signature is not valid so ignore call + return {}; + } + + let responseData = JSON.parse(data.join('')); + let endpoint = ''; + + if (event === 'response_completed') { + const resolveData = this.getNodeParameter('resolveData') as boolean; + if (resolveData) { + if (objectType === 'survey') { + endpoint = `/surveys/${responseData.resources.survey_id}/responses/${responseData.object_id}/details`; + } else { + endpoint = `/collectors/${responseData.resources.collector_id}/responses/${responseData.object_id}/details`; + } + responseData = await surveyMonkeyApiRequest.call(this, 'GET', endpoint); + } + } + + const returnItem: INodeExecutionData = { + json: responseData, + }; + + return resolve({ + workflowData: [ + [ + returnItem, + ], + ], + }); + }); + + req.on('error', (err) => { + throw new Error(err.message); + }); + }); + } +} diff --git a/packages/nodes-base/nodes/SurveyMonkey/surveyMonkey.png b/packages/nodes-base/nodes/SurveyMonkey/surveyMonkey.png new file mode 100644 index 0000000000000000000000000000000000000000..80e09c3afeba2230fbd4ce7364820bb5811c7cfd GIT binary patch literal 3327 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 ow4Hb{6MGU;`FwUW=l;^s9G6arP3(`x