diff --git a/docker/compose/subfolderWithSSL/.env b/docker/compose/subfolderWithSSL/.env new file mode 100644 index 0000000000..7008bd631a --- /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 0000000000..61fcb5b7e7 --- /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 0000000000..5e540abbb5 --- /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 380a93c4a8..9492b4cb63 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 (seconds) after execution data has been pruned', + default: 3600, + env: 'EXECUTIONS_DATA_PRUNE_TIMEOUT' + }, }, generic: { diff --git a/packages/cli/src/GenericHelpers.ts b/packages/cli/src/GenericHelpers.ts index 8b02b73e8e..cab67f7bce 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 5cfacd8449..1a0b75842a 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); diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index ed20a985f8..4230e79f58 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,30 @@ 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 throttling = false; +function pruneExecutionData(): void { + if (!throttling) { + throttling = true; + 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({ stoppedAt: LessThanOrEqual(date.toISOString()) }) + .then(data => + setTimeout(() => { + throttling = false; + }, timeout * 1000) + ).catch(err => throttling = false); + } +} + /** * Pushes the execution out to all connected clients @@ -189,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 { diff --git a/packages/core/README.md b/packages/core/README.md index c1b11d9fbf..b1e2e31410 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 cf05ce87d1..f4949d3d8e 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/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index c5462690cc..a9cf787ef4 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -16,7 +16,7 @@ diff --git a/packages/node-dev/README.md b/packages/node-dev/README.md index fa817c9124..526b45cebf 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 bd069d0c3a..cfa12a488d 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/nodes-base/credentials/XeroOAuth2Api.credentials.ts b/packages/nodes-base/credentials/XeroOAuth2Api.credentials.ts new file mode 100644 index 0000000000..2db47c13de --- /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/HackerNews/GenericFunctions.ts b/packages/nodes-base/nodes/HackerNews/GenericFunctions.ts new file mode 100644 index 0000000000..76fbdd3bb5 --- /dev/null +++ b/packages/nodes-base/nodes/HackerNews/GenericFunctions.ts @@ -0,0 +1,80 @@ +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, + qs, + uri: `http://hn.algolia.com/api/v1/${endpoint}`, + json: true, + }; + + 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; + } +} + + +/** + * 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 0000000000..b77d5d35bd --- /dev/null +++ b/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts @@ -0,0 +1,384 @@ +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: 'All', + value: 'all', + }, + { + name: 'Article', + value: 'article', + }, + { + name: 'User', + value: 'user', + }, + ], + 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', + type: 'options', + displayOptions: { + show: { + resource: [ + 'article', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a Hacker News article', + }, + ], + 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: [ + 'all', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 100, + description: 'Limit of Hacker News articles to be returned for the query.', + displayOptions: { + show: { + resource: [ + 'all', + ], + 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: [ + 'all', + ], + 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 === 'all') { + 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 === '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') { + + 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 === 'all' && 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 0000000000..67ba3047ff Binary files /dev/null and b/packages/nodes-base/nodes/HackerNews/hackernews.png differ diff --git a/packages/nodes-base/nodes/Uplead/Uplead.node.ts b/packages/nodes-base/nodes/Uplead/Uplead.node.ts index e47bdbe71c..93350e99d9 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)]; diff --git a/packages/nodes-base/nodes/Xero/ContactDescription.ts b/packages/nodes-base/nodes/Xero/ContactDescription.ts new file mode 100644 index 0000000000..418aef44ac --- /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 0000000000..840579bf2f --- /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 0000000000..1fc5eebe6a --- /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 0000000000..6591adc24c --- /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 0000000000..6d6da63fb9 --- /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 0000000000..31a1df36e8 --- /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 0000000000..a9d46c10aa Binary files /dev/null and b/packages/nodes-base/nodes/Xero/xero.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 94716b7f4b..17ab0f0ade 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", @@ -143,6 +143,7 @@ "dist/credentials/WebflowOAuth2Api.credentials.js", "dist/credentials/WooCommerceApi.credentials.js", "dist/credentials/WordpressApi.credentials.js", + "dist/credentials/XeroOAuth2Api.credentials.js", "dist/credentials/ZendeskApi.credentials.js", "dist/credentials/ZendeskOAuth2Api.credentials.js", "dist/credentials/ZohoOAuth2Api.credentials.js", @@ -171,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", @@ -212,6 +213,7 @@ "dist/nodes/Google/Task/GoogleTasks.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", @@ -304,6 +306,7 @@ "dist/nodes/WooCommerce/WooCommerce.node.js", "dist/nodes/WooCommerce/WooCommerceTrigger.node.js", "dist/nodes/WriteBinaryFile.node.js", + "dist/nodes/Xero/Xero.node.js", "dist/nodes/Xml.node.js", "dist/nodes/Zendesk/Zendesk.node.js", "dist/nodes/Zendesk/ZendeskTrigger.node.js", diff --git a/packages/workflow/README.md b/packages/workflow/README.md index 40a74b1116..4f3ef155a3 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