import { IHookFunctions, IWebhookFunctions } from 'n8n-core'; import { deepCopy, IDataObject, ILoadOptionsFunctions, INodeExecutionData, INodePropertyOptions, INodeType, INodeTypeDescription, IWebhookResponseData, jsonParse, NodeOperationError, } from 'n8n-workflow'; import { idsExist, surveyMonkeyApiRequest, surveyMonkeyRequestAllItems } from './GenericFunctions'; import { IAnswer, IChoice, IOther, IQuestion, IRow } from './Interfaces'; import { createHmac } from 'crypto'; export class SurveyMonkeyTrigger implements INodeType { description: INodeTypeDescription = { displayName: 'SurveyMonkey Trigger', name: 'surveyMonkeyTrigger', icon: 'file:surveyMonkey.svg', group: ['trigger'], version: 1, description: 'Starts the workflow when Survey Monkey events occur', defaults: { name: 'SurveyMonkey Trigger', }, inputs: [], outputs: ['main'], credentials: [ { name: 'surveyMonkeyApi', required: true, displayOptions: { show: { authentication: ['accessToken'], }, }, }, { name: 'surveyMonkeyOAuth2Api', required: true, displayOptions: { show: { authentication: ['oAuth2'], }, }, }, ], webhooks: [ { name: 'setup', httpMethod: 'HEAD', responseMode: 'onReceived', path: 'webhook', }, { name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook', }, ], properties: [ { displayName: 'Authentication', name: 'authentication', type: 'options', options: [ { name: 'Access Token', value: 'accessToken', }, { name: 'OAuth2', value: 'oAuth2', }, ], default: 'accessToken', }, { displayName: 'Type', name: 'objectType', type: 'options', options: [ { name: 'Collector', value: 'collector', }, { name: 'Survey', value: 'survey', }, ], default: '', required: true, }, { displayName: 'Event', name: 'event', displayOptions: { show: { objectType: ['survey'], }, }, type: 'options', options: [ { name: 'Collector Created', value: 'collector_created', description: 'A collector is created', }, { name: 'Collector Deleted', value: 'collector_deleted', description: 'A collector is deleted', }, { name: 'Collector Updated', value: 'collector_updated', description: 'A collector is updated', }, { 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 Disqualified', value: 'response_disqualified', 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: 'Event', name: 'event', type: 'options', displayOptions: { show: { objectType: ['collector'], }, }, options: [ { name: 'Collector Deleted', value: 'collector_deleted', description: 'A collector is deleted', }, { name: 'Collector Updated', value: 'collector_updated', description: 'A collector is updated', }, { 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 Disqualified', value: 'response_disqualified', 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', }, ], default: '', required: true, }, { displayName: 'Survey Names or IDs', name: 'surveyIds', type: 'multiOptions', description: 'Choose from the list, or specify IDs using an expression', displayOptions: { show: { objectType: ['survey'], }, hide: { event: ['survey_created'], }, }, typeOptions: { loadOptionsMethod: 'getSurveys', }, options: [], default: [], required: true, }, { displayName: 'Survey Name or ID', name: 'surveyId', type: 'options', description: 'Choose from the list, or specify an ID using an expression', displayOptions: { show: { objectType: ['collector'], }, }, typeOptions: { loadOptionsMethod: 'getSurveys', }, default: [], required: true, }, { displayName: 'Collector Names or IDs', name: 'collectorIds', type: 'multiOptions', description: 'Choose from the list, or specify IDs using an expression', 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, // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether description: 'By default the webhook-data only contain the IDs. If this option gets activated, it will resolve the data automatically.', }, { displayName: 'Only Answers', name: 'onlyAnswers', displayOptions: { show: { resolveData: [true], event: ['response_completed'], }, }, type: 'boolean', default: true, description: 'Whether to return only the answers of the form and not any of the other data', }, ], }; 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' && event !== 'survey_created') { 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) && 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' && event !== 'survey_created') { 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 === 'survey' && event === 'survey_created') { delete body.object_type; delete body.object_ids; } 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 (error) { 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 authenticationMethod = this.getNodeParameter('authentication') as string; let credentials: IDataObject; const headerData = this.getHeaderData() as IDataObject; const req = this.getRequestObject(); const webhookName = this.getWebhookName(); if (authenticationMethod === 'accessToken') { credentials = await this.getCredentials('surveyMonkeyApi'); } else { credentials = await this.getCredentials('surveyMonkeyOAuth2Api'); } 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 = jsonParse(data.join('')); let endpoint = ''; let returnItem: INodeExecutionData[] = [ { json: responseData, }, ]; 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 surveyId = responseData.survey_id; const questions: IQuestion[] = []; const answers = new Map(); const { pages } = await surveyMonkeyApiRequest.call( this, 'GET', `/surveys/${surveyId}/details`, ); for (const page of pages) { questions.push.apply(questions, page.questions); } for (const page of responseData.pages as IDataObject[]) { for (const question of page.questions as IDataObject[]) { answers.set(question.id as string, question.answers as IAnswer[]); } } const responseQuestions = new Map(); for (const question of questions) { /* TODO: add support for premium components - File Upload - Matrix of dropdowm menus */ // if question does not have an answer ignore it if (!answers.get(question.id)) { continue; } const heading = question.headings![0].heading as string; if (question.family === 'open_ended' || question.family === 'datetime') { if (question.subtype !== 'multi') { responseQuestions.set(heading, answers.get(question.id)![0].text as string); } else { const results: IDataObject = {}; const keys = (question.answers.rows as IRow[]).map((e) => e.text); const values = answers.get(question.id)?.map((e) => e.text) as string[]; for (let i = 0; i < keys.length; i++) { // if for some reason there are questions texts repeted add the index to the key if (results[keys[i]] !== undefined) { results[`${keys[i]}(${i})`] = values[i] || ''; } else { results[keys[i]] = values[i] || ''; } } responseQuestions.set(heading, results); } } if (question.family === 'single_choice') { const other = question.answers.other as IOther; if ( other?.visible && other.is_answer_choice && answers.get(question.id)![0].other_id ) { responseQuestions.set(heading, answers.get(question.id)![0].text as string); } else if (other?.visible && !other.is_answer_choice) { const choiceId = answers.get(question.id)![0].choice_id; const choice = (question.answers.choices as IChoice[]).filter( (e) => e.id === choiceId, )[0]; const comment = answers.get(question.id)?.find((e) => e.other_id === other.id) ?.text as string; responseQuestions.set(heading, { value: choice.text, comment }); } else { const choiceId = answers.get(question.id)![0].choice_id; const choice = (question.answers.choices as IChoice[]).filter( (e) => e.id === choiceId, )[0]; responseQuestions.set(heading, choice.text); } } if (question.family === 'multiple_choice') { const other = question.answers.other as IOther; const choiceIds = answers.get(question.id)?.map((e) => e.choice_id); const value = (question.answers.choices as IChoice[]) .filter((e) => choiceIds?.includes(e.id)) .map((e) => e.text); // if "Add an "Other" Answer Option for Comments" is active and was selected if (other?.is_answer_choice && other.visible) { const text = answers.get(question.id)?.find((e) => e.other_id === other.id) ?.text as string; value.push(text); } responseQuestions.set(heading, value); } if (question.family === 'matrix') { // if more than one row it's a matrix/rating-scale const rows = question.answers.rows as IRow[]; if (rows.length > 1) { const results: IDataObject = {}; const choiceIds = answers.get(question.id)?.map((e) => e.choice_id) as string[]; const rowIds = answers.get(question.id)?.map((e) => e.row_id) as string[]; const rowsValues = (question.answers.rows as IRow[]) .filter((e) => rowIds.includes(e.id)) .map((e) => e.text); const choicesValues = (question.answers.choices as IChoice[]) .filter((e) => choiceIds.includes(e.id)) .map((e) => e.text); for (let i = 0; i < rowsValues.length; i++) { results[rowsValues[i]] = choicesValues[i] || ''; } // add the rows that were not answered for (const row of question.answers.rows as IDataObject[]) { if (!rowIds.includes(row.id as string)) { results[row.text as string] = ''; } } // the comment then add the comment const other = question.answers.other as IOther; if (other?.visible) { results.comment = answers.get(question.id)?.filter((e) => e.other_id)[0].text; } responseQuestions.set(heading, results); } else { const choiceIds = answers.get(question.id)?.map((e) => e.choice_id); const value = (question.answers.choices as IChoice[]) .filter((e) => choiceIds!.includes(e.id)) .map((e) => (e.text === '' ? e.weight : e.text))[0]; responseQuestions.set(heading, value); // if "Add an Other Answer Option for Comments" is active then add comment to the answer const other = question.answers.other as IOther; if (other?.visible) { const response: IDataObject = {}; //const questionName = (question.answers.other as IOther).text as string; const text = answers.get(question.id)?.filter((e) => e.other_id)[0].text; response.value = value; response.comment = text; responseQuestions.set(heading, response); } } } if (question.family === 'demographic') { const rows: IDataObject = {}; for (const row of answers.get(question.id) as IAnswer[]) { rows[row.row_id as string] = row.text; } const addressInfo: IDataObject = {}; for (const answer of question.answers.rows as IDataObject[]) { addressInfo[answer.type as string] = rows[answer.id as string] ?? ''; } responseQuestions.set(heading, addressInfo); } if (question.family === 'presentation') { if (question.subtype === 'image') { const { url } = question.headings![0].image as IDataObject; responseQuestions.set(heading, url as string); } } } delete responseData.pages; responseData.questions = {}; // Map the "Map" to JSON const tuples = deepCopy([...responseQuestions]); for (const [key, value] of tuples) { responseData.questions[key] = value; } const onlyAnswers = this.getNodeParameter('onlyAnswers') as boolean; if (onlyAnswers) { responseData = responseData.questions; } returnItem = [ { json: responseData, }, ]; } } return resolve({ workflowData: [returnItem], }); }); req.on('error', (error) => { throw new NodeOperationError(this.getNode(), error); }); }); } }