diff --git a/packages/nodes-base/credentials/WufooApi.credentials.ts b/packages/nodes-base/credentials/WufooApi.credentials.ts new file mode 100644 index 0000000000..70e0a27dac --- /dev/null +++ b/packages/nodes-base/credentials/WufooApi.credentials.ts @@ -0,0 +1,23 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class WufooApi implements ICredentialType { + name = 'wufooApi'; + displayName = 'Wufoo API'; + properties = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Subdomain', + name: 'subdomain', + type: 'string' as NodePropertyTypes, + default: '', + } + ]; +} diff --git a/packages/nodes-base/nodes/Wufoo/GenericFunctions.ts b/packages/nodes-base/nodes/Wufoo/GenericFunctions.ts new file mode 100644 index 0000000000..bcc773c91c --- /dev/null +++ b/packages/nodes-base/nodes/Wufoo/GenericFunctions.ts @@ -0,0 +1,45 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function wufooApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('wufooApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + let options: OptionsWithUri = { + auth: { + username: credentials!.apiKey as string, + password: '', + }, + method, + form: body, + body, + qs, + uri: `https://${credentials!.subdomain}.wufoo.com/api/v3/${resource}`, + json: true + }; + + options = Object.assign({}, options, option); + if (Object.keys(options.body).length === 0 || method === 'PUT') { + delete options.body; + } + + try { + return await this.helpers.request!(options); + } catch (error) { + throw new Error(error.message); + } +} diff --git a/packages/nodes-base/nodes/Wufoo/Interface.ts b/packages/nodes-base/nodes/Wufoo/Interface.ts new file mode 100644 index 0000000000..88a76965b2 --- /dev/null +++ b/packages/nodes-base/nodes/Wufoo/Interface.ts @@ -0,0 +1,27 @@ +export interface IFormQuery { + includeTodayCount?: boolean; +} + +export interface IWebhook { + url: string; + handshakeKey?: string; + metadata?: boolean; +} + +interface ISubField { + DefaultVal: string; + ID: string; + Label: string; +} + +export interface IField { + Title: string; + Instructions: string; + IsRequired: number; + ClassNames: string; + DefaultVal: string; + Page: number; + Type: string; + ID: string; + SubFields: [ISubField]; +} diff --git a/packages/nodes-base/nodes/Wufoo/WufooTrigger.node.ts b/packages/nodes-base/nodes/Wufoo/WufooTrigger.node.ts new file mode 100644 index 0000000000..b4b0eff988 --- /dev/null +++ b/packages/nodes-base/nodes/Wufoo/WufooTrigger.node.ts @@ -0,0 +1,248 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + wufooApiRequest, +} from './GenericFunctions'; + +import { + IField, + IFormQuery, + IWebhook, +} from './Interface'; + +import { + randomBytes, +} from 'crypto'; + +export class WufooTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Wufoo Trigger', + name: 'wufooTrigger', + icon: 'file:wufoo.png', + group: ['trigger'], + version: 1, + description: 'Handle Wufoo events via webhooks', + defaults: { + name: 'Wufoo Trigger', + color: '#c35948', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'wufooApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Forms', + name: 'form', + type: 'options', + required: true, + default: '', + typeOptions: { + loadOptionsMethod: 'getForms', + }, + description: 'The form upon which will trigger this node when a new entry is made.', + }, + { + displayName: 'Only Answers', + name: 'onlyAnswers', + type: 'boolean', + default: true, + description: 'Returns only the answers of the form and not any of the other data.', + }, + ], + }; + + methods = { + loadOptions: { + async getForms(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const body: IFormQuery = { includeTodayCount: true }; + // https://wufoo.github.io/docs/#all-forms + const formObject = await wufooApiRequest.call(this, 'GET', 'forms.json', body); + for (const form of formObject.Forms) { + const name = form.Name; + const value = form.Hash; + returnData.push({ + name, + value, + }); + } + // Entries submitted on the same day are present in separate property in data object + if (formObject.EntryCountToday) { + for (const form of formObject.EntryCountToday) { + const name = form.Name; + const value = form.Hash; + returnData.push({ + name, + value, + }); + } + } + return returnData; + }, + }, + }; + + // @ts-ignore + webhookMethods = { + default: { + // No API endpoint to allow checking of existing webhooks. + // Creating new webhook will not overwrite existing one if parameters are the same. + // Otherwise an update occurs. + async checkExists(this: IHookFunctions): Promise { + return false; + }, + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); + const formHash = this.getNodeParameter('form') as IDataObject; + const endpoint = `forms/${formHash}/webhooks.json`; + + // Handshake key for webhook endpoint protection + webhookData.handshakeKey = randomBytes(20).toString('hex') as string; + const body: IWebhook = { + url: webhookUrl as string, + handshakeKey: webhookData.handshakeKey as string, + metadata: true, + }; + + const result = await wufooApiRequest.call(this, 'PUT', endpoint, body); + webhookData.webhookId = result.WebHookPutResult.Hash; + + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const formHash = this.getNodeParameter('form') as IDataObject; + const endpoint = `forms/${formHash}/webhooks/${webhookData.webhookId}.json`; + try { + await wufooApiRequest.call(this, 'DELETE', endpoint); + } catch (error) { + return false; + } + delete webhookData.webhookId; + delete webhookData.handshakeKey; + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const req = this.getRequestObject(); + const body = this.getBodyData(); + const webhookData = this.getWorkflowStaticData('node'); + const onlyAnswers = this.getNodeParameter('onlyAnswers') as boolean; + const entries: IDataObject = {}; + let returnObject: IDataObject = {}; + + if (req.body.HandshakeKey !== webhookData.handshakeKey) { + return {}; + } + + const fieldsObject = JSON.parse(req.body.FieldStructure); + + fieldsObject.Fields.map((field: IField) => { + + // TODO + // Handle docusign field + + if (field.Type === 'file') { + + entries[field.Title] = req.body[`${field.ID}-url`]; + + } else if (field.Type === 'address') { + const address: IDataObject = {}; + + for (const subfield of field.SubFields) { + address[subfield.Label] = body[subfield.ID]; + } + + entries[field.Title] = address; + + } else if (field.Type === 'checkbox') { + + const responses: string[] = []; + + for (const subfield of field.SubFields) { + if (body[subfield.ID] !== '') { + responses.push(body[subfield.ID] as string); + } + } + + entries[field.Title] = responses; + + } else if (field.Type === 'likert') { + + const likert: IDataObject = {}; + + for (const subfield of field.SubFields) { + likert[subfield.Label] = body[subfield.ID]; + } + + entries[field.Title] = likert; + + } else if (field.Type === 'shortname') { + + const shortname: IDataObject = {}; + + for (const subfield of field.SubFields) { + shortname[subfield.Label] = body[subfield.ID]; + } + + entries[field.Title] = shortname; + + } else { + entries[field.Title] = req.body[field.ID]; + } + }); + + if (onlyAnswers === false) { + returnObject = { + createdBy: req.body.CreatedBy as string, + entryId: req.body.EntryId as number, + dateCreated: req.body.DateCreated as Date, + formId: req.body.FormId as string, + formStructure: JSON.parse(req.body.FormStructure), + fieldStructure: JSON.parse(req.body.FieldStructure), + entries + }; + + return { + workflowData: [ + this.helpers.returnJsonArray([returnObject as unknown as IDataObject]), + ], + }; + + } else { + return { + workflowData: [ + this.helpers.returnJsonArray(entries as unknown as IDataObject), + ], + }; + } + } +} diff --git a/packages/nodes-base/nodes/Wufoo/wufoo.png b/packages/nodes-base/nodes/Wufoo/wufoo.png new file mode 100644 index 0000000000..75a2eba2fc Binary files /dev/null and b/packages/nodes-base/nodes/Wufoo/wufoo.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 2b6eee5176..a703f528dc 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -179,6 +179,7 @@ "dist/credentials/WebflowOAuth2Api.credentials.js", "dist/credentials/WooCommerceApi.credentials.js", "dist/credentials/WordpressApi.credentials.js", + "dist/credentials/WufooApi.credentials.js", "dist/credentials/XeroOAuth2Api.credentials.js", "dist/credentials/ZendeskApi.credentials.js", "dist/credentials/ZendeskOAuth2Api.credentials.js", @@ -368,6 +369,7 @@ "dist/nodes/WooCommerce/WooCommerce.node.js", "dist/nodes/WooCommerce/WooCommerceTrigger.node.js", "dist/nodes/WriteBinaryFile.node.js", + "dist/nodes/Wufoo/WufooTrigger.node.js", "dist/nodes/Xero/Xero.node.js", "dist/nodes/Xml.node.js", "dist/nodes/Zendesk/Zendesk.node.js", diff --git a/packages/nodes-base/tslint.json b/packages/nodes-base/tslint.json index 7eb9d0110e..5e273ff674 100644 --- a/packages/nodes-base/tslint.json +++ b/packages/nodes-base/tslint.json @@ -46,6 +46,7 @@ "forin": true, "jsdoc-format": true, "label-position": true, + "indent": [true, "tabs", 2], "member-access": [ true, "no-public" @@ -60,6 +61,10 @@ "no-default-export": true, "no-duplicate-variable": true, "no-inferrable-types": true, + "ordered-imports": [true, { + "import-sources-order": "any", + "named-imports-order": "case-insensitive" + }], "no-namespace": [ true, "allow-declarations"