diff --git a/packages/nodes-base/credentials/TypeformApi.credentials.ts b/packages/nodes-base/credentials/TypeformApi.credentials.ts new file mode 100644 index 0000000000..2c0136e121 --- /dev/null +++ b/packages/nodes-base/credentials/TypeformApi.credentials.ts @@ -0,0 +1,18 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class TypeformApi implements ICredentialType { + name = 'typeformApi'; + displayName = 'Typeform API'; + properties = [ + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Typeform/GenericFunctions.ts b/packages/nodes-base/nodes/Typeform/GenericFunctions.ts new file mode 100644 index 0000000000..f202a18a64 --- /dev/null +++ b/packages/nodes-base/nodes/Typeform/GenericFunctions.ts @@ -0,0 +1,80 @@ +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { OptionsWithUri } from 'request'; +import { IDataObject } from 'n8n-workflow'; + + +// Interface in Typeform +export interface ITypeformDefinition { + fields: ITypeformDefinitionField[]; +} + +export interface ITypeformDefinitionField { + id: string; + title: string; +} + +export interface ITypeformAnswer { + field: ITypeformAnswerField; + type: string; + [key: string]: string | ITypeformAnswerField | object; +} + +export interface ITypeformAnswerField { + id: string; + type: string; + ref: string; + [key: string]: string | object; +} + +/** + * Make an API request to Typeform + * + * @param {IHookFunctions} this + * @param {string} method + * @param {string} url + * @param {object} body + * @returns {Promise} + */ +export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, query?: IDataObject): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('typeformApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + query = query || {}; + + const options: OptionsWithUri = { + headers: { + 'Authorization': `bearer ${credentials.accessToken}`, + }, + method, + body, + qs: query, + uri: `https://api.typeform.com/${endpoint}`, + json: true, + }; + + try { + return await this.helpers.request!(options); + } catch (error) { + if (error.statusCode === 401) { + // Return a clear error + throw new Error('The Typeform credentials are not valid!'); + } + + if (error.response && error.response.body && error.response.body.description) { + // Try to return the error prettier + const errorBody = error.response.body; + throw new Error(`Typeform error response [${error.statusCode} - errorBody.code]: ${errorBody.description}`); + } + + // Expected error data did not get returned so throw the actual error + throw error; + } +} diff --git a/packages/nodes-base/nodes/Typeform/TypeformTrigger.node.ts b/packages/nodes-base/nodes/Typeform/TypeformTrigger.node.ts new file mode 100644 index 0000000000..6b7506247c --- /dev/null +++ b/packages/nodes-base/nodes/Typeform/TypeformTrigger.node.ts @@ -0,0 +1,228 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + INodeTypeDescription, + INodeType, + IWebhookResonseData, + IDataObject, +} from 'n8n-workflow'; + +import { + apiRequest, + ITypeformAnswer, + ITypeformAnswerField, + ITypeformDefinition, +} from './GenericFunctions'; + + +export class TypeformTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Typeform Trigger', + name: 'typeformTrigger', + icon: 'file:typeform.png', + group: ['trigger'], + version: 1, + subtitle: '=Form ID: {{$parameter["formId"]}}', + description: 'Starts the workflow on a Typeform form submission.', + defaults: { + name: 'Typeform Trigger', + color: '#404040', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'typeformApi', + required: true, + } + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Form ID', + name: 'formId', + type: 'string', + default: '', + required: true, + placeholder: 'OUiTe2', + description: 'Unique ID of the form.', + }, + { + displayName: 'Simplify Answers', + name: 'simplifyAnswers', + type: 'boolean', + default: true, + description: 'Converts the answers to a key:value pair ("FIELD_TITLE":"USER_ANSER") to be easily processable.', + }, + { + displayName: 'Only Answers', + name: 'onlyAnswers', + type: 'boolean', + default: true, + description: 'Returns only the answers of the form and not any of the other data.', + }, + ], + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + + if (webhookData.webhookId === undefined) { + // No webhook id is set so no webhook can exist + return false; + } + + const formId = this.getNodeParameter('formId') as string; + + const endpoint = `forms/${formId}/webhooks/${webhookData.webhookId}`; + + try { + const body = {}; + await apiRequest.call(this, 'POST', endpoint, body); + } catch (e) { + return false; + } + + return true; + }, + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + + const formId = this.getNodeParameter('formId') as string; + const webhookId = 'n8n-' + Math.random().toString(36).substring(2, 15); + + const endpoint = `forms/${formId}/webhooks/${webhookId}`; + + // TODO: Add HMAC-validation once either the JSON data can be used for it or there is a way to access the binary-payload-data + const body = { + url: webhookUrl, + enabled: true, + verify_ssl: true, + }; + + await apiRequest.call(this, 'PUT', endpoint, body); + + const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = webhookId; + + return true; + }, + async delete(this: IHookFunctions): Promise { + const formId = this.getNodeParameter('formId') as string; + + const webhookData = this.getWorkflowStaticData('node'); + + if (webhookData.webhookId !== undefined) { + const endpoint = `forms/${formId}/webhooks/${webhookData.webhookId}`; + + + try { + const body = {}; + await apiRequest.call(this, 'DELETE', endpoint, body); + } 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 bodyData = this.getBodyData(); + + const simplifyAnswers = this.getNodeParameter('simplifyAnswers') as boolean; + const onlyAnswers = this.getNodeParameter('onlyAnswers') as boolean; + + if (bodyData.form_response === undefined || + (bodyData.form_response as IDataObject).definition === undefined || + (bodyData.form_response as IDataObject).answers === undefined + ) { + throw new Error('Expected definition/answers data is missing!'); + } + + const answers = (bodyData.form_response as IDataObject).answers as ITypeformAnswer[]; + + // Some fields contain lower level fields of which we are only interested of the values + const subvalueKeys = [ + 'label', + 'labels', + ]; + + if (simplifyAnswers === true) { + // Convert the answers to simple key -> value pairs + const definition = (bodyData.form_response as IDataObject).definition as ITypeformDefinition; + + // Create a dictionary to get the field title by its ID + const defintitionsById: { [key: string]: string; } = {}; + for (const field of definition.fields) { + defintitionsById[field.id] = field.title; + } + + // Convert the answers to key -> value pair + const convertedAnswers: IDataObject = {}; + for (const answer of answers) { + let value = answer[answer.type]; + if (typeof value === 'object') { + for (const key of subvalueKeys) { + if ((value as IDataObject)[key] !== undefined) { + value = (value as ITypeformAnswerField)[key]; + break; + } + } + } + convertedAnswers[defintitionsById[answer.field.id]] = value; + } + + if (onlyAnswers === true) { + // Only the answers should be returned so do it directly + return { + workflowData: [ + this.helpers.returnJsonArray([convertedAnswers]), + ], + }; + } else { + // All data should be returned but the answers should still be + // converted to key -> value pair so overwrite the answers. + (bodyData.form_response as IDataObject).answers = convertedAnswers; + } + } + + if (onlyAnswers === true) { + // Return only the answer + return { + workflowData: [ + this.helpers.returnJsonArray([answers as unknown as IDataObject]), + ], + }; + } else { + // Return all the data that got received + return { + workflowData: [ + this.helpers.returnJsonArray([bodyData]), + ], + }; + } + + } +} diff --git a/packages/nodes-base/nodes/Typeform/typeform.png b/packages/nodes-base/nodes/Typeform/typeform.png new file mode 100644 index 0000000000..0bd6123d0c Binary files /dev/null and b/packages/nodes-base/nodes/Typeform/typeform.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a2efe9795b..cabe67a0d8 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -48,7 +48,8 @@ "dist/credentials/Smtp.credentials.js", "dist/credentials/TelegramApi.credentials.js", "dist/credentials/TrelloApi.credentials.js", - "dist/credentials/TwilioApi.credentials.js" + "dist/credentials/TwilioApi.credentials.js", + "dist/credentials/TypeformApi.credentials.js" ], "nodes": [ "dist/nodes/ActiveCampaign/ActiveCampaign.node.js", @@ -97,6 +98,7 @@ "dist/nodes/Trello/Trello.node.js", "dist/nodes/Trello/TrelloTrigger.node.js", "dist/nodes/Twilio/Twilio.node.js", + "dist/nodes/Typeform/TypeformTrigger.node.js", "dist/nodes/WriteBinaryFile.node.js", "dist/nodes/Webhook.node.js", "dist/nodes/Xml.node.js"