From d426586006145ada793a10947fb4ff4ef6e6bd15 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Thu, 3 Dec 2020 02:05:54 -0500 Subject: [PATCH] :sparkles: Add Iterable Node (#1215) * :sparkles: Iterable Node * :zap: Improvements * :zap: Improvements * :zap: Small improvements to Iterable-Node Co-authored-by: Jan Oberhauser --- .../credentials/IterableApi.credentials.ts | 18 + .../nodes/Iterable/EventDescription.ts | 145 ++++++++ .../nodes/Iterable/GenericFunctions.ts | 71 ++++ .../nodes/Iterable/Iterable.node.ts | 239 +++++++++++++ .../nodes/Iterable/UserDescription.ts | 316 ++++++++++++++++++ .../nodes-base/nodes/Iterable/iterable.png | Bin 0 -> 2272 bytes packages/nodes-base/package.json | 2 + 7 files changed, 791 insertions(+) create mode 100644 packages/nodes-base/credentials/IterableApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Iterable/EventDescription.ts create mode 100644 packages/nodes-base/nodes/Iterable/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Iterable/Iterable.node.ts create mode 100644 packages/nodes-base/nodes/Iterable/UserDescription.ts create mode 100644 packages/nodes-base/nodes/Iterable/iterable.png diff --git a/packages/nodes-base/credentials/IterableApi.credentials.ts b/packages/nodes-base/credentials/IterableApi.credentials.ts new file mode 100644 index 0000000000..e6a5a8cca7 --- /dev/null +++ b/packages/nodes-base/credentials/IterableApi.credentials.ts @@ -0,0 +1,18 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class IterableApi implements ICredentialType { + name = 'iterableApi'; + displayName = 'Iterable API'; + documentationUrl = 'iterable'; + properties = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Iterable/EventDescription.ts b/packages/nodes-base/nodes/Iterable/EventDescription.ts new file mode 100644 index 0000000000..bb92165495 --- /dev/null +++ b/packages/nodes-base/nodes/Iterable/EventDescription.ts @@ -0,0 +1,145 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const eventOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'event', + ], + }, + }, + options: [ + { + name: 'Track', + value: 'track', + description: 'Record the actions a user perform', + }, + ], + default: 'track', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const eventFields = [ + + /* -------------------------------------------------------------------------- */ + /* event:track */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Name', + name: 'name', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'track', + ], + }, + }, + description: 'The name of the event to track.', + default: '', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'track', + ], + }, + }, + options: [ + { + displayName: 'Campaign ID', + name: 'campaignId', + type: 'string', + default: '', + description: `Campaign tied to conversion`, + }, + { + displayName: 'Created At', + name: 'createdAt', + type: 'dateTime', + default: '', + description: `Time event happened.`, + }, + { + displayName: 'Data Fields', + name: 'dataFieldsUi', + type: 'fixedCollection', + default: '', + placeholder: 'Add Data Field', + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'dataFieldValues', + displayName: 'Data Field', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: 'The end event specified key of the event defined data.', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'The end event specified value of the event defined data.', + }, + ], + }, + ], + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + default: '', + description: `Either email or userId must be passed in to identify the user. If both are passed in, email takes precedence.`, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + default: '', + description: `Optional event id. If an event exists with that id, the event will be updated. If none is specified, a new id will automatically be generated and returned.`, + }, + { + displayName: 'Template ID', + name: 'templateId', + type: 'string', + default: '', + description: `Template id`, + }, + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + description: `userId that was passed into the updateUser call`, + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Iterable/GenericFunctions.ts b/packages/nodes-base/nodes/Iterable/GenericFunctions.ts new file mode 100644 index 0000000000..35ec1e3420 --- /dev/null +++ b/packages/nodes-base/nodes/Iterable/GenericFunctions.ts @@ -0,0 +1,71 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function iterableApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, headers: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const credentials = this.getCredentials('iterableApi') as IDataObject; + + const options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + 'Api_Key': credentials.apiKey, + }, + method, + body, + qs, + uri: uri || `https://api.iterable.com/api${resource}`, + json: true, + }; + try { + if (Object.keys(headers).length !== 0) { + options.headers = Object.assign({}, options.headers, headers); + } + if (Object.keys(body).length === 0) { + delete options.body; + } + //@ts-ignore + return await this.helpers.request.call(this, options); + + } catch (error) { + if (error.response && error.response.body && error.response.body.msg) { + + const message = error.response.body.msg; + + // Try to return the error prettier + throw new Error( + `Iterable error response [${error.statusCode}]: ${message}`, + ); + } + throw error; + } +} + +export async function iterableApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + query.maxResults = 100; + + do { + responseData = await iterableApiRequest.call(this, method, endpoint, body, query); + query.pageToken = responseData['nextPageToken']; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + responseData['nextPageToken'] !== undefined && + responseData['nextPageToken'] !== '' + ); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Iterable/Iterable.node.ts b/packages/nodes-base/nodes/Iterable/Iterable.node.ts new file mode 100644 index 0000000000..4d04be5685 --- /dev/null +++ b/packages/nodes-base/nodes/Iterable/Iterable.node.ts @@ -0,0 +1,239 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + iterableApiRequest, +} from './GenericFunctions'; + +import { + eventFields, + eventOperations, +} from './EventDescription'; + +import { + userFields, + userOperations, +} from './UserDescription'; + +import * as moment from 'moment-timezone'; + +export class Iterable implements INodeType { + description: INodeTypeDescription = { + displayName: 'Iterable', + name: 'iterable', + icon: 'file:iterable.png', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Iterable API.', + defaults: { + name: 'Iterable', + color: '#725ed8', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'iterableApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Event', + value: 'event', + }, + { + name: 'User', + value: 'user', + }, + ], + default: 'user', + description: 'The resource to operate on.', + }, + ...eventOperations, + ...eventFields, + ...userOperations, + ...userFields, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = (items.length as unknown) as number; + const timezone = this.getTimezone(); + const qs: IDataObject = {}; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + if (resource === 'event') { + if (operation === 'track') { + // https://api.iterable.com/api/docs#events_trackBulk + const events = []; + + for (let i = 0; i < length; i++) { + + const name = this.getNodeParameter('name', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (!additionalFields.email && !additionalFields.id) { + throw new Error('Either email or userId must be passed in to identify the user. Please add one of both via "Additional Fields". If both are passed in, email takes precedence.'); + } + + const body: IDataObject = { + eventName: name, + }; + + Object.assign(body, additionalFields); + + if (body.dataFieldsUi) { + const dataFields = (body.dataFieldsUi as IDataObject).dataFieldValues as IDataObject[]; + const data: IDataObject = {}; + for (const dataField of dataFields) { + data[dataField.key as string] = dataField.value; + } + body.dataFields = data; + delete body.dataFieldsUi; + } + + if (body.createdAt) { + body.createdAt = moment.tz(body.createdAt, timezone).unix(); + } + + events.push(body); + } + + responseData = await iterableApiRequest.call(this, 'POST', '/events/trackBulk', { events }); + + returnData.push(responseData); + } + } + + if (resource === 'user') { + if (operation === 'upsert') { + // https://api.iterable.com/api/docs#users_updateUser + for (let i = 0; i < length; i++) { + + const identifier = this.getNodeParameter('identifier', i) as string; + + const value = this.getNodeParameter('value', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const body: IDataObject = {}; + + if (identifier === 'email') { + body.email = value; + } else { + body.preferUserId = this.getNodeParameter('preferUserId', i) as boolean; + body.userId = value; + } + + Object.assign(body, additionalFields); + + if (body.dataFieldsUi) { + const dataFields = (body.dataFieldsUi as IDataObject).dataFieldValues as IDataObject[]; + const data: IDataObject = {}; + for (const dataField of dataFields) { + data[dataField.key as string] = dataField.value; + } + body.dataFields = data; + delete body.dataFieldsUi; + } + + responseData = await iterableApiRequest.call(this, 'POST', '/users/update', body); + + if (this.continueOnFail() === false) { + if (responseData.code !== 'Success') { + throw new Error( + `Iterable error response [400]: ${responseData.msg}`, + ); + } + } + + returnData.push(responseData); + } + } + + if (operation === 'delete') { + // https://api.iterable.com/api/docs#users_delete + // https://api.iterable.com/api/docs#users_delete_0 + for (let i = 0; i < length; i++) { + const by = this.getNodeParameter('by', i) as string; + + let endpoint; + + if (by === 'email') { + const email = this.getNodeParameter('email', i) as string; + endpoint = `/users/${email}`; + } else { + const userId = this.getNodeParameter('userId', i) as string; + endpoint = `/users/byUserId/${userId}`; + } + + responseData = await iterableApiRequest.call(this, 'DELETE', endpoint); + + if (this.continueOnFail() === false) { + if (responseData.code !== 'Success') { + throw new Error( + `Iterable error response [400]: ${responseData.msg}`, + ); + } + } + + returnData.push(responseData); + } + } + + if (operation === 'get') { + // https://api.iterable.com/api/docs#users_getUser + // https://api.iterable.com/api/docs#users_getUserById + for (let i = 0; i < length; i++) { + + const by = this.getNodeParameter('by', i) as string; + + let endpoint; + + if (by === 'email') { + const email = this.getNodeParameter('email', i) as string; + endpoint = `/users/getByEmail`; + qs.email = email; + } else { + const userId = this.getNodeParameter('userId', i) as string; + endpoint = `/users/byUserId/${userId}`; + } + + responseData = await iterableApiRequest.call(this, 'GET', endpoint, {}, qs); + + if (this.continueOnFail() === false) { + if (Object.keys(responseData).length === 0) { + throw new Error( + `Iterable error response [404]: User not found`, + ); + } + } + + responseData = responseData.user || {}; + returnData.push(responseData); + } + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Iterable/UserDescription.ts b/packages/nodes-base/nodes/Iterable/UserDescription.ts new file mode 100644 index 0000000000..ad12844068 --- /dev/null +++ b/packages/nodes-base/nodes/Iterable/UserDescription.ts @@ -0,0 +1,316 @@ +import { + INodeProperties, + } from 'n8n-workflow'; + +export const userOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'user', + ], + }, + }, + options: [ + { + name: 'Create/Update', + value: 'upsert', + description: 'Create/Update a user', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a user', + }, + { + name: 'Get', + value: 'get', + description: 'Get a user', + }, + ], + default: 'upsert', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const userFields = [ + +/* -------------------------------------------------------------------------- */ +/* user:upsert */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Identifier', + name: 'identifier', + type: 'options', + required: true, + options: [ + { + name: 'Email', + value: 'email', + }, + { + name: 'User ID', + value: 'userId', + }, + ], + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'upsert', + ], + }, + }, + default: '', + description: 'Identifier to be used', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'upsert', + ], + }, + }, + default: '', + }, + { + displayName: `Create If Doesn't exist`, + name: 'preferUserId', + type: 'boolean', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'upsert', + ], + identifier: [ + 'userId', + ], + }, + }, + default: true, + description: 'Create a new user if the idetifier does not exist.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'upsert', + ], + }, + }, + options: [ + { + displayName: 'Data Fields', + name: 'dataFieldsUi', + type: 'fixedCollection', + default: '', + placeholder: 'Add Data Field', + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'dataFieldValues', + displayName: 'Data Field', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: 'The end user specified key of the user defined data.', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'The end user specified value of the user defined data.', + }, + ], + }, + ], + }, + { + displayName: 'Merge Nested Objects', + name: 'mergeNestedObjects', + type: 'boolean', + default: false, + description: `Merge top level objects instead of overwriting (default: false).
+ e.g. if user profile has data: {mySettings:{mobile:true}} and change contact field has data: {mySettings:{email:true}},
+ the resulting profile: {mySettings:{mobile:true,email:true}}`, + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* user:delete */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'By', + name: 'by', + type: 'options', + required: true, + options: [ + { + name: 'Email', + value: 'email', + }, + { + name: 'User ID', + value: 'userId', + }, + ], + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'delete', + ], + }, + }, + default: 'email', + description: 'Identifier to be used', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'delete', + ], + by: [ + 'userId', + ], + }, + }, + default: '', + description: 'Unique identifier for a particular user', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'delete', + ], + by: [ + 'email', + ], + }, + }, + default: '', + description: 'Email for a particular user', + }, +/* -------------------------------------------------------------------------- */ +/* user:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'By', + name: 'by', + type: 'options', + required: true, + options: [ + { + name: 'Email', + value: 'email', + }, + { + name: 'User ID', + value: 'userId', + }, + ], + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'get', + ], + }, + }, + default: 'email', + description: 'Identifier to be used', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'get', + ], + by: [ + 'userId', + ], + }, + }, + default: '', + description: 'Unique identifier for a particular user', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'get', + ], + by: [ + 'email', + ], + }, + }, + default: '', + description: 'Email for a particular user', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Iterable/iterable.png b/packages/nodes-base/nodes/Iterable/iterable.png new file mode 100644 index 0000000000000000000000000000000000000000..a5a248f5b737a47894fe063645be6b0c0124f5c0 GIT binary patch literal 2272 zcmV<62p{)}P)d;BpV<1H z-1u3+r)w^6YcOzE!l%c(t(B?CtlY$;-};Y#rlfwP_m`9NnUgug^J*z=?>|<%ON?}yU@>$67WXtc6)#1BHHt!%@r`J0tH#PdkS@oB-RImGi) z#_(}Wf9yzIbik?YMq6pi>vn6DfOnp9ICFWvrtC#sZOi80UU7xJrQ}U#Y%6Z?R%4ac z-jutgaL)ET#PWrpz+J+r=U;E#RdJrTqnp^`fKGsqveIiSZSrw-!F-WB!}DFjs%|iF z>wkl!=Ka}=lS0Px*qobS#H*g({&s|}bUAeGPhUsG@bFAtTFLMKmyGaLVN%5Hj7@!Q zKXz?4b3w%MZbW)*EpA%DsBkuOj=-yI#jcgUsqao-bUt^3leF(bScuTwme}f-*4V1Y zwza42&BKwAGjSNS_sYyfBcJ5^W7tv1mAH_ZMe$NN;x zv0clsRK==R!l-T{aV^UIE5`X>(zP$d^<2-hO~geX<-rdZ}#>Bh1 zxwW*iv9GA7qn@6Zl#!2(iimr9cyefDUtU>NQ#m;}H8dzDBOx3b{`c|j?&{Oh(9F8F zs-mBpo0E@;hJ%8EfPH*>dwO?tb8l>CWnW)kTUu3AOGi36H5e8ZOwsc(000EXNkllPT=*Y8+;i^vo&WO=I0y8<@DYliHFfH&c=G6Bz{U?(rr;=tul7<45xS{N zu_$pYDWQwK(|oj8`M!chX~CZg@0-Sh_QV$0^e&WGlF*;c>LWkTb;7BX3KmC{DO0@A z1tMBM2mCPbW6L@BJ<~!|KoBy(8CQSYuSub*D4$1`$IFBLMV@Fr?xh4F+n2_ zdv$DB!-j(Jf57kq|;SvWahZ!a>bH-eSMRXDyDUh#4iV>5=-ku?df111r{{kHJQ21*H<}~ z@QP2zkYX)%jF#AkT&*Xdz&dx!C-qIQOJFWvg)XE@5*?A)8aBQpaJ9L+CY#i#M|#~P z&}s9~L1(PB^_t%Jyu(5_A1&NHvQgKU&iLsJbT|*5yMmmrd-d%-|G<~s$DXSw(HXgP zllxGzu)bZR9yErrS89oZ4V$mvazTUDiS_OJrsr%nY#7>^8qFNlXUXTV*#@pEze9uN zh^3sMUG7JP?nb9Z(>N^AI^MwL=I3HvCr5+j=r~ew(BRxl;#=`4xg_{%N2h8fBsP0n zg97(*b2ZG&A(86!amg=>k@$Y;vXs8uE4O{JTdh`GpJ}uO%~&axjBc|o87Dn0b~&-+ zYn3p&?T8U=r&ThLiE%)KB_WfaY!ep~JNQ5p@)IIj=QETt7!0LUv;{5X(-t&G9~2a% zyO@CZLdP9wP^z-R;XvoE7ow9&#YGB6x&DY1uU#UKR#je6vWQO1an;xsg>!^%H>(IZ zu}>_Px?_F|5UCcQATv##W%RSMJDnmFzCpuxJw4>Yj>ruLhA zck9-B>)voOIaqSO|J95Q81?o|W@hG_H$PhGv1YNLbL`OXS?cmQ>Z~U%^o%VG_Mf^G z(JHQK;x~2qtOq3Jt37EIE@iQdCMTvc%{OMMi6yC)Wh^4r!OScg7e7-olysA7HVc!R zh62XbPg;a?*eu*N(?D0eC7f9=tH#{{Zn(LwU=&!E?mMGgaAu1uaIaW3|6E25z%?=s zC9qrAwFb>!8{g9Ot?_m9`d~fj?xfBz(%6_`?Bvzo8zCRvGG;%hf%71HV<*zIH%mtC?|Mo8;>q;5f%Hu2m0000