From 937e4248069321d25741f1c3dc027e43d0eea8fb Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 21 Aug 2019 08:45:03 +0200 Subject: [PATCH] :sparkles: Add ActiveCampaign-Node --- .../ActiveCampaignApi.credentials.ts | 24 + .../ActiveCampaign/ActiveCampaign.node.ts | 491 ++++++++++++++++++ .../nodes/ActiveCampaign/GenericFunctions.ts | 116 +++++ .../nodes/ActiveCampaign/activeCampaign.png | Bin 0 -> 2315 bytes packages/nodes-base/package.json | 2 + 5 files changed, 633 insertions(+) create mode 100644 packages/nodes-base/credentials/ActiveCampaignApi.credentials.ts create mode 100644 packages/nodes-base/nodes/ActiveCampaign/ActiveCampaign.node.ts create mode 100644 packages/nodes-base/nodes/ActiveCampaign/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/ActiveCampaign/activeCampaign.png diff --git a/packages/nodes-base/credentials/ActiveCampaignApi.credentials.ts b/packages/nodes-base/credentials/ActiveCampaignApi.credentials.ts new file mode 100644 index 0000000000..cb47b5f6ed --- /dev/null +++ b/packages/nodes-base/credentials/ActiveCampaignApi.credentials.ts @@ -0,0 +1,24 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class ActiveCampaignApi implements ICredentialType { + name = 'activeCampaignApi'; + displayName = 'ActiveCampaign API'; + properties = [ + { + displayName: 'API URL', + name: 'apiUrl', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'API Key', + name: 'apiKey', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/ActiveCampaign/ActiveCampaign.node.ts b/packages/nodes-base/nodes/ActiveCampaign/ActiveCampaign.node.ts new file mode 100644 index 0000000000..f8a610463f --- /dev/null +++ b/packages/nodes-base/nodes/ActiveCampaign/ActiveCampaign.node.ts @@ -0,0 +1,491 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; +import { + IDataObject, + INodeTypeDescription, + INodeExecutionData, + INodeType, +} from 'n8n-workflow'; + +import { + activeCampaignApiRequest, + activeCampaignApiRequestAllItems, +} from './GenericFunctions'; + +interface CustomProperty { + name: string; + value: string; +} + + +/** + * Add the additional fields to the body + * + * @param {IDataObject} body The body object to add fields to + * @param {IDataObject} additionalFields The fields to add + */ +function addAdditionalFields(body: IDataObject, additionalFields: IDataObject) { + for (const key of Object.keys(additionalFields)) { + if (key === 'customProperties' && (additionalFields.customProperties as IDataObject).property !== undefined) { + for (const customProperty of (additionalFields.customProperties as IDataObject)!.property! as CustomProperty[]) { + body[customProperty.name] = customProperty.value; + } + } else { + body[key] = additionalFields[key]; + } + } +} + +export class ActiveCampaign implements INodeType { + description: INodeTypeDescription = { + displayName: 'ActiveCampaign', + name: 'activeCampaign', + icon: 'file:activeCampaign.png', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Create and edit data in ActiveCampaign', + defaults: { + name: 'ActiveCampaign', + color: '#356ae6', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'activeCampaignApi', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Contact', + value: 'contact', + }, + ], + default: 'contact', + description: 'The resource to operate on.', + }, + + + + // ---------------------------------- + // operations + // ---------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'contact', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a contact', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a contact', + }, + { + name: 'Get', + value: 'get', + description: 'Get data of a contact', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get data of all contact', + }, + { + name: 'Update', + value: 'update', + description: 'Update a contact', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, + + + + // ---------------------------------- + // contact + // ---------------------------------- + + // ---------------------------------- + // contact:create + // ---------------------------------- + { + displayName: 'Email', + name: 'email', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'contact', + ], + }, + }, + description: 'The email of the contact to create', + }, + { + displayName: 'Update if exists', + name: 'updateIfExists', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'contact', + ], + }, + }, + default: false, + description: 'Update user if it exists already. If not set and user exists it will error instead.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'contact', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: 'The first name of the contact to create', + }, + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + default: '', + description: 'The last name of the contact to create', + }, + { + displayName: 'Phone', + name: 'phone', + type: 'string', + default: '', + description: 'Phone number of the contact.', + }, + ], + }, + + // ---------------------------------- + // contact:delete + // ---------------------------------- + { + displayName: 'Contact ID', + name: 'contactId', + type: 'number', + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'contact', + ], + }, + }, + default: 0, + required: true, + description: 'ID of the contact to delete.', + }, + + // ---------------------------------- + // person:get + // ---------------------------------- + { + displayName: 'Contact ID', + name: 'contactId', + type: 'number', + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'contact', + ], + }, + }, + default: 0, + required: true, + description: 'ID of the contact to get.', + }, + + // ---------------------------------- + // contact:getAll + // ---------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'contact', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'contact', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + + // ---------------------------------- + // contact:update + // ---------------------------------- + { + displayName: 'Contact ID', + name: 'contactId', + type: 'number', + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'contact', + ], + }, + }, + default: 0, + required: true, + description: 'ID of the contact to update.', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + description: 'The fields to update.', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'contact', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Email', + name: 'email', + type: 'string', + default: '', + description: 'Email of the contact.', + }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: 'First name of the contact', + }, + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + default: '', + description: 'Last name of the contact', + }, + { + displayName: 'Phone', + name: 'phone', + type: 'string', + default: '', + description: 'Phone number of the contact.', + }, + ], + }, + + ], + }; + + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + + let resource: string; + let operation: string; + + // For Post + let body: IDataObject; + // For Query string + let qs: IDataObject; + + let requestMethod: string; + let endpoint: string; + let returnAll = false; + let dataKey: string | undefined; + + for (let i = 0; i < items.length; i++) { + dataKey = undefined; + resource = this.getNodeParameter('resource', 0) as string; + operation = this.getNodeParameter('operation', 0) as string; + + requestMethod = 'GET'; + endpoint = ''; + body = {} as IDataObject; + qs = {} as IDataObject; + + if (resource === 'contact') { + if (operation === 'create') { + // ---------------------------------- + // contact:create + // ---------------------------------- + + requestMethod = 'POST'; + + const updateIfExists = this.getNodeParameter('updateIfExists', i) as boolean; + if (updateIfExists === true) { + endpoint = '/api/3/contact/sync'; + } else { + endpoint = '/api/3/contacts'; + } + + dataKey = 'contact'; + body.contact = { + email: this.getNodeParameter('email', i) as string, + } as IDataObject; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + addAdditionalFields(body.contact as IDataObject, additionalFields); + + } else if (operation === 'delete') { + // ---------------------------------- + // contact:delete + // ---------------------------------- + + requestMethod = 'DELETE'; + + const contactId = this.getNodeParameter('contactId', i) as number; + endpoint = `/api/3/contacts/${contactId}`; + + } else if (operation === 'get') { + // ---------------------------------- + // contact:get + // ---------------------------------- + + requestMethod = 'GET'; + + const contactId = this.getNodeParameter('contactId', i) as number; + endpoint = `/api/3/contacts/${contactId}`; + + } else if (operation === 'getAll') { + // ---------------------------------- + // persons:getAll + // ---------------------------------- + + requestMethod = 'GET'; + + returnAll = this.getNodeParameter('returnAll', i) as boolean; + if (returnAll === false) { + qs.limit = this.getNodeParameter('limit', i) as number; + } + + dataKey = 'contacts'; + endpoint = `/api/3/contacts`; + + } else if (operation === 'update') { + // ---------------------------------- + // contact:update + // ---------------------------------- + + requestMethod = 'PUT'; + + const contactId = this.getNodeParameter('contactId', i) as number; + endpoint = `/api/3/contacts/${contactId}`; + + dataKey = 'contact'; + body.contact = {} as IDataObject; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + addAdditionalFields(body.contact as IDataObject, updateFields); + + } + } else { + throw new Error(`The resource "${resource}" is not known!`); + } + + let responseData; + if (returnAll === true) { + responseData = await activeCampaignApiRequestAllItems.call(this, requestMethod, endpoint, body, qs, dataKey); + } else { + responseData = await activeCampaignApiRequest.call(this, requestMethod, endpoint, body, qs, dataKey); + } + + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as IDataObject); + } + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/ActiveCampaign/GenericFunctions.ts b/packages/nodes-base/nodes/ActiveCampaign/GenericFunctions.ts new file mode 100644 index 0000000000..169c9f1146 --- /dev/null +++ b/packages/nodes-base/nodes/ActiveCampaign/GenericFunctions.ts @@ -0,0 +1,116 @@ +import { + IExecuteFunctions, + IHookFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +import { OptionsWithUri } from 'request'; + + +/** + * Make an API request to ActiveCampaign + * + * @param {IHookFunctions} this + * @param {string} method + * @param {string} url + * @param {object} body + * @returns {Promise} + */ +export async function activeCampaignApiRequest(this: IHookFunctions | IExecuteFunctions, method: string, endpoint: string, body: IDataObject, query?: IDataObject, dataKey?: string): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('activeCampaignApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + if (query === undefined) { + query = {}; + } + + const options: OptionsWithUri = { + headers: { + 'Api-Token': credentials.apiKey, + }, + method, + qs: query, + uri: `${credentials.apiUrl}${endpoint}`, + json: true + }; + + if (Object.keys(body).length !== 0) { + options.body = body; + } + + try { + const responseData = await this.helpers.request(options); + + if (responseData.success === false) { + throw new Error(`ActiveCampaign error response: ${responseData.error} (${responseData.error_info})`); + } + + if (dataKey === undefined) { + return responseData; + } else { + return responseData[dataKey] as IDataObject; + } + + } catch (error) { + if (error.statusCode === 403) { + // Return a clear error + throw new Error('The ActiveCampaign credentials are not valid!'); + } + + // If that data does not exist for some reason return the actual error + throw error; + } +} + + + +/** + * Make an API request to paginated ActiveCampaign endpoint + * and return all results + * + * @export + * @param {(IHookFunctions | IExecuteFunctions)} this + * @param {string} method + * @param {string} endpoint + * @param {IDataObject} body + * @param {IDataObject} [query] + * @returns {Promise} + */ +export async function activeCampaignApiRequestAllItems(this: IHookFunctions | IExecuteFunctions, method: string, endpoint: string, body: IDataObject, query?: IDataObject, dataKey?: string): Promise { // tslint:disable-line:no-any + + if (query === undefined) { + query = {}; + } + query.limit = 100; + query.offset = 0; + + const returnData: IDataObject[] = []; + + let responseData; + + let itemsReceived = 0; + do { + responseData = await activeCampaignApiRequest.call(this, method, endpoint, body, query); + + if (dataKey === undefined) { + returnData.push.apply(returnData, responseData); + itemsReceived += returnData.length; + } else { + returnData.push.apply(returnData, responseData[dataKey]); + itemsReceived += responseData[dataKey].length; + } + + query.offset = itemsReceived; + } while ( + responseData.meta !== undefined && + responseData.meta.total !== undefined && + responseData.meta.total > itemsReceived + ); + + return returnData; +} diff --git a/packages/nodes-base/nodes/ActiveCampaign/activeCampaign.png b/packages/nodes-base/nodes/ActiveCampaign/activeCampaign.png new file mode 100644 index 0000000000000000000000000000000000000000..4a40c9158334c466ca8c206f034ad845bbe613be GIT binary patch literal 2315 zcmV+m3H0`fP)JNRCwC# zT76Jd*A+kaee5pm@+r&b8bDA46BEQT2{!lr8zwGq1sG0pBw{2%t?tu(N)48SV_`J(GOT`c^6Zj&FdW0T}f5PHBnhKCrv; zl;YZV6weoa2R3IhXXN&+VM&$*tH`dLz3^Fk`MP5)&9jE5EHLBt18htV1c+icf6+sr zB&Ft$W!1bF%&+;oq$cHv+Pj?q>*tTY%BCmUAp#nO$)GxsvKBmMzx%Nd<(!JI0~fA1 zVA~SFEO_;B@YL@6=}`M#BHMV1n+GtZi6d>E?aoK`sPotS8I{;I+qG>?4J#_PjKH|o zzVFdc*ZUh-(AQ_CI58C)Q(Dfs^C|mXkA5U&&U*wzZPQyAu!c3N2SmT5LuYqso%?I) zSWi9guQ#xrl(9%%_|RUpWYup#akyuLZEI4RGe#r?lnKP{0v7UlLmmHqQ0qDJH59SS zz|&l)n=TB5>{+_WUiF<;WP3GG4Acznx>&=oXi3yeVeX7Lcu4_cyzkvm$G@tL;f}um zIxv%M7Ow5ox%Rss->;Oe-ieoG3d3@%09HIppSD##aYW86dP3M$uEvfuAT}oony)9+ z-SDK|ec-z+G}RB1a*b_CQkCqAjkfZ2SlcCQMWz6|1ppU)@tbl+QLQ>}#b4Fp+InQK zj38)YPZ6MrWCMM$?affz-b!Qq(|Xgww+(|O$5PNNnwFTx zXwV!6(&w(WEqu69&aU_|9-pdii@t3QU@kgQHO3oN>~7gpwL>nb+=n8zh^HxWWbmGh_+>Cg&b_UjtFJL8 zy!(Zc^Pq^@)<8;1TP){SzN-{e{T6%r9Fh8Q=d`>h>X>h&MKHzzaAvUgO0Jx>YU%1$ zIlKHtAxcV;NOMpzYeW8vTF>E!^ouPxVd)b>)S?1tfSh;N^J>MqR+5tYCH`GjDY-Rm zX=?1Ge&NJQWBBaP@rI|b2@~Z3uqy>ievNhBfMDDXm}?yGhU%Zt)P|w3vVN zfOh`ieRQbpS4fn0!_5=okPDXlK`mL)0QQt}&ZK_vW1#&ffqQV{YX2^+``|s;zxSBs zA(;UaE-ihLt?bJUm@V7lYfp;w3!R@jb$ye-Ph*qrhdezG-L@q*RlA?9HgWqHi;8inpO+wRF{6@|^iAxpOnY zhIIi?qW!0KXnn`F06ag-fO+))pXnDa>NBtKY;3Z|1K<~cu}^PChqXzvIo(X34quN` zm%`QiQj2!6Wh3*Wxd8&R&M;0vLZpPl;>JJxVxa&sH_eq7n3`nTPB6d-ObFA#Hw}m@ zdnu;y7TI0)739#Its|_`(3IBO{2CqZ*p6*#QhY|@0dU|7j)NY&!D$dT9iESQAb6@? zW5o!w8W1Ute)|O+Ew|weog4)!Y8Az6YV@r3$$L-IvA$Nx=|)w@sndcfYGzUz!ao@Z z#-)_(C1@F6N1t3H>}Lcl&k{qYdT-;;u$Aq^U|l7uBizcYSu-bE=HnL|wx1s=o~k-f zlQN<}c|b75*%fb-*4tQTOb#3(HkX;!7Eenv0GJQ?{Gpk9fm&GeCz6`KOw@9;R#haQ z;KbFuUF&On3G|Q-c2}5H>o)>o3s`KvGf?I({i%|>_&M~?c@TLDA>5c9=y-3V?rr%c z^9?pcn2zOAV#R8B6Mm7JzhZ}+Rr(;K`i*_npaIR>{F>f-bSob)i4Esu5{mVfRtx}# zS(S6jx5(KQKfwvg$*yA+kX1H0a2h@K(}r*8q-bG^W5%RJvAR}?Gi$lxF0Yf)^416s z9GPEvs!~nUdJbhB3x_H?vrjW(F{~hQJ(3R>?-$z3^pF>`B*lC&CKs8)`+c zo9Jj)BUfO;jmRuKT(bl)GF75_s&`At8P#I%D#|L_LTpDy-|;#+_}^Dp$R8w%wS9G# z_NABof+S~Fn-9sD0K^cOHvGwv!pV