From 81ef9c6801892ed7c0bed6fd463467c75d38cbef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 12 Jun 2020 17:23:36 -0300 Subject: [PATCH 01/11] :sparkles: Hacker News node --- .../nodes/HackerNews/GenericFunctions.ts | 69 ++++ .../nodes/HackerNews/HackerNews.node.ts | 357 ++++++++++++++++++ .../nodes/HackerNews/hackernews.png | Bin 0 -> 1952 bytes packages/nodes-base/package.json | 1 + 4 files changed, 427 insertions(+) create mode 100644 packages/nodes-base/nodes/HackerNews/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/HackerNews/HackerNews.node.ts create mode 100644 packages/nodes-base/nodes/HackerNews/hackernews.png diff --git a/packages/nodes-base/nodes/HackerNews/GenericFunctions.ts b/packages/nodes-base/nodes/HackerNews/GenericFunctions.ts new file mode 100644 index 00000000000..56f6d8aef67 --- /dev/null +++ b/packages/nodes-base/nodes/HackerNews/GenericFunctions.ts @@ -0,0 +1,69 @@ +import { + IExecuteFunctions, + IHookFunctions, +} from 'n8n-core'; + +import { + IDataObject, ILoadOptionsFunctions, +} from 'n8n-workflow'; + +import { + OptionsWithUri +} from 'request'; + + +/** + * Make an API request to HackerNews + * + * @param {IHookFunctions} this + * @param {string} method + * @param {string} endpoint + * @param {IDataObject} qs + * @returns {Promise} + */ +export async function hackerNewsApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, qs: IDataObject): Promise { // tslint:disable-line:no-any + const options: OptionsWithUri = { + method: method, + qs, + uri: `http://hn.algolia.com/api/v1/${endpoint}`, + json: true, + }; + + return await this.helpers.request!(options); +} + + +/** + * Make an API request to HackerNews + * and return all results + * + * @export + * @param {(IHookFunctions | IExecuteFunctions)} this + * @param {string} method + * @param {string} endpoint + * @param {IDataObject} qs + * @returns {Promise} + */ +export async function hackerNewsApiRequestAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, qs: IDataObject): Promise { // tslint:disable-line:no-any + + qs.hitsPerPage = 100; + + const returnData: IDataObject[] = []; + + let responseData; + let itemsReceived = 0; + + do { + responseData = await hackerNewsApiRequest.call(this, method, endpoint, qs); + returnData.push.apply(returnData, responseData.hits); + + if (returnData !== undefined) { + itemsReceived += returnData.length; + } + + } while ( + responseData.nbHits > itemsReceived + ); + + return returnData; +} diff --git a/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts b/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts new file mode 100644 index 00000000000..91edf0679d1 --- /dev/null +++ b/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts @@ -0,0 +1,357 @@ +import { + IExecuteFunctions +} from 'n8n-core'; + +import { + INodeExecutionData, + INodeType, + INodeTypeDescription, + IDataObject, +} from 'n8n-workflow'; + +import { + hackerNewsApiRequest, + hackerNewsApiRequestAllItems +} from './GenericFunctions'; + +export class HackerNews implements INodeType { + description: INodeTypeDescription = { + displayName: 'Hacker News', + name: 'hackerNews', + icon: 'file:hackernews.png', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Hacker News API', + defaults: { + name: 'Hacker News', + color: '#ff6600', + }, + inputs: ['main'], + outputs: ['main'], + properties: [ + // ---------------------------------- + // Resources + // ---------------------------------- + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Article', + value: 'article' + }, + { + name: 'User', + value: 'user' + } + ], + default: 'article', + description: 'Resource to consume.', + }, + // ---------------------------------- + // Operations + // ---------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'article' + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a Hacker News article', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all Hacker News articles', + } + ], + default: 'get', + description: 'Operation to perform.', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'user' + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a Hacker News user', + } + ], + default: 'get', + description: 'Operation to perform.', + }, + // ---------------------------------- + // Fields + // ---------------------------------- + { + displayName: 'Article ID', + name: 'articleId', + type: 'string', + required: true, + default: '', + description: 'The ID of the Hacker News article to be returned', + displayOptions: { + show: { + resource: [ + 'article' + ], + operation: [ + 'get' + ], + }, + }, + }, + { + displayName: 'Username', + name: 'username', + type: 'string', + required: true, + default: '', + description: 'The Hacker News user to be returned', + displayOptions: { + show: { + resource: [ + 'user' + ], + operation: [ + 'get' + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results for the query or only up to a limit.', + displayOptions: { + show: { + resource: [ + 'article' + ], + operation: [ + 'getAll' + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 5, + description: 'Limit of Hacker News articles to be returned for the query.', + displayOptions: { + show: { + resource: [ + 'article' + ], + operation: [ + 'getAll' + ], + returnAll: [ + false + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'article' + ], + operation: [ + 'get' + ], + }, + }, + options: [ + { + displayName: 'Include comments', + name: 'includeComments', + type: 'boolean', + default: false, + description: 'Whether to include all the comments in a Hacker News article.' + }, + ] + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'article' + ], + operation: [ + 'getAll' + ], + }, + }, + options: [ + { + displayName: 'Keyword', + name: 'keyword', + type: 'string', + default: '', + description: 'The keyword for filtering the results of the query.', + }, + { + displayName: 'Tags', + name: 'tags', + type: 'multiOptions', + options: [ + { + name: 'Story', + value: 'story', + description: 'Returns query results filtered by story tag', + }, + { + name: 'Comment', + value: 'comment', + description: 'Returns query results filtered by comment tag', + }, + { + name: 'Poll', + value: 'poll', + description: 'Returns query results filtered by poll tag', + }, + { + name: 'Show HN', + value: 'show_hn', // snake case per HN tags + description: 'Returns query results filtered by Show HN tag', + }, + { + name: 'Ask HN', + value: 'ask_hn', // snake case per HN tags + description: 'Returns query results filtered by Ask HN tag', + }, + { + name: 'Front Page', + value: 'front_page', // snake case per HN tags + description: 'Returns query results filtered by Front Page tag', + } + ], + default: '', + description: 'Tags for filtering the results of the query.', + } + ] + } + ] + }; + + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + let returnAll = false; + + for (let i = 0; i < items.length; i++) { + + let qs: IDataObject = {}; + let endpoint = ''; + let includeComments = false; + + if (resource === 'article') { + + if (operation === 'get') { + + endpoint = `items/${this.getNodeParameter('articleId', i)}`; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + includeComments = additionalFields.includeComments as boolean; + + } else if (operation === 'getAll') { + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const keyword = additionalFields.keyword as string; + const tags = additionalFields.tags as string[]; + + qs = { + query: keyword, + tags: tags ? tags.join() : '', + }; + + returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (!returnAll) { + qs.hitsPerPage = this.getNodeParameter('limit', i) as number; + } + + endpoint = 'search?'; + + } else { + throw new Error(`The operation '${operation}' is unknown!`); + } + + } else if (resource === 'user') { + + if (operation === 'get') { + endpoint = `users/${this.getNodeParameter('username', i)}`; + + } else { + throw new Error(`The operation '${operation}' is unknown!`); + } + + } else { + throw new Error(`The resource '${resource}' is unknown!`); + } + + + let responseData; + if (returnAll === true) { + responseData = await hackerNewsApiRequestAllItems.call(this, 'GET', endpoint, qs); + } else { + responseData = await hackerNewsApiRequest.call(this, 'GET', endpoint, qs); + if (resource === 'article' && operation === 'getAll') + responseData = responseData.hits; + } + + if (resource === 'article' && operation === 'get' && !includeComments) { + delete responseData.children; + } + + 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/HackerNews/hackernews.png b/packages/nodes-base/nodes/HackerNews/hackernews.png new file mode 100644 index 0000000000000000000000000000000000000000..67ba3047ff3d07ec99bd35a2124161d8779cbb0f GIT binary patch literal 1952 zcmcgrX;f3m622?~23dy1u;_@03>aYof-0>ydUrHtJ8JrRQ30LU0tVJ;N`hp zjbuQ=Fied>clIXYmPJ=lSgaVEI}$-L*ooEH787{3! zg&cVpV~#{a;AIicRe@2)aJ(DFt^?Jl(A+dU>j3VCAp~HI84wMEKd!(-%7||WM_$1= z3amZ_XM`wa1H8e4U_nZxvF4DFs?WqZ3V5g%=zR=DgD}<%apXbXUi3{&bctjLR3@TN z?-7n*Wdh=2Pb;rXgd;Y#zT~Fz8)T7 zfJf-#T;;xwP)*{!8uPt9cVQT5y?(8HOKH^CvOGD|uhGUqj_M*#S`B(IaqB5VsbWt8tBB`rT-YVBsVwi$} z;q2r?e{pNC_~4+I=8hY;6wSO&)6CsFmld@I47{pJwu+RbKWO-m$<{Pf@p+w+fw?YF zauLjf&%^yedbyLVrK-J##%Wncd~^&$(x{V9o?PKRZ)`E`uqx`ayD+P@(XvNJdnINW zpRwtOoDVAArp+s6G%~Jfy=Jtoa505La1&7L+tSNBLDE3f& zwM3`q{8X~5%Z1!7lb-du2JxJWLL0aKBWn$!Y=gcl5URPxm-uN0zMi{o^=7%HS%pb` z$E1_J;fhPl$cFR>A0iqb8l8LDXZq4@c>KYP4p~p5KxC!;N;oz?o>O*RaaNbI`_1dS z=O;RD)~lCxtLx9Z8Lq|HC^{9 zGhd$mI)CsXpYcnObwy#u3Ja3l^*gsFcM5fNYUS??`hF*1<@u5euFsV*D-_#n_D5&B zPPXrqkG}n;@DIz?2F+H*0}~DWT*q~y=Q53m1Cg5@Gq0-ejy&U3&WIJnlnqY?NMtvJ zVD4G`bnii4aHmy31^1SnzmWl1J#5#e@6+OzneQCgN;Z6+^5WZACY7(J6m!DK{L7iF zg~RU-t-4N;~d@2^W0Ew)D=QXI8U*FFo+A;*OKywy{=d)eW5C+m`RH^buHRnz7< z{VmOHP0pHCHF@``yEt#1xPvJV^b(?GU6OuP3^LiB9@M-I1I9N;$vTWUEL^^HfjPQCLpp_Pt7HN$|eaf^rL5r*`^NR_c-PGO7iaX6W?@Of*r zyV*3(wrK zi)DVfrTcGd1~sKsnMRI|^AmnI8medBr)p(C6^aWQPPlhI0ycpjS_;*TB#rL_i?*CR z@|(UM{nQ5~hbYqDAHS(`XM3{UI@K(rmi{fOPM%-s-dU?})^u#xySZ;!%;3NFlndIi z>ZAfSuc4DfDB0URSDmzM~RZ_vItLaLJ`Z@la^b^oXmF+%T^AtfbkX&m_ykIvVH7is+u!`pE zN86W2+t)*jHceS@T>2?QXW+N>eG~5;NbVm^b`3YLBL1m9-{luR?HG^8KFY^vVe9}t zn`IOk!aroRox$|lo2X7EKx-F4EMm((=-&v;nrcn8pl-CVqWW0b*i-H7tv67pRC_9w h;ugB+zX-fztPoDr|0ej7TSNo_W4L%amu~Y<`UkDC^S=N9 literal 0 HcmV?d00001 diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 37b9adecf23..c27f5fa98bb 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -190,6 +190,7 @@ "dist/nodes/Google/Sheet/GoogleSheets.node.js", "dist/nodes/GraphQL/GraphQL.node.js", "dist/nodes/Gumroad/GumroadTrigger.node.js", + "dist/nodes/HackerNews/HackerNews.node.js", "dist/nodes/Harvest/Harvest.node.js", "dist/nodes/HelpScout/HelpScout.node.js", "dist/nodes/HelpScout/HelpScoutTrigger.node.js", From 86a42f47474e9484326b32eaf5a77016267ee406 Mon Sep 17 00:00:00 2001 From: ricardo Date: Wed, 24 Jun 2020 14:02:17 -0400 Subject: [PATCH 02/11] :zap: Improvements to HackerNews-Node --- .../nodes/HackerNews/GenericFunctions.ts | 19 ++++-- .../nodes/HackerNews/HackerNews.node.ts | 63 ++++++++++--------- 2 files changed, 47 insertions(+), 35 deletions(-) diff --git a/packages/nodes-base/nodes/HackerNews/GenericFunctions.ts b/packages/nodes-base/nodes/HackerNews/GenericFunctions.ts index 56f6d8aef67..76fbdd3bb5a 100644 --- a/packages/nodes-base/nodes/HackerNews/GenericFunctions.ts +++ b/packages/nodes-base/nodes/HackerNews/GenericFunctions.ts @@ -4,11 +4,12 @@ import { } from 'n8n-core'; import { - IDataObject, ILoadOptionsFunctions, + IDataObject, + ILoadOptionsFunctions, } from 'n8n-workflow'; import { - OptionsWithUri + OptionsWithUri, } from 'request'; @@ -23,13 +24,23 @@ import { */ export async function hackerNewsApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, qs: IDataObject): Promise { // tslint:disable-line:no-any const options: OptionsWithUri = { - method: method, + method, qs, uri: `http://hn.algolia.com/api/v1/${endpoint}`, json: true, }; - return await this.helpers.request!(options); + try { + return await this.helpers.request!(options); + } catch (error) { + + if (error.response && error.response.body && error.response.body.error) { + // Try to return the error prettier + throw new Error(`Hacker News error response [${error.statusCode}]: ${error.response.body.error}`); + } + + throw error; + } } diff --git a/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts b/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts index 91edf0679d1..65afe2b83c6 100644 --- a/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts +++ b/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts @@ -1,5 +1,5 @@ import { - IExecuteFunctions + IExecuteFunctions, } from 'n8n-core'; import { @@ -11,7 +11,7 @@ import { import { hackerNewsApiRequest, - hackerNewsApiRequestAllItems + hackerNewsApiRequestAllItems, } from './GenericFunctions'; export class HackerNews implements INodeType { @@ -40,12 +40,12 @@ export class HackerNews implements INodeType { options: [ { name: 'Article', - value: 'article' + value: 'article', }, { name: 'User', - value: 'user' - } + value: 'user', + }, ], default: 'article', description: 'Resource to consume.', @@ -60,7 +60,7 @@ export class HackerNews implements INodeType { displayOptions: { show: { resource: [ - 'article' + 'article', ], }, }, @@ -74,7 +74,7 @@ export class HackerNews implements INodeType { name: 'Get All', value: 'getAll', description: 'Get all Hacker News articles', - } + }, ], default: 'get', description: 'Operation to perform.', @@ -86,7 +86,7 @@ export class HackerNews implements INodeType { displayOptions: { show: { resource: [ - 'user' + 'user', ], }, }, @@ -95,7 +95,7 @@ export class HackerNews implements INodeType { name: 'Get', value: 'get', description: 'Get a Hacker News user', - } + }, ], default: 'get', description: 'Operation to perform.', @@ -113,10 +113,10 @@ export class HackerNews implements INodeType { displayOptions: { show: { resource: [ - 'article' + 'article', ], operation: [ - 'get' + 'get', ], }, }, @@ -131,10 +131,10 @@ export class HackerNews implements INodeType { displayOptions: { show: { resource: [ - 'user' + 'user', ], operation: [ - 'get' + 'get', ], }, }, @@ -148,10 +148,10 @@ export class HackerNews implements INodeType { displayOptions: { show: { resource: [ - 'article' + 'article', ], operation: [ - 'getAll' + 'getAll', ], }, }, @@ -165,13 +165,13 @@ export class HackerNews implements INodeType { displayOptions: { show: { resource: [ - 'article' + 'article', ], operation: [ - 'getAll' + 'getAll', ], returnAll: [ - false + false, ], }, }, @@ -185,10 +185,10 @@ export class HackerNews implements INodeType { displayOptions: { show: { resource: [ - 'article' + 'article', ], operation: [ - 'get' + 'get', ], }, }, @@ -198,9 +198,9 @@ export class HackerNews implements INodeType { name: 'includeComments', type: 'boolean', default: false, - description: 'Whether to include all the comments in a Hacker News article.' + description: 'Whether to include all the comments in a Hacker News article.', }, - ] + ], }, { displayName: 'Additional Fields', @@ -211,10 +211,10 @@ export class HackerNews implements INodeType { displayOptions: { show: { resource: [ - 'article' + 'article', ], operation: [ - 'getAll' + 'getAll', ], }, }, @@ -260,14 +260,14 @@ export class HackerNews implements INodeType { name: 'Front Page', value: 'front_page', // snake case per HN tags description: 'Returns query results filtered by Front Page tag', - } + }, ], default: '', description: 'Tags for filtering the results of the query.', - } - ] - } - ] + }, + ], + }, + ], }; @@ -335,8 +335,9 @@ export class HackerNews implements INodeType { responseData = await hackerNewsApiRequestAllItems.call(this, 'GET', endpoint, qs); } else { responseData = await hackerNewsApiRequest.call(this, 'GET', endpoint, qs); - if (resource === 'article' && operation === 'getAll') - responseData = responseData.hits; + if (resource === 'article' && operation === 'getAll') { + responseData = responseData.hits; + } } if (resource === 'article' && operation === 'get' && !includeComments) { From 05507fc19b848b8fd8f293b817da734154210d74 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Thu, 9 Jul 2020 15:17:47 +0200 Subject: [PATCH 03/11] :tada: basic throtteling with cleaning logs --- .../cli/src/WorkflowExecuteAdditionalData.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index ed20a985f80..86e5c3fe22b 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -41,6 +41,8 @@ import { import * as config from '../config'; +import { LessThanOrEqual } from "typeorm"; + /** * Checks if there was an error and if errorWorkflow is defined. If so it collects @@ -79,6 +81,25 @@ function executeErrorWorkflow(workflowData: IWorkflowBase, fullRunData: IRun, mo } } +/** + * Prunes Saved Execution which are older than configured. + * Throttled to be executed just once in configured timeframe. + * + */ +let inThrottle: boolean; +function pruneSavedExecutions(): void { + console.log('THROTTLE:', inThrottle); + if (!inThrottle) { + inThrottle = true; + Db.collections.Execution!.delete({ startedAt: LessThanOrEqual(new Date().toISOString()) }); + console.log('Deleting logs'); + setTimeout(() => { + console.log('resetting throttle'); + inThrottle = false; + }, 30000); + } +} + /** * Pushes the execution out to all connected clients @@ -251,6 +272,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { // Save the Execution in DB const executionResult = await Db.collections.Execution!.save(executionData as IExecutionFlattedDb); + pruneSavedExecutions() if (fullRunData.finished === true && this.retryOf !== undefined) { // If the retry was successful save the reference it on the original execution From b956444c0e41c24c426daaac91e597093c7ef129 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Thu, 9 Jul 2020 16:52:38 +0200 Subject: [PATCH 04/11] :racehorse: pruning execution data complete --- packages/cli/config/index.ts | 25 ++++++++++++-- .../cli/src/WorkflowExecuteAdditionalData.ts | 33 ++++++++++++------- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 847587460f0..0022c08b7ad 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -161,8 +161,8 @@ const config = convict({ // If a workflow executes all the data gets saved by default. This // could be a problem when a workflow gets executed a lot and processes - // a lot of data. To not write the database full it is possible to - // not save the execution at all. + // a lot of data. To not exceed the database's capacity it is possible to + // prune the database regularly or to not save the execution at all. // Depending on if the execution did succeed or error a different // save behaviour can be set. saveDataOnError: { @@ -188,6 +188,27 @@ const config = convict({ default: false, env: 'EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS' }, + + // To not exceed the database's capacity and keep its size moderate + // the execution data gets pruned regularly (default: 1 hour interval). + // All saved execution data older than the max age will be deleted. + // Pruning is currently not activated by default, which will change in + // a future version. + pruneData: { + doc: 'Delete data of past executions on a rolling basis', + default: false, + env: 'EXECUTIONS_DATA_PRUNE' + }, + pruneDataMaxAge: { + doc: 'How old (hours) the execution data has to be to get deleted', + default: 336, + env: 'EXECUTIONS_DATA_MAX_AGE' + }, + pruneDataTimeout: { + doc: 'Timeout (ms) after execution data has been pruned', + default: 3600000, + env: 'EXECUTIONS_DATA_PRUNE_TIMEOUT' + }, }, generic: { diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 86e5c3fe22b..9b55f88b587 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -86,17 +86,22 @@ function executeErrorWorkflow(workflowData: IWorkflowBase, fullRunData: IRun, mo * Throttled to be executed just once in configured timeframe. * */ -let inThrottle: boolean; -function pruneSavedExecutions(): void { - console.log('THROTTLE:', inThrottle); - if (!inThrottle) { - inThrottle = true; - Db.collections.Execution!.delete({ startedAt: LessThanOrEqual(new Date().toISOString()) }); - console.log('Deleting logs'); - setTimeout(() => { - console.log('resetting throttle'); - inThrottle = false; - }, 30000); +let throttling: boolean; +function pruneExecutionData(): void { + if (!throttling) { + throttling = true; + const timeout = config.get('executions.pruneDataTimeout') as number; // in ms + const maxAge = config.get('executions.pruneDataMaxAge') as number; // in h + const date = new Date(); // today + date.setHours(date.getHours() - maxAge); + + // throttle just on success to allow for self healing on failure + Db.collections.Execution!.delete({ startedAt: LessThanOrEqual(date.toISOString()) }) + .then(data => + setTimeout(() => { + throttling = false; + }, timeout) + ).catch(err => throttling = false) } } @@ -272,7 +277,6 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { // Save the Execution in DB const executionResult = await Db.collections.Execution!.save(executionData as IExecutionFlattedDb); - pruneSavedExecutions() if (fullRunData.finished === true && this.retryOf !== undefined) { // If the retry was successful save the reference it on the original execution @@ -280,6 +284,11 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { await Db.collections.Execution!.update(this.retryOf, { retrySuccessId: executionResult.id }); } + // Prune old execution data + if (config.get('executions.pruneData')) { + pruneExecutionData() + } + if (!isManualMode) { executeErrorWorkflow(this.workflowData, fullRunData, this.mode, executionResult ? executionResult.id as string : undefined, this.retryOf); } From 941ee06b146d0c72dc8de56288dd45d6abea945a Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 12 Jul 2020 09:28:08 +0200 Subject: [PATCH 05/11] :zap: Make n8n work in subfolder & Fix events in AffinityTrigger --- docker/compose/subfolderWithSSL/.env | 25 ++++++++ docker/compose/subfolderWithSSL/README.md | 26 +++++++++ .../subfolderWithSSL/docker-compose.yml | 57 +++++++++++++++++++ packages/cli/config/index.ts | 7 +++ packages/cli/src/GenericHelpers.ts | 5 +- packages/cli/src/Server.ts | 39 ++++++++++--- packages/editor-ui/public/index.html | 3 +- .../editor-ui/src/components/MainSidebar.vue | 4 +- packages/editor-ui/src/router.ts | 3 +- packages/editor-ui/src/store.ts | 3 +- packages/editor-ui/vue.config.js | 2 +- .../nodes/Affinity/AffinityTrigger.node.ts | 6 +- 12 files changed, 161 insertions(+), 19 deletions(-) create mode 100644 docker/compose/subfolderWithSSL/.env create mode 100644 docker/compose/subfolderWithSSL/README.md create mode 100644 docker/compose/subfolderWithSSL/docker-compose.yml diff --git a/docker/compose/subfolderWithSSL/.env b/docker/compose/subfolderWithSSL/.env new file mode 100644 index 00000000000..7008bd631a0 --- /dev/null +++ b/docker/compose/subfolderWithSSL/.env @@ -0,0 +1,25 @@ +# Folder where data should be saved +DATA_FOLDER=/root/n8n/ + +# The top level domain to serve from +DOMAIN_NAME=example.com + +# The subfolder to serve from +SUBFOLDER=app1 +N8N_PATH=/app1/ + +# DOMAIN_NAME and SUBDOMAIN combined decide where n8n will be reachable from +# above example would result in: https://example.com/n8n/ + +# The user name to use for autentication - IMPORTANT ALWAYS CHANGE! +N8N_BASIC_AUTH_USER=user + +# The password to use for autentication - IMPORTANT ALWAYS CHANGE! +N8N_BASIC_AUTH_PASSWORD=password + +# Optional timezone to set which gets used by Cron-Node by default +# If not set New York time will be used +GENERIC_TIMEZONE=Europe/Berlin + +# The email address to use for the SSL certificate creation +SSL_EMAIL=user@example.com diff --git a/docker/compose/subfolderWithSSL/README.md b/docker/compose/subfolderWithSSL/README.md new file mode 100644 index 00000000000..61fcb5b7e74 --- /dev/null +++ b/docker/compose/subfolderWithSSL/README.md @@ -0,0 +1,26 @@ +# n8n on Subfolder with SSL + +Starts n8n and deployes it on a subfolder + + +## Start + +To start n8n in a subfolder simply start docker-compose by executing the following +command in the current folder. + + +**IMPORTANT:** But before you do that change the default users and passwords in the `.env` file! + +``` +docker-compose up -d +``` + +To stop it execute: + +``` +docker-compose stop +``` + +## Configuration + +The default name of the database, user and password for MongoDB can be changed in the `.env` file in the current directory. diff --git a/docker/compose/subfolderWithSSL/docker-compose.yml b/docker/compose/subfolderWithSSL/docker-compose.yml new file mode 100644 index 00000000000..5e540abbb51 --- /dev/null +++ b/docker/compose/subfolderWithSSL/docker-compose.yml @@ -0,0 +1,57 @@ +version: "3" + +services: + traefik: + image: "traefik" + command: + - "--api=true" + - "--api.insecure=true" + - "--api.dashboard=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.websecure.address=:443" + - "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" + - "--certificatesresolvers.mytlschallenge.acme.email=${SSL_EMAIL}" + - "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json" + - /home/jan/www/n8n/n8n:/data + ports: + - "443:443" + - "80:80" + volumes: + - ${DATA_FOLDER}/letsencrypt:/letsencrypt + - /var/run/docker.sock:/var/run/docker.sock:ro + n8n: + image: n8nio/n8n + ports: + - "127.0.0.1:5678:5678" + labels: + - traefik.enable=true + - traefik.http.routers.n8n.rule=Host(`${DOMAIN_NAME}`) + - traefik.http.routers.n8n.tls=true + - traefik.http.routers.n8n.entrypoints=websecure + - "traefik.http.routers.n8n.rule=PathPrefix(`/${SUBFOLDER}{regex:$$|/.*}`)" + - "traefik.http.middlewares.n8n-stripprefix.stripprefix.prefixes=/${SUBFOLDER}" + - "traefik.http.routers.n8n.middlewares=n8n-stripprefix" + - traefik.http.routers.n8n.tls.certresolver=mytlschallenge + - traefik.http.middlewares.n8n.headers.SSLRedirect=true + - traefik.http.middlewares.n8n.headers.STSSeconds=315360000 + - traefik.http.middlewares.n8n.headers.browserXSSFilter=true + - traefik.http.middlewares.n8n.headers.contentTypeNosniff=true + - traefik.http.middlewares.n8n.headers.forceSTSHeader=true + - traefik.http.middlewares.n8n.headers.SSLHost=${DOMAIN_NAME} + - traefik.http.middlewares.n8n.headers.STSIncludeSubdomains=true + - traefik.http.middlewares.n8n.headers.STSPreload=true + environment: + - N8N_BASIC_AUTH_ACTIVE=true + - N8N_BASIC_AUTH_USER + - N8N_BASIC_AUTH_PASSWORD + - N8N_HOST=${DOMAIN_NAME} + - N8N_PORT=5678 + - N8N_PROTOCOL=https + - NODE_ENV=production + - N8N_PATH + - WEBHOOK_TUNNEL_URL=http://${DOMAIN_NAME}${N8N_PATH} + - VUE_APP_URL_BASE_API=http://${DOMAIN_NAME}${N8N_PATH} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${DATA_FOLDER}/.n8n:/root/.n8n diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 847587460f0..380a93c4a81 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -204,6 +204,13 @@ const config = convict({ }, // How n8n can be reached (Editor & REST-API) + path: { + format: String, + default: '/', + arg: 'path', + env: 'N8N_PATH', + doc: 'Path n8n is deployed to' + }, host: { format: String, default: 'localhost', diff --git a/packages/cli/src/GenericHelpers.ts b/packages/cli/src/GenericHelpers.ts index 8b02b73e8e4..cab67f7bce9 100644 --- a/packages/cli/src/GenericHelpers.ts +++ b/packages/cli/src/GenericHelpers.ts @@ -40,11 +40,12 @@ export function getBaseUrl(): string { const protocol = config.get('protocol') as string; const host = config.get('host') as string; const port = config.get('port') as number; + const path = config.get('path') as string; if (protocol === 'http' && port === 80 || protocol === 'https' && port === 443) { - return `${protocol}://${host}/`; + return `${protocol}://${host}${path}`; } - return `${protocol}://${host}:${port}/`; + return `${protocol}://${host}:${port}${path}`; } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 2507b6b1365..1a0b75842ab 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -931,7 +931,8 @@ class App { // Authorize OAuth Data this.app.get(`/${this.restEndpoint}/oauth1-credential/auth`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { if (req.query.id === undefined) { - throw new Error('Required credential id is missing!'); + res.status(500).send('Required credential id is missing!'); + return ''; } const result = await Db.collections.Credentials!.findOne(req.query.id as string); @@ -943,7 +944,8 @@ class App { let encryptionKey = undefined; encryptionKey = await UserSettings.getEncryptionKey(); if (encryptionKey === undefined) { - throw new Error('No encryption key got found to decrypt the credentials!'); + res.status(500).send('No encryption key got found to decrypt the credentials!'); + return ''; } // Decrypt the currently saved credentials @@ -1015,7 +1017,8 @@ class App { const { oauth_verifier, oauth_token, cid } = req.query; if (oauth_verifier === undefined || oauth_token === undefined) { - throw new Error('Insufficient parameters for OAuth1 callback'); + const errorResponse = new ResponseHelper.ResponseError('Insufficient parameters for OAuth1 callback. Received following query parameters: ' + JSON.stringify(req.query), undefined, 503); + return ResponseHelper.sendErrorResponse(res, errorResponse); } const result = await Db.collections.Credentials!.findOne(cid as any); // tslint:disable-line:no-any @@ -1085,7 +1088,8 @@ class App { // Authorize OAuth Data this.app.get(`/${this.restEndpoint}/oauth2-credential/auth`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { if (req.query.id === undefined) { - throw new Error('Required credential id is missing!'); + res.status(500).send('Required credential id is missing.'); + return ''; } const result = await Db.collections.Credentials!.findOne(req.query.id as string); @@ -1097,7 +1101,8 @@ class App { let encryptionKey = undefined; encryptionKey = await UserSettings.getEncryptionKey(); if (encryptionKey === undefined) { - throw new Error('No encryption key got found to decrypt the credentials!'); + res.status(500).send('No encryption key got found to decrypt the credentials!'); + return ''; } // Decrypt the currently saved credentials @@ -1161,7 +1166,8 @@ class App { const {code, state: stateEncoded } = req.query; if (code === undefined || stateEncoded === undefined) { - throw new Error('Insufficient parameters for OAuth2 callback'); + const errorResponse = new ResponseHelper.ResponseError('Insufficient parameters for OAuth2 callback. Received following query parameters: ' + JSON.stringify(req.query), undefined, 503); + return ResponseHelper.sendErrorResponse(res, errorResponse); } let state; @@ -1211,17 +1217,20 @@ class App { }, }; } + const redirectUri = `${WebhookHelpers.getWebhookBaseUrl()}${this.restEndpoint}/oauth2-credential/callback`; const oAuthObj = new clientOAuth2({ clientId: _.get(oauthCredentials, 'clientId') as string, clientSecret: _.get(oauthCredentials, 'clientSecret', '') as string, accessTokenUri: _.get(oauthCredentials, 'accessTokenUrl', '') as string, authorizationUri: _.get(oauthCredentials, 'authUrl', '') as string, - redirectUri: `${WebhookHelpers.getWebhookBaseUrl()}${this.restEndpoint}/oauth2-credential/callback`, + redirectUri, scopes: _.split(_.get(oauthCredentials, 'scope', 'openid,') as string, ',') }); - const oauthToken = await oAuthObj.code.getToken(req.originalUrl, options); + const queryParameters = req.originalUrl.split('?').splice(1, 1).join(''); + + const oauthToken = await oAuthObj.code.getToken(`${redirectUri}?${queryParameters}`, options); if (oauthToken === undefined) { const errorResponse = new ResponseHelper.ResponseError('Unable to get access tokens!', undefined, 404); @@ -1693,9 +1702,21 @@ class App { }); } + + // Read the index file and replace the path placeholder + const editorUiPath = require.resolve('n8n-editor-ui'); + const filePath = pathJoin(pathDirname(editorUiPath), 'dist', 'index.html'); + let readIndexFile = readFileSync(filePath, 'utf8'); + const n8nPath = config.get('path'); + readIndexFile = readIndexFile.replace(/\/%BASE_PATH%\//g, n8nPath); + + // Serve the altered index.html file separately + this.app.get(`/index.html`, async (req: express.Request, res: express.Response) => { + res.send(readIndexFile); + }); + // Serve the website const startTime = (new Date()).toUTCString(); - const editorUiPath = require.resolve('n8n-editor-ui'); this.app.use('/', express.static(pathJoin(pathDirname(editorUiPath), 'dist'), { index: 'index.html', setHeaders: (res, path) => { diff --git a/packages/editor-ui/public/index.html b/packages/editor-ui/public/index.html index 2f2450023d4..9193fda976e 100644 --- a/packages/editor-ui/public/index.html +++ b/packages/editor-ui/public/index.html @@ -4,7 +4,8 @@ - + + n8n.io - Workflow Automation diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index 71388c2cd33..a9cf787ef40 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -16,7 +16,7 @@ @@ -208,6 +208,8 @@ export default mixins( data () { return { aboutDialogVisible: false, + // @ts-ignore + basePath: window.BASE_PATH, isCollapsed: true, credentialNewDialogVisible: false, credentialOpenDialogVisible: false, diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index e82b30b5883..4754098c97b 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -8,7 +8,8 @@ Vue.use(Router); export default new Router({ mode: 'history', - base: process.env.BASE_URL, + // @ts-ignore + base: window.BASE_PATH, routes: [ { path: '/execution/:id', diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index 80454fc9e43..0e1e5f14c44 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -38,7 +38,8 @@ export const store = new Vuex.Store({ activeWorkflows: [] as string[], activeActions: [] as string[], activeNode: null as string | null, - baseUrl: process.env.VUE_APP_URL_BASE_API ? process.env.VUE_APP_URL_BASE_API : '/', + // @ts-ignore + baseUrl: window.BASE_PATH ? window.BASE_PATH : '/', credentials: null as ICredentialsResponse[] | null, credentialTypes: null as ICredentialType[] | null, endpointWebhook: 'webhook', diff --git a/packages/editor-ui/vue.config.js b/packages/editor-ui/vue.config.js index cdcd8259f95..c5ffc5fed87 100644 --- a/packages/editor-ui/vue.config.js +++ b/packages/editor-ui/vue.config.js @@ -29,5 +29,5 @@ module.exports = { }, }, }, - publicPath: process.env.VUE_APP_PUBLIC_PATH ? process.env.VUE_APP_PUBLIC_PATH : '/', + publicPath: process.env.VUE_APP_PUBLIC_PATH ? process.env.VUE_APP_PUBLIC_PATH : '/%BASE_PATH%/', }; diff --git a/packages/nodes-base/nodes/Affinity/AffinityTrigger.node.ts b/packages/nodes-base/nodes/Affinity/AffinityTrigger.node.ts index 6b57d468047..3d387e7b4db 100644 --- a/packages/nodes-base/nodes/Affinity/AffinityTrigger.node.ts +++ b/packages/nodes-base/nodes/Affinity/AffinityTrigger.node.ts @@ -52,10 +52,10 @@ export class AffinityTrigger implements INodeType { options: [ { name: 'file.created', - value: 'file.deleted', + value: 'file.created', }, { - name: 'file.created', + name: 'file.deleted', value: 'file.deleted', }, { @@ -136,7 +136,7 @@ export class AffinityTrigger implements INodeType { }, { name: 'opportunity.deleted', - value: 'organization.deleted', + value: 'opportunity.deleted', }, { name: 'person.created', From 8bfc5a4b65803cd9afef1fce106f13968eb5685d Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 12 Jul 2020 12:48:32 +0200 Subject: [PATCH 06/11] :zap: Small improvements on execution pruning --- packages/cli/config/index.ts | 4 ++-- .../cli/src/WorkflowExecuteAdditionalData.ts | 20 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 23906174665..9492b4cb634 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -205,8 +205,8 @@ const config = convict({ env: 'EXECUTIONS_DATA_MAX_AGE' }, pruneDataTimeout: { - doc: 'Timeout (ms) after execution data has been pruned', - default: 3600000, + doc: 'Timeout (seconds) after execution data has been pruned', + default: 3600, env: 'EXECUTIONS_DATA_PRUNE_TIMEOUT' }, }, diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 9b55f88b587..4230e79f58f 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -86,22 +86,22 @@ function executeErrorWorkflow(workflowData: IWorkflowBase, fullRunData: IRun, mo * Throttled to be executed just once in configured timeframe. * */ -let throttling: boolean; +let throttling = false; function pruneExecutionData(): void { if (!throttling) { throttling = true; - const timeout = config.get('executions.pruneDataTimeout') as number; // in ms + const timeout = config.get('executions.pruneDataTimeout') as number; // in seconds const maxAge = config.get('executions.pruneDataMaxAge') as number; // in h const date = new Date(); // today date.setHours(date.getHours() - maxAge); // throttle just on success to allow for self healing on failure - Db.collections.Execution!.delete({ startedAt: LessThanOrEqual(date.toISOString()) }) + Db.collections.Execution!.delete({ stoppedAt: LessThanOrEqual(date.toISOString()) }) .then(data => setTimeout(() => { throttling = false; - }, timeout) - ).catch(err => throttling = false) + }, timeout * 1000) + ).catch(err => throttling = false); } } @@ -215,6 +215,11 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { workflowExecuteAfter: [ async function (this: WorkflowHooks, fullRunData: IRun, newStaticData: IDataObject): Promise { + // Prune old execution data + if (config.get('executions.pruneData')) { + pruneExecutionData(); + } + const isManualMode = [this.mode, parentProcessMode].includes('manual'); try { @@ -284,11 +289,6 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { await Db.collections.Execution!.update(this.retryOf, { retrySuccessId: executionResult.id }); } - // Prune old execution data - if (config.get('executions.pruneData')) { - pruneExecutionData() - } - if (!isManualMode) { executeErrorWorkflow(this.workflowData, fullRunData, this.mode, executionResult ? executionResult.id as string : undefined, this.retryOf); } From 687f6087146aa9194ad37b69fec79cc00fecfbff Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 12 Jul 2020 17:18:50 +0200 Subject: [PATCH 07/11] :zap: Small adjustment to HN-Node --- .../nodes/HackerNews/HackerNews.node.ts | 66 +++++++++++++------ 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts b/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts index 65afe2b83c6..b77d5d35bd9 100644 --- a/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts +++ b/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts @@ -38,6 +38,10 @@ export class HackerNews implements INodeType { name: 'resource', type: 'options', options: [ + { + name: 'All', + value: 'all', + }, { name: 'Article', value: 'article', @@ -50,9 +54,32 @@ export class HackerNews implements INodeType { default: 'article', description: 'Resource to consume.', }, + + // ---------------------------------- // Operations // ---------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'all', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'Get all items', + }, + ], + default: 'getAll', + description: 'Operation to perform.', + }, { displayName: 'Operation', name: 'operation', @@ -70,11 +97,6 @@ export class HackerNews implements INodeType { value: 'get', description: 'Get a Hacker News article', }, - { - name: 'Get All', - value: 'getAll', - description: 'Get all Hacker News articles', - }, ], default: 'get', description: 'Operation to perform.', @@ -148,7 +170,7 @@ export class HackerNews implements INodeType { displayOptions: { show: { resource: [ - 'article', + 'all', ], operation: [ 'getAll', @@ -160,12 +182,12 @@ export class HackerNews implements INodeType { displayName: 'Limit', name: 'limit', type: 'number', - default: 5, + default: 100, description: 'Limit of Hacker News articles to be returned for the query.', displayOptions: { show: { resource: [ - 'article', + 'all', ], operation: [ 'getAll', @@ -211,7 +233,7 @@ export class HackerNews implements INodeType { displayOptions: { show: { resource: [ - 'article', + 'all', ], operation: [ 'getAll', @@ -285,15 +307,8 @@ export class HackerNews implements INodeType { let endpoint = ''; let includeComments = false; - if (resource === 'article') { - - if (operation === 'get') { - - endpoint = `items/${this.getNodeParameter('articleId', i)}`; - const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; - includeComments = additionalFields.includeComments as boolean; - - } else if (operation === 'getAll') { + if (resource === 'all') { + if (operation === 'getAll') { const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; const keyword = additionalFields.keyword as string; @@ -301,7 +316,7 @@ export class HackerNews implements INodeType { qs = { query: keyword, - tags: tags ? tags.join() : '', + tags: tags ? tags.join() : '', }; returnAll = this.getNodeParameter('returnAll', i) as boolean; @@ -315,6 +330,17 @@ export class HackerNews implements INodeType { } else { throw new Error(`The operation '${operation}' is unknown!`); } + } else if (resource === 'article') { + + if (operation === 'get') { + + endpoint = `items/${this.getNodeParameter('articleId', i)}`; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + includeComments = additionalFields.includeComments as boolean; + + } else { + throw new Error(`The operation '${operation}' is unknown!`); + } } else if (resource === 'user') { @@ -335,7 +361,7 @@ export class HackerNews implements INodeType { responseData = await hackerNewsApiRequestAllItems.call(this, 'GET', endpoint, qs); } else { responseData = await hackerNewsApiRequest.call(this, 'GET', endpoint, qs); - if (resource === 'article' && operation === 'getAll') { + if (resource === 'all' && operation === 'getAll') { responseData = responseData.hits; } } From b46161aee761c133ab35bfa8e42b7730fd9ab1ae Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Sun, 12 Jul 2020 12:12:32 -0400 Subject: [PATCH 08/11] :sparkles: Xero Integration (#639) * :sparkles: Xero Integration * :zap: Add contact resource * :bug: Small fix --- .../credentials/XeroOAuth2Api.credentials.ts | 51 + .../nodes/Xero/ContactDescription.ts | 838 +++++++++++++++ .../nodes-base/nodes/Xero/GenericFunctions.ts | 76 ++ .../nodes/Xero/IContactInterface.ts | 44 + .../nodes/Xero/InvoiceDescription.ts | 983 ++++++++++++++++++ .../nodes-base/nodes/Xero/InvoiceInterface.ts | 40 + packages/nodes-base/nodes/Xero/Xero.node.ts | 681 ++++++++++++ packages/nodes-base/nodes/Xero/xero.png | Bin 0 -> 9587 bytes packages/nodes-base/package.json | 2 + 9 files changed, 2715 insertions(+) create mode 100644 packages/nodes-base/credentials/XeroOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/Xero/ContactDescription.ts create mode 100644 packages/nodes-base/nodes/Xero/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Xero/IContactInterface.ts create mode 100644 packages/nodes-base/nodes/Xero/InvoiceDescription.ts create mode 100644 packages/nodes-base/nodes/Xero/InvoiceInterface.ts create mode 100644 packages/nodes-base/nodes/Xero/Xero.node.ts create mode 100644 packages/nodes-base/nodes/Xero/xero.png diff --git a/packages/nodes-base/credentials/XeroOAuth2Api.credentials.ts b/packages/nodes-base/credentials/XeroOAuth2Api.credentials.ts new file mode 100644 index 00000000000..2db47c13de6 --- /dev/null +++ b/packages/nodes-base/credentials/XeroOAuth2Api.credentials.ts @@ -0,0 +1,51 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'offline_access', + 'accounting.transactions', + 'accounting.settings', + 'accounting.contacts', +]; + +export class XeroOAuth2Api implements ICredentialType { + name = 'xeroOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Xero OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://login.xero.com/identity/connect/authorize', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://identity.xero.com/connect/token', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' '), + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'header', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Xero/ContactDescription.ts b/packages/nodes-base/nodes/Xero/ContactDescription.ts new file mode 100644 index 00000000000..418aef44acd --- /dev/null +++ b/packages/nodes-base/nodes/Xero/ContactDescription.ts @@ -0,0 +1,838 @@ +import { + INodeProperties, + } from 'n8n-workflow'; + +export const contactOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'contact', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'create a contact', + }, + { + name: 'Get', + value: 'get', + description: 'Get a contact', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all contacts', + }, + { + name: 'Update', + value: 'update', + description: 'Update a contact', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const contactFields = [ + +/* -------------------------------------------------------------------------- */ +/* contact:create */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'create', + ], + }, + }, + description: 'Full name of contact/organisation', + required: true, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Account Number', + name: 'accountNumber', + type: 'string', + default: '', + description: 'A user defined account number', + }, + // { + // displayName: 'Addresses', + // name: 'addressesUi', + // type: 'fixedCollection', + // typeOptions: { + // multipleValues: true, + // }, + // default: '', + // placeholder: 'Add Address', + // options: [ + // { + // name: 'addressesValues', + // displayName: 'Address', + // values: [ + // { + // displayName: 'Type', + // name: 'type', + // type: 'options', + // options: [ + // { + // name: 'PO Box', + // value: 'POBOX', + // }, + // { + // name: 'Street', + // value: 'STREET', + // }, + // ], + // default: '', + // }, + // { + // displayName: 'Line 1', + // name: 'line1', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Line 2', + // name: 'line2', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'City', + // name: 'city', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Region', + // name: 'region', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Postal Code', + // name: 'postalCode', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Country', + // name: 'country', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Attention To', + // name: 'attentionTo', + // type: 'string', + // default: '', + // }, + // ], + // }, + // ], + // }, + { + displayName: 'Bank Account Details', + name: 'bankAccountDetails', + type: 'string', + default: '', + description: 'Bank account number of contact', + }, + { + displayName: 'Contact Number', + name: 'contactNumber', + type: 'string', + default: '', + description: 'This field is read only on the Xero contact screen, used to identify contacts in external systems', + }, + { + displayName: 'Contact Status', + name: 'contactStatus', + type: 'options', + options: [ + { + name: 'Active', + value: 'ACTIVE', + description: 'The Contact is active and can be used in transactions', + }, + { + name: 'Archived', + value: 'ARCHIVED', + description: 'The Contact is archived and can no longer be used in transactions', + }, + { + name: 'GDPR Request', + value: 'GDPRREQUEST', + description: 'The Contact is the subject of a GDPR erasure request', + }, + ], + default: '', + description: 'Current status of a contact - see contact status types', + }, + { + displayName: 'Default Currency', + name: 'defaultCurrency', + type: 'string', + default: '', + description: 'Default currency for raising invoices against contact', + }, + { + displayName: 'Email', + name: 'emailAddress', + type: 'string', + default: '', + description: 'Email address of contact person (umlauts not supported) (max length = 255)', + }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: 'First name of contact person (max length = 255)', + }, + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + default: '', + description: 'Last name of contact person (max length = 255)', + }, + // { + // displayName: 'Phones', + // name: 'phonesUi', + // type: 'fixedCollection', + // typeOptions: { + // multipleValues: true, + // }, + // default: '', + // placeholder: 'Add Phone', + // options: [ + // { + // name: 'phonesValues', + // displayName: 'Phones', + // values: [ + // { + // displayName: 'Type', + // name: 'type', + // type: 'options', + // options: [ + // { + // name: 'Default', + // value: 'DEFAULT', + // }, + // { + // name: 'DDI', + // value: 'DDI', + // }, + // { + // name: 'Mobile', + // value: 'MOBILE', + // }, + // { + // name: 'Fax', + // value: 'FAX', + // }, + // ], + // default: '', + // }, + // { + // displayName: 'Number', + // name: 'phoneNumber', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Area Code', + // name: 'phoneAreaCode', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Country Code', + // name: 'phoneCountryCode', + // type: 'string', + // default: '', + // }, + // ], + // }, + // ], + // }, + { + displayName: 'Purchase Default Account Code', + name: 'purchasesDefaultAccountCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAccountCodes', + }, + default: '', + description: 'The default purchases account code for contacts', + }, + { + displayName: 'Sales Default Account Code', + name: 'salesDefaultAccountCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAccountCodes', + }, + default: '', + description: 'The default sales account code for contacts', + }, + { + displayName: 'Skype', + name: 'skypeUserName', + type: 'string', + default: '', + description: 'Skype user name of contact', + }, + { + displayName: 'Tax Number', + name: 'taxNumber', + type: 'string', + default: '', + description: 'Tax number of contact', + }, + { + displayName: 'Xero Network Key', + name: 'xeroNetworkKey', + type: 'string', + default: '', + description: 'Store XeroNetworkKey for contacts', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* contact:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + }, + { + displayName: 'Contact ID', + name: 'contactId', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + }, +/* -------------------------------------------------------------------------- */ +/* contact:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + }, + }, + required: true, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Include Archived', + name: 'includeArchived', + type: 'boolean', + default: false, + description: `Contacts with a status of ARCHIVED will be included in the response`, + }, + { + displayName: 'Order By', + name: 'orderBy', + type: 'string', + placeholder: 'contactID', + default: '', + description: 'Order by any element returned', + }, + { + displayName: 'Sort Order', + name: 'sortOrder', + type: 'options', + options: [ + { + name: 'Asc', + value: 'ASC', + }, + { + name: 'Desc', + value: 'DESC', + }, + ], + default: '', + description: 'Sort order', + }, + { + displayName: 'Where', + name: 'where', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + placeholder: 'EmailAddress!=null&&EmailAddress.StartsWith("boom")', + default: '', + description: `The where parameter allows you to filter on endpoints and elements that don't have explicit parameters. Examples Here`, + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* contact:update */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'update', + ], + }, + }, + required: true, + }, + { + displayName: 'Contact ID', + name: 'contactId', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'update', + ], + }, + }, + required: true, + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Account Number', + name: 'accountNumber', + type: 'string', + default: '', + description: 'A user defined account number', + }, + // { + // displayName: 'Addresses', + // name: 'addressesUi', + // type: 'fixedCollection', + // typeOptions: { + // multipleValues: true, + // }, + // default: '', + // placeholder: 'Add Address', + // options: [ + // { + // name: 'addressesValues', + // displayName: 'Address', + // values: [ + // { + // displayName: 'Type', + // name: 'type', + // type: 'options', + // options: [ + // { + // name: 'PO Box', + // value: 'POBOX', + // }, + // { + // name: 'Street', + // value: 'STREET', + // }, + // ], + // default: '', + // }, + // { + // displayName: 'Line 1', + // name: 'line1', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Line 2', + // name: 'line2', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'City', + // name: 'city', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Region', + // name: 'region', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Postal Code', + // name: 'postalCode', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Country', + // name: 'country', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Attention To', + // name: 'attentionTo', + // type: 'string', + // default: '', + // }, + // ], + // }, + // ], + // }, + { + displayName: 'Bank Account Details', + name: 'bankAccountDetails', + type: 'string', + default: '', + description: 'Bank account number of contact', + }, + { + displayName: 'Contact Number', + name: 'contactNumber', + type: 'string', + default: '', + description: 'This field is read only on the Xero contact screen, used to identify contacts in external systems', + }, + { + displayName: 'Contact Status', + name: 'contactStatus', + type: 'options', + options: [ + { + name: 'Active', + value: 'ACTIVE', + description: 'The Contact is active and can be used in transactions', + }, + { + name: 'Archived', + value: 'ARCHIVED', + description: 'The Contact is archived and can no longer be used in transactions', + }, + { + name: 'GDPR Request', + value: 'GDPRREQUEST', + description: 'The Contact is the subject of a GDPR erasure request', + }, + ], + default: '', + description: 'Current status of a contact - see contact status types', + }, + { + displayName: 'Default Currency', + name: 'defaultCurrency', + type: 'string', + default: '', + description: 'Default currency for raising invoices against contact', + }, + { + displayName: 'Email', + name: 'emailAddress', + type: 'string', + default: '', + description: 'Email address of contact person (umlauts not supported) (max length = 255)', + }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: 'First name of contact person (max length = 255)', + }, + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + default: '', + description: 'Last name of contact person (max length = 255)', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Full name of contact/organisation', + }, + // { + // displayName: 'Phones', + // name: 'phonesUi', + // type: 'fixedCollection', + // typeOptions: { + // multipleValues: true, + // }, + // default: '', + // placeholder: 'Add Phone', + // options: [ + // { + // name: 'phonesValues', + // displayName: 'Phones', + // values: [ + // { + // displayName: 'Type', + // name: 'type', + // type: 'options', + // options: [ + // { + // name: 'Default', + // value: 'DEFAULT', + // }, + // { + // name: 'DDI', + // value: 'DDI', + // }, + // { + // name: 'Mobile', + // value: 'MOBILE', + // }, + // { + // name: 'Fax', + // value: 'FAX', + // }, + // ], + // default: '', + // }, + // { + // displayName: 'Number', + // name: 'phoneNumber', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Area Code', + // name: 'phoneAreaCode', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Country Code', + // name: 'phoneCountryCode', + // type: 'string', + // default: '', + // }, + // ], + // }, + // ], + // }, + { + displayName: 'Purchase Default Account Code', + name: 'purchasesDefaultAccountCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAccountCodes', + }, + default: '', + description: 'The default purchases account code for contacts', + }, + { + displayName: 'Sales Default Account Code', + name: 'salesDefaultAccountCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAccountCodes', + }, + default: '', + description: 'The default sales account code for contacts', + }, + { + displayName: 'Skype', + name: 'skypeUserName', + type: 'string', + default: '', + description: 'Skype user name of contact', + }, + { + displayName: 'Tax Number', + name: 'taxNumber', + type: 'string', + default: '', + description: 'Tax number of contact', + }, + { + displayName: 'Xero Network Key', + name: 'xeroNetworkKey', + type: 'string', + default: '', + description: 'Store XeroNetworkKey for contacts', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Xero/GenericFunctions.ts b/packages/nodes-base/nodes/Xero/GenericFunctions.ts new file mode 100644 index 00000000000..840579bf2f6 --- /dev/null +++ b/packages/nodes-base/nodes/Xero/GenericFunctions.ts @@ -0,0 +1,76 @@ +import { + OptionsWithUri, + } from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function xeroApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, headers: IDataObject = {}): Promise { // tslint:disable-line:no-any + const options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + }, + method, + body, + qs, + uri: uri || `https://api.xero.com/api.xro/2.0${resource}`, + json: true + }; + try { + if (body.organizationId) { + options.headers = { ...options.headers, 'Xero-tenant-id': body.organizationId }; + delete body.organizationId; + } + 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.requestOAuth2.call(this, 'xeroOAuth2Api', options); + } catch (error) { + let errorMessage; + + if (error.response && error.response.body && error.response.body.Message) { + + errorMessage = error.response.body.Message; + + if (error.response.body.Elements) { + const elementErrors = []; + for (const element of error.response.body.Elements) { + elementErrors.push(element.ValidationErrors.map((error: IDataObject) => error.Message).join('|')); + } + errorMessage = elementErrors.join('-'); + } + // Try to return the error prettier + throw new Error(`Xero error response [${error.statusCode}]: ${errorMessage}`); + } + throw error; + } +} + +export async function xeroApiRequestAllItems(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.page = 1; + + do { + responseData = await xeroApiRequest.call(this, method, endpoint, body, query); + query.page++; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + responseData[propertyName].length !== 0 + ); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Xero/IContactInterface.ts b/packages/nodes-base/nodes/Xero/IContactInterface.ts new file mode 100644 index 00000000000..1fc5eebe6a2 --- /dev/null +++ b/packages/nodes-base/nodes/Xero/IContactInterface.ts @@ -0,0 +1,44 @@ + +export interface IAddress { + Type?: string; + AddressLine1?: string; + AddressLine2?: string; + City?: string; + Region?: string; + PostalCode?: string; + Country?: string; + AttentionTo?: string; +} + +export interface IPhone { + Type?: string; + PhoneNumber?: string; + PhoneAreaCode?: string; + PhoneCountryCode?: string; +} + +export interface IContact extends ITenantId { + AccountNumber?: string; + Addresses?: IAddress[]; + BankAccountDetails?: string; + ContactId?: string; + ContactNumber?: string; + ContactStatus?: string; + DefaultCurrency?: string; + EmailAddress?: string; + FirstName?: string; + LastName?: string; + Name?: string; + Phones?: IPhone[]; + PurchaseTrackingCategory?: string; + PurchasesDefaultAccountCode?: string; + SalesDefaultAccountCode?: string; + SalesTrackingCategory?: string; + SkypeUserName?: string; + taxNumber?: string; + xeroNetworkKey?: string; +} + +export interface ITenantId { + organizationId?: string; +} diff --git a/packages/nodes-base/nodes/Xero/InvoiceDescription.ts b/packages/nodes-base/nodes/Xero/InvoiceDescription.ts new file mode 100644 index 00000000000..6591adc24cb --- /dev/null +++ b/packages/nodes-base/nodes/Xero/InvoiceDescription.ts @@ -0,0 +1,983 @@ +import { + INodeProperties, + } from 'n8n-workflow'; + +export const invoiceOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a invoice', + }, + { + name: 'Get', + value: 'get', + description: 'Get a invoice', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all invoices', + }, + { + name: 'Update', + value: 'update', + description: 'Update a invoice', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const invoiceFields = [ + +/* -------------------------------------------------------------------------- */ +/* invoice:create */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Bill', + value: 'ACCPAY', + description: 'Accounts Payable or supplier invoice' + }, + { + name: 'Sales Invoice', + value: 'ACCREC', + description: ' Accounts Receivable or customer invoice' + }, + ], + default: '', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + description: 'Invoice Type', + }, + { + displayName: 'Contact ID', + name: 'contactId', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + description: 'Contact ID', + }, + { + displayName: 'Line Items', + name: 'lineItemsUi', + placeholder: 'Add Line Item', + type: 'fixedCollection', + default: '', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'create', + ] + }, + }, + description: 'Line item data', + options: [ + { + name: 'lineItemsValues', + displayName: 'Line Item', + values: [ + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'A line item with just a description', + }, + { + displayName: 'Quantity', + name: 'quantity', + type: 'number', + default: 1, + typeOptions: { + minValue: 1, + }, + description: 'LineItem Quantity', + }, + { + displayName: 'Unit Amount', + name: 'unitAmount', + type: 'string', + default: '', + description: 'Lineitem unit amount. By default, unit amount will be rounded to two decimal places.', + }, + { + displayName: 'Item Code', + name: 'itemCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getItemCodes', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Account Code', + name: 'accountCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAccountCodes', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Tax Type', + name: 'taxType', + type: 'options', + options: [ + { + name: 'Tax on Purchases', + value: 'INPUT', + }, + { + name: 'Tax Exempt', + value: 'NONE', + }, + { + name: 'Tax on Sales', + value: 'OUTPUT', + }, + { + name: 'Sales Tax on Imports ', + value: 'GSTONIMPORTS', + }, + ], + default: '', + required: true, + description: 'Tax Type', + }, + { + displayName: 'Tax Amount', + name: 'taxAmount', + type: 'string', + default: '', + description: 'The tax amount is auto calculated as a percentage of the line amount based on the tax rate.', + }, + { + displayName: 'Line Amount', + name: 'lineAmount', + type: 'string', + default: '', + description: 'The line amount reflects the discounted price if a DiscountRate has been used', + }, + { + displayName: 'Discount Rate', + name: 'discountRate', + type: 'string', + default: '', + description: 'Percentage discount or discount amount being applied to a line item. Only supported on ACCREC invoices - ACCPAY invoices and credit notes in Xero do not support discounts', + }, + // { + // displayName: 'Tracking', + // name: 'trackingUi', + // placeholder: 'Add Tracking', + // description: 'Any LineItem can have a maximum of 2 TrackingCategory elements.', + // type: 'fixedCollection', + // typeOptions: { + // multipleValues: true, + // }, + // default: {}, + // options: [ + // { + // name: 'trackingValues', + // displayName: 'Tracking', + // values: [ + // { + // displayName: 'Name', + // name: 'name', + // type: 'options', + // typeOptions: { + // loadOptionsMethod: 'getTrakingCategories', + // loadOptionsDependsOn: [ + // 'organizationId', + // ], + // }, + // default: '', + // description: 'Name of the tracking category', + // }, + // { + // displayName: 'Option', + // name: 'option', + // type: 'options', + // typeOptions: { + // loadOptionsMethod: 'getTrakingOptions', + // loadOptionsDependsOn: [ + // '/name', + // ], + // }, + // default: '', + // description: 'Name of the option', + // }, + // ], + // }, + // ], + // }, + ], + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Branding Theme ID', + name: 'brandingThemeId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getBrandingThemes', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Currency', + name: 'currency', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getCurrencies', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Currency Rate', + name: 'currencyRate', + type: 'string', + default: '', + description: 'The currency rate for a multicurrency invoice. If no rate is specified, the XE.com day rate is used.', + }, + { + displayName: 'Date', + name: 'date', + type: 'dateTime', + default: '', + description: 'Date invoice was issued - YYYY-MM-DD. If the Date element is not specified it will default to the current date based on the timezone setting of the organisation', + }, + { + displayName: 'Due Date', + name: 'dueDate', + type: 'dateTime', + default: '', + description: 'Date invoice is due - YYYY-MM-DD', + }, + { + displayName: 'Expected Payment Date', + name: 'expectedPaymentDate', + type: 'dateTime', + default: '', + description: 'Shown on sales invoices (Accounts Receivable) when this has been set', + }, + { + displayName: 'Invoice Number', + name: 'invoiceNumber', + type: 'string', + default: '', + }, + { + displayName: 'Line Amount Type', + name: 'lineAmountType', + type: 'options', + options: [ + { + name: 'Exclusive', + value: 'Exclusive', + description: 'Line items are exclusive of tax', + }, + { + name: 'Inclusive', + value: 'Inclusive', + description: 'Line items are inclusive tax', + }, + { + name: 'NoTax', + value: 'NoTax', + description: 'Line have no tax', + }, + ], + default: 'Exclusive', + }, + { + displayName: 'Planned Payment Date ', + name: 'plannedPaymentDate', + type: 'dateTime', + default: '', + description: 'Shown on bills (Accounts Payable) when this has been set', + }, + { + displayName: 'Reference', + name: 'reference', + type: 'string', + default: '', + description: 'ACCREC only - additional reference number (max length = 255)', + }, + { + displayName: 'Send To Contact', + name: 'sendToContact', + type: 'boolean', + default: false, + description: 'Whether the invoice in the Xero app should be marked as "sent". This can be set only on invoices that have been approved', + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + options: [ + { + name: 'Draft', + value: 'DRAFT', + }, + { + name: 'Submitted', + value: 'SUBMITTED', + }, + { + name: 'Authorised', + value: 'AUTHORISED', + }, + ], + default: 'DRAFT', + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + description: 'URL link to a source document - shown as "Go to [appName]" in the Xero app', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* invoice:update */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'update', + ], + }, + }, + required: true, + }, + { + displayName: 'Invoice ID', + name: 'invoiceId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'update', + ], + }, + }, + description: 'Invoice ID', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Branding Theme ID', + name: 'brandingThemeId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getBrandingThemes', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Contact ID', + name: 'contactId', + type: 'string', + default: '', + description: 'Contact ID', + }, + { + displayName: 'Currency', + name: 'currency', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getCurrencies', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Currency Rate', + name: 'currencyRate', + type: 'string', + default: '', + description: 'The currency rate for a multicurrency invoice. If no rate is specified, the XE.com day rate is used.', + }, + { + displayName: 'Date', + name: 'date', + type: 'dateTime', + default: '', + description: 'Date invoice was issued - YYYY-MM-DD. If the Date element is not specified it will default to the current date based on the timezone setting of the organisation', + }, + { + displayName: 'Due Date', + name: 'dueDate', + type: 'dateTime', + default: '', + description: 'Date invoice is due - YYYY-MM-DD', + }, + { + displayName: 'Expected Payment Date', + name: 'expectedPaymentDate', + type: 'dateTime', + default: '', + description: 'Shown on sales invoices (Accounts Receivable) when this has been set', + }, + { + displayName: 'Invoice Number', + name: 'invoiceNumber', + type: 'string', + default: '', + }, + { + displayName: 'Line Amount Type', + name: 'lineAmountType', + type: 'options', + options: [ + { + name: 'Exclusive', + value: 'Exclusive', + description: 'Line items are exclusive of tax', + }, + { + name: 'Inclusive', + value: 'Inclusive', + description: 'Line items are inclusive tax', + }, + { + name: 'NoTax', + value: 'NoTax', + description: 'Line have no tax', + }, + ], + default: 'Exclusive', + }, + { + displayName: 'Line Items', + name: 'lineItemsUi', + placeholder: 'Add Line Item', + type: 'fixedCollection', + default: '', + typeOptions: { + multipleValues: true, + }, + description: 'Line item data', + options: [ + { + name: 'lineItemsValues', + displayName: 'Line Item', + values: [ + { + displayName: 'Line Item ID', + name: 'lineItemId', + type: 'string', + default: '', + description: 'The Xero generated identifier for a LineItem', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'A line item with just a description', + }, + { + displayName: 'Quantity', + name: 'quantity', + type: 'number', + default: 1, + typeOptions: { + minValue: 1, + }, + description: 'LineItem Quantity', + }, + { + displayName: 'Unit Amount', + name: 'unitAmount', + type: 'string', + default: '', + description: 'Lineitem unit amount. By default, unit amount will be rounded to two decimal places.', + }, + { + displayName: 'Item Code', + name: 'itemCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getItemCodes', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Account Code', + name: 'accountCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAccountCodes', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Tax Type', + name: 'taxType', + type: 'options', + options: [ + { + name: 'Tax on Purchases', + value: 'INPUT', + }, + { + name: 'Tax Exempt', + value: 'NONE', + }, + { + name: 'Tax on Sales', + value: 'OUTPUT', + }, + { + name: 'Sales Tax on Imports ', + value: 'GSTONIMPORTS', + }, + ], + default: '', + required: true, + description: 'Tax Type', + }, + { + displayName: 'Tax Amount', + name: 'taxAmount', + type: 'string', + default: '', + description: 'The tax amount is auto calculated as a percentage of the line amount based on the tax rate.', + }, + { + displayName: 'Line Amount', + name: 'lineAmount', + type: 'string', + default: '', + description: 'The line amount reflects the discounted price if a DiscountRate has been used', + }, + { + displayName: 'Discount Rate', + name: 'discountRate', + type: 'string', + default: '', + description: 'Percentage discount or discount amount being applied to a line item. Only supported on ACCREC invoices - ACCPAY invoices and credit notes in Xero do not support discounts', + }, + // { + // displayName: 'Tracking', + // name: 'trackingUi', + // placeholder: 'Add Tracking', + // description: 'Any LineItem can have a maximum of 2 TrackingCategory elements.', + // type: 'fixedCollection', + // typeOptions: { + // multipleValues: true, + // }, + // default: {}, + // options: [ + // { + // name: 'trackingValues', + // displayName: 'Tracking', + // values: [ + // { + // displayName: 'Name', + // name: 'name', + // type: 'options', + // typeOptions: { + // loadOptionsMethod: 'getTrakingCategories', + // loadOptionsDependsOn: [ + // 'organizationId', + // ], + // }, + // default: '', + // description: 'Name of the tracking category', + // }, + // { + // displayName: 'Option', + // name: 'option', + // type: 'options', + // typeOptions: { + // loadOptionsMethod: 'getTrakingOptions', + // loadOptionsDependsOn: [ + // '/name', + // ], + // }, + // default: '', + // description: 'Name of the option', + // }, + // ], + // }, + // ], + // }, + ], + }, + ], + }, + { + displayName: 'Planned Payment Date ', + name: 'plannedPaymentDate', + type: 'dateTime', + default: '', + description: 'Shown on bills (Accounts Payable) when this has been set', + }, + { + displayName: 'Reference', + name: 'reference', + type: 'string', + default: '', + description: 'ACCREC only - additional reference number (max length = 255)', + }, + { + displayName: 'Send To Contact', + name: 'sendToContact', + type: 'boolean', + default: false, + description: 'Whether the invoice in the Xero app should be marked as "sent". This can be set only on invoices that have been approved', + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + options: [ + { + name: 'Draft', + value: 'DRAFT', + }, + { + name: 'Submitted', + value: 'SUBMITTED', + }, + { + name: 'Authorised', + value: 'AUTHORISED', + }, + ], + default: 'DRAFT', + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + description: 'URL link to a source document - shown as "Go to [appName]" in the Xero app', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* invoice:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + }, + { + displayName: 'Invoice ID', + name: 'invoiceId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'get', + ], + }, + }, + description: 'Invoice ID', + }, +/* -------------------------------------------------------------------------- */ +/* invoice:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'getAll', + ], + }, + }, + required: true, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Created By My App', + name: 'createdByMyApp', + type: 'boolean', + default: false, + description: `When set to true you'll only retrieve Invoices created by your app`, + }, + { + displayName: 'Order By', + name: 'orderBy', + type: 'string', + placeholder: 'InvoiceID', + default: '', + description: 'Order by any element returned', + }, + { + displayName: 'Sort Order', + name: 'sortOrder', + type: 'options', + options: [ + { + name: 'Asc', + value: 'ASC', + }, + { + name: 'Desc', + value: 'DESC', + }, + ], + default: '', + description: 'Sort order', + }, + { + displayName: 'Statuses', + name: 'statuses', + type: 'multiOptions', + options: [ + { + name: 'Draft', + value: 'DRAFT', + }, + { + name: 'Submitted', + value: 'SUBMITTED', + }, + { + name: 'Authorised', + value: 'AUTHORISED', + }, + ], + default: [], + }, + { + displayName: 'Where', + name: 'where', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + placeholder: 'EmailAddress!=null&&EmailAddress.StartsWith("boom")', + default: '', + description: `The where parameter allows you to filter on endpoints and elements that don't have explicit parameters. Examples Here`, + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Xero/InvoiceInterface.ts b/packages/nodes-base/nodes/Xero/InvoiceInterface.ts new file mode 100644 index 00000000000..6d6da63fb9f --- /dev/null +++ b/packages/nodes-base/nodes/Xero/InvoiceInterface.ts @@ -0,0 +1,40 @@ +import { + IDataObject, + } from 'n8n-workflow'; + + export interface ILineItem { + Description?: string; + Quantity?: string; + UnitAmount?: string; + ItemCode?: string; + AccountCode?: string; + LineItemID?: string; + TaxType?: string; + TaxAmount?: string; + LineAmount?: string; + DiscountRate?: string; + Tracking?: IDataObject[]; +} + +export interface IInvoice extends ITenantId { + Type?: string; + LineItems?: ILineItem[]; + Contact?: IDataObject; + Date?: string; + DueDate?: string; + LineAmountType?: string; + InvoiceNumber?: string; + Reference?: string; + BrandingThemeID?: string; + Url?: string; + CurrencyCode?: string; + CurrencyRate?: string; + Status?: string; + SentToContact?: boolean; + ExpectedPaymentDate?: string; + PlannedPaymentDate?: string; +} + +export interface ITenantId { + organizationId?: string; +} diff --git a/packages/nodes-base/nodes/Xero/Xero.node.ts b/packages/nodes-base/nodes/Xero/Xero.node.ts new file mode 100644 index 00000000000..7703bf0c880 --- /dev/null +++ b/packages/nodes-base/nodes/Xero/Xero.node.ts @@ -0,0 +1,681 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeTypeDescription, + INodeExecutionData, + INodeType, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; + +import { + xeroApiRequest, + xeroApiRequestAllItems, +} from './GenericFunctions'; + +import { + invoiceFields, + invoiceOperations +} from './InvoiceDescription'; + +import { + contactFields, + contactOperations, +} from './ContactDescription'; + +import { + IInvoice, + ILineItem, +} from './InvoiceInterface'; + +import { + IContact, + IPhone, + IAddress, +} from './IContactInterface'; + +export class Xero implements INodeType { + description: INodeTypeDescription = { + displayName: 'Xero', + name: 'xero', + icon: 'file:xero.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Xero API', + defaults: { + name: 'Xero', + color: '#13b5ea', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'xeroOAuth2Api', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Contact', + value: 'contact', + }, + { + name: 'Invoice', + value: 'invoice', + }, + ], + default: 'invoice', + description: 'Resource to consume.', + }, + // CONTACT + ...contactOperations, + ...contactFields, + // INVOICE + ...invoiceOperations, + ...invoiceFields, + ], + }; + + methods = { + loadOptions: { + // Get all the item codes to display them to user so that he can + // select them easily + async getItemCodes(this: ILoadOptionsFunctions): Promise { + const organizationId = this.getCurrentNodeParameter('organizationId'); + const returnData: INodePropertyOptions[] = []; + const { Items: items } = await xeroApiRequest.call(this, 'GET', '/items', { organizationId }); + for (const item of items) { + const itemName = item.Description; + const itemId = item.Code; + returnData.push({ + name: itemName, + value: itemId, + }); + } + return returnData; + }, + // Get all the account codes to display them to user so that he can + // select them easily + async getAccountCodes(this: ILoadOptionsFunctions): Promise { + const organizationId = this.getCurrentNodeParameter('organizationId'); + const returnData: INodePropertyOptions[] = []; + const { Accounts: accounts } = await xeroApiRequest.call(this, 'GET', '/Accounts', { organizationId }); + for (const account of accounts) { + const accountName = account.Name; + const accountId = account.Code; + returnData.push({ + name: accountName, + value: accountId, + }); + } + return returnData; + }, + // Get all the tenants to display them to user so that he can + // select them easily + async getTenants(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const tenants = await xeroApiRequest.call(this, 'GET', '', {}, {}, 'https://api.xero.com/connections'); + for (const tenant of tenants) { + const tenantName = tenant.tenantName; + const tenantId = tenant.tenantId; + returnData.push({ + name: tenantName, + value: tenantId, + }); + } + return returnData; + }, + // Get all the brading themes to display them to user so that he can + // select them easily + async getBrandingThemes(this: ILoadOptionsFunctions): Promise { + const organizationId = this.getCurrentNodeParameter('organizationId'); + const returnData: INodePropertyOptions[] = []; + const { BrandingThemes: themes } = await xeroApiRequest.call(this, 'GET', '/BrandingThemes', { organizationId }); + for (const theme of themes) { + const themeName = theme.Name; + const themeId = theme.BrandingThemeID; + returnData.push({ + name: themeName, + value: themeId, + }); + } + return returnData; + }, + // Get all the brading themes to display them to user so that he can + // select them easily + async getCurrencies(this: ILoadOptionsFunctions): Promise { + const organizationId = this.getCurrentNodeParameter('organizationId'); + const returnData: INodePropertyOptions[] = []; + const { Currencies: currencies } = await xeroApiRequest.call(this, 'GET', '/Currencies', { organizationId }); + for (const currency of currencies) { + const currencyName = currency.Code; + const currencyId = currency.Description; + returnData.push({ + name: currencyName, + value: currencyId, + }); + } + return returnData; + }, + // Get all the tracking categories to display them to user so that he can + // select them easily + async getTrakingCategories(this: ILoadOptionsFunctions): Promise { + const organizationId = this.getCurrentNodeParameter('organizationId'); + const returnData: INodePropertyOptions[] = []; + const { TrackingCategories: categories } = await xeroApiRequest.call(this, 'GET', '/TrackingCategories', { organizationId }); + for (const category of categories) { + const categoryName = category.Name; + const categoryId = category.TrackingCategoryID; + returnData.push({ + name: categoryName, + value: categoryId, + }); + } + return returnData; + }, + // // Get all the tracking categories to display them to user so that he can + // // select them easily + // async getTrakingOptions(this: ILoadOptionsFunctions): Promise { + // const organizationId = this.getCurrentNodeParameter('organizationId'); + // const name = this.getCurrentNodeParameter('name'); + // const returnData: INodePropertyOptions[] = []; + // const { TrackingCategories: categories } = await xeroApiRequest.call(this, 'GET', '/TrackingCategories', { organizationId }); + // const { Options: options } = categories.filter((category: IDataObject) => category.Name === name)[0]; + // for (const option of options) { + // const optionName = option.Name; + // const optionId = option.TrackingOptionID; + // returnData.push({ + // name: optionName, + // value: optionId, + // }); + // } + // return returnData; + // }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length as unknown as number; + const qs: IDataObject = {}; + let responseData; + for (let i = 0; i < length; i++) { + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + //https://developer.xero.com/documentation/api/invoices + if (resource === 'invoice') { + if (operation === 'create') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const type = this.getNodeParameter('type', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const contactId = this.getNodeParameter('contactId', i) as string; + const lineItemsValues = ((this.getNodeParameter('lineItemsUi', i) as IDataObject).lineItemsValues as IDataObject[]); + + const body: IInvoice = { + organizationId, + Type: type, + Contact: { ContactID: contactId }, + }; + + if (lineItemsValues) { + const lineItems: ILineItem[] = []; + for (const lineItemValue of lineItemsValues) { + const lineItem: ILineItem = { + Tracking: [], + }; + lineItem.AccountCode = lineItemValue.accountCode as string; + lineItem.Description = lineItemValue.description as string; + lineItem.DiscountRate = lineItemValue.discountRate as string; + lineItem.ItemCode = lineItemValue.itemCode as string; + lineItem.LineAmount = lineItemValue.lineAmount as string; + lineItem.Quantity = (lineItemValue.quantity as number).toString(); + lineItem.TaxAmount = lineItemValue.taxAmount as string; + lineItem.TaxType = lineItemValue.taxType as string; + lineItem.UnitAmount = lineItemValue.unitAmount as string; + // if (lineItemValue.trackingUi) { + // //@ts-ignore + // const { trackingValues } = lineItemValue.trackingUi as IDataObject[]; + // if (trackingValues) { + // for (const trackingValue of trackingValues) { + // const tracking: IDataObject = {}; + // tracking.Name = trackingValue.name as string; + // tracking.Option = trackingValue.option as string; + // lineItem.Tracking!.push(tracking); + // } + // } + // } + lineItems.push(lineItem); + } + body.LineItems = lineItems; + } + + if (additionalFields.brandingThemeId) { + body.BrandingThemeID = additionalFields.brandingThemeId as string; + } + if (additionalFields.currency) { + body.CurrencyCode = additionalFields.currency as string; + } + if (additionalFields.currencyRate) { + body.CurrencyRate = additionalFields.currencyRate as string; + } + if (additionalFields.date) { + body.Date = additionalFields.date as string; + } + if (additionalFields.dueDate) { + body.DueDate = additionalFields.dueDate as string; + } + if (additionalFields.dueDate) { + body.DueDate = additionalFields.dueDate as string; + } + if (additionalFields.expectedPaymentDate) { + body.ExpectedPaymentDate = additionalFields.expectedPaymentDate as string; + } + if (additionalFields.invoiceNumber) { + body.InvoiceNumber = additionalFields.invoiceNumber as string; + } + if (additionalFields.lineAmountType) { + body.LineAmountType = additionalFields.lineAmountType as string; + } + if (additionalFields.plannedPaymentDate) { + body.PlannedPaymentDate = additionalFields.plannedPaymentDate as string; + } + if (additionalFields.reference) { + body.Reference = additionalFields.reference as string; + } + if (additionalFields.sendToContact) { + body.SentToContact = additionalFields.sendToContact as boolean; + } + if (additionalFields.status) { + body.Status = additionalFields.status as string; + } + if (additionalFields.url) { + body.Url = additionalFields.url as string; + } + + responseData = await xeroApiRequest.call(this, 'POST', '/Invoices', body); + responseData = responseData.Invoices; + } + if (operation === 'update') { + const invoiceId = this.getNodeParameter('invoiceId', i) as string; + const organizationId = this.getNodeParameter('organizationId', i) as string; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + + const body: IInvoice = { + organizationId, + }; + + if (updateFields.lineItemsUi) { + const lineItemsValues = (updateFields.lineItemsUi as IDataObject).lineItemsValues as IDataObject[]; + if (lineItemsValues) { + const lineItems: ILineItem[] = []; + for (const lineItemValue of lineItemsValues) { + const lineItem: ILineItem = { + Tracking: [], + }; + lineItem.AccountCode = lineItemValue.accountCode as string; + lineItem.Description = lineItemValue.description as string; + lineItem.DiscountRate = lineItemValue.discountRate as string; + lineItem.ItemCode = lineItemValue.itemCode as string; + lineItem.LineAmount = lineItemValue.lineAmount as string; + lineItem.Quantity = (lineItemValue.quantity as number).toString(); + lineItem.TaxAmount = lineItemValue.taxAmount as string; + lineItem.TaxType = lineItemValue.taxType as string; + lineItem.UnitAmount = lineItemValue.unitAmount as string; + // if (lineItemValue.trackingUi) { + // //@ts-ignore + // const { trackingValues } = lineItemValue.trackingUi as IDataObject[]; + // if (trackingValues) { + // for (const trackingValue of trackingValues) { + // const tracking: IDataObject = {}; + // tracking.Name = trackingValue.name as string; + // tracking.Option = trackingValue.option as string; + // lineItem.Tracking!.push(tracking); + // } + // } + // } + lineItems.push(lineItem); + } + body.LineItems = lineItems; + } + } + + if (updateFields.type) { + body.Type = updateFields.type as string; + } + if (updateFields.Contact) { + body.Contact = { ContactID: updateFields.contactId as string }; + } + if (updateFields.brandingThemeId) { + body.BrandingThemeID = updateFields.brandingThemeId as string; + } + if (updateFields.currency) { + body.CurrencyCode = updateFields.currency as string; + } + if (updateFields.currencyRate) { + body.CurrencyRate = updateFields.currencyRate as string; + } + if (updateFields.date) { + body.Date = updateFields.date as string; + } + if (updateFields.dueDate) { + body.DueDate = updateFields.dueDate as string; + } + if (updateFields.dueDate) { + body.DueDate = updateFields.dueDate as string; + } + if (updateFields.expectedPaymentDate) { + body.ExpectedPaymentDate = updateFields.expectedPaymentDate as string; + } + if (updateFields.invoiceNumber) { + body.InvoiceNumber = updateFields.invoiceNumber as string; + } + if (updateFields.lineAmountType) { + body.LineAmountType = updateFields.lineAmountType as string; + } + if (updateFields.plannedPaymentDate) { + body.PlannedPaymentDate = updateFields.plannedPaymentDate as string; + } + if (updateFields.reference) { + body.Reference = updateFields.reference as string; + } + if (updateFields.sendToContact) { + body.SentToContact = updateFields.sendToContact as boolean; + } + if (updateFields.status) { + body.Status = updateFields.status as string; + } + if (updateFields.url) { + body.Url = updateFields.url as string; + } + + responseData = await xeroApiRequest.call(this, 'POST', `/Invoices/${invoiceId}`, body); + responseData = responseData.Invoices; + } + if (operation === 'get') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const invoiceId = this.getNodeParameter('invoiceId', i) as string; + responseData = await xeroApiRequest.call(this, 'GET', `/Invoices/${invoiceId}`, { organizationId }); + responseData = responseData.Invoices; + } + if (operation === 'getAll') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const options = this.getNodeParameter('options', i) as IDataObject; + if (options.statuses) { + qs.statuses = (options.statuses as string[]).join(','); + } + if (options.orderBy) { + qs.order = `${options.orderBy} ${(options.sortOrder === undefined) ? 'DESC' : options.sortOrder}`; + } + if (options.where) { + qs.where = options.where; + } + if (options.createdByMyApp) { + qs.createdByMyApp = options.createdByMyApp as boolean; + } + if (returnAll) { + responseData = await xeroApiRequestAllItems.call(this, 'Invoices', 'GET', '/Invoices', { organizationId }, qs); + } else { + const limit = this.getNodeParameter('limit', i) as number; + responseData = await xeroApiRequest.call(this, 'GET', `/Invoices`, { organizationId }, qs); + responseData = responseData.Invoices; + responseData = responseData.splice(0, limit); + } + } + } + if (resource === 'contact') { + } + if (operation === 'create') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const name = this.getNodeParameter('name', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + // const addressesUi = additionalFields.addressesUi as IDataObject; + // const phonesUi = additionalFields.phonesUi as IDataObject; + + const body: IContact = { + Name: name, + }; + + if (additionalFields.accountNumber) { + body.AccountNumber = additionalFields.accountNumber as string; + } + + if (additionalFields.bankAccountDetails) { + body.BankAccountDetails = additionalFields.bankAccountDetails as string; + } + + if (additionalFields.contactNumber) { + body.ContactNumber = additionalFields.contactNumber as string; + } + + if (additionalFields.contactStatus) { + body.ContactStatus = additionalFields.contactStatus as string; + } + + if (additionalFields.defaultCurrency) { + body.DefaultCurrency = additionalFields.defaultCurrency as string; + } + + if (additionalFields.emailAddress) { + body.EmailAddress = additionalFields.emailAddress as string; + } + + if (additionalFields.firstName) { + body.FirstName = additionalFields.firstName as string; + } + + if (additionalFields.lastName) { + body.LastName = additionalFields.lastName as string; + } + + if (additionalFields.purchasesDefaultAccountCode) { + body.PurchasesDefaultAccountCode = additionalFields.purchasesDefaultAccountCode as string; + } + + if (additionalFields.salesDefaultAccountCode) { + body.SalesDefaultAccountCode = additionalFields.salesDefaultAccountCode as string; + } + + if (additionalFields.skypeUserName) { + body.SkypeUserName = additionalFields.skypeUserName as string; + } + + if (additionalFields.taxNumber) { + body.taxNumber = additionalFields.taxNumber as string; + } + + if (additionalFields.xeroNetworkKey) { + body.xeroNetworkKey = additionalFields.xeroNetworkKey as string; + } + + // if (phonesUi) { + // const phoneValues = phonesUi?.phonesValues as IDataObject[]; + // if (phoneValues) { + // const phones: IPhone[] = []; + // for (const phoneValue of phoneValues) { + // const phone: IPhone = {}; + // phone.Type = phoneValue.type as string; + // phone.PhoneNumber = phoneValue.PhoneNumber as string; + // phone.PhoneAreaCode = phoneValue.phoneAreaCode as string; + // phone.PhoneCountryCode = phoneValue.phoneCountryCode as string; + // phones.push(phone); + // } + // body.Phones = phones; + // } + // } + + // if (addressesUi) { + // const addressValues = addressesUi?.addressesValues as IDataObject[]; + // if (addressValues) { + // const addresses: IAddress[] = []; + // for (const addressValue of addressValues) { + // const address: IAddress = {}; + // address.Type = addressValue.type as string; + // address.AddressLine1 = addressValue.line1 as string; + // address.AddressLine2 = addressValue.line2 as string; + // address.City = addressValue.city as string; + // address.Region = addressValue.region as string; + // address.PostalCode = addressValue.postalCode as string; + // address.Country = addressValue.country as string; + // address.AttentionTo = addressValue.attentionTo as string; + // addresses.push(address); + // } + // body.Addresses = addresses; + // } + // } + + responseData = await xeroApiRequest.call(this, 'POST', '/Contacts', { organizationId, Contacts: [body] }); + responseData = responseData.Contacts; + } + if (operation === 'get') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const contactId = this.getNodeParameter('contactId', i) as string; + responseData = await xeroApiRequest.call(this, 'GET', `/Contacts/${contactId}`, { organizationId }); + responseData = responseData.Contacts; + } + if (operation === 'getAll') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const options = this.getNodeParameter('options', i) as IDataObject; + if (options.includeArchived) { + qs.includeArchived = options.includeArchived as boolean; + } + if (options.orderBy) { + qs.order = `${options.orderBy} ${(options.sortOrder === undefined) ? 'DESC' : options.sortOrder}`; + } + if (options.where) { + qs.where = options.where; + } + if (returnAll) { + responseData = await xeroApiRequestAllItems.call(this, 'Contacts', 'GET', '/Contacts', { organizationId }, qs); + } else { + const limit = this.getNodeParameter('limit', i) as number; + responseData = await xeroApiRequest.call(this, 'GET', `/Contacts`, { organizationId }, qs); + responseData = responseData.Contacts; + responseData = responseData.splice(0, limit); + } + + } + if (operation === 'update') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const contactId = this.getNodeParameter('contactId', i) as string; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + // const addressesUi = updateFields.addressesUi as IDataObject; + // const phonesUi = updateFields.phonesUi as IDataObject; + + const body: IContact = {}; + + if (updateFields.accountNumber) { + body.AccountNumber = updateFields.accountNumber as string; + } + + if (updateFields.name) { + body.Name = updateFields.name as string; + } + + if (updateFields.bankAccountDetails) { + body.BankAccountDetails = updateFields.bankAccountDetails as string; + } + + if (updateFields.contactNumber) { + body.ContactNumber = updateFields.contactNumber as string; + } + + if (updateFields.contactStatus) { + body.ContactStatus = updateFields.contactStatus as string; + } + + if (updateFields.defaultCurrency) { + body.DefaultCurrency = updateFields.defaultCurrency as string; + } + + if (updateFields.emailAddress) { + body.EmailAddress = updateFields.emailAddress as string; + } + + if (updateFields.firstName) { + body.FirstName = updateFields.firstName as string; + } + + if (updateFields.lastName) { + body.LastName = updateFields.lastName as string; + } + + if (updateFields.purchasesDefaultAccountCode) { + body.PurchasesDefaultAccountCode = updateFields.purchasesDefaultAccountCode as string; + } + + if (updateFields.salesDefaultAccountCode) { + body.SalesDefaultAccountCode = updateFields.salesDefaultAccountCode as string; + } + + if (updateFields.skypeUserName) { + body.SkypeUserName = updateFields.skypeUserName as string; + } + + if (updateFields.taxNumber) { + body.taxNumber = updateFields.taxNumber as string; + } + + if (updateFields.xeroNetworkKey) { + body.xeroNetworkKey = updateFields.xeroNetworkKey as string; + } + + // if (phonesUi) { + // const phoneValues = phonesUi?.phonesValues as IDataObject[]; + // if (phoneValues) { + // const phones: IPhone[] = []; + // for (const phoneValue of phoneValues) { + // const phone: IPhone = {}; + // phone.Type = phoneValue.type as string; + // phone.PhoneNumber = phoneValue.PhoneNumber as string; + // phone.PhoneAreaCode = phoneValue.phoneAreaCode as string; + // phone.PhoneCountryCode = phoneValue.phoneCountryCode as string; + // phones.push(phone); + // } + // body.Phones = phones; + // } + // } + + // if (addressesUi) { + // const addressValues = addressesUi?.addressesValues as IDataObject[]; + // if (addressValues) { + // const addresses: IAddress[] = []; + // for (const addressValue of addressValues) { + // const address: IAddress = {}; + // address.Type = addressValue.type as string; + // address.AddressLine1 = addressValue.line1 as string; + // address.AddressLine2 = addressValue.line2 as string; + // address.City = addressValue.city as string; + // address.Region = addressValue.region as string; + // address.PostalCode = addressValue.postalCode as string; + // address.Country = addressValue.country as string; + // address.AttentionTo = addressValue.attentionTo as string; + // addresses.push(address); + // } + // body.Addresses = addresses; + // } + // } + + responseData = await xeroApiRequest.call(this, 'POST', `/Contacts/${contactId}`, { organizationId, Contacts: [body] }); + responseData = responseData.Contacts; + } + 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/Xero/xero.png b/packages/nodes-base/nodes/Xero/xero.png new file mode 100644 index 0000000000000000000000000000000000000000..a9d46c10aacd655047a0039b75024aebcab3cf84 GIT binary patch literal 9587 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 Date: Sun, 12 Jul 2020 18:29:11 +0200 Subject: [PATCH 09/11] :zap: Small improvements --- packages/nodes-base/nodes/Xero/Xero.node.ts | 4 ++-- packages/nodes-base/package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/nodes-base/nodes/Xero/Xero.node.ts b/packages/nodes-base/nodes/Xero/Xero.node.ts index 7703bf0c880..31a1df36e87 100644 --- a/packages/nodes-base/nodes/Xero/Xero.node.ts +++ b/packages/nodes-base/nodes/Xero/Xero.node.ts @@ -33,8 +33,8 @@ import { import { IContact, - IPhone, - IAddress, + // IPhone, + // IAddress, } from './IContactInterface'; export class Xero implements INodeType { diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 159fdd8cd00..17ab0f0adeb 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -37,8 +37,8 @@ "dist/credentials/BannerbearApi.credentials.js", "dist/credentials/BitbucketApi.credentials.js", "dist/credentials/BitlyApi.credentials.js", - "dist/credentials/CircleCiApi.credentials.js", "dist/credentials/ChargebeeApi.credentials.js", + "dist/credentials/CircleCiApi.credentials.js", "dist/credentials/ClearbitApi.credentials.js", "dist/credentials/ClickUpApi.credentials.js", "dist/credentials/ClockifyApi.credentials.js", @@ -172,9 +172,9 @@ "dist/nodes/Bitbucket/BitbucketTrigger.node.js", "dist/nodes/Bitly/Bitly.node.js", "dist/nodes/Calendly/CalendlyTrigger.node.js", - "dist/nodes/CircleCi/CircleCi.node.js", "dist/nodes/Chargebee/Chargebee.node.js", "dist/nodes/Chargebee/ChargebeeTrigger.node.js", + "dist/nodes/CircleCi/CircleCi.node.js", "dist/nodes/Clearbit/Clearbit.node.js", "dist/nodes/ClickUp/ClickUp.node.js", "dist/nodes/ClickUp/ClickUpTrigger.node.js", From c03c9a06c8c7fa39d0b4fecbf820b5accca2b0da Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Mon, 13 Jul 2020 14:22:34 +0200 Subject: [PATCH 10/11] :zap: Fix logos in README.md files --- packages/core/README.md | 2 +- packages/editor-ui/README.md | 2 +- packages/node-dev/README.md | 2 +- packages/nodes-base/README.md | 2 +- packages/workflow/README.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index c1b11d9fbff..b1e2e314108 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,6 +1,6 @@ # n8n-core -![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/docs/images/n8n-logo.png) +![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-logo.png) Core components for n8n diff --git a/packages/editor-ui/README.md b/packages/editor-ui/README.md index cf05ce87d19..f4949d3d8e4 100644 --- a/packages/editor-ui/README.md +++ b/packages/editor-ui/README.md @@ -1,6 +1,6 @@ # n8n-editor-ui -![n8n.io - Workflow Automation](https://n8n.io/n8n-logo.png) +![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-logo.png) The UI to create and update n8n workflows diff --git a/packages/node-dev/README.md b/packages/node-dev/README.md index fa817c91243..526b45cebff 100644 --- a/packages/node-dev/README.md +++ b/packages/node-dev/README.md @@ -1,6 +1,6 @@ # n8n-node-dev -![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/docs/images/n8n-logo.png) +![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-logo.png) Currently very simple and not very sophisticated CLI which makes it easier to create credentials and nodes in TypeScript for n8n. diff --git a/packages/nodes-base/README.md b/packages/nodes-base/README.md index bd069d0c3a5..cfa12a488d7 100644 --- a/packages/nodes-base/README.md +++ b/packages/nodes-base/README.md @@ -1,6 +1,6 @@ # n8n-nodes-base -![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/docs/images/n8n-logo.png) +![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-logo.png) The nodes which are included by default in n8n diff --git a/packages/workflow/README.md b/packages/workflow/README.md index 40a74b11161..4f3ef155a36 100644 --- a/packages/workflow/README.md +++ b/packages/workflow/README.md @@ -1,6 +1,6 @@ # n8n-workflow -![n8n.io - Workflow Automation](https://n8n.io/n8n-logo.png) +![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-logo.png) Workflow base code for n8n From e558a48c82694838fca37d48454dc041dba23131 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Mon, 13 Jul 2020 17:28:56 +0200 Subject: [PATCH 11/11] :bug: Fix bug if Uplead did not have any data --- packages/nodes-base/nodes/Uplead/Uplead.node.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Uplead/Uplead.node.ts b/packages/nodes-base/nodes/Uplead/Uplead.node.ts index e47bdbe71c4..93350e99d91 100644 --- a/packages/nodes-base/nodes/Uplead/Uplead.node.ts +++ b/packages/nodes-base/nodes/Uplead/Uplead.node.ts @@ -113,7 +113,9 @@ export class Uplead implements INodeType { if (Array.isArray(responseData.data)) { returnData.push.apply(returnData, responseData.data as IDataObject[]); } else { - returnData.push(responseData.data as IDataObject); + if (responseData.data !== null) { + returnData.push(responseData.data as IDataObject); + } } } return [this.helpers.returnJsonArray(returnData)];