From 91c40367e11b93967b093b50d012efa2b8396d13 Mon Sep 17 00:00:00 2001 From: ricardo Date: Wed, 27 May 2020 19:32:49 -0400 Subject: [PATCH 001/155] Done --- packages/cli/src/ActiveWorkflowRunner.ts | 156 ++++++++++++------ packages/cli/src/Db.ts | 7 +- packages/cli/src/Interfaces.ts | 8 + packages/cli/src/Server.ts | 12 +- packages/cli/src/WebhookHelpers.ts | 27 +++ .../src/databases/mongodb/WebhookEntity.ts | 31 ++++ packages/cli/src/databases/mongodb/index.ts | 2 + .../src/databases/mysqldb/WebhookEntity.ts | 30 ++++ packages/cli/src/databases/mysqldb/index.ts | 1 + .../src/databases/postgresdb/WebhookEntity.ts | 30 ++++ .../cli/src/databases/postgresdb/index.ts | 2 + .../migrations/1589476000887-WebhookModel.ts | 62 +++++++ .../databases/postgresdb/migrations/index.ts | 2 + .../cli/src/databases/sqlite/WebhookEntity.ts | 30 ++++ packages/cli/src/databases/sqlite/index.ts | 2 +- packages/editor-ui/package.json | 4 +- packages/editor-ui/src/views/NodeView.vue | 6 + packages/workflow/src/Interfaces.ts | 1 + packages/workflow/src/NodeHelpers.ts | 64 ++++++- 19 files changed, 423 insertions(+), 54 deletions(-) create mode 100644 packages/cli/src/databases/mongodb/WebhookEntity.ts create mode 100644 packages/cli/src/databases/mysqldb/WebhookEntity.ts create mode 100644 packages/cli/src/databases/postgresdb/WebhookEntity.ts create mode 100644 packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts create mode 100644 packages/cli/src/databases/sqlite/WebhookEntity.ts diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index 9fef6ed243..4be2326323 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -11,11 +11,11 @@ import { WorkflowHelpers, WorkflowRunner, WorkflowExecuteAdditionalData, + IWebhookDb, } from './'; import { ActiveWorkflows, - ActiveWebhooks, NodeExecuteFunctions, } from 'n8n-core'; @@ -26,7 +26,7 @@ import { INode, INodeExecutionData, IRunExecutionData, - IWebhookData, + NodeHelpers, IWorkflowExecuteAdditionalData as IWorkflowExecuteAdditionalDataWorkflow, WebhookHttpMethod, Workflow, @@ -38,19 +38,21 @@ import * as express from 'express'; export class ActiveWorkflowRunner { private activeWorkflows: ActiveWorkflows | null = null; - private activeWebhooks: ActiveWebhooks | null = null; + private activationErrors: { [key: string]: IActivationError; } = {}; async init() { + // Get the active workflows from database + + // NOTE + // Here I guess we can have a flag on the workflow table like hasTrigger + // so intead of pulling all the active wehhooks just pull the actives that have a trigger const workflowsData: IWorkflowDb[] = await Db.collections.Workflow!.find({ active: true }) as IWorkflowDb[]; - this.activeWebhooks = new ActiveWebhooks(); - - // Add them as active workflows this.activeWorkflows = new ActiveWorkflows(); if (workflowsData.length !== 0) { @@ -58,20 +60,27 @@ export class ActiveWorkflowRunner { console.log(' Start Active Workflows:'); console.log(' ================================'); + const nodeTypes = NodeTypes(); + for (const workflowData of workflowsData) { - console.log(` - ${workflowData.name}`); - try { - await this.add(workflowData.id.toString(), workflowData); - console.log(` => Started`); - } catch (error) { - console.log(` => ERROR: Workflow could not be activated:`); - console.log(` ${error.message}`); + + const workflow = new Workflow({ id: workflowData.id.toString(), name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings}); + + if (workflow.getTriggerNodes().length !== 0 + || workflow.getPollNodes().length !== 0) { + console.log(` - ${workflowData.name}`); + try { + await this.add(workflowData.id.toString(), workflowData); + console.log(` => Started`); + } catch (error) { + console.log(` => ERROR: Workflow could not be activated:`); + console.log(` ${error.message}`); + } } } } } - /** * Removes all the currently active workflows * @@ -94,7 +103,6 @@ export class ActiveWorkflowRunner { return; } - /** * Checks if a webhook for the given method and path exists and executes the workflow. * @@ -110,30 +118,41 @@ export class ActiveWorkflowRunner { throw new ResponseHelper.ResponseError('The "activeWorkflows" instance did not get initialized yet.', 404, 404); } - const webhookData: IWebhookData | undefined = this.activeWebhooks!.get(httpMethod, path); + const webhook = await Db.collections.Webhook?.findOne({ webhookPath: path, method: httpMethod }) as IWebhookDb; - if (webhookData === undefined) { + // check if something exist + if (webhook === undefined) { // The requested webhook is not registered throw new ResponseHelper.ResponseError(`The requested webhook "${httpMethod} ${path}" is not registered.`, 404, 404); } - const workflowData = await Db.collections.Workflow!.findOne(webhookData.workflowId); + const workflowData = await Db.collections.Workflow!.findOne(webhook.workflowId); if (workflowData === undefined) { - throw new ResponseHelper.ResponseError(`Could not find workflow with id "${webhookData.workflowId}"`, 404, 404); + throw new ResponseHelper.ResponseError(`Could not find workflow with id "${webhook.workflowId}"`, 404, 404); } const nodeTypes = NodeTypes(); - const workflow = new Workflow({ id: webhookData.workflowId, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings}); + const workflow = new Workflow({ id: webhook.workflowId.toString(), name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings}); + + const credentials = await WorkflowCredentials([workflow.getNode(webhook.node as string) as INode]); + + const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials); + + const webhookData = NodeHelpers.getNodeWebhooks(workflow, workflow.getNode(webhook.node as string) as INode, additionalData).filter((webhook) => { + return (webhook.httpMethod === httpMethod && webhook.path === path); + })[0]; // Get the node which has the webhook defined to know where to start from and to // get additional data const workflowStartNode = workflow.getNode(webhookData.node); + if (workflowStartNode === null) { throw new ResponseHelper.ResponseError('Could not find node to process webhook.', 404, 404); } return new Promise((resolve, reject) => { const executionMode = 'webhook'; + //@ts-ignore WebhookHelpers.executeWebhook(workflow, webhookData, workflowData, workflowStartNode, executionMode, undefined, req, res, (error: Error | null, data: object) => { if (error !== null) { return reject(error); @@ -143,19 +162,14 @@ export class ActiveWorkflowRunner { }); } - /** * Returns the ids of the currently active workflows * * @returns {string[]} * @memberof ActiveWorkflowRunner */ - getActiveWorkflows(): string[] { - if (this.activeWorkflows === null) { - return []; - } - - return this.activeWorkflows.allActiveWorkflows(); + getActiveWorkflows(): Promise { + return Db.collections.Workflow?.find({ select: ['id'] }) as Promise; } @@ -166,15 +180,11 @@ export class ActiveWorkflowRunner { * @returns {boolean} * @memberof ActiveWorkflowRunner */ - isActive(id: string): boolean { - if (this.activeWorkflows !== null) { - return this.activeWorkflows.isActive(id); - } - - return false; + async isActive(id: string): Promise { + const workflow = await Db.collections.Workflow?.findOne({ id }) as IWorkflowDb; + return workflow?.active as boolean; } - /** * Return error if there was a problem activating the workflow * @@ -190,7 +200,6 @@ export class ActiveWorkflowRunner { return this.activationErrors[id]; } - /** * Adds all the webhooks of the workflow * @@ -202,12 +211,47 @@ export class ActiveWorkflowRunner { */ async addWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflowExecuteAdditionalDataWorkflow, mode: WorkflowExecuteMode): Promise { const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData); + let path = ''; for (const webhookData of webhooks) { - await this.activeWebhooks!.add(workflow, webhookData, mode); - // Save static data! - await WorkflowHelpers.saveStaticData(workflow); + + const node = workflow.getNode(webhookData.node) as INode; + node.name = webhookData.node; + + path = node.parameters.path as string; + + if (node.parameters.path === undefined) { + path = workflow.getSimpleParameterValue(node, webhookData.webhookDescription['path'], 'GET') as string; + } + + const webhook = { + workflowId: webhookData.workflowId, + webhookPath: NodeHelpers.getNodeWebhookPath(workflow.id as string, node, path), + node: node.name, + method: webhookData.httpMethod, + } as IWebhookDb; + + try { + await Db.collections.Webhook?.insert(webhook); + + const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, false); + if (webhookExists === false) { + // If webhook does not exist yet create it + await workflow.runWebhookMethod('create', webhookData, NodeExecuteFunctions, mode, false); + } + + } catch (error) { + // The workflow was saved with two webhooks with the + // same path/method so delete all webhooks saved + + await Db.collections.Webhook?.delete({ workflowId: workflow.id }); + + // then show error to the user + throw new Error(error.message || error.detail); + } } + // Save static data! + await WorkflowHelpers.saveStaticData(workflow); } @@ -227,10 +271,22 @@ export class ActiveWorkflowRunner { const nodeTypes = NodeTypes(); const workflow = new Workflow({ id: workflowId, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings }); - await this.activeWebhooks!.removeWorkflow(workflow); + const mode = 'internal'; - // Save the static workflow data if needed - await WorkflowHelpers.saveStaticData(workflow); + const credentials = await WorkflowCredentials(workflowData.nodes); + const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials); + + const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData); + + for (const webhookData of webhooks) { + await workflow.runWebhookMethod('delete', webhookData, NodeExecuteFunctions, mode, false); + } + + const webhook = { + workflowId: workflowData.id, + } as IWebhookDb; + + await Db.collections.Webhook?.delete(webhook); } @@ -322,7 +378,6 @@ export class ActiveWorkflowRunner { }); } - /** * Makes a workflow active * @@ -361,7 +416,11 @@ export class ActiveWorkflowRunner { // Add the workflows which have webhooks defined await this.addWorkflowWebhooks(workflowInstance, additionalData, mode); - await this.activeWorkflows.add(workflowId, workflowInstance, additionalData, getTriggerFunctions, getPollFunctions); + + if (workflowInstance.getTriggerNodes().length !== 0 + || workflowInstance.getPollNodes().length !== 0) { + await this.activeWorkflows.add(workflowId, workflowInstance, additionalData, getTriggerFunctions, getPollFunctions); + } if (this.activationErrors[workflowId] !== undefined) { // If there were activation errors delete them @@ -386,7 +445,6 @@ export class ActiveWorkflowRunner { await WorkflowHelpers.saveStaticData(workflowInstance!); } - /** * Makes a workflow inactive * @@ -395,6 +453,7 @@ export class ActiveWorkflowRunner { * @memberof ActiveWorkflowRunner */ async remove(workflowId: string): Promise { + if (this.activeWorkflows !== null) { // Remove all the webhooks of the workflow await this.removeWorkflowWebhooks(workflowId); @@ -404,8 +463,13 @@ export class ActiveWorkflowRunner { delete this.activationErrors[workflowId]; } - // Remove the workflow from the "list" of active workflows - return this.activeWorkflows.remove(workflowId); + // if it's active in memory then it's a trigger + // so remove from list of actives workflows + if (this.activeWorkflows.isActive(workflowId)) { + this.activeWorkflows.remove(workflowId); + } + + return; } throw new Error(`The "activeWorkflows" instance did not get initialized yet.`); diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index 54633adb13..9077922a5c 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -27,10 +27,12 @@ export let collections: IDatabaseCollections = { Credentials: null, Execution: null, Workflow: null, + Webhook: null, }; import { - InitialMigration1587669153312 + InitialMigration1587669153312, + WebhookModel1589476000887, } from './databases/postgresdb/migrations'; import { @@ -81,7 +83,7 @@ export async function init(): Promise { port: await GenericHelpers.getConfigValue('database.postgresdb.port') as number, username: await GenericHelpers.getConfigValue('database.postgresdb.user') as string, schema: config.get('database.postgresdb.schema'), - migrations: [InitialMigration1587669153312], + migrations: [InitialMigration1587669153312, WebhookModel1589476000887], migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, }; @@ -135,6 +137,7 @@ export async function init(): Promise { collections.Credentials = getRepository(entities.CredentialsEntity); collections.Execution = getRepository(entities.ExecutionEntity); collections.Workflow = getRepository(entities.WorkflowEntity); + collections.Webhook = getRepository(entities.WebhookEntity); return collections; } diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 225e02885f..65b211b2a9 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -40,8 +40,16 @@ export interface IDatabaseCollections { Credentials: Repository | null; Execution: Repository | null; Workflow: Repository | null; + Webhook: Repository | null; } +export interface IWebhookDb { + id?: number | ObjectID; + workflowId: number | string | ObjectID; + webhookPath: string; + method: string; + node: string; +} export interface IWorkflowBase extends IWorkflowBaseWorkflow { id?: number | string | ObjectID; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index d078c8594e..4e27568983 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -427,7 +427,9 @@ class App { const newWorkflowData = req.body; const id = req.params.id; - if (this.activeWorkflowRunner.isActive(id)) { + const isActive = await this.activeWorkflowRunner.isActive(id); + + if (isActive) { // When workflow gets saved always remove it as the triggers could have been // changed and so the changes would not take effect await this.activeWorkflowRunner.remove(id); @@ -492,7 +494,9 @@ class App { this.app.delete('/rest/workflows/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const id = req.params.id; - if (this.activeWorkflowRunner.isActive(id)) { + const isActive = await this.activeWorkflowRunner.isActive(id); + + if (isActive) { // Before deleting a workflow deactivate it await this.activeWorkflowRunner.remove(id); } @@ -503,6 +507,7 @@ class App { })); + this.app.post('/rest/workflows/run', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const workflowData = req.body.workflowData; const runData: IRunData | undefined = req.body.runData; @@ -632,7 +637,8 @@ class App { // Returns the active workflow ids this.app.get('/rest/active', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - return this.activeWorkflowRunner.getActiveWorkflows(); + const activeWorkflows = await this.activeWorkflowRunner.getActiveWorkflows(); + return activeWorkflows.map(workflow => workflow.id.toString()) as string[]; })); diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index d39e3f5dee..0badd1c9d1 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -69,6 +69,33 @@ export function getWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflo return returnData; } +/** + * Returns all the webhooks which should be created for the give workflow + * + * @export + * @param {string} workflowId + * @param {Workflow} workflow + * @returns {IWebhookData[]} + */ +export function getWorkflowWebhooksBasic(workflow: Workflow): IWebhookData[] { + // Check all the nodes in the workflow if they have webhooks + + const returnData: IWebhookData[] = []; + + let parentNodes: string[] | undefined; + + for (const node of Object.values(workflow.nodes)) { + if (parentNodes !== undefined && !parentNodes.includes(node.name)) { + // If parentNodes are given check only them if they have webhooks + // and no other ones + continue; + } + returnData.push.apply(returnData, NodeHelpers.getNodeWebhooksBasic(workflow, node)); + } + + return returnData; +} + /** * Executes a webhook diff --git a/packages/cli/src/databases/mongodb/WebhookEntity.ts b/packages/cli/src/databases/mongodb/WebhookEntity.ts new file mode 100644 index 0000000000..c9952c2c6d --- /dev/null +++ b/packages/cli/src/databases/mongodb/WebhookEntity.ts @@ -0,0 +1,31 @@ +import { + Column, + Entity, + Unique, + ObjectIdColumn, + ObjectID, +} from 'typeorm'; + +import { + IWebhookDb, + } from '../../Interfaces'; + +@Entity() +@Unique(['webhookPath', 'method']) +export class WebhookEntity implements IWebhookDb { + + @ObjectIdColumn() + id: ObjectID; + + @Column() + workflowId: number; + + @Column() + webhookPath: string; + + @Column() + method: string; + + @Column() + node: string; +} diff --git a/packages/cli/src/databases/mongodb/index.ts b/packages/cli/src/databases/mongodb/index.ts index 164d67fd0c..bd6b9abd60 100644 --- a/packages/cli/src/databases/mongodb/index.ts +++ b/packages/cli/src/databases/mongodb/index.ts @@ -1,3 +1,5 @@ export * from './CredentialsEntity'; export * from './ExecutionEntity'; export * from './WorkflowEntity'; +export * from './WebhookEntity'; + diff --git a/packages/cli/src/databases/mysqldb/WebhookEntity.ts b/packages/cli/src/databases/mysqldb/WebhookEntity.ts new file mode 100644 index 0000000000..df89da4319 --- /dev/null +++ b/packages/cli/src/databases/mysqldb/WebhookEntity.ts @@ -0,0 +1,30 @@ +import { + Column, + Entity, + Unique, + PrimaryGeneratedColumn, +} from 'typeorm'; + +import { + IWebhookDb, + } from '../../Interfaces'; + +@Entity() +@Unique(['webhookPath', 'method']) +export class WebhookEntity implements IWebhookDb { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + workflowId: number; + + @Column() + webhookPath: string; + + @Column() + method: string; + + @Column() + node: string; +} diff --git a/packages/cli/src/databases/mysqldb/index.ts b/packages/cli/src/databases/mysqldb/index.ts index 164d67fd0c..a3494531db 100644 --- a/packages/cli/src/databases/mysqldb/index.ts +++ b/packages/cli/src/databases/mysqldb/index.ts @@ -1,3 +1,4 @@ export * from './CredentialsEntity'; export * from './ExecutionEntity'; export * from './WorkflowEntity'; +export * from './WebhookEntity'; diff --git a/packages/cli/src/databases/postgresdb/WebhookEntity.ts b/packages/cli/src/databases/postgresdb/WebhookEntity.ts new file mode 100644 index 0000000000..07c1c88ae8 --- /dev/null +++ b/packages/cli/src/databases/postgresdb/WebhookEntity.ts @@ -0,0 +1,30 @@ +import { + Column, + Entity, + Unique, + PrimaryGeneratedColumn, +} from 'typeorm'; + +import { + IWebhookDb, + } from '../../'; + +@Entity() +@Unique(['webhookPath', 'method']) +export class WebhookEntity implements IWebhookDb { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + workflowId: number; + + @Column() + webhookPath: string; + + @Column() + method: string; + + @Column() + node: string; +} diff --git a/packages/cli/src/databases/postgresdb/index.ts b/packages/cli/src/databases/postgresdb/index.ts index 164d67fd0c..bd6b9abd60 100644 --- a/packages/cli/src/databases/postgresdb/index.ts +++ b/packages/cli/src/databases/postgresdb/index.ts @@ -1,3 +1,5 @@ export * from './CredentialsEntity'; export * from './ExecutionEntity'; export * from './WorkflowEntity'; +export * from './WebhookEntity'; + diff --git a/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts b/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts new file mode 100644 index 0000000000..0e8ed1e76a --- /dev/null +++ b/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts @@ -0,0 +1,62 @@ +import {MigrationInterface, QueryRunner} from 'typeorm'; + +import { IWorkflowDb, NodeTypes, WebhookHelpers } from '../../..'; +import { Workflow } from 'n8n-workflow/dist/src/Workflow'; +import { + IWebhookDb, + } from '../../../Interfaces'; + + import * as config from '../../../../config'; + +export class WebhookModel1589476000887 implements MigrationInterface { + name = 'WebhookModel1589476000887'; + + async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + + await queryRunner.query(`CREATE TABLE ${tablePrefix}webhook_entity ("id" SERIAL NOT NULL, "workflowId" integer NOT NULL, "webhookPath" character varying NOT NULL, "method" character varying NOT NULL, "node" character varying NOT NULL, CONSTRAINT "UQ_b21ace2e13596ccd87dc9bf4ea6" UNIQUE ("webhookPath", "method"), CONSTRAINT "PK_202217c8b912cf70b93b1e87256" PRIMARY KEY ("id"))`, undefined); + + const workflows = await queryRunner.query(`SELECT * FROM ${tablePrefix}workflow_entity WHERE active=true`) as IWorkflowDb[]; + const data: IWebhookDb[] = []; + const nodeTypes = NodeTypes(); + for (const workflow of workflows) { + const workflowInstance = new Workflow({ id: workflow.id as string, name: workflow.name, nodes: workflow.nodes, connections: workflow.connections, active: workflow.active, nodeTypes, staticData: workflow.staticData, settings: workflow.settings }); + // I'm writing something more simple than this. I tried to use the built in method + // getWorkflowWebhooks but it needs additionaldata and to get it I need the credentials + // and for some reason when I use + // const credentials = await WorkflowCredentials(node); + // to get the credentials I got an error I think is cuz the database is yet not ready. + const webhooks = WebhookHelpers.getWorkflowWebhooksBasic(workflowInstance); + for (const webhook of webhooks) { + data.push({ + workflowId: workflowInstance.id as string, + webhookPath: webhook.path, + method: webhook.httpMethod, + node: webhook.node, + }); + } + } + + if (data.length !== 0) { + await queryRunner.manager.createQueryBuilder() + .insert() + .into(`${tablePrefix}webhook_entity`) + .values(data) + .execute(); + } + } + + async down(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + await queryRunner.query(`DROP TABLE ${tablePrefix}webhook_entity`, undefined); + } + +} diff --git a/packages/cli/src/databases/postgresdb/migrations/index.ts b/packages/cli/src/databases/postgresdb/migrations/index.ts index 5bb6551492..827f01796c 100644 --- a/packages/cli/src/databases/postgresdb/migrations/index.ts +++ b/packages/cli/src/databases/postgresdb/migrations/index.ts @@ -1 +1,3 @@ export * from './1587669153312-InitialMigration'; +export * from './1589476000887-WebhookModel'; + diff --git a/packages/cli/src/databases/sqlite/WebhookEntity.ts b/packages/cli/src/databases/sqlite/WebhookEntity.ts new file mode 100644 index 0000000000..df89da4319 --- /dev/null +++ b/packages/cli/src/databases/sqlite/WebhookEntity.ts @@ -0,0 +1,30 @@ +import { + Column, + Entity, + Unique, + PrimaryGeneratedColumn, +} from 'typeorm'; + +import { + IWebhookDb, + } from '../../Interfaces'; + +@Entity() +@Unique(['webhookPath', 'method']) +export class WebhookEntity implements IWebhookDb { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + workflowId: number; + + @Column() + webhookPath: string; + + @Column() + method: string; + + @Column() + node: string; +} diff --git a/packages/cli/src/databases/sqlite/index.ts b/packages/cli/src/databases/sqlite/index.ts index 2c7d6e25e9..a3494531db 100644 --- a/packages/cli/src/databases/sqlite/index.ts +++ b/packages/cli/src/databases/sqlite/index.ts @@ -1,4 +1,4 @@ export * from './CredentialsEntity'; export * from './ExecutionEntity'; export * from './WorkflowEntity'; - +export * from './WebhookEntity'; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index ec505a172c..783a6ef1dc 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -23,7 +23,9 @@ "test:e2e": "vue-cli-service test:e2e", "test:unit": "vue-cli-service test:unit" }, - "dependencies": {}, + "dependencies": { + "uuid": "^8.1.0" + }, "devDependencies": { "@beyonk/google-fonts-webpack-plugin": "^1.2.3", "@fortawesome/fontawesome-svg-core": "^1.2.19", diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index de89534e09..2a86c1be12 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -126,6 +126,8 @@ import RunData from '@/components/RunData.vue'; import mixins from 'vue-typed-mixins'; +import { v4 as uuidv4 } from 'uuid'; + import { debounce } from 'lodash'; import axios from 'axios'; import { @@ -946,6 +948,10 @@ export default mixins( // Check if node-name is unique else find one that is newNodeData.name = this.getUniqueNodeName(newNodeData.name); + if (nodeTypeData.webhooks && nodeTypeData.webhooks.length) { + newNodeData.webhookPath = uuidv4(); + } + await this.addNodes([newNodeData]); // Automatically deselect all nodes and select the current one and also active diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index ce75635fc0..b5f143e3f6 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -297,6 +297,7 @@ export interface INode { continueOnFail?: boolean; parameters: INodeParameters; credentials?: INodeCredentials; + webhookPath?: string; } diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index a72ee565de..87e219699d 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -791,6 +791,59 @@ export function getNodeWebhooks(workflow: Workflow, node: INode, additionalData: return returnData; } +export function getNodeWebhooksBasic(workflow: Workflow, node: INode): IWebhookData[] { + if (node.disabled === true) { + // Node is disabled so webhooks will also not be enabled + return []; + } + + const nodeType = workflow.nodeTypes.getByName(node.type) as INodeType; + + if (nodeType.description.webhooks === undefined) { + // Node does not have any webhooks so return + return []; + } + + const workflowId = workflow.id || '__UNSAVED__'; + + const returnData: IWebhookData[] = []; + for (const webhookDescription of nodeType.description.webhooks) { + let nodeWebhookPath = workflow.getSimpleParameterValue(node, webhookDescription['path'], 'GET'); + if (nodeWebhookPath === undefined) { + // TODO: Use a proper logger + console.error(`No webhook path could be found for node "${node.name}" in workflow "${workflowId}".`); + continue; + } + + nodeWebhookPath = nodeWebhookPath.toString(); + + if (nodeWebhookPath.charAt(0) === '/') { + nodeWebhookPath = nodeWebhookPath.slice(1); + } + + const path = getNodeWebhookPath(workflowId, node, nodeWebhookPath); + + const httpMethod = workflow.getSimpleParameterValue(node, webhookDescription['httpMethod'], 'GET'); + + if (httpMethod === undefined) { + // TODO: Use a proper logger + console.error(`The webhook "${path}" for node "${node.name}" in workflow "${workflowId}" could not be added because the httpMethod is not defined.`); + continue; + } + + //@ts-ignore + returnData.push({ + httpMethod: httpMethod.toString() as WebhookHttpMethod, + node: node.name, + path, + webhookDescription, + workflowId, + }); + } + + return returnData; +} + /** * Returns the webhook path @@ -802,7 +855,16 @@ export function getNodeWebhooks(workflow: Workflow, node: INode, additionalData: * @returns {string} */ export function getNodeWebhookPath(workflowId: string, node: INode, path: string): string { - return `${workflowId}/${encodeURIComponent(node.name.toLowerCase())}/${path}`; + let webhookPath = ''; + if (node.webhookPath === undefined) { + webhookPath = `${workflowId}/${encodeURIComponent(node.name.toLowerCase())}/${path}`; + } else { + if (node.type === 'n8n-nodes-base.webhook') { + return path; + } + webhookPath = `${node.webhookPath}/${path}`; + } + return webhookPath; } From 4e9490a88d4c6a97414c438ea6ae45604c99c3b0 Mon Sep 17 00:00:00 2001 From: ricardo Date: Sat, 30 May 2020 19:03:58 -0400 Subject: [PATCH 002/155] :zap: Improvements --- packages/cli/src/ActiveWorkflowRunner.ts | 21 +++++++++++++++---- packages/cli/src/Interfaces.ts | 1 - packages/cli/src/TestWebhooks.ts | 4 +++- .../src/databases/mongodb/WebhookEntity.ts | 12 +++-------- .../src/databases/mysqldb/WebhookEntity.ts | 11 +++------- .../src/databases/postgresdb/WebhookEntity.ts | 11 +++------- .../1587669153312-InitialMigration.ts | 3 ++- .../migrations/1589476000887-WebhookModel.ts | 19 +++++++++++++---- .../cli/src/databases/sqlite/WebhookEntity.ts | 11 +++------- packages/core/src/ActiveWebhooks.ts | 9 +++++++- packages/editor-ui/src/views/NodeView.vue | 5 +++++ 11 files changed, 62 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index 4be2326323..a72cb065c7 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -241,13 +241,26 @@ export class ActiveWorkflowRunner { } } catch (error) { - // The workflow was saved with two webhooks with the - // same path/method so delete all webhooks saved + + let errorMessage = ''; await Db.collections.Webhook?.delete({ workflowId: workflow.id }); - // then show error to the user - throw new Error(error.message || error.detail); + // if it's a workflow from the the insert + // TODO check if there is standard error code for deplicate key violation that works + // with all databases + if (error.name === 'QueryFailedError') { + + errorMessage = `The webhook path [${webhook.webhookPath}] and method [${webhook.method}] already exist.`; + + } else if (error.detail) { + // it's a error runnig the webhook methods (checkExists, create) + errorMessage = error.detail; + } else { + errorMessage = error; + } + + throw new Error(errorMessage); } } // Save static data! diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 65b211b2a9..fd56ad3ada 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -44,7 +44,6 @@ export interface IDatabaseCollections { } export interface IWebhookDb { - id?: number | ObjectID; workflowId: number | string | ObjectID; webhookPath: string; method: string; diff --git a/packages/cli/src/TestWebhooks.ts b/packages/cli/src/TestWebhooks.ts index 45ae624e2b..a20fa76c88 100644 --- a/packages/cli/src/TestWebhooks.ts +++ b/packages/cli/src/TestWebhooks.ts @@ -141,12 +141,14 @@ export class TestWebhooks { let key: string; for (const webhookData of webhooks) { key = this.activeWebhooks!.getWebhookKey(webhookData.httpMethod, webhookData.path); + + await this.activeWebhooks!.add(workflow, webhookData, mode); + this.testWebhookData[key] = { sessionId, timeout, workflowData, }; - await this.activeWebhooks!.add(workflow, webhookData, mode); // Save static data! this.testWebhookData[key].workflowData.staticData = workflow.staticData; diff --git a/packages/cli/src/databases/mongodb/WebhookEntity.ts b/packages/cli/src/databases/mongodb/WebhookEntity.ts index c9952c2c6d..a78fd34ae9 100644 --- a/packages/cli/src/databases/mongodb/WebhookEntity.ts +++ b/packages/cli/src/databases/mongodb/WebhookEntity.ts @@ -1,9 +1,7 @@ import { Column, Entity, - Unique, - ObjectIdColumn, - ObjectID, + PrimaryColumn, } from 'typeorm'; import { @@ -11,19 +9,15 @@ import { } from '../../Interfaces'; @Entity() -@Unique(['webhookPath', 'method']) export class WebhookEntity implements IWebhookDb { - @ObjectIdColumn() - id: ObjectID; - @Column() workflowId: number; - @Column() + @PrimaryColumn() webhookPath: string; - @Column() + @PrimaryColumn() method: string; @Column() diff --git a/packages/cli/src/databases/mysqldb/WebhookEntity.ts b/packages/cli/src/databases/mysqldb/WebhookEntity.ts index df89da4319..a78fd34ae9 100644 --- a/packages/cli/src/databases/mysqldb/WebhookEntity.ts +++ b/packages/cli/src/databases/mysqldb/WebhookEntity.ts @@ -1,8 +1,7 @@ import { Column, Entity, - Unique, - PrimaryGeneratedColumn, + PrimaryColumn, } from 'typeorm'; import { @@ -10,19 +9,15 @@ import { } from '../../Interfaces'; @Entity() -@Unique(['webhookPath', 'method']) export class WebhookEntity implements IWebhookDb { - @PrimaryGeneratedColumn() - id: number; - @Column() workflowId: number; - @Column() + @PrimaryColumn() webhookPath: string; - @Column() + @PrimaryColumn() method: string; @Column() diff --git a/packages/cli/src/databases/postgresdb/WebhookEntity.ts b/packages/cli/src/databases/postgresdb/WebhookEntity.ts index 07c1c88ae8..6e511cde74 100644 --- a/packages/cli/src/databases/postgresdb/WebhookEntity.ts +++ b/packages/cli/src/databases/postgresdb/WebhookEntity.ts @@ -1,8 +1,7 @@ import { Column, Entity, - Unique, - PrimaryGeneratedColumn, + PrimaryColumn, } from 'typeorm'; import { @@ -10,19 +9,15 @@ import { } from '../../'; @Entity() -@Unique(['webhookPath', 'method']) export class WebhookEntity implements IWebhookDb { - @PrimaryGeneratedColumn() - id: number; - @Column() workflowId: number; - @Column() + @PrimaryColumn() webhookPath: string; - @Column() + @PrimaryColumn() method: string; @Column() diff --git a/packages/cli/src/databases/postgresdb/migrations/1587669153312-InitialMigration.ts b/packages/cli/src/databases/postgresdb/migrations/1587669153312-InitialMigration.ts index 555015c10d..5f3798ca66 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1587669153312-InitialMigration.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1587669153312-InitialMigration.ts @@ -1,4 +1,5 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { + MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; diff --git a/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts b/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts index 0e8ed1e76a..a521d227d0 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts @@ -1,7 +1,18 @@ -import {MigrationInterface, QueryRunner} from 'typeorm'; +import { + MigrationInterface, + QueryRunner, +} from 'typeorm'; + +import { + IWorkflowDb, + NodeTypes, + WebhookHelpers, + } from '../../..'; + +import { + Workflow, +} from 'n8n-workflow/dist/src/Workflow'; -import { IWorkflowDb, NodeTypes, WebhookHelpers } from '../../..'; -import { Workflow } from 'n8n-workflow/dist/src/Workflow'; import { IWebhookDb, } from '../../../Interfaces'; @@ -18,7 +29,7 @@ export class WebhookModel1589476000887 implements MigrationInterface { tablePrefix = schema + '.' + tablePrefix; } - await queryRunner.query(`CREATE TABLE ${tablePrefix}webhook_entity ("id" SERIAL NOT NULL, "workflowId" integer NOT NULL, "webhookPath" character varying NOT NULL, "method" character varying NOT NULL, "node" character varying NOT NULL, CONSTRAINT "UQ_b21ace2e13596ccd87dc9bf4ea6" UNIQUE ("webhookPath", "method"), CONSTRAINT "PK_202217c8b912cf70b93b1e87256" PRIMARY KEY ("id"))`, undefined); + await queryRunner.query(`CREATE TABLE ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" character varying NOT NULL, "method" character varying NOT NULL, "node" character varying NOT NULL, CONSTRAINT "PK_b21ace2e13596ccd87dc9bf4ea6" PRIMARY KEY ("webhookPath", "method"))`, undefined); const workflows = await queryRunner.query(`SELECT * FROM ${tablePrefix}workflow_entity WHERE active=true`) as IWorkflowDb[]; const data: IWebhookDb[] = []; diff --git a/packages/cli/src/databases/sqlite/WebhookEntity.ts b/packages/cli/src/databases/sqlite/WebhookEntity.ts index df89da4319..a78fd34ae9 100644 --- a/packages/cli/src/databases/sqlite/WebhookEntity.ts +++ b/packages/cli/src/databases/sqlite/WebhookEntity.ts @@ -1,8 +1,7 @@ import { Column, Entity, - Unique, - PrimaryGeneratedColumn, + PrimaryColumn, } from 'typeorm'; import { @@ -10,19 +9,15 @@ import { } from '../../Interfaces'; @Entity() -@Unique(['webhookPath', 'method']) export class WebhookEntity implements IWebhookDb { - @PrimaryGeneratedColumn() - id: number; - @Column() workflowId: number; - @Column() + @PrimaryColumn() webhookPath: string; - @Column() + @PrimaryColumn() method: string; @Column() diff --git a/packages/core/src/ActiveWebhooks.ts b/packages/core/src/ActiveWebhooks.ts index 17cf753830..7f6d705ccd 100644 --- a/packages/core/src/ActiveWebhooks.ts +++ b/packages/core/src/ActiveWebhooks.ts @@ -35,13 +35,20 @@ export class ActiveWebhooks { throw new Error('Webhooks can only be added for saved workflows as an id is needed!'); } + const webhookKey = this.getWebhookKey(webhookData.httpMethod, webhookData.path); + + //check that there is not a webhook already registed with that path/method + if (this.webhookUrls[webhookKey] !== undefined) { + throw new Error('There is test wenhook registered on that path'); + } + if (this.workflowWebhooks[webhookData.workflowId] === undefined) { this.workflowWebhooks[webhookData.workflowId] = []; } // Make the webhook available directly because sometimes to create it successfully // it gets called - this.webhookUrls[this.getWebhookKey(webhookData.httpMethod, webhookData.path)] = webhookData; + this.webhookUrls[webhookKey] = webhookData; const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); if (webhookExists === false) { diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 2a86c1be12..65ff1b5267 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -1585,6 +1585,11 @@ export default mixins( console.error(e); // eslint-disable-line no-console } node.parameters = nodeParameters !== null ? nodeParameters : {}; + + // if it's a webhook and the path is empty set the UUID as the default path + if (node.type === 'n8n-nodes-base.webhook' && node.parameters.path === '') { + node.parameters.path = node.webhookPath as string; + } } foundNodeIssues = this.getNodeIssues(nodeType, node); From 1f4b8f8999046a62c3e85b5b4dfd16bc723abf29 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 31 May 2020 21:13:45 +0200 Subject: [PATCH 003/155] :zap: Small improvements --- packages/core/src/ActiveWebhooks.ts | 2 +- packages/nodes-base/nodes/Webhook.node.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/ActiveWebhooks.ts b/packages/core/src/ActiveWebhooks.ts index 7f6d705ccd..529fe01af3 100644 --- a/packages/core/src/ActiveWebhooks.ts +++ b/packages/core/src/ActiveWebhooks.ts @@ -39,7 +39,7 @@ export class ActiveWebhooks { //check that there is not a webhook already registed with that path/method if (this.webhookUrls[webhookKey] !== undefined) { - throw new Error('There is test wenhook registered on that path'); + throw new Error(`Test-Webhook can not be activated because another one with the same method "${webhookData.httpMethod}" and path "${webhookData.path}" is already active!`); } if (this.workflowWebhooks[webhookData.workflowId] === undefined) { diff --git a/packages/nodes-base/nodes/Webhook.node.ts b/packages/nodes-base/nodes/Webhook.node.ts index 135960ea4e..26562abc4c 100644 --- a/packages/nodes-base/nodes/Webhook.node.ts +++ b/packages/nodes-base/nodes/Webhook.node.ts @@ -133,7 +133,7 @@ export class Webhook implements INodeType { default: '', placeholder: 'webhook', required: true, - description: 'The path to listen to. Slashes("/") in the path are not allowed.', + description: 'The path to listen to.', }, { displayName: 'Response Code', From 48765b7db662430e3c9a6434dfb017f60db2d966 Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Tue, 9 Jun 2020 15:48:40 +0200 Subject: [PATCH 004/155] OAuth2 support --- .../PipedriveOAuth2Api.credentials.ts | 47 +++++++++++++++++++ .../nodes/Pipedrive/GenericFunctions.ts | 37 +++++++++------ .../nodes/Pipedrive/Pipedrive.node.ts | 37 ++++++++++++++- .../nodes/Pipedrive/PipedriveTrigger.node.ts | 22 ++++++--- packages/nodes-base/package.json | 1 + 5 files changed, 121 insertions(+), 23 deletions(-) create mode 100644 packages/nodes-base/credentials/PipedriveOAuth2Api.credentials.ts diff --git a/packages/nodes-base/credentials/PipedriveOAuth2Api.credentials.ts b/packages/nodes-base/credentials/PipedriveOAuth2Api.credentials.ts new file mode 100644 index 0000000000..813202739f --- /dev/null +++ b/packages/nodes-base/credentials/PipedriveOAuth2Api.credentials.ts @@ -0,0 +1,47 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class PipedriveOAuth2Api implements ICredentialType { + name = 'pipedriveOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Pipedrive OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://oauth.pipedrive.com/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://oauth.pipedrive.com/oauth/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + 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/Pipedrive/GenericFunctions.ts b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts index 32e8194359..9809d0feb3 100644 --- a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts @@ -23,7 +23,6 @@ export interface ICustomProperties { [key: string]: ICustomInterface; } - /** * Make an API request to Pipedrive * @@ -34,16 +33,7 @@ export interface ICustomProperties { * @returns {Promise} */ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctions, method: string, endpoint: string, body: IDataObject, query?: IDataObject, formData?: IDataObject, downloadFile?: boolean): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('pipedriveApi'); - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - - if (query === undefined) { - query = {}; - } - - query.api_token = credentials.apiToken; + const authenticationMethod = this.getNodeParameter('authentication', 0); const options: OptionsWithUri = { method, @@ -65,8 +55,27 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio options.formData = formData; } + if (query === undefined) { + query = {}; + } + + let responseData; + try { - const responseData = await this.helpers.request(options); + if (authenticationMethod === 'accessToken') { + + const credentials = this.getCredentials('pipedriveApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + query.api_token = credentials.apiToken; + + responseData = await this.helpers.request(options); + + } else { + responseData = await this.helpers.requestOAuth2!.call(this, 'pipedriveOAuth2Api', options); + } if (downloadFile === true) { return { @@ -82,7 +91,7 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio additionalData: responseData.additional_data, data: responseData.data, }; - } catch (error) { + } catch(error) { if (error.statusCode === 401) { // Return a clear error throw new Error('The Pipedrive credentials are not valid!'); @@ -102,8 +111,6 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio } } - - /** * Make an API request to paginated Pipedrive endpoint * and return all results diff --git a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts index 8ae9c42630..3aef48ce59 100644 --- a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts @@ -61,9 +61,44 @@ export class Pipedrive implements INodeType { { name: 'pipedriveApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'pipedriveOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Resource', name: 'resource', diff --git a/packages/nodes-base/nodes/Pipedrive/PipedriveTrigger.node.ts b/packages/nodes-base/nodes/Pipedrive/PipedriveTrigger.node.ts index 2c55246a32..0fb3d37f99 100644 --- a/packages/nodes-base/nodes/Pipedrive/PipedriveTrigger.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/PipedriveTrigger.node.ts @@ -52,14 +52,21 @@ export class PipedriveTrigger implements INodeType { { name: 'pipedriveApi', required: true, + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, }, { - name: 'httpBasicAuth', + name: 'pipedriveOAuth2Api', required: true, displayOptions: { show: { authentication: [ - 'basicAuth', + 'oAuth2', ], }, }, @@ -80,15 +87,16 @@ export class PipedriveTrigger implements INodeType { type: 'options', options: [ { - name: 'Basic Auth', - value: 'basicAuth' + name: 'Access Token', + value: 'accessToken' }, { - name: 'None', - value: 'none' + name: 'OAuth2', + value: 'oAuth2' }, + ], - default: 'none', + default: 'accessToken', description: 'If authentication should be activated for the webhook (makes it more scure).', }, { diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 3f28db9dec..57332957ee 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -99,6 +99,7 @@ "dist/credentials/PagerDutyApi.credentials.js", "dist/credentials/PayPalApi.credentials.js", "dist/credentials/PipedriveApi.credentials.js", + "dist/credentials/PipedriveOAuth2Api.credentials.js", "dist/credentials/Postgres.credentials.js", "dist/credentials/Redis.credentials.js", "dist/credentials/RocketchatApi.credentials.js", From 5ed86670a8a029c841407a901d7829492b81f311 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 10 Jun 2020 15:39:15 +0200 Subject: [PATCH 005/155] :zap: Make it use of full webhook path more generic --- packages/cli/src/ActiveWorkflowRunner.ts | 16 +++++++++---- packages/cli/src/Server.ts | 1 - packages/core/src/NodeExecuteFunctions.ts | 3 ++- .../editor-ui/src/components/NodeWebhooks.vue | 3 ++- packages/nodes-base/nodes/Webhook.node.ts | 1 + packages/workflow/src/Interfaces.ts | 3 ++- packages/workflow/src/NodeHelpers.ts | 23 +++++++++++-------- packages/workflow/src/Workflow.ts | 2 +- 8 files changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index a72cb065c7..13fc5697ec 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -211,7 +211,7 @@ export class ActiveWorkflowRunner { */ async addWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflowExecuteAdditionalDataWorkflow, mode: WorkflowExecuteMode): Promise { const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData); - let path = ''; + let path = '' as string | undefined; for (const webhookData of webhooks) { @@ -221,12 +221,20 @@ export class ActiveWorkflowRunner { path = node.parameters.path as string; if (node.parameters.path === undefined) { - path = workflow.getSimpleParameterValue(node, webhookData.webhookDescription['path'], 'GET') as string; + path = workflow.getSimpleParameterValue(node, webhookData.webhookDescription['path']) as string | undefined; + + if (path === undefined) { + // TODO: Use a proper logger + console.error(`No webhook path could be found for node "${node.name}" in workflow "${workflow.id}".`); + continue; + } } + const isFullPath: boolean = workflow.getSimpleParameterValue(node, webhookData.webhookDescription['isFullPath'], false) as boolean; + const webhook = { workflowId: webhookData.workflowId, - webhookPath: NodeHelpers.getNodeWebhookPath(workflow.id as string, node, path), + webhookPath: NodeHelpers.getNodeWebhookPath(workflow.id as string, node, path, isFullPath), node: node.name, method: webhookData.httpMethod, } as IWebhookDb; @@ -257,7 +265,7 @@ export class ActiveWorkflowRunner { // it's a error runnig the webhook methods (checkExists, create) errorMessage = error.detail; } else { - errorMessage = error; + errorMessage = error.message; } throw new Error(errorMessage); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 4e27568983..cf53b85665 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -46,7 +46,6 @@ import { WorkflowCredentials, WebhookHelpers, WorkflowExecuteAdditionalData, - WorkflowHelpers, WorkflowRunner, GenericHelpers, } from './'; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 640a75ca9a..9cc9c776de 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -289,7 +289,8 @@ export function getNodeWebhookUrl(name: string, workflow: Workflow, node: INode, return undefined; } - return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id!, node, path.toString()); + const isFullPath: boolean = workflow.getSimpleParameterValue(node, webhookDescription['isFullPath'], false) as boolean; + return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id!, node, path.toString(), isFullPath); } diff --git a/packages/editor-ui/src/components/NodeWebhooks.vue b/packages/editor-ui/src/components/NodeWebhooks.vue index b649575a0b..e2875a49fd 100644 --- a/packages/editor-ui/src/components/NodeWebhooks.vue +++ b/packages/editor-ui/src/components/NodeWebhooks.vue @@ -110,8 +110,9 @@ export default mixins( const workflowId = this.$store.getters.workflowId; const path = this.getValue(webhookData, 'path'); + const isFullPath = this.getValue(webhookData, 'isFullPath') as unknown as boolean || false; - return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, this.node, path); + return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, this.node, path, isFullPath); }, }, watch: { diff --git a/packages/nodes-base/nodes/Webhook.node.ts b/packages/nodes-base/nodes/Webhook.node.ts index 26562abc4c..1778d44290 100644 --- a/packages/nodes-base/nodes/Webhook.node.ts +++ b/packages/nodes-base/nodes/Webhook.node.ts @@ -77,6 +77,7 @@ export class Webhook implements INodeType { { name: 'default', httpMethod: '={{$parameter["httpMethod"]}}', + isFullPath: true, responseCode: '={{$parameter["responseCode"]}}', responseMode: '={{$parameter["responseMode"]}}', responseData: '={{$parameter["responseData"]}}', diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index b5f143e3f6..32f272f25c 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -520,8 +520,9 @@ export interface IWebhookData { } export interface IWebhookDescription { - [key: string]: WebhookHttpMethod | WebhookResponseMode | string | undefined; + [key: string]: WebhookHttpMethod | WebhookResponseMode | boolean | string | undefined; httpMethod: WebhookHttpMethod | string; + isFullPath?: boolean; name: string; path: string; responseBinaryPropertyName?: string; diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 87e219699d..610aefda56 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -755,7 +755,7 @@ export function getNodeWebhooks(workflow: Workflow, node: INode, additionalData: const returnData: IWebhookData[] = []; for (const webhookDescription of nodeType.description.webhooks) { - let nodeWebhookPath = workflow.getSimpleParameterValue(node, webhookDescription['path'], 'GET'); + let nodeWebhookPath = workflow.getSimpleParameterValue(node, webhookDescription['path']); if (nodeWebhookPath === undefined) { // TODO: Use a proper logger console.error(`No webhook path could be found for node "${node.name}" in workflow "${workflowId}".`); @@ -768,7 +768,8 @@ export function getNodeWebhooks(workflow: Workflow, node: INode, additionalData: nodeWebhookPath = nodeWebhookPath.slice(1); } - const path = getNodeWebhookPath(workflowId, node, nodeWebhookPath); + const isFullPath: boolean = workflow.getSimpleParameterValue(node, webhookDescription['isFullPath'], false) as boolean; + const path = getNodeWebhookPath(workflowId, node, nodeWebhookPath, isFullPath); const httpMethod = workflow.getSimpleParameterValue(node, webhookDescription['httpMethod'], 'GET'); @@ -808,7 +809,7 @@ export function getNodeWebhooksBasic(workflow: Workflow, node: INode): IWebhookD const returnData: IWebhookData[] = []; for (const webhookDescription of nodeType.description.webhooks) { - let nodeWebhookPath = workflow.getSimpleParameterValue(node, webhookDescription['path'], 'GET'); + let nodeWebhookPath = workflow.getSimpleParameterValue(node, webhookDescription['path']); if (nodeWebhookPath === undefined) { // TODO: Use a proper logger console.error(`No webhook path could be found for node "${node.name}" in workflow "${workflowId}".`); @@ -821,9 +822,11 @@ export function getNodeWebhooksBasic(workflow: Workflow, node: INode): IWebhookD nodeWebhookPath = nodeWebhookPath.slice(1); } - const path = getNodeWebhookPath(workflowId, node, nodeWebhookPath); + const isFullPath: boolean = workflow.getSimpleParameterValue(node, webhookDescription['isFullPath'], false) as boolean; - const httpMethod = workflow.getSimpleParameterValue(node, webhookDescription['httpMethod'], 'GET'); + const path = getNodeWebhookPath(workflowId, node, nodeWebhookPath, isFullPath); + + const httpMethod = workflow.getSimpleParameterValue(node, webhookDescription['httpMethod']); if (httpMethod === undefined) { // TODO: Use a proper logger @@ -854,12 +857,12 @@ export function getNodeWebhooksBasic(workflow: Workflow, node: INode): IWebhookD * @param {string} path * @returns {string} */ -export function getNodeWebhookPath(workflowId: string, node: INode, path: string): string { +export function getNodeWebhookPath(workflowId: string, node: INode, path: string, isFullPath?: boolean): string { let webhookPath = ''; if (node.webhookPath === undefined) { webhookPath = `${workflowId}/${encodeURIComponent(node.name.toLowerCase())}/${path}`; } else { - if (node.type === 'n8n-nodes-base.webhook') { + if (isFullPath === true) { return path; } webhookPath = `${node.webhookPath}/${path}`; @@ -876,11 +879,11 @@ export function getNodeWebhookPath(workflowId: string, node: INode, path: string * @param {string} workflowId * @param {string} nodeTypeName * @param {string} path + * @param {boolean} isFullPath * @returns {string} */ -export function getNodeWebhookUrl(baseUrl: string, workflowId: string, node: INode, path: string): string { - // return `${baseUrl}/${workflowId}/${nodeTypeName}/${path}`; - return `${baseUrl}/${getNodeWebhookPath(workflowId, node, path)}`; +export function getNodeWebhookUrl(baseUrl: string, workflowId: string, node: INode, path: string, isFullPath?: boolean): string { + return `${baseUrl}/${getNodeWebhookPath(workflowId, node, path, isFullPath)}`; } diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 9a7d3ef1eb..8d09738598 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -715,7 +715,7 @@ export class Workflow { * @returns {(string | undefined)} * @memberof Workflow */ - getSimpleParameterValue(node: INode, parameterValue: string | undefined, defaultValue?: boolean | number | string): boolean | number | string | undefined { + getSimpleParameterValue(node: INode, parameterValue: string | boolean | undefined, defaultValue?: boolean | number | string): boolean | number | string | undefined { if (parameterValue === undefined) { // Value is not set so return the default return defaultValue; From 17ee152eaf0647d333e6f27e4243b287e15f6296 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 10 Jun 2020 15:58:57 +0200 Subject: [PATCH 006/155] :zap: Fix indentation --- .../migrations/1589476000887-WebhookModel.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts b/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts index a521d227d0..8ff5d583ca 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts @@ -7,7 +7,7 @@ import { IWorkflowDb, NodeTypes, WebhookHelpers, - } from '../../..'; +} from '../../..'; import { Workflow, @@ -15,21 +15,21 @@ import { import { IWebhookDb, - } from '../../../Interfaces'; +} from '../../../Interfaces'; - import * as config from '../../../../config'; +import * as config from '../../../../config'; export class WebhookModel1589476000887 implements MigrationInterface { name = 'WebhookModel1589476000887'; - async up(queryRunner: QueryRunner): Promise { + async up(queryRunner: QueryRunner): Promise { let tablePrefix = config.get('database.tablePrefix'); const schema = config.get('database.postgresdb.schema'); if (schema) { tablePrefix = schema + '.' + tablePrefix; } - await queryRunner.query(`CREATE TABLE ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" character varying NOT NULL, "method" character varying NOT NULL, "node" character varying NOT NULL, CONSTRAINT "PK_b21ace2e13596ccd87dc9bf4ea6" PRIMARY KEY ("webhookPath", "method"))`, undefined); + await queryRunner.query(`CREATE TABLE ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" character varying NOT NULL, "method" character varying NOT NULL, "node" character varying NOT NULL, CONSTRAINT "PK_b21ace2e13596ccd87dc9bf4ea6" PRIMARY KEY ("webhookPath", "method"))`, undefined); const workflows = await queryRunner.query(`SELECT * FROM ${tablePrefix}workflow_entity WHERE active=true`) as IWorkflowDb[]; const data: IWebhookDb[] = []; @@ -61,13 +61,13 @@ export class WebhookModel1589476000887 implements MigrationInterface { } } - async down(queryRunner: QueryRunner): Promise { + async down(queryRunner: QueryRunner): Promise { let tablePrefix = config.get('database.tablePrefix'); const schema = config.get('database.postgresdb.schema'); if (schema) { tablePrefix = schema + '.' + tablePrefix; } - await queryRunner.query(`DROP TABLE ${tablePrefix}webhook_entity`, undefined); - } + await queryRunner.query(`DROP TABLE ${tablePrefix}webhook_entity`, undefined); + } } From cee5c522de495d1977a548a9dd1d88c60fcc944c Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 10 Jun 2020 16:17:16 +0200 Subject: [PATCH 007/155] :zap: Rename webhookPath parameter on node to webhookId --- packages/editor-ui/src/views/NodeView.vue | 4 ++-- packages/workflow/src/Interfaces.ts | 2 +- packages/workflow/src/NodeHelpers.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 65ff1b5267..e22b98f595 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -949,7 +949,7 @@ export default mixins( newNodeData.name = this.getUniqueNodeName(newNodeData.name); if (nodeTypeData.webhooks && nodeTypeData.webhooks.length) { - newNodeData.webhookPath = uuidv4(); + newNodeData.webhookId = uuidv4(); } await this.addNodes([newNodeData]); @@ -1588,7 +1588,7 @@ export default mixins( // if it's a webhook and the path is empty set the UUID as the default path if (node.type === 'n8n-nodes-base.webhook' && node.parameters.path === '') { - node.parameters.path = node.webhookPath as string; + node.parameters.path = node.webhookId as string; } } diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 32f272f25c..393f9e28af 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -297,7 +297,7 @@ export interface INode { continueOnFail?: boolean; parameters: INodeParameters; credentials?: INodeCredentials; - webhookPath?: string; + webhookId?: string; } diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 610aefda56..cede36f5b5 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -859,13 +859,13 @@ export function getNodeWebhooksBasic(workflow: Workflow, node: INode): IWebhookD */ export function getNodeWebhookPath(workflowId: string, node: INode, path: string, isFullPath?: boolean): string { let webhookPath = ''; - if (node.webhookPath === undefined) { + if (node.webhookId === undefined) { webhookPath = `${workflowId}/${encodeURIComponent(node.name.toLowerCase())}/${path}`; } else { if (isFullPath === true) { return path; } - webhookPath = `${node.webhookPath}/${path}`; + webhookPath = `${node.webhookId}/${path}`; } return webhookPath; } From e5683a90ab8192c739ba4edcddfed4c2ba67d0b7 Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Tue, 16 Jun 2020 09:27:47 +0200 Subject: [PATCH 008/155] OAuth2 support --- .../AcuitySchedulingOAuth2Api.credentials.ts | 48 +++++++++++++++++++ .../AcuitySchedulingTrigger.node.ts | 37 +++++++++++++- .../AcuityScheduling/GenericFunctions.ts | 35 ++++++++------ packages/nodes-base/package.json | 1 + 4 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 packages/nodes-base/credentials/AcuitySchedulingOAuth2Api.credentials.ts diff --git a/packages/nodes-base/credentials/AcuitySchedulingOAuth2Api.credentials.ts b/packages/nodes-base/credentials/AcuitySchedulingOAuth2Api.credentials.ts new file mode 100644 index 0000000000..305aefcfe5 --- /dev/null +++ b/packages/nodes-base/credentials/AcuitySchedulingOAuth2Api.credentials.ts @@ -0,0 +1,48 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class AcuitySchedulingOAuth2Api implements ICredentialType { + name = 'acuitySchedulingOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'AcuityScheduling OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://acuityscheduling.com/oauth2/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://acuityscheduling.com/oauth2/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: 'api-v1', + required: true + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + }, + ]; +} diff --git a/packages/nodes-base/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts b/packages/nodes-base/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts index 2166b5cc85..5c72f02d13 100644 --- a/packages/nodes-base/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts +++ b/packages/nodes-base/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts @@ -32,7 +32,25 @@ export class AcuitySchedulingTrigger implements INodeType { { name: 'acuitySchedulingApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'acuitySchedulingOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], webhooks: [ { @@ -43,6 +61,23 @@ export class AcuitySchedulingTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Event', name: 'event', diff --git a/packages/nodes-base/nodes/AcuityScheduling/GenericFunctions.ts b/packages/nodes-base/nodes/AcuityScheduling/GenericFunctions.ts index 9a2d0fc597..49d96e395c 100644 --- a/packages/nodes-base/nodes/AcuityScheduling/GenericFunctions.ts +++ b/packages/nodes-base/nodes/AcuityScheduling/GenericFunctions.ts @@ -9,34 +9,39 @@ import { import { IDataObject } from 'n8n-workflow'; export async function acuitySchedulingApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('acuitySchedulingApi'); - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } + const authenticationMethod = this.getNodeParameter('authentication', 0); const options: OptionsWithUri = { headers: { 'Content-Type': 'application/json', }, - auth: { - user: credentials.userId as string, - password: credentials.apiKey as string, - }, + auth: {}, method, qs, body, uri: uri ||`https://acuityscheduling.com/api/v1${resource}`, json: true }; + try { - return await this.helpers.request!(options); - } catch (error) { + if (authenticationMethod === 'accessToken') { + const credentials = this.getCredentials('acuitySchedulingApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } - let errorMessage = error.message; - if (error.response.body && error.response.body.message) { - errorMessage = `[${error.response.body.status_code}] ${error.response.body.message}`; + options.auth = { + user: credentials.userId as string, + password: credentials.apiKey as string, + }; + + return await this.helpers.request!(options); + } else { + delete options.auth; + //@ts-ignore + return await this.helpers.requestOAuth2!.call(this, 'acuitySchedulingOAuth2Api', options, true); } - - throw new Error('Acuity Scheduling Error: ' + errorMessage); + } catch (error) { + throw new Error('Acuity Scheduling Error: ' + error.message); } } diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 649bde4837..4494504fab 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -29,6 +29,7 @@ "dist/credentials/ActiveCampaignApi.credentials.js", "dist/credentials/AgileCrmApi.credentials.js", "dist/credentials/AcuitySchedulingApi.credentials.js", + "dist/credentials/AcuitySchedulingOAuth2Api.credentials.js", "dist/credentials/AirtableApi.credentials.js", "dist/credentials/Amqp.credentials.js", "dist/credentials/AsanaApi.credentials.js", From 8015c91f258743fcf73759a0c8241aaa942f0c30 Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Tue, 16 Jun 2020 09:29:46 +0200 Subject: [PATCH 009/155] Update AcuitySchedulingTrigger.node.ts --- .../nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts b/packages/nodes-base/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts index 5c72f02d13..079b4e65b3 100644 --- a/packages/nodes-base/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts +++ b/packages/nodes-base/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts @@ -76,7 +76,7 @@ export class AcuitySchedulingTrigger implements INodeType { }, ], default: 'accessToken', - description: 'The resource to operate on.', + description: 'Method of authentication.', }, { displayName: 'Event', From 39f5cf92d829469a31aefce63226229e439c2632 Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Tue, 16 Jun 2020 15:50:17 +0200 Subject: [PATCH 010/155] Gitlab OAuth2 support --- .../GitlabOAuth2Api.credentials.ts | 53 +++++++++++++++++ .../nodes/Gitlab/GenericFunctions.ts | 42 +++++++++---- .../nodes-base/nodes/Gitlab/Gitlab.node.ts | 59 +++++++++++++++++-- .../nodes/Gitlab/GitlabTrigger.node.ts | 37 +++++++++++- packages/nodes-base/package.json | 1 + 5 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts diff --git a/packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts new file mode 100644 index 0000000000..60cc091836 --- /dev/null +++ b/packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts @@ -0,0 +1,53 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class GitlabOAuth2Api implements ICredentialType { + name = 'gitlabOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Gitlab OAuth2 API'; + properties = [ + { + displayName: 'Gitlab Server', + name: 'server', + type: 'string' as NodePropertyTypes, + default: 'https://gitlab.com' + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://gitlab.com/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://gitlab.com/oauth/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'string' as NodePropertyTypes, + default: 'api', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Gitlab/GenericFunctions.ts b/packages/nodes-base/nodes/Gitlab/GenericFunctions.ts index 8f1811b8c7..4b91358b28 100644 --- a/packages/nodes-base/nodes/Gitlab/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Gitlab/GenericFunctions.ts @@ -6,6 +6,7 @@ import { import { IDataObject, } from 'n8n-workflow'; +import { OptionsWithUri } from 'request'; /** * Make an API request to Gitlab @@ -17,24 +18,43 @@ import { * @returns {Promise} */ export async function gitlabApiRequest(this: IHookFunctions | IExecuteFunctions, method: string, endpoint: string, body: object, query?: object): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('gitlabApi'); - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - - const options = { + const options : OptionsWithUri = { method, - headers: { - 'Private-Token': `${credentials.accessToken}`, - }, + headers: {}, body, qs: query, - uri: `${(credentials.server as string).replace(/\/$/, '')}/api/v4${endpoint}`, + uri: '', json: true }; + if (query === undefined) { + delete options.qs; + } + + const authenticationMethod = this.getNodeParameter('authentication', 0); + try { - return await this.helpers.request(options); + if (authenticationMethod === 'accessToken') { + const credentials = this.getCredentials('gitlabApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.headers!['Private-Token'] = `${credentials.accessToken}`; + + options.uri = `${(credentials.server as string).replace(/\/$/, '')}/api/v4${endpoint}`; + + return await this.helpers.request(options); + } else { + const credentials = this.getCredentials('gitlabOAuth2Api'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.uri = `${(credentials.server as string).replace(/\/$/, '')}/api/v4${endpoint}`; + + return await this.helpers.requestOAuth2!.call(this, 'gitlabOAuth2Api', options); + } } catch (error) { if (error.statusCode === 401) { // Return a clear error diff --git a/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts b/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts index 39d71ae3da..920a2867ec 100644 --- a/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts +++ b/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts @@ -33,9 +33,44 @@ export class Gitlab implements INodeType { { name: 'gitlabApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'gitlabOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Resource', name: 'resource', @@ -793,10 +828,26 @@ export class Gitlab implements INodeType { const items = this.getInputData(); const returnData: IDataObject[] = []; - const credentials = this.getCredentials('gitlabApi'); + let credentials; - if (credentials === undefined) { - throw new Error('No credentials got returned!'); + const authenticationMethod = this.getNodeParameter('authentication', 0); + + try { + if (authenticationMethod === 'accessToken') { + credentials = this.getCredentials('gitlabApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + } else { + credentials = this.getCredentials('gitlabOAuth2Api'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + } + } catch (error) { + throw new Error(error); } // Operations which overwrite the returned data diff --git a/packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts b/packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts index 28987c2458..a3e9b9ee2a 100644 --- a/packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts +++ b/packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts @@ -34,7 +34,25 @@ export class GitlabTrigger implements INodeType { { name: 'gitlabApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'gitlabOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], webhooks: [ { @@ -45,6 +63,23 @@ export class GitlabTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Repository Owner', name: 'owner', diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index dcfaeb2269..989cb0f055 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -56,6 +56,7 @@ "dist/credentials/GithubApi.credentials.js", "dist/credentials/GithubOAuth2Api.credentials.js", "dist/credentials/GitlabApi.credentials.js", + "dist/credentials/GitlabOAuth2Api.credentials.js", "dist/credentials/GoogleApi.credentials.js", "dist/credentials/GoogleCalendarOAuth2Api.credentials.js", "dist/credentials/GoogleDriveOAuth2Api.credentials.js", From 355ccc3201f388b6c0160b8f53339e8fd571845b Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Tue, 16 Jun 2020 17:46:38 -0700 Subject: [PATCH 011/155] add authentication --- .../credentials/ZoomApi.credentials.ts | 14 ++ .../credentials/ZoomOAuth2Api.credentials.ts | 50 +++++++ .../nodes-base/nodes/Zoom/GenericFunctions.ts | 128 ++++++++++++++++++ packages/nodes-base/nodes/Zoom/Zoom.node.ts | 103 ++++++++++++++ packages/nodes-base/nodes/Zoom/zoom.png | Bin 0 -> 1848 bytes packages/nodes-base/package.json | 3 + 6 files changed, 298 insertions(+) create mode 100644 packages/nodes-base/credentials/ZoomApi.credentials.ts create mode 100644 packages/nodes-base/credentials/ZoomOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/Zoom/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Zoom/Zoom.node.ts create mode 100644 packages/nodes-base/nodes/Zoom/zoom.png diff --git a/packages/nodes-base/credentials/ZoomApi.credentials.ts b/packages/nodes-base/credentials/ZoomApi.credentials.ts new file mode 100644 index 0000000000..3db4aadbe0 --- /dev/null +++ b/packages/nodes-base/credentials/ZoomApi.credentials.ts @@ -0,0 +1,14 @@ +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; + +export class ZoomApi implements ICredentialType { + name = 'zoomApi'; + displayName = 'Zoom API'; + properties = [ + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '' + } + ]; +} diff --git a/packages/nodes-base/credentials/ZoomOAuth2Api.credentials.ts b/packages/nodes-base/credentials/ZoomOAuth2Api.credentials.ts new file mode 100644 index 0000000000..2b05c819a7 --- /dev/null +++ b/packages/nodes-base/credentials/ZoomOAuth2Api.credentials.ts @@ -0,0 +1,50 @@ +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; + +const userScopes = [ + 'meeting:read', + 'meeting:write', + 'user:read', + 'user:write', + 'user_profile', + 'webinar:read', + 'webinar:write' +]; + +export class ZoomOAuth2Api implements ICredentialType { + name = 'zoomOAuth2Api'; + extends = ['oAuth2Api']; + displayName = 'Zoom OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://zoom.us/oauth/authorize' + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://zoom.us/oauth/token' + }, + + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '' + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '' + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body' + } + ]; +} diff --git a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts new file mode 100644 index 0000000000..0e2feda8db --- /dev/null +++ b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts @@ -0,0 +1,128 @@ +import { OptionsWithUri } from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + ILoadOptionsFunctions +} from 'n8n-core'; + +import { IDataObject } from 'n8n-workflow'; +import * as _ from 'lodash'; + +export async function zoomApiRequest( + this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + method: string, + resource: string, + body: object = {}, + query: object = {}, + headers: {} | undefined = undefined, + option: {} = {} +): Promise { + // tslint:disable-line:no-any + const authenticationMethod = this.getNodeParameter( + 'authentication', + 0, + 'accessToken' + ) as string; + let options: OptionsWithUri = { + method, + headers: headers || { + 'Content-Type': 'application/json' + }, + body, + qs: query, + uri: `https://zoom.us/oauth${resource}`, + json: true + }; + options = Object.assign({}, options, option); + if (Object.keys(body).length === 0) { + delete options.body; + } + if (Object.keys(query).length === 0) { + delete options.qs; + } + try { + if (authenticationMethod === 'accessToken') { + const credentials = this.getCredentials('zoomApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + options.headers!.Authorization = `Bearer ${credentials.accessToken}`; + //@ts-ignore + return await this.helpers.request(options); + } else { + //@ts-ignore + return await this.helpers.requestOAuth2.call( + this, + 'zoomOAuth2Api', + options + ); + } + } catch (error) { + if (error.statusCode === 401) { + // Return a clear error + throw new Error('The Zoom credentials are not valid!'); + } + + if (error.response && error.response.body && error.response.body.message) { + // Try to return the error prettier + throw new Error( + `Zoom error response [${error.statusCode}]: ${error.response.body.message}` + ); + } + + // If that data does not exist for some reason return the actual error + throw error; + } +} + +export async function zoomApiRequestAllItems( + 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; + query.count = 100; + do { + responseData = await zoomApiRequest.call( + this, + method, + endpoint, + body, + query + ); + query.cursor = encodeURIComponent( + _.get(responseData, 'response_metadata.next_cursor') + ); + query.page++; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + (responseData.response_metadata !== undefined && + responseData.response_metadata.mext_cursor !== undefined && + responseData.response_metadata.next_cursor !== '' && + responseData.response_metadata.next_cursor !== null) || + (responseData.paging !== undefined && + responseData.paging.pages !== undefined && + responseData.paging.page !== undefined && + responseData.paging.page < responseData.paging.pages) + ); + + return returnData; +} + +export function validateJSON(json: string | undefined): any { + // tslint:disable-line:no-any + let result; + try { + result = JSON.parse(json!); + } catch (exception) { + result = undefined; + } + return result; +} diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts new file mode 100644 index 0000000000..ce32b4c51b --- /dev/null +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -0,0 +1,103 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription +} from 'n8n-workflow'; +import { + zoomApiRequest, + zoomApiRequestAllItems, + validateJSON +} from './GenericFunctions'; +export class Zoom implements INodeType { + description: INodeTypeDescription = { + displayName: 'Zoom', + name: 'zoom', + group: ['input'], + version: 1, + description: 'Consume Zoom API', + defaults: { + name: 'Zoom', + color: '#772244' + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'zoomApi', + required: true, + displayOptions: { + show: { + authentication: ['accessToken'] + } + } + }, + { + name: 'zoomOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'] + } + } + } + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken' + }, + { + name: 'OAuth2', + value: 'oAuth2' + } + ], + default: 'accessToken', + description: 'The resource to operate on.' + } + ] + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = (items.length as unknown) as number; + let qs: IDataObject; + let responseData; + const authentication = this.getNodeParameter('authentication', 0) as string; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < length; i++) { + qs = {}; + if (resource === 'channel') { + //https://api.slack.com/methods/conversations.archive + if (operation === 'archive') { + const channel = this.getNodeParameter('channelId', i) as string; + const body: IDataObject = { + channel + }; + responseData = await zoomApiRequest.call( + this, + 'POST', + '/conversations.archive', + body, + qs + ); + } + } + } + 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/Zoom/zoom.png b/packages/nodes-base/nodes/Zoom/zoom.png new file mode 100644 index 0000000000000000000000000000000000000000..dfc72331ebddb8f4f2bfded903500be639581567 GIT binary patch literal 1848 zcmV-82gmq{P)Px%JbF}EbW&k=AaHVTW@&6?Aar?fWgvKMZ~y=}jg?hP zvfLmH+;fT?0ZT$M$MFnim#Qpt{B*;7-uQVkiCtx5B-E{z!0GqTU+D+0SS9IId#sUt zarKLrFv%`nJiPj@O=Rzv%cYB8zPfRygcfU>twlD4@9H(#8e3pt(b#M+=6EBZDi(=o z*Ilw+W7REI$3kxe^F=bh{px!tZHWkAW8TR_w`q8|4<^qrn7eR=Jyu9AtF@AokX(;3 zrA2mCY2cG}QJ^aF#oVH%g=5TJF)PC^((#^8Ptu0lHXJv&gyh35xAX)o;8VoR{E(WY zVPtx181Aea(&;fS*#lm(55@uHMJw+I6T!1h05A#-)Q5_oij4@gzp?-YlnR9quS2N{ zh2U|w`;s774+u+EQ`oPypupex4zT@MB8)X**pSK!KAGepZNAs)7}6TL=S|r38uvUb z0=ARj8dli4L*w+B^m%YLcR(-rbcRczLnFb0GUHR~iOujvfeV5dYgY)+{rasbGZ?{v zPC7=J4O$d!m<1g>cHpe(@R0fh8H#O2E0Ux}q+hJssN8fyN_A|}O?d@c5S@;VSkb)I zgnf^1Y*1UUE^AISVpxZ{D*QlbK|V)hrlC=|3(4>Z%+5YRw&fl2JK*%_;aBuBdf0)E z4~$v4{H5TV-?X_)lm1bg_{1=sYV#HOgEn_*(gAt>Uu}e^0K~zWE&cC%d}{dgn&;7D z_I7%v=P5i*C%*!uG!K2k^At~i0DWd{JA-D?0ssI232;bRa{vGU0RR910RT@W#MS@+ z0Kia8R7L;)|0#0+DY^eCYXATL{V96?D7OFq{{Q{{|0uBkDyjb|ZvQB%|0{t1D5d`> zp#LbW|0{FUP>}zY$o^Hp|AxN*aHamW+Wp(+{&2?rNUr}pa{YDmg%SV&1Gz~= zK~zY`)t75ysxS#S=h0`rOkJY$Xx#LekY=iKf8|ErU$BL)MyWD&~x4T#m6|#L%aLQ;3GC zb4sdyc&(v7JRu(z95Jor$4Ro@{W+`MI^mp`L-X~`>ZXQPH*2ok8Y*u7*Xp8%ZeUq` z@%(`^Tsd9WVx?AVo<(MIyqA}k7qAX4&*=hp$sunDOd#+e2M5|Ex8b&0t3}IptHCdH zwNoB18@^s&{YJ08@`!ze1z+nT*RMXy%Z>otxdyebype25SWH1kAd~(DUe*H?zIxn8ifElSQGzW)pxRvbF(oHlIxF4}vWOU~rxJ z2S))5X<$mBGe(o|1TzHq$p*ifpS4eVgr9Vg(Vl$8!UAbOO$1zue0QgXvAwh84LHsi zrvTq6ktKX5q#ZN(0gUaMV%q3eiX7lMU1We5z*Mwb&VL8XF~4E2qWGu~4)U}qCWCMg z1W$9SsW6hIjgb)!5LP2kgFwLYT=C~ujG4wn!XY|NBYK1*3BMpLNv8ZHz?k>=s9*pz zgGWS#UtHIfaCJVwNaSu4DvIPj48U(1RpAhZ#*TA{V|hl?(EVWep4^Av=xSj1Wh)D* zG&|vRG)5cRm{a(K)JlV=E(xtx~ z9oi5BxY0kYu6ocYMD~}BZVffEU1RCAETcdei;A0q@R7`!d62lVRZ<=*z&}I(|JN63T z<8-_k*U)C1#)r8lHfB7C$!EI#xjmzwZTg8n97>lFb9`uh%paXm#Ico8{I^01|5Z3Y m^XEj^W09%Wm7f*)|MeG&CZon#`=kW`0000 Date: Wed, 17 Jun 2020 19:38:30 +0200 Subject: [PATCH 012/155] NextCloud OAuth2 support --- .../NextCloudOAuth2Api.credentials.ts | 54 +++++++++++++++ .../nodes/NextCloud/GenericFunctions.ts | 64 ++++++++++++++++++ .../nodes/NextCloud/NextCloud.node.ts | 65 ++++++++++++++----- packages/nodes-base/package.json | 1 + 4 files changed, 167 insertions(+), 17 deletions(-) create mode 100644 packages/nodes-base/credentials/NextCloudOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/NextCloud/GenericFunctions.ts diff --git a/packages/nodes-base/credentials/NextCloudOAuth2Api.credentials.ts b/packages/nodes-base/credentials/NextCloudOAuth2Api.credentials.ts new file mode 100644 index 0000000000..dbb4efcda1 --- /dev/null +++ b/packages/nodes-base/credentials/NextCloudOAuth2Api.credentials.ts @@ -0,0 +1,54 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class NextCloudOAuth2Api implements ICredentialType { + name = 'nextCloudOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'NextCloud OAuth2 API'; + properties = [ + { + displayName: 'Web DAV URL', + name: 'webDavUrl', + type: 'string' as NodePropertyTypes, + placeholder: 'https://nextcloud.example.com/remote.php/webdav/', + default: '', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'string' as NodePropertyTypes, + default: 'https://nextcloud.example.com/apps/oauth2/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'string' as NodePropertyTypes, + default: 'https://nextcloud.example.com/apps/oauth2/api/v1/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + }, + ]; +} diff --git a/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts b/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts new file mode 100644 index 0000000000..fc4e7646bf --- /dev/null +++ b/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts @@ -0,0 +1,64 @@ +import { + IExecuteFunctions, + IHookFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; +import { OptionsWithUri } from 'request'; + +/** + * Make an API request to NextCloud + * + * @param {IHookFunctions} this + * @param {string} method + * @param {string} url + * @param {object} body + * @returns {Promise} + */ +export async function nextCloudApiRequest(this: IHookFunctions | IExecuteFunctions, method: string, endpoint: string, body: object | string | Buffer, headers?: object, encoding?: null | undefined, query?: object): Promise { // tslint:disable-line:no-any + const options : OptionsWithUri = { + headers, + method, + body, + qs: {}, + uri: '', + json: false, + }; + + if (encoding === null) { + options.encoding = null; + } + + const authenticationMethod = this.getNodeParameter('authentication', 0); + + try { + if (authenticationMethod === 'accessToken') { + const credentials = this.getCredentials('nextCloudApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.auth = { + user: credentials.user as string, + pass: credentials.password as string, + }; + + options.uri = `${credentials.webDavUrl}/${encodeURI(endpoint)}`; + + return await this.helpers.request(options); + } else { + const credentials = this.getCredentials('nextCloudOAuth2Api'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.uri = `${credentials.webDavUrl}/${encodeURI(endpoint)}`; + + return await this.helpers.requestOAuth2!.call(this, 'nextCloudOAuth2Api', options); + } + } catch (error) { + throw new Error(`NextCloud Error. Status Code: ${error.statusCode}. Message: ${error.message}`); + } +} diff --git a/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts b/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts index 309f53ceae..8b3f0620fc 100644 --- a/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts +++ b/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts @@ -11,6 +11,7 @@ import { import { parseString } from 'xml2js'; import { OptionsWithUri } from 'request'; +import { nextCloudApiRequest } from './GenericFunctions'; export class NextCloud implements INodeType { @@ -32,9 +33,44 @@ export class NextCloud implements INodeType { { name: 'nextCloudApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'nextCloudOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Resource', name: 'resource', @@ -446,7 +482,14 @@ export class NextCloud implements INodeType { const items = this.getInputData().slice(); const returnData: IDataObject[] = []; - const credentials = this.getCredentials('nextCloudApi'); + const authenticationMethod = this.getNodeParameter('authentication', 0); + let credentials; + + if (authenticationMethod === 'accessToken') { + credentials = this.getCredentials('nextCloudApi'); + } else { + credentials = this.getCredentials('nextCloudOAuth2Api'); + } if (credentials === undefined) { throw new Error('No credentials got returned!'); @@ -562,26 +605,14 @@ export class NextCloud implements INodeType { webDavUrl = webDavUrl.slice(0, -1); } - const options: OptionsWithUri = { - auth: { - user: credentials.user as string, - pass: credentials.password as string, - }, - headers, - method: requestMethod, - body, - qs: {}, - uri: `${credentials.webDavUrl}/${encodeURI(endpoint)}`, - json: false, - }; - + let encoding = undefined; if (resource === 'file' && operation === 'download') { // Return the data as a buffer - options.encoding = null; + encoding = null; } try { - responseData = await this.helpers.request(options); + responseData = await nextCloudApiRequest.call(this, requestMethod, endpoint, body, headers, encoding); } catch (error) { if (this.continueOnFail() === true) { returnData.push({ error }); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index dcfaeb2269..c3a3183db0 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -98,6 +98,7 @@ "dist/credentials/Msg91Api.credentials.js", "dist/credentials/MySql.credentials.js", "dist/credentials/NextCloudApi.credentials.js", + "dist/credentials/NextCloudOAuth2Api.credentials.js", "dist/credentials/OAuth1Api.credentials.js", "dist/credentials/OAuth2Api.credentials.js", "dist/credentials/OpenWeatherMapApi.credentials.js", From eb2ced7e3c702a103e142cf1772a2edc6b19f448 Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Wed, 17 Jun 2020 11:16:31 -0700 Subject: [PATCH 013/155] add resource --- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 56 +++++++++++++-------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index ce32b4c51b..ff7487a2e2 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -17,10 +17,12 @@ export class Zoom implements INodeType { group: ['input'], version: 1, description: 'Consume Zoom API', + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', defaults: { name: 'Zoom', color: '#772244' }, + icon: 'file:zoom.png', inputs: ['main'], outputs: ['main'], credentials: [ @@ -60,6 +62,19 @@ export class Zoom implements INodeType { ], default: 'accessToken', description: 'The resource to operate on.' + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'meeting', + value: 'meeting' + } + ], + default: 'meeting', + description: 'The resource to operate on.' } ] }; @@ -72,26 +87,27 @@ export class Zoom implements INodeType { let responseData; const authentication = this.getNodeParameter('authentication', 0) as string; const resource = this.getNodeParameter('resource', 0) as string; - const operation = this.getNodeParameter('operation', 0) as string; - for (let i = 0; i < length; i++) { - qs = {}; - if (resource === 'channel') { - //https://api.slack.com/methods/conversations.archive - if (operation === 'archive') { - const channel = this.getNodeParameter('channelId', i) as string; - const body: IDataObject = { - channel - }; - responseData = await zoomApiRequest.call( - this, - 'POST', - '/conversations.archive', - body, - qs - ); - } - } - } + // const operation = this.getNodeParameter('operation', 0) as string; + console.log(this.getCredentials('zoomOAuth2Api')); + // for (let i = 0; i < length; i++) { + // qs = {}; + // if (resource === 'channel') { + // //https://api.slack.com/methods/conversations.archive + // if (operation === 'archive') { + // const channel = this.getNodeParameter('channelId', i) as string; + // const body: IDataObject = { + // channel + // }; + // responseData = await zoomApiRequest.call( + // this, + // 'POST', + // '/conversations.archive', + // body, + // qs + // ); + // } + // } + // } if (Array.isArray(responseData)) { returnData.push.apply(returnData, responseData as IDataObject[]); } else { From 84c4b32261fcb48e2777cf08b83e74636bf56cd9 Mon Sep 17 00:00:00 2001 From: ricardo Date: Wed, 17 Jun 2020 23:42:04 -0400 Subject: [PATCH 014/155] :zap: Added migrations to Sqlite and Mysql --- packages/cli/src/Db.ts | 10 ++-- .../src/databases/mongodb/WebhookEntity.ts | 7 ++- .../migrations/1592447867632-WebhookModel.ts | 59 +++++++++++++++++++ .../src/databases/mysqldb/migrations/index.ts | 3 +- .../migrations/1592445003908-WebhookModel.ts | 59 +++++++++++++++++++ .../src/databases/sqlite/migrations/index.ts | 3 +- 6 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/databases/mysqldb/migrations/1592447867632-WebhookModel.ts create mode 100644 packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index 9077922a5c..7a8c808363 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -40,11 +40,13 @@ import { } from './databases/mongodb/migrations'; import { - InitialMigration1588157391238 + InitialMigration1588157391238, + WebhookModel1592447867632, } from './databases/mysqldb/migrations'; import { - InitialMigration1588102412422 + InitialMigration1588102412422, + WebhookModel1592445003908, } from './databases/sqlite/migrations'; import * as path from 'path'; @@ -100,7 +102,7 @@ export async function init(): Promise { password: await GenericHelpers.getConfigValue('database.mysqldb.password') as string, port: await GenericHelpers.getConfigValue('database.mysqldb.port') as number, username: await GenericHelpers.getConfigValue('database.mysqldb.user') as string, - migrations: [InitialMigration1588157391238], + migrations: [InitialMigration1588157391238, WebhookModel1592447867632], migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, }; @@ -112,7 +114,7 @@ export async function init(): Promise { type: 'sqlite', database: path.join(n8nFolder, 'database.sqlite'), entityPrefix, - migrations: [InitialMigration1588102412422], + migrations: [InitialMigration1588102412422, WebhookModel1592445003908], migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, }; diff --git a/packages/cli/src/databases/mongodb/WebhookEntity.ts b/packages/cli/src/databases/mongodb/WebhookEntity.ts index a78fd34ae9..646a995ba9 100644 --- a/packages/cli/src/databases/mongodb/WebhookEntity.ts +++ b/packages/cli/src/databases/mongodb/WebhookEntity.ts @@ -1,7 +1,7 @@ import { Column, Entity, - PrimaryColumn, + Index, } from 'typeorm'; import { @@ -9,15 +9,16 @@ import { } from '../../Interfaces'; @Entity() +@Index(['webhookPath', 'method'], { unique: true }) export class WebhookEntity implements IWebhookDb { @Column() workflowId: number; - @PrimaryColumn() + @Column() webhookPath: string; - @PrimaryColumn() + @Column() method: string; @Column() diff --git a/packages/cli/src/databases/mysqldb/migrations/1592447867632-WebhookModel.ts b/packages/cli/src/databases/mysqldb/migrations/1592447867632-WebhookModel.ts new file mode 100644 index 0000000000..6c81f612ed --- /dev/null +++ b/packages/cli/src/databases/mysqldb/migrations/1592447867632-WebhookModel.ts @@ -0,0 +1,59 @@ +import { + MigrationInterface, + QueryRunner, +} from 'typeorm'; + +import * as config from '../../../../config'; + +import { + IWorkflowDb, + NodeTypes, + WebhookHelpers, +} from '../../..'; + +import { + Workflow, +} from 'n8n-workflow/dist/src/Workflow'; + +import { + IWebhookDb, +} from '../../../Interfaces'; + +export class WebhookModel1592447867632 implements MigrationInterface { + name = 'WebhookModel1592447867632'; + + async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}webhook_entity (workflowId int NOT NULL, webhookPath varchar(255) NOT NULL, method varchar(255) NOT NULL, node varchar(255) NOT NULL, PRIMARY KEY (webhookPath, method)) ENGINE=InnoDB`); + + const workflows = await queryRunner.query(`SELECT * FROM ${tablePrefix}workflow_entity WHERE active=true`) as IWorkflowDb[]; + const data: IWebhookDb[] = []; + const nodeTypes = NodeTypes(); + for (const workflow of workflows) { + const workflowInstance = new Workflow({ id: workflow.id as string, name: workflow.name, nodes: workflow.nodes, connections: workflow.connections, active: workflow.active, nodeTypes, staticData: workflow.staticData, settings: workflow.settings }); + const webhooks = WebhookHelpers.getWorkflowWebhooksBasic(workflowInstance); + for (const webhook of webhooks) { + data.push({ + workflowId: workflowInstance.id as string, + webhookPath: webhook.path, + method: webhook.httpMethod, + node: webhook.node, + }); + } + } + + if (data.length !== 0) { + await queryRunner.manager.createQueryBuilder() + .insert() + .into(`${tablePrefix}webhook_entity`) + .values(data) + .execute(); + } + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP TABLE ${tablePrefix}webhook_entity`); + } +} diff --git a/packages/cli/src/databases/mysqldb/migrations/index.ts b/packages/cli/src/databases/mysqldb/migrations/index.ts index ac2dcab467..9e5abd3b8c 100644 --- a/packages/cli/src/databases/mysqldb/migrations/index.ts +++ b/packages/cli/src/databases/mysqldb/migrations/index.ts @@ -1 +1,2 @@ -export * from './1588157391238-InitialMigration'; \ No newline at end of file +export * from './1588157391238-InitialMigration'; +export * from './1592447867632-WebhookModel'; diff --git a/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts b/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts new file mode 100644 index 0000000000..83e59b2f6f --- /dev/null +++ b/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts @@ -0,0 +1,59 @@ +import { + MigrationInterface, + QueryRunner, +} from "typeorm"; + +import * as config from '../../../../config'; + +import { + IWorkflowDb, + NodeTypes, + WebhookHelpers, +} from '../../..'; + +import { + Workflow, +} from 'n8n-workflow/dist/src/Workflow'; + +import { + IWebhookDb, +} from '../../../Interfaces'; + +export class WebhookModel1592445003908 implements MigrationInterface { + name = 'WebhookModel1592445003908'; + + async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, PRIMARY KEY ("webhookPath", "method"))`); + + const workflows = await queryRunner.query(`SELECT * FROM ${tablePrefix}workflow_entity WHERE active=true`) as IWorkflowDb[]; + const data: IWebhookDb[] = []; + const nodeTypes = NodeTypes(); + for (const workflow of workflows) { + const workflowInstance = new Workflow({ id: workflow.id as string, name: workflow.name, nodes: workflow.nodes, connections: workflow.connections, active: workflow.active, nodeTypes, staticData: workflow.staticData, settings: workflow.settings }); + const webhooks = WebhookHelpers.getWorkflowWebhooksBasic(workflowInstance); + for (const webhook of webhooks) { + data.push({ + workflowId: workflowInstance.id as string, + webhookPath: webhook.path, + method: webhook.httpMethod, + node: webhook.node, + }); + } + } + + if (data.length !== 0) { + await queryRunner.manager.createQueryBuilder() + .insert() + .into(`${tablePrefix}webhook_entity`) + .values(data) + .execute(); + } + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP TABLE ${tablePrefix}webhook_entity`); + } +} diff --git a/packages/cli/src/databases/sqlite/migrations/index.ts b/packages/cli/src/databases/sqlite/migrations/index.ts index 8d9a0a0b16..a830a007c7 100644 --- a/packages/cli/src/databases/sqlite/migrations/index.ts +++ b/packages/cli/src/databases/sqlite/migrations/index.ts @@ -1 +1,2 @@ -export * from './1588102412422-InitialMigration'; \ No newline at end of file +export * from './1588102412422-InitialMigration'; +export * from './1592445003908-WebhookModel'; From 0d1b4611c8a32214f6c9bfd5103ae885e39d08e7 Mon Sep 17 00:00:00 2001 From: Tanay Pant <7481165+tanay1337@users.noreply.github.com> Date: Thu, 18 Jun 2020 10:08:31 +0200 Subject: [PATCH 015/155] =?UTF-8?q?=E2=9C=8D=EF=B8=8F=20Make=20a=20few=20l?= =?UTF-8?q?anguage=20changes=20(#681)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 10 +++++----- LICENSE.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 568f8fc9a7..fcc046beef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ The most important directories: execution, active webhooks and workflows - [/packages/editor-ui](/packages/editor-ui) - Vue frontend workflow editor - - [/packages/node-dev](/packages/node-dev) - Simple CLI to create new n8n-nodes + - [/packages/node-dev](/packages/node-dev) - CLI to create new n8n-nodes - [/packages/nodes-base](/packages/nodes-base) - Base n8n nodes - [/packages/workflow](/packages/workflow) - Workflow code with interfaces which get used by front- & backend @@ -159,7 +159,7 @@ tests of all packages. ## Create Custom Nodes -It is very easy to create own nodes for n8n. More information about that can +It is very straightforward to create your own nodes for n8n. More information about that can be found in the documentation of "n8n-node-dev" which is a small CLI which helps with n8n-node-development. @@ -177,9 +177,9 @@ If you want to create a node which should be added to n8n follow these steps: 1. Create a new folder for the new node. For a service named "Example" the folder would be called: `/packages/nodes-base/nodes/Example` - 1. If there is already a similar node simply copy the existing one in the new folder and rename it. If none exists yet, create a boilerplate node with [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev) and copy that one in the folder. + 1. If there is already a similar node, copy the existing one in the new folder and rename it. If none exists yet, create a boilerplate node with [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev) and copy that one in the folder. - 1. If the node needs credentials because it has to authenticate with an API or similar create new ones. Existing ones can be found in folder `/packages/nodes-base/credentials`. Also there it is the easiest to simply copy existing similar ones. + 1. If the node needs credentials because it has to authenticate with an API or similar create new ones. Existing ones can be found in folder `/packages/nodes-base/credentials`. Also there it is the easiest to copy existing similar ones. 1. Add the path to the new node (and optionally credentials) to package.json of `nodes-base`. It already contains a property `n8n` with its own keys `credentials` and `nodes`. @@ -236,6 +236,6 @@ docsify serve ./docs That we do not have any potential problems later it is sadly necessary to sign a [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md). That can be done literally with the push of a button. -We used the most simple one that exists. It is from [Indie Open Source](https://indieopensource.com/forms/cla) which uses plain English and is literally just a few lines long. +We used the most simple one that exists. It is from [Indie Open Source](https://indieopensource.com/forms/cla) which uses plain English and is literally only a few lines long. A bot will automatically comment on the pull request once it got opened asking for the agreement to be signed. Before it did not get signed it is sadly not possible to merge it in. diff --git a/LICENSE.md b/LICENSE.md index aac54547eb..c2aec2148e 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright [2020] [n8n GmbH] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 3ca9647215c9e137f4b5e0fa2e0a524d48366927 Mon Sep 17 00:00:00 2001 From: ricardo Date: Sat, 20 Jun 2020 21:59:06 -0400 Subject: [PATCH 016/155] :zap: Add mongodb migration --- packages/cli/src/ActiveWorkflowRunner.ts | 10 +++- packages/cli/src/Db.ts | 5 +- .../src/databases/mongodb/WebhookEntity.ts | 6 +- .../migrations/1592679094242-WebhookModel.ts | 57 +++++++++++++++++++ .../src/databases/mongodb/migrations/index.ts | 1 + .../migrations/1589476000887-WebhookModel.ts | 5 -- 6 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/databases/mongodb/migrations/1592679094242-WebhookModel.ts diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index 13fc5697ec..c7f7ea1d69 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -35,7 +35,6 @@ import { import * as express from 'express'; - export class ActiveWorkflowRunner { private activeWorkflows: ActiveWorkflows | null = null; @@ -240,6 +239,7 @@ export class ActiveWorkflowRunner { } as IWebhookDb; try { + await Db.collections.Webhook?.insert(webhook); const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, false); @@ -257,7 +257,7 @@ export class ActiveWorkflowRunner { // if it's a workflow from the the insert // TODO check if there is standard error code for deplicate key violation that works // with all databases - if (error.name === 'QueryFailedError') { + if (error.name === 'MongoError' || error.name === 'QueryFailedError') { errorMessage = `The webhook path [${webhook.webhookPath}] and method [${webhook.method}] already exist.`; @@ -303,6 +303,11 @@ export class ActiveWorkflowRunner { await workflow.runWebhookMethod('delete', webhookData, NodeExecuteFunctions, mode, false); } + // if it's a mongo objectId convert it to string + if (typeof workflowData.id === 'object') { + workflowData.id = workflowData.id.toString(); + } + const webhook = { workflowId: workflowData.id, } as IWebhookDb; @@ -310,7 +315,6 @@ export class ActiveWorkflowRunner { await Db.collections.Webhook?.delete(webhook); } - /** * Runs the given workflow * diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index 7a8c808363..ace8b62f1f 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -36,7 +36,8 @@ import { } from './databases/postgresdb/migrations'; import { - InitialMigration1587563438936 + InitialMigration1587563438936, + WebhookModel1592679094242, } from './databases/mongodb/migrations'; import { @@ -68,7 +69,7 @@ export async function init(): Promise { entityPrefix, url: await GenericHelpers.getConfigValue('database.mongodb.connectionUrl') as string, useNewUrlParser: true, - migrations: [InitialMigration1587563438936], + migrations: [InitialMigration1587563438936, WebhookModel1592679094242], migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, }; diff --git a/packages/cli/src/databases/mongodb/WebhookEntity.ts b/packages/cli/src/databases/mongodb/WebhookEntity.ts index 646a995ba9..dbf90f3da1 100644 --- a/packages/cli/src/databases/mongodb/WebhookEntity.ts +++ b/packages/cli/src/databases/mongodb/WebhookEntity.ts @@ -2,6 +2,8 @@ import { Column, Entity, Index, + ObjectID, + ObjectIdColumn, } from 'typeorm'; import { @@ -9,9 +11,11 @@ import { } from '../../Interfaces'; @Entity() -@Index(['webhookPath', 'method'], { unique: true }) export class WebhookEntity implements IWebhookDb { + @ObjectIdColumn() + id: ObjectID; + @Column() workflowId: number; diff --git a/packages/cli/src/databases/mongodb/migrations/1592679094242-WebhookModel.ts b/packages/cli/src/databases/mongodb/migrations/1592679094242-WebhookModel.ts new file mode 100644 index 0000000000..c05a44f765 --- /dev/null +++ b/packages/cli/src/databases/mongodb/migrations/1592679094242-WebhookModel.ts @@ -0,0 +1,57 @@ +import { + MigrationInterface, +} from 'typeorm'; + +import { + IWorkflowDb, + NodeTypes, + WebhookHelpers, +} from '../../..'; + +import { + Workflow, +} from 'n8n-workflow/dist/src/Workflow'; + +import { + IWebhookDb, +} from '../../../Interfaces'; + +import * as config from '../../../../config'; + +import { + MongoQueryRunner, +} from 'typeorm/driver/mongodb/MongoQueryRunner'; + +export class WebhookModel1592679094242 implements MigrationInterface { + name = 'WebhookModel1592679094242'; + + async up(queryRunner: MongoQueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + const workflows = await queryRunner.cursor( `${tablePrefix}workflow_entity`, { active: true }).toArray() as IWorkflowDb[]; + const data: IWebhookDb[] = []; + const nodeTypes = NodeTypes(); + for (const workflow of workflows) { + const workflowInstance = new Workflow({ id: workflow.id as string, name: workflow.name, nodes: workflow.nodes, connections: workflow.connections, active: workflow.active, nodeTypes, staticData: workflow.staticData, settings: workflow.settings }); + const webhooks = WebhookHelpers.getWorkflowWebhooksBasic(workflowInstance); + for (const webhook of webhooks) { + data.push({ + workflowId: workflowInstance.id as string, + webhookPath: webhook.path, + method: webhook.httpMethod, + node: webhook.node, + }); + } + } + + if (data.length !== 0) { + await queryRunner.manager.insertMany(`${tablePrefix}webhook_entity`, data); + } + + await queryRunner.manager.createCollectionIndex(`${tablePrefix}webhook_entity`, ['webhookPath', 'method'], { unique: true, background: false }); + } + + async down(queryRunner: MongoQueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.dropTable(`${tablePrefix}webhook_entity`); + } +} diff --git a/packages/cli/src/databases/mongodb/migrations/index.ts b/packages/cli/src/databases/mongodb/migrations/index.ts index a60bdc7cf8..4072ab582d 100644 --- a/packages/cli/src/databases/mongodb/migrations/index.ts +++ b/packages/cli/src/databases/mongodb/migrations/index.ts @@ -1 +1,2 @@ export * from './1587563438936-InitialMigration'; +export * from './1592679094242-WebhookModel'; diff --git a/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts b/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts index 8ff5d583ca..dfbf94a799 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts @@ -36,11 +36,6 @@ export class WebhookModel1589476000887 implements MigrationInterface { const nodeTypes = NodeTypes(); for (const workflow of workflows) { const workflowInstance = new Workflow({ id: workflow.id as string, name: workflow.name, nodes: workflow.nodes, connections: workflow.connections, active: workflow.active, nodeTypes, staticData: workflow.staticData, settings: workflow.settings }); - // I'm writing something more simple than this. I tried to use the built in method - // getWorkflowWebhooks but it needs additionaldata and to get it I need the credentials - // and for some reason when I use - // const credentials = await WorkflowCredentials(node); - // to get the credentials I got an error I think is cuz the database is yet not ready. const webhooks = WebhookHelpers.getWorkflowWebhooksBasic(workflowInstance); for (const webhook of webhooks) { data.push({ From e59dd00c107d656abb03d132113298d0c7bf9f1c Mon Sep 17 00:00:00 2001 From: ricardo Date: Mon, 22 Jun 2020 13:26:41 -0400 Subject: [PATCH 017/155] done --- .../databases/sqlite/migrations/1592445003908-WebhookModel.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts b/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts index 83e59b2f6f..f1402fad88 100644 --- a/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts +++ b/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts @@ -31,6 +31,10 @@ export class WebhookModel1592445003908 implements MigrationInterface { const data: IWebhookDb[] = []; const nodeTypes = NodeTypes(); for (const workflow of workflows) { + workflow.nodes = JSON.parse(workflow.nodes as unknown as string); + workflow.connections = JSON.parse(workflow.connections as unknown as string); + workflow.staticData = JSON.parse(workflow.staticData as unknown as string); + workflow.settings = JSON.parse(workflow.settings as unknown as string); const workflowInstance = new Workflow({ id: workflow.id as string, name: workflow.name, nodes: workflow.nodes, connections: workflow.connections, active: workflow.active, nodeTypes, staticData: workflow.staticData, settings: workflow.settings }); const webhooks = WebhookHelpers.getWorkflowWebhooksBasic(workflowInstance); for (const webhook of webhooks) { From b95e3464a45112f7202cb36829226746d571084e Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Mon, 22 Jun 2020 11:51:15 -0700 Subject: [PATCH 018/155] add meeting functionality --- CONTRIBUTING.md | 10 +- LICENSE.md | 2 +- packages/cli/LICENSE.md | 2 +- packages/core/LICENSE.md | 2 +- packages/editor-ui/LICENSE.md | 2 +- packages/node-dev/LICENSE.md | 2 +- packages/nodes-base/LICENSE.md | 2 +- .../nodes/Salesforce/GenericFunctions.ts | 2 +- packages/nodes-base/nodes/Slack/Slack.node.ts | 114 +-- .../nodes-base/nodes/Zoom/GenericFunctions.ts | 12 +- .../nodes/Zoom/MeetingDescription.ts | 751 ++++++++++++++++++ .../Zoom/MeetingRegistrantDescription.ts | 407 ++++++++++ packages/nodes-base/nodes/Zoom/Zoom.node.ts | 355 ++++++++- .../nodes-base/nodes/Zoom/ZoomOperations.ts | 71 -- packages/workflow/LICENSE.md | 2 +- 15 files changed, 1561 insertions(+), 175 deletions(-) create mode 100644 packages/nodes-base/nodes/Zoom/MeetingDescription.ts create mode 100644 packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts delete mode 100644 packages/nodes-base/nodes/Zoom/ZoomOperations.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 568f8fc9a7..fcc046beef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ The most important directories: execution, active webhooks and workflows - [/packages/editor-ui](/packages/editor-ui) - Vue frontend workflow editor - - [/packages/node-dev](/packages/node-dev) - Simple CLI to create new n8n-nodes + - [/packages/node-dev](/packages/node-dev) - CLI to create new n8n-nodes - [/packages/nodes-base](/packages/nodes-base) - Base n8n nodes - [/packages/workflow](/packages/workflow) - Workflow code with interfaces which get used by front- & backend @@ -159,7 +159,7 @@ tests of all packages. ## Create Custom Nodes -It is very easy to create own nodes for n8n. More information about that can +It is very straightforward to create your own nodes for n8n. More information about that can be found in the documentation of "n8n-node-dev" which is a small CLI which helps with n8n-node-development. @@ -177,9 +177,9 @@ If you want to create a node which should be added to n8n follow these steps: 1. Create a new folder for the new node. For a service named "Example" the folder would be called: `/packages/nodes-base/nodes/Example` - 1. If there is already a similar node simply copy the existing one in the new folder and rename it. If none exists yet, create a boilerplate node with [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev) and copy that one in the folder. + 1. If there is already a similar node, copy the existing one in the new folder and rename it. If none exists yet, create a boilerplate node with [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev) and copy that one in the folder. - 1. If the node needs credentials because it has to authenticate with an API or similar create new ones. Existing ones can be found in folder `/packages/nodes-base/credentials`. Also there it is the easiest to simply copy existing similar ones. + 1. If the node needs credentials because it has to authenticate with an API or similar create new ones. Existing ones can be found in folder `/packages/nodes-base/credentials`. Also there it is the easiest to copy existing similar ones. 1. Add the path to the new node (and optionally credentials) to package.json of `nodes-base`. It already contains a property `n8n` with its own keys `credentials` and `nodes`. @@ -236,6 +236,6 @@ docsify serve ./docs That we do not have any potential problems later it is sadly necessary to sign a [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md). That can be done literally with the push of a button. -We used the most simple one that exists. It is from [Indie Open Source](https://indieopensource.com/forms/cla) which uses plain English and is literally just a few lines long. +We used the most simple one that exists. It is from [Indie Open Source](https://indieopensource.com/forms/cla) which uses plain English and is literally only a few lines long. A bot will automatically comment on the pull request once it got opened asking for the agreement to be signed. Before it did not get signed it is sadly not possible to merge it in. diff --git a/LICENSE.md b/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packages/cli/LICENSE.md b/packages/cli/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/cli/LICENSE.md +++ b/packages/cli/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packages/core/LICENSE.md b/packages/core/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/core/LICENSE.md +++ b/packages/core/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packages/editor-ui/LICENSE.md b/packages/editor-ui/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/editor-ui/LICENSE.md +++ b/packages/editor-ui/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packages/node-dev/LICENSE.md b/packages/node-dev/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/node-dev/LICENSE.md +++ b/packages/node-dev/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packages/nodes-base/LICENSE.md b/packages/nodes-base/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/nodes-base/LICENSE.md +++ b/packages/nodes-base/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts index 648735feb2..4c81432238 100644 --- a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts @@ -30,7 +30,7 @@ export async function salesforceApiRequest(this: IExecuteFunctions | IExecuteSin } } -export async function salesforceApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string ,method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any +export async function salesforceApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any const returnData: IDataObject[] = []; diff --git a/packages/nodes-base/nodes/Slack/Slack.node.ts b/packages/nodes-base/nodes/Slack/Slack.node.ts index 57fe569d22..bd3a7b3c21 100644 --- a/packages/nodes-base/nodes/Slack/Slack.node.ts +++ b/packages/nodes-base/nodes/Slack/Slack.node.ts @@ -289,7 +289,7 @@ export class Slack implements INodeType { if (operation === 'get') { const channel = this.getNodeParameter('channelId', i) as string; qs.channel = channel, - responseData = await slackApiRequest.call(this, 'POST', '/conversations.info', {}, qs); + responseData = await slackApiRequest.call(this, 'POST', '/conversations.info', {}, qs); responseData = responseData.channel; } //https://api.slack.com/methods/conversations.list @@ -456,7 +456,7 @@ export class Slack implements INodeType { if (!jsonParameters) { const attachments = this.getNodeParameter('attachments', i, []) as unknown as Attachment[]; - const blocksUi = (this.getNodeParameter('blocksUi', i, []) as IDataObject).blocksValues as IDataObject[]; + const blocksUi = (this.getNodeParameter('blocksUi', i, []) as IDataObject).blocksValues as IDataObject[]; // The node does save the fields data differently than the API // expects so fix the data befre we send the request @@ -482,7 +482,7 @@ export class Slack implements INodeType { block.block_id = blockUi.blockId as string; block.type = blockUi.type as string; if (block.type === 'actions') { - const elementsUi = (blockUi.elementsUi as IDataObject).elementsValues as IDataObject[]; + const elementsUi = (blockUi.elementsUi as IDataObject).elementsValues as IDataObject[]; if (elementsUi) { for (const elementUi of elementsUi) { const element: Element = {}; @@ -498,7 +498,7 @@ export class Slack implements INodeType { text: elementUi.text as string, type: 'plain_text', emoji: elementUi.emoji as boolean, - }; + }; if (elementUi.url) { element.url = elementUi.url as string; } @@ -508,13 +508,13 @@ export class Slack implements INodeType { if (elementUi.style !== 'default') { element.style = elementUi.style as string; } - const confirmUi = (elementUi.confirmUi as IDataObject).confirmValue as IDataObject; - if (confirmUi) { + const confirmUi = (elementUi.confirmUi as IDataObject).confirmValue as IDataObject; + if (confirmUi) { const confirm: Confirm = {}; - const titleUi = (confirmUi.titleUi as IDataObject).titleValue as IDataObject; - const textUi = (confirmUi.textUi as IDataObject).textValue as IDataObject; - const confirmTextUi = (confirmUi.confirmTextUi as IDataObject).confirmValue as IDataObject; - const denyUi = (confirmUi.denyUi as IDataObject).denyValue as IDataObject; + const titleUi = (confirmUi.titleUi as IDataObject).titleValue as IDataObject; + const textUi = (confirmUi.textUi as IDataObject).textValue as IDataObject; + const confirmTextUi = (confirmUi.confirmTextUi as IDataObject).confirmValue as IDataObject; + const denyUi = (confirmUi.denyUi as IDataObject).denyValue as IDataObject; const style = confirmUi.style as string; if (titleUi) { confirm.title = { @@ -548,13 +548,13 @@ export class Slack implements INodeType { confirm.style = style as string; } element.confirm = confirm; - } - elements.push(element); + } + elements.push(element); } block.elements = elements; } } else if (block.type === 'section') { - const textUi = (blockUi.textUi as IDataObject).textValue as IDataObject; + const textUi = (blockUi.textUi as IDataObject).textValue as IDataObject; if (textUi) { const text: Text = {}; if (textUi.type === 'plainText') { @@ -569,7 +569,7 @@ export class Slack implements INodeType { } else { throw new Error('Property text must be defined'); } - const fieldsUi = (blockUi.fieldsUi as IDataObject).fieldsValues as IDataObject[]; + const fieldsUi = (blockUi.fieldsUi as IDataObject).fieldsValues as IDataObject[]; if (fieldsUi) { const fields: Text[] = []; for (const fieldUi of fieldsUi) { @@ -589,7 +589,7 @@ export class Slack implements INodeType { block.fields = fields; } } - const accessoryUi = (blockUi.accessoryUi as IDataObject).accessoriesValues as IDataObject; + const accessoryUi = (blockUi.accessoryUi as IDataObject).accessoriesValues as IDataObject; if (accessoryUi) { const accessory: Element = {}; if (accessoryUi.type === 'button') { @@ -608,46 +608,46 @@ export class Slack implements INodeType { if (accessoryUi.style !== 'default') { accessory.style = accessoryUi.style as string; } - const confirmUi = (accessoryUi.confirmUi as IDataObject).confirmValue as IDataObject; + const confirmUi = (accessoryUi.confirmUi as IDataObject).confirmValue as IDataObject; if (confirmUi) { - const confirm: Confirm = {}; - const titleUi = (confirmUi.titleUi as IDataObject).titleValue as IDataObject; - const textUi = (confirmUi.textUi as IDataObject).textValue as IDataObject; - const confirmTextUi = (confirmUi.confirmTextUi as IDataObject).confirmValue as IDataObject; - const denyUi = (confirmUi.denyUi as IDataObject).denyValue as IDataObject; - const style = confirmUi.style as string; - if (titleUi) { - confirm.title = { - type: 'plain_text', - text: titleUi.text as string, - emoji: titleUi.emoji as boolean, - }; - } - if (textUi) { - confirm.text = { - type: 'plain_text', - text: textUi.text as string, - emoji: textUi.emoji as boolean, - }; - } - if (confirmTextUi) { - confirm.confirm = { - type: 'plain_text', - text: confirmTextUi.text as string, - emoji: confirmTextUi.emoji as boolean, - }; - } - if (denyUi) { - confirm.deny = { - type: 'plain_text', - text: denyUi.text as string, - emoji: denyUi.emoji as boolean, - }; - } - if (style !== 'default') { - confirm.style = style as string; - } - accessory.confirm = confirm; + const confirm: Confirm = {}; + const titleUi = (confirmUi.titleUi as IDataObject).titleValue as IDataObject; + const textUi = (confirmUi.textUi as IDataObject).textValue as IDataObject; + const confirmTextUi = (confirmUi.confirmTextUi as IDataObject).confirmValue as IDataObject; + const denyUi = (confirmUi.denyUi as IDataObject).denyValue as IDataObject; + const style = confirmUi.style as string; + if (titleUi) { + confirm.title = { + type: 'plain_text', + text: titleUi.text as string, + emoji: titleUi.emoji as boolean, + }; + } + if (textUi) { + confirm.text = { + type: 'plain_text', + text: textUi.text as string, + emoji: textUi.emoji as boolean, + }; + } + if (confirmTextUi) { + confirm.confirm = { + type: 'plain_text', + text: confirmTextUi.text as string, + emoji: confirmTextUi.emoji as boolean, + }; + } + if (denyUi) { + confirm.deny = { + type: 'plain_text', + text: denyUi.text as string, + emoji: denyUi.emoji as boolean, + }; + } + if (style !== 'default') { + confirm.style = style as string; + } + accessory.confirm = confirm; } } block.accessory = accessory; @@ -790,8 +790,8 @@ export class Slack implements INodeType { if (binaryData) { const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string; if (items[i].binary === undefined - //@ts-ignore - || items[i].binary[binaryPropertyName] === undefined) { + //@ts-ignore + || items[i].binary[binaryPropertyName] === undefined) { throw new Error(`No binary data property "${binaryPropertyName}" does not exists on item!`); } body.file = { @@ -804,7 +804,7 @@ export class Slack implements INodeType { contentType: items[i].binary[binaryPropertyName].mimeType, } }; - responseData = await slackApiRequest.call(this, 'POST', '/files.upload', {}, qs, { 'Content-Type': 'multipart/form-data' }, { formData: body }); + responseData = await slackApiRequest.call(this, 'POST', '/files.upload', {}, qs, { 'Content-Type': 'multipart/form-data' }, { formData: body }); responseData = responseData.file; } else { const fileContent = this.getNodeParameter('fileContent', i) as string; diff --git a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts index e8e0247461..9cde3e67e2 100644 --- a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts @@ -40,21 +40,11 @@ export async function zoomApiRequest(this: IExecuteFunctions | IExecuteSingleFun throw new Error('No credentials got returned!'); } options.headers!.Authorization = `Bearer ${credentials.accessToken}`; - console.log("options if"); - console.log(options); + //@ts-ignore return await this.helpers.request(options); } else { - console.log("options else"); - console.log(options); - let credentials = this.getCredentials('zoomOAuth2Api'); - // let oauthtoken1 = credentials!.oauthTokenData; - - - console.log(credentials); - console.log("credss"); - //@ts-ignore return await this.helpers.requestOAuth2.call(this, 'zoomOAuth2Api', options); diff --git a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts new file mode 100644 index 0000000000..bd4b8ce271 --- /dev/null +++ b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts @@ -0,0 +1,751 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const meetingOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'meeting', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a meeting', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a meeting', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a meeting', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all meetings', + }, + { + name: 'Update', + value: 'update', + description: 'Update a meeting', + } + ], + default: 'create', + description: 'The operation to perform.', + } +] as INodeProperties[]; + +export const meetingFields = [ + /* -------------------------------------------------------------------------- */ + /* meeting:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Id', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + + + ], + resource: [ + 'meeting', + ], + }, + }, + description: 'User ID.', + }, + { + displayName: 'Additional settings', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'create', + + ], + resource: [ + 'meeting', + ], + } + }, + options: [ + { + displayName: 'Meeting topic', + name: 'topic', + type: 'string', + default: '', + description: `Meeting topic.`, + }, + { + displayName: 'Meeting type', + name: 'type', + type: 'options', + options: [ + { + name: 'Instant Meeting', + value: 1, + }, + { + name: 'Scheduled Meeting', + value: 2, + }, + { + name: 'Recurring meeting with no fixed time', + value: 3, + }, + { + name: 'Recurring meeting with no fixed time', + value: 8, + }, + + ], + default: 2, + description: 'Meeting type.' + }, + { + displayName: 'Start time', + name: 'startTime', + type: 'dateTime', + default: '', + description: 'Start time should be used only for scheduled or recurring meetings with fixed time', + }, + { + displayName: 'Duration', + name: 'duration', + type: 'number', + default: '', + description: 'Duration.', + }, + { + displayName: 'Timezone', + name: 'timeZone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: '', + description: `Time zone used in the response. The default is the time zone of the calendar.`, + }, + { + displayName: 'Schedule for', + name: 'scheduleFor', + type: 'string', + default: '', + description: 'Schedule meeting for someone else from your account, provide their email id.', + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: 'Password to join the meeting with maximum 10 characters.', + }, + { + displayName: 'Agenda', + name: 'agenda', + type: 'string', + default: '', + description: 'Meeting agenda.', + }, + { + displayName: 'Host Meeting in China', + name: 'cn_meeting', + type: 'boolean', + default: false, + description: 'Host Meeting in China.', + }, + { + displayName: 'Host Meeting in India', + name: 'in_meeting', + type: 'boolean', + default: false, + description: 'Host Meeting in India.', + }, + { + displayName: 'Host Video', + name: 'host_video', + type: 'boolean', + default: false, + description: 'Start video when host joins the meeting.', + }, + { + displayName: 'Participant Video', + name: 'participant_video', + type: 'boolean', + default: false, + description: 'Start video when participant joins the meeting.', + }, + { + displayName: 'Join before Host', + name: 'join_before_host', + type: 'boolean', + default: false, + description: 'Allow participants to join the meeting before host starts it.', + }, + { + displayName: 'Muting before entry', + name: 'mute_upon_entry', + type: 'boolean', + default: false, + description: 'Mute participants upon entry.', + }, + { + displayName: 'Watermark', + name: 'watermark', + type: 'boolean', + default: false, + description: 'Adds watermark when viewing a shared screen.', + }, + { + displayName: 'Alternative Hosts', + name: 'alternative_hosts', + type: 'string', + default: '', + description: 'Alternative hosts email ids.', + }, + { + displayName: 'Auto recording', + name: 'auto_recording', + type: 'options', + options: [ + { + name: 'Record on local', + value: 'local', + }, + { + name: 'Record on cloud', + value: 'cloud', + }, + { + name: 'Disabled', + value: 'none', + }, + + + ], + default: 'none', + description: 'Auto recording.', + }, + { + displayName: 'Audio', + name: 'auto_recording', + type: 'options', + options: [ + { + name: 'Both Telephony and VoiP', + value: 'both', + }, + { + name: 'Telephony', + value: 'telephony', + }, + { + name: 'VOIP', + value: 'voip', + }, + + + ], + default: 'both', + description: 'Determine how participants can join audio portion of the meeting.', + }, + { + displayName: 'Registration type', + name: 'registration_type', + type: 'options', + options: [ + { + name: 'Attendees register once and can attend any of the occurences', + value: 1, + }, + { + name: 'Attendees need to register for every occurence', + value: 2, + }, + { + name: 'Attendees register once and can choose one or more occurences to attend', + value: 3, + }, + + + ], + default: 1, + description: 'Registration type. Used for recurring meetings with fixed time only', + }, + + + ], + }, + /* -------------------------------------------------------------------------- */ + /* meeting:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Id', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'meeting', + ], + }, + }, + description: 'User ID.', + }, + /* -------------------------------------------------------------------------- */ + /* meeting:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Id', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'meeting', + ], + }, + }, + description: 'User ID.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'meeting', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'meeting', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 300 + }, + default: 30, + description: 'How many results to return.', + }, + { + displayName: 'Additional settings', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'getAll', + + ], + resource: [ + 'meeting', + ], + } + }, + options: [ + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Scheduled', + value: 'scheduled', + }, + { + name: 'Live', + value: 'live', + }, + { + name: 'Upcoming', + value: 'upcoming', + }, + + + ], + default: 'live', + description: `Meeting type.`, + }, + ] + }, + /* -------------------------------------------------------------------------- */ + /* meeting:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Meeting Id', + name: 'meetingId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'delete' + ], + resource: [ + 'meeting', + ], + }, + }, + description: 'Meeting ID.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'meeting', + ], + }, + }, + options: [ + { + displayName: 'Occurence Id', + name: 'occurenceId', + type: 'string', + default: '', + description: 'Meeting occurence Id.', + }, + { + displayName: 'Schedule a reminder', + name: 'scheduleForReminder', + type: 'boolean', + default: false, + description: 'Schedule a reminder via email', + }, + + + + ], + + }, + /* -------------------------------------------------------------------------- */ + /* meeting:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Meeting Id', + name: 'meetingId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + + + ], + resource: [ + 'meeting', + ], + }, + }, + description: 'Meeting ID.', + }, + { + displayName: 'Occurence Id', + name: 'occurenceId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + + + ], + resource: [ + 'meeting', + ], + }, + }, + description: 'Occurence ID.', + }, + { + displayName: 'Additional settings', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + + 'update', + ], + resource: [ + 'meeting', + ], + } + }, + options: [ + { + displayName: 'Meeting topic', + name: 'topic', + type: 'string', + default: '', + description: `Meeting topic.`, + }, + { + displayName: 'Meeting type', + name: 'type', + type: 'options', + options: [ + { + name: 'Instant Meeting', + value: 1, + }, + { + name: 'Scheduled Meeting', + value: 2, + }, + { + name: 'Recurring meeting with no fixed time', + value: 3, + }, + { + name: 'Recurring meeting with no fixed time', + value: 8, + }, + + ], + default: 2, + description: 'Meeting type.' + }, + { + displayName: 'Start time', + name: 'startTime', + type: 'dateTime', + default: '', + description: 'Start time should be used only for scheduled or recurring meetings with fixed time', + }, + { + displayName: 'Duration', + name: 'duration', + type: 'number', + default: '', + description: 'Duration.', + }, + { + displayName: 'Timezone', + name: 'timeZone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: '', + description: `Time zone used in the response. The default is the time zone of the calendar.`, + }, + { + displayName: 'Schedule for', + name: 'scheduleFor', + type: 'string', + default: '', + description: 'Schedule meeting for someone else from your account, provide their email id.', + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: 'Password to join the meeting with maximum 10 characters.', + }, + { + displayName: 'Agenda', + name: 'agenda', + type: 'string', + default: '', + description: 'Meeting agenda.', + }, + { + displayName: 'Host Meeting in China', + name: 'cn_meeting', + type: 'boolean', + default: false, + description: 'Host Meeting in China.', + }, + { + displayName: 'Host Meeting in India', + name: 'in_meeting', + type: 'boolean', + default: false, + description: 'Host Meeting in India.', + }, + { + displayName: 'Host Video', + name: 'host_video', + type: 'boolean', + default: false, + description: 'Start video when host joins the meeting.', + }, + { + displayName: 'Participant Video', + name: 'participant_video', + type: 'boolean', + default: false, + description: 'Start video when participant joins the meeting.', + }, + { + displayName: 'Join before Host', + name: 'join_before_host', + type: 'boolean', + default: false, + description: 'Allow participants to join the meeting before host starts it.', + }, + { + displayName: 'Muting before entry', + name: 'mute_upon_entry', + type: 'boolean', + default: false, + description: 'Mute participants upon entry.', + }, + { + displayName: 'Watermark', + name: 'watermark', + type: 'boolean', + default: false, + description: 'Adds watermark when viewing a shared screen.', + }, + { + displayName: 'Alternative Hosts', + name: 'alternative_hosts', + type: 'string', + default: '', + description: 'Alternative hosts email ids.', + }, + { + displayName: 'Auto recording', + name: 'auto_recording', + type: 'options', + options: [ + { + name: 'Record on local', + value: 'local', + }, + { + name: 'Record on cloud', + value: 'cloud', + }, + { + name: 'Disabled', + value: 'none', + }, + + + ], + default: 'none', + description: 'Auto recording.', + }, + { + displayName: 'Audio', + name: 'auto_recording', + type: 'options', + options: [ + { + name: 'Both Telephony and VoiP', + value: 'both', + }, + { + name: 'Telephony', + value: 'telephony', + }, + { + name: 'VOIP', + value: 'voip', + }, + + + ], + default: 'both', + description: 'Determine how participants can join audio portion of the meeting.', + }, + { + displayName: 'Registration type', + name: 'registration_type', + type: 'options', + options: [ + { + name: 'Attendees register once and can attend any of the occurences', + value: 1, + }, + { + name: 'Attendees need to register for every occurence', + value: 2, + }, + { + name: 'Attendees register once and can choose one or more occurences to attend', + value: 3, + }, + + + ], + default: 1, + description: 'Registration type. Used for recurring meetings with fixed time only', + }, + + + ], + }, + +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts new file mode 100644 index 0000000000..b062786892 --- /dev/null +++ b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts @@ -0,0 +1,407 @@ +import { + INodeProperties, +} from 'n8n-workflow'; +export const meetingRegistrantOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'meetingRegistrants', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create Meeting Registrants', + }, + { + name: 'Update', + value: 'update', + description: 'Update Meeting Registrant status', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all meeting registrants', + }, + + ], + default: 'create', + description: 'The operation to perform.', + } +] as INodeProperties[]; + + + +export const meetingRegistrantFields = [ + /* -------------------------------------------------------------------------- */ + /* meetingRegistrants:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Meeting Id', + name: 'meetingId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + + + ], + resource: [ + 'meetingRegistrants', + ], + }, + }, + description: 'Meeting ID.', + }, + { + displayName: 'Occurence Id', + name: 'occurenceId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + + + ], + resource: [ + 'meetingRegistrants', + ], + }, + }, + description: 'Occurence ID.', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + default: '', + description: 'Valid email-id of registrant.', + }, + { + displayName: 'First name', + name: 'firstName', + required: true, + type: 'string', + default: '', + description: 'First Name.', + }, + { + displayName: 'Additional settings', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'get', + + ], + resource: [ + 'meetingRegistrants', + ], + } + }, + options: [ + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + default: '', + description: 'Last Name.', + }, + { + displayName: 'Address', + name: 'address', + type: 'string', + default: '', + description: 'Valid address of registrant.', + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + description: 'Valid city of registrant.', + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + description: 'Valid state of registrant.', + }, + { + displayName: 'Country', + name: 'country', + type: 'string', + default: '', + description: 'Valid country of registrant.', + }, + { + displayName: 'Zip code', + name: 'zip', + type: 'string', + default: '', + description: 'Valid zip-code of registrant.', + }, + { + displayName: 'Phone Number', + name: 'phone', + type: 'string', + default: '', + description: 'Valid phone number of registrant.', + }, + { + displayName: 'Comments', + name: 'comments', + type: 'string', + default: '', + description: 'Allows registrants to provide any questions they have.', + }, + { + displayName: 'Organization', + name: 'org', + type: 'string', + default: '', + description: 'Organization of registrant.', + }, + { + displayName: 'Job title', + name: 'job_title', + type: 'string', + default: '', + description: 'Job title of registrant.', + }, + { + displayName: 'Purchasing time frame', + name: 'purchasing_time_frame', + type: 'options', + options: [ + { + name: 'Within a month', + value: 'Within a month', + }, + { + name: '1-3 months', + value: '1-3 months', + }, + { + name: '4-6 months', + value: '4-6 months', + }, + { + name: 'More than 6 months', + value: 'More than 6 months', + }, + { + name: 'No timeframe', + value: 'No timeframe', + }, + + + ], + default: '', + description: 'Meeting type.' + }, + { + displayName: 'Role in purchase process', + name: 'role_in_purchase_process', + type: 'options', + options: [ + { + name: 'Decision Maker', + value: 'Decision Maker', + }, + { + name: 'Evaluator/Recommender', + value: 'Evaluator/Recommender', + }, + { + name: 'Influener', + value: 'Influener', + }, + { + name: 'Not Involved', + value: 'Not Involved', + }, + + ], + default: '', + description: 'Role in purchase process.' + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* meetingRegistrants:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Meeting Id', + name: 'meetingId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'getAll', + + + ], + resource: [ + 'meetingRegistrants', + ], + }, + }, + description: 'Meeting ID.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'meetingRegistrants', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'meetingRegistrants', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 300 + }, + default: 30, + description: 'How many results to return.', + }, + { + displayName: 'Additional settings', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'get', + + ], + resource: [ + 'meetingRegistrants', + ], + } + }, + options: [ + { + displayName: 'Occurence Id', + name: 'occurence_id', + type: 'string', + default: '', + description: `Occurence Id.`, + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + options: [ + { + name: 'Pending', + value: 'pending', + }, + { + name: 'Approved', + value: 'approved', + }, + { + name: 'Denied', + value: 'denied', + }, + + + ], + default: '', + description: `Registrant Status.`, + }, + + ] + }, + /* -------------------------------------------------------------------------- */ + /* meetingRegistrants:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Meeting Id', + name: 'meetingId', + type: 'number', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + + + ], + resource: [ + 'meetingRegistrants', + ], + }, + }, + description: 'Meeting ID.', + }, + { + displayName: 'Occurence Id', + name: 'occurenceId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + + + ], + resource: [ + 'meetingRegistrants', + ], + }, + }, + description: 'Occurence ID.', + }, + +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index 524fc08440..c1915d4ab4 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -3,7 +3,9 @@ import { IDataObject, INodeExecutionData, INodeType, + ILoadOptionsFunctions, INodeTypeDescription, + INodePropertyOptions, } from 'n8n-workflow'; import { zoomApiRequest, @@ -14,7 +16,30 @@ import { import { meetingOperations, meetingFields, -} from './ZoomOperations'; +} from './MeetingDescription'; + +import { + meetingRegistrantOperations, + meetingRegistrantFields, + +} from './MeetingRegistrantDescription'; + +import * as moment from 'moment-timezone'; + +interface Settings { + host_video?: boolean; + participant_video?: boolean; + cn_meeting?: boolean; + in_meeting?: boolean; + join_before_host?: boolean; + mute_upon_entry?: boolean; + watermark?: boolean; + audio?: string; + alternative_hosts?: string; + auto_recording?: string; + registration_type?: number; + +} export class Zoom implements INodeType { description: INodeTypeDescription = { displayName: 'Zoom', @@ -25,31 +50,11 @@ export class Zoom implements INodeType { subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', defaults: { name: 'Zoom', - color: '#772244' + color: '#0B6CF9' }, icon: 'file:zoom.png', inputs: ['main'], outputs: ['main'], - // credentials: [ - // { - // name: 'zoomApi', - // required: true, - // displayOptions: { - // show: { - // authentication: ['accessToken'] - // } - // } - // }, - // { - // name: 'zoomOAuth2Api', - // required: true, - // displayOptions: { - // show: { - // authentication: ['oAuth2'] - // } - // } - // } - // ], credentials: [ { name: 'zoomApi', @@ -100,16 +105,41 @@ export class Zoom implements INodeType { { name: 'Meeting', value: 'meeting' + }, + { + name: 'Meeting Registrants', + value: 'meetingRegistrants' } ], default: 'meeting', description: 'The resource to operate on.' }, ...meetingOperations, - ...meetingFields + ...meetingFields, + ...meetingRegistrantOperations, + ...meetingRegistrantFields, ] }; + methods = { + loadOptions: { + // Get all the timezones to display them to user so that he can select them easily + async getTimezones( + this: ILoadOptionsFunctions + ): Promise { + const returnData: INodePropertyOptions[] = []; + for (const timezone of moment.tz.names()) { + const timezoneName = timezone; + const timezoneId = timezone; + returnData.push({ + name: timezoneName, + value: timezoneId + }); + } + return returnData; + } + } + }; async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); @@ -120,11 +150,13 @@ export class Zoom implements INodeType { const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; console.log(this.getCredentials('zoomOAuth2Api')); + let body: IDataObject = {}; for (let i = 0; i < length; i++) { qs = {}; if (resource === 'meeting') { if (operation === 'get') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meeting const userId = this.getNodeParameter('userId', i) as string; responseData = await zoomApiRequest.call( @@ -135,6 +167,283 @@ export class Zoom implements INodeType { qs ); } + if (operation === 'getAll') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetings + const userId = this.getNodeParameter('userId', i) as string; + + responseData = await zoomApiRequest.call( + this, + 'GET', + `/users/${userId}/meetings`, + {}, + qs + ); + } + if (operation === 'delete') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingdelete + const meetingId = this.getNodeParameter('meetingId', i) as string; + responseData = await zoomApiRequest.call( + this, + 'DELETE', + `/meetings/${meetingId}`, + {}, + qs + ); + responseData = { success: true }; + } + if (operation === 'create') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate + const userId = this.getNodeParameter('userId', i) as string; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + const settings: Settings = {}; + if (additionalFields.cn_meeting) { + settings.cn_meeting = additionalFields.cn_meeting as boolean; + + } + + if (additionalFields.in_meeting) { + settings.in_meeting = additionalFields.in_meeting as boolean; + + } + + if (additionalFields.join_before_host) { + settings.join_before_host = additionalFields.join_before_host as boolean; + + } + + if (additionalFields.mute_upon_entry) { + settings.mute_upon_entry = additionalFields.mute_upon_entry as boolean; + + } + + if (additionalFields.watermark) { + settings.watermark = additionalFields.watermark as boolean; + + } + + if (additionalFields.audio) { + settings.audio = additionalFields.audio as string; + + } + + if (additionalFields.alternative_hosts) { + settings.alternative_hosts = additionalFields.alternative_hosts as string; + + } + + if (additionalFields.participant_video) { + settings.participant_video = additionalFields.participant_video as boolean; + + } + + if (additionalFields.host_video) { + settings.host_video = additionalFields.host_video as boolean; + + } + + if (additionalFields.auto_recording) { + settings.auto_recording = additionalFields.auto_recording as string; + + } + + if (additionalFields.registration_type) { + settings.registration_type = additionalFields.registration_type as number; + + } + + body = { + settings, + }; + + if (additionalFields.topic) { + body.topic = additionalFields.topic as string; + + } + + if (additionalFields.type) { + body.type = additionalFields.type as string; + + } + + if (additionalFields.startTime) { + body.start_time = additionalFields.startTime as string; + + } + + if (additionalFields.duration) { + body.duration = additionalFields.duration as number; + + } + + if (additionalFields.scheduleFor) { + body.schedule_for = additionalFields.scheduleFor as string; + + } + + if (additionalFields.timeZone) { + body.timezone = additionalFields.timeZone as string; + + } + + if (additionalFields.password) { + body.password = additionalFields.password as string; + + } + + if (additionalFields.agenda) { + body.agenda = additionalFields.agenda as string; + + } + + + + + responseData = await zoomApiRequest.call( + this, + 'POST', + `/users/${userId}/meetings`, + body, + qs + ); + } + if (operation === 'update') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingupdate + const meetingId = this.getNodeParameter('meetingId', i) as string; + qs.occurence_id = this.getNodeParameter('occurenceId', i) as string; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + const settings: Settings = {}; + if (additionalFields.cn_meeting) { + settings.cn_meeting = additionalFields.cn_meeting as boolean; + + } + + if (additionalFields.in_meeting) { + settings.in_meeting = additionalFields.in_meeting as boolean; + + } + + if (additionalFields.join_before_host) { + settings.join_before_host = additionalFields.join_before_host as boolean; + + } + + if (additionalFields.mute_upon_entry) { + settings.mute_upon_entry = additionalFields.mute_upon_entry as boolean; + + } + + if (additionalFields.watermark) { + settings.watermark = additionalFields.watermark as boolean; + + } + + if (additionalFields.audio) { + settings.audio = additionalFields.audio as string; + + } + + if (additionalFields.alternative_hosts) { + settings.alternative_hosts = additionalFields.alternative_hosts as string; + + } + + if (additionalFields.participant_video) { + settings.participant_video = additionalFields.participant_video as boolean; + + } + + if (additionalFields.host_video) { + settings.host_video = additionalFields.host_video as boolean; + + } + + if (additionalFields.auto_recording) { + settings.auto_recording = additionalFields.auto_recording as string; + + } + + if (additionalFields.registration_type) { + settings.registration_type = additionalFields.registration_type as number; + + } + + body = { + settings, + }; + if (additionalFields.topic) { + body.topic = additionalFields.topic as string; + + } + + if (additionalFields.type) { + body.type = additionalFields.type as string; + + } + + if (additionalFields.startTime) { + body.start_time = additionalFields.startTime as string; + + } + + if (additionalFields.duration) { + body.duration = additionalFields.duration as number; + + } + + if (additionalFields.scheduleFor) { + body.schedule_for = additionalFields.scheduleFor as string; + + } + + if (additionalFields.timeZone) { + body.timezone = additionalFields.timeZone as string; + + } + + if (additionalFields.password) { + body.password = additionalFields.password as string; + + } + + if (additionalFields.agenda) { + body.agenda = additionalFields.agenda as string; + + } + + responseData = await zoomApiRequest.call( + this, + 'PATCH', + `/meetings/${meetingId}`, + body, + qs + ); + } + } + if (resource === 'meetingRegistrant') { + if (operation === 'create') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrantcreate + const meetingId = this.getNodeParameter('meetingId', i) as string; + qs.occurence_id = this.getNodeParameter('occurenceId', i) as string; + responseData = await zoomApiRequest.call( + this, + 'PATCH', + `/meetings/${meetingId}/registrants`, + body, + qs + ); + } + if (operation === 'getAll') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrants + } + if (operation === 'update') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrantstatus + } } } if (Array.isArray(responseData)) { diff --git a/packages/nodes-base/nodes/Zoom/ZoomOperations.ts b/packages/nodes-base/nodes/Zoom/ZoomOperations.ts deleted file mode 100644 index 58f1825435..0000000000 --- a/packages/nodes-base/nodes/Zoom/ZoomOperations.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - INodeProperties, -} from 'n8n-workflow'; - -export const meetingOperations = [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - displayOptions: { - show: { - resource: [ - 'meeting', - ], - }, - }, - options: [ - { - name: 'Create', - value: 'create', - description: 'Create a meeting', - }, - { - name: 'Delete', - value: 'delete', - description: 'Delete a meeting', - }, - { - name: 'Get', - value: 'get', - description: 'Retrieve a meeting', - }, - { - name: 'Get All', - value: 'getAll', - description: 'Retrieve all meetings', - }, - { - name: 'Update', - value: 'update', - description: 'Update a meeting', - } - ], - default: 'create', - description: 'The operation to perform.', - } -] as INodeProperties[]; - -export const meetingFields = [ - /* -------------------------------------------------------------------------- */ - /* meeting:get */ - /* -------------------------------------------------------------------------- */ - { - displayName: 'User Id', - name: 'userId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'get', - ], - resource: [ - 'meeting', - ], - }, - }, - description: 'User ID.', - }, -] as INodeProperties[]; diff --git a/packages/workflow/LICENSE.md b/packages/workflow/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/workflow/LICENSE.md +++ b/packages/workflow/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 94cd2c76c2abcbbcc880f88dda7ebfa280089f68 Mon Sep 17 00:00:00 2001 From: ricardo Date: Mon, 22 Jun 2020 19:37:58 -0400 Subject: [PATCH 019/155] :zap: updated n8n packages to it's latest version. --- packages/core/package.json | 2 +- packages/node-dev/package.json | 4 ++-- packages/nodes-base/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 598ae14b23..9a6352671b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,7 +45,7 @@ "crypto-js": "3.1.9-1", "lodash.get": "^4.4.2", "mmmagic": "^0.5.2", - "n8n-workflow": "~0.32.0", + "n8n-workflow": "~0.33.0", "p-cancelable": "^2.0.0", "request": "^2.88.2", "request-promise-native": "^1.0.7" diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 66394fec29..8df65a2792 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -58,8 +58,8 @@ "change-case": "^4.1.1", "copyfiles": "^2.1.1", "inquirer": "^7.0.0", - "n8n-core": "^0.31.0", - "n8n-workflow": "^0.28.0", + "n8n-core": "^0.36.0", + "n8n-workflow": "^0.33.0", "replace-in-file": "^6.0.0", "request": "^2.88.2", "tmp-promise": "^2.0.2", diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a09d97d032..e38403add3 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -317,7 +317,7 @@ "@types/xml2js": "^0.4.3", "gulp": "^4.0.0", "jest": "^24.9.0", - "n8n-workflow": "~0.32.0", + "n8n-workflow": "~0.33.0", "ts-jest": "^24.0.2", "tslint": "^5.17.0", "typescript": "~3.7.4" From 815f823f5ac6371d2796d30da68792d473f20baf Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Mon, 22 Jun 2020 18:39:21 -0700 Subject: [PATCH 020/155] add webinars and registrants resource --- .../nodes-base/nodes/Zoom/GenericFunctions.ts | 34 +- .../nodes/Zoom/MeetingDescription.ts | 107 ++- .../Zoom/MeetingRegistrantDescription.ts | 104 ++- .../nodes/Zoom/WebinarDescription.ts | 685 ++++++++++++++++++ packages/nodes-base/nodes/Zoom/Zoom.node.ts | 405 ++++++++++- 5 files changed, 1198 insertions(+), 137 deletions(-) create mode 100644 packages/nodes-base/nodes/Zoom/WebinarDescription.ts diff --git a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts index 9cde3e67e2..d361a9761a 100644 --- a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts @@ -31,8 +31,6 @@ export async function zoomApiRequest(this: IExecuteFunctions | IExecuteSingleFun if (Object.keys(query).length === 0) { delete options.qs; } - console.log("options"); - console.log(options); try { if (authenticationMethod === 'accessToken') { const credentials = this.getCredentials('zoomApi'); @@ -42,7 +40,6 @@ export async function zoomApiRequest(this: IExecuteFunctions | IExecuteSingleFun options.headers!.Authorization = `Bearer ${credentials.accessToken}`; //@ts-ignore - return await this.helpers.request(options); } else { //@ts-ignore @@ -78,9 +75,10 @@ export async function zoomApiRequestAllItems( ): Promise { // tslint:disable-line:no-any const returnData: IDataObject[] = []; + let responseData; - query.page = 1; - query.count = 100; + //query.maxResults = 300; + do { responseData = await zoomApiRequest.call( this, @@ -89,32 +87,14 @@ export async function zoomApiRequestAllItems( body, query ); - query.cursor = encodeURIComponent( - _.get(responseData, 'response_metadata.next_cursor') - ); - query.page++; + query.page_number = responseData['page_number']; returnData.push.apply(returnData, responseData[propertyName]); } while ( - (responseData.response_metadata !== undefined && - responseData.response_metadata.mext_cursor !== undefined && - responseData.response_metadata.next_cursor !== '' && - responseData.response_metadata.next_cursor !== null) || - (responseData.paging !== undefined && - responseData.paging.pages !== undefined && - responseData.paging.page !== undefined && - responseData.paging.page < responseData.paging.pages) + responseData['page_number'] !== undefined && + responseData['page_number'] !== '' ); return returnData; } -export function validateJSON(json: string | undefined): any { - // tslint:disable-line:no-any - let result; - try { - result = JSON.parse(json!); - } catch (exception) { - result = undefined; - } - return result; -} + diff --git a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts index bd4b8ce271..274b48ff97 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts @@ -51,7 +51,7 @@ export const meetingFields = [ /* meeting:create */ /* -------------------------------------------------------------------------- */ { - displayName: 'Id', + displayName: 'User Id', name: 'userId', type: 'string', default: '', @@ -60,15 +60,13 @@ export const meetingFields = [ show: { operation: [ 'create', - - ], resource: [ 'meeting', ], }, }, - description: 'User ID.', + description: 'User ID or email address of user.', }, { displayName: 'Additional settings', @@ -239,15 +237,13 @@ export const meetingFields = [ name: 'Disabled', value: 'none', }, - - ], default: 'none', description: 'Auto recording.', }, { displayName: 'Audio', - name: 'auto_recording', + name: 'audio', type: 'options', options: [ { @@ -263,7 +259,6 @@ export const meetingFields = [ value: 'voip', }, - ], default: 'both', description: 'Determine how participants can join audio portion of the meeting.', @@ -285,22 +280,19 @@ export const meetingFields = [ name: 'Attendees register once and can choose one or more occurences to attend', value: 3, }, - - ], default: 1, description: 'Registration type. Used for recurring meetings with fixed time only', }, - ], }, /* -------------------------------------------------------------------------- */ /* meeting:get */ /* -------------------------------------------------------------------------- */ { - displayName: 'Id', - name: 'userId', + displayName: 'Meeting Id', + name: 'meetingId', type: 'string', default: '', required: true, @@ -314,13 +306,47 @@ export const meetingFields = [ ], }, }, - description: 'User ID.', + description: 'Meeting ID.', + }, + { + displayName: 'Additional settings', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'get', + + ], + resource: [ + 'meeting', + ], + }, + }, + options: [ + { + displayName: 'Occurence Id', + name: 'occurenceId', + type: 'string', + default: '', + description: 'To view meeting details of a particular occurence of the recurring meeting.', + }, + { + displayName: 'Show Previous Occurences', + name: 'showPreviousOccurences', + type: 'boolean', + default: '', + description: 'To view meeting details of all previous occurences of the recurring meeting.', + }, + ], }, /* -------------------------------------------------------------------------- */ /* meeting:getAll */ /* -------------------------------------------------------------------------- */ { - displayName: 'Id', + displayName: 'User Id', name: 'userId', type: 'string', default: '', @@ -335,7 +361,7 @@ export const meetingFields = [ ], }, }, - description: 'User ID.', + description: 'User ID or email-id.', }, { displayName: 'Return All', @@ -393,7 +419,7 @@ export const meetingFields = [ resource: [ 'meeting', ], - } + }, }, options: [ { @@ -413,8 +439,6 @@ export const meetingFields = [ name: 'Upcoming', value: 'upcoming', }, - - ], default: 'live', description: `Meeting type.`, @@ -471,11 +495,8 @@ export const meetingFields = [ name: 'scheduleForReminder', type: 'boolean', default: false, - description: 'Schedule a reminder via email', + description: 'Notify hosts and alternative hosts about meeting cancellation via email', }, - - - ], }, @@ -492,8 +513,6 @@ export const meetingFields = [ show: { operation: [ 'update', - - ], resource: [ 'meeting', @@ -502,26 +521,6 @@ export const meetingFields = [ }, description: 'Meeting ID.', }, - { - displayName: 'Occurence Id', - name: 'occurenceId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'update', - - - ], - resource: [ - 'meeting', - ], - }, - }, - description: 'Occurence ID.', - }, { displayName: 'Additional settings', name: 'additionalFields', @@ -531,15 +530,21 @@ export const meetingFields = [ displayOptions: { show: { operation: [ - 'update', ], resource: [ 'meeting', ], - } + }, }, options: [ + { + displayName: 'Occurence Id', + name: 'occurenceId', + type: 'string', + default: '', + description: 'Occurence ID.', + }, { displayName: 'Meeting topic', name: 'topic', @@ -691,8 +696,6 @@ export const meetingFields = [ name: 'Disabled', value: 'none', }, - - ], default: 'none', description: 'Auto recording.', @@ -714,8 +717,6 @@ export const meetingFields = [ name: 'VOIP', value: 'voip', }, - - ], default: 'both', description: 'Determine how participants can join audio portion of the meeting.', @@ -737,14 +738,10 @@ export const meetingFields = [ name: 'Attendees register once and can choose one or more occurences to attend', value: 3, }, - - ], default: 1, description: 'Registration type. Used for recurring meetings with fixed time only', }, - - ], }, diff --git a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts index b062786892..592c7a285a 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts @@ -52,8 +52,6 @@ export const meetingRegistrantFields = [ show: { operation: [ 'create', - - ], resource: [ 'meetingRegistrants', @@ -63,31 +61,21 @@ export const meetingRegistrantFields = [ description: 'Meeting ID.', }, { - displayName: 'Occurence Id', - name: 'occurenceId', + displayName: 'Email', + name: 'email', type: 'string', - default: '', required: true, + default: '', displayOptions: { show: { operation: [ 'create', - - ], resource: [ 'meetingRegistrants', ], }, }, - description: 'Occurence ID.', - }, - { - displayName: 'Email', - name: 'email', - type: 'string', - required: true, - default: '', description: 'Valid email-id of registrant.', }, { @@ -96,6 +84,16 @@ export const meetingRegistrantFields = [ required: true, type: 'string', default: '', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'meetingRegistrants', + ], + }, + }, description: 'First Name.', }, { @@ -107,7 +105,7 @@ export const meetingRegistrantFields = [ displayOptions: { show: { operation: [ - 'get', + 'create', ], resource: [ @@ -116,6 +114,13 @@ export const meetingRegistrantFields = [ } }, options: [ + { + displayName: 'Occurence Ids', + name: 'occurenceId', + type: 'string', + default: '', + description: 'Occurence IDs separated by comma.', + }, { displayName: 'Last Name', name: 'lastName', @@ -211,8 +216,6 @@ export const meetingRegistrantFields = [ name: 'No timeframe', value: 'No timeframe', }, - - ], default: '', description: 'Meeting type.' @@ -258,8 +261,6 @@ export const meetingRegistrantFields = [ show: { operation: [ 'getAll', - - ], resource: [ 'meetingRegistrants', @@ -318,7 +319,7 @@ export const meetingRegistrantFields = [ displayOptions: { show: { operation: [ - 'get', + 'getAll', ], resource: [ @@ -351,10 +352,8 @@ export const meetingRegistrantFields = [ name: 'Denied', value: 'denied', }, - - ], - default: '', + default: 'approved', description: `Registrant Status.`, }, @@ -366,15 +365,13 @@ export const meetingRegistrantFields = [ { displayName: 'Meeting Id', name: 'meetingId', - type: 'number', + type: 'string', default: '', required: true, displayOptions: { show: { operation: [ 'update', - - ], resource: [ 'meetingRegistrants', @@ -384,24 +381,63 @@ export const meetingRegistrantFields = [ description: 'Meeting ID.', }, { - displayName: 'Occurence Id', - name: 'occurenceId', - type: 'string', - default: '', + displayName: 'Action', + name: 'action', + type: 'options', required: true, displayOptions: { show: { operation: [ 'update', - - ], resource: [ 'meetingRegistrants', ], }, }, - description: 'Occurence ID.', + options: [ + { + name: 'Cancel', + value: 'cancel', + }, + { + name: 'Approved', + value: 'approve', + }, + { + name: 'Deny', + value: 'deny', + }, + ], + default: '', + description: `Registrant Status.`, }, + { + displayName: 'Additional settings', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'meetingRegistrants', + ], + }, + }, + options: [ + { + displayName: 'Occurence Id', + name: 'occurenceId', + type: 'string', + default: '', + description: 'Occurence ID.', + }, + + ], + } ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts new file mode 100644 index 0000000000..5cefbdace8 --- /dev/null +++ b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts @@ -0,0 +1,685 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const webinarOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'webinar', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a webinar', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a webinar', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a webinar', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all webinars', + }, + { + name: 'Update', + value: 'update', + description: 'Update a webinar', + } + ], + default: 'create', + description: 'The operation to perform.', + } +] as INodeProperties[]; + +export const webinarFields = [ + /* -------------------------------------------------------------------------- */ + /* webinar:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User Id', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'webinar', + ], + }, + }, + description: 'User ID or email address of user.', + }, + { + displayName: 'Additional settings', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'create', + + ], + resource: [ + 'webinar', + ], + } + }, + options: [ + { + displayName: 'Webinar topic', + name: 'topic', + type: 'string', + default: '', + description: `Webinar topic.`, + }, + { + displayName: 'Webinar type', + name: 'type', + type: 'options', + options: [ + { + name: 'Webinar', + value: 5, + }, + { + name: 'Recurring webinar with no fixed time', + value: 6, + }, + { + name: 'Recurring webinar with fixed time', + value: 9, + }, + ], + default: 5, + description: 'Webinar type.' + }, + { + displayName: 'Start time', + name: 'startTime', + type: 'dateTime', + default: '', + description: 'Start time should be used only for scheduled or recurring webinar with fixed time', + }, + { + displayName: 'Duration', + name: 'duration', + type: 'string', + default: '', + description: 'Duration.', + }, + { + displayName: 'Timezone', + name: 'timeZone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: '', + description: `Time zone used in the response. The default is the time zone of the calendar.`, + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: 'Password to join the webinar with maximum 10 characters.', + }, + { + displayName: 'Agenda', + name: 'agenda', + type: 'string', + default: '', + description: 'Webinar agenda.', + }, + { + displayName: 'Host Video', + name: 'host_video', + type: 'boolean', + default: false, + description: 'Start video when host joins the webinar.', + }, + { + displayName: 'Panelists Video', + name: 'panelists_video', + type: 'boolean', + default: false, + description: 'Start video when panelists joins the webinar.', + }, + { + displayName: 'Practice Session', + name: 'practice_session', + type: 'boolean', + default: false, + description: 'Enable Practice session.', + }, + { + displayName: 'Alternative Hosts', + name: 'alternative_hosts', + type: 'string', + default: '', + description: 'Alternative hosts email ids.', + }, + { + displayName: 'Approval type', + name: 'approval_type', + type: 'options', + options: [ + { + name: 'Automatically approve', + value: 0, + }, + { + name: 'Manually approve', + value: 1, + }, + { + name: 'No registration required', + value: 2, + }, + ], + default: 2, + description: 'Approval type.', + }, + { + displayName: 'Auto recording', + name: 'auto_recording', + type: 'options', + options: [ + { + name: 'Record on local', + value: 'local', + }, + { + name: 'Record on cloud', + value: 'cloud', + }, + { + name: 'Disabled', + value: 'none', + }, + ], + default: 'none', + description: 'Auto recording.', + }, + { + displayName: 'Audio', + name: 'audio', + type: 'options', + options: [ + { + name: 'Both Telephony and VoiP', + value: 'both', + }, + { + name: 'Telephony', + value: 'telephony', + }, + { + name: 'VOIP', + value: 'voip', + }, + + ], + default: 'both', + description: 'Determine how participants can join audio portion of the webinar.', + }, + { + displayName: 'Registration type', + name: 'registration_type', + type: 'options', + options: [ + { + name: 'Attendees register once and can attend any of the occurences', + value: 1, + }, + { + name: 'Attendees need to register for every occurence', + value: 2, + }, + { + name: 'Attendees register once and can choose one or more occurences to attend', + value: 3, + }, + ], + default: 1, + description: 'Registration type. Used for recurring webinar with fixed time only', + }, + + ], + }, + /* -------------------------------------------------------------------------- */ + /* webinar:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Webinar Id', + name: 'webinarId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'webinar', + ], + }, + }, + description: 'Webinar ID.', + }, + { + displayName: 'Additional settings', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'get', + + ], + resource: [ + 'webinar', + ], + }, + }, + options: [ + { + displayName: 'Occurence Id', + name: 'occurenceId', + type: 'string', + default: '', + description: 'To view webinar details of a particular occurence of the recurring webinar.', + }, + { + displayName: 'Show Previous Occurences', + name: 'showPreviousOccurences', + type: 'boolean', + default: '', + description: 'To view webinar details of all previous occurences of the recurring webinar.', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* webinar:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User Id', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'webinar', + ], + }, + }, + description: 'User ID or email-id.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'webinar', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'webinar', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 300 + }, + default: 30, + description: 'How many results to return.', + }, + { + displayName: 'Additional settings', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'getAll', + + ], + resource: [ + 'webinar', + ], + }, + }, + + }, + /* -------------------------------------------------------------------------- */ + /* webina:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Webinar Id', + name: 'webinarId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'delete' + ], + resource: [ + 'webinarId', + ], + }, + }, + description: 'WebinarId ID.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'webinar', + ], + }, + }, + options: [ + { + displayName: 'Occurence Id', + name: 'occurenceId', + type: 'string', + default: '', + description: 'Webinar occurence Id.', + }, + + ], + + }, + /* -------------------------------------------------------------------------- */ + /* webinar:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User Id', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'webinar', + ], + }, + }, + description: 'User ID or email address of user.', + }, + { + displayName: 'Additional settings', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'update', + + ], + resource: [ + 'webinar', + ], + } + }, + options: [ + { + displayName: 'Occurence Id', + name: 'occurence_id', + type: 'string', + default: '', + description: `Webinar occurence Id.`, + }, + { + displayName: 'Webinar topic', + name: 'topic', + type: 'string', + default: '', + description: `Webinar topic.`, + }, + { + displayName: 'Webinar type', + name: 'type', + type: 'options', + options: [ + { + name: 'Webinar', + value: 5, + }, + { + name: 'Recurring webinar with no fixed time', + value: 6, + }, + { + name: 'Recurring webinar with fixed time', + value: 9, + }, + ], + default: 5, + description: 'Webinar type.' + }, + { + displayName: 'Start time', + name: 'startTime', + type: 'dateTime', + default: '', + description: 'Start time should be used only for scheduled or recurring webinar with fixed time', + }, + { + displayName: 'Duration', + name: 'duration', + type: 'string', + default: '', + description: 'Duration.', + }, + { + displayName: 'Timezone', + name: 'timeZone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: '', + description: `Time zone used in the response. The default is the time zone of the calendar.`, + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: 'Password to join the webinar with maximum 10 characters.', + }, + { + displayName: 'Agenda', + name: 'agenda', + type: 'string', + default: '', + description: 'Webinar agenda.', + }, + { + displayName: 'Host Video', + name: 'host_video', + type: 'boolean', + default: false, + description: 'Start video when host joins the webinar.', + }, + { + displayName: 'Panelists Video', + name: 'panelists_video', + type: 'boolean', + default: false, + description: 'Start video when panelists joins the webinar.', + }, + { + displayName: 'Practice Session', + name: 'practice_session', + type: 'boolean', + default: false, + description: 'Enable Practice session.', + }, + { + displayName: 'Alternative Hosts', + name: 'alternative_hosts', + type: 'string', + default: '', + description: 'Alternative hosts email ids.', + }, + { + displayName: 'Approval type', + name: 'approval_type', + type: 'options', + options: [ + { + name: 'Automatically approve', + value: 0, + }, + { + name: 'Manually approve', + value: 1, + }, + { + name: 'No registration required', + value: 2, + }, + ], + default: 2, + description: 'Approval type.', + }, + { + displayName: 'Auto recording', + name: 'auto_recording', + type: 'options', + options: [ + { + name: 'Record on local', + value: 'local', + }, + { + name: 'Record on cloud', + value: 'cloud', + }, + { + name: 'Disabled', + value: 'none', + }, + ], + default: 'none', + description: 'Auto recording.', + }, + { + displayName: 'Audio', + name: 'audio', + type: 'options', + options: [ + { + name: 'Both Telephony and VoiP', + value: 'both', + }, + { + name: 'Telephony', + value: 'telephony', + }, + { + name: 'VOIP', + value: 'voip', + }, + + ], + default: 'both', + description: 'Determine how participants can join audio portion of the webinar.', + }, + { + displayName: 'Registration type', + name: 'registration_type', + type: 'options', + options: [ + { + name: 'Attendees register once and can attend any of the occurences', + value: 1, + }, + { + name: 'Attendees need to register for every occurence', + value: 2, + }, + { + name: 'Attendees register once and can choose one or more occurences to attend', + value: 3, + }, + ], + default: 1, + description: 'Registration type. Used for recurring webinars with fixed time only', + }, + + ], + }, + +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index c1915d4ab4..fb3b7565f7 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -1,4 +1,6 @@ -import { IExecuteFunctions } from 'n8n-core'; +import { + IExecuteFunctions, +} from 'n8n-core'; import { IDataObject, INodeExecutionData, @@ -10,7 +12,6 @@ import { import { zoomApiRequest, zoomApiRequestAllItems, - validateJSON, } from './GenericFunctions'; import { @@ -24,11 +25,16 @@ import { } from './MeetingRegistrantDescription'; +import { + webinarOperations, + webinarFields, +} from './WebinarDescription'; import * as moment from 'moment-timezone'; interface Settings { host_video?: boolean; participant_video?: boolean; + panelists_video?: boolean; cn_meeting?: boolean; in_meeting?: boolean; join_before_host?: boolean; @@ -38,6 +44,9 @@ interface Settings { alternative_hosts?: string; auto_recording?: string; registration_type?: number; + approval_type?: number; + practice_session?: boolean; + } export class Zoom implements INodeType { @@ -107,17 +116,28 @@ export class Zoom implements INodeType { value: 'meeting' }, { - name: 'Meeting Registrants', + name: 'Meeting Registrant', value: 'meetingRegistrants' + }, + { + name: 'Webinar', + value: 'webinar' } ], default: 'meeting', description: 'The resource to operate on.' }, + //MEETINGS ...meetingOperations, ...meetingFields, + + //MEETING REGISTRANTS ...meetingRegistrantOperations, ...meetingRegistrantFields, + + //WEBINARS + ...webinarOperations, + ...webinarFields, ] }; @@ -149,20 +169,30 @@ export class Zoom implements INodeType { let responseData; const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; - console.log(this.getCredentials('zoomOAuth2Api')); let body: IDataObject = {}; for (let i = 0; i < length; i++) { qs = {}; + //https://marketplace.zoom.us/docs/api-reference/zoom-api/ if (resource === 'meeting') { if (operation === 'get') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meeting - const userId = this.getNodeParameter('userId', i) as string; + const meetingId = this.getNodeParameter('meetingId', i) as string; + + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + if (additionalFields.showPreviousOccurences) + qs.show_previous_occurences = additionalFields.showPreviousOccurences as boolean; + + if (additionalFields.occurenceId) + qs.occurence_id = additionalFields.occurenceId as string; responseData = await zoomApiRequest.call( this, 'GET', - `/meetings/${userId}`, + `/meetings/${meetingId}`, {}, qs ); @@ -170,18 +200,30 @@ export class Zoom implements INodeType { if (operation === 'getAll') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetings const userId = this.getNodeParameter('userId', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + if (returnAll) { + responseData = await zoomApiRequestAllItems.call(this, 'results', 'GET', `/users/${userId}/meetings`, {}, qs); + } else { + const limit = this.getNodeParameter('limit', i) as number; + qs.page_size = limit; + responseData = await zoomApiRequest.call(this, 'GET', `/users/${userId}/meetings`, {}, qs); + responseData = responseData.results; + } - responseData = await zoomApiRequest.call( - this, - 'GET', - `/users/${userId}/meetings`, - {}, - qs - ); } if (operation === 'delete') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingdelete const meetingId = this.getNodeParameter('meetingId', i) as string; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + if (additionalFields.scheduleForReminder) + qs.schedule_for_reminder = additionalFields.scheduleForReminder as boolean; + + if (additionalFields.occurenceId) + qs.occurence_id = additionalFields.occurenceId; + responseData = await zoomApiRequest.call( this, 'DELETE', @@ -297,10 +339,6 @@ export class Zoom implements INodeType { body.agenda = additionalFields.agenda as string; } - - - - responseData = await zoomApiRequest.call( this, 'POST', @@ -312,12 +350,16 @@ export class Zoom implements INodeType { if (operation === 'update') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingupdate const meetingId = this.getNodeParameter('meetingId', i) as string; - qs.occurence_id = this.getNodeParameter('occurenceId', i) as string; + const settings: Settings = {}; const additionalFields = this.getNodeParameter( 'additionalFields', i ) as IDataObject; - const settings: Settings = {}; + + if (additionalFields.occurenceId) { + qs.occurence_id = additionalFields.occurenceId as string; + } + if (additionalFields.cn_meeting) { settings.cn_meeting = additionalFields.cn_meeting as boolean; @@ -423,16 +465,64 @@ export class Zoom implements INodeType { body, qs ); + responseData = { updated: true }; + } } if (resource === 'meetingRegistrant') { if (operation === 'create') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrantcreate const meetingId = this.getNodeParameter('meetingId', i) as string; - qs.occurence_id = this.getNodeParameter('occurenceId', i) as string; + const emailId = this.getNodeParameter('email', i) as string; + body.email = emailId; + const firstName = this.getNodeParameter('firstName', i) as string; + body.first_name = firstName; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + if (additionalFields.occurenceId) { + qs.occurence_ids = additionalFields.occurenceId as string; + } + if (additionalFields.lastName) { + body.last_name = additionalFields.lastName as string; + } + if (additionalFields.address) { + body.address = additionalFields.address as string; + } + if (additionalFields.city) { + body.city = additionalFields.city as string; + } + if (additionalFields.state) { + body.state = additionalFields.state as string; + } + if (additionalFields.country) { + body.country = additionalFields.country as string; + } + if (additionalFields.zip) { + body.zip = additionalFields.zip as string; + } + if (additionalFields.phone) { + body.phone = additionalFields.phone as string; + } + if (additionalFields.comments) { + body.comments = additionalFields.comments as string; + } + if (additionalFields.org) { + body.org = additionalFields.org as string; + } + if (additionalFields.job_title) { + body.job_title = additionalFields.job_title as string; + } + if (additionalFields.purchasing_time_frame) { + body.purchasing_time_frame = additionalFields.purchasing_time_frame as string; + } + if (additionalFields.role_in_purchase_process) { + body.role_in_purchase_process = additionalFields.role_in_purchase_process as string; + } responseData = await zoomApiRequest.call( this, - 'PATCH', + 'POST', `/meetings/${meetingId}/registrants`, body, qs @@ -440,9 +530,282 @@ export class Zoom implements INodeType { } if (operation === 'getAll') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrants + const meetingId = this.getNodeParameter('meetingId', i) as string; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + if (additionalFields.occurenceId) { + qs.occurence_id = additionalFields.occurenceId as string; + } + if (additionalFields.status) { + qs.status = additionalFields.status as string; + } + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + if (returnAll) { + responseData = await zoomApiRequestAllItems.call(this, 'results', 'GET', `/meetings/${meetingId}/registrants`, {}, qs); + } else { + const limit = this.getNodeParameter('limit', i) as number; + qs.page_size = limit; + responseData = await zoomApiRequest.call(this, 'GET', `/meetings/${meetingId}/registrants`, {}, qs); + responseData = responseData.results; + } + } if (operation === 'update') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrantstatus + const meetingId = this.getNodeParameter('meetingId', i) as string; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + if (additionalFields.occurenceId) { + qs.occurence_id = additionalFields.occurenceId as string; + } + responseData = await zoomApiRequest.call( + this, + 'PUT', + `/meetings/${meetingId}/registrants/status`, + body, + qs + ); + } + } + if (resource === 'webinar') { + if (operation === 'create') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinarcreate + const userId = this.getNodeParameter('userId', i) as string; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + const settings: Settings = {}; + + + if (additionalFields.audio) { + settings.audio = additionalFields.audio as string; + + } + + if (additionalFields.alternative_hosts) { + settings.alternative_hosts = additionalFields.alternative_hosts as string; + + } + + if (additionalFields.panelists_video) { + settings.panelists_video = additionalFields.panelists_video as boolean; + + } + if (additionalFields.practice_session) { + settings.practice_session = additionalFields.practice_session as boolean; + + } + if (additionalFields.auto_recording) { + settings.auto_recording = additionalFields.auto_recording as string; + + } + + if (additionalFields.registration_type) { + settings.registration_type = additionalFields.registration_type as number; + + } + if (additionalFields.approval_type) { + settings.approval_type = additionalFields.approval_type as number; + + } + + body = { + settings, + }; + + if (additionalFields.topic) { + body.topic = additionalFields.topic as string; + + } + + if (additionalFields.type) { + body.type = additionalFields.type as string; + + } + + if (additionalFields.startTime) { + body.start_time = additionalFields.startTime as string; + + } + + if (additionalFields.duration) { + body.duration = additionalFields.duration as number; + + } + + + if (additionalFields.timeZone) { + body.timezone = additionalFields.timeZone as string; + + } + + if (additionalFields.password) { + body.password = additionalFields.password as string; + + } + + if (additionalFields.agenda) { + body.agenda = additionalFields.agenda as string; + + } + responseData = await zoomApiRequest.call( + this, + 'POST', + `/users/${userId}/webinars`, + body, + qs + ); + } + if (operation === 'get') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinar + const webinarId = this.getNodeParameter('webinarId', i) as string; + + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + if (additionalFields.showPreviousOccurences) + qs.show_previous_occurences = additionalFields.showPreviousOccurences as boolean; + + if (additionalFields.occurenceId) + qs.occurence_id = additionalFields.occurenceId as string; + + responseData = await zoomApiRequest.call( + this, + 'GET', + `/webinars/${webinarId}`, + {}, + qs + ); + } + if (operation === 'getAll') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinars + const userId = this.getNodeParameter('userId', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + if (returnAll) { + responseData = await zoomApiRequestAllItems.call(this, 'results', 'GET', `/users/${userId}/webinars`, {}, qs); + } else { + const limit = this.getNodeParameter('limit', i) as number; + qs.page_size = limit; + responseData = await zoomApiRequest.call(this, 'GET', `/users/${userId}/webinars`, {}, qs); + responseData = responseData.results; + } + } + if (operation === 'delete') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinardelete + const webinarId = this.getNodeParameter('webinarId', i) as string; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + + + if (additionalFields.occurenceId) + qs.occurence_id = additionalFields.occurenceId; + + responseData = await zoomApiRequest.call( + this, + 'DELETE', + `/webinars/${webinarId}`, + {}, + qs + ); + responseData = { success: true }; + } + if (operation === 'update') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinarupdate + const webinarId = this.getNodeParameter('webinarId', i) as string; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + if (additionalFields.occurence_id) { + qs.occurence_id = additionalFields.occurence_id as string; + + } + const settings: Settings = {}; + if (additionalFields.audio) { + settings.audio = additionalFields.audio as string; + + } + if (additionalFields.alternative_hosts) { + settings.alternative_hosts = additionalFields.alternative_hosts as string; + + } + + if (additionalFields.panelists_video) { + settings.panelists_video = additionalFields.panelists_video as boolean; + + } + if (additionalFields.practice_session) { + settings.practice_session = additionalFields.practice_session as boolean; + + } + if (additionalFields.auto_recording) { + settings.auto_recording = additionalFields.auto_recording as string; + + } + + if (additionalFields.registration_type) { + settings.registration_type = additionalFields.registration_type as number; + + } + if (additionalFields.approval_type) { + settings.approval_type = additionalFields.approval_type as number; + + } + + body = { + settings, + }; + + if (additionalFields.topic) { + body.topic = additionalFields.topic as string; + + } + + if (additionalFields.type) { + body.type = additionalFields.type as string; + + } + + if (additionalFields.startTime) { + body.start_time = additionalFields.startTime as string; + + } + + if (additionalFields.duration) { + body.duration = additionalFields.duration as number; + + } + + + if (additionalFields.timeZone) { + body.timezone = additionalFields.timeZone as string; + + } + + if (additionalFields.password) { + body.password = additionalFields.password as string; + + } + + if (additionalFields.agenda) { + body.agenda = additionalFields.agenda as string; + + } + responseData = await zoomApiRequest.call( + this, + 'PATCH', + `/users/${webinarId}/webinars`, + body, + qs + ); } } } From e60edc3bff68a6e7fb72af7454bf8f77d2e3baad Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 23 Jun 2020 12:43:40 +0200 Subject: [PATCH 021/155] :zap: Minor improvements --- .../mysqldb/migrations/1592447867632-WebhookModel.ts | 2 +- .../postgresdb/migrations/1589476000887-WebhookModel.ts | 5 +++-- .../sqlite/migrations/1588102412422-InitialMigration.ts | 5 ++++- .../sqlite/migrations/1592445003908-WebhookModel.ts | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/databases/mysqldb/migrations/1592447867632-WebhookModel.ts b/packages/cli/src/databases/mysqldb/migrations/1592447867632-WebhookModel.ts index 6c81f612ed..8a49080462 100644 --- a/packages/cli/src/databases/mysqldb/migrations/1592447867632-WebhookModel.ts +++ b/packages/cli/src/databases/mysqldb/migrations/1592447867632-WebhookModel.ts @@ -13,7 +13,7 @@ import { import { Workflow, -} from 'n8n-workflow/dist/src/Workflow'; +} from 'n8n-workflow'; import { IWebhookDb, diff --git a/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts b/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts index dfbf94a799..e53fc28915 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts @@ -11,7 +11,7 @@ import { import { Workflow, -} from 'n8n-workflow/dist/src/Workflow'; +} from 'n8n-workflow'; import { IWebhookDb, @@ -24,12 +24,13 @@ export class WebhookModel1589476000887 implements MigrationInterface { async up(queryRunner: QueryRunner): Promise { let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixIndex = tablePrefix; const schema = config.get('database.postgresdb.schema'); if (schema) { tablePrefix = schema + '.' + tablePrefix; } - await queryRunner.query(`CREATE TABLE ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" character varying NOT NULL, "method" character varying NOT NULL, "node" character varying NOT NULL, CONSTRAINT "PK_b21ace2e13596ccd87dc9bf4ea6" PRIMARY KEY ("webhookPath", "method"))`, undefined); + await queryRunner.query(`CREATE TABLE ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" character varying NOT NULL, "method" character varying NOT NULL, "node" character varying NOT NULL, CONSTRAINT "PK_${tablePrefixIndex}b21ace2e13596ccd87dc9bf4ea6" PRIMARY KEY ("webhookPath", "method"))`, undefined); const workflows = await queryRunner.query(`SELECT * FROM ${tablePrefix}workflow_entity WHERE active=true`) as IWorkflowDb[]; const data: IWebhookDb[] = []; diff --git a/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts b/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts index c2bb55040e..09a0da911a 100644 --- a/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts +++ b/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts @@ -1,4 +1,7 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { + MigrationInterface, + QueryRunner, +} from 'typeorm'; import * as config from '../../../../config'; diff --git a/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts b/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts index f1402fad88..92704482b2 100644 --- a/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts +++ b/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts @@ -1,7 +1,7 @@ import { MigrationInterface, QueryRunner, -} from "typeorm"; +} from 'typeorm'; import * as config from '../../../../config'; @@ -13,7 +13,7 @@ import { import { Workflow, -} from 'n8n-workflow/dist/src/Workflow'; +} from 'n8n-workflow'; import { IWebhookDb, From 807db166fd6ec8b46ed987d15cc254606307eb2b Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Tue, 23 Jun 2020 12:14:04 -0700 Subject: [PATCH 022/155] refactor code --- .../nodes/Zoom/MeetingDescription.ts | 498 +++++++++--------- .../Zoom/MeetingRegistrantDescription.ts | 59 ++- .../nodes/Zoom/WebinarDescription.ts | 396 +++++++------- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 28 +- 4 files changed, 489 insertions(+), 492 deletions(-) diff --git a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts index 274b48ff97..b9a1c74cb5 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts @@ -86,77 +86,6 @@ export const meetingFields = [ } }, options: [ - { - displayName: 'Meeting topic', - name: 'topic', - type: 'string', - default: '', - description: `Meeting topic.`, - }, - { - displayName: 'Meeting type', - name: 'type', - type: 'options', - options: [ - { - name: 'Instant Meeting', - value: 1, - }, - { - name: 'Scheduled Meeting', - value: 2, - }, - { - name: 'Recurring meeting with no fixed time', - value: 3, - }, - { - name: 'Recurring meeting with no fixed time', - value: 8, - }, - - ], - default: 2, - description: 'Meeting type.' - }, - { - displayName: 'Start time', - name: 'startTime', - type: 'dateTime', - default: '', - description: 'Start time should be used only for scheduled or recurring meetings with fixed time', - }, - { - displayName: 'Duration', - name: 'duration', - type: 'number', - default: '', - description: 'Duration.', - }, - { - displayName: 'Timezone', - name: 'timeZone', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getTimezones', - }, - default: '', - description: `Time zone used in the response. The default is the time zone of the calendar.`, - }, - { - displayName: 'Schedule for', - name: 'scheduleFor', - type: 'string', - default: '', - description: 'Schedule meeting for someone else from your account, provide their email id.', - }, - { - displayName: 'Password', - name: 'password', - type: 'string', - default: '', - description: 'Password to join the meeting with maximum 10 characters.', - }, { displayName: 'Agenda', name: 'agenda', @@ -164,55 +93,6 @@ export const meetingFields = [ default: '', description: 'Meeting agenda.', }, - { - displayName: 'Host Meeting in China', - name: 'cn_meeting', - type: 'boolean', - default: false, - description: 'Host Meeting in China.', - }, - { - displayName: 'Host Meeting in India', - name: 'in_meeting', - type: 'boolean', - default: false, - description: 'Host Meeting in India.', - }, - { - displayName: 'Host Video', - name: 'host_video', - type: 'boolean', - default: false, - description: 'Start video when host joins the meeting.', - }, - { - displayName: 'Participant Video', - name: 'participant_video', - type: 'boolean', - default: false, - description: 'Start video when participant joins the meeting.', - }, - { - displayName: 'Join before Host', - name: 'join_before_host', - type: 'boolean', - default: false, - description: 'Allow participants to join the meeting before host starts it.', - }, - { - displayName: 'Muting before entry', - name: 'mute_upon_entry', - type: 'boolean', - default: false, - description: 'Mute participants upon entry.', - }, - { - displayName: 'Watermark', - name: 'watermark', - type: 'boolean', - default: false, - description: 'Adds watermark when viewing a shared screen.', - }, { displayName: 'Alternative Hosts', name: 'alternative_hosts', @@ -263,6 +143,95 @@ export const meetingFields = [ default: 'both', description: 'Determine how participants can join audio portion of the meeting.', }, + { + displayName: 'Duration', + name: 'duration', + type: 'number', + default: '', + description: 'Duration.', + }, + { + displayName: 'Host Meeting in China', + name: 'cn_meeting', + type: 'boolean', + default: false, + description: 'Host Meeting in China.', + }, + { + displayName: 'Host Meeting in India', + name: 'in_meeting', + type: 'boolean', + default: false, + description: 'Host Meeting in India.', + }, + { + displayName: 'Host Video', + name: 'host_video', + type: 'boolean', + default: false, + description: 'Start video when host joins the meeting.', + }, + { + displayName: 'Join before Host', + name: 'join_before_host', + type: 'boolean', + default: false, + description: 'Allow participants to join the meeting before host starts it.', + }, + { + displayName: 'Meeting topic', + name: 'topic', + type: 'string', + default: '', + description: `Meeting topic.`, + }, + { + displayName: 'Meeting type', + name: 'type', + type: 'options', + options: [ + { + name: 'Instant Meeting', + value: 1, + }, + { + name: 'Scheduled Meeting', + value: 2, + }, + { + name: 'Recurring meeting with no fixed time', + value: 3, + }, + { + name: 'Recurring meeting with no fixed time', + value: 8, + }, + + ], + default: 2, + description: 'Meeting type.' + }, + { + displayName: 'Muting before entry', + name: 'mute_upon_entry', + type: 'boolean', + default: false, + description: 'Mute participants upon entry.', + }, + { + displayName: 'Participant Video', + name: 'participant_video', + type: 'boolean', + default: false, + description: 'Start video when participant joins the meeting.', + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: 'Password to join the meeting with maximum 10 characters.', + }, { displayName: 'Registration type', name: 'registration_type', @@ -284,7 +253,37 @@ export const meetingFields = [ default: 1, description: 'Registration type. Used for recurring meetings with fixed time only', }, - + { + displayName: 'Schedule for', + name: 'scheduleFor', + type: 'string', + default: '', + description: 'Schedule meeting for someone else from your account, provide their email id.', + }, + { + displayName: 'Start time', + name: 'startTime', + type: 'dateTime', + default: '', + description: 'Start time should be used only for scheduled or recurring meetings with fixed time', + }, + { + displayName: 'Timezone', + name: 'timeZone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: '', + description: `Time zone used in the response. The default is the time zone of the calendar.`, + }, + { + displayName: 'Watermark', + name: 'watermark', + type: 'boolean', + default: false, + description: 'Adds watermark when viewing a shared screen.', + }, ], }, /* -------------------------------------------------------------------------- */ @@ -538,6 +537,98 @@ export const meetingFields = [ }, }, options: [ + { + displayName: 'Agenda', + name: 'agenda', + type: 'string', + default: '', + description: 'Meeting agenda.', + }, + { + displayName: 'Alternative Hosts', + name: 'alternative_hosts', + type: 'string', + default: '', + description: 'Alternative hosts email ids.', + }, + { + displayName: 'Audio', + name: 'auto_recording', + type: 'options', + options: [ + { + name: 'Both Telephony and VoiP', + value: 'both', + }, + { + name: 'Telephony', + value: 'telephony', + }, + { + name: 'VOIP', + value: 'voip', + }, + ], + default: 'both', + description: 'Determine how participants can join audio portion of the meeting.', + }, + { + displayName: 'Auto recording', + name: 'auto_recording', + type: 'options', + options: [ + { + name: 'Record on local', + value: 'local', + }, + { + name: 'Record on cloud', + value: 'cloud', + }, + { + name: 'Disabled', + value: 'none', + }, + ], + default: 'none', + description: 'Auto recording.', + }, + + { + displayName: 'Duration', + name: 'duration', + type: 'number', + default: '', + description: 'Duration.', + }, + { + displayName: 'Join before Host', + name: 'join_before_host', + type: 'boolean', + default: false, + description: 'Allow participants to join the meeting before host starts it.', + }, + { + displayName: 'Host Meeting in China', + name: 'cn_meeting', + type: 'boolean', + default: false, + description: 'Host Meeting in China.', + }, + { + displayName: 'Host Meeting in India', + name: 'in_meeting', + type: 'boolean', + default: false, + description: 'Host Meeting in India.', + }, + { + displayName: 'Host Video', + name: 'host_video', + type: 'boolean', + default: false, + description: 'Start video when host joins the meeting.', + }, { displayName: 'Occurence Id', name: 'occurenceId', @@ -579,35 +670,11 @@ export const meetingFields = [ description: 'Meeting type.' }, { - displayName: 'Start time', - name: 'startTime', - type: 'dateTime', - default: '', - description: 'Start time should be used only for scheduled or recurring meetings with fixed time', - }, - { - displayName: 'Duration', - name: 'duration', - type: 'number', - default: '', - description: 'Duration.', - }, - { - displayName: 'Timezone', - name: 'timeZone', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getTimezones', - }, - default: '', - description: `Time zone used in the response. The default is the time zone of the calendar.`, - }, - { - displayName: 'Schedule for', - name: 'scheduleFor', - type: 'string', - default: '', - description: 'Schedule meeting for someone else from your account, provide their email id.', + displayName: 'Muting before entry', + name: 'mute_upon_entry', + type: 'boolean', + default: false, + description: 'Mute participants upon entry.', }, { displayName: 'Password', @@ -616,34 +683,6 @@ export const meetingFields = [ default: '', description: 'Password to join the meeting with maximum 10 characters.', }, - { - displayName: 'Agenda', - name: 'agenda', - type: 'string', - default: '', - description: 'Meeting agenda.', - }, - { - displayName: 'Host Meeting in China', - name: 'cn_meeting', - type: 'boolean', - default: false, - description: 'Host Meeting in China.', - }, - { - displayName: 'Host Meeting in India', - name: 'in_meeting', - type: 'boolean', - default: false, - description: 'Host Meeting in India.', - }, - { - displayName: 'Host Video', - name: 'host_video', - type: 'boolean', - default: false, - description: 'Start video when host joins the meeting.', - }, { displayName: 'Participant Video', name: 'participant_video', @@ -651,76 +690,6 @@ export const meetingFields = [ default: false, description: 'Start video when participant joins the meeting.', }, - { - displayName: 'Join before Host', - name: 'join_before_host', - type: 'boolean', - default: false, - description: 'Allow participants to join the meeting before host starts it.', - }, - { - displayName: 'Muting before entry', - name: 'mute_upon_entry', - type: 'boolean', - default: false, - description: 'Mute participants upon entry.', - }, - { - displayName: 'Watermark', - name: 'watermark', - type: 'boolean', - default: false, - description: 'Adds watermark when viewing a shared screen.', - }, - { - displayName: 'Alternative Hosts', - name: 'alternative_hosts', - type: 'string', - default: '', - description: 'Alternative hosts email ids.', - }, - { - displayName: 'Auto recording', - name: 'auto_recording', - type: 'options', - options: [ - { - name: 'Record on local', - value: 'local', - }, - { - name: 'Record on cloud', - value: 'cloud', - }, - { - name: 'Disabled', - value: 'none', - }, - ], - default: 'none', - description: 'Auto recording.', - }, - { - displayName: 'Audio', - name: 'auto_recording', - type: 'options', - options: [ - { - name: 'Both Telephony and VoiP', - value: 'both', - }, - { - name: 'Telephony', - value: 'telephony', - }, - { - name: 'VOIP', - value: 'voip', - }, - ], - default: 'both', - description: 'Determine how participants can join audio portion of the meeting.', - }, { displayName: 'Registration type', name: 'registration_type', @@ -742,6 +711,39 @@ export const meetingFields = [ default: 1, description: 'Registration type. Used for recurring meetings with fixed time only', }, + { + displayName: 'Schedule for', + name: 'scheduleFor', + type: 'string', + default: '', + description: 'Schedule meeting for someone else from your account, provide their email id.', + }, + { + displayName: 'Start time', + name: 'startTime', + type: 'dateTime', + default: '', + description: 'Start time should be used only for scheduled or recurring meetings with fixed time', + }, + { + displayName: 'Timezone', + name: 'timeZone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: '', + description: `Time zone used in the response. The default is the time zone of the calendar.`, + }, + { + displayName: 'Watermark', + name: 'watermark', + type: 'boolean', + default: false, + description: 'Adds watermark when viewing a shared screen.', + }, + + ], }, diff --git a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts index 592c7a285a..232f8cf42c 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts @@ -114,20 +114,6 @@ export const meetingRegistrantFields = [ } }, options: [ - { - displayName: 'Occurence Ids', - name: 'occurenceId', - type: 'string', - default: '', - description: 'Occurence IDs separated by comma.', - }, - { - displayName: 'Last Name', - name: 'lastName', - type: 'string', - default: '', - description: 'Last Name.', - }, { displayName: 'Address', name: 'address', @@ -143,11 +129,11 @@ export const meetingRegistrantFields = [ description: 'Valid city of registrant.', }, { - displayName: 'State', - name: 'state', + displayName: 'Comments', + name: 'comments', type: 'string', default: '', - description: 'Valid state of registrant.', + description: 'Allows registrants to provide any questions they have.', }, { displayName: 'Country', @@ -157,25 +143,25 @@ export const meetingRegistrantFields = [ description: 'Valid country of registrant.', }, { - displayName: 'Zip code', - name: 'zip', + displayName: 'Job title', + name: 'job_title', type: 'string', default: '', - description: 'Valid zip-code of registrant.', + description: 'Job title of registrant.', }, { - displayName: 'Phone Number', - name: 'phone', + displayName: 'Last Name', + name: 'lastName', type: 'string', default: '', - description: 'Valid phone number of registrant.', + description: 'Last Name.', }, { - displayName: 'Comments', - name: 'comments', + displayName: 'Occurence Ids', + name: 'occurenceId', type: 'string', default: '', - description: 'Allows registrants to provide any questions they have.', + description: 'Occurence IDs separated by comma.', }, { displayName: 'Organization', @@ -185,11 +171,11 @@ export const meetingRegistrantFields = [ description: 'Organization of registrant.', }, { - displayName: 'Job title', - name: 'job_title', + displayName: 'Phone Number', + name: 'phone', type: 'string', default: '', - description: 'Job title of registrant.', + description: 'Valid phone number of registrant.', }, { displayName: 'Purchasing time frame', @@ -246,6 +232,21 @@ export const meetingRegistrantFields = [ default: '', description: 'Role in purchase process.' }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + description: 'Valid state of registrant.', + }, + { + displayName: 'Zip code', + name: 'zip', + type: 'string', + default: '', + description: 'Valid zip-code of registrant.', + }, + ], }, /* -------------------------------------------------------------------------- */ diff --git a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts index 5cefbdace8..4bbec11fd4 100644 --- a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts +++ b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts @@ -86,65 +86,6 @@ export const webinarFields = [ } }, options: [ - { - displayName: 'Webinar topic', - name: 'topic', - type: 'string', - default: '', - description: `Webinar topic.`, - }, - { - displayName: 'Webinar type', - name: 'type', - type: 'options', - options: [ - { - name: 'Webinar', - value: 5, - }, - { - name: 'Recurring webinar with no fixed time', - value: 6, - }, - { - name: 'Recurring webinar with fixed time', - value: 9, - }, - ], - default: 5, - description: 'Webinar type.' - }, - { - displayName: 'Start time', - name: 'startTime', - type: 'dateTime', - default: '', - description: 'Start time should be used only for scheduled or recurring webinar with fixed time', - }, - { - displayName: 'Duration', - name: 'duration', - type: 'string', - default: '', - description: 'Duration.', - }, - { - displayName: 'Timezone', - name: 'timeZone', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getTimezones', - }, - default: '', - description: `Time zone used in the response. The default is the time zone of the calendar.`, - }, - { - displayName: 'Password', - name: 'password', - type: 'string', - default: '', - description: 'Password to join the webinar with maximum 10 characters.', - }, { displayName: 'Agenda', name: 'agenda', @@ -152,27 +93,6 @@ export const webinarFields = [ default: '', description: 'Webinar agenda.', }, - { - displayName: 'Host Video', - name: 'host_video', - type: 'boolean', - default: false, - description: 'Start video when host joins the webinar.', - }, - { - displayName: 'Panelists Video', - name: 'panelists_video', - type: 'boolean', - default: false, - description: 'Start video when panelists joins the webinar.', - }, - { - displayName: 'Practice Session', - name: 'practice_session', - type: 'boolean', - default: false, - description: 'Enable Practice session.', - }, { displayName: 'Alternative Hosts', name: 'alternative_hosts', @@ -201,27 +121,6 @@ export const webinarFields = [ default: 2, description: 'Approval type.', }, - { - displayName: 'Auto recording', - name: 'auto_recording', - type: 'options', - options: [ - { - name: 'Record on local', - value: 'local', - }, - { - name: 'Record on cloud', - value: 'cloud', - }, - { - name: 'Disabled', - value: 'none', - }, - ], - default: 'none', - description: 'Auto recording.', - }, { displayName: 'Audio', name: 'audio', @@ -244,6 +143,62 @@ export const webinarFields = [ default: 'both', description: 'Determine how participants can join audio portion of the webinar.', }, + { + displayName: 'Auto recording', + name: 'auto_recording', + type: 'options', + options: [ + { + name: 'Record on local', + value: 'local', + }, + { + name: 'Record on cloud', + value: 'cloud', + }, + { + name: 'Disabled', + value: 'none', + }, + ], + default: 'none', + description: 'Auto recording.', + }, + { + displayName: 'Duration', + name: 'duration', + type: 'string', + default: '', + description: 'Duration.', + }, + { + displayName: 'Host Video', + name: 'host_video', + type: 'boolean', + default: false, + description: 'Start video when host joins the webinar.', + }, + { + displayName: 'Panelists Video', + name: 'panelists_video', + type: 'boolean', + default: false, + description: 'Start video when panelists joins the webinar.', + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: 'Password to join the webinar with maximum 10 characters.', + }, + { + displayName: 'Practice Session', + name: 'practice_session', + type: 'boolean', + default: false, + description: 'Enable Practice session.', + }, { displayName: 'Registration type', name: 'registration_type', @@ -265,6 +220,51 @@ export const webinarFields = [ default: 1, description: 'Registration type. Used for recurring webinar with fixed time only', }, + { + displayName: 'Start time', + name: 'startTime', + type: 'dateTime', + default: '', + description: 'Start time should be used only for scheduled or recurring webinar with fixed time', + }, + { + displayName: 'Timezone', + name: 'timeZone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: '', + description: `Time zone used in the response. The default is the time zone of the calendar.`, + }, + { + displayName: 'Webinar topic', + name: 'topic', + type: 'string', + default: '', + description: `Webinar topic.`, + }, + { + displayName: 'Webinar type', + name: 'type', + type: 'options', + options: [ + { + name: 'Webinar', + value: 5, + }, + { + name: 'Recurring webinar with no fixed time', + value: 6, + }, + { + name: 'Recurring webinar with fixed time', + value: 9, + }, + ], + default: 5, + description: 'Webinar type.' + }, ], }, @@ -385,25 +385,6 @@ export const webinarFields = [ default: 30, description: 'How many results to return.', }, - { - displayName: 'Additional settings', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - default: {}, - displayOptions: { - show: { - operation: [ - 'getAll', - - ], - resource: [ - 'webinar', - ], - }, - }, - - }, /* -------------------------------------------------------------------------- */ /* webina:delete */ /* -------------------------------------------------------------------------- */ @@ -492,72 +473,6 @@ export const webinarFields = [ } }, options: [ - { - displayName: 'Occurence Id', - name: 'occurence_id', - type: 'string', - default: '', - description: `Webinar occurence Id.`, - }, - { - displayName: 'Webinar topic', - name: 'topic', - type: 'string', - default: '', - description: `Webinar topic.`, - }, - { - displayName: 'Webinar type', - name: 'type', - type: 'options', - options: [ - { - name: 'Webinar', - value: 5, - }, - { - name: 'Recurring webinar with no fixed time', - value: 6, - }, - { - name: 'Recurring webinar with fixed time', - value: 9, - }, - ], - default: 5, - description: 'Webinar type.' - }, - { - displayName: 'Start time', - name: 'startTime', - type: 'dateTime', - default: '', - description: 'Start time should be used only for scheduled or recurring webinar with fixed time', - }, - { - displayName: 'Duration', - name: 'duration', - type: 'string', - default: '', - description: 'Duration.', - }, - { - displayName: 'Timezone', - name: 'timeZone', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getTimezones', - }, - default: '', - description: `Time zone used in the response. The default is the time zone of the calendar.`, - }, - { - displayName: 'Password', - name: 'password', - type: 'string', - default: '', - description: 'Password to join the webinar with maximum 10 characters.', - }, { displayName: 'Agenda', name: 'agenda', @@ -565,27 +480,6 @@ export const webinarFields = [ default: '', description: 'Webinar agenda.', }, - { - displayName: 'Host Video', - name: 'host_video', - type: 'boolean', - default: false, - description: 'Start video when host joins the webinar.', - }, - { - displayName: 'Panelists Video', - name: 'panelists_video', - type: 'boolean', - default: false, - description: 'Start video when panelists joins the webinar.', - }, - { - displayName: 'Practice Session', - name: 'practice_session', - type: 'boolean', - default: false, - description: 'Enable Practice session.', - }, { displayName: 'Alternative Hosts', name: 'alternative_hosts', @@ -657,6 +551,48 @@ export const webinarFields = [ default: 'both', description: 'Determine how participants can join audio portion of the webinar.', }, + { + displayName: 'Duration', + name: 'duration', + type: 'string', + default: '', + description: 'Duration.', + }, + { + displayName: 'Host Video', + name: 'host_video', + type: 'boolean', + default: false, + description: 'Start video when host joins the webinar.', + }, + { + displayName: 'Occurence Id', + name: 'occurence_id', + type: 'string', + default: '', + description: `Webinar occurence Id.`, + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: 'Password to join the webinar with maximum 10 characters.', + }, + { + displayName: 'Panelists Video', + name: 'panelists_video', + type: 'boolean', + default: false, + description: 'Start video when panelists joins the webinar.', + }, + { + displayName: 'Practice Session', + name: 'practice_session', + type: 'boolean', + default: false, + description: 'Enable Practice session.', + }, { displayName: 'Registration type', name: 'registration_type', @@ -678,7 +614,51 @@ export const webinarFields = [ default: 1, description: 'Registration type. Used for recurring webinars with fixed time only', }, - + { + displayName: 'Start time', + name: 'startTime', + type: 'dateTime', + default: '', + description: 'Start time should be used only for scheduled or recurring webinar with fixed time', + }, + { + displayName: 'Timezone', + name: 'timeZone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: '', + description: `Time zone used in the response. The default is the time zone of the calendar.`, + }, + { + displayName: 'Webinar topic', + name: 'topic', + type: 'string', + default: '', + description: `Webinar topic.`, + }, + { + displayName: 'Webinar type', + name: 'type', + type: 'options', + options: [ + { + name: 'Webinar', + value: 5, + }, + { + name: 'Recurring webinar with no fixed time', + value: 6, + }, + { + name: 'Recurring webinar with fixed time', + value: 9, + }, + ], + default: 5, + description: 'Webinar type.' + }, ], }, diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index fb3b7565f7..8e6eb10d2b 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -183,12 +183,16 @@ export class Zoom implements INodeType { 'additionalFields', i ) as IDataObject; - if (additionalFields.showPreviousOccurences) + if (additionalFields.showPreviousOccurences) { qs.show_previous_occurences = additionalFields.showPreviousOccurences as boolean; - if (additionalFields.occurenceId) + } + + if (additionalFields.occurenceId) { qs.occurence_id = additionalFields.occurenceId as string; + } + responseData = await zoomApiRequest.call( this, 'GET', @@ -218,12 +222,16 @@ export class Zoom implements INodeType { 'additionalFields', i ) as IDataObject; - if (additionalFields.scheduleForReminder) + if (additionalFields.scheduleForReminder) { qs.schedule_for_reminder = additionalFields.scheduleForReminder as boolean; - if (additionalFields.occurenceId) + } + + if (additionalFields.occurenceId) { qs.occurence_id = additionalFields.occurenceId; + } + responseData = await zoomApiRequest.call( this, 'DELETE', @@ -669,12 +677,16 @@ export class Zoom implements INodeType { 'additionalFields', i ) as IDataObject; - if (additionalFields.showPreviousOccurences) + if (additionalFields.showPreviousOccurences) { qs.show_previous_occurences = additionalFields.showPreviousOccurences as boolean; - if (additionalFields.occurenceId) + } + + if (additionalFields.occurenceId) { qs.occurence_id = additionalFields.occurenceId as string; + } + responseData = await zoomApiRequest.call( this, 'GET', @@ -705,9 +717,11 @@ export class Zoom implements INodeType { ) as IDataObject; - if (additionalFields.occurenceId) + if (additionalFields.occurenceId) { qs.occurence_id = additionalFields.occurenceId; + } + responseData = await zoomApiRequest.call( this, 'DELETE', From a6e40aaebe3cbd1b099f65ad4797cace9104ac92 Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Tue, 23 Jun 2020 12:27:47 -0700 Subject: [PATCH 023/155] minor fix --- .../nodes-base/nodes/Zoom/GenericFunctions.ts | 16 +++++++--------- .../nodes/Zoom/MeetingRegistrantDescription.ts | 2 -- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 3 +-- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts index d361a9761a..d5cad39297 100644 --- a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts @@ -1,13 +1,16 @@ -import { OptionsWithUri } from 'request'; +import { + OptionsWithUri, +} from 'request'; import { IExecuteFunctions, IExecuteSingleFunctions, - ILoadOptionsFunctions + ILoadOptionsFunctions, } from 'n8n-core'; -import { IDataObject } from 'n8n-workflow'; -import * as _ from 'lodash'; +import { + IDataObject, +} from 'n8n-workflow'; export async function zoomApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: object = {}, query: object = {}, headers: {} | undefined = undefined, option: {} = {}): Promise { // tslint:disable-line:no-any @@ -60,8 +63,6 @@ export async function zoomApiRequest(this: IExecuteFunctions | IExecuteSingleFun // If that data does not exist for some reason return the actual error throw error; } - - } @@ -75,10 +76,7 @@ export async function zoomApiRequestAllItems( ): Promise { // tslint:disable-line:no-any const returnData: IDataObject[] = []; - let responseData; - //query.maxResults = 300; - do { responseData = await zoomApiRequest.call( this, diff --git a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts index 232f8cf42c..2d32c55b42 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts @@ -36,8 +36,6 @@ export const meetingRegistrantOperations = [ } ] as INodeProperties[]; - - export const meetingRegistrantFields = [ /* -------------------------------------------------------------------------- */ /* meetingRegistrants:create */ diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index 8e6eb10d2b..078da4879c 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -29,6 +29,7 @@ import { webinarOperations, webinarFields, } from './WebinarDescription'; + import * as moment from 'moment-timezone'; interface Settings { @@ -46,8 +47,6 @@ interface Settings { registration_type?: number; approval_type?: number; practice_session?: boolean; - - } export class Zoom implements INodeType { description: INodeTypeDescription = { From 37ff6a8f1333b74dea2548e9ca56746fddced9c1 Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Tue, 23 Jun 2020 13:40:43 -0700 Subject: [PATCH 024/155] fix spellings --- .../nodes-base/nodes/Zoom/GenericFunctions.ts | 1 - .../nodes/Zoom/MeetingDescription.ts | 40 ++++++++--------- .../Zoom/MeetingRegistrantDescription.ts | 18 ++++---- .../nodes/Zoom/WebinarDescription.ts | 36 ++++++++-------- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 43 +++++++++---------- 5 files changed, 68 insertions(+), 70 deletions(-) diff --git a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts index d5cad39297..74b5a18d06 100644 --- a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts @@ -46,7 +46,6 @@ export async function zoomApiRequest(this: IExecuteFunctions | IExecuteSingleFun return await this.helpers.request(options); } else { //@ts-ignore - return await this.helpers.requestOAuth2.call(this, 'zoomOAuth2Api', options); } } catch (error) { diff --git a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts index b9a1c74cb5..4d8281660d 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts @@ -98,7 +98,7 @@ export const meetingFields = [ name: 'alternative_hosts', type: 'string', default: '', - description: 'Alternative hosts email ids.', + description: 'Alternative hosts email IDs.', }, { displayName: 'Auto recording', @@ -242,11 +242,11 @@ export const meetingFields = [ value: 1, }, { - name: 'Attendees need to register for every occurence', + name: 'Attendees need to register for every occurrence', value: 2, }, { - name: 'Attendees register once and can choose one or more occurences to attend', + name: 'Attendees register once and can choose one or more occurrences to attend', value: 3, }, ], @@ -326,18 +326,18 @@ export const meetingFields = [ }, options: [ { - displayName: 'Occurence Id', - name: 'occurenceId', + displayName: 'Occurrence ID', + name: 'occurrenceId', type: 'string', default: '', - description: 'To view meeting details of a particular occurence of the recurring meeting.', + description: 'To view meeting details of a particular occurrence of the recurring meeting.', }, { - displayName: 'Show Previous Occurences', - name: 'showPreviousOccurences', + displayName: 'Show Previous Occurrences', + name: 'showPreviousOccurrences', type: 'boolean', default: '', - description: 'To view meeting details of all previous occurences of the recurring meeting.', + description: 'To view meeting details of all previous occurrences of the recurring meeting.', }, ], }, @@ -360,7 +360,7 @@ export const meetingFields = [ ], }, }, - description: 'User ID or email-id.', + description: 'User ID or email-ID.', }, { displayName: 'Return All', @@ -483,11 +483,11 @@ export const meetingFields = [ }, options: [ { - displayName: 'Occurence Id', - name: 'occurenceId', + displayName: 'Occurence ID', + name: 'occurrenceId', type: 'string', default: '', - description: 'Meeting occurence Id.', + description: 'Meeting occurrence ID.', }, { displayName: 'Schedule a reminder', @@ -503,7 +503,7 @@ export const meetingFields = [ /* meeting:update */ /* -------------------------------------------------------------------------- */ { - displayName: 'Meeting Id', + displayName: 'Meeting ID', name: 'meetingId', type: 'string', default: '', @@ -630,11 +630,11 @@ export const meetingFields = [ description: 'Start video when host joins the meeting.', }, { - displayName: 'Occurence Id', - name: 'occurenceId', + displayName: 'Occurrence Id', + name: 'occurrenceId', type: 'string', default: '', - description: 'Occurence ID.', + description: 'Occurrence ID.', }, { displayName: 'Meeting topic', @@ -696,15 +696,15 @@ export const meetingFields = [ type: 'options', options: [ { - name: 'Attendees register once and can attend any of the occurences', + name: 'Attendees register once and can attend any of the occurrences', value: 1, }, { - name: 'Attendees need to register for every occurence', + name: 'Attendees need to register for every occurrence', value: 2, }, { - name: 'Attendees register once and can choose one or more occurences to attend', + name: 'Attendees register once and can choose one or more occurrences to attend', value: 3, }, ], diff --git a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts index 2d32c55b42..8cef3383aa 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts @@ -155,11 +155,11 @@ export const meetingRegistrantFields = [ description: 'Last Name.', }, { - displayName: 'Occurence Ids', - name: 'occurenceId', + displayName: 'Occurrence Ids', + name: 'occurrenceId', type: 'string', default: '', - description: 'Occurence IDs separated by comma.', + description: 'Occurrence IDs separated by comma.', }, { displayName: 'Organization', @@ -328,11 +328,11 @@ export const meetingRegistrantFields = [ }, options: [ { - displayName: 'Occurence Id', - name: 'occurence_id', + displayName: 'Occurrence Id', + name: 'occurrence_id', type: 'string', default: '', - description: `Occurence Id.`, + description: `Occurrence Id.`, }, { displayName: 'Status', @@ -429,11 +429,11 @@ export const meetingRegistrantFields = [ }, options: [ { - displayName: 'Occurence Id', - name: 'occurenceId', + displayName: 'Occurrence Id', + name: 'occurrenceId', type: 'string', default: '', - description: 'Occurence ID.', + description: 'Occurrence ID.', }, ], diff --git a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts index 4bbec11fd4..b673b77fa9 100644 --- a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts +++ b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts @@ -209,11 +209,11 @@ export const webinarFields = [ value: 1, }, { - name: 'Attendees need to register for every occurence', + name: 'Attendees need to register for every occurrence', value: 2, }, { - name: 'Attendees register once and can choose one or more occurences to attend', + name: 'Attendees register once and can choose one or more occurrences to attend', value: 3, }, ], @@ -312,14 +312,14 @@ export const webinarFields = [ name: 'occurenceId', type: 'string', default: '', - description: 'To view webinar details of a particular occurence of the recurring webinar.', + description: 'To view webinar details of a particular occurrence of the recurring webinar.', }, { - displayName: 'Show Previous Occurences', - name: 'showPreviousOccurences', + displayName: 'Show Previous Occurrences', + name: 'showPreviousOccurrences', type: 'boolean', default: '', - description: 'To view webinar details of all previous occurences of the recurring webinar.', + description: 'To view webinar details of all previous occurrences of the recurring webinar.', }, ], }, @@ -386,7 +386,7 @@ export const webinarFields = [ description: 'How many results to return.', }, /* -------------------------------------------------------------------------- */ - /* webina:delete */ + /* webinar:delete */ /* -------------------------------------------------------------------------- */ { displayName: 'Webinar Id', @@ -424,11 +424,11 @@ export const webinarFields = [ }, options: [ { - displayName: 'Occurence Id', - name: 'occurenceId', + displayName: 'Occurrence Id', + name: 'occurrenceId', type: 'string', default: '', - description: 'Webinar occurence Id.', + description: 'Webinar occurrence Id.', }, ], @@ -566,11 +566,11 @@ export const webinarFields = [ description: 'Start video when host joins the webinar.', }, { - displayName: 'Occurence Id', - name: 'occurence_id', + displayName: 'Occurrence Id', + name: 'occurrence_id', type: 'string', default: '', - description: `Webinar occurence Id.`, + description: `Webinar occurrence Id.`, }, { displayName: 'Password', @@ -599,27 +599,27 @@ export const webinarFields = [ type: 'options', options: [ { - name: 'Attendees register once and can attend any of the occurences', + name: 'Attendees register once and can attend any of the occurrences', value: 1, }, { - name: 'Attendees need to register for every occurence', + name: 'Attendees need to register for every occurrence', value: 2, }, { - name: 'Attendees register once and can choose one or more occurences to attend', + name: 'Attendees register once and can choose one or more occurrences to attend', value: 3, }, ], default: 1, - description: 'Registration type. Used for recurring webinars with fixed time only', + description: 'Registration type. Used for recurring webinars with fixed time only.', }, { displayName: 'Start time', name: 'startTime', type: 'dateTime', default: '', - description: 'Start time should be used only for scheduled or recurring webinar with fixed time', + description: 'Start time should be used only for scheduled or recurring webinar with fixed time.', }, { displayName: 'Timezone', diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index 078da4879c..9e0cf88889 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -177,18 +177,17 @@ export class Zoom implements INodeType { if (operation === 'get') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meeting const meetingId = this.getNodeParameter('meetingId', i) as string; - const additionalFields = this.getNodeParameter( 'additionalFields', i ) as IDataObject; - if (additionalFields.showPreviousOccurences) { - qs.show_previous_occurences = additionalFields.showPreviousOccurences as boolean; + if (additionalFields.showPreviousOccurrences) { + qs.show_previous_occurrences = additionalFields.showPreviousOccurrences as boolean; } - if (additionalFields.occurenceId) { - qs.occurence_id = additionalFields.occurenceId as string; + if (additionalFields.occurrenceId) { + qs.occurrence_id = additionalFields.occurrenceId as string; } @@ -226,8 +225,8 @@ export class Zoom implements INodeType { } - if (additionalFields.occurenceId) { - qs.occurence_id = additionalFields.occurenceId; + if (additionalFields.occurrenceId) { + qs.occurrence_id = additionalFields.occurrenceId; } @@ -363,8 +362,8 @@ export class Zoom implements INodeType { i ) as IDataObject; - if (additionalFields.occurenceId) { - qs.occurence_id = additionalFields.occurenceId as string; + if (additionalFields.occurrenceId) { + qs.occurrence_id = additionalFields.occurrenceId as string; } if (additionalFields.cn_meeting) { @@ -488,8 +487,8 @@ export class Zoom implements INodeType { 'additionalFields', i ) as IDataObject; - if (additionalFields.occurenceId) { - qs.occurence_ids = additionalFields.occurenceId as string; + if (additionalFields.occurrenceId) { + qs.occurrence_ids = additionalFields.occurrenceId as string; } if (additionalFields.lastName) { body.last_name = additionalFields.lastName as string; @@ -542,8 +541,8 @@ export class Zoom implements INodeType { 'additionalFields', i ) as IDataObject; - if (additionalFields.occurenceId) { - qs.occurence_id = additionalFields.occurenceId as string; + if (additionalFields.occurrenceId) { + qs.occurrence_id = additionalFields.occurrenceId as string; } if (additionalFields.status) { qs.status = additionalFields.status as string; @@ -567,7 +566,7 @@ export class Zoom implements INodeType { i ) as IDataObject; if (additionalFields.occurenceId) { - qs.occurence_id = additionalFields.occurenceId as string; + qs.occurrence_id = additionalFields.occurrenceId as string; } responseData = await zoomApiRequest.call( this, @@ -676,13 +675,13 @@ export class Zoom implements INodeType { 'additionalFields', i ) as IDataObject; - if (additionalFields.showPreviousOccurences) { - qs.show_previous_occurences = additionalFields.showPreviousOccurences as boolean; + if (additionalFields.showPreviousOccurrences) { + qs.show_previous_occurrences = additionalFields.showPreviousOccurrences as boolean; } - if (additionalFields.occurenceId) { - qs.occurence_id = additionalFields.occurenceId as string; + if (additionalFields.occurrenceId) { + qs.occurrence_id = additionalFields.occurrenceId as string; } @@ -716,8 +715,8 @@ export class Zoom implements INodeType { ) as IDataObject; - if (additionalFields.occurenceId) { - qs.occurence_id = additionalFields.occurenceId; + if (additionalFields.occurrenceId) { + qs.occurrence_id = additionalFields.occurrenceId; } @@ -737,8 +736,8 @@ export class Zoom implements INodeType { 'additionalFields', i ) as IDataObject; - if (additionalFields.occurence_id) { - qs.occurence_id = additionalFields.occurence_id as string; + if (additionalFields.occurrence_id) { + qs.occurrence_id = additionalFields.occurrence_id as string; } const settings: Settings = {}; From 83828a19abda3945196523fc698812d2bfa9bd02 Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Tue, 23 Jun 2020 14:26:00 -0700 Subject: [PATCH 025/155] follow codebase conventions --- .../nodes-base/nodes/Zoom/GenericFunctions.ts | 1 - .../nodes/Zoom/MeetingDescription.ts | 16 +++++------ .../Zoom/MeetingRegistrantDescription.ts | 12 ++++---- .../nodes/Zoom/WebinarDescription.ts | 28 +++++++++---------- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 8 +++--- 5 files changed, 32 insertions(+), 33 deletions(-) diff --git a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts index 74b5a18d06..500b0a64d9 100644 --- a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts @@ -14,7 +14,6 @@ import { export async function zoomApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: object = {}, query: object = {}, headers: {} | undefined = undefined, option: {} = {}): Promise { // tslint:disable-line:no-any - // tslint:disable-line:no-any const authenticationMethod = this.getNodeParameter('authentication', 0, 'accessToken') as string; let options: OptionsWithUri = { diff --git a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts index 4d8281660d..829418d12b 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts @@ -51,7 +51,7 @@ export const meetingFields = [ /* meeting:create */ /* -------------------------------------------------------------------------- */ { - displayName: 'User Id', + displayName: 'User ID', name: 'userId', type: 'string', default: '', @@ -258,7 +258,7 @@ export const meetingFields = [ name: 'scheduleFor', type: 'string', default: '', - description: 'Schedule meeting for someone else from your account, provide their email id.', + description: 'Schedule meeting for someone else from your account, provide their email ID.', }, { displayName: 'Start time', @@ -290,7 +290,7 @@ export const meetingFields = [ /* meeting:get */ /* -------------------------------------------------------------------------- */ { - displayName: 'Meeting Id', + displayName: 'Meeting ID', name: 'meetingId', type: 'string', default: '', @@ -345,7 +345,7 @@ export const meetingFields = [ /* meeting:getAll */ /* -------------------------------------------------------------------------- */ { - displayName: 'User Id', + displayName: 'User ID', name: 'userId', type: 'string', default: '', @@ -448,7 +448,7 @@ export const meetingFields = [ /* meeting:delete */ /* -------------------------------------------------------------------------- */ { - displayName: 'Meeting Id', + displayName: 'Meeting ID', name: 'meetingId', type: 'string', default: '', @@ -549,7 +549,7 @@ export const meetingFields = [ name: 'alternative_hosts', type: 'string', default: '', - description: 'Alternative hosts email ids.', + description: 'Alternative hosts email IDs.', }, { displayName: 'Audio', @@ -630,7 +630,7 @@ export const meetingFields = [ description: 'Start video when host joins the meeting.', }, { - displayName: 'Occurrence Id', + displayName: 'Occurrence ID', name: 'occurrenceId', type: 'string', default: '', @@ -716,7 +716,7 @@ export const meetingFields = [ name: 'scheduleFor', type: 'string', default: '', - description: 'Schedule meeting for someone else from your account, provide their email id.', + description: 'Schedule meeting for someone else from your account, provide their email ID.', }, { displayName: 'Start time', diff --git a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts index 8cef3383aa..b9a5147364 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts @@ -155,7 +155,7 @@ export const meetingRegistrantFields = [ description: 'Last Name.', }, { - displayName: 'Occurrence Ids', + displayName: 'Occurrence IDs', name: 'occurrenceId', type: 'string', default: '', @@ -251,7 +251,7 @@ export const meetingRegistrantFields = [ /* meetingRegistrants:getAll */ /* -------------------------------------------------------------------------- */ { - displayName: 'Meeting Id', + displayName: 'Meeting ID', name: 'meetingId', type: 'string', default: '', @@ -328,11 +328,11 @@ export const meetingRegistrantFields = [ }, options: [ { - displayName: 'Occurrence Id', + displayName: 'Occurrence ID', name: 'occurrence_id', type: 'string', default: '', - description: `Occurrence Id.`, + description: `Occurrence ID.`, }, { displayName: 'Status', @@ -362,7 +362,7 @@ export const meetingRegistrantFields = [ /* meetingRegistrants:update */ /* -------------------------------------------------------------------------- */ { - displayName: 'Meeting Id', + displayName: 'Meeting ID', name: 'meetingId', type: 'string', default: '', @@ -429,7 +429,7 @@ export const meetingRegistrantFields = [ }, options: [ { - displayName: 'Occurrence Id', + displayName: 'Occurrence ID', name: 'occurrenceId', type: 'string', default: '', diff --git a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts index b673b77fa9..19796ea51a 100644 --- a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts +++ b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts @@ -51,7 +51,7 @@ export const webinarFields = [ /* webinar:create */ /* -------------------------------------------------------------------------- */ { - displayName: 'User Id', + displayName: 'User ID', name: 'userId', type: 'string', default: '', @@ -98,7 +98,7 @@ export const webinarFields = [ name: 'alternative_hosts', type: 'string', default: '', - description: 'Alternative hosts email ids.', + description: 'Alternative hosts email IDs.', }, { displayName: 'Approval type', @@ -272,7 +272,7 @@ export const webinarFields = [ /* webinar:get */ /* -------------------------------------------------------------------------- */ { - displayName: 'Webinar Id', + displayName: 'Webinar ID', name: 'webinarId', type: 'string', default: '', @@ -308,7 +308,7 @@ export const webinarFields = [ }, options: [ { - displayName: 'Occurence Id', + displayName: 'Occurence ID', name: 'occurenceId', type: 'string', default: '', @@ -327,7 +327,7 @@ export const webinarFields = [ /* webinar:getAll */ /* -------------------------------------------------------------------------- */ { - displayName: 'User Id', + displayName: 'User ID', name: 'userId', type: 'string', default: '', @@ -342,7 +342,7 @@ export const webinarFields = [ ], }, }, - description: 'User ID or email-id.', + description: 'User ID or email-ID.', }, { displayName: 'Return All', @@ -389,7 +389,7 @@ export const webinarFields = [ /* webinar:delete */ /* -------------------------------------------------------------------------- */ { - displayName: 'Webinar Id', + displayName: 'Webinar ID', name: 'webinarId', type: 'string', default: '', @@ -404,7 +404,7 @@ export const webinarFields = [ ], }, }, - description: 'WebinarId ID.', + description: 'Webinar ID.', }, { displayName: 'Additional Fields', @@ -424,11 +424,11 @@ export const webinarFields = [ }, options: [ { - displayName: 'Occurrence Id', + displayName: 'Occurrence ID', name: 'occurrenceId', type: 'string', default: '', - description: 'Webinar occurrence Id.', + description: 'Webinar occurrence ID.', }, ], @@ -438,7 +438,7 @@ export const webinarFields = [ /* webinar:update */ /* -------------------------------------------------------------------------- */ { - displayName: 'User Id', + displayName: 'User ID', name: 'userId', type: 'string', default: '', @@ -485,7 +485,7 @@ export const webinarFields = [ name: 'alternative_hosts', type: 'string', default: '', - description: 'Alternative hosts email ids.', + description: 'Alternative hosts email IDs.', }, { displayName: 'Approval type', @@ -566,11 +566,11 @@ export const webinarFields = [ description: 'Start video when host joins the webinar.', }, { - displayName: 'Occurrence Id', + displayName: 'Occurrence ID', name: 'occurrence_id', type: 'string', default: '', - description: `Webinar occurrence Id.`, + description: `Webinar occurrence ID.`, }, { displayName: 'Password', diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index 9e0cf88889..56901ac2ce 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -163,13 +163,13 @@ export class Zoom implements INodeType { async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: IDataObject[] = []; - const length = (items.length as unknown) as number; - let qs: IDataObject; + let qs: IDataObject = {}; + let body: IDataObject = {}; let responseData; const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; - let body: IDataObject = {}; - for (let i = 0; i < length; i++) { + + for (let i = 0; i < items.length; i++) { qs = {}; //https://marketplace.zoom.us/docs/api-reference/zoom-api/ if (resource === 'meeting') { From 69a1f8af00f6bd64795c709fd25e407cfc28c0e5 Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Tue, 23 Jun 2020 20:29:47 -0700 Subject: [PATCH 026/155] fix pagination --- .../credentials/ZoomOAuth2Api.credentials.ts | 16 ++++---------- .../nodes-base/nodes/Zoom/GenericFunctions.ts | 18 ++++++++++----- .../nodes/Zoom/MeetingDescription.ts | 22 +++++++++---------- .../Zoom/MeetingRegistrantDescription.ts | 16 +++++++------- .../nodes/Zoom/WebinarDescription.ts | 14 ++++++------ packages/nodes-base/nodes/Zoom/Zoom.node.ts | 16 ++++++++++---- 6 files changed, 55 insertions(+), 47 deletions(-) diff --git a/packages/nodes-base/credentials/ZoomOAuth2Api.credentials.ts b/packages/nodes-base/credentials/ZoomOAuth2Api.credentials.ts index c00c694661..f85cc75254 100644 --- a/packages/nodes-base/credentials/ZoomOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/ZoomOAuth2Api.credentials.ts @@ -1,14 +1,7 @@ -import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; - -const userScopes = [ - 'meeting:read', - 'meeting:write', - 'user:read', - 'user:write', - 'user_profile', - 'webinar:read', - 'webinar:write' -]; +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; export class ZoomOAuth2Api implements ICredentialType { name = 'zoomOAuth2Api'; @@ -27,7 +20,6 @@ export class ZoomOAuth2Api implements ICredentialType { type: 'hidden' as NodePropertyTypes, default: 'https://zoom.us/oauth/token' }, - { displayName: 'Scope', name: 'scope', diff --git a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts index 500b0a64d9..6a8593e3ab 100644 --- a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts @@ -75,6 +75,7 @@ export async function zoomApiRequestAllItems( // tslint:disable-line:no-any const returnData: IDataObject[] = []; let responseData; + query.page_number = 0; do { responseData = await zoomApiRequest.call( this, @@ -83,14 +84,21 @@ export async function zoomApiRequestAllItems( body, query ); - query.page_number = responseData['page_number']; + query.page_number++; returnData.push.apply(returnData, responseData[propertyName]); + // zoom free plan rate limit is 1 request/second + // TODO just wait when the plan is free + await wait(); } while ( - responseData['page_number'] !== undefined && - responseData['page_number'] !== '' + responseData.page_count !== responseData.page_number ); return returnData; } - - +function wait() { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve(true); + }, 1000); + }); +} diff --git a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts index 829418d12b..5a8a5966a9 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts @@ -39,7 +39,7 @@ export const meetingOperations = [ name: 'Update', value: 'update', description: 'Update a meeting', - } + }, ], default: 'create', description: 'The operation to perform.', @@ -69,7 +69,7 @@ export const meetingFields = [ description: 'User ID or email address of user.', }, { - displayName: 'Additional settings', + displayName: 'Additional fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -83,7 +83,7 @@ export const meetingFields = [ resource: [ 'meeting', ], - } + }, }, options: [ { @@ -209,7 +209,7 @@ export const meetingFields = [ ], default: 2, - description: 'Meeting type.' + description: 'Meeting type.', }, { displayName: 'Muting before entry', @@ -219,7 +219,7 @@ export const meetingFields = [ description: 'Mute participants upon entry.', }, { - displayName: 'Participant Video', + displayName: 'Participant video', name: 'participant_video', type: 'boolean', default: false, @@ -308,7 +308,7 @@ export const meetingFields = [ description: 'Meeting ID.', }, { - displayName: 'Additional settings', + displayName: 'Additional fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -398,13 +398,13 @@ export const meetingFields = [ }, typeOptions: { minValue: 1, - maxValue: 300 + maxValue: 300, }, default: 30, description: 'How many results to return.', }, { - displayName: 'Additional settings', + displayName: 'Additional fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -456,7 +456,7 @@ export const meetingFields = [ displayOptions: { show: { operation: [ - 'delete' + 'delete', ], resource: [ 'meeting', @@ -521,7 +521,7 @@ export const meetingFields = [ description: 'Meeting ID.', }, { - displayName: 'Additional settings', + displayName: 'Additional fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -667,7 +667,7 @@ export const meetingFields = [ ], default: 2, - description: 'Meeting type.' + description: 'Meeting type.', }, { displayName: 'Muting before entry', diff --git a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts index b9a5147364..b7e5271f55 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts @@ -95,7 +95,7 @@ export const meetingRegistrantFields = [ description: 'First Name.', }, { - displayName: 'Additional settings', + displayName: 'Additional fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -109,7 +109,7 @@ export const meetingRegistrantFields = [ resource: [ 'meetingRegistrants', ], - } + }, }, options: [ { @@ -202,7 +202,7 @@ export const meetingRegistrantFields = [ }, ], default: '', - description: 'Meeting type.' + description: 'Meeting type.', }, { displayName: 'Role in purchase process', @@ -228,7 +228,7 @@ export const meetingRegistrantFields = [ ], default: '', - description: 'Role in purchase process.' + description: 'Role in purchase process.', }, { displayName: 'State', @@ -304,13 +304,13 @@ export const meetingRegistrantFields = [ }, typeOptions: { minValue: 1, - maxValue: 300 + maxValue: 300, }, default: 30, description: 'How many results to return.', }, { - displayName: 'Additional settings', + displayName: 'Additional fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -324,7 +324,7 @@ export const meetingRegistrantFields = [ resource: [ 'meetingRegistrants', ], - } + }, }, options: [ { @@ -412,7 +412,7 @@ export const meetingRegistrantFields = [ description: `Registrant Status.`, }, { - displayName: 'Additional settings', + displayName: 'Additional fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', diff --git a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts index 19796ea51a..228cf9f1ae 100644 --- a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts +++ b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts @@ -69,7 +69,7 @@ export const webinarFields = [ description: 'User ID or email address of user.', }, { - displayName: 'Additional settings', + displayName: 'Additional fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -263,7 +263,7 @@ export const webinarFields = [ }, ], default: 5, - description: 'Webinar type.' + description: 'Webinar type.', }, ], @@ -290,7 +290,7 @@ export const webinarFields = [ description: 'Webinar ID.', }, { - displayName: 'Additional settings', + displayName: 'Additional fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -380,7 +380,7 @@ export const webinarFields = [ }, typeOptions: { minValue: 1, - maxValue: 300 + maxValue: 300, }, default: 30, description: 'How many results to return.', @@ -397,7 +397,7 @@ export const webinarFields = [ displayOptions: { show: { operation: [ - 'delete' + 'delete', ], resource: [ 'webinarId', @@ -456,7 +456,7 @@ export const webinarFields = [ description: 'User ID or email address of user.', }, { - displayName: 'Additional settings', + displayName: 'Additional fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -470,7 +470,7 @@ export const webinarFields = [ resource: [ 'webinar', ], - } + }, }, options: [ { diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index 56901ac2ce..18e3614cc4 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -203,13 +203,21 @@ export class Zoom implements INodeType { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetings const userId = this.getNodeParameter('userId', i) as string; const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + if (additionalFields.type) { + qs.type = additionalFields.type as string; + + } if (returnAll) { - responseData = await zoomApiRequestAllItems.call(this, 'results', 'GET', `/users/${userId}/meetings`, {}, qs); + responseData = await zoomApiRequestAllItems.call(this, 'meetings', 'GET', `/users/${userId}/meetings`, {}, qs); } else { - const limit = this.getNodeParameter('limit', i) as number; - qs.page_size = limit; + qs.page_size = this.getNodeParameter('limit', i) as number;; responseData = await zoomApiRequest.call(this, 'GET', `/users/${userId}/meetings`, {}, qs); - responseData = responseData.results; + } } From 5d98f5673f7d7637b3944dd63135853b6bd91ddc Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Tue, 23 Jun 2020 20:53:49 -0700 Subject: [PATCH 027/155] fix specific results for registrants --- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index 18e3614cc4..f33a2a338e 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -215,7 +215,7 @@ export class Zoom implements INodeType { if (returnAll) { responseData = await zoomApiRequestAllItems.call(this, 'meetings', 'GET', `/users/${userId}/meetings`, {}, qs); } else { - qs.page_size = this.getNodeParameter('limit', i) as number;; + qs.page_size = this.getNodeParameter('limit', i) as number; responseData = await zoomApiRequest.call(this, 'GET', `/users/${userId}/meetings`, {}, qs); } @@ -559,10 +559,9 @@ export class Zoom implements INodeType { if (returnAll) { responseData = await zoomApiRequestAllItems.call(this, 'results', 'GET', `/meetings/${meetingId}/registrants`, {}, qs); } else { - const limit = this.getNodeParameter('limit', i) as number; - qs.page_size = limit; + qs.page_size = this.getNodeParameter('limit', i) as number; responseData = await zoomApiRequest.call(this, 'GET', `/meetings/${meetingId}/registrants`, {}, qs); - responseData = responseData.results; + } } @@ -708,10 +707,9 @@ export class Zoom implements INodeType { if (returnAll) { responseData = await zoomApiRequestAllItems.call(this, 'results', 'GET', `/users/${userId}/webinars`, {}, qs); } else { - const limit = this.getNodeParameter('limit', i) as number; - qs.page_size = limit; + qs.page_size = this.getNodeParameter('limit', i) as number; responseData = await zoomApiRequest.call(this, 'GET', `/users/${userId}/webinars`, {}, qs); - responseData = responseData.results; + } } if (operation === 'delete') { 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 028/155] :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 0000000000..56f6d8aef6 --- /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 0000000000..91edf0679d --- /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 37b9adecf2..c27f5fa98b 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 55ed53579f9b7c22e818c49eead34c5cffceeb8f Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Wed, 24 Jun 2020 15:46:56 +0200 Subject: [PATCH 029/155] Postmark trigger --- .../credentials/PostmarkApi.credentials.ts | 18 ++ .../nodes/Postmark/GenericFunctions.ts | 47 +++ .../nodes/Postmark/PostmarkTrigger.node.ts | 275 ++++++++++++++++++ .../nodes-base/nodes/Postmark/postmark.png | Bin 0 -> 3301 bytes packages/nodes-base/package.json | 2 + 5 files changed, 342 insertions(+) create mode 100644 packages/nodes-base/credentials/PostmarkApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Postmark/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Postmark/postmark.png diff --git a/packages/nodes-base/credentials/PostmarkApi.credentials.ts b/packages/nodes-base/credentials/PostmarkApi.credentials.ts new file mode 100644 index 0000000000..88df53aa30 --- /dev/null +++ b/packages/nodes-base/credentials/PostmarkApi.credentials.ts @@ -0,0 +1,18 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class PostmarkApi implements ICredentialType { + name = 'postmarkApi'; + displayName = 'Postmark API'; + properties = [ + { + displayName: 'Server Token', + name: 'serverToken', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Postmark/GenericFunctions.ts b/packages/nodes-base/nodes/Postmark/GenericFunctions.ts new file mode 100644 index 0000000000..d65777d881 --- /dev/null +++ b/packages/nodes-base/nodes/Postmark/GenericFunctions.ts @@ -0,0 +1,47 @@ +import { + OptionsWithUri, + } from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + IHookFunctions, + IWebhookFunctions +} from 'n8n-workflow'; + + +export async function postmarkApiRequest(this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method : string, endpoint : string, body: any = {}, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('postmarkApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + let options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Postmark-Server-Token' : credentials.serverToken + }, + method, + body, + uri: 'https://api.postmarkapp.com' + endpoint, + json: true + }; + if (body === {}) { + delete options.body; + } + options = Object.assign({}, options, option); + + try { + return await this.helpers.request!(options); + } catch (error) { + throw new Error(`Postmark: ${error.statusCode} Message: ${error.message}`); + } +} + + diff --git a/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts b/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts new file mode 100644 index 0000000000..15af530cad --- /dev/null +++ b/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts @@ -0,0 +1,275 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + INodeTypeDescription, + INodeType, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + postmarkApiRequest, +} from './GenericFunctions'; + +export class PostmarkTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Postmark Trigger', + name: 'postmarkTrigger', + icon: 'file:postmark.png', + group: ['trigger'], + version: 1, + description: 'Starts the workflow when Postmark events occur.', + defaults: { + name: 'Postmark Trigger', + color: '#fedd00', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'postmarkApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Open', + name: 'open', + type: 'boolean', + default: false, + description: 'Listing for if the Open webhook is enabled/disabled.', + }, + { + displayName: 'First Open Only', + name: 'postFirstOpenOnly', + type: 'boolean', + default: false, + displayOptions: { + show: { + open: [ + true + ], + }, + }, + description: 'Webhook will only post on first open if enabled.', + }, + { + displayName: 'Click', + name: 'click', + type: 'boolean', + default: false, + description: 'Listing for if the Click webhook is enabled/disabled.', + }, + { + displayName: 'Delivery', + name: 'delivery', + type: 'boolean', + default: false, + description: 'Listing for if the Delivery webhook is enabled/disabled.', + }, + { + displayName: 'Bounce', + name: 'bounce', + type: 'boolean', + default: false, + description: 'Listing for if the Bounce webhook is enabled/disabled.', + }, + { + displayName: 'Bounce Include Content', + name: 'bounceIncludeContent', + type: 'boolean', + default: false, + displayOptions: { + show: { + bounce: [ + true + ], + }, + }, + description: 'Webhook will send full bounce content if IncludeContent is enabled.', + }, + { + displayName: 'Spam Complaint', + name: 'spamComplaint', + type: 'boolean', + default: false, + description: 'Listing for if the Spam webhook is enabled/disabled.', + }, + { + displayName: 'Spam Complaint Include Content', + name: 'spamComplaintIncludeContent', + type: 'boolean', + default: false, + displayOptions: { + show: { + spamComplaint: [ + true + ], + }, + }, + description: 'Webhook will send full spam content if IncludeContent is enabled.', + }, + { + displayName: 'Subscription Change', + name: 'subscriptionChange', + type: 'boolean', + default: false, + description: 'Listing for if the Subscription Change webhook is enabled/disabled.', + }, + ], + + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + + if (webhookData.webhookId === undefined) { + // No webhook id is set so no webhook can exist + return false; + } + + // Webhook got created before so check if it still exists + const endpoint = `/webhooks/${webhookData.webhookId}`; + + const responseData = await postmarkApiRequest.call(this, 'GET', endpoint, {}); + + if (responseData.ID === undefined) { + return false; + } + else if (responseData.ID === webhookData.id) { + return true; + } + return false; + }, + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + + const endpoint = `/webhooks`; + + // tslint:disable-next-line: no-any + const body : any = { + Url: webhookUrl, + Triggers: { + Open:{ + Enabled: false, + PostFirstOpenOnly: false + }, + Click:{ + Enabled: false + }, + Delivery:{ + Enabled: false + }, + Bounce:{ + Enabled: false, + IncludeContent: false + }, + SpamComplaint:{ + Enabled: false, + IncludeContent: false + }, + SubscriptionChange: { + Enabled: false + } + } + }; + + const open = this.getNodeParameter('open', 0); + const postFirstOpenOnly = this.getNodeParameter('postFirstOpenOnly', 0); + const click = this.getNodeParameter('click', 0); + const delivery = this.getNodeParameter('delivery', 0); + const bounce = this.getNodeParameter('bounce', 0); + const bounceIncludeContent = this.getNodeParameter('bounceIncludeContent', 0); + const spamComplaint = this.getNodeParameter('spamComplaint', 0); + const spamComplaintIncludeContent = this.getNodeParameter('spamComplaintIncludeContent', 0); + const subscriptionChange = this.getNodeParameter('subscriptionChange', 0); + + if (open) { + body.Triggers.Open.Enabled = true; + + if (postFirstOpenOnly) { + body.Triggers.Open.PostFirstOpenOnly = true; + } + } + if (click) { + body.Triggers.Click.Enabled = true; + } + if (delivery) { + body.Triggers.Delivery.Enabled = true; + } + if (bounce) { + body.Triggers.Bounce.Enabled = true; + + if (bounceIncludeContent) { + body.Triggers.Bounce.IncludeContent = true; + } + } + if (spamComplaint) { + body.Triggers.SpamComplaint.Enabled = true; + + if (spamComplaintIncludeContent) { + body.Triggers.SpamComplaint.IncludeContent = true; + } + } + if (subscriptionChange) { + body.Triggers.SubscriptionChange.Enabled = true; + } + + const responseData = await postmarkApiRequest.call(this, 'POST', endpoint, body); + + if (responseData.ID === undefined) { + // Required data is missing so was not successful + return false; + } + + const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = responseData.ID as string; + + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + + if (webhookData.webhookId !== undefined) { + const endpoint = `/webhooks/${webhookData.webhookId}`; + const body = {}; + + try { + await postmarkApiRequest.call(this, 'DELETE', endpoint, body); + } catch (e) { + return false; + } + + // Remove from the static workflow data so that it is clear + // that no webhooks are registred anymore + delete webhookData.webhookId; + delete webhookData.webhookEvents; + } + + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const req = this.getRequestObject(); + return { + workflowData: [ + this.helpers.returnJsonArray(req.body) + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Postmark/postmark.png b/packages/nodes-base/nodes/Postmark/postmark.png new file mode 100644 index 0000000000000000000000000000000000000000..44f90b2a6c25f66c011e849512dbbbea5f128dfa GIT binary patch literal 3301 zcmcInc~lek7LKec0xB+`h%v4RlVq|%0s#vcpcQE>sZ`X+3z=bg6Cf8LxkGryVNcfb4HW$rn% z*Wb_EeCDE=7!1bT*T*XWy}Rnq=_crv@$Qx!z0HvMY*t_}W>)%h8s^w{)) zIUJba7sO47=F)jMcQ>r7nt>8XU?qfAOT zfG%geT(1e+-n(X81}KN=V9ueZ81CR1{t$;xVXP zkVcP$A^~SEOaWjrg$K|fQX~M8xFisUBFT{uZ%h|z?}cz<_36{wkGCxk;i4Q<8Dh{$ zFi0lDAOM0+bb!XC5CMo!rU4WZnH0;AVU;XpA#Phcp#rd1wjx3og;Br9+$yK>G&E(@1nFeWv=UY`UB?>)?j-neA_ zyvvvO?fW_>7q_1iCbpu8idHR>_&zZ`_UV4MX5EciWb3oEoE+;)k$9fxo`T}rrq{A^ z76n8H3g=igdj;ZZ=bG<5dHk^VfT6u?Fx;-f^=i!fq0qSY0DR&bg9?``5P|G+zE>r9 z-9RTRY`9Nc7e>E%dbf3WxFq)B)%nqvzgn{3MIn#R=QIn0d_qFfKQp1ryZ40uoRw9z zroOSUnC0!gn?j{t=)-MFfAG1$=C((MaB09cBMh}`IxCYaZ(hH? ze5Pmif!RMbHu^RDNF-;5JI^`x4Gu1}WSu&F`llB!Gy=_rc7q>t)Ej;Fl-J*Nc<*m6F|9x}f zZgPED$H*&lmP;pJS$O!fjv2=6ndQ`^2x5x4#-QZXsjC4C9P!%qALJJrgbynkzS>Yx z7r0qtk?!zl&v#)Ds2raNqr4@+#+9#%^E@lh{tA{e4a$D;l;)qg zbcvN1TW#sUO!#!|=&z=$s?OhXbzfU)8|M4|=@+AEmJfDnT6rbel@5m_I$m9lVQU@{ zRYK(0+uPr0X~DH7-#6$vdJHeWu;Pg8%iP>|icNWT{k<*PZg)D#D2Sj<4bF(Q3(NcR z^=70h!eFuWeo^kplXY$J&PVNNUif#ta)dk^(%OCrO-u} z(3zai+U^~3_EuGVe0*?hY*9t1Q$ba4$u4W=$jQxvU6=2ryt;2^T^$uBVhVIF#N^7Q z$+f57++2Mxg?b1CsxQ2Das7@e%XkuQ^kTz;j8{py-6K}dcc(_}ZHsL0yO#H1sI_Lh z$CT6`G+wZuIhC2p&-g4_L^ry^eE0E|G>b?(bGFp11m|K zQr^Cy=bE+185F}1MuDLK-3tFG?;khFUf>o>mr_vV_ui*VuZRF-|n^I}eN zk7R$Yut9soBqN%0%&hD1VjJM%&ia8v@|qtX|NhvrGoYel!58XB2AO%wmgG6!KR2|^ zI1yhw?A)`Ana=9xe3M|Njg7Xh7+I?&@3&}g9j@`L5$YZmzN}@i+=CTOk2kQ2=4+C= z+J3mmfB80s#bh6HD|z<)rlxoI5{@11X?d=W>8EUPS-y6=bJN3yC+h?QYR46Aq&aD7 ze4BikvE^P)5oJfJv6bDb+3oY~MvF07A%wCO#2;-}KWk-2*}N$)J#n|=?V>h=?Z^gK ziTvWcO9CH*r4CLhLxma6r=HSR8-DOIC_Ad&8eZG~VCa#9h6cwtee+fqO=fqM#Yg;b z{@doB{Vy^*zk0!_*=wBn+CPm@U$|xDrbDEPHrqxwuXhx*TwGt6M5rw&b&u;pdNA!p zvj_WwHx%4)yP`YTfBpIl+XLl*=KaZozd^W^@G#(_G^gm)aGckd%C>pd`DXt@jt`f9 z+tVAD>ulL#qb+Qzi4%3vY7JH0%Ven>1C@A2YSHVbcQam^UawkXkz@Sy5rnl&H|ZOe wby@5&eYzTx^7f#2SJEY`lBH3`11HoU3?)ewA9`$Vj{S)EuJ`lG_lVf>Z!h~rQvd(} literal 0 HcmV?d00001 diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a09d97d032..99d390ee09 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -107,6 +107,7 @@ "dist/credentials/PayPalApi.credentials.js", "dist/credentials/PipedriveApi.credentials.js", "dist/credentials/Postgres.credentials.js", + "dist/credentials/PostmarkApi.credentials.js", "dist/credentials/Redis.credentials.js", "dist/credentials/RocketchatApi.credentials.js", "dist/credentials/RundeckApi.credentials.js", @@ -248,6 +249,7 @@ "dist/nodes/Pipedrive/Pipedrive.node.js", "dist/nodes/Pipedrive/PipedriveTrigger.node.js", "dist/nodes/Postgres/Postgres.node.js", + "dist/nodes/Postmark/PostmarkTrigger.node.js", "dist/nodes/ReadBinaryFile.node.js", "dist/nodes/ReadBinaryFiles.node.js", "dist/nodes/ReadPdf.node.js", From c1b4c570fdd5020a6b7ed161988816b8a8aebf20 Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Wed, 24 Jun 2020 16:02:44 +0200 Subject: [PATCH 030/155] re-added basicAuth for authentication --- .../nodes/Pipedrive/GenericFunctions.ts | 2 +- .../nodes/Pipedrive/Pipedrive.node.ts | 14 +++++++++----- .../nodes/Pipedrive/PipedriveTrigger.node.ts | 18 +++++++++++------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts index 9809d0feb3..59ba514acf 100644 --- a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts @@ -62,7 +62,7 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio let responseData; try { - if (authenticationMethod === 'accessToken') { + if (authenticationMethod === 'basicAuth') { const credentials = this.getCredentials('pipedriveApi'); if (credentials === undefined) { diff --git a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts index 3aef48ce59..48a619a331 100644 --- a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts @@ -64,7 +64,7 @@ export class Pipedrive implements INodeType { displayOptions: { show: { authentication: [ - 'accessToken', + 'basicAuth', ], }, }, @@ -88,16 +88,20 @@ export class Pipedrive implements INodeType { type: 'options', options: [ { - name: 'Access Token', - value: 'accessToken', + name: 'Basic Auth', + value: 'basicAuth' }, { name: 'OAuth2', value: 'oAuth2', }, + { + name: 'None', + value: 'none', + }, ], - default: 'accessToken', - description: 'The resource to operate on.', + default: 'basicAuth', + description: 'Method of authentication.', }, { displayName: 'Resource', diff --git a/packages/nodes-base/nodes/Pipedrive/PipedriveTrigger.node.ts b/packages/nodes-base/nodes/Pipedrive/PipedriveTrigger.node.ts index 0fb3d37f99..ca4a1a7ec8 100644 --- a/packages/nodes-base/nodes/Pipedrive/PipedriveTrigger.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/PipedriveTrigger.node.ts @@ -55,7 +55,7 @@ export class PipedriveTrigger implements INodeType { displayOptions: { show: { authentication: [ - 'accessToken', + 'basicAuth', ], }, }, @@ -72,6 +72,7 @@ export class PipedriveTrigger implements INodeType { }, }, ], + ], webhooks: [ { name: 'default', @@ -87,17 +88,20 @@ export class PipedriveTrigger implements INodeType { type: 'options', options: [ { - name: 'Access Token', - value: 'accessToken' + name: 'Basic Auth', + value: 'basicAuth' }, { name: 'OAuth2', - value: 'oAuth2' + value: 'oAuth2', + }, + { + name: 'None', + value: 'none', }, - ], - default: 'accessToken', - description: 'If authentication should be activated for the webhook (makes it more scure).', + default: 'basicAuth', + description: 'Method of authentication.', }, { displayName: 'Action', From e3cf858ebc64c78fdf7c3ce5ef4f0772a45f61ba Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Wed, 24 Jun 2020 10:47:35 -0700 Subject: [PATCH 031/155] fix naming conventions --- .../nodes/Zoom/MeetingDescription.ts | 52 +++++++++--------- .../Zoom/MeetingRegistrantDescription.ts | 20 +++---- .../nodes/Zoom/WebinarDescription.ts | 54 +++++++++---------- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 2 +- 4 files changed, 64 insertions(+), 64 deletions(-) diff --git a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts index 5a8a5966a9..d335901bdf 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts @@ -69,7 +69,7 @@ export const meetingFields = [ description: 'User ID or email address of user.', }, { - displayName: 'Additional fields', + displayName: 'Additional Fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -101,7 +101,7 @@ export const meetingFields = [ description: 'Alternative hosts email IDs.', }, { - displayName: 'Auto recording', + displayName: 'Auto Recording', name: 'auto_recording', type: 'options', options: [ @@ -179,14 +179,14 @@ export const meetingFields = [ description: 'Allow participants to join the meeting before host starts it.', }, { - displayName: 'Meeting topic', + displayName: 'Meeting Topic', name: 'topic', type: 'string', default: '', description: `Meeting topic.`, }, { - displayName: 'Meeting type', + displayName: 'Meeting Type', name: 'type', type: 'options', options: [ @@ -199,11 +199,11 @@ export const meetingFields = [ value: 2, }, { - name: 'Recurring meeting with no fixed time', + name: 'Recurring Meeting with no fixed time', value: 3, }, { - name: 'Recurring meeting with no fixed time', + name: 'Recurring Meeting with fixed time', value: 8, }, @@ -219,7 +219,7 @@ export const meetingFields = [ description: 'Mute participants upon entry.', }, { - displayName: 'Participant video', + displayName: 'Participant Video', name: 'participant_video', type: 'boolean', default: false, @@ -233,7 +233,7 @@ export const meetingFields = [ description: 'Password to join the meeting with maximum 10 characters.', }, { - displayName: 'Registration type', + displayName: 'Registration Type', name: 'registration_type', type: 'options', options: [ @@ -254,14 +254,14 @@ export const meetingFields = [ description: 'Registration type. Used for recurring meetings with fixed time only', }, { - displayName: 'Schedule for', + displayName: 'Schedule For', name: 'scheduleFor', type: 'string', default: '', description: 'Schedule meeting for someone else from your account, provide their email ID.', }, { - displayName: 'Start time', + displayName: 'Start Time', name: 'startTime', type: 'dateTime', default: '', @@ -308,7 +308,7 @@ export const meetingFields = [ description: 'Meeting ID.', }, { - displayName: 'Additional fields', + displayName: 'Additional Fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -404,7 +404,7 @@ export const meetingFields = [ description: 'How many results to return.', }, { - displayName: 'Additional fields', + displayName: 'Additional Fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -490,7 +490,7 @@ export const meetingFields = [ description: 'Meeting occurrence ID.', }, { - displayName: 'Schedule a reminder', + displayName: 'Schedule Reminder', name: 'scheduleForReminder', type: 'boolean', default: false, @@ -521,7 +521,7 @@ export const meetingFields = [ description: 'Meeting ID.', }, { - displayName: 'Additional fields', + displayName: 'Additional Fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -573,16 +573,16 @@ export const meetingFields = [ description: 'Determine how participants can join audio portion of the meeting.', }, { - displayName: 'Auto recording', + displayName: 'Auto Recording', name: 'auto_recording', type: 'options', options: [ { - name: 'Record on local', + name: 'Record on Local', value: 'local', }, { - name: 'Record on cloud', + name: 'Record on Cloud', value: 'cloud', }, { @@ -602,7 +602,7 @@ export const meetingFields = [ description: 'Duration.', }, { - displayName: 'Join before Host', + displayName: 'Join Before Host', name: 'join_before_host', type: 'boolean', default: false, @@ -637,14 +637,14 @@ export const meetingFields = [ description: 'Occurrence ID.', }, { - displayName: 'Meeting topic', + displayName: 'Meeting Topic', name: 'topic', type: 'string', default: '', description: `Meeting topic.`, }, { - displayName: 'Meeting type', + displayName: 'Meeting Type', name: 'type', type: 'options', options: [ @@ -657,11 +657,11 @@ export const meetingFields = [ value: 2, }, { - name: 'Recurring meeting with no fixed time', + name: 'Recurring Meeting with no fixed time', value: 3, }, { - name: 'Recurring meeting with no fixed time', + name: 'Recurring Meeting with fixed time', value: 8, }, @@ -670,7 +670,7 @@ export const meetingFields = [ description: 'Meeting type.', }, { - displayName: 'Muting before entry', + displayName: 'Muting Before Entry', name: 'mute_upon_entry', type: 'boolean', default: false, @@ -691,7 +691,7 @@ export const meetingFields = [ description: 'Start video when participant joins the meeting.', }, { - displayName: 'Registration type', + displayName: 'Registration Type', name: 'registration_type', type: 'options', options: [ @@ -712,14 +712,14 @@ export const meetingFields = [ description: 'Registration type. Used for recurring meetings with fixed time only', }, { - displayName: 'Schedule for', + displayName: 'Schedule For', name: 'scheduleFor', type: 'string', default: '', description: 'Schedule meeting for someone else from your account, provide their email ID.', }, { - displayName: 'Start time', + displayName: 'Start Time', name: 'startTime', type: 'dateTime', default: '', diff --git a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts index b7e5271f55..d1527f165a 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts @@ -22,12 +22,12 @@ export const meetingRegistrantOperations = [ { name: 'Update', value: 'update', - description: 'Update Meeting Registrant status', + description: 'Update Meeting Registrant Status', }, { name: 'Get All', value: 'getAll', - description: 'Retrieve all meeting registrants', + description: 'Retrieve all Meeting Registrants', }, ], @@ -77,7 +77,7 @@ export const meetingRegistrantFields = [ description: 'Valid email-id of registrant.', }, { - displayName: 'First name', + displayName: 'First Name', name: 'firstName', required: true, type: 'string', @@ -95,7 +95,7 @@ export const meetingRegistrantFields = [ description: 'First Name.', }, { - displayName: 'Additional fields', + displayName: 'Additional Fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -141,7 +141,7 @@ export const meetingRegistrantFields = [ description: 'Valid country of registrant.', }, { - displayName: 'Job title', + displayName: 'Job Title', name: 'job_title', type: 'string', default: '', @@ -176,7 +176,7 @@ export const meetingRegistrantFields = [ description: 'Valid phone number of registrant.', }, { - displayName: 'Purchasing time frame', + displayName: 'Purchasing Time Frame', name: 'purchasing_time_frame', type: 'options', options: [ @@ -205,7 +205,7 @@ export const meetingRegistrantFields = [ description: 'Meeting type.', }, { - displayName: 'Role in purchase process', + displayName: 'Role in Purchase Process', name: 'role_in_purchase_process', type: 'options', options: [ @@ -238,7 +238,7 @@ export const meetingRegistrantFields = [ description: 'Valid state of registrant.', }, { - displayName: 'Zip code', + displayName: 'Zip Code', name: 'zip', type: 'string', default: '', @@ -310,7 +310,7 @@ export const meetingRegistrantFields = [ description: 'How many results to return.', }, { - displayName: 'Additional fields', + displayName: 'Additional Fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -412,7 +412,7 @@ export const meetingRegistrantFields = [ description: `Registrant Status.`, }, { - displayName: 'Additional fields', + displayName: 'Additional Fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', diff --git a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts index 228cf9f1ae..1c0e03e096 100644 --- a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts +++ b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts @@ -69,7 +69,7 @@ export const webinarFields = [ description: 'User ID or email address of user.', }, { - displayName: 'Additional fields', + displayName: 'Additional Fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -101,20 +101,20 @@ export const webinarFields = [ description: 'Alternative hosts email IDs.', }, { - displayName: 'Approval type', + displayName: 'Approval Type', name: 'approval_type', type: 'options', options: [ { - name: 'Automatically approve', + name: 'Automatically Approve', value: 0, }, { - name: 'Manually approve', + name: 'Manually Approve', value: 1, }, { - name: 'No registration required', + name: 'No Registration Required', value: 2, }, ], @@ -144,16 +144,16 @@ export const webinarFields = [ description: 'Determine how participants can join audio portion of the webinar.', }, { - displayName: 'Auto recording', + displayName: 'Auto Recording', name: 'auto_recording', type: 'options', options: [ { - name: 'Record on local', + name: 'Record on Local', value: 'local', }, { - name: 'Record on cloud', + name: 'Record on Cloud', value: 'cloud', }, { @@ -200,7 +200,7 @@ export const webinarFields = [ description: 'Enable Practice session.', }, { - displayName: 'Registration type', + displayName: 'Registration Type', name: 'registration_type', type: 'options', options: [ @@ -221,7 +221,7 @@ export const webinarFields = [ description: 'Registration type. Used for recurring webinar with fixed time only', }, { - displayName: 'Start time', + displayName: 'Start Time', name: 'startTime', type: 'dateTime', default: '', @@ -238,14 +238,14 @@ export const webinarFields = [ description: `Time zone used in the response. The default is the time zone of the calendar.`, }, { - displayName: 'Webinar topic', + displayName: 'Webinar Topic', name: 'topic', type: 'string', default: '', description: `Webinar topic.`, }, { - displayName: 'Webinar type', + displayName: 'Webinar Type', name: 'type', type: 'options', options: [ @@ -258,7 +258,7 @@ export const webinarFields = [ value: 6, }, { - name: 'Recurring webinar with fixed time', + name: 'Recurring webinar with fixed time', value: 9, }, ], @@ -290,7 +290,7 @@ export const webinarFields = [ description: 'Webinar ID.', }, { - displayName: 'Additional fields', + displayName: 'Additional Fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -456,7 +456,7 @@ export const webinarFields = [ description: 'User ID or email address of user.', }, { - displayName: 'Additional fields', + displayName: 'Additional Fields', name: 'additionalFields', type: 'collection', placeholder: 'Add Field', @@ -488,20 +488,20 @@ export const webinarFields = [ description: 'Alternative hosts email IDs.', }, { - displayName: 'Approval type', + displayName: 'Approval Type', name: 'approval_type', type: 'options', options: [ { - name: 'Automatically approve', + name: 'Automatically Approve', value: 0, }, { - name: 'Manually approve', + name: 'Manually Approve', value: 1, }, { - name: 'No registration required', + name: 'No Registration Required', value: 2, }, ], @@ -509,16 +509,16 @@ export const webinarFields = [ description: 'Approval type.', }, { - displayName: 'Auto recording', + displayName: 'Auto Recording', name: 'auto_recording', type: 'options', options: [ { - name: 'Record on local', + name: 'Record on Local', value: 'local', }, { - name: 'Record on cloud', + name: 'Record on Cloud', value: 'cloud', }, { @@ -594,7 +594,7 @@ export const webinarFields = [ description: 'Enable Practice session.', }, { - displayName: 'Registration type', + displayName: 'Registration Type', name: 'registration_type', type: 'options', options: [ @@ -615,7 +615,7 @@ export const webinarFields = [ description: 'Registration type. Used for recurring webinars with fixed time only.', }, { - displayName: 'Start time', + displayName: 'Start Time', name: 'startTime', type: 'dateTime', default: '', @@ -632,14 +632,14 @@ export const webinarFields = [ description: `Time zone used in the response. The default is the time zone of the calendar.`, }, { - displayName: 'Webinar topic', + displayName: 'Webinar Topic', name: 'topic', type: 'string', default: '', description: `Webinar topic.`, }, { - displayName: 'Webinar type', + displayName: 'Webinar Type', name: 'type', type: 'options', options: [ @@ -652,7 +652,7 @@ export const webinarFields = [ value: 6, }, { - name: 'Recurring webinar with fixed time', + name: 'Recurring webinar with fixed time', value: 9, }, ], diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index f33a2a338e..c0d1a05a9e 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -483,7 +483,7 @@ export class Zoom implements INodeType { } } - if (resource === 'meetingRegistrant') { + if (resource === 'meetingRegistrants') { if (operation === 'create') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrantcreate const meetingId = this.getNodeParameter('meetingId', i) as string; From 86a42f47474e9484326b32eaf5a77016267ee406 Mon Sep 17 00:00:00 2001 From: ricardo Date: Wed, 24 Jun 2020 14:02:17 -0400 Subject: [PATCH 032/155] :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 56f6d8aef6..76fbdd3bb5 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 91edf0679d..65afe2b83c 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 582954008352add0c2b88b5a2094872ae846c34a Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Wed, 24 Jun 2020 11:09:11 -0700 Subject: [PATCH 033/155] fix registrants bug --- .../nodes/Zoom/MeetingDescription.ts | 46 +++++----- .../Zoom/MeetingRegistrantDescription.ts | 10 +-- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 87 ++++++++++--------- 3 files changed, 73 insertions(+), 70 deletions(-) diff --git a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts index d335901bdf..3efcc2a2bc 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts @@ -66,7 +66,7 @@ export const meetingFields = [ ], }, }, - description: 'User ID or email address of user.', + description: 'User ID or Email Address.', }, { displayName: 'Additional Fields', @@ -95,22 +95,22 @@ export const meetingFields = [ }, { displayName: 'Alternative Hosts', - name: 'alternative_hosts', + name: 'alternativeHosts', type: 'string', default: '', description: 'Alternative hosts email IDs.', }, { displayName: 'Auto Recording', - name: 'auto_recording', + name: 'autoRecording', type: 'options', options: [ { - name: 'Record on local', + name: 'Record on Local', value: 'local', }, { - name: 'Record on cloud', + name: 'Record on Cloud', value: 'cloud', }, { @@ -152,28 +152,28 @@ export const meetingFields = [ }, { displayName: 'Host Meeting in China', - name: 'cn_meeting', + name: 'cnMeeting', type: 'boolean', default: false, description: 'Host Meeting in China.', }, { displayName: 'Host Meeting in India', - name: 'in_meeting', + name: 'inMeeting', type: 'boolean', default: false, description: 'Host Meeting in India.', }, { displayName: 'Host Video', - name: 'host_video', + name: 'hostVideo', type: 'boolean', default: false, description: 'Start video when host joins the meeting.', }, { - displayName: 'Join before Host', - name: 'join_before_host', + displayName: 'Join Before Host', + name: 'joinBeforeHost', type: 'boolean', default: false, description: 'Allow participants to join the meeting before host starts it.', @@ -213,14 +213,14 @@ export const meetingFields = [ }, { displayName: 'Muting before entry', - name: 'mute_upon_entry', + name: 'muteUponEntry', type: 'boolean', default: false, description: 'Mute participants upon entry.', }, { displayName: 'Participant Video', - name: 'participant_video', + name: 'participantVideo', type: 'boolean', default: false, description: 'Start video when participant joins the meeting.', @@ -234,7 +234,7 @@ export const meetingFields = [ }, { displayName: 'Registration Type', - name: 'registration_type', + name: 'registrationType', type: 'options', options: [ { @@ -546,14 +546,14 @@ export const meetingFields = [ }, { displayName: 'Alternative Hosts', - name: 'alternative_hosts', + name: 'alternativeHosts', type: 'string', default: '', description: 'Alternative hosts email IDs.', }, { displayName: 'Audio', - name: 'auto_recording', + name: 'audio', type: 'options', options: [ { @@ -574,7 +574,7 @@ export const meetingFields = [ }, { displayName: 'Auto Recording', - name: 'auto_recording', + name: 'autoRecording', type: 'options', options: [ { @@ -603,28 +603,28 @@ export const meetingFields = [ }, { displayName: 'Join Before Host', - name: 'join_before_host', + name: 'joinBeforeHost', type: 'boolean', default: false, description: 'Allow participants to join the meeting before host starts it.', }, { displayName: 'Host Meeting in China', - name: 'cn_meeting', + name: 'cnMeeting', type: 'boolean', default: false, description: 'Host Meeting in China.', }, { displayName: 'Host Meeting in India', - name: 'in_meeting', + name: 'inMeeting', type: 'boolean', default: false, description: 'Host Meeting in India.', }, { displayName: 'Host Video', - name: 'host_video', + name: 'hostVideo', type: 'boolean', default: false, description: 'Start video when host joins the meeting.', @@ -671,7 +671,7 @@ export const meetingFields = [ }, { displayName: 'Muting Before Entry', - name: 'mute_upon_entry', + name: 'muteUponEntry', type: 'boolean', default: false, description: 'Mute participants upon entry.', @@ -685,14 +685,14 @@ export const meetingFields = [ }, { displayName: 'Participant Video', - name: 'participant_video', + name: 'participantVideo', type: 'boolean', default: false, description: 'Start video when participant joins the meeting.', }, { displayName: 'Registration Type', - name: 'registration_type', + name: 'registrationType', type: 'options', options: [ { diff --git a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts index d1527f165a..5d40aae014 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts @@ -74,7 +74,7 @@ export const meetingRegistrantFields = [ ], }, }, - description: 'Valid email-id of registrant.', + description: 'Valid Email-ID.', }, { displayName: 'First Name', @@ -142,7 +142,7 @@ export const meetingRegistrantFields = [ }, { displayName: 'Job Title', - name: 'job_title', + name: 'jobTitle', type: 'string', default: '', description: 'Job title of registrant.', @@ -177,7 +177,7 @@ export const meetingRegistrantFields = [ }, { displayName: 'Purchasing Time Frame', - name: 'purchasing_time_frame', + name: 'purchasingTimeFrame', type: 'options', options: [ { @@ -206,7 +206,7 @@ export const meetingRegistrantFields = [ }, { displayName: 'Role in Purchase Process', - name: 'role_in_purchase_process', + name: 'roleInPurchaseProcess', type: 'options', options: [ { @@ -329,7 +329,7 @@ export const meetingRegistrantFields = [ options: [ { displayName: 'Occurrence ID', - name: 'occurrence_id', + name: 'occurrenceId', type: 'string', default: '', description: `Occurrence ID.`, diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index c0d1a05a9e..43221e8288 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -255,23 +255,23 @@ export class Zoom implements INodeType { i ) as IDataObject; const settings: Settings = {}; - if (additionalFields.cn_meeting) { - settings.cn_meeting = additionalFields.cn_meeting as boolean; + if (additionalFields.cnMeeting) { + settings.cn_meeting = additionalFields.cnMeeting as boolean; } - if (additionalFields.in_meeting) { - settings.in_meeting = additionalFields.in_meeting as boolean; + if (additionalFields.inMeeting) { + settings.in_meeting = additionalFields.inMeeting as boolean; } - if (additionalFields.join_before_host) { - settings.join_before_host = additionalFields.join_before_host as boolean; + if (additionalFields.joinBeforeHost) { + settings.join_before_host = additionalFields.joinBeforeHost as boolean; } - if (additionalFields.mute_upon_entry) { - settings.mute_upon_entry = additionalFields.mute_upon_entry as boolean; + if (additionalFields.muteUponEntry) { + settings.mute_upon_entry = additionalFields.muteUponEntry as boolean; } @@ -285,28 +285,28 @@ export class Zoom implements INodeType { } - if (additionalFields.alternative_hosts) { - settings.alternative_hosts = additionalFields.alternative_hosts as string; + if (additionalFields.alternativeHosts) { + settings.alternative_hosts = additionalFields.alternativeHosts as string; } - if (additionalFields.participant_video) { - settings.participant_video = additionalFields.participant_video as boolean; + if (additionalFields.participantVideo) { + settings.participant_video = additionalFields.participantVideo as boolean; } - if (additionalFields.host_video) { - settings.host_video = additionalFields.host_video as boolean; + if (additionalFields.hostVideo) { + settings.host_video = additionalFields.hostVideo as boolean; } - if (additionalFields.auto_recording) { - settings.auto_recording = additionalFields.auto_recording as string; + if (additionalFields.autoRecording) { + settings.auto_recording = additionalFields.autoRecording as string; } - if (additionalFields.registration_type) { - settings.registration_type = additionalFields.registration_type as number; + if (additionalFields.registrationType) { + settings.registration_type = additionalFields.registrationType as number; } @@ -374,23 +374,23 @@ export class Zoom implements INodeType { qs.occurrence_id = additionalFields.occurrenceId as string; } - if (additionalFields.cn_meeting) { - settings.cn_meeting = additionalFields.cn_meeting as boolean; + if (additionalFields.cnMeeting) { + settings.cn_meeting = additionalFields.cnMeeting as boolean; } - if (additionalFields.in_meeting) { - settings.in_meeting = additionalFields.in_meeting as boolean; + if (additionalFields.inMeeting) { + settings.in_meeting = additionalFields.inMeeting as boolean; } - if (additionalFields.join_before_host) { - settings.join_before_host = additionalFields.join_before_host as boolean; + if (additionalFields.joinBeforeHost) { + settings.join_before_host = additionalFields.joinBeforeHost as boolean; } - if (additionalFields.mute_upon_entry) { - settings.mute_upon_entry = additionalFields.mute_upon_entry as boolean; + if (additionalFields.muteUponEntry) { + settings.mute_upon_entry = additionalFields.muteUponEntry as boolean; } @@ -404,28 +404,28 @@ export class Zoom implements INodeType { } - if (additionalFields.alternative_hosts) { - settings.alternative_hosts = additionalFields.alternative_hosts as string; + if (additionalFields.alternativeHosts) { + settings.alternative_hosts = additionalFields.alternativeHosts as string; } - if (additionalFields.participant_video) { - settings.participant_video = additionalFields.participant_video as boolean; + if (additionalFields.participantVideo) { + settings.participant_video = additionalFields.participantVideo as boolean; } - if (additionalFields.host_video) { - settings.host_video = additionalFields.host_video as boolean; + if (additionalFields.hostVideo) { + settings.host_video = additionalFields.hostVideo as boolean; } - if (additionalFields.auto_recording) { - settings.auto_recording = additionalFields.auto_recording as string; + if (additionalFields.autoRecording) { + settings.auto_recording = additionalFields.autoRecording as string; } - if (additionalFields.registration_type) { - settings.registration_type = additionalFields.registration_type as number; + if (additionalFields.registrationType) { + settings.registration_type = additionalFields.registrationType as number; } @@ -525,14 +525,14 @@ export class Zoom implements INodeType { if (additionalFields.org) { body.org = additionalFields.org as string; } - if (additionalFields.job_title) { - body.job_title = additionalFields.job_title as string; + if (additionalFields.jobTitle) { + body.job_title = additionalFields.jobTitle as string; } - if (additionalFields.purchasing_time_frame) { - body.purchasing_time_frame = additionalFields.purchasing_time_frame as string; + if (additionalFields.purchasingTimeFrame) { + body.purchasing_time_frame = additionalFields.purchasingTimeFrame as string; } - if (additionalFields.role_in_purchase_process) { - body.role_in_purchase_process = additionalFields.role_in_purchase_process as string; + if (additionalFields.roleInPurchaseProcess) { + body.role_in_purchase_process = additionalFields.roleInPurchaseProcess as string; } responseData = await zoomApiRequest.call( this, @@ -575,6 +575,9 @@ export class Zoom implements INodeType { if (additionalFields.occurenceId) { qs.occurrence_id = additionalFields.occurrenceId as string; } + if (additionalFields.action) { + body.action = additionalFields.action as string; + } responseData = await zoomApiRequest.call( this, 'PUT', From 7fea380af557151b0419032d04f3336e06d2429b Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Wed, 24 Jun 2020 11:18:56 -0700 Subject: [PATCH 034/155] fix webinar resource --- .../nodes/Zoom/WebinarDescription.ts | 20 ++++++------- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 30 +++++++++++-------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts index 1c0e03e096..eff6f4159c 100644 --- a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts +++ b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts @@ -66,7 +66,7 @@ export const webinarFields = [ ], }, }, - description: 'User ID or email address of user.', + description: 'User ID or email ID.', }, { displayName: 'Additional Fields', @@ -95,14 +95,14 @@ export const webinarFields = [ }, { displayName: 'Alternative Hosts', - name: 'alternative_hosts', + name: 'alternativeHosts', type: 'string', default: '', description: 'Alternative hosts email IDs.', }, { displayName: 'Approval Type', - name: 'approval_type', + name: 'approvalType', type: 'options', options: [ { @@ -145,7 +145,7 @@ export const webinarFields = [ }, { displayName: 'Auto Recording', - name: 'auto_recording', + name: 'autoRecording', type: 'options', options: [ { @@ -173,14 +173,14 @@ export const webinarFields = [ }, { displayName: 'Host Video', - name: 'host_video', + name: 'hostVideo', type: 'boolean', default: false, description: 'Start video when host joins the webinar.', }, { displayName: 'Panelists Video', - name: 'panelists_video', + name: 'panelistsVideo', type: 'boolean', default: false, description: 'Start video when panelists joins the webinar.', @@ -194,14 +194,14 @@ export const webinarFields = [ }, { displayName: 'Practice Session', - name: 'practice_session', + name: 'practiceSession', type: 'boolean', default: false, description: 'Enable Practice session.', }, { displayName: 'Registration Type', - name: 'registration_type', + name: 'registrationType', type: 'options', options: [ { @@ -438,8 +438,8 @@ export const webinarFields = [ /* webinar:update */ /* -------------------------------------------------------------------------- */ { - displayName: 'User ID', - name: 'userId', + displayName: 'Webinar ID', + name: 'webinarId', type: 'string', default: '', required: true, diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index 43221e8288..60f186b31a 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -603,30 +603,34 @@ export class Zoom implements INodeType { } - if (additionalFields.alternative_hosts) { - settings.alternative_hosts = additionalFields.alternative_hosts as string; + if (additionalFields.alternativeHosts) { + settings.alternative_hosts = additionalFields.alternativeHosts as string; } - if (additionalFields.panelists_video) { - settings.panelists_video = additionalFields.panelists_video as boolean; + if (additionalFields.panelistsVideo) { + settings.panelists_video = additionalFields.panelistsVideo as boolean; } - if (additionalFields.practice_session) { - settings.practice_session = additionalFields.practice_session as boolean; + if (additionalFields.hostVideo) { + settings.host_video = additionalFields.hostVideo as boolean; } - if (additionalFields.auto_recording) { - settings.auto_recording = additionalFields.auto_recording as string; + if (additionalFields.practiceSession) { + settings.practice_session = additionalFields.practiceSession as boolean; + + } + if (additionalFields.autoRecording) { + settings.auto_recording = additionalFields.autoRecording as string; } - if (additionalFields.registration_type) { - settings.registration_type = additionalFields.registration_type as number; + if (additionalFields.registrationType) { + settings.registration_type = additionalFields.registrationType as number; } - if (additionalFields.approval_type) { - settings.approval_type = additionalFields.approval_type as number; + if (additionalFields.approvalType) { + settings.approval_type = additionalFields.approvalType as number; } @@ -823,7 +827,7 @@ export class Zoom implements INodeType { responseData = await zoomApiRequest.call( this, 'PATCH', - `/users/${webinarId}/webinars`, + `webinars/${webinarId}`, body, qs ); From f9fe9de235184f13d2871356a830173f8787083b Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Wed, 24 Jun 2020 11:25:07 -0700 Subject: [PATCH 035/155] fix webinar naming conventions --- .../nodes/Zoom/WebinarDescription.ts | 16 +++++----- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 32 +++++++++++-------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts index eff6f4159c..421eb2cf50 100644 --- a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts +++ b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts @@ -482,14 +482,14 @@ export const webinarFields = [ }, { displayName: 'Alternative Hosts', - name: 'alternative_hosts', + name: 'alternativeHosts', type: 'string', default: '', description: 'Alternative hosts email IDs.', }, { displayName: 'Approval Type', - name: 'approval_type', + name: 'approvalType', type: 'options', options: [ { @@ -510,7 +510,7 @@ export const webinarFields = [ }, { displayName: 'Auto Recording', - name: 'auto_recording', + name: 'autoRecording', type: 'options', options: [ { @@ -560,14 +560,14 @@ export const webinarFields = [ }, { displayName: 'Host Video', - name: 'host_video', + name: 'hostVideo', type: 'boolean', default: false, description: 'Start video when host joins the webinar.', }, { displayName: 'Occurrence ID', - name: 'occurrence_id', + name: 'occurrenceId', type: 'string', default: '', description: `Webinar occurrence ID.`, @@ -581,21 +581,21 @@ export const webinarFields = [ }, { displayName: 'Panelists Video', - name: 'panelists_video', + name: 'panelistsVideo', type: 'boolean', default: false, description: 'Start video when panelists joins the webinar.', }, { displayName: 'Practice Session', - name: 'practice_session', + name: 'practiceSession', type: 'boolean', default: false, description: 'Enable Practice session.', }, { displayName: 'Registration Type', - name: 'registration_type', + name: 'registrationType', type: 'options', options: [ { diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index 60f186b31a..f7e00b0de7 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -749,8 +749,8 @@ export class Zoom implements INodeType { 'additionalFields', i ) as IDataObject; - if (additionalFields.occurrence_id) { - qs.occurrence_id = additionalFields.occurrence_id as string; + if (additionalFields.occurrenceId) { + qs.occurrence_id = additionalFields.occurrenceId as string; } const settings: Settings = {}; @@ -758,30 +758,34 @@ export class Zoom implements INodeType { settings.audio = additionalFields.audio as string; } - if (additionalFields.alternative_hosts) { - settings.alternative_hosts = additionalFields.alternative_hosts as string; + if (additionalFields.alternativeHosts) { + settings.alternative_hosts = additionalFields.alternativeHosts as string; } - if (additionalFields.panelists_video) { - settings.panelists_video = additionalFields.panelists_video as boolean; + if (additionalFields.panelistsVideo) { + settings.panelists_video = additionalFields.panelistsVideo as boolean; } - if (additionalFields.practice_session) { - settings.practice_session = additionalFields.practice_session as boolean; + if (additionalFields.hostVideo) { + settings.host_video = additionalFields.hostVideo as boolean; } - if (additionalFields.auto_recording) { - settings.auto_recording = additionalFields.auto_recording as string; + if (additionalFields.practiceSession) { + settings.practice_session = additionalFields.practiceSession as boolean; + + } + if (additionalFields.autoRecording) { + settings.auto_recording = additionalFields.autoRecording as string; } - if (additionalFields.registration_type) { - settings.registration_type = additionalFields.registration_type as number; + if (additionalFields.registrationType) { + settings.registration_type = additionalFields.registrationType as number; } - if (additionalFields.approval_type) { - settings.approval_type = additionalFields.approval_type as number; + if (additionalFields.approvalType) { + settings.approval_type = additionalFields.approvalType as number; } From 34e05f0f72d1f9726ffd9b1e9c18840470bdee42 Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Wed, 24 Jun 2020 12:01:06 -0700 Subject: [PATCH 036/155] add credentials comments --- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index f7e00b0de7..e6e6a78600 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -65,6 +65,9 @@ export class Zoom implements INodeType { outputs: ['main'], credentials: [ { + // create a JWT app on Zoom Marketplace + //https://marketplace.zoom.us/develop/create + //get the JWT token as access token name: 'zoomApi', required: true, displayOptions: { @@ -76,6 +79,8 @@ export class Zoom implements INodeType { }, }, { + //create a account level OAuth app + //https://marketplace.zoom.us/develop/create name: 'zoomOAuth2Api', required: true, displayOptions: { From dee03a5e4c0de14911edcbeb899902774ce93ab1 Mon Sep 17 00:00:00 2001 From: shraddha shaligram Date: Wed, 24 Jun 2020 15:50:23 -0700 Subject: [PATCH 037/155] minor fix --- packages/nodes-base/nodes/Zoom/MeetingDescription.ts | 7 +++++++ packages/nodes-base/nodes/Zoom/Zoom.node.ts | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts index 3efcc2a2bc..0df1f35b72 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts @@ -277,6 +277,13 @@ export const meetingFields = [ default: '', description: `Time zone used in the response. The default is the time zone of the calendar.`, }, + { + displayName: 'Waiting Room', + name: 'waitingRoom', + type: 'boolean', + default: false, + description: 'Enable waiting room.', + }, { displayName: 'Watermark', name: 'watermark', diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index e6e6a78600..52435e5ab9 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -41,6 +41,7 @@ interface Settings { join_before_host?: boolean; mute_upon_entry?: boolean; watermark?: boolean; + waiting_room?: boolean; audio?: string; alternative_hosts?: string; auto_recording?: string; @@ -278,6 +279,10 @@ export class Zoom implements INodeType { if (additionalFields.muteUponEntry) { settings.mute_upon_entry = additionalFields.muteUponEntry as boolean; + } + if (additionalFields.waitingRoom) { + settings.waiting_room = additionalFields.waitingRoom as boolean; + } if (additionalFields.watermark) { From 4555d6bfc9a18df485990968fb34c70de5b2242a Mon Sep 17 00:00:00 2001 From: ricardo Date: Wed, 24 Jun 2020 19:28:08 -0400 Subject: [PATCH 038/155] :zap: Improvements to Zoom-Node --- .../nodes-base/nodes/Zoom/GenericFunctions.ts | 1 + .../nodes/Zoom/MeetingDescription.ts | 592 +++++---- .../Zoom/MeetingRegistrantDescription.ts | 31 +- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 1060 ++++++++--------- 4 files changed, 821 insertions(+), 863 deletions(-) diff --git a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts index 6a8593e3ab..95e07f09b9 100644 --- a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts @@ -33,6 +33,7 @@ export async function zoomApiRequest(this: IExecuteFunctions | IExecuteSingleFun if (Object.keys(query).length === 0) { delete options.qs; } + try { if (authenticationMethod === 'accessToken') { const credentials = this.getCredentials('zoomApi'); diff --git a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts index 3efcc2a2bc..130830b9fe 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts @@ -43,31 +43,13 @@ export const meetingOperations = [ ], default: 'create', description: 'The operation to perform.', - } + }, ] as INodeProperties[]; export const meetingFields = [ /* -------------------------------------------------------------------------- */ - /* meeting:create */ + /* meeting:create */ /* -------------------------------------------------------------------------- */ - { - displayName: 'User ID', - name: 'userId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'create', - ], - resource: [ - 'meeting', - ], - }, - }, - description: 'User ID or Email Address.', - }, { displayName: 'Additional Fields', name: 'additionalFields', @@ -90,98 +72,29 @@ export const meetingFields = [ displayName: 'Agenda', name: 'agenda', type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, default: '', description: 'Meeting agenda.', }, - { - displayName: 'Alternative Hosts', - name: 'alternativeHosts', - type: 'string', - default: '', - description: 'Alternative hosts email IDs.', - }, - { - displayName: 'Auto Recording', - name: 'autoRecording', - type: 'options', - options: [ - { - name: 'Record on Local', - value: 'local', - }, - { - name: 'Record on Cloud', - value: 'cloud', - }, - { - name: 'Disabled', - value: 'none', - }, - ], - default: 'none', - description: 'Auto recording.', - }, - { - displayName: 'Audio', - name: 'audio', - type: 'options', - options: [ - { - name: 'Both Telephony and VoiP', - value: 'both', - }, - { - name: 'Telephony', - value: 'telephony', - }, - { - name: 'VOIP', - value: 'voip', - }, - - ], - default: 'both', - description: 'Determine how participants can join audio portion of the meeting.', - }, { displayName: 'Duration', name: 'duration', type: 'number', - default: '', - description: 'Duration.', - }, - { - displayName: 'Host Meeting in China', - name: 'cnMeeting', - type: 'boolean', - default: false, - description: 'Host Meeting in China.', - }, - { - displayName: 'Host Meeting in India', - name: 'inMeeting', - type: 'boolean', - default: false, - description: 'Host Meeting in India.', - }, - { - displayName: 'Host Video', - name: 'hostVideo', - type: 'boolean', - default: false, - description: 'Start video when host joins the meeting.', - }, - { - displayName: 'Join Before Host', - name: 'joinBeforeHost', - type: 'boolean', - default: false, - description: 'Allow participants to join the meeting before host starts it.', + typeOptions: { + minValue: 0, + }, + default: 0, + description: 'Meeting duration (minutes).', }, { displayName: 'Meeting Topic', name: 'topic', type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, default: '', description: `Meeting topic.`, }, @@ -211,20 +124,6 @@ export const meetingFields = [ default: 2, description: 'Meeting type.', }, - { - displayName: 'Muting before entry', - name: 'muteUponEntry', - type: 'boolean', - default: false, - description: 'Mute participants upon entry.', - }, - { - displayName: 'Participant Video', - name: 'participantVideo', - type: 'boolean', - default: false, - description: 'Start video when participant joins the meeting.', - }, { displayName: 'Password', name: 'password', @@ -232,27 +131,6 @@ export const meetingFields = [ default: '', description: 'Password to join the meeting with maximum 10 characters.', }, - { - displayName: 'Registration Type', - name: 'registrationType', - type: 'options', - options: [ - { - name: 'Attendees register once and can attend any of the occurences', - value: 1, - }, - { - name: 'Attendees need to register for every occurrence', - value: 2, - }, - { - name: 'Attendees register once and can choose one or more occurrences to attend', - value: 3, - }, - ], - default: 1, - description: 'Registration type. Used for recurring meetings with fixed time only', - }, { displayName: 'Schedule For', name: 'scheduleFor', @@ -260,6 +138,135 @@ export const meetingFields = [ default: '', description: 'Schedule meeting for someone else from your account, provide their email ID.', }, + { + displayName: 'Settings', + name: 'settings', + type: 'collection', + placeholder: 'Add Setting', + default: {}, + options: [ + { + displayName: 'Audio', + name: 'audio', + type: 'options', + options: [ + { + name: 'Both Telephony and VoiP', + value: 'both', + }, + { + name: 'Telephony', + value: 'telephony', + }, + { + name: 'VOIP', + value: 'voip', + }, + + ], + default: 'both', + description: 'Determine how participants can join audio portion of the meeting.', + }, + { + displayName: 'Alternative Hosts', + name: 'alternativeHosts', + type: 'string', + default: '', + description: 'Alternative hosts email IDs.', + }, + { + displayName: 'Auto Recording', + name: 'autoRecording', + type: 'options', + options: [ + { + name: 'Record on Local', + value: 'local', + }, + { + name: 'Record on Cloud', + value: 'cloud', + }, + { + name: 'Disabled', + value: 'none', + }, + ], + default: 'none', + description: 'Auto recording.', + }, + { + displayName: 'Host Meeting in China', + name: 'cnMeeting', + type: 'boolean', + default: false, + description: 'Host Meeting in China.', + }, + { + displayName: 'Host Meeting in India', + name: 'inMeeting', + type: 'boolean', + default: false, + description: 'Host Meeting in India.', + }, + { + displayName: 'Host Video', + name: 'hostVideo', + type: 'boolean', + default: false, + description: 'Start video when host joins the meeting.', + }, + { + displayName: 'Join Before Host', + name: 'joinBeforeHost', + type: 'boolean', + default: false, + description: 'Allow participants to join the meeting before host starts it.', + }, + { + displayName: 'Muting Upon Entry', + name: 'muteUponEntry', + type: 'boolean', + default: false, + description: 'Mute participants upon entry.', + }, + { + displayName: 'Participant Video', + name: 'participantVideo', + type: 'boolean', + default: false, + description: 'Start video when participant joins the meeting.', + }, + { + displayName: 'Registration Type', + name: 'registrationType', + type: 'options', + options: [ + { + name: 'Attendees register once and can attend any of the occurences', + value: 1, + }, + { + name: 'Attendees need to register for every occurrence', + value: 2, + }, + { + name: 'Attendees register once and can choose one or more occurrences to attend', + value: 3, + }, + ], + default: 1, + description: 'Registration type. Used for recurring meetings with fixed time only', + }, + { + displayName: 'Watermark', + name: 'watermark', + type: 'boolean', + default: false, + description: 'Adds watermark when viewing a shared screen.', + }, + ], + }, { displayName: 'Start Time', name: 'startTime', @@ -277,13 +284,6 @@ export const meetingFields = [ default: '', description: `Time zone used in the response. The default is the time zone of the calendar.`, }, - { - displayName: 'Watermark', - name: 'watermark', - type: 'boolean', - default: false, - description: 'Adds watermark when viewing a shared screen.', - }, ], }, /* -------------------------------------------------------------------------- */ @@ -342,26 +342,8 @@ export const meetingFields = [ ], }, /* -------------------------------------------------------------------------- */ - /* meeting:getAll */ + /* meeting:getAll */ /* -------------------------------------------------------------------------- */ - { - displayName: 'User ID', - name: 'userId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'getAll', - ], - resource: [ - 'meeting', - ], - }, - }, - description: 'User ID or email-ID.', - }, { displayName: 'Return All', name: 'returnAll', @@ -404,10 +386,10 @@ export const meetingFields = [ description: 'How many results to return.', }, { - displayName: 'Additional Fields', - name: 'additionalFields', + displayName: 'Filters', + name: 'filters', type: 'collection', - placeholder: 'Add Field', + placeholder: 'Add Filter', default: {}, displayOptions: { show: { @@ -429,23 +411,26 @@ export const meetingFields = [ { name: 'Scheduled', value: 'scheduled', + description: 'This includes all valid past meetings, live meetings and upcoming scheduled meetings' }, { name: 'Live', value: 'live', + description: 'All ongoing meetings', }, { name: 'Upcoming', value: 'upcoming', + description: 'All upcoming meetings including live meetings', }, ], default: 'live', description: `Meeting type.`, }, - ] + ], }, /* -------------------------------------------------------------------------- */ - /* meeting:delete */ + /* meeting:delete */ /* -------------------------------------------------------------------------- */ { displayName: 'Meeting ID', @@ -500,7 +485,7 @@ export const meetingFields = [ }, /* -------------------------------------------------------------------------- */ - /* meeting:update */ + /* meeting:update */ /* -------------------------------------------------------------------------- */ { displayName: 'Meeting ID', @@ -521,8 +506,8 @@ export const meetingFields = [ description: 'Meeting ID.', }, { - displayName: 'Additional Fields', - name: 'additionalFields', + displayName: 'Update Fields', + name: 'updateFields', type: 'collection', placeholder: 'Add Field', default: {}, @@ -541,100 +526,21 @@ export const meetingFields = [ displayName: 'Agenda', name: 'agenda', type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, default: '', description: 'Meeting agenda.', }, - { - displayName: 'Alternative Hosts', - name: 'alternativeHosts', - type: 'string', - default: '', - description: 'Alternative hosts email IDs.', - }, - { - displayName: 'Audio', - name: 'audio', - type: 'options', - options: [ - { - name: 'Both Telephony and VoiP', - value: 'both', - }, - { - name: 'Telephony', - value: 'telephony', - }, - { - name: 'VOIP', - value: 'voip', - }, - ], - default: 'both', - description: 'Determine how participants can join audio portion of the meeting.', - }, - { - displayName: 'Auto Recording', - name: 'autoRecording', - type: 'options', - options: [ - { - name: 'Record on Local', - value: 'local', - }, - { - name: 'Record on Cloud', - value: 'cloud', - }, - { - name: 'Disabled', - value: 'none', - }, - ], - default: 'none', - description: 'Auto recording.', - }, - { displayName: 'Duration', name: 'duration', type: 'number', - default: '', - description: 'Duration.', - }, - { - displayName: 'Join Before Host', - name: 'joinBeforeHost', - type: 'boolean', - default: false, - description: 'Allow participants to join the meeting before host starts it.', - }, - { - displayName: 'Host Meeting in China', - name: 'cnMeeting', - type: 'boolean', - default: false, - description: 'Host Meeting in China.', - }, - { - displayName: 'Host Meeting in India', - name: 'inMeeting', - type: 'boolean', - default: false, - description: 'Host Meeting in India.', - }, - { - displayName: 'Host Video', - name: 'hostVideo', - type: 'boolean', - default: false, - description: 'Start video when host joins the meeting.', - }, - { - displayName: 'Occurrence ID', - name: 'occurrenceId', - type: 'string', - default: '', - description: 'Occurrence ID.', + typeOptions: { + minValue: 0, + }, + default: 0, + description: 'Meeting duration (minutes).', }, { displayName: 'Meeting Topic', @@ -669,13 +575,6 @@ export const meetingFields = [ default: 2, description: 'Meeting type.', }, - { - displayName: 'Muting Before Entry', - name: 'muteUponEntry', - type: 'boolean', - default: false, - description: 'Mute participants upon entry.', - }, { displayName: 'Password', name: 'password', @@ -683,34 +582,6 @@ export const meetingFields = [ default: '', description: 'Password to join the meeting with maximum 10 characters.', }, - { - displayName: 'Participant Video', - name: 'participantVideo', - type: 'boolean', - default: false, - description: 'Start video when participant joins the meeting.', - }, - { - displayName: 'Registration Type', - name: 'registrationType', - type: 'options', - options: [ - { - name: 'Attendees register once and can attend any of the occurrences', - value: 1, - }, - { - name: 'Attendees need to register for every occurrence', - value: 2, - }, - { - name: 'Attendees register once and can choose one or more occurrences to attend', - value: 3, - }, - ], - default: 1, - description: 'Registration type. Used for recurring meetings with fixed time only', - }, { displayName: 'Schedule For', name: 'scheduleFor', @@ -718,6 +589,135 @@ export const meetingFields = [ default: '', description: 'Schedule meeting for someone else from your account, provide their email ID.', }, + { + displayName: 'Settings', + name: 'settings', + type: 'collection', + placeholder: 'Add Setting', + default: {}, + options: [ + { + displayName: 'Audio', + name: 'audio', + type: 'options', + options: [ + { + name: 'Both Telephony and VoiP', + value: 'both', + }, + { + name: 'Telephony', + value: 'telephony', + }, + { + name: 'VOIP', + value: 'voip', + }, + + ], + default: 'both', + description: 'Determine how participants can join audio portion of the meeting.', + }, + { + displayName: 'Alternative Hosts', + name: 'alternativeHosts', + type: 'string', + default: '', + description: 'Alternative hosts email IDs.', + }, + { + displayName: 'Auto Recording', + name: 'autoRecording', + type: 'options', + options: [ + { + name: 'Record on Local', + value: 'local', + }, + { + name: 'Record on Cloud', + value: 'cloud', + }, + { + name: 'Disabled', + value: 'none', + }, + ], + default: 'none', + description: 'Auto recording.', + }, + { + displayName: 'Host Meeting in China', + name: 'cnMeeting', + type: 'boolean', + default: false, + description: 'Host Meeting in China.', + }, + { + displayName: 'Host Meeting in India', + name: 'inMeeting', + type: 'boolean', + default: false, + description: 'Host Meeting in India.', + }, + { + displayName: 'Host Video', + name: 'hostVideo', + type: 'boolean', + default: false, + description: 'Start video when host joins the meeting.', + }, + { + displayName: 'Join Before Host', + name: 'joinBeforeHost', + type: 'boolean', + default: false, + description: 'Allow participants to join the meeting before host starts it.', + }, + { + displayName: 'Muting Upon Entry', + name: 'muteUponEntry', + type: 'boolean', + default: false, + description: 'Mute participants upon entry.', + }, + { + displayName: 'Participant Video', + name: 'participantVideo', + type: 'boolean', + default: false, + description: 'Start video when participant joins the meeting.', + }, + { + displayName: 'Registration Type', + name: 'registrationType', + type: 'options', + options: [ + { + name: 'Attendees register once and can attend any of the occurences', + value: 1, + }, + { + name: 'Attendees need to register for every occurrence', + value: 2, + }, + { + name: 'Attendees register once and can choose one or more occurrences to attend', + value: 3, + }, + ], + default: 1, + description: 'Registration type. Used for recurring meetings with fixed time only', + }, + { + displayName: 'Watermark', + name: 'watermark', + type: 'boolean', + default: false, + description: 'Adds watermark when viewing a shared screen.', + }, + ], + }, { displayName: 'Start Time', name: 'startTime', @@ -735,16 +735,6 @@ export const meetingFields = [ default: '', description: `Time zone used in the response. The default is the time zone of the calendar.`, }, - { - displayName: 'Watermark', - name: 'watermark', - type: 'boolean', - default: false, - description: 'Adds watermark when viewing a shared screen.', - }, - - ], }, - ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts index 5d40aae014..5438f18fa3 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts @@ -1,6 +1,7 @@ import { INodeProperties, } from 'n8n-workflow'; + export const meetingRegistrantOperations = [ { displayName: 'Operation', @@ -9,7 +10,7 @@ export const meetingRegistrantOperations = [ displayOptions: { show: { resource: [ - 'meetingRegistrants', + 'meetingRegistrant', ], }, }, @@ -38,7 +39,7 @@ export const meetingRegistrantOperations = [ export const meetingRegistrantFields = [ /* -------------------------------------------------------------------------- */ - /* meetingRegistrants:create */ + /* meetingRegistrant:create */ /* -------------------------------------------------------------------------- */ { displayName: 'Meeting Id', @@ -52,7 +53,7 @@ export const meetingRegistrantFields = [ 'create', ], resource: [ - 'meetingRegistrants', + 'meetingRegistrant', ], }, }, @@ -70,7 +71,7 @@ export const meetingRegistrantFields = [ 'create', ], resource: [ - 'meetingRegistrants', + 'meetingRegistrant', ], }, }, @@ -88,7 +89,7 @@ export const meetingRegistrantFields = [ 'create', ], resource: [ - 'meetingRegistrants', + 'meetingRegistrant', ], }, }, @@ -107,7 +108,7 @@ export const meetingRegistrantFields = [ ], resource: [ - 'meetingRegistrants', + 'meetingRegistrant', ], }, }, @@ -248,7 +249,7 @@ export const meetingRegistrantFields = [ ], }, /* -------------------------------------------------------------------------- */ - /* meetingRegistrants:getAll */ + /* meetingRegistrant:getAll */ /* -------------------------------------------------------------------------- */ { displayName: 'Meeting ID', @@ -262,7 +263,7 @@ export const meetingRegistrantFields = [ 'getAll', ], resource: [ - 'meetingRegistrants', + 'meetingRegistrant', ], }, }, @@ -278,7 +279,7 @@ export const meetingRegistrantFields = [ 'getAll', ], resource: [ - 'meetingRegistrants', + 'meetingRegistrant', ], }, }, @@ -295,7 +296,7 @@ export const meetingRegistrantFields = [ 'getAll', ], resource: [ - 'meetingRegistrants', + 'meetingRegistrant', ], returnAll: [ false, @@ -322,7 +323,7 @@ export const meetingRegistrantFields = [ ], resource: [ - 'meetingRegistrants', + 'meetingRegistrant', ], }, }, @@ -359,7 +360,7 @@ export const meetingRegistrantFields = [ ] }, /* -------------------------------------------------------------------------- */ - /* meetingRegistrants:update */ + /* meetingRegistrant:update */ /* -------------------------------------------------------------------------- */ { displayName: 'Meeting ID', @@ -373,7 +374,7 @@ export const meetingRegistrantFields = [ 'update', ], resource: [ - 'meetingRegistrants', + 'meetingRegistrant', ], }, }, @@ -390,7 +391,7 @@ export const meetingRegistrantFields = [ 'update', ], resource: [ - 'meetingRegistrants', + 'meetingRegistrant', ], }, }, @@ -423,7 +424,7 @@ export const meetingRegistrantFields = [ 'update', ], resource: [ - 'meetingRegistrants', + 'meetingRegistrant', ], }, }, diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index e6e6a78600..e3c9a083a5 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -1,14 +1,16 @@ import { IExecuteFunctions, } from 'n8n-core'; + import { IDataObject, - INodeExecutionData, - INodeType, ILoadOptionsFunctions, - INodeTypeDescription, + INodeExecutionData, INodePropertyOptions, + INodeType, + INodeTypeDescription, } from 'n8n-workflow'; + import { zoomApiRequest, zoomApiRequestAllItems, @@ -19,16 +21,15 @@ import { meetingFields, } from './MeetingDescription'; -import { - meetingRegistrantOperations, - meetingRegistrantFields, +// import { +// meetingRegistrantOperations, +// meetingRegistrantFields, +// } from './MeetingRegistrantDescription'; -} from './MeetingRegistrantDescription'; - -import { - webinarOperations, - webinarFields, -} from './WebinarDescription'; +// import { +// webinarOperations, +// webinarFields, +// } from './WebinarDescription'; import * as moment from 'moment-timezone'; @@ -48,6 +49,7 @@ interface Settings { approval_type?: number; practice_session?: boolean; } + export class Zoom implements INodeType { description: INodeTypeDescription = { displayName: 'Zoom', @@ -119,14 +121,14 @@ export class Zoom implements INodeType { name: 'Meeting', value: 'meeting' }, - { - name: 'Meeting Registrant', - value: 'meetingRegistrants' - }, - { - name: 'Webinar', - value: 'webinar' - } + // { + // name: 'Meeting Registrant', + // value: 'meetingRegistrant' + // }, + // { + // name: 'Webinar', + // value: 'webinar' + // } ], default: 'meeting', description: 'The resource to operate on.' @@ -135,13 +137,13 @@ export class Zoom implements INodeType { ...meetingOperations, ...meetingFields, - //MEETING REGISTRANTS - ...meetingRegistrantOperations, - ...meetingRegistrantFields, + // //MEETING REGISTRANTS + // ...meetingRegistrantOperations, + // ...meetingRegistrantFields, - //WEBINARS - ...webinarOperations, - ...webinarFields, + // //WEBINARS + // ...webinarOperations, + // ...webinarFields, ] }; @@ -169,7 +171,6 @@ export class Zoom implements INodeType { const items = this.getInputData(); const returnData: IDataObject[] = []; let qs: IDataObject = {}; - let body: IDataObject = {}; let responseData; const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; @@ -186,14 +187,13 @@ export class Zoom implements INodeType { 'additionalFields', i ) as IDataObject; + if (additionalFields.showPreviousOccurrences) { qs.show_previous_occurrences = additionalFields.showPreviousOccurrences as boolean; - } if (additionalFields.occurrenceId) { qs.occurrence_id = additionalFields.occurrenceId as string; - } responseData = await zoomApiRequest.call( @@ -206,23 +206,22 @@ export class Zoom implements INodeType { } if (operation === 'getAll') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetings - const userId = this.getNodeParameter('userId', i) as string; const returnAll = this.getNodeParameter('returnAll', i) as boolean; - const additionalFields = this.getNodeParameter( - 'additionalFields', + const filters = this.getNodeParameter( + 'filters', i ) as IDataObject; - if (additionalFields.type) { - qs.type = additionalFields.type as string; - + if (filters.type) { + qs.type = filters.type as string; } + if (returnAll) { - responseData = await zoomApiRequestAllItems.call(this, 'meetings', 'GET', `/users/${userId}/meetings`, {}, qs); + responseData = await zoomApiRequestAllItems.call(this, 'meetings', 'GET', '/users/me/meetings', {}, qs); } else { qs.page_size = this.getNodeParameter('limit', i) as number; - responseData = await zoomApiRequest.call(this, 'GET', `/users/${userId}/meetings`, {}, qs); - + responseData = await zoomApiRequest.call(this, 'GET', '/users/me/meetings', {}, qs); + responseData = responseData.meetings; } } @@ -235,12 +234,10 @@ export class Zoom implements INodeType { ) as IDataObject; if (additionalFields.scheduleForReminder) { qs.schedule_for_reminder = additionalFields.scheduleForReminder as boolean; - } if (additionalFields.occurrenceId) { qs.occurrence_id = additionalFields.occurrenceId; - } responseData = await zoomApiRequest.call( @@ -254,114 +251,100 @@ export class Zoom implements INodeType { } if (operation === 'create') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate - const userId = this.getNodeParameter('userId', i) as string; const additionalFields = this.getNodeParameter( 'additionalFields', i ) as IDataObject; - const settings: Settings = {}; - if (additionalFields.cnMeeting) { - settings.cn_meeting = additionalFields.cnMeeting as boolean; + const body: IDataObject = {}; + + if (additionalFields.settings) { + const settingValues: Settings = {}; + const settings = additionalFields.settings as IDataObject; + + if (settings.cnMeeting) { + settingValues.cn_meeting = settings.cnMeeting as boolean; + } + + if (settings.inMeeting) { + settingValues.in_meeting = settings.inMeeting as boolean; + } + + if (settings.joinBeforeHost) { + settingValues.join_before_host = settings.joinBeforeHost as boolean; + } + + if (settings.muteUponEntry) { + settingValues.mute_upon_entry = settings.muteUponEntry as boolean; + } + + if (settings.watermark) { + settingValues.watermark = settings.watermark as boolean; + } + + if (settings.audio) { + settingValues.audio = settings.audio as string; + } + + if (settings.alternativeHosts) { + settingValues.alternative_hosts = settings.alternativeHosts as string; + } + + if (settings.participantVideo) { + settingValues.participant_video = settings.participantVideo as boolean; + } + + if (settings.hostVideo) { + settingValues.host_video = settings.hostVideo as boolean; + } + + if (settings.autoRecording) { + settingValues.auto_recording = settings.autoRecording as string; + } + + if (settings.registrationType) { + settingValues.registration_type = settings.registrationType as number; + } + + body.settings = settingValues; } - if (additionalFields.inMeeting) { - settings.in_meeting = additionalFields.inMeeting as boolean; - - } - - if (additionalFields.joinBeforeHost) { - settings.join_before_host = additionalFields.joinBeforeHost as boolean; - - } - - if (additionalFields.muteUponEntry) { - settings.mute_upon_entry = additionalFields.muteUponEntry as boolean; - - } - - if (additionalFields.watermark) { - settings.watermark = additionalFields.watermark as boolean; - - } - - if (additionalFields.audio) { - settings.audio = additionalFields.audio as string; - - } - - if (additionalFields.alternativeHosts) { - settings.alternative_hosts = additionalFields.alternativeHosts as string; - - } - - if (additionalFields.participantVideo) { - settings.participant_video = additionalFields.participantVideo as boolean; - - } - - if (additionalFields.hostVideo) { - settings.host_video = additionalFields.hostVideo as boolean; - - } - - if (additionalFields.autoRecording) { - settings.auto_recording = additionalFields.autoRecording as string; - - } - - if (additionalFields.registrationType) { - settings.registration_type = additionalFields.registrationType as number; - - } - - body = { - settings, - }; - if (additionalFields.topic) { body.topic = additionalFields.topic as string; - } if (additionalFields.type) { body.type = additionalFields.type as string; - } if (additionalFields.startTime) { body.start_time = additionalFields.startTime as string; - } if (additionalFields.duration) { body.duration = additionalFields.duration as number; - } if (additionalFields.scheduleFor) { body.schedule_for = additionalFields.scheduleFor as string; - } if (additionalFields.timeZone) { body.timezone = additionalFields.timeZone as string; - } if (additionalFields.password) { body.password = additionalFields.password as string; - } if (additionalFields.agenda) { body.agenda = additionalFields.agenda as string; - } + responseData = await zoomApiRequest.call( this, 'POST', - `/users/${userId}/meetings`, + `/users/me/meetings`, body, qs ); @@ -369,112 +352,94 @@ export class Zoom implements INodeType { if (operation === 'update') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingupdate const meetingId = this.getNodeParameter('meetingId', i) as string; - const settings: Settings = {}; - const additionalFields = this.getNodeParameter( - 'additionalFields', + const updateFields = this.getNodeParameter( + 'updateFields', i ) as IDataObject; - if (additionalFields.occurrenceId) { - qs.occurrence_id = additionalFields.occurrenceId as string; + const body: IDataObject = {}; + + if (updateFields.settings) { + const settingValues: Settings = {}; + const settings = updateFields.settings as IDataObject; + + if (settings.cnMeeting) { + settingValues.cn_meeting = settings.cnMeeting as boolean; + } + + if (settings.inMeeting) { + settingValues.in_meeting = settings.inMeeting as boolean; + } + + if (settings.joinBeforeHost) { + settingValues.join_before_host = settings.joinBeforeHost as boolean; + } + + if (settings.muteUponEntry) { + settingValues.mute_upon_entry = settings.muteUponEntry as boolean; + } + + if (settings.watermark) { + settingValues.watermark = settings.watermark as boolean; + } + + if (settings.audio) { + settingValues.audio = settings.audio as string; + } + + if (settings.alternativeHosts) { + settingValues.alternative_hosts = settings.alternativeHosts as string; + } + + if (settings.participantVideo) { + settingValues.participant_video = settings.participantVideo as boolean; + } + + if (settings.hostVideo) { + settingValues.host_video = settings.hostVideo as boolean; + } + + if (settings.autoRecording) { + settingValues.auto_recording = settings.autoRecording as string; + } + + if (settings.registrationType) { + settingValues.registration_type = settings.registrationType as number; + } + + body.settings = settingValues; } - if (additionalFields.cnMeeting) { - settings.cn_meeting = additionalFields.cnMeeting as boolean; - + if (updateFields.topic) { + body.topic = updateFields.topic as string; } - if (additionalFields.inMeeting) { - settings.in_meeting = additionalFields.inMeeting as boolean; - + if (updateFields.type) { + body.type = updateFields.type as string; } - if (additionalFields.joinBeforeHost) { - settings.join_before_host = additionalFields.joinBeforeHost as boolean; - + if (updateFields.startTime) { + body.start_time = updateFields.startTime as string; } - if (additionalFields.muteUponEntry) { - settings.mute_upon_entry = additionalFields.muteUponEntry as boolean; - + if (updateFields.duration) { + body.duration = updateFields.duration as number; } - if (additionalFields.watermark) { - settings.watermark = additionalFields.watermark as boolean; - + if (updateFields.scheduleFor) { + body.schedule_for = updateFields.scheduleFor as string; } - if (additionalFields.audio) { - settings.audio = additionalFields.audio as string; - + if (updateFields.timeZone) { + body.timezone = updateFields.timeZone as string; } - if (additionalFields.alternativeHosts) { - settings.alternative_hosts = additionalFields.alternativeHosts as string; - + if (updateFields.password) { + body.password = updateFields.password as string; } - if (additionalFields.participantVideo) { - settings.participant_video = additionalFields.participantVideo as boolean; - - } - - if (additionalFields.hostVideo) { - settings.host_video = additionalFields.hostVideo as boolean; - - } - - if (additionalFields.autoRecording) { - settings.auto_recording = additionalFields.autoRecording as string; - - } - - if (additionalFields.registrationType) { - settings.registration_type = additionalFields.registrationType as number; - - } - - body = { - settings, - }; - if (additionalFields.topic) { - body.topic = additionalFields.topic as string; - - } - - if (additionalFields.type) { - body.type = additionalFields.type as string; - - } - - if (additionalFields.startTime) { - body.start_time = additionalFields.startTime as string; - - } - - if (additionalFields.duration) { - body.duration = additionalFields.duration as number; - - } - - if (additionalFields.scheduleFor) { - body.schedule_for = additionalFields.scheduleFor as string; - - } - - if (additionalFields.timeZone) { - body.timezone = additionalFields.timeZone as string; - - } - - if (additionalFields.password) { - body.password = additionalFields.password as string; - - } - - if (additionalFields.agenda) { - body.agenda = additionalFields.agenda as string; - + if (updateFields.agenda) { + body.agenda = updateFields.agenda as string; } responseData = await zoomApiRequest.call( @@ -484,364 +449,365 @@ export class Zoom implements INodeType { body, qs ); - responseData = { updated: true }; - } - } - if (resource === 'meetingRegistrants') { - if (operation === 'create') { - //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrantcreate - const meetingId = this.getNodeParameter('meetingId', i) as string; - const emailId = this.getNodeParameter('email', i) as string; - body.email = emailId; - const firstName = this.getNodeParameter('firstName', i) as string; - body.first_name = firstName; - const additionalFields = this.getNodeParameter( - 'additionalFields', - i - ) as IDataObject; - if (additionalFields.occurrenceId) { - qs.occurrence_ids = additionalFields.occurrenceId as string; - } - if (additionalFields.lastName) { - body.last_name = additionalFields.lastName as string; - } - if (additionalFields.address) { - body.address = additionalFields.address as string; - } - if (additionalFields.city) { - body.city = additionalFields.city as string; - } - if (additionalFields.state) { - body.state = additionalFields.state as string; - } - if (additionalFields.country) { - body.country = additionalFields.country as string; - } - if (additionalFields.zip) { - body.zip = additionalFields.zip as string; - } - if (additionalFields.phone) { - body.phone = additionalFields.phone as string; - } - if (additionalFields.comments) { - body.comments = additionalFields.comments as string; - } - if (additionalFields.org) { - body.org = additionalFields.org as string; - } - if (additionalFields.jobTitle) { - body.job_title = additionalFields.jobTitle as string; - } - if (additionalFields.purchasingTimeFrame) { - body.purchasing_time_frame = additionalFields.purchasingTimeFrame as string; - } - if (additionalFields.roleInPurchaseProcess) { - body.role_in_purchase_process = additionalFields.roleInPurchaseProcess as string; - } - responseData = await zoomApiRequest.call( - this, - 'POST', - `/meetings/${meetingId}/registrants`, - body, - qs - ); - } - if (operation === 'getAll') { - //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrants - const meetingId = this.getNodeParameter('meetingId', i) as string; - const additionalFields = this.getNodeParameter( - 'additionalFields', - i - ) as IDataObject; - if (additionalFields.occurrenceId) { - qs.occurrence_id = additionalFields.occurrenceId as string; - } - if (additionalFields.status) { - qs.status = additionalFields.status as string; - } - const returnAll = this.getNodeParameter('returnAll', i) as boolean; - if (returnAll) { - responseData = await zoomApiRequestAllItems.call(this, 'results', 'GET', `/meetings/${meetingId}/registrants`, {}, qs); - } else { - qs.page_size = this.getNodeParameter('limit', i) as number; - responseData = await zoomApiRequest.call(this, 'GET', `/meetings/${meetingId}/registrants`, {}, qs); - - } - - } - if (operation === 'update') { - //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrantstatus - const meetingId = this.getNodeParameter('meetingId', i) as string; - const additionalFields = this.getNodeParameter( - 'additionalFields', - i - ) as IDataObject; - if (additionalFields.occurenceId) { - qs.occurrence_id = additionalFields.occurrenceId as string; - } - if (additionalFields.action) { - body.action = additionalFields.action as string; - } - responseData = await zoomApiRequest.call( - this, - 'PUT', - `/meetings/${meetingId}/registrants/status`, - body, - qs - ); - } - } - if (resource === 'webinar') { - if (operation === 'create') { - //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinarcreate - const userId = this.getNodeParameter('userId', i) as string; - const additionalFields = this.getNodeParameter( - 'additionalFields', - i - ) as IDataObject; - const settings: Settings = {}; - - - if (additionalFields.audio) { - settings.audio = additionalFields.audio as string; - - } - - if (additionalFields.alternativeHosts) { - settings.alternative_hosts = additionalFields.alternativeHosts as string; - - } - - if (additionalFields.panelistsVideo) { - settings.panelists_video = additionalFields.panelistsVideo as boolean; - - } - if (additionalFields.hostVideo) { - settings.host_video = additionalFields.hostVideo as boolean; - - } - if (additionalFields.practiceSession) { - settings.practice_session = additionalFields.practiceSession as boolean; - - } - if (additionalFields.autoRecording) { - settings.auto_recording = additionalFields.autoRecording as string; - - } - - if (additionalFields.registrationType) { - settings.registration_type = additionalFields.registrationType as number; - - } - if (additionalFields.approvalType) { - settings.approval_type = additionalFields.approvalType as number; - - } - - body = { - settings, - }; - - if (additionalFields.topic) { - body.topic = additionalFields.topic as string; - - } - - if (additionalFields.type) { - body.type = additionalFields.type as string; - - } - - if (additionalFields.startTime) { - body.start_time = additionalFields.startTime as string; - - } - - if (additionalFields.duration) { - body.duration = additionalFields.duration as number; - - } - - - if (additionalFields.timeZone) { - body.timezone = additionalFields.timeZone as string; - - } - - if (additionalFields.password) { - body.password = additionalFields.password as string; - - } - - if (additionalFields.agenda) { - body.agenda = additionalFields.agenda as string; - - } - responseData = await zoomApiRequest.call( - this, - 'POST', - `/users/${userId}/webinars`, - body, - qs - ); - } - if (operation === 'get') { - //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinar - const webinarId = this.getNodeParameter('webinarId', i) as string; - - const additionalFields = this.getNodeParameter( - 'additionalFields', - i - ) as IDataObject; - if (additionalFields.showPreviousOccurrences) { - qs.show_previous_occurrences = additionalFields.showPreviousOccurrences as boolean; - - } - - if (additionalFields.occurrenceId) { - qs.occurrence_id = additionalFields.occurrenceId as string; - - } - - responseData = await zoomApiRequest.call( - this, - 'GET', - `/webinars/${webinarId}`, - {}, - qs - ); - } - if (operation === 'getAll') { - //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinars - const userId = this.getNodeParameter('userId', i) as string; - const returnAll = this.getNodeParameter('returnAll', i) as boolean; - if (returnAll) { - responseData = await zoomApiRequestAllItems.call(this, 'results', 'GET', `/users/${userId}/webinars`, {}, qs); - } else { - qs.page_size = this.getNodeParameter('limit', i) as number; - responseData = await zoomApiRequest.call(this, 'GET', `/users/${userId}/webinars`, {}, qs); - - } - } - if (operation === 'delete') { - //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinardelete - const webinarId = this.getNodeParameter('webinarId', i) as string; - const additionalFields = this.getNodeParameter( - 'additionalFields', - i - ) as IDataObject; - - - if (additionalFields.occurrenceId) { - qs.occurrence_id = additionalFields.occurrenceId; - - } - - responseData = await zoomApiRequest.call( - this, - 'DELETE', - `/webinars/${webinarId}`, - {}, - qs - ); responseData = { success: true }; - } - if (operation === 'update') { - //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinarupdate - const webinarId = this.getNodeParameter('webinarId', i) as string; - const additionalFields = this.getNodeParameter( - 'additionalFields', - i - ) as IDataObject; - if (additionalFields.occurrenceId) { - qs.occurrence_id = additionalFields.occurrenceId as string; - } - const settings: Settings = {}; - if (additionalFields.audio) { - settings.audio = additionalFields.audio as string; - - } - if (additionalFields.alternativeHosts) { - settings.alternative_hosts = additionalFields.alternativeHosts as string; - - } - - if (additionalFields.panelistsVideo) { - settings.panelists_video = additionalFields.panelistsVideo as boolean; - - } - if (additionalFields.hostVideo) { - settings.host_video = additionalFields.hostVideo as boolean; - - } - if (additionalFields.practiceSession) { - settings.practice_session = additionalFields.practiceSession as boolean; - - } - if (additionalFields.autoRecording) { - settings.auto_recording = additionalFields.autoRecording as string; - - } - - if (additionalFields.registrationType) { - settings.registration_type = additionalFields.registrationType as number; - - } - if (additionalFields.approvalType) { - settings.approval_type = additionalFields.approvalType as number; - - } - - body = { - settings, - }; - - if (additionalFields.topic) { - body.topic = additionalFields.topic as string; - - } - - if (additionalFields.type) { - body.type = additionalFields.type as string; - - } - - if (additionalFields.startTime) { - body.start_time = additionalFields.startTime as string; - - } - - if (additionalFields.duration) { - body.duration = additionalFields.duration as number; - - } - - - if (additionalFields.timeZone) { - body.timezone = additionalFields.timeZone as string; - - } - - if (additionalFields.password) { - body.password = additionalFields.password as string; - - } - - if (additionalFields.agenda) { - body.agenda = additionalFields.agenda as string; - - } - responseData = await zoomApiRequest.call( - this, - 'PATCH', - `webinars/${webinarId}`, - body, - qs - ); } } + // if (resource === 'meetingRegistrant') { + // if (operation === 'create') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrantcreate + // const meetingId = this.getNodeParameter('meetingId', i) as string; + // const emailId = this.getNodeParameter('email', i) as string; + // body.email = emailId; + // const firstName = this.getNodeParameter('firstName', i) as string; + // body.first_name = firstName; + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + // if (additionalFields.occurrenceId) { + // qs.occurrence_ids = additionalFields.occurrenceId as string; + // } + // if (additionalFields.lastName) { + // body.last_name = additionalFields.lastName as string; + // } + // if (additionalFields.address) { + // body.address = additionalFields.address as string; + // } + // if (additionalFields.city) { + // body.city = additionalFields.city as string; + // } + // if (additionalFields.state) { + // body.state = additionalFields.state as string; + // } + // if (additionalFields.country) { + // body.country = additionalFields.country as string; + // } + // if (additionalFields.zip) { + // body.zip = additionalFields.zip as string; + // } + // if (additionalFields.phone) { + // body.phone = additionalFields.phone as string; + // } + // if (additionalFields.comments) { + // body.comments = additionalFields.comments as string; + // } + // if (additionalFields.org) { + // body.org = additionalFields.org as string; + // } + // if (additionalFields.jobTitle) { + // body.job_title = additionalFields.jobTitle as string; + // } + // if (additionalFields.purchasingTimeFrame) { + // body.purchasing_time_frame = additionalFields.purchasingTimeFrame as string; + // } + // if (additionalFields.roleInPurchaseProcess) { + // body.role_in_purchase_process = additionalFields.roleInPurchaseProcess as string; + // } + // responseData = await zoomApiRequest.call( + // this, + // 'POST', + // `/meetings/${meetingId}/registrants`, + // body, + // qs + // ); + // } + // if (operation === 'getAll') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrants + // const meetingId = this.getNodeParameter('meetingId', i) as string; + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + // if (additionalFields.occurrenceId) { + // qs.occurrence_id = additionalFields.occurrenceId as string; + // } + // if (additionalFields.status) { + // qs.status = additionalFields.status as string; + // } + // const returnAll = this.getNodeParameter('returnAll', i) as boolean; + // if (returnAll) { + // responseData = await zoomApiRequestAllItems.call(this, 'results', 'GET', `/meetings/${meetingId}/registrants`, {}, qs); + // } else { + // qs.page_size = this.getNodeParameter('limit', i) as number; + // responseData = await zoomApiRequest.call(this, 'GET', `/meetings/${meetingId}/registrants`, {}, qs); + + // } + + // } + // if (operation === 'update') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrantstatus + // const meetingId = this.getNodeParameter('meetingId', i) as string; + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + // if (additionalFields.occurenceId) { + // qs.occurrence_id = additionalFields.occurrenceId as string; + // } + // if (additionalFields.action) { + // body.action = additionalFields.action as string; + // } + // responseData = await zoomApiRequest.call( + // this, + // 'PUT', + // `/meetings/${meetingId}/registrants/status`, + // body, + // qs + // ); + // } + // } + // if (resource === 'webinar') { + // if (operation === 'create') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinarcreate + // const userId = this.getNodeParameter('userId', i) as string; + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + // const settings: Settings = {}; + + + // if (additionalFields.audio) { + // settings.audio = additionalFields.audio as string; + + // } + + // if (additionalFields.alternativeHosts) { + // settings.alternative_hosts = additionalFields.alternativeHosts as string; + + // } + + // if (additionalFields.panelistsVideo) { + // settings.panelists_video = additionalFields.panelistsVideo as boolean; + + // } + // if (additionalFields.hostVideo) { + // settings.host_video = additionalFields.hostVideo as boolean; + + // } + // if (additionalFields.practiceSession) { + // settings.practice_session = additionalFields.practiceSession as boolean; + + // } + // if (additionalFields.autoRecording) { + // settings.auto_recording = additionalFields.autoRecording as string; + + // } + + // if (additionalFields.registrationType) { + // settings.registration_type = additionalFields.registrationType as number; + + // } + // if (additionalFields.approvalType) { + // settings.approval_type = additionalFields.approvalType as number; + + // } + + // body = { + // settings, + // }; + + // if (additionalFields.topic) { + // body.topic = additionalFields.topic as string; + + // } + + // if (additionalFields.type) { + // body.type = additionalFields.type as string; + + // } + + // if (additionalFields.startTime) { + // body.start_time = additionalFields.startTime as string; + + // } + + // if (additionalFields.duration) { + // body.duration = additionalFields.duration as number; + + // } + + + // if (additionalFields.timeZone) { + // body.timezone = additionalFields.timeZone as string; + + // } + + // if (additionalFields.password) { + // body.password = additionalFields.password as string; + + // } + + // if (additionalFields.agenda) { + // body.agenda = additionalFields.agenda as string; + + // } + // responseData = await zoomApiRequest.call( + // this, + // 'POST', + // `/users/${userId}/webinars`, + // body, + // qs + // ); + // } + // if (operation === 'get') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinar + // const webinarId = this.getNodeParameter('webinarId', i) as string; + + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + // if (additionalFields.showPreviousOccurrences) { + // qs.show_previous_occurrences = additionalFields.showPreviousOccurrences as boolean; + + // } + + // if (additionalFields.occurrenceId) { + // qs.occurrence_id = additionalFields.occurrenceId as string; + + // } + + // responseData = await zoomApiRequest.call( + // this, + // 'GET', + // `/webinars/${webinarId}`, + // {}, + // qs + // ); + // } + // if (operation === 'getAll') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinars + // const userId = this.getNodeParameter('userId', i) as string; + // const returnAll = this.getNodeParameter('returnAll', i) as boolean; + // if (returnAll) { + // responseData = await zoomApiRequestAllItems.call(this, 'results', 'GET', `/users/${userId}/webinars`, {}, qs); + // } else { + // qs.page_size = this.getNodeParameter('limit', i) as number; + // responseData = await zoomApiRequest.call(this, 'GET', `/users/${userId}/webinars`, {}, qs); + + // } + // } + // if (operation === 'delete') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinardelete + // const webinarId = this.getNodeParameter('webinarId', i) as string; + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + + + // if (additionalFields.occurrenceId) { + // qs.occurrence_id = additionalFields.occurrenceId; + + // } + + // responseData = await zoomApiRequest.call( + // this, + // 'DELETE', + // `/webinars/${webinarId}`, + // {}, + // qs + // ); + // responseData = { success: true }; + // } + // if (operation === 'update') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinarupdate + // const webinarId = this.getNodeParameter('webinarId', i) as string; + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + // if (additionalFields.occurrenceId) { + // qs.occurrence_id = additionalFields.occurrenceId as string; + + // } + // const settings: Settings = {}; + // if (additionalFields.audio) { + // settings.audio = additionalFields.audio as string; + + // } + // if (additionalFields.alternativeHosts) { + // settings.alternative_hosts = additionalFields.alternativeHosts as string; + + // } + + // if (additionalFields.panelistsVideo) { + // settings.panelists_video = additionalFields.panelistsVideo as boolean; + + // } + // if (additionalFields.hostVideo) { + // settings.host_video = additionalFields.hostVideo as boolean; + + // } + // if (additionalFields.practiceSession) { + // settings.practice_session = additionalFields.practiceSession as boolean; + + // } + // if (additionalFields.autoRecording) { + // settings.auto_recording = additionalFields.autoRecording as string; + + // } + + // if (additionalFields.registrationType) { + // settings.registration_type = additionalFields.registrationType as number; + + // } + // if (additionalFields.approvalType) { + // settings.approval_type = additionalFields.approvalType as number; + + // } + + // body = { + // settings, + // }; + + // if (additionalFields.topic) { + // body.topic = additionalFields.topic as string; + + // } + + // if (additionalFields.type) { + // body.type = additionalFields.type as string; + + // } + + // if (additionalFields.startTime) { + // body.start_time = additionalFields.startTime as string; + + // } + + // if (additionalFields.duration) { + // body.duration = additionalFields.duration as number; + + // } + + + // if (additionalFields.timeZone) { + // body.timezone = additionalFields.timeZone as string; + + // } + + // if (additionalFields.password) { + // body.password = additionalFields.password as string; + + // } + + // if (additionalFields.agenda) { + // body.agenda = additionalFields.agenda as string; + + // } + // responseData = await zoomApiRequest.call( + // this, + // 'PATCH', + // `webinars/${webinarId}`, + // body, + // qs + // ); + // } + // } } if (Array.isArray(responseData)) { returnData.push.apply(returnData, responseData as IDataObject[]); From ef2b477a2848aa31e6c3af1bbc87a33f0668fd8f Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 25 Jun 2020 12:25:53 +0200 Subject: [PATCH 039/155] :bookmark: Release n8n-nodes-base@0.66.0 --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index fa2ca8f79a..9638243632 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.65.0", + "version": "0.66.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From f99da78c1d51e9ee232d0e71d7dfb7fdca147fb3 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 25 Jun 2020 12:27:26 +0200 Subject: [PATCH 040/155] :bookmark: Release n8n-editor-ui@0.48.0 --- packages/editor-ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 48caca7524..9491626ddf 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.47.0", + "version": "0.48.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 24746f57d0f016a6c8804b68c9d745b8a2a39d73 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 25 Jun 2020 12:28:40 +0200 Subject: [PATCH 041/155] :arrow_up: Set n8n-editor-ui@0.48.0 and n8n-nodes-base@0.66.0 on n8n --- packages/cli/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 1628a80b82..ef0edfcc1d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -101,8 +101,8 @@ "mongodb": "^3.5.5", "mysql2": "^2.0.1", "n8n-core": "~0.36.0", - "n8n-editor-ui": "~0.47.0", - "n8n-nodes-base": "~0.65.0", + "n8n-editor-ui": "~0.48.0", + "n8n-nodes-base": "~0.66.0", "n8n-workflow": "~0.33.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", From 91b3c262fc9689edc5eab3f9e172349f614ff7c9 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 25 Jun 2020 12:29:25 +0200 Subject: [PATCH 042/155] :bookmark: Release n8n@0.71.0 --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index ef0edfcc1d..9e23c91021 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.70.0", + "version": "0.71.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From d199e1e638a62c909ec9a3034635c4f5409097db Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Fri, 26 Jun 2020 13:32:48 +0200 Subject: [PATCH 043/155] checkExists modified --- .../nodes/Postmark/GenericFunctions.ts | 48 +++++ .../nodes/Postmark/PostmarkTrigger.node.ts | 197 +++++++----------- 2 files changed, 128 insertions(+), 117 deletions(-) diff --git a/packages/nodes-base/nodes/Postmark/GenericFunctions.ts b/packages/nodes-base/nodes/Postmark/GenericFunctions.ts index d65777d881..5844d28d7c 100644 --- a/packages/nodes-base/nodes/Postmark/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Postmark/GenericFunctions.ts @@ -37,6 +37,8 @@ export async function postmarkApiRequest(this: IExecuteFunctions | IWebhookFunct } options = Object.assign({}, options, option); + console.log(options); + try { return await this.helpers.request!(options); } catch (error) { @@ -44,4 +46,50 @@ export async function postmarkApiRequest(this: IExecuteFunctions | IWebhookFunct } } +// tslint:disable-next-line: no-any +export function convertTriggerObjectToStringArray (webhookObject : any) : string[] { + const triggers = webhookObject.Triggers; + const webhookEvents : string[] = []; + + // Translate Webhook trigger settings to string array + if (triggers.Open.Enabled) { + webhookEvents.push('open'); + } + if (triggers.Open.PostFirstOpenOnly) { + webhookEvents.push('firstOpen'); + } + if (triggers.Click.Enabled) { + webhookEvents.push('click'); + } + if (triggers.Delivery.Enabled) { + webhookEvents.push('delivery'); + } + if (triggers.Bounce.Enabled) { + webhookEvents.push('bounce'); + } + if (triggers.Bounce.IncludeContent) { + webhookEvents.push('bounceContent'); + } + if (triggers.SpamComplaint.Enabled) { + webhookEvents.push('spamComplaint'); + } + if (triggers.SpamComplaint.IncludeContent) { + webhookEvents.push('spamComplaintContent'); + } + if (triggers.SubscriptionChange.Enabled) { + webhookEvents.push('subscriptionChange'); + } + + return webhookEvents; +} + +export function eventExists (currentEvents : string[], webhookEvents: string[]) { + for (const currentEvent of currentEvents) { + if (!webhookEvents.includes(currentEvent)) { + return false; + } + } + return true; +} + diff --git a/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts b/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts index 15af530cad..f6f09afdd4 100644 --- a/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts +++ b/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts @@ -10,7 +10,9 @@ import { } from 'n8n-workflow'; import { - postmarkApiRequest, + convertTriggerObjectToStringArray, + eventExists, + postmarkApiRequest } from './GenericFunctions'; export class PostmarkTrigger implements INodeType { @@ -43,88 +45,54 @@ export class PostmarkTrigger implements INodeType { ], properties: [ { - displayName: 'Open', - name: 'open', - type: 'boolean', - default: false, - description: 'Listing for if the Open webhook is enabled/disabled.', - }, - { - displayName: 'First Open Only', - name: 'postFirstOpenOnly', - type: 'boolean', - default: false, - displayOptions: { - show: { - open: [ - true - ], + displayName: 'Events', + name: 'events', + type: 'multiOptions', + options: [ + { + name: 'Open', + value: 'open', + description: 'Trigger webhook on open.' }, - }, - description: 'Webhook will only post on first open if enabled.', - }, - { - displayName: 'Click', - name: 'click', - type: 'boolean', - default: false, - description: 'Listing for if the Click webhook is enabled/disabled.', - }, - { - displayName: 'Delivery', - name: 'delivery', - type: 'boolean', - default: false, - description: 'Listing for if the Delivery webhook is enabled/disabled.', - }, - { - displayName: 'Bounce', - name: 'bounce', - type: 'boolean', - default: false, - description: 'Listing for if the Bounce webhook is enabled/disabled.', - }, - { - displayName: 'Bounce Include Content', - name: 'bounceIncludeContent', - type: 'boolean', - default: false, - displayOptions: { - show: { - bounce: [ - true - ], + { + name: 'First Open', + value: 'firstOpen', + description: 'Trigger on first open only.' }, - }, - description: 'Webhook will send full bounce content if IncludeContent is enabled.', - }, - { - displayName: 'Spam Complaint', - name: 'spamComplaint', - type: 'boolean', - default: false, - description: 'Listing for if the Spam webhook is enabled/disabled.', - }, - { - displayName: 'Spam Complaint Include Content', - name: 'spamComplaintIncludeContent', - type: 'boolean', - default: false, - displayOptions: { - show: { - spamComplaint: [ - true - ], + { + name: 'Click', + value: 'click', }, - }, - description: 'Webhook will send full spam content if IncludeContent is enabled.', - }, - { - displayName: 'Subscription Change', - name: 'subscriptionChange', - type: 'boolean', - default: false, - description: 'Listing for if the Subscription Change webhook is enabled/disabled.', + { + name: 'Delivery', + value: 'delivery', + }, + { + name: 'Bounce', + value: 'bounce', + }, + { + name: 'Bounce Content', + value: 'bounceContent', + description: 'Webhook will send full bounce content.' + }, + { + name: 'Spam Complaint', + value: 'spamComplaint', + }, + { + name: 'Spam Complaint Content', + value: 'spamComplaintContent', + description: 'Webhook will send full bounce content.' + }, + { + name: 'Subscription Change', + value: 'subscriptionChange', + }, + ], + default: [], + required: true, + description: 'Webhook events that will be enabled for that endpoint.', }, ], @@ -135,23 +103,28 @@ export class PostmarkTrigger implements INodeType { default: { async checkExists(this: IHookFunctions): Promise { const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default'); + const events = this.getNodeParameter('events') as string[]; - if (webhookData.webhookId === undefined) { - // No webhook id is set so no webhook can exist - return false; - } - - // Webhook got created before so check if it still exists - const endpoint = `/webhooks/${webhookData.webhookId}`; + // Get all webhooks + const endpoint = `/webhooks`; const responseData = await postmarkApiRequest.call(this, 'GET', endpoint, {}); - if (responseData.ID === undefined) { + // No webhooks exist + if (responseData.Webhooks.length === 0) { return false; } - else if (responseData.ID === webhookData.id) { - return true; + + // If webhooks exist, check if any match current settings + for (const webhook of responseData.Webhooks) { + if (webhook.Url === webhookUrl && eventExists(events, convertTriggerObjectToStringArray(webhook))) { + webhookData.webhookId = webhook.ID; + // webhook identical to current settings. re-assign webhook id to found webhook. + return true; + } } + return false; }, async create(this: IHookFunctions): Promise { @@ -187,44 +160,34 @@ export class PostmarkTrigger implements INodeType { } }; - const open = this.getNodeParameter('open', 0); - const postFirstOpenOnly = this.getNodeParameter('postFirstOpenOnly', 0); - const click = this.getNodeParameter('click', 0); - const delivery = this.getNodeParameter('delivery', 0); - const bounce = this.getNodeParameter('bounce', 0); - const bounceIncludeContent = this.getNodeParameter('bounceIncludeContent', 0); - const spamComplaint = this.getNodeParameter('spamComplaint', 0); - const spamComplaintIncludeContent = this.getNodeParameter('spamComplaintIncludeContent', 0); - const subscriptionChange = this.getNodeParameter('subscriptionChange', 0); + const events = this.getNodeParameter('events') as string[]; - if (open) { + if (events.includes('open')) { body.Triggers.Open.Enabled = true; - - if (postFirstOpenOnly) { - body.Triggers.Open.PostFirstOpenOnly = true; - } } - if (click) { + if (events.includes('firstOpen')) { + body.Triggers.Open.Enabled = true; + body.Triggers.Open.PostFirstOpenOnly = true; + } + if (events.includes('click')) { body.Triggers.Click.Enabled = true; } - if (delivery) { + if (events.includes('delivery')) { body.Triggers.Delivery.Enabled = true; } - if (bounce) { + if (events.includes('bounce')) { body.Triggers.Bounce.Enabled = true; - - if (bounceIncludeContent) { - body.Triggers.Bounce.IncludeContent = true; - } } - if (spamComplaint) { + if (events.includes('bounceContent')) { + body.Triggers.Bounce.IncludeContent = true; + } + if (events.includes('spamComplaint')) { body.Triggers.SpamComplaint.Enabled = true; - - if (spamComplaintIncludeContent) { - body.Triggers.SpamComplaint.IncludeContent = true; - } } - if (subscriptionChange) { + if (events.includes('spamComplaintContent')) { + body.Triggers.SpamComplaint.IncludeContent = true; + } + if (events.includes('subscriptionChange')) { body.Triggers.SubscriptionChange.Enabled = true; } From 7c9ecdb1722f5c0f80327cab6b43b91459c235d4 Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Fri, 26 Jun 2020 14:56:24 +0200 Subject: [PATCH 044/155] Removed console log --- packages/nodes-base/nodes/Postmark/GenericFunctions.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/nodes-base/nodes/Postmark/GenericFunctions.ts b/packages/nodes-base/nodes/Postmark/GenericFunctions.ts index 5844d28d7c..115fac51f0 100644 --- a/packages/nodes-base/nodes/Postmark/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Postmark/GenericFunctions.ts @@ -37,8 +37,6 @@ export async function postmarkApiRequest(this: IExecuteFunctions | IWebhookFunct } options = Object.assign({}, options, option); - console.log(options); - try { return await this.helpers.request!(options); } catch (error) { From 591630abd16c6a18e803071ca85650aaf800e9d2 Mon Sep 17 00:00:00 2001 From: ricardo Date: Fri, 26 Jun 2020 09:27:41 -0400 Subject: [PATCH 045/155] :bug: Fix timezone issue --- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index 2395f8a318..7f735d33b8 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -319,7 +319,12 @@ export class Zoom implements INodeType { } if (additionalFields.startTime) { - body.start_time = additionalFields.startTime as string; + if (additionalFields.timeZone) { + body.start_time = moment(additionalFields.startTime as string).format('YYYY-MM-DDTHH:mm:ss'); + } else { + // if none timezone it's defined used n8n timezone + body.start_time = moment.tz(additionalFields.startTime as string, this.getTimezone()).format(); + } } if (additionalFields.duration) { @@ -342,6 +347,8 @@ export class Zoom implements INodeType { body.agenda = additionalFields.agenda as string; } + console.log(body); + responseData = await zoomApiRequest.call( this, 'POST', From 3660f535ac4ced653f7257c8c2f9f363f17ba064 Mon Sep 17 00:00:00 2001 From: ricardo Date: Fri, 26 Jun 2020 17:48:07 -0400 Subject: [PATCH 046/155] :zap: Add .svg to gulpfile --- packages/nodes-base/gulpfile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/gulpfile.js b/packages/nodes-base/gulpfile.js index 9771a4017c..58ba6ec51a 100644 --- a/packages/nodes-base/gulpfile.js +++ b/packages/nodes-base/gulpfile.js @@ -1,7 +1,7 @@ const { src, dest } = require('gulp'); function copyIcons() { - return src('nodes/**/*.png') + return src('nodes/**/*.{png,svg}') .pipe(dest('dist/nodes')); } From a6557e32dc0e29f183f0f96db21835b0c7c42664 Mon Sep 17 00:00:00 2001 From: Pablo Estevez Date: Sun, 28 Jun 2020 11:30:26 -0400 Subject: [PATCH 047/155] :zap: Add option to ignore SSL issues to Graph API node (#703) --- .../nodes-base/nodes/Facebook/FacebookGraphApi.node.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts index 59c55a33f4..f1fc3b8881 100644 --- a/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts +++ b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts @@ -137,6 +137,13 @@ export class FacebookGraphApi implements INodeType { placeholder: 'videos', required: false, }, + { + displayName: 'Ignore SSL Issues', + name: 'allowUnauthorizedCerts', + type: 'boolean', + default: false, + description: 'Still download the response even if SSL certificate validation is not possible. (Not recommended)', + }, { displayName: 'Send Binary Data', name: 'sendBinaryData', @@ -301,6 +308,7 @@ export class FacebookGraphApi implements INodeType { qs: { access_token: graphApiCredentials!.accessToken, }, + rejectUnauthorized: !this.getNodeParameter('allowUnauthorizedCerts', itemIndex, false) as boolean, }; if (options !== undefined) { From 4d5c166414c3faade7dce0fa0a59d8c007bb44bb Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Mon, 29 Jun 2020 14:39:43 +0200 Subject: [PATCH 048/155] :rocket: Switch to new docs --- docs/CNAME | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CNAME b/docs/CNAME index 3ea71f7d8f..22a8459481 100644 --- a/docs/CNAME +++ b/docs/CNAME @@ -1 +1 @@ -docs.n8n.io \ No newline at end of file +docs-old.n8n.io From 13f71d3af0308e8a95e3db90d0681d0e344fda3b Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Mon, 29 Jun 2020 09:38:50 -0400 Subject: [PATCH 049/155] :zap: Improvements to Pipedrive node (#707) --- .../nodes/Pipedrive/GenericFunctions.ts | 9 ++- .../nodes/Pipedrive/Pipedrive.node.ts | 77 +++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts index 32e8194359..33bf215b12 100644 --- a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts @@ -5,10 +5,12 @@ import { import { IDataObject, + ILoadOptionsFunctions, } from 'n8n-workflow'; -import { OptionsWithUri } from 'request'; - +import { + OptionsWithUri, +} from 'request'; export interface ICustomInterface { name: string; @@ -33,7 +35,7 @@ export interface ICustomProperties { * @param {object} body * @returns {Promise} */ -export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctions, method: string, endpoint: string, body: IDataObject, query?: IDataObject, formData?: IDataObject, downloadFile?: boolean): Promise { // tslint:disable-line:no-any +export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: IDataObject, query?: IDataObject, formData?: IDataObject, downloadFile?: boolean): Promise { // tslint:disable-line:no-any const credentials = this.getCredentials('pipedriveApi'); if (credentials === undefined) { throw new Error('No credentials got returned!'); @@ -66,6 +68,7 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio } try { + //@ts-ignore const responseData = await this.helpers.request(options); if (downloadFile === true) { diff --git a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts index 8ae9c42630..a5cd93ab9f 100644 --- a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts @@ -1,12 +1,14 @@ import { BINARY_ENCODING, IExecuteFunctions, + ILoadOptionsFunctions, } from 'n8n-core'; import { IDataObject, INodeTypeDescription, INodeExecutionData, INodeType, + INodePropertyOptions, } from 'n8n-workflow'; import { @@ -2047,9 +2049,68 @@ export class Pipedrive implements INodeType { description: 'How many results to return.', }, + // ---------------------------------- + // person:getAll + // ---------------------------------- + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'person', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Filter ID', + name: 'filterId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getFilters', + }, + default: '', + description: 'ID of the filter to use.', + }, + { + displayName: 'First Char', + name: 'firstChar', + type: 'string', + default: '', + description: 'If supplied, only persons whose name starts with the specified letter will be returned ', + }, + ], + }, ], }; + methods = { + loadOptions: { + // Get all the filters to display them to user so that he can + // select them easily + async getFilters(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { data } = await pipedriveApiRequest.call(this, 'GET', '/filters', {}, { type: 'people' }); + for (const filter of data) { + const filterName = filter.name; + const filterId = filter.id; + returnData.push({ + name: filterName, + value: filterId, + }); + } + return returnData; + }, + } + }; + async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); @@ -2453,6 +2514,16 @@ export class Pipedrive implements INodeType { qs.limit = this.getNodeParameter('limit', i) as number; } + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.filterId) { + qs.filter_id = additionalFields.filterId as string; + } + + if (additionalFields.firstChar) { + qs.first_char = additionalFields.firstChar as string; + } + endpoint = `/persons`; } else if (operation === 'update') { @@ -2499,6 +2570,7 @@ export class Pipedrive implements INodeType { } responseData = await pipedriveApiRequest.call(this, requestMethod, endpoint, body, qs, formData, downloadFile); + } if (resource === 'file' && operation === 'download') { @@ -2520,6 +2592,11 @@ export class Pipedrive implements INodeType { items[i].binary![binaryPropertyName] = await this.helpers.prepareBinaryData(responseData.data); } else { + + if (responseData.data === null) { + responseData.data = []; + } + if (Array.isArray(responseData.data)) { returnData.push.apply(returnData, responseData.data as IDataObject[]); } else { From 95068aa132526a3469275b6541c191fd2ea51e1b Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 30 Jun 2020 16:55:17 +0200 Subject: [PATCH 050/155] :whale: Expose port in dockerfile --- docker/images/n8n-custom/Dockerfile | 2 ++ docker/images/n8n-ubuntu/Dockerfile | 2 ++ docker/images/n8n/Dockerfile | 2 ++ 3 files changed, 6 insertions(+) diff --git a/docker/images/n8n-custom/Dockerfile b/docker/images/n8n-custom/Dockerfile index f4e4af4ed7..d12f8f6b08 100644 --- a/docker/images/n8n-custom/Dockerfile +++ b/docker/images/n8n-custom/Dockerfile @@ -42,3 +42,5 @@ COPY --from=builder /data ./ COPY docker/images/n8n-custom/docker-entrypoint.sh /docker-entrypoint.sh ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"] + +EXPOSE 5678/tcp diff --git a/docker/images/n8n-ubuntu/Dockerfile b/docker/images/n8n-ubuntu/Dockerfile index 200506f058..94935f0602 100644 --- a/docker/images/n8n-ubuntu/Dockerfile +++ b/docker/images/n8n-ubuntu/Dockerfile @@ -19,3 +19,5 @@ WORKDIR /data COPY docker-entrypoint.sh /docker-entrypoint.sh ENTRYPOINT ["/docker-entrypoint.sh"] + +EXPOSE 5678/tcp diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile index c0997dcabd..af8f29cc5c 100644 --- a/docker/images/n8n/Dockerfile +++ b/docker/images/n8n/Dockerfile @@ -22,3 +22,5 @@ WORKDIR /data COPY docker-entrypoint.sh /docker-entrypoint.sh ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"] + +EXPOSE 5678/tcp From 574167bf3d40cd5a74597ffa73cee59c248eb09d Mon Sep 17 00:00:00 2001 From: Erin McNulty Date: Tue, 30 Jun 2020 11:59:58 -0400 Subject: [PATCH 051/155] :bug: Changed reponseMode to responseMode (#711) --- packages/node-dev/templates/webhook/simple.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-dev/templates/webhook/simple.ts b/packages/node-dev/templates/webhook/simple.ts index eaf1521e84..ab81ca51d2 100644 --- a/packages/node-dev/templates/webhook/simple.ts +++ b/packages/node-dev/templates/webhook/simple.ts @@ -27,7 +27,7 @@ export class ClassNameReplace implements INodeType { { name: 'default', httpMethod: 'POST', - reponseMode: 'onReceived', + responseMode: 'onReceived', // Each webhook property can either be hardcoded // like the above ones or referenced from a parameter // like the "path" property bellow From bdd63fd54d7a8ffcdf961450751fc6719ddfdd16 Mon Sep 17 00:00:00 2001 From: Sven Schmidt <0xsven@gmail.com> Date: Tue, 30 Jun 2020 18:02:26 +0200 Subject: [PATCH 052/155] =?UTF-8?q?=F0=9F=93=9A=20Fix=20typo=20in=20create?= =?UTF-8?q?-node=20docs=20(#693)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/create-node.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/create-node.md b/docs/create-node.md index aa19393983..183c11c734 100644 --- a/docs/create-node.md +++ b/docs/create-node.md @@ -135,7 +135,7 @@ When a node can perform multiple operations like edit and delete some kind of en Some nodes may need a lot of options. Add only the very important ones to the top level and for all others, create an "Options" parameter where they can be added if needed. This ensures that the interface stays clean and does not unnecessarily confuse people. A good example of that would be the XML node. -### Follow exiting parameter naming guideline +### Follow existing parameter naming guideline There is not much of a guideline yet but if your node can do multiple things, call the parameter which sets the behavior either "mode" (like "Merge" and "XML" node) or "operation" like the most other ones. If these operations can be done on different resources (like "User" or "Order) create a "resource" parameter (like "Pipedrive" and "Trello" node) From 872bc9df75427ffe742cfc169729dfc99210e195 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 30 Jun 2020 20:08:52 +0200 Subject: [PATCH 053/155] :bug: Write env encryption key to config if file does not exist #713 --- packages/core/src/UserSettings.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/src/UserSettings.ts b/packages/core/src/UserSettings.ts index 211341ed25..fb38fc779c 100644 --- a/packages/core/src/UserSettings.ts +++ b/packages/core/src/UserSettings.ts @@ -41,8 +41,13 @@ export async function prepareUserSettings(): Promise { userSettings = {}; } - // Settings and/or key do not exist. So generate a new encryption key - userSettings.encryptionKey = randomBytes(24).toString('base64'); + if (process.env[ENCRYPTION_KEY_ENV_OVERWRITE] !== undefined) { + // Use the encryption key which got set via environment + userSettings.encryptionKey = process.env[ENCRYPTION_KEY_ENV_OVERWRITE]; + } else { + // Generate a new encryption key + userSettings.encryptionKey = randomBytes(24).toString('base64'); + } console.log(`UserSettings got generated and saved to: ${settingsPath}`); From fc4ebfedca4071bfd6f57d8db774bb9a3131e89c Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 30 Jun 2020 20:30:14 +0200 Subject: [PATCH 054/155] :bug: Fix bug that fromFormat field did not get used in pre-check #712 --- packages/nodes-base/nodes/DateTime.node.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/DateTime.node.ts b/packages/nodes-base/nodes/DateTime.node.ts index 953375e8d9..15c33dcffc 100644 --- a/packages/nodes-base/nodes/DateTime.node.ts +++ b/packages/nodes-base/nodes/DateTime.node.ts @@ -243,9 +243,10 @@ export class DateTime implements INodeType { if (currentDate === undefined) { continue; } - if (!moment(currentDate as string | number).isValid()) { + if (options.fromFormat === undefined && !moment(currentDate as string | number).isValid()) { throw new Error('The date input format could not be recognized. Please set the "From Format" field'); } + if (Number.isInteger(currentDate as unknown as number)) { newDate = moment.unix(currentDate as unknown as number); } else { From 6f51657ef8d8a1ddbd9180e1931bbc1b07ff9f74 Mon Sep 17 00:00:00 2001 From: ricardo Date: Tue, 30 Jun 2020 18:46:45 -0400 Subject: [PATCH 055/155] :sparkles: CircleCI-Node --- .../credentials/CircleCiApi.credentials.ts | 17 ++ .../nodes/CircleCi/CircleCi.node.ts | 137 +++++++++++ .../nodes/CircleCi/GenericFunctions.ts | 67 ++++++ .../nodes/CircleCi/PipelineDescription.ts | 222 ++++++++++++++++++ .../nodes-base/nodes/CircleCi/circleCi.png | Bin 0 -> 4787 bytes packages/nodes-base/package.json | 2 + 6 files changed, 445 insertions(+) create mode 100644 packages/nodes-base/credentials/CircleCiApi.credentials.ts create mode 100644 packages/nodes-base/nodes/CircleCi/CircleCi.node.ts create mode 100644 packages/nodes-base/nodes/CircleCi/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/CircleCi/PipelineDescription.ts create mode 100644 packages/nodes-base/nodes/CircleCi/circleCi.png diff --git a/packages/nodes-base/credentials/CircleCiApi.credentials.ts b/packages/nodes-base/credentials/CircleCiApi.credentials.ts new file mode 100644 index 0000000000..88ecdc4fe1 --- /dev/null +++ b/packages/nodes-base/credentials/CircleCiApi.credentials.ts @@ -0,0 +1,17 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class CircleCiApi implements ICredentialType { + name = 'circleCiApi'; + displayName = 'CircleCI API'; + properties = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts b/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts new file mode 100644 index 0000000000..00eded6366 --- /dev/null +++ b/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts @@ -0,0 +1,137 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeTypeDescription, + INodeExecutionData, + INodeType, +} from 'n8n-workflow'; + +import { + pipelineFields, + pipelineOperations, +} from './PipelineDescription'; + +import { + circleciApiRequest, + circleciApiRequestAllItems, +} from './GenericFunctions'; + +export class CircleCi implements INodeType { + description: INodeTypeDescription = { + displayName: 'CircleCI', + name: 'circleCi', + icon: 'file:circleCi.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume CircleCI API', + defaults: { + name: 'CircleCI', + color: '#04AA51', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'circleCiApi', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: ' Pipeline', + value: 'pipeline', + }, + ], + default: 'pipeline', + description: 'Resource to consume.', + }, + ...pipelineOperations, + ...pipelineFields, + ], + }; + + 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; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < length; i++) { + if (resource === 'pipeline') { + if (operation === 'get') { + let slug = this.getNodeParameter('projectSlug', i) as string; + const pipelineNumber = this.getNodeParameter('pipelineNumber', i) as number; + + slug = slug.replace(new RegExp(/\//g), '%2F'); + + const endpoint = `/project/${slug}/pipeline/${pipelineNumber}`; + + responseData = await circleciApiRequest.call(this, 'GET', endpoint, {}, qs); + } + if (operation === 'getAll') { + const filters = this.getNodeParameter('filters', i) as IDataObject; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + let slug = this.getNodeParameter('projectSlug', i) as string; + + slug = slug.replace(new RegExp(/\//g), '%2F'); + + if (filters.branch) { + qs.branch = filters.branch; + } + + const endpoint = `/project/${slug}/pipeline`; + + if (returnAll === true) { + responseData = await circleciApiRequestAllItems.call(this, 'items', 'GET', endpoint, {}, qs); + + } else { + qs.limit = this.getNodeParameter('limit', i) as number; + responseData = await circleciApiRequest.call(this, 'GET', endpoint, {}, qs); + responseData = responseData.items; + responseData = responseData.splice(0, qs.limit); + } + } + + if (operation === 'trigger') { + let slug = this.getNodeParameter('projectSlug', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + slug = slug.replace(new RegExp(/\//g), '%2F'); + + const endpoint = `/project/${slug}/pipeline`; + + const body: IDataObject = {}; + + if (additionalFields.branch) { + body.branch = additionalFields.branch as string; + } + + if (additionalFields.tag) { + body.tag = additionalFields.tag as string; + } + + responseData = await circleciApiRequest.call(this, 'POST', endpoint, body, qs); + } + } + 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/CircleCi/GenericFunctions.ts b/packages/nodes-base/nodes/CircleCi/GenericFunctions.ts new file mode 100644 index 0000000000..fb30950a1a --- /dev/null +++ b/packages/nodes-base/nodes/CircleCi/GenericFunctions.ts @@ -0,0 +1,67 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function circleciApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('circleCiApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + let options: OptionsWithUri = { + headers: { + 'Circle-Token': credentials.apiKey, + 'Accept': 'application/json', + }, + method, + qs, + body, + uri: uri ||`https://circleci.com/api/v2${resource}`, + json: true + }; + options = Object.assign({}, options, option); + if (Object.keys(options.body).length === 0) { + delete options.body; + } + try { + return await this.helpers.request!(options); + } catch (err) { + if (err.response && err.response.body && err.response.body.message) { + // Try to return the error prettier + throw new Error(`CircleCI error response [${err.statusCode}]: ${err.response.body.message}`); + } + + // If that data does not exist for some reason return the actual error + throw err; } +} + +/** + * Make an API request to paginated CircleCI endpoint + * and return all results + */ +export async function circleciApiRequestAllItems(this: IHookFunctions | IExecuteFunctions| ILoadOptionsFunctions, propertyName: string, method: string, resource: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + do { + responseData = await circleciApiRequest.call(this, method, resource, body, query); + returnData.push.apply(returnData, responseData[propertyName]); + query['page-token'] = responseData.next_page_token; + } while ( + responseData.next_page_token !== undefined && + responseData.next_page_token !== null + ); + return returnData; +} diff --git a/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts b/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts new file mode 100644 index 0000000000..66b99f3eb3 --- /dev/null +++ b/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts @@ -0,0 +1,222 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const pipelineOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'pipeline', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a pipeline', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all pipelines', + }, + { + name: 'Trigger', + value: 'trigger', + description: 'Trigger a pipeline', + }, + ], + default: 'get', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const pipelineFields = [ + +/* -------------------------------------------------------------------------- */ +/* pipeline:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Project Slug', + name: 'projectSlug', + type: 'string', + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: '', + description: 'Project slug in the form vcs-slug/org-name/repo-name', + }, + { + displayName: 'Pipeline Number', + name: 'pipelineNumber', + type: 'number', + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: 0, + description: 'The number of the pipeline', + }, +/* -------------------------------------------------------------------------- */ +/* pipeline:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Project Slug', + name: 'projectSlug', + type: 'string', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: '', + description: 'Project slug in the form vcs-slug/org-name/repo-name', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'pipeline', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'pipeline', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Branch', + name: 'branch', + type: 'string', + default: '', + description: 'The name of a vcs branch.', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* pipeline:trigger */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Project Slug', + name: 'projectSlug', + type: 'string', + displayOptions: { + show: { + operation: [ + 'trigger', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: '', + description: 'Project slug in the form vcs-slug/org-name/repo-name', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'pipeline', + ], + operation: [ + 'trigger', + ], + }, + }, + options: [ + { + displayName: 'Branch', + name: 'branch', + type: 'string', + default: '', + description: `The branch where the pipeline ran.
+ The HEAD commit on this branch was used for the pipeline.
+ Note that branch and tag are mutually exclusive.`, + }, + { + displayName: 'Tag', + name: 'tag', + type: 'string', + default: '', + description: `The tag used by the pipeline.
+ The commit that this tag points to was used for the pipeline.
+ Note that branch and tag are mutually exclusive`, + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/CircleCi/circleCi.png b/packages/nodes-base/nodes/CircleCi/circleCi.png new file mode 100644 index 0000000000000000000000000000000000000000..1708a6a3dd604cd31db2718e9052fb312246e0eb GIT binary patch literal 4787 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 literal 0 HcmV?d00001 diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index c19d8e2349..a04293da42 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -37,6 +37,7 @@ "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/ClearbitApi.credentials.js", "dist/credentials/ClickUpApi.credentials.js", @@ -165,6 +166,7 @@ "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/Clearbit/Clearbit.node.js", From d72a7119570025df299acec221bb50314ce71ace Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 1 Jul 2020 15:08:00 +0200 Subject: [PATCH 056/155] :bug: Fix credential issue with ExecuteWorkflow-Node --- packages/cli/src/WorkflowExecuteAdditionalData.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 7a61bfd657..ed20a985f8 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -316,14 +316,14 @@ export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additi // Does not get used so set it simply to empty string const executionId = ''; - // Create new additionalData to have different workflow loaded and to call - // different webooks - const additionalDataIntegrated = await getBase(additionalData.credentials); - additionalDataIntegrated.hooks = getWorkflowHooksIntegrated(mode, executionId, workflowData!, { parentProcessMode: additionalData.hooks!.mode }); - // Get the needed credentials for the current workflow as they will differ to the ones of the // calling workflow. - additionalDataIntegrated.credentials = await WorkflowCredentials(workflowData!.nodes); + const credentials = await WorkflowCredentials(workflowData!.nodes); + + // Create new additionalData to have different workflow loaded and to call + // different webooks + const additionalDataIntegrated = await getBase(credentials); + additionalDataIntegrated.hooks = getWorkflowHooksIntegrated(mode, executionId, workflowData!, { parentProcessMode: additionalData.hooks!.mode }); // Find Start-Node const requiredNodeTypes = ['n8n-nodes-base.start']; From 7de478d502f75f67b458bf350d3a2e795eb98515 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 2 Jul 2020 07:18:08 +0200 Subject: [PATCH 057/155] :bookmark: Release n8n-core@0.37.0 --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 598ae14b23..6dc56f7bea 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.36.0", + "version": "0.37.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 8c19b2fbaed69ef262948834a92761b8b657e503 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 2 Jul 2020 07:19:21 +0200 Subject: [PATCH 058/155] :arrow_up: Set n8n-core@0.37.0 on n8n-nodes-base --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index c19d8e2349..94689feaf3 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -348,7 +348,7 @@ "moment-timezone": "^0.5.28", "mongodb": "^3.5.5", "mysql2": "^2.0.1", - "n8n-core": "~0.36.0", + "n8n-core": "~0.37.0", "nodemailer": "^6.4.6", "pdf-parse": "^1.1.1", "pg-promise": "^9.0.3", From 6720e7593b1046f2a9ef37d4073938b35a12c122 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 2 Jul 2020 07:19:57 +0200 Subject: [PATCH 059/155] :bookmark: Release n8n-nodes-base@0.67.0 --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 94689feaf3..86980d1efa 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.66.0", + "version": "0.67.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 69e95730049f31d93f41bf671d3777fd66cdfd6e Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 2 Jul 2020 07:21:15 +0200 Subject: [PATCH 060/155] :arrow_up: Set n8n-core@0.37.0 and n8n-nodes-base@0.67.0 on n8n --- packages/cli/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 9e23c91021..6516faa21d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -100,9 +100,9 @@ "lodash.get": "^4.4.2", "mongodb": "^3.5.5", "mysql2": "^2.0.1", - "n8n-core": "~0.36.0", + "n8n-core": "~0.37.0", "n8n-editor-ui": "~0.48.0", - "n8n-nodes-base": "~0.66.0", + "n8n-nodes-base": "~0.67.0", "n8n-workflow": "~0.33.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", From a01a764874908dd979fa7041333b80d3814ac3e7 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 2 Jul 2020 07:21:56 +0200 Subject: [PATCH 061/155] :bookmark: Release n8n@0.72.0 --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 6516faa21d..4eaaade97f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.71.0", + "version": "0.72.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 549b26fa3d0766647a4b44061e238f429f7e6312 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 2 Jul 2020 15:07:55 +0200 Subject: [PATCH 062/155] :bug: Fix issue with nodes in stack which do actually not get executed --- packages/core/src/WorkflowExecute.ts | 8 +++++++- packages/workflow/src/Workflow.ts | 8 ++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 1e8b8898a7..c36ffe997e 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -593,9 +593,15 @@ export class WorkflowExecute { } } - this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name; nodeSuccessData = await workflow.runNode(executionData.node, executionData.data, this.runExecutionData, runIndex, this.additionalData, NodeExecuteFunctions, this.mode); + if (nodeSuccessData === undefined) { + // Node did not get executed + nodeSuccessData = null; + } else { + this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name; + } + if (nodeSuccessData === null || nodeSuccessData[0][0] === undefined) { if (executionData.node.alwaysOutputData === true) { nodeSuccessData = nodeSuccessData || []; diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 9a7d3ef1eb..5fbd780524 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -1085,18 +1085,18 @@ export class Workflow { * @returns {(Promise)} * @memberof Workflow */ - async runNode(node: INode, inputData: ITaskDataConnections, runExecutionData: IRunExecutionData, runIndex: number, additionalData: IWorkflowExecuteAdditionalData, nodeExecuteFunctions: INodeExecuteFunctions, mode: WorkflowExecuteMode): Promise { + async runNode(node: INode, inputData: ITaskDataConnections, runExecutionData: IRunExecutionData, runIndex: number, additionalData: IWorkflowExecuteAdditionalData, nodeExecuteFunctions: INodeExecuteFunctions, mode: WorkflowExecuteMode): Promise { if (node.disabled === true) { // If node is disabled simply pass the data through // return NodeRunHelpers. if (inputData.hasOwnProperty('main') && inputData.main.length > 0) { // If the node is disabled simply return the data from the first main input if (inputData.main[0] === null) { - return null; + return undefined; } return [(inputData.main[0] as INodeExecutionData[])]; } - return null; + return undefined; } const nodeType = this.nodeTypes.getByName(node.type); @@ -1112,7 +1112,7 @@ export class Workflow { if (connectionInputData.length === 0) { // No data for node so return - return null; + return undefined; } if (runExecutionData.resultData.lastNodeExecuted === node.name && runExecutionData.resultData.error !== undefined) { From 63280b74078f891d6e7793486ee753db77011a48 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 2 Jul 2020 23:07:28 +0200 Subject: [PATCH 063/155] :bug: Fix build issue --- packages/core/package.json | 2 +- packages/core/src/WorkflowExecute.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 6dc56f7bea..7a4b0c7349 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,7 +45,7 @@ "crypto-js": "3.1.9-1", "lodash.get": "^4.4.2", "mmmagic": "^0.5.2", - "n8n-workflow": "~0.32.0", + "n8n-workflow": "~0.33.0", "p-cancelable": "^2.0.0", "request": "^2.88.2", "request-promise-native": "^1.0.7" diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index c36ffe997e..c30be39ca1 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -459,7 +459,7 @@ export class WorkflowExecute { let executionData: IExecuteData; let executionError: IExecutionError | undefined; let executionNode: INode; - let nodeSuccessData: INodeExecutionData[][] | null; + let nodeSuccessData: INodeExecutionData[][] | null | undefined; let runIndex: number; let startTime: number; let taskData: ITaskData; From ae902589b87f80c3eaf43e5e4b885144d270c8e8 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Fri, 3 Jul 2020 07:31:08 +0200 Subject: [PATCH 064/155] :zap: Temporary fix to repair build --- packages/nodes-base/nodes/Redis/Redis.node.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nodes-base/nodes/Redis/Redis.node.ts b/packages/nodes-base/nodes/Redis/Redis.node.ts index 68b7401205..12263b1d64 100644 --- a/packages/nodes-base/nodes/Redis/Redis.node.ts +++ b/packages/nodes-base/nodes/Redis/Redis.node.ts @@ -402,6 +402,7 @@ export class Redis implements INodeType { } else if (type === 'hash') { const clientHset = util.promisify(client.hset).bind(client); for (const key of Object.keys(value)) { + // @ts-ignore await clientHset(keyName, key, (value as IDataObject)[key]!.toString()); } } else if (type === 'list') { From 1f04c9eaac5c8bcd364e2df60860d4487b3901d2 Mon Sep 17 00:00:00 2001 From: einSelbst Date: Fri, 3 Jul 2020 15:35:58 +0200 Subject: [PATCH 065/155] Fix a few typos in node-dev readme I hope I didn't took it to far :) --- packages/node-dev/README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/node-dev/README.md b/packages/node-dev/README.md index 1c3d8df52f..fa817c9124 100644 --- a/packages/node-dev/README.md +++ b/packages/node-dev/README.md @@ -127,7 +127,7 @@ export class MyNode implements INodeType { The "description" property has to be set on all nodes because it contains all the base information. Additionally do all nodes have to have exactly one of the -following methods defined which contains the the actual logic: +following methods defined which contains the actual logic: **Regular node** @@ -138,8 +138,8 @@ Method get called when the workflow gets executed By default always `execute` should be used especially when creating a third-party integration. The reason for that is that it is way more flexible and allows to, for example, return a different amount of items than it received -as input. This is very important when a node should query data like return -all users. In that case, does the node normally just receive one input-item +as input. This is very important when a node should query data like *return +all users*. In that case, does the node normally just receive one input-item but returns as many as users exist. So in doubt always `execute` should be used! @@ -188,10 +188,10 @@ The following properties can be set in the node description: - **outputs** [required]: Types of outputs the node has (currently only "main" exists) and the amount - **outputNames** [optional]: In case a node has multiple outputs names can be set that users know what data to expect - **maxNodes** [optional]: If not an unlimited amount of nodes of that type can exist in a workflow the max-amount can be specified - - **name** [required]: Nme of the node (for n8n to use internally in camelCase) + - **name** [required]: Name of the node (for n8n to use internally, in camelCase) - **properties** [required]: Properties which get displayed in the Editor UI and can be set by the user - **subtitle** [optional]: Text which should be displayed underneath the name of the node in the Editor UI (can be an expression) - - **version** [required]: Version of the node. Currently always "1" (integer). For future usage does not get used yet. + - **version** [required]: Version of the node. Currently always "1" (integer). For future usage, does not get used yet. - **webhooks** [optional]: Webhooks the node should listen to @@ -200,12 +200,12 @@ The following properties can be set in the node description: The following properties can be set in the node properties: - **default** [required]: Default value of the property - - **description** [required]: Description to display users in Editor UI - - **displayName** [required]: Name to display users in Editor UI + - **description** [required]: Description that is displayed to users in the Editor UI + - **displayName** [required]: Name that is displayed to users in the Editor UI - **displayOptions** [optional]: Defines logic to decide if a property should be displayed or not - - **name** [required]: Name of the property (for n8n to use internally in camelCase) + - **name** [required]: Name of the property (for n8n to use internally, in camelCase) - **options** [optional]: The options the user can select when type of property is "collection", "fixedCollection" or "options" - - **placeholder** [optional]: Placeholder text to display users in Editor UI + - **placeholder** [optional]: Placeholder text that is displayed to users in the Editor UI - **type** [required]: Type of the property. If it is for example a "string", "number", ... - **typeOptions** [optional]: Additional options for type. Like for example the min or max value of a number - **required** [optional]: Defines if the value has to be set or if it can stay empty @@ -215,11 +215,11 @@ The following properties can be set in the node properties: The following properties can be set in the node property options. -All properties are optional. The most, however, work only work when the node-property is of a specfic type. +All properties are optional. However, most only work when the node-property is of a specfic type. - - **alwaysOpenEditWindow** [type: string]: If set then the "Editor Window" will always open when the user tries to edit the field. Is helpful when long texts normally get used in the property + - **alwaysOpenEditWindow** [type: string]: If set then the "Editor Window" will always open when the user tries to edit the field. Helpful if long text is typically used in the property. - **loadOptionsMethod** [type: options]: Method to use to load options from an external service - - **maxValue** [type: number]: Maximal value of the number + - **maxValue** [type: number]: Maximum value of the number - **minValue** [type: number]: Minimum value of the number - **multipleValues** [type: all]: If set the property gets turned into an Array and the user can add multiple values - **multipleValueButtonText** [type: all]: Custom text for add button in case "multipleValues" got set From fe56c8778d69cc13442000b6ebbeefa117a2133b Mon Sep 17 00:00:00 2001 From: Innokenty Lebedev Date: Sun, 5 Jul 2020 12:19:13 +0300 Subject: [PATCH 066/155] :bug: Fix slack as_user (#708) * Ignore as_user only when it is false * Update as_user description --- .../nodes/Slack/MessageDescription.ts | 22 ++++++++++++++++++- packages/nodes-base/nodes/Slack/Slack.node.ts | 5 +---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/nodes-base/nodes/Slack/MessageDescription.ts b/packages/nodes-base/nodes/Slack/MessageDescription.ts index 8d91663155..6619c51812 100644 --- a/packages/nodes-base/nodes/Slack/MessageDescription.ts +++ b/packages/nodes-base/nodes/Slack/MessageDescription.ts @@ -91,7 +91,7 @@ export const messageFields = [ ], }, }, - description: 'Post the message as authenticated user instead of bot.', + description: 'Post the message as authenticated user instead of bot. Works only with user token.', }, { displayName: 'User Name', @@ -486,6 +486,26 @@ export const messageFields = [ }, description: `Timestamp of the message to be updated.`, }, + { + displayName: 'As User', + name: 'as_user', + type: 'boolean', + default: false, + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + operation: [ + 'update' + ], + resource: [ + 'message', + ], + }, + }, + description: 'Pass true to update the message as the authed user. Works only with user token.', + }, { displayName: 'Update Fields', name: 'updateFields', diff --git a/packages/nodes-base/nodes/Slack/Slack.node.ts b/packages/nodes-base/nodes/Slack/Slack.node.ts index befe931fb5..d6df14ab41 100644 --- a/packages/nodes-base/nodes/Slack/Slack.node.ts +++ b/packages/nodes-base/nodes/Slack/Slack.node.ts @@ -452,12 +452,9 @@ export class Slack implements INodeType { } if (body.as_user === false) { body.username = this.getNodeParameter('username', i) as string; + delete body.as_user; } - // ignore body.as_user as it's deprecated - - delete body.as_user; - if (!jsonParameters) { const attachments = this.getNodeParameter('attachments', i, []) as unknown as Attachment[]; const blocksUi = (this.getNodeParameter('blocksUi', i, []) as IDataObject).blocksValues as IDataObject[]; From 9c58ca8f77eb5217b60517c179dffb94d56bc275 Mon Sep 17 00:00:00 2001 From: Shraddha Shaligram Date: Sun, 5 Jul 2020 02:33:05 -0700 Subject: [PATCH 067/155] :zap: Move Google Task title to top level (#718) --- .../nodes/Google/Task/GoogleTasks.node.ts | 8 ++------ .../nodes/Google/Task/TaskDescription.ts | 15 ++++++++------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/nodes-base/nodes/Google/Task/GoogleTasks.node.ts b/packages/nodes-base/nodes/Google/Task/GoogleTasks.node.ts index 8e2f06dcf5..603bc1af5e 100644 --- a/packages/nodes-base/nodes/Google/Task/GoogleTasks.node.ts +++ b/packages/nodes-base/nodes/Google/Task/GoogleTasks.node.ts @@ -1,6 +1,6 @@ import { IExecuteFunctions, - } from 'n8n-core'; +} from 'n8n-core'; import { IDataObject, @@ -102,6 +102,7 @@ export class GoogleTasks implements INodeType { body = {}; //https://developers.google.com/tasks/v1/reference/tasks/insert const taskId = this.getNodeParameter('task', i) as string; + body.title = this.getNodeParameter('title', i) as string; const additionalFields = this.getNodeParameter( 'additionalFields', i @@ -121,11 +122,6 @@ export class GoogleTasks implements INodeType { if (additionalFields.notes) { body.notes = additionalFields.notes as string; } - - if (additionalFields.title) { - body.title = additionalFields.title as string; - } - if (additionalFields.dueDate) { body.dueDate = additionalFields.dueDate as string; } diff --git a/packages/nodes-base/nodes/Google/Task/TaskDescription.ts b/packages/nodes-base/nodes/Google/Task/TaskDescription.ts index 8030f0a7f3..3300572f2c 100644 --- a/packages/nodes-base/nodes/Google/Task/TaskDescription.ts +++ b/packages/nodes-base/nodes/Google/Task/TaskDescription.ts @@ -70,6 +70,13 @@ export const taskFields = [ }, default: '', }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'Title of the task.', + }, { displayName: 'Additional Fields', name: 'additionalFields', @@ -146,13 +153,7 @@ export const taskFields = [ default: '', description: 'Current status of the task.', }, - { - displayName: 'Title', - name: 'title', - type: 'string', - default: '', - description: 'Title of the task.', - }, + ], }, /* -------------------------------------------------------------------------- */ From 62612d0ad4a62a0d143bea9d9cd467d68a92dacd Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 5 Jul 2020 13:03:50 +0200 Subject: [PATCH 068/155] :books: Removed docs-folders as they moved to n8n-io/n8n-docs --- docs/.nojekyll | 0 docs/CNAME | 1 - docs/README.md | 10 -- docs/_sidebar.md | 43 ------ docs/configuration.md | 244 ------------------------------- docs/create-node.md | 145 ------------------- docs/data-structure.md | 39 ----- docs/database.md | 109 -------------- docs/development.md | 3 - docs/docker.md | 7 - docs/faq.md | 47 ------ docs/images/n8n-logo.png | Bin 2675 -> 0 bytes docs/images/n8n-screenshot.png | Bin 129698 -> 0 bytes docs/index.html | 53 ------- docs/key-components.md | 25 ---- docs/keyboard-shortcuts.md | 28 ---- docs/license.md | 5 - docs/node-basics.md | 76 ---------- docs/nodes.md | 247 -------------------------------- docs/quick-start.md | 43 ------ docs/security.md | 13 -- docs/sensitive-data.md | 18 --- docs/server-setup.md | 183 ----------------------- docs/setup.md | 35 ----- docs/start-workflows-via-cli.md | 15 -- docs/test.md | 3 - docs/troubleshooting.md | 58 -------- docs/tutorials.md | 26 ---- docs/workflow.md | 111 -------------- 29 files changed, 1587 deletions(-) delete mode 100644 docs/.nojekyll delete mode 100644 docs/CNAME delete mode 100644 docs/README.md delete mode 100644 docs/_sidebar.md delete mode 100644 docs/configuration.md delete mode 100644 docs/create-node.md delete mode 100644 docs/data-structure.md delete mode 100644 docs/database.md delete mode 100644 docs/development.md delete mode 100644 docs/docker.md delete mode 100644 docs/faq.md delete mode 100644 docs/images/n8n-logo.png delete mode 100644 docs/images/n8n-screenshot.png delete mode 100644 docs/index.html delete mode 100644 docs/key-components.md delete mode 100644 docs/keyboard-shortcuts.md delete mode 100644 docs/license.md delete mode 100644 docs/node-basics.md delete mode 100644 docs/nodes.md delete mode 100644 docs/quick-start.md delete mode 100644 docs/security.md delete mode 100644 docs/sensitive-data.md delete mode 100644 docs/server-setup.md delete mode 100644 docs/setup.md delete mode 100644 docs/start-workflows-via-cli.md delete mode 100644 docs/test.md delete mode 100644 docs/troubleshooting.md delete mode 100644 docs/tutorials.md delete mode 100644 docs/workflow.md diff --git a/docs/.nojekyll b/docs/.nojekyll deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 22a8459481..0000000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -docs-old.n8n.io diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 3454cc15e2..0000000000 --- a/docs/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# n8n Documentation - -This is the documentation of n8n, a free and open [fair-code](http://faircode.io) licensed node-based Workflow Automation Tool. - -It covers everything from setup to usage and development. It is still a work in progress and all contributions are welcome. - - -## What is n8n? - -n8n (pronounced nodemation) helps you to interconnect every app with an API in the world with each other to share and manipulate its data without a single line of code. It is an easy to use, user-friendly and highly customizable service, which uses an intuitive user interface for you to design your unique workflows very fast. Hosted on your server and not based in the cloud, it keeps your sensible data very secure in your own trusted database. diff --git a/docs/_sidebar.md b/docs/_sidebar.md deleted file mode 100644 index 6cbe725286..0000000000 --- a/docs/_sidebar.md +++ /dev/null @@ -1,43 +0,0 @@ -- Home - - - [Welcome](/) - -- Getting started - - - [Key Components](key-components.md) - - [Quick Start](quick-start.md) - - [Setup](setup.md) - - [Tutorials](tutorials.md) - - [Docker](docker.md) - -- Advanced - - - [Configuration](configuration.md) - - [Data Structure](data-structure.md) - - [Database](database.md) - - [Keyboard Shortcuts](keyboard-shortcuts.md) - - [Node Basics](node-basics.md) - - [Nodes](nodes.md) - - [Security](security.md) - - [Sensitive Data](sensitive-data.md) - - [Server Setup](server-setup.md) - - [Start Workflows via CLI](start-workflows-via-cli.md) - - [Workflow](workflow.md) - -- Development - - - [Create Node](create-node.md) - - [Development](development.md) - - -- Other - - - [FAQ](faq.md) - - [License](license.md) - - [Troubleshooting](troubleshooting.md) - - -- Links - - - [![Jobs](https://n8n.io/favicon.ico ':size=16')Jobs](https://jobs.n8n.io) - - [![Website](https://n8n.io/favicon.ico ':size=16')n8n.io](https://n8n.io) diff --git a/docs/configuration.md b/docs/configuration.md deleted file mode 100644 index 63b8c95f12..0000000000 --- a/docs/configuration.md +++ /dev/null @@ -1,244 +0,0 @@ - - -# Configuration - -It is possible to change some of the n8n defaults via special environment variables. -The ones that currently exist are: - - -## Publish - -Sets how n8n should be made available. - -```bash -# The port n8n should be made available on -N8N_PORT=5678 - -# The IP address n8n should listen on -N8N_LISTEN_ADDRESS=0.0.0.0 - -# This ones are currently only important for the webhook URL creation. -# So if "WEBHOOK_TUNNEL_URL" got set they do get ignored. It is however -# encouraged to set them correctly anyway in case they will become -# important in the future. -N8N_PROTOCOL=https -N8N_HOST=n8n.example.com -``` - - -## Base URL - -Tells the frontend how to reach the REST API of the backend. - -```bash -export VUE_APP_URL_BASE_API="https://n8n.example.com/" -``` - - -## Execution Data Manual Runs - -n8n creates a random encryption key automatically on the first launch and saves -it in the `~/.n8n` folder. That key is used to encrypt the credentials before -they get saved to the database. It is also possible to overwrite that key and -set it via an environment variable. - -```bash -export N8N_ENCRYPTION_KEY="" -``` - - -## Execution Data Manual Runs - -Normally executions which got started via the Editor UI will not be saved as -they are normally only for testing and debugging. That default can be changed -with this environment variable. - -```bash -export EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS=true -``` - -This setting can also be overwritten on a per workflow basis in the workflow -settings in the Editor UI. - - -## Execution Data Error/Success - -When a workflow gets executed, it will save the result in the database. That's -the case for executions that succeeded and for the ones that failed. The -default behavior can be changed like this: - -```bash -export EXECUTIONS_DATA_SAVE_ON_ERROR=none -export EXECUTIONS_DATA_SAVE_ON_SUCCESS=none -``` - -Possible values are: - - **all**: Saves all data - - **none**: Does not save anything (recommended if a workflow runs very often and/or processes a lot of data, set up "Error Workflow" instead) - -These settings can also be overwritten on a per workflow basis in the workflow -settings in the Editor UI. - - -## Execute In Same Process - -All workflows get executed in their own separate process. This ensures that all CPU cores -get used and that they do not block each other on CPU intensive tasks. Additionally, this makes sure that -the crash of one execution does not take down the whole application. The disadvantage is, however, -that it slows down the start-time considerably and uses much more memory. So in case the -workflows are not CPU intensive and they have to start very fast, it is possible to run them -all directly in the main-process with this setting. - -```bash -export EXECUTIONS_PROCESS=main -``` - - -## Exclude Nodes - -It is possible to not allow users to use nodes of a specific node type. For example, if you -do not want that people can write data to the disk with the "n8n-nodes-base.writeBinaryFile" -node and that they cannot execute commands with the "n8n-nodes-base.executeCommand" node, you can -set the following: - -```bash -export NODES_EXCLUDE="[\"n8n-nodes-base.executeCommand\",\"n8n-nodes-base.writeBinaryFile\"]" -``` - - -## Custom Nodes Location - -Every user can add custom nodes that get loaded by n8n on startup. The default -location is in the subfolder `.n8n/custom` of the user who started n8n. -Additional folders can be defined with an environment variable. - -```bash -export N8N_CUSTOM_EXTENSIONS="/home/jim/n8n/custom-nodes;/data/n8n/nodes" -``` - - -## Use built-in and external modules in Function-Nodes - -For security reasons, importing modules is restricted by default in the Function-Nodes. -It is, however, possible to lift that restriction for built-in and external modules by -setting the following environment variables: -- `NODE_FUNCTION_ALLOW_BUILTIN`: For builtin modules -- `NODE_FUNCTION_ALLOW_EXTERNAL`: For external modules sourced from n8n/node_modules directory. External module support is disabled when env variable is not set. - -```bash -# Allows usage of all builtin modules -export NODE_FUNCTION_ALLOW_BUILTIN=* - -# Allows usage of only crypto -export NODE_FUNCTION_ALLOW_BUILTIN=crypto - -# Allows usage of only crypto and fs -export NODE_FUNCTION_ALLOW_BUILTIN=crypto,fs - -# Allow usage of external npm modules. Wildcard matching is not supported. -export NODE_FUNCTION_ALLOW_EXTERNAL=moment,lodash -``` - - -## SSL - -It is possible to start n8n with SSL enabled by supplying a certificate to use: - - -```bash -export N8N_PROTOCOL=https -export N8N_SSL_KEY=/data/certs/server.key -export N8N_SSL_CERT=/data/certs/server.pem -``` - - - -## Timezone - -The timezone is set by default to "America/New_York". For instance, it is used by the -Cron node to know at what time the workflow should be started. To set a different -default timezone simply set `GENERIC_TIMEZONE` to the appropriate value. For example, -if you want to set the timezone to Berlin (Germany): - -```bash -export GENERIC_TIMEZONE="Europe/Berlin" -``` - -You can find the name of your timezone here: -[https://momentjs.com/timezone/](https://momentjs.com/timezone/) - - -## User Folder - -User-specific data like the encryption key, SQLite database file, and -the ID of the tunnel (if used) gets saved by default in the subfolder -`.n8n` of the user who started n8n. It is possible to overwrite the -user-folder via an environment variable. - -```bash -export N8N_USER_FOLDER="/home/jim/n8n" -``` - - -## Webhook URL - -The webhook URL will normally be created automatically by combining -`N8N_PROTOCOL`, `N8N_HOST` and `N8N_PORT`. However, if n8n runs behind a -reverse proxy that would not work. That's because n8n runs internally -on port 5678 but is exposed to the web via the reverse proxy on port 443. In -that case, it is important to set the webhook URL manually so that it can be -displayed correctly in the Editor UI and even more important is that the correct -webhook URLs get registred with the external services. - -```bash -export WEBHOOK_TUNNEL_URL="https://n8n.example.com/" -``` - - -## Configuration via file - -It is also possible to configure n8n using a configuration file. - -It is not necessary to define all values but only the ones that should be -different from the defaults. - -If needed multiple files can also be supplied to. For example, have generic -base settings and some specific ones depending on the environment. - -The path to the JSON configuration file to use can be set using the environment -variable `N8N_CONFIG_FILES`. - -```bash -# Single file -export N8N_CONFIG_FILES=/folder/my-config.json - -# Multiple files can be comma-separated -export N8N_CONFIG_FILES=/folder/my-config.json,/folder/production.json -``` - -A possible configuration file could look like this: -```json -{ - "executions": { - "process": "main", - "saveDataOnSuccess": "none" - }, - "generic": { - "timezone": "Europe/Berlin" - }, - "security": { - "basicAuth": { - "active": true, - "user": "frank", - "password": "some-secure-password" - } - }, - "nodes": { - "exclude": "[\"n8n-nodes-base.executeCommand\",\"n8n-nodes-base.writeBinaryFile\"]" - } -} -``` - -All possible values which can be set and their defaults can be found here: - -[https://github.com/n8n-io/n8n/blob/master/packages/cli/config/index.ts](https://github.com/n8n-io/n8n/blob/master/packages/cli/config/index.ts) diff --git a/docs/create-node.md b/docs/create-node.md deleted file mode 100644 index 183c11c734..0000000000 --- a/docs/create-node.md +++ /dev/null @@ -1,145 +0,0 @@ -# Create Node - -It is quite easy to create your own nodes in n8n. Mainly three things have to be defined: - - 1. Generic information like name, description, image/icon - 1. The parameters to display via which the user can interact with it - 1. The code to run once the node gets executed - -To simplify the development process, we created a very basic CLI which creates boilerplate code to get started, builds the node (as they are written in TypeScript), and copies it to the correct location. - - -## Create the first basic node - - 1. Install the n8n-node-dev CLI: `npm install -g n8n-node-dev` - 1. Create and go into the newly created folder in which you want to keep the code of the node - 1. Use CLI to create boilerplate node code: `n8n-node-dev new` - 1. Answer the questions (the “Execute” node type is the regular node type that you probably want to create). - It will then create the node in the current folder. - 1. Program… Add the functionality to the node - 1. Build the node and copy to correct location: `n8n-node-dev build` - That command will build the JavaScript version of the node from the TypeScript code and copy it to the user folder where custom nodes get read from `~/.n8n/custom/` - 1. Restart n8n and refresh the window so that the new node gets displayed - - -## Create own custom n8n-nodes-module - -If you want to create multiple custom nodes which are either: - - - Only for yourself/your company - - Are only useful for a small number of people - - Require many or large dependencies - -It is best to create your own `n8n-nodes-module` which can be installed separately. -That is a simple npm package that contains the nodes and is set up in a way -that n8n can automatically find and load them on startup. - -When creating such a module the following rules have to be followed that n8n -can automatically find the nodes in the module: - - - The name of the module has to start with `n8n-nodes-` - - The `package.json` file has to contain a key `n8n` with the paths to nodes and credentials - - The module has to be installed alongside n8n - -An example starter module which contains one node and credentials and implements -the above can be found here: - -[https://github.com/n8n-io/n8n-nodes-starter](https://github.com/n8n-io/n8n-nodes-starter) - - -### Setup to use n8n-nodes-module - -To use a custom `n8n-nodes-module`, it simply has to be installed alongside n8n. -For example like this: - -```bash -# Create folder for n8n installation -mkdir my-n8n -cd my-n8n - -# Install n8n -npm install n8n - -# Install custom nodes module -npm install n8n-nodes-my-custom-nodes - -# Start n8n -n8n -``` - - -### Development/Testing of custom n8n-nodes-module - -This works in the same way as for any other npm module. - -Execute in the folder which contains the code of the custom `n8n-nodes-module` -which should be loaded with n8n: - -```bash -# Build the code -npm run build - -# "Publish" the package locally -npm link -``` - -Then in the folder in which n8n is installed: - -```bash -# "Install" the above locally published module -npm link n8n-nodes-my-custom-nodes - -# Start n8n -n8n -``` - - - -## Node Development Guidelines - - -Please make sure that everything works correctly and that no unnecessary code gets added. It is important to follow the following guidelines: - - -### Do not change incoming data - -Never change the incoming data a node receives (which can be queried with `this.getInputData()`) as it gets shared by all nodes. If data has to get added, changed or deleted it has to be cloned and the new data returned. If that is not done, sibling nodes which execute after the current one will operate on the altered data and would process different data than they were supposed to. -It is however not needed to always clone all the data. If a node for, example only, changes only the binary data but not the JSON data, a new item can be created which reuses the reference to the JSON item. - -An example can be seen in the code of the [ReadBinaryFile-Node](https://github.com/n8n-io/n8n/blob/master/packages/nodes-base/nodes/ReadBinaryFile.node.ts#L69-L83). - - -### Write nodes in TypeScript - -All code of n8n is written in TypeScript and hence, the nodes should also be written in TypeScript. That makes development easier, faster, and avoids at least some bugs. - - -### Use the built in request library - -Some third-party services have their own libraries on npm which make it easier to create an integration. It can be quite tempting to use them. The problem with those is that you add another dependency and not just one you add but also all the dependencies of the dependencies. This means more and more code gets added, has to get loaded, can introduce security vulnerabilities, bugs and so on. So please use the built-in module which can be used like this: - -```typescript -const response = await this.helpers.request(options); -``` - -That is simply using the npm package [`request-promise-native`](https://github.com/request/request-promise-native) which is the basic npm `request` module but with promises. For a full set of `options` consider looking at [the underlying `request` options documentation](https://github.com/request/request#requestoptions-callback). - - -### Reuse parameter names - -When a node can perform multiple operations like edit and delete some kind of entity, for both operations, it would need an entity-id. Do not call them "editId" and "deleteId" simply call them "id". n8n can handle multiple parameters with the same name without a problem as long as only one is visible. To make sure that is the case, the "displayOptions" can be used. By keeping the same name, the value can be kept if a user switches the operation from "edit" to "delete". - - -### Create an "Options" parameter - -Some nodes may need a lot of options. Add only the very important ones to the top level and for all others, create an "Options" parameter where they can be added if needed. This ensures that the interface stays clean and does not unnecessarily confuse people. A good example of that would be the XML node. - - -### Follow existing parameter naming guideline - -There is not much of a guideline yet but if your node can do multiple things, call the parameter which sets the behavior either "mode" (like "Merge" and "XML" node) or "operation" like the most other ones. If these operations can be done on different resources (like "User" or "Order) create a "resource" parameter (like "Pipedrive" and "Trello" node) - - -### Node Icons - -Check existing node icons as a reference when you create own ones. The resolution of an icon should be 60x60px and saved as PNG. diff --git a/docs/data-structure.md b/docs/data-structure.md deleted file mode 100644 index daeea8474d..0000000000 --- a/docs/data-structure.md +++ /dev/null @@ -1,39 +0,0 @@ -# Data Structure - -For "basic usage" it is not necessarily needed to understand how the data that -gets passed from one node to another is structured. However, it becomes important if you want to: - - - create your own node - - write custom expressions - - use the Function or Function Item node - - you want to get the most out of n8n - - -In n8n, all the data that is passed between nodes is an array of objects. It has the following structure: - -```json -[ - { - // Each item has to contain a "json" property. But it can be an empty object like {}. - // Any kind of JSON data is allowed. So arrays and the data being deeply nested is fine. - json: { // The actual data n8n operates on (required) - // This data is only an example it could be any kind of JSON data - jsonKeyName: 'keyValue', - anotherJsonKey: { - lowerLevelJsonKey: 1 - } - }, - // Binary data of item. The most items in n8n do not contain any (optional) - binary: { - // The key-name "binaryKeyName" is only an example. Any kind of key-name is possible. - binaryKeyName: { - data: '....', // Base64 encoded binary data (required) - mimeType: 'image/png', // Optional but should be set if possible (optional) - fileExtension: 'png', // Optional but should be set if possible (optional) - fileName: 'example.png', // Optional but should be set if possible (optional) - } - } - }, - ... -] -``` diff --git a/docs/database.md b/docs/database.md deleted file mode 100644 index 041520cf15..0000000000 --- a/docs/database.md +++ /dev/null @@ -1,109 +0,0 @@ -# Database - -By default, n8n uses SQLite to save credentials, past executions, and workflows. However, -n8n also supports MongoDB and PostgresDB. - - -## Shared Settings - -The following environment variables get used by all databases: - - - `DB_TABLE_PREFIX` (default: '') - Prefix for table names - - -## MongoDB - -!> **WARNING**: Use PostgresDB, if possible! MongoDB has problems saving large - amounts of data in a document, among other issues. So, support - may be dropped in the future. - -To use MongoDB as the database, you can provide the following environment variables like -in the example below: - - `DB_TYPE=mongodb` - - `DB_MONGODB_CONNECTION_URL=` - -Replace the following placeholders with the actual data: - - MONGO_DATABASE - - MONGO_HOST - - MONGO_PORT - - MONGO_USER - - MONGO_PASSWORD - -```bash -export DB_TYPE=mongodb -export DB_MONGODB_CONNECTION_URL=mongodb://MONGO_USER:MONGO_PASSWORD@MONGO_HOST:MONGO_PORT/MONGO_DATABASE -n8n start -``` - - -## PostgresDB - -To use PostgresDB as the database, you can provide the following environment variables - - `DB_TYPE=postgresdb` - - `DB_POSTGRESDB_DATABASE` (default: 'n8n') - - `DB_POSTGRESDB_HOST` (default: 'localhost') - - `DB_POSTGRESDB_PORT` (default: 5432) - - `DB_POSTGRESDB_USER` (default: 'root') - - `DB_POSTGRESDB_PASSWORD` (default: empty) - - `DB_POSTGRESDB_SCHEMA` (default: 'public') - - -```bash -export DB_TYPE=postgresdb -export DB_POSTGRESDB_DATABASE=n8n -export DB_POSTGRESDB_HOST=postgresdb -export DB_POSTGRESDB_PORT=5432 -export DB_POSTGRESDB_USER=n8n -export DB_POSTGRESDB_PASSWORD=n8n -export DB_POSTGRESDB_SCHEMA=n8n - -n8n start -``` - -## MySQL / MariaDB - -The compatibility with MySQL/MariaDB has been tested. Even then, it is advisable to observe the operation of the application with this database as this option has been recently added. If you spot any problems, feel free to submit a burg report or a pull request. - -To use MySQL as database you can provide the following environment variables: - - `DB_TYPE=mysqldb` or `DB_TYPE=mariadb` - - `DB_MYSQLDB_DATABASE` (default: 'n8n') - - `DB_MYSQLDB_HOST` (default: 'localhost') - - `DB_MYSQLDB_PORT` (default: 3306) - - `DB_MYSQLDB_USER` (default: 'root') - - `DB_MYSQLDB_PASSWORD` (default: empty) - - -```bash -export DB_TYPE=mysqldb -export DB_MYSQLDB_DATABASE=n8n -export DB_MYSQLDB_HOST=mysqldb -export DB_MYSQLDB_PORT=3306 -export DB_MYSQLDB_USER=n8n -export DB_MYSQLDB_PASSWORD=n8n - -n8n start -``` - -## SQLite - -This is the default database that gets used if nothing is defined. - -The database file is located at: -`~/.n8n/database.sqlite` - - -## Other Databases - -Currently, only the databases mentioned above are supported. n8n internally uses -[TypeORM](https://typeorm.io), so adding support for the following databases -should not be too much work: - - - CockroachDB - - Microsoft SQL - - Oracle - -If you cannot use any of the currently supported databases for some reason and -you can code, we'd appreciate your support in the form of a pull request. If not, you can request -for support here: - -[https://community.n8n.io/c/feature-requests/cli](https://community.n8n.io/c/feature-requests/cli) diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index d7d8de4744..0000000000 --- a/docs/development.md +++ /dev/null @@ -1,3 +0,0 @@ -# Development - -Have you found a bug :bug:? Or maybe you have a nice feature :sparkles: to contribute? The [CONTRIBUTING guide](https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md) will help you get your development environment ready in minutes. diff --git a/docs/docker.md b/docs/docker.md deleted file mode 100644 index 317b5b9552..0000000000 --- a/docs/docker.md +++ /dev/null @@ -1,7 +0,0 @@ -# Docker - -Detailed information about how to run n8n in Docker can be found in the README -of the [Docker Image](https://github.com/n8n-io/n8n/blob/master/docker/images/n8n/README.md). - -A basic step by step example setup of n8n with docker-compose and Let's Encrypt is available on the -[Server Setup](server-setup.md) page. diff --git a/docs/faq.md b/docs/faq.md deleted file mode 100644 index 2b03a0b76d..0000000000 --- a/docs/faq.md +++ /dev/null @@ -1,47 +0,0 @@ -# FAQ - -## Integrations - - -### Can you create an integration for service X? - -You can request new integrations to be added to our forum. There is a special section for that where -other users can also upvote it so that we know which integrations are important and should be -created next. Request a new feature [here](https://community.n8n.io/c/feature-requests/nodes). - - -### An integration exists already but a feature is missing. Can you add it? - -Adding new functionality to an existing integration is normally not that complicated. So the chance is -high that we can do that quite fast. Post your feature request in the forum and we'll see -what we can do. Request a new feature [here](https://community.n8n.io/c/feature-requests/nodes). - - -### How can I create an integration myself? - -Information about that can be found in the [CONTRIBUTING guide](https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md). - - -## License - - -### Which license does n8n use? - -n8n is [fair-code](http://faircode.io) licensed under [Apache 2.0 with Commons Clause](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) - - -### Is n8n open-source? - -No. The [Commons Clause](https://commonsclause.com) that is attached to the Apache 2.0 license takes away some rights. Hence, according to the definition of the [Open Source Initiative (OSI)](https://opensource.org/osd), n8n is not open-source. Nonetheless, the source code is open and everyone (individuals and companies) can use it for free. However, it is not allowed to make money directly with n8n. - -For instance, one cannot charge others to host or support n8n. However, to make things simpler, we grant everyone (individuals and companies) the right to offer consulting or support without prior permission as long as it is less than 30,000 USD ($30k) per annum. -If your revenue from services based on n8n is greater than $30k per annum, we'd invite you to become a partner and apply for a license. If you have any questions about this, feel free to reach out to us at [license@n8n.io](mailto:license@n8n.io). - - -### Why is n8n not open-source but [fair-code](http://faircode.io) licensed instead? - -We love open-source and the idea that everybody can freely use and extend what we wrote. Our community is at the heart of everything that we do and we understand that people who contribute to a project are the main drivers that push a project forward. So to make sure that the project continues to evolve and stay alive in the longer run, we decided to attach the Commons Clause. This ensures that no other person or company can make money directly with n8n. Especially if it competes with how we plan to finance our further development. For the greater majority of the people, it will not make any difference at all. At the same time, it protects the project. - -As n8n itself depends on and uses a lot of other open-source projects, it is only fair that we support them back. That is why we have planned to contribute a certain percentage of revenue/profit every month to these projects. - -We have already started with the first monthly contributions via [Open Collective](https://opencollective.com/n8n). It is not much yet, but we hope to be able to ramp that up substantially over time. diff --git a/docs/images/n8n-logo.png b/docs/images/n8n-logo.png deleted file mode 100644 index a77f36aeeead7e17aa4390937e90f48652d811c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2675 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 diff --git a/docs/images/n8n-screenshot.png b/docs/images/n8n-screenshot.png deleted file mode 100644 index 55b476cfb61bcf418baf09ebfd3c827ce563b4e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 129698 zcmce;Wk6Kj+6IjBD34MK(g+Fy($dl(l0!%g9ZGk1st8Eu&<#Tj-3=-|z#v@%NDSS> zPy@_|KJPh4&-;EqzwZ~DwfA1@UiZ4=TGzVwguYRh#lKH^9}5c$UtaFD8Wz@_AuKFx zqrY$8w0s&iF24EmmxZK~BoEH+VM&@g7@Iwjw|i${ zre^le)XS;gOa$xJ1+V;TNe$20oq1e+4YzB|At83B*_&HUeSgz^ex92gW4xf5J=hAi z(%qa@nkyTuSF&L(S+qC9%yP_4>bh5PYt3Iu3`{!n&WX7FeS6{e`YW``` zzWe`MXPxD4-J9b&ckw|7@0OPs3YDQz+JcQ*$B0(tU$SJQY?RzcRUA?-eC|C1?jD}`TKm5?8Evnepw?ZBacU#j>5bbQW7FI;gj-JgE> zk;hxA+l;f<&~ANi5^UkY=|I@<;|tAW(`|!!A;5tIg#gg|E(2yvhXkI;c|xnH9?FZBFM7SgwQp zaQX~fx`w1+nX&3A#=K#XN<&gWAk+@?#L4l2hl-%OHqZP#AjMGA8f7cFVrH9ve5_I5 zJEK3-A7lc4>TM@ zd}YohW#&v6qstWIJ~AAEATp8@$rRG1x0<Mu%i=gKkVSyHmkiOyr^qaECU!<45dH zvc($P0mGmXZPbK^uziQwYcxk4Zys#48%QU^EY4A`Fcvr7S#1|ijaG_4-REEd zjZ#^TlW&e071yk;_hn{4CF)Un1+J(^fs*caS{&2x?EC7wz58H252sxgWM(3F26tR| zq=GWFx~7G+b(4Lgn(4?csH20iaDv-BR`EU&=5MFySa>1_yOw4v$JflbdMUhCm0MVL zUl+QU@{HG?VLUP&P*>l8Jh#nv(}+9vQSbOd?pXnHr8J}DBLbSU?r4&oj?!nnHXn( ziyIB+dBqc1FsJBGB3q4YoA3}6h-HeZ{ra5+c;uzq&S$Y}u}Ij;#@m3ggBRjuQ?o?qbeZ{o} zZswVt?&kh#6epID@FbLy&L9J1&KV*wkXnHl3}PMY%NUlc?CakWEK*q7i$2>BDPpr*UT``9mckPeD8p3$X&Wj z7Xs1rt3hQJ3)D?%)fI0Gh5U?qxz!-{#lAEQ6YCP9|W?}#dN0O7hIJ!STyZ{e@=4R)^;%<3)~B68hq0w>OU zBiAj3)G0av$NY0h-{vb-VZhsbI1hk&knpNjx;WYLZvSPkX%8{CnA~g5L#}m@>RquX zJap2)=Rpj)PM}H(Za>eONnzhixPbI!TY_8h4p=Wn(Nl3R+!jbSeizHt(*4~?9<|8| z(ZJv-w7z@iI1|;^Se@(;2chMhqoCTS6!nieS6+Y#?9nU?jP)1S@Lx^_l@Nd?*h~lL z+j=+w3)elRFUr){sglxPI^g%yDsN9X?9CDfRSm0MhV#b!~fwdhY%))mcfR z&Mu$pYvSYiS9?N|7?>BwA918@kzN}s5zTbGg!fy0W8yRVl3?BEErEl<>2>H&=2iUfAp<(DNLDQl|+QEpn(QBu^e6w(=cHQ!t zRGd*|IKDv)*aJdunR$))o(7`S(03x!1)pTG#dW}0acN>0vY_&!KEznr(xYNVB*La6 z+YO+yOHYfd=6OR{4iTidK`L7GjW|A85>1zi8*Zd% zrt-RBtC`gYe>dt-A-cs`dlfN5yjq5@x`y>xgxY{&+RWW_^TB<6lDQnQLlpz5ZR?^_ znyZCVs}3sA@PK#?q^ic7Pep0EdEPN_?$=&>{p?3mK8SuVB8zJjj$+a+>+1q6EM1)5 zC6nMJyKp7Vtw&~uX~vK93Wm~n*=*D!yuzM!cMlY8EJe|g^g9facMRIYQvL4S>0L3R zOsIW3O3$_4E+KEoy}sGCmsoSDP zOx-G92s`cUK-^qhY+?ICmSL{)Nk&5}UAsmcTtVDuKCcyQihX8Q!}$2R8nZ6B!=dUM zC)~tzIfb-dS2r%w54SOJiahP!Ms%#HAfbwgfu|GXUKiM2hZW0d8~||*0wydh%Yv|c zv#iGvnPPUA5r@^@I<`*p7R%}K!v)f9G-+|5dKU!1Bcz2ux6-Mz<{gcC(&J$$G|Y&% z(Vn8yT-j}|`Uu|ZG?NIT5?0C}L1;HxE!C1$_XNagIMzS84tmdbs!~>5_Oh7;h8Z7( zv5tch1!m&Fd*8o)Z9nG`fYg>=Y8Xn&meqJ#fdZnzlS*9k9762YK49#TN;wKrhADj2 zf;7gp^$8Hbf5rfL{7^taB4(6M#)!iyEtOnm3KHl42yAZ>Q@f=bUE>P5?&yFENj4ei z7>kKGCyt!<#oQHEin?@_IA2Vrr&!Aq#!(szoICp@XMv2Ir$W?9;sqTomaWl9l@pDI zbg*E(*Zjt3?RyqFJ?)Bk&uJe1IvUp{GL_WNcx5(E!HM2oPt#MQ)>-6ateT;J#mL)5 zIk_P(&*sY+*){5OZ>oUil&`pED*MQOX*$`&@La>*4|ZYur8A~6bH8?HWk+I)I4!nt ze1lrk>lgdVQRUa2q^&K_MeO?_S40o^*xnlXwjSNeAgy5#zBu%HI>(4`my?XaGIQbxm?a=BOrH=BtXjy3th>)oW?AlQdfgYq6!=oXf=KN z{v}>q>>4(9UB{<0xdN~Hadu}&hhp?2EuWFIzEzwquZA5CQ;*wNyy`meYM>YKnxeBC zwTKO*H`T0;ws>v0%OXisv^}e##AE;6NMIXhU4mqd`FU^ojK{bQXt55Hrj<~Ti#bQ^Oa&(;+*B{V zdUe%w_kBz=wtV!#i^bJ0KQrzp-kyaShd$EEU0u%zsl@$!YaMI{>$&^GBSjz4Jf1j` z8JNJX0~Mty0C3@|t8ZVw@l!0T(X!tAf?7O+RUX}Ol8yUl{FGIabgK$ZJ9VS0 z%T_6h@e>adkP|SDDBp)LwLG8`%(B;-}D@{eqv5$>_u3k(Wa1V!5f){eEqCG z%Y^C3YLqb8=s3g0?8jR$Ebc5meeh++xCtu(0aQ9~KtZ}fe4krJ>qB$&Un!^2^V>t6 z%LM}vAklHM>rv9J>o_MfSwJ^~@r9l3F35%-czQ?RaRZR>zPfJL;?0_sjBpAmpuP2d z)!c0*$6vY3kxU(~1P`M{7-z)AsyvRj+m$%K^^c4ce?2r&)9nZ;DDZ1^>>tFyaNFUQ z?wxrdp!RllL1DqQA4NJN$d*(7i#iR47JRH9_tUj&Y^B_FyZ>q+nK&I#OuTCS0sd;ne*Gv*jP-KgM!i zPIDuVtUhFA7GW3jKU&AcTNr}48d&hP39jjUJXi%3!VraJP;jae&fCm+n4Z6fc+-`= z&RLwu6-1;;yI@Fji%&}(YzPS%LLOF}Llm|hID$67t@%0t@)riz2gwnei+xfl9dd5n z2f@svKz0_bCa%%eL?XfT9vBXmrRcYw*TTC|Iz&nQzWT1Hhbh~Q#hs_!95c0Jb{ekY z$W!ixs|%}WM#c$0io{kIBOSSpm~i$nr_q{;V)4-yfd#pQfP0?Nnepp${wG;NR2 zudQAA#8($6bq>Iz$0V7p?Bc}U(ui9o@*@zaBFukk@Z{Drj!t+c#JY{ez!Z&Cmx zy0PYGyLTmiyIoxLNLU!`Ga`7)Z*@U7;g-HgYeI=w+Vx|vcsP#=#V`WGRnW(QL=}D9 z2X$OYW$Mudq~<2PI`SCUgPYrm(>9rf;gQp=FbxC=hc3PE+IiEyzeUO%aHX-JO{aPd zJ(em#kUb8#Axz5pVm97+*-Y+~Y|@!>lqizZQg%2?Z60vdCDAm#gQfdd4U4t2F;^&t zjTvJW=`m0m@%?gu0JBqszt_wwFFzQvDoJd7+hdA;SXj8HB$(~%A8jmmsUp{s zIh|L9SelJS;}}{x3!``}sCdPi+06TcKQ5{AeT}L*-QpAPTpP+_EJ^BqlqyENjS}q%_9u@vl%^TW0O{IVh3OouW3nW#9N-JExkWMO8D8Djg_(mmq`@0!b3Y1dEqCKhSXN4 zA!@12vGnCNvB4VR4LFyZ%k*n@{Tv=Ubw!$~tZFtnm+R$;7VZD&g7B*6|oK^4axb%Q&3`&jtZ4a$A z*ykTbQ)B2YWF?oT0W9I`XbX2}kKdGa$>x;75w)S>@ zAkxAR3=ATS-#iU-VM0_7^{Rm+4KGf#Anc1}NkiKpm@Eqj?wke}(P^?DVZtO3_j;o1 z-Z@m5QvD>#B+@EqY7Pi{E*RK{3w?hc2bP){2&jT$;QCLBjNXR~y>XEf?8t^gSuZ3)j%CYiv|lV4kT__rFkuJ*sy3rAS9m@$SxE ziuc%3?Mk6D?ih~>f+T%}iiN|+>cm=@uz}-RY`#^r<>cPM=99UD)Org^VHuxae6&RG z|K}8Sr3o=FuVdxenf$*0@|b#4nwMe}<3OlMwFBYY-Cx?(zwMUj?duIK6%TV5Ml#+F zdM?h$6x9zs1y%pUi%Xbn2x!>AdCvz1@Wa_qDKy+eFYH;;ex4d&Vey6uh0iov4Y6k@ zuSK*5;s4Vo$Lox4xIvM0nMMJuoTB+33O$=a`g?0Vtbf4w6i*S{Wn?=9q;fmoJK{PykD z*shA$dDr4%M~cc{zivL(38|?03uW^nM=}<%;InVHM*NEgm%x$rNkpcCACZ#sm-HEr z&PTW5{CZ*Bg6YP|SjV-1z=_gxUYKn&;5iFq^krSSoJ?K<2)9^Hnnf)(Oe3F;q{LWqJ)VGBTtjBtRpp z4K*cQN2j4u&Hw4?>rhhiT0Q{(G>vSf>6U2 z3;bJ+4|a7ZwdNsa&;;h#*w{xp9Dxc9u+&MCe#?YW?te|`rz~|L8qyZS*NfYj>429( z*bZ1__-={eH=?}p_7B8rI}n6RCe|=4UrLhSJjA zHh?5m(_7!IRV_XMU(ksCuD#Nw-Ds^Hao0~Pc35SkZlu~l_`lzlGqY-m^PjI|(8S6n z$%EZ4DZ0`4Z^~zbj9U8c*8r)luq_cqHD6dZ#1(%Ze_-}#<>Z(+<^T2mMKFyN*6i(n zPIh!#E+&T)Fs>*vC)2YU@ncuA>sfIjTu>&?ve>KZmgs8Vg} zEl05B@a>XpEuMeNax*Af(d$B+k;{wD5+fbAAGQroRYWldU4UBaV&>{1l^%9XuXz|7 z7Tf!u6EPh6eIm^Tc<2uAt5A|8wz_c=9Wn2njI($UbvJe;)<24y1p^XwpI2j55|l&^ zS6fBe>dy5Y7k4D}IoW#rbf`%Sj=H{YQ|7E-Qb7StTxn7)O_RD4Hs=0F6^t7pRU7?( ziK>g^Qj8e$F4KjP==f*%-Trb^>f4s{S(W?PgRg=3nyIB;$Gi&Tr=#J*04=eH_M?uX z1CQW5qu>Qwr-B)p#F27#!|Shv2*-Rl>3E-1H;(CqHFE=5oeW)woMM59Sm6A|t7BTm z|11?9u;krz2Mi8DEId`YvRm4Cg?ZODB%SENd^0H#)+rshD8uvowUZIkaQ1o!hfqOJ z!wiID*jadfLLUlN7q9S4+O)bPC?IdQgkhD7!pgAporll#9XxNX!J)&J6*&6bWb7q+-021{P7HhU_QFh3VSk z#fdUIUuCCyK?xO{91bgw9TGT+c4qwzG4Qf#)4B-%SGkw$Ek7pDyYK$K>_V>~J#P0X z-Th9th(GVOg?zH{$SS2Nxr4<9wRR(Aiagri)JLp;+tli-FFeakFro7s^cXk|dn$Ju zJ&;&tO#W%C^8lg(3P%JIP3A96g z+W4MeL+z9)#__!Kb83r4eIH9KPVaLSBQkpFwL-1WuHgwOv2N#OORJhX7dckgea+p* z+Rp^gdX6*ywTP^04nO7?bUHIry{;=8wG8BSzXcD?c|7j#V;1>MhW8%mZ{Gp(1sHQ2}-oM{1D=Xv@MX<$2#sv5h>n(b>r6OuJ(ppR&w4oAqlp>_arTUX+i}bcQqr+v zSm-m0b)>I4`kUL1R3qTmOosv(v**gODu%=pJVeWEPv=4QN>3;9+7kM{5-~5ZH$PM} za8eMYyH-zd9!}au^e{cEJRt=5=eUKl6+~7Zt23V@%rW+xygQkI%vuc+AEURuX z3#UC|tw@^K*Y3_2@B%BTkd=7BF(Cxw!Y$={1^TMvJH!|2(x5a(HE89%XZH6VXEBbo zJuNh$eXZ^om4KbHCNCJb$0B*c2aX)-=-aK?3hqNarq9DWGVvZVkcv9aTJVfzKmlfE z4Ez@(+!^Mk0MqbPx^VfzCrvJmwhItA!4{`go$ByTV%Wn`EvEju0BLn+hG|)=&fFTZ zQ|}2q51SKZ==5`d7)VW}X}yAoPi9S&QS9#d^?vo!poX8=U=}39v1HqRdnC+T>q&Fi z{f2N>7aL{4qiz6Vr))FwONs$%)(CdReYn!3G9pmDa(dR74Bbh#i_!OxfixutqjnBZtsy$SITEw z9$?3?QD1Y6AsD3e=ELP_w!ZjlmNAC8-t-}W{T2r;XDn4=9~SL)y{Wtr=Y9ogTQvMC zLf@i%&6VOAGBXBT(Vy6@cK8WCnI-TOj?D(jgt74j;ef~KwAz5-_KRT-VYxJ3yn_gc z{artAYu%JZ5J&e9Zr6(;W{h^7Utbdr9Ww!$+YXFE<8Lw>=d+)d7UT9)placm-O;XwuI&>{}puW2=z=grFHiF_QC2Q-CVk0Py5g;fUej4F*maLm`G0+)q zy1B@($5-sDXiw`Jw4H-_@q2XCwTVTff^-#5dUmH|+PpWzdKaQI0WIfERMppQ#wM@K z4drzX0x_Ph`RC&j0dz~@6KNh9&qu>&%htconJZK1ddx@X($B55M=vJ1>sq`>g-O=Z z7rzirI#=%HYz54VB-|W&sTc*!_`FddQ235r7q8FI z_ax*#Lz^6(wO-}4V7ThkK@(WW$+2CdCN}=A*9Jwu0domIG*F4(%84|Dh?%r_ajLNT zjVopGUh^ZOXLD-L8sFeeg|!6P zMMuu8*_VP|CLqOx=4HVxr5KU3eBT3)nd@S_%a-G&+;GvcfaG8J`F~(hS!18GodUZo zfte>NYi1SeR`ACz6;0D~m4K})ETvtKPCAM-qf0I0EMZ!2dU&TH9@BDC$9Xj3Bz*5) z$(6e%=WNxf%En4gdh><~Lo22tx_yEpd0L5>-$YUygFs@aE_?E%hK0KvtUr*YD?O*` zJzd*;$jOFTme{zu;&hI3bzekRRIrfy41 zGn^>}Tn0HB$;MWfesa)ks~I_de{xiFt1J|147fn^9Gw85(aCzH8jJk%FMD0l8QpHg zB^H*|og)l?9Vf;6Fmylh+r9#C&FK+1fZ%&>kArLjyF{giK)N)#&s?$^zuiR4?Mvgp-<5603(}qc=Fea+<_G6AFfG=y z)^Yk_o3!UwJJ~*|q^g|xb)aNZlS1%bXzJWr6#k1Wz__nK`sq~we~Yr0WxblmoBELb z10NbZ`K#rS(pjb^JaWO>=00QNMZ1m1dC#665}6e9Hhl?iBl_S0r)Uj8_0)zZ(n)y6@xcUh@O^r0yJK1p`6(`I%Aqo&@`tiZY9q9B$s#t76q@d?Dyzu-@DpcwTSijlvW2cN=aNA$U4!)$juS8x$DLr=Ppdt0@+!sGJT`r?#3)TwUkT-x;YlQhj;GgdfhhiV;1%eWak=*Y z)+vfYsZzcc7K&Vy zI@2SvM_UnLF9Alh{rJKa=xDnbS9O3ypBK-?a7@3R>BN9So^YOF`p4CU5OB&AvI-TK zqP`kP*nL_B5S8YUTMM)G)Z*7jvNCwKb&tZaJog=Efq24P;9y2D3^5pcbjY_9DSb5G z66f%suBPbNc$;~J_Q4c?tI5cpo}O4%lMc0;0ofmWHJuNYzh7O~4{~yxu&$F_j6fB& zp!v?Az^s(mO)Bc|*)l8TD4`6HI`f27R)Xm>UyRf%HC@A;udPu>bbe=j;r8j~YzIV4 z;!=ewpMA_GP6NpzcHOV(yD3s+I{t3Jg~2Z`;r@Mnv2Tp}2L{~^NBDOzD_eU@wkfs} z=#m{|8a{+UzQin@1obm=2ElluCZ)!Gxwt(dh> zWLivkex^k>S7JnFLT!P~u_wqV%DSAs>{|9wP(sN(2eo;(yq$whj_sE^^GzW@ypqNZ zm6KO9z4vqDmCE_7=rn3R)b29r&FSvS>GcOG1_465O}Q7=ICU;(qWc)G*2551b%D}; z^7HR5CVtxm6nMb{MsImMrH3NI0(_*6H!d%XRE4c~2Er*RTqFy5iukPc)HUjKC$#fu z+9Zvvs%{20YdX|2qwU7T&Yyj{@Jyr(NKW*2?NKT zMtGKG1ltMXLf8(rkunOQ5&grL$yZGr2D?uIJ4pP79Q| zxbwJGft&k2L|AIZ+tZ8G%$$#{HenS3(tbhZsH9UaI)EK5z!m7A;)Rl`_r#H`F#mnS zr4y4C&EM!zv6?Vm@0S2~+t{aHF+8_4$+|;)@v?q3w0-FzXulf_7ZEP1a&n!ZtW(v} z=5XHgv72un61R}I+%=G`YgQ~&50125xSiH3<_Nu#9LD(M^&sKN%=I7WJKX>)Y7Xr@ zmG%Z8okdJCiP+O=5oux?h(S_i#WRss!@SJgPp`*Vg~vb6ytk3-W_-{0y(H!Lh@9xQ zoxD*WkX%a8h{{3N-K%IlTbTVco?e|=d@)9WR z^c=H7$8f06Hijb6kN{yn)vZ(7RKBqLvVG;-`XcS2bq;V+IupJYg-Rns&T+q*41(dV?4fQ zTKDhpzhWKc@;!Nr+5Gl`5(sXr<>XZXJpKW<6_41 zS_E!*z3tb(>Wd}xmbXR`8A+KP{%-^L@4VOi{C{vvG)b@wBZK4BGJ%FbR#9wAhyR15 zDyaq*UtfOls}Mte%2M$l=(tr&<8FIfJMh1_Hou4Ou$6A=8XugVPc3E+!j@iAf8mq8 znS$z{RFnZbu|pX5m8oeayvbre)97@4jf`|VY?w4DVH-uAg53!H-$Fo228Rnv((t&^y6FCf;kIxQQ zv>FdeBRSc1+f_WSUw5hi7H0}fLaO$9{}y$V|J|Y>E+42fUfZd}-m#2lYOuyZu)}vw z9kw6iXcdmNov)>7=~}Bcx%Nk$TZ;ohC6SrUzmt=6Kzy8=qLSP1>j#cW`P*XC3V}ur zayKuYF8FBG5t7d(ORk_tIUlrq-0^H8P|pxavOv&c+`$ zM;a9-W{5X&yV+E`4?0GtZiSn?UH!rnii5vADe%VQ`9!uN>jkP3FNNBZaWm%Jeqo6% z$=`oRS1I>TvlQ}X0hLY9I$LAavK9!`K|)X6LN=do5ILdy{Ae8%{Y-V=&Uf+j*3=Mm z`^ITaLBSdo6cFMOf{m1pmE=z+9J5JmIUT3lu|~D1%BQaH?J-Wdt`gG1G;wUPyDHobMz}WAf=1b8V3K2i_7Hybis&r@WJrnNy)4I z$I%-*jIR)J`Spz= zxF`gQZ0@sWcsFJ#EAYMM<(9CoxW(ZV!sD+5By82A0CzL7Rlb$oQVskX5P|Chko70aq4kC%TCkuUFCIb>?O zF`B6aL-yZ)fu%pq!OqxouS~SHr{8b(-yEG5Ns>bvxH+!68C9I~PwdxAD53B`cLR$i z>Y-9!351OaY`$4&is#}ZhtWcYWsldtDo;@VX%RqK6|l8(aaWAbGeolIUqKfw$sJdG zi5Y^KiS-R#9&aU*0O@5{4(l{#Yfu*OtqAAQDdrcg714KO=gU}zhRR%#TjOw^9-bMi zbAk^`S0cckFrHrb$F%_Tt{ zz6GxE`_=7au=8PmD5<44S0(@|ux{OI!!L;s+%IST^4UF`f1k;~)Z>Ktr(X_zk2qnw zNH?+Su@C$>zdM}qRnUEtf;xLPD^c?S^{V^GSkoeYi($ka91k-=LcGo5Dqr`Nl=NEL z5aprr8uEbwR8+uYY|_`0(8m%xesP|L- zhN(Uxh_jinqqw%vLcaOiuXqay3TJ|wPhL)Cf{(`?`k4UG)s6Do!m<_bKeQ`HmbPsv zDl9Yi8fJu#Dray^{Y5}dI;{s`D@TS&RLyM}dX6i!H+nTLlnNYrDdrDlD_1R{x zmSmMvjf@sA&FHx7lbtoXpde{r?y#_5&kSpJiJDH83-VYaYe?Lrbt5hu*Lgtr^nma5 zshXfG66q|Gk)-Ez-f_Y(xzOFK0*Jk12d{G&Uf$mJxyg_K5oq{0=}gQ&C&2##1lDv# zJ-|G1;G2=5yd^IkD?>@?LK#8VGUVaTrCOPmlVNO|Y!+;S>J_+JxIubncb5fl z3^Xnj&`2M;@bYj+nVNth0EBds4eyX`VSR<(=UGEM?@mOPQlgRVF26MsPP2aYP|Jz% zq06vn@4)wKjsMEd)*qqs4cmvE6ipdEUXv{vF;&l)80P9_F{FeJHtS1E{yU~H3e?Q2 zUVCht5?-0W_UZzrz^YPx2OA3qdV-UBcaGVwPxpodpK1se?Xo~oz&aAPHc3uvG%L9X z_^?i^!`7}q?ymkNd*{zR%>Rym7o1i%mN4E+_>5L8)5uZAqrT|H_Vh?s=Q&BPxVo+7 z`DR8*L(8FtH;iJve4g5di87pr2XxBO0BFcADjK;G_j8A#zVArhxUk8H9hd@eaJjaG?(oSq`}iomgqaloN}uhB%=*S zZ!U7q=oR|E!Di^6=ZE-QIV>5pdAkdxULrZ&4Qi)?JW+UiR*Fg(!JN_O{3qpxS3WHb z$BNZ84fWe!iCc)Oc^BHg`S91f&)pqxlJGE0b z|2k*50J4im6vUrjSqaq{Yo?=!RMTbr0~LY5Mfet+sLDJU+ln9IyGI{8acXvMf5bMR zN_-Sd@HjOl=KEPq7>~zeHp13+Ev1=PNhoCd==-N_aAp8jqMFWAvu1#Z@;?VpT5dwS-p1|Jo(|ftw*wnNaQGW6sJpp>=(h(*^9{)AqO3GQytE zEtuXTk{8B%7<(*ol4wf51^=FT^XWkCa7sYi#*Z6_`!y}~Mx5L~eUlDUrG|&No$dFc zEhCLGUHWEdh5ts@%*O}?S!^u3jLk@RE2;I*t-*iCPwNDb0w~tyuWWoCN|1mv@_)V! zErAdjR5_nN#j=at9MaQ;Uvh-%JN*4Kv6WWnGm*IG)_3S?J-O}J&BEH1?O-(}zOYnz z-+L?W4;drVYf}7eHH<9^HS`oEl^XOM^s`rrA!^>|Dc5{$KvouxieB0qcyF`*><{24 zH=!+-cZA=xi6i0Db#}AOq530ChG}34-PG7rhW)WUxd|KpH2*I#u!Nrc+$;UJbibN^ z&Ik)D@uuu#FT5?d)lb~F4Rw%mUr)FN8g{?VXL`Cnw#~qEw`}X!s{Qpwc0GA}dlPbq zQm?RIP_+=Eyhg6Fm7jl+b34GS!V%EW#OT3fF1z;}J>qH-=mD4O?b~Xhbb@U-Ox6fm!Sg=*ZHYSGW|NbI z;(*Nmr;_x zV9A&tu0B~)k^V5{@N8I_4A_d;$2RpT$`lcrf0VWE=GB_7)=+HPsYI|MTr5;$^zVQR z0lcBQDH5^Vm_4Pkeq+it)8w*VnB{LB8?=6P22>kvSx;xJPIkRd?Xqu*A~a&y@hxlq50PkH=Zim=X+%d6$2FD9fGpGo+xEWz2u_Eqq~ z-*u)vY*+oluRyh|M&mMpuxtVvpQ+>pKBkc19Rs6)v}6m7a8;%~diyNOy?3l?UK>cW z4%BWTKuF&-wfl|ro6|JkQzd`vlLBvYuXg(US(E8=*QG2&D}dlsw`X z4Lcp)ay822no?$HaTP~3;JQ>gz3uo<9Sqj@XD#(auKNpZHyg|Skvv)4Oq0Go?hAZJ z>(z&ZM$x%>Iz&r_c#A|Gh%+f9ZE1N~0@%OpxMufMxTUj6vaCaadKP#XPo|< zVUaWe?Gq|Z@iYy2Q!9k9iBucb<}Q_mXu<3Ce$nZStrthWXl0OU?BoE~kGtrls#F91 z-D8QKV;`?sZ{~f!GIPa=$K<10|FXJ_G9u?wrf7LbZCja^=#;`wYAtGn%}ea)uau9I+y5M}^rG8Xx` z*$!J{3BM=f-S6wsqU2vA&gThfeH$_{;3#)8b3ZINX zrBu@W&kPy@PPGV4{}?n!bHrhzhsptd<32%TG}an#-n^+H+j+<@=iY0ThV%7fU96`6|T>Y8sE7l%^U4POY~Adz$7<{NV5#`fj0Ei zAyv$KTdu>4aHG09gbnx4U>VtXauv*|oZjfN4l|E(61JUwK}Z-)$~Lb_bsjQ<<_~B) zX3r>+j!?@v`MYKFy2U4pDo%RW`RX$b`etqOBw6sA?<;L2w9r9lcOLnl?z6TVqLn`u zm87*>UMRKuBqV0seH>{uBWw`~lKD8M$?I zbVSOoJDx6>k)Ceoe{L2jW70oi(bHN}Hgzc+I77l#QdFd{GX4uqXmaFRTS2NJqsk}B z{KwArl_6B&bjru`OCY$8{!GQcAn#UX@py0zHQ6o8A2(5ug`M3FX1lky$74QN=@<)M zYMK7Gls6Bik1cJpiLI;;n>v!(q<@Tqh~`tSi#TOVk)x5)(Mu2r%}YOvU@p+_ zrXs;NdxH|zv+}Kx#l{02fHJvv7VWEC=|frim?QB{^Apy8C)zt$6F*Y{d~~@%U!&YM z1kj?y)7d+cw)<26*_1cG4H5ZL9ayW<(K|L7uHo`R&Wj;zaHJ#?6 z$;wn(w$L4qId7NdglX7`0Bz4b2%%Ct{X--3Fb~~A;olYkh->SmV;nsNci*BDd&j+| zc{t{1>V~gTP_BIpZ4kUkNzuN?D`+Vy#qoWE_P1>VE5l0nD$P3U?q93|k&Dpe<8SK3 zEp@pX|KKgV?cyUy^@QebZB zu>0|N7~=vNUCnvE*GATWBZ`76(Li0@=*>8N>@3ISBFUr^C%?rUcf8(wRyD`NsEo7` zt@;Ok&P}Sft(?cobPKQyxmm#S16u5U&tB|f>jX_M?-MYL`>l-U9r6(6yJ>`9;yPm4 zg;&QZzz>Lh^AtZnVglcPqSqcXk4D(GRd~>4Jq#f^x^imTBXqV%<_0%|6{kf~xU!eF z;sp9J?(+8S31N+=aa8*;+qKZ+vj)>nrzW&j|5?McOGc^87$;Go@v8JT8TG3>c|Dq4 zsw?oOlNZ_zSD{rN(s1~p?e`n?dnQYKv7V}0p)Z(hWHlaiEDMCJ{Ut$}EF0bB{ zS%GD*k(Xo!>kH=En8N}oLBBmO%t#K5T2Ekc=2K%JP5j;g+W+e0ssTowT>!hCO+vMI z^NSnbebWE}N4qyas?w-m=SGZ!v@*V+JU=B|{=q&KB7c2r1{w6I1hMe&XpD)-N4qx9 zEWN>2k5%XHy$r^83(l1@+g#@IA9TH?KXXaN5e_6}$u(GXwG`w&p}H=vE#-1@UuG!w zu?r(C)6OJwN9H-)ZGPGEc}MtEZnA;4Y?$I|9IZ-oQ94w?q5QY^R+F%wv~q7-hX~(B z6KmaFyk6uR3ny6fsPGTvWQ$6+O^#K;?zW z_3;(9dR|>C2ZQa(`UZ==z(hP5i>Z|>yV+R<|S5HDR>6Lcm%EG-@+udyr1Zs$XhShV)JwR%&(T_ z1)rK48aQT8>Fgfl6vA|-7z{P$&HY6nzh!4p;VGusQVetRaD8#o()33-{w>L?Pp6%2 z5zPI83slM@ys0spbw?wE#yVa|QE$!?fKBCPH}jn7uC7cRUPc9!FIGGtAUalG&kZ1ikM6W>3|NKFb!!B35s1h1Uk{(=;+^R16Pm z=<7t!EzBzkOQdvnYMI(!XhdBdlC~OBPHHt)eKUl<--n-1{S3vG9i~|P0t0+umk{bb zpqt`Z0RKuCD#bQKlq{>I`Z1qnFWd50=K`MJ(592`<4S7t?-S0EPO~7*5%6|5?6YmA ztgd4Un3hkPPWBLfNY~DsmM$w}<;B0n#NO&_Z+nKLWtw1|NH5j@54NBMF7qVNT#S<1 zQi)ge1isc++E)v$kT08`=Cf?jG3nQ2HIY8PSe!Iu#;w#2FjzECzAWH9O!UmgzJ$6q z3m!T5eV&YBt=MN3=l6gn@7XaADOO)Lp+>7+dpW_|e2K@fsZ?RAIpz3(Z4+Jre*EZj z^Q`~qnE&Y%VQTC1=Oah>ls)B3gfxv*m&)>T^=StdgL(wNEQ>(ShRkeuB5ifIRo#fY zqVifZ4MC2#kPAG&W$=YA6t`n!{Fcbq2{$%qEvpS94=S%F=LcG20a0v8kuU`6;BE8r}(uKhzf0H?u3`vYja3_MW@_qKl8lyTyi($r8N4Ey8rOHhZ{2^ zA_`BQni4n%8cqVb=DX&EaPE7tPV$OnFnc_%1fFg_q2<*2^?ZG^;2>YTnJ>a6Qh~>= zLw#h_y(05>+^L-O!NLECi)No+;=51CF>xy%t-ps{33v>%STglza^>Wn{&$fIQKjgj zNS0BqScbo($&q23Da{H^NiFV!F(_dV*EI_ni}gI)T>K9a~i=4x4ADOdv;mfew!a+bni1M}OU)lLqz zt=bJiTs^(S8V61^{o?Il{^ka|5Ltbn@hVFN>g4ykY8LwZ>^yH+KLfZxY%drA&2K11*gHep{8%Nnq7gWE?hbJy$(PBy zt|QE&YfkPwSlNuBqj$co@S-?3u@eTlK|R8Jo6`-4L7Q1l6IN_FX&PMZ#%CT-I1HSS z*kI^z-faWWKMt<5w54^Y1*vbD9I8*3@3zY-PoimSSDN!U`BSl;k|%?0kXkFd;pi1} zeJgS^wA|_`jS6c(+2Y73H(e)fpG-@awA;U4wqyXO4*hBf4Qo&(1HiaI&fRFG*-qjF2j@*T=No`95Kb1VZ2$ zeFPP^_dFY!O-ziJ$!m`z^CxpwBAVptGet_vGBy%bF$1~Yn#erCzjd)xnBZnm_aoVXRhm{q2VWlDF$~n{WW7$DQEWxWKf;pl5+`e-1d`EQ$y9)R7I9sYK9ANwQK)zHL zidyaMdT=dO9Icj6%Gi|d{rO+bUb__(#eAh*pnZDyZt}W9O@a)$`S>H`GxbBJJ4d8> zoMtBOGqhb}X3zA`Hfhe!&$aNIE9E3ZX(+P~BJZFK5=p43Fa#&nQl98M?*ZSJ%_8+5 zwDu+@gp<3ykY{h-)mqH(Q}x1a(&Qcgkqpo-mZ4BU)7q+fsb=s%(T;kprPkx~;cO{A z9ohUE&@a#}ywCxcf6O6sLE=-x=>a~oO)_PZw02Dv?%GQC7a*W}*7%to|_bdPI}N z%Y5Ws%NF@zfnMJR%rLMuZ^UBRq3X)lr1iZkQqC?@3wS8yAQL@YR-tK!=5phum*dg_ z)X2^h7Qgt)_QPY{;|J4yI81nz8Vf^T0v0x^mAj^sP0$TPyB&j)@=l;)hmQMwc3D|j zW@g6~!)|60jk~UG-CgEHPYqvNSbzRxwo8fN^1K&bn-?u&D^OMC?KqHlyt zbZ>X~uD~i4=zMOte(1PvE0N7aOuSxWNt0r8tiGN$^R%8ad`dj^XWAWDo3*!vg@7#2 zxsa`G#rf`+Z!+9t>VCvS+rV0{Ou@8ilUD$Jpj_@5OXkA@^-pOVd<(Ag&m!;<`g z`_p)NRL$=`*;_0RkmZe2g9PZ88V%uIUPLyzB(gURrV`b*U+v880s54~9~%LZl`~h6 zut2D(CU>u{dh~?g^dN4^Zr-FAfu37LHdCce;=uKaVXjVHQ}pb$$mEX|qW0YUyPFJTN4s{(VU5MN+lvj)~Oh1O|=T zd|j;li1#8Cll?-M{`r@p=kE!RHCDUsj(2iOQ)@$k!3#+LXbcz_0^AISasS}pT+(vrFB1hiBBK^IRlG0ND z{%i?GCfA=vTrdXLwM>|{FX^+<3tZ;t-pa_Zj)7&@5+-Gd8S~!P_nH4Q#H?d=bL;Iy zVAk9Lm7d@~hshuo-rSTzsh_fJ@pn7IrJ12@qT_g1kuZEGaxHaNR}oIG^nbB~P%$>o z-8--aR)2X&FwdL06P`NEj=RYfTc=u`3S3sRsgz{C35(AKe~$?5eBT9V^Yl#E1xdGq zjXt->T8%}kOCqzz96b&+H1taUJ~6SP-2eQa7Y3N_ohJu8HVVUKQ?bVUUV28nZ>i21 zmFvX%zWWexp~{qHxi_`92Tf3b?Dzk6^(K#J;FoW|2MXi|;C4mlRAS*bWgo*Ad-*h1 z-A4f*QG`TAPZz2Sk#|SJg4j~Soc}gyUegiP&fAhZaywgLX-6A6LWDjAXir1-Gu}NC3^pF5sV@*uuixuWAJwnfAmOt$osI% zL7UojlQS3V{eu9+&$a7Wo!946k5Ywv=O1v=lOE5lv(Sai@MhfqxF^S?TRuUwHh5t$ zKMV}REhEzWM#BXMYZ5xkZ&{7of3^kwm%s%A@0OuTI~q~Gm48HmS@C#%CcH5_KgYQq z+3EUy9PPkcf#I@PZ!Mhth4CG<0ew{n||lTHo5008#G{^NaZ_D)}42gGAq=t zpp$y8&XhwgzcK?JZr~z!(`mBQ@%}x}73^JKtYlVBgZFb!N{=8mJ0#d1%3<>R z*(7eW; z{W95?WGDofKV~L&ZJ6Mis5ZP9h@xm7;uq56`G0Ox5MN}{0%FJ;9vbpp(f1{@F>Vu% z@)Qub0k#*8J$Tb}@eILT|ATF))j&$v?kuS5`DZH)+~c7iSGtgHOC26Imo+QzN6HKI zRQ|+x z){a~zAnjZxepr|&?7o|E68P7pyG{Nwr(8 zdK*FfTGP$n+MzD?QrEPIzg4IbMn;RGPG2*+m`OxP1SlcK7*)`X>)oFpc7~Ibsgy5M zSS5$8MWK!@devHQ8 zZ^&XC>-a!FAHfv94ttl9s&4)5@y04<@|`o9ar;L&hM}@dj^KeI3+}_iLw2i`CZpj* zK0kG(f8ck|X-%r$5kw}|=+*D{7jz?^N#jIXo5qZPsnYzhv`K2)z0PGFPS*1OqM4M8 z`d_K?!g;WajMq9IZmgF-UZM=$ciuHL9h=V_)1yi0B%#t1Um&YkO5JMK*pRw?BxP;> zf_^J};o5qg>Ocip9kfAO)0 zS?JN^GHZAjc}PamrT!jxZ1GTⓈ&|eMmSFYY{aFcd}WsmeQbVsh<(}{1561ehB5^ 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 EKL8|sWdHyG diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index bd53af45e6..0000000000 --- a/docs/index.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - n8n Documentation - - - - - - - - -
- - - - - - - - - - - - - diff --git a/docs/key-components.md b/docs/key-components.md deleted file mode 100644 index 9caba3a0b0..0000000000 --- a/docs/key-components.md +++ /dev/null @@ -1,25 +0,0 @@ -# Key Components - - -## Connection - -A connection establishes a link between nodes to route data through the workflow. Each node can have one or multiple connections. - - -## Node - -A node is an entry point for retrieving data, a function to process data or an exit for sending data. The data process includes filtering, recomposing and changing data. There can be one or several nodes for your API, service or app. You can easily connect multiple nodes, which allows you to create simple and complex workflows with them intuitively. - -For example, consider a Google Sheets node. It can be used to retrieve or write data to a Google Sheet. - - -## Trigger Node - -A trigger node is a node that starts a workflow and supplies the initial data. What triggers it, depends on the node. It could be the time, a webhook call or an event from an external service. - -For example, consider a Trello trigger node. When a Trello Board gets updated, it will trigger a workflow to start using the data from the updated board. - - -## Workflow - -A workflow is a canvas on which you can place and connect nodes. A workflow can be started manually or by trigger nodes. A workflow run ends when all active and connected nodes have processed their data. diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md deleted file mode 100644 index ace6db8d8e..0000000000 --- a/docs/keyboard-shortcuts.md +++ /dev/null @@ -1,28 +0,0 @@ -# Keyboard Shortcuts - -The following keyboard shortcuts can currently be used: - -## General - - - **Ctrl + Left Mouse Button**: Move/Pan Node View - - **Ctrl + a**: Select all nodes - - **Ctrl + Alt + n**: Create new workflow - - **Ctrl + o**: Open workflow - - **Ctrl + s**: Save the current workflow - - **Ctrl + v**: Paste nodes - - **Tab**: Open "Node Creator". Type to filter and navigate with arrow keys. To create press "enter" - - -## With node(s) selected - - - **ArrowDown**: Select sibling node bellow the current one - - **ArrowLeft**: Select node left of the current one - - **ArrowRight**: Select node right of the current one - - **ArrowUp**: Select sibling node above the current one - - **Ctrl + c**: Copy nodes - - **Ctrl + x**: Cut nodes - - **d**: Deactivate nodes - - **Delete**: Delete nodes - - **F2**: Rename node - - **Shift + ArrowLeft**: Select all nodes left of the current one - - **Shift + ArrowRight**: Select all nodes right of the current one diff --git a/docs/license.md b/docs/license.md deleted file mode 100644 index ace732e676..0000000000 --- a/docs/license.md +++ /dev/null @@ -1,5 +0,0 @@ -# License - -n8n is [fair-code](http://faircode.io) licensed under [Apache 2.0 with Commons Clause](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) - -Additional information about the license can be found in the [FAQ](faq.md?id=license) and [fair-code](http://faircode.io). diff --git a/docs/node-basics.md b/docs/node-basics.md deleted file mode 100644 index aea6bf6e33..0000000000 --- a/docs/node-basics.md +++ /dev/null @@ -1,76 +0,0 @@ -# Node Basics - - -## Types - -There are two main node types in n8n: Trigger nodes and Regular nodes. - - -### Trigger Nodes - -The Trigger nodes start a workflow and supply the initial data. A workflow can contain multiple trigger nodes but with each execution, only one of them will execute. This is because the other trigger nodes would not have any input as they are the nodes from which the execution of the workflow starts. - - -### Regular Nodes - -These nodes do the actual work. They can add, remove, and edit the data in the flow as well as request and send data to external APIs. They can do everything possible with Node.js in general. - - -## Credentials - -External services need a way to identify and authenticate users. This data can range from an API key over an email/password combination to a very long multi-line private key and can be saved in n8n as credentials. - -Nodes in n8n can then request that credential information. As an additional layer of security credentials can only be accessed by node types which specifically have the right to do so. - -To make sure that the data is secure, it gets saved to the database encrypted. A random personal encryption key is used which gets automatically generated on the first run of n8n and then saved under `~/.n8n/config`. - - -## Expressions - -With the help of expressions, it is possible to set node parameters dynamically by referencing other data. That can be data from the flow, nodes, the environment or self-generated data. Expressions are normal text with placeholders (everything between {{...}}) that can execute JavaScript code, which offers access to special variables to access data. - -An expression could look like this: - -My name is: `{{$node["Webhook"].json["query"]["name"]}}` - -This one would return "My name is: " and then attach the value that the node with the name "Webhook" outputs and there select the property "query" and its key "name". So if the node would output this data: - -```json -{ - "query": { - "name": "Jim" - } -} -``` - -the value would be: "My name is: Jim" - -The following special variables are available: - - - **$binary**: Incoming binary data of a node - - **$evaluateExpression**: Evaluates a string as expression - - **$env**: Environment variables - - **$items**: Environment variables - - **$json**: Incoming JSON data of a node - - **$node**: Data of other nodes (binary, context, json, parameter, runIndex) - - **$parameters**: Parameters of the current node - - **$runIndex**: The current run index (first time node gets executed it is 0, second time 1, ...) - - **$workflow**: Returns workflow metadata like: active, id, name - -Normally it is not needed to write the JavaScript variables manually as they can be selected with the help of the Expression Editor. - - -## Parameters - -Parameters can be set for most nodes in n8n. The values that get set define what exactly a node does. - -Parameter values are static by default and are always the same no matter what kind of data the node processes. However, it is possible to set the values dynamically with the help of an Expression. Using Expressions, it is possible to make the parameter value dependent on other factors like the data of flow or parameters of other nodes. - -More information about it can be found under [Expressions](#expressions). - - -## Pausing Node - -Sometimes when creating and debugging a workflow, it is helpful to not execute specific nodes. To do that without disconnecting each node, you can pause them. When a node gets paused, the data passes through the node without being changed. - -There are two ways to pause a node. You can either press the pause button which gets displayed above the node when hovering over it or select the node and press “d”. diff --git a/docs/nodes.md b/docs/nodes.md deleted file mode 100644 index 8b9c7cbf0e..0000000000 --- a/docs/nodes.md +++ /dev/null @@ -1,247 +0,0 @@ -# Nodes - -## Function and Function Item Nodes - -These are the most powerful nodes in n8n. With these, almost everything can be done if you know how to -write JavaScript code. Both nodes work very similarly. They give you access to the incoming data -and you can manipulate it. - - -### Difference between both nodes - -The difference is that the code of the Function node gets executed only once. It receives the -full items (JSON and binary data) as an array and expects an array of items as a return value. The items -returned can be totally different from the incoming ones. So it is not only possible to remove and edit -existing items, but also to add or return totally new ones. - -The code of the Function Item node on the other hand gets executed once for every item. It receives -one item at a time as input and also just the JSON data. As a return value, it expects the JSON data -of one single item. That makes it possible to add, remove and edit JSON properties of items -but it is not possible to add new or remove existing items. Accessing and changing binary data is only -possible via the methods `getBinaryData` and `setBinaryData`. - -Both nodes support promises. So instead of returning the item or items directly, it is also possible to -return a promise which resolves accordingly. - - -### Function-Node - -#### Variable: items - -It contains all the items that the node received as input. - -Information about how the data is structured can be found on the page [Data Structure](data-structure.md). - -The data can be accessed and manipulated like this: - -```typescript -// Sets the JSON data property "myFileName" of the first item to the name of the -// file which is set in the binary property "image" of the same item. -items[0].json.myFileName = items[0].binary.image.fileName; - -return items; -``` - -This example creates 10 dummy items with the ids 0 to 9: - -```typescript -const newItems = []; - -for (let i=0;i<10;i++) { - newItems.push({ - json: { - id: i - } - }); -} - -return newItems; -``` - - -#### Method: $item(index: number, runIndex?: number) - -With `$item` it is possible to access the data of parent nodes. That can be the item data but also -the parameters. It expects as input an index of the item the data should be returned for. This is -needed because for each item the data returned can be different. This is probably obvious for the -item data itself but maybe less for data like parameters. The reason why it is also needed, is -that they may contain an expression. Expressions get always executed of the context for an item. -If that would not be the case, for example, the Email Send node not would be able to send multiple -emails at once to different people. Instead, the same person would receive multiple emails. - -The index is 0 based. So `$item(0)` will return the first item, `$item(1)` the second one, ... - -By default the item of the last run of the node will be returned. So if the referenced node ran -3x (its last runIndex is 2) and the current node runs the first time (its runIndex is 0) the -data of runIndex 2 of the referenced node will be returned. - -For more information about what data can be accessed via $node, check [here](#variable-node). - -Example: - -```typescript -// Returns the value of the JSON data property "myNumber" of Node "Set" (first item) -const myNumber = $item(0).$node["Set"].json["myNumber"]; -// Like above but data of the 6th item -const myNumber = $item(5).$node["Set"].json["myNumber"]; - -// Returns the value of the parameter "channel" of Node "Slack". -// If it contains an expression the value will be resolved with the -// data of the first item. -const channel = $item(0).$node["Slack"].parameter["channel"]; -// Like above but resolved with the value of the 10th item. -const channel = $item(9).$node["Slack"].parameter["channel"]; -``` - - -#### Method: $items(nodeName?: string, outputIndex?: number, runIndex?: number) - -Gives access to all the items of current or parent nodes. If no parameters get supplied, -it returns all the items of the current node. -If a node-name is given, it returns the items the node output on its first output -(index: 0, most nodes only have one output, exceptions are IF and Switch-Node) on -its last run. - -Example: - -```typescript -// Returns all the items of the current node and current run -const allItems = $items(); - -// Returns all items the node "IF" outputs (index: 0 which is Output "true" of its most recent run) -const allItems = $items("IF"); - -// Returns all items the node "IF" outputs (index: 0 which is Output "true" of the same run as current node) -const allItems = $items("IF", 0, $runIndex); - -// Returns all items the node "IF" outputs (index: 1 which is Output "false" of run 0 which is the first run) -const allItems = $items("IF", 1, 0); -``` - - -#### Variable: $node - -Works exactly like `$item` with the difference that it will always return the data of the first item and -the last run of the node. - -```typescript -// Returns the fileName of binary property "data" of Node "HTTP Request" -const fileName = $node["HTTP Request"].binary["data"]["fileName"]}} - -// Returns the context data "noItemsLeft" of Node "SplitInBatches" -const noItemsLeft = $node["SplitInBatches"].context["noItemsLeft"]; - -// Returns the value of the JSON data property "myNumber" of Node "Set" -const myNumber = $node["Set"].json['myNumber']; - -// Returns the value of the parameter "channel" of Node "Slack" -const channel = $node["Slack"].parameter["channel"]; - -// Returns the index of the last run of Node "HTTP Request" -const runIndex = $node["HTTP Request"].runIndex}} -``` - - -#### Variable: $runIndex - -Contains the index of the current run of the node. - -```typescript -// Returns all items the node "IF" outputs (index: 0 which is Output "true" of the same run as current node) -const allItems = $items("IF", 0, $runIndex); -``` - - -#### Variable: $workflow - -Gives information about the current workflow. - -```typescript -const isActive = $workflow.active; -const workflowId = $workflow.id; -const workflowName = $workflow.name; -``` - - -#### Method: $evaluateExpression(expression: string, itemIndex: number) - -Evaluates a given string as expression. -If no `itemIndex` is provided it uses by default in the Function-Node the data of item 0 and -in the Function Item-Node the data of the current item. - -Example: - -```javascript -items[0].json.variable1 = $evaluateExpression('{{1+2}}'); -items[0].json.variable2 = $evaluateExpression($node["Set"].json["myExpression"], 1); - -return items; -``` - - -#### Method: getWorkflowStaticData(type) - -Gives access to the static workflow data. -It is possible to save data directly with the workflow. This data should, however, be very small. -A common use case is to for example to save a timestamp of the last item that got processed from -an RSS-Feed or database. It will always return an object. Properties can then read, delete or -set on that object. When the workflow execution succeeds, n8n will check automatically if the data -has changed and will save it, if necessary. - -There are two types of static data. The "global" and the "node" one. Global static data is the -same in the whole workflow. And every node in the workflow can access it. The node static data -, however, is different for every node and only the node which set it can retrieve it again. - -Example: - -```javascript -// Get the global workflow static data -const staticData = getWorkflowStaticData('global'); -// Get the static data of the node -const staticData = getWorkflowStaticData('node'); - -// Access its data -const lastExecution = staticData.lastExecution; - -// Update its data -staticData.lastExecution = new Date().getTime(); - -// Delete data -delete staticData.lastExecution; -``` - -It is important to know that the static data can not be read and written when testing via the UI. -The data there will always be empty and the changes will not persist. Only when a workflow -is active and it gets called by a Trigger or Webhook, the static data will be saved. - - - -### Function Item-Node - - -#### Variable: item - -It contains the "json" data of the currently processed item. - -The data can be accessed and manipulated like this: - -```json -// Uses the data of an already existing key to create a new additional one -item.newIncrementedCounter = item.existingCounter + 1; -return item; -``` - - -#### Method: getBinaryData() - -Returns all the binary data (all keys) of the item which gets currently processed. - - -#### Method: setBinaryData(binaryData) - -Sets all the binary data (all keys) of the item which gets currently processed. - - -#### Method: getWorkflowStaticData(type) - -As described above for Function node. diff --git a/docs/quick-start.md b/docs/quick-start.md deleted file mode 100644 index 0c33cafbe8..0000000000 --- a/docs/quick-start.md +++ /dev/null @@ -1,43 +0,0 @@ -# Quick Start - - -## Give n8n a spin - -To spin up n8n, you can run: - -```bash -npx n8n -``` - -It will download everything that is needed to start n8n. - -You can then access n8n by opening: -[http://localhost:5678](http://localhost:5678) - - -## Start with docker - -To play around with n8n, you can also start it using docker: - -```bash -docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - n8nio/n8n -``` - -Be aware that all the data will be lost once the docker container gets removed. To -persist the data mount the `~/.n8n` folder: - -```bash -docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n -``` - -More information about the Docker setup can be found in the README file of the -[Docker Image](https://github.com/n8n-io/n8n/blob/master/docker/images/n8n/README.md). - -In case you run into issues, check out the [troubleshooting](troubleshooting.md) page or ask for help in the community [forum](https://community.n8n.io/). diff --git a/docs/security.md b/docs/security.md deleted file mode 100644 index 5682b2c29a..0000000000 --- a/docs/security.md +++ /dev/null @@ -1,13 +0,0 @@ -# Security - -By default, n8n can be accessed by everybody. This is okay if you only have it running -locally but if you deploy it on a server which is accessible from the web, you have -to make sure that n8n is protected. -Right now we have very basic protection in place using basic-auth. It can be activated -by setting the following environment variables: - -```bash -export N8N_BASIC_AUTH_ACTIVE=true -export N8N_BASIC_AUTH_USER= -export N8N_BASIC_AUTH_PASSWORD= -``` diff --git a/docs/sensitive-data.md b/docs/sensitive-data.md deleted file mode 100644 index fa7b0bb1b6..0000000000 --- a/docs/sensitive-data.md +++ /dev/null @@ -1,18 +0,0 @@ -# Sensitive Data via File - -To avoid passing sensitive information via environment variables, "_FILE" may be -appended to some environment variables. It will then load the data from a file -with the given name. That makes it possible to load data easily from -Docker and Kubernetes secrets. - -The following environment variables support file input: - - - DB_MONGODB_CONNECTION_URL_FILE - - DB_POSTGRESDB_DATABASE_FILE - - DB_POSTGRESDB_HOST_FILE - - DB_POSTGRESDB_PASSWORD_FILE - - DB_POSTGRESDB_PORT_FILE - - DB_POSTGRESDB_USER_FILE - - DB_POSTGRESDB_SCHEMA_FILE - - N8N_BASIC_AUTH_PASSWORD_FILE - - N8N_BASIC_AUTH_USER_FILE diff --git a/docs/server-setup.md b/docs/server-setup.md deleted file mode 100644 index d34d076a6f..0000000000 --- a/docs/server-setup.md +++ /dev/null @@ -1,183 +0,0 @@ -# Server Setup - -!> ***Important***: Make sure that you secure your n8n instance as described under [Security](security.md). - - -## Example setup with docker-compose - -If you have already installed docker and docker-compose, then you can directly start with step 4. - - -### 1. Install Docker - -This can vary depending on the Linux distribution used. Example bellow is for Ubuntu: - -```bash -sudo apt update -sudo apt install apt-transport-https ca-certificates curl software-properties-common -curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - -sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable" -sudo apt update -sudo apt install docker-ce -y -``` - -### 2. Optional: If it should run as not root user - -Run when logged in as the user that should also be allowed to run docker: - -```bash -sudo usermod -aG docker ${USER} -su - ${USER} -``` - -### 3. Install Docker-compose - -This can vary depending on the Linux distribution used. Example bellow is for Ubuntu: - -Check before what version the latestand replace "1.24.1" with that version accordingly. -https://github.com/docker/compose/releases - -```bash -sudo curl -L https://github.com/docker/compose/releases/download/1.24.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose -sudo chmod +x /usr/local/bin/docker-compose -``` - - -### 4. Setup DNS - -Add A record to route the subdomain accordingly. - -``` -Type: A -Name: n8n (or whatever the subdomain should be) -IP address: -``` - - -### 5. Create docker-compose file - -Save this file as `docker-compose.yml` - -Normally no changes should be needed. - -```yaml -version: "3" - -services: - traefik: - image: "traefik" - command: - - "--api=true" - - "--api.insecure=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" - ports: - - "443:443" - 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(`${SUBDOMAIN}.${DOMAIN_NAME}`) - - traefik.http.routers.n8n.tls=true - - traefik.http.routers.n8n.entrypoints=websecure - - 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=${SUBDOMAIN}.${DOMAIN_NAME} - - N8N_PORT=5678 - - N8N_LISTEN_ADDRESS=0.0.0.0 - - N8N_PROTOCOL=https - - NODE_ENV=production - - WEBHOOK_TUNNEL_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/ - - VUE_APP_URL_BASE_API=https://${SUBDOMAIN}.${DOMAIN_NAME}/ - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ${DATA_FOLDER}/.n8n:/root/.n8n -``` - - -### 6. Create `.env` file - -Create `.env` file and change it accordingly. - -```bash -# Folder where data should be saved -DATA_FOLDER=/root/n8n/ - -# The top level domain to serve from -DOMAIN_NAME=example.com - -# The subdomain to serve from -SUBDOMAIN=n8n - -# DOMAIN_NAME and SUBDOMAIN combined decide where n8n will be reachable from -# above example would result in: https://n8n.example.com - -# 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 -``` - - -### 7. Create data folder - -Create the folder which is defined as `DATA_FOLDER`. In the example -above, it is `/root/n8n/`. - -In that folder, the database file from SQLite as well as the encryption key will be saved. - -The folder can be created like this: -``` -mkdir /root/n8n/ -``` - - -### 8. Start docker-compose setup - -n8n can now be started via: - -```bash -sudo docker-compose up -d -``` - -In case it should ever be stopped that can be done with this command: -```bash -sudo docker-compose stop -``` - - -### 9. Done - -n8n will now be reachable via the above defined subdomain + domain combination. -The above example would result in: https://n8n.example.com - -n8n will only be reachable via https and not via http. diff --git a/docs/setup.md b/docs/setup.md deleted file mode 100644 index 27e65e54ea..0000000000 --- a/docs/setup.md +++ /dev/null @@ -1,35 +0,0 @@ -# Setup - - -## Installation - -To install n8n globally: - -```bash -npm install n8n -g -``` - -## Start - -After the installation n8n can be started by simply typing in: - -```bash -n8n -# or -n8n start -``` - - -## Start with tunnel - -!> **WARNING**: This is only meant for local development and testing. It should not be used in production! - -To be able to use webhooks for trigger nodes of external services like GitHub, n8n has to be reachable from the web. To make that easy, n8n has a special tunnel service, which redirects requests from our servers to your local n8n instance (uses this code: [https://github.com/localtunnel/localtunnel](https://github.com/localtunnel/localtunnel)). - -To use it, simply start n8n with `--tunnel` - -```bash -n8n start --tunnel -``` - -In case you run into issues, check out the [troubleshooting](troubleshooting.md) page or ask for help in the community [forum](https://community.n8n.io/). diff --git a/docs/start-workflows-via-cli.md b/docs/start-workflows-via-cli.md deleted file mode 100644 index 6327f32963..0000000000 --- a/docs/start-workflows-via-cli.md +++ /dev/null @@ -1,15 +0,0 @@ -# Start Workflows via CLI - -Workflows cannot be only started by triggers, webhooks or manually via the -Editor. It is also possible to start them directly via the CLI. - -Execute a saved workflow by its ID: - -```bash -n8n execute --id -``` - -Execute a workflow from a workflow file: -```bash -n8n execute --file -``` diff --git a/docs/test.md b/docs/test.md deleted file mode 100644 index 02a308b3ad..0000000000 --- a/docs/test.md +++ /dev/null @@ -1,3 +0,0 @@ -# This is a simple test - -with some text diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md deleted file mode 100644 index 7e6b4b058b..0000000000 --- a/docs/troubleshooting.md +++ /dev/null @@ -1,58 +0,0 @@ -# Troubleshooting - -## Windows - -If you are experiencing issues running n8n with the typical flow of: - -```powershell -npx n8n -``` - -### Requirements - -Please ensure that you have the following requirements fulfilled: - -- Install latest version of [NodeJS](https://nodejs.org/en/download/) -- Install [Python 2.7](https://www.python.org/downloads/release/python-2717/) (It is okay to have multiple versions installed on the machine) -- Windows SDK -- C++ Desktop Development Tools -- Windows Build Tools - -#### Install build tools - -If you haven't satisfied the above, follow this procedure through your PowerShell (run with administrative privileges). -This command installs the build tools, windows SDK and the C++ development tools in one package. - -```powershell -npm install --global --production windows-build-tools -``` - -#### Configure npm to use Python version 2.7 - -```powershell -npm config set python python2.7 -``` - -#### Configure npm to use correct msvs version - -```powershell -npm config set msvs_version 2017 --global -``` - -### Lesser known issues: - -#### mmmagic npm package when using MSbuild tools with Visual Studio - -While installing this package, `node-gyp` is run and it might fail to install it with an error appearing in the ballpark of: - -``` -gyp ERR! stack Error: spawn C:\Program Files (x86)\Microsoft Visual Studio\2019\**Enterprise**\MSBuild\Current\Bin\MSBuild.exe ENOENT -``` - -It is seeking the `MSBuild.exe` in a directory that does not exist. If you are using Visual Studio Community or vice versa, you can change the path of MSBuild with command: - -```powershell -npm config set msbuild_path "C:\Program Files (x86)\Microsoft Visual Studio\2019\**Community**\MSBuild\Current\Bin\MSBuild.exe" -``` - -Attempt to install package again after running the command above. diff --git a/docs/tutorials.md b/docs/tutorials.md deleted file mode 100644 index 527274be53..0000000000 --- a/docs/tutorials.md +++ /dev/null @@ -1,26 +0,0 @@ -# Tutorials - - -## Examples - -Example workflows which show what can be done with n8n can be found here: -[https://n8n.io/workflows](https://n8n.io/workflows) - -If you want to know how a node can get used in context, you can search for it here: -[https://n8n.io/nodes](https://n8n.io/nodes). There it shows in which workflows the -node got used. - - - -## Videos - - - [Slack Notification on Github Star](https://www.youtube.com/watch?v=3w7xIMKLVAg) - - [Typeform to Google Sheet and Slack or Email](https://www.youtube.com/watch?v=rn3-d4IiW44) - - -### Community Tutorials - - - [n8n basics 1/3 - Getting Started](https://www.youtube.com/watch?v=JIaxjH2CyFc) - - [n8n basics 2/3 - Simple Workflow](https://www.youtube.com/watch?v=ovlxledZfM4) - - [n8n basics 3/3 - Transforming JSON](https://www.youtube.com/watch?v=wGAEAcfwV8w) - - [n8n Google Integration - Using Google Sheets and Google Api nodes](https://www.youtube.com/watch?v=KFqx8OmkqVE) diff --git a/docs/workflow.md b/docs/workflow.md deleted file mode 100644 index 344504d158..0000000000 --- a/docs/workflow.md +++ /dev/null @@ -1,111 +0,0 @@ -# Workflow - - -## Activate - -Activating a workflow means that the Trigger and Webhook nodes get activated and can trigger a workflow to run. By default all the newly created workflows are deactivated. That means that even if a Trigger node like the Cron node should start a workflow because a predefined time is reached, it will not unless the workflow gets activated. It is only possible to activate a workflow which contains a Trigger or a Webhook node. - - -## Data Flow - -Nodes do not only process one "item", they process multiple ones. So if the Trello node is set to "Create-Card" and it has an expression set for "Name" to be set depending on "name" property, it will create a card for each item, always choosing the name-property-value of the current one. - -This data would, for example, create two boards. One named "test1" the other one named "test2": - -```json -[ - { - name: "test1" - }, - { - name: "test2" - } -] -``` - - -## Error Workflows - -For each workflow, an optional "Error Workflow" can be set. It gets executed in case the execution of the workflow fails. That makes it possible to, for instance, inform the user via Email or Slack if something goes wrong. The same "Error Workflow" can be set on multiple workflows. - -The only difference between a regular workflow and an "Error Workflow" is that it contains an "Error Trigger" node. So it is important to make sure that this node gets created before setting a workflow as "Error Workflow". - -The "Error Trigger" node will trigger in case the execution fails and receives information about it. The data looks like this: - -```json -[ - { - "execution": { - "id": "231", - "url": "https://n8n.example.com/execution/231", - "retryOf": "34", - "error": { - "message": "Example Error Message", - "stack": "Stacktrace" - }, - "lastNodeExecuted": "Node With Error", - "mode": "manual" - }, - "workflow": { - "id": "1", - "name": "Example Workflow" - } - } -] - -``` - -All information is always present except: -- **execution.id**: Only present when the execution gets saved in the database -- **execution.url**: Only present when the execution gets saved in the database -- **execution.retryOf**: Only present when the execution is a retry of a previously failed execution - - -### Setting Error Workflow - -An "Error Workflow" can be set in the Workflow Settings which can be accessed by pressing the "Workflow" button in the menu on the on the left side. The last option is "Settings". In the window that appears, the "Error Workflow" can be selected via the Dropdown "Error Workflow". - - -## Share Workflows - -All workflows are JSON and can be shared very easily. - -There are multiple ways to download a workflow as JSON to then share it with other people via Email, Slack, Skype, Dropbox, … - - 1. Press the "Download" button under the Workflow menu in the sidebar on the left. It then downloads the workflow as a JSON file. - 1. Select the nodes in the editor which should be exported and then copy them (Ctrl + c). The nodes then get saved as JSON in the clipboard and can be pasted wherever desired (Ctrl + v). - -Importing that JSON representation again into n8n is as easy and can also be done in different ways: - - 1. Press "Import from File" or "Import from URL" under the Workflow menu in the sidebar on the left. - 1. Copy the JSON workflow to the clipboard (Ctrl + c) and then simply pasting it directly into the editor (Ctrl + v). - - -## Workflow Settings - -On each workflow, it is possible to set some custom settings and overwrite some of the global default settings. Currently, the following settings can be set: - - -### Error Workflow - -Workflow to run in case the execution of the current workflow fails. More information in section [Error Workflows](#error-workflows). - - -### Timezone - -The timezone to use in the current workflow. If not set, the global Timezone (by default "New York" gets used). For instance, this is important for the Cron Trigger node. - - -### Save Data Error Execution - -If the Execution data of the workflow should be saved when it fails. - - -### Save Data Success Execution - -If the Execution data of the workflow should be saved when it succeeds. - - -### Save Manual Executions - -If executions started from the Editor UI should be saved. From 559afb488bb8960adb60755d9f4aea5e1026d0a5 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 5 Jul 2020 19:18:01 +0200 Subject: [PATCH 069/155] :whale: Add Raspberry Pi Docker image --- .github/workflows/docker-images.yml | 5 +++++ docker/images/n8n-rpi/Dockerfile | 20 ++++++++++++++++++++ docker/images/n8n-rpi/README.md | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 docker/images/n8n-rpi/Dockerfile create mode 100644 docker/images/n8n-rpi/README.md diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 8d034b5b91..b05b6aa714 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -32,3 +32,8 @@ jobs: run: docker build --build-arg N8N_VERSION=${{steps.vars.outputs.tag}} -t n8nio/n8n:${{steps.vars.outputs.tag}}-ubuntu docker/images/n8n-ubuntu - name: Push Docker image of version (Ubuntu) run: docker push n8nio/n8n:${{steps.vars.outputs.tag}}-ubuntu + + - name: Build the Docker image of version (Rpi) + run: docker build --build-arg N8N_VERSION=${{steps.vars.outputs.tag}} -t n8nio/n8n:${{steps.vars.outputs.tag}}-rpi docker/images/n8n-rpi + - name: Push Docker image of version (Rpi) + run: docker push n8nio/n8n:${{steps.vars.outputs.tag}}-rpi diff --git a/docker/images/n8n-rpi/Dockerfile b/docker/images/n8n-rpi/Dockerfile new file mode 100644 index 0000000000..b60d50bdeb --- /dev/null +++ b/docker/images/n8n-rpi/Dockerfile @@ -0,0 +1,20 @@ +FROM arm32v7/node:12.16 + +ARG N8N_VERSION + +RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" ; exit 1; fi + +RUN \ + apt-get update && \ + apt-get -y install graphicsmagick gosu + +RUN npm_config_user=root npm install -g full-icu n8n@${N8N_VERSION} + +ENV NODE_ICU_DATA /usr/local/lib/node_modules/full-icu +ENV NODE_ENV production + +WORKDIR /data + +USER node + +CMD n8n diff --git a/docker/images/n8n-rpi/README.md b/docker/images/n8n-rpi/README.md new file mode 100644 index 0000000000..9eca14e3f6 --- /dev/null +++ b/docker/images/n8n-rpi/README.md @@ -0,0 +1,21 @@ +## n8n - Raspberry PI Docker Image + +Dockerfile to build n8n for Raspberry PI. + +For information about how to run n8n with Docker check the generic +[Docker-Readme](https://github.com/n8n-io/n8n/tree/master/docker/images/n8n/README.md) + + +``` +docker build --build-arg N8N_VERSION= -t n8nio/n8n: . + +# For example: +docker build --build-arg N8N_VERSION=0.43.0 -t n8nio/n8n:0.43.0-rpi . +``` + +``` +docker run -it --rm \ + --name n8n \ + -p 5678:5678 \ + n8nio/n8n:0.70.0-rpi +``` From b4b65bb90624ad00e6e3336c276a363548fb60cc Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 5 Jul 2020 19:22:25 +0200 Subject: [PATCH 070/155] :whale: Build Raspberry Pi Docker image correctly --- .github/workflows/docker-images-rpi.yml | 28 ++++++++++++++ .github/workflows/docker-images.yml | 5 --- docker/images/n8n-custom/Dockerfile copy | 49 ++++++++++++++++++++++++ docker/images/n8n-rhel7/Dockerfile | 23 +++++++++++ docker/images/n8n-rhel7/README.md | 16 ++++++++ 5 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/docker-images-rpi.yml create mode 100644 docker/images/n8n-custom/Dockerfile copy create mode 100644 docker/images/n8n-rhel7/Dockerfile create mode 100644 docker/images/n8n-rhel7/README.md diff --git a/.github/workflows/docker-images-rpi.yml b/.github/workflows/docker-images-rpi.yml new file mode 100644 index 0000000000..c6db9ed95b --- /dev/null +++ b/.github/workflows/docker-images-rpi.yml @@ -0,0 +1,28 @@ +name: Docker Image CI - Rpi + +on: + push: + tags: + - n8n@* + +jobs: + armv7_job: + runs-on: ubuntu-18.04 + name: Build on ARMv7 (Rpi) + steps: + - uses: actions/checkout@v1 + - name: Get the version + id: vars + run: echo ::set-output name=tag::$(echo ${GITHUB_REF:14}) + + - name: Log in to Docker registry + run: docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: crazy-max/ghaction-docker-buildx@v1 + with: + version: latest + - name: Run Buildx (push image) + if: success() + run: | + docker buildx build --platform linux/arm/v7 --build-arg N8N_VERSION=${{steps.vars.outputs.tag}} -t n8nio/n8n:${{steps.vars.outputs.tag}}-rpi --output type=image,push=true docker/images/n8n-rpi diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index b05b6aa714..8d034b5b91 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -32,8 +32,3 @@ jobs: run: docker build --build-arg N8N_VERSION=${{steps.vars.outputs.tag}} -t n8nio/n8n:${{steps.vars.outputs.tag}}-ubuntu docker/images/n8n-ubuntu - name: Push Docker image of version (Ubuntu) run: docker push n8nio/n8n:${{steps.vars.outputs.tag}}-ubuntu - - - name: Build the Docker image of version (Rpi) - run: docker build --build-arg N8N_VERSION=${{steps.vars.outputs.tag}} -t n8nio/n8n:${{steps.vars.outputs.tag}}-rpi docker/images/n8n-rpi - - name: Push Docker image of version (Rpi) - run: docker push n8nio/n8n:${{steps.vars.outputs.tag}}-rpi diff --git a/docker/images/n8n-custom/Dockerfile copy b/docker/images/n8n-custom/Dockerfile copy new file mode 100644 index 0000000000..19f08a16dd --- /dev/null +++ b/docker/images/n8n-custom/Dockerfile copy @@ -0,0 +1,49 @@ +FROM node:12.16-alpine as builder +# FROM node:12.16-alpine + +# Update everything and install needed dependencies +RUN apk add --update graphicsmagick tzdata git tini su-exec + +USER root + +# Install all needed dependencies +RUN apk --update add --virtual build-dependencies python build-base ca-certificates && \ + npm_config_user=root npm install -g full-icu lerna + +ENV NODE_ICU_DATA /usr/local/lib/node_modules/full-icu + +WORKDIR /data + +COPY lerna.json . +COPY package.json . +COPY packages/cli/ ./packages/cli/ +COPY packages/core/ ./packages/core/ +COPY packages/editor-ui/ ./packages/editor-ui/ +COPY packages/nodes-base/ ./packages/nodes-base/ +COPY packages/workflow/ ./packages/workflow/ +RUN rm -rf node_modules packages/*/node_modules packages/*/dist + +RUN npm install --loglevel notice +RUN lerna bootstrap --hoist +RUN npm run build + + +FROM node:12.16-alpine + +WORKDIR /data + +# Install all needed dependencies +RUN npm_config_user=root npm install -g full-icu + +USER root + +ENV NODE_ICU_DATA /usr/local/lib/node_modules/full-icu + +COPY --from=builder /data ./ + +RUN apk add --update graphicsmagick tzdata git tini su-exec + +COPY docker/images/n8n-dev/docker-entrypoint.sh /docker-entrypoint.sh +ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"] + +EXPOSE 5678/tcp diff --git a/docker/images/n8n-rhel7/Dockerfile b/docker/images/n8n-rhel7/Dockerfile new file mode 100644 index 0000000000..949d436602 --- /dev/null +++ b/docker/images/n8n-rhel7/Dockerfile @@ -0,0 +1,23 @@ +FROM richxsl/rhel7 + +ARG N8N_VERSION + +RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" ; exit 1; fi + +RUN \ + yum install -y gcc-c++ make + +RUN \ + curl -sL https://rpm.nodesource.com/setup_12.x | sudo -E bash - + +RUN \ + sudo yum install nodejs + +# Set a custom user to not have n8n run as root +USER root + +RUN npm_config_user=root npm install -g n8n@${N8N_VERSION} + +WORKDIR /data + +CMD "n8n" diff --git a/docker/images/n8n-rhel7/README.md b/docker/images/n8n-rhel7/README.md new file mode 100644 index 0000000000..015f7ac07f --- /dev/null +++ b/docker/images/n8n-rhel7/README.md @@ -0,0 +1,16 @@ +## Build Docker-Image + +``` +docker build --build-arg N8N_VERSION= -t n8nio/n8n: . + +# For example: +docker build --build-arg N8N_VERSION=0.36.1 -t n8nio/n8n:0.36.1-rhel7 . +``` + + +``` +docker run -it --rm \ + --name n8n \ + -p 5678:5678 \ + n8nio/n8n:0.25.0-ubuntu +``` From af4333f268bf3de5422bcdac4ec3e41dc25ac983 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Mon, 6 Jul 2020 15:05:05 +0200 Subject: [PATCH 071/155] :zap: Some minior improvements on Zoom-Node --- .../credentials/ZoomApi.credentials.ts | 2 +- .../nodes/Zoom/MeetingDescription.ts | 155 ++++++++++-------- packages/nodes-base/nodes/Zoom/Zoom.node.ts | 11 +- 3 files changed, 86 insertions(+), 82 deletions(-) diff --git a/packages/nodes-base/credentials/ZoomApi.credentials.ts b/packages/nodes-base/credentials/ZoomApi.credentials.ts index 3db4aadbe0..dbef996429 100644 --- a/packages/nodes-base/credentials/ZoomApi.credentials.ts +++ b/packages/nodes-base/credentials/ZoomApi.credentials.ts @@ -5,7 +5,7 @@ export class ZoomApi implements ICredentialType { displayName = 'Zoom API'; properties = [ { - displayName: 'Access Token', + displayName: 'JTW Token', name: 'accessToken', type: 'string' as NodePropertyTypes, default: '' diff --git a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts index 130830b9fe..f412235396 100644 --- a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts +++ b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts @@ -50,6 +50,27 @@ export const meetingFields = [ /* -------------------------------------------------------------------------- */ /* meeting:create */ /* -------------------------------------------------------------------------- */ + { + displayName: 'Topic', + name: 'topic', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + displayOptions: { + show: { + operation: [ + 'create', + + ], + resource: [ + 'meeting', + ], + }, + }, + description: `Topic of the meeting.`, + }, { displayName: 'Additional Fields', name: 'additionalFields', @@ -88,42 +109,6 @@ export const meetingFields = [ default: 0, description: 'Meeting duration (minutes).', }, - { - displayName: 'Meeting Topic', - name: 'topic', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - description: `Meeting topic.`, - }, - { - displayName: 'Meeting Type', - name: 'type', - type: 'options', - options: [ - { - name: 'Instant Meeting', - value: 1, - }, - { - name: 'Scheduled Meeting', - value: 2, - }, - { - name: 'Recurring Meeting with no fixed time', - value: 3, - }, - { - name: 'Recurring Meeting with fixed time', - value: 8, - }, - - ], - default: 2, - description: 'Meeting type.', - }, { displayName: 'Password', name: 'password', @@ -284,13 +269,39 @@ export const meetingFields = [ default: '', description: `Time zone used in the response. The default is the time zone of the calendar.`, }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Instant Meeting', + value: 1, + }, + { + name: 'Scheduled Meeting', + value: 2, + }, + { + name: 'Recurring Meeting with no fixed time', + value: 3, + }, + { + name: 'Recurring Meeting with fixed time', + value: 8, + }, + + ], + default: 2, + description: 'Meeting type.', + }, ], }, /* -------------------------------------------------------------------------- */ /* meeting:get */ /* -------------------------------------------------------------------------- */ { - displayName: 'Meeting ID', + displayName: 'ID', name: 'meetingId', type: 'string', default: '', @@ -433,7 +444,7 @@ export const meetingFields = [ /* meeting:delete */ /* -------------------------------------------------------------------------- */ { - displayName: 'Meeting ID', + displayName: 'ID', name: 'meetingId', type: 'string', default: '', @@ -488,7 +499,7 @@ export const meetingFields = [ /* meeting:update */ /* -------------------------------------------------------------------------- */ { - displayName: 'Meeting ID', + displayName: 'ID', name: 'meetingId', type: 'string', default: '', @@ -542,39 +553,6 @@ export const meetingFields = [ default: 0, description: 'Meeting duration (minutes).', }, - { - displayName: 'Meeting Topic', - name: 'topic', - type: 'string', - default: '', - description: `Meeting topic.`, - }, - { - displayName: 'Meeting Type', - name: 'type', - type: 'options', - options: [ - { - name: 'Instant Meeting', - value: 1, - }, - { - name: 'Scheduled Meeting', - value: 2, - }, - { - name: 'Recurring Meeting with no fixed time', - value: 3, - }, - { - name: 'Recurring Meeting with fixed time', - value: 8, - }, - - ], - default: 2, - description: 'Meeting type.', - }, { displayName: 'Password', name: 'password', @@ -735,6 +713,39 @@ export const meetingFields = [ default: '', description: `Time zone used in the response. The default is the time zone of the calendar.`, }, + { + displayName: 'Topic', + name: 'topic', + type: 'string', + default: '', + description: `Meeting topic.`, + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Instant Meeting', + value: 1, + }, + { + name: 'Scheduled Meeting', + value: 2, + }, + { + name: 'Recurring Meeting with no fixed time', + value: 3, + }, + { + name: 'Recurring Meeting with fixed time', + value: 8, + }, + + ], + default: 2, + description: 'Meeting type.', + }, ], }, ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts index 7f735d33b8..f7ecff0de8 100644 --- a/packages/nodes-base/nodes/Zoom/Zoom.node.ts +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -252,10 +252,7 @@ export class Zoom implements INodeType { } if (operation === 'create') { //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate - const additionalFields = this.getNodeParameter( - 'additionalFields', - i - ) as IDataObject; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; const body: IDataObject = {}; @@ -310,9 +307,7 @@ export class Zoom implements INodeType { body.settings = settingValues; } - if (additionalFields.topic) { - body.topic = additionalFields.topic as string; - } + body.topic = this.getNodeParameter('topic', i) as string; if (additionalFields.type) { body.type = additionalFields.type as string; @@ -347,8 +342,6 @@ export class Zoom implements INodeType { body.agenda = additionalFields.agenda as string; } - console.log(body); - responseData = await zoomApiRequest.call( this, 'POST', From c4332634eb2ef083842d64b6d715a407d7773b3b Mon Sep 17 00:00:00 2001 From: ricardo Date: Mon, 6 Jul 2020 12:18:36 -0400 Subject: [PATCH 072/155] :zap: Improvements --- .../credentials/CircleCiApi.credentials.ts | 2 +- .../nodes/CircleCi/CircleCi.node.ts | 9 +- .../nodes/CircleCi/PipelineDescription.ts | 92 ++++++++++++++++++- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/credentials/CircleCiApi.credentials.ts b/packages/nodes-base/credentials/CircleCiApi.credentials.ts index 88ecdc4fe1..ef3104b5e6 100644 --- a/packages/nodes-base/credentials/CircleCiApi.credentials.ts +++ b/packages/nodes-base/credentials/CircleCiApi.credentials.ts @@ -8,7 +8,7 @@ export class CircleCiApi implements ICredentialType { displayName = 'CircleCI API'; properties = [ { - displayName: 'API Key', + displayName: 'Personal API Token', name: 'apiKey', type: 'string' as NodePropertyTypes, default: '', diff --git a/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts b/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts index 00eded6366..a15a9511e2 100644 --- a/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts +++ b/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts @@ -71,16 +71,18 @@ export class CircleCi implements INodeType { for (let i = 0; i < length; i++) { if (resource === 'pipeline') { if (operation === 'get') { + const vcs = this.getNodeParameter('vcs', i) as string; let slug = this.getNodeParameter('projectSlug', i) as string; const pipelineNumber = this.getNodeParameter('pipelineNumber', i) as number; slug = slug.replace(new RegExp(/\//g), '%2F'); - const endpoint = `/project/${slug}/pipeline/${pipelineNumber}`; + const endpoint = `/project/${vcs}/${slug}/pipeline/${pipelineNumber}`; responseData = await circleciApiRequest.call(this, 'GET', endpoint, {}, qs); } if (operation === 'getAll') { + const vcs = this.getNodeParameter('vcs', i) as string; const filters = this.getNodeParameter('filters', i) as IDataObject; const returnAll = this.getNodeParameter('returnAll', i) as boolean; let slug = this.getNodeParameter('projectSlug', i) as string; @@ -91,7 +93,7 @@ export class CircleCi implements INodeType { qs.branch = filters.branch; } - const endpoint = `/project/${slug}/pipeline`; + const endpoint = `/project/${vcs}/${slug}/pipeline`; if (returnAll === true) { responseData = await circleciApiRequestAllItems.call(this, 'items', 'GET', endpoint, {}, qs); @@ -105,13 +107,14 @@ export class CircleCi implements INodeType { } if (operation === 'trigger') { + const vcs = this.getNodeParameter('vcs', i) as string; let slug = this.getNodeParameter('projectSlug', i) as string; const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; slug = slug.replace(new RegExp(/\//g), '%2F'); - const endpoint = `/project/${slug}/pipeline`; + const endpoint = `/project/${vcs}/${slug}/pipeline`; const body: IDataObject = {}; diff --git a/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts b/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts index 66b99f3eb3..4ffd2183d6 100644 --- a/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts +++ b/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts @@ -41,6 +41,33 @@ export const pipelineFields = [ /* -------------------------------------------------------------------------- */ /* pipeline:get */ /* -------------------------------------------------------------------------- */ + { + displayName: 'VCS', + name: 'vcs', + type: 'options', + options: [ + { + name: 'Github', + value: 'github', + }, + { + name: 'Bitbucket', + value: 'bitbucket', + }, + ], + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: '', + description: 'Version control system', + }, { displayName: 'Project Slug', name: 'projectSlug', @@ -56,12 +83,15 @@ export const pipelineFields = [ }, }, default: '', - description: 'Project slug in the form vcs-slug/org-name/repo-name', + description: 'Project slug in the form org-name/repo-name', }, { displayName: 'Pipeline Number', name: 'pipelineNumber', type: 'number', + typeOptions: { + minValue: 1, + }, displayOptions: { show: { operation: [ @@ -72,12 +102,39 @@ export const pipelineFields = [ ], }, }, - default: 0, + default: 1, description: 'The number of the pipeline', }, /* -------------------------------------------------------------------------- */ /* pipeline:getAll */ /* -------------------------------------------------------------------------- */ + { + displayName: 'VCS', + name: 'vcs', + type: 'options', + options: [ + { + name: 'Github', + value: 'github', + }, + { + name: 'Bitbucket', + value: 'bitbucket', + }, + ], + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: '', + description: 'Version control system', + }, { displayName: 'Project Slug', name: 'projectSlug', @@ -93,7 +150,7 @@ export const pipelineFields = [ }, }, default: '', - description: 'Project slug in the form vcs-slug/org-name/repo-name', + description: 'Project slug in the form org-name/repo-name', }, { displayName: 'Return All', @@ -165,6 +222,33 @@ export const pipelineFields = [ /* -------------------------------------------------------------------------- */ /* pipeline:trigger */ /* -------------------------------------------------------------------------- */ + { + displayName: 'VCS', + name: 'vcs', + type: 'options', + options: [ + { + name: 'Github', + value: 'github', + }, + { + name: 'Bitbucket', + value: 'bitbucket', + }, + ], + displayOptions: { + show: { + operation: [ + 'trigger', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: '', + description: 'Version control system', + }, { displayName: 'Project Slug', name: 'projectSlug', @@ -180,7 +264,7 @@ export const pipelineFields = [ }, }, default: '', - description: 'Project slug in the form vcs-slug/org-name/repo-name', + description: 'Project slug in the form org-name/repo-name', }, { displayName: 'Additional Fields', From 87b67d66701e1e820e24e08513cd917a44c7f867 Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Tue, 7 Jul 2020 09:35:15 +0200 Subject: [PATCH 073/155] :white_check_mark: Replaced png and added descriptions to all events --- .../nodes/Postmark/PostmarkTrigger.node.ts | 5 +++++ .../nodes-base/nodes/Postmark/postmark.png | Bin 3301 -> 1297 bytes 2 files changed, 5 insertions(+) diff --git a/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts b/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts index f6f09afdd4..9cd8f7b01c 100644 --- a/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts +++ b/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts @@ -62,14 +62,17 @@ export class PostmarkTrigger implements INodeType { { name: 'Click', value: 'click', + description: 'Trigger on click.' }, { name: 'Delivery', value: 'delivery', + description: 'Trigger on delivery.' }, { name: 'Bounce', value: 'bounce', + description: 'Trigger on bounce.' }, { name: 'Bounce Content', @@ -79,6 +82,7 @@ export class PostmarkTrigger implements INodeType { { name: 'Spam Complaint', value: 'spamComplaint', + description: 'Trigger on spam complaint.' }, { name: 'Spam Complaint Content', @@ -88,6 +92,7 @@ export class PostmarkTrigger implements INodeType { { name: 'Subscription Change', value: 'subscriptionChange', + description: 'Trigger on subscription change.' }, ], default: [], diff --git a/packages/nodes-base/nodes/Postmark/postmark.png b/packages/nodes-base/nodes/Postmark/postmark.png index 44f90b2a6c25f66c011e849512dbbbea5f128dfa..6298b4ae94bdbd1b930a5bdd870200fa9f8f157a 100644 GIT binary patch delta 1269 zcmV2c7k>o9NklH0HK@eA>;KxcAA})#x!JQ}wZbd=6&_clvw1|S}LX?VP zYCo(=o3#0Al4&v@@7;S`%rqTaXx==gGnUSOF&~dR^L}&Qx##@veM3Esn@(P{0e_OH z1>GpMpc|zYbfeUQZj@TkYlwi><%<$P6JYrvE(Ena8Xy!MunS^D_jD*dWl}+oHR-F7Mm3 zbno_DTVSD@AZL>0w2}~`rk0DWFn`4o#xHo|Q*Gb@dl z$;F)id52*yrlUwmVDVaX@q9Jg;>dgt|tOlR>5gVHH;%igiEjt>s6`|Wt9QcILU z5FUK#+@rhaVQWb4Bfrge5B3t#GOoI6+mu6GC)nqlZSrk)hJTclqmB#)y)d zFL+;mpWSoMQddQE^;t=U za1B}ntRcPHOsgtX0WvUA@A`0b*YbgVb9as80b8q?#*KhZ1%Ga^uefTOLHM3)4_r1A z4fI7@Hy5_uk>5N}^t=cT$|OtlIzu~$Ed%*CUpbpeSNt>y5qbu`ufAs?4hpzR`(y24 zM%|$mG#jVLbb^AhjxpXwWdO!ht1L|*F-9jC)$!bA2N-gL{sRi9A fwV)fNn-cn8xOMsu?|pBW00000NkvXXu0mjfo}+Cw literal 3301 zcmcInc~lek7LKec0xB+`h%v4RlVq|%0s#vcpcQE>sZ`X+3z=bg6Cf8LxkGryVNcfb4HW$rn% z*Wb_EeCDE=7!1bT*T*XWy}Rnq=_crv@$Qx!z0HvMY*t_}W>)%h8s^w{)) zIUJba7sO47=F)jMcQ>r7nt>8XU?qfAOT zfG%geT(1e+-n(X81}KN=V9ueZ81CR1{t$;xVXP zkVcP$A^~SEOaWjrg$K|fQX~M8xFisUBFT{uZ%h|z?}cz<_36{wkGCxk;i4Q<8Dh{$ zFi0lDAOM0+bb!XC5CMo!rU4WZnH0;AVU;XpA#Phcp#rd1wjx3og;Br9+$yK>G&E(@1nFeWv=UY`UB?>)?j-neA_ zyvvvO?fW_>7q_1iCbpu8idHR>_&zZ`_UV4MX5EciWb3oEoE+;)k$9fxo`T}rrq{A^ z76n8H3g=igdj;ZZ=bG<5dHk^VfT6u?Fx;-f^=i!fq0qSY0DR&bg9?``5P|G+zE>r9 z-9RTRY`9Nc7e>E%dbf3WxFq)B)%nqvzgn{3MIn#R=QIn0d_qFfKQp1ryZ40uoRw9z zroOSUnC0!gn?j{t=)-MFfAG1$=C((MaB09cBMh}`IxCYaZ(hH? ze5Pmif!RMbHu^RDNF-;5JI^`x4Gu1}WSu&F`llB!Gy=_rc7q>t)Ej;Fl-J*Nc<*m6F|9x}f zZgPED$H*&lmP;pJS$O!fjv2=6ndQ`^2x5x4#-QZXsjC4C9P!%qALJJrgbynkzS>Yx z7r0qtk?!zl&v#)Ds2raNqr4@+#+9#%^E@lh{tA{e4a$D;l;)qg zbcvN1TW#sUO!#!|=&z=$s?OhXbzfU)8|M4|=@+AEmJfDnT6rbel@5m_I$m9lVQU@{ zRYK(0+uPr0X~DH7-#6$vdJHeWu;Pg8%iP>|icNWT{k<*PZg)D#D2Sj<4bF(Q3(NcR z^=70h!eFuWeo^kplXY$J&PVNNUif#ta)dk^(%OCrO-u} z(3zai+U^~3_EuGVe0*?hY*9t1Q$ba4$u4W=$jQxvU6=2ryt;2^T^$uBVhVIF#N^7Q z$+f57++2Mxg?b1CsxQ2Das7@e%XkuQ^kTz;j8{py-6K}dcc(_}ZHsL0yO#H1sI_Lh z$CT6`G+wZuIhC2p&-g4_L^ry^eE0E|G>b?(bGFp11m|K zQr^Cy=bE+185F}1MuDLK-3tFG?;khFUf>o>mr_vV_ui*VuZRF-|n^I}eN zk7R$Yut9soBqN%0%&hD1VjJM%&ia8v@|qtX|NhvrGoYel!58XB2AO%wmgG6!KR2|^ zI1yhw?A)`Ana=9xe3M|Njg7Xh7+I?&@3&}g9j@`L5$YZmzN}@i+=CTOk2kQ2=4+C= z+J3mmfB80s#bh6HD|z<)rlxoI5{@11X?d=W>8EUPS-y6=bJN3yC+h?QYR46Aq&aD7 ze4BikvE^P)5oJfJv6bDb+3oY~MvF07A%wCO#2;-}KWk-2*}N$)J#n|=?V>h=?Z^gK ziTvWcO9CH*r4CLhLxma6r=HSR8-DOIC_Ad&8eZG~VCa#9h6cwtee+fqO=fqM#Yg;b z{@doB{Vy^*zk0!_*=wBn+CPm@U$|xDrbDEPHrqxwuXhx*TwGt6M5rw&b&u;pdNA!p zvj_WwHx%4)yP`YTfBpIl+XLl*=KaZozd^W^@G#(_G^gm)aGckd%C>pd`DXt@jt`f9 z+tVAD>ulL#qb+Qzi4%3vY7JH0%Ven>1C@A2YSHVbcQam^UawkXkz@Sy5rnl&H|ZOe wby@5&eYzTx^7f#2SJEY`lBH3`11HoU3?)ewA9`$Vj{S)EuJ`lG_lVf>Z!h~rQvd(} From d74e59801a6e5de7ecd5ec0d2d5c7d14e2bbeed3 Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Tue, 7 Jul 2020 10:48:56 +0200 Subject: [PATCH 074/155] Credentials name to Server API Token --- packages/nodes-base/credentials/PostmarkApi.credentials.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/credentials/PostmarkApi.credentials.ts b/packages/nodes-base/credentials/PostmarkApi.credentials.ts index 88df53aa30..b5f73621c4 100644 --- a/packages/nodes-base/credentials/PostmarkApi.credentials.ts +++ b/packages/nodes-base/credentials/PostmarkApi.credentials.ts @@ -9,7 +9,7 @@ export class PostmarkApi implements ICredentialType { displayName = 'Postmark API'; properties = [ { - displayName: 'Server Token', + displayName: 'Server API Token', name: 'serverToken', type: 'string' as NodePropertyTypes, default: '', From 1b8e75dfadc42e0ca450dfb56ced53860acfc744 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 7 Jul 2020 11:10:01 +0200 Subject: [PATCH 075/155] :zap: Minor improvements to CircleCI-Node --- .../nodes/CircleCi/PipelineDescription.ts | 111 +++--------------- 1 file changed, 17 insertions(+), 94 deletions(-) diff --git a/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts b/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts index 4ffd2183d6..beb5e345fe 100644 --- a/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts +++ b/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts @@ -39,26 +39,28 @@ export const pipelineOperations = [ export const pipelineFields = [ /* -------------------------------------------------------------------------- */ -/* pipeline:get */ +/* pipeline:shared */ /* -------------------------------------------------------------------------- */ { - displayName: 'VCS', + displayName: 'Provider', name: 'vcs', type: 'options', options: [ - { - name: 'Github', - value: 'github', - }, { name: 'Bitbucket', value: 'bitbucket', }, + { + name: 'Github', + value: 'github', + }, ], displayOptions: { show: { operation: [ 'get', + 'getAll', + 'trigger', ], resource: [ 'pipeline', @@ -76,6 +78,8 @@ export const pipelineFields = [ show: { operation: [ 'get', + 'getAll', + 'trigger', ], resource: [ 'pipeline', @@ -83,8 +87,13 @@ export const pipelineFields = [ }, }, default: '', + placeholder: 'n8n-io/n8n', description: 'Project slug in the form org-name/repo-name', }, + +/* -------------------------------------------------------------------------- */ +/* pipeline:get */ +/* -------------------------------------------------------------------------- */ { displayName: 'Pipeline Number', name: 'pipelineNumber', @@ -105,53 +114,10 @@ export const pipelineFields = [ default: 1, description: 'The number of the pipeline', }, + /* -------------------------------------------------------------------------- */ /* pipeline:getAll */ /* -------------------------------------------------------------------------- */ - { - displayName: 'VCS', - name: 'vcs', - type: 'options', - options: [ - { - name: 'Github', - value: 'github', - }, - { - name: 'Bitbucket', - value: 'bitbucket', - }, - ], - displayOptions: { - show: { - operation: [ - 'getAll', - ], - resource: [ - 'pipeline', - ], - }, - }, - default: '', - description: 'Version control system', - }, - { - displayName: 'Project Slug', - name: 'projectSlug', - type: 'string', - displayOptions: { - show: { - operation: [ - 'getAll', - ], - resource: [ - 'pipeline', - ], - }, - }, - default: '', - description: 'Project slug in the form org-name/repo-name', - }, { displayName: 'Return All', name: 'returnAll', @@ -219,53 +185,10 @@ export const pipelineFields = [ }, ], }, + /* -------------------------------------------------------------------------- */ /* pipeline:trigger */ /* -------------------------------------------------------------------------- */ - { - displayName: 'VCS', - name: 'vcs', - type: 'options', - options: [ - { - name: 'Github', - value: 'github', - }, - { - name: 'Bitbucket', - value: 'bitbucket', - }, - ], - displayOptions: { - show: { - operation: [ - 'trigger', - ], - resource: [ - 'pipeline', - ], - }, - }, - default: '', - description: 'Version control system', - }, - { - displayName: 'Project Slug', - name: 'projectSlug', - type: 'string', - displayOptions: { - show: { - operation: [ - 'trigger', - ], - resource: [ - 'pipeline', - ], - }, - }, - default: '', - description: 'Project slug in the form org-name/repo-name', - }, { displayName: 'Additional Fields', name: 'additionalFields', From c8cadbc03b670e4d70f5fbde0b21b8d71c9618f6 Mon Sep 17 00:00:00 2001 From: smamudhan <48641776+smamudhan@users.noreply.github.com> Date: Tue, 7 Jul 2020 14:42:23 +0530 Subject: [PATCH 076/155] Updated dropdown descriptions to match documentation (#730) * Updated Dropdown Descriptions to match documentation * Removed full stops and hyphens in field descriptions * Removed hyphen on description on line 267 --- .../nodes/Mattermost/Mattermost.node.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts b/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts index 0aa50f3d0e..cc7bc65ee3 100644 --- a/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts +++ b/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts @@ -62,7 +62,7 @@ export class Mattermost implements INodeType { }, ], default: 'message', - description: 'The resource to operate on.', + description: 'The resource to operate on', }, @@ -95,22 +95,22 @@ export class Mattermost implements INodeType { { name: 'Delete', value: 'delete', - description: 'Soft-deletes a channel', + description: 'Soft delete a channel', }, { name: 'Member', value: 'members', - description: 'Get a page of members for a channel.', + description: 'Get a page of members for a channel', }, { name: 'Restore', value: 'restore', - description: 'Restores a soft-deleted channel', + description: 'Restores a soft deleted channel', }, { name: 'Statistics', value: 'statistics', - description: 'Get statistics for a channel.', + description: 'Get statistics for a channel', }, ], default: 'create', @@ -131,7 +131,7 @@ export class Mattermost implements INodeType { { name: 'Delete', value: 'delete', - description: 'Soft deletes a post, by marking the post as deleted in the database.', + description: 'Soft delete a post, by marking the post as deleted in the database', }, { name: 'Post', @@ -140,7 +140,7 @@ export class Mattermost implements INodeType { }, ], default: 'post', - description: 'The operation to perform.', + description: 'The operation to perform', }, @@ -191,7 +191,7 @@ export class Mattermost implements INodeType { }, }, required: true, - description: 'The non-unique UI name for the channel.', + description: 'The non-unique UI name for the channel', }, { displayName: 'Name', @@ -210,7 +210,7 @@ export class Mattermost implements INodeType { }, }, required: true, - description: 'The unique handle for the channel, will be present in the channel URL.', + description: 'The unique handle for the channel, will be present in the channel URL', }, { displayName: 'Type', @@ -264,7 +264,7 @@ export class Mattermost implements INodeType { ], }, }, - description: 'The ID of the channel to soft-delete.', + description: 'The ID of the channel to soft delete', }, // ---------------------------------- From eaa9898de565f07c8ad038446f18fd8a0f7e7f11 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 7 Jul 2020 11:15:53 +0200 Subject: [PATCH 077/155] :zap: Fix typo --- packages/nodes-base/nodes/CircleCi/PipelineDescription.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts b/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts index beb5e345fe..520fdbcd05 100644 --- a/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts +++ b/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts @@ -51,7 +51,7 @@ export const pipelineFields = [ value: 'bitbucket', }, { - name: 'Github', + name: 'GitHub', value: 'github', }, ], From b1ee1efcb1ff802fbd1c514bc1cab990a866f70f Mon Sep 17 00:00:00 2001 From: smamudhan <48641776+smamudhan@users.noreply.github.com> Date: Tue, 7 Jul 2020 15:01:05 +0530 Subject: [PATCH 078/155] Fixed typographical errors for the GitHub node (#732) * Updated Dropdown Descriptions to match documentation * Removed full stops and hyphens in field descriptions * Removed hyphen on description on line 267 * Fixed typographical errors for Github node --- .../nodes-base/nodes/Github/Github.node.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/nodes-base/nodes/Github/Github.node.ts b/packages/nodes-base/nodes/Github/Github.node.ts index 035b381091..408c0a4360 100644 --- a/packages/nodes-base/nodes/Github/Github.node.ts +++ b/packages/nodes-base/nodes/Github/Github.node.ts @@ -17,14 +17,14 @@ import { export class Github implements INodeType { description: INodeTypeDescription = { - displayName: 'Github', + displayName: 'GitHub', name: 'github', icon: 'file:github.png', group: ['input'], version: 1, - description: 'Retrieve data from Github API.', + description: 'Retrieve data from GitHub API.', defaults: { - name: 'Github', + name: 'GitHub', color: '#665533', }, inputs: ['main'], @@ -178,7 +178,7 @@ export class Github implements INodeType { { name: 'Get', value: 'get', - description: 'Get the data of a single issues', + description: 'Get the data of a single issue', }, ], default: 'create', @@ -220,7 +220,7 @@ export class Github implements INodeType { { name: 'List Popular Paths', value: 'listPopularPaths', - description: 'Get the data of a file in repositoryGet the top 10 popular content paths over the last 14 days.', + description: 'Get the top 10 popular content paths over the last 14 days.', }, { name: 'List Referrers', @@ -458,7 +458,7 @@ export class Github implements INodeType { description: 'The name of the author of the commit.', }, { - displayName: 'EMail', + displayName: 'Email', name: 'email', type: 'string', default: '', @@ -491,7 +491,7 @@ export class Github implements INodeType { description: 'The name of the committer of the commit.', }, { - displayName: 'EMail', + displayName: 'Email', name: 'email', type: 'string', default: '', @@ -1014,28 +1014,28 @@ export class Github implements INodeType { name: 'assignee', type: 'string', default: '', - description: 'Return only issuse which are assigned to a specific user.', + description: 'Return only issues which are assigned to a specific user.', }, { displayName: 'Creator', name: 'creator', type: 'string', default: '', - description: 'Return only issuse which were created by a specific user.', + description: 'Return only issues which were created by a specific user.', }, { displayName: 'Mentioned', name: 'mentioned', type: 'string', default: '', - description: 'Return only issuse in which a specific user was mentioned.', + description: 'Return only issues in which a specific user was mentioned.', }, { displayName: 'Labels', name: 'labels', type: 'string', default: '', - description: 'Return only issuse with the given labels. Multiple lables can be separated by comma.', + description: 'Return only issues with the given labels. Multiple lables can be separated by comma.', }, { displayName: 'Updated Since', From 657d5498d6c2e18cd3de942decdf6504e1a326f1 Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Tue, 7 Jul 2020 11:44:55 +0200 Subject: [PATCH 079/155] :construction: Incorrect event description fixed --- packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts b/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts index 9cd8f7b01c..ed902a1347 100644 --- a/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts +++ b/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts @@ -87,7 +87,7 @@ export class PostmarkTrigger implements INodeType { { name: 'Spam Complaint Content', value: 'spamComplaintContent', - description: 'Webhook will send full bounce content.' + description: 'Webhook will send full spam complaint content.' }, { name: 'Subscription Change', From e55e8c4fd7f8b0a7f398a07ed8038d925c2a0a4f Mon Sep 17 00:00:00 2001 From: "David G. Simmons" Date: Tue, 7 Jul 2020 09:24:21 -0400 Subject: [PATCH 080/155] Added Node for QuestDB --- .../credentials/Questdb.credentials.ts | 73 ++++ .../nodes-base/nodes/QuestDB/QuestDB.node.ts | 337 ++++++++++++++++++ packages/nodes-base/nodes/QuestDB/questdb.png | Bin 0 -> 11244 bytes 3 files changed, 410 insertions(+) create mode 100644 packages/nodes-base/credentials/Questdb.credentials.ts create mode 100644 packages/nodes-base/nodes/QuestDB/QuestDB.node.ts create mode 100644 packages/nodes-base/nodes/QuestDB/questdb.png diff --git a/packages/nodes-base/credentials/Questdb.credentials.ts b/packages/nodes-base/credentials/Questdb.credentials.ts new file mode 100644 index 0000000000..5cd60f960a --- /dev/null +++ b/packages/nodes-base/credentials/Questdb.credentials.ts @@ -0,0 +1,73 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class QuestDB implements ICredentialType { + name = 'questdb'; + displayName = 'QuestDB'; + properties = [ + { + displayName: 'Host', + name: 'host', + type: 'string' as NodePropertyTypes, + default: 'localhost', + }, + { + displayName: 'Database', + name: 'database', + type: 'string' as NodePropertyTypes, + default: 'qdb', + }, + { + displayName: 'User', + name: 'user', + type: 'string' as NodePropertyTypes, + default: 'admin', + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true, + }, + default: 'quest', + }, + { + displayName: 'SSL', + name: 'ssl', + type: 'options' as NodePropertyTypes, + options: [ + { + name: 'disable', + value: 'disable', + }, + { + name: 'allow', + value: 'allow', + }, + { + name: 'require', + value: 'require', + }, + { + name: 'verify (not implemented)', + value: 'verify', + }, + { + name: 'verify-full (not implemented)', + value: 'verify-full', + } + ], + default: 'disable', + }, + { + displayName: 'Port', + name: 'port', + type: 'number' as NodePropertyTypes, + default: 8812, + }, + ]; +} diff --git a/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts b/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts new file mode 100644 index 0000000000..8e54e102f7 --- /dev/null +++ b/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts @@ -0,0 +1,337 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import * as pgPromise from 'pg-promise'; + + +/** + * Returns of copy of the items which only contains the json data and + * of that only the define properties + * + * @param {INodeExecutionData[]} items The items to copy + * @param {string[]} properties The properties it should include + * @returns + */ +function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataObject[] { + // Prepare the data to insert and copy it to be returned + let newItem: IDataObject; + return items.map((item) => { + newItem = {}; + for (const property of properties) { + if (item.json[property] === undefined) { + newItem[property] = null; + } else { + newItem[property] = JSON.parse(JSON.stringify(item.json[property])); + } + } + return newItem; + }); +} + + +export class QuestDB implements INodeType { + description: INodeTypeDescription = { + displayName: 'QuestDB', + name: 'questdb', + icon: 'file:questdb.png', + group: ['input'], + version: 1, + description: 'Gets, add and update data in QuestDB.', + defaults: { + name: 'QuestDB', + color: '#336791', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'questdb', + required: true, + } + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Execute Query', + value: 'executeQuery', + description: 'Executes a SQL query.', + }, + { + name: 'Insert', + value: 'insert', + description: 'Insert rows in database.', + }, + { + name: 'Update', + value: 'update', + description: 'Updates rows in database.', + }, + ], + default: 'insert', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // executeQuery + // ---------------------------------- + { + displayName: 'Query', + name: 'query', + type: 'string', + typeOptions: { + rows: 5, + }, + displayOptions: { + show: { + operation: [ + 'executeQuery' + ], + }, + }, + default: '', + placeholder: 'SELECT id, name FROM product WHERE id < 40', + required: true, + description: 'The SQL query to execute.', + }, + + + // ---------------------------------- + // insert + // ---------------------------------- + { + displayName: 'Schema', + name: 'schema', + type: 'string', + displayOptions: { + show: { + operation: [ + 'insert' + ], + }, + }, + default: 'public', + required: true, + description: 'Name of the schema the table belongs to', + }, + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: [ + 'insert' + ], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to insert data to.', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: [ + 'insert' + ], + }, + }, + default: '', + placeholder: 'id,name,description', + description: 'Comma separated list of the properties which should used as columns for the new rows.', + }, + { + displayName: 'Return Fields', + name: 'returnFields', + type: 'string', + displayOptions: { + show: { + operation: [ + 'insert' + ], + }, + }, + default: '*', + description: 'Comma separated list of the fields that the operation will return', + }, + + + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: [ + 'update' + ], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to update data in', + }, + { + displayName: 'Update Key', + name: 'updateKey', + type: 'string', + displayOptions: { + show: { + operation: [ + 'update' + ], + }, + }, + default: 'id', + required: true, + description: 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: [ + 'update' + ], + }, + }, + default: '', + placeholder: 'name,description', + description: 'Comma separated list of the properties which should used as columns for rows to update.', + }, + + ] + }; + + + async execute(this: IExecuteFunctions): Promise { + + const credentials = this.getCredentials('questdb'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const pgp = pgPromise(); + + const config = { + host: credentials.host as string, + port: credentials.port as number, + database: credentials.database as string, + user: credentials.user as string, + password: credentials.password as string, + ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), + sslmode: credentials.ssl as string || 'disable', + }; + + const db = pgp(config); + + let returnItems = []; + + const items = this.getInputData(); + const operation = this.getNodeParameter('operation', 0) as string; + + if (operation === 'executeQuery') { + // ---------------------------------- + // executeQuery + // ---------------------------------- + + const queries: string[] = []; + for (let i = 0; i < items.length; i++) { + queries.push(this.getNodeParameter('query', i) as string); + } + + const queryResult = await db.any(pgp.helpers.concat(queries)); + + returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); + + } else if (operation === 'insert') { + // ---------------------------------- + // insert + // ---------------------------------- + + const table = this.getNodeParameter('table', 0) as string; + const schema = this.getNodeParameter('schema', 0) as string; + let returnFields = (this.getNodeParameter('returnFields', 0) as string).split(',') as string[]; + const columnString = this.getNodeParameter('columns', 0) as string; + const columns = columnString.split(',').map(column => column.trim()); + + const cs = new pgp.helpers.ColumnSet(columns); + + const te = new pgp.helpers.TableName({ table, schema }); + + // Prepare the data to insert and copy it to be returned + const insertItems = getItemCopy(items, columns); + + // Generate the multi-row insert query and return the id of new row + returnFields = returnFields.map(value => value.trim()).filter(value => !!value); + const query = pgp.helpers.insert(insertItems, cs, te) + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); + + // Executing the query to insert the data + const insertData = await db.manyOrNone(query); + + // Add the id to the data + for (let i = 0; i < insertData.length; i++) { + returnItems.push({ + json: { + ...insertData[i], + ...insertItems[i], + } + }); + } + + } else if (operation === 'update') { + // ---------------------------------- + // update + // ---------------------------------- + + const table = this.getNodeParameter('table', 0) as string; + const updateKey = this.getNodeParameter('updateKey', 0) as string; + const columnString = this.getNodeParameter('columns', 0) as string; + + const columns = columnString.split(',').map(column => column.trim()); + + // Make sure that the updateKey does also get queried + if (!columns.includes(updateKey)) { + columns.unshift(updateKey); + } + + // Prepare the data to update and copy it to be returned + const updateItems = getItemCopy(items, columns); + + // Generate the multi-row update query + const query = pgp.helpers.update(updateItems, columns, table) + ' WHERE v.' + updateKey + ' = t.' + updateKey; + + // Executing the query to update the data + await db.none(query); + + returnItems = this.helpers.returnJsonArray(updateItems as IDataObject[]); + + } else { + await pgp.end(); + throw new Error(`The operation "${operation}" is not supported!`); + } + + // Close the connection + await pgp.end(); + + return this.prepareOutputData(returnItems); + } +} diff --git a/packages/nodes-base/nodes/QuestDB/questdb.png b/packages/nodes-base/nodes/QuestDB/questdb.png new file mode 100644 index 0000000000000000000000000000000000000000..90febff64f5d4facdd03c641e2cb6ffd0d2dd373 GIT binary patch literal 11244 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 literal 0 HcmV?d00001 From de707f039b3b1273d6ce3e16927cbb2d0dc26a52 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Tue, 7 Jul 2020 18:31:38 +0200 Subject: [PATCH 081/155] :tada: executeQuery function extracted --- .../nodes/Postgres/Postgres.node.functions.ts | 48 ++++++ .../nodes/Postgres/Postgres.node.ts | 147 +++++++++--------- 2 files changed, 120 insertions(+), 75 deletions(-) create mode 100644 packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts new file mode 100644 index 0000000000..b9aae05372 --- /dev/null +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts @@ -0,0 +1,48 @@ +import { IDataObject, INodeExecutionData } from 'n8n-workflow'; +import pgPromise = require('pg-promise'); +import pg = require('pg-promise/typescript/pg-subset'); + +/** + * Executes the given SQL query on the database. + * + * @param {Function} getNodeParam The getter of the Node + * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance + * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection + * @param {input[]} input The Node's input data + * @returns Promise + */ +export function executeQuery( + getNodeParam: Function, + pgp: pgPromise.IMain<{}, pg.IClient>, + db: pgPromise.IDatabase<{}, pg.IClient>, + input: INodeExecutionData[] +): Promise { + const queries: string[] = []; + for (let i = 0; i < input.length; i++) { + queries.push(getNodeParam('query', i) as string); + } + + return db.any(pgp.helpers.concat(queries)); +} + +// /** +// * Returns of copy of the items which only contains the json data and +// * of that only the define properties +// * +// * @param {items[]} items The items execute the query with +// * @param {string[]} properties The properties it should include +// * @returns +// */ +// export function insert( +// getNodeParam: Function, +// pgp: pgPromise.IMain<{}, pg.IClient>, +// db: pgPromise.IDatabase<{}, pg.IClient>, +// items: INodeExecutionData[] +// ): Promise { +// const queries: string[] = []; +// for (let i = 0; i < items.length; i++) { +// queries.push(getNodeParam('query', i) as string); +// } + +// return db.any(pgp.helpers.concat(queries)); +// } diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.ts index 2fa010576b..2e40dc8290 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.ts @@ -3,11 +3,12 @@ import { IDataObject, INodeExecutionData, INodeType, - INodeTypeDescription, + INodeTypeDescription } from 'n8n-workflow'; import * as pgPromise from 'pg-promise'; +import { executeQuery } from './Postgres.node.functions'; /** * Returns of copy of the items which only contains the json data and @@ -17,10 +18,13 @@ import * as pgPromise from 'pg-promise'; * @param {string[]} properties The properties it should include * @returns */ -function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataObject[] { +function getItemCopy( + items: INodeExecutionData[], + properties: string[] +): IDataObject[] { // Prepare the data to insert and copy it to be returned let newItem: IDataObject; - return items.map((item) => { + return items.map(item => { newItem = {}; for (const property of properties) { if (item.json[property] === undefined) { @@ -33,7 +37,6 @@ function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataOb }); } - export class Postgres implements INodeType { description: INodeTypeDescription = { displayName: 'Postgres', @@ -44,14 +47,14 @@ export class Postgres implements INodeType { description: 'Gets, add and update data in Postgres.', defaults: { name: 'Postgres', - color: '#336791', + color: '#336791' }, inputs: ['main'], outputs: ['main'], credentials: [ { name: 'postgres', - required: true, + required: true } ], properties: [ @@ -63,21 +66,21 @@ export class Postgres implements INodeType { { name: 'Execute Query', value: 'executeQuery', - description: 'Executes a SQL query.', + description: 'Executes a SQL query.' }, { name: 'Insert', value: 'insert', - description: 'Insert rows in database.', + description: 'Insert rows in database.' }, { name: 'Update', value: 'update', - description: 'Updates rows in database.', - }, + description: 'Updates rows in database.' + } ], default: 'insert', - description: 'The operation to perform.', + description: 'The operation to perform.' }, // ---------------------------------- @@ -88,22 +91,19 @@ export class Postgres implements INodeType { name: 'query', type: 'string', typeOptions: { - rows: 5, + rows: 5 }, displayOptions: { show: { - operation: [ - 'executeQuery' - ], - }, + operation: ['executeQuery'] + } }, default: '', placeholder: 'SELECT id, name FROM product WHERE id < 40', required: true, - description: 'The SQL query to execute.', + description: 'The SQL query to execute.' }, - // ---------------------------------- // insert // ---------------------------------- @@ -113,14 +113,12 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], - }, + operation: ['insert'] + } }, default: 'public', required: true, - description: 'Name of the schema the table belongs to', + description: 'Name of the schema the table belongs to' }, { displayName: 'Table', @@ -128,14 +126,12 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], - }, + operation: ['insert'] + } }, default: '', required: true, - description: 'Name of the table in which to insert data to.', + description: 'Name of the table in which to insert data to.' }, { displayName: 'Columns', @@ -143,14 +139,13 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], - }, + operation: ['insert'] + } }, default: '', placeholder: 'id,name,description', - description: 'Comma separated list of the properties which should used as columns for the new rows.', + description: + 'Comma separated list of the properties which should used as columns for the new rows.' }, { displayName: 'Return Fields', @@ -158,16 +153,14 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], - }, + operation: ['insert'] + } }, default: '*', - description: 'Comma separated list of the fields that the operation will return', + description: + 'Comma separated list of the fields that the operation will return' }, - // ---------------------------------- // update // ---------------------------------- @@ -177,14 +170,12 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'update' - ], - }, + operation: ['update'] + } }, default: '', required: true, - description: 'Name of the table in which to update data in', + description: 'Name of the table in which to update data in' }, { displayName: 'Update Key', @@ -192,14 +183,13 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'update' - ], - }, + operation: ['update'] + } }, default: 'id', required: true, - description: 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + description: + 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".' }, { displayName: 'Columns', @@ -207,22 +197,18 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'update' - ], - }, + operation: ['update'] + } }, default: '', placeholder: 'name,description', - description: 'Comma separated list of the properties which should used as columns for rows to update.', - }, - + description: + 'Comma separated list of the properties which should used as columns for rows to update.' + } ] }; - async execute(this: IExecuteFunctions): Promise { - const credentials = this.getCredentials('postgres'); if (credentials === undefined) { @@ -237,8 +223,10 @@ export class Postgres implements INodeType { database: credentials.database as string, user: credentials.user as string, password: credentials.password as string, - ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), - sslmode: credentials.ssl as string || 'disable', + ssl: !['disable', undefined].includes( + credentials.ssl as string | undefined + ), + sslmode: (credentials.ssl as string) || 'disable' }; const db = pgp(config); @@ -253,15 +241,14 @@ export class Postgres implements INodeType { // executeQuery // ---------------------------------- - const queries: string[] = []; - for (let i = 0; i < items.length; i++) { - queries.push(this.getNodeParameter('query', i) as string); - } - - const queryResult = await db.any(pgp.helpers.concat(queries)); + const queryResult = await executeQuery( + this.getNodeParameter, + pgp, + db, + items + ); returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); - } else if (operation === 'insert') { // ---------------------------------- // insert @@ -269,7 +256,10 @@ export class Postgres implements INodeType { const table = this.getNodeParameter('table', 0) as string; const schema = this.getNodeParameter('schema', 0) as string; - let returnFields = (this.getNodeParameter('returnFields', 0) as string).split(',') as string[]; + let returnFields = (this.getNodeParameter( + 'returnFields', + 0 + ) as string).split(',') as string[]; const columnString = this.getNodeParameter('columns', 0) as string; const columns = columnString.split(',').map(column => column.trim()); @@ -281,8 +271,12 @@ export class Postgres implements INodeType { const insertItems = getItemCopy(items, columns); // Generate the multi-row insert query and return the id of new row - returnFields = returnFields.map(value => value.trim()).filter(value => !!value); - const query = pgp.helpers.insert(insertItems, cs, te) + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); + returnFields = returnFields + .map(value => value.trim()) + .filter(value => !!value); + const query = + pgp.helpers.insert(insertItems, cs, te) + + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); // Executing the query to insert the data const insertData = await db.manyOrNone(query); @@ -292,11 +286,10 @@ export class Postgres implements INodeType { returnItems.push({ json: { ...insertData[i], - ...insertItems[i], + ...insertItems[i] } }); } - } else if (operation === 'update') { // ---------------------------------- // update @@ -317,13 +310,17 @@ export class Postgres implements INodeType { const updateItems = getItemCopy(items, columns); // Generate the multi-row update query - const query = pgp.helpers.update(updateItems, columns, table) + ' WHERE v.' + updateKey + ' = t.' + updateKey; + const query = + pgp.helpers.update(updateItems, columns, table) + + ' WHERE v.' + + updateKey + + ' = t.' + + updateKey; // Executing the query to update the data await db.none(query); - returnItems = this.helpers.returnJsonArray(updateItems as IDataObject[]); - + returnItems = this.helpers.returnJsonArray(updateItems as IDataObject[]); } else { await pgp.end(); throw new Error(`The operation "${operation}" is not supported!`); From 8aff042e04b6e5ee16e26eb5a77349edd629d2d4 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 7 Jul 2020 13:03:53 -0400 Subject: [PATCH 082/155] * :sparkles: Endpoint to preset credentials * :sparkles: Endpoint to preset credentials * :zap: Improvements * :bug: Small fix * :bug: Small fix --- packages/cli/config/index.ts | 26 +++++++++----- packages/cli/src/CredentialsOverwrites.ts | 3 +- packages/cli/src/Server.ts | 43 +++++++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index a3c2cfd03a..847587460f 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -128,15 +128,23 @@ const config = convict({ credentials: { overwrite: { - // Allows to set default values for credentials which - // get automatically prefilled and the user does not get - // displayed and can not change. - // Format: { CREDENTIAL_NAME: { PARAMTER: VALUE }} - doc: 'Overwrites for credentials', - format: '*', - default: '{}', - env: 'CREDENTIALS_OVERWRITE' - } + data: { + // Allows to set default values for credentials which + // get automatically prefilled and the user does not get + // displayed and can not change. + // Format: { CREDENTIAL_NAME: { PARAMTER: VALUE }} + doc: 'Overwrites for credentials', + format: '*', + default: '{}', + env: 'CREDENTIALS_OVERWRITE_DATA' + }, + endpoint: { + doc: 'Fetch credentials from API', + format: String, + default: '', + env: 'CREDENTIALS_OVERWRITE_ENDPOINT', + }, + }, }, executions: { diff --git a/packages/cli/src/CredentialsOverwrites.ts b/packages/cli/src/CredentialsOverwrites.ts index a6e115100e..ca09b87626 100644 --- a/packages/cli/src/CredentialsOverwrites.ts +++ b/packages/cli/src/CredentialsOverwrites.ts @@ -20,7 +20,7 @@ class CredentialsOverwritesClass { return; } - const data = await GenericHelpers.getConfigValue('credentials.overwrite') as string; + const data = await GenericHelpers.getConfigValue('credentials.overwrite.data') as string; try { this.overwriteData = JSON.parse(data); @@ -30,6 +30,7 @@ class CredentialsOverwritesClass { } applyOverwrite(type: string, data: ICredentialDataDecryptedObject) { + const overwrites = this.get(type); if (overwrites === undefined) { diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 1b3750a20c..2507b6b136 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -58,6 +58,9 @@ import { WorkflowExecuteAdditionalData, WorkflowRunner, GenericHelpers, + CredentialsOverwrites, + ICredentialsOverwrite, + LoadNodesAndCredentials, } from './'; import { @@ -105,6 +108,7 @@ class App { testWebhooks: TestWebhooks.TestWebhooks; endpointWebhook: string; endpointWebhookTest: string; + endpointPresetCredentials: string; externalHooks: IExternalHooksClass; saveDataErrorExecution: string; saveDataSuccessExecution: string; @@ -119,6 +123,8 @@ class App { sslKey: string; sslCert: string; + presetCredentialsLoaded: boolean; + constructor() { this.app = express(); @@ -141,6 +147,9 @@ class App { this.sslCert = config.get('ssl_cert'); this.externalHooks = ExternalHooks(); + + this.presetCredentialsLoaded = false; + this.endpointPresetCredentials = config.get('credentials.overwrite.endpoint') as string; } @@ -1650,6 +1659,40 @@ class App { }); + if (this.endpointPresetCredentials !== '') { + + // POST endpoint to set preset credentials + this.app.post(`/${this.endpointPresetCredentials}`, async (req: express.Request, res: express.Response) => { + + if (this.presetCredentialsLoaded === false) { + + const body = req.body as ICredentialsOverwrite; + + if (req.headers['content-type'] !== 'application/json') { + ResponseHelper.sendErrorResponse(res, new Error('Body must be a valid JSON, make sure the content-type is application/json')); + return; + } + + const loadNodesAndCredentials = LoadNodesAndCredentials(); + + const credentialsOverwrites = CredentialsOverwrites(); + + await credentialsOverwrites.init(body); + + const credentialTypes = CredentialTypes(); + + await credentialTypes.init(loadNodesAndCredentials.credentialTypes); + + this.presetCredentialsLoaded = true; + + ResponseHelper.sendSuccessResponse(res, { success: true }, true, 200); + + } else { + ResponseHelper.sendErrorResponse(res, new Error('Preset credentials can be set once')); + } + }); + } + // Serve the website const startTime = (new Date()).toUTCString(); const editorUiPath = require.resolve('n8n-editor-ui'); From 97c8af661c6616a999cbfd58f6df00497c6e0dba Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 7 Jul 2020 20:35:58 +0200 Subject: [PATCH 083/155] :zap: Small improvement on PostmarkTrigger-Node --- .../nodes/Postmark/GenericFunctions.ts | 8 +- .../nodes/Postmark/PostmarkTrigger.node.ts | 83 +++++++++++-------- 2 files changed, 52 insertions(+), 39 deletions(-) diff --git a/packages/nodes-base/nodes/Postmark/GenericFunctions.ts b/packages/nodes-base/nodes/Postmark/GenericFunctions.ts index 115fac51f0..df1e3a1f09 100644 --- a/packages/nodes-base/nodes/Postmark/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Postmark/GenericFunctions.ts @@ -66,13 +66,15 @@ export function convertTriggerObjectToStringArray (webhookObject : any) : string webhookEvents.push('bounce'); } if (triggers.Bounce.IncludeContent) { - webhookEvents.push('bounceContent'); + webhookEvents.push('includeContent'); } if (triggers.SpamComplaint.Enabled) { webhookEvents.push('spamComplaint'); } if (triggers.SpamComplaint.IncludeContent) { - webhookEvents.push('spamComplaintContent'); + if (!webhookEvents.includes('IncludeContent')) { + webhookEvents.push('includeContent'); + } } if (triggers.SubscriptionChange.Enabled) { webhookEvents.push('subscriptionChange'); @@ -89,5 +91,3 @@ export function eventExists (currentEvents : string[], webhookEvents: string[]) } return true; } - - diff --git a/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts b/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts index ed902a1347..4956d647b7 100644 --- a/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts +++ b/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts @@ -50,55 +50,69 @@ export class PostmarkTrigger implements INodeType { type: 'multiOptions', options: [ { - name: 'Open', - value: 'open', - description: 'Trigger webhook on open.' - }, - { - name: 'First Open', - value: 'firstOpen', - description: 'Trigger on first open only.' + name: 'Bounce', + value: 'bounce', + description: 'Trigger on bounce.', }, { name: 'Click', value: 'click', - description: 'Trigger on click.' + description: 'Trigger on click.', }, { name: 'Delivery', value: 'delivery', - description: 'Trigger on delivery.' + description: 'Trigger on delivery.', }, { - name: 'Bounce', - value: 'bounce', - description: 'Trigger on bounce.' - }, - { - name: 'Bounce Content', - value: 'bounceContent', - description: 'Webhook will send full bounce content.' + name: 'Open', + value: 'open', + description: 'Trigger webhook on open.', }, { name: 'Spam Complaint', value: 'spamComplaint', - description: 'Trigger on spam complaint.' - }, - { - name: 'Spam Complaint Content', - value: 'spamComplaintContent', - description: 'Webhook will send full spam complaint content.' + description: 'Trigger on spam complaint.', }, { name: 'Subscription Change', value: 'subscriptionChange', - description: 'Trigger on subscription change.' + description: 'Trigger on subscription change.', }, ], default: [], required: true, description: 'Webhook events that will be enabled for that endpoint.', }, + { + displayName: 'First Open', + name: 'firstOpen', + description: 'Only fires on first open for event "Open".', + type: 'boolean', + default: false, + displayOptions: { + show: { + events: [ + 'open', + ], + }, + }, + }, + { + displayName: 'Include Content', + name: 'includeContent', + description: 'Includes message content for events "Bounce" and "Spam Complaint".', + type: 'boolean', + default: false, + displayOptions: { + show: { + events: [ + 'bounce', + 'spamComplaint', + ], + }, + }, + }, ], }; @@ -110,6 +124,12 @@ export class PostmarkTrigger implements INodeType { const webhookData = this.getWorkflowStaticData('node'); const webhookUrl = this.getNodeWebhookUrl('default'); const events = this.getNodeParameter('events') as string[]; + if (this.getNodeParameter('includeContent') as boolean) { + events.push('includeContent'); + } + if (this.getNodeParameter('firstOpen') as boolean) { + events.push('firstOpen'); + } // Get all webhooks const endpoint = `/webhooks`; @@ -169,10 +189,7 @@ export class PostmarkTrigger implements INodeType { if (events.includes('open')) { body.Triggers.Open.Enabled = true; - } - if (events.includes('firstOpen')) { - body.Triggers.Open.Enabled = true; - body.Triggers.Open.PostFirstOpenOnly = true; + body.Triggers.Open.PostFirstOpenOnly = this.getNodeParameter('firstOpen') as boolean; } if (events.includes('click')) { body.Triggers.Click.Enabled = true; @@ -182,15 +199,11 @@ export class PostmarkTrigger implements INodeType { } if (events.includes('bounce')) { body.Triggers.Bounce.Enabled = true; - } - if (events.includes('bounceContent')) { - body.Triggers.Bounce.IncludeContent = true; + body.Triggers.Bounce.IncludeContent = this.getNodeParameter('includeContent') as boolean; } if (events.includes('spamComplaint')) { body.Triggers.SpamComplaint.Enabled = true; - } - if (events.includes('spamComplaintContent')) { - body.Triggers.SpamComplaint.IncludeContent = true; + body.Triggers.SpamComplaint.IncludeContent = this.getNodeParameter('includeContent') as boolean; } if (events.includes('subscriptionChange')) { body.Triggers.SubscriptionChange.Enabled = true; From 80aa0bd5ddfd0d15b8f5d66acfb1da9a7b9b4d31 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Wed, 8 Jul 2020 09:32:21 +0200 Subject: [PATCH 084/155] :construction: add prettierrc to gitignore --- .gitignore | 1 + packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b3eac39207..0441d445b4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ _START_PACKAGE .env .vscode .idea +.prettierrc.js diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts index b9aae05372..a6d986643d 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts @@ -15,7 +15,7 @@ export function executeQuery( getNodeParam: Function, pgp: pgPromise.IMain<{}, pg.IClient>, db: pgPromise.IDatabase<{}, pg.IClient>, - input: INodeExecutionData[] + input: INodeExecutionData[], ): Promise { const queries: string[] = []; for (let i = 0; i < input.length; i++) { From 224842c790fd0f976d8d50adef871ef2e5c4fe83 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 09:40:47 +0200 Subject: [PATCH 085/155] :zap: Get rid of mmmagic for mime-type detection --- packages/core/package.json | 5 +-- packages/core/src/NodeExecuteFunctions.ts | 39 +++++++++++++---------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 7a4b0c7349..ac55db99c9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "@types/express": "^4.16.1", "@types/jest": "^24.0.18", "@types/lodash.get": "^4.4.6", - "@types/mmmagic": "^0.4.29", + "@types/mime-types": "^2.1.0", "@types/node": "^10.10.1", "@types/request-promise-native": "^1.0.15", "jest": "^24.9.0", @@ -43,8 +43,9 @@ "client-oauth2": "^4.2.5", "cron": "^1.7.2", "crypto-js": "3.1.9-1", + "file-type": "^14.6.2", "lodash.get": "^4.4.2", - "mmmagic": "^0.5.2", + "mime-types": "^2.1.27", "n8n-workflow": "~0.33.0", "p-cancelable": "^2.0.0", "request": "^2.88.2", diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index d500d56f88..5efbe32914 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -44,14 +44,9 @@ import * as express from 'express'; import * as path from 'path'; import { OptionsWithUrl, OptionsWithUri } from 'request'; import * as requestPromise from 'request-promise-native'; - -import { Magic, MAGIC_MIME_TYPE } from 'mmmagic'; - import { createHmac } from 'crypto'; - - -const magic = new Magic(MAGIC_MIME_TYPE); - +import { fromBuffer } from 'file-type'; +import { lookup } from 'mime-types'; /** @@ -66,18 +61,28 @@ const magic = new Magic(MAGIC_MIME_TYPE); */ export async function prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise { if (!mimeType) { - // If not mime type is given figure it out - mimeType = await new Promise( - (resolve, reject) => { - magic.detect(binaryData, (err: Error, mimeType: string) => { - if (err) { - return reject(err); - } + // If no mime type is given figure it out - return resolve(mimeType); - }); + if (filePath) { + // Use file path to guess mime type + const mimeTypeLookup = lookup(filePath); + if (mimeTypeLookup) { + mimeType = mimeTypeLookup; } - ); + } + + if (!mimeType) { + // Use buffer to guess mime type + const fileTypeData = await fromBuffer(binaryData); + if (fileTypeData) { + mimeType = fileTypeData.mime; + } + } + + if (!mimeType) { + // Fall back to text + mimeType = 'text/plain'; + } } const returnData: IBinaryData = { From b1035d539dd41ff748f176b77217fc3e0c955ddb Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com> Date: Wed, 8 Jul 2020 10:00:13 +0200 Subject: [PATCH 086/155] :sparkles: MSSQL Node Integration (#729) * basic setup mssql node * executeQuery for MSSQL working * work on insert MSSQL, incomplete * :construction: basic setup update functionality * :hammer: refactor insert for handling >1000 values * :sparkles: complete MSSQL node * :sparkles: add delete action to node * :construction: handling multiple tables and column sets * :bug: enabling usage of expression for every field * :hammer: remove lodash dependency * :sparkles: enable continue on fail option * :racehorse: minify icon * :hammer: improve table creation, item copying, :bug: correct output of node when active continue on fail * :bug: move mssql types to dev dependencies * :art: remove auto formatting from redis * :art: apply corrected syntax format * :rewind: reset Redis node to master stage * :bug: fix building issue --- .../MicrosoftSqlServer.credentials.ts | 47 ++ .../Microsoft/SqlServer/GenericFunctions.ts | 144 ++++++ .../SqlServer/MicrosoftSqlServer.node.ts | 410 ++++++++++++++++++ .../Microsoft/SqlServer/TableInterface.ts | 7 + .../nodes/Microsoft/SqlServer/mssql.png | Bin 0 -> 3447 bytes packages/nodes-base/nodes/utils/utilities.ts | 57 +++ packages/nodes-base/package.json | 6 +- 7 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 packages/nodes-base/credentials/MicrosoftSqlServer.credentials.ts create mode 100644 packages/nodes-base/nodes/Microsoft/SqlServer/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.ts create mode 100644 packages/nodes-base/nodes/Microsoft/SqlServer/TableInterface.ts create mode 100644 packages/nodes-base/nodes/Microsoft/SqlServer/mssql.png create mode 100644 packages/nodes-base/nodes/utils/utilities.ts diff --git a/packages/nodes-base/credentials/MicrosoftSqlServer.credentials.ts b/packages/nodes-base/credentials/MicrosoftSqlServer.credentials.ts new file mode 100644 index 0000000000..b0a14de771 --- /dev/null +++ b/packages/nodes-base/credentials/MicrosoftSqlServer.credentials.ts @@ -0,0 +1,47 @@ +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; + +export class MicrosoftSqlServer implements ICredentialType { + name = 'microsoftSqlServer'; + displayName = 'Microsoft SQL Server'; + properties = [ + { + displayName: 'Server', + name: 'server', + type: 'string' as NodePropertyTypes, + default: 'localhost' + }, + { + displayName: 'Database', + name: 'database', + type: 'string' as NodePropertyTypes, + default: 'master' + }, + { + displayName: 'User', + name: 'user', + type: 'string' as NodePropertyTypes, + default: 'sa' + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true + }, + default: '' + }, + { + displayName: 'Port', + name: 'port', + type: 'number' as NodePropertyTypes, + default: 1433 + }, + { + displayName: 'Domain', + name: 'domain', + type: 'string' as NodePropertyTypes, + default: '' + } + ]; +} diff --git a/packages/nodes-base/nodes/Microsoft/SqlServer/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/SqlServer/GenericFunctions.ts new file mode 100644 index 0000000000..8309b35db0 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SqlServer/GenericFunctions.ts @@ -0,0 +1,144 @@ +import { IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { ITables } from './TableInterface'; + +/** + * Returns a copy of the item which only contains the json data and + * of that only the defined properties + * + * @param {INodeExecutionData} item The item to copy + * @param {string[]} properties The properties it should include + * @returns + */ +export function copyInputItem( + item: INodeExecutionData, + properties: string[], +): IDataObject { + // Prepare the data to insert and copy it to be returned + let newItem: IDataObject = {}; + for (const property of properties) { + if (item.json[property] === undefined) { + newItem[property] = null; + } else { + newItem[property] = JSON.parse(JSON.stringify(item.json[property])); + } + } + return newItem; +} + +/** + * Creates an ITables with the columns for the operations + * + * @param {INodeExecutionData[]} items The items to extract the tables/columns for + * @param {function} getNodeParam getter for the Node's Parameters + * @returns {ITables} {tableName: {colNames: [items]}}; + */ +export function createTableStruct( + getNodeParam: Function, + items: INodeExecutionData[], + additionalProperties: string[] = [], + keyName?: string, +): ITables { + return items.reduce((tables, item, index) => { + const table = getNodeParam('table', index) as string; + const columnString = getNodeParam('columns', index) as string; + const columns = columnString.split(',').map(column => column.trim()); + const itemCopy = copyInputItem(item, columns.concat(additionalProperties)); + const keyParam = keyName + ? (getNodeParam(keyName, index) as string) + : undefined; + if (tables[table] === undefined) { + tables[table] = {}; + } + if (tables[table][columnString] === undefined) { + tables[table][columnString] = []; + } + if (keyName) { + itemCopy[keyName] = keyParam; + } + tables[table][columnString].push(itemCopy); + return tables; + }, {} as ITables); +} + +/** + * Executes a queue of queries on given ITables. + * + * @param {ITables} tables The ITables to be processed. + * @param {function} buildQueryQueue function that builds the queue of promises + * @returns {Promise} + */ +export function executeQueryQueue( + tables: ITables, + buildQueryQueue: Function, +): Promise { + return Promise.all( + Object.keys(tables).map(table => { + const columnsResults = Object.keys(tables[table]).map(columnString => { + return Promise.all( + buildQueryQueue({ + table: table, + columnString: columnString, + items: tables[table][columnString], + }), + ); + }); + return Promise.all(columnsResults); + }), + ); +} + +/** + * Extracts the values from the item for INSERT + * + * @param {IDataObject} item The item to extract + * @returns {string} (Val1, Val2, ...) + */ +export function extractValues(item: IDataObject): string { + return `(${Object.values(item as any) + .map(val => (typeof val === 'string' ? `'${val}'` : val)) // maybe other types such as dates have to be handled as well + .join(',')})`; +} + +/** + * Extracts the SET from the item for UPDATE + * + * @param {IDataObject} item The item to extract from + * @param {string[]} columns The columns to update + * @returns {string} col1 = val1, col2 = val2 + */ +export function extractUpdateSet(item: IDataObject, columns: string[]): string { + return columns + .map( + column => + `${column} = ${ + typeof item[column] === 'string' ? `'${item[column]}'` : item[column] + }`, + ) + .join(','); +} + +/** + * Extracts the WHERE condition from the item for UPDATE + * + * @param {IDataObject} item The item to extract from + * @param {string} key The column name to build the condition with + * @returns {string} id = '123' + */ +export function extractUpdateCondition(item: IDataObject, key: string): string { + return `${key} = ${ + typeof item[key] === 'string' ? `'${item[key]}'` : item[key] + }`; +} + +/** + * Extracts the WHERE condition from the items for DELETE + * + * @param {IDataObject[]} items The items to extract the values from + * @param {string} key The column name to extract the value from for the delete condition + * @returns {string} (Val1, Val2, ...) + */ +export function extractDeleteValues(items: IDataObject[], key: string): string { + return `(${items + .map(item => (typeof item[key] === 'string' ? `'${item[key]}'` : item[key])) + .join(',')})`; +} diff --git a/packages/nodes-base/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.ts b/packages/nodes-base/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.ts new file mode 100644 index 0000000000..b24d93b0f5 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.ts @@ -0,0 +1,410 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { chunk, flatten } from '../../utils/utilities'; + +import * as mssql from 'mssql'; + +import { ITables } from './TableInterface'; + +import { + copyInputItem, + createTableStruct, + executeQueryQueue, + extractDeleteValues, + extractUpdateCondition, + extractUpdateSet, + extractValues, +} from './GenericFunctions'; + +export class MicrosoftSqlServer implements INodeType { + description: INodeTypeDescription = { + displayName: 'Microsoft SQL Server', + name: 'microsoftSqlServer', + icon: 'file:mssql.png', + group: ['input'], + version: 1, + description: 'Gets, add and update data in Microsoft SQL Server.', + defaults: { + name: 'Microsoft SQL Server', + color: '#1d4bab', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'microsoftSqlServer', + required: true, + }, + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Execute Query', + value: 'executeQuery', + description: 'Executes a SQL query.', + }, + { + name: 'Insert', + value: 'insert', + description: 'Insert rows in database.', + }, + { + name: 'Update', + value: 'update', + description: 'Updates rows in database.', + }, + { + name: 'Delete', + value: 'delete', + description: 'Deletes rows in database.', + }, + ], + default: 'insert', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // executeQuery + // ---------------------------------- + { + displayName: 'Query', + name: 'query', + type: 'string', + typeOptions: { + rows: 5, + }, + displayOptions: { + show: { + operation: ['executeQuery'], + }, + }, + default: '', + placeholder: 'SELECT id, name FROM product WHERE id < 40', + required: true, + description: 'The SQL query to execute.', + }, + + // ---------------------------------- + // insert + // ---------------------------------- + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to insert data to.', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '', + placeholder: 'id,name,description', + description: + 'Comma separated list of the properties which should used as columns for the new rows.', + }, + + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to update data in', + }, + { + displayName: 'Update Key', + name: 'updateKey', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: 'id', + required: true, + description: + 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: '', + placeholder: 'name,description', + description: + 'Comma separated list of the properties which should used as columns for rows to update.', + }, + + // ---------------------------------- + // delete + // ---------------------------------- + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['delete'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to delete data.', + }, + { + displayName: 'Delete Key', + name: 'deleteKey', + type: 'string', + displayOptions: { + show: { + operation: ['delete'], + }, + }, + default: 'id', + required: true, + description: + 'Name of the property which decides which rows in the database should be deleted. Normally that would be "id".', + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const credentials = this.getCredentials('microsoftSqlServer'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const config = { + server: credentials.server as string, + port: credentials.port as number, + database: credentials.database as string, + user: credentials.user as string, + password: credentials.password as string, + domain: credentials.domain ? (credentials.domain as string) : undefined, + }; + + const pool = new mssql.ConnectionPool(config); + await pool.connect(); + + let returnItems: any = []; + + const items = this.getInputData(); + const operation = this.getNodeParameter('operation', 0) as string; + + try { + if (operation === 'executeQuery') { + // ---------------------------------- + // executeQuery + // ---------------------------------- + + const rawQuery = this.getNodeParameter('query', 0) as string; + + const queryResult = await pool.request().query(rawQuery); + + const result = + queryResult.recordsets.length > 1 + ? flatten(queryResult.recordsets) + : queryResult.recordsets[0]; + + returnItems = this.helpers.returnJsonArray(result as IDataObject[]); + } else if (operation === 'insert') { + // ---------------------------------- + // insert + // ---------------------------------- + + const tables = createTableStruct(this.getNodeParameter, items); + const queriesResults = await executeQueryQueue( + tables, + ({ + table, + columnString, + items, + }: { + table: string; + columnString: string; + items: IDataObject[]; + }): Promise[] => { + return chunk(items, 1000).map(insertValues => { + const values = insertValues + .map((item: IDataObject) => extractValues(item)) + .join(','); + + return pool + .request() + .query( + `INSERT INTO ${table}(${columnString}) VALUES ${values};`, + ); + }); + }, + ); + + const rowsAffected = flatten(queriesResults).reduce( + (acc: number, resp: mssql.IResult): number => + (acc += resp.rowsAffected.reduce((sum, val) => (sum += val))), + 0, + ); + + returnItems = this.helpers.returnJsonArray({ + rowsAffected, + } as IDataObject); + } else if (operation === 'update') { + // ---------------------------------- + // update + // ---------------------------------- + + const updateKeys = items.map( + (item, index) => this.getNodeParameter('updateKey', index) as string, + ); + const tables = createTableStruct( + this.getNodeParameter, + items, + ['updateKey'], + 'updateKey', + ); + const queriesResults = await executeQueryQueue( + tables, + ({ + table, + columnString, + items, + }: { + table: string; + columnString: string; + items: IDataObject[]; + }): Promise[] => { + return items.map(item => { + const columns = columnString + .split(',') + .map(column => column.trim()); + + const setValues = extractUpdateSet(item, columns); + const condition = extractUpdateCondition( + item, + item.updateKey as string, + ); + + return pool + .request() + .query(`UPDATE ${table} SET ${setValues} WHERE ${condition};`); + }); + }, + ); + + const rowsAffected = flatten(queriesResults).reduce( + (acc: number, resp: mssql.IResult): number => + (acc += resp.rowsAffected.reduce((sum, val) => (sum += val))), + 0, + ); + + returnItems = this.helpers.returnJsonArray({ + rowsAffected, + } as IDataObject); + } else if (operation === 'delete') { + // ---------------------------------- + // delete + // ---------------------------------- + + const tables = items.reduce((tables, item, index) => { + const table = this.getNodeParameter('table', index) as string; + const deleteKey = this.getNodeParameter('deleteKey', index) as string; + if (tables[table] === undefined) { + tables[table] = {}; + } + if (tables[table][deleteKey] === undefined) { + tables[table][deleteKey] = []; + } + tables[table][deleteKey].push(item); + return tables; + }, {} as ITables); + + const queriesResults = await Promise.all( + Object.keys(tables).map(table => { + const deleteKeyResults = Object.keys(tables[table]).map( + deleteKey => { + const deleteItemsList = chunk( + tables[table][deleteKey].map(item => + copyInputItem(item as INodeExecutionData, [deleteKey]), + ), + 1000, + ); + const queryQueue = deleteItemsList.map(deleteValues => { + return pool + .request() + .query( + `DELETE FROM ${table} WHERE ${deleteKey} IN ${extractDeleteValues( + deleteValues, + deleteKey, + )};`, + ); + }); + return Promise.all(queryQueue); + }, + ); + return Promise.all(deleteKeyResults); + }), + ); + + const rowsAffected = flatten(queriesResults).reduce( + (acc: number, resp: mssql.IResult): number => + (acc += resp.rowsAffected.reduce((sum, val) => (sum += val))), + 0, + ); + + returnItems = this.helpers.returnJsonArray({ + rowsAffected, + } as IDataObject); + } else { + await pool.close(); + throw new Error(`The operation "${operation}" is not supported!`); + } + } catch (err) { + if (this.continueOnFail() === true) { + returnItems = items; + } else { + await pool.close(); + throw err; + } + } + + // Close the connection + await pool.close(); + + return this.prepareOutputData(returnItems); + } +} diff --git a/packages/nodes-base/nodes/Microsoft/SqlServer/TableInterface.ts b/packages/nodes-base/nodes/Microsoft/SqlServer/TableInterface.ts new file mode 100644 index 0000000000..a0343e6e0b --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SqlServer/TableInterface.ts @@ -0,0 +1,7 @@ +import { IDataObject } from 'n8n-workflow'; + +export interface ITables { + [key: string]: { + [key: string]: Array; + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/SqlServer/mssql.png b/packages/nodes-base/nodes/Microsoft/SqlServer/mssql.png new file mode 100644 index 0000000000000000000000000000000000000000..18349dc1cc58adfd3e724c264f96d2e3546b4474 GIT binary patch literal 3447 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 SQGp*wkb$m=PRUKXu>S$oUE5&* literal 0 HcmV?d00001 diff --git a/packages/nodes-base/nodes/utils/utilities.ts b/packages/nodes-base/nodes/utils/utilities.ts new file mode 100644 index 0000000000..73105a901c --- /dev/null +++ b/packages/nodes-base/nodes/utils/utilities.ts @@ -0,0 +1,57 @@ +/** + * Creates an array of elements split into groups the length of `size`. + * If `array` can't be split evenly, the final chunk will be the remaining + * elements. + * + * @param {Array} array The array to process. + * @param {number} [size=1] The length of each chunk + * @returns {Array} Returns the new array of chunks. + * @example + * + * chunk(['a', 'b', 'c', 'd'], 2) + * // => [['a', 'b'], ['c', 'd']] + * + * chunk(['a', 'b', 'c', 'd'], 3) + * // => [['a', 'b', 'c'], ['d']] + */ +export function chunk(array: any[], size: number = 1) { + const length = array == null ? 0 : array.length; + if (!length || size < 1) { + return []; + } + let index = 0; + let resIndex = 0; + const result = new Array(Math.ceil(length / size)); + + while (index < length) { + result[resIndex++] = array.slice(index, (index += size)); + } + return result; +} + +/** + * Takes a multidimensional array and converts it to a one-dimensional array. + * + * @param {Array} nestedArray The array to be flattened. + * @returns {Array} Returns the new flattened array. + * @example + * + * flatten([['a', 'b'], ['c', 'd']]) + * // => ['a', 'b', 'c', 'd'] + * + */ +export function flatten(nestedArray: any[][]) { + const result = []; + + (function loop(array: any[]) { + for (var i = 0; i < array.length; i++) { + if (Array.isArray(array[i])) { + loop(array[i]); + } else { + result.push(array[i]); + } + } + })(nestedArray); + + return result; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 35700feb0a..1d196f3eef 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -96,6 +96,7 @@ "dist/credentials/MicrosoftExcelOAuth2Api.credentials.js", "dist/credentials/MicrosoftOAuth2Api.credentials.js", "dist/credentials/MicrosoftOneDriveOAuth2Api.credentials.js", + "dist/credentials/MicrosoftSqlServer.credentials.js", "dist/credentials/MoceanApi.credentials.js", "dist/credentials/MondayComApi.credentials.js", "dist/credentials/MongoDb.credentials.js", @@ -207,7 +208,7 @@ "dist/nodes/Google/Calendar/GoogleCalendar.node.js", "dist/nodes/Google/Drive/GoogleDrive.node.js", "dist/nodes/Google/Sheet/GoogleSheets.node.js", - "dist/nodes/Google/Task/GoogleTasks.node.js", + "dist/nodes/Google/Task/GoogleTasks.node.js", "dist/nodes/GraphQL/GraphQL.node.js", "dist/nodes/Gumroad/GumroadTrigger.node.js", "dist/nodes/Harvest/Harvest.node.js", @@ -241,6 +242,7 @@ "dist/nodes/MessageBird/MessageBird.node.js", "dist/nodes/Microsoft/Excel/MicrosoftExcel.node.js", "dist/nodes/Microsoft/OneDrive/MicrosoftOneDrive.node.js", + "dist/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.js", "dist/nodes/MoveBinaryData.node.js", "dist/nodes/Mocean/Mocean.node.js", "dist/nodes/MondayCom/MondayCom.node.js", @@ -322,6 +324,7 @@ "@types/lodash.set": "^4.3.6", "@types/moment-timezone": "^0.5.12", "@types/mongodb": "^3.5.4", + "@types/mssql": "^6.0.2", "@types/node": "^10.10.1", "@types/nodemailer": "^6.4.0", "@types/redis": "^2.8.11", @@ -354,6 +357,7 @@ "moment": "2.24.0", "moment-timezone": "^0.5.28", "mongodb": "^3.5.5", + "mssql": "^6.2.0", "mysql2": "^2.0.1", "n8n-core": "~0.37.0", "nodemailer": "^6.4.6", From 6d0210e8f3db665d6642360c04d384d4635d5fea Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 10:08:00 +0200 Subject: [PATCH 087/155] :shirt: Fix some lint issues --- .../nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nodes-base/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.ts b/packages/nodes-base/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.ts index b24d93b0f5..4f6c2342a4 100644 --- a/packages/nodes-base/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.ts +++ b/packages/nodes-base/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.ts @@ -222,7 +222,7 @@ export class MicrosoftSqlServer implements INodeType { const pool = new mssql.ConnectionPool(config); await pool.connect(); - let returnItems: any = []; + let returnItems: INodeExecutionData[] = []; const items = this.getInputData(); const operation = this.getNodeParameter('operation', 0) as string; @@ -259,7 +259,7 @@ export class MicrosoftSqlServer implements INodeType { table: string; columnString: string; items: IDataObject[]; - }): Promise[] => { + }): Array> => { return chunk(items, 1000).map(insertValues => { const values = insertValues .map((item: IDataObject) => extractValues(item)) @@ -307,7 +307,7 @@ export class MicrosoftSqlServer implements INodeType { table: string; columnString: string; items: IDataObject[]; - }): Promise[] => { + }): Array> => { return items.map(item => { const columns = columnString .split(',') From b598036b3d014b7ee0dac1d5e937ccead3d8f481 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 10:08:00 +0200 Subject: [PATCH 088/155] :shirt: Fix some lint issues --- ...entials.ts => MicrosoftSql.credentials.ts} | 6 +++--- .../{SqlServer => Sql}/GenericFunctions.ts | 0 .../MicrosoftSql.node.ts} | 20 +++++++++--------- .../{SqlServer => Sql}/TableInterface.ts | 0 .../Microsoft/{SqlServer => Sql}/mssql.png | Bin packages/nodes-base/package.json | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) rename packages/nodes-base/credentials/{MicrosoftSqlServer.credentials.ts => MicrosoftSql.credentials.ts} (86%) rename packages/nodes-base/nodes/Microsoft/{SqlServer => Sql}/GenericFunctions.ts (100%) rename packages/nodes-base/nodes/Microsoft/{SqlServer/MicrosoftSqlServer.node.ts => Sql/MicrosoftSql.node.ts} (95%) rename packages/nodes-base/nodes/Microsoft/{SqlServer => Sql}/TableInterface.ts (100%) rename packages/nodes-base/nodes/Microsoft/{SqlServer => Sql}/mssql.png (100%) diff --git a/packages/nodes-base/credentials/MicrosoftSqlServer.credentials.ts b/packages/nodes-base/credentials/MicrosoftSql.credentials.ts similarity index 86% rename from packages/nodes-base/credentials/MicrosoftSqlServer.credentials.ts rename to packages/nodes-base/credentials/MicrosoftSql.credentials.ts index b0a14de771..812e9bfdd7 100644 --- a/packages/nodes-base/credentials/MicrosoftSqlServer.credentials.ts +++ b/packages/nodes-base/credentials/MicrosoftSql.credentials.ts @@ -1,8 +1,8 @@ import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; -export class MicrosoftSqlServer implements ICredentialType { - name = 'microsoftSqlServer'; - displayName = 'Microsoft SQL Server'; +export class MicrosoftSql implements ICredentialType { + name = 'microsoftSql'; + displayName = 'Microsoft SQL'; properties = [ { displayName: 'Server', diff --git a/packages/nodes-base/nodes/Microsoft/SqlServer/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/Sql/GenericFunctions.ts similarity index 100% rename from packages/nodes-base/nodes/Microsoft/SqlServer/GenericFunctions.ts rename to packages/nodes-base/nodes/Microsoft/Sql/GenericFunctions.ts diff --git a/packages/nodes-base/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.ts b/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts similarity index 95% rename from packages/nodes-base/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.ts rename to packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts index b24d93b0f5..1d036147f6 100644 --- a/packages/nodes-base/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.ts +++ b/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts @@ -22,23 +22,23 @@ import { extractValues, } from './GenericFunctions'; -export class MicrosoftSqlServer implements INodeType { +export class MicrosoftSql implements INodeType { description: INodeTypeDescription = { - displayName: 'Microsoft SQL Server', - name: 'microsoftSqlServer', + displayName: 'Microsoft SQL', + name: 'microsoftSql', icon: 'file:mssql.png', group: ['input'], version: 1, - description: 'Gets, add and update data in Microsoft SQL Server.', + description: 'Gets, add and update data in Microsoft SQL.', defaults: { - name: 'Microsoft SQL Server', + name: 'Microsoft SQL', color: '#1d4bab', }, inputs: ['main'], outputs: ['main'], credentials: [ { - name: 'microsoftSqlServer', + name: 'microsoftSql', required: true, }, ], @@ -204,7 +204,7 @@ export class MicrosoftSqlServer implements INodeType { }; async execute(this: IExecuteFunctions): Promise { - const credentials = this.getCredentials('microsoftSqlServer'); + const credentials = this.getCredentials('microsoftSql'); if (credentials === undefined) { throw new Error('No credentials got returned!'); @@ -222,7 +222,7 @@ export class MicrosoftSqlServer implements INodeType { const pool = new mssql.ConnectionPool(config); await pool.connect(); - let returnItems: any = []; + let returnItems: INodeExecutionData[] = []; const items = this.getInputData(); const operation = this.getNodeParameter('operation', 0) as string; @@ -259,7 +259,7 @@ export class MicrosoftSqlServer implements INodeType { table: string; columnString: string; items: IDataObject[]; - }): Promise[] => { + }): Array> => { return chunk(items, 1000).map(insertValues => { const values = insertValues .map((item: IDataObject) => extractValues(item)) @@ -307,7 +307,7 @@ export class MicrosoftSqlServer implements INodeType { table: string; columnString: string; items: IDataObject[]; - }): Promise[] => { + }): Array> => { return items.map(item => { const columns = columnString .split(',') diff --git a/packages/nodes-base/nodes/Microsoft/SqlServer/TableInterface.ts b/packages/nodes-base/nodes/Microsoft/Sql/TableInterface.ts similarity index 100% rename from packages/nodes-base/nodes/Microsoft/SqlServer/TableInterface.ts rename to packages/nodes-base/nodes/Microsoft/Sql/TableInterface.ts diff --git a/packages/nodes-base/nodes/Microsoft/SqlServer/mssql.png b/packages/nodes-base/nodes/Microsoft/Sql/mssql.png similarity index 100% rename from packages/nodes-base/nodes/Microsoft/SqlServer/mssql.png rename to packages/nodes-base/nodes/Microsoft/Sql/mssql.png diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 1d196f3eef..3c51466ea3 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -96,7 +96,7 @@ "dist/credentials/MicrosoftExcelOAuth2Api.credentials.js", "dist/credentials/MicrosoftOAuth2Api.credentials.js", "dist/credentials/MicrosoftOneDriveOAuth2Api.credentials.js", - "dist/credentials/MicrosoftSqlServer.credentials.js", + "dist/credentials/MicrosoftSql.credentials.js", "dist/credentials/MoceanApi.credentials.js", "dist/credentials/MondayComApi.credentials.js", "dist/credentials/MongoDb.credentials.js", @@ -242,7 +242,7 @@ "dist/nodes/MessageBird/MessageBird.node.js", "dist/nodes/Microsoft/Excel/MicrosoftExcel.node.js", "dist/nodes/Microsoft/OneDrive/MicrosoftOneDrive.node.js", - "dist/nodes/Microsoft/SqlServer/MicrosoftSqlServer.node.js", + "dist/nodes/Microsoft/Sql/MicrosoftSql.node.js", "dist/nodes/MoveBinaryData.node.js", "dist/nodes/Mocean/Mocean.node.js", "dist/nodes/MondayCom/MondayCom.node.js", From c1161c2699b4933c75331fcc9011129cb02a6254 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Wed, 8 Jul 2020 10:50:18 +0200 Subject: [PATCH 089/155] :bug: fix mssql update operation --- packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts b/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts index 1d036147f6..855a4647a0 100644 --- a/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts +++ b/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts @@ -294,7 +294,7 @@ export class MicrosoftSql implements INodeType { const tables = createTableStruct( this.getNodeParameter, items, - ['updateKey'], + ['updateKey'].concat(updateKeys), 'updateKey', ); const queriesResults = await executeQueryQueue( From 9ea4d870cfb1862352dfd400309277a249cae5bf Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 11:21:57 +0200 Subject: [PATCH 090/155] :bookmark: Release n8n-workflow@0.34.0 --- packages/workflow/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 068dc6d108..b65cf8cb6e 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "0.33.0", + "version": "0.34.0", "description": "Workflow base code of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From c8718bf96b26640a49eede02bdd2bacfdabe675d Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 11:22:55 +0200 Subject: [PATCH 091/155] :arrow_up: Set n8n-workflow@0.34.0 on n8n-core --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index ac55db99c9..da71092613 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,7 +46,7 @@ "file-type": "^14.6.2", "lodash.get": "^4.4.2", "mime-types": "^2.1.27", - "n8n-workflow": "~0.33.0", + "n8n-workflow": "~0.34.0", "p-cancelable": "^2.0.0", "request": "^2.88.2", "request-promise-native": "^1.0.7" From 5e61a77d23883517db2c5ed3cb56d66f68b1c4eb Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 11:23:38 +0200 Subject: [PATCH 092/155] :bookmark: Release n8n-core@0.38.0 --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index da71092613..fe195d2e36 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.37.0", + "version": "0.38.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 4d5308379116165b30ecce7780143d888f56330d Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 11:24:47 +0200 Subject: [PATCH 093/155] :arrow_up: Set n8n-core@0.38.0 and n8n-workflow@0.34.0 on n8n-nodes-base --- packages/nodes-base/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 3c51466ea3..df0c9782ec 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -333,7 +333,7 @@ "@types/xml2js": "^0.4.3", "gulp": "^4.0.0", "jest": "^24.9.0", - "n8n-workflow": "~0.32.0", + "n8n-workflow": "~0.34.0", "ts-jest": "^24.0.2", "tslint": "^5.17.0", "typescript": "~3.7.4" @@ -359,7 +359,7 @@ "mongodb": "^3.5.5", "mssql": "^6.2.0", "mysql2": "^2.0.1", - "n8n-core": "~0.37.0", + "n8n-core": "~0.38.0", "nodemailer": "^6.4.6", "pdf-parse": "^1.1.1", "pg-promise": "^9.0.3", From 66339a6afaa485fd09539071aed7c982976c5793 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 11:25:18 +0200 Subject: [PATCH 094/155] :bookmark: Release n8n-nodes-base@0.68.0 --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index df0c9782ec..1141485b4c 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.67.0", + "version": "0.68.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From d97ed68ce96c033e0363a4833477ae4b5745ee37 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 11:26:22 +0200 Subject: [PATCH 095/155] :arrow_up: Set n8n-workflow@0.34.0 on n8n-editor-ui --- packages/editor-ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 9491626ddf..26a5f0862a 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -64,7 +64,7 @@ "lodash.debounce": "^4.0.8", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", - "n8n-workflow": "~0.33.0", + "n8n-workflow": "~0.34.0", "node-sass": "^4.12.0", "prismjs": "^1.17.1", "quill": "^2.0.0-dev.3", From 22cb017c1af0f8ecd9bccd2eede4af420a98294b Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 11:26:47 +0200 Subject: [PATCH 096/155] :bookmark: Release n8n-editor-ui@0.49.0 --- packages/editor-ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 26a5f0862a..6298fdb18a 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.48.0", + "version": "0.49.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From bf66609c800136259959c1194254b100f4376409 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 11:28:04 +0200 Subject: [PATCH 097/155] :arrow_up: Set n8n-core@0.38.0, n8n-editor-ui@0.49.0, n8n-nodes-base@0.68.0 and n8n-workflow@0.34.0 on n8n --- packages/cli/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 4eaaade97f..a67661d2af 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -100,10 +100,10 @@ "lodash.get": "^4.4.2", "mongodb": "^3.5.5", "mysql2": "^2.0.1", - "n8n-core": "~0.37.0", - "n8n-editor-ui": "~0.48.0", - "n8n-nodes-base": "~0.67.0", - "n8n-workflow": "~0.33.0", + "n8n-core": "~0.38.0", + "n8n-editor-ui": "~0.49.0", + "n8n-nodes-base": "~0.68.0", + "n8n-workflow": "~0.34.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", "pg": "^7.11.0", From 243f4ee9b52d049eb44a3c4afd5be72eaae93282 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 11:28:39 +0200 Subject: [PATCH 098/155] :bookmark: Release n8n@0.73.0 --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index a67661d2af..0bda408ec3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.72.0", + "version": "0.73.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 0108538fcc94bfdc9edf14babe0789926a274843 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Wed, 8 Jul 2020 12:01:16 +0200 Subject: [PATCH 099/155] :construction: insert function extracted --- .../nodes/Postgres/Postgres.node.functions.ts | 94 +++++++--- .../nodes/Postgres/Postgres.node.ts | 175 +++++++----------- 2 files changed, 136 insertions(+), 133 deletions(-) diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts index a6d986643d..83a5701bad 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts @@ -2,6 +2,33 @@ import { IDataObject, INodeExecutionData } from 'n8n-workflow'; import pgPromise = require('pg-promise'); import pg = require('pg-promise/typescript/pg-subset'); +/** + * Returns of copy of the items which only contains the json data and + * of that only the define properties + * + * @param {INodeExecutionData[]} items The items to copy + * @param {string[]} properties The properties it should include + * @returns + */ +function getItemCopy( + items: INodeExecutionData[], + properties: string[], +): IDataObject[] { + // Prepare the data to insert and copy it to be returned + let newItem: IDataObject; + return items.map(item => { + newItem = {}; + for (const property of properties) { + if (item.json[property] === undefined) { + newItem[property] = null; + } else { + newItem[property] = JSON.parse(JSON.stringify(item.json[property])); + } + } + return newItem; + }); +} + /** * Executes the given SQL query on the database. * @@ -9,14 +36,14 @@ import pg = require('pg-promise/typescript/pg-subset'); * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection * @param {input[]} input The Node's input data - * @returns Promise + * @returns Promise> */ export function executeQuery( getNodeParam: Function, pgp: pgPromise.IMain<{}, pg.IClient>, db: pgPromise.IDatabase<{}, pg.IClient>, input: INodeExecutionData[], -): Promise { +): Promise> { const queries: string[] = []; for (let i = 0; i < input.length; i++) { queries.push(getNodeParam('query', i) as string); @@ -25,24 +52,47 @@ export function executeQuery( return db.any(pgp.helpers.concat(queries)); } -// /** -// * Returns of copy of the items which only contains the json data and -// * of that only the define properties -// * -// * @param {items[]} items The items execute the query with -// * @param {string[]} properties The properties it should include -// * @returns -// */ -// export function insert( -// getNodeParam: Function, -// pgp: pgPromise.IMain<{}, pg.IClient>, -// db: pgPromise.IDatabase<{}, pg.IClient>, -// items: INodeExecutionData[] -// ): Promise { -// const queries: string[] = []; -// for (let i = 0; i < items.length; i++) { -// queries.push(getNodeParam('query', i) as string); -// } +/** + * Returns of copy of the items which only contains the json data and + * of that only the define properties + * + * @param {Function} getNodeParam The getter of the Node + * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance + * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection + * @param {input[]} input The Node's input data + * @returns Promise + */ +export async function executeInsert( + getNodeParam: Function, + pgp: pgPromise.IMain<{}, pg.IClient>, + db: pgPromise.IDatabase<{}, pg.IClient>, + items: INodeExecutionData[], +): Promise> { + const table = getNodeParam('table', 0) as string; + const schema = getNodeParam('schema', 0) as string; + let returnFields = (getNodeParam('returnFields', 0) as string).split( + ',', + ) as string[]; + const columnString = getNodeParam('columns', 0) as string; + const columns = columnString.split(',').map(column => column.trim()); -// return db.any(pgp.helpers.concat(queries)); -// } + const cs = new pgp.helpers.ColumnSet(columns); + + const te = new pgp.helpers.TableName({ table, schema }); + + // Prepare the data to insert and copy it to be returned + const insertItems = getItemCopy(items, columns); + + // Generate the multi-row insert query and return the id of new row + returnFields = returnFields + .map(value => value.trim()) + .filter(value => !!value); + const query = + pgp.helpers.insert(insertItems, cs, te) + + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); + + // Executing the query to insert the data + const insertData = await db.manyOrNone(query); + + return [insertData, insertItems]; +} diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.ts index 2e40dc8290..a7fe2b72d8 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.ts @@ -3,39 +3,12 @@ import { IDataObject, INodeExecutionData, INodeType, - INodeTypeDescription + INodeTypeDescription, } from 'n8n-workflow'; import * as pgPromise from 'pg-promise'; -import { executeQuery } from './Postgres.node.functions'; - -/** - * Returns of copy of the items which only contains the json data and - * of that only the define properties - * - * @param {INodeExecutionData[]} items The items to copy - * @param {string[]} properties The properties it should include - * @returns - */ -function getItemCopy( - items: INodeExecutionData[], - properties: string[] -): IDataObject[] { - // Prepare the data to insert and copy it to be returned - let newItem: IDataObject; - return items.map(item => { - newItem = {}; - for (const property of properties) { - if (item.json[property] === undefined) { - newItem[property] = null; - } else { - newItem[property] = JSON.parse(JSON.stringify(item.json[property])); - } - } - return newItem; - }); -} +import { executeInsert, executeQuery } from './Postgres.node.functions'; export class Postgres implements INodeType { description: INodeTypeDescription = { @@ -47,15 +20,15 @@ export class Postgres implements INodeType { description: 'Gets, add and update data in Postgres.', defaults: { name: 'Postgres', - color: '#336791' + color: '#336791', }, inputs: ['main'], outputs: ['main'], credentials: [ { name: 'postgres', - required: true - } + required: true, + }, ], properties: [ { @@ -66,21 +39,21 @@ export class Postgres implements INodeType { { name: 'Execute Query', value: 'executeQuery', - description: 'Executes a SQL query.' + description: 'Executes a SQL query.', }, { name: 'Insert', value: 'insert', - description: 'Insert rows in database.' + description: 'Insert rows in database.', }, { name: 'Update', value: 'update', - description: 'Updates rows in database.' - } + description: 'Updates rows in database.', + }, ], default: 'insert', - description: 'The operation to perform.' + description: 'The operation to perform.', }, // ---------------------------------- @@ -91,17 +64,17 @@ export class Postgres implements INodeType { name: 'query', type: 'string', typeOptions: { - rows: 5 + rows: 5, }, displayOptions: { show: { - operation: ['executeQuery'] - } + operation: ['executeQuery'], + }, }, default: '', placeholder: 'SELECT id, name FROM product WHERE id < 40', required: true, - description: 'The SQL query to execute.' + description: 'The SQL query to execute.', }, // ---------------------------------- @@ -113,12 +86,12 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: ['insert'] - } + operation: ['insert'], + }, }, default: 'public', required: true, - description: 'Name of the schema the table belongs to' + description: 'Name of the schema the table belongs to', }, { displayName: 'Table', @@ -126,12 +99,12 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: ['insert'] - } + operation: ['insert'], + }, }, default: '', required: true, - description: 'Name of the table in which to insert data to.' + description: 'Name of the table in which to insert data to.', }, { displayName: 'Columns', @@ -139,13 +112,13 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: ['insert'] - } + operation: ['insert'], + }, }, default: '', placeholder: 'id,name,description', description: - 'Comma separated list of the properties which should used as columns for the new rows.' + 'Comma separated list of the properties which should used as columns for the new rows.', }, { displayName: 'Return Fields', @@ -153,12 +126,12 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: ['insert'] - } + operation: ['insert'], + }, }, default: '*', description: - 'Comma separated list of the fields that the operation will return' + 'Comma separated list of the fields that the operation will return', }, // ---------------------------------- @@ -170,12 +143,12 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: ['update'] - } + operation: ['update'], + }, }, default: '', required: true, - description: 'Name of the table in which to update data in' + description: 'Name of the table in which to update data in', }, { displayName: 'Update Key', @@ -183,13 +156,13 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: ['update'] - } + operation: ['update'], + }, }, default: 'id', required: true, description: - 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".' + 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', }, { displayName: 'Columns', @@ -197,15 +170,15 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: ['update'] - } + operation: ['update'], + }, }, default: '', placeholder: 'name,description', description: - 'Comma separated list of the properties which should used as columns for rows to update.' - } - ] + 'Comma separated list of the properties which should used as columns for rows to update.', + }, + ], }; async execute(this: IExecuteFunctions): Promise { @@ -224,9 +197,9 @@ export class Postgres implements INodeType { user: credentials.user as string, password: credentials.password as string, ssl: !['disable', undefined].includes( - credentials.ssl as string | undefined + credentials.ssl as string | undefined, ), - sslmode: (credentials.ssl as string) || 'disable' + sslmode: (credentials.ssl as string) || 'disable', }; const db = pgp(config); @@ -245,7 +218,7 @@ export class Postgres implements INodeType { this.getNodeParameter, pgp, db, - items + items, ); returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); @@ -254,40 +227,20 @@ export class Postgres implements INodeType { // insert // ---------------------------------- - const table = this.getNodeParameter('table', 0) as string; - const schema = this.getNodeParameter('schema', 0) as string; - let returnFields = (this.getNodeParameter( - 'returnFields', - 0 - ) as string).split(',') as string[]; - const columnString = this.getNodeParameter('columns', 0) as string; - const columns = columnString.split(',').map(column => column.trim()); - - const cs = new pgp.helpers.ColumnSet(columns); - - const te = new pgp.helpers.TableName({ table, schema }); - - // Prepare the data to insert and copy it to be returned - const insertItems = getItemCopy(items, columns); - - // Generate the multi-row insert query and return the id of new row - returnFields = returnFields - .map(value => value.trim()) - .filter(value => !!value); - const query = - pgp.helpers.insert(insertItems, cs, te) + - (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); - - // Executing the query to insert the data - const insertData = await db.manyOrNone(query); + const [insertData, insertItems] = await executeInsert( + this.getNodeParameter, + pgp, + db, + items, + ); // Add the id to the data for (let i = 0; i < insertData.length; i++) { returnItems.push({ json: { ...insertData[i], - ...insertItems[i] - } + ...insertItems[i], + }, }); } } else if (operation === 'update') { @@ -301,26 +254,26 @@ export class Postgres implements INodeType { const columns = columnString.split(',').map(column => column.trim()); - // Make sure that the updateKey does also get queried - if (!columns.includes(updateKey)) { - columns.unshift(updateKey); - } + // // Make sure that the updateKey does also get queried + // if (!columns.includes(updateKey)) { + // columns.unshift(updateKey); + // } - // Prepare the data to update and copy it to be returned - const updateItems = getItemCopy(items, columns); + // // Prepare the data to update and copy it to be returned + // const updateItems = getItemCopy(items, columns); - // Generate the multi-row update query - const query = - pgp.helpers.update(updateItems, columns, table) + - ' WHERE v.' + - updateKey + - ' = t.' + - updateKey; + // // Generate the multi-row update query + // const query = + // pgp.helpers.update(updateItems, columns, table) + + // ' WHERE v.' + + // updateKey + + // ' = t.' + + // updateKey; - // Executing the query to update the data - await db.none(query); + // // Executing the query to update the data + // await db.none(query); - returnItems = this.helpers.returnJsonArray(updateItems as IDataObject[]); + // returnItems = this.helpers.returnJsonArray(updateItems as IDataObject[]); } else { await pgp.end(); throw new Error(`The operation "${operation}" is not supported!`); From b8ff3c76ae0ba0d311314e53fe3f59fd5b2962a2 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 12:29:29 +0200 Subject: [PATCH 100/155] :books: Add back images for README files --- assets/n8n-logo.png | Bin 0 -> 2675 bytes assets/n8n-screenshot.png | Bin 0 -> 129698 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/n8n-logo.png create mode 100644 assets/n8n-screenshot.png diff --git a/assets/n8n-logo.png b/assets/n8n-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a77f36aeeead7e17aa4390937e90f48652d811c8 GIT binary patch literal 2675 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 literal 0 HcmV?d00001 diff --git a/assets/n8n-screenshot.png b/assets/n8n-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..55b476cfb61bcf418baf09ebfd3c827ce563b4e8 GIT binary patch literal 129698 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Ⓢ&|eMmSFYY{aFcd}WsmeQbVsh<(}{1561ehB5^ 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 EKL8|sWdHyG literal 0 HcmV?d00001 From af1ada2a34867a3022aa0a6e6148e0e85abcb097 Mon Sep 17 00:00:00 2001 From: Tanay Pant <7481165+tanay1337@users.noreply.github.com> Date: Wed, 8 Jul 2020 12:32:26 +0200 Subject: [PATCH 101/155] =?UTF-8?q?=F0=9F=90=9B=20Fix=20broken=20images=20?= =?UTF-8?q?in=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a9a161f9a2..eb692157a1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # n8n - Workflow Automation Tool -![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) n8n is a free and open [fair-code](http://faircode.io) licensed node based Workflow Automation Tool. It can be self-hosted, easily extended, and so also used with internal tools. -n8n.io - Screenshot +n8n.io - Screenshot From bafc1fc45cd15c66c3821368a5f59f3191267c3c Mon Sep 17 00:00:00 2001 From: Tanay Pant Date: Wed, 8 Jul 2020 12:38:25 +0200 Subject: [PATCH 102/155] =?UTF-8?q?=F0=9F=90=9B=20Fix=20broken=20images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/images/n8n/README.md | 4 ++-- packages/cli/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md index e1b39d82d6..7170dda595 100644 --- a/docker/images/n8n/README.md +++ b/docker/images/n8n/README.md @@ -1,10 +1,10 @@ # n8n - Workflow Automation -![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) n8n is a free and open [fair-code](http://faircode.io) licensed node based Workflow Automation Tool. It can be self-hosted, easily extended, and so also used with internal tools. -n8n.io - Screenshot +n8n.io - Screenshot ## Contents diff --git a/packages/cli/README.md b/packages/cli/README.md index be5f42ae8b..5ae03ffa41 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,10 +1,10 @@ # n8n - Workflow Automation Tool -![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) n8n is a free and open [fair-code](http://faircode.io) licensed node based Workflow Automation Tool. It can be self-hosted, easily extended, and so also used with internal tools. -n8n.io - Screenshot +n8n.io - Screenshot ## Contents From 2fea79f5f11f8092aed1fe21d3bbc21567612d85 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Wed, 8 Jul 2020 14:36:40 +0200 Subject: [PATCH 103/155] :zap: extract update function --- .../nodes/Postgres/Postgres.node.functions.ts | 52 +++++++++++++++++-- .../nodes/Postgres/Postgres.node.ts | 38 ++------------ 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts index 83a5701bad..8c46ef7e3a 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts @@ -38,7 +38,7 @@ function getItemCopy( * @param {input[]} input The Node's input data * @returns Promise> */ -export function executeQuery( +export function pgQuery( getNodeParam: Function, pgp: pgPromise.IMain<{}, pg.IClient>, db: pgPromise.IDatabase<{}, pg.IClient>, @@ -53,16 +53,15 @@ export function executeQuery( } /** - * Returns of copy of the items which only contains the json data and - * of that only the define properties + * Inserts the given items into the database. * * @param {Function} getNodeParam The getter of the Node * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection * @param {input[]} input The Node's input data - * @returns Promise + * @returns Promise> */ -export async function executeInsert( +export async function pgInsert( getNodeParam: Function, pgp: pgPromise.IMain<{}, pg.IClient>, db: pgPromise.IDatabase<{}, pg.IClient>, @@ -96,3 +95,46 @@ export async function executeInsert( return [insertData, insertItems]; } + +/** + * Updates the given items in the database. + * + * @param {Function} getNodeParam The getter of the Node + * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance + * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection + * @param {input[]} input The Node's input data + * @returns Promise> + */ +export async function pgUpdate( + getNodeParam: Function, + pgp: pgPromise.IMain<{}, pg.IClient>, + db: pgPromise.IDatabase<{}, pg.IClient>, + items: INodeExecutionData[], +): Promise> { + const table = getNodeParam('table', 0) as string; + const updateKey = getNodeParam('updateKey', 0) as string; + const columnString = getNodeParam('columns', 0) as string; + + const columns = columnString.split(',').map(column => column.trim()); + + // Make sure that the updateKey does also get queried + if (!columns.includes(updateKey)) { + columns.unshift(updateKey); + } + + // Prepare the data to update and copy it to be returned + const updateItems = getItemCopy(items, columns); + + // Generate the multi-row update query + const query = + pgp.helpers.update(updateItems, columns, table) + + ' WHERE v.' + + updateKey + + ' = t.' + + updateKey; + + // Executing the query to update the data + await db.none(query); + + return updateItems; +} diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.ts index a7fe2b72d8..702799c5c2 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.ts @@ -8,7 +8,7 @@ import { import * as pgPromise from 'pg-promise'; -import { executeInsert, executeQuery } from './Postgres.node.functions'; +import { pgInsert, pgQuery, pgUpdate } from './Postgres.node.functions'; export class Postgres implements INodeType { description: INodeTypeDescription = { @@ -214,12 +214,7 @@ export class Postgres implements INodeType { // executeQuery // ---------------------------------- - const queryResult = await executeQuery( - this.getNodeParameter, - pgp, - db, - items, - ); + const queryResult = await pgQuery(this.getNodeParameter, pgp, db, items); returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); } else if (operation === 'insert') { @@ -227,7 +222,7 @@ export class Postgres implements INodeType { // insert // ---------------------------------- - const [insertData, insertItems] = await executeInsert( + const [insertData, insertItems] = await pgInsert( this.getNodeParameter, pgp, db, @@ -248,32 +243,9 @@ export class Postgres implements INodeType { // update // ---------------------------------- - const table = this.getNodeParameter('table', 0) as string; - const updateKey = this.getNodeParameter('updateKey', 0) as string; - const columnString = this.getNodeParameter('columns', 0) as string; + const updateItems = await pgUpdate(this.getNodeParameter, pgp, db, items); - const columns = columnString.split(',').map(column => column.trim()); - - // // Make sure that the updateKey does also get queried - // if (!columns.includes(updateKey)) { - // columns.unshift(updateKey); - // } - - // // Prepare the data to update and copy it to be returned - // const updateItems = getItemCopy(items, columns); - - // // Generate the multi-row update query - // const query = - // pgp.helpers.update(updateItems, columns, table) + - // ' WHERE v.' + - // updateKey + - // ' = t.' + - // updateKey; - - // // Executing the query to update the data - // await db.none(query); - - // returnItems = this.helpers.returnJsonArray(updateItems as IDataObject[]); + returnItems = this.helpers.returnJsonArray(updateItems); } else { await pgp.end(); throw new Error(`The operation "${operation}" is not supported!`); From 10a26ee75d3763a61926b7971ab5ff9713b2c00c Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Wed, 8 Jul 2020 14:39:44 +0200 Subject: [PATCH 104/155] :bulb: fix function docs --- packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts index 8c46ef7e3a..6855217bd4 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts @@ -58,7 +58,7 @@ export function pgQuery( * @param {Function} getNodeParam The getter of the Node * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection - * @param {input[]} input The Node's input data + * @param {INodeExecutionData[]} items The items to be inserted * @returns Promise> */ export async function pgInsert( @@ -102,7 +102,7 @@ export async function pgInsert( * @param {Function} getNodeParam The getter of the Node * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection - * @param {input[]} input The Node's input data + * @param {INodeExecutionData[]} items The items to be updated * @returns Promise> */ export async function pgUpdate( From cdc42f5558b684c143b990aed3b35e2ec01c9e4d Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Wed, 8 Jul 2020 15:12:54 +0200 Subject: [PATCH 105/155] :bulb: add in code documentation --- .../nodes-base/nodes/Postgres/Postgres.node.functions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts index 6855217bd4..b07835d1f6 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts @@ -32,7 +32,7 @@ function getItemCopy( /** * Executes the given SQL query on the database. * - * @param {Function} getNodeParam The getter of the Node + * @param {Function} getNodeParam The getter for the Node's parameters * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection * @param {input[]} input The Node's input data @@ -55,7 +55,7 @@ export function pgQuery( /** * Inserts the given items into the database. * - * @param {Function} getNodeParam The getter of the Node + * @param {Function} getNodeParam The getter for the Node's parameters * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection * @param {INodeExecutionData[]} items The items to be inserted @@ -99,7 +99,7 @@ export async function pgInsert( /** * Updates the given items in the database. * - * @param {Function} getNodeParam The getter of the Node + * @param {Function} getNodeParam The getter for the Node's parameters * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection * @param {INodeExecutionData[]} items The items to be updated From 0b55fc2ed7907a3c9b6bb8b9a7963b5016a22e16 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Wed, 8 Jul 2020 13:48:35 +0200 Subject: [PATCH 106/155] :bug: fix insert and update return input --- .../nodes/Microsoft/Sql/MicrosoftSql.node.ts | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts b/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts index 855a4647a0..a13a2425ca 100644 --- a/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts +++ b/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts @@ -222,7 +222,7 @@ export class MicrosoftSql implements INodeType { const pool = new mssql.ConnectionPool(config); await pool.connect(); - let returnItems: INodeExecutionData[] = []; + let returnItems = []; const items = this.getInputData(); const operation = this.getNodeParameter('operation', 0) as string; @@ -249,7 +249,7 @@ export class MicrosoftSql implements INodeType { // ---------------------------------- const tables = createTableStruct(this.getNodeParameter, items); - const queriesResults = await executeQueryQueue( + await executeQueryQueue( tables, ({ table, @@ -274,15 +274,7 @@ export class MicrosoftSql implements INodeType { }, ); - const rowsAffected = flatten(queriesResults).reduce( - (acc: number, resp: mssql.IResult): number => - (acc += resp.rowsAffected.reduce((sum, val) => (sum += val))), - 0, - ); - - returnItems = this.helpers.returnJsonArray({ - rowsAffected, - } as IDataObject); + returnItems = items; } else if (operation === 'update') { // ---------------------------------- // update @@ -297,7 +289,7 @@ export class MicrosoftSql implements INodeType { ['updateKey'].concat(updateKeys), 'updateKey', ); - const queriesResults = await executeQueryQueue( + await executeQueryQueue( tables, ({ table, @@ -326,15 +318,7 @@ export class MicrosoftSql implements INodeType { }, ); - const rowsAffected = flatten(queriesResults).reduce( - (acc: number, resp: mssql.IResult): number => - (acc += resp.rowsAffected.reduce((sum, val) => (sum += val))), - 0, - ); - - returnItems = this.helpers.returnJsonArray({ - rowsAffected, - } as IDataObject); + returnItems = items; } else if (operation === 'delete') { // ---------------------------------- // delete @@ -380,14 +364,14 @@ export class MicrosoftSql implements INodeType { }), ); - const rowsAffected = flatten(queriesResults).reduce( + const rowsDeleted = flatten(queriesResults).reduce( (acc: number, resp: mssql.IResult): number => (acc += resp.rowsAffected.reduce((sum, val) => (sum += val))), 0, ); returnItems = this.helpers.returnJsonArray({ - rowsAffected, + rowsDeleted, } as IDataObject); } else { await pool.close(); From 51bd1b473fa21b3853d27fa78004e87c261dbb32 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Wed, 8 Jul 2020 16:03:59 +0200 Subject: [PATCH 107/155] :racehorse: adjust icon dimensions and tinify it --- packages/nodes-base/nodes/QuestDB/questdb.png | Bin 11244 -> 2542 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/nodes-base/nodes/QuestDB/questdb.png b/packages/nodes-base/nodes/QuestDB/questdb.png index 90febff64f5d4facdd03c641e2cb6ffd0d2dd373..85543a9c55466d8cf2ff61ff882c0cea0e433688 100644 GIT binary patch literal 2542 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 Eg1%4-ApigX literal 11244 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 From 8405d48e0c20f72b1026050f67ec44dc621b2897 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Wed, 8 Jul 2020 16:30:21 +0200 Subject: [PATCH 108/155] :zap: complete QuestDB Node --- .../nodes-base/nodes/QuestDB/QuestDB.node.ts | 150 +++++------------- packages/nodes-base/package.json | 2 + 2 files changed, 40 insertions(+), 112 deletions(-) diff --git a/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts b/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts index 8e54e102f7..93dcdbe66d 100644 --- a/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts +++ b/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts @@ -8,31 +8,11 @@ import { import * as pgPromise from 'pg-promise'; - -/** - * Returns of copy of the items which only contains the json data and - * of that only the define properties - * - * @param {INodeExecutionData[]} items The items to copy - * @param {string[]} properties The properties it should include - * @returns - */ -function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataObject[] { - // Prepare the data to insert and copy it to be returned - let newItem: IDataObject; - return items.map((item) => { - newItem = {}; - for (const property of properties) { - if (item.json[property] === undefined) { - newItem[property] = null; - } else { - newItem[property] = JSON.parse(JSON.stringify(item.json[property])); - } - } - return newItem; - }); -} - +import { + pgInsert, + pgQuery, + pgUpdate, +} from '../Postgres/Postgres.node.functions'; export class QuestDB implements INodeType { description: INodeTypeDescription = { @@ -44,7 +24,7 @@ export class QuestDB implements INodeType { description: 'Gets, add and update data in QuestDB.', defaults: { name: 'QuestDB', - color: '#336791', + color: '#2C4A79', }, inputs: ['main'], outputs: ['main'], @@ -52,7 +32,7 @@ export class QuestDB implements INodeType { { name: 'questdb', required: true, - } + }, ], properties: [ { @@ -92,9 +72,7 @@ export class QuestDB implements INodeType { }, displayOptions: { show: { - operation: [ - 'executeQuery' - ], + operation: ['executeQuery'], }, }, default: '', @@ -103,7 +81,6 @@ export class QuestDB implements INodeType { description: 'The SQL query to execute.', }, - // ---------------------------------- // insert // ---------------------------------- @@ -113,9 +90,7 @@ export class QuestDB implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: 'public', @@ -128,9 +103,7 @@ export class QuestDB implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: '', @@ -143,14 +116,13 @@ export class QuestDB implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: '', placeholder: 'id,name,description', - description: 'Comma separated list of the properties which should used as columns for the new rows.', + description: + 'Comma separated list of the properties which should used as columns for the new rows.', }, { displayName: 'Return Fields', @@ -158,16 +130,14 @@ export class QuestDB implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: '*', - description: 'Comma separated list of the fields that the operation will return', + description: + 'Comma separated list of the fields that the operation will return', }, - // ---------------------------------- // update // ---------------------------------- @@ -177,9 +147,7 @@ export class QuestDB implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'update' - ], + operation: ['update'], }, }, default: '', @@ -192,14 +160,13 @@ export class QuestDB implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'update' - ], + operation: ['update'], }, }, default: 'id', required: true, - description: 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + description: + 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', }, { displayName: 'Columns', @@ -207,22 +174,18 @@ export class QuestDB implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'update' - ], + operation: ['update'], }, }, default: '', placeholder: 'name,description', - description: 'Comma separated list of the properties which should used as columns for rows to update.', + description: + 'Comma separated list of the properties which should used as columns for rows to update.', }, - - ] + ], }; - async execute(this: IExecuteFunctions): Promise { - const credentials = this.getCredentials('questdb'); if (credentials === undefined) { @@ -237,8 +200,10 @@ export class QuestDB implements INodeType { database: credentials.database as string, user: credentials.user as string, password: credentials.password as string, - ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), - sslmode: credentials.ssl as string || 'disable', + ssl: !['disable', undefined].includes( + credentials.ssl as string | undefined, + ), + sslmode: (credentials.ssl as string) || 'disable', }; const db = pgp(config); @@ -253,39 +218,20 @@ export class QuestDB implements INodeType { // executeQuery // ---------------------------------- - const queries: string[] = []; - for (let i = 0; i < items.length; i++) { - queries.push(this.getNodeParameter('query', i) as string); - } - - const queryResult = await db.any(pgp.helpers.concat(queries)); + const queryResult = await pgQuery(this.getNodeParameter, pgp, db, items); returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); - } else if (operation === 'insert') { // ---------------------------------- // insert // ---------------------------------- - const table = this.getNodeParameter('table', 0) as string; - const schema = this.getNodeParameter('schema', 0) as string; - let returnFields = (this.getNodeParameter('returnFields', 0) as string).split(',') as string[]; - const columnString = this.getNodeParameter('columns', 0) as string; - const columns = columnString.split(',').map(column => column.trim()); - - const cs = new pgp.helpers.ColumnSet(columns); - - const te = new pgp.helpers.TableName({ table, schema }); - - // Prepare the data to insert and copy it to be returned - const insertItems = getItemCopy(items, columns); - - // Generate the multi-row insert query and return the id of new row - returnFields = returnFields.map(value => value.trim()).filter(value => !!value); - const query = pgp.helpers.insert(insertItems, cs, te) + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); - - // Executing the query to insert the data - const insertData = await db.manyOrNone(query); + const [insertData, insertItems] = await pgInsert( + this.getNodeParameter, + pgp, + db, + items, + ); // Add the id to the data for (let i = 0; i < insertData.length; i++) { @@ -293,37 +239,17 @@ export class QuestDB implements INodeType { json: { ...insertData[i], ...insertItems[i], - } + }, }); } - } else if (operation === 'update') { // ---------------------------------- // update // ---------------------------------- - const table = this.getNodeParameter('table', 0) as string; - const updateKey = this.getNodeParameter('updateKey', 0) as string; - const columnString = this.getNodeParameter('columns', 0) as string; - - const columns = columnString.split(',').map(column => column.trim()); - - // Make sure that the updateKey does also get queried - if (!columns.includes(updateKey)) { - columns.unshift(updateKey); - } - - // Prepare the data to update and copy it to be returned - const updateItems = getItemCopy(items, columns); - - // Generate the multi-row update query - const query = pgp.helpers.update(updateItems, columns, table) + ' WHERE v.' + updateKey + ' = t.' + updateKey; - - // Executing the query to update the data - await db.none(query); - - returnItems = this.helpers.returnJsonArray(updateItems as IDataObject[]); + const updateItems = await pgUpdate(this.getNodeParameter, pgp, db, items); + returnItems = this.helpers.returnJsonArray(updateItems); } else { await pgp.end(); throw new Error(`The operation "${operation}" is not supported!`); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 3c51466ea3..99fc37c9b0 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -112,6 +112,7 @@ "dist/credentials/PipedriveApi.credentials.js", "dist/credentials/Postgres.credentials.js", "dist/credentials/PostmarkApi.credentials.js", + "dist/credentials/Questdb.credentials.js", "dist/credentials/Redis.credentials.js", "dist/credentials/RocketchatApi.credentials.js", "dist/credentials/RundeckApi.credentials.js", @@ -260,6 +261,7 @@ "dist/nodes/Pipedrive/PipedriveTrigger.node.js", "dist/nodes/Postgres/Postgres.node.js", "dist/nodes/Postmark/PostmarkTrigger.node.js", + "dist/nodes/QuestDB/QuestDB.node.js", "dist/nodes/ReadBinaryFile.node.js", "dist/nodes/ReadBinaryFiles.node.js", "dist/nodes/ReadPdf.node.js", From 6870b4dfd3b4602693f3c96bcf3194ae4c050a37 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Wed, 8 Jul 2020 16:36:15 +0200 Subject: [PATCH 109/155] :rewind: Revert "adjust icon dimensions and tinify it" This reverts commit 51bd1b473fa21b3853d27fa78004e87c261dbb32. --- packages/nodes-base/nodes/QuestDB/questdb.png | Bin 2542 -> 11244 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/nodes-base/nodes/QuestDB/questdb.png b/packages/nodes-base/nodes/QuestDB/questdb.png index 85543a9c55466d8cf2ff61ff882c0cea0e433688..90febff64f5d4facdd03c641e2cb6ffd0d2dd373 100644 GIT binary patch literal 11244 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 literal 2542 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 Eg1%4-ApigX From 9f39156994173c895edb9daf4bc3a61f2eed60fe Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Wed, 8 Jul 2020 16:44:05 +0200 Subject: [PATCH 110/155] :racehorse: tinify icon --- packages/nodes-base/nodes/QuestDB/questdb.png | Bin 11244 -> 2835 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/nodes-base/nodes/QuestDB/questdb.png b/packages/nodes-base/nodes/QuestDB/questdb.png index 90febff64f5d4facdd03c641e2cb6ffd0d2dd373..5be1906e5d9d408257f4d2831489dc310126ecaf 100644 GIT binary patch literal 2835 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 literal 11244 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 From e32ec5b206581f45ea2a55c231671c041a319536 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 16:47:08 +0200 Subject: [PATCH 111/155] :bookmark: Release n8n-nodes-base@0.68.1 --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 1141485b4c..b3ccc3e267 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.68.0", + "version": "0.68.1", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From b3671380e3642d276a557e7d57086d3c5cb7fd2e Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 16:48:54 +0200 Subject: [PATCH 112/155] :arrow_up: Set n8n-nodes-base@0.68.1 on n8n --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 0bda408ec3..ab9d715f37 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -102,7 +102,7 @@ "mysql2": "^2.0.1", "n8n-core": "~0.38.0", "n8n-editor-ui": "~0.49.0", - "n8n-nodes-base": "~0.68.0", + "n8n-nodes-base": "~0.68.1", "n8n-workflow": "~0.34.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", From 16bd10fbd11f595862501ecac73f7985d74f853a Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 8 Jul 2020 16:50:15 +0200 Subject: [PATCH 113/155] :bookmark: Release n8n@0.73.1 --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index ab9d715f37..e9b16ad9e2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.73.0", + "version": "0.73.1", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From e07da55a1a538a05f322b9d089008b65f235f0ea Mon Sep 17 00:00:00 2001 From: smamudhan <48641776+smamudhan@users.noreply.github.com> Date: Thu, 9 Jul 2020 18:03:05 +0530 Subject: [PATCH 114/155] Typographical changes to ensure consistency with docs (#740) * Updated Dropdown Descriptions to match documentation * Removed full stops and hyphens in field descriptions * Removed hyphen on description on line 267 * Fixed typographical errors for Github node * Fixed typo for Zoom Credentials * Changed Postgres node description to match Postgres docs (https://github.com/n8n-io/n8n-docs/pull/30) * Changed Rocketchat displayname to RocketChat * Fixed typographical errors (and matched with docs) for Trello node descriptions * Updated Jira node descriptions to match docs --- packages/nodes-base/credentials/ZoomApi.credentials.ts | 2 +- packages/nodes-base/nodes/Jira/IssueDescription.ts | 4 ++-- packages/nodes-base/nodes/Postgres/Postgres.node.ts | 6 +++--- packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts | 6 +++--- packages/nodes-base/nodes/Trello/AttachmentDescription.ts | 2 +- packages/nodes-base/nodes/Trello/ChecklistDescription.ts | 6 +++--- packages/nodes-base/nodes/Trello/LabelDescription.ts | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/nodes-base/credentials/ZoomApi.credentials.ts b/packages/nodes-base/credentials/ZoomApi.credentials.ts index dbef996429..6efd857568 100644 --- a/packages/nodes-base/credentials/ZoomApi.credentials.ts +++ b/packages/nodes-base/credentials/ZoomApi.credentials.ts @@ -5,7 +5,7 @@ export class ZoomApi implements ICredentialType { displayName = 'Zoom API'; properties = [ { - displayName: 'JTW Token', + displayName: 'JWT Token', name: 'accessToken', type: 'string' as NodePropertyTypes, default: '' diff --git a/packages/nodes-base/nodes/Jira/IssueDescription.ts b/packages/nodes-base/nodes/Jira/IssueDescription.ts index bdb06179df..d29c9a5350 100644 --- a/packages/nodes-base/nodes/Jira/IssueDescription.ts +++ b/packages/nodes-base/nodes/Jira/IssueDescription.ts @@ -41,12 +41,12 @@ export const issueOperations = [ { name: 'Notify', value: 'notify', - description: 'Creates an email notification for an issue and adds it to the mail queue.', + description: 'Create an email notification for an issue and add it to the mail queue', }, { name: 'Status', value: 'transitions', - description: `Returns either all transitions or a transition that can be performed by the user on an issue, based on the issue's status.`, + description: `Return either all transitions or a transition that can be performed by the user on an issue, based on the issue's status`, }, { name: 'Delete', diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.ts index 2fa010576b..ad074a5f51 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.ts @@ -63,17 +63,17 @@ export class Postgres implements INodeType { { name: 'Execute Query', value: 'executeQuery', - description: 'Executes a SQL query.', + description: 'Execute an SQL query', }, { name: 'Insert', value: 'insert', - description: 'Insert rows in database.', + description: 'Insert rows in database', }, { name: 'Update', value: 'update', - description: 'Updates rows in database.', + description: 'Update rows in database', }, ], default: 'insert', diff --git a/packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts b/packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts index e83a3afbfe..65dd1a6820 100644 --- a/packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts +++ b/packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts @@ -48,15 +48,15 @@ interface IPostMessageBody { export class Rocketchat implements INodeType { description: INodeTypeDescription = { - displayName: 'Rocketchat', + displayName: 'RocketChat', name: 'rocketchat', icon: 'file:rocketchat.png', group: ['output'], version: 1, subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}', - description: 'Consume Rocketchat API', + description: 'Consume RocketChat API', defaults: { - name: 'Rocketchat', + name: 'RocketChat', color: '#c02428', }, inputs: ['main'], diff --git a/packages/nodes-base/nodes/Trello/AttachmentDescription.ts b/packages/nodes-base/nodes/Trello/AttachmentDescription.ts index a04f507518..4306b2ed11 100644 --- a/packages/nodes-base/nodes/Trello/AttachmentDescription.ts +++ b/packages/nodes-base/nodes/Trello/AttachmentDescription.ts @@ -29,7 +29,7 @@ export const attachmentOperations = [ { name: 'Get', value: 'get', - description: 'Get the data of an attachments', + description: 'Get the data of an attachment', }, { name: 'Get All', diff --git a/packages/nodes-base/nodes/Trello/ChecklistDescription.ts b/packages/nodes-base/nodes/Trello/ChecklistDescription.ts index 34036f83e2..be11ba6f17 100644 --- a/packages/nodes-base/nodes/Trello/ChecklistDescription.ts +++ b/packages/nodes-base/nodes/Trello/ChecklistDescription.ts @@ -44,17 +44,17 @@ export const checklistOperations = [ { name: 'Get Checklist Items', value: 'getCheckItem', - description: 'Get a specific Checklist on a card', + description: 'Get a specific checklist on a card', }, { name: 'Get Completed Checklist Items', value: 'completedCheckItems', - description: 'Get the completed Checklist items on a card', + description: 'Get the completed checklist items on a card', }, { name: 'Update Checklist Item', value: 'updateCheckItem', - description: 'Update an item in a checklist on a card.', + description: 'Update an item in a checklist on a card', }, ], default: 'getAll', diff --git a/packages/nodes-base/nodes/Trello/LabelDescription.ts b/packages/nodes-base/nodes/Trello/LabelDescription.ts index 9c23053282..2b938ae5de 100644 --- a/packages/nodes-base/nodes/Trello/LabelDescription.ts +++ b/packages/nodes-base/nodes/Trello/LabelDescription.ts @@ -39,7 +39,7 @@ export const labelOperations = [ { name: 'Get All', value: 'getAll', - description: 'Returns all label for the board', + description: 'Returns all labels for the board', }, { name: 'Remove From Card', From 05507fc19b848b8fd8f293b817da734154210d74 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Thu, 9 Jul 2020 15:17:47 +0200 Subject: [PATCH 115/155] :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 ed20a985f8..86e5c3fe22 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 116/155] :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 847587460f..0022c08b7a 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 86e5c3fe22..9b55f88b58 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 3bf34cffa90a1810b265d5ce0872d25db13a658c Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck Date: Thu, 9 Jul 2020 17:16:45 +0200 Subject: [PATCH 117/155] :art: fix format --- .../credentials/Questdb.credentials.ts | 8 ++---- .../nodes/Postgres/Postgres.node.functions.ts | 19 +++---------- .../nodes/Postgres/Postgres.node.ts | 21 +++------------ .../nodes-base/nodes/QuestDB/QuestDB.node.ts | 27 ++++--------------- 4 files changed, 15 insertions(+), 60 deletions(-) diff --git a/packages/nodes-base/credentials/Questdb.credentials.ts b/packages/nodes-base/credentials/Questdb.credentials.ts index 5cd60f960a..3672f6204d 100644 --- a/packages/nodes-base/credentials/Questdb.credentials.ts +++ b/packages/nodes-base/credentials/Questdb.credentials.ts @@ -1,8 +1,4 @@ -import { - ICredentialType, - NodePropertyTypes, -} from 'n8n-workflow'; - +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; export class QuestDB implements ICredentialType { name = 'questdb'; @@ -59,7 +55,7 @@ export class QuestDB implements ICredentialType { { name: 'verify-full (not implemented)', value: 'verify-full', - } + }, ], default: 'disable', }, diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts index b07835d1f6..fb4c50051c 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts @@ -10,10 +10,7 @@ import pg = require('pg-promise/typescript/pg-subset'); * @param {string[]} properties The properties it should include * @returns */ -function getItemCopy( - items: INodeExecutionData[], - properties: string[], -): IDataObject[] { +function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataObject[] { // Prepare the data to insert and copy it to be returned let newItem: IDataObject; return items.map(item => { @@ -69,9 +66,7 @@ export async function pgInsert( ): Promise> { const table = getNodeParam('table', 0) as string; const schema = getNodeParam('schema', 0) as string; - let returnFields = (getNodeParam('returnFields', 0) as string).split( - ',', - ) as string[]; + let returnFields = (getNodeParam('returnFields', 0) as string).split(',') as string[]; const columnString = getNodeParam('columns', 0) as string; const columns = columnString.split(',').map(column => column.trim()); @@ -83,9 +78,7 @@ export async function pgInsert( const insertItems = getItemCopy(items, columns); // Generate the multi-row insert query and return the id of new row - returnFields = returnFields - .map(value => value.trim()) - .filter(value => !!value); + returnFields = returnFields.map(value => value.trim()).filter(value => !!value); const query = pgp.helpers.insert(insertItems, cs, te) + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); @@ -127,11 +120,7 @@ export async function pgUpdate( // Generate the multi-row update query const query = - pgp.helpers.update(updateItems, columns, table) + - ' WHERE v.' + - updateKey + - ' = t.' + - updateKey; + pgp.helpers.update(updateItems, columns, table) + ' WHERE v.' + updateKey + ' = t.' + updateKey; // Executing the query to update the data await db.none(query); diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.ts index 702799c5c2..847620a6fa 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.ts @@ -1,10 +1,5 @@ import { IExecuteFunctions } from 'n8n-core'; -import { - IDataObject, - INodeExecutionData, - INodeType, - INodeTypeDescription, -} from 'n8n-workflow'; +import { IDataObject, INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow'; import * as pgPromise from 'pg-promise'; @@ -130,8 +125,7 @@ export class Postgres implements INodeType { }, }, default: '*', - description: - 'Comma separated list of the fields that the operation will return', + description: 'Comma separated list of the fields that the operation will return', }, // ---------------------------------- @@ -196,9 +190,7 @@ export class Postgres implements INodeType { database: credentials.database as string, user: credentials.user as string, password: credentials.password as string, - ssl: !['disable', undefined].includes( - credentials.ssl as string | undefined, - ), + ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), sslmode: (credentials.ssl as string) || 'disable', }; @@ -222,12 +214,7 @@ export class Postgres implements INodeType { // insert // ---------------------------------- - const [insertData, insertItems] = await pgInsert( - this.getNodeParameter, - pgp, - db, - items, - ); + const [insertData, insertItems] = await pgInsert(this.getNodeParameter, pgp, db, items); // Add the id to the data for (let i = 0; i < insertData.length; i++) { diff --git a/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts b/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts index 93dcdbe66d..bc8af9f17c 100644 --- a/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts +++ b/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts @@ -1,18 +1,9 @@ import { IExecuteFunctions } from 'n8n-core'; -import { - IDataObject, - INodeExecutionData, - INodeType, - INodeTypeDescription, -} from 'n8n-workflow'; +import { IDataObject, INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow'; import * as pgPromise from 'pg-promise'; -import { - pgInsert, - pgQuery, - pgUpdate, -} from '../Postgres/Postgres.node.functions'; +import { pgInsert, pgQuery, pgUpdate } from '../Postgres/Postgres.node.functions'; export class QuestDB implements INodeType { description: INodeTypeDescription = { @@ -134,8 +125,7 @@ export class QuestDB implements INodeType { }, }, default: '*', - description: - 'Comma separated list of the fields that the operation will return', + description: 'Comma separated list of the fields that the operation will return', }, // ---------------------------------- @@ -200,9 +190,7 @@ export class QuestDB implements INodeType { database: credentials.database as string, user: credentials.user as string, password: credentials.password as string, - ssl: !['disable', undefined].includes( - credentials.ssl as string | undefined, - ), + ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), sslmode: (credentials.ssl as string) || 'disable', }; @@ -226,12 +214,7 @@ export class QuestDB implements INodeType { // insert // ---------------------------------- - const [insertData, insertItems] = await pgInsert( - this.getNodeParameter, - pgp, - db, - items, - ); + const [insertData, insertItems] = await pgInsert(this.getNodeParameter, pgp, db, items); // Add the id to the data for (let i = 0; i < insertData.length; i++) { From bf81b064e15c5ec0118399f5b04bbab81395fdf5 Mon Sep 17 00:00:00 2001 From: ricardo Date: Thu, 9 Jul 2020 13:37:35 -0400 Subject: [PATCH 118/155] :zap: Improvements to Mailchimp-Node --- .../nodes/Mailchimp/GenericFunctions.ts | 1 - .../nodes/Mailchimp/Mailchimp.node.ts | 348 +++++++++++++++++- 2 files changed, 347 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Mailchimp/GenericFunctions.ts b/packages/nodes-base/nodes/Mailchimp/GenericFunctions.ts index 59362b50d6..99c0af67fd 100644 --- a/packages/nodes-base/nodes/Mailchimp/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Mailchimp/GenericFunctions.ts @@ -49,7 +49,6 @@ export async function mailchimpApiRequest(this: IHookFunctions | IExecuteFunctio const datacenter = (credentials.apiKey as string).split('-').pop(); options.url = `https://${datacenter}.${host}${endpoint}`; - return await this.helpers.request!(options); } else { const credentials = this.getCredentials('mailchimpOAuth2Api') as IDataObject; diff --git a/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts b/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts index 25dbeb9984..443fd5d20c 100644 --- a/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts +++ b/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts @@ -47,6 +47,7 @@ interface ICreateMemberBody { timestamp_opt?: string; tags?: string[]; merge_fields?: IDataObject; + interests?: IDataObject; } export class Mailchimp implements INodeType { @@ -112,6 +113,10 @@ export class Mailchimp implements INodeType { name: 'resource', type: 'options', options: [ + { + name: 'List Group', + value: 'listGroup', + }, { name: 'Member', value: 'member', @@ -194,6 +199,28 @@ export class Mailchimp implements INodeType { default: 'create', description: 'The operation to perform.', }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + required: true, + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'Get all groups', + }, + ], + default: 'getAll', + description: 'The operation to perform.', + }, /* -------------------------------------------------------------------------- */ /* member:create */ /* -------------------------------------------------------------------------- */ @@ -534,6 +561,89 @@ export class Mailchimp implements INodeType { }, }, }, + { + displayName: 'Interest Groups', + name: 'groupsUi', + placeholder: 'Add Interest Group', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource:[ + 'member' + ], + operation: [ + 'create', + ], + jsonParameters: [ + false, + ], + }, + }, + options: [ + { + name: 'groupsValues', + displayName: 'Group', + typeOptions: { + multipleValueButtonText: 'Add Interest Group', + }, + values: [ + { + displayName: 'Category ID', + name: 'categoryId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getGroupCategories', + loadOptionsDependsOn: [ + 'list', + ], + }, + default: '', + }, + { + displayName: 'Category Field ID', + name: 'categoryFieldId', + type: 'string', + default: '', + description: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'boolean', + default: false, + description: '', + }, + ], + }, + ], + }, + { + displayName: 'Interest Groups', + name: 'groupJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: '', + displayOptions: { + show: { + resource:[ + 'member', + ], + operation: [ + 'create', + ], + jsonParameters: [ + true, + ], + }, + }, + }, /* -------------------------------------------------------------------------- */ /* member:delete */ /* -------------------------------------------------------------------------- */ @@ -922,6 +1032,66 @@ export class Mailchimp implements INodeType { default: '', description: 'Type of email this member asked to get', }, + { + displayName: 'Interest Groups', + name: 'groupsUi', + placeholder: 'Add Interest Group', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + '/resource':[ + 'member' + ], + '/operation':[ + 'update', + ], + '/jsonParameters': [ + false, + ], + }, + }, + options: [ + { + name: 'groupsValues', + displayName: 'Group', + typeOptions: { + multipleValueButtonText: 'Add Interest Group', + }, + values: [ + { + displayName: 'Category ID', + name: 'categoryId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getGroupCategories', + loadOptionsDependsOn: [ + 'list', + ], + }, + default: '', + }, + { + displayName: 'Category Field ID', + name: 'categoryFieldId', + type: 'string', + default: '', + description: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'boolean', + default: false, + description: '', + }, + ], + }, + ], + }, { displayName: 'Language', name: 'language', @@ -1157,6 +1327,29 @@ export class Mailchimp implements INodeType { }, }, }, + { + displayName: 'Interest Groups', + name: 'groupJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: '', + displayOptions: { + show: { + resource:[ + 'member', + ], + operation: [ + 'update', + ], + jsonParameters: [ + true, + ], + }, + }, + }, /* -------------------------------------------------------------------------- */ /* memberTag:create */ /* -------------------------------------------------------------------------- */ @@ -1250,6 +1443,96 @@ export class Mailchimp implements INodeType { }, ], }, +/* -------------------------------------------------------------------------- */ +/* member:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'List', + name: 'list', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getLists', + }, + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + operation: [ + 'getAll', + ], + }, + }, + default: '', + options: [], + required: true, + description: 'List of lists', + }, + { + displayName: 'Group Category', + name: 'groupCategory', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getGroupCategories', + loadOptionsDependsOn: [ + 'list', + ], + }, + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + operation: [ + 'getAll', + ], + }, + }, + default: '', + options: [], + required: true, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + 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: [ + 'listGroup', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 1000, + }, + default: 500, + description: 'How many results to return.', + }, ], }; @@ -1261,7 +1544,7 @@ export class Mailchimp implements INodeType { // select them easily async getLists(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; - const { lists } = await mailchimpApiRequest.call(this, '/lists', 'GET'); + const lists = await mailchimpApiRequestAllItems.call(this, '/lists', 'GET', 'lists'); for (const list of lists) { const listName = list.name; const listId = list.id; @@ -1289,6 +1572,23 @@ export class Mailchimp implements INodeType { } return returnData; }, + + // Get all the interest fields to display them to user so that he can + // select them easily + async getGroupCategories(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const listId = this.getCurrentNodeParameter('list'); + const { categories } = await mailchimpApiRequest.call(this, `/lists/${listId}/interest-categories`, 'GET'); + for (const category of categories) { + const categoryName = category.title; + const categoryId = category.id; + returnData.push({ + name: categoryName, + value: categoryId, + }); + } + return returnData; + }, } }; @@ -1302,6 +1602,22 @@ export class Mailchimp implements INodeType { const operation = this.getNodeParameter('operation', 0) as string; for (let i = 0; i < length; i++) { + if (resource === 'listGroup') { + //https://mailchimp.com/developer/reference/lists/interest-categories/#get_/lists/-list_id-/interest-categories/-interest_category_id- + if (operation === 'getAll') { + const listId = this.getNodeParameter('list', i) as string; + const categoryId = this.getNodeParameter('groupCategory', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (returnAll === true) { + responseData = await mailchimpApiRequestAllItems.call(this, `/lists/${listId}/interest-categories/${categoryId}/interests`, 'GET', 'interests', {}, qs); + } else { + qs.count = this.getNodeParameter('limit', i) as number; + responseData = await mailchimpApiRequest.call(this, `/lists/${listId}/interest-categories/${categoryId}/interests`, 'GET', {}, qs); + responseData = responseData.interests; + } + } + } if (resource === 'member') { //https://mailchimp.com/developer/reference/lists/list-members/#post_/lists/-list_id-/members if (operation === 'create') { @@ -1363,15 +1679,29 @@ export class Mailchimp implements INodeType { } body.merge_fields = mergeFields; } + + const groupsValues = (this.getNodeParameter('groupsUi', i) as IDataObject).groupsValues as IDataObject[]; + if (groupsValues) { + const groups = {}; + for (let i = 0; i < groupsValues.length; i++) { + // @ts-ignore + groups[groupsValues[i].categoryFieldId] = groupsValues[i].value; + } + body.interests = groups; + } } else { const locationJson = validateJSON(this.getNodeParameter('locationJson', i) as string); const mergeFieldsJson = validateJSON(this.getNodeParameter('mergeFieldsJson', i) as string); + const groupJson = validateJSON(this.getNodeParameter('groupJson', i) as string); if (locationJson) { body.location = locationJson; } if (mergeFieldsJson) { body.merge_fields = mergeFieldsJson; } + if (groupJson) { + body.interests = groupJson; + } } responseData = await mailchimpApiRequest.call(this, `/lists/${listId}/members`, 'POST', body); } @@ -1504,15 +1834,31 @@ export class Mailchimp implements INodeType { body.merge_fields = mergeFields; } } + if (updateFields.groupsUi) { + const groupsValues = (updateFields.groupsUi as IDataObject).groupsValues as IDataObject[]; + if (groupsValues) { + const groups = {}; + for (let i = 0; i < groupsValues.length; i++) { + // @ts-ignore + groups[groupsValues[i].categoryFieldId] = groupsValues[i].value; + } + body.interests = groups; + } + } } else { const locationJson = validateJSON(this.getNodeParameter('locationJson', i) as string); const mergeFieldsJson = validateJSON(this.getNodeParameter('mergeFieldsJson', i) as string); + const groupJson = validateJSON(this.getNodeParameter('groupJson', i) as string); + if (locationJson) { body.location = locationJson; } if (mergeFieldsJson) { body.merge_fields = mergeFieldsJson; } + if (groupJson) { + body.interests = groupJson; + } } responseData = await mailchimpApiRequest.call(this, `/lists/${listId}/members/${email}`, 'PUT', body); } From 5adec0a299a9e1f23e1a6fd039a3765307cd6439 Mon Sep 17 00:00:00 2001 From: ricardo Date: Thu, 9 Jul 2020 16:23:17 -0400 Subject: [PATCH 119/155] :zap: Add search operation to resource person --- .../nodes/Pipedrive/GenericFunctions.ts | 14 ++- .../nodes/Pipedrive/Pipedrive.node.ts | 115 +++++++++++++++++- 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts index 33bf215b12..a263afa096 100644 --- a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts @@ -48,6 +48,9 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio query.api_token = credentials.apiToken; const options: OptionsWithUri = { + headers: { + "Accept": "application/json", + }, method, qs: query, uri: `https://api.pipedrive.com/v1${endpoint}`, @@ -93,7 +96,7 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio if (error.response && error.response.body && error.response.body.error) { // Try to return the error prettier - let errorMessage = `Pipedrive error response [${error.statusCode}]: ${error.response.body.error}`; + let errorMessage = `Pipedrive error response [${error.statusCode}]: ${error.response.body.error.message}`; if (error.response.body.error_info) { errorMessage += ` - ${error.response.body.error_info}`; } @@ -124,7 +127,7 @@ export async function pipedriveApiRequestAllItems(this: IHookFunctions | IExecut if (query === undefined) { query = {}; } - query.limit = 500; + query.limit = 100; query.start = 0; const returnData: IDataObject[] = []; @@ -133,7 +136,12 @@ export async function pipedriveApiRequestAllItems(this: IHookFunctions | IExecut do { responseData = await pipedriveApiRequest.call(this, method, endpoint, body, query); - returnData.push.apply(returnData, responseData.data); + // the search path returns data diferently + if (responseData.data.items) { + returnData.push.apply(returnData, responseData.data.items); + } else { + returnData.push.apply(returnData, responseData.data); + } query.start = responseData.additionalData.pagination.next_start; } while ( diff --git a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts index a5cd93ab9f..8157f94bde 100644 --- a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts @@ -25,7 +25,6 @@ interface CustomProperty { value: string; } - /** * Add the additional fields to the body * @@ -362,6 +361,11 @@ export class Pipedrive implements INodeType { value: 'getAll', description: 'Get data of all persons', }, + { + name: 'Search', + value: 'search', + description: 'Search all persons', + }, { name: 'Update', value: 'update', @@ -2021,6 +2025,7 @@ export class Pipedrive implements INodeType { show: { operation: [ 'getAll', + 'search', ], }, }, @@ -2035,6 +2040,7 @@ export class Pipedrive implements INodeType { show: { operation: [ 'getAll', + 'search', ], returnAll: [ false, @@ -2088,6 +2094,74 @@ export class Pipedrive implements INodeType { }, ], }, + + // ---------------------------------- + // person:search + // ---------------------------------- + { + displayName: 'Term', + name: 'term', + type: 'string', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'person', + ], + }, + }, + default: '', + description: 'The search term to look for. Minimum 2 characters (or 1 if using exact_match).', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'person', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Exact Match', + name: 'exactMatch', + type: 'boolean', + default: false, + description: 'When enabled, only full exact matches against the given term are returned. It is not case sensitive.', + }, + { + displayName: 'Fields', + name: 'fields', + type: 'string', + default: '', + description: 'A comma-separated string array. The fields to perform the search from. Defaults to all of them.', + }, + { + displayName: 'Include Fields', + name: 'includeFields', + type: 'string', + default: '', + description: 'Supports including optional fields in the results which are not provided by default.', + }, + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + default: '', + description: 'Will filter Deals by the provided Organization ID.', + }, + ], + }, ], }; @@ -2526,6 +2600,39 @@ export class Pipedrive implements INodeType { endpoint = `/persons`; + } else if (operation === 'search') { + // ---------------------------------- + // persons:search + // ---------------------------------- + + requestMethod = 'GET'; + + qs.term = this.getNodeParameter('term', i) as string; + returnAll = this.getNodeParameter('returnAll', i) as boolean; + if (returnAll === false) { + qs.limit = this.getNodeParameter('limit', i) as number; + } + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.fields) { + qs.fields = additionalFields.fields as string; + } + + if (additionalFields.exactMatch) { + qs.exact_match = additionalFields.exactMatch as boolean; + } + + if (additionalFields.organizationId) { + qs.organization_id = parseInt(additionalFields.organizationId as string, 10); + } + + if (additionalFields.includeFields) { + qs.include_fields = additionalFields.includeFields as string; + } + + endpoint = `/persons/search`; + } else if (operation === 'update') { // ---------------------------------- // person:update @@ -2562,7 +2669,9 @@ export class Pipedrive implements INodeType { let responseData; if (returnAll === true) { + responseData = await pipedriveApiRequestAllItems.call(this, requestMethod, endpoint, body, qs); + } else { if (customProperties !== undefined) { @@ -2597,6 +2706,10 @@ export class Pipedrive implements INodeType { responseData.data = []; } + if (operation === 'search' && responseData.data && responseData.data.items) { + responseData.data = responseData.data.items; + } + if (Array.isArray(responseData.data)) { returnData.push.apply(returnData, responseData.data as IDataObject[]); } else { From 305894d9b4de60d14fe7d9a8727610a3557f5c51 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Fri, 10 Jul 2020 10:12:30 +0200 Subject: [PATCH 120/155] :bug: Fix item display issue --- docker/images/n8n/README.md | 151 ++++++++---------- packages/editor-ui/src/components/RunData.vue | 8 +- 2 files changed, 77 insertions(+), 82 deletions(-) diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md index 7170dda595..977c53fef4 100644 --- a/docker/images/n8n/README.md +++ b/docker/images/n8n/README.md @@ -6,25 +6,23 @@ n8n is a free and open [fair-code](http://faircode.io) licensed node based Workf n8n.io - Screenshot - ## Contents -- [Demo](#demo) -- [Available integrations](#available-integrations) -- [Documentation](#documentation) -- [Start n8n in Docker](#start-n8n-in-docker) -- [Start with tunnel](#start-with-tunnel) -- [Securing n8n](#securing-n8n) -- [Persist data](#persist-data) -- [Passing Sensitive Data via File](#passing-sensitive-data-via-file) -- [Updating a Running docker-compose Instance](#updating-a-running-docker-compose-instance) -- [Example Setup with Lets Encrypt](#example-setup-with-lets-encrypt) -- [What does n8n mean and how do you pronounce it](#what-does-n8n-mean-and-how-do-you-pronounce-it) -- [Support](#support) -- [Jobs](#jobs) -- [Upgrading](#upgrading) -- [License](#license) - + - [Demo](#demo) + - [Available integrations](#available-integrations) + - [Documentation](#documentation) + - [Start n8n in Docker](#start-n8n-in-docker) + - [Start with tunnel](#start-with-tunnel) + - [Securing n8n](#securing-n8n) + - [Persist data](#persist-data) + - [Passing Sensitive Data via File](#passing-sensitive-data-via-file) + - [Updating a Running docker-compose Instance](#updating-a-running-docker-compose-instance) + - [Example Setup with Lets Encrypt](#example-setup-with-lets-encrypt) + - [What does n8n mean and how do you pronounce it](#what-does-n8n-mean-and-how-do-you-pronounce-it) + - [Support](#support) + - [Jobs](#jobs) + - [Upgrading](#upgrading) + - [License](#license) ## Demo @@ -49,9 +47,9 @@ Additional information and example workflows on the n8n.io website: [https://n8n ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - n8nio/n8n + --name n8n \ + -p 5678:5678 \ + n8nio/n8n ``` You can then access n8n by opening: @@ -71,14 +69,13 @@ To use it simply start n8n with `--tunnel` ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n \ - n8n start --tunnel + --name n8n \ + -p 5678:5678 \ + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start --tunnel ``` - ## Securing n8n By default n8n can be accessed by everybody. This is OK if you have it only running @@ -93,7 +90,6 @@ N8N_BASIC_AUTH_USER= N8N_BASIC_AUTH_PASSWORD= ``` - ## Persist data The workflow data gets by default saved in an SQLite database in the user @@ -102,10 +98,10 @@ settings like webhook URL and encryption key. ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n + --name n8n \ + -p 5678:5678 \ + -v ~/.n8n:/root/.n8n \ + n8nio/n8n ``` ### Start with other Database @@ -121,7 +117,6 @@ for the credentials. If none gets found n8n creates automatically one on startup. In case credentials are already saved with a different encryption key it can not be used anymore as encrypting it is not possible anymore. - #### Use with MongoDB > **WARNING**: Use Postgres if possible! Mongo has problems with saving large @@ -129,40 +124,39 @@ it can not be used anymore as encrypting it is not possible anymore. > may be dropped in the future. Replace the following placeholders with the actual data: - - - - - - - - - - + - MONGO_DATABASE + - MONGO_HOST + - MONGO_PORT + - MONGO_USER + - MONGO_PASSWORD ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ + --name n8n \ + -p 5678:5678 \ -e DB_TYPE=mongodb \ -e DB_MONGODB_CONNECTION_URL="mongodb://:@:/" \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n \ - n8n start + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start ``` A full working setup with docker-compose can be found [here](https://github.com/n8n-io/n8n/blob/master/docker/compose/withMongo/README.md) - #### Use with PostgresDB Replace the following placeholders with the actual data: - - - - - - - - - - - - + - POSTGRES_DATABASE + - POSTGRES_HOST + - POSTGRES_PASSWORD + - POSTGRES_PORT + - POSTGRES_USER + - POSTGRES_SCHEMA ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ + --name n8n \ + -p 5678:5678 \ -e DB_TYPE=postgresdb \ -e DB_POSTGRESDB_DATABASE= \ -e DB_POSTGRESDB_HOST= \ @@ -170,39 +164,37 @@ docker run -it --rm \ -e DB_POSTGRESDB_USER= \ -e DB_POSTGRESDB_SCHEMA= \ -e DB_POSTGRESDB_PASSWORD= \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n \ - n8n start + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start ``` A full working setup with docker-compose can be found [here](https://github.com/n8n-io/n8n/blob/master/docker/compose/withPostgres/README.md) - #### Use with MySQL Replace the following placeholders with the actual data: - - - - - - - - - - + - MYSQLDB_DATABASE + - MYSQLDB_HOST + - MYSQLDB_PASSWORD + - MYSQLDB_PORT + - MYSQLDB_USER ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ + --name n8n \ + -p 5678:5678 \ -e DB_TYPE=mysqldb \ -e DB_MYSQLDB_DATABASE= \ -e DB_MYSQLDB_HOST= \ -e DB_MYSQLDB_PORT= \ -e DB_MYSQLDB_USER= \ -e DB_MYSQLDB_PASSWORD= \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n \ - n8n start + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start ``` - ## Passing Sensitive Data via File To avoid passing sensitive information via environment variables "_FILE" may be @@ -211,16 +203,15 @@ with the given name. That makes it possible to load data easily from Docker- and Kubernetes-Secrets. The following environment variables support file input: - - DB_MONGODB_CONNECTION_URL_FILE - - DB_POSTGRESDB_DATABASE_FILE - - DB_POSTGRESDB_HOST_FILE - - DB_POSTGRESDB_PASSWORD_FILE - - DB_POSTGRESDB_PORT_FILE - - DB_POSTGRESDB_USER_FILE - - DB_POSTGRESDB_SCHEMA_FILE - - N8N_BASIC_AUTH_PASSWORD_FILE - - N8N_BASIC_AUTH_USER_FILE - + - DB_MONGODB_CONNECTION_URL_FILE + - DB_POSTGRESDB_DATABASE_FILE + - DB_POSTGRESDB_HOST_FILE + - DB_POSTGRESDB_PASSWORD_FILE + - DB_POSTGRESDB_PORT_FILE + - DB_POSTGRESDB_USER_FILE + - DB_POSTGRESDB_SCHEMA_FILE + - N8N_BASIC_AUTH_PASSWORD_FILE + - N8N_BASIC_AUTH_USER_FILE ## Example Setup with Lets Encrypt @@ -235,7 +226,7 @@ docker pull n8nio/n8n # Stop current setup sudo docker-compose stop # Delete it (will only delete the docker-containers, data is stored separately) -sudo docker-compose rm +sudo docker-compose rm # Then start it again sudo docker-compose up -d ``` @@ -251,11 +242,11 @@ the environment variable `TZ`. Example to use the same timezone for both: ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ + --name n8n \ + -p 5678:5678 \ -e GENERIC_TIMEZONE="Europe/Berlin" \ -e TZ="Europe/Berlin" \ - n8nio/n8n + n8nio/n8n ``` diff --git a/packages/editor-ui/src/components/RunData.vue b/packages/editor-ui/src/components/RunData.vue index b3729e4f68..42fbaa5b19 100644 --- a/packages/editor-ui/src/components/RunData.vue +++ b/packages/editor-ui/src/components/RunData.vue @@ -19,7 +19,7 @@
- + Results: {{ dataCount }} Results: @@ -248,7 +248,11 @@ export default mixins( return executionData.resultData.runData; }, maxDisplayItemsOptions (): number[] { - return [25, 50, 100, 250, 500, 1000, this.dataCount].filter(option => option <= this.dataCount); + const options = [25, 50, 100, 250, 500, 1000].filter(option => option <= this.dataCount); + if (!options.includes(this.dataCount)) { + options.push(this.dataCount); + } + return options; }, node (): INodeUi | null { return this.$store.getters.activeNode; From e3e3f038d6bbc13c940453987c4236387a88b52f Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Fri, 10 Jul 2020 10:25:54 +0200 Subject: [PATCH 121/155] :zap: Minor improvement to Pipedrive-Node --- .../nodes/Pipedrive/GenericFunctions.ts | 2 +- .../nodes-base/nodes/Pipedrive/Pipedrive.node.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts index a263afa096..069ff5fe55 100644 --- a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts @@ -49,7 +49,7 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio const options: OptionsWithUri = { headers: { - "Accept": "application/json", + Accept: 'application/json', }, method, qs: query, diff --git a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts index 8157f94bde..13786ba94e 100644 --- a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts @@ -2160,6 +2160,13 @@ export class Pipedrive implements INodeType { default: '', description: 'Will filter Deals by the provided Organization ID.', }, + { + displayName: 'RAW Data', + name: 'rawData', + type: 'boolean', + default: false, + description: `Returns the data exactly in the way it got received from the API.`, + }, ], }, ], @@ -2708,6 +2715,15 @@ export class Pipedrive implements INodeType { if (operation === 'search' && responseData.data && responseData.data.items) { responseData.data = responseData.data.items; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + if (additionalFields.rawData !== true) { + responseData.data = responseData.data.map((item: { result_score: number, item: object }) => { + return { + result_score: item.result_score, + ...item.item, + }; + }); + } } if (Array.isArray(responseData.data)) { From db972b384fc9d7c8efb4ca0b2dd6344af58b1ef2 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Fri, 10 Jul 2020 04:38:23 -0400 Subject: [PATCH 122/155] :bug: Fixes issue #735 (#743) --- packages/nodes-base/nodes/HttpRequest.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/HttpRequest.node.ts b/packages/nodes-base/nodes/HttpRequest.node.ts index 417d80eeea..08d3a0ac15 100644 --- a/packages/nodes-base/nodes/HttpRequest.node.ts +++ b/packages/nodes-base/nodes/HttpRequest.node.ts @@ -802,7 +802,7 @@ export class HttpRequest implements INodeType { if (oAuth2Api !== undefined) { //@ts-ignore - response = await this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions); + response = await this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions, 'Bearer'); } else { response = await this.helpers.request(requestOptions); } From a00fedb35109344ddffb1aa496773f63e83c6860 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com> Date: Fri, 10 Jul 2020 10:48:19 +0200 Subject: [PATCH 123/155] :art: Extract postgres functionality (#737) * :tada: executeQuery function extracted * :construction: add prettierrc to gitignore * :construction: insert function extracted * :zap: extract update function * :bulb: fix function docs * :bulb: add in code documentation * :art: fix format * :art: fix format --- .gitignore | 1 + .../nodes/Postgres/Postgres.node.functions.ts | 129 ++++++++++++++++++ .../nodes/Postgres/Postgres.node.ts | 95 ++----------- 3 files changed, 140 insertions(+), 85 deletions(-) create mode 100644 packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts diff --git a/.gitignore b/.gitignore index b3eac39207..0441d445b4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ _START_PACKAGE .env .vscode .idea +.prettierrc.js diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts new file mode 100644 index 0000000000..fb4c50051c --- /dev/null +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts @@ -0,0 +1,129 @@ +import { IDataObject, INodeExecutionData } from 'n8n-workflow'; +import pgPromise = require('pg-promise'); +import pg = require('pg-promise/typescript/pg-subset'); + +/** + * Returns of copy of the items which only contains the json data and + * of that only the define properties + * + * @param {INodeExecutionData[]} items The items to copy + * @param {string[]} properties The properties it should include + * @returns + */ +function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataObject[] { + // Prepare the data to insert and copy it to be returned + let newItem: IDataObject; + return items.map(item => { + newItem = {}; + for (const property of properties) { + if (item.json[property] === undefined) { + newItem[property] = null; + } else { + newItem[property] = JSON.parse(JSON.stringify(item.json[property])); + } + } + return newItem; + }); +} + +/** + * Executes the given SQL query on the database. + * + * @param {Function} getNodeParam The getter for the Node's parameters + * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance + * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection + * @param {input[]} input The Node's input data + * @returns Promise> + */ +export function pgQuery( + getNodeParam: Function, + pgp: pgPromise.IMain<{}, pg.IClient>, + db: pgPromise.IDatabase<{}, pg.IClient>, + input: INodeExecutionData[], +): Promise> { + const queries: string[] = []; + for (let i = 0; i < input.length; i++) { + queries.push(getNodeParam('query', i) as string); + } + + return db.any(pgp.helpers.concat(queries)); +} + +/** + * Inserts the given items into the database. + * + * @param {Function} getNodeParam The getter for the Node's parameters + * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance + * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection + * @param {INodeExecutionData[]} items The items to be inserted + * @returns Promise> + */ +export async function pgInsert( + getNodeParam: Function, + pgp: pgPromise.IMain<{}, pg.IClient>, + db: pgPromise.IDatabase<{}, pg.IClient>, + items: INodeExecutionData[], +): Promise> { + const table = getNodeParam('table', 0) as string; + const schema = getNodeParam('schema', 0) as string; + let returnFields = (getNodeParam('returnFields', 0) as string).split(',') as string[]; + const columnString = getNodeParam('columns', 0) as string; + const columns = columnString.split(',').map(column => column.trim()); + + const cs = new pgp.helpers.ColumnSet(columns); + + const te = new pgp.helpers.TableName({ table, schema }); + + // Prepare the data to insert and copy it to be returned + const insertItems = getItemCopy(items, columns); + + // Generate the multi-row insert query and return the id of new row + returnFields = returnFields.map(value => value.trim()).filter(value => !!value); + const query = + pgp.helpers.insert(insertItems, cs, te) + + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); + + // Executing the query to insert the data + const insertData = await db.manyOrNone(query); + + return [insertData, insertItems]; +} + +/** + * Updates the given items in the database. + * + * @param {Function} getNodeParam The getter for the Node's parameters + * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance + * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection + * @param {INodeExecutionData[]} items The items to be updated + * @returns Promise> + */ +export async function pgUpdate( + getNodeParam: Function, + pgp: pgPromise.IMain<{}, pg.IClient>, + db: pgPromise.IDatabase<{}, pg.IClient>, + items: INodeExecutionData[], +): Promise> { + const table = getNodeParam('table', 0) as string; + const updateKey = getNodeParam('updateKey', 0) as string; + const columnString = getNodeParam('columns', 0) as string; + + const columns = columnString.split(',').map(column => column.trim()); + + // Make sure that the updateKey does also get queried + if (!columns.includes(updateKey)) { + columns.unshift(updateKey); + } + + // Prepare the data to update and copy it to be returned + const updateItems = getItemCopy(items, columns); + + // Generate the multi-row update query + const query = + pgp.helpers.update(updateItems, columns, table) + ' WHERE v.' + updateKey + ' = t.' + updateKey; + + // Executing the query to update the data + await db.none(query); + + return updateItems; +} diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.ts index ad074a5f51..f465ca05c8 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.ts @@ -3,36 +3,12 @@ import { IDataObject, INodeExecutionData, INodeType, - INodeTypeDescription, + INodeTypeDescription } from 'n8n-workflow'; import * as pgPromise from 'pg-promise'; - -/** - * Returns of copy of the items which only contains the json data and - * of that only the define properties - * - * @param {INodeExecutionData[]} items The items to copy - * @param {string[]} properties The properties it should include - * @returns - */ -function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataObject[] { - // Prepare the data to insert and copy it to be returned - let newItem: IDataObject; - return items.map((item) => { - newItem = {}; - for (const property of properties) { - if (item.json[property] === undefined) { - newItem[property] = null; - } else { - newItem[property] = JSON.parse(JSON.stringify(item.json[property])); - } - } - return newItem; - }); -} - +import { pgInsert, pgQuery, pgUpdate } from './Postgres.node.functions'; export class Postgres implements INodeType { description: INodeTypeDescription = { @@ -52,7 +28,7 @@ export class Postgres implements INodeType { { name: 'postgres', required: true, - } + }, ], properties: [ { @@ -103,7 +79,6 @@ export class Postgres implements INodeType { description: 'The SQL query to execute.', }, - // ---------------------------------- // insert // ---------------------------------- @@ -143,9 +118,7 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: '', @@ -167,7 +140,6 @@ export class Postgres implements INodeType { description: 'Comma separated list of the fields that the operation will return', }, - // ---------------------------------- // update // ---------------------------------- @@ -216,13 +188,10 @@ export class Postgres implements INodeType { placeholder: 'name,description', description: 'Comma separated list of the properties which should used as columns for rows to update.', }, - - ] + ], }; - async execute(this: IExecuteFunctions): Promise { - const credentials = this.getCredentials('postgres'); if (credentials === undefined) { @@ -253,39 +222,15 @@ export class Postgres implements INodeType { // executeQuery // ---------------------------------- - const queries: string[] = []; - for (let i = 0; i < items.length; i++) { - queries.push(this.getNodeParameter('query', i) as string); - } - - const queryResult = await db.any(pgp.helpers.concat(queries)); + const queryResult = await pgQuery(this.getNodeParameter, pgp, db, items); returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); - } else if (operation === 'insert') { // ---------------------------------- // insert // ---------------------------------- - const table = this.getNodeParameter('table', 0) as string; - const schema = this.getNodeParameter('schema', 0) as string; - let returnFields = (this.getNodeParameter('returnFields', 0) as string).split(',') as string[]; - const columnString = this.getNodeParameter('columns', 0) as string; - const columns = columnString.split(',').map(column => column.trim()); - - const cs = new pgp.helpers.ColumnSet(columns); - - const te = new pgp.helpers.TableName({ table, schema }); - - // Prepare the data to insert and copy it to be returned - const insertItems = getItemCopy(items, columns); - - // Generate the multi-row insert query and return the id of new row - returnFields = returnFields.map(value => value.trim()).filter(value => !!value); - const query = pgp.helpers.insert(insertItems, cs, te) + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); - - // Executing the query to insert the data - const insertData = await db.manyOrNone(query); + const [insertData, insertItems] = await pgInsert(this.getNodeParameter, pgp, db, items); // Add the id to the data for (let i = 0; i < insertData.length; i++) { @@ -293,37 +238,17 @@ export class Postgres implements INodeType { json: { ...insertData[i], ...insertItems[i], - } + }, }); } - } else if (operation === 'update') { // ---------------------------------- // update // ---------------------------------- - const table = this.getNodeParameter('table', 0) as string; - const updateKey = this.getNodeParameter('updateKey', 0) as string; - const columnString = this.getNodeParameter('columns', 0) as string; - - const columns = columnString.split(',').map(column => column.trim()); - - // Make sure that the updateKey does also get queried - if (!columns.includes(updateKey)) { - columns.unshift(updateKey); - } - - // Prepare the data to update and copy it to be returned - const updateItems = getItemCopy(items, columns); - - // Generate the multi-row update query - const query = pgp.helpers.update(updateItems, columns, table) + ' WHERE v.' + updateKey + ' = t.' + updateKey; - - // Executing the query to update the data - await db.none(query); - - returnItems = this.helpers.returnJsonArray(updateItems as IDataObject[]); + const updateItems = await pgUpdate(this.getNodeParameter, pgp, db, items); + returnItems = this.helpers.returnJsonArray(updateItems); } else { await pgp.end(); throw new Error(`The operation "${operation}" is not supported!`); From ae67735d149e8b76b9aaf49b86e5db4681b04293 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Fri, 10 Jul 2020 10:58:39 +0200 Subject: [PATCH 124/155] :zap: Minor improvements to QuestDb-Node --- ...uestdb.credentials.ts => QuestDb.credentials.ts} | 4 ++-- .../QuestDB.node.ts => QuestDb/QuestDb.node.ts} | 8 ++++---- .../nodes/{QuestDB => QuestDb}/questdb.png | Bin packages/nodes-base/package.json | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) rename packages/nodes-base/credentials/{Questdb.credentials.ts => QuestDb.credentials.ts} (94%) rename packages/nodes-base/nodes/{QuestDB/QuestDB.node.ts => QuestDb/QuestDb.node.ts} (97%) rename packages/nodes-base/nodes/{QuestDB => QuestDb}/questdb.png (100%) diff --git a/packages/nodes-base/credentials/Questdb.credentials.ts b/packages/nodes-base/credentials/QuestDb.credentials.ts similarity index 94% rename from packages/nodes-base/credentials/Questdb.credentials.ts rename to packages/nodes-base/credentials/QuestDb.credentials.ts index 3672f6204d..24c1522737 100644 --- a/packages/nodes-base/credentials/Questdb.credentials.ts +++ b/packages/nodes-base/credentials/QuestDb.credentials.ts @@ -1,7 +1,7 @@ import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; -export class QuestDB implements ICredentialType { - name = 'questdb'; +export class QuestDb implements ICredentialType { + name = 'questDb'; displayName = 'QuestDB'; properties = [ { diff --git a/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts b/packages/nodes-base/nodes/QuestDb/QuestDb.node.ts similarity index 97% rename from packages/nodes-base/nodes/QuestDB/QuestDB.node.ts rename to packages/nodes-base/nodes/QuestDb/QuestDb.node.ts index bc8af9f17c..20b6d48615 100644 --- a/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts +++ b/packages/nodes-base/nodes/QuestDb/QuestDb.node.ts @@ -5,10 +5,10 @@ import * as pgPromise from 'pg-promise'; import { pgInsert, pgQuery, pgUpdate } from '../Postgres/Postgres.node.functions'; -export class QuestDB implements INodeType { +export class QuestDb implements INodeType { description: INodeTypeDescription = { displayName: 'QuestDB', - name: 'questdb', + name: 'questDb', icon: 'file:questdb.png', group: ['input'], version: 1, @@ -21,7 +21,7 @@ export class QuestDB implements INodeType { outputs: ['main'], credentials: [ { - name: 'questdb', + name: 'questDb', required: true, }, ], @@ -176,7 +176,7 @@ export class QuestDB implements INodeType { }; async execute(this: IExecuteFunctions): Promise { - const credentials = this.getCredentials('questdb'); + const credentials = this.getCredentials('questDb'); if (credentials === undefined) { throw new Error('No credentials got returned!'); diff --git a/packages/nodes-base/nodes/QuestDB/questdb.png b/packages/nodes-base/nodes/QuestDb/questdb.png similarity index 100% rename from packages/nodes-base/nodes/QuestDB/questdb.png rename to packages/nodes-base/nodes/QuestDb/questdb.png diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 0de5410ea0..94716b7f4b 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -112,7 +112,7 @@ "dist/credentials/PipedriveApi.credentials.js", "dist/credentials/Postgres.credentials.js", "dist/credentials/PostmarkApi.credentials.js", - "dist/credentials/Questdb.credentials.js", + "dist/credentials/QuestDb.credentials.js", "dist/credentials/Redis.credentials.js", "dist/credentials/RocketchatApi.credentials.js", "dist/credentials/RundeckApi.credentials.js", @@ -261,7 +261,7 @@ "dist/nodes/Pipedrive/PipedriveTrigger.node.js", "dist/nodes/Postgres/Postgres.node.js", "dist/nodes/Postmark/PostmarkTrigger.node.js", - "dist/nodes/QuestDB/QuestDB.node.js", + "dist/nodes/QuestDb/QuestDb.node.js", "dist/nodes/ReadBinaryFile.node.js", "dist/nodes/ReadBinaryFiles.node.js", "dist/nodes/ReadPdf.node.js", From f33eebcec9d23cf8518e90fc4fc8bfec347ed8ab Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Fri, 10 Jul 2020 12:30:40 +0200 Subject: [PATCH 125/155] :zap: Remove empty description properties --- .../nodes/Mailchimp/Mailchimp.node.ts | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts b/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts index 443fd5d20c..ff5ee63645 100644 --- a/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts +++ b/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts @@ -283,27 +283,22 @@ export class Mailchimp implements INodeType { { name: 'Subscribed', value: 'subscribed', - description: '', }, { name: 'Unsubscribed', value: 'unsubscribed', - description: '', }, { name: 'Cleaned', value: 'cleaned', - description: '', }, { name: 'Pending', value: 'pending', - description: '', }, { name: 'Transactional', value: 'transactional', - description: '', }, ], default: '', @@ -314,7 +309,6 @@ export class Mailchimp implements INodeType { name: 'jsonParameters', type: 'boolean', default: false, - description: '', displayOptions: { show: { resource:[ @@ -351,12 +345,10 @@ export class Mailchimp implements INodeType { { name: 'HTML', value: 'html', - description: '', }, { name: 'Text', value: 'text', - description: '', }, ], default: '', @@ -523,7 +515,6 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', displayOptions: { show: { resource:[ @@ -546,7 +537,6 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', displayOptions: { show: { resource:[ @@ -608,14 +598,12 @@ export class Mailchimp implements INodeType { name: 'categoryFieldId', type: 'string', default: '', - description: '', }, { displayName: 'Value', name: 'value', type: 'boolean', default: false, - description: '', }, ], }, @@ -629,7 +617,6 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', displayOptions: { show: { resource:[ @@ -882,12 +869,10 @@ export class Mailchimp implements INodeType { { name: 'HTML', value: 'html', - description: '', }, { name: 'Text', value: 'text', - description: '', }, ], default: '', @@ -901,27 +886,22 @@ export class Mailchimp implements INodeType { { name: 'Subscribed', value: 'subscribed', - description: '', }, { name: 'Unsubscribed', value: 'unsubscribed', - description: '', }, { name: 'Cleaned', value: 'cleaned', - description: '', }, { name: 'Pending', value: 'pending', - description: '', }, { name: 'Transactional', value: 'transactional', - description: '', }, ], default: '', @@ -984,7 +964,6 @@ export class Mailchimp implements INodeType { name: 'jsonParameters', type: 'boolean', default: false, - description: '', displayOptions: { show: { resource:[ @@ -1021,12 +1000,10 @@ export class Mailchimp implements INodeType { { name: 'HTML', value: 'html', - description: '', }, { name: 'Text', value: 'text', - description: '', }, ], default: '', @@ -1079,14 +1056,12 @@ export class Mailchimp implements INodeType { name: 'categoryFieldId', type: 'string', default: '', - description: '', }, { displayName: 'Value', name: 'value', type: 'boolean', default: false, - description: '', }, ], }, @@ -1194,27 +1169,22 @@ export class Mailchimp implements INodeType { { name: 'Subscribed', value: 'subscribed', - description: '', }, { name: 'Unsubscribed', value: 'unsubscribed', - description: '', }, { name: 'Cleaned', value: 'cleaned', - description: '', }, { name: 'Pending', value: 'pending', - description: '', }, { name: 'Transactional', value: 'transactional', - description: '', }, ], default: '', @@ -1289,7 +1259,6 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', displayOptions: { show: { resource:[ @@ -1312,7 +1281,6 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', displayOptions: { show: { resource:[ @@ -1335,7 +1303,6 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', displayOptions: { show: { resource:[ From bcabdc457bbd31a2dacbef9770b657839b47f43a Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 12 Jul 2020 09:28:08 +0200 Subject: [PATCH 126/155] :bug: Fix naming of events in AffinityTrigger Node --- packages/cli/config/index.ts | 7 +++++++ packages/cli/src/Server.ts | 14 +++++++++++++- packages/editor-ui/public/index.html | 3 ++- packages/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 +++--- 8 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 847587460f..380a93c4a8 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/Server.ts b/packages/cli/src/Server.ts index 2507b6b136..5cfacd8449 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1693,9 +1693,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 2f2450023d..9193fda976 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 71388c2cd3..c5462690cc 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 e82b30b588..4754098c97 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 80454fc9e4..0e1e5f14c4 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 cdcd8259f9..c5ffc5fed8 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 6b57d46804..3d387e7b4d 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 941ee06b146d0c72dc8de56288dd45d6abea945a Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 12 Jul 2020 09:28:08 +0200 Subject: [PATCH 127/155] :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 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 847587460f..380a93c4a8 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 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 2507b6b136..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); @@ -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 2f2450023d..9193fda976 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 71388c2cd3..a9cf787ef4 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 e82b30b588..4754098c97 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 80454fc9e4..0e1e5f14c4 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 cdcd8259f9..c5ffc5fed8 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 6b57d46804..3d387e7b4d 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 128/155] :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 2390617466..9492b4cb63 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 9b55f88b58..4230e79f58 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 129/155] :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 65afe2b83c..b77d5d35bd 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 130/155] :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 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/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..7703bf0c88 --- /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 131/155] :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 7703bf0c88..31a1df36e8 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 159fdd8cd0..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", @@ -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 132/155] :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 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/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/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 From e558a48c82694838fca37d48454dc041dba23131 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Mon, 13 Jul 2020 17:28:56 +0200 Subject: [PATCH 133/155] :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 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)]; From 93c37f3844e4747ce6969b35bf5a4df49a22c0e0 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 14 Jul 2020 12:39:42 +0200 Subject: [PATCH 134/155] :zap: Improve parameter naming on Msg91-Node --- packages/nodes-base/nodes/Msg91/Msg91.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Msg91/Msg91.node.ts b/packages/nodes-base/nodes/Msg91/Msg91.node.ts index 240a3623e0..8fb0444de5 100644 --- a/packages/nodes-base/nodes/Msg91/Msg91.node.ts +++ b/packages/nodes-base/nodes/Msg91/Msg91.node.ts @@ -68,7 +68,7 @@ export class Msg91 implements INodeType { description: 'The operation to perform.', }, { - displayName: 'From', + displayName: 'Sender ID', name: 'from', type: 'string', default: '', From df3077b5d2f594646bfe89c7e2d782e65ae37c60 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 14 Jul 2020 13:59:37 +0200 Subject: [PATCH 135/155] :shirt: Fix lint issue --- package.json | 1 + packages/cli/src/Server.ts | 14 +++++++------- .../nodes/Google/Task/GenericFunctions.ts | 8 ++++---- .../nodes/Microsoft/Sql/GenericFunctions.ts | 10 +++++----- .../nodes/Microsoft/Sql/TableInterface.ts | 2 +- .../nodes/Postgres/Postgres.node.functions.ts | 6 +++--- packages/nodes-base/nodes/Zoom/GenericFunctions.ts | 5 ++--- packages/nodes-base/nodes/utils/utilities.ts | 8 ++++---- 8 files changed, 27 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 0c7d8dac15..30fcfb7959 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start:default": "cd packages/cli/bin && ./n8n", "start:windows": "cd packages/cli/bin && n8n", "test": "lerna run test", + "tslint": "lerna exec npm run tslint", "watch": "lerna run --parallel watch" }, "devDependencies": { diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index e4093e5a15..ee248e4d14 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -18,7 +18,7 @@ import * as clientOAuth2 from 'client-oauth2'; import * as clientOAuth1 from 'oauth-1.0a'; import { RequestOptions } from 'oauth-1.0a'; import * as csrf from 'csrf'; -import * as requestPromise from 'request-promise-native'; +import * as requestPromise from 'request-promise-native'; import { createHmac } from 'crypto'; import { @@ -120,7 +120,7 @@ class App { restEndpoint: string; protocol: string; - sslKey: string; + sslKey: string; sslCert: string; presetCredentialsLoaded: boolean; @@ -143,7 +143,7 @@ class App { this.activeExecutionsInstance = ActiveExecutions.getInstance(); this.protocol = config.get('protocol'); - this.sslKey = config.get('ssl_key'); + this.sslKey = config.get('ssl_key'); this.sslCert = config.get('ssl_cert'); this.externalHooks = ExternalHooks(); @@ -204,7 +204,7 @@ class App { } // Check for and validate JWT if configured - const jwtAuthActive = config.get('security.jwtAuth.active') as boolean; + const jwtAuthActive = config.get('security.jwtAuth.active') as boolean; if (jwtAuthActive === true) { const jwtAuthHeader = await GenericHelpers.getConfigValue('security.jwtAuth.jwtHeader') as string; if (jwtAuthHeader === '') { @@ -282,7 +282,7 @@ class App { normalize: true, // Trim whitespace inside text nodes normalizeTags: true, // Transform tags to lowercase explicitArray: false, // Only put properties in array if length > 1 - } })); + } })); this.app.use(bodyParser.text({ limit: '16mb', verify: (req, res, buf) => { @@ -981,7 +981,7 @@ class App { const callback = `${WebhookHelpers.getWebhookBaseUrl()}${this.restEndpoint}/oauth1-credential/callback?cid=${req.query.id}`; - const options: RequestOptions = { + const options: RequestOptions = { method: 'POST', url: (_.get(oauthCredentials, 'requestTokenUrl') as string), data: { @@ -1049,7 +1049,7 @@ class App { const decryptedDataOriginal = credentialsHelper.getDecrypted(result.name, result.type, true); const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(decryptedDataOriginal, result.type); - const options: OptionsWithUrl = { + const options: OptionsWithUrl = { method: 'POST', url: _.get(oauthCredentials, 'accessTokenUrl') as string, qs: { diff --git a/packages/nodes-base/nodes/Google/Task/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Task/GenericFunctions.ts index c3a5031511..55a690ad73 100644 --- a/packages/nodes-base/nodes/Google/Task/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Google/Task/GenericFunctions.ts @@ -16,11 +16,11 @@ export async function googleApiRequest( this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, - body: any = {}, + body: IDataObject = {}, qs: IDataObject = {}, uri?: string, headers: IDataObject = {} -): Promise { +): Promise { // tslint:disable-line:no-any const options: OptionsWithUri = { headers: { 'Content-Type': 'application/json' @@ -65,9 +65,9 @@ export async function googleApiRequestAllItems( propertyName: string, method: string, endpoint: string, - body: any = {}, + body: IDataObject = {}, query: IDataObject = {} -): Promise { +): Promise { // tslint:disable-line:no-any const returnData: IDataObject[] = []; let responseData; diff --git a/packages/nodes-base/nodes/Microsoft/Sql/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/Sql/GenericFunctions.ts index 8309b35db0..601d3b5393 100644 --- a/packages/nodes-base/nodes/Microsoft/Sql/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/Sql/GenericFunctions.ts @@ -14,7 +14,7 @@ export function copyInputItem( properties: string[], ): IDataObject { // Prepare the data to insert and copy it to be returned - let newItem: IDataObject = {}; + const newItem: IDataObject = {}; for (const property of properties) { if (item.json[property] === undefined) { newItem[property] = null; @@ -70,14 +70,14 @@ export function createTableStruct( export function executeQueryQueue( tables: ITables, buildQueryQueue: Function, -): Promise { +): Promise { // tslint:disable-line:no-any return Promise.all( Object.keys(tables).map(table => { const columnsResults = Object.keys(tables[table]).map(columnString => { return Promise.all( buildQueryQueue({ - table: table, - columnString: columnString, + table, + columnString, items: tables[table][columnString], }), ); @@ -94,7 +94,7 @@ export function executeQueryQueue( * @returns {string} (Val1, Val2, ...) */ export function extractValues(item: IDataObject): string { - return `(${Object.values(item as any) + return `(${Object.values(item as any) // tslint:disable-line:no-any .map(val => (typeof val === 'string' ? `'${val}'` : val)) // maybe other types such as dates have to be handled as well .join(',')})`; } diff --git a/packages/nodes-base/nodes/Microsoft/Sql/TableInterface.ts b/packages/nodes-base/nodes/Microsoft/Sql/TableInterface.ts index a0343e6e0b..c260c36ab3 100644 --- a/packages/nodes-base/nodes/Microsoft/Sql/TableInterface.ts +++ b/packages/nodes-base/nodes/Microsoft/Sql/TableInterface.ts @@ -2,6 +2,6 @@ import { IDataObject } from 'n8n-workflow'; export interface ITables { [key: string]: { - [key: string]: Array; + [key: string]: IDataObject[]; }; } diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts index fb4c50051c..4dae3a64c1 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts @@ -40,7 +40,7 @@ export function pgQuery( pgp: pgPromise.IMain<{}, pg.IClient>, db: pgPromise.IDatabase<{}, pg.IClient>, input: INodeExecutionData[], -): Promise> { +): Promise { const queries: string[] = []; for (let i = 0; i < input.length; i++) { queries.push(getNodeParam('query', i) as string); @@ -63,7 +63,7 @@ export async function pgInsert( pgp: pgPromise.IMain<{}, pg.IClient>, db: pgPromise.IDatabase<{}, pg.IClient>, items: INodeExecutionData[], -): Promise> { +): Promise { const table = getNodeParam('table', 0) as string; const schema = getNodeParam('schema', 0) as string; let returnFields = (getNodeParam('returnFields', 0) as string).split(',') as string[]; @@ -103,7 +103,7 @@ export async function pgUpdate( pgp: pgPromise.IMain<{}, pg.IClient>, db: pgPromise.IDatabase<{}, pg.IClient>, items: INodeExecutionData[], -): Promise> { +): Promise { const table = getNodeParam('table', 0) as string; const updateKey = getNodeParam('updateKey', 0) as string; const columnString = getNodeParam('columns', 0) as string; diff --git a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts index 95e07f09b9..3d036f5f39 100644 --- a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts @@ -70,10 +70,9 @@ export async function zoomApiRequestAllItems( propertyName: string, method: string, endpoint: string, - body: any = {}, + body: IDataObject = {}, query: IDataObject = {} -): Promise { - // tslint:disable-line:no-any +): Promise { // tslint:disable-line:no-any const returnData: IDataObject[] = []; let responseData; query.page_number = 0; diff --git a/packages/nodes-base/nodes/utils/utilities.ts b/packages/nodes-base/nodes/utils/utilities.ts index 73105a901c..0ba469dd10 100644 --- a/packages/nodes-base/nodes/utils/utilities.ts +++ b/packages/nodes-base/nodes/utils/utilities.ts @@ -14,7 +14,7 @@ * chunk(['a', 'b', 'c', 'd'], 3) * // => [['a', 'b', 'c'], ['d']] */ -export function chunk(array: any[], size: number = 1) { +export function chunk(array: any[], size = 1) { // tslint:disable-line:no-any const length = array == null ? 0 : array.length; if (!length || size < 1) { return []; @@ -40,11 +40,11 @@ export function chunk(array: any[], size: number = 1) { * // => ['a', 'b', 'c', 'd'] * */ -export function flatten(nestedArray: any[][]) { +export function flatten(nestedArray: any[][]) { // tslint:disable-line:no-any const result = []; - (function loop(array: any[]) { - for (var i = 0; i < array.length; i++) { + (function loop(array: any[]) { // tslint:disable-line:no-any + for (let i = 0; i < array.length; i++) { if (Array.isArray(array[i])) { loop(array[i]); } else { From 25cc7458949e995d23b43d20820a90225790862d Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 14 Jul 2020 23:36:05 +0200 Subject: [PATCH 136/155] :bug: Fix editor-ui build --- packages/cli/src/Server.ts | 4 +++- packages/editor-ui/package.json | 2 +- packages/editor-ui/public/index.html | 4 ++-- packages/editor-ui/src/components/MainSidebar.vue | 2 +- packages/editor-ui/src/router.ts | 2 +- packages/editor-ui/src/store.ts | 2 +- packages/editor-ui/vue.config.js | 2 +- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index ee248e4d14..09ef7e18a6 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1711,9 +1711,11 @@ 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'); + + let readIndexFile = readFileSync(filePath, 'utf8'); readIndexFile = readIndexFile.replace(/\/%BASE_PATH%\//g, n8nPath); + readIndexFile = readIndexFile.replace(/\/favicon.ico/g, `${n8nPath}/favicon.ico`); // Serve the altered index.html file separately this.app.get(`/index.html`, async (req: express.Request, res: express.Response) => { diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 552fe79330..8a6dfcfb38 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -14,7 +14,7 @@ "url": "git+https://github.com/n8n-io/n8n.git" }, "scripts": { - "build": "vue-cli-service build", + "build": "cross-env VUE_APP_PUBLIC_PATH=\"/%BASE_PATH%/\" vue-cli-service build", "dev": "npm run serve", "lint": "vue-cli-service lint", "serve": "cross-env VUE_APP_URL_BASE_API=http://localhost:5678/ vue-cli-service serve", diff --git a/packages/editor-ui/public/index.html b/packages/editor-ui/public/index.html index 9193fda976..b533aea170 100644 --- a/packages/editor-ui/public/index.html +++ b/packages/editor-ui/public/index.html @@ -4,13 +4,13 @@ - + n8n.io - Workflow Automation
diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index a9cf787ef4..baea830b01 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -209,7 +209,7 @@ export default mixins( return { aboutDialogVisible: false, // @ts-ignore - basePath: window.BASE_PATH, + basePath: this.$store.getters.getBaseUrl, isCollapsed: true, credentialNewDialogVisible: false, credentialOpenDialogVisible: false, diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 4754098c97..348a1ea66b 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -9,7 +9,7 @@ Vue.use(Router); export default new Router({ mode: 'history', // @ts-ignore - base: window.BASE_PATH, + base: window.BASE_PATH === '/%BASE_PATH%/' ? '/' : window.BASE_PATH, routes: [ { path: '/execution/:id', diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index 0e1e5f14c4..e0ba93b7cf 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -39,7 +39,7 @@ export const store = new Vuex.Store({ activeActions: [] as string[], activeNode: null as string | null, // @ts-ignore - baseUrl: window.BASE_PATH ? window.BASE_PATH : '/', + baseUrl: process.env.VUE_APP_URL_BASE_API ? process.env.VUE_APP_URL_BASE_API : (window.BASE_PATH === '/%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 c5ffc5fed8..cdcd8259f9 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 : '/%BASE_PATH%/', + publicPath: process.env.VUE_APP_PUBLIC_PATH ? process.env.VUE_APP_PUBLIC_PATH : '/', }; From cf1e4468bb0e1fe96af12eb831c5e32ae5dfec4f Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Wed, 15 Jul 2020 03:52:28 -0400 Subject: [PATCH 137/155] :bug: fixes bug with latin characters (#758) --- .../nodes-base/nodes/Dropbox/Dropbox.node.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Dropbox/Dropbox.node.ts b/packages/nodes-base/nodes/Dropbox/Dropbox.node.ts index 6140568bc2..20c8248c56 100644 --- a/packages/nodes-base/nodes/Dropbox/Dropbox.node.ts +++ b/packages/nodes-base/nodes/Dropbox/Dropbox.node.ts @@ -2,6 +2,7 @@ import { BINARY_ENCODING, IExecuteFunctions, } from 'n8n-core'; + import { IDataObject, INodeTypeDescription, @@ -9,8 +10,9 @@ import { INodeType, } from 'n8n-workflow'; -import { OptionsWithUri } from 'request'; - +import { + OptionsWithUri +} from 'request'; export class Dropbox implements INodeType { description: INodeTypeDescription = { @@ -23,7 +25,7 @@ export class Dropbox implements INodeType { description: 'Access data on Dropbox', defaults: { name: 'Dropbox', - color: '#22BB44', + color: '#0061FF', }, inputs: ['main'], outputs: ['main'], @@ -454,6 +456,7 @@ export class Dropbox implements INodeType { let requestMethod = ''; let body: IDataObject | Buffer; let isJson = false; + let query: IDataObject = {}; let headers: IDataObject; @@ -470,8 +473,9 @@ export class Dropbox implements INodeType { // ---------------------------------- requestMethod = 'POST'; - headers['Dropbox-API-Arg'] = JSON.stringify({ - path: this.getNodeParameter('path', i) as string, + + query.arg = JSON.stringify({ + path: this.getNodeParameter('path', i) as string }); endpoint = 'https://content.dropboxapi.com/2/files/download'; @@ -483,9 +487,10 @@ export class Dropbox implements INodeType { requestMethod = 'POST'; headers['Content-Type'] = 'application/octet-stream'; - headers['Dropbox-API-Arg'] = JSON.stringify({ + + query.arg = JSON.stringify({ mode: 'overwrite', - path: this.getNodeParameter('path', i) as string, + path: this.getNodeParameter('path', i) as string }); endpoint = 'https://content.dropboxapi.com/2/files/upload'; @@ -594,8 +599,8 @@ export class Dropbox implements INodeType { const options: OptionsWithUri = { headers, method: requestMethod, - qs: {}, uri: endpoint, + qs: query, json: isJson, }; From c1378c2f8833e07809084cc98cf9a8ab3ae21c81 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 15 Jul 2020 11:54:03 +0200 Subject: [PATCH 138/155] :bug: Fix Expression editor issue #719 --- packages/editor-ui/src/components/ExpressionInput.vue | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/editor-ui/src/components/ExpressionInput.vue b/packages/editor-ui/src/components/ExpressionInput.vue index 543eb12f22..d0c734e2c6 100644 --- a/packages/editor-ui/src/components/ExpressionInput.vue +++ b/packages/editor-ui/src/components/ExpressionInput.vue @@ -122,6 +122,13 @@ export default mixins( readOnly: !!this.resolvedValue, modules: { autoformat: {}, + keyboard: { + bindings: { + 'list autofill': { + prefix: /^$/, + }, + }, + }, }, }); From 95062205db659ae6960ddf250449400cceb3a8e6 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 15 Jul 2020 12:34:14 +0200 Subject: [PATCH 139/155] :bookmark: Release n8n-workflow@0.35.0 --- packages/workflow/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/package.json b/packages/workflow/package.json index b65cf8cb6e..aed6e160ba 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "0.34.0", + "version": "0.35.0", "description": "Workflow base code of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 3872c7cba9b2893f1187f89389431cb779bfc8cc Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 15 Jul 2020 12:35:42 +0200 Subject: [PATCH 140/155] :arrow_up: Set n8n-workflow@0.35.0 on n8n-core --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index fe195d2e36..ad54c6baec 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,7 +46,7 @@ "file-type": "^14.6.2", "lodash.get": "^4.4.2", "mime-types": "^2.1.27", - "n8n-workflow": "~0.34.0", + "n8n-workflow": "~0.35.0", "p-cancelable": "^2.0.0", "request": "^2.88.2", "request-promise-native": "^1.0.7" From d24d215b437a342b9c505ad7d1415d6f5b58f772 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 15 Jul 2020 12:36:07 +0200 Subject: [PATCH 141/155] :bookmark: Release n8n-core@0.39.0 --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index ad54c6baec..4fb55a9d8d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.38.0", + "version": "0.39.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 6b86a2bd48f117862410935619eabb82756f4c67 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 15 Jul 2020 12:37:30 +0200 Subject: [PATCH 142/155] :arrow_up: Set n8n-core@0.39.0 and n8n-workflow@0.35.0 on n8n-nodes-base --- packages/nodes-base/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 17ab0f0ade..e166af04be 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -338,7 +338,7 @@ "@types/xml2js": "^0.4.3", "gulp": "^4.0.0", "jest": "^24.9.0", - "n8n-workflow": "~0.34.0", + "n8n-workflow": "~0.35.0", "ts-jest": "^24.0.2", "tslint": "^5.17.0", "typescript": "~3.7.4" @@ -364,7 +364,7 @@ "mongodb": "^3.5.5", "mssql": "^6.2.0", "mysql2": "^2.0.1", - "n8n-core": "~0.38.0", + "n8n-core": "~0.39.0", "nodemailer": "^6.4.6", "pdf-parse": "^1.1.1", "pg-promise": "^9.0.3", From aa9461edbea09b6bb37bfff7ee73317b4466cc1a Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 15 Jul 2020 12:38:03 +0200 Subject: [PATCH 143/155] :bookmark: Release n8n-nodes-base@0.69.0 --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e166af04be..df18bb8fba 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.68.1", + "version": "0.69.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From bbd39e19caec3fd3933dd0c55da528f0b80d8962 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 15 Jul 2020 12:39:06 +0200 Subject: [PATCH 144/155] :arrow_up: Set n8n-workflow@0.35.0 on n8n-editor-ui --- packages/editor-ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 8a6dfcfb38..eda1b6ff6f 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -66,7 +66,7 @@ "lodash.debounce": "^4.0.8", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", - "n8n-workflow": "~0.34.0", + "n8n-workflow": "~0.35.0", "node-sass": "^4.12.0", "prismjs": "^1.17.1", "quill": "^2.0.0-dev.3", From 0b66b8e753009bad72c77b4936e5d64746886b8d Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 15 Jul 2020 12:39:32 +0200 Subject: [PATCH 145/155] :bookmark: Release n8n-editor-ui@0.50.0 --- packages/editor-ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index eda1b6ff6f..4bcc6b8579 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.49.0", + "version": "0.50.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From f8e3e42b46dd96143fd50e809cc0ada18bb07a57 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 15 Jul 2020 12:41:15 +0200 Subject: [PATCH 146/155] :arrow_up: Set n8n-core@0.39.0, n8n-editor-ui@0.50.0, n8n-nodes-base@0.69.0 and n8n-workflow@0.35.0 on n8n --- packages/cli/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index e9b16ad9e2..549c118e23 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -100,10 +100,10 @@ "lodash.get": "^4.4.2", "mongodb": "^3.5.5", "mysql2": "^2.0.1", - "n8n-core": "~0.38.0", - "n8n-editor-ui": "~0.49.0", - "n8n-nodes-base": "~0.68.1", - "n8n-workflow": "~0.34.0", + "n8n-core": "~0.39.0", + "n8n-editor-ui": "~0.50.0", + "n8n-nodes-base": "~0.69.0", + "n8n-workflow": "~0.35.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", "pg": "^7.11.0", From b946d49beac09743d429b1b1f78716a77bda90d5 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 15 Jul 2020 12:41:40 +0200 Subject: [PATCH 147/155] :bookmark: Release n8n@0.74.0 --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 549c118e23..4749a2ed0d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.73.1", + "version": "0.74.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 479606753b9bbbb26468a832858fa556ef573905 Mon Sep 17 00:00:00 2001 From: Tanay Pant <7481165+tanay1337@users.noreply.github.com> Date: Fri, 17 Jul 2020 16:10:31 +0200 Subject: [PATCH 148/155] :sparkles: Add CrateDB node (#768) --- .../credentials/CrateDb.credentials.ts | 69 +++++ .../nodes-base/nodes/CrateDb/CrateDb.node.ts | 246 ++++++++++++++++++ packages/nodes-base/nodes/CrateDb/cratedb.png | Bin 0 -> 475 bytes packages/nodes-base/package.json | 2 + 4 files changed, 317 insertions(+) create mode 100644 packages/nodes-base/credentials/CrateDb.credentials.ts create mode 100644 packages/nodes-base/nodes/CrateDb/CrateDb.node.ts create mode 100644 packages/nodes-base/nodes/CrateDb/cratedb.png diff --git a/packages/nodes-base/credentials/CrateDb.credentials.ts b/packages/nodes-base/credentials/CrateDb.credentials.ts new file mode 100644 index 0000000000..a5e0fb776c --- /dev/null +++ b/packages/nodes-base/credentials/CrateDb.credentials.ts @@ -0,0 +1,69 @@ +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; + +export class CrateDb implements ICredentialType { + name = 'crateDb'; + displayName = 'CrateDB'; + properties = [ + { + displayName: 'Host', + name: 'host', + type: 'string' as NodePropertyTypes, + default: 'localhost', + }, + { + displayName: 'Database', + name: 'database', + type: 'string' as NodePropertyTypes, + default: 'doc', + }, + { + displayName: 'User', + name: 'user', + type: 'string' as NodePropertyTypes, + default: 'crate', + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true, + }, + default: '', + }, + { + displayName: 'SSL', + name: 'ssl', + type: 'options' as NodePropertyTypes, + options: [ + { + name: 'disable', + value: 'disable', + }, + { + name: 'allow', + value: 'allow', + }, + { + name: 'require', + value: 'require', + }, + { + name: 'verify (not implemented)', + value: 'verify', + }, + { + name: 'verify-full (not implemented)', + value: 'verify-full', + }, + ], + default: 'disable', + }, + { + displayName: 'Port', + name: 'port', + type: 'number' as NodePropertyTypes, + default: 5432, + }, + ]; +} diff --git a/packages/nodes-base/nodes/CrateDb/CrateDb.node.ts b/packages/nodes-base/nodes/CrateDb/CrateDb.node.ts new file mode 100644 index 0000000000..1d7ec41dbb --- /dev/null +++ b/packages/nodes-base/nodes/CrateDb/CrateDb.node.ts @@ -0,0 +1,246 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { IDataObject, INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow'; + +import * as pgPromise from 'pg-promise'; + +import { pgInsert, pgQuery, pgUpdate } from '../Postgres/Postgres.node.functions'; + +export class CrateDb implements INodeType { + description: INodeTypeDescription = { + displayName: 'CrateDB', + name: 'crateDb', + icon: 'file:cratedb.png', + group: ['input'], + version: 1, + description: 'Gets, add and update data in CrateDB.', + defaults: { + name: 'CrateDB', + color: '#47889f', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'crateDb', + required: true, + }, + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Execute Query', + value: 'executeQuery', + description: 'Executes a SQL query.', + }, + { + name: 'Insert', + value: 'insert', + description: 'Insert rows in database.', + }, + { + name: 'Update', + value: 'update', + description: 'Updates rows in database.', + }, + ], + default: 'insert', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // executeQuery + // ---------------------------------- + { + displayName: 'Query', + name: 'query', + type: 'string', + typeOptions: { + rows: 5, + }, + displayOptions: { + show: { + operation: ['executeQuery'], + }, + }, + default: '', + placeholder: 'SELECT id, name FROM product WHERE id < 40', + required: true, + description: 'The SQL query to execute.', + }, + + // ---------------------------------- + // insert + // ---------------------------------- + { + displayName: 'Schema', + name: 'schema', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: 'public', + required: true, + description: 'Name of the schema the table belongs to', + }, + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to insert data to.', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '', + placeholder: 'id,name,description', + description: + 'Comma separated list of the properties which should used as columns for the new rows.', + }, + { + displayName: 'Return Fields', + name: 'returnFields', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '*', + description: 'Comma separated list of the fields that the operation will return', + }, + + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to update data in', + }, + { + displayName: 'Update Key', + name: 'updateKey', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: 'id', + required: true, + description: + 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: '', + placeholder: 'name,description', + description: + 'Comma separated list of the properties which should used as columns for rows to update.', + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const credentials = this.getCredentials('crateDb'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const pgp = pgPromise(); + + const config = { + host: credentials.host as string, + port: credentials.port as number, + database: credentials.database as string, + user: credentials.user as string, + password: credentials.password as string, + ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), + sslmode: (credentials.ssl as string) || 'disable', + }; + + const db = pgp(config); + + let returnItems = []; + + const items = this.getInputData(); + const operation = this.getNodeParameter('operation', 0) as string; + + if (operation === 'executeQuery') { + // ---------------------------------- + // executeQuery + // ---------------------------------- + + const queryResult = await pgQuery(this.getNodeParameter, pgp, db, items); + + returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); + } else if (operation === 'insert') { + // ---------------------------------- + // insert + // ---------------------------------- + + const [insertData, insertItems] = await pgInsert(this.getNodeParameter, pgp, db, items); + + // Add the id to the data + for (let i = 0; i < insertData.length; i++) { + returnItems.push({ + json: { + ...insertData[i], + ...insertItems[i], + }, + }); + } + } else if (operation === 'update') { + // ---------------------------------- + // update + // ---------------------------------- + + const updateItems = await pgUpdate(this.getNodeParameter, pgp, db, items); + + returnItems = this.helpers.returnJsonArray(updateItems); + } else { + await pgp.end(); + throw new Error(`The operation "${operation}" is not supported!`); + } + + // Close the connection + await pgp.end(); + + return this.prepareOutputData(returnItems); + } +} diff --git a/packages/nodes-base/nodes/CrateDb/cratedb.png b/packages/nodes-base/nodes/CrateDb/cratedb.png new file mode 100644 index 0000000000000000000000000000000000000000..d2e90eade715e7a78024e942a0107ccefd9fdc76 GIT binary patch literal 475 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw1|+Ti+$;i8oCO|{#S9GG!XV7ZFl&wk0|R5P zr;B4qM&sLC8~vCZ1sWboe!F($!KN^=xJ5TI9!f`iVly#wQ=Vofa_i;~Cb4}DQoTzR zuc=Mh*VNgYapcgc#|CcwskN+S*EZ}pb3MQ9|B2WgZ*5O7|4scPxIz3Nx@ zuw}d3cW}7=n`par-MKEC(;AzFPbtP1ty>gcKAZjNmRYwR7Cb4A&^nmD{c`Qz-FttA zA7o&-CXsxWA*Sizv;$JfHP6_%`C6Nkk00Fl_`!|Bf;ScxWsXg?_uFgFslWd1e%$V` z=8{zzE4SRv;$TS7^;r5fYH?-66WdGMT4wX#?+gCl#QOT9>S6(h*r&qTY)=j@{1Ky5 zmvOWY$-rg$60BP-?=U@JUio@qh-mEE_a+JI5m+{>1`^(l@-XT=lX#qxB!d<6}t9m3hO| zDxe|6P!cWYwAY=vf<^pq+_#FtDyP5A85iaMXPuBp?(v-w&(zQqD7_^=9vE8;p00i_ I>zopr0Cm^hivR!s literal 0 HcmV?d00001 diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index df18bb8fba..d5e1796141 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -46,6 +46,7 @@ "dist/credentials/CodaApi.credentials.js", "dist/credentials/CopperApi.credentials.js", "dist/credentials/CalendlyApi.credentials.js", + "dist/credentials/CrateDb.credentials.js", "dist/credentials/DisqusApi.credentials.js", "dist/credentials/DriftApi.credentials.js", "dist/credentials/DriftOAuth2Api.credentials.js", @@ -182,6 +183,7 @@ "dist/nodes/Cockpit/Cockpit.node.js", "dist/nodes/Coda/Coda.node.js", "dist/nodes/Copper/CopperTrigger.node.js", + "dist/nodes/CrateDb/CrateDb.node.js", "dist/nodes/Cron.node.js", "dist/nodes/Crypto.node.js", "dist/nodes/DateTime.node.js", From ebe2775701f9803165e14f28e11ad0610dc155a1 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com> Date: Fri, 17 Jul 2020 17:08:40 +0200 Subject: [PATCH 149/155] :zap: Add index stopped at (#766) * :construction: add index on sqlite * :construction: add migration for postgres * :construction: add mysql migration * :construction: add mongodb migration * :rewind: revert change of default postgresdb user --- packages/cli/config/index.ts | 4 +++ packages/cli/migrations/ormconfig.ts | 10 +++---- packages/cli/src/Db.ts | 28 ++++++++++++++++--- .../src/databases/mongodb/ExecutionEntity.ts | 1 + .../151594910478695-CreateIndexStoppedAt.ts | 22 +++++++++++++++ .../src/databases/mongodb/migrations/index.ts | 1 + .../src/databases/mysqldb/ExecutionEntity.ts | 1 + .../1594902918301-CreateIndexStoppedAt.ts | 20 +++++++++++++ .../src/databases/mysqldb/migrations/index.ts | 1 + .../databases/postgresdb/ExecutionEntity.ts | 1 + .../1594828256133-CreateIndexStoppedAt.ts | 25 +++++++++++++++++ .../databases/postgresdb/migrations/index.ts | 1 + .../src/databases/sqlite/ExecutionEntity.ts | 1 + .../1594825041918-CreateIndexStoppedAt.ts | 20 +++++++++++++ .../src/databases/sqlite/migrations/index.ts | 1 + 15 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/databases/mongodb/migrations/151594910478695-CreateIndexStoppedAt.ts create mode 100644 packages/cli/src/databases/mysqldb/migrations/1594902918301-CreateIndexStoppedAt.ts create mode 100644 packages/cli/src/databases/postgresdb/migrations/1594828256133-CreateIndexStoppedAt.ts create mode 100644 packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 9492b4cb63..2759c6d794 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -185,6 +185,7 @@ const config = convict({ // in the editor. saveDataManualExecutions: { doc: 'Save data of executions when started manually via editor', + format: 'Boolean', default: false, env: 'EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS' }, @@ -196,16 +197,19 @@ const config = convict({ // a future version. pruneData: { doc: 'Delete data of past executions on a rolling basis', + format: 'Boolean', default: false, env: 'EXECUTIONS_DATA_PRUNE' }, pruneDataMaxAge: { doc: 'How old (hours) the execution data has to be to get deleted', + format: Number, default: 336, env: 'EXECUTIONS_DATA_MAX_AGE' }, pruneDataTimeout: { doc: 'Timeout (seconds) after execution data has been pruned', + format: Number, default: 3600, env: 'EXECUTIONS_DATA_PRUNE_TIMEOUT' }, diff --git a/packages/cli/migrations/ormconfig.ts b/packages/cli/migrations/ormconfig.ts index 2a0cda0d9c..1ea583beb4 100644 --- a/packages/cli/migrations/ormconfig.ts +++ b/packages/cli/migrations/ormconfig.ts @@ -44,9 +44,9 @@ module.exports = [ "logging": false, "host": "localhost", "username": "postgres", - "password": "docker", + "password": "", "port": 5432, - "database": "postgres", + "database": "n8n", "schema": "public", "entities": Object.values(PostgresDb), "migrations": [ @@ -68,7 +68,7 @@ module.exports = [ "username": "root", "password": "password", "host": "localhost", - "port": "3308", + "port": "3306", "logging": false, "entities": Object.values(MySQLDb), "migrations": [ @@ -90,7 +90,7 @@ module.exports = [ "username": "root", "password": "password", "host": "localhost", - "port": "3308", + "port": "3306", "logging": false, "entities": Object.values(MySQLDb), "migrations": [ @@ -105,4 +105,4 @@ module.exports = [ "subscribersDir": "./src/databases/mysqldb/Subscribers" } }, -]; \ No newline at end of file +]; diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index 3739b779ef..06eeca7976 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -35,21 +35,25 @@ export let collections: IDatabaseCollections = { import { InitialMigration1587669153312, WebhookModel1589476000887, + CreateIndexStoppedAt1594828256133, } from './databases/postgresdb/migrations'; import { InitialMigration1587563438936, WebhookModel1592679094242, + CreateIndexStoppedAt1594910478695, } from './databases/mongodb/migrations'; import { InitialMigration1588157391238, WebhookModel1592447867632, + CreateIndexStoppedAt1594902918301, } from './databases/mysqldb/migrations'; import { InitialMigration1588102412422, WebhookModel1592445003908, + CreateIndexStoppedAt1594825041918, } from './databases/sqlite/migrations'; import * as path from 'path'; @@ -71,7 +75,11 @@ export async function init(): Promise { entityPrefix, url: await GenericHelpers.getConfigValue('database.mongodb.connectionUrl') as string, useNewUrlParser: true, - migrations: [InitialMigration1587563438936, WebhookModel1592679094242], + migrations: [ + InitialMigration1587563438936, + WebhookModel1592679094242, + CreateIndexStoppedAt1594910478695, + ], migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, }; @@ -104,7 +112,11 @@ export async function init(): Promise { port: await GenericHelpers.getConfigValue('database.postgresdb.port') as number, username: await GenericHelpers.getConfigValue('database.postgresdb.user') as string, schema: config.get('database.postgresdb.schema'), - migrations: [InitialMigration1587669153312, WebhookModel1589476000887], + migrations: [ + InitialMigration1587669153312, + WebhookModel1589476000887, + CreateIndexStoppedAt1594828256133, + ], migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, ssl, @@ -123,7 +135,11 @@ export async function init(): Promise { password: await GenericHelpers.getConfigValue('database.mysqldb.password') as string, port: await GenericHelpers.getConfigValue('database.mysqldb.port') as number, username: await GenericHelpers.getConfigValue('database.mysqldb.user') as string, - migrations: [InitialMigration1588157391238, WebhookModel1592447867632], + migrations: [ + InitialMigration1588157391238, + WebhookModel1592447867632, + CreateIndexStoppedAt1594902918301, + ], migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, }; @@ -135,7 +151,11 @@ export async function init(): Promise { type: 'sqlite', database: path.join(n8nFolder, 'database.sqlite'), entityPrefix, - migrations: [InitialMigration1588102412422, WebhookModel1592445003908], + migrations: [ + InitialMigration1588102412422, + WebhookModel1592445003908, + CreateIndexStoppedAt1594825041918 + ], migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, }; diff --git a/packages/cli/src/databases/mongodb/ExecutionEntity.ts b/packages/cli/src/databases/mongodb/ExecutionEntity.ts index ba5071a36d..02a639d66a 100644 --- a/packages/cli/src/databases/mongodb/ExecutionEntity.ts +++ b/packages/cli/src/databases/mongodb/ExecutionEntity.ts @@ -39,6 +39,7 @@ export class ExecutionEntity implements IExecutionFlattedDb { @Column('Date') startedAt: Date; + @Index() @Column('Date') stoppedAt: Date; diff --git a/packages/cli/src/databases/mongodb/migrations/151594910478695-CreateIndexStoppedAt.ts b/packages/cli/src/databases/mongodb/migrations/151594910478695-CreateIndexStoppedAt.ts new file mode 100644 index 0000000000..9cfe4480dc --- /dev/null +++ b/packages/cli/src/databases/mongodb/migrations/151594910478695-CreateIndexStoppedAt.ts @@ -0,0 +1,22 @@ +import { MigrationInterface } from "typeorm"; +import { + MongoQueryRunner, +} from 'typeorm/driver/mongodb/MongoQueryRunner'; + +import * as config from '../../../../config'; + +export class CreateIndexStoppedAt1594910478695 implements MigrationInterface { + name = 'CreateIndexStoppedAt1594910478695' + + public async up(queryRunner: MongoQueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.manager.createCollectionIndex(`${tablePrefix}execution_entity`, 'stoppedAt', { name: `IDX_${tablePrefix}execution_entity_stoppedAt`}); + } + + public async down(queryRunner: MongoQueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.manager.dropCollectionIndex + (`${tablePrefix}execution_entity`, `IDX_${tablePrefix}execution_entity_stoppedAt`); + } + +} diff --git a/packages/cli/src/databases/mongodb/migrations/index.ts b/packages/cli/src/databases/mongodb/migrations/index.ts index 4072ab582d..ae4a6deb38 100644 --- a/packages/cli/src/databases/mongodb/migrations/index.ts +++ b/packages/cli/src/databases/mongodb/migrations/index.ts @@ -1,2 +1,3 @@ export * from './1587563438936-InitialMigration'; export * from './1592679094242-WebhookModel'; +export * from './151594910478695-CreateIndexStoppedAt'; diff --git a/packages/cli/src/databases/mysqldb/ExecutionEntity.ts b/packages/cli/src/databases/mysqldb/ExecutionEntity.ts index e0c084fcfc..3db01032b2 100644 --- a/packages/cli/src/databases/mysqldb/ExecutionEntity.ts +++ b/packages/cli/src/databases/mysqldb/ExecutionEntity.ts @@ -39,6 +39,7 @@ export class ExecutionEntity implements IExecutionFlattedDb { @Column('datetime') startedAt: Date; + @Index() @Column('datetime') stoppedAt: Date; diff --git a/packages/cli/src/databases/mysqldb/migrations/1594902918301-CreateIndexStoppedAt.ts b/packages/cli/src/databases/mysqldb/migrations/1594902918301-CreateIndexStoppedAt.ts new file mode 100644 index 0000000000..2cc4c86367 --- /dev/null +++ b/packages/cli/src/databases/mysqldb/migrations/1594902918301-CreateIndexStoppedAt.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +import * as config from '../../../../config'; + +export class CreateIndexStoppedAt1594902918301 implements MigrationInterface { + name = 'CreateIndexStoppedAt1594902918301' + + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query('CREATE INDEX `IDX_' + tablePrefix + 'cefb067df2402f6aed0638a6c1` ON `' + tablePrefix + 'execution_entity` (`stoppedAt`)'); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query('DROP INDEX `IDX_' + tablePrefix + 'cefb067df2402f6aed0638a6c1` ON `' + tablePrefix + 'execution_entity`'); + } + +} diff --git a/packages/cli/src/databases/mysqldb/migrations/index.ts b/packages/cli/src/databases/mysqldb/migrations/index.ts index 9e5abd3b8c..7c0cb217ef 100644 --- a/packages/cli/src/databases/mysqldb/migrations/index.ts +++ b/packages/cli/src/databases/mysqldb/migrations/index.ts @@ -1,2 +1,3 @@ export * from './1588157391238-InitialMigration'; export * from './1592447867632-WebhookModel'; +export * from './1594902918301-CreateIndexStoppedAt'; diff --git a/packages/cli/src/databases/postgresdb/ExecutionEntity.ts b/packages/cli/src/databases/postgresdb/ExecutionEntity.ts index 8a7f691f0f..8b45336c2f 100644 --- a/packages/cli/src/databases/postgresdb/ExecutionEntity.ts +++ b/packages/cli/src/databases/postgresdb/ExecutionEntity.ts @@ -39,6 +39,7 @@ export class ExecutionEntity implements IExecutionFlattedDb { @Column('timestamp') startedAt: Date; + @Index() @Column('timestamp') stoppedAt: Date; diff --git a/packages/cli/src/databases/postgresdb/migrations/1594828256133-CreateIndexStoppedAt.ts b/packages/cli/src/databases/postgresdb/migrations/1594828256133-CreateIndexStoppedAt.ts new file mode 100644 index 0000000000..7dd9578634 --- /dev/null +++ b/packages/cli/src/databases/postgresdb/migrations/1594828256133-CreateIndexStoppedAt.ts @@ -0,0 +1,25 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +import * as config from '../../../../config'; + +export class CreateIndexStoppedAt1594828256133 implements MigrationInterface { + name = 'CreateIndexStoppedAt1594828256133' + + public async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixPure = tablePrefix; + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixPure}33228da131bb1112247cf52a42 ON ${tablePrefix}execution_entity ("stoppedAt") `); + } + + public async down(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query(`DROP INDEX IDX_${tablePrefix}33228da131bb1112247cf52a42`); + } + +} diff --git a/packages/cli/src/databases/postgresdb/migrations/index.ts b/packages/cli/src/databases/postgresdb/migrations/index.ts index 827f01796c..3b10537067 100644 --- a/packages/cli/src/databases/postgresdb/migrations/index.ts +++ b/packages/cli/src/databases/postgresdb/migrations/index.ts @@ -1,3 +1,4 @@ export * from './1587669153312-InitialMigration'; export * from './1589476000887-WebhookModel'; +export * from './1594828256133-CreateIndexStoppedAt'; diff --git a/packages/cli/src/databases/sqlite/ExecutionEntity.ts b/packages/cli/src/databases/sqlite/ExecutionEntity.ts index 825fed7fb5..bb7de2605d 100644 --- a/packages/cli/src/databases/sqlite/ExecutionEntity.ts +++ b/packages/cli/src/databases/sqlite/ExecutionEntity.ts @@ -39,6 +39,7 @@ export class ExecutionEntity implements IExecutionFlattedDb { @Column() startedAt: Date; + @Index() @Column() stoppedAt: Date; diff --git a/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts b/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts new file mode 100644 index 0000000000..cfa4812020 --- /dev/null +++ b/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +import * as config from '../../../../config'; + +export class CreateIndexStoppedAt1594825041918 implements MigrationInterface { + name = 'CreateIndexStoppedAt1594825041918' + + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "execution_entity" ("stoppedAt") `); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1"`); + } + +} diff --git a/packages/cli/src/databases/sqlite/migrations/index.ts b/packages/cli/src/databases/sqlite/migrations/index.ts index a830a007c7..f0a2068b92 100644 --- a/packages/cli/src/databases/sqlite/migrations/index.ts +++ b/packages/cli/src/databases/sqlite/migrations/index.ts @@ -1,2 +1,3 @@ export * from './1588102412422-InitialMigration'; export * from './1592445003908-WebhookModel'; +export * from './1594825041918-CreateIndexStoppedAt' From 35cb56675e6414ec64d7cc456e084d6a5667bd47 Mon Sep 17 00:00:00 2001 From: ricardo Date: Wed, 22 Jul 2020 17:52:40 -0400 Subject: [PATCH 150/155] :zap: Small improvements --- .../credentials/NextCloudApi.credentials.ts | 2 +- .../credentials/NextCloudOAuth2Api.credentials.ts | 2 +- .../nodes-base/nodes/NextCloud/GenericFunctions.ts | 5 ++--- .../nodes-base/nodes/NextCloud/NextCloud.node.ts | 12 ++++++++---- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/nodes-base/credentials/NextCloudApi.credentials.ts b/packages/nodes-base/credentials/NextCloudApi.credentials.ts index c75632df00..3081455506 100644 --- a/packages/nodes-base/credentials/NextCloudApi.credentials.ts +++ b/packages/nodes-base/credentials/NextCloudApi.credentials.ts @@ -12,7 +12,7 @@ export class NextCloudApi implements ICredentialType { displayName: 'Web DAV URL', name: 'webDavUrl', type: 'string' as NodePropertyTypes, - placeholder: 'https://nextcloud.example.com/remote.php/webdav/', + placeholder: 'https://nextcloud.example.com/remote.php/webdav', default: '', }, { diff --git a/packages/nodes-base/credentials/NextCloudOAuth2Api.credentials.ts b/packages/nodes-base/credentials/NextCloudOAuth2Api.credentials.ts index dbb4efcda1..9379ee5203 100644 --- a/packages/nodes-base/credentials/NextCloudOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/NextCloudOAuth2Api.credentials.ts @@ -15,7 +15,7 @@ export class NextCloudOAuth2Api implements ICredentialType { displayName: 'Web DAV URL', name: 'webDavUrl', type: 'string' as NodePropertyTypes, - placeholder: 'https://nextcloud.example.com/remote.php/webdav/', + placeholder: 'https://nextcloud.example.com/remote.php/webdav', default: '', }, { diff --git a/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts b/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts index fc4e7646bf..2198cb5513 100644 --- a/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts +++ b/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts @@ -4,9 +4,8 @@ import { } from 'n8n-core'; import { - IDataObject, -} from 'n8n-workflow'; -import { OptionsWithUri } from 'request'; + OptionsWithUri, +} from 'request'; /** * Make an API request to NextCloud diff --git a/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts b/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts index 8b3f0620fc..a5b3109c6e 100644 --- a/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts +++ b/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts @@ -2,6 +2,7 @@ import { BINARY_ENCODING, IExecuteFunctions, } from 'n8n-core'; + import { IDataObject, INodeTypeDescription, @@ -9,10 +10,13 @@ import { INodeType, } from 'n8n-workflow'; -import { parseString } from 'xml2js'; -import { OptionsWithUri } from 'request'; -import { nextCloudApiRequest } from './GenericFunctions'; +import { + parseString, +} from 'xml2js'; +import { + nextCloudApiRequest, +} from './GenericFunctions'; export class NextCloud implements INodeType { description: INodeTypeDescription = { @@ -25,7 +29,7 @@ export class NextCloud implements INodeType { description: 'Access data on NextCloud', defaults: { name: 'NextCloud', - color: '#22BB44', + color: '#1cafff', }, inputs: ['main'], outputs: ['main'], From 5545bc7dfc72588575d356790323f61a2a724195 Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Thu, 23 Jul 2020 11:14:03 +0200 Subject: [PATCH 151/155] :construction: Removed update function, fixed insert function --- .../nodes-base/nodes/QuestDb/QuestDb.node.ts | 126 +++++++++--------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/packages/nodes-base/nodes/QuestDb/QuestDb.node.ts b/packages/nodes-base/nodes/QuestDb/QuestDb.node.ts index 20b6d48615..b3ae15efee 100644 --- a/packages/nodes-base/nodes/QuestDb/QuestDb.node.ts +++ b/packages/nodes-base/nodes/QuestDb/QuestDb.node.ts @@ -4,6 +4,7 @@ import { IDataObject, INodeExecutionData, INodeType, INodeTypeDescription } from import * as pgPromise from 'pg-promise'; import { pgInsert, pgQuery, pgUpdate } from '../Postgres/Postgres.node.functions'; +import { table } from 'console'; export class QuestDb implements INodeType { description: INodeTypeDescription = { @@ -40,12 +41,7 @@ export class QuestDb implements INodeType { name: 'Insert', value: 'insert', description: 'Insert rows in database.', - }, - { - name: 'Update', - value: 'update', - description: 'Updates rows in database.', - }, + } ], default: 'insert', description: 'The operation to perform.', @@ -131,47 +127,47 @@ export class QuestDb implements INodeType { // ---------------------------------- // update // ---------------------------------- - { - displayName: 'Table', - name: 'table', - type: 'string', - displayOptions: { - show: { - operation: ['update'], - }, - }, - default: '', - required: true, - description: 'Name of the table in which to update data in', - }, - { - displayName: 'Update Key', - name: 'updateKey', - type: 'string', - displayOptions: { - show: { - operation: ['update'], - }, - }, - default: 'id', - required: true, - description: - 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', - }, - { - displayName: 'Columns', - name: 'columns', - type: 'string', - displayOptions: { - show: { - operation: ['update'], - }, - }, - default: '', - placeholder: 'name,description', - description: - 'Comma separated list of the properties which should used as columns for rows to update.', - }, + // { + // displayName: 'Table', + // name: 'table', + // type: 'string', + // displayOptions: { + // show: { + // operation: ['update'], + // }, + // }, + // default: '', + // required: true, + // description: 'Name of the table in which to update data in', + // }, + // { + // displayName: 'Update Key', + // name: 'updateKey', + // type: 'string', + // displayOptions: { + // show: { + // operation: ['update'], + // }, + // }, + // default: 'id', + // required: true, + // description: + // 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + // }, + // { + // displayName: 'Columns', + // name: 'columns', + // type: 'string', + // displayOptions: { + // show: { + // operation: ['update'], + // }, + // }, + // default: '', + // placeholder: 'name,description', + // description: + // 'Comma separated list of the properties which should used as columns for rows to update.', + // }, ], }; @@ -213,26 +209,30 @@ export class QuestDb implements INodeType { // ---------------------------------- // insert // ---------------------------------- + const tableName = this.getNodeParameter('table', 0) as string; + const returnFields = this.getNodeParameter('returnFields', 0) as string; + + let queries : string[] = []; + items.map(item => { + let columns = Object.keys(item.json); - const [insertData, insertItems] = await pgInsert(this.getNodeParameter, pgp, db, items); + let values : string = columns.map((col : string) => { + if (typeof item.json[col] === 'string') { + return `\'${item.json[col]}\'`; + } else { + return item.json[col]; + } + }).join(','); - // Add the id to the data - for (let i = 0; i < insertData.length; i++) { - returnItems.push({ - json: { - ...insertData[i], - ...insertItems[i], - }, - }); - } - } else if (operation === 'update') { - // ---------------------------------- - // update - // ---------------------------------- + let query = `INSERT INTO ${tableName} (${columns.join(',')}) VALUES (${values});`; + queries.push(query); + }); + + await db.any(pgp.helpers.concat(queries)); - const updateItems = await pgUpdate(this.getNodeParameter, pgp, db, items); + let returnedItems = await db.any(`SELECT ${returnFields} from ${tableName}`); - returnItems = this.helpers.returnJsonArray(updateItems); + returnItems = this.helpers.returnJsonArray(returnedItems as IDataObject[]); } else { await pgp.end(); throw new Error(`The operation "${operation}" is not supported!`); From 138a4d75189470859131fcd46d6db9f1d4ae057e Mon Sep 17 00:00:00 2001 From: ricardo Date: Thu, 23 Jul 2020 14:53:31 -0400 Subject: [PATCH 152/155] :zap: Small improvements --- packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts | 2 +- packages/nodes-base/nodes/Gitlab/Gitlab.node.ts | 1 - packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts index 60cc091836..bfd7f5afbc 100644 --- a/packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts @@ -34,7 +34,7 @@ export class GitlabOAuth2Api implements ICredentialType { { displayName: 'Scope', name: 'scope', - type: 'string' as NodePropertyTypes, + type: 'hidden' as NodePropertyTypes, default: 'api', }, { diff --git a/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts b/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts index 920a2867ec..fa485e452a 100644 --- a/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts +++ b/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts @@ -13,7 +13,6 @@ import { gitlabApiRequest, } from './GenericFunctions'; - export class Gitlab implements INodeType { description: INodeTypeDescription = { displayName: 'Gitlab', diff --git a/packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts b/packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts index a3e9b9ee2a..fbb4c261e9 100644 --- a/packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts +++ b/packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts @@ -14,7 +14,6 @@ import { gitlabApiRequest, } from './GenericFunctions'; - export class GitlabTrigger implements INodeType { description: INodeTypeDescription = { displayName: 'Gitlab Trigger', From b187a8fd7dfd27cee56dd606c00ef581612c95f0 Mon Sep 17 00:00:00 2001 From: ricardo Date: Thu, 23 Jul 2020 16:51:05 -0400 Subject: [PATCH 153/155] Merge branch 'Master' into 'Pipedrive-OAuth2-support' --- .github/workflows/docker-images-rpi.yml | 28 + .gitignore | 1 + CONTRIBUTING.md | 10 +- LICENSE.md | 2 +- README.md | 4 +- {docs/images => assets}/n8n-logo.png | Bin {docs/images => assets}/n8n-screenshot.png | Bin docker/compose/subfolderWithSSL/README.md | 26 + .../subfolderWithSSL/docker-compose.yml | 57 + docker/images/n8n-custom/Dockerfile | 2 + docker/images/n8n-custom/Dockerfile copy | 49 + docker/images/n8n-rhel7/Dockerfile | 23 + docker/images/n8n-rhel7/README.md | 16 + docker/images/n8n-rpi/Dockerfile | 20 + docker/images/n8n-rpi/README.md | 21 + docker/images/n8n-ubuntu/Dockerfile | 2 + docker/images/n8n/Dockerfile | 2 + docker/images/n8n/README.md | 155 ++- docs/.nojekyll | 0 docs/CNAME | 1 - docs/README.md | 10 - docs/_sidebar.md | 43 - docs/configuration.md | 244 ----- docs/create-node.md | 145 --- docs/data-structure.md | 39 - docs/database.md | 109 -- docs/development.md | 3 - docs/docker.md | 7 - docs/faq.md | 47 - docs/index.html | 53 - docs/key-components.md | 25 - docs/keyboard-shortcuts.md | 28 - docs/license.md | 5 - docs/node-basics.md | 76 -- docs/nodes.md | 247 ----- docs/quick-start.md | 43 - docs/security.md | 13 - docs/sensitive-data.md | 18 - docs/server-setup.md | 183 ---- docs/setup.md | 35 - docs/start-workflows-via-cli.md | 15 - docs/test.md | 3 - docs/troubleshooting.md | 58 -- docs/tutorials.md | 26 - docs/workflow.md | 111 -- package.json | 1 + packages/cli/LICENSE.md | 2 +- packages/cli/README.md | 4 +- packages/cli/commands/execute.ts | 5 + packages/cli/commands/start.ts | 6 +- packages/cli/config/index.ts | 97 +- packages/cli/migrations/ormconfig.ts | 10 +- packages/cli/package.json | 10 +- packages/cli/src/ActiveWorkflowRunner.ts | 185 +++- packages/cli/src/CredentialsOverwrites.ts | 3 +- packages/cli/src/Db.ts | 62 +- packages/cli/src/ExternalHooks.ts | 79 ++ packages/cli/src/GenericHelpers.ts | 5 +- packages/cli/src/Interfaces.ts | 31 + packages/cli/src/Server.ts | 204 +++- packages/cli/src/TestWebhooks.ts | 4 +- packages/cli/src/WebhookHelpers.ts | 30 + .../cli/src/WorkflowExecuteAdditionalData.ts | 48 +- packages/cli/src/WorkflowRunner.ts | 4 + .../src/databases/mongodb/ExecutionEntity.ts | 1 + .../src/databases/mongodb/WebhookEntity.ts | 30 + packages/cli/src/databases/mongodb/index.ts | 2 + .../151594910478695-CreateIndexStoppedAt.ts | 22 + .../migrations/1592679094242-WebhookModel.ts | 57 + .../src/databases/mongodb/migrations/index.ts | 2 + .../src/databases/mysqldb/ExecutionEntity.ts | 1 + .../src/databases/mysqldb/WebhookEntity.ts | 25 + packages/cli/src/databases/mysqldb/index.ts | 1 + .../1588157391238-InitialMigration.ts | 8 +- .../migrations/1592447867632-WebhookModel.ts | 59 ++ .../1594902918301-CreateIndexStoppedAt.ts | 20 + .../src/databases/mysqldb/migrations/index.ts | 4 +- .../databases/postgresdb/ExecutionEntity.ts | 1 + .../src/databases/postgresdb/WebhookEntity.ts | 25 + .../cli/src/databases/postgresdb/index.ts | 2 + .../1587669153312-InitialMigration.ts | 19 +- .../migrations/1589476000887-WebhookModel.ts | 69 ++ .../1594828256133-CreateIndexStoppedAt.ts | 25 + .../databases/postgresdb/migrations/index.ts | 3 + .../src/databases/sqlite/ExecutionEntity.ts | 1 + .../cli/src/databases/sqlite/WebhookEntity.ts | 25 + packages/cli/src/databases/sqlite/index.ts | 2 +- .../1588102412422-InitialMigration.ts | 13 +- .../migrations/1592445003908-WebhookModel.ts | 63 ++ .../1594825041918-CreateIndexStoppedAt.ts | 20 + .../src/databases/sqlite/migrations/index.ts | 4 +- packages/cli/src/index.ts | 1 + packages/core/LICENSE.md | 2 +- packages/core/README.md | 2 +- packages/core/package.json | 9 +- packages/core/src/ActiveWebhooks.ts | 9 +- packages/core/src/NodeExecuteFunctions.ts | 42 +- packages/core/src/UserSettings.ts | 9 +- packages/core/src/WorkflowExecute.ts | 10 +- packages/editor-ui/LICENSE.md | 2 +- packages/editor-ui/README.md | 2 +- packages/editor-ui/package.json | 10 +- packages/editor-ui/public/index.html | 5 +- .../src/components/ExpressionInput.vue | 7 + .../editor-ui/src/components/MainSidebar.vue | 4 +- .../editor-ui/src/components/NodeWebhooks.vue | 3 +- packages/editor-ui/src/components/RunData.vue | 8 +- packages/editor-ui/src/router.ts | 3 +- packages/editor-ui/src/store.ts | 5 +- packages/editor-ui/src/views/NodeView.vue | 11 + packages/editor-ui/vue.config.js | 1 + packages/node-dev/LICENSE.md | 2 +- packages/node-dev/README.md | 26 +- packages/node-dev/package.json | 6 +- packages/node-dev/src/Build.ts | 2 +- packages/node-dev/templates/webhook/simple.ts | 2 +- packages/nodes-base/LICENSE.md | 2 +- packages/nodes-base/README.md | 2 +- .../credentials/CircleCiApi.credentials.ts | 17 + .../credentials/CrateDb.credentials.ts | 69 ++ .../credentials/DriftOAuth2Api.credentials.ts | 47 + .../credentials/EventbriteApi.credentials.ts | 2 +- .../EventbriteOAuth2Api.credentials.ts | 47 + .../GitlabOAuth2Api.credentials.ts | 53 + .../GoogleDriveOAuth2Api.credentials.ts | 26 + .../GoogleTasksOAuth2Api.credentials.ts | 22 + .../HubspotOAuth2Api.credentials.ts | 53 + .../MailchimpOAuth2Api.credentials.ts | 54 + .../MauticOAuth2Api.credentials.ts | 55 + .../credentials/MessageBirdApi.credentials.ts | 14 + .../credentials/MicrosoftSql.credentials.ts | 47 + .../credentials/NextCloudApi.credentials.ts | 2 +- .../NextCloudOAuth2Api.credentials.ts | 54 + .../PagerDutyOAuth2Api.credentials.ts | 45 + .../PipedriveOAuth2Api.credentials.ts | 3 +- .../credentials/PostmarkApi.credentials.ts | 18 + .../credentials/QuestDb.credentials.ts | 69 ++ .../credentials/Signl4Api.credentials.ts | 18 + .../credentials/SlackOAuth2Api.credentials.ts | 3 - .../SpotifyOAuth2Api.credentials.ts | 53 + .../SurveyMonkeyOAuth2Api.credentials.ts | 55 + .../TypeformOAuth2Api.credentials.ts | 53 + .../WebflowOAuth2Api.credentials.ts | 50 + .../credentials/XeroOAuth2Api.credentials.ts | 51 + .../credentials/ZendeskApi.credentials.ts | 7 +- .../ZendeskOAuth2Api.credentials.ts | 79 ++ .../credentials/ZoomApi.credentials.ts | 14 + .../credentials/ZoomOAuth2Api.credentials.ts | 42 + packages/nodes-base/gulpfile.js | 2 +- .../nodes/Affinity/AffinityTrigger.node.ts | 6 +- .../nodes/CircleCi/CircleCi.node.ts | 140 +++ .../nodes/CircleCi/GenericFunctions.ts | 67 ++ .../nodes/CircleCi/PipelineDescription.ts | 229 ++++ .../nodes-base/nodes/CircleCi/circleCi.png | Bin 0 -> 4787 bytes .../nodes-base/nodes/CrateDb/CrateDb.node.ts | 246 +++++ packages/nodes-base/nodes/CrateDb/cratedb.png | Bin 0 -> 475 bytes packages/nodes-base/nodes/DateTime.node.ts | 3 +- packages/nodes-base/nodes/Drift/Drift.node.ts | 35 + .../nodes/Drift/GenericFunctions.ts | 38 +- .../nodes-base/nodes/Dropbox/Dropbox.node.ts | 21 +- .../Eventbrite/EventbriteTrigger.node.ts | 74 +- .../nodes/Eventbrite/GenericFunctions.ts | 33 +- .../nodes/Facebook/FacebookGraphApi.node.ts | 8 + .../nodes-base/nodes/Github/Github.node.ts | 27 +- .../nodes/Github/GithubTrigger.node.ts | 37 +- .../nodes/Gitlab/GenericFunctions.ts | 43 +- .../nodes-base/nodes/Gitlab/Gitlab.node.ts | 60 +- .../nodes/Gitlab/GitlabTrigger.node.ts | 61 +- .../nodes/Google/Calendar/EventDescription.ts | 149 +-- .../nodes/Google/Calendar/EventInterface.ts | 4 +- .../nodes/Google/Calendar/GenericFunctions.ts | 10 +- .../Google/Calendar/GoogleCalendar.node.ts | 216 ++-- .../nodes/Google/Drive/GenericFunctions.ts | 142 +++ .../nodes/Google/Drive/GoogleDrive.node.ts | 172 +-- packages/nodes-base/nodes/Google/GoogleApi.ts | 23 - .../nodes/Google/Sheet/GoogleSheets.node.ts | 16 +- .../nodes/Google/Task/GenericFunctions.ts | 92 ++ .../nodes/Google/Task/GoogleTasks.node.ts | 279 +++++ .../nodes/Google/Task/TaskDescription.ts | 493 +++++++++ .../nodes/Google/Task/googleTasks.png | Bin 0 -> 3290 bytes .../nodes/HackerNews/GenericFunctions.ts | 80 ++ .../nodes/HackerNews/HackerNews.node.ts | 384 +++++++ .../nodes/HackerNews/hackernews.png | Bin 0 -> 1952 bytes packages/nodes-base/nodes/HttpRequest.node.ts | 2 +- .../nodes/Hubspot/GenericFunctions.ts | 49 +- .../nodes-base/nodes/Hubspot/Hubspot.node.ts | 37 +- .../nodes/Hubspot/HubspotTrigger.node.ts | 24 +- .../nodes-base/nodes/Jira/IssueDescription.ts | 4 +- .../nodes/Mailchimp/GenericFunctions.ts | 72 +- .../nodes/Mailchimp/Mailchimp.node.ts | 407 +++++++- .../nodes/Mailchimp/MailchimpTrigger.node.ts | 37 +- .../nodes/Mattermost/Mattermost.node.ts | 20 +- .../nodes/Mautic/ContactDescription.ts | 562 +++++++++- .../nodes/Mautic/GenericFunctions.ts | 40 +- .../nodes-base/nodes/Mautic/Mautic.node.ts | 184 +++- .../nodes/Mautic/MauticTrigger.node.ts | 36 +- .../nodes/MessageBird/GenericFunctions.ts | 64 ++ .../nodes/MessageBird/MessageBird.node.ts | 364 +++++++ .../nodes/MessageBird/messagebird.png | Bin 0 -> 1305 bytes .../nodes/Microsoft/Sql/GenericFunctions.ts | 144 +++ .../nodes/Microsoft/Sql/MicrosoftSql.node.ts | 394 +++++++ .../nodes/Microsoft/Sql/TableInterface.ts | 7 + .../nodes-base/nodes/Microsoft/Sql/mssql.png | Bin 0 -> 3447 bytes .../nodes/MondayCom/BoardItemDescription.ts | 201 ++++ .../nodes/MondayCom/MondayCom.node.ts | 78 ++ .../nodes-base/nodes/MongoDb/MongoDb.node.ts | 13 +- .../nodes/MongoDb/mongo.node.options.ts | 30 +- packages/nodes-base/nodes/Msg91/Msg91.node.ts | 2 +- .../nodes/NextCloud/GenericFunctions.ts | 63 ++ .../nodes/NextCloud/NextCloud.node.ts | 75 +- .../nodes-base/nodes/OpenWeatherMap.node.ts | 14 +- .../nodes/PagerDuty/GenericFunctions.ts | 26 +- .../nodes/PagerDuty/PagerDuty.node.ts | 34 + .../nodes/Pipedrive/GenericFunctions.ts | 43 +- .../nodes/Pipedrive/Pipedrive.node.ts | 226 +++- .../nodes/Pipedrive/PipedriveTrigger.node.ts | 33 +- .../nodes/Postgres/Postgres.node.functions.ts | 129 +++ .../nodes/Postgres/Postgres.node.ts | 140 +-- .../nodes/Postmark/GenericFunctions.ts | 93 ++ .../nodes/Postmark/PostmarkTrigger.node.ts | 256 +++++ .../nodes-base/nodes/Postmark/postmark.png | Bin 0 -> 1297 bytes .../nodes-base/nodes/QuestDb/QuestDb.node.ts | 246 +++++ packages/nodes-base/nodes/QuestDb/questdb.png | Bin 0 -> 2835 bytes packages/nodes-base/nodes/Redis/Redis.node.ts | 1 + .../nodes/Rocketchat/Rocketchat.node.ts | 6 +- .../nodes/Salesforce/GenericFunctions.ts | 2 +- .../nodes/Signl4/GenericFunctions.ts | 52 + .../nodes-base/nodes/Signl4/Signl4.node.ts | 325 ++++++ packages/nodes-base/nodes/Signl4/signl4.png | Bin 0 -> 3045 bytes .../nodes/Slack/MessageDescription.ts | 6 +- packages/nodes-base/nodes/Slack/Slack.node.ts | 119 ++- .../nodes/Spotify/GenericFunctions.ts | 84 ++ .../nodes-base/nodes/Spotify/Spotify.node.ts | 816 +++++++++++++++ packages/nodes-base/nodes/Spotify/spotify.png | Bin 0 -> 6664 bytes .../nodes/SurveyMonkey/GenericFunctions.ts | 25 +- .../SurveyMonkey/SurveyMonkeyTrigger.node.ts | 44 +- .../nodes/Trello/AttachmentDescription.ts | 2 +- .../nodes/Trello/ChecklistDescription.ts | 6 +- .../nodes/Trello/LabelDescription.ts | 2 +- .../nodes/Twitter/TweetDescription.ts | 2 +- .../nodes-base/nodes/Twitter/Twitter.node.ts | 10 +- .../nodes/Typeform/GenericFunctions.ts | 29 +- .../nodes/Typeform/TypeformTrigger.node.ts | 37 +- .../nodes-base/nodes/Uplead/Uplead.node.ts | 4 +- .../nodes/Webflow/GenericFunctions.ts | 38 +- .../nodes/Webflow/WebflowTrigger.node.ts | 37 +- packages/nodes-base/nodes/Webhook.node.ts | 3 +- .../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 .../nodes/Zendesk/GenericFunctions.ts | 40 +- .../nodes-base/nodes/Zendesk/Zendesk.node.ts | 37 +- .../nodes/Zendesk/ZendeskTrigger.node.ts | 37 +- .../nodes-base/nodes/Zoom/GenericFunctions.ts | 104 ++ .../nodes/Zoom/MeetingDescription.ts | 751 +++++++++++++ .../Zoom/MeetingRegistrantDescription.ts | 443 ++++++++ .../nodes/Zoom/WebinarDescription.ts | 665 ++++++++++++ packages/nodes-base/nodes/Zoom/Zoom.node.ts | 821 +++++++++++++++ packages/nodes-base/nodes/Zoom/zoom.png | Bin 0 -> 1848 bytes packages/nodes-base/nodes/utils/utilities.ts | 57 + packages/nodes-base/package.json | 502 +++++++-- packages/workflow/LICENSE.md | 2 +- packages/workflow/README.md | 2 +- packages/workflow/package.json | 2 +- packages/workflow/src/Interfaces.ts | 4 +- packages/workflow/src/NodeHelpers.ts | 79 +- packages/workflow/src/Workflow.ts | 10 +- 271 files changed, 17019 insertions(+), 2796 deletions(-) create mode 100644 .github/workflows/docker-images-rpi.yml rename {docs/images => assets}/n8n-logo.png (100%) rename {docs/images => assets}/n8n-screenshot.png (100%) create mode 100644 docker/compose/subfolderWithSSL/README.md create mode 100644 docker/compose/subfolderWithSSL/docker-compose.yml create mode 100644 docker/images/n8n-custom/Dockerfile copy create mode 100644 docker/images/n8n-rhel7/Dockerfile create mode 100644 docker/images/n8n-rhel7/README.md create mode 100644 docker/images/n8n-rpi/Dockerfile create mode 100644 docker/images/n8n-rpi/README.md delete mode 100644 docs/.nojekyll delete mode 100644 docs/CNAME delete mode 100644 docs/README.md delete mode 100644 docs/_sidebar.md delete mode 100644 docs/configuration.md delete mode 100644 docs/create-node.md delete mode 100644 docs/data-structure.md delete mode 100644 docs/database.md delete mode 100644 docs/development.md delete mode 100644 docs/docker.md delete mode 100644 docs/faq.md delete mode 100644 docs/index.html delete mode 100644 docs/key-components.md delete mode 100644 docs/keyboard-shortcuts.md delete mode 100644 docs/license.md delete mode 100644 docs/node-basics.md delete mode 100644 docs/nodes.md delete mode 100644 docs/quick-start.md delete mode 100644 docs/security.md delete mode 100644 docs/sensitive-data.md delete mode 100644 docs/server-setup.md delete mode 100644 docs/setup.md delete mode 100644 docs/start-workflows-via-cli.md delete mode 100644 docs/test.md delete mode 100644 docs/troubleshooting.md delete mode 100644 docs/tutorials.md delete mode 100644 docs/workflow.md create mode 100644 packages/cli/src/ExternalHooks.ts create mode 100644 packages/cli/src/databases/mongodb/WebhookEntity.ts create mode 100644 packages/cli/src/databases/mongodb/migrations/151594910478695-CreateIndexStoppedAt.ts create mode 100644 packages/cli/src/databases/mongodb/migrations/1592679094242-WebhookModel.ts create mode 100644 packages/cli/src/databases/mysqldb/WebhookEntity.ts create mode 100644 packages/cli/src/databases/mysqldb/migrations/1592447867632-WebhookModel.ts create mode 100644 packages/cli/src/databases/mysqldb/migrations/1594902918301-CreateIndexStoppedAt.ts create mode 100644 packages/cli/src/databases/postgresdb/WebhookEntity.ts create mode 100644 packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts create mode 100644 packages/cli/src/databases/postgresdb/migrations/1594828256133-CreateIndexStoppedAt.ts create mode 100644 packages/cli/src/databases/sqlite/WebhookEntity.ts create mode 100644 packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts create mode 100644 packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts create mode 100644 packages/nodes-base/credentials/CircleCiApi.credentials.ts create mode 100644 packages/nodes-base/credentials/CrateDb.credentials.ts create mode 100644 packages/nodes-base/credentials/DriftOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/EventbriteOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/GoogleDriveOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/GoogleTasksOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/HubspotOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/MailchimpOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/MauticOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/MessageBirdApi.credentials.ts create mode 100644 packages/nodes-base/credentials/MicrosoftSql.credentials.ts create mode 100644 packages/nodes-base/credentials/NextCloudOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/PagerDutyOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/PostmarkApi.credentials.ts create mode 100644 packages/nodes-base/credentials/QuestDb.credentials.ts create mode 100644 packages/nodes-base/credentials/Signl4Api.credentials.ts create mode 100644 packages/nodes-base/credentials/SpotifyOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/SurveyMonkeyOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/TypeformOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/WebflowOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/XeroOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/ZendeskOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/ZoomApi.credentials.ts create mode 100644 packages/nodes-base/credentials/ZoomOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/CircleCi/CircleCi.node.ts create mode 100644 packages/nodes-base/nodes/CircleCi/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/CircleCi/PipelineDescription.ts create mode 100644 packages/nodes-base/nodes/CircleCi/circleCi.png create mode 100644 packages/nodes-base/nodes/CrateDb/CrateDb.node.ts create mode 100644 packages/nodes-base/nodes/CrateDb/cratedb.png create mode 100644 packages/nodes-base/nodes/Google/Drive/GenericFunctions.ts delete mode 100644 packages/nodes-base/nodes/Google/GoogleApi.ts create mode 100644 packages/nodes-base/nodes/Google/Task/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Google/Task/GoogleTasks.node.ts create mode 100644 packages/nodes-base/nodes/Google/Task/TaskDescription.ts create mode 100644 packages/nodes-base/nodes/Google/Task/googleTasks.png 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 create mode 100644 packages/nodes-base/nodes/MessageBird/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/MessageBird/MessageBird.node.ts create mode 100644 packages/nodes-base/nodes/MessageBird/messagebird.png create mode 100644 packages/nodes-base/nodes/Microsoft/Sql/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Sql/TableInterface.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Sql/mssql.png create mode 100644 packages/nodes-base/nodes/NextCloud/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts create mode 100644 packages/nodes-base/nodes/Postmark/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Postmark/postmark.png create mode 100644 packages/nodes-base/nodes/QuestDb/QuestDb.node.ts create mode 100644 packages/nodes-base/nodes/QuestDb/questdb.png create mode 100644 packages/nodes-base/nodes/Signl4/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Signl4/Signl4.node.ts create mode 100644 packages/nodes-base/nodes/Signl4/signl4.png create mode 100644 packages/nodes-base/nodes/Spotify/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Spotify/Spotify.node.ts create mode 100644 packages/nodes-base/nodes/Spotify/spotify.png 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 create mode 100644 packages/nodes-base/nodes/Zoom/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Zoom/MeetingDescription.ts create mode 100644 packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts create mode 100644 packages/nodes-base/nodes/Zoom/WebinarDescription.ts create mode 100644 packages/nodes-base/nodes/Zoom/Zoom.node.ts create mode 100644 packages/nodes-base/nodes/Zoom/zoom.png create mode 100644 packages/nodes-base/nodes/utils/utilities.ts diff --git a/.github/workflows/docker-images-rpi.yml b/.github/workflows/docker-images-rpi.yml new file mode 100644 index 0000000000..c6db9ed95b --- /dev/null +++ b/.github/workflows/docker-images-rpi.yml @@ -0,0 +1,28 @@ +name: Docker Image CI - Rpi + +on: + push: + tags: + - n8n@* + +jobs: + armv7_job: + runs-on: ubuntu-18.04 + name: Build on ARMv7 (Rpi) + steps: + - uses: actions/checkout@v1 + - name: Get the version + id: vars + run: echo ::set-output name=tag::$(echo ${GITHUB_REF:14}) + + - name: Log in to Docker registry + run: docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: crazy-max/ghaction-docker-buildx@v1 + with: + version: latest + - name: Run Buildx (push image) + if: success() + run: | + docker buildx build --platform linux/arm/v7 --build-arg N8N_VERSION=${{steps.vars.outputs.tag}} -t n8nio/n8n:${{steps.vars.outputs.tag}}-rpi --output type=image,push=true docker/images/n8n-rpi diff --git a/.gitignore b/.gitignore index b3eac39207..0441d445b4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ _START_PACKAGE .env .vscode .idea +.prettierrc.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 568f8fc9a7..fcc046beef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ The most important directories: execution, active webhooks and workflows - [/packages/editor-ui](/packages/editor-ui) - Vue frontend workflow editor - - [/packages/node-dev](/packages/node-dev) - Simple CLI to create new n8n-nodes + - [/packages/node-dev](/packages/node-dev) - CLI to create new n8n-nodes - [/packages/nodes-base](/packages/nodes-base) - Base n8n nodes - [/packages/workflow](/packages/workflow) - Workflow code with interfaces which get used by front- & backend @@ -159,7 +159,7 @@ tests of all packages. ## Create Custom Nodes -It is very easy to create own nodes for n8n. More information about that can +It is very straightforward to create your own nodes for n8n. More information about that can be found in the documentation of "n8n-node-dev" which is a small CLI which helps with n8n-node-development. @@ -177,9 +177,9 @@ If you want to create a node which should be added to n8n follow these steps: 1. Create a new folder for the new node. For a service named "Example" the folder would be called: `/packages/nodes-base/nodes/Example` - 1. If there is already a similar node simply copy the existing one in the new folder and rename it. If none exists yet, create a boilerplate node with [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev) and copy that one in the folder. + 1. If there is already a similar node, copy the existing one in the new folder and rename it. If none exists yet, create a boilerplate node with [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev) and copy that one in the folder. - 1. If the node needs credentials because it has to authenticate with an API or similar create new ones. Existing ones can be found in folder `/packages/nodes-base/credentials`. Also there it is the easiest to simply copy existing similar ones. + 1. If the node needs credentials because it has to authenticate with an API or similar create new ones. Existing ones can be found in folder `/packages/nodes-base/credentials`. Also there it is the easiest to copy existing similar ones. 1. Add the path to the new node (and optionally credentials) to package.json of `nodes-base`. It already contains a property `n8n` with its own keys `credentials` and `nodes`. @@ -236,6 +236,6 @@ docsify serve ./docs That we do not have any potential problems later it is sadly necessary to sign a [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md). That can be done literally with the push of a button. -We used the most simple one that exists. It is from [Indie Open Source](https://indieopensource.com/forms/cla) which uses plain English and is literally just a few lines long. +We used the most simple one that exists. It is from [Indie Open Source](https://indieopensource.com/forms/cla) which uses plain English and is literally only a few lines long. A bot will automatically comment on the pull request once it got opened asking for the agreement to be signed. Before it did not get signed it is sadly not possible to merge it in. diff --git a/LICENSE.md b/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index a9a161f9a2..eb692157a1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # n8n - Workflow Automation Tool -![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) n8n is a free and open [fair-code](http://faircode.io) licensed node based Workflow Automation Tool. It can be self-hosted, easily extended, and so also used with internal tools. -n8n.io - Screenshot +n8n.io - Screenshot diff --git a/docs/images/n8n-logo.png b/assets/n8n-logo.png similarity index 100% rename from docs/images/n8n-logo.png rename to assets/n8n-logo.png diff --git a/docs/images/n8n-screenshot.png b/assets/n8n-screenshot.png similarity index 100% rename from docs/images/n8n-screenshot.png rename to assets/n8n-screenshot.png 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/docker/images/n8n-custom/Dockerfile b/docker/images/n8n-custom/Dockerfile index f4e4af4ed7..d12f8f6b08 100644 --- a/docker/images/n8n-custom/Dockerfile +++ b/docker/images/n8n-custom/Dockerfile @@ -42,3 +42,5 @@ COPY --from=builder /data ./ COPY docker/images/n8n-custom/docker-entrypoint.sh /docker-entrypoint.sh ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"] + +EXPOSE 5678/tcp diff --git a/docker/images/n8n-custom/Dockerfile copy b/docker/images/n8n-custom/Dockerfile copy new file mode 100644 index 0000000000..19f08a16dd --- /dev/null +++ b/docker/images/n8n-custom/Dockerfile copy @@ -0,0 +1,49 @@ +FROM node:12.16-alpine as builder +# FROM node:12.16-alpine + +# Update everything and install needed dependencies +RUN apk add --update graphicsmagick tzdata git tini su-exec + +USER root + +# Install all needed dependencies +RUN apk --update add --virtual build-dependencies python build-base ca-certificates && \ + npm_config_user=root npm install -g full-icu lerna + +ENV NODE_ICU_DATA /usr/local/lib/node_modules/full-icu + +WORKDIR /data + +COPY lerna.json . +COPY package.json . +COPY packages/cli/ ./packages/cli/ +COPY packages/core/ ./packages/core/ +COPY packages/editor-ui/ ./packages/editor-ui/ +COPY packages/nodes-base/ ./packages/nodes-base/ +COPY packages/workflow/ ./packages/workflow/ +RUN rm -rf node_modules packages/*/node_modules packages/*/dist + +RUN npm install --loglevel notice +RUN lerna bootstrap --hoist +RUN npm run build + + +FROM node:12.16-alpine + +WORKDIR /data + +# Install all needed dependencies +RUN npm_config_user=root npm install -g full-icu + +USER root + +ENV NODE_ICU_DATA /usr/local/lib/node_modules/full-icu + +COPY --from=builder /data ./ + +RUN apk add --update graphicsmagick tzdata git tini su-exec + +COPY docker/images/n8n-dev/docker-entrypoint.sh /docker-entrypoint.sh +ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"] + +EXPOSE 5678/tcp diff --git a/docker/images/n8n-rhel7/Dockerfile b/docker/images/n8n-rhel7/Dockerfile new file mode 100644 index 0000000000..949d436602 --- /dev/null +++ b/docker/images/n8n-rhel7/Dockerfile @@ -0,0 +1,23 @@ +FROM richxsl/rhel7 + +ARG N8N_VERSION + +RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" ; exit 1; fi + +RUN \ + yum install -y gcc-c++ make + +RUN \ + curl -sL https://rpm.nodesource.com/setup_12.x | sudo -E bash - + +RUN \ + sudo yum install nodejs + +# Set a custom user to not have n8n run as root +USER root + +RUN npm_config_user=root npm install -g n8n@${N8N_VERSION} + +WORKDIR /data + +CMD "n8n" diff --git a/docker/images/n8n-rhel7/README.md b/docker/images/n8n-rhel7/README.md new file mode 100644 index 0000000000..015f7ac07f --- /dev/null +++ b/docker/images/n8n-rhel7/README.md @@ -0,0 +1,16 @@ +## Build Docker-Image + +``` +docker build --build-arg N8N_VERSION= -t n8nio/n8n: . + +# For example: +docker build --build-arg N8N_VERSION=0.36.1 -t n8nio/n8n:0.36.1-rhel7 . +``` + + +``` +docker run -it --rm \ + --name n8n \ + -p 5678:5678 \ + n8nio/n8n:0.25.0-ubuntu +``` diff --git a/docker/images/n8n-rpi/Dockerfile b/docker/images/n8n-rpi/Dockerfile new file mode 100644 index 0000000000..b60d50bdeb --- /dev/null +++ b/docker/images/n8n-rpi/Dockerfile @@ -0,0 +1,20 @@ +FROM arm32v7/node:12.16 + +ARG N8N_VERSION + +RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" ; exit 1; fi + +RUN \ + apt-get update && \ + apt-get -y install graphicsmagick gosu + +RUN npm_config_user=root npm install -g full-icu n8n@${N8N_VERSION} + +ENV NODE_ICU_DATA /usr/local/lib/node_modules/full-icu +ENV NODE_ENV production + +WORKDIR /data + +USER node + +CMD n8n diff --git a/docker/images/n8n-rpi/README.md b/docker/images/n8n-rpi/README.md new file mode 100644 index 0000000000..9eca14e3f6 --- /dev/null +++ b/docker/images/n8n-rpi/README.md @@ -0,0 +1,21 @@ +## n8n - Raspberry PI Docker Image + +Dockerfile to build n8n for Raspberry PI. + +For information about how to run n8n with Docker check the generic +[Docker-Readme](https://github.com/n8n-io/n8n/tree/master/docker/images/n8n/README.md) + + +``` +docker build --build-arg N8N_VERSION= -t n8nio/n8n: . + +# For example: +docker build --build-arg N8N_VERSION=0.43.0 -t n8nio/n8n:0.43.0-rpi . +``` + +``` +docker run -it --rm \ + --name n8n \ + -p 5678:5678 \ + n8nio/n8n:0.70.0-rpi +``` diff --git a/docker/images/n8n-ubuntu/Dockerfile b/docker/images/n8n-ubuntu/Dockerfile index 200506f058..94935f0602 100644 --- a/docker/images/n8n-ubuntu/Dockerfile +++ b/docker/images/n8n-ubuntu/Dockerfile @@ -19,3 +19,5 @@ WORKDIR /data COPY docker-entrypoint.sh /docker-entrypoint.sh ENTRYPOINT ["/docker-entrypoint.sh"] + +EXPOSE 5678/tcp diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile index c0997dcabd..af8f29cc5c 100644 --- a/docker/images/n8n/Dockerfile +++ b/docker/images/n8n/Dockerfile @@ -22,3 +22,5 @@ WORKDIR /data COPY docker-entrypoint.sh /docker-entrypoint.sh ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"] + +EXPOSE 5678/tcp diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md index e1b39d82d6..977c53fef4 100644 --- a/docker/images/n8n/README.md +++ b/docker/images/n8n/README.md @@ -1,30 +1,28 @@ # n8n - Workflow Automation -![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) n8n is a free and open [fair-code](http://faircode.io) licensed node based Workflow Automation Tool. It can be self-hosted, easily extended, and so also used with internal tools. -n8n.io - Screenshot - +n8n.io - Screenshot ## Contents -- [Demo](#demo) -- [Available integrations](#available-integrations) -- [Documentation](#documentation) -- [Start n8n in Docker](#start-n8n-in-docker) -- [Start with tunnel](#start-with-tunnel) -- [Securing n8n](#securing-n8n) -- [Persist data](#persist-data) -- [Passing Sensitive Data via File](#passing-sensitive-data-via-file) -- [Updating a Running docker-compose Instance](#updating-a-running-docker-compose-instance) -- [Example Setup with Lets Encrypt](#example-setup-with-lets-encrypt) -- [What does n8n mean and how do you pronounce it](#what-does-n8n-mean-and-how-do-you-pronounce-it) -- [Support](#support) -- [Jobs](#jobs) -- [Upgrading](#upgrading) -- [License](#license) - + - [Demo](#demo) + - [Available integrations](#available-integrations) + - [Documentation](#documentation) + - [Start n8n in Docker](#start-n8n-in-docker) + - [Start with tunnel](#start-with-tunnel) + - [Securing n8n](#securing-n8n) + - [Persist data](#persist-data) + - [Passing Sensitive Data via File](#passing-sensitive-data-via-file) + - [Updating a Running docker-compose Instance](#updating-a-running-docker-compose-instance) + - [Example Setup with Lets Encrypt](#example-setup-with-lets-encrypt) + - [What does n8n mean and how do you pronounce it](#what-does-n8n-mean-and-how-do-you-pronounce-it) + - [Support](#support) + - [Jobs](#jobs) + - [Upgrading](#upgrading) + - [License](#license) ## Demo @@ -49,9 +47,9 @@ Additional information and example workflows on the n8n.io website: [https://n8n ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - n8nio/n8n + --name n8n \ + -p 5678:5678 \ + n8nio/n8n ``` You can then access n8n by opening: @@ -71,14 +69,13 @@ To use it simply start n8n with `--tunnel` ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n \ - n8n start --tunnel + --name n8n \ + -p 5678:5678 \ + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start --tunnel ``` - ## Securing n8n By default n8n can be accessed by everybody. This is OK if you have it only running @@ -93,7 +90,6 @@ N8N_BASIC_AUTH_USER= N8N_BASIC_AUTH_PASSWORD= ``` - ## Persist data The workflow data gets by default saved in an SQLite database in the user @@ -102,10 +98,10 @@ settings like webhook URL and encryption key. ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n + --name n8n \ + -p 5678:5678 \ + -v ~/.n8n:/root/.n8n \ + n8nio/n8n ``` ### Start with other Database @@ -121,7 +117,6 @@ for the credentials. If none gets found n8n creates automatically one on startup. In case credentials are already saved with a different encryption key it can not be used anymore as encrypting it is not possible anymore. - #### Use with MongoDB > **WARNING**: Use Postgres if possible! Mongo has problems with saving large @@ -129,40 +124,39 @@ it can not be used anymore as encrypting it is not possible anymore. > may be dropped in the future. Replace the following placeholders with the actual data: - - - - - - - - - - + - MONGO_DATABASE + - MONGO_HOST + - MONGO_PORT + - MONGO_USER + - MONGO_PASSWORD ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ + --name n8n \ + -p 5678:5678 \ -e DB_TYPE=mongodb \ -e DB_MONGODB_CONNECTION_URL="mongodb://:@:/" \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n \ - n8n start + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start ``` A full working setup with docker-compose can be found [here](https://github.com/n8n-io/n8n/blob/master/docker/compose/withMongo/README.md) - #### Use with PostgresDB Replace the following placeholders with the actual data: - - - - - - - - - - - - + - POSTGRES_DATABASE + - POSTGRES_HOST + - POSTGRES_PASSWORD + - POSTGRES_PORT + - POSTGRES_USER + - POSTGRES_SCHEMA ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ + --name n8n \ + -p 5678:5678 \ -e DB_TYPE=postgresdb \ -e DB_POSTGRESDB_DATABASE= \ -e DB_POSTGRESDB_HOST= \ @@ -170,39 +164,37 @@ docker run -it --rm \ -e DB_POSTGRESDB_USER= \ -e DB_POSTGRESDB_SCHEMA= \ -e DB_POSTGRESDB_PASSWORD= \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n \ - n8n start + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start ``` A full working setup with docker-compose can be found [here](https://github.com/n8n-io/n8n/blob/master/docker/compose/withPostgres/README.md) - #### Use with MySQL Replace the following placeholders with the actual data: - - - - - - - - - - + - MYSQLDB_DATABASE + - MYSQLDB_HOST + - MYSQLDB_PASSWORD + - MYSQLDB_PORT + - MYSQLDB_USER ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ + --name n8n \ + -p 5678:5678 \ -e DB_TYPE=mysqldb \ -e DB_MYSQLDB_DATABASE= \ -e DB_MYSQLDB_HOST= \ -e DB_MYSQLDB_PORT= \ -e DB_MYSQLDB_USER= \ -e DB_MYSQLDB_PASSWORD= \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n \ - n8n start + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start ``` - ## Passing Sensitive Data via File To avoid passing sensitive information via environment variables "_FILE" may be @@ -211,16 +203,15 @@ with the given name. That makes it possible to load data easily from Docker- and Kubernetes-Secrets. The following environment variables support file input: - - DB_MONGODB_CONNECTION_URL_FILE - - DB_POSTGRESDB_DATABASE_FILE - - DB_POSTGRESDB_HOST_FILE - - DB_POSTGRESDB_PASSWORD_FILE - - DB_POSTGRESDB_PORT_FILE - - DB_POSTGRESDB_USER_FILE - - DB_POSTGRESDB_SCHEMA_FILE - - N8N_BASIC_AUTH_PASSWORD_FILE - - N8N_BASIC_AUTH_USER_FILE - + - DB_MONGODB_CONNECTION_URL_FILE + - DB_POSTGRESDB_DATABASE_FILE + - DB_POSTGRESDB_HOST_FILE + - DB_POSTGRESDB_PASSWORD_FILE + - DB_POSTGRESDB_PORT_FILE + - DB_POSTGRESDB_USER_FILE + - DB_POSTGRESDB_SCHEMA_FILE + - N8N_BASIC_AUTH_PASSWORD_FILE + - N8N_BASIC_AUTH_USER_FILE ## Example Setup with Lets Encrypt @@ -235,7 +226,7 @@ docker pull n8nio/n8n # Stop current setup sudo docker-compose stop # Delete it (will only delete the docker-containers, data is stored separately) -sudo docker-compose rm +sudo docker-compose rm # Then start it again sudo docker-compose up -d ``` @@ -251,11 +242,11 @@ the environment variable `TZ`. Example to use the same timezone for both: ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ + --name n8n \ + -p 5678:5678 \ -e GENERIC_TIMEZONE="Europe/Berlin" \ -e TZ="Europe/Berlin" \ - n8nio/n8n + n8nio/n8n ``` diff --git a/docs/.nojekyll b/docs/.nojekyll deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 3ea71f7d8f..0000000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -docs.n8n.io \ No newline at end of file diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 3454cc15e2..0000000000 --- a/docs/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# n8n Documentation - -This is the documentation of n8n, a free and open [fair-code](http://faircode.io) licensed node-based Workflow Automation Tool. - -It covers everything from setup to usage and development. It is still a work in progress and all contributions are welcome. - - -## What is n8n? - -n8n (pronounced nodemation) helps you to interconnect every app with an API in the world with each other to share and manipulate its data without a single line of code. It is an easy to use, user-friendly and highly customizable service, which uses an intuitive user interface for you to design your unique workflows very fast. Hosted on your server and not based in the cloud, it keeps your sensible data very secure in your own trusted database. diff --git a/docs/_sidebar.md b/docs/_sidebar.md deleted file mode 100644 index 6cbe725286..0000000000 --- a/docs/_sidebar.md +++ /dev/null @@ -1,43 +0,0 @@ -- Home - - - [Welcome](/) - -- Getting started - - - [Key Components](key-components.md) - - [Quick Start](quick-start.md) - - [Setup](setup.md) - - [Tutorials](tutorials.md) - - [Docker](docker.md) - -- Advanced - - - [Configuration](configuration.md) - - [Data Structure](data-structure.md) - - [Database](database.md) - - [Keyboard Shortcuts](keyboard-shortcuts.md) - - [Node Basics](node-basics.md) - - [Nodes](nodes.md) - - [Security](security.md) - - [Sensitive Data](sensitive-data.md) - - [Server Setup](server-setup.md) - - [Start Workflows via CLI](start-workflows-via-cli.md) - - [Workflow](workflow.md) - -- Development - - - [Create Node](create-node.md) - - [Development](development.md) - - -- Other - - - [FAQ](faq.md) - - [License](license.md) - - [Troubleshooting](troubleshooting.md) - - -- Links - - - [![Jobs](https://n8n.io/favicon.ico ':size=16')Jobs](https://jobs.n8n.io) - - [![Website](https://n8n.io/favicon.ico ':size=16')n8n.io](https://n8n.io) diff --git a/docs/configuration.md b/docs/configuration.md deleted file mode 100644 index 63b8c95f12..0000000000 --- a/docs/configuration.md +++ /dev/null @@ -1,244 +0,0 @@ - - -# Configuration - -It is possible to change some of the n8n defaults via special environment variables. -The ones that currently exist are: - - -## Publish - -Sets how n8n should be made available. - -```bash -# The port n8n should be made available on -N8N_PORT=5678 - -# The IP address n8n should listen on -N8N_LISTEN_ADDRESS=0.0.0.0 - -# This ones are currently only important for the webhook URL creation. -# So if "WEBHOOK_TUNNEL_URL" got set they do get ignored. It is however -# encouraged to set them correctly anyway in case they will become -# important in the future. -N8N_PROTOCOL=https -N8N_HOST=n8n.example.com -``` - - -## Base URL - -Tells the frontend how to reach the REST API of the backend. - -```bash -export VUE_APP_URL_BASE_API="https://n8n.example.com/" -``` - - -## Execution Data Manual Runs - -n8n creates a random encryption key automatically on the first launch and saves -it in the `~/.n8n` folder. That key is used to encrypt the credentials before -they get saved to the database. It is also possible to overwrite that key and -set it via an environment variable. - -```bash -export N8N_ENCRYPTION_KEY="" -``` - - -## Execution Data Manual Runs - -Normally executions which got started via the Editor UI will not be saved as -they are normally only for testing and debugging. That default can be changed -with this environment variable. - -```bash -export EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS=true -``` - -This setting can also be overwritten on a per workflow basis in the workflow -settings in the Editor UI. - - -## Execution Data Error/Success - -When a workflow gets executed, it will save the result in the database. That's -the case for executions that succeeded and for the ones that failed. The -default behavior can be changed like this: - -```bash -export EXECUTIONS_DATA_SAVE_ON_ERROR=none -export EXECUTIONS_DATA_SAVE_ON_SUCCESS=none -``` - -Possible values are: - - **all**: Saves all data - - **none**: Does not save anything (recommended if a workflow runs very often and/or processes a lot of data, set up "Error Workflow" instead) - -These settings can also be overwritten on a per workflow basis in the workflow -settings in the Editor UI. - - -## Execute In Same Process - -All workflows get executed in their own separate process. This ensures that all CPU cores -get used and that they do not block each other on CPU intensive tasks. Additionally, this makes sure that -the crash of one execution does not take down the whole application. The disadvantage is, however, -that it slows down the start-time considerably and uses much more memory. So in case the -workflows are not CPU intensive and they have to start very fast, it is possible to run them -all directly in the main-process with this setting. - -```bash -export EXECUTIONS_PROCESS=main -``` - - -## Exclude Nodes - -It is possible to not allow users to use nodes of a specific node type. For example, if you -do not want that people can write data to the disk with the "n8n-nodes-base.writeBinaryFile" -node and that they cannot execute commands with the "n8n-nodes-base.executeCommand" node, you can -set the following: - -```bash -export NODES_EXCLUDE="[\"n8n-nodes-base.executeCommand\",\"n8n-nodes-base.writeBinaryFile\"]" -``` - - -## Custom Nodes Location - -Every user can add custom nodes that get loaded by n8n on startup. The default -location is in the subfolder `.n8n/custom` of the user who started n8n. -Additional folders can be defined with an environment variable. - -```bash -export N8N_CUSTOM_EXTENSIONS="/home/jim/n8n/custom-nodes;/data/n8n/nodes" -``` - - -## Use built-in and external modules in Function-Nodes - -For security reasons, importing modules is restricted by default in the Function-Nodes. -It is, however, possible to lift that restriction for built-in and external modules by -setting the following environment variables: -- `NODE_FUNCTION_ALLOW_BUILTIN`: For builtin modules -- `NODE_FUNCTION_ALLOW_EXTERNAL`: For external modules sourced from n8n/node_modules directory. External module support is disabled when env variable is not set. - -```bash -# Allows usage of all builtin modules -export NODE_FUNCTION_ALLOW_BUILTIN=* - -# Allows usage of only crypto -export NODE_FUNCTION_ALLOW_BUILTIN=crypto - -# Allows usage of only crypto and fs -export NODE_FUNCTION_ALLOW_BUILTIN=crypto,fs - -# Allow usage of external npm modules. Wildcard matching is not supported. -export NODE_FUNCTION_ALLOW_EXTERNAL=moment,lodash -``` - - -## SSL - -It is possible to start n8n with SSL enabled by supplying a certificate to use: - - -```bash -export N8N_PROTOCOL=https -export N8N_SSL_KEY=/data/certs/server.key -export N8N_SSL_CERT=/data/certs/server.pem -``` - - - -## Timezone - -The timezone is set by default to "America/New_York". For instance, it is used by the -Cron node to know at what time the workflow should be started. To set a different -default timezone simply set `GENERIC_TIMEZONE` to the appropriate value. For example, -if you want to set the timezone to Berlin (Germany): - -```bash -export GENERIC_TIMEZONE="Europe/Berlin" -``` - -You can find the name of your timezone here: -[https://momentjs.com/timezone/](https://momentjs.com/timezone/) - - -## User Folder - -User-specific data like the encryption key, SQLite database file, and -the ID of the tunnel (if used) gets saved by default in the subfolder -`.n8n` of the user who started n8n. It is possible to overwrite the -user-folder via an environment variable. - -```bash -export N8N_USER_FOLDER="/home/jim/n8n" -``` - - -## Webhook URL - -The webhook URL will normally be created automatically by combining -`N8N_PROTOCOL`, `N8N_HOST` and `N8N_PORT`. However, if n8n runs behind a -reverse proxy that would not work. That's because n8n runs internally -on port 5678 but is exposed to the web via the reverse proxy on port 443. In -that case, it is important to set the webhook URL manually so that it can be -displayed correctly in the Editor UI and even more important is that the correct -webhook URLs get registred with the external services. - -```bash -export WEBHOOK_TUNNEL_URL="https://n8n.example.com/" -``` - - -## Configuration via file - -It is also possible to configure n8n using a configuration file. - -It is not necessary to define all values but only the ones that should be -different from the defaults. - -If needed multiple files can also be supplied to. For example, have generic -base settings and some specific ones depending on the environment. - -The path to the JSON configuration file to use can be set using the environment -variable `N8N_CONFIG_FILES`. - -```bash -# Single file -export N8N_CONFIG_FILES=/folder/my-config.json - -# Multiple files can be comma-separated -export N8N_CONFIG_FILES=/folder/my-config.json,/folder/production.json -``` - -A possible configuration file could look like this: -```json -{ - "executions": { - "process": "main", - "saveDataOnSuccess": "none" - }, - "generic": { - "timezone": "Europe/Berlin" - }, - "security": { - "basicAuth": { - "active": true, - "user": "frank", - "password": "some-secure-password" - } - }, - "nodes": { - "exclude": "[\"n8n-nodes-base.executeCommand\",\"n8n-nodes-base.writeBinaryFile\"]" - } -} -``` - -All possible values which can be set and their defaults can be found here: - -[https://github.com/n8n-io/n8n/blob/master/packages/cli/config/index.ts](https://github.com/n8n-io/n8n/blob/master/packages/cli/config/index.ts) diff --git a/docs/create-node.md b/docs/create-node.md deleted file mode 100644 index aa19393983..0000000000 --- a/docs/create-node.md +++ /dev/null @@ -1,145 +0,0 @@ -# Create Node - -It is quite easy to create your own nodes in n8n. Mainly three things have to be defined: - - 1. Generic information like name, description, image/icon - 1. The parameters to display via which the user can interact with it - 1. The code to run once the node gets executed - -To simplify the development process, we created a very basic CLI which creates boilerplate code to get started, builds the node (as they are written in TypeScript), and copies it to the correct location. - - -## Create the first basic node - - 1. Install the n8n-node-dev CLI: `npm install -g n8n-node-dev` - 1. Create and go into the newly created folder in which you want to keep the code of the node - 1. Use CLI to create boilerplate node code: `n8n-node-dev new` - 1. Answer the questions (the “Execute” node type is the regular node type that you probably want to create). - It will then create the node in the current folder. - 1. Program… Add the functionality to the node - 1. Build the node and copy to correct location: `n8n-node-dev build` - That command will build the JavaScript version of the node from the TypeScript code and copy it to the user folder where custom nodes get read from `~/.n8n/custom/` - 1. Restart n8n and refresh the window so that the new node gets displayed - - -## Create own custom n8n-nodes-module - -If you want to create multiple custom nodes which are either: - - - Only for yourself/your company - - Are only useful for a small number of people - - Require many or large dependencies - -It is best to create your own `n8n-nodes-module` which can be installed separately. -That is a simple npm package that contains the nodes and is set up in a way -that n8n can automatically find and load them on startup. - -When creating such a module the following rules have to be followed that n8n -can automatically find the nodes in the module: - - - The name of the module has to start with `n8n-nodes-` - - The `package.json` file has to contain a key `n8n` with the paths to nodes and credentials - - The module has to be installed alongside n8n - -An example starter module which contains one node and credentials and implements -the above can be found here: - -[https://github.com/n8n-io/n8n-nodes-starter](https://github.com/n8n-io/n8n-nodes-starter) - - -### Setup to use n8n-nodes-module - -To use a custom `n8n-nodes-module`, it simply has to be installed alongside n8n. -For example like this: - -```bash -# Create folder for n8n installation -mkdir my-n8n -cd my-n8n - -# Install n8n -npm install n8n - -# Install custom nodes module -npm install n8n-nodes-my-custom-nodes - -# Start n8n -n8n -``` - - -### Development/Testing of custom n8n-nodes-module - -This works in the same way as for any other npm module. - -Execute in the folder which contains the code of the custom `n8n-nodes-module` -which should be loaded with n8n: - -```bash -# Build the code -npm run build - -# "Publish" the package locally -npm link -``` - -Then in the folder in which n8n is installed: - -```bash -# "Install" the above locally published module -npm link n8n-nodes-my-custom-nodes - -# Start n8n -n8n -``` - - - -## Node Development Guidelines - - -Please make sure that everything works correctly and that no unnecessary code gets added. It is important to follow the following guidelines: - - -### Do not change incoming data - -Never change the incoming data a node receives (which can be queried with `this.getInputData()`) as it gets shared by all nodes. If data has to get added, changed or deleted it has to be cloned and the new data returned. If that is not done, sibling nodes which execute after the current one will operate on the altered data and would process different data than they were supposed to. -It is however not needed to always clone all the data. If a node for, example only, changes only the binary data but not the JSON data, a new item can be created which reuses the reference to the JSON item. - -An example can be seen in the code of the [ReadBinaryFile-Node](https://github.com/n8n-io/n8n/blob/master/packages/nodes-base/nodes/ReadBinaryFile.node.ts#L69-L83). - - -### Write nodes in TypeScript - -All code of n8n is written in TypeScript and hence, the nodes should also be written in TypeScript. That makes development easier, faster, and avoids at least some bugs. - - -### Use the built in request library - -Some third-party services have their own libraries on npm which make it easier to create an integration. It can be quite tempting to use them. The problem with those is that you add another dependency and not just one you add but also all the dependencies of the dependencies. This means more and more code gets added, has to get loaded, can introduce security vulnerabilities, bugs and so on. So please use the built-in module which can be used like this: - -```typescript -const response = await this.helpers.request(options); -``` - -That is simply using the npm package [`request-promise-native`](https://github.com/request/request-promise-native) which is the basic npm `request` module but with promises. For a full set of `options` consider looking at [the underlying `request` options documentation](https://github.com/request/request#requestoptions-callback). - - -### Reuse parameter names - -When a node can perform multiple operations like edit and delete some kind of entity, for both operations, it would need an entity-id. Do not call them "editId" and "deleteId" simply call them "id". n8n can handle multiple parameters with the same name without a problem as long as only one is visible. To make sure that is the case, the "displayOptions" can be used. By keeping the same name, the value can be kept if a user switches the operation from "edit" to "delete". - - -### Create an "Options" parameter - -Some nodes may need a lot of options. Add only the very important ones to the top level and for all others, create an "Options" parameter where they can be added if needed. This ensures that the interface stays clean and does not unnecessarily confuse people. A good example of that would be the XML node. - - -### Follow exiting parameter naming guideline - -There is not much of a guideline yet but if your node can do multiple things, call the parameter which sets the behavior either "mode" (like "Merge" and "XML" node) or "operation" like the most other ones. If these operations can be done on different resources (like "User" or "Order) create a "resource" parameter (like "Pipedrive" and "Trello" node) - - -### Node Icons - -Check existing node icons as a reference when you create own ones. The resolution of an icon should be 60x60px and saved as PNG. diff --git a/docs/data-structure.md b/docs/data-structure.md deleted file mode 100644 index daeea8474d..0000000000 --- a/docs/data-structure.md +++ /dev/null @@ -1,39 +0,0 @@ -# Data Structure - -For "basic usage" it is not necessarily needed to understand how the data that -gets passed from one node to another is structured. However, it becomes important if you want to: - - - create your own node - - write custom expressions - - use the Function or Function Item node - - you want to get the most out of n8n - - -In n8n, all the data that is passed between nodes is an array of objects. It has the following structure: - -```json -[ - { - // Each item has to contain a "json" property. But it can be an empty object like {}. - // Any kind of JSON data is allowed. So arrays and the data being deeply nested is fine. - json: { // The actual data n8n operates on (required) - // This data is only an example it could be any kind of JSON data - jsonKeyName: 'keyValue', - anotherJsonKey: { - lowerLevelJsonKey: 1 - } - }, - // Binary data of item. The most items in n8n do not contain any (optional) - binary: { - // The key-name "binaryKeyName" is only an example. Any kind of key-name is possible. - binaryKeyName: { - data: '....', // Base64 encoded binary data (required) - mimeType: 'image/png', // Optional but should be set if possible (optional) - fileExtension: 'png', // Optional but should be set if possible (optional) - fileName: 'example.png', // Optional but should be set if possible (optional) - } - } - }, - ... -] -``` diff --git a/docs/database.md b/docs/database.md deleted file mode 100644 index 041520cf15..0000000000 --- a/docs/database.md +++ /dev/null @@ -1,109 +0,0 @@ -# Database - -By default, n8n uses SQLite to save credentials, past executions, and workflows. However, -n8n also supports MongoDB and PostgresDB. - - -## Shared Settings - -The following environment variables get used by all databases: - - - `DB_TABLE_PREFIX` (default: '') - Prefix for table names - - -## MongoDB - -!> **WARNING**: Use PostgresDB, if possible! MongoDB has problems saving large - amounts of data in a document, among other issues. So, support - may be dropped in the future. - -To use MongoDB as the database, you can provide the following environment variables like -in the example below: - - `DB_TYPE=mongodb` - - `DB_MONGODB_CONNECTION_URL=` - -Replace the following placeholders with the actual data: - - MONGO_DATABASE - - MONGO_HOST - - MONGO_PORT - - MONGO_USER - - MONGO_PASSWORD - -```bash -export DB_TYPE=mongodb -export DB_MONGODB_CONNECTION_URL=mongodb://MONGO_USER:MONGO_PASSWORD@MONGO_HOST:MONGO_PORT/MONGO_DATABASE -n8n start -``` - - -## PostgresDB - -To use PostgresDB as the database, you can provide the following environment variables - - `DB_TYPE=postgresdb` - - `DB_POSTGRESDB_DATABASE` (default: 'n8n') - - `DB_POSTGRESDB_HOST` (default: 'localhost') - - `DB_POSTGRESDB_PORT` (default: 5432) - - `DB_POSTGRESDB_USER` (default: 'root') - - `DB_POSTGRESDB_PASSWORD` (default: empty) - - `DB_POSTGRESDB_SCHEMA` (default: 'public') - - -```bash -export DB_TYPE=postgresdb -export DB_POSTGRESDB_DATABASE=n8n -export DB_POSTGRESDB_HOST=postgresdb -export DB_POSTGRESDB_PORT=5432 -export DB_POSTGRESDB_USER=n8n -export DB_POSTGRESDB_PASSWORD=n8n -export DB_POSTGRESDB_SCHEMA=n8n - -n8n start -``` - -## MySQL / MariaDB - -The compatibility with MySQL/MariaDB has been tested. Even then, it is advisable to observe the operation of the application with this database as this option has been recently added. If you spot any problems, feel free to submit a burg report or a pull request. - -To use MySQL as database you can provide the following environment variables: - - `DB_TYPE=mysqldb` or `DB_TYPE=mariadb` - - `DB_MYSQLDB_DATABASE` (default: 'n8n') - - `DB_MYSQLDB_HOST` (default: 'localhost') - - `DB_MYSQLDB_PORT` (default: 3306) - - `DB_MYSQLDB_USER` (default: 'root') - - `DB_MYSQLDB_PASSWORD` (default: empty) - - -```bash -export DB_TYPE=mysqldb -export DB_MYSQLDB_DATABASE=n8n -export DB_MYSQLDB_HOST=mysqldb -export DB_MYSQLDB_PORT=3306 -export DB_MYSQLDB_USER=n8n -export DB_MYSQLDB_PASSWORD=n8n - -n8n start -``` - -## SQLite - -This is the default database that gets used if nothing is defined. - -The database file is located at: -`~/.n8n/database.sqlite` - - -## Other Databases - -Currently, only the databases mentioned above are supported. n8n internally uses -[TypeORM](https://typeorm.io), so adding support for the following databases -should not be too much work: - - - CockroachDB - - Microsoft SQL - - Oracle - -If you cannot use any of the currently supported databases for some reason and -you can code, we'd appreciate your support in the form of a pull request. If not, you can request -for support here: - -[https://community.n8n.io/c/feature-requests/cli](https://community.n8n.io/c/feature-requests/cli) diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index d7d8de4744..0000000000 --- a/docs/development.md +++ /dev/null @@ -1,3 +0,0 @@ -# Development - -Have you found a bug :bug:? Or maybe you have a nice feature :sparkles: to contribute? The [CONTRIBUTING guide](https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md) will help you get your development environment ready in minutes. diff --git a/docs/docker.md b/docs/docker.md deleted file mode 100644 index 317b5b9552..0000000000 --- a/docs/docker.md +++ /dev/null @@ -1,7 +0,0 @@ -# Docker - -Detailed information about how to run n8n in Docker can be found in the README -of the [Docker Image](https://github.com/n8n-io/n8n/blob/master/docker/images/n8n/README.md). - -A basic step by step example setup of n8n with docker-compose and Let's Encrypt is available on the -[Server Setup](server-setup.md) page. diff --git a/docs/faq.md b/docs/faq.md deleted file mode 100644 index 2b03a0b76d..0000000000 --- a/docs/faq.md +++ /dev/null @@ -1,47 +0,0 @@ -# FAQ - -## Integrations - - -### Can you create an integration for service X? - -You can request new integrations to be added to our forum. There is a special section for that where -other users can also upvote it so that we know which integrations are important and should be -created next. Request a new feature [here](https://community.n8n.io/c/feature-requests/nodes). - - -### An integration exists already but a feature is missing. Can you add it? - -Adding new functionality to an existing integration is normally not that complicated. So the chance is -high that we can do that quite fast. Post your feature request in the forum and we'll see -what we can do. Request a new feature [here](https://community.n8n.io/c/feature-requests/nodes). - - -### How can I create an integration myself? - -Information about that can be found in the [CONTRIBUTING guide](https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md). - - -## License - - -### Which license does n8n use? - -n8n is [fair-code](http://faircode.io) licensed under [Apache 2.0 with Commons Clause](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) - - -### Is n8n open-source? - -No. The [Commons Clause](https://commonsclause.com) that is attached to the Apache 2.0 license takes away some rights. Hence, according to the definition of the [Open Source Initiative (OSI)](https://opensource.org/osd), n8n is not open-source. Nonetheless, the source code is open and everyone (individuals and companies) can use it for free. However, it is not allowed to make money directly with n8n. - -For instance, one cannot charge others to host or support n8n. However, to make things simpler, we grant everyone (individuals and companies) the right to offer consulting or support without prior permission as long as it is less than 30,000 USD ($30k) per annum. -If your revenue from services based on n8n is greater than $30k per annum, we'd invite you to become a partner and apply for a license. If you have any questions about this, feel free to reach out to us at [license@n8n.io](mailto:license@n8n.io). - - -### Why is n8n not open-source but [fair-code](http://faircode.io) licensed instead? - -We love open-source and the idea that everybody can freely use and extend what we wrote. Our community is at the heart of everything that we do and we understand that people who contribute to a project are the main drivers that push a project forward. So to make sure that the project continues to evolve and stay alive in the longer run, we decided to attach the Commons Clause. This ensures that no other person or company can make money directly with n8n. Especially if it competes with how we plan to finance our further development. For the greater majority of the people, it will not make any difference at all. At the same time, it protects the project. - -As n8n itself depends on and uses a lot of other open-source projects, it is only fair that we support them back. That is why we have planned to contribute a certain percentage of revenue/profit every month to these projects. - -We have already started with the first monthly contributions via [Open Collective](https://opencollective.com/n8n). It is not much yet, but we hope to be able to ramp that up substantially over time. diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index bd53af45e6..0000000000 --- a/docs/index.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - n8n Documentation - - - - - - - - -
- - - - - - - - - - - - - diff --git a/docs/key-components.md b/docs/key-components.md deleted file mode 100644 index 9caba3a0b0..0000000000 --- a/docs/key-components.md +++ /dev/null @@ -1,25 +0,0 @@ -# Key Components - - -## Connection - -A connection establishes a link between nodes to route data through the workflow. Each node can have one or multiple connections. - - -## Node - -A node is an entry point for retrieving data, a function to process data or an exit for sending data. The data process includes filtering, recomposing and changing data. There can be one or several nodes for your API, service or app. You can easily connect multiple nodes, which allows you to create simple and complex workflows with them intuitively. - -For example, consider a Google Sheets node. It can be used to retrieve or write data to a Google Sheet. - - -## Trigger Node - -A trigger node is a node that starts a workflow and supplies the initial data. What triggers it, depends on the node. It could be the time, a webhook call or an event from an external service. - -For example, consider a Trello trigger node. When a Trello Board gets updated, it will trigger a workflow to start using the data from the updated board. - - -## Workflow - -A workflow is a canvas on which you can place and connect nodes. A workflow can be started manually or by trigger nodes. A workflow run ends when all active and connected nodes have processed their data. diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md deleted file mode 100644 index ace6db8d8e..0000000000 --- a/docs/keyboard-shortcuts.md +++ /dev/null @@ -1,28 +0,0 @@ -# Keyboard Shortcuts - -The following keyboard shortcuts can currently be used: - -## General - - - **Ctrl + Left Mouse Button**: Move/Pan Node View - - **Ctrl + a**: Select all nodes - - **Ctrl + Alt + n**: Create new workflow - - **Ctrl + o**: Open workflow - - **Ctrl + s**: Save the current workflow - - **Ctrl + v**: Paste nodes - - **Tab**: Open "Node Creator". Type to filter and navigate with arrow keys. To create press "enter" - - -## With node(s) selected - - - **ArrowDown**: Select sibling node bellow the current one - - **ArrowLeft**: Select node left of the current one - - **ArrowRight**: Select node right of the current one - - **ArrowUp**: Select sibling node above the current one - - **Ctrl + c**: Copy nodes - - **Ctrl + x**: Cut nodes - - **d**: Deactivate nodes - - **Delete**: Delete nodes - - **F2**: Rename node - - **Shift + ArrowLeft**: Select all nodes left of the current one - - **Shift + ArrowRight**: Select all nodes right of the current one diff --git a/docs/license.md b/docs/license.md deleted file mode 100644 index ace732e676..0000000000 --- a/docs/license.md +++ /dev/null @@ -1,5 +0,0 @@ -# License - -n8n is [fair-code](http://faircode.io) licensed under [Apache 2.0 with Commons Clause](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) - -Additional information about the license can be found in the [FAQ](faq.md?id=license) and [fair-code](http://faircode.io). diff --git a/docs/node-basics.md b/docs/node-basics.md deleted file mode 100644 index aea6bf6e33..0000000000 --- a/docs/node-basics.md +++ /dev/null @@ -1,76 +0,0 @@ -# Node Basics - - -## Types - -There are two main node types in n8n: Trigger nodes and Regular nodes. - - -### Trigger Nodes - -The Trigger nodes start a workflow and supply the initial data. A workflow can contain multiple trigger nodes but with each execution, only one of them will execute. This is because the other trigger nodes would not have any input as they are the nodes from which the execution of the workflow starts. - - -### Regular Nodes - -These nodes do the actual work. They can add, remove, and edit the data in the flow as well as request and send data to external APIs. They can do everything possible with Node.js in general. - - -## Credentials - -External services need a way to identify and authenticate users. This data can range from an API key over an email/password combination to a very long multi-line private key and can be saved in n8n as credentials. - -Nodes in n8n can then request that credential information. As an additional layer of security credentials can only be accessed by node types which specifically have the right to do so. - -To make sure that the data is secure, it gets saved to the database encrypted. A random personal encryption key is used which gets automatically generated on the first run of n8n and then saved under `~/.n8n/config`. - - -## Expressions - -With the help of expressions, it is possible to set node parameters dynamically by referencing other data. That can be data from the flow, nodes, the environment or self-generated data. Expressions are normal text with placeholders (everything between {{...}}) that can execute JavaScript code, which offers access to special variables to access data. - -An expression could look like this: - -My name is: `{{$node["Webhook"].json["query"]["name"]}}` - -This one would return "My name is: " and then attach the value that the node with the name "Webhook" outputs and there select the property "query" and its key "name". So if the node would output this data: - -```json -{ - "query": { - "name": "Jim" - } -} -``` - -the value would be: "My name is: Jim" - -The following special variables are available: - - - **$binary**: Incoming binary data of a node - - **$evaluateExpression**: Evaluates a string as expression - - **$env**: Environment variables - - **$items**: Environment variables - - **$json**: Incoming JSON data of a node - - **$node**: Data of other nodes (binary, context, json, parameter, runIndex) - - **$parameters**: Parameters of the current node - - **$runIndex**: The current run index (first time node gets executed it is 0, second time 1, ...) - - **$workflow**: Returns workflow metadata like: active, id, name - -Normally it is not needed to write the JavaScript variables manually as they can be selected with the help of the Expression Editor. - - -## Parameters - -Parameters can be set for most nodes in n8n. The values that get set define what exactly a node does. - -Parameter values are static by default and are always the same no matter what kind of data the node processes. However, it is possible to set the values dynamically with the help of an Expression. Using Expressions, it is possible to make the parameter value dependent on other factors like the data of flow or parameters of other nodes. - -More information about it can be found under [Expressions](#expressions). - - -## Pausing Node - -Sometimes when creating and debugging a workflow, it is helpful to not execute specific nodes. To do that without disconnecting each node, you can pause them. When a node gets paused, the data passes through the node without being changed. - -There are two ways to pause a node. You can either press the pause button which gets displayed above the node when hovering over it or select the node and press “d”. diff --git a/docs/nodes.md b/docs/nodes.md deleted file mode 100644 index 8b9c7cbf0e..0000000000 --- a/docs/nodes.md +++ /dev/null @@ -1,247 +0,0 @@ -# Nodes - -## Function and Function Item Nodes - -These are the most powerful nodes in n8n. With these, almost everything can be done if you know how to -write JavaScript code. Both nodes work very similarly. They give you access to the incoming data -and you can manipulate it. - - -### Difference between both nodes - -The difference is that the code of the Function node gets executed only once. It receives the -full items (JSON and binary data) as an array and expects an array of items as a return value. The items -returned can be totally different from the incoming ones. So it is not only possible to remove and edit -existing items, but also to add or return totally new ones. - -The code of the Function Item node on the other hand gets executed once for every item. It receives -one item at a time as input and also just the JSON data. As a return value, it expects the JSON data -of one single item. That makes it possible to add, remove and edit JSON properties of items -but it is not possible to add new or remove existing items. Accessing and changing binary data is only -possible via the methods `getBinaryData` and `setBinaryData`. - -Both nodes support promises. So instead of returning the item or items directly, it is also possible to -return a promise which resolves accordingly. - - -### Function-Node - -#### Variable: items - -It contains all the items that the node received as input. - -Information about how the data is structured can be found on the page [Data Structure](data-structure.md). - -The data can be accessed and manipulated like this: - -```typescript -// Sets the JSON data property "myFileName" of the first item to the name of the -// file which is set in the binary property "image" of the same item. -items[0].json.myFileName = items[0].binary.image.fileName; - -return items; -``` - -This example creates 10 dummy items with the ids 0 to 9: - -```typescript -const newItems = []; - -for (let i=0;i<10;i++) { - newItems.push({ - json: { - id: i - } - }); -} - -return newItems; -``` - - -#### Method: $item(index: number, runIndex?: number) - -With `$item` it is possible to access the data of parent nodes. That can be the item data but also -the parameters. It expects as input an index of the item the data should be returned for. This is -needed because for each item the data returned can be different. This is probably obvious for the -item data itself but maybe less for data like parameters. The reason why it is also needed, is -that they may contain an expression. Expressions get always executed of the context for an item. -If that would not be the case, for example, the Email Send node not would be able to send multiple -emails at once to different people. Instead, the same person would receive multiple emails. - -The index is 0 based. So `$item(0)` will return the first item, `$item(1)` the second one, ... - -By default the item of the last run of the node will be returned. So if the referenced node ran -3x (its last runIndex is 2) and the current node runs the first time (its runIndex is 0) the -data of runIndex 2 of the referenced node will be returned. - -For more information about what data can be accessed via $node, check [here](#variable-node). - -Example: - -```typescript -// Returns the value of the JSON data property "myNumber" of Node "Set" (first item) -const myNumber = $item(0).$node["Set"].json["myNumber"]; -// Like above but data of the 6th item -const myNumber = $item(5).$node["Set"].json["myNumber"]; - -// Returns the value of the parameter "channel" of Node "Slack". -// If it contains an expression the value will be resolved with the -// data of the first item. -const channel = $item(0).$node["Slack"].parameter["channel"]; -// Like above but resolved with the value of the 10th item. -const channel = $item(9).$node["Slack"].parameter["channel"]; -``` - - -#### Method: $items(nodeName?: string, outputIndex?: number, runIndex?: number) - -Gives access to all the items of current or parent nodes. If no parameters get supplied, -it returns all the items of the current node. -If a node-name is given, it returns the items the node output on its first output -(index: 0, most nodes only have one output, exceptions are IF and Switch-Node) on -its last run. - -Example: - -```typescript -// Returns all the items of the current node and current run -const allItems = $items(); - -// Returns all items the node "IF" outputs (index: 0 which is Output "true" of its most recent run) -const allItems = $items("IF"); - -// Returns all items the node "IF" outputs (index: 0 which is Output "true" of the same run as current node) -const allItems = $items("IF", 0, $runIndex); - -// Returns all items the node "IF" outputs (index: 1 which is Output "false" of run 0 which is the first run) -const allItems = $items("IF", 1, 0); -``` - - -#### Variable: $node - -Works exactly like `$item` with the difference that it will always return the data of the first item and -the last run of the node. - -```typescript -// Returns the fileName of binary property "data" of Node "HTTP Request" -const fileName = $node["HTTP Request"].binary["data"]["fileName"]}} - -// Returns the context data "noItemsLeft" of Node "SplitInBatches" -const noItemsLeft = $node["SplitInBatches"].context["noItemsLeft"]; - -// Returns the value of the JSON data property "myNumber" of Node "Set" -const myNumber = $node["Set"].json['myNumber']; - -// Returns the value of the parameter "channel" of Node "Slack" -const channel = $node["Slack"].parameter["channel"]; - -// Returns the index of the last run of Node "HTTP Request" -const runIndex = $node["HTTP Request"].runIndex}} -``` - - -#### Variable: $runIndex - -Contains the index of the current run of the node. - -```typescript -// Returns all items the node "IF" outputs (index: 0 which is Output "true" of the same run as current node) -const allItems = $items("IF", 0, $runIndex); -``` - - -#### Variable: $workflow - -Gives information about the current workflow. - -```typescript -const isActive = $workflow.active; -const workflowId = $workflow.id; -const workflowName = $workflow.name; -``` - - -#### Method: $evaluateExpression(expression: string, itemIndex: number) - -Evaluates a given string as expression. -If no `itemIndex` is provided it uses by default in the Function-Node the data of item 0 and -in the Function Item-Node the data of the current item. - -Example: - -```javascript -items[0].json.variable1 = $evaluateExpression('{{1+2}}'); -items[0].json.variable2 = $evaluateExpression($node["Set"].json["myExpression"], 1); - -return items; -``` - - -#### Method: getWorkflowStaticData(type) - -Gives access to the static workflow data. -It is possible to save data directly with the workflow. This data should, however, be very small. -A common use case is to for example to save a timestamp of the last item that got processed from -an RSS-Feed or database. It will always return an object. Properties can then read, delete or -set on that object. When the workflow execution succeeds, n8n will check automatically if the data -has changed and will save it, if necessary. - -There are two types of static data. The "global" and the "node" one. Global static data is the -same in the whole workflow. And every node in the workflow can access it. The node static data -, however, is different for every node and only the node which set it can retrieve it again. - -Example: - -```javascript -// Get the global workflow static data -const staticData = getWorkflowStaticData('global'); -// Get the static data of the node -const staticData = getWorkflowStaticData('node'); - -// Access its data -const lastExecution = staticData.lastExecution; - -// Update its data -staticData.lastExecution = new Date().getTime(); - -// Delete data -delete staticData.lastExecution; -``` - -It is important to know that the static data can not be read and written when testing via the UI. -The data there will always be empty and the changes will not persist. Only when a workflow -is active and it gets called by a Trigger or Webhook, the static data will be saved. - - - -### Function Item-Node - - -#### Variable: item - -It contains the "json" data of the currently processed item. - -The data can be accessed and manipulated like this: - -```json -// Uses the data of an already existing key to create a new additional one -item.newIncrementedCounter = item.existingCounter + 1; -return item; -``` - - -#### Method: getBinaryData() - -Returns all the binary data (all keys) of the item which gets currently processed. - - -#### Method: setBinaryData(binaryData) - -Sets all the binary data (all keys) of the item which gets currently processed. - - -#### Method: getWorkflowStaticData(type) - -As described above for Function node. diff --git a/docs/quick-start.md b/docs/quick-start.md deleted file mode 100644 index 0c33cafbe8..0000000000 --- a/docs/quick-start.md +++ /dev/null @@ -1,43 +0,0 @@ -# Quick Start - - -## Give n8n a spin - -To spin up n8n, you can run: - -```bash -npx n8n -``` - -It will download everything that is needed to start n8n. - -You can then access n8n by opening: -[http://localhost:5678](http://localhost:5678) - - -## Start with docker - -To play around with n8n, you can also start it using docker: - -```bash -docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - n8nio/n8n -``` - -Be aware that all the data will be lost once the docker container gets removed. To -persist the data mount the `~/.n8n` folder: - -```bash -docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n -``` - -More information about the Docker setup can be found in the README file of the -[Docker Image](https://github.com/n8n-io/n8n/blob/master/docker/images/n8n/README.md). - -In case you run into issues, check out the [troubleshooting](troubleshooting.md) page or ask for help in the community [forum](https://community.n8n.io/). diff --git a/docs/security.md b/docs/security.md deleted file mode 100644 index 5682b2c29a..0000000000 --- a/docs/security.md +++ /dev/null @@ -1,13 +0,0 @@ -# Security - -By default, n8n can be accessed by everybody. This is okay if you only have it running -locally but if you deploy it on a server which is accessible from the web, you have -to make sure that n8n is protected. -Right now we have very basic protection in place using basic-auth. It can be activated -by setting the following environment variables: - -```bash -export N8N_BASIC_AUTH_ACTIVE=true -export N8N_BASIC_AUTH_USER= -export N8N_BASIC_AUTH_PASSWORD= -``` diff --git a/docs/sensitive-data.md b/docs/sensitive-data.md deleted file mode 100644 index fa7b0bb1b6..0000000000 --- a/docs/sensitive-data.md +++ /dev/null @@ -1,18 +0,0 @@ -# Sensitive Data via File - -To avoid passing sensitive information via environment variables, "_FILE" may be -appended to some environment variables. It will then load the data from a file -with the given name. That makes it possible to load data easily from -Docker and Kubernetes secrets. - -The following environment variables support file input: - - - DB_MONGODB_CONNECTION_URL_FILE - - DB_POSTGRESDB_DATABASE_FILE - - DB_POSTGRESDB_HOST_FILE - - DB_POSTGRESDB_PASSWORD_FILE - - DB_POSTGRESDB_PORT_FILE - - DB_POSTGRESDB_USER_FILE - - DB_POSTGRESDB_SCHEMA_FILE - - N8N_BASIC_AUTH_PASSWORD_FILE - - N8N_BASIC_AUTH_USER_FILE diff --git a/docs/server-setup.md b/docs/server-setup.md deleted file mode 100644 index d34d076a6f..0000000000 --- a/docs/server-setup.md +++ /dev/null @@ -1,183 +0,0 @@ -# Server Setup - -!> ***Important***: Make sure that you secure your n8n instance as described under [Security](security.md). - - -## Example setup with docker-compose - -If you have already installed docker and docker-compose, then you can directly start with step 4. - - -### 1. Install Docker - -This can vary depending on the Linux distribution used. Example bellow is for Ubuntu: - -```bash -sudo apt update -sudo apt install apt-transport-https ca-certificates curl software-properties-common -curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - -sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable" -sudo apt update -sudo apt install docker-ce -y -``` - -### 2. Optional: If it should run as not root user - -Run when logged in as the user that should also be allowed to run docker: - -```bash -sudo usermod -aG docker ${USER} -su - ${USER} -``` - -### 3. Install Docker-compose - -This can vary depending on the Linux distribution used. Example bellow is for Ubuntu: - -Check before what version the latestand replace "1.24.1" with that version accordingly. -https://github.com/docker/compose/releases - -```bash -sudo curl -L https://github.com/docker/compose/releases/download/1.24.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose -sudo chmod +x /usr/local/bin/docker-compose -``` - - -### 4. Setup DNS - -Add A record to route the subdomain accordingly. - -``` -Type: A -Name: n8n (or whatever the subdomain should be) -IP address: -``` - - -### 5. Create docker-compose file - -Save this file as `docker-compose.yml` - -Normally no changes should be needed. - -```yaml -version: "3" - -services: - traefik: - image: "traefik" - command: - - "--api=true" - - "--api.insecure=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" - ports: - - "443:443" - 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(`${SUBDOMAIN}.${DOMAIN_NAME}`) - - traefik.http.routers.n8n.tls=true - - traefik.http.routers.n8n.entrypoints=websecure - - 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=${SUBDOMAIN}.${DOMAIN_NAME} - - N8N_PORT=5678 - - N8N_LISTEN_ADDRESS=0.0.0.0 - - N8N_PROTOCOL=https - - NODE_ENV=production - - WEBHOOK_TUNNEL_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/ - - VUE_APP_URL_BASE_API=https://${SUBDOMAIN}.${DOMAIN_NAME}/ - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ${DATA_FOLDER}/.n8n:/root/.n8n -``` - - -### 6. Create `.env` file - -Create `.env` file and change it accordingly. - -```bash -# Folder where data should be saved -DATA_FOLDER=/root/n8n/ - -# The top level domain to serve from -DOMAIN_NAME=example.com - -# The subdomain to serve from -SUBDOMAIN=n8n - -# DOMAIN_NAME and SUBDOMAIN combined decide where n8n will be reachable from -# above example would result in: https://n8n.example.com - -# 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 -``` - - -### 7. Create data folder - -Create the folder which is defined as `DATA_FOLDER`. In the example -above, it is `/root/n8n/`. - -In that folder, the database file from SQLite as well as the encryption key will be saved. - -The folder can be created like this: -``` -mkdir /root/n8n/ -``` - - -### 8. Start docker-compose setup - -n8n can now be started via: - -```bash -sudo docker-compose up -d -``` - -In case it should ever be stopped that can be done with this command: -```bash -sudo docker-compose stop -``` - - -### 9. Done - -n8n will now be reachable via the above defined subdomain + domain combination. -The above example would result in: https://n8n.example.com - -n8n will only be reachable via https and not via http. diff --git a/docs/setup.md b/docs/setup.md deleted file mode 100644 index 27e65e54ea..0000000000 --- a/docs/setup.md +++ /dev/null @@ -1,35 +0,0 @@ -# Setup - - -## Installation - -To install n8n globally: - -```bash -npm install n8n -g -``` - -## Start - -After the installation n8n can be started by simply typing in: - -```bash -n8n -# or -n8n start -``` - - -## Start with tunnel - -!> **WARNING**: This is only meant for local development and testing. It should not be used in production! - -To be able to use webhooks for trigger nodes of external services like GitHub, n8n has to be reachable from the web. To make that easy, n8n has a special tunnel service, which redirects requests from our servers to your local n8n instance (uses this code: [https://github.com/localtunnel/localtunnel](https://github.com/localtunnel/localtunnel)). - -To use it, simply start n8n with `--tunnel` - -```bash -n8n start --tunnel -``` - -In case you run into issues, check out the [troubleshooting](troubleshooting.md) page or ask for help in the community [forum](https://community.n8n.io/). diff --git a/docs/start-workflows-via-cli.md b/docs/start-workflows-via-cli.md deleted file mode 100644 index 6327f32963..0000000000 --- a/docs/start-workflows-via-cli.md +++ /dev/null @@ -1,15 +0,0 @@ -# Start Workflows via CLI - -Workflows cannot be only started by triggers, webhooks or manually via the -Editor. It is also possible to start them directly via the CLI. - -Execute a saved workflow by its ID: - -```bash -n8n execute --id -``` - -Execute a workflow from a workflow file: -```bash -n8n execute --file -``` diff --git a/docs/test.md b/docs/test.md deleted file mode 100644 index 02a308b3ad..0000000000 --- a/docs/test.md +++ /dev/null @@ -1,3 +0,0 @@ -# This is a simple test - -with some text diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md deleted file mode 100644 index 7e6b4b058b..0000000000 --- a/docs/troubleshooting.md +++ /dev/null @@ -1,58 +0,0 @@ -# Troubleshooting - -## Windows - -If you are experiencing issues running n8n with the typical flow of: - -```powershell -npx n8n -``` - -### Requirements - -Please ensure that you have the following requirements fulfilled: - -- Install latest version of [NodeJS](https://nodejs.org/en/download/) -- Install [Python 2.7](https://www.python.org/downloads/release/python-2717/) (It is okay to have multiple versions installed on the machine) -- Windows SDK -- C++ Desktop Development Tools -- Windows Build Tools - -#### Install build tools - -If you haven't satisfied the above, follow this procedure through your PowerShell (run with administrative privileges). -This command installs the build tools, windows SDK and the C++ development tools in one package. - -```powershell -npm install --global --production windows-build-tools -``` - -#### Configure npm to use Python version 2.7 - -```powershell -npm config set python python2.7 -``` - -#### Configure npm to use correct msvs version - -```powershell -npm config set msvs_version 2017 --global -``` - -### Lesser known issues: - -#### mmmagic npm package when using MSbuild tools with Visual Studio - -While installing this package, `node-gyp` is run and it might fail to install it with an error appearing in the ballpark of: - -``` -gyp ERR! stack Error: spawn C:\Program Files (x86)\Microsoft Visual Studio\2019\**Enterprise**\MSBuild\Current\Bin\MSBuild.exe ENOENT -``` - -It is seeking the `MSBuild.exe` in a directory that does not exist. If you are using Visual Studio Community or vice versa, you can change the path of MSBuild with command: - -```powershell -npm config set msbuild_path "C:\Program Files (x86)\Microsoft Visual Studio\2019\**Community**\MSBuild\Current\Bin\MSBuild.exe" -``` - -Attempt to install package again after running the command above. diff --git a/docs/tutorials.md b/docs/tutorials.md deleted file mode 100644 index 527274be53..0000000000 --- a/docs/tutorials.md +++ /dev/null @@ -1,26 +0,0 @@ -# Tutorials - - -## Examples - -Example workflows which show what can be done with n8n can be found here: -[https://n8n.io/workflows](https://n8n.io/workflows) - -If you want to know how a node can get used in context, you can search for it here: -[https://n8n.io/nodes](https://n8n.io/nodes). There it shows in which workflows the -node got used. - - - -## Videos - - - [Slack Notification on Github Star](https://www.youtube.com/watch?v=3w7xIMKLVAg) - - [Typeform to Google Sheet and Slack or Email](https://www.youtube.com/watch?v=rn3-d4IiW44) - - -### Community Tutorials - - - [n8n basics 1/3 - Getting Started](https://www.youtube.com/watch?v=JIaxjH2CyFc) - - [n8n basics 2/3 - Simple Workflow](https://www.youtube.com/watch?v=ovlxledZfM4) - - [n8n basics 3/3 - Transforming JSON](https://www.youtube.com/watch?v=wGAEAcfwV8w) - - [n8n Google Integration - Using Google Sheets and Google Api nodes](https://www.youtube.com/watch?v=KFqx8OmkqVE) diff --git a/docs/workflow.md b/docs/workflow.md deleted file mode 100644 index 344504d158..0000000000 --- a/docs/workflow.md +++ /dev/null @@ -1,111 +0,0 @@ -# Workflow - - -## Activate - -Activating a workflow means that the Trigger and Webhook nodes get activated and can trigger a workflow to run. By default all the newly created workflows are deactivated. That means that even if a Trigger node like the Cron node should start a workflow because a predefined time is reached, it will not unless the workflow gets activated. It is only possible to activate a workflow which contains a Trigger or a Webhook node. - - -## Data Flow - -Nodes do not only process one "item", they process multiple ones. So if the Trello node is set to "Create-Card" and it has an expression set for "Name" to be set depending on "name" property, it will create a card for each item, always choosing the name-property-value of the current one. - -This data would, for example, create two boards. One named "test1" the other one named "test2": - -```json -[ - { - name: "test1" - }, - { - name: "test2" - } -] -``` - - -## Error Workflows - -For each workflow, an optional "Error Workflow" can be set. It gets executed in case the execution of the workflow fails. That makes it possible to, for instance, inform the user via Email or Slack if something goes wrong. The same "Error Workflow" can be set on multiple workflows. - -The only difference between a regular workflow and an "Error Workflow" is that it contains an "Error Trigger" node. So it is important to make sure that this node gets created before setting a workflow as "Error Workflow". - -The "Error Trigger" node will trigger in case the execution fails and receives information about it. The data looks like this: - -```json -[ - { - "execution": { - "id": "231", - "url": "https://n8n.example.com/execution/231", - "retryOf": "34", - "error": { - "message": "Example Error Message", - "stack": "Stacktrace" - }, - "lastNodeExecuted": "Node With Error", - "mode": "manual" - }, - "workflow": { - "id": "1", - "name": "Example Workflow" - } - } -] - -``` - -All information is always present except: -- **execution.id**: Only present when the execution gets saved in the database -- **execution.url**: Only present when the execution gets saved in the database -- **execution.retryOf**: Only present when the execution is a retry of a previously failed execution - - -### Setting Error Workflow - -An "Error Workflow" can be set in the Workflow Settings which can be accessed by pressing the "Workflow" button in the menu on the on the left side. The last option is "Settings". In the window that appears, the "Error Workflow" can be selected via the Dropdown "Error Workflow". - - -## Share Workflows - -All workflows are JSON and can be shared very easily. - -There are multiple ways to download a workflow as JSON to then share it with other people via Email, Slack, Skype, Dropbox, … - - 1. Press the "Download" button under the Workflow menu in the sidebar on the left. It then downloads the workflow as a JSON file. - 1. Select the nodes in the editor which should be exported and then copy them (Ctrl + c). The nodes then get saved as JSON in the clipboard and can be pasted wherever desired (Ctrl + v). - -Importing that JSON representation again into n8n is as easy and can also be done in different ways: - - 1. Press "Import from File" or "Import from URL" under the Workflow menu in the sidebar on the left. - 1. Copy the JSON workflow to the clipboard (Ctrl + c) and then simply pasting it directly into the editor (Ctrl + v). - - -## Workflow Settings - -On each workflow, it is possible to set some custom settings and overwrite some of the global default settings. Currently, the following settings can be set: - - -### Error Workflow - -Workflow to run in case the execution of the current workflow fails. More information in section [Error Workflows](#error-workflows). - - -### Timezone - -The timezone to use in the current workflow. If not set, the global Timezone (by default "New York" gets used). For instance, this is important for the Cron Trigger node. - - -### Save Data Error Execution - -If the Execution data of the workflow should be saved when it fails. - - -### Save Data Success Execution - -If the Execution data of the workflow should be saved when it succeeds. - - -### Save Manual Executions - -If executions started from the Editor UI should be saved. diff --git a/package.json b/package.json index 0c7d8dac15..30fcfb7959 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start:default": "cd packages/cli/bin && ./n8n", "start:windows": "cd packages/cli/bin && n8n", "test": "lerna run test", + "tslint": "lerna exec npm run tslint", "watch": "lerna run --parallel watch" }, "devDependencies": { diff --git a/packages/cli/LICENSE.md b/packages/cli/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/cli/LICENSE.md +++ b/packages/cli/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packages/cli/README.md b/packages/cli/README.md index be5f42ae8b..5ae03ffa41 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,10 +1,10 @@ # n8n - Workflow Automation Tool -![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) n8n is a free and open [fair-code](http://faircode.io) licensed node based Workflow Automation Tool. It can be self-hosted, easily extended, and so also used with internal tools. -n8n.io - Screenshot +n8n.io - Screenshot ## Contents diff --git a/packages/cli/commands/execute.ts b/packages/cli/commands/execute.ts index cdea6a2a0d..3eb5956e9d 100644 --- a/packages/cli/commands/execute.ts +++ b/packages/cli/commands/execute.ts @@ -11,6 +11,7 @@ import { ActiveExecutions, CredentialsOverwrites, Db, + ExternalHooks, GenericHelpers, IWorkflowBase, IWorkflowExecutionDataProcess, @@ -108,6 +109,10 @@ export class Execute extends Command { const credentialsOverwrites = CredentialsOverwrites(); await credentialsOverwrites.init(); + // Load all external hooks + const externalHooks = ExternalHooks(); + await externalHooks.init(); + // Add the found types to an instance other parts of the application can use const nodeTypes = NodeTypes(); await nodeTypes.init(loadNodesAndCredentials.nodeTypes); diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index 10dde58b35..1b76459de7 100644 --- a/packages/cli/commands/start.ts +++ b/packages/cli/commands/start.ts @@ -5,7 +5,6 @@ import { } from 'n8n-core'; import { Command, flags } from '@oclif/command'; const open = require('open'); -// import { dirname } from 'path'; import * as config from '../config'; import { @@ -13,6 +12,7 @@ import { CredentialTypes, CredentialsOverwrites, Db, + ExternalHooks, GenericHelpers, LoadNodesAndCredentials, NodeTypes, @@ -113,6 +113,10 @@ export class Start extends Command { const credentialsOverwrites = CredentialsOverwrites(); await credentialsOverwrites.init(); + // Load all external hooks + const externalHooks = ExternalHooks(); + await externalHooks.init(); + // Add the found types to an instance other parts of the application can use const nodeTypes = NodeTypes(); await nodeTypes.init(loadNodesAndCredentials.nodeTypes); diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 648d579b30..2759c6d794 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -63,6 +63,34 @@ const config = convict({ default: 'public', env: 'DB_POSTGRESDB_SCHEMA' }, + + ssl: { + ca: { + doc: 'SSL certificate authority', + format: String, + default: '', + env: 'DB_POSTGRESDB_SSL_CA', + }, + cert: { + doc: 'SSL certificate', + format: String, + default: '', + env: 'DB_POSTGRESDB_SSL_CERT', + }, + key: { + doc: 'SSL key', + format: String, + default: '', + env: 'DB_POSTGRESDB_SSL_KEY', + }, + rejectUnauthorized: { + doc: 'If unauthorized SSL connections should be rejected', + format: 'Boolean', + default: true, + env: 'DB_POSTGRESDB_SSL_REJECT_UNAUTHORIZED', + }, + } + }, mysqldb: { database: { @@ -100,15 +128,23 @@ const config = convict({ credentials: { overwrite: { - // Allows to set default values for credentials which - // get automatically prefilled and the user does not get - // displayed and can not change. - // Format: { CREDENTIAL_NAME: { PARAMTER: VALUE }} - doc: 'Overwrites for credentials', - format: '*', - default: '{}', - env: 'CREDENTIALS_OVERWRITE' - } + data: { + // Allows to set default values for credentials which + // get automatically prefilled and the user does not get + // displayed and can not change. + // Format: { CREDENTIAL_NAME: { PARAMTER: VALUE }} + doc: 'Overwrites for credentials', + format: '*', + default: '{}', + env: 'CREDENTIALS_OVERWRITE_DATA' + }, + endpoint: { + doc: 'Fetch credentials from API', + format: String, + default: '', + env: 'CREDENTIALS_OVERWRITE_ENDPOINT', + }, + }, }, executions: { @@ -125,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: { @@ -149,9 +185,34 @@ const config = convict({ // in the editor. saveDataManualExecutions: { doc: 'Save data of executions when started manually via editor', + format: 'Boolean', 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', + format: 'Boolean', + default: false, + env: 'EXECUTIONS_DATA_PRUNE' + }, + pruneDataMaxAge: { + doc: 'How old (hours) the execution data has to be to get deleted', + format: Number, + default: 336, + env: 'EXECUTIONS_DATA_MAX_AGE' + }, + pruneDataTimeout: { + doc: 'Timeout (seconds) after execution data has been pruned', + format: Number, + default: 3600, + env: 'EXECUTIONS_DATA_PRUNE_TIMEOUT' + }, }, generic: { @@ -168,6 +229,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', @@ -271,6 +339,13 @@ const config = convict({ }, }, + externalHookFiles: { + doc: 'Files containing external hooks. Multiple files can be separated by colon (":")', + format: String, + default: '', + env: 'EXTERNAL_HOOK_FILES' + }, + nodes: { exclude: { doc: 'Nodes not to load', diff --git a/packages/cli/migrations/ormconfig.ts b/packages/cli/migrations/ormconfig.ts index 2a0cda0d9c..1ea583beb4 100644 --- a/packages/cli/migrations/ormconfig.ts +++ b/packages/cli/migrations/ormconfig.ts @@ -44,9 +44,9 @@ module.exports = [ "logging": false, "host": "localhost", "username": "postgres", - "password": "docker", + "password": "", "port": 5432, - "database": "postgres", + "database": "n8n", "schema": "public", "entities": Object.values(PostgresDb), "migrations": [ @@ -68,7 +68,7 @@ module.exports = [ "username": "root", "password": "password", "host": "localhost", - "port": "3308", + "port": "3306", "logging": false, "entities": Object.values(MySQLDb), "migrations": [ @@ -90,7 +90,7 @@ module.exports = [ "username": "root", "password": "password", "host": "localhost", - "port": "3308", + "port": "3306", "logging": false, "entities": Object.values(MySQLDb), "migrations": [ @@ -105,4 +105,4 @@ module.exports = [ "subscribersDir": "./src/databases/mysqldb/Subscribers" } }, -]; \ No newline at end of file +]; diff --git a/packages/cli/package.json b/packages/cli/package.json index 325a73d874..4749a2ed0d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.68.2", + "version": "0.74.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -100,10 +100,10 @@ "lodash.get": "^4.4.2", "mongodb": "^3.5.5", "mysql2": "^2.0.1", - "n8n-core": "~0.35.0", - "n8n-editor-ui": "~0.46.0", - "n8n-nodes-base": "~0.63.1", - "n8n-workflow": "~0.32.0", + "n8n-core": "~0.39.0", + "n8n-editor-ui": "~0.50.0", + "n8n-nodes-base": "~0.69.0", + "n8n-workflow": "~0.35.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", "pg": "^7.11.0", diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index 9fef6ed243..c7f7ea1d69 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -11,11 +11,11 @@ import { WorkflowHelpers, WorkflowRunner, WorkflowExecuteAdditionalData, + IWebhookDb, } from './'; import { ActiveWorkflows, - ActiveWebhooks, NodeExecuteFunctions, } from 'n8n-core'; @@ -26,7 +26,7 @@ import { INode, INodeExecutionData, IRunExecutionData, - IWebhookData, + NodeHelpers, IWorkflowExecuteAdditionalData as IWorkflowExecuteAdditionalDataWorkflow, WebhookHttpMethod, Workflow, @@ -35,22 +35,23 @@ import { import * as express from 'express'; - export class ActiveWorkflowRunner { private activeWorkflows: ActiveWorkflows | null = null; - private activeWebhooks: ActiveWebhooks | null = null; + private activationErrors: { [key: string]: IActivationError; } = {}; async init() { + // Get the active workflows from database + + // NOTE + // Here I guess we can have a flag on the workflow table like hasTrigger + // so intead of pulling all the active wehhooks just pull the actives that have a trigger const workflowsData: IWorkflowDb[] = await Db.collections.Workflow!.find({ active: true }) as IWorkflowDb[]; - this.activeWebhooks = new ActiveWebhooks(); - - // Add them as active workflows this.activeWorkflows = new ActiveWorkflows(); if (workflowsData.length !== 0) { @@ -58,20 +59,27 @@ export class ActiveWorkflowRunner { console.log(' Start Active Workflows:'); console.log(' ================================'); + const nodeTypes = NodeTypes(); + for (const workflowData of workflowsData) { - console.log(` - ${workflowData.name}`); - try { - await this.add(workflowData.id.toString(), workflowData); - console.log(` => Started`); - } catch (error) { - console.log(` => ERROR: Workflow could not be activated:`); - console.log(` ${error.message}`); + + const workflow = new Workflow({ id: workflowData.id.toString(), name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings}); + + if (workflow.getTriggerNodes().length !== 0 + || workflow.getPollNodes().length !== 0) { + console.log(` - ${workflowData.name}`); + try { + await this.add(workflowData.id.toString(), workflowData); + console.log(` => Started`); + } catch (error) { + console.log(` => ERROR: Workflow could not be activated:`); + console.log(` ${error.message}`); + } } } } } - /** * Removes all the currently active workflows * @@ -94,7 +102,6 @@ export class ActiveWorkflowRunner { return; } - /** * Checks if a webhook for the given method and path exists and executes the workflow. * @@ -110,30 +117,41 @@ export class ActiveWorkflowRunner { throw new ResponseHelper.ResponseError('The "activeWorkflows" instance did not get initialized yet.', 404, 404); } - const webhookData: IWebhookData | undefined = this.activeWebhooks!.get(httpMethod, path); + const webhook = await Db.collections.Webhook?.findOne({ webhookPath: path, method: httpMethod }) as IWebhookDb; - if (webhookData === undefined) { + // check if something exist + if (webhook === undefined) { // The requested webhook is not registered throw new ResponseHelper.ResponseError(`The requested webhook "${httpMethod} ${path}" is not registered.`, 404, 404); } - const workflowData = await Db.collections.Workflow!.findOne(webhookData.workflowId); + const workflowData = await Db.collections.Workflow!.findOne(webhook.workflowId); if (workflowData === undefined) { - throw new ResponseHelper.ResponseError(`Could not find workflow with id "${webhookData.workflowId}"`, 404, 404); + throw new ResponseHelper.ResponseError(`Could not find workflow with id "${webhook.workflowId}"`, 404, 404); } const nodeTypes = NodeTypes(); - const workflow = new Workflow({ id: webhookData.workflowId, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings}); + const workflow = new Workflow({ id: webhook.workflowId.toString(), name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings}); + + const credentials = await WorkflowCredentials([workflow.getNode(webhook.node as string) as INode]); + + const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials); + + const webhookData = NodeHelpers.getNodeWebhooks(workflow, workflow.getNode(webhook.node as string) as INode, additionalData).filter((webhook) => { + return (webhook.httpMethod === httpMethod && webhook.path === path); + })[0]; // Get the node which has the webhook defined to know where to start from and to // get additional data const workflowStartNode = workflow.getNode(webhookData.node); + if (workflowStartNode === null) { throw new ResponseHelper.ResponseError('Could not find node to process webhook.', 404, 404); } return new Promise((resolve, reject) => { const executionMode = 'webhook'; + //@ts-ignore WebhookHelpers.executeWebhook(workflow, webhookData, workflowData, workflowStartNode, executionMode, undefined, req, res, (error: Error | null, data: object) => { if (error !== null) { return reject(error); @@ -143,19 +161,14 @@ export class ActiveWorkflowRunner { }); } - /** * Returns the ids of the currently active workflows * * @returns {string[]} * @memberof ActiveWorkflowRunner */ - getActiveWorkflows(): string[] { - if (this.activeWorkflows === null) { - return []; - } - - return this.activeWorkflows.allActiveWorkflows(); + getActiveWorkflows(): Promise { + return Db.collections.Workflow?.find({ select: ['id'] }) as Promise; } @@ -166,15 +179,11 @@ export class ActiveWorkflowRunner { * @returns {boolean} * @memberof ActiveWorkflowRunner */ - isActive(id: string): boolean { - if (this.activeWorkflows !== null) { - return this.activeWorkflows.isActive(id); - } - - return false; + async isActive(id: string): Promise { + const workflow = await Db.collections.Workflow?.findOne({ id }) as IWorkflowDb; + return workflow?.active as boolean; } - /** * Return error if there was a problem activating the workflow * @@ -190,7 +199,6 @@ export class ActiveWorkflowRunner { return this.activationErrors[id]; } - /** * Adds all the webhooks of the workflow * @@ -202,12 +210,69 @@ export class ActiveWorkflowRunner { */ async addWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflowExecuteAdditionalDataWorkflow, mode: WorkflowExecuteMode): Promise { const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData); + let path = '' as string | undefined; for (const webhookData of webhooks) { - await this.activeWebhooks!.add(workflow, webhookData, mode); - // Save static data! - await WorkflowHelpers.saveStaticData(workflow); + + const node = workflow.getNode(webhookData.node) as INode; + node.name = webhookData.node; + + path = node.parameters.path as string; + + if (node.parameters.path === undefined) { + path = workflow.getSimpleParameterValue(node, webhookData.webhookDescription['path']) as string | undefined; + + if (path === undefined) { + // TODO: Use a proper logger + console.error(`No webhook path could be found for node "${node.name}" in workflow "${workflow.id}".`); + continue; + } + } + + const isFullPath: boolean = workflow.getSimpleParameterValue(node, webhookData.webhookDescription['isFullPath'], false) as boolean; + + const webhook = { + workflowId: webhookData.workflowId, + webhookPath: NodeHelpers.getNodeWebhookPath(workflow.id as string, node, path, isFullPath), + node: node.name, + method: webhookData.httpMethod, + } as IWebhookDb; + + try { + + await Db.collections.Webhook?.insert(webhook); + + const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, false); + if (webhookExists === false) { + // If webhook does not exist yet create it + await workflow.runWebhookMethod('create', webhookData, NodeExecuteFunctions, mode, false); + } + + } catch (error) { + + let errorMessage = ''; + + await Db.collections.Webhook?.delete({ workflowId: workflow.id }); + + // if it's a workflow from the the insert + // TODO check if there is standard error code for deplicate key violation that works + // with all databases + if (error.name === 'MongoError' || error.name === 'QueryFailedError') { + + errorMessage = `The webhook path [${webhook.webhookPath}] and method [${webhook.method}] already exist.`; + + } else if (error.detail) { + // it's a error runnig the webhook methods (checkExists, create) + errorMessage = error.detail; + } else { + errorMessage = error.message; + } + + throw new Error(errorMessage); + } } + // Save static data! + await WorkflowHelpers.saveStaticData(workflow); } @@ -227,13 +292,29 @@ export class ActiveWorkflowRunner { const nodeTypes = NodeTypes(); const workflow = new Workflow({ id: workflowId, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings }); - await this.activeWebhooks!.removeWorkflow(workflow); + const mode = 'internal'; - // Save the static workflow data if needed - await WorkflowHelpers.saveStaticData(workflow); + const credentials = await WorkflowCredentials(workflowData.nodes); + const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials); + + const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData); + + for (const webhookData of webhooks) { + await workflow.runWebhookMethod('delete', webhookData, NodeExecuteFunctions, mode, false); + } + + // if it's a mongo objectId convert it to string + if (typeof workflowData.id === 'object') { + workflowData.id = workflowData.id.toString(); + } + + const webhook = { + workflowId: workflowData.id, + } as IWebhookDb; + + await Db.collections.Webhook?.delete(webhook); } - /** * Runs the given workflow * @@ -322,7 +403,6 @@ export class ActiveWorkflowRunner { }); } - /** * Makes a workflow active * @@ -361,7 +441,11 @@ export class ActiveWorkflowRunner { // Add the workflows which have webhooks defined await this.addWorkflowWebhooks(workflowInstance, additionalData, mode); - await this.activeWorkflows.add(workflowId, workflowInstance, additionalData, getTriggerFunctions, getPollFunctions); + + if (workflowInstance.getTriggerNodes().length !== 0 + || workflowInstance.getPollNodes().length !== 0) { + await this.activeWorkflows.add(workflowId, workflowInstance, additionalData, getTriggerFunctions, getPollFunctions); + } if (this.activationErrors[workflowId] !== undefined) { // If there were activation errors delete them @@ -386,7 +470,6 @@ export class ActiveWorkflowRunner { await WorkflowHelpers.saveStaticData(workflowInstance!); } - /** * Makes a workflow inactive * @@ -395,6 +478,7 @@ export class ActiveWorkflowRunner { * @memberof ActiveWorkflowRunner */ async remove(workflowId: string): Promise { + if (this.activeWorkflows !== null) { // Remove all the webhooks of the workflow await this.removeWorkflowWebhooks(workflowId); @@ -404,8 +488,13 @@ export class ActiveWorkflowRunner { delete this.activationErrors[workflowId]; } - // Remove the workflow from the "list" of active workflows - return this.activeWorkflows.remove(workflowId); + // if it's active in memory then it's a trigger + // so remove from list of actives workflows + if (this.activeWorkflows.isActive(workflowId)) { + this.activeWorkflows.remove(workflowId); + } + + return; } throw new Error(`The "activeWorkflows" instance did not get initialized yet.`); diff --git a/packages/cli/src/CredentialsOverwrites.ts b/packages/cli/src/CredentialsOverwrites.ts index a6e115100e..ca09b87626 100644 --- a/packages/cli/src/CredentialsOverwrites.ts +++ b/packages/cli/src/CredentialsOverwrites.ts @@ -20,7 +20,7 @@ class CredentialsOverwritesClass { return; } - const data = await GenericHelpers.getConfigValue('credentials.overwrite') as string; + const data = await GenericHelpers.getConfigValue('credentials.overwrite.data') as string; try { this.overwriteData = JSON.parse(data); @@ -30,6 +30,7 @@ class CredentialsOverwritesClass { } applyOverwrite(type: string, data: ICredentialDataDecryptedObject) { + const overwrites = this.get(type); if (overwrites === undefined) { diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index 54633adb13..06eeca7976 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -14,6 +14,8 @@ import { getRepository, } from 'typeorm'; +import { TlsOptions } from 'tls'; + import * as config from '../config'; import { @@ -27,22 +29,31 @@ export let collections: IDatabaseCollections = { Credentials: null, Execution: null, Workflow: null, + Webhook: null, }; import { - InitialMigration1587669153312 + InitialMigration1587669153312, + WebhookModel1589476000887, + CreateIndexStoppedAt1594828256133, } from './databases/postgresdb/migrations'; import { - InitialMigration1587563438936 + InitialMigration1587563438936, + WebhookModel1592679094242, + CreateIndexStoppedAt1594910478695, } from './databases/mongodb/migrations'; import { - InitialMigration1588157391238 + InitialMigration1588157391238, + WebhookModel1592447867632, + CreateIndexStoppedAt1594902918301, } from './databases/mysqldb/migrations'; import { - InitialMigration1588102412422 + InitialMigration1588102412422, + WebhookModel1592445003908, + CreateIndexStoppedAt1594825041918, } from './databases/sqlite/migrations'; import * as path from 'path'; @@ -64,7 +75,11 @@ export async function init(): Promise { entityPrefix, url: await GenericHelpers.getConfigValue('database.mongodb.connectionUrl') as string, useNewUrlParser: true, - migrations: [InitialMigration1587563438936], + migrations: [ + InitialMigration1587563438936, + WebhookModel1592679094242, + CreateIndexStoppedAt1594910478695, + ], migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, }; @@ -72,6 +87,22 @@ export async function init(): Promise { case 'postgresdb': entities = PostgresDb; + + const sslCa = await GenericHelpers.getConfigValue('database.postgresdb.ssl.ca') as string; + const sslCert = await GenericHelpers.getConfigValue('database.postgresdb.ssl.cert') as string; + const sslKey = await GenericHelpers.getConfigValue('database.postgresdb.ssl.key') as string; + const sslRejectUnauthorized = await GenericHelpers.getConfigValue('database.postgresdb.ssl.rejectUnauthorized') as boolean; + + let ssl: TlsOptions | undefined = undefined; + if (sslCa !== '' || sslCert !== '' || sslKey !== '' || sslRejectUnauthorized !== true) { + ssl = { + ca: sslCa || undefined, + cert: sslCert || undefined, + key: sslKey || undefined, + rejectUnauthorized: sslRejectUnauthorized, + }; + } + connectionOptions = { type: 'postgres', entityPrefix, @@ -81,10 +112,16 @@ export async function init(): Promise { port: await GenericHelpers.getConfigValue('database.postgresdb.port') as number, username: await GenericHelpers.getConfigValue('database.postgresdb.user') as string, schema: config.get('database.postgresdb.schema'), - migrations: [InitialMigration1587669153312], + migrations: [ + InitialMigration1587669153312, + WebhookModel1589476000887, + CreateIndexStoppedAt1594828256133, + ], migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, + ssl, }; + break; case 'mariadb': @@ -98,7 +135,11 @@ export async function init(): Promise { password: await GenericHelpers.getConfigValue('database.mysqldb.password') as string, port: await GenericHelpers.getConfigValue('database.mysqldb.port') as number, username: await GenericHelpers.getConfigValue('database.mysqldb.user') as string, - migrations: [InitialMigration1588157391238], + migrations: [ + InitialMigration1588157391238, + WebhookModel1592447867632, + CreateIndexStoppedAt1594902918301, + ], migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, }; @@ -110,7 +151,11 @@ export async function init(): Promise { type: 'sqlite', database: path.join(n8nFolder, 'database.sqlite'), entityPrefix, - migrations: [InitialMigration1588102412422], + migrations: [ + InitialMigration1588102412422, + WebhookModel1592445003908, + CreateIndexStoppedAt1594825041918 + ], migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, }; @@ -135,6 +180,7 @@ export async function init(): Promise { collections.Credentials = getRepository(entities.CredentialsEntity); collections.Execution = getRepository(entities.ExecutionEntity); collections.Workflow = getRepository(entities.WorkflowEntity); + collections.Webhook = getRepository(entities.WebhookEntity); return collections; } diff --git a/packages/cli/src/ExternalHooks.ts b/packages/cli/src/ExternalHooks.ts new file mode 100644 index 0000000000..355415158a --- /dev/null +++ b/packages/cli/src/ExternalHooks.ts @@ -0,0 +1,79 @@ +import { + Db, + IExternalHooksFunctions, + IExternalHooksClass, +} from './'; + +import * as config from '../config'; + + +class ExternalHooksClass implements IExternalHooksClass { + + externalHooks: { + [key: string]: Array<() => {}> + } = {}; + initDidRun = false; + + + async init(): Promise { + if (this.initDidRun === true) { + return; + } + + const externalHookFiles = config.get('externalHookFiles').split(':'); + + // Load all the provided hook-files + for (let hookFilePath of externalHookFiles) { + hookFilePath = hookFilePath.trim(); + if (hookFilePath !== '') { + try { + const hookFile = require(hookFilePath); + + for (const resource of Object.keys(hookFile)) { + for (const operation of Object.keys(hookFile[resource])) { + // Save all the hook functions directly under their string + // format in an array + const hookString = `${resource}.${operation}`; + if (this.externalHooks[hookString] === undefined) { + this.externalHooks[hookString] = []; + } + + this.externalHooks[hookString].push.apply(this.externalHooks[hookString], hookFile[resource][operation]); + } + } + } catch (error) { + throw new Error(`Problem loading external hook file "${hookFilePath}": ${error.message}`); + } + } + } + + this.initDidRun = true; + } + + async run(hookName: string, hookParameters?: any[]): Promise { // tslint:disable-line:no-any + const externalHookFunctions: IExternalHooksFunctions = { + dbCollections: Db.collections, + }; + + if (this.externalHooks[hookName] === undefined) { + return; + } + + for(const externalHookFunction of this.externalHooks[hookName]) { + await externalHookFunction.apply(externalHookFunctions, hookParameters); + } + } + +} + + + +let externalHooksInstance: ExternalHooksClass | undefined; + +export function ExternalHooks(): ExternalHooksClass { + if (externalHooksInstance === undefined) { + externalHooksInstance = new ExternalHooksClass(); + } + + return externalHooksInstance; +} 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/Interfaces.ts b/packages/cli/src/Interfaces.ts index abab09bd13..2aecd27466 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -49,8 +49,15 @@ export interface IDatabaseCollections { Credentials: Repository | null; Execution: Repository | null; Workflow: Repository | null; + Webhook: Repository | null; } +export interface IWebhookDb { + workflowId: number | string | ObjectID; + webhookPath: string; + method: string; + node: string; +} export interface IWorkflowBase extends IWorkflowBaseWorkflow { id?: number | string | ObjectID; @@ -197,6 +204,30 @@ export interface IExecutingWorkflowData { workflowExecution?: PCancelable; } +export interface IExternalHooks { + credentials?: { + create?: Array<{ (this: IExternalHooksFunctions, credentialsData: ICredentialsEncrypted): Promise; }> + delete?: Array<{ (this: IExternalHooksFunctions, credentialId: string): Promise; }> + update?: Array<{ (this: IExternalHooksFunctions, credentialsData: ICredentialsDb): Promise; }> + }; + workflow?: { + activate?: Array<{ (this: IExternalHooksFunctions, workflowData: IWorkflowDb): Promise; }> + create?: Array<{ (this: IExternalHooksFunctions, workflowData: IWorkflowBase): Promise; }> + delete?: Array<{ (this: IExternalHooksFunctions, workflowId: string): Promise; }> + execute?: Array<{ (this: IExternalHooksFunctions, workflowData: IWorkflowDb, mode: WorkflowExecuteMode): Promise; }> + update?: Array<{ (this: IExternalHooksFunctions, workflowData: IWorkflowDb): Promise; }> + }; +} + +export interface IExternalHooksFunctions { + dbCollections: IDatabaseCollections; +} + +export interface IExternalHooksClass { + init(): Promise; + run(hookName: string, hookParameters?: any[]): Promise; // tslint:disable-line:no-any +} + export interface IN8nConfig { database: IN8nConfigDatabase; endpoints: IN8nConfigEndpoints; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 7c8716dd8c..09ef7e18a6 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -18,7 +18,7 @@ import * as clientOAuth2 from 'client-oauth2'; import * as clientOAuth1 from 'oauth-1.0a'; import { RequestOptions } from 'oauth-1.0a'; import * as csrf from 'csrf'; -import * as requestPromise from 'request-promise-native'; +import * as requestPromise from 'request-promise-native'; import { createHmac } from 'crypto'; import { @@ -27,6 +27,7 @@ import { CredentialsHelper, CredentialTypes, Db, + ExternalHooks, IActivationError, ICustomRequest, ICredentialsDb, @@ -41,6 +42,7 @@ import { IExecutionsListResponse, IExecutionsStopData, IExecutionsSummary, + IExternalHooksClass, IN8nUISettings, IPackageVersions, IWorkflowBase, @@ -56,6 +58,9 @@ import { WorkflowExecuteAdditionalData, WorkflowRunner, GenericHelpers, + CredentialsOverwrites, + ICredentialsOverwrite, + LoadNodesAndCredentials, } from './'; import { @@ -103,6 +108,8 @@ class App { testWebhooks: TestWebhooks.TestWebhooks; endpointWebhook: string; endpointWebhookTest: string; + endpointPresetCredentials: string; + externalHooks: IExternalHooksClass; saveDataErrorExecution: string; saveDataSuccessExecution: string; saveManualExecutions: boolean; @@ -110,11 +117,14 @@ class App { activeExecutionsInstance: ActiveExecutions.ActiveExecutions; push: Push.Push; versions: IPackageVersions | undefined; + restEndpoint: string; protocol: string; - sslKey: string; + sslKey: string; sslCert: string; + presetCredentialsLoaded: boolean; + constructor() { this.app = express(); @@ -124,6 +134,7 @@ class App { this.saveDataSuccessExecution = config.get('executions.saveDataOnSuccess') as string; this.saveManualExecutions = config.get('executions.saveDataManualExecutions') as boolean; this.timezone = config.get('generic.timezone') as string; + this.restEndpoint = config.get('endpoints.rest') as string; this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance(); this.testWebhooks = TestWebhooks.getInstance(); @@ -132,8 +143,13 @@ class App { this.activeExecutionsInstance = ActiveExecutions.getInstance(); this.protocol = config.get('protocol'); - this.sslKey = config.get('ssl_key'); + this.sslKey = config.get('ssl_key'); this.sslCert = config.get('ssl_cert'); + + this.externalHooks = ExternalHooks(); + + this.presetCredentialsLoaded = false; + this.endpointPresetCredentials = config.get('credentials.overwrite.endpoint') as string; } @@ -188,7 +204,7 @@ class App { } // Check for and validate JWT if configured - const jwtAuthActive = config.get('security.jwtAuth.active') as boolean; + const jwtAuthActive = config.get('security.jwtAuth.active') as boolean; if (jwtAuthActive === true) { const jwtAuthHeader = await GenericHelpers.getConfigValue('security.jwtAuth.jwtHeader') as string; if (jwtAuthHeader === '') { @@ -230,7 +246,7 @@ class App { // Get push connections this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { - if (req.url.indexOf('/rest/push') === 0) { + if (req.url.indexOf(`/${this.restEndpoint}/push`) === 0) { // TODO: Later also has to add some kind of authentication token if (req.query.sessionId === undefined) { next(new Error('The query parameter "sessionId" is missing!')); @@ -266,7 +282,7 @@ class App { normalize: true, // Trim whitespace inside text nodes normalizeTags: true, // Transform tags to lowercase explicitArray: false, // Only put properties in array if length > 1 - } })); + } })); this.app.use(bodyParser.text({ limit: '16mb', verify: (req, res, buf) => { @@ -279,7 +295,7 @@ class App { this.app.use(history({ rewrites: [ { - from: new RegExp(`^\/(rest|healthz|css|js|${this.endpointWebhook}|${this.endpointWebhookTest})\/?.*$`), + from: new RegExp(`^\/(${this.restEndpoint}|healthz|css|js|${this.endpointWebhook}|${this.endpointWebhookTest})\/?.*$`), to: (context) => { return context.parsedUrl!.pathname!.toString(); } @@ -349,9 +365,9 @@ class App { // Creates a new workflow - this.app.post('/rest/workflows', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.post(`/${this.restEndpoint}/workflows`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - const newWorkflowData = req.body; + const newWorkflowData = req.body as IWorkflowBase; newWorkflowData.name = newWorkflowData.name.trim(); newWorkflowData.createdAt = this.getCurrentDate(); @@ -359,6 +375,8 @@ class App { newWorkflowData.id = undefined; + await this.externalHooks.run('workflow.create', [newWorkflowData]); + // Save the workflow in DB const result = await Db.collections.Workflow!.save(newWorkflowData); @@ -370,7 +388,7 @@ class App { // Reads and returns workflow data from an URL - this.app.get('/rest/workflows/from-url', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/workflows/from-url`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { if (req.query.url === undefined) { throw new ResponseHelper.ResponseError(`The parameter "url" is missing!`, undefined, 400); } @@ -398,7 +416,7 @@ class App { // Returns workflows - this.app.get('/rest/workflows', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/workflows`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const findQuery = {} as FindManyOptions; if (req.query.filter) { findQuery.where = JSON.parse(req.query.filter as string); @@ -418,7 +436,7 @@ class App { // Returns a specific workflow - this.app.get('/rest/workflows/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/workflows/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const result = await Db.collections.Workflow!.findOne(req.params.id); if (result === undefined) { @@ -432,12 +450,16 @@ class App { // Updates an existing workflow - this.app.patch('/rest/workflows/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.patch(`/${this.restEndpoint}/workflows/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - const newWorkflowData = req.body; + const newWorkflowData = req.body as IWorkflowBase; const id = req.params.id; - if (this.activeWorkflowRunner.isActive(id)) { + await this.externalHooks.run('workflow.update', [newWorkflowData]); + + const isActive = await this.activeWorkflowRunner.isActive(id); + + if (isActive) { // When workflow gets saved always remove it as the triggers could have been // changed and so the changes would not take effect await this.activeWorkflowRunner.remove(id); @@ -478,6 +500,8 @@ class App { if (responseData.active === true) { // When the workflow is supposed to be active add it again try { + await this.externalHooks.run('workflow.activate', [responseData]); + await this.activeWorkflowRunner.add(id); } catch (error) { // If workflow could not be activated set it again to inactive @@ -499,10 +523,14 @@ class App { // Deletes a specific workflow - this.app.delete('/rest/workflows/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.delete(`/${this.restEndpoint}/workflows/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const id = req.params.id; - if (this.activeWorkflowRunner.isActive(id)) { + await this.externalHooks.run('workflow.delete', [id]); + + const isActive = await this.activeWorkflowRunner.isActive(id); + + if (isActive) { // Before deleting a workflow deactivate it await this.activeWorkflowRunner.remove(id); } @@ -513,7 +541,7 @@ class App { })); - this.app.post('/rest/workflows/run', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.post(`/${this.restEndpoint}/workflows/run`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const workflowData = req.body.workflowData; const runData: IRunData | undefined = req.body.runData; const startNodes: string[] | undefined = req.body.startNodes; @@ -562,7 +590,7 @@ class App { // Returns parameter values which normally get loaded from an external API or // get generated dynamically - this.app.get('/rest/node-parameter-options', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/node-parameter-options`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const nodeType = req.query.nodeType as string; let credentials: INodeCredentials | undefined = undefined; const currentNodeParameters = JSON.parse('' + req.query.currentNodeParameters) as INodeParameters; @@ -584,7 +612,7 @@ class App { // Returns all the node-types - this.app.get('/rest/node-types', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/node-types`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const returnData: INodeTypeDescription[] = []; @@ -607,7 +635,7 @@ class App { // Returns the node icon - this.app.get(['/rest/node-icon/:nodeType', '/rest/node-icon/:scope/:nodeType'], async (req: express.Request, res: express.Response): Promise => { + this.app.get([`/${this.restEndpoint}/node-icon/:nodeType`, `/${this.restEndpoint}/node-icon/:scope/:nodeType`], async (req: express.Request, res: express.Response): Promise => { const nodeTypeName = `${req.params.scope ? `${req.params.scope}/` : ''}${req.params.nodeType}`; const nodeTypes = NodeTypes(); @@ -641,13 +669,14 @@ class App { // Returns the active workflow ids - this.app.get('/rest/active', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - return this.activeWorkflowRunner.getActiveWorkflows(); + this.app.get(`/${this.restEndpoint}/active`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const activeWorkflows = await this.activeWorkflowRunner.getActiveWorkflows(); + return activeWorkflows.map(workflow => workflow.id.toString()) as string[]; })); // Returns if the workflow with the given id had any activation errors - this.app.get('/rest/active/error/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/active/error/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const id = req.params.id; return this.activeWorkflowRunner.getActivationError(id); })); @@ -660,16 +689,18 @@ class App { // Deletes a specific credential - this.app.delete('/rest/credentials/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.delete(`/${this.restEndpoint}/credentials/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const id = req.params.id; + await this.externalHooks.run('credentials.delete', [id]); + await Db.collections.Credentials!.delete({ id }); return true; })); // Creates new credentials - this.app.post('/rest/credentials', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.post(`/${this.restEndpoint}/credentials`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const incomingData = req.body; if (!incomingData.name || incomingData.name.length < 3) { @@ -708,6 +739,8 @@ class App { credentials.setData(incomingData.data, encryptionKey); const newCredentialsData = credentials.getDataToSave() as ICredentialsDb; + await this.externalHooks.run('credentials.create', [newCredentialsData]); + // Add special database related data newCredentialsData.createdAt = this.getCurrentDate(); newCredentialsData.updatedAt = this.getCurrentDate(); @@ -725,7 +758,7 @@ class App { // Updates existing credentials - this.app.patch('/rest/credentials/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.patch(`/${this.restEndpoint}/credentials/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const incomingData = req.body; const id = req.params.id; @@ -783,6 +816,8 @@ class App { // Add special database related data newCredentialsData.updatedAt = this.getCurrentDate(); + await this.externalHooks.run('credentials.update', [newCredentialsData]); + // Update the credentials in DB await Db.collections.Credentials!.update(id, newCredentialsData); @@ -804,7 +839,7 @@ class App { // Returns specific credentials - this.app.get('/rest/credentials/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/credentials/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const findQuery = {} as FindManyOptions; // Make sure the variable has an expected value @@ -839,7 +874,7 @@ class App { // Returns all the saved credentials - this.app.get('/rest/credentials', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/credentials`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const findQuery = {} as FindManyOptions; if (req.query.filter) { findQuery.where = JSON.parse(req.query.filter as string); @@ -881,7 +916,7 @@ class App { // Returns all the credential types which are defined in the loaded n8n-modules - this.app.get('/rest/credential-types', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/credential-types`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const returnData: ICredentialType[] = []; @@ -899,9 +934,10 @@ class App { // ---------------------------------------- // Authorize OAuth Data - this.app.get('/rest/oauth1-credential/auth', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + 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); @@ -913,7 +949,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 @@ -942,9 +979,9 @@ class App { }, }); - const callback = `${WebhookHelpers.getWebhookBaseUrl()}rest/oauth1-credential/callback?cid=${req.query.id}`; + const callback = `${WebhookHelpers.getWebhookBaseUrl()}${this.restEndpoint}/oauth1-credential/callback?cid=${req.query.id}`; - const options: RequestOptions = { + const options: RequestOptions = { method: 'POST', url: (_.get(oauthCredentials, 'requestTokenUrl') as string), data: { @@ -981,11 +1018,12 @@ class App { })); // Verify and store app code. Generate access tokens and store for respective credential. - this.app.get('/rest/oauth1-credential/callback', async (req: express.Request, res: express.Response) => { + this.app.get(`/${this.restEndpoint}/oauth1-credential/callback`, async (req: express.Request, res: express.Response) => { 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 @@ -1011,7 +1049,7 @@ class App { const decryptedDataOriginal = credentialsHelper.getDecrypted(result.name, result.type, true); const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(decryptedDataOriginal, result.type); - const options: OptionsWithUrl = { + const options: OptionsWithUrl = { method: 'POST', url: _.get(oauthCredentials, 'accessTokenUrl') as string, qs: { @@ -1053,9 +1091,10 @@ class App { // Authorize OAuth Data - this.app.get('/rest/oauth2-credential/auth', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + 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); @@ -1067,7 +1106,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 @@ -1094,7 +1134,7 @@ class App { clientSecret: _.get(oauthCredentials, 'clientSecret', '') as string, accessTokenUri: _.get(oauthCredentials, 'accessTokenUrl', '') as string, authorizationUri: _.get(oauthCredentials, 'authUrl', '') as string, - redirectUri: `${WebhookHelpers.getWebhookBaseUrl()}rest/oauth2-credential/callback`, + redirectUri: `${WebhookHelpers.getWebhookBaseUrl()}${this.restEndpoint}/oauth2-credential/callback`, scopes: _.split(_.get(oauthCredentials, 'scope', 'openid,') as string, ','), state: stateEncodedStr, }); @@ -1127,11 +1167,12 @@ class App { // ---------------------------------------- // Verify and store app code. Generate access tokens and store for respective credential. - this.app.get('/rest/oauth2-credential/callback', async (req: express.Request, res: express.Response) => { + this.app.get(`/${this.restEndpoint}/oauth2-credential/callback`, async (req: express.Request, res: express.Response) => { 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; @@ -1181,17 +1222,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()}rest/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); @@ -1227,7 +1271,7 @@ class App { // Returns all finished executions - this.app.get('/rest/executions', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/executions`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { let filter: any = {}; // tslint:disable-line:no-any if (req.query.filter) { @@ -1292,7 +1336,7 @@ class App { // Returns a specific execution - this.app.get('/rest/executions/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/executions/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const result = await Db.collections.Execution!.findOne(req.params.id); if (result === undefined) { @@ -1306,7 +1350,7 @@ class App { // Retries a failed execution - this.app.post('/rest/executions/:id/retry', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.post(`/${this.restEndpoint}/executions/:id/retry`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { // Get the data to execute const fullExecutionDataFlatted = await Db.collections.Execution!.findOne(req.params.id); @@ -1380,7 +1424,7 @@ class App { // Delete Executions // INFORMATION: We use POST instead of DELETE to not run into any issues // with the query data getting to long - this.app.post('/rest/executions/delete', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.post(`/${this.restEndpoint}/executions/delete`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const deleteData = req.body as IExecutionDeleteFilter; if (deleteData.deleteBefore !== undefined) { @@ -1407,7 +1451,7 @@ class App { // Returns all the currently working executions - this.app.get('/rest/executions-current', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/executions-current`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const executingWorkflows = this.activeExecutionsInstance.getActiveExecutions(); const returnData: IExecutionsSummary[] = []; @@ -1436,7 +1480,7 @@ class App { })); // Forces the execution to stop - this.app.post('/rest/executions-current/:id/stop', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.post(`/${this.restEndpoint}/executions-current/:id/stop`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const executionId = req.params.id; // Stopt he execution and wait till it is done and we got the data @@ -1458,7 +1502,7 @@ class App { // Removes a test webhook - this.app.delete('/rest/test-webhook/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.delete(`/${this.restEndpoint}/test-webhook/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const workflowId = req.params.id; return this.testWebhooks.cancelTestWebhook(workflowId); })); @@ -1470,7 +1514,7 @@ class App { // ---------------------------------------- // Returns all the available timezones - this.app.get('/rest/options/timezones', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/options/timezones`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { return timezones; })); @@ -1483,7 +1527,7 @@ class App { // Returns the settings which are needed in the UI - this.app.get('/rest/settings', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get(`/${this.restEndpoint}/settings`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { return { endpointWebhook: this.endpointWebhook, endpointWebhookTest: this.endpointWebhookTest, @@ -1629,9 +1673,57 @@ class App { }); + if (this.endpointPresetCredentials !== '') { + + // POST endpoint to set preset credentials + this.app.post(`/${this.endpointPresetCredentials}`, async (req: express.Request, res: express.Response) => { + + if (this.presetCredentialsLoaded === false) { + + const body = req.body as ICredentialsOverwrite; + + if (req.headers['content-type'] !== 'application/json') { + ResponseHelper.sendErrorResponse(res, new Error('Body must be a valid JSON, make sure the content-type is application/json')); + return; + } + + const loadNodesAndCredentials = LoadNodesAndCredentials(); + + const credentialsOverwrites = CredentialsOverwrites(); + + await credentialsOverwrites.init(body); + + const credentialTypes = CredentialTypes(); + + await credentialTypes.init(loadNodesAndCredentials.credentialTypes); + + this.presetCredentialsLoaded = true; + + ResponseHelper.sendSuccessResponse(res, { success: true }, true, 200); + + } else { + ResponseHelper.sendErrorResponse(res, new Error('Preset credentials can be set once')); + } + }); + } + + + // Read the index file and replace the path placeholder + const editorUiPath = require.resolve('n8n-editor-ui'); + const filePath = pathJoin(pathDirname(editorUiPath), 'dist', 'index.html'); + const n8nPath = config.get('path'); + + let readIndexFile = readFileSync(filePath, 'utf8'); + readIndexFile = readIndexFile.replace(/\/%BASE_PATH%\//g, n8nPath); + readIndexFile = readIndexFile.replace(/\/favicon.ico/g, `${n8nPath}/favicon.ico`); + + // 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/cli/src/TestWebhooks.ts b/packages/cli/src/TestWebhooks.ts index 45ae624e2b..a20fa76c88 100644 --- a/packages/cli/src/TestWebhooks.ts +++ b/packages/cli/src/TestWebhooks.ts @@ -141,12 +141,14 @@ export class TestWebhooks { let key: string; for (const webhookData of webhooks) { key = this.activeWebhooks!.getWebhookKey(webhookData.httpMethod, webhookData.path); + + await this.activeWebhooks!.add(workflow, webhookData, mode); + this.testWebhookData[key] = { sessionId, timeout, workflowData, }; - await this.activeWebhooks!.add(workflow, webhookData, mode); // Save static data! this.testWebhookData[key].workflowData.staticData = workflow.staticData; diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index d39e3f5dee..cbfb7be95c 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -69,6 +69,33 @@ export function getWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflo return returnData; } +/** + * Returns all the webhooks which should be created for the give workflow + * + * @export + * @param {string} workflowId + * @param {Workflow} workflow + * @returns {IWebhookData[]} + */ +export function getWorkflowWebhooksBasic(workflow: Workflow): IWebhookData[] { + // Check all the nodes in the workflow if they have webhooks + + const returnData: IWebhookData[] = []; + + let parentNodes: string[] | undefined; + + for (const node of Object.values(workflow.nodes)) { + if (parentNodes !== undefined && !parentNodes.includes(node.name)) { + // If parentNodes are given check only them if they have webhooks + // and no other ones + continue; + } + returnData.push.apply(returnData, NodeHelpers.getNodeWebhooksBasic(workflow, node)); + } + + return returnData; +} + /** * Executes a webhook @@ -149,6 +176,9 @@ export function getWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflo }; } + // Save static data if it changed + await WorkflowHelpers.saveStaticData(workflow); + if (webhookData.webhookDescription['responseHeaders'] !== undefined) { const responseHeaders = workflow.getComplexParameterValue(workflowStartNode, webhookData.webhookDescription['responseHeaders'], undefined) as { entries?: Array<{ diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 1c672410c5..4230e79f58 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -1,6 +1,7 @@ import { CredentialsHelper, Db, + ExternalHooks, IExecutionDb, IExecutionFlattedDb, IPushDataExecutionFinished, @@ -40,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 @@ -78,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 @@ -188,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 { @@ -303,6 +335,10 @@ export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additi workflowData = workflowInfo.code; } + const externalHooks = ExternalHooks(); + await externalHooks.init(); + await externalHooks.run('workflow.execute', [workflowData, mode]); + const nodeTypes = NodeTypes(); const workflowName = workflowData ? workflowData.name : undefined; @@ -311,14 +347,14 @@ export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additi // Does not get used so set it simply to empty string const executionId = ''; - // Create new additionalData to have different workflow loaded and to call - // different webooks - const additionalDataIntegrated = await getBase(additionalData.credentials); - additionalDataIntegrated.hooks = getWorkflowHooksIntegrated(mode, executionId, workflowData!, { parentProcessMode: additionalData.hooks!.mode }); - // Get the needed credentials for the current workflow as they will differ to the ones of the // calling workflow. - additionalDataIntegrated.credentials = await WorkflowCredentials(workflowData!.nodes); + const credentials = await WorkflowCredentials(workflowData!.nodes); + + // Create new additionalData to have different workflow loaded and to call + // different webooks + const additionalDataIntegrated = await getBase(credentials); + additionalDataIntegrated.hooks = getWorkflowHooksIntegrated(mode, executionId, workflowData!, { parentProcessMode: additionalData.hooks!.mode }); // Find Start-Node const requiredNodeTypes = ['n8n-nodes-base.start']; diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 48ecff4cc0..c0d08f446c 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -2,6 +2,7 @@ import { ActiveExecutions, CredentialsOverwrites, CredentialTypes, + ExternalHooks, ICredentialsOverwrite, ICredentialsTypeData, IProcessMessageDataHook, @@ -100,6 +101,9 @@ export class WorkflowRunner { * @memberof WorkflowRunner */ async run(data: IWorkflowExecutionDataProcess, loadStaticData?: boolean): Promise { + const externalHooks = ExternalHooks(); + await externalHooks.run('workflow.execute', [data.workflowData, data.executionMode]); + const executionsProcess = config.get('executions.process') as string; if (executionsProcess === 'main') { return this.runMainProcess(data, loadStaticData); diff --git a/packages/cli/src/databases/mongodb/ExecutionEntity.ts b/packages/cli/src/databases/mongodb/ExecutionEntity.ts index ba5071a36d..02a639d66a 100644 --- a/packages/cli/src/databases/mongodb/ExecutionEntity.ts +++ b/packages/cli/src/databases/mongodb/ExecutionEntity.ts @@ -39,6 +39,7 @@ export class ExecutionEntity implements IExecutionFlattedDb { @Column('Date') startedAt: Date; + @Index() @Column('Date') stoppedAt: Date; diff --git a/packages/cli/src/databases/mongodb/WebhookEntity.ts b/packages/cli/src/databases/mongodb/WebhookEntity.ts new file mode 100644 index 0000000000..dbf90f3da1 --- /dev/null +++ b/packages/cli/src/databases/mongodb/WebhookEntity.ts @@ -0,0 +1,30 @@ +import { + Column, + Entity, + Index, + ObjectID, + ObjectIdColumn, +} from 'typeorm'; + +import { + IWebhookDb, + } from '../../Interfaces'; + +@Entity() +export class WebhookEntity implements IWebhookDb { + + @ObjectIdColumn() + id: ObjectID; + + @Column() + workflowId: number; + + @Column() + webhookPath: string; + + @Column() + method: string; + + @Column() + node: string; +} diff --git a/packages/cli/src/databases/mongodb/index.ts b/packages/cli/src/databases/mongodb/index.ts index 164d67fd0c..bd6b9abd60 100644 --- a/packages/cli/src/databases/mongodb/index.ts +++ b/packages/cli/src/databases/mongodb/index.ts @@ -1,3 +1,5 @@ export * from './CredentialsEntity'; export * from './ExecutionEntity'; export * from './WorkflowEntity'; +export * from './WebhookEntity'; + diff --git a/packages/cli/src/databases/mongodb/migrations/151594910478695-CreateIndexStoppedAt.ts b/packages/cli/src/databases/mongodb/migrations/151594910478695-CreateIndexStoppedAt.ts new file mode 100644 index 0000000000..9cfe4480dc --- /dev/null +++ b/packages/cli/src/databases/mongodb/migrations/151594910478695-CreateIndexStoppedAt.ts @@ -0,0 +1,22 @@ +import { MigrationInterface } from "typeorm"; +import { + MongoQueryRunner, +} from 'typeorm/driver/mongodb/MongoQueryRunner'; + +import * as config from '../../../../config'; + +export class CreateIndexStoppedAt1594910478695 implements MigrationInterface { + name = 'CreateIndexStoppedAt1594910478695' + + public async up(queryRunner: MongoQueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.manager.createCollectionIndex(`${tablePrefix}execution_entity`, 'stoppedAt', { name: `IDX_${tablePrefix}execution_entity_stoppedAt`}); + } + + public async down(queryRunner: MongoQueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.manager.dropCollectionIndex + (`${tablePrefix}execution_entity`, `IDX_${tablePrefix}execution_entity_stoppedAt`); + } + +} diff --git a/packages/cli/src/databases/mongodb/migrations/1592679094242-WebhookModel.ts b/packages/cli/src/databases/mongodb/migrations/1592679094242-WebhookModel.ts new file mode 100644 index 0000000000..c05a44f765 --- /dev/null +++ b/packages/cli/src/databases/mongodb/migrations/1592679094242-WebhookModel.ts @@ -0,0 +1,57 @@ +import { + MigrationInterface, +} from 'typeorm'; + +import { + IWorkflowDb, + NodeTypes, + WebhookHelpers, +} from '../../..'; + +import { + Workflow, +} from 'n8n-workflow/dist/src/Workflow'; + +import { + IWebhookDb, +} from '../../../Interfaces'; + +import * as config from '../../../../config'; + +import { + MongoQueryRunner, +} from 'typeorm/driver/mongodb/MongoQueryRunner'; + +export class WebhookModel1592679094242 implements MigrationInterface { + name = 'WebhookModel1592679094242'; + + async up(queryRunner: MongoQueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + const workflows = await queryRunner.cursor( `${tablePrefix}workflow_entity`, { active: true }).toArray() as IWorkflowDb[]; + const data: IWebhookDb[] = []; + const nodeTypes = NodeTypes(); + for (const workflow of workflows) { + const workflowInstance = new Workflow({ id: workflow.id as string, name: workflow.name, nodes: workflow.nodes, connections: workflow.connections, active: workflow.active, nodeTypes, staticData: workflow.staticData, settings: workflow.settings }); + const webhooks = WebhookHelpers.getWorkflowWebhooksBasic(workflowInstance); + for (const webhook of webhooks) { + data.push({ + workflowId: workflowInstance.id as string, + webhookPath: webhook.path, + method: webhook.httpMethod, + node: webhook.node, + }); + } + } + + if (data.length !== 0) { + await queryRunner.manager.insertMany(`${tablePrefix}webhook_entity`, data); + } + + await queryRunner.manager.createCollectionIndex(`${tablePrefix}webhook_entity`, ['webhookPath', 'method'], { unique: true, background: false }); + } + + async down(queryRunner: MongoQueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.dropTable(`${tablePrefix}webhook_entity`); + } +} diff --git a/packages/cli/src/databases/mongodb/migrations/index.ts b/packages/cli/src/databases/mongodb/migrations/index.ts index a60bdc7cf8..ae4a6deb38 100644 --- a/packages/cli/src/databases/mongodb/migrations/index.ts +++ b/packages/cli/src/databases/mongodb/migrations/index.ts @@ -1 +1,3 @@ export * from './1587563438936-InitialMigration'; +export * from './1592679094242-WebhookModel'; +export * from './151594910478695-CreateIndexStoppedAt'; diff --git a/packages/cli/src/databases/mysqldb/ExecutionEntity.ts b/packages/cli/src/databases/mysqldb/ExecutionEntity.ts index e0c084fcfc..3db01032b2 100644 --- a/packages/cli/src/databases/mysqldb/ExecutionEntity.ts +++ b/packages/cli/src/databases/mysqldb/ExecutionEntity.ts @@ -39,6 +39,7 @@ export class ExecutionEntity implements IExecutionFlattedDb { @Column('datetime') startedAt: Date; + @Index() @Column('datetime') stoppedAt: Date; diff --git a/packages/cli/src/databases/mysqldb/WebhookEntity.ts b/packages/cli/src/databases/mysqldb/WebhookEntity.ts new file mode 100644 index 0000000000..a78fd34ae9 --- /dev/null +++ b/packages/cli/src/databases/mysqldb/WebhookEntity.ts @@ -0,0 +1,25 @@ +import { + Column, + Entity, + PrimaryColumn, +} from 'typeorm'; + +import { + IWebhookDb, + } from '../../Interfaces'; + +@Entity() +export class WebhookEntity implements IWebhookDb { + + @Column() + workflowId: number; + + @PrimaryColumn() + webhookPath: string; + + @PrimaryColumn() + method: string; + + @Column() + node: string; +} diff --git a/packages/cli/src/databases/mysqldb/index.ts b/packages/cli/src/databases/mysqldb/index.ts index 164d67fd0c..a3494531db 100644 --- a/packages/cli/src/databases/mysqldb/index.ts +++ b/packages/cli/src/databases/mysqldb/index.ts @@ -1,3 +1,4 @@ export * from './CredentialsEntity'; export * from './ExecutionEntity'; export * from './WorkflowEntity'; +export * from './WebhookEntity'; diff --git a/packages/cli/src/databases/mysqldb/migrations/1588157391238-InitialMigration.ts b/packages/cli/src/databases/mysqldb/migrations/1588157391238-InitialMigration.ts index fc11ef32fb..1d1d4d8cc5 100644 --- a/packages/cli/src/databases/mysqldb/migrations/1588157391238-InitialMigration.ts +++ b/packages/cli/src/databases/mysqldb/migrations/1588157391238-InitialMigration.ts @@ -8,8 +8,8 @@ export class InitialMigration1588157391238 implements MigrationInterface { async up(queryRunner: QueryRunner): Promise { const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query('CREATE TABLE IF NOT EXISTS `' + tablePrefix + 'credentials_entity` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL, `data` text NOT NULL, `type` varchar(32) NOT NULL, `nodesAccess` json NOT NULL, `createdAt` datetime NOT NULL, `updatedAt` datetime NOT NULL, INDEX `IDX_07fde106c0b471d8cc80a64fc8` (`type`), PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); - await queryRunner.query('CREATE TABLE IF NOT EXISTS `' + tablePrefix + 'execution_entity` (`id` int NOT NULL AUTO_INCREMENT, `data` text NOT NULL, `finished` tinyint NOT NULL, `mode` varchar(255) NOT NULL, `retryOf` varchar(255) NULL, `retrySuccessId` varchar(255) NULL, `startedAt` datetime NOT NULL, `stoppedAt` datetime NOT NULL, `workflowData` json NOT NULL, `workflowId` varchar(255) NULL, INDEX `IDX_c4d999a5e90784e8caccf5589d` (`workflowId`), PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); + await queryRunner.query('CREATE TABLE IF NOT EXISTS `' + tablePrefix + 'credentials_entity` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL, `data` text NOT NULL, `type` varchar(32) NOT NULL, `nodesAccess` json NOT NULL, `createdAt` datetime NOT NULL, `updatedAt` datetime NOT NULL, INDEX `IDX_' + tablePrefix + '07fde106c0b471d8cc80a64fc8` (`type`), PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); + await queryRunner.query('CREATE TABLE IF NOT EXISTS `' + tablePrefix + 'execution_entity` (`id` int NOT NULL AUTO_INCREMENT, `data` text NOT NULL, `finished` tinyint NOT NULL, `mode` varchar(255) NOT NULL, `retryOf` varchar(255) NULL, `retrySuccessId` varchar(255) NULL, `startedAt` datetime NOT NULL, `stoppedAt` datetime NOT NULL, `workflowData` json NOT NULL, `workflowId` varchar(255) NULL, INDEX `IDX_' + tablePrefix + 'c4d999a5e90784e8caccf5589d` (`workflowId`), PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); await queryRunner.query('CREATE TABLE IF NOT EXISTS`' + tablePrefix + 'workflow_entity` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL, `active` tinyint NOT NULL, `nodes` json NOT NULL, `connections` json NOT NULL, `createdAt` datetime NOT NULL, `updatedAt` datetime NOT NULL, `settings` json NULL, `staticData` json NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); } @@ -17,9 +17,9 @@ export class InitialMigration1588157391238 implements MigrationInterface { const tablePrefix = config.get('database.tablePrefix'); await queryRunner.query('DROP TABLE `' + tablePrefix + 'workflow_entity`', undefined); - await queryRunner.query('DROP INDEX `IDX_c4d999a5e90784e8caccf5589d` ON `' + tablePrefix + 'execution_entity`', undefined); + await queryRunner.query('DROP INDEX `IDX_' + tablePrefix + 'c4d999a5e90784e8caccf5589d` ON `' + tablePrefix + 'execution_entity`', undefined); await queryRunner.query('DROP TABLE `' + tablePrefix + 'execution_entity`', undefined); - await queryRunner.query('DROP INDEX `IDX_07fde106c0b471d8cc80a64fc8` ON `' + tablePrefix + 'credentials_entity`', undefined); + await queryRunner.query('DROP INDEX `IDX_' + tablePrefix + '07fde106c0b471d8cc80a64fc8` ON `' + tablePrefix + 'credentials_entity`', undefined); await queryRunner.query('DROP TABLE `' + tablePrefix + 'credentials_entity`', undefined); } diff --git a/packages/cli/src/databases/mysqldb/migrations/1592447867632-WebhookModel.ts b/packages/cli/src/databases/mysqldb/migrations/1592447867632-WebhookModel.ts new file mode 100644 index 0000000000..8a49080462 --- /dev/null +++ b/packages/cli/src/databases/mysqldb/migrations/1592447867632-WebhookModel.ts @@ -0,0 +1,59 @@ +import { + MigrationInterface, + QueryRunner, +} from 'typeorm'; + +import * as config from '../../../../config'; + +import { + IWorkflowDb, + NodeTypes, + WebhookHelpers, +} from '../../..'; + +import { + Workflow, +} from 'n8n-workflow'; + +import { + IWebhookDb, +} from '../../../Interfaces'; + +export class WebhookModel1592447867632 implements MigrationInterface { + name = 'WebhookModel1592447867632'; + + async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}webhook_entity (workflowId int NOT NULL, webhookPath varchar(255) NOT NULL, method varchar(255) NOT NULL, node varchar(255) NOT NULL, PRIMARY KEY (webhookPath, method)) ENGINE=InnoDB`); + + const workflows = await queryRunner.query(`SELECT * FROM ${tablePrefix}workflow_entity WHERE active=true`) as IWorkflowDb[]; + const data: IWebhookDb[] = []; + const nodeTypes = NodeTypes(); + for (const workflow of workflows) { + const workflowInstance = new Workflow({ id: workflow.id as string, name: workflow.name, nodes: workflow.nodes, connections: workflow.connections, active: workflow.active, nodeTypes, staticData: workflow.staticData, settings: workflow.settings }); + const webhooks = WebhookHelpers.getWorkflowWebhooksBasic(workflowInstance); + for (const webhook of webhooks) { + data.push({ + workflowId: workflowInstance.id as string, + webhookPath: webhook.path, + method: webhook.httpMethod, + node: webhook.node, + }); + } + } + + if (data.length !== 0) { + await queryRunner.manager.createQueryBuilder() + .insert() + .into(`${tablePrefix}webhook_entity`) + .values(data) + .execute(); + } + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP TABLE ${tablePrefix}webhook_entity`); + } +} diff --git a/packages/cli/src/databases/mysqldb/migrations/1594902918301-CreateIndexStoppedAt.ts b/packages/cli/src/databases/mysqldb/migrations/1594902918301-CreateIndexStoppedAt.ts new file mode 100644 index 0000000000..2cc4c86367 --- /dev/null +++ b/packages/cli/src/databases/mysqldb/migrations/1594902918301-CreateIndexStoppedAt.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +import * as config from '../../../../config'; + +export class CreateIndexStoppedAt1594902918301 implements MigrationInterface { + name = 'CreateIndexStoppedAt1594902918301' + + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query('CREATE INDEX `IDX_' + tablePrefix + 'cefb067df2402f6aed0638a6c1` ON `' + tablePrefix + 'execution_entity` (`stoppedAt`)'); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query('DROP INDEX `IDX_' + tablePrefix + 'cefb067df2402f6aed0638a6c1` ON `' + tablePrefix + 'execution_entity`'); + } + +} diff --git a/packages/cli/src/databases/mysqldb/migrations/index.ts b/packages/cli/src/databases/mysqldb/migrations/index.ts index ac2dcab467..7c0cb217ef 100644 --- a/packages/cli/src/databases/mysqldb/migrations/index.ts +++ b/packages/cli/src/databases/mysqldb/migrations/index.ts @@ -1 +1,3 @@ -export * from './1588157391238-InitialMigration'; \ No newline at end of file +export * from './1588157391238-InitialMigration'; +export * from './1592447867632-WebhookModel'; +export * from './1594902918301-CreateIndexStoppedAt'; diff --git a/packages/cli/src/databases/postgresdb/ExecutionEntity.ts b/packages/cli/src/databases/postgresdb/ExecutionEntity.ts index 8a7f691f0f..8b45336c2f 100644 --- a/packages/cli/src/databases/postgresdb/ExecutionEntity.ts +++ b/packages/cli/src/databases/postgresdb/ExecutionEntity.ts @@ -39,6 +39,7 @@ export class ExecutionEntity implements IExecutionFlattedDb { @Column('timestamp') startedAt: Date; + @Index() @Column('timestamp') stoppedAt: Date; diff --git a/packages/cli/src/databases/postgresdb/WebhookEntity.ts b/packages/cli/src/databases/postgresdb/WebhookEntity.ts new file mode 100644 index 0000000000..6e511cde74 --- /dev/null +++ b/packages/cli/src/databases/postgresdb/WebhookEntity.ts @@ -0,0 +1,25 @@ +import { + Column, + Entity, + PrimaryColumn, +} from 'typeorm'; + +import { + IWebhookDb, + } from '../../'; + +@Entity() +export class WebhookEntity implements IWebhookDb { + + @Column() + workflowId: number; + + @PrimaryColumn() + webhookPath: string; + + @PrimaryColumn() + method: string; + + @Column() + node: string; +} diff --git a/packages/cli/src/databases/postgresdb/index.ts b/packages/cli/src/databases/postgresdb/index.ts index 164d67fd0c..bd6b9abd60 100644 --- a/packages/cli/src/databases/postgresdb/index.ts +++ b/packages/cli/src/databases/postgresdb/index.ts @@ -1,3 +1,5 @@ export * from './CredentialsEntity'; export * from './ExecutionEntity'; export * from './WorkflowEntity'; +export * from './WebhookEntity'; + diff --git a/packages/cli/src/databases/postgresdb/migrations/1587669153312-InitialMigration.ts b/packages/cli/src/databases/postgresdb/migrations/1587669153312-InitialMigration.ts index 555015c10d..eace7a92fb 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1587669153312-InitialMigration.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1587669153312-InitialMigration.ts @@ -1,4 +1,5 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { + MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; @@ -7,29 +8,31 @@ export class InitialMigration1587669153312 implements MigrationInterface { async up(queryRunner: QueryRunner): Promise { let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixIndex = tablePrefix; const schema = config.get('database.postgresdb.schema'); if (schema) { tablePrefix = schema + '.' + tablePrefix; } - await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}credentials_entity ("id" SERIAL NOT NULL, "name" character varying(128) NOT NULL, "data" text NOT NULL, "type" character varying(32) NOT NULL, "nodesAccess" json NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT PK_814c3d3c36e8a27fa8edb761b0e PRIMARY KEY ("id"))`, undefined); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_07fde106c0b471d8cc80a64fc8 ON ${tablePrefix}credentials_entity (type) `, undefined); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}execution_entity ("id" SERIAL NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" character varying NOT NULL, "retryOf" character varying, "retrySuccessId" character varying, "startedAt" TIMESTAMP NOT NULL, "stoppedAt" TIMESTAMP NOT NULL, "workflowData" json NOT NULL, "workflowId" character varying, CONSTRAINT PK_e3e63bbf986767844bbe1166d4e PRIMARY KEY ("id"))`, undefined); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_c4d999a5e90784e8caccf5589d ON ${tablePrefix}execution_entity ("workflowId") `, undefined); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}workflow_entity ("id" SERIAL NOT NULL, "name" character varying(128) NOT NULL, "active" boolean NOT NULL, "nodes" json NOT NULL, "connections" json NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, "settings" json, "staticData" json, CONSTRAINT PK_eded7d72664448da7745d551207 PRIMARY KEY ("id"))`, undefined); + await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}credentials_entity ("id" SERIAL NOT NULL, "name" character varying(128) NOT NULL, "data" text NOT NULL, "type" character varying(32) NOT NULL, "nodesAccess" json NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT PK_${tablePrefixIndex}814c3d3c36e8a27fa8edb761b0e PRIMARY KEY ("id"))`, undefined); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixIndex}07fde106c0b471d8cc80a64fc8 ON ${tablePrefix}credentials_entity (type) `, undefined); + await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}execution_entity ("id" SERIAL NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" character varying NOT NULL, "retryOf" character varying, "retrySuccessId" character varying, "startedAt" TIMESTAMP NOT NULL, "stoppedAt" TIMESTAMP NOT NULL, "workflowData" json NOT NULL, "workflowId" character varying, CONSTRAINT PK_${tablePrefixIndex}e3e63bbf986767844bbe1166d4e PRIMARY KEY ("id"))`, undefined); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixIndex}c4d999a5e90784e8caccf5589d ON ${tablePrefix}execution_entity ("workflowId") `, undefined); + await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}workflow_entity ("id" SERIAL NOT NULL, "name" character varying(128) NOT NULL, "active" boolean NOT NULL, "nodes" json NOT NULL, "connections" json NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, "settings" json, "staticData" json, CONSTRAINT PK_${tablePrefixIndex}eded7d72664448da7745d551207 PRIMARY KEY ("id"))`, undefined); } async down(queryRunner: QueryRunner): Promise { let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixIndex = tablePrefix; const schema = config.get('database.postgresdb.schema'); if (schema) { tablePrefix = schema + '.' + tablePrefix; } await queryRunner.query(`DROP TABLE ${tablePrefix}workflow_entity`, undefined); - await queryRunner.query(`DROP INDEX IDX_c4d999a5e90784e8caccf5589d`, undefined); + await queryRunner.query(`DROP INDEX IDX_${tablePrefixIndex}c4d999a5e90784e8caccf5589d`, undefined); await queryRunner.query(`DROP TABLE ${tablePrefix}execution_entity`, undefined); - await queryRunner.query(`DROP INDEX IDX_07fde106c0b471d8cc80a64fc8`, undefined); + await queryRunner.query(`DROP INDEX IDX_${tablePrefixIndex}07fde106c0b471d8cc80a64fc8`, undefined); await queryRunner.query(`DROP TABLE ${tablePrefix}credentials_entity`, undefined); } diff --git a/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts b/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts new file mode 100644 index 0000000000..e53fc28915 --- /dev/null +++ b/packages/cli/src/databases/postgresdb/migrations/1589476000887-WebhookModel.ts @@ -0,0 +1,69 @@ +import { + MigrationInterface, + QueryRunner, +} from 'typeorm'; + +import { + IWorkflowDb, + NodeTypes, + WebhookHelpers, +} from '../../..'; + +import { + Workflow, +} from 'n8n-workflow'; + +import { + IWebhookDb, +} from '../../../Interfaces'; + +import * as config from '../../../../config'; + +export class WebhookModel1589476000887 implements MigrationInterface { + name = 'WebhookModel1589476000887'; + + async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixIndex = tablePrefix; + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + + await queryRunner.query(`CREATE TABLE ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" character varying NOT NULL, "method" character varying NOT NULL, "node" character varying NOT NULL, CONSTRAINT "PK_${tablePrefixIndex}b21ace2e13596ccd87dc9bf4ea6" PRIMARY KEY ("webhookPath", "method"))`, undefined); + + const workflows = await queryRunner.query(`SELECT * FROM ${tablePrefix}workflow_entity WHERE active=true`) as IWorkflowDb[]; + const data: IWebhookDb[] = []; + const nodeTypes = NodeTypes(); + for (const workflow of workflows) { + const workflowInstance = new Workflow({ id: workflow.id as string, name: workflow.name, nodes: workflow.nodes, connections: workflow.connections, active: workflow.active, nodeTypes, staticData: workflow.staticData, settings: workflow.settings }); + const webhooks = WebhookHelpers.getWorkflowWebhooksBasic(workflowInstance); + for (const webhook of webhooks) { + data.push({ + workflowId: workflowInstance.id as string, + webhookPath: webhook.path, + method: webhook.httpMethod, + node: webhook.node, + }); + } + } + + if (data.length !== 0) { + await queryRunner.manager.createQueryBuilder() + .insert() + .into(`${tablePrefix}webhook_entity`) + .values(data) + .execute(); + } + } + + async down(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + await queryRunner.query(`DROP TABLE ${tablePrefix}webhook_entity`, undefined); + } + +} diff --git a/packages/cli/src/databases/postgresdb/migrations/1594828256133-CreateIndexStoppedAt.ts b/packages/cli/src/databases/postgresdb/migrations/1594828256133-CreateIndexStoppedAt.ts new file mode 100644 index 0000000000..7dd9578634 --- /dev/null +++ b/packages/cli/src/databases/postgresdb/migrations/1594828256133-CreateIndexStoppedAt.ts @@ -0,0 +1,25 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +import * as config from '../../../../config'; + +export class CreateIndexStoppedAt1594828256133 implements MigrationInterface { + name = 'CreateIndexStoppedAt1594828256133' + + public async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixPure = tablePrefix; + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixPure}33228da131bb1112247cf52a42 ON ${tablePrefix}execution_entity ("stoppedAt") `); + } + + public async down(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query(`DROP INDEX IDX_${tablePrefix}33228da131bb1112247cf52a42`); + } + +} diff --git a/packages/cli/src/databases/postgresdb/migrations/index.ts b/packages/cli/src/databases/postgresdb/migrations/index.ts index 5bb6551492..3b10537067 100644 --- a/packages/cli/src/databases/postgresdb/migrations/index.ts +++ b/packages/cli/src/databases/postgresdb/migrations/index.ts @@ -1 +1,4 @@ export * from './1587669153312-InitialMigration'; +export * from './1589476000887-WebhookModel'; +export * from './1594828256133-CreateIndexStoppedAt'; + diff --git a/packages/cli/src/databases/sqlite/ExecutionEntity.ts b/packages/cli/src/databases/sqlite/ExecutionEntity.ts index 825fed7fb5..bb7de2605d 100644 --- a/packages/cli/src/databases/sqlite/ExecutionEntity.ts +++ b/packages/cli/src/databases/sqlite/ExecutionEntity.ts @@ -39,6 +39,7 @@ export class ExecutionEntity implements IExecutionFlattedDb { @Column() startedAt: Date; + @Index() @Column() stoppedAt: Date; diff --git a/packages/cli/src/databases/sqlite/WebhookEntity.ts b/packages/cli/src/databases/sqlite/WebhookEntity.ts new file mode 100644 index 0000000000..a78fd34ae9 --- /dev/null +++ b/packages/cli/src/databases/sqlite/WebhookEntity.ts @@ -0,0 +1,25 @@ +import { + Column, + Entity, + PrimaryColumn, +} from 'typeorm'; + +import { + IWebhookDb, + } from '../../Interfaces'; + +@Entity() +export class WebhookEntity implements IWebhookDb { + + @Column() + workflowId: number; + + @PrimaryColumn() + webhookPath: string; + + @PrimaryColumn() + method: string; + + @Column() + node: string; +} diff --git a/packages/cli/src/databases/sqlite/index.ts b/packages/cli/src/databases/sqlite/index.ts index 2c7d6e25e9..a3494531db 100644 --- a/packages/cli/src/databases/sqlite/index.ts +++ b/packages/cli/src/databases/sqlite/index.ts @@ -1,4 +1,4 @@ export * from './CredentialsEntity'; export * from './ExecutionEntity'; export * from './WorkflowEntity'; - +export * from './WebhookEntity'; diff --git a/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts b/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts index 31b271d633..09a0da911a 100644 --- a/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts +++ b/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts @@ -1,4 +1,7 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { + MigrationInterface, + QueryRunner, +} from 'typeorm'; import * as config from '../../../../config'; @@ -9,9 +12,9 @@ export class InitialMigration1588102412422 implements MigrationInterface { const tablePrefix = config.get('database.tablePrefix'); await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`, undefined); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `, undefined); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `, undefined); await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime NOT NULL, "workflowData" text NOT NULL, "workflowId" varchar)`, undefined); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_c4d999a5e90784e8caccf5589d" ON "${tablePrefix}execution_entity" ("workflowId") `, undefined); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}c4d999a5e90784e8caccf5589d" ON "${tablePrefix}execution_entity" ("workflowId") `, undefined); await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text)`, undefined); } @@ -19,9 +22,9 @@ export class InitialMigration1588102412422 implements MigrationInterface { const tablePrefix = config.get('database.tablePrefix'); await queryRunner.query(`DROP TABLE "${tablePrefix}workflow_entity"`, undefined); - await queryRunner.query(`DROP INDEX "IDX_c4d999a5e90784e8caccf5589d"`, undefined); + await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}c4d999a5e90784e8caccf5589d"`, undefined); await queryRunner.query(`DROP TABLE "${tablePrefix}execution_entity"`, undefined); - await queryRunner.query(`DROP INDEX "IDX_07fde106c0b471d8cc80a64fc8"`, undefined); + await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8"`, undefined); await queryRunner.query(`DROP TABLE "${tablePrefix}credentials_entity"`, undefined); } diff --git a/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts b/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts new file mode 100644 index 0000000000..92704482b2 --- /dev/null +++ b/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts @@ -0,0 +1,63 @@ +import { + MigrationInterface, + QueryRunner, +} from 'typeorm'; + +import * as config from '../../../../config'; + +import { + IWorkflowDb, + NodeTypes, + WebhookHelpers, +} from '../../..'; + +import { + Workflow, +} from 'n8n-workflow'; + +import { + IWebhookDb, +} from '../../../Interfaces'; + +export class WebhookModel1592445003908 implements MigrationInterface { + name = 'WebhookModel1592445003908'; + + async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, PRIMARY KEY ("webhookPath", "method"))`); + + const workflows = await queryRunner.query(`SELECT * FROM ${tablePrefix}workflow_entity WHERE active=true`) as IWorkflowDb[]; + const data: IWebhookDb[] = []; + const nodeTypes = NodeTypes(); + for (const workflow of workflows) { + workflow.nodes = JSON.parse(workflow.nodes as unknown as string); + workflow.connections = JSON.parse(workflow.connections as unknown as string); + workflow.staticData = JSON.parse(workflow.staticData as unknown as string); + workflow.settings = JSON.parse(workflow.settings as unknown as string); + const workflowInstance = new Workflow({ id: workflow.id as string, name: workflow.name, nodes: workflow.nodes, connections: workflow.connections, active: workflow.active, nodeTypes, staticData: workflow.staticData, settings: workflow.settings }); + const webhooks = WebhookHelpers.getWorkflowWebhooksBasic(workflowInstance); + for (const webhook of webhooks) { + data.push({ + workflowId: workflowInstance.id as string, + webhookPath: webhook.path, + method: webhook.httpMethod, + node: webhook.node, + }); + } + } + + if (data.length !== 0) { + await queryRunner.manager.createQueryBuilder() + .insert() + .into(`${tablePrefix}webhook_entity`) + .values(data) + .execute(); + } + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP TABLE ${tablePrefix}webhook_entity`); + } +} diff --git a/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts b/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts new file mode 100644 index 0000000000..cfa4812020 --- /dev/null +++ b/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +import * as config from '../../../../config'; + +export class CreateIndexStoppedAt1594825041918 implements MigrationInterface { + name = 'CreateIndexStoppedAt1594825041918' + + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "execution_entity" ("stoppedAt") `); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1"`); + } + +} diff --git a/packages/cli/src/databases/sqlite/migrations/index.ts b/packages/cli/src/databases/sqlite/migrations/index.ts index 8d9a0a0b16..f0a2068b92 100644 --- a/packages/cli/src/databases/sqlite/migrations/index.ts +++ b/packages/cli/src/databases/sqlite/migrations/index.ts @@ -1 +1,3 @@ -export * from './1588102412422-InitialMigration'; \ No newline at end of file +export * from './1588102412422-InitialMigration'; +export * from './1592445003908-WebhookModel'; +export * from './1594825041918-CreateIndexStoppedAt' diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3916e79edd..3a6337a35d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ export * from './CredentialsHelper'; export * from './CredentialTypes'; export * from './CredentialsOverwrites'; +export * from './ExternalHooks'; export * from './Interfaces'; export * from './LoadNodesAndCredentials'; export * from './NodeTypes'; diff --git a/packages/core/LICENSE.md b/packages/core/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/core/LICENSE.md +++ b/packages/core/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. 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/core/package.json b/packages/core/package.json index 259ce7d4cb..4fb55a9d8d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.35.0", + "version": "0.39.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -30,7 +30,7 @@ "@types/express": "^4.16.1", "@types/jest": "^24.0.18", "@types/lodash.get": "^4.4.6", - "@types/mmmagic": "^0.4.29", + "@types/mime-types": "^2.1.0", "@types/node": "^10.10.1", "@types/request-promise-native": "^1.0.15", "jest": "^24.9.0", @@ -43,9 +43,10 @@ "client-oauth2": "^4.2.5", "cron": "^1.7.2", "crypto-js": "3.1.9-1", + "file-type": "^14.6.2", "lodash.get": "^4.4.2", - "mmmagic": "^0.5.2", - "n8n-workflow": "~0.32.0", + "mime-types": "^2.1.27", + "n8n-workflow": "~0.35.0", "p-cancelable": "^2.0.0", "request": "^2.88.2", "request-promise-native": "^1.0.7" diff --git a/packages/core/src/ActiveWebhooks.ts b/packages/core/src/ActiveWebhooks.ts index 17cf753830..529fe01af3 100644 --- a/packages/core/src/ActiveWebhooks.ts +++ b/packages/core/src/ActiveWebhooks.ts @@ -35,13 +35,20 @@ export class ActiveWebhooks { throw new Error('Webhooks can only be added for saved workflows as an id is needed!'); } + const webhookKey = this.getWebhookKey(webhookData.httpMethod, webhookData.path); + + //check that there is not a webhook already registed with that path/method + if (this.webhookUrls[webhookKey] !== undefined) { + throw new Error(`Test-Webhook can not be activated because another one with the same method "${webhookData.httpMethod}" and path "${webhookData.path}" is already active!`); + } + if (this.workflowWebhooks[webhookData.workflowId] === undefined) { this.workflowWebhooks[webhookData.workflowId] = []; } // Make the webhook available directly because sometimes to create it successfully // it gets called - this.webhookUrls[this.getWebhookKey(webhookData.httpMethod, webhookData.path)] = webhookData; + this.webhookUrls[webhookKey] = webhookData; const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); if (webhookExists === false) { diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index d500d56f88..f9811099ec 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -44,14 +44,9 @@ import * as express from 'express'; import * as path from 'path'; import { OptionsWithUrl, OptionsWithUri } from 'request'; import * as requestPromise from 'request-promise-native'; - -import { Magic, MAGIC_MIME_TYPE } from 'mmmagic'; - import { createHmac } from 'crypto'; - - -const magic = new Magic(MAGIC_MIME_TYPE); - +import { fromBuffer } from 'file-type'; +import { lookup } from 'mime-types'; /** @@ -66,18 +61,28 @@ const magic = new Magic(MAGIC_MIME_TYPE); */ export async function prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise { if (!mimeType) { - // If not mime type is given figure it out - mimeType = await new Promise( - (resolve, reject) => { - magic.detect(binaryData, (err: Error, mimeType: string) => { - if (err) { - return reject(err); - } + // If no mime type is given figure it out - return resolve(mimeType); - }); + if (filePath) { + // Use file path to guess mime type + const mimeTypeLookup = lookup(filePath); + if (mimeTypeLookup) { + mimeType = mimeTypeLookup; } - ); + } + + if (!mimeType) { + // Use buffer to guess mime type + const fileTypeData = await fromBuffer(binaryData); + if (fileTypeData) { + mimeType = fileTypeData.mime; + } + } + + if (!mimeType) { + // Fall back to text + mimeType = 'text/plain'; + } } const returnData: IBinaryData = { @@ -413,7 +418,8 @@ export function getNodeWebhookUrl(name: string, workflow: Workflow, node: INode, return undefined; } - return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id!, node, path.toString()); + const isFullPath: boolean = workflow.getSimpleParameterValue(node, webhookDescription['isFullPath'], false) as boolean; + return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id!, node, path.toString(), isFullPath); } diff --git a/packages/core/src/UserSettings.ts b/packages/core/src/UserSettings.ts index 211341ed25..fb38fc779c 100644 --- a/packages/core/src/UserSettings.ts +++ b/packages/core/src/UserSettings.ts @@ -41,8 +41,13 @@ export async function prepareUserSettings(): Promise { userSettings = {}; } - // Settings and/or key do not exist. So generate a new encryption key - userSettings.encryptionKey = randomBytes(24).toString('base64'); + if (process.env[ENCRYPTION_KEY_ENV_OVERWRITE] !== undefined) { + // Use the encryption key which got set via environment + userSettings.encryptionKey = process.env[ENCRYPTION_KEY_ENV_OVERWRITE]; + } else { + // Generate a new encryption key + userSettings.encryptionKey = randomBytes(24).toString('base64'); + } console.log(`UserSettings got generated and saved to: ${settingsPath}`); diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 1e8b8898a7..c30be39ca1 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -459,7 +459,7 @@ export class WorkflowExecute { let executionData: IExecuteData; let executionError: IExecutionError | undefined; let executionNode: INode; - let nodeSuccessData: INodeExecutionData[][] | null; + let nodeSuccessData: INodeExecutionData[][] | null | undefined; let runIndex: number; let startTime: number; let taskData: ITaskData; @@ -593,9 +593,15 @@ export class WorkflowExecute { } } - this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name; nodeSuccessData = await workflow.runNode(executionData.node, executionData.data, this.runExecutionData, runIndex, this.additionalData, NodeExecuteFunctions, this.mode); + if (nodeSuccessData === undefined) { + // Node did not get executed + nodeSuccessData = null; + } else { + this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name; + } + if (nodeSuccessData === null || nodeSuccessData[0][0] === undefined) { if (executionData.node.alwaysOutputData === true) { nodeSuccessData = nodeSuccessData || []; diff --git a/packages/editor-ui/LICENSE.md b/packages/editor-ui/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/editor-ui/LICENSE.md +++ b/packages/editor-ui/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. 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/package.json b/packages/editor-ui/package.json index 8543d2fdd0..4bcc6b8579 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.46.0", + "version": "0.50.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -14,7 +14,7 @@ "url": "git+https://github.com/n8n-io/n8n.git" }, "scripts": { - "build": "vue-cli-service build", + "build": "cross-env VUE_APP_PUBLIC_PATH=\"/%BASE_PATH%/\" vue-cli-service build", "dev": "npm run serve", "lint": "vue-cli-service lint", "serve": "cross-env VUE_APP_URL_BASE_API=http://localhost:5678/ vue-cli-service serve", @@ -23,7 +23,9 @@ "test:e2e": "vue-cli-service test:e2e", "test:unit": "vue-cli-service test:unit" }, - "dependencies": {}, + "dependencies": { + "uuid": "^8.1.0" + }, "devDependencies": { "@beyonk/google-fonts-webpack-plugin": "^1.2.3", "@fortawesome/fontawesome-svg-core": "^1.2.19", @@ -64,7 +66,7 @@ "lodash.debounce": "^4.0.8", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", - "n8n-workflow": "~0.32.0", + "n8n-workflow": "~0.35.0", "node-sass": "^4.12.0", "prismjs": "^1.17.1", "quill": "^2.0.0-dev.3", diff --git a/packages/editor-ui/public/index.html b/packages/editor-ui/public/index.html index 2f2450023d..b533aea170 100644 --- a/packages/editor-ui/public/index.html +++ b/packages/editor-ui/public/index.html @@ -4,12 +4,13 @@ - + + n8n.io - Workflow Automation
diff --git a/packages/editor-ui/src/components/ExpressionInput.vue b/packages/editor-ui/src/components/ExpressionInput.vue index 543eb12f22..d0c734e2c6 100644 --- a/packages/editor-ui/src/components/ExpressionInput.vue +++ b/packages/editor-ui/src/components/ExpressionInput.vue @@ -122,6 +122,13 @@ export default mixins( readOnly: !!this.resolvedValue, modules: { autoformat: {}, + keyboard: { + bindings: { + 'list autofill': { + prefix: /^$/, + }, + }, + }, }, }); diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index 71388c2cd3..baea830b01 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: this.$store.getters.getBaseUrl, isCollapsed: true, credentialNewDialogVisible: false, credentialOpenDialogVisible: false, diff --git a/packages/editor-ui/src/components/NodeWebhooks.vue b/packages/editor-ui/src/components/NodeWebhooks.vue index b649575a0b..e2875a49fd 100644 --- a/packages/editor-ui/src/components/NodeWebhooks.vue +++ b/packages/editor-ui/src/components/NodeWebhooks.vue @@ -110,8 +110,9 @@ export default mixins( const workflowId = this.$store.getters.workflowId; const path = this.getValue(webhookData, 'path'); + const isFullPath = this.getValue(webhookData, 'isFullPath') as unknown as boolean || false; - return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, this.node, path); + return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, this.node, path, isFullPath); }, }, watch: { diff --git a/packages/editor-ui/src/components/RunData.vue b/packages/editor-ui/src/components/RunData.vue index b3729e4f68..42fbaa5b19 100644 --- a/packages/editor-ui/src/components/RunData.vue +++ b/packages/editor-ui/src/components/RunData.vue @@ -19,7 +19,7 @@
- + Results: {{ dataCount }} Results: @@ -248,7 +248,11 @@ export default mixins( return executionData.resultData.runData; }, maxDisplayItemsOptions (): number[] { - return [25, 50, 100, 250, 500, 1000, this.dataCount].filter(option => option <= this.dataCount); + const options = [25, 50, 100, 250, 500, 1000].filter(option => option <= this.dataCount); + if (!options.includes(this.dataCount)) { + options.push(this.dataCount); + } + return options; }, node (): INodeUi | null { return this.$store.getters.activeNode; diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index e82b30b588..348a1ea66b 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 === '/%BASE_PATH%/' ? '/' : window.BASE_PATH, routes: [ { path: '/execution/:id', diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index b9762bb616..e0ba93b7cf 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -29,8 +29,6 @@ import { XYPositon, } from './Interface'; -import { get } from 'lodash'; - Vue.use(Vuex); export const store = new Vuex.Store({ @@ -40,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: process.env.VUE_APP_URL_BASE_API ? process.env.VUE_APP_URL_BASE_API : (window.BASE_PATH === '/%BASE_PATH%/' ? '/' : window.BASE_PATH), credentials: null as ICredentialsResponse[] | null, credentialTypes: null as ICredentialType[] | null, endpointWebhook: 'webhook', diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index de89534e09..e22b98f595 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -126,6 +126,8 @@ import RunData from '@/components/RunData.vue'; import mixins from 'vue-typed-mixins'; +import { v4 as uuidv4 } from 'uuid'; + import { debounce } from 'lodash'; import axios from 'axios'; import { @@ -946,6 +948,10 @@ export default mixins( // Check if node-name is unique else find one that is newNodeData.name = this.getUniqueNodeName(newNodeData.name); + if (nodeTypeData.webhooks && nodeTypeData.webhooks.length) { + newNodeData.webhookId = uuidv4(); + } + await this.addNodes([newNodeData]); // Automatically deselect all nodes and select the current one and also active @@ -1579,6 +1585,11 @@ export default mixins( console.error(e); // eslint-disable-line no-console } node.parameters = nodeParameters !== null ? nodeParameters : {}; + + // if it's a webhook and the path is empty set the UUID as the default path + if (node.type === 'n8n-nodes-base.webhook' && node.parameters.path === '') { + node.parameters.path = node.webhookId as string; + } } foundNodeIssues = this.getNodeIssues(nodeType, node); diff --git a/packages/editor-ui/vue.config.js b/packages/editor-ui/vue.config.js index f70f41c5b2..cdcd8259f9 100644 --- a/packages/editor-ui/vue.config.js +++ b/packages/editor-ui/vue.config.js @@ -29,4 +29,5 @@ module.exports = { }, }, }, + publicPath: process.env.VUE_APP_PUBLIC_PATH ? process.env.VUE_APP_PUBLIC_PATH : '/', }; diff --git a/packages/node-dev/LICENSE.md b/packages/node-dev/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/node-dev/LICENSE.md +++ b/packages/node-dev/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packages/node-dev/README.md b/packages/node-dev/README.md index 1c3d8df52f..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. @@ -127,7 +127,7 @@ export class MyNode implements INodeType { The "description" property has to be set on all nodes because it contains all the base information. Additionally do all nodes have to have exactly one of the -following methods defined which contains the the actual logic: +following methods defined which contains the actual logic: **Regular node** @@ -138,8 +138,8 @@ Method get called when the workflow gets executed By default always `execute` should be used especially when creating a third-party integration. The reason for that is that it is way more flexible and allows to, for example, return a different amount of items than it received -as input. This is very important when a node should query data like return -all users. In that case, does the node normally just receive one input-item +as input. This is very important when a node should query data like *return +all users*. In that case, does the node normally just receive one input-item but returns as many as users exist. So in doubt always `execute` should be used! @@ -188,10 +188,10 @@ The following properties can be set in the node description: - **outputs** [required]: Types of outputs the node has (currently only "main" exists) and the amount - **outputNames** [optional]: In case a node has multiple outputs names can be set that users know what data to expect - **maxNodes** [optional]: If not an unlimited amount of nodes of that type can exist in a workflow the max-amount can be specified - - **name** [required]: Nme of the node (for n8n to use internally in camelCase) + - **name** [required]: Name of the node (for n8n to use internally, in camelCase) - **properties** [required]: Properties which get displayed in the Editor UI and can be set by the user - **subtitle** [optional]: Text which should be displayed underneath the name of the node in the Editor UI (can be an expression) - - **version** [required]: Version of the node. Currently always "1" (integer). For future usage does not get used yet. + - **version** [required]: Version of the node. Currently always "1" (integer). For future usage, does not get used yet. - **webhooks** [optional]: Webhooks the node should listen to @@ -200,12 +200,12 @@ The following properties can be set in the node description: The following properties can be set in the node properties: - **default** [required]: Default value of the property - - **description** [required]: Description to display users in Editor UI - - **displayName** [required]: Name to display users in Editor UI + - **description** [required]: Description that is displayed to users in the Editor UI + - **displayName** [required]: Name that is displayed to users in the Editor UI - **displayOptions** [optional]: Defines logic to decide if a property should be displayed or not - - **name** [required]: Name of the property (for n8n to use internally in camelCase) + - **name** [required]: Name of the property (for n8n to use internally, in camelCase) - **options** [optional]: The options the user can select when type of property is "collection", "fixedCollection" or "options" - - **placeholder** [optional]: Placeholder text to display users in Editor UI + - **placeholder** [optional]: Placeholder text that is displayed to users in the Editor UI - **type** [required]: Type of the property. If it is for example a "string", "number", ... - **typeOptions** [optional]: Additional options for type. Like for example the min or max value of a number - **required** [optional]: Defines if the value has to be set or if it can stay empty @@ -215,11 +215,11 @@ The following properties can be set in the node properties: The following properties can be set in the node property options. -All properties are optional. The most, however, work only work when the node-property is of a specfic type. +All properties are optional. However, most only work when the node-property is of a specfic type. - - **alwaysOpenEditWindow** [type: string]: If set then the "Editor Window" will always open when the user tries to edit the field. Is helpful when long texts normally get used in the property + - **alwaysOpenEditWindow** [type: string]: If set then the "Editor Window" will always open when the user tries to edit the field. Helpful if long text is typically used in the property. - **loadOptionsMethod** [type: options]: Method to use to load options from an external service - - **maxValue** [type: number]: Maximal value of the number + - **maxValue** [type: number]: Maximum value of the number - **minValue** [type: number]: Minimum value of the number - **multipleValues** [type: all]: If set the property gets turned into an Array and the user can add multiple values - **multipleValueButtonText** [type: all]: Custom text for add button in case "multipleValues" got set diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 9d859de394..8df65a2792 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "0.7.0", + "version": "0.9.0", "description": "CLI to simplify n8n credentials/node development", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -58,8 +58,8 @@ "change-case": "^4.1.1", "copyfiles": "^2.1.1", "inquirer": "^7.0.0", - "n8n-core": "^0.31.0", - "n8n-workflow": "^0.28.0", + "n8n-core": "^0.36.0", + "n8n-workflow": "^0.33.0", "replace-in-file": "^6.0.0", "request": "^2.88.2", "tmp-promise": "^2.0.2", diff --git a/packages/node-dev/src/Build.ts b/packages/node-dev/src/Build.ts index ddb74add0a..fd695efb4b 100644 --- a/packages/node-dev/src/Build.ts +++ b/packages/node-dev/src/Build.ts @@ -105,10 +105,10 @@ export async function buildFiles (options?: IBuildOptions): Promise { } return new Promise((resolve, reject) => { + copyfiles([join(process.cwd(), './*.png'), outputDirectory], { up: true }, () => resolve(outputDirectory)); buildProcess.on('exit', code => { // Remove the tmp tsconfig file tsconfigData.cleanup(); - copyfiles([join(process.cwd(), './*.png'), outputDirectory], { up: true }, () => resolve(outputDirectory)); }); }); } diff --git a/packages/node-dev/templates/webhook/simple.ts b/packages/node-dev/templates/webhook/simple.ts index eaf1521e84..ab81ca51d2 100644 --- a/packages/node-dev/templates/webhook/simple.ts +++ b/packages/node-dev/templates/webhook/simple.ts @@ -27,7 +27,7 @@ export class ClassNameReplace implements INodeType { { name: 'default', httpMethod: 'POST', - reponseMode: 'onReceived', + responseMode: 'onReceived', // Each webhook property can either be hardcoded // like the above ones or referenced from a parameter // like the "path" property bellow diff --git a/packages/nodes-base/LICENSE.md b/packages/nodes-base/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/nodes-base/LICENSE.md +++ b/packages/nodes-base/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. 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/CircleCiApi.credentials.ts b/packages/nodes-base/credentials/CircleCiApi.credentials.ts new file mode 100644 index 0000000000..ef3104b5e6 --- /dev/null +++ b/packages/nodes-base/credentials/CircleCiApi.credentials.ts @@ -0,0 +1,17 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class CircleCiApi implements ICredentialType { + name = 'circleCiApi'; + displayName = 'CircleCI API'; + properties = [ + { + displayName: 'Personal API Token', + name: 'apiKey', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/credentials/CrateDb.credentials.ts b/packages/nodes-base/credentials/CrateDb.credentials.ts new file mode 100644 index 0000000000..a5e0fb776c --- /dev/null +++ b/packages/nodes-base/credentials/CrateDb.credentials.ts @@ -0,0 +1,69 @@ +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; + +export class CrateDb implements ICredentialType { + name = 'crateDb'; + displayName = 'CrateDB'; + properties = [ + { + displayName: 'Host', + name: 'host', + type: 'string' as NodePropertyTypes, + default: 'localhost', + }, + { + displayName: 'Database', + name: 'database', + type: 'string' as NodePropertyTypes, + default: 'doc', + }, + { + displayName: 'User', + name: 'user', + type: 'string' as NodePropertyTypes, + default: 'crate', + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true, + }, + default: '', + }, + { + displayName: 'SSL', + name: 'ssl', + type: 'options' as NodePropertyTypes, + options: [ + { + name: 'disable', + value: 'disable', + }, + { + name: 'allow', + value: 'allow', + }, + { + name: 'require', + value: 'require', + }, + { + name: 'verify (not implemented)', + value: 'verify', + }, + { + name: 'verify-full (not implemented)', + value: 'verify-full', + }, + ], + default: 'disable', + }, + { + displayName: 'Port', + name: 'port', + type: 'number' as NodePropertyTypes, + default: 5432, + }, + ]; +} diff --git a/packages/nodes-base/credentials/DriftOAuth2Api.credentials.ts b/packages/nodes-base/credentials/DriftOAuth2Api.credentials.ts new file mode 100644 index 0000000000..1d25c49dfe --- /dev/null +++ b/packages/nodes-base/credentials/DriftOAuth2Api.credentials.ts @@ -0,0 +1,47 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class DriftOAuth2Api implements ICredentialType { + name = 'driftOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Drift OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://dev.drift.com/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://driftapi.com/oauth2/token', + required: true, + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + }, + ]; +} diff --git a/packages/nodes-base/credentials/EventbriteApi.credentials.ts b/packages/nodes-base/credentials/EventbriteApi.credentials.ts index 9fa48753fb..e54be8580c 100644 --- a/packages/nodes-base/credentials/EventbriteApi.credentials.ts +++ b/packages/nodes-base/credentials/EventbriteApi.credentials.ts @@ -8,7 +8,7 @@ export class EventbriteApi implements ICredentialType { displayName = 'Eventbrite API'; properties = [ { - displayName: 'API Key', + displayName: 'Private Key', name: 'apiKey', type: 'string' as NodePropertyTypes, default: '', diff --git a/packages/nodes-base/credentials/EventbriteOAuth2Api.credentials.ts b/packages/nodes-base/credentials/EventbriteOAuth2Api.credentials.ts new file mode 100644 index 0000000000..46a9df4266 --- /dev/null +++ b/packages/nodes-base/credentials/EventbriteOAuth2Api.credentials.ts @@ -0,0 +1,47 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class EventbriteOAuth2Api implements ICredentialType { + name = 'eventbriteOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Eventbrite OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://www.eventbrite.com/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://www.eventbrite.com/oauth/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body' + }, + ]; +} diff --git a/packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts new file mode 100644 index 0000000000..bfd7f5afbc --- /dev/null +++ b/packages/nodes-base/credentials/GitlabOAuth2Api.credentials.ts @@ -0,0 +1,53 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class GitlabOAuth2Api implements ICredentialType { + name = 'gitlabOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Gitlab OAuth2 API'; + properties = [ + { + displayName: 'Gitlab Server', + name: 'server', + type: 'string' as NodePropertyTypes, + default: 'https://gitlab.com' + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://gitlab.com/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://gitlab.com/oauth/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: 'api', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + }, + ]; +} diff --git a/packages/nodes-base/credentials/GoogleDriveOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GoogleDriveOAuth2Api.credentials.ts new file mode 100644 index 0000000000..77b0a9504c --- /dev/null +++ b/packages/nodes-base/credentials/GoogleDriveOAuth2Api.credentials.ts @@ -0,0 +1,26 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/drive.appdata', + 'https://www.googleapis.com/auth/drive.photos.readonly', +]; + +export class GoogleDriveOAuth2Api implements ICredentialType { + name = 'googleDriveOAuth2Api'; + extends = [ + 'googleOAuth2Api', + ]; + displayName = 'Google Drive OAuth2 API'; + properties = [ + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' '), + }, + ]; +} diff --git a/packages/nodes-base/credentials/GoogleTasksOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GoogleTasksOAuth2Api.credentials.ts new file mode 100644 index 0000000000..ac1242be2b --- /dev/null +++ b/packages/nodes-base/credentials/GoogleTasksOAuth2Api.credentials.ts @@ -0,0 +1,22 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'https://www.googleapis.com/auth/tasks', +]; + +export class GoogleTasksOAuth2Api implements ICredentialType { + name = 'googleTasksOAuth2Api'; + extends = ['googleOAuth2Api']; + displayName = 'Google Tasks OAuth2 API'; + properties = [ + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' ') + }, + ]; +} diff --git a/packages/nodes-base/credentials/HubspotOAuth2Api.credentials.ts b/packages/nodes-base/credentials/HubspotOAuth2Api.credentials.ts new file mode 100644 index 0000000000..ce18b899df --- /dev/null +++ b/packages/nodes-base/credentials/HubspotOAuth2Api.credentials.ts @@ -0,0 +1,53 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'contacts', + 'forms', + 'tickets', +]; + +export class HubspotOAuth2Api implements ICredentialType { + name = 'hubspotOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Hubspot OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://app.hubspot.com/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://api.hubapi.com/oauth/v1/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' '), + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: 'grant_type=authorization_code', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + description: 'Resource to consume.', + }, + ]; +} diff --git a/packages/nodes-base/credentials/MailchimpOAuth2Api.credentials.ts b/packages/nodes-base/credentials/MailchimpOAuth2Api.credentials.ts new file mode 100644 index 0000000000..886424b6a6 --- /dev/null +++ b/packages/nodes-base/credentials/MailchimpOAuth2Api.credentials.ts @@ -0,0 +1,54 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class MailchimpOAuth2Api implements ICredentialType { + name = 'mailchimpOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Mailchimp OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://login.mailchimp.com/oauth2/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://login.mailchimp.com/oauth2/token', + required: true, + }, + { + displayName: 'Metadata', + name: 'metadataUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://login.mailchimp.com/oauth2/metadata', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + 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/credentials/MauticOAuth2Api.credentials.ts b/packages/nodes-base/credentials/MauticOAuth2Api.credentials.ts new file mode 100644 index 0000000000..8c52f9372c --- /dev/null +++ b/packages/nodes-base/credentials/MauticOAuth2Api.credentials.ts @@ -0,0 +1,55 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class MauticOAuth2Api implements ICredentialType { + name = 'mauticOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Mautic OAuth2 API'; + properties = [ + { + displayName: 'URL', + name: 'url', + type: 'string' as NodePropertyTypes, + default: '', + placeholder: 'https://name.mautic.net', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'string' as NodePropertyTypes, + default: '', + placeholder: 'https://name.mautic.net/oauth/v2/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'string' as NodePropertyTypes, + default: '', + placeholder: 'https://name.mautic.net/oauth/v2/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + 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/credentials/MessageBirdApi.credentials.ts b/packages/nodes-base/credentials/MessageBirdApi.credentials.ts new file mode 100644 index 0000000000..e67c6a0c9e --- /dev/null +++ b/packages/nodes-base/credentials/MessageBirdApi.credentials.ts @@ -0,0 +1,14 @@ +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; + +export class MessageBirdApi implements ICredentialType { + name = 'messageBirdApi'; + displayName = 'MessageBird API'; + properties = [ + { + displayName: 'API Key', + name: 'accessKey', + type: 'string' as NodePropertyTypes, + default: '' + } + ]; +} diff --git a/packages/nodes-base/credentials/MicrosoftSql.credentials.ts b/packages/nodes-base/credentials/MicrosoftSql.credentials.ts new file mode 100644 index 0000000000..812e9bfdd7 --- /dev/null +++ b/packages/nodes-base/credentials/MicrosoftSql.credentials.ts @@ -0,0 +1,47 @@ +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; + +export class MicrosoftSql implements ICredentialType { + name = 'microsoftSql'; + displayName = 'Microsoft SQL'; + properties = [ + { + displayName: 'Server', + name: 'server', + type: 'string' as NodePropertyTypes, + default: 'localhost' + }, + { + displayName: 'Database', + name: 'database', + type: 'string' as NodePropertyTypes, + default: 'master' + }, + { + displayName: 'User', + name: 'user', + type: 'string' as NodePropertyTypes, + default: 'sa' + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true + }, + default: '' + }, + { + displayName: 'Port', + name: 'port', + type: 'number' as NodePropertyTypes, + default: 1433 + }, + { + displayName: 'Domain', + name: 'domain', + type: 'string' as NodePropertyTypes, + default: '' + } + ]; +} diff --git a/packages/nodes-base/credentials/NextCloudApi.credentials.ts b/packages/nodes-base/credentials/NextCloudApi.credentials.ts index c75632df00..3081455506 100644 --- a/packages/nodes-base/credentials/NextCloudApi.credentials.ts +++ b/packages/nodes-base/credentials/NextCloudApi.credentials.ts @@ -12,7 +12,7 @@ export class NextCloudApi implements ICredentialType { displayName: 'Web DAV URL', name: 'webDavUrl', type: 'string' as NodePropertyTypes, - placeholder: 'https://nextcloud.example.com/remote.php/webdav/', + placeholder: 'https://nextcloud.example.com/remote.php/webdav', default: '', }, { diff --git a/packages/nodes-base/credentials/NextCloudOAuth2Api.credentials.ts b/packages/nodes-base/credentials/NextCloudOAuth2Api.credentials.ts new file mode 100644 index 0000000000..9379ee5203 --- /dev/null +++ b/packages/nodes-base/credentials/NextCloudOAuth2Api.credentials.ts @@ -0,0 +1,54 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class NextCloudOAuth2Api implements ICredentialType { + name = 'nextCloudOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'NextCloud OAuth2 API'; + properties = [ + { + displayName: 'Web DAV URL', + name: 'webDavUrl', + type: 'string' as NodePropertyTypes, + placeholder: 'https://nextcloud.example.com/remote.php/webdav', + default: '', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'string' as NodePropertyTypes, + default: 'https://nextcloud.example.com/apps/oauth2/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'string' as NodePropertyTypes, + default: 'https://nextcloud.example.com/apps/oauth2/api/v1/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + }, + ]; +} diff --git a/packages/nodes-base/credentials/PagerDutyOAuth2Api.credentials.ts b/packages/nodes-base/credentials/PagerDutyOAuth2Api.credentials.ts new file mode 100644 index 0000000000..28b44011db --- /dev/null +++ b/packages/nodes-base/credentials/PagerDutyOAuth2Api.credentials.ts @@ -0,0 +1,45 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class PagerDutyOAuth2Api implements ICredentialType { + name = 'pagerDutyOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'PagerDuty OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://app.pagerduty.com/oauth/authorize', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://app.pagerduty.com/oauth/token', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'header', + description: 'Method of authentication.', + }, + ]; +} diff --git a/packages/nodes-base/credentials/PipedriveOAuth2Api.credentials.ts b/packages/nodes-base/credentials/PipedriveOAuth2Api.credentials.ts index 813202739f..3902bb6212 100644 --- a/packages/nodes-base/credentials/PipedriveOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/PipedriveOAuth2Api.credentials.ts @@ -3,7 +3,6 @@ import { NodePropertyTypes, } from 'n8n-workflow'; - export class PipedriveOAuth2Api implements ICredentialType { name = 'pipedriveOAuth2Api'; extends = [ @@ -28,7 +27,7 @@ export class PipedriveOAuth2Api implements ICredentialType { { displayName: 'Scope', name: 'scope', - type: 'string' as NodePropertyTypes, + type: 'hidden' as NodePropertyTypes, default: '', }, { diff --git a/packages/nodes-base/credentials/PostmarkApi.credentials.ts b/packages/nodes-base/credentials/PostmarkApi.credentials.ts new file mode 100644 index 0000000000..b5f73621c4 --- /dev/null +++ b/packages/nodes-base/credentials/PostmarkApi.credentials.ts @@ -0,0 +1,18 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class PostmarkApi implements ICredentialType { + name = 'postmarkApi'; + displayName = 'Postmark API'; + properties = [ + { + displayName: 'Server API Token', + name: 'serverToken', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/credentials/QuestDb.credentials.ts b/packages/nodes-base/credentials/QuestDb.credentials.ts new file mode 100644 index 0000000000..24c1522737 --- /dev/null +++ b/packages/nodes-base/credentials/QuestDb.credentials.ts @@ -0,0 +1,69 @@ +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; + +export class QuestDb implements ICredentialType { + name = 'questDb'; + displayName = 'QuestDB'; + properties = [ + { + displayName: 'Host', + name: 'host', + type: 'string' as NodePropertyTypes, + default: 'localhost', + }, + { + displayName: 'Database', + name: 'database', + type: 'string' as NodePropertyTypes, + default: 'qdb', + }, + { + displayName: 'User', + name: 'user', + type: 'string' as NodePropertyTypes, + default: 'admin', + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true, + }, + default: 'quest', + }, + { + displayName: 'SSL', + name: 'ssl', + type: 'options' as NodePropertyTypes, + options: [ + { + name: 'disable', + value: 'disable', + }, + { + name: 'allow', + value: 'allow', + }, + { + name: 'require', + value: 'require', + }, + { + name: 'verify (not implemented)', + value: 'verify', + }, + { + name: 'verify-full (not implemented)', + value: 'verify-full', + }, + ], + default: 'disable', + }, + { + displayName: 'Port', + name: 'port', + type: 'number' as NodePropertyTypes, + default: 8812, + }, + ]; +} diff --git a/packages/nodes-base/credentials/Signl4Api.credentials.ts b/packages/nodes-base/credentials/Signl4Api.credentials.ts new file mode 100644 index 0000000000..842136de02 --- /dev/null +++ b/packages/nodes-base/credentials/Signl4Api.credentials.ts @@ -0,0 +1,18 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class Signl4Api implements ICredentialType { + name = 'signl4Api'; + displayName = 'SIGNL4 Webhook'; + properties = [ + { + displayName: 'Team Secret', + name: 'teamSecret', + type: 'string' as NodePropertyTypes, + default: '', + description: 'The team secret is the last part of your SIGNL4 webhook URL.' + }, + ]; +} diff --git a/packages/nodes-base/credentials/SlackOAuth2Api.credentials.ts b/packages/nodes-base/credentials/SlackOAuth2Api.credentials.ts index b56699fe68..0426ceee02 100644 --- a/packages/nodes-base/credentials/SlackOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/SlackOAuth2Api.credentials.ts @@ -6,15 +6,12 @@ import { //https://api.slack.com/authentication/oauth-v2 const userScopes = [ 'chat:write', - 'conversations:history', - 'conversations:read', 'files:read', 'files:write', 'stars:read', 'stars:write', ]; - export class SlackOAuth2Api implements ICredentialType { name = 'slackOAuth2Api'; extends = [ diff --git a/packages/nodes-base/credentials/SpotifyOAuth2Api.credentials.ts b/packages/nodes-base/credentials/SpotifyOAuth2Api.credentials.ts new file mode 100644 index 0000000000..1dc9f1057a --- /dev/null +++ b/packages/nodes-base/credentials/SpotifyOAuth2Api.credentials.ts @@ -0,0 +1,53 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class SpotifyOAuth2Api implements ICredentialType { + name = 'spotifyOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Spotify OAuth2 API'; + properties = [ + { + displayName: 'Spotify Server', + name: 'server', + type: 'hidden' as NodePropertyTypes, + default: 'https://api.spotify.com/', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://accounts.spotify.com/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://accounts.spotify.com/api/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: 'user-read-playback-state playlist-read-collaborative user-modify-playback-state playlist-modify-public user-read-currently-playing playlist-read-private user-read-recently-played playlist-modify-private', + }, + { + 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/credentials/SurveyMonkeyOAuth2Api.credentials.ts b/packages/nodes-base/credentials/SurveyMonkeyOAuth2Api.credentials.ts new file mode 100644 index 0000000000..26949d879f --- /dev/null +++ b/packages/nodes-base/credentials/SurveyMonkeyOAuth2Api.credentials.ts @@ -0,0 +1,55 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'surveys_read', + 'collectors_read', + 'responses_read', + 'responses_read_detail', + 'webhooks_write', + 'webhooks_read', +]; + +export class SurveyMonkeyOAuth2Api implements ICredentialType { + name = 'surveyMonkeyOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'SurveyMonkey OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://api.surveymonkey.com/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://api.surveymonkey.com/oauth/token', + required: true, + }, + { + 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: 'body' + }, + ]; +} diff --git a/packages/nodes-base/credentials/TypeformOAuth2Api.credentials.ts b/packages/nodes-base/credentials/TypeformOAuth2Api.credentials.ts new file mode 100644 index 0000000000..a876e87ed0 --- /dev/null +++ b/packages/nodes-base/credentials/TypeformOAuth2Api.credentials.ts @@ -0,0 +1,53 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'webhooks:write', + 'webhooks:read', + 'forms:read', +]; + + +export class TypeformOAuth2Api implements ICredentialType { + name = 'typeformOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Typeform OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://api.typeform.com/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://api.typeform.com/oauth/token', + required: true, + }, + { + 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/credentials/WebflowOAuth2Api.credentials.ts b/packages/nodes-base/credentials/WebflowOAuth2Api.credentials.ts new file mode 100644 index 0000000000..ba5501910c --- /dev/null +++ b/packages/nodes-base/credentials/WebflowOAuth2Api.credentials.ts @@ -0,0 +1,50 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class WebflowOAuth2Api implements ICredentialType { + name = 'webflowOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Webflow OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://webflow.com/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://api.webflow.com/oauth/access_token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + description: 'For some services additional query parameters have to be set which can be defined here.', + placeholder: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + description: '', + }, + ]; +} 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/credentials/ZendeskApi.credentials.ts b/packages/nodes-base/credentials/ZendeskApi.credentials.ts index 29048c1172..4f285912b6 100644 --- a/packages/nodes-base/credentials/ZendeskApi.credentials.ts +++ b/packages/nodes-base/credentials/ZendeskApi.credentials.ts @@ -8,10 +8,11 @@ export class ZendeskApi implements ICredentialType { displayName = 'Zendesk API'; properties = [ { - displayName: 'URL', - name: 'url', + displayName: 'Subdomain', + name: 'subdomain', type: 'string' as NodePropertyTypes, - default: '', + description: 'The subdomain of your Zendesk work environment.', + default: 'n8n', }, { displayName: 'Email', diff --git a/packages/nodes-base/credentials/ZendeskOAuth2Api.credentials.ts b/packages/nodes-base/credentials/ZendeskOAuth2Api.credentials.ts new file mode 100644 index 0000000000..06428456e6 --- /dev/null +++ b/packages/nodes-base/credentials/ZendeskOAuth2Api.credentials.ts @@ -0,0 +1,79 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'read', + 'write', +]; + +export class ZendeskOAuth2Api implements ICredentialType { + name = 'zendeskOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Zendesk OAuth2 API'; + properties = [ + { + displayName: 'Subdomain', + name: 'subdomain', + type: 'string' as NodePropertyTypes, + default: '', + placeholder: 'n8n', + description: 'The subdomain of your Zendesk work environment.', + required: true, + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'string' as NodePropertyTypes, + default: 'https://{SUBDOMAIN_HERE}.zendesk.com/oauth/authorizations/new', + description: 'URL to get authorization code. Replace {SUBDOMAIN_HERE} with your subdomain.', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'string' as NodePropertyTypes, + default: 'https://{SUBDOMAIN_HERE}.zendesk.com/oauth/tokens', + description: 'URL to get access token. Replace {SUBDOMAIN_HERE} with your subdomain.', + required: true, + }, + { + displayName: 'Client ID', + name: 'clientId', + type: 'string' as NodePropertyTypes, + default: '', + required: true, + }, + { + displayName: 'Client Secret', + name: 'clientSecret', + type: 'string' as NodePropertyTypes, + default: '', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' '), + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + description: 'For some services additional query parameters have to be set which can be defined here.', + placeholder: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + description: 'Resource to consume.', + }, + ]; +} diff --git a/packages/nodes-base/credentials/ZoomApi.credentials.ts b/packages/nodes-base/credentials/ZoomApi.credentials.ts new file mode 100644 index 0000000000..6efd857568 --- /dev/null +++ b/packages/nodes-base/credentials/ZoomApi.credentials.ts @@ -0,0 +1,14 @@ +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; + +export class ZoomApi implements ICredentialType { + name = 'zoomApi'; + displayName = 'Zoom API'; + properties = [ + { + displayName: 'JWT Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '' + } + ]; +} diff --git a/packages/nodes-base/credentials/ZoomOAuth2Api.credentials.ts b/packages/nodes-base/credentials/ZoomOAuth2Api.credentials.ts new file mode 100644 index 0000000000..f85cc75254 --- /dev/null +++ b/packages/nodes-base/credentials/ZoomOAuth2Api.credentials.ts @@ -0,0 +1,42 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class ZoomOAuth2Api implements ICredentialType { + name = 'zoomOAuth2Api'; + extends = ['oAuth2Api']; + displayName = 'Zoom OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://zoom.us/oauth/authorize' + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://zoom.us/oauth/token' + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '' + }, + { + 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/gulpfile.js b/packages/nodes-base/gulpfile.js index 9771a4017c..58ba6ec51a 100644 --- a/packages/nodes-base/gulpfile.js +++ b/packages/nodes-base/gulpfile.js @@ -1,7 +1,7 @@ const { src, dest } = require('gulp'); function copyIcons() { - return src('nodes/**/*.png') + return src('nodes/**/*.{png,svg}') .pipe(dest('dist/nodes')); } diff --git a/packages/nodes-base/nodes/Affinity/AffinityTrigger.node.ts b/packages/nodes-base/nodes/Affinity/AffinityTrigger.node.ts index 6b57d46804..3d387e7b4d 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', diff --git a/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts b/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts new file mode 100644 index 0000000000..a15a9511e2 --- /dev/null +++ b/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts @@ -0,0 +1,140 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeTypeDescription, + INodeExecutionData, + INodeType, +} from 'n8n-workflow'; + +import { + pipelineFields, + pipelineOperations, +} from './PipelineDescription'; + +import { + circleciApiRequest, + circleciApiRequestAllItems, +} from './GenericFunctions'; + +export class CircleCi implements INodeType { + description: INodeTypeDescription = { + displayName: 'CircleCI', + name: 'circleCi', + icon: 'file:circleCi.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume CircleCI API', + defaults: { + name: 'CircleCI', + color: '#04AA51', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'circleCiApi', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: ' Pipeline', + value: 'pipeline', + }, + ], + default: 'pipeline', + description: 'Resource to consume.', + }, + ...pipelineOperations, + ...pipelineFields, + ], + }; + + 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; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < length; i++) { + if (resource === 'pipeline') { + if (operation === 'get') { + const vcs = this.getNodeParameter('vcs', i) as string; + let slug = this.getNodeParameter('projectSlug', i) as string; + const pipelineNumber = this.getNodeParameter('pipelineNumber', i) as number; + + slug = slug.replace(new RegExp(/\//g), '%2F'); + + const endpoint = `/project/${vcs}/${slug}/pipeline/${pipelineNumber}`; + + responseData = await circleciApiRequest.call(this, 'GET', endpoint, {}, qs); + } + if (operation === 'getAll') { + const vcs = this.getNodeParameter('vcs', i) as string; + const filters = this.getNodeParameter('filters', i) as IDataObject; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + let slug = this.getNodeParameter('projectSlug', i) as string; + + slug = slug.replace(new RegExp(/\//g), '%2F'); + + if (filters.branch) { + qs.branch = filters.branch; + } + + const endpoint = `/project/${vcs}/${slug}/pipeline`; + + if (returnAll === true) { + responseData = await circleciApiRequestAllItems.call(this, 'items', 'GET', endpoint, {}, qs); + + } else { + qs.limit = this.getNodeParameter('limit', i) as number; + responseData = await circleciApiRequest.call(this, 'GET', endpoint, {}, qs); + responseData = responseData.items; + responseData = responseData.splice(0, qs.limit); + } + } + + if (operation === 'trigger') { + const vcs = this.getNodeParameter('vcs', i) as string; + let slug = this.getNodeParameter('projectSlug', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + slug = slug.replace(new RegExp(/\//g), '%2F'); + + const endpoint = `/project/${vcs}/${slug}/pipeline`; + + const body: IDataObject = {}; + + if (additionalFields.branch) { + body.branch = additionalFields.branch as string; + } + + if (additionalFields.tag) { + body.tag = additionalFields.tag as string; + } + + responseData = await circleciApiRequest.call(this, 'POST', endpoint, body, qs); + } + } + 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/CircleCi/GenericFunctions.ts b/packages/nodes-base/nodes/CircleCi/GenericFunctions.ts new file mode 100644 index 0000000000..fb30950a1a --- /dev/null +++ b/packages/nodes-base/nodes/CircleCi/GenericFunctions.ts @@ -0,0 +1,67 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function circleciApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('circleCiApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + let options: OptionsWithUri = { + headers: { + 'Circle-Token': credentials.apiKey, + 'Accept': 'application/json', + }, + method, + qs, + body, + uri: uri ||`https://circleci.com/api/v2${resource}`, + json: true + }; + options = Object.assign({}, options, option); + if (Object.keys(options.body).length === 0) { + delete options.body; + } + try { + return await this.helpers.request!(options); + } catch (err) { + if (err.response && err.response.body && err.response.body.message) { + // Try to return the error prettier + throw new Error(`CircleCI error response [${err.statusCode}]: ${err.response.body.message}`); + } + + // If that data does not exist for some reason return the actual error + throw err; } +} + +/** + * Make an API request to paginated CircleCI endpoint + * and return all results + */ +export async function circleciApiRequestAllItems(this: IHookFunctions | IExecuteFunctions| ILoadOptionsFunctions, propertyName: string, method: string, resource: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + do { + responseData = await circleciApiRequest.call(this, method, resource, body, query); + returnData.push.apply(returnData, responseData[propertyName]); + query['page-token'] = responseData.next_page_token; + } while ( + responseData.next_page_token !== undefined && + responseData.next_page_token !== null + ); + return returnData; +} diff --git a/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts b/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts new file mode 100644 index 0000000000..520fdbcd05 --- /dev/null +++ b/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts @@ -0,0 +1,229 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const pipelineOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'pipeline', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a pipeline', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all pipelines', + }, + { + name: 'Trigger', + value: 'trigger', + description: 'Trigger a pipeline', + }, + ], + default: 'get', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const pipelineFields = [ + +/* -------------------------------------------------------------------------- */ +/* pipeline:shared */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Provider', + name: 'vcs', + type: 'options', + options: [ + { + name: 'Bitbucket', + value: 'bitbucket', + }, + { + name: 'GitHub', + value: 'github', + }, + ], + displayOptions: { + show: { + operation: [ + 'get', + 'getAll', + 'trigger', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: '', + description: 'Version control system', + }, + { + displayName: 'Project Slug', + name: 'projectSlug', + type: 'string', + displayOptions: { + show: { + operation: [ + 'get', + 'getAll', + 'trigger', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: '', + placeholder: 'n8n-io/n8n', + description: 'Project slug in the form org-name/repo-name', + }, + +/* -------------------------------------------------------------------------- */ +/* pipeline:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Pipeline Number', + name: 'pipelineNumber', + type: 'number', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: 1, + description: 'The number of the pipeline', + }, + +/* -------------------------------------------------------------------------- */ +/* pipeline:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'pipeline', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'pipeline', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Branch', + name: 'branch', + type: 'string', + default: '', + description: 'The name of a vcs branch.', + }, + ], + }, + +/* -------------------------------------------------------------------------- */ +/* pipeline:trigger */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'pipeline', + ], + operation: [ + 'trigger', + ], + }, + }, + options: [ + { + displayName: 'Branch', + name: 'branch', + type: 'string', + default: '', + description: `The branch where the pipeline ran.
+ The HEAD commit on this branch was used for the pipeline.
+ Note that branch and tag are mutually exclusive.`, + }, + { + displayName: 'Tag', + name: 'tag', + type: 'string', + default: '', + description: `The tag used by the pipeline.
+ The commit that this tag points to was used for the pipeline.
+ Note that branch and tag are mutually exclusive`, + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/CircleCi/circleCi.png b/packages/nodes-base/nodes/CircleCi/circleCi.png new file mode 100644 index 0000000000000000000000000000000000000000..1708a6a3dd604cd31db2718e9052fb312246e0eb GIT binary patch literal 4787 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 literal 0 HcmV?d00001 diff --git a/packages/nodes-base/nodes/CrateDb/CrateDb.node.ts b/packages/nodes-base/nodes/CrateDb/CrateDb.node.ts new file mode 100644 index 0000000000..1d7ec41dbb --- /dev/null +++ b/packages/nodes-base/nodes/CrateDb/CrateDb.node.ts @@ -0,0 +1,246 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { IDataObject, INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow'; + +import * as pgPromise from 'pg-promise'; + +import { pgInsert, pgQuery, pgUpdate } from '../Postgres/Postgres.node.functions'; + +export class CrateDb implements INodeType { + description: INodeTypeDescription = { + displayName: 'CrateDB', + name: 'crateDb', + icon: 'file:cratedb.png', + group: ['input'], + version: 1, + description: 'Gets, add and update data in CrateDB.', + defaults: { + name: 'CrateDB', + color: '#47889f', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'crateDb', + required: true, + }, + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Execute Query', + value: 'executeQuery', + description: 'Executes a SQL query.', + }, + { + name: 'Insert', + value: 'insert', + description: 'Insert rows in database.', + }, + { + name: 'Update', + value: 'update', + description: 'Updates rows in database.', + }, + ], + default: 'insert', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // executeQuery + // ---------------------------------- + { + displayName: 'Query', + name: 'query', + type: 'string', + typeOptions: { + rows: 5, + }, + displayOptions: { + show: { + operation: ['executeQuery'], + }, + }, + default: '', + placeholder: 'SELECT id, name FROM product WHERE id < 40', + required: true, + description: 'The SQL query to execute.', + }, + + // ---------------------------------- + // insert + // ---------------------------------- + { + displayName: 'Schema', + name: 'schema', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: 'public', + required: true, + description: 'Name of the schema the table belongs to', + }, + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to insert data to.', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '', + placeholder: 'id,name,description', + description: + 'Comma separated list of the properties which should used as columns for the new rows.', + }, + { + displayName: 'Return Fields', + name: 'returnFields', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '*', + description: 'Comma separated list of the fields that the operation will return', + }, + + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to update data in', + }, + { + displayName: 'Update Key', + name: 'updateKey', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: 'id', + required: true, + description: + 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: '', + placeholder: 'name,description', + description: + 'Comma separated list of the properties which should used as columns for rows to update.', + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const credentials = this.getCredentials('crateDb'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const pgp = pgPromise(); + + const config = { + host: credentials.host as string, + port: credentials.port as number, + database: credentials.database as string, + user: credentials.user as string, + password: credentials.password as string, + ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), + sslmode: (credentials.ssl as string) || 'disable', + }; + + const db = pgp(config); + + let returnItems = []; + + const items = this.getInputData(); + const operation = this.getNodeParameter('operation', 0) as string; + + if (operation === 'executeQuery') { + // ---------------------------------- + // executeQuery + // ---------------------------------- + + const queryResult = await pgQuery(this.getNodeParameter, pgp, db, items); + + returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); + } else if (operation === 'insert') { + // ---------------------------------- + // insert + // ---------------------------------- + + const [insertData, insertItems] = await pgInsert(this.getNodeParameter, pgp, db, items); + + // Add the id to the data + for (let i = 0; i < insertData.length; i++) { + returnItems.push({ + json: { + ...insertData[i], + ...insertItems[i], + }, + }); + } + } else if (operation === 'update') { + // ---------------------------------- + // update + // ---------------------------------- + + const updateItems = await pgUpdate(this.getNodeParameter, pgp, db, items); + + returnItems = this.helpers.returnJsonArray(updateItems); + } else { + await pgp.end(); + throw new Error(`The operation "${operation}" is not supported!`); + } + + // Close the connection + await pgp.end(); + + return this.prepareOutputData(returnItems); + } +} diff --git a/packages/nodes-base/nodes/CrateDb/cratedb.png b/packages/nodes-base/nodes/CrateDb/cratedb.png new file mode 100644 index 0000000000000000000000000000000000000000..d2e90eade715e7a78024e942a0107ccefd9fdc76 GIT binary patch literal 475 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw1|+Ti+$;i8oCO|{#S9GG!XV7ZFl&wk0|R5P zr;B4qM&sLC8~vCZ1sWboe!F($!KN^=xJ5TI9!f`iVly#wQ=Vofa_i;~Cb4}DQoTzR zuc=Mh*VNgYapcgc#|CcwskN+S*EZ}pb3MQ9|B2WgZ*5O7|4scPxIz3Nx@ zuw}d3cW}7=n`par-MKEC(;AzFPbtP1ty>gcKAZjNmRYwR7Cb4A&^nmD{c`Qz-FttA zA7o&-CXsxWA*Sizv;$JfHP6_%`C6Nkk00Fl_`!|Bf;ScxWsXg?_uFgFslWd1e%$V` z=8{zzE4SRv;$TS7^;r5fYH?-66WdGMT4wX#?+gCl#QOT9>S6(h*r&qTY)=j@{1Ky5 zmvOWY$-rg$60BP-?=U@JUio@qh-mEE_a+JI5m+{>1`^(l@-XT=lX#qxB!d<6}t9m3hO| zDxe|6P!cWYwAY=vf<^pq+_#FtDyP5A85iaMXPuBp?(v-w&(zQqD7_^=9vE8;p00i_ I>zopr0Cm^hivR!s literal 0 HcmV?d00001 diff --git a/packages/nodes-base/nodes/DateTime.node.ts b/packages/nodes-base/nodes/DateTime.node.ts index 953375e8d9..15c33dcffc 100644 --- a/packages/nodes-base/nodes/DateTime.node.ts +++ b/packages/nodes-base/nodes/DateTime.node.ts @@ -243,9 +243,10 @@ export class DateTime implements INodeType { if (currentDate === undefined) { continue; } - if (!moment(currentDate as string | number).isValid()) { + if (options.fromFormat === undefined && !moment(currentDate as string | number).isValid()) { throw new Error('The date input format could not be recognized. Please set the "From Format" field'); } + if (Number.isInteger(currentDate as unknown as number)) { newDate = moment.unix(currentDate as unknown as number); } else { diff --git a/packages/nodes-base/nodes/Drift/Drift.node.ts b/packages/nodes-base/nodes/Drift/Drift.node.ts index 53a9a2695e..46afa3d519 100644 --- a/packages/nodes-base/nodes/Drift/Drift.node.ts +++ b/packages/nodes-base/nodes/Drift/Drift.node.ts @@ -37,9 +37,44 @@ export class Drift implements INodeType { { name: 'driftApi', required: true, + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'driftOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Resource', name: 'resource', diff --git a/packages/nodes-base/nodes/Drift/GenericFunctions.ts b/packages/nodes-base/nodes/Drift/GenericFunctions.ts index 47b38a86e3..904fc4c6b3 100644 --- a/packages/nodes-base/nodes/Drift/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Drift/GenericFunctions.ts @@ -12,25 +12,15 @@ import { } from 'n8n-workflow'; export async function driftApiRequest(this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any - - const credentials = this.getCredentials('driftApi'); - - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - - const endpoint = 'https://driftapi.com'; - let options: OptionsWithUri = { - headers: { - Authorization: `Bearer ${credentials.accessToken}`, - }, + headers: {}, method, body, qs: query, - uri: uri || `${endpoint}${resource}`, + uri: uri || `https://driftapi.com${resource}`, json: true }; + if (!Object.keys(body).length) { delete options.form; } @@ -38,11 +28,27 @@ export async function driftApiRequest(this: IExecuteFunctions | IWebhookFunction delete options.qs; } options = Object.assign({}, options, option); + + const authenticationMethod = this.getNodeParameter('authentication', 0); + try { - return await this.helpers.request!(options); + if (authenticationMethod === 'accessToken') { + const credentials = this.getCredentials('driftApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.headers!['Authorization'] = `Bearer ${credentials.accessToken}`; + + return await this.helpers.request!(options); + } else { + return await this.helpers.requestOAuth2!.call(this, 'driftOAuth2Api', options); + } } catch (error) { - if (error.response) { - const errorMessage = error.message || (error.response.body && error.response.body.message ); + + if (error.response && error.response.body && error.response.body.error) { + const errorMessage = error.response.body.error.message; throw new Error(`Drift error response [${error.statusCode}]: ${errorMessage}`); } throw error; diff --git a/packages/nodes-base/nodes/Dropbox/Dropbox.node.ts b/packages/nodes-base/nodes/Dropbox/Dropbox.node.ts index 6140568bc2..20c8248c56 100644 --- a/packages/nodes-base/nodes/Dropbox/Dropbox.node.ts +++ b/packages/nodes-base/nodes/Dropbox/Dropbox.node.ts @@ -2,6 +2,7 @@ import { BINARY_ENCODING, IExecuteFunctions, } from 'n8n-core'; + import { IDataObject, INodeTypeDescription, @@ -9,8 +10,9 @@ import { INodeType, } from 'n8n-workflow'; -import { OptionsWithUri } from 'request'; - +import { + OptionsWithUri +} from 'request'; export class Dropbox implements INodeType { description: INodeTypeDescription = { @@ -23,7 +25,7 @@ export class Dropbox implements INodeType { description: 'Access data on Dropbox', defaults: { name: 'Dropbox', - color: '#22BB44', + color: '#0061FF', }, inputs: ['main'], outputs: ['main'], @@ -454,6 +456,7 @@ export class Dropbox implements INodeType { let requestMethod = ''; let body: IDataObject | Buffer; let isJson = false; + let query: IDataObject = {}; let headers: IDataObject; @@ -470,8 +473,9 @@ export class Dropbox implements INodeType { // ---------------------------------- requestMethod = 'POST'; - headers['Dropbox-API-Arg'] = JSON.stringify({ - path: this.getNodeParameter('path', i) as string, + + query.arg = JSON.stringify({ + path: this.getNodeParameter('path', i) as string }); endpoint = 'https://content.dropboxapi.com/2/files/download'; @@ -483,9 +487,10 @@ export class Dropbox implements INodeType { requestMethod = 'POST'; headers['Content-Type'] = 'application/octet-stream'; - headers['Dropbox-API-Arg'] = JSON.stringify({ + + query.arg = JSON.stringify({ mode: 'overwrite', - path: this.getNodeParameter('path', i) as string, + path: this.getNodeParameter('path', i) as string }); endpoint = 'https://content.dropboxapi.com/2/files/upload'; @@ -594,8 +599,8 @@ export class Dropbox implements INodeType { const options: OptionsWithUri = { headers, method: requestMethod, - qs: {}, uri: endpoint, + qs: query, json: isJson, }; diff --git a/packages/nodes-base/nodes/Eventbrite/EventbriteTrigger.node.ts b/packages/nodes-base/nodes/Eventbrite/EventbriteTrigger.node.ts index fdeae599c2..0c9158b6c3 100644 --- a/packages/nodes-base/nodes/Eventbrite/EventbriteTrigger.node.ts +++ b/packages/nodes-base/nodes/Eventbrite/EventbriteTrigger.node.ts @@ -35,7 +35,25 @@ export class EventbriteTrigger implements INodeType { { name: 'eventbriteApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'privateKey', + ], + }, + }, + }, + { + name: 'eventbriteOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], webhooks: [ { @@ -46,6 +64,23 @@ export class EventbriteTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Private Key', + value: 'privateKey', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'privateKey', + description: 'The resource to operate on.', + }, { displayName: 'Organization', name: 'organization', @@ -149,7 +184,6 @@ export class EventbriteTrigger implements INodeType { description: 'By default does the webhook-data only contain the URL to receive
the object data manually. If this option gets activated it
will resolve the data automatically.', }, ], - }; methods = { @@ -192,23 +226,39 @@ export class EventbriteTrigger implements INodeType { default: { async checkExists(this: IHookFunctions): Promise { const webhookData = this.getWorkflowStaticData('node'); - if (webhookData.webhookId === undefined) { - return false; + const webhookUrl = this.getNodeWebhookUrl('default'); + const organisation = this.getNodeParameter('organization') as string; + const actions = this.getNodeParameter('actions') as string[]; + + const endpoint = `/organizations/${organisation}/webhooks/`; + + const { webhooks } = await eventbriteApiRequest.call(this, 'GET', endpoint); + + const check = (currentActions: string[], webhookActions: string[]) => { + for (const currentAction of currentActions) { + if (!webhookActions.includes(currentAction)) { + return false; + } + } + return true; + }; + + for (const webhook of webhooks) { + if (webhook.endpoint_url === webhookUrl && check(actions, webhook.actions)) { + webhookData.webhookId = webhook.id; + return true; + } } - const endpoint = `/webhooks/${webhookData.webhookId}/`; - try { - await eventbriteApiRequest.call(this, 'GET', endpoint); - } catch (e) { - return false; - } - return true; + + return false; }, async create(this: IHookFunctions): Promise { const webhookUrl = this.getNodeWebhookUrl('default'); const webhookData = this.getWorkflowStaticData('node'); + const organisation = this.getNodeParameter('organization') as string; const event = this.getNodeParameter('event') as string; const actions = this.getNodeParameter('actions') as string[]; - const endpoint = `/webhooks/`; + const endpoint = `/organizations/${organisation}/webhooks/`; const body: IDataObject = { endpoint_url: webhookUrl, actions: actions.join(','), diff --git a/packages/nodes-base/nodes/Eventbrite/GenericFunctions.ts b/packages/nodes-base/nodes/Eventbrite/GenericFunctions.ts index 285c89b1f8..2392a0ae20 100644 --- a/packages/nodes-base/nodes/Eventbrite/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Eventbrite/GenericFunctions.ts @@ -1,4 +1,7 @@ -import { OptionsWithUri } from 'request'; +import { + OptionsWithUri, +} from 'request'; + import { IExecuteFunctions, IExecuteSingleFunctions, @@ -6,16 +9,14 @@ import { ILoadOptionsFunctions, IWebhookFunctions, } from 'n8n-core'; -import { IDataObject } from 'n8n-workflow'; + +import { + IDataObject, +} from 'n8n-workflow'; export async function eventbriteApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('eventbriteApi'); - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - let options: OptionsWithUri = { - headers: { 'Authorization': `Bearer ${credentials.apiKey}`}, + headers: {}, method, qs, body, @@ -27,14 +28,26 @@ export async function eventbriteApiRequest(this: IHookFunctions | IExecuteFuncti delete options.body; } + const authenticationMethod = this.getNodeParameter('authentication', 0); + try { - return await this.helpers.request!(options); + if (authenticationMethod === 'privateKey') { + const credentials = this.getCredentials('eventbriteApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.headers!['Authorization'] = `Bearer ${credentials.apiKey}`; + + return await this.helpers.request!(options); + } else { + return await this.helpers.requestOAuth2!.call(this, 'eventbriteOAuth2Api', options); + } } catch (error) { let errorMessage = error.message; if (error.response.body && error.response.body.error_description) { errorMessage = error.response.body.error_description; } - throw new Error('Eventbrite Error: ' + errorMessage); } } diff --git a/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts index 59c55a33f4..f1fc3b8881 100644 --- a/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts +++ b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts @@ -137,6 +137,13 @@ export class FacebookGraphApi implements INodeType { placeholder: 'videos', required: false, }, + { + displayName: 'Ignore SSL Issues', + name: 'allowUnauthorizedCerts', + type: 'boolean', + default: false, + description: 'Still download the response even if SSL certificate validation is not possible. (Not recommended)', + }, { displayName: 'Send Binary Data', name: 'sendBinaryData', @@ -301,6 +308,7 @@ export class FacebookGraphApi implements INodeType { qs: { access_token: graphApiCredentials!.accessToken, }, + rejectUnauthorized: !this.getNodeParameter('allowUnauthorizedCerts', itemIndex, false) as boolean, }; if (options !== undefined) { diff --git a/packages/nodes-base/nodes/Github/Github.node.ts b/packages/nodes-base/nodes/Github/Github.node.ts index 00133ec302..408c0a4360 100644 --- a/packages/nodes-base/nodes/Github/Github.node.ts +++ b/packages/nodes-base/nodes/Github/Github.node.ts @@ -17,14 +17,14 @@ import { export class Github implements INodeType { description: INodeTypeDescription = { - displayName: 'Github', + displayName: 'GitHub', name: 'github', icon: 'file:github.png', group: ['input'], version: 1, - description: 'Retrieve data from Github API.', + description: 'Retrieve data from GitHub API.', defaults: { - name: 'Github', + name: 'GitHub', color: '#665533', }, inputs: ['main'], @@ -178,7 +178,7 @@ export class Github implements INodeType { { name: 'Get', value: 'get', - description: 'Get the data of a single issues', + description: 'Get the data of a single issue', }, ], default: 'create', @@ -220,7 +220,7 @@ export class Github implements INodeType { { name: 'List Popular Paths', value: 'listPopularPaths', - description: 'Get the data of a file in repositoryGet the top 10 popular content paths over the last 14 days.', + description: 'Get the top 10 popular content paths over the last 14 days.', }, { name: 'List Referrers', @@ -244,11 +244,6 @@ export class Github implements INodeType { }, }, options: [ - { - name: 'Get Emails', - value: 'getEmails', - description: 'Returns the email addresses of a user', - }, { name: 'Get Repositories', value: 'getRepositories', @@ -463,7 +458,7 @@ export class Github implements INodeType { description: 'The name of the author of the commit.', }, { - displayName: 'EMail', + displayName: 'Email', name: 'email', type: 'string', default: '', @@ -496,7 +491,7 @@ export class Github implements INodeType { description: 'The name of the committer of the commit.', }, { - displayName: 'EMail', + displayName: 'Email', name: 'email', type: 'string', default: '', @@ -1019,28 +1014,28 @@ export class Github implements INodeType { name: 'assignee', type: 'string', default: '', - description: 'Return only issuse which are assigned to a specific user.', + description: 'Return only issues which are assigned to a specific user.', }, { displayName: 'Creator', name: 'creator', type: 'string', default: '', - description: 'Return only issuse which were created by a specific user.', + description: 'Return only issues which were created by a specific user.', }, { displayName: 'Mentioned', name: 'mentioned', type: 'string', default: '', - description: 'Return only issuse in which a specific user was mentioned.', + description: 'Return only issues in which a specific user was mentioned.', }, { displayName: 'Labels', name: 'labels', type: 'string', default: '', - description: 'Return only issuse with the given labels. Multiple lables can be separated by comma.', + description: 'Return only issues with the given labels. Multiple lables can be separated by comma.', }, { displayName: 'Updated Since', diff --git a/packages/nodes-base/nodes/Github/GithubTrigger.node.ts b/packages/nodes-base/nodes/Github/GithubTrigger.node.ts index 2a14b5d3db..1360d7536a 100644 --- a/packages/nodes-base/nodes/Github/GithubTrigger.node.ts +++ b/packages/nodes-base/nodes/Github/GithubTrigger.node.ts @@ -34,7 +34,25 @@ export class GithubTrigger implements INodeType { { name: 'githubApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'githubOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], webhooks: [ { @@ -45,6 +63,23 @@ export class GithubTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Repository Owner', name: 'owner', diff --git a/packages/nodes-base/nodes/Gitlab/GenericFunctions.ts b/packages/nodes-base/nodes/Gitlab/GenericFunctions.ts index 8f1811b8c7..3085f17678 100644 --- a/packages/nodes-base/nodes/Gitlab/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Gitlab/GenericFunctions.ts @@ -1,11 +1,13 @@ import { IExecuteFunctions, IHookFunctions, + ILoadOptionsFunctions, } from 'n8n-core'; import { IDataObject, } from 'n8n-workflow'; +import { OptionsWithUri } from 'request'; /** * Make an API request to Gitlab @@ -17,24 +19,43 @@ import { * @returns {Promise} */ export async function gitlabApiRequest(this: IHookFunctions | IExecuteFunctions, method: string, endpoint: string, body: object, query?: object): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('gitlabApi'); - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - - const options = { + const options : OptionsWithUri = { method, - headers: { - 'Private-Token': `${credentials.accessToken}`, - }, + headers: {}, body, qs: query, - uri: `${(credentials.server as string).replace(/\/$/, '')}/api/v4${endpoint}`, + uri: '', json: true }; + if (query === undefined) { + delete options.qs; + } + + const authenticationMethod = this.getNodeParameter('authentication', 0); + try { - return await this.helpers.request(options); + if (authenticationMethod === 'accessToken') { + const credentials = this.getCredentials('gitlabApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.headers!['Private-Token'] = `${credentials.accessToken}`; + + options.uri = `${(credentials.server as string).replace(/\/$/, '')}/api/v4${endpoint}`; + + return await this.helpers.request(options); + } else { + const credentials = this.getCredentials('gitlabOAuth2Api'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.uri = `${(credentials.server as string).replace(/\/$/, '')}/api/v4${endpoint}`; + + return await this.helpers.requestOAuth2!.call(this, 'gitlabOAuth2Api', options); + } } catch (error) { if (error.statusCode === 401) { // Return a clear error diff --git a/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts b/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts index 39d71ae3da..fa485e452a 100644 --- a/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts +++ b/packages/nodes-base/nodes/Gitlab/Gitlab.node.ts @@ -13,7 +13,6 @@ import { gitlabApiRequest, } from './GenericFunctions'; - export class Gitlab implements INodeType { description: INodeTypeDescription = { displayName: 'Gitlab', @@ -33,9 +32,44 @@ export class Gitlab implements INodeType { { name: 'gitlabApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'gitlabOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Resource', name: 'resource', @@ -793,10 +827,26 @@ export class Gitlab implements INodeType { const items = this.getInputData(); const returnData: IDataObject[] = []; - const credentials = this.getCredentials('gitlabApi'); + let credentials; - if (credentials === undefined) { - throw new Error('No credentials got returned!'); + const authenticationMethod = this.getNodeParameter('authentication', 0); + + try { + if (authenticationMethod === 'accessToken') { + credentials = this.getCredentials('gitlabApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + } else { + credentials = this.getCredentials('gitlabOAuth2Api'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + } + } catch (error) { + throw new Error(error); } // Operations which overwrite the returned data diff --git a/packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts b/packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts index 28987c2458..05c2095715 100644 --- a/packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts +++ b/packages/nodes-base/nodes/Gitlab/GitlabTrigger.node.ts @@ -14,7 +14,6 @@ import { gitlabApiRequest, } from './GenericFunctions'; - export class GitlabTrigger implements INodeType { description: INodeTypeDescription = { displayName: 'Gitlab Trigger', @@ -34,7 +33,25 @@ export class GitlabTrigger implements INodeType { { name: 'gitlabApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'gitlabOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], webhooks: [ { @@ -45,6 +62,23 @@ export class GitlabTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Repository Owner', name: 'owner', @@ -135,7 +169,10 @@ export class GitlabTrigger implements INodeType { // Webhook got created before so check if it still exists const owner = this.getNodeParameter('owner') as string; const repository = this.getNodeParameter('repository') as string; - const endpoint = `/projects/${owner}%2F${repository}/hooks/${webhookData.webhookId}`; + + const path = (`${owner}/${repository}`).replace(/\//g,'%2F'); + + const endpoint = `/projects/${path}/hooks/${webhookData.webhookId}`; try { await gitlabApiRequest.call(this, 'GET', endpoint, {}); @@ -175,15 +212,22 @@ export class GitlabTrigger implements INodeType { events[`${e}_events`] = true; } - const endpoint = `/projects/${owner}%2F${repository}/hooks`; + // gitlab set the push_events to true when the field it's not sent. + // set it to false when it's not picked by the user. + if (events['push_events'] === undefined) { + events['push_events'] = false; + } + + const path = (`${owner}/${repository}`).replace(/\//g,'%2F'); + + const endpoint = `/projects/${path}/hooks`; const body = { url: webhookUrl, - events, + ...events, enable_ssl_verification: false, }; - let responseData; try { responseData = await gitlabApiRequest.call(this, 'POST', endpoint, body); @@ -208,7 +252,10 @@ export class GitlabTrigger implements INodeType { if (webhookData.webhookId !== undefined) { const owner = this.getNodeParameter('owner') as string; const repository = this.getNodeParameter('repository') as string; - const endpoint = `/projects/${owner}%2F${repository}/hooks/${webhookData.webhookId}`; + + const path = (`${owner}/${repository}`).replace(/\//g,'%2F'); + + const endpoint = `/projects/${path}/hooks/${webhookData.webhookId}`; const body = {}; try { diff --git a/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts b/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts index e92582a847..545ed841f1 100644 --- a/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts +++ b/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts @@ -1,4 +1,6 @@ -import { INodeProperties } from "n8n-workflow"; +import { + INodeProperties, +} from 'n8n-workflow'; export const eventOperations = [ { @@ -37,37 +39,36 @@ export const eventOperations = [ name: 'Update', value: 'update', description: 'Update an event', - }, + } ], default: 'create', - description: 'The operation to perform.', - }, + description: 'The operation to perform.' + } ] as INodeProperties[]; export const eventFields = [ - -/* -------------------------------------------------------------------------- */ -/* event:create */ -/* -------------------------------------------------------------------------- */ + /* -------------------------------------------------------------------------- */ + /* event:create */ + /* -------------------------------------------------------------------------- */ { displayName: 'Calendar', name: 'calendar', type: 'options', typeOptions: { - loadOptionsMethod: 'getCalendars', + loadOptionsMethod: 'getCalendars' }, required: true, displayOptions: { show: { operation: [ - 'create', + 'create' ], resource: [ - 'event', + 'event' ], }, }, - default: '', + default: '' }, { displayName: 'Start', @@ -85,7 +86,7 @@ export const eventFields = [ }, }, default: '', - description: 'Start time of the event.', + description: 'Start time of the event.' }, { displayName: 'End', @@ -103,7 +104,7 @@ export const eventFields = [ }, }, default: '', - description: 'End time of the event.', + description: 'End time of the event.' }, { displayName: 'Use Default Reminders', @@ -119,7 +120,7 @@ export const eventFields = [ ], }, }, - default: true, + default: true }, { displayName: 'Additional Fields', @@ -153,7 +154,7 @@ export const eventFields = [ }, ], default: 'no', - description: 'Wheater the event is all day or not', + description: 'Wheater the event is all day or not' }, { displayName: 'Attendees', @@ -176,6 +177,15 @@ export const eventFields = [ default: '', description: 'The color of the event.', }, + { + displayName: 'Description', + name: 'description', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + }, { displayName: 'Guests Can Invite Others', name: 'guestsCanInviteOthers', @@ -239,7 +249,7 @@ export const eventFields = [ { name: 'Yearly', value: 'yearly', - }, + } ], default: '', }, @@ -254,9 +264,9 @@ export const eventFields = [ name: 'repeatHowManyTimes', type: 'number', typeOptions: { - minValue: 1, + minValue: 1 }, - default: 1, + default: 1 }, { displayName: 'Send Updates', @@ -266,7 +276,7 @@ export const eventFields = [ { name: 'All', value: 'all', - description: ' Notifications are sent to all guests', + description: 'Notifications are sent to all guests' }, { name: 'External Only', @@ -276,8 +286,8 @@ export const eventFields = [ { name: 'None', value: 'none', - description: ' No notifications are sent. This value should only be used for migration use case', - }, + description: 'No notifications are sent. This value should only be used for migration use case', + } ], description: 'Whether to send notifications about the creation of the new event', default: '', @@ -303,7 +313,7 @@ export const eventFields = [ name: 'Busy', value: 'opaque', description: ' The event does block time on the calendar.', - }, + } ], default: 'opaque', description: 'Whether the event blocks time on the calendar', @@ -316,7 +326,7 @@ export const eventFields = [ loadOptionsMethod: 'getTimezones', }, default: '', - description: 'The timezone the event will have set. By default events are schedule on timezone set in n8n.' + description: 'The timezone the event will have set. By default events are schedule on timezone set in n8n.', }, { displayName: 'Visibility', @@ -331,7 +341,7 @@ export const eventFields = [ { name: 'Default', value: 'default', - description: ' Uses the default visibility for events on the calendar.', + description: 'Uses the default visibility for events on the calendar.', }, { name: 'Private', @@ -345,7 +355,7 @@ export const eventFields = [ }, ], default: 'default', - description: 'Visibility of the event.', + description: 'Visibility of the event.' }, ], }, @@ -356,7 +366,7 @@ export const eventFields = [ default: '', placeholder: 'Add Reminder', typeOptions: { - multipleValues: true, + multipleValues: true }, required: false, displayOptions: { @@ -404,13 +414,13 @@ export const eventFields = [ default: 0, }, ], - } + }, ], - description: `If the event doesn't use the default reminders, this lists the reminders specific to the event`, + description: `If the event doesn't use the default reminders, this lists the reminders specific to the event` }, -/* -------------------------------------------------------------------------- */ -/* event:delete */ -/* -------------------------------------------------------------------------- */ + /* -------------------------------------------------------------------------- */ + /* event:delete */ + /* -------------------------------------------------------------------------- */ { displayName: 'Calendar', name: 'calendar', @@ -429,7 +439,7 @@ export const eventFields = [ ], }, }, - default: '', + default: '' }, { displayName: 'Event ID', @@ -473,7 +483,7 @@ export const eventFields = [ { name: 'All', value: 'all', - description: ' Notifications are sent to all guests', + description: 'Notifications are sent to all guests', }, { name: 'External Only', @@ -483,17 +493,17 @@ export const eventFields = [ { name: 'None', value: 'none', - description: ' No notifications are sent. This value should only be used for migration use case', - }, + description: 'No notifications are sent. This value should only be used for migration use case', + } ], description: 'Whether to send notifications about the creation of the new event', default: '', }, ], }, -/* -------------------------------------------------------------------------- */ -/* event:get */ -/* -------------------------------------------------------------------------- */ + /* -------------------------------------------------------------------------- */ + /* event:get */ + /* -------------------------------------------------------------------------- */ { displayName: 'Calendar', name: 'calendar', @@ -512,7 +522,7 @@ export const eventFields = [ ], }, }, - default: '', + default: '' }, { displayName: 'Event ID', @@ -565,12 +575,12 @@ export const eventFields = [ }, default: '', description: `Time zone used in the response. The default is the time zone of the calendar.`, - }, - ], + } + ] }, -/* -------------------------------------------------------------------------- */ -/* event:getAll */ -/* -------------------------------------------------------------------------- */ + /* -------------------------------------------------------------------------- */ + /* event:getAll */ + /* -------------------------------------------------------------------------- */ { displayName: 'Calendar', name: 'calendar', @@ -589,7 +599,7 @@ export const eventFields = [ ], }, }, - default: '', + default: '' }, { displayName: 'Return All', @@ -678,7 +688,7 @@ export const eventFields = [ name: 'Updated', value: 'updated', description: 'Order by last modification time (ascending).', - }, + } ], default: '', description: 'The order of the events returned in the result.', @@ -743,18 +753,18 @@ export const eventFields = [ default: '', description: `Lower bound for an event's last modification time (as a RFC3339 timestamp) to filter by. When specified, entries deleted since this time will always be included regardless of showDeleted`, - }, - ], + } + ] }, -/* -------------------------------------------------------------------------- */ -/* event:update */ -/* -------------------------------------------------------------------------- */ + /* -------------------------------------------------------------------------- */ + /* event:update */ + /* -------------------------------------------------------------------------- */ { displayName: 'Calendar', name: 'calendar', type: 'options', typeOptions: { - loadOptionsMethod: 'getCalendars', + loadOptionsMethod: 'getCalendars' }, required: true, displayOptions: { @@ -800,7 +810,7 @@ export const eventFields = [ ], }, }, - default: true, + default: true }, { displayName: 'Update Fields', @@ -831,7 +841,7 @@ export const eventFields = [ { name: 'No', value: 'no', - }, + } ], default: 'no', description: 'Wheater the event is all day or not', @@ -857,6 +867,15 @@ export const eventFields = [ default: '', description: 'The color of the event.', }, + { + displayName: 'Description', + name: 'description', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + }, { displayName: 'End', name: 'end', @@ -927,7 +946,7 @@ export const eventFields = [ { name: 'Yearly', value: 'yearly', - }, + } ], default: '', }, @@ -971,8 +990,8 @@ export const eventFields = [ { name: 'None', value: 'none', - description: ' No notifications are sent. This value should only be used for migration use case', - }, + description: 'No notifications are sent. This value should only be used for migration use case', + } ], description: 'Whether to send notifications about the creation of the new event', default: '', @@ -1011,7 +1030,7 @@ export const eventFields = [ loadOptionsMethod: 'getTimezones', }, default: '', - description: 'The timezone the event will have set. By default events are schedule on n8n timezone ' + description: 'The timezone the event will have set. By default events are schedule on n8n timezone', }, { displayName: 'Visibility', @@ -1026,7 +1045,7 @@ export const eventFields = [ { name: 'Default', value: 'default', - description: ' Uses the default visibility for events on the calendar.', + description: 'Uses the default visibility for events on the calendar.', }, { name: 'Public', @@ -1037,7 +1056,7 @@ export const eventFields = [ name: 'Private', value: 'private', description: 'The event is private and only event attendees may view event details.', - }, + } ], default: 'default', description: 'Visibility of the event.', @@ -1051,7 +1070,7 @@ export const eventFields = [ default: '', placeholder: 'Add Reminder', typeOptions: { - multipleValues: true, + multipleValues: true }, required: false, displayOptions: { @@ -1084,7 +1103,7 @@ export const eventFields = [ { name: 'Popup', value: 'popup', - }, + } ], default: '', }, @@ -1099,8 +1118,8 @@ export const eventFields = [ default: 0, }, ], - } + }, ], description: `If the event doesn't use the default reminders, this lists the reminders specific to the event`, - }, + } ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Google/Calendar/EventInterface.ts b/packages/nodes-base/nodes/Google/Calendar/EventInterface.ts index 72bf96cc80..14cda0fe41 100644 --- a/packages/nodes-base/nodes/Google/Calendar/EventInterface.ts +++ b/packages/nodes-base/nodes/Google/Calendar/EventInterface.ts @@ -1,4 +1,6 @@ -import { IDataObject } from "n8n-workflow"; +import { + IDataObject, + } from 'n8n-workflow'; export interface IReminder { useDefault?: boolean; diff --git a/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts index 18d808cef7..caf4c9868d 100644 --- a/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts @@ -33,9 +33,15 @@ export async function googleApiRequest(this: IExecuteFunctions | IExecuteSingleF //@ts-ignore return await this.helpers.requestOAuth2.call(this, 'googleCalendarOAuth2Api', options); } catch (error) { - if (error.response && error.response.body && error.response.body.message) { + if (error.response && error.response.body && error.response.body.error) { + + let errors = error.response.body.error.errors; + + errors = errors.map((e: IDataObject) => e.message); // Try to return the error prettier - throw new Error(`Google Calendar error response [${error.statusCode}]: ${error.response.body.message}`); + throw new Error( + `Google Calendar error response [${error.statusCode}]: ${errors.join('|')}` + ); } throw error; } diff --git a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts index 1f185935d7..4c8208590e 100644 --- a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts +++ b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts @@ -46,7 +46,7 @@ export class GoogleCalendar implements INodeType { { name: 'googleCalendarOAuth2Api', required: true, - }, + } ], properties: [ { @@ -60,7 +60,7 @@ export class GoogleCalendar implements INodeType { }, ], default: 'event', - description: 'The resource to operate on.', + description: 'The resource to operate on.' }, ...eventOperations, ...eventFields, @@ -71,55 +71,70 @@ export class GoogleCalendar implements INodeType { loadOptions: { // Get all the calendars to display them to user so that he can // select them easily - async getCalendars(this: ILoadOptionsFunctions): Promise { + async getCalendars( + this: ILoadOptionsFunctions + ): Promise { const returnData: INodePropertyOptions[] = []; - const calendars = await googleApiRequestAllItems.call(this, 'items', 'GET', '/calendar/v3/users/me/calendarList'); + const calendars = await googleApiRequestAllItems.call( + this, + 'items', + 'GET', + '/calendar/v3/users/me/calendarList' + ); for (const calendar of calendars) { const calendarName = calendar.summary; const calendarId = calendar.id; returnData.push({ name: calendarName, - value: calendarId, + value: calendarId }); } return returnData; }, // Get all the colors to display them to user so that he can // select them easily - async getColors(this: ILoadOptionsFunctions): Promise { + async getColors( + this: ILoadOptionsFunctions + ): Promise { const returnData: INodePropertyOptions[] = []; - const { calendar } = await googleApiRequest.call(this, 'GET', '/calendar/v3/colors'); - for (const key of Object.keys(calendar)) { - const colorName = calendar[key].background; + const { event } = await googleApiRequest.call( + this, + 'GET', + '/calendar/v3/colors' + ); + for (const key of Object.keys(event)) { + const colorName = `Background: ${event[key].background} - Foreground: ${event[key].foreground}`; const colorId = key; returnData.push({ - name: `${colorName} - ${colorId}`, - value: colorId, + name: `${colorName}`, + value: colorId }); } return returnData; }, // Get all the timezones to display them to user so that he can // select them easily - async getTimezones(this: ILoadOptionsFunctions): Promise { + async getTimezones( + this: ILoadOptionsFunctions + ): Promise { const returnData: INodePropertyOptions[] = []; for (const timezone of moment.tz.names()) { const timezoneName = timezone; const timezoneId = timezone; returnData.push({ name: timezoneName, - value: timezoneId, + value: timezoneId }); } return returnData; - }, - }, + } + } }; async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: IDataObject[] = []; - const length = items.length as unknown as number; + const length = (items.length as unknown) as number; const qs: IDataObject = {}; let responseData; const resource = this.getNodeParameter('resource', 0) as string; @@ -131,8 +146,14 @@ export class GoogleCalendar implements INodeType { const calendarId = this.getNodeParameter('calendar', i) as string; const start = this.getNodeParameter('start', i) as string; const end = this.getNodeParameter('end', i) as string; - const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean; - const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const useDefaultReminders = this.getNodeParameter( + 'useDefaultReminders', + i + ) as boolean; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; if (additionalFields.maxAttendees) { qs.maxAttendees = additionalFields.maxAttendees as number; } @@ -145,17 +166,19 @@ export class GoogleCalendar implements INodeType { const body: IEvent = { start: { dateTime: start, - timeZone: additionalFields.timeZone || this.getTimezone(), + timeZone: additionalFields.timeZone || this.getTimezone() }, end: { dateTime: end, - timeZone: additionalFields.timeZone || this.getTimezone(), + timeZone: additionalFields.timeZone || this.getTimezone() } }; if (additionalFields.attendees) { - body.attendees = (additionalFields.attendees as string[]).map(attendee => { - return { email: attendee }; - }); + body.attendees = (additionalFields.attendees as string[]).map( + attendee => { + return { email: attendee }; + } + ); } if (additionalFields.color) { body.colorId = additionalFields.color as string; @@ -188,9 +211,12 @@ export class GoogleCalendar implements INodeType { body.visibility = additionalFields.visibility as string; } if (!useDefaultReminders) { - const reminders = (this.getNodeParameter('remindersUi', i) as IDataObject).remindersValues as IDataObject[]; + const reminders = (this.getNodeParameter( + 'remindersUi', + i + ) as IDataObject).remindersValues as IDataObject[]; body.reminders = { - useDefault: false, + useDefault: false }; if (reminders) { body.reminders.overrides = reminders; @@ -198,32 +224,54 @@ export class GoogleCalendar implements INodeType { } if (additionalFields.allday) { body.start = { - date: moment(start).utc().format('YYYY-MM-DD'), + date: moment(start) + .utc() + .format('YYYY-MM-DD') }; body.end = { - date: moment(end).utc().format('YYYY-MM-DD'), + date: moment(end) + .utc() + .format('YYYY-MM-DD') }; } //exampel: RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=10;UNTIL=20110701T170000Z //https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html body.recurrence = []; - if (additionalFields.repeatHowManyTimes - && additionalFields.repeatUntil) { - throw new Error(`You can set either 'Repeat How Many Times' or 'Repeat Until' but not both`); + if ( + additionalFields.repeatHowManyTimes && + additionalFields.repeatUntil + ) { + throw new Error( + `You can set either 'Repeat How Many Times' or 'Repeat Until' but not both` + ); } if (additionalFields.repeatFrecuency) { - body.recurrence?.push(`FREQ=${(additionalFields.repeatFrecuency as string).toUpperCase()};`); + body.recurrence?.push( + `FREQ=${(additionalFields.repeatFrecuency as string).toUpperCase()};` + ); } if (additionalFields.repeatHowManyTimes) { - body.recurrence?.push(`COUNT=${additionalFields.repeatHowManyTimes};`); + body.recurrence?.push( + `COUNT=${additionalFields.repeatHowManyTimes};` + ); } if (additionalFields.repeatUntil) { - body.recurrence?.push(`UNTIL=${moment(additionalFields.repeatUntil as string).utc().format('YYYYMMDDTHHmmss')}Z`); + body.recurrence?.push( + `UNTIL=${moment(additionalFields.repeatUntil as string) + .utc() + .format('YYYYMMDDTHHmmss')}Z` + ); } if (body.recurrence.length !== 0) { body.recurrence = [`RRULE:${body.recurrence.join('')}`]; } - responseData = await googleApiRequest.call(this, 'POST', `/calendar/v3/calendars/${calendarId}/events`, body, qs); + responseData = await googleApiRequest.call( + this, + 'POST', + `/calendar/v3/calendars/${calendarId}/events`, + body, + qs + ); } //https://developers.google.com/calendar/v3/reference/events/delete if (operation === 'delete') { @@ -233,8 +281,13 @@ export class GoogleCalendar implements INodeType { if (options.sendUpdates) { qs.sendUpdates = options.sendUpdates as number; } - responseData = await googleApiRequest.call(this, 'DELETE', `/calendar/v3/calendars/${calendarId}/events/${eventId}`, {}); - responseData = { success: true }; + responseData = await googleApiRequest.call( + this, + 'DELETE', + `/calendar/v3/calendars/${calendarId}/events/${eventId}`, + {} + ); + responseData = { success: true }; } //https://developers.google.com/calendar/v3/reference/events/get if (operation === 'get') { @@ -247,7 +300,13 @@ export class GoogleCalendar implements INodeType { if (options.timeZone) { qs.timeZone = options.timeZone as string; } - responseData = await googleApiRequest.call(this, 'GET', `/calendar/v3/calendars/${calendarId}/events/${eventId}`, {}, qs); + responseData = await googleApiRequest.call( + this, + 'GET', + `/calendar/v3/calendars/${calendarId}/events/${eventId}`, + {}, + qs + ); } //https://developers.google.com/calendar/v3/reference/events/list if (operation === 'getAll') { @@ -288,10 +347,23 @@ export class GoogleCalendar implements INodeType { qs.updatedMin = options.updatedMin as string; } if (returnAll) { - responseData = await googleApiRequestAllItems.call(this, 'items', 'GET', `/calendar/v3/calendars/${calendarId}/events`, {}, qs); + responseData = await googleApiRequestAllItems.call( + this, + 'items', + 'GET', + `/calendar/v3/calendars/${calendarId}/events`, + {}, + qs + ); } else { qs.maxResults = this.getNodeParameter('limit', i) as number; - responseData = await googleApiRequest.call(this, 'GET', `/calendar/v3/calendars/${calendarId}/events`, {}, qs); + responseData = await googleApiRequest.call( + this, + 'GET', + `/calendar/v3/calendars/${calendarId}/events`, + {}, + qs + ); responseData = responseData.items; } } @@ -299,8 +371,14 @@ export class GoogleCalendar implements INodeType { if (operation === 'update') { const calendarId = this.getNodeParameter('calendar', i) as string; const eventId = this.getNodeParameter('eventId', i) as string; - const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean; - const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + const useDefaultReminders = this.getNodeParameter( + 'useDefaultReminders', + i + ) as boolean; + const updateFields = this.getNodeParameter( + 'updateFields', + i + ) as IDataObject; if (updateFields.maxAttendees) { qs.maxAttendees = updateFields.maxAttendees as number; } @@ -314,19 +392,21 @@ export class GoogleCalendar implements INodeType { if (updateFields.start) { body.start = { dateTime: updateFields.start, - timeZone: updateFields.timeZone || this.getTimezone(), + timeZone: updateFields.timeZone || this.getTimezone() }; } if (updateFields.end) { body.end = { dateTime: updateFields.end, - timeZone: updateFields.timeZone || this.getTimezone(), + timeZone: updateFields.timeZone || this.getTimezone() }; } if (updateFields.attendees) { - body.attendees = (updateFields.attendees as string[]).map(attendee => { - return { email: attendee }; - }); + body.attendees = (updateFields.attendees as string[]).map( + attendee => { + return { email: attendee }; + } + ); } if (updateFields.color) { body.colorId = updateFields.color as string; @@ -359,46 +439,64 @@ export class GoogleCalendar implements INodeType { body.visibility = updateFields.visibility as string; } if (!useDefaultReminders) { - const reminders = (this.getNodeParameter('remindersUi', i) as IDataObject).remindersValues as IDataObject[]; + const reminders = (this.getNodeParameter( + 'remindersUi', + i + ) as IDataObject).remindersValues as IDataObject[]; body.reminders = { - useDefault: false, + useDefault: false }; if (reminders) { body.reminders.overrides = reminders; } } - if (updateFields.allday - && updateFields.start - && updateFields.end) { + if (updateFields.allday && updateFields.start && updateFields.end) { body.start = { - date: moment(updateFields.start as string).utc().format('YYYY-MM-DD'), + date: moment(updateFields.start as string) + .utc() + .format('YYYY-MM-DD') }; body.end = { - date: moment(updateFields.end as string).utc().format('YYYY-MM-DD'), + date: moment(updateFields.end as string) + .utc() + .format('YYYY-MM-DD') }; } //exampel: RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=10;UNTIL=20110701T170000Z //https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html body.recurrence = []; - if (updateFields.repeatHowManyTimes - && updateFields.repeatUntil) { - throw new Error(`You can set either 'Repeat How Many Times' or 'Repeat Until' but not both`); + if (updateFields.repeatHowManyTimes && updateFields.repeatUntil) { + throw new Error( + `You can set either 'Repeat How Many Times' or 'Repeat Until' but not both` + ); } if (updateFields.repeatFrecuency) { - body.recurrence?.push(`FREQ=${(updateFields.repeatFrecuency as string).toUpperCase()};`); + body.recurrence?.push( + `FREQ=${(updateFields.repeatFrecuency as string).toUpperCase()};` + ); } if (updateFields.repeatHowManyTimes) { body.recurrence?.push(`COUNT=${updateFields.repeatHowManyTimes};`); } if (updateFields.repeatUntil) { - body.recurrence?.push(`UNTIL=${moment(updateFields.repeatUntil as string).utc().format('YYYYMMDDTHHmmss')}Z`); + body.recurrence?.push( + `UNTIL=${moment(updateFields.repeatUntil as string) + .utc() + .format('YYYYMMDDTHHmmss')}Z` + ); } if (body.recurrence.length !== 0) { body.recurrence = [`RRULE:${body.recurrence.join('')}`]; } else { delete body.recurrence; } - responseData = await googleApiRequest.call(this, 'PATCH', `/calendar/v3/calendars/${calendarId}/events/${eventId}`, body, qs); + responseData = await googleApiRequest.call( + this, + 'PATCH', + `/calendar/v3/calendars/${calendarId}/events/${eventId}`, + body, + qs + ); } } } diff --git a/packages/nodes-base/nodes/Google/Drive/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Drive/GenericFunctions.ts new file mode 100644 index 0000000000..7d15e492ad --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/GenericFunctions.ts @@ -0,0 +1,142 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +import * as moment from 'moment-timezone'; + +import * as jwt from 'jsonwebtoken'; + +export async function googleApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const authenticationMethod = this.getNodeParameter('authentication', 0, 'serviceAccount') as string; + + let options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + }, + method, + body, + qs, + uri: uri || `https://www.googleapis.com${resource}`, + json: true, + }; + options = Object.assign({}, options, option); + try { + if (Object.keys(body).length === 0) { + delete options.body; + } + + if (authenticationMethod === 'serviceAccount') { + const credentials = this.getCredentials('googleApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const { access_token } = await getAccessToken.call(this, credentials as IDataObject); + + options.headers!.Authorization = `Bearer ${access_token}`; + //@ts-ignore + return await this.helpers.request(options); + } else { + //@ts-ignore + return await this.helpers.requestOAuth2.call(this, 'googleDriveOAuth2Api', options); + } + } catch (error) { + if (error.response && error.response.body && error.response.body.error) { + + let errorMessages; + + if (error.response.body.error.errors) { + // Try to return the error prettier + errorMessages = error.response.body.error.errors; + + errorMessages = errorMessages.map((errorItem: IDataObject) => errorItem.message); + + errorMessages = errorMessages.join('|'); + + } else if (error.response.body.error.message) { + errorMessages = error.response.body.error.message; + } + + throw new Error(`Google Drive error response [${error.statusCode}]: ${errorMessages}`); + } + throw error; + } +} + +export async function googleApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + query.maxResults = 100; + + do { + responseData = await googleApiRequest.call(this, method, endpoint, body, query); + query.pageToken = responseData['nextPageToken']; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + responseData['nextPageToken'] !== undefined && + responseData['nextPageToken'] !== '' + ); + + return returnData; +} + +function getAccessToken(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, credentials: IDataObject): Promise { + //https://developers.google.com/identity/protocols/oauth2/service-account#httprest + + const scopes = [ + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/drive.appdata', + 'https://www.googleapis.com/auth/drive.photos.readonly', + ]; + + const now = moment().unix(); + + const signature = jwt.sign( + { + 'iss': credentials.email as string, + 'sub': credentials.email as string, + 'scope': scopes.join(' '), + 'aud': `https://oauth2.googleapis.com/token`, + 'iat': now, + 'exp': now + 3600, + }, + credentials.privateKey as string, + { + algorithm: 'RS256', + header: { + 'kid': credentials.privateKey as string, + 'typ': 'JWT', + 'alg': 'RS256', + }, + } + ); + + const options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + form: { + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: signature, + }, + uri: 'https://oauth2.googleapis.com/token', + json: true + }; + + //@ts-ignore + return this.helpers.request(options); +} diff --git a/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts b/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts index 387a3d7bc0..f6291b1045 100644 --- a/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts +++ b/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts @@ -1,10 +1,8 @@ -import { google } from 'googleapis'; -const { Readable } = require('stream'); - import { BINARY_ENCODING, IExecuteFunctions, } from 'n8n-core'; + import { IDataObject, INodeTypeDescription, @@ -12,8 +10,9 @@ import { INodeType, } from 'n8n-workflow'; -import { getAuthenticationClient } from '../GoogleApi'; - +import { + googleApiRequest, +} from './GenericFunctions'; export class GoogleDrive implements INodeType { description: INodeTypeDescription = { @@ -34,9 +33,43 @@ export class GoogleDrive implements INodeType { { name: 'googleApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'serviceAccount', + ], + }, + }, + }, + { + name: 'googleDriveOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Service Account', + value: 'serviceAccount', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'serviceAccount', + }, { displayName: 'Resource', name: 'resource', @@ -764,7 +797,7 @@ export class GoogleDrive implements INodeType { { name: 'domain', value: 'domain', - description:"All files shared to the user's domain that are searchable", + description: 'All files shared to the user\'s domain that are searchable', }, { name: 'drive', @@ -813,26 +846,6 @@ export class GoogleDrive implements INodeType { const items = this.getInputData(); const returnData: IDataObject[] = []; - const credentials = this.getCredentials('googleApi'); - - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - - const scopes = [ - 'https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/drive.appdata', - 'https://www.googleapis.com/auth/drive.photos.readonly', - ]; - - const client = await getAuthenticationClient(credentials.email as string, credentials.privateKey as string, scopes); - - const drive = google.drive({ - version: 'v3', - // @ts-ignore - auth: client, - }); - const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; @@ -857,22 +870,20 @@ export class GoogleDrive implements INodeType { const fileId = this.getNodeParameter('fileId', i) as string; - const copyOptions = { - fileId, + const body: IDataObject = { fields: queryFields, - requestBody: {} as IDataObject, }; const optionProperties = ['name', 'parents']; for (const propertyName of optionProperties) { if (options[propertyName] !== undefined) { - copyOptions.requestBody[propertyName] = options[propertyName]; + body[propertyName] = options[propertyName]; } } - const response = await drive.files.copy(copyOptions); + const response = await googleApiRequest.call(this, 'POST', `/drive/v3/files/${fileId}/copy`, body); - returnData.push(response.data as IDataObject); + returnData.push(response as IDataObject); } else if (operation === 'download') { // ---------------------------------- @@ -881,15 +892,13 @@ export class GoogleDrive implements INodeType { const fileId = this.getNodeParameter('fileId', i) as string; - const response = await drive.files.get( - { - fileId, - alt: 'media', - }, - { - responseType: 'arraybuffer', - }, - ); + const requestOptions = { + resolveWithFullResponse: true, + encoding: null, + json: false, + }; + + const response = await googleApiRequest.call(this, 'GET', `/drive/v3/files/${fileId}`, {}, { alt: 'media' }, undefined, requestOptions); let mimeType: string | undefined; if (response.headers['content-type']) { @@ -912,7 +921,7 @@ export class GoogleDrive implements INodeType { const dataPropertyNameDownload = this.getNodeParameter('binaryPropertyName', i) as string; - const data = Buffer.from(response.data as string); + const data = Buffer.from(response.body as string); items[i].binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData(data as unknown as Buffer, undefined, mimeType); @@ -936,7 +945,7 @@ export class GoogleDrive implements INodeType { queryCorpora = options.corpora as string; } - let driveId : string | undefined; + let driveId: string | undefined; driveId = options.driveId as string; if (driveId === '') { driveId = undefined; @@ -988,20 +997,19 @@ export class GoogleDrive implements INodeType { const pageSize = this.getNodeParameter('limit', i) as number; - const res = await drive.files.list({ + const qs = { pageSize, orderBy: 'modifiedTime', fields: `nextPageToken, files(${queryFields})`, spaces: querySpaces, - corpora: queryCorpora, - driveId, q: queryString, - includeItemsFromAllDrives: (queryCorpora !== '' || driveId !== ''), // Actually depracated, - supportsAllDrives: (queryCorpora !== '' || driveId !== ''), // see https://developers.google.com/drive/api/v3/reference/files/list - // However until June 2020 still needs to be set, to avoid API errors. - }); + includeItemsFromAllDrives: (queryCorpora !== '' || driveId !== ''), + supportsAllDrives: (queryCorpora !== '' || driveId !== ''), + }; - const files = res!.data.files; + const response = await googleApiRequest.call(this, 'GET', `/drive/v3/files`, {}, qs); + + const files = response!.files; return [this.helpers.returnJsonArray(files as IDataObject[])]; @@ -1044,29 +1052,35 @@ export class GoogleDrive implements INodeType { const name = this.getNodeParameter('name', i) as string; const parents = this.getNodeParameter('parents', i) as string[]; - const response = await drive.files.create({ - requestBody: { - name, - originalFilename, - parents, - }, + let qs: IDataObject = { fields: queryFields, - media: { - mimeType, - body: ((buffer: Buffer) => { - const readableInstanceStream = new Readable({ - read() { - this.push(buffer); - this.push(null); - } - }); + uploadType: 'media', + }; - return readableInstanceStream; - })(body), + const requestOptions = { + headers: { + 'Content-Type': mimeType, + 'Content-Length': body.byteLength, }, - }); + encoding: null, + json: false, + }; - returnData.push(response.data as IDataObject); + let response = await googleApiRequest.call(this, 'POST', `/upload/drive/v3/files`, body, qs, undefined, requestOptions); + + body = { + mimeType, + name, + originalFilename, + }; + + qs = { + addParents: parents.join(','), + }; + + response = await googleApiRequest.call(this, 'PATCH', `/drive/v3/files/${JSON.parse(response).id}`, body, qs); + + returnData.push(response as IDataObject); } } else if (resource === 'folder') { @@ -1077,19 +1091,19 @@ export class GoogleDrive implements INodeType { const name = this.getNodeParameter('name', i) as string; - const fileMetadata = { + const body = { name, mimeType: 'application/vnd.google-apps.folder', parents: options.parents || [], }; - const response = await drive.files.create({ - // @ts-ignore - resource: fileMetadata, + const qs = { fields: queryFields, - }); + }; - returnData.push(response.data as IDataObject); + const response = await googleApiRequest.call(this, 'POST', '/drive/v3/files', body, qs); + + returnData.push(response as IDataObject); } } if (['file', 'folder'].includes(resource)) { @@ -1100,9 +1114,7 @@ export class GoogleDrive implements INodeType { const fileId = this.getNodeParameter('fileId', i) as string; - await drive.files.delete({ - fileId, - }); + const response = await googleApiRequest.call(this, 'DELETE', `/drive/v3/files/${fileId}`); // If we are still here it did succeed returnData.push({ diff --git a/packages/nodes-base/nodes/Google/GoogleApi.ts b/packages/nodes-base/nodes/Google/GoogleApi.ts deleted file mode 100644 index 64ba9f9bec..0000000000 --- a/packages/nodes-base/nodes/Google/GoogleApi.ts +++ /dev/null @@ -1,23 +0,0 @@ - -import { JWT } from 'google-auth-library'; -import { google } from 'googleapis'; - - -/** - * Returns the authentication client needed to access spreadsheet - */ -export async function getAuthenticationClient(email: string, privateKey: string, scopes: string[]): Promise { - const client = new google.auth.JWT( - email, - undefined, - privateKey, - scopes, - undefined - ); - - // TODO: Check later if this or the above should be cached - await client.authorize(); - - // @ts-ignore - return client; -} diff --git a/packages/nodes-base/nodes/Google/Sheet/GoogleSheets.node.ts b/packages/nodes-base/nodes/Google/Sheet/GoogleSheets.node.ts index 90e4e8b1fd..042eb39105 100644 --- a/packages/nodes-base/nodes/Google/Sheet/GoogleSheets.node.ts +++ b/packages/nodes-base/nodes/Google/Sheet/GoogleSheets.node.ts @@ -622,7 +622,7 @@ export class GoogleSheets implements INodeType { // ---------------------------------- // append // ---------------------------------- - const keyRow = this.getNodeParameter('keyRow', 0) as number; + const keyRow = parseInt(this.getNodeParameter('keyRow', 0) as string, 10); const items = this.getInputData(); @@ -670,7 +670,7 @@ export class GoogleSheets implements INodeType { sheetId: range.sheetId, dimension: deletePropertyToDimensions[propertyName] as string, startIndex: range.startIndex, - endIndex: range.startIndex + range.amount, + endIndex: parseInt(range.startIndex.toString(), 10) + parseInt(range.amount.toString(), 10), } } }); @@ -693,8 +693,8 @@ export class GoogleSheets implements INodeType { return []; } - const dataStartRow = this.getNodeParameter('dataStartRow', 0) as number; - const keyRow = this.getNodeParameter('keyRow', 0) as number; + const dataStartRow = parseInt(this.getNodeParameter('dataStartRow', 0) as string, 10); + const keyRow = parseInt(this.getNodeParameter('keyRow', 0) as string, 10); const items = this.getInputData(); @@ -735,8 +735,8 @@ export class GoogleSheets implements INodeType { } ]; } else { - const dataStartRow = this.getNodeParameter('dataStartRow', 0) as number; - const keyRow = this.getNodeParameter('keyRow', 0) as number; + const dataStartRow = parseInt(this.getNodeParameter('dataStartRow', 0) as string, 10); + const keyRow = parseInt(this.getNodeParameter('keyRow', 0) as string, 10); returnData = sheet.structureArrayDataByColumn(sheetData, keyRow, dataStartRow); } @@ -769,8 +769,8 @@ export class GoogleSheets implements INodeType { const data = await sheet.batchUpdate(updateData, valueInputMode); } else { const keyName = this.getNodeParameter('key', 0) as string; - const keyRow = this.getNodeParameter('keyRow', 0) as number; - const dataStartRow = this.getNodeParameter('dataStartRow', 0) as number; + const keyRow = parseInt(this.getNodeParameter('keyRow', 0) as string, 10); + const dataStartRow = parseInt(this.getNodeParameter('dataStartRow', 0) as string, 10); const setData: IDataObject[] = []; items.forEach((item) => { diff --git a/packages/nodes-base/nodes/Google/Task/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Task/GenericFunctions.ts new file mode 100644 index 0000000000..55a690ad73 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Task/GenericFunctions.ts @@ -0,0 +1,92 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function googleApiRequest( + this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + method: string, + resource: string, + body: IDataObject = {}, + 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://www.googleapis.com${resource}`, + json: true + }; + + try { + if (Object.keys(headers).length !== 0) { + options.headers = Object.assign({}, options.headers, headers); + } + if (Object.keys(body).length === 0) { + delete options.body; + } + //@ts-ignore + return await this.helpers.requestOAuth2.call( + this, + 'googleTasksOAuth2Api', + options + ); + } catch (error) { + if (error.response && error.response.body && error.response.body.error) { + + let errors = error.response.body.error.errors; + + errors = errors.map((e: IDataObject) => e.message); + // Try to return the error prettier + throw new Error( + `Google Tasks error response [${error.statusCode}]: ${errors.join('|')}` + ); + } + throw error; + } +} + +export async function googleApiRequestAllItems( + this: IExecuteFunctions | ILoadOptionsFunctions, + propertyName: string, + method: string, + endpoint: string, + body: IDataObject = {}, + query: IDataObject = {} +): Promise { // tslint:disable-line:no-any + const returnData: IDataObject[] = []; + + let responseData; + query.maxResults = 100; + + do { + responseData = await googleApiRequest.call( + this, + method, + endpoint, + body, + query + ); + query.pageToken = responseData['nextPageToken']; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + responseData['nextPageToken'] !== undefined && + responseData['nextPageToken'] !== '' + ); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Google/Task/GoogleTasks.node.ts b/packages/nodes-base/nodes/Google/Task/GoogleTasks.node.ts new file mode 100644 index 0000000000..603bc1af5e --- /dev/null +++ b/packages/nodes-base/nodes/Google/Task/GoogleTasks.node.ts @@ -0,0 +1,279 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + googleApiRequest, + googleApiRequestAllItems, +} from './GenericFunctions'; + +import { + taskOperations, + taskFields, +} from './TaskDescription'; + +export class GoogleTasks implements INodeType { + description: INodeTypeDescription = { + displayName: 'Google Tasks', + name: 'googleTasks', + icon: 'file:googleTasks.png', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Google Tasks API.', + defaults: { + name: 'Google Tasks', + color: '#3E87E4' + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'googleTasksOAuth2Api', + required: true + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Task', + value: 'task' + } + ], + default: 'task', + description: 'The resource to operate on.' + }, + ...taskOperations, + ...taskFields + ] + }; + methods = { + loadOptions: { + // Get all the tasklists to display them to user so that he can select them easily + + async getTasks( + this: ILoadOptionsFunctions + ): Promise { + const returnData: INodePropertyOptions[] = []; + const tasks = await googleApiRequestAllItems.call( + this, + 'items', + 'GET', + '/tasks/v1/users/@me/lists' + ); + for (const task of tasks) { + const taskName = task.title; + const taskId = task.id; + returnData.push({ + name: taskName, + value: taskId + }); + } + 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; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + let body: IDataObject = {}; + for (let i = 0; i < length; i++) { + if (resource === 'task') { + if (operation === 'create') { + body = {}; + //https://developers.google.com/tasks/v1/reference/tasks/insert + const taskId = this.getNodeParameter('task', i) as string; + body.title = this.getNodeParameter('title', i) as string; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + + if (additionalFields.parent) { + qs.parent = additionalFields.parent as string; + } + if (additionalFields.previous) { + qs.previous = additionalFields.previous as string; + } + + if (additionalFields.status) { + body.status = additionalFields.status as string; + } + + if (additionalFields.notes) { + body.notes = additionalFields.notes as string; + } + if (additionalFields.dueDate) { + body.dueDate = additionalFields.dueDate as string; + } + + if (additionalFields.completed) { + body.completed = additionalFields.completed as string; + } + + if (additionalFields.deleted) { + body.deleted = additionalFields.deleted as boolean; + } + + responseData = await googleApiRequest.call( + this, + 'POST', + `/tasks/v1/lists/${taskId}/tasks`, + body, + qs + ); + } + if (operation === 'delete') { + //https://developers.google.com/tasks/v1/reference/tasks/delete + const taskListId = this.getNodeParameter('task', i) as string; + const taskId = this.getNodeParameter('taskId', i) as string; + + responseData = await googleApiRequest.call( + this, + 'DELETE', + `/tasks/v1/lists/${taskListId}/tasks/${taskId}`, + {} + ); + responseData = { success: true }; + } + if (operation === 'get') { + //https://developers.google.com/tasks/v1/reference/tasks/get + const taskListId = this.getNodeParameter('task', i) as string; + const taskId = this.getNodeParameter('taskId', i) as string; + responseData = await googleApiRequest.call( + this, + 'GET', + `/tasks/v1/lists/${taskListId}/tasks/${taskId}`, + {}, + qs + ); + } + if (operation === 'getAll') { + //https://developers.google.com/tasks/v1/reference/tasks/list + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const taskListId = this.getNodeParameter('task', i) as string; + const options = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + if (options.completedMax) { + qs.completedMax = options.completedMax as string; + } + if (options.completedMin) { + qs.completedMin = options.completedMin as string; + } + if (options.dueMin) { + qs.dueMin = options.dueMin as string; + } + if (options.dueMax) { + qs.dueMax = options.dueMax as string; + } + if (options.showCompleted) { + qs.showCompleted = options.showCompleted as boolean; + } + if (options.showDeleted) { + qs.showDeleted = options.showDeleted as boolean; + } + if (options.showHidden) { + qs.showHidden = options.showHidden as boolean; + } + if (options.updatedMin) { + qs.updatedMin = options.updatedMin as string; + } + + if (returnAll) { + responseData = await googleApiRequestAllItems.call( + this, + 'items', + 'GET', + `/tasks/v1/lists/${taskListId}/tasks`, + {}, + qs + ); + } else { + qs.maxResults = this.getNodeParameter('limit', i) as number; + responseData = await googleApiRequest.call( + this, + 'GET', + `/tasks/v1/lists/${taskListId}/tasks`, + {}, + qs + ); + responseData = responseData.items; + } + } + if (operation === 'update') { + body = {}; + //https://developers.google.com/tasks/v1/reference/tasks/patch + const taskListId = this.getNodeParameter('task', i) as string; + const taskId = this.getNodeParameter('taskId', i) as string; + const updateFields = this.getNodeParameter( + 'updateFields', + i + ) as IDataObject; + + if (updateFields.previous) { + qs.previous = updateFields.previous as string; + } + + if (updateFields.status) { + body.status = updateFields.status as string; + } + + if (updateFields.notes) { + body.notes = updateFields.notes as string; + } + + if (updateFields.title) { + body.title = updateFields.title as string; + } + + if (updateFields.dueDate) { + body.dueDate = updateFields.dueDate as string; + } + + if (updateFields.completed) { + body.completed = updateFields.completed as string; + } + + if (updateFields.deleted) { + body.deleted = updateFields.deleted as boolean; + } + + responseData = await googleApiRequest.call( + this, + 'PATCH', + `/tasks/v1/lists/${taskListId}/tasks/${taskId}`, + body, + qs + ); + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else if (responseData !== undefined) { + returnData.push(responseData as IDataObject); + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Google/Task/TaskDescription.ts b/packages/nodes-base/nodes/Google/Task/TaskDescription.ts new file mode 100644 index 0000000000..3300572f2c --- /dev/null +++ b/packages/nodes-base/nodes/Google/Task/TaskDescription.ts @@ -0,0 +1,493 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const taskOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'task', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Add a task to tasklist', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a task', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a task', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all tasks from a tasklist', + }, + { + name: 'Update', + value: 'update', + description: 'Update a task', + } + ], + default: 'create', + description: 'The operation to perform.', + } +] as INodeProperties[]; + +export const taskFields = [ + /* -------------------------------------------------------------------------- */ + /* task:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'TaskList', + name: 'task', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTasks', + }, + required: true, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'task', + ], + }, + }, + default: '', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'Title of the task.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'task', + ], + } + }, + options: [ + { + displayName: 'Completion Date', + name: 'completed', + type: 'dateTime', + default: '', + description: `Completion date of the task (as a RFC 3339 timestamp). This field is omitted if the task has not been completed.`, + }, + { + displayName: 'Deleted', + name: 'deleted', + type: 'boolean', + default: false, + description: 'Flag indicating whether the task has been deleted.', + }, + { + displayName: 'Due Date', + name: 'dueDate', + type: 'dateTime', + default: '', + description: 'Due date of the task.', + }, + { + displayName: 'Notes', + name: 'notes', + type: 'string', + default: '', + description: 'Additional Notes.', + }, + { + displayName: 'Parent', + name: 'parent', + type: 'string', + default: '', + description: 'Parent task identifier. If the task is created at the top level, this parameter is omitted.', + }, + { + displayName: 'Previous', + name: 'previous', + type: 'string', + default: '', + description: 'Previous sibling task identifier. If the task is created at the first position among its siblings, this parameter is omitted.', + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + options: [ + { + name: 'Needs Action', + value: 'needsAction', + }, + { + name: 'Completed', + value: 'completed', + } + ], + default: '', + description: 'Current status of the task.', + }, + + ], + }, + /* -------------------------------------------------------------------------- */ + /* task:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'TaskList', + name: 'task', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTasks', + }, + required: true, + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'task', + ], + }, + }, + default: '', + }, + { + displayName: 'Task ID', + name: 'taskId', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'task', + ], + }, + }, + default: '', + }, + /* -------------------------------------------------------------------------- */ + /* task:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'TaskList', + name: 'task', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTasks', + }, + required: true, + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'task', + ], + } + }, + default: '', + }, + { + displayName: 'Task ID', + name: 'taskId', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'task', + ], + }, + }, + default: '', + }, + /* -------------------------------------------------------------------------- */ + /* task:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'TaskList', + name: 'task', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTasks', + }, + required: true, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'task', + ], + }, + }, + default: '', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'task', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'task', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100 + }, + default: 20, + description: 'How many results to return.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'task', + ], + }, + }, + options: [ + { + displayName: 'Completed Max', + name: 'completedMax', + type: 'dateTime', + default: '', + description: 'Upper bound for a task completion date (as a RFC 3339 timestamp) to filter by.', + }, + { + displayName: 'Completed Min', + name: 'completedMin', + type: 'dateTime', + default: '', + description: 'Lower bound for a task completion date (as a RFC 3339 timestamp) to filter by.', + }, + { + displayName: 'Due Min', + name: 'dueMin', + type: 'dateTime', + default: '', + description: 'Lower bound for a task due date (as a RFC 3339 timestamp) to filter by.', + }, + { + displayName: 'Due Max', + name: 'dueMax', + type: 'dateTime', + default: '', + description: 'Upper bound for a task due date (as a RFC 3339 timestamp) to filter by.', + }, + { + displayName: 'Show Completed', + name: 'showCompleted', + type: 'boolean', + default: true, + description: 'Flag indicating whether completed tasks are returned in the result', + }, + { + displayName: 'Show Deleted', + name: 'showDeleted', + type: 'boolean', + default: false, + description: 'Flag indicating whether deleted tasks are returned in the result', + }, + { + displayName: 'Show Hidden', + name: 'showHidden', + type: 'boolean', + default: false, + description: 'Flag indicating whether hidden tasks are returned in the result', + }, + { + displayName: 'Updated Min', + name: 'updatedMin', + type: 'dateTime', + default: '', + description: 'Lower bound for a task last modification time (as a RFC 3339 timestamp) to filter by.', + }, + ] + }, + /* -------------------------------------------------------------------------- */ + /* task:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'TaskList', + name: 'task', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTasks', + }, + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'task', + ], + }, + }, + default: '', + }, + { + displayName: 'Task ID', + name: 'taskId', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'task', + ], + }, + }, + default: '', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Update Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'task', + ], + } + }, + options: [ + { + displayName: 'Completion Date', + name: 'completed', + type: 'dateTime', + default: '', + description: `Completion date of the task (as a RFC 3339 timestamp). This field is omitted if the task has not been completed.`, + }, + + { + displayName: 'Deleted', + name: 'deleted', + type: 'boolean', + default: false, + description: 'Flag indicating whether the task has been deleted.', + }, + { + displayName: 'Notes', + name: 'notes', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Additional Notes.', + }, + { + displayName: 'Previous', + name: 'previous', + type: 'string', + default: '', + description: 'Previous sibling task identifier. If the task is created at the first position among its siblings, this parameter is omitted.', + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + options: [ + { + name: 'Needs Update', + value: 'needsAction', + }, + { + name: 'Completed', + value: 'completed', + } + ], + default: '', + description: 'Current status of the task.', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'Title of the task.', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Google/Task/googleTasks.png b/packages/nodes-base/nodes/Google/Task/googleTasks.png new file mode 100644 index 0000000000000000000000000000000000000000..bbc31280afdc23d93d1d012775e301395d8d6558 GIT binary patch literal 3290 zcmZuzcQo8>79J8^)DVOy35i4*eaIlnL^o=P7QHikCWPp1^xj4Jh!Ubjiq7Z+8;M{> zNkoe}dYKvXTiM-ncK_IW&wcK5pL6d$?|a^V-gpCjjcc@Av>*`ZnwF-z(Pf7I3F<3< z6anE!m+7jDik=Dx)Ram`w4=DJ$^48oR6+F<+#8pLAQEPdG_nie^Z5(m?B?mjhYa*_ z;&bysI)G^ZAT8g&4NTiJO?18-2>8DP5PLT-5bYoQ&wwUWtn z{}qo%Jd`dkHEE!00s}~Y{0smH003M7#7q1aCH)~62LN#Vhv10-fpjU6E{MQ|O61nw zCB1AT0tOjJ%Ap&oFITlwPoWW8`e|o6iATmMM~Y!P7r?o05VbAT-ZcFHmUwKOvZWZd zsu41;g4)xF*(Y59kAk*TQ73wdd+_LaXwbSr20=USOb>Mc3;BIW1e5|7Vd2w60v-?G z)xvj9F90jlSKat+otQPl=rMfnH3A-hr|)&n0gsb#I^pB0k?Y10-*o(YOcSQ{UJm1k zKwk4oXvJprAW;Q50Yh!Ao&rvp3&`SCMdv2#1gzf6FUbw-6@$n7X#VX1h(l0T%M9R_ zxBRMg7vlUex8=7@+Lmt8`owSG*AcJ{043-{oyeKxp{I|+jvuF<3@-rIaRVkvORXQ1 zeY2+0J{{Hn1BkA|_WuH`G7qqa0ID9R>ee&81^A?WsIHA1ZS{6e_;7@#$U~o*1b;lh zUxbxlTvI0(`<;#D(?I*fVpXT)iddh%qFg!)WBcP1h4j?{>wa{z$7U|ZQq zY(Ku$71V`4Y6$&-w;t~0f{ppUs+JQ-jDgYj(UhgH2M*Njbujx=^BXppT~DGQ<6o+Z zG0$0-Ks5~~HfoR$*@Y*UX&Pox1ChKUHip*?`Px0ezmB~XDIPM1=7GkS8}FV*l+ZXt z%V(YdtEU9c!MvG6>fHzTDWm>=o92}n ztrM@^I^QNrcPk!x&Q9W_d}A?TMER~-h@R^@lYITmRv>d%Ut~_?i2AoNpeM426h7j@ zd?wPfPyPy(3yw5%Vx^p;mU6sp)h+8TraLB67a=@0DJ{A&ZL5``e|M6DIPeP1XN4Nb zA#X`Wdrejk^yac{msb?s_(zG?HV-c^0y-DmbXy`Oyn4@D$%lthtyGG_-{WUc&#>S7 z_x}C|$e@kjN`>m0IVrVo@EP6_BSBC#B=A~n?rjCukCcdd4Vm+SElV{FQlU^UD5|(o zE}y5M1^aTcx^ePeS-pn2j>|md+uyP#uc@|D8{R4^`XVIrkAk4Zr8NWMQgev}#z72M z7egPbZJCq@&7PCROx_Qkp!}1RB#1`aTN{Lk|AqmTsfN3qw|#(3uGEKOJifE9xv!)wVxnUEm$qgq>78DXC&CaQe>_r5v5IW>d23tS ztNkpgTl6*M%rf6tYwMM=x~-xt#psw=5~d(|N+J2h%@#6AC@v z&tDo>@{bA6tB$m*@NMxIK4@$IR<(h|x)(LGNN>=-v|psYcgsYPQzlcGUE>CB)CyzZ z9a|=dr4?6cV0=H>;16$h^T=h(nb{<->s!iOJFZbN6{SQ2!7q@`_-ysuYn0T!?6x`m z(6Z}Y0yxHsks`Ed6>EL{Kv7c*yJ3;Li0r1xr`u71VUG6QD(!5vqt`}+GQ312^X~

U@>#&tup@H!K?iYlVd; z96m;e_)zK=g&A-`@V?q9VLVxNIa!H}q2Q=tGonwM)P7pVyP#e9!Dse+wDpL5-Oy5i z+Zzg$#kiP~=a={MFBF-#PUb^b9LUy62l-k1zzM242l(vn&ozQ%JHCU&=xHl4JsoTR z_8f-kJgxMcXJZre^kdsw=F>xV+AGY$ejUA<58-f)Uj@&_Xnc2Ed;PS!d4p33s9U2- z4j`3T@zcj`TVdN%v*f~`>NW?;RaYmG(+U}!WC&}Us&6>>#G7KD#&yX&o9JqhQrX8} zi$w2k=5PLj@`TTh69T_La7n{R% z6A`4gw%LEz8pL+hm72OHi+Ug>R54569kUUDQx3k^Bh871iL4GsYfNo#R(3f1Bqj)q z5D-B+Ctv+t)hZ3hIJLyXV?D#fX~ZX9->33bI|C@P9nKZFzCB_8hW?fv?}+c4#0-l$P*_8pM)XgGlYJw8vx)Dx8PMgc9Ff!uJCa5J`3{7}vNFDrU|8wV}QMEU(?7FA% z$%c$4%hUczJ}#ZMSnmc}vigf0cZ7Ywu2nFkt@B+l*hbBJz^HunxX|*1B=J%#w;40m+89FGt4c*^E)Rw1?xCB`f4~VDs2| zN}9;m=M`~xw|RN+!ARs0=PW8&obR1$nUn2KV`w8V+_0GaM)qC^Q!$h=#*Ar$D$J3G zQ{!>F#ZzmFFn+4ma6xV6I+l}+kkRSMMFvlo)7fP+mJX>gCdZm^tgvm67XU5xeQgx7 zj}8i2 zAVOZH_QoW1gd%D`Nlz<7ojjBOgJ;wl1k}SLw%CsyV|qp*%k9`@326XGhNrjzi`_BmE`K= z(bSr{i}mv}_^Du_%m%UDqRt=!p-_E<(qH9`zu$uFm)zP^(IPDw zy3hIvX{NM2d0)3U3s^q4ttXz=1;S4sJ$sF9?;x(}mHo(xO51krbv~17KDHu7&ffb9 z?+q{Klg#)XI0mL%O$?T7s#|MUL*&78JTnX$f(< z!Rp9*XuSM6M%`4DVI^w7UI`*I|3lu2aTO$FkJ*&6Y`EzmbZampyzs8+CyZ}F3S7YC zaO7^{cg<&azUFdr3?Dsb$W`^Xq}%2AfGCniO0(aK6aPf&26I|o0cSQ=3n-TqwLDvx zpbuZ?JPe~1e_0Y80ipjvwuoZ0z@oM68}A6o`d-;8jW0DTNaJ9RXJC)h^&I`8S(cFp z;e5W2JMLtsl`|pW+AXqB-O|7D^BzA{_P9*4wfw%Y)%SM z%OxXIXYV+yvs<3GX>(g??B zSCP9-#8TYXgmIPWI*Zke8vlI*<7YEwDa1S=trvGDAS%eR&-luMhqxeY^nk|0t=yy; s1Af{Ie$k%~or@&} + */ +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 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/nodes/HttpRequest.node.ts b/packages/nodes-base/nodes/HttpRequest.node.ts index 417d80eeea..08d3a0ac15 100644 --- a/packages/nodes-base/nodes/HttpRequest.node.ts +++ b/packages/nodes-base/nodes/HttpRequest.node.ts @@ -802,7 +802,7 @@ export class HttpRequest implements INodeType { if (oAuth2Api !== undefined) { //@ts-ignore - response = await this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions); + response = await this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions, 'Bearer'); } else { response = await this.helpers.request(requestOptions); } diff --git a/packages/nodes-base/nodes/Hubspot/GenericFunctions.ts b/packages/nodes-base/nodes/Hubspot/GenericFunctions.ts index 969c392cdd..d8d68abfb6 100644 --- a/packages/nodes-base/nodes/Hubspot/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Hubspot/GenericFunctions.ts @@ -15,11 +15,12 @@ import { export async function hubspotApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: any = {}, query: IDataObject = {}, uri?: string): Promise { // tslint:disable-line:no-any - const node = this.getNode(); - const credentialName = Object.keys(node.credentials!)[0]; - const credentials = this.getCredentials(credentialName); + let authenticationMethod = this.getNodeParameter('authentication', 0); + + if (this.getNode().type.includes('Trigger')) { + authenticationMethod = 'developerApi'; + } - query!.hapikey = credentials!.apiKey as string; const options: OptionsWithUri = { method, qs: query, @@ -28,18 +29,42 @@ export async function hubspotApiRequest(this: IHookFunctions | IExecuteFunctions json: true, useQuerystring: true, }; + try { - return await this.helpers.request!(options); + if (authenticationMethod === 'apiKey') { + const credentials = this.getCredentials('hubspotApi'); + + options.qs.hapikey = credentials!.apiKey as string; + + return await this.helpers.request!(options); + } else if (authenticationMethod === 'developerApi') { + const credentials = this.getCredentials('hubspotDeveloperApi'); + + options.qs.hapikey = credentials!.apiKey as string; + + return await this.helpers.request!(options); + } else { + // @ts-ignore + return await this.helpers.requestOAuth2!.call(this, 'hubspotOAuth2Api', options, 'Bearer'); + } } catch (error) { - if (error.response && error.response.body && error.response.body.errors) { - // Try to return the error prettier - let errorMessages = error.response.body.errors; + let errorMessages; - if (errorMessages[0].message) { - // @ts-ignore - errorMessages = errorMessages.map(errorItem => errorItem.message); + if (error.response && error.response.body) { + + if (error.response.body.message) { + + errorMessages = [error.response.body.message]; + + } else if (error.response.body.errors) { + // Try to return the error prettier + errorMessages = error.response.body.errors; + + if (errorMessages[0].message) { + // @ts-ignore + errorMessages = errorMessages.map(errorItem => errorItem.message); + } } - throw new Error(`Hubspot error response [${error.statusCode}]: ${errorMessages.join('|')}`); } diff --git a/packages/nodes-base/nodes/Hubspot/Hubspot.node.ts b/packages/nodes-base/nodes/Hubspot/Hubspot.node.ts index 6c1d7be400..adf01d1ec3 100644 --- a/packages/nodes-base/nodes/Hubspot/Hubspot.node.ts +++ b/packages/nodes-base/nodes/Hubspot/Hubspot.node.ts @@ -73,9 +73,44 @@ export class Hubspot implements INodeType { { name: 'hubspotApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'apiKey', + ], + }, + }, + }, + { + name: 'hubspotOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'API Key', + value: 'apiKey', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'apiKey', + description: 'The method of authentication.', + }, { displayName: 'Resource', name: 'resource', diff --git a/packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts b/packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts index 25028f88e8..47b2318b36 100644 --- a/packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts +++ b/packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts @@ -246,7 +246,13 @@ export class HubspotTrigger implements INodeType { }; async webhook(this: IWebhookFunctions): Promise { - const credentials = this.getCredentials('hubspotDeveloperApi'); + + const credentials = this.getCredentials('hubspotDeveloperApi') as IDataObject; + + if (credentials === undefined) { + throw new Error('No credentials found!'); + } + const req = this.getRequestObject(); const bodyData = req.body; const headerData = this.getHeaderData(); @@ -254,12 +260,18 @@ export class HubspotTrigger implements INodeType { if (headerData['x-hubspot-signature'] === undefined) { return {}; } - const hash = `${credentials!.clientSecret}${JSON.stringify(bodyData)}`; - const signature = createHash('sha256').update(hash).digest('hex'); - //@ts-ignore - if (signature !== headerData['x-hubspot-signature']) { - return {}; + + // check signare if client secret is defined + + if (credentials.clientSecret !== '') { + const hash = `${credentials!.clientSecret}${JSON.stringify(bodyData)}`; + const signature = createHash('sha256').update(hash).digest('hex'); + //@ts-ignore + if (signature !== headerData['x-hubspot-signature']) { + return {}; + } } + for (let i = 0; i < bodyData.length; i++) { const subscriptionType = bodyData[i].subscriptionType as string; if (subscriptionType.includes('contact')) { diff --git a/packages/nodes-base/nodes/Jira/IssueDescription.ts b/packages/nodes-base/nodes/Jira/IssueDescription.ts index bdb06179df..d29c9a5350 100644 --- a/packages/nodes-base/nodes/Jira/IssueDescription.ts +++ b/packages/nodes-base/nodes/Jira/IssueDescription.ts @@ -41,12 +41,12 @@ export const issueOperations = [ { name: 'Notify', value: 'notify', - description: 'Creates an email notification for an issue and adds it to the mail queue.', + description: 'Create an email notification for an issue and add it to the mail queue', }, { name: 'Status', value: 'transitions', - description: `Returns either all transitions or a transition that can be performed by the user on an issue, based on the issue's status.`, + description: `Return either all transitions or a transition that can be performed by the user on an issue, based on the issue's status`, }, { name: 'Delete', diff --git a/packages/nodes-base/nodes/Mailchimp/GenericFunctions.ts b/packages/nodes-base/nodes/Mailchimp/GenericFunctions.ts index 91dfcdde85..99c0af67fd 100644 --- a/packages/nodes-base/nodes/Mailchimp/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Mailchimp/GenericFunctions.ts @@ -1,5 +1,5 @@ import { - OptionsWithUri, + OptionsWithUrl, } from 'request'; import { @@ -14,37 +14,53 @@ import { } from 'n8n-workflow'; export async function mailchimpApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, endpoint: string, method: string, body: any = {}, qs: IDataObject = {} ,headers?: object): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('mailchimpApi'); - - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - - const headerWithAuthentication = Object.assign({}, headers, { Authorization: `apikey ${credentials.apiKey}` }); - - if (!(credentials.apiKey as string).includes('-')) { - throw new Error('The API key is not valid!'); - } - - const datacenter = (credentials.apiKey as string).split('-').pop(); + const authenticationMethod = this.getNodeParameter('authentication', 0) as string; const host = 'api.mailchimp.com/3.0'; - const options: OptionsWithUri = { - headers: headerWithAuthentication, + const options: OptionsWithUrl = { + headers: { + 'Accept': 'application/json' + }, method, qs, - uri: `https://${datacenter}.${host}${endpoint}`, + body, + url: ``, json: true, }; - if (Object.keys(body).length !== 0) { - options.body = body; + if (Object.keys(body).length === 0) { + delete options.body; } + try { - return await this.helpers.request!(options); + if (authenticationMethod === 'apiKey') { + const credentials = this.getCredentials('mailchimpApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.headers = Object.assign({}, headers, { Authorization: `apikey ${credentials.apiKey}` }); + + if (!(credentials.apiKey as string).includes('-')) { + throw new Error('The API key is not valid!'); + } + + const datacenter = (credentials.apiKey as string).split('-').pop(); + options.url = `https://${datacenter}.${host}${endpoint}`; + return await this.helpers.request!(options); + } else { + const credentials = this.getCredentials('mailchimpOAuth2Api') as IDataObject; + + const { api_endpoint } = await getMetadata.call(this, credentials.oauthTokenData as IDataObject); + + options.url = `${api_endpoint}/3.0${endpoint}`; + //@ts-ignore + return await this.helpers.requestOAuth2!.call(this, 'mailchimpOAuth2Api', options, 'Bearer'); + } } catch (error) { - if (error.response.body && error.response.body.detail) { + if (error.respose && error.response.body && error.response.body.detail) { throw new Error(`Mailchimp Error response [${error.statusCode}]: ${error.response.body.detail}`); } throw error; @@ -80,3 +96,17 @@ export function validateJSON(json: string | undefined): any { // tslint:disable- } return result; } + +function getMetadata(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, oauthTokenData: IDataObject) { + const credentials = this.getCredentials('mailchimpOAuth2Api') as IDataObject; + const options: OptionsWithUrl = { + headers: { + 'Accept': 'application/json', + 'Authorization': `OAuth ${oauthTokenData.access_token}`, + }, + method: 'GET', + url: credentials.metadataUrl as string, + json: true, + }; + return this.helpers.request!(options); +} diff --git a/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts b/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts index cb54ac24e9..ff5ee63645 100644 --- a/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts +++ b/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts @@ -47,6 +47,7 @@ interface ICreateMemberBody { timestamp_opt?: string; tags?: string[]; merge_fields?: IDataObject; + interests?: IDataObject; } export class Mailchimp implements INodeType { @@ -69,14 +70,53 @@ export class Mailchimp implements INodeType { { name: 'mailchimpApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'apiKey', + ], + }, + }, + }, + { + name: 'mailchimpOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'API Key', + value: 'apiKey', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'apiKey', + description: 'Method of authentication.', + }, { displayName: 'Resource', name: 'resource', type: 'options', options: [ + { + name: 'List Group', + value: 'listGroup', + }, { name: 'Member', value: 'member', @@ -159,6 +199,28 @@ export class Mailchimp implements INodeType { default: 'create', description: 'The operation to perform.', }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + required: true, + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'Get all groups', + }, + ], + default: 'getAll', + description: 'The operation to perform.', + }, /* -------------------------------------------------------------------------- */ /* member:create */ /* -------------------------------------------------------------------------- */ @@ -221,27 +283,22 @@ export class Mailchimp implements INodeType { { name: 'Subscribed', value: 'subscribed', - description: '', }, { name: 'Unsubscribed', value: 'unsubscribed', - description: '', }, { name: 'Cleaned', value: 'cleaned', - description: '', }, { name: 'Pending', value: 'pending', - description: '', }, { name: 'Transactional', value: 'transactional', - description: '', }, ], default: '', @@ -252,7 +309,6 @@ export class Mailchimp implements INodeType { name: 'jsonParameters', type: 'boolean', default: false, - description: '', displayOptions: { show: { resource:[ @@ -289,12 +345,10 @@ export class Mailchimp implements INodeType { { name: 'HTML', value: 'html', - description: '', }, { name: 'Text', value: 'text', - description: '', }, ], default: '', @@ -461,7 +515,6 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', displayOptions: { show: { resource:[ @@ -484,7 +537,86 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', + displayOptions: { + show: { + resource:[ + 'member', + ], + operation: [ + 'create', + ], + jsonParameters: [ + true, + ], + }, + }, + }, + { + displayName: 'Interest Groups', + name: 'groupsUi', + placeholder: 'Add Interest Group', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource:[ + 'member' + ], + operation: [ + 'create', + ], + jsonParameters: [ + false, + ], + }, + }, + options: [ + { + name: 'groupsValues', + displayName: 'Group', + typeOptions: { + multipleValueButtonText: 'Add Interest Group', + }, + values: [ + { + displayName: 'Category ID', + name: 'categoryId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getGroupCategories', + loadOptionsDependsOn: [ + 'list', + ], + }, + default: '', + }, + { + displayName: 'Category Field ID', + name: 'categoryFieldId', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'boolean', + default: false, + }, + ], + }, + ], + }, + { + displayName: 'Interest Groups', + name: 'groupJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', displayOptions: { show: { resource:[ @@ -737,12 +869,10 @@ export class Mailchimp implements INodeType { { name: 'HTML', value: 'html', - description: '', }, { name: 'Text', value: 'text', - description: '', }, ], default: '', @@ -756,27 +886,22 @@ export class Mailchimp implements INodeType { { name: 'Subscribed', value: 'subscribed', - description: '', }, { name: 'Unsubscribed', value: 'unsubscribed', - description: '', }, { name: 'Cleaned', value: 'cleaned', - description: '', }, { name: 'Pending', value: 'pending', - description: '', }, { name: 'Transactional', value: 'transactional', - description: '', }, ], default: '', @@ -839,7 +964,6 @@ export class Mailchimp implements INodeType { name: 'jsonParameters', type: 'boolean', default: false, - description: '', displayOptions: { show: { resource:[ @@ -876,17 +1000,73 @@ export class Mailchimp implements INodeType { { name: 'HTML', value: 'html', - description: '', }, { name: 'Text', value: 'text', - description: '', }, ], default: '', description: 'Type of email this member asked to get', }, + { + displayName: 'Interest Groups', + name: 'groupsUi', + placeholder: 'Add Interest Group', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + '/resource':[ + 'member' + ], + '/operation':[ + 'update', + ], + '/jsonParameters': [ + false, + ], + }, + }, + options: [ + { + name: 'groupsValues', + displayName: 'Group', + typeOptions: { + multipleValueButtonText: 'Add Interest Group', + }, + values: [ + { + displayName: 'Category ID', + name: 'categoryId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getGroupCategories', + loadOptionsDependsOn: [ + 'list', + ], + }, + default: '', + }, + { + displayName: 'Category Field ID', + name: 'categoryFieldId', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'boolean', + default: false, + }, + ], + }, + ], + }, { displayName: 'Language', name: 'language', @@ -989,27 +1169,22 @@ export class Mailchimp implements INodeType { { name: 'Subscribed', value: 'subscribed', - description: '', }, { name: 'Unsubscribed', value: 'unsubscribed', - description: '', }, { name: 'Cleaned', value: 'cleaned', - description: '', }, { name: 'Pending', value: 'pending', - description: '', }, { name: 'Transactional', value: 'transactional', - description: '', }, ], default: '', @@ -1084,7 +1259,6 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', displayOptions: { show: { resource:[ @@ -1107,7 +1281,28 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', + displayOptions: { + show: { + resource:[ + 'member', + ], + operation: [ + 'update', + ], + jsonParameters: [ + true, + ], + }, + }, + }, + { + displayName: 'Interest Groups', + name: 'groupJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', displayOptions: { show: { resource:[ @@ -1215,6 +1410,96 @@ export class Mailchimp implements INodeType { }, ], }, +/* -------------------------------------------------------------------------- */ +/* member:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'List', + name: 'list', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getLists', + }, + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + operation: [ + 'getAll', + ], + }, + }, + default: '', + options: [], + required: true, + description: 'List of lists', + }, + { + displayName: 'Group Category', + name: 'groupCategory', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getGroupCategories', + loadOptionsDependsOn: [ + 'list', + ], + }, + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + operation: [ + 'getAll', + ], + }, + }, + default: '', + options: [], + required: true, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + 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: [ + 'listGroup', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 1000, + }, + default: 500, + description: 'How many results to return.', + }, ], }; @@ -1226,7 +1511,7 @@ export class Mailchimp implements INodeType { // select them easily async getLists(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; - const { lists } = await mailchimpApiRequest.call(this, '/lists', 'GET'); + const lists = await mailchimpApiRequestAllItems.call(this, '/lists', 'GET', 'lists'); for (const list of lists) { const listName = list.name; const listId = list.id; @@ -1254,6 +1539,23 @@ export class Mailchimp implements INodeType { } return returnData; }, + + // Get all the interest fields to display them to user so that he can + // select them easily + async getGroupCategories(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const listId = this.getCurrentNodeParameter('list'); + const { categories } = await mailchimpApiRequest.call(this, `/lists/${listId}/interest-categories`, 'GET'); + for (const category of categories) { + const categoryName = category.title; + const categoryId = category.id; + returnData.push({ + name: categoryName, + value: categoryId, + }); + } + return returnData; + }, } }; @@ -1267,6 +1569,22 @@ export class Mailchimp implements INodeType { const operation = this.getNodeParameter('operation', 0) as string; for (let i = 0; i < length; i++) { + if (resource === 'listGroup') { + //https://mailchimp.com/developer/reference/lists/interest-categories/#get_/lists/-list_id-/interest-categories/-interest_category_id- + if (operation === 'getAll') { + const listId = this.getNodeParameter('list', i) as string; + const categoryId = this.getNodeParameter('groupCategory', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (returnAll === true) { + responseData = await mailchimpApiRequestAllItems.call(this, `/lists/${listId}/interest-categories/${categoryId}/interests`, 'GET', 'interests', {}, qs); + } else { + qs.count = this.getNodeParameter('limit', i) as number; + responseData = await mailchimpApiRequest.call(this, `/lists/${listId}/interest-categories/${categoryId}/interests`, 'GET', {}, qs); + responseData = responseData.interests; + } + } + } if (resource === 'member') { //https://mailchimp.com/developer/reference/lists/list-members/#post_/lists/-list_id-/members if (operation === 'create') { @@ -1328,15 +1646,29 @@ export class Mailchimp implements INodeType { } body.merge_fields = mergeFields; } + + const groupsValues = (this.getNodeParameter('groupsUi', i) as IDataObject).groupsValues as IDataObject[]; + if (groupsValues) { + const groups = {}; + for (let i = 0; i < groupsValues.length; i++) { + // @ts-ignore + groups[groupsValues[i].categoryFieldId] = groupsValues[i].value; + } + body.interests = groups; + } } else { const locationJson = validateJSON(this.getNodeParameter('locationJson', i) as string); const mergeFieldsJson = validateJSON(this.getNodeParameter('mergeFieldsJson', i) as string); + const groupJson = validateJSON(this.getNodeParameter('groupJson', i) as string); if (locationJson) { body.location = locationJson; } if (mergeFieldsJson) { body.merge_fields = mergeFieldsJson; } + if (groupJson) { + body.interests = groupJson; + } } responseData = await mailchimpApiRequest.call(this, `/lists/${listId}/members`, 'POST', body); } @@ -1469,15 +1801,31 @@ export class Mailchimp implements INodeType { body.merge_fields = mergeFields; } } + if (updateFields.groupsUi) { + const groupsValues = (updateFields.groupsUi as IDataObject).groupsValues as IDataObject[]; + if (groupsValues) { + const groups = {}; + for (let i = 0; i < groupsValues.length; i++) { + // @ts-ignore + groups[groupsValues[i].categoryFieldId] = groupsValues[i].value; + } + body.interests = groups; + } + } } else { const locationJson = validateJSON(this.getNodeParameter('locationJson', i) as string); const mergeFieldsJson = validateJSON(this.getNodeParameter('mergeFieldsJson', i) as string); + const groupJson = validateJSON(this.getNodeParameter('groupJson', i) as string); + if (locationJson) { body.location = locationJson; } if (mergeFieldsJson) { body.merge_fields = mergeFieldsJson; } + if (groupJson) { + body.interests = groupJson; + } } responseData = await mailchimpApiRequest.call(this, `/lists/${listId}/members/${email}`, 'PUT', body); } @@ -1536,6 +1884,7 @@ export class Mailchimp implements INodeType { responseData = { success: true }; } } + if (Array.isArray(responseData)) { returnData.push.apply(returnData, responseData as IDataObject[]); } else { diff --git a/packages/nodes-base/nodes/Mailchimp/MailchimpTrigger.node.ts b/packages/nodes-base/nodes/Mailchimp/MailchimpTrigger.node.ts index 25eac04c20..9fbd80a0f5 100644 --- a/packages/nodes-base/nodes/Mailchimp/MailchimpTrigger.node.ts +++ b/packages/nodes-base/nodes/Mailchimp/MailchimpTrigger.node.ts @@ -33,7 +33,25 @@ export class MailchimpTrigger implements INodeType { { name: 'mailchimpApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'apiKey', + ], + }, + }, + }, + { + name: 'mailchimpOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], webhooks: [ { @@ -50,6 +68,23 @@ export class MailchimpTrigger implements INodeType { } ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'API Key', + value: 'apiKey', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'apiKey', + description: 'Method of authentication.', + }, { displayName: 'List', name: 'list', diff --git a/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts b/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts index 0aa50f3d0e..cc7bc65ee3 100644 --- a/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts +++ b/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts @@ -62,7 +62,7 @@ export class Mattermost implements INodeType { }, ], default: 'message', - description: 'The resource to operate on.', + description: 'The resource to operate on', }, @@ -95,22 +95,22 @@ export class Mattermost implements INodeType { { name: 'Delete', value: 'delete', - description: 'Soft-deletes a channel', + description: 'Soft delete a channel', }, { name: 'Member', value: 'members', - description: 'Get a page of members for a channel.', + description: 'Get a page of members for a channel', }, { name: 'Restore', value: 'restore', - description: 'Restores a soft-deleted channel', + description: 'Restores a soft deleted channel', }, { name: 'Statistics', value: 'statistics', - description: 'Get statistics for a channel.', + description: 'Get statistics for a channel', }, ], default: 'create', @@ -131,7 +131,7 @@ export class Mattermost implements INodeType { { name: 'Delete', value: 'delete', - description: 'Soft deletes a post, by marking the post as deleted in the database.', + description: 'Soft delete a post, by marking the post as deleted in the database', }, { name: 'Post', @@ -140,7 +140,7 @@ export class Mattermost implements INodeType { }, ], default: 'post', - description: 'The operation to perform.', + description: 'The operation to perform', }, @@ -191,7 +191,7 @@ export class Mattermost implements INodeType { }, }, required: true, - description: 'The non-unique UI name for the channel.', + description: 'The non-unique UI name for the channel', }, { displayName: 'Name', @@ -210,7 +210,7 @@ export class Mattermost implements INodeType { }, }, required: true, - description: 'The unique handle for the channel, will be present in the channel URL.', + description: 'The unique handle for the channel, will be present in the channel URL', }, { displayName: 'Type', @@ -264,7 +264,7 @@ export class Mattermost implements INodeType { ], }, }, - description: 'The ID of the channel to soft-delete.', + description: 'The ID of the channel to soft delete', }, // ---------------------------------- diff --git a/packages/nodes-base/nodes/Mautic/ContactDescription.ts b/packages/nodes-base/nodes/Mautic/ContactDescription.ts index b9eb9b0f42..1ea81acbc8 100644 --- a/packages/nodes-base/nodes/Mautic/ContactDescription.ts +++ b/packages/nodes-base/nodes/Mautic/ContactDescription.ts @@ -226,6 +226,94 @@ export const contactFields = [ }, }, options: [ + { + displayName: 'Address', + name: 'addressUi', + placeholder: 'Address', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: {}, + options: [ + { + name: 'addressValues', + displayName: 'Address', + values: [ + { + displayName: 'Address Line 1', + name: 'address1', + type: 'string', + default: '', + }, + { + displayName: 'Address Line 2', + name: 'address2', + type: 'string', + default: '', + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + }, + { + displayName: 'Country', + name: 'country', + type: 'string', + default: '', + }, + { + displayName: 'Zip Code', + name: 'zipCode', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'B2B or B2C', + name: 'b2bOrb2c', + type: 'options', + options: [ + { + name: 'B2B', + value: 'B2B', + }, + { + name: 'B2C', + value: 'B2C', + }, + ], + default: '', + }, + { + displayName: 'CRM ID', + name: 'crmId', + type: 'string', + default: '', + }, + { + displayName: 'Fax', + name: 'fax', + type: 'string', + default: '', + }, + { + displayName: 'Has Purchased', + name: 'hasPurchased', + type: 'boolean', + default: false, + }, { displayName: 'IP Address', name: 'ipAddress', @@ -240,6 +328,12 @@ export const contactFields = [ default: '', description: 'Date/time in UTC;', }, + { + displayName: 'Mobile', + name: 'mobile', + type: 'string', + default: '', + }, { displayName: 'Owner ID', name: 'ownerId', @@ -247,6 +341,112 @@ export const contactFields = [ default: '', description: 'ID of a Mautic user to assign this contact to', }, + { + displayName: 'Phone', + name: 'phone', + type: 'string', + default: '', + }, + { + displayName: 'Prospect or Customer', + name: 'prospectOrCustomer', + type: 'options', + options: [ + { + name: 'Prospect', + value: 'Prospect', + }, + { + name: 'Customer', + value: 'Customer', + }, + ], + default: '', + }, + { + displayName: 'Sandbox', + name: 'sandbox', + type: 'boolean', + default: false, + }, + { + displayName: 'Stage', + name: 'stage', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getStages', + }, + default: '', + }, + { + displayName: 'Tags', + name: 'tags', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getTags', + }, + default: '', + }, + { + displayName: 'Social Media', + name: 'socialMediaUi', + placeholder: 'Social Media', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: {}, + options: [ + { + name: 'socialMediaValues', + displayName: 'Social Media', + values: [ + { + displayName: 'Facebook', + name: 'facebook', + type: 'string', + default: '', + }, + { + displayName: 'Foursquare', + name: 'foursquare', + type: 'string', + default: '', + }, + { + displayName: 'Instagram', + name: 'instagram', + type: 'string', + default: '', + }, + { + displayName: 'LinkedIn', + name: 'linkedIn', + type: 'string', + default: '', + }, + { + displayName: 'Skype', + name: 'skype', + type: 'string', + default: '', + }, + { + displayName: 'Twitter', + name: 'twitter', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Website', + name: 'website', + type: 'string', + default: '', + }, ], }, @@ -318,6 +518,103 @@ export const contactFields = [ default: '', description: 'Contact parameters', }, + { + displayName: 'Address', + name: 'addressUi', + placeholder: 'Address', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: {}, + options: [ + { + name: 'addressValues', + displayName: 'Address', + values: [ + { + displayName: 'Address Line 1', + name: 'address1', + type: 'string', + default: '', + }, + { + displayName: 'Address Line 2', + name: 'address2', + type: 'string', + default: '', + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + }, + { + displayName: 'Country', + name: 'country', + type: 'string', + default: '', + }, + { + displayName: 'Zip Code', + name: 'zipCode', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'B2B or B2C', + name: 'b2bOrb2c', + type: 'options', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + options: [ + { + name: 'B2B', + value: 'B2B', + }, + { + name: 'B2C', + value: 'B2C', + }, + ], + default: '', + }, + { + displayName: 'CRM ID', + name: 'crmId', + type: 'string', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: '', + }, { displayName: 'Email', name: 'email', @@ -332,6 +629,19 @@ export const contactFields = [ default: '', description: 'Email address of the contact.', }, + { + displayName: 'Fax', + name: 'fax', + type: 'string', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: '', + }, { displayName: 'First Name', name: 'firstName', @@ -346,6 +656,47 @@ export const contactFields = [ default: '', description: 'First Name', }, + { + displayName: 'Has Purchased', + name: 'hasPurchased', + type: 'boolean', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: false, + }, + { + displayName: 'IP Address', + name: 'ipAddress', + type: 'string', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: '', + description: 'IP address to associate with the contact', + }, + { + displayName: 'Last Active', + name: 'lastActive', + type: 'dateTime', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: '', + description: 'Date/time in UTC;', + }, { displayName: 'Last Name', name: 'lastName', @@ -360,6 +711,60 @@ export const contactFields = [ default: '', description: 'LastName', }, + { + displayName: 'Mobile', + name: 'mobile', + type: 'string', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: '', + }, + { + displayName: 'Owner ID', + name: 'ownerId', + type: 'string', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: '', + description: 'ID of a Mautic user to assign this contact to', + }, + { + displayName: 'Phone', + name: 'phone', + type: 'string', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: '', + }, + { + displayName: 'Position', + name: 'position', + type: 'string', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: '', + description: 'Position', + }, { displayName: 'Primary Company', name: 'company', @@ -378,9 +783,9 @@ export const contactFields = [ description: 'Primary company', }, { - displayName: 'Position', - name: 'position', - type: 'string', + displayName: 'Prospect or Customer', + name: 'prospectOrCustomer', + type: 'options', displayOptions: { show: { '/jsonParameters': [ @@ -388,8 +793,62 @@ export const contactFields = [ ], }, }, + options: [ + { + name: 'Prospect', + value: 'Prospect', + }, + { + name: 'Customer', + value: 'Customer', + }, + ], + default: '', + }, + { + displayName: 'Sandbox', + name: 'sandbox', + type: 'boolean', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: false, + }, + { + displayName: 'Stage', + name: 'stage', + type: 'options', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getStages', + }, + default: '', + }, + { + displayName: 'Tags', + name: 'tags', + type: 'multiOptions', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getTags', + }, default: '', - description: 'Position', }, { displayName: 'Title', @@ -405,27 +864,94 @@ export const contactFields = [ default: '', description: 'Title', }, + { + displayName: 'Social Media', + name: 'socialMediaUi', + placeholder: 'Social Media', + type: 'fixedCollection', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + typeOptions: { + multipleValues: false, + }, + default: {}, + options: [ + { + name: 'socialMediaValues', + displayName: 'Social Media', + values: [ + { + displayName: 'Facebook', + name: 'facebook', + type: 'string', + default: '', + }, + { + displayName: 'Foursquare', + name: 'foursquare', + type: 'string', + default: '', + }, + { + displayName: 'Instagram', + name: 'instagram', + type: 'string', + default: '', + }, + { + displayName: 'LinkedIn', + name: 'linkedIn', + type: 'string', + default: '', + }, + { + displayName: 'Skype', + name: 'skype', + type: 'string', + default: '', + }, + { + displayName: 'Twitter', + name: 'twitter', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Website', + name: 'website', + type: 'string', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: '', + }, { displayName: 'IP Address', name: 'ipAddress', type: 'string', + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, default: '', description: 'IP address to associate with the contact', }, - { - displayName: 'Last Active', - name: 'lastActive', - type: 'dateTime', - default: '', - description: 'Date/time in UTC;', - }, - { - displayName: 'Owner ID', - name: 'ownerId', - type: 'string', - default: '', - description: 'ID of a Mautic user to assign this contact to', - }, ], }, diff --git a/packages/nodes-base/nodes/Mautic/GenericFunctions.ts b/packages/nodes-base/nodes/Mautic/GenericFunctions.ts index 6b179db7fd..9861690254 100644 --- a/packages/nodes-base/nodes/Mautic/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Mautic/GenericFunctions.ts @@ -10,7 +10,6 @@ import { import { IDataObject, } from 'n8n-workflow'; -import { errors } from 'imap-simple'; interface OMauticErrorResponse { errors: Array<{ @@ -19,7 +18,7 @@ interface OMauticErrorResponse { }>; } -function getErrors(error: OMauticErrorResponse): string { +export function getErrors(error: OMauticErrorResponse): string { const returnErrors: string[] = []; for (const errorItem of error.errors) { @@ -31,23 +30,40 @@ function getErrors(error: OMauticErrorResponse): string { export async function mauticApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: any = {}, query?: IDataObject, uri?: string): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('mauticApi'); - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - const base64Key = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + const authenticationMethod = this.getNodeParameter('authentication', 0, 'credentials') as string; + const options: OptionsWithUri = { - headers: { Authorization: `Basic ${base64Key}` }, + headers: {}, method, qs: query, - uri: uri || `${credentials.url}/api${endpoint}`, + uri: uri || `/api${endpoint}`, body, json: true }; - try { - const returnData = await this.helpers.request!(options); - if (returnData.error) { + try { + + let returnData; + + if (authenticationMethod === 'credentials') { + const credentials = this.getCredentials('mauticApi') as IDataObject; + + const base64Key = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + + options.headers!.Authorization = `Basic ${base64Key}`; + + options.uri = `${credentials.url}${options.uri}`; + //@ts-ignore + returnData = await this.helpers.request(options); + } else { + const credentials = this.getCredentials('mauticOAuth2Api') as IDataObject; + + options.uri = `${credentials.url}${options.uri}`; + //@ts-ignore + returnData = await this.helpers.requestOAuth2.call(this, 'mauticOAuth2Api', options); + } + + if (returnData.errors) { // They seem to to sometimes return 200 status but still error. throw new Error(getErrors(returnData)); } diff --git a/packages/nodes-base/nodes/Mautic/Mautic.node.ts b/packages/nodes-base/nodes/Mautic/Mautic.node.ts index 50abcda83e..7bdafe7605 100644 --- a/packages/nodes-base/nodes/Mautic/Mautic.node.ts +++ b/packages/nodes-base/nodes/Mautic/Mautic.node.ts @@ -1,5 +1,3 @@ -import { snakeCase } from 'change-case'; - import { IExecuteFunctions, } from 'n8n-core'; @@ -15,12 +13,18 @@ import { mauticApiRequest, mauticApiRequestAllItems, validateJSON, + getErrors, } from './GenericFunctions'; + import { contactFields, contactOperations, } from './ContactDescription'; +import { + snakeCase, + } from 'change-case'; + export class Mautic implements INodeType { description: INodeTypeDescription = { displayName: 'Mautic', @@ -40,9 +44,43 @@ export class Mautic implements INodeType { { name: 'mauticApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'credentials', + ], + }, + }, + }, + { + name: 'mauticOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Credentials', + value: 'credentials', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'credentials', + }, { displayName: 'Resource', name: 'resource', @@ -77,6 +115,32 @@ export class Mautic implements INodeType { } return returnData; }, + // Get all the available tags to display them to user so that he can + // select them easily + async getTags(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const tags = await mauticApiRequestAllItems.call(this, 'tags', 'GET', '/tags'); + for (const tag of tags) { + returnData.push({ + name: tag.tag, + value: tag.tag, + }); + } + return returnData; + }, + // Get all the available stages to display them to user so that he can + // select them easily + async getStages(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const stages = await mauticApiRequestAllItems.call(this, 'stages', 'GET', '/stages'); + for (const stage of stages) { + returnData.push({ + name: stage.name, + value: stage.id, + }); + } + return returnData; + }, }, }; @@ -124,6 +188,62 @@ export class Mautic implements INodeType { if (additionalFields.ownerId) { body.ownerId = additionalFields.ownerId as string; } + if (additionalFields.addressUi) { + const addressValues = (additionalFields.addressUi as IDataObject).addressValues as IDataObject; + if (addressValues) { + body.address1 = addressValues.address1 as string; + body.address2 = addressValues.address2 as string; + body.city = addressValues.city as string; + body.state = addressValues.state as string; + body.country = addressValues.country as string; + body.zipcode = addressValues.zipCode as string; + } + } + if (additionalFields.socialMediaUi) { + const socialMediaValues = (additionalFields.socialMediaUi as IDataObject).socialMediaValues as IDataObject; + if (socialMediaValues) { + body.facebook = socialMediaValues.facebook as string; + body.foursquare = socialMediaValues.foursquare as string; + body.instagram = socialMediaValues.instagram as string; + body.linkedin = socialMediaValues.linkedIn as string; + body.skype = socialMediaValues.skype as string; + body.twitter = socialMediaValues.twitter as string; + } + } + if (additionalFields.b2bOrb2c) { + body.b2b_or_b2c = additionalFields.b2bOrb2c as string; + } + if (additionalFields.crmId) { + body.crm_id = additionalFields.crmId as string; + } + if (additionalFields.fax) { + body.fax = additionalFields.fax as string; + } + if (additionalFields.hasPurchased) { + body.haspurchased = additionalFields.hasPurchased as boolean; + } + if (additionalFields.mobile) { + body.mobile = additionalFields.mobile as string; + } + if (additionalFields.phone) { + body.phone = additionalFields.phone as string; + } + if (additionalFields.prospectOrCustomer) { + body.prospect_or_customer = additionalFields.prospectOrCustomer as string; + } + if (additionalFields.sandbox) { + body.sandbox = additionalFields.sandbox as boolean; + } + if (additionalFields.stage) { + body.stage = additionalFields.stage as string; + } + if (additionalFields.tags) { + body.tags = additionalFields.tags as string; + } + if (additionalFields.website) { + body.website = additionalFields.website as string; + } + responseData = await mauticApiRequest.call(this, 'POST', '/contacts/new', body); responseData = responseData.contact; } @@ -167,6 +287,61 @@ export class Mautic implements INodeType { if (updateFields.ownerId) { body.ownerId = updateFields.ownerId as string; } + if (updateFields.addressUi) { + const addressValues = (updateFields.addressUi as IDataObject).addressValues as IDataObject; + if (addressValues) { + body.address1 = addressValues.address1 as string; + body.address2 = addressValues.address2 as string; + body.city = addressValues.city as string; + body.state = addressValues.state as string; + body.country = addressValues.country as string; + body.zipcode = addressValues.zipCode as string; + } + } + if (updateFields.socialMediaUi) { + const socialMediaValues = (updateFields.socialMediaUi as IDataObject).socialMediaValues as IDataObject; + if (socialMediaValues) { + body.facebook = socialMediaValues.facebook as string; + body.foursquare = socialMediaValues.foursquare as string; + body.instagram = socialMediaValues.instagram as string; + body.linkedin = socialMediaValues.linkedIn as string; + body.skype = socialMediaValues.skype as string; + body.twitter = socialMediaValues.twitter as string; + } + } + if (updateFields.b2bOrb2c) { + body.b2b_or_b2c = updateFields.b2bOrb2c as string; + } + if (updateFields.crmId) { + body.crm_id = updateFields.crmId as string; + } + if (updateFields.fax) { + body.fax = updateFields.fax as string; + } + if (updateFields.hasPurchased) { + body.haspurchased = updateFields.hasPurchased as boolean; + } + if (updateFields.mobile) { + body.mobile = updateFields.mobile as string; + } + if (updateFields.phone) { + body.phone = updateFields.phone as string; + } + if (updateFields.prospectOrCustomer) { + body.prospect_or_customer = updateFields.prospectOrCustomer as string; + } + if (updateFields.sandbox) { + body.sandbox = updateFields.sandbox as boolean; + } + if (updateFields.stage) { + body.stage = updateFields.stage as string; + } + if (updateFields.tags) { + body.tags = updateFields.tags as string; + } + if (updateFields.website) { + body.website = updateFields.website as string; + } responseData = await mauticApiRequest.call(this, 'PATCH', `/contacts/${contactId}/edit`, body); responseData = responseData.contact; } @@ -193,6 +368,9 @@ export class Mautic implements INodeType { qs.limit = this.getNodeParameter('limit', i) as number; qs.start = 0; responseData = await mauticApiRequest.call(this, 'GET', '/contacts', {}, qs); + if (responseData.errors) { + throw new Error(getErrors(responseData)); + } responseData = responseData.contacts; responseData = Object.values(responseData); } diff --git a/packages/nodes-base/nodes/Mautic/MauticTrigger.node.ts b/packages/nodes-base/nodes/Mautic/MauticTrigger.node.ts index a3ba20f28a..4f844e1188 100644 --- a/packages/nodes-base/nodes/Mautic/MauticTrigger.node.ts +++ b/packages/nodes-base/nodes/Mautic/MauticTrigger.node.ts @@ -38,7 +38,25 @@ export class MauticTrigger implements INodeType { { name: 'mauticApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'credentials', + ], + }, + }, + }, + { + name: 'mauticOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], webhooks: [ { @@ -49,6 +67,22 @@ export class MauticTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Credentials', + value: 'credentials', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'credentials', + }, { displayName: 'Events', name: 'events', diff --git a/packages/nodes-base/nodes/MessageBird/GenericFunctions.ts b/packages/nodes-base/nodes/MessageBird/GenericFunctions.ts new file mode 100644 index 0000000000..5a7eda2210 --- /dev/null +++ b/packages/nodes-base/nodes/MessageBird/GenericFunctions.ts @@ -0,0 +1,64 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IHookFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +/** + * Make an API request to Message Bird + * + * @param {IHookFunctions} this + * @param {string} method + * @param {string} url + * @param {object} body + * @returns {Promise} + */ +export async function messageBirdApiRequest( + this: IHookFunctions | IExecuteFunctions, + method: string, + resource: string, + body: IDataObject, + query: IDataObject = {}, +): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('messageBirdApi'); + if (credentials === undefined) { + throw new Error('No credentials returned!'); + } + + const options: OptionsWithUri = { + headers: { + Accept: 'application/json', + Authorization: `AccessKey ${credentials.accessKey}`, + }, + method, + qs: query, + body, + uri: `https://rest.messagebird.com${resource}`, + json: true, + }; + + try { + return await this.helpers.request(options); + } catch (error) { + if (error.statusCode === 401) { + throw new Error('The Message Bird credentials are not valid!'); + } + + if (error.response && error.response.body && error.response.body.errors) { + // Try to return the error prettier + const errorMessage = error.response.body.errors.map((e: IDataObject) => e.description); + + throw new Error(`MessageBird Error response [${error.statusCode}]: ${errorMessage.join('|')}`); + } + + // If that data does not exist for some reason return the actual error + throw error; + } +} diff --git a/packages/nodes-base/nodes/MessageBird/MessageBird.node.ts b/packages/nodes-base/nodes/MessageBird/MessageBird.node.ts new file mode 100644 index 0000000000..46f70c85d6 --- /dev/null +++ b/packages/nodes-base/nodes/MessageBird/MessageBird.node.ts @@ -0,0 +1,364 @@ +import { + IExecuteFunctions, + } from 'n8n-core'; + +import { + IDataObject, + INodeTypeDescription, + INodeExecutionData, + INodeType, +} from 'n8n-workflow'; + +import { + messageBirdApiRequest, +} from './GenericFunctions'; + +export class MessageBird implements INodeType { + description: INodeTypeDescription = { + displayName: 'MessageBird', + name: 'messageBird', + icon: 'file:messagebird.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Sending SMS', + defaults: { + name: 'MessageBird', + color: '#2481d7', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'messageBirdApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'SMS', + value: 'sms', + }, + ], + default: 'sms', + description: 'The resource to operate on.', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'sms', + ], + }, + }, + options: [ + { + name: 'Send', + value: 'send', + description: 'Send text messages (SMS)', + }, + ], + default: 'send', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // sms:send + // ---------------------------------- + { + displayName: 'From', + name: 'originator', + type: 'string', + default: '', + placeholder: '14155238886', + required: true, + displayOptions: { + show: { + operation: [ + 'send', + ], + resource: [ + 'sms', + ], + }, + }, + description: 'The number from which to send the message.', + }, + { + displayName: 'To', + name: 'recipients', + type: 'string', + default: '', + placeholder: '14155238886/+14155238886', + required: true, + displayOptions: { + show: { + operation: [ + 'send', + ], + resource: [ + 'sms', + ], + }, + }, + description: 'All recipients separated by commas.', + }, + { + displayName: 'Message', + name: 'message', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'send', + ], + resource: [ + 'sms', + ], + }, + }, + description: 'The message to be send.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Fields', + default: {}, + options: [ + { + displayName: 'Created Date-time', + name: 'createdDatetime', + type: 'dateTime', + default: '', + description: 'The date and time of the creation of the message in RFC3339 format (Y-m-dTH:i:sP).', + }, + { + displayName: 'Datacoding', + name: 'datacoding', + type: 'options', + options: [ + { + name: 'Auto', + value: 'auto', + }, + { + name: 'Plain', + value: 'plain', + }, + { + name: 'Unicode', + value: 'unicode', + }, + ], + default: '', + description: 'Using unicode will limit the maximum number of characters to 70 instead of 160.', + }, + { + displayName: 'Gateway', + name: 'gateway', + type: 'number', + default: '', + description: 'The SMS route that is used to send the message.', + }, + { + displayName: 'Group IDs', + name: 'groupIds', + placeholder: '1,2', + type: 'string', + default: '', + description: 'Group IDs separated by commas, If provided recipients can be omitted.', + }, + { + displayName: 'Message Type', + name: 'mclass', + type: 'options', + placeholder: 'Permissible values from 0-3', + options: [ + { + name: 'Flash', + value: 1, + }, + { + name: 'Normal', + value: 0, + }, + ], + default: 1, + description: 'Indicated the message type. 1 is a normal message, 0 is a flash message.', + }, + { + displayName: 'Reference', + name: 'reference', + type: 'string', + default: '', + description: 'A client reference.', + }, + { + displayName: 'Report Url', + name: 'reportUrl', + type: 'string', + default: '', + description: 'The status report URL to be used on a per-message basis.
Reference is required for a status report webhook to be sent.', + }, + { + displayName: 'Scheduled Date-time', + name: 'scheduledDatetime', + type: 'dateTime', + default: '', + description: 'The scheduled date and time of the message in RFC3339 format (Y-m-dTH:i:sP).', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Binary', + value: 'binary', + }, + { + name: 'Flash', + value: 'flash', + }, + { + name: 'SMS', + value: 'sms', + }, + ], + default: '', + description: 'The type of message.
Values can be: sms, binary, or flash.', + }, + { + displayName: 'Type Details', + name: 'typeDetails', + type: 'string', + default: '', + description: 'A hash with extra information.
Is only used when a binary message is sent.', + }, + { + displayName: 'Validity', + name: 'validity', + type: 'number', + default: 1, + typeOptions: { + minValue: 1, + }, + description: 'The amount of seconds that the message is valid.', + }, + ], + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + + let operation: string; + let resource: string; + + // For POST + let bodyRequest: IDataObject; + // For Query string + let qs: IDataObject; + + let requestMethod; + + for (let i = 0; i < items.length; i++) { + qs = {}; + + resource = this.getNodeParameter('resource', i) as string; + operation = this.getNodeParameter('operation', i) as string; + + if (resource === 'sms') { + //https://developers.messagebird.com/api/sms-messaging/#sms-api + if (operation === 'send') { + // ---------------------------------- + // sms:send + // ---------------------------------- + + requestMethod = 'POST'; + const originator = this.getNodeParameter('originator', i) as string; + const body = this.getNodeParameter('message', i) as string; + + bodyRequest = { + recipients: [], + originator, + body + }; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + + if (additionalFields.groupIds) { + bodyRequest.groupIds = additionalFields.groupIds as string; + } + if (additionalFields.type) { + bodyRequest.type = additionalFields.type as string; + } + if (additionalFields.reference) { + bodyRequest.reference = additionalFields.reference as string; + } + if (additionalFields.reportUrl) { + bodyRequest.reportUrl = additionalFields.reportUrl as string; + } + if (additionalFields.validity) { + bodyRequest.validity = additionalFields.reportUrl as number; + } + if (additionalFields.gateway) { + bodyRequest.gateway = additionalFields.gateway as string; + } + if (additionalFields.typeDetails) { + bodyRequest.typeDetails = additionalFields.typeDetails as string; + } + if (additionalFields.datacoding) { + bodyRequest.datacoding = additionalFields.datacoding as string; + } + if (additionalFields.mclass) { + bodyRequest.mclass = additionalFields.mclass as number; + } + if (additionalFields.scheduledDatetime) { + bodyRequest.scheduledDatetime = additionalFields.scheduledDatetime as string; + } + if (additionalFields.createdDatetime) { + bodyRequest.createdDatetime = additionalFields.createdDatetime as string; + } + + const receivers = this.getNodeParameter('recipients', i) as string; + + bodyRequest.recipients = receivers.split(',').map(item => { + return parseInt(item, 10); + }); + } else { + throw new Error(`The operation "${operation}" is not known!`); + } + } else { + throw new Error(`The resource "${resource}" is not known!`); + } + + const responseData = await messageBirdApiRequest.call( + this, + requestMethod, + '/messages', + bodyRequest, + qs + ); + + returnData.push(responseData as IDataObject); + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/MessageBird/messagebird.png b/packages/nodes-base/nodes/MessageBird/messagebird.png new file mode 100644 index 0000000000000000000000000000000000000000..006b762950d6604ab0e58a3ebf90e984f04907f6 GIT binary patch literal 1305 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw1|+Ti+$;i8Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xg+bI6B3hk`{3u+q^6EQYb$Fqb0`Z}TW&b_ z-}^zkqPOjSoVabPclqS!dv2C(uD$;xw(Q>QzqM&~pR4cBRJYJmeeF@1E!)8ShQay( z-v%)C+MTap-D$>irE9Gdwtt&E^F~OQ4%<4-R^B*+t+V@d4m{vFV|PYphuMeSz6BZV zI!Dg3d@^9G{r=#y+z-Pktq1y_Hm^UBV~`uYa2pR_*`cd~Mn;z-!?$b;KXCBv%-8YN zZM*-o^>ZglY3epIiN9If%Im6qd;Thh?b;h-R8F#9dM1*|JbxG0@^vr%7oJwj;9Zg@ zJ=2?KNy{stibCNK(Zg#!e`vA<3vRg_D_!w_@`WeuGu)4ockQ1YS7^3>3%e1zH0^7I z`J9~Q>DwI^+cU0F+$*a1@Y#~e8}|}3{)^sWvdd_Tz543=^FJT1#;wq465jjaEyLxzEI2Nx`2e|Nhi>6UA@9h-Qwmce%K z$TJ~@9u6EC>zZ_zuKfM#C;PrVJa+st)sCVo-LHOB|I&Z6c`NIl71yLJZt=9fyRt88 zQP9q1B3fMG+s?Z_tk7>sd+_*qV*K4x)yEDMq$L!7xh52G;Cu6h2jMTH7A2Y{2T9Ig z_CI`^H>*G{^uFy5vpo50b_Q8(cE4TMzWhGxiuU2hoBq{!Hm*A0wB-A<=kE?MJMY%G zeSqPj#hiT$eGa~3*dr1!@5Xb_zQ9FYceZ_ck`?>P^Zxe;2|b=!9YLNFf9>Xc*sJ8f z_9avJr(J!EeEc!EdYbA>n4Lk*jUt-nsCK2}_KiCcQ_ zwcR%5$KNb$hpVD{F0v+cN64?YdVdmE zSm~eo!`Kkg_J1y0^#<<37d!Yie@;7W%&oI3OqSP5I||Jl(eT z+b+pn{PRl=S4cd59`&qYY1LJkJfHo_n-)#9toW}~(t74_)5=9#ZKisE*}c<{(S7#< zW9F4PA`u)F+3I_OR)7CfAz_v8_&fDS?UXk*9r2S|WThW|t$Nz_NUSDWXWctqztxw| zZ{EJ}{kmI4X}^lQ1+ybxwlZPW)%M51ZrULboFyt I=akR{0A)r^>i_@% literal 0 HcmV?d00001 diff --git a/packages/nodes-base/nodes/Microsoft/Sql/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/Sql/GenericFunctions.ts new file mode 100644 index 0000000000..601d3b5393 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Sql/GenericFunctions.ts @@ -0,0 +1,144 @@ +import { IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { ITables } from './TableInterface'; + +/** + * Returns a copy of the item which only contains the json data and + * of that only the defined properties + * + * @param {INodeExecutionData} item The item to copy + * @param {string[]} properties The properties it should include + * @returns + */ +export function copyInputItem( + item: INodeExecutionData, + properties: string[], +): IDataObject { + // Prepare the data to insert and copy it to be returned + const newItem: IDataObject = {}; + for (const property of properties) { + if (item.json[property] === undefined) { + newItem[property] = null; + } else { + newItem[property] = JSON.parse(JSON.stringify(item.json[property])); + } + } + return newItem; +} + +/** + * Creates an ITables with the columns for the operations + * + * @param {INodeExecutionData[]} items The items to extract the tables/columns for + * @param {function} getNodeParam getter for the Node's Parameters + * @returns {ITables} {tableName: {colNames: [items]}}; + */ +export function createTableStruct( + getNodeParam: Function, + items: INodeExecutionData[], + additionalProperties: string[] = [], + keyName?: string, +): ITables { + return items.reduce((tables, item, index) => { + const table = getNodeParam('table', index) as string; + const columnString = getNodeParam('columns', index) as string; + const columns = columnString.split(',').map(column => column.trim()); + const itemCopy = copyInputItem(item, columns.concat(additionalProperties)); + const keyParam = keyName + ? (getNodeParam(keyName, index) as string) + : undefined; + if (tables[table] === undefined) { + tables[table] = {}; + } + if (tables[table][columnString] === undefined) { + tables[table][columnString] = []; + } + if (keyName) { + itemCopy[keyName] = keyParam; + } + tables[table][columnString].push(itemCopy); + return tables; + }, {} as ITables); +} + +/** + * Executes a queue of queries on given ITables. + * + * @param {ITables} tables The ITables to be processed. + * @param {function} buildQueryQueue function that builds the queue of promises + * @returns {Promise} + */ +export function executeQueryQueue( + tables: ITables, + buildQueryQueue: Function, +): Promise { // tslint:disable-line:no-any + return Promise.all( + Object.keys(tables).map(table => { + const columnsResults = Object.keys(tables[table]).map(columnString => { + return Promise.all( + buildQueryQueue({ + table, + columnString, + items: tables[table][columnString], + }), + ); + }); + return Promise.all(columnsResults); + }), + ); +} + +/** + * Extracts the values from the item for INSERT + * + * @param {IDataObject} item The item to extract + * @returns {string} (Val1, Val2, ...) + */ +export function extractValues(item: IDataObject): string { + return `(${Object.values(item as any) // tslint:disable-line:no-any + .map(val => (typeof val === 'string' ? `'${val}'` : val)) // maybe other types such as dates have to be handled as well + .join(',')})`; +} + +/** + * Extracts the SET from the item for UPDATE + * + * @param {IDataObject} item The item to extract from + * @param {string[]} columns The columns to update + * @returns {string} col1 = val1, col2 = val2 + */ +export function extractUpdateSet(item: IDataObject, columns: string[]): string { + return columns + .map( + column => + `${column} = ${ + typeof item[column] === 'string' ? `'${item[column]}'` : item[column] + }`, + ) + .join(','); +} + +/** + * Extracts the WHERE condition from the item for UPDATE + * + * @param {IDataObject} item The item to extract from + * @param {string} key The column name to build the condition with + * @returns {string} id = '123' + */ +export function extractUpdateCondition(item: IDataObject, key: string): string { + return `${key} = ${ + typeof item[key] === 'string' ? `'${item[key]}'` : item[key] + }`; +} + +/** + * Extracts the WHERE condition from the items for DELETE + * + * @param {IDataObject[]} items The items to extract the values from + * @param {string} key The column name to extract the value from for the delete condition + * @returns {string} (Val1, Val2, ...) + */ +export function extractDeleteValues(items: IDataObject[], key: string): string { + return `(${items + .map(item => (typeof item[key] === 'string' ? `'${item[key]}'` : item[key])) + .join(',')})`; +} diff --git a/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts b/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts new file mode 100644 index 0000000000..a13a2425ca --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts @@ -0,0 +1,394 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { chunk, flatten } from '../../utils/utilities'; + +import * as mssql from 'mssql'; + +import { ITables } from './TableInterface'; + +import { + copyInputItem, + createTableStruct, + executeQueryQueue, + extractDeleteValues, + extractUpdateCondition, + extractUpdateSet, + extractValues, +} from './GenericFunctions'; + +export class MicrosoftSql implements INodeType { + description: INodeTypeDescription = { + displayName: 'Microsoft SQL', + name: 'microsoftSql', + icon: 'file:mssql.png', + group: ['input'], + version: 1, + description: 'Gets, add and update data in Microsoft SQL.', + defaults: { + name: 'Microsoft SQL', + color: '#1d4bab', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'microsoftSql', + required: true, + }, + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Execute Query', + value: 'executeQuery', + description: 'Executes a SQL query.', + }, + { + name: 'Insert', + value: 'insert', + description: 'Insert rows in database.', + }, + { + name: 'Update', + value: 'update', + description: 'Updates rows in database.', + }, + { + name: 'Delete', + value: 'delete', + description: 'Deletes rows in database.', + }, + ], + default: 'insert', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // executeQuery + // ---------------------------------- + { + displayName: 'Query', + name: 'query', + type: 'string', + typeOptions: { + rows: 5, + }, + displayOptions: { + show: { + operation: ['executeQuery'], + }, + }, + default: '', + placeholder: 'SELECT id, name FROM product WHERE id < 40', + required: true, + description: 'The SQL query to execute.', + }, + + // ---------------------------------- + // insert + // ---------------------------------- + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to insert data to.', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '', + placeholder: 'id,name,description', + description: + 'Comma separated list of the properties which should used as columns for the new rows.', + }, + + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to update data in', + }, + { + displayName: 'Update Key', + name: 'updateKey', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: 'id', + required: true, + description: + 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: '', + placeholder: 'name,description', + description: + 'Comma separated list of the properties which should used as columns for rows to update.', + }, + + // ---------------------------------- + // delete + // ---------------------------------- + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['delete'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to delete data.', + }, + { + displayName: 'Delete Key', + name: 'deleteKey', + type: 'string', + displayOptions: { + show: { + operation: ['delete'], + }, + }, + default: 'id', + required: true, + description: + 'Name of the property which decides which rows in the database should be deleted. Normally that would be "id".', + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const credentials = this.getCredentials('microsoftSql'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const config = { + server: credentials.server as string, + port: credentials.port as number, + database: credentials.database as string, + user: credentials.user as string, + password: credentials.password as string, + domain: credentials.domain ? (credentials.domain as string) : undefined, + }; + + const pool = new mssql.ConnectionPool(config); + await pool.connect(); + + let returnItems = []; + + const items = this.getInputData(); + const operation = this.getNodeParameter('operation', 0) as string; + + try { + if (operation === 'executeQuery') { + // ---------------------------------- + // executeQuery + // ---------------------------------- + + const rawQuery = this.getNodeParameter('query', 0) as string; + + const queryResult = await pool.request().query(rawQuery); + + const result = + queryResult.recordsets.length > 1 + ? flatten(queryResult.recordsets) + : queryResult.recordsets[0]; + + returnItems = this.helpers.returnJsonArray(result as IDataObject[]); + } else if (operation === 'insert') { + // ---------------------------------- + // insert + // ---------------------------------- + + const tables = createTableStruct(this.getNodeParameter, items); + await executeQueryQueue( + tables, + ({ + table, + columnString, + items, + }: { + table: string; + columnString: string; + items: IDataObject[]; + }): Array> => { + return chunk(items, 1000).map(insertValues => { + const values = insertValues + .map((item: IDataObject) => extractValues(item)) + .join(','); + + return pool + .request() + .query( + `INSERT INTO ${table}(${columnString}) VALUES ${values};`, + ); + }); + }, + ); + + returnItems = items; + } else if (operation === 'update') { + // ---------------------------------- + // update + // ---------------------------------- + + const updateKeys = items.map( + (item, index) => this.getNodeParameter('updateKey', index) as string, + ); + const tables = createTableStruct( + this.getNodeParameter, + items, + ['updateKey'].concat(updateKeys), + 'updateKey', + ); + await executeQueryQueue( + tables, + ({ + table, + columnString, + items, + }: { + table: string; + columnString: string; + items: IDataObject[]; + }): Array> => { + return items.map(item => { + const columns = columnString + .split(',') + .map(column => column.trim()); + + const setValues = extractUpdateSet(item, columns); + const condition = extractUpdateCondition( + item, + item.updateKey as string, + ); + + return pool + .request() + .query(`UPDATE ${table} SET ${setValues} WHERE ${condition};`); + }); + }, + ); + + returnItems = items; + } else if (operation === 'delete') { + // ---------------------------------- + // delete + // ---------------------------------- + + const tables = items.reduce((tables, item, index) => { + const table = this.getNodeParameter('table', index) as string; + const deleteKey = this.getNodeParameter('deleteKey', index) as string; + if (tables[table] === undefined) { + tables[table] = {}; + } + if (tables[table][deleteKey] === undefined) { + tables[table][deleteKey] = []; + } + tables[table][deleteKey].push(item); + return tables; + }, {} as ITables); + + const queriesResults = await Promise.all( + Object.keys(tables).map(table => { + const deleteKeyResults = Object.keys(tables[table]).map( + deleteKey => { + const deleteItemsList = chunk( + tables[table][deleteKey].map(item => + copyInputItem(item as INodeExecutionData, [deleteKey]), + ), + 1000, + ); + const queryQueue = deleteItemsList.map(deleteValues => { + return pool + .request() + .query( + `DELETE FROM ${table} WHERE ${deleteKey} IN ${extractDeleteValues( + deleteValues, + deleteKey, + )};`, + ); + }); + return Promise.all(queryQueue); + }, + ); + return Promise.all(deleteKeyResults); + }), + ); + + const rowsDeleted = flatten(queriesResults).reduce( + (acc: number, resp: mssql.IResult): number => + (acc += resp.rowsAffected.reduce((sum, val) => (sum += val))), + 0, + ); + + returnItems = this.helpers.returnJsonArray({ + rowsDeleted, + } as IDataObject); + } else { + await pool.close(); + throw new Error(`The operation "${operation}" is not supported!`); + } + } catch (err) { + if (this.continueOnFail() === true) { + returnItems = items; + } else { + await pool.close(); + throw err; + } + } + + // Close the connection + await pool.close(); + + return this.prepareOutputData(returnItems); + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Sql/TableInterface.ts b/packages/nodes-base/nodes/Microsoft/Sql/TableInterface.ts new file mode 100644 index 0000000000..c260c36ab3 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Sql/TableInterface.ts @@ -0,0 +1,7 @@ +import { IDataObject } from 'n8n-workflow'; + +export interface ITables { + [key: string]: { + [key: string]: IDataObject[]; + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/Sql/mssql.png b/packages/nodes-base/nodes/Microsoft/Sql/mssql.png new file mode 100644 index 0000000000000000000000000000000000000000..18349dc1cc58adfd3e724c264f96d2e3546b4474 GIT binary patch literal 3447 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 SQGp*wkb$m=PRUKXu>S$oUE5&* literal 0 HcmV?d00001 diff --git a/packages/nodes-base/nodes/MondayCom/BoardItemDescription.ts b/packages/nodes-base/nodes/MondayCom/BoardItemDescription.ts index c6725279ed..9537c32521 100644 --- a/packages/nodes-base/nodes/MondayCom/BoardItemDescription.ts +++ b/packages/nodes-base/nodes/MondayCom/BoardItemDescription.ts @@ -15,6 +15,21 @@ export const boardItemOperations = [ }, }, options: [ + { + name: 'Add Update', + value: 'addUpdate', + description: `Add an update to an item.`, + }, + { + name: 'Change Column Value', + value: 'changeColumnValue', + description: 'Change a column value for a board item', + }, + { + name: 'Change Multiple Column Values', + value: 'changeMultipleColumnValues', + description: 'Change multiple column values for a board item', + }, { name: 'Create', value: 'create', @@ -48,6 +63,192 @@ export const boardItemOperations = [ export const boardItemFields = [ +/* -------------------------------------------------------------------------- */ +/* boardItem:addUpdate */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Item ID', + name: 'itemId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'boardItem', + ], + operation: [ + 'addUpdate', + ], + }, + }, + description: 'The unique identifier of the item to add update to.', + }, + { + displayName: 'Update Text', + name: 'value', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'boardItem', + ], + operation: [ + 'addUpdate', + ], + }, + }, + description: 'The update text to add.', + }, +/* -------------------------------------------------------------------------- */ +/* boardItem:changeColumnValue */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Board ID', + name: 'boardId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getBoards', + }, + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'boardItem', + ], + operation: [ + 'changeColumnValue', + ], + }, + }, + description: 'The unique identifier of the board.', + }, + { + displayName: 'Item ID', + name: 'itemId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'boardItem', + ], + operation: [ + 'changeColumnValue', + ], + }, + }, + description: 'The unique identifier of the item to to change column of.', + }, + { + displayName: 'Column ID', + name: 'columnId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getColumns', + loadOptionsDependsOn: [ + 'boardId' + ], + }, + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'boardItem', + ], + operation: [ + 'changeColumnValue', + ], + }, + }, + description: `The column's unique identifier.`, + }, + { + displayName: 'Value', + name: 'value', + type: 'json', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'boardItem', + ], + operation: [ + 'changeColumnValue', + ], + }, + }, + description: 'The column value in JSON format. Documentation can be found here.', + }, +/* -------------------------------------------------------------------------- */ +/* boardItem:changeMultipleColumnValues */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Board ID', + name: 'boardId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getBoards', + }, + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'boardItem', + ], + operation: [ + 'changeMultipleColumnValues', + ], + }, + }, + description: 'The unique identifier of the board.', + }, + { + displayName: 'Item ID', + name: 'itemId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'boardItem', + ], + operation: [ + 'changeMultipleColumnValues', + ], + }, + }, + description: `Item's ID` + }, + { + displayName: 'Column Values', + name: 'columnValues', + type: 'json', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'boardItem', + ], + operation: [ + 'changeMultipleColumnValues', + ], + }, + }, + description: 'The column fields and values in JSON format. Documentation can be found here.', + typeOptions: { + alwaysOpenEditWindow: true, + }, + }, /* -------------------------------------------------------------------------- */ /* boardItem:create */ /* -------------------------------------------------------------------------- */ diff --git a/packages/nodes-base/nodes/MondayCom/MondayCom.node.ts b/packages/nodes-base/nodes/MondayCom/MondayCom.node.ts index f8e538e5cb..5dc7457081 100644 --- a/packages/nodes-base/nodes/MondayCom/MondayCom.node.ts +++ b/packages/nodes-base/nodes/MondayCom/MondayCom.node.ts @@ -455,6 +455,84 @@ export class MondayCom implements INodeType { } } if (resource === 'boardItem') { + if (operation === 'addUpdate') { + const itemId = parseInt((this.getNodeParameter('itemId', i) as string), 10); + const value = this.getNodeParameter('value', i) as string; + + const body: IGraphqlBody = { + query: + `mutation ($itemId: Int!, $value: String!) { + create_update (item_id: $itemId, body: $value) { + id + } + }`, + variables: { + itemId, + value, + }, + }; + + responseData = await mondayComApiRequest.call(this, body); + responseData = responseData.data.create_update; + } + if (operation === 'changeColumnValue') { + const boardId = parseInt(this.getNodeParameter('boardId', i) as string, 10); + const itemId = parseInt((this.getNodeParameter('itemId', i) as string), 10); + const columnId = this.getNodeParameter('columnId', i) as string; + const value = this.getNodeParameter('value', i) as string; + + const body: IGraphqlBody = { + query: + `mutation ($boardId: Int!, $itemId: Int!, $columnId: String!, $value: JSON!) { + change_column_value (board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { + id + } + }`, + variables: { + boardId, + itemId, + columnId, + }, + }; + + try { + JSON.parse(value); + } catch (e) { + throw new Error('Custom Values must be a valid JSON'); + } + body.variables.value = JSON.stringify(JSON.parse(value)); + + responseData = await mondayComApiRequest.call(this, body); + responseData = responseData.data.change_column_value; + } + if (operation === 'changeMultipleColumnValues') { + const boardId = parseInt(this.getNodeParameter('boardId', i) as string, 10); + const itemId = parseInt((this.getNodeParameter('itemId', i) as string), 10); + const columnValues = this.getNodeParameter('columnValues', i) as string; + + const body: IGraphqlBody = { + query: + `mutation ($boardId: Int!, $itemId: Int!, $columnValues: JSON!) { + change_multiple_column_values (board_id: $boardId, item_id: $itemId, column_values: $columnValues) { + id + } + }`, + variables: { + boardId, + itemId, + }, + }; + + try { + JSON.parse(columnValues); + } catch (e) { + throw new Error('Custom Values must be a valid JSON'); + } + body.variables.columnValues = JSON.stringify(JSON.parse(columnValues)); + + responseData = await mondayComApiRequest.call(this, body); + responseData = responseData.data.change_multiple_column_values; + } if (operation === 'create') { const boardId = parseInt(this.getNodeParameter('boardId', i) as string, 10); const groupId = this.getNodeParameter('groupId', i) as string; diff --git a/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts b/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts index dbd7ab2929..d91a287b88 100644 --- a/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts +++ b/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts @@ -32,7 +32,18 @@ export class MongoDb implements INodeType { const items = this.getInputData(); const operation = this.getNodeParameter('operation', 0) as string; - if (operation === 'find') { + if (operation === 'delete') { + // ---------------------------------- + // delete + // ---------------------------------- + + const { deletedCount } = await mdb + .collection(this.getNodeParameter('collection', 0) as string) + .deleteMany(JSON.parse(this.getNodeParameter('query', 0) as string)); + + returnItems = this.helpers.returnJsonArray([{ deletedCount }]); + + } else if (operation === 'find') { // ---------------------------------- // find // ---------------------------------- diff --git a/packages/nodes-base/nodes/MongoDb/mongo.node.options.ts b/packages/nodes-base/nodes/MongoDb/mongo.node.options.ts index 190ff3de88..953a45a620 100644 --- a/packages/nodes-base/nodes/MongoDb/mongo.node.options.ts +++ b/packages/nodes-base/nodes/MongoDb/mongo.node.options.ts @@ -28,6 +28,11 @@ export const nodeDescription: INodeTypeDescription = { name: 'operation', type: 'options', options: [ + { + name: 'Delete', + value: 'delete', + description: 'Delete documents.' + }, { name: 'Find', value: 'find', @@ -57,13 +62,36 @@ export const nodeDescription: INodeTypeDescription = { description: 'MongoDB Collection' }, + // ---------------------------------- + // delete + // ---------------------------------- + { + displayName: 'Delete Query (JSON format)', + name: 'query', + type: 'json', + typeOptions: { + rows: 5 + }, + displayOptions: { + show: { + operation: [ + 'delete' + ], + }, + }, + default: '{}', + placeholder: `{ "birth": { "$gt": "1950-01-01" } }`, + required: true, + description: 'MongoDB Delete query.' + }, + // ---------------------------------- // find // ---------------------------------- { displayName: 'Query (JSON format)', name: 'query', - type: 'string', + type: 'json', typeOptions: { rows: 5 }, diff --git a/packages/nodes-base/nodes/Msg91/Msg91.node.ts b/packages/nodes-base/nodes/Msg91/Msg91.node.ts index 240a3623e0..8fb0444de5 100644 --- a/packages/nodes-base/nodes/Msg91/Msg91.node.ts +++ b/packages/nodes-base/nodes/Msg91/Msg91.node.ts @@ -68,7 +68,7 @@ export class Msg91 implements INodeType { description: 'The operation to perform.', }, { - displayName: 'From', + displayName: 'Sender ID', name: 'from', type: 'string', default: '', diff --git a/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts b/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts new file mode 100644 index 0000000000..2198cb5513 --- /dev/null +++ b/packages/nodes-base/nodes/NextCloud/GenericFunctions.ts @@ -0,0 +1,63 @@ +import { + IExecuteFunctions, + IHookFunctions, +} from 'n8n-core'; + +import { + OptionsWithUri, +} from 'request'; + +/** + * Make an API request to NextCloud + * + * @param {IHookFunctions} this + * @param {string} method + * @param {string} url + * @param {object} body + * @returns {Promise} + */ +export async function nextCloudApiRequest(this: IHookFunctions | IExecuteFunctions, method: string, endpoint: string, body: object | string | Buffer, headers?: object, encoding?: null | undefined, query?: object): Promise { // tslint:disable-line:no-any + const options : OptionsWithUri = { + headers, + method, + body, + qs: {}, + uri: '', + json: false, + }; + + if (encoding === null) { + options.encoding = null; + } + + const authenticationMethod = this.getNodeParameter('authentication', 0); + + try { + if (authenticationMethod === 'accessToken') { + const credentials = this.getCredentials('nextCloudApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.auth = { + user: credentials.user as string, + pass: credentials.password as string, + }; + + options.uri = `${credentials.webDavUrl}/${encodeURI(endpoint)}`; + + return await this.helpers.request(options); + } else { + const credentials = this.getCredentials('nextCloudOAuth2Api'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.uri = `${credentials.webDavUrl}/${encodeURI(endpoint)}`; + + return await this.helpers.requestOAuth2!.call(this, 'nextCloudOAuth2Api', options); + } + } catch (error) { + throw new Error(`NextCloud Error. Status Code: ${error.statusCode}. Message: ${error.message}`); + } +} diff --git a/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts b/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts index 309f53ceae..a5b3109c6e 100644 --- a/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts +++ b/packages/nodes-base/nodes/NextCloud/NextCloud.node.ts @@ -2,6 +2,7 @@ import { BINARY_ENCODING, IExecuteFunctions, } from 'n8n-core'; + import { IDataObject, INodeTypeDescription, @@ -9,9 +10,13 @@ import { INodeType, } from 'n8n-workflow'; -import { parseString } from 'xml2js'; -import { OptionsWithUri } from 'request'; +import { + parseString, +} from 'xml2js'; +import { + nextCloudApiRequest, +} from './GenericFunctions'; export class NextCloud implements INodeType { description: INodeTypeDescription = { @@ -24,7 +29,7 @@ export class NextCloud implements INodeType { description: 'Access data on NextCloud', defaults: { name: 'NextCloud', - color: '#22BB44', + color: '#1cafff', }, inputs: ['main'], outputs: ['main'], @@ -32,9 +37,44 @@ export class NextCloud implements INodeType { { name: 'nextCloudApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'nextCloudOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Resource', name: 'resource', @@ -446,7 +486,14 @@ export class NextCloud implements INodeType { const items = this.getInputData().slice(); const returnData: IDataObject[] = []; - const credentials = this.getCredentials('nextCloudApi'); + const authenticationMethod = this.getNodeParameter('authentication', 0); + let credentials; + + if (authenticationMethod === 'accessToken') { + credentials = this.getCredentials('nextCloudApi'); + } else { + credentials = this.getCredentials('nextCloudOAuth2Api'); + } if (credentials === undefined) { throw new Error('No credentials got returned!'); @@ -562,26 +609,14 @@ export class NextCloud implements INodeType { webDavUrl = webDavUrl.slice(0, -1); } - const options: OptionsWithUri = { - auth: { - user: credentials.user as string, - pass: credentials.password as string, - }, - headers, - method: requestMethod, - body, - qs: {}, - uri: `${credentials.webDavUrl}/${encodeURI(endpoint)}`, - json: false, - }; - + let encoding = undefined; if (resource === 'file' && operation === 'download') { // Return the data as a buffer - options.encoding = null; + encoding = null; } try { - responseData = await this.helpers.request(options); + responseData = await nextCloudApiRequest.call(this, requestMethod, endpoint, body, headers, encoding); } catch (error) { if (this.continueOnFail() === true) { returnData.push({ error }); diff --git a/packages/nodes-base/nodes/OpenWeatherMap.node.ts b/packages/nodes-base/nodes/OpenWeatherMap.node.ts index e12e384094..38e883e53f 100644 --- a/packages/nodes-base/nodes/OpenWeatherMap.node.ts +++ b/packages/nodes-base/nodes/OpenWeatherMap.node.ts @@ -213,20 +213,20 @@ export class OpenWeatherMap implements INodeType { // Set base data qs = { APPID: credentials.accessToken, - units: this.getNodeParameter('format', 0) as string + units: this.getNodeParameter('format', i) as string }; // Get the location - locationSelection = this.getNodeParameter('locationSelection', 0) as string; + locationSelection = this.getNodeParameter('locationSelection', i) as string; if (locationSelection === 'cityName') { - qs.q = this.getNodeParameter('cityName', 0) as string; + qs.q = this.getNodeParameter('cityName', i) as string; } else if (locationSelection === 'cityId') { - qs.id = this.getNodeParameter('cityId', 0) as number; + qs.id = this.getNodeParameter('cityId', i) as number; } else if (locationSelection === 'coordinates') { - qs.lat = this.getNodeParameter('latitude', 0) as string; - qs.lon = this.getNodeParameter('longitude', 0) as string; + qs.lat = this.getNodeParameter('latitude', i) as string; + qs.lon = this.getNodeParameter('longitude', i) as string; } else if (locationSelection === 'zipCode') { - qs.zip = this.getNodeParameter('zipCode', 0) as string; + qs.zip = this.getNodeParameter('zipCode', i) as string; } else { throw new Error(`The locationSelection "${locationSelection}" is not known!`); } diff --git a/packages/nodes-base/nodes/PagerDuty/GenericFunctions.ts b/packages/nodes-base/nodes/PagerDuty/GenericFunctions.ts index c360114144..f4832b923c 100644 --- a/packages/nodes-base/nodes/PagerDuty/GenericFunctions.ts +++ b/packages/nodes-base/nodes/PagerDuty/GenericFunctions.ts @@ -19,16 +19,11 @@ import { export async function pagerDutyApiRequest(this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}, uri?: string, headers: IDataObject = {}): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('pagerDutyApi'); - - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } + const authenticationMethod = this.getNodeParameter('authentication', 0); const options: OptionsWithUri = { headers: { - Accept: 'application/vnd.pagerduty+json;version=2', - Authorization: `Token token=${credentials.apiToken}`, + Accept: 'application/vnd.pagerduty+json;version=2' }, method, body, @@ -39,15 +34,30 @@ export async function pagerDutyApiRequest(this: IExecuteFunctions | IWebhookFunc arrayFormat: 'brackets', }, }; + if (!Object.keys(body).length) { delete options.form; } if (!Object.keys(query).length) { delete options.qs; } + options.headers = Object.assign({}, options.headers, headers); + try { - return await this.helpers.request!(options); + if (authenticationMethod === 'apiToken') { + const credentials = this.getCredentials('pagerDutyApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.headers!['Authorization'] = `Token token=${credentials.apiToken}`; + + return await this.helpers.request!(options); + } else { + return await this.helpers.requestOAuth2!.call(this, 'pagerDutyOAuth2Api', options); + } } catch (error) { if (error.response && error.response.body && error.response.body.error && error.response.body.error.errors) { // Try to return the error prettier diff --git a/packages/nodes-base/nodes/PagerDuty/PagerDuty.node.ts b/packages/nodes-base/nodes/PagerDuty/PagerDuty.node.ts index d53e5921b8..8d62b8fc6f 100644 --- a/packages/nodes-base/nodes/PagerDuty/PagerDuty.node.ts +++ b/packages/nodes-base/nodes/PagerDuty/PagerDuty.node.ts @@ -66,9 +66,43 @@ export class PagerDuty implements INodeType { { name: 'pagerDutyApi', required: true, + displayOptions: { + show: { + authentication: [ + 'apiToken', + ], + }, + }, + }, + { + name: 'pagerDutyOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'API Token', + value: 'apiToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'apiToken', + }, { displayName: 'Resource', name: 'resource', diff --git a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts index 59ba514acf..23ef841bee 100644 --- a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts @@ -5,10 +5,16 @@ import { import { IDataObject, + ILoadOptionsFunctions, } from 'n8n-workflow'; -import { OptionsWithUri } from 'request'; +import { + OptionsWithUri, +} from 'request'; +<<<<<<< HEAD +======= +>>>>>>> master export interface ICustomInterface { name: string; @@ -32,10 +38,27 @@ export interface ICustomProperties { * @param {object} body * @returns {Promise} */ +<<<<<<< HEAD export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctions, method: string, endpoint: string, body: IDataObject, query?: IDataObject, formData?: IDataObject, downloadFile?: boolean): Promise { // tslint:disable-line:no-any const authenticationMethod = this.getNodeParameter('authentication', 0); +======= +export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: IDataObject, query?: IDataObject, formData?: IDataObject, downloadFile?: boolean): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('pipedriveApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + if (query === undefined) { + query = {}; + } + + query.api_token = credentials.apiToken; +>>>>>>> master const options: OptionsWithUri = { + headers: { + Accept: 'application/json', + }, method, qs: query, uri: `https://api.pipedrive.com/v1${endpoint}`, @@ -62,7 +85,8 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio let responseData; try { - if (authenticationMethod === 'basicAuth') { +<<<<<<< HEAD + if (authenticationMethod === 'basicAuth' || authenticationMethod === 'apiToken') { const credentials = this.getCredentials('pipedriveApi'); if (credentials === undefined) { @@ -76,6 +100,10 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio } else { responseData = await this.helpers.requestOAuth2!.call(this, 'pipedriveOAuth2Api', options); } +======= + //@ts-ignore + const responseData = await this.helpers.request(options); +>>>>>>> master if (downloadFile === true) { return { @@ -99,7 +127,7 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio if (error.response && error.response.body && error.response.body.error) { // Try to return the error prettier - let errorMessage = `Pipedrive error response [${error.statusCode}]: ${error.response.body.error}`; + let errorMessage = `Pipedrive error response [${error.statusCode}]: ${error.response.body.error.message}`; if (error.response.body.error_info) { errorMessage += ` - ${error.response.body.error_info}`; } @@ -128,7 +156,7 @@ export async function pipedriveApiRequestAllItems(this: IHookFunctions | IExecut if (query === undefined) { query = {}; } - query.limit = 500; + query.limit = 100; query.start = 0; const returnData: IDataObject[] = []; @@ -137,7 +165,12 @@ export async function pipedriveApiRequestAllItems(this: IHookFunctions | IExecut do { responseData = await pipedriveApiRequest.call(this, method, endpoint, body, query); - returnData.push.apply(returnData, responseData.data); + // the search path returns data diferently + if (responseData.data.items) { + returnData.push.apply(returnData, responseData.data.items); + } else { + returnData.push.apply(returnData, responseData.data); + } query.start = responseData.additionalData.pagination.next_start; } while ( diff --git a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts index 48a619a331..9a0da8d25f 100644 --- a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts @@ -1,12 +1,14 @@ import { BINARY_ENCODING, IExecuteFunctions, + ILoadOptionsFunctions, } from 'n8n-core'; import { IDataObject, INodeTypeDescription, INodeExecutionData, INodeType, + INodePropertyOptions, } from 'n8n-workflow'; import { @@ -23,7 +25,6 @@ interface CustomProperty { value: string; } - /** * Add the additional fields to the body * @@ -64,7 +65,7 @@ export class Pipedrive implements INodeType { displayOptions: { show: { authentication: [ - 'basicAuth', + 'apiToken', ], }, }, @@ -88,19 +89,15 @@ export class Pipedrive implements INodeType { type: 'options', options: [ { - name: 'Basic Auth', - value: 'basicAuth' + name: 'API Token', + value: 'apiToken' }, { name: 'OAuth2', value: 'oAuth2', }, - { - name: 'None', - value: 'none', - }, ], - default: 'basicAuth', + default: 'apiToken', description: 'Method of authentication.', }, { @@ -399,6 +396,11 @@ export class Pipedrive implements INodeType { value: 'getAll', description: 'Get data of all persons', }, + { + name: 'Search', + value: 'search', + description: 'Search all persons', + }, { name: 'Update', value: 'update', @@ -2058,6 +2060,7 @@ export class Pipedrive implements INodeType { show: { operation: [ 'getAll', + 'search', ], }, }, @@ -2072,6 +2075,7 @@ export class Pipedrive implements INodeType { show: { operation: [ 'getAll', + 'search', ], returnAll: [ false, @@ -2086,9 +2090,143 @@ export class Pipedrive implements INodeType { description: 'How many results to return.', }, + // ---------------------------------- + // person:getAll + // ---------------------------------- + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'person', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Filter ID', + name: 'filterId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getFilters', + }, + default: '', + description: 'ID of the filter to use.', + }, + { + displayName: 'First Char', + name: 'firstChar', + type: 'string', + default: '', + description: 'If supplied, only persons whose name starts with the specified letter will be returned ', + }, + ], + }, + + // ---------------------------------- + // person:search + // ---------------------------------- + { + displayName: 'Term', + name: 'term', + type: 'string', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'person', + ], + }, + }, + default: '', + description: 'The search term to look for. Minimum 2 characters (or 1 if using exact_match).', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'person', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Exact Match', + name: 'exactMatch', + type: 'boolean', + default: false, + description: 'When enabled, only full exact matches against the given term are returned. It is not case sensitive.', + }, + { + displayName: 'Fields', + name: 'fields', + type: 'string', + default: '', + description: 'A comma-separated string array. The fields to perform the search from. Defaults to all of them.', + }, + { + displayName: 'Include Fields', + name: 'includeFields', + type: 'string', + default: '', + description: 'Supports including optional fields in the results which are not provided by default.', + }, + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + default: '', + description: 'Will filter Deals by the provided Organization ID.', + }, + { + displayName: 'RAW Data', + name: 'rawData', + type: 'boolean', + default: false, + description: `Returns the data exactly in the way it got received from the API.`, + }, + ], + }, ], }; + methods = { + loadOptions: { + // Get all the filters to display them to user so that he can + // select them easily + async getFilters(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { data } = await pipedriveApiRequest.call(this, 'GET', '/filters', {}, { type: 'people' }); + for (const filter of data) { + const filterName = filter.name; + const filterId = filter.id; + returnData.push({ + name: filterName, + value: filterId, + }); + } + return returnData; + }, + } + }; + async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); @@ -2492,8 +2630,51 @@ export class Pipedrive implements INodeType { qs.limit = this.getNodeParameter('limit', i) as number; } + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.filterId) { + qs.filter_id = additionalFields.filterId as string; + } + + if (additionalFields.firstChar) { + qs.first_char = additionalFields.firstChar as string; + } + endpoint = `/persons`; + } else if (operation === 'search') { + // ---------------------------------- + // persons:search + // ---------------------------------- + + requestMethod = 'GET'; + + qs.term = this.getNodeParameter('term', i) as string; + returnAll = this.getNodeParameter('returnAll', i) as boolean; + if (returnAll === false) { + qs.limit = this.getNodeParameter('limit', i) as number; + } + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.fields) { + qs.fields = additionalFields.fields as string; + } + + if (additionalFields.exactMatch) { + qs.exact_match = additionalFields.exactMatch as boolean; + } + + if (additionalFields.organizationId) { + qs.organization_id = parseInt(additionalFields.organizationId as string, 10); + } + + if (additionalFields.includeFields) { + qs.include_fields = additionalFields.includeFields as string; + } + + endpoint = `/persons/search`; + } else if (operation === 'update') { // ---------------------------------- // person:update @@ -2530,7 +2711,9 @@ export class Pipedrive implements INodeType { let responseData; if (returnAll === true) { + responseData = await pipedriveApiRequestAllItems.call(this, requestMethod, endpoint, body, qs); + } else { if (customProperties !== undefined) { @@ -2538,6 +2721,13 @@ export class Pipedrive implements INodeType { } responseData = await pipedriveApiRequest.call(this, requestMethod, endpoint, body, qs, formData, downloadFile); + +<<<<<<< HEAD + if (responseData.data === null) { + responseData.data = []; + } +======= +>>>>>>> master } if (resource === 'file' && operation === 'download') { @@ -2559,6 +2749,24 @@ export class Pipedrive implements INodeType { items[i].binary![binaryPropertyName] = await this.helpers.prepareBinaryData(responseData.data); } else { + + if (responseData.data === null) { + responseData.data = []; + } + + if (operation === 'search' && responseData.data && responseData.data.items) { + responseData.data = responseData.data.items; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + if (additionalFields.rawData !== true) { + responseData.data = responseData.data.map((item: { result_score: number, item: object }) => { + return { + result_score: item.result_score, + ...item.item, + }; + }); + } + } + if (Array.isArray(responseData.data)) { returnData.push.apply(returnData, responseData.data as IDataObject[]); } else { diff --git a/packages/nodes-base/nodes/Pipedrive/PipedriveTrigger.node.ts b/packages/nodes-base/nodes/Pipedrive/PipedriveTrigger.node.ts index ca4a1a7ec8..06aa9c9234 100644 --- a/packages/nodes-base/nodes/Pipedrive/PipedriveTrigger.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/PipedriveTrigger.node.ts @@ -14,8 +14,10 @@ import { } from './GenericFunctions'; import * as basicAuth from 'basic-auth'; -import { Response } from 'express'; +import { + Response, +} from 'express'; function authorizationError(resp: Response, realm: string, responseCode: number, message?: string) { if (message === undefined) { @@ -52,6 +54,10 @@ export class PipedriveTrigger implements INodeType { { name: 'pipedriveApi', required: true, + }, + { + name: 'httpBasicAuth', + required: true, displayOptions: { show: { authentication: [ @@ -60,18 +66,6 @@ export class PipedriveTrigger implements INodeType { }, }, }, - { - name: 'pipedriveOAuth2Api', - required: true, - displayOptions: { - show: { - authentication: [ - 'oAuth2', - ], - }, - }, - }, - ], ], webhooks: [ { @@ -91,17 +85,13 @@ export class PipedriveTrigger implements INodeType { name: 'Basic Auth', value: 'basicAuth' }, - { - name: 'OAuth2', - value: 'oAuth2', - }, { name: 'None', - value: 'none', + value: 'none' }, ], - default: 'basicAuth', - description: 'Method of authentication.', + default: 'none', + description: 'If authentication should be activated for the webhook (makes it more scure).', }, { displayName: 'Action', @@ -191,7 +181,6 @@ export class PipedriveTrigger implements INodeType { description: 'Type of object to receive notifications about.', }, ], - }; // @ts-ignore (because of request) @@ -288,8 +277,6 @@ export class PipedriveTrigger implements INodeType { }, }; - - async webhook(this: IWebhookFunctions): Promise { const req = this.getRequestObject(); const resp = this.getResponseObject(); diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts new file mode 100644 index 0000000000..4dae3a64c1 --- /dev/null +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts @@ -0,0 +1,129 @@ +import { IDataObject, INodeExecutionData } from 'n8n-workflow'; +import pgPromise = require('pg-promise'); +import pg = require('pg-promise/typescript/pg-subset'); + +/** + * Returns of copy of the items which only contains the json data and + * of that only the define properties + * + * @param {INodeExecutionData[]} items The items to copy + * @param {string[]} properties The properties it should include + * @returns + */ +function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataObject[] { + // Prepare the data to insert and copy it to be returned + let newItem: IDataObject; + return items.map(item => { + newItem = {}; + for (const property of properties) { + if (item.json[property] === undefined) { + newItem[property] = null; + } else { + newItem[property] = JSON.parse(JSON.stringify(item.json[property])); + } + } + return newItem; + }); +} + +/** + * Executes the given SQL query on the database. + * + * @param {Function} getNodeParam The getter for the Node's parameters + * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance + * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection + * @param {input[]} input The Node's input data + * @returns Promise> + */ +export function pgQuery( + getNodeParam: Function, + pgp: pgPromise.IMain<{}, pg.IClient>, + db: pgPromise.IDatabase<{}, pg.IClient>, + input: INodeExecutionData[], +): Promise { + const queries: string[] = []; + for (let i = 0; i < input.length; i++) { + queries.push(getNodeParam('query', i) as string); + } + + return db.any(pgp.helpers.concat(queries)); +} + +/** + * Inserts the given items into the database. + * + * @param {Function} getNodeParam The getter for the Node's parameters + * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance + * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection + * @param {INodeExecutionData[]} items The items to be inserted + * @returns Promise> + */ +export async function pgInsert( + getNodeParam: Function, + pgp: pgPromise.IMain<{}, pg.IClient>, + db: pgPromise.IDatabase<{}, pg.IClient>, + items: INodeExecutionData[], +): Promise { + const table = getNodeParam('table', 0) as string; + const schema = getNodeParam('schema', 0) as string; + let returnFields = (getNodeParam('returnFields', 0) as string).split(',') as string[]; + const columnString = getNodeParam('columns', 0) as string; + const columns = columnString.split(',').map(column => column.trim()); + + const cs = new pgp.helpers.ColumnSet(columns); + + const te = new pgp.helpers.TableName({ table, schema }); + + // Prepare the data to insert and copy it to be returned + const insertItems = getItemCopy(items, columns); + + // Generate the multi-row insert query and return the id of new row + returnFields = returnFields.map(value => value.trim()).filter(value => !!value); + const query = + pgp.helpers.insert(insertItems, cs, te) + + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); + + // Executing the query to insert the data + const insertData = await db.manyOrNone(query); + + return [insertData, insertItems]; +} + +/** + * Updates the given items in the database. + * + * @param {Function} getNodeParam The getter for the Node's parameters + * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance + * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection + * @param {INodeExecutionData[]} items The items to be updated + * @returns Promise> + */ +export async function pgUpdate( + getNodeParam: Function, + pgp: pgPromise.IMain<{}, pg.IClient>, + db: pgPromise.IDatabase<{}, pg.IClient>, + items: INodeExecutionData[], +): Promise { + const table = getNodeParam('table', 0) as string; + const updateKey = getNodeParam('updateKey', 0) as string; + const columnString = getNodeParam('columns', 0) as string; + + const columns = columnString.split(',').map(column => column.trim()); + + // Make sure that the updateKey does also get queried + if (!columns.includes(updateKey)) { + columns.unshift(updateKey); + } + + // Prepare the data to update and copy it to be returned + const updateItems = getItemCopy(items, columns); + + // Generate the multi-row update query + const query = + pgp.helpers.update(updateItems, columns, table) + ' WHERE v.' + updateKey + ' = t.' + updateKey; + + // Executing the query to update the data + await db.none(query); + + return updateItems; +} diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.ts index 2fa010576b..f92234eb06 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.ts @@ -3,36 +3,12 @@ import { IDataObject, INodeExecutionData, INodeType, - INodeTypeDescription, + INodeTypeDescription } from 'n8n-workflow'; import * as pgPromise from 'pg-promise'; - -/** - * Returns of copy of the items which only contains the json data and - * of that only the define properties - * - * @param {INodeExecutionData[]} items The items to copy - * @param {string[]} properties The properties it should include - * @returns - */ -function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataObject[] { - // Prepare the data to insert and copy it to be returned - let newItem: IDataObject; - return items.map((item) => { - newItem = {}; - for (const property of properties) { - if (item.json[property] === undefined) { - newItem[property] = null; - } else { - newItem[property] = JSON.parse(JSON.stringify(item.json[property])); - } - } - return newItem; - }); -} - +import { pgInsert, pgQuery, pgUpdate } from './Postgres.node.functions'; export class Postgres implements INodeType { description: INodeTypeDescription = { @@ -52,7 +28,7 @@ export class Postgres implements INodeType { { name: 'postgres', required: true, - } + }, ], properties: [ { @@ -63,17 +39,17 @@ export class Postgres implements INodeType { { name: 'Execute Query', value: 'executeQuery', - description: 'Executes a SQL query.', + description: 'Execute an SQL query', }, { name: 'Insert', value: 'insert', - description: 'Insert rows in database.', + description: 'Insert rows in database', }, { name: 'Update', value: 'update', - description: 'Updates rows in database.', + description: 'Update rows in database', }, ], default: 'insert', @@ -92,9 +68,7 @@ export class Postgres implements INodeType { }, displayOptions: { show: { - operation: [ - 'executeQuery' - ], + operation: ['executeQuery'], }, }, default: '', @@ -103,7 +77,6 @@ export class Postgres implements INodeType { description: 'The SQL query to execute.', }, - // ---------------------------------- // insert // ---------------------------------- @@ -113,9 +86,7 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: 'public', @@ -128,9 +99,7 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: '', @@ -143,14 +112,13 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: '', placeholder: 'id,name,description', - description: 'Comma separated list of the properties which should used as columns for the new rows.', + description: + 'Comma separated list of the properties which should used as columns for the new rows.', }, { displayName: 'Return Fields', @@ -158,16 +126,13 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: '*', description: 'Comma separated list of the fields that the operation will return', }, - // ---------------------------------- // update // ---------------------------------- @@ -177,9 +142,7 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'update' - ], + operation: ['update'], }, }, default: '', @@ -192,14 +155,13 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'update' - ], + operation: ['update'], }, }, default: 'id', required: true, - description: 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + description: + 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', }, { displayName: 'Columns', @@ -207,22 +169,18 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'update' - ], + operation: ['update'], }, }, default: '', placeholder: 'name,description', - description: 'Comma separated list of the properties which should used as columns for rows to update.', + description: + 'Comma separated list of the properties which should used as columns for rows to update.', }, - - ] + ], }; - async execute(this: IExecuteFunctions): Promise { - const credentials = this.getCredentials('postgres'); if (credentials === undefined) { @@ -238,7 +196,7 @@ export class Postgres implements INodeType { user: credentials.user as string, password: credentials.password as string, ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), - sslmode: credentials.ssl as string || 'disable', + sslmode: (credentials.ssl as string) || 'disable', }; const db = pgp(config); @@ -253,39 +211,15 @@ export class Postgres implements INodeType { // executeQuery // ---------------------------------- - const queries: string[] = []; - for (let i = 0; i < items.length; i++) { - queries.push(this.getNodeParameter('query', i) as string); - } - - const queryResult = await db.any(pgp.helpers.concat(queries)); + const queryResult = await pgQuery(this.getNodeParameter, pgp, db, items); returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); - } else if (operation === 'insert') { // ---------------------------------- // insert // ---------------------------------- - const table = this.getNodeParameter('table', 0) as string; - const schema = this.getNodeParameter('schema', 0) as string; - let returnFields = (this.getNodeParameter('returnFields', 0) as string).split(',') as string[]; - const columnString = this.getNodeParameter('columns', 0) as string; - const columns = columnString.split(',').map(column => column.trim()); - - const cs = new pgp.helpers.ColumnSet(columns); - - const te = new pgp.helpers.TableName({ table, schema }); - - // Prepare the data to insert and copy it to be returned - const insertItems = getItemCopy(items, columns); - - // Generate the multi-row insert query and return the id of new row - returnFields = returnFields.map(value => value.trim()).filter(value => !!value); - const query = pgp.helpers.insert(insertItems, cs, te) + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); - - // Executing the query to insert the data - const insertData = await db.manyOrNone(query); + const [insertData, insertItems] = await pgInsert(this.getNodeParameter, pgp, db, items); // Add the id to the data for (let i = 0; i < insertData.length; i++) { @@ -293,37 +227,17 @@ export class Postgres implements INodeType { json: { ...insertData[i], ...insertItems[i], - } + }, }); } - } else if (operation === 'update') { // ---------------------------------- // update // ---------------------------------- - const table = this.getNodeParameter('table', 0) as string; - const updateKey = this.getNodeParameter('updateKey', 0) as string; - const columnString = this.getNodeParameter('columns', 0) as string; - - const columns = columnString.split(',').map(column => column.trim()); - - // Make sure that the updateKey does also get queried - if (!columns.includes(updateKey)) { - columns.unshift(updateKey); - } - - // Prepare the data to update and copy it to be returned - const updateItems = getItemCopy(items, columns); - - // Generate the multi-row update query - const query = pgp.helpers.update(updateItems, columns, table) + ' WHERE v.' + updateKey + ' = t.' + updateKey; - - // Executing the query to update the data - await db.none(query); - - returnItems = this.helpers.returnJsonArray(updateItems as IDataObject[]); + const updateItems = await pgUpdate(this.getNodeParameter, pgp, db, items); + returnItems = this.helpers.returnJsonArray(updateItems); } else { await pgp.end(); throw new Error(`The operation "${operation}" is not supported!`); diff --git a/packages/nodes-base/nodes/Postmark/GenericFunctions.ts b/packages/nodes-base/nodes/Postmark/GenericFunctions.ts new file mode 100644 index 0000000000..df1e3a1f09 --- /dev/null +++ b/packages/nodes-base/nodes/Postmark/GenericFunctions.ts @@ -0,0 +1,93 @@ +import { + OptionsWithUri, + } from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + IHookFunctions, + IWebhookFunctions +} from 'n8n-workflow'; + + +export async function postmarkApiRequest(this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method : string, endpoint : string, body: any = {}, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('postmarkApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + let options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Postmark-Server-Token' : credentials.serverToken + }, + method, + body, + uri: 'https://api.postmarkapp.com' + endpoint, + json: true + }; + if (body === {}) { + delete options.body; + } + options = Object.assign({}, options, option); + + try { + return await this.helpers.request!(options); + } catch (error) { + throw new Error(`Postmark: ${error.statusCode} Message: ${error.message}`); + } +} + +// tslint:disable-next-line: no-any +export function convertTriggerObjectToStringArray (webhookObject : any) : string[] { + const triggers = webhookObject.Triggers; + const webhookEvents : string[] = []; + + // Translate Webhook trigger settings to string array + if (triggers.Open.Enabled) { + webhookEvents.push('open'); + } + if (triggers.Open.PostFirstOpenOnly) { + webhookEvents.push('firstOpen'); + } + if (triggers.Click.Enabled) { + webhookEvents.push('click'); + } + if (triggers.Delivery.Enabled) { + webhookEvents.push('delivery'); + } + if (triggers.Bounce.Enabled) { + webhookEvents.push('bounce'); + } + if (triggers.Bounce.IncludeContent) { + webhookEvents.push('includeContent'); + } + if (triggers.SpamComplaint.Enabled) { + webhookEvents.push('spamComplaint'); + } + if (triggers.SpamComplaint.IncludeContent) { + if (!webhookEvents.includes('IncludeContent')) { + webhookEvents.push('includeContent'); + } + } + if (triggers.SubscriptionChange.Enabled) { + webhookEvents.push('subscriptionChange'); + } + + return webhookEvents; +} + +export function eventExists (currentEvents : string[], webhookEvents: string[]) { + for (const currentEvent of currentEvents) { + if (!webhookEvents.includes(currentEvent)) { + return false; + } + } + return true; +} diff --git a/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts b/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts new file mode 100644 index 0000000000..4956d647b7 --- /dev/null +++ b/packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts @@ -0,0 +1,256 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + INodeTypeDescription, + INodeType, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + convertTriggerObjectToStringArray, + eventExists, + postmarkApiRequest +} from './GenericFunctions'; + +export class PostmarkTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Postmark Trigger', + name: 'postmarkTrigger', + icon: 'file:postmark.png', + group: ['trigger'], + version: 1, + description: 'Starts the workflow when Postmark events occur.', + defaults: { + name: 'Postmark Trigger', + color: '#fedd00', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'postmarkApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Events', + name: 'events', + type: 'multiOptions', + options: [ + { + name: 'Bounce', + value: 'bounce', + description: 'Trigger on bounce.', + }, + { + name: 'Click', + value: 'click', + description: 'Trigger on click.', + }, + { + name: 'Delivery', + value: 'delivery', + description: 'Trigger on delivery.', + }, + { + name: 'Open', + value: 'open', + description: 'Trigger webhook on open.', + }, + { + name: 'Spam Complaint', + value: 'spamComplaint', + description: 'Trigger on spam complaint.', + }, + { + name: 'Subscription Change', + value: 'subscriptionChange', + description: 'Trigger on subscription change.', + }, + ], + default: [], + required: true, + description: 'Webhook events that will be enabled for that endpoint.', + }, + { + displayName: 'First Open', + name: 'firstOpen', + description: 'Only fires on first open for event "Open".', + type: 'boolean', + default: false, + displayOptions: { + show: { + events: [ + 'open', + ], + }, + }, + }, + { + displayName: 'Include Content', + name: 'includeContent', + description: 'Includes message content for events "Bounce" and "Spam Complaint".', + type: 'boolean', + default: false, + displayOptions: { + show: { + events: [ + 'bounce', + 'spamComplaint', + ], + }, + }, + }, + ], + + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default'); + const events = this.getNodeParameter('events') as string[]; + if (this.getNodeParameter('includeContent') as boolean) { + events.push('includeContent'); + } + if (this.getNodeParameter('firstOpen') as boolean) { + events.push('firstOpen'); + } + + // Get all webhooks + const endpoint = `/webhooks`; + + const responseData = await postmarkApiRequest.call(this, 'GET', endpoint, {}); + + // No webhooks exist + if (responseData.Webhooks.length === 0) { + return false; + } + + // If webhooks exist, check if any match current settings + for (const webhook of responseData.Webhooks) { + if (webhook.Url === webhookUrl && eventExists(events, convertTriggerObjectToStringArray(webhook))) { + webhookData.webhookId = webhook.ID; + // webhook identical to current settings. re-assign webhook id to found webhook. + return true; + } + } + + return false; + }, + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + + const endpoint = `/webhooks`; + + // tslint:disable-next-line: no-any + const body : any = { + Url: webhookUrl, + Triggers: { + Open:{ + Enabled: false, + PostFirstOpenOnly: false + }, + Click:{ + Enabled: false + }, + Delivery:{ + Enabled: false + }, + Bounce:{ + Enabled: false, + IncludeContent: false + }, + SpamComplaint:{ + Enabled: false, + IncludeContent: false + }, + SubscriptionChange: { + Enabled: false + } + } + }; + + const events = this.getNodeParameter('events') as string[]; + + if (events.includes('open')) { + body.Triggers.Open.Enabled = true; + body.Triggers.Open.PostFirstOpenOnly = this.getNodeParameter('firstOpen') as boolean; + } + if (events.includes('click')) { + body.Triggers.Click.Enabled = true; + } + if (events.includes('delivery')) { + body.Triggers.Delivery.Enabled = true; + } + if (events.includes('bounce')) { + body.Triggers.Bounce.Enabled = true; + body.Triggers.Bounce.IncludeContent = this.getNodeParameter('includeContent') as boolean; + } + if (events.includes('spamComplaint')) { + body.Triggers.SpamComplaint.Enabled = true; + body.Triggers.SpamComplaint.IncludeContent = this.getNodeParameter('includeContent') as boolean; + } + if (events.includes('subscriptionChange')) { + body.Triggers.SubscriptionChange.Enabled = true; + } + + const responseData = await postmarkApiRequest.call(this, 'POST', endpoint, body); + + if (responseData.ID === undefined) { + // Required data is missing so was not successful + return false; + } + + const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = responseData.ID as string; + + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + + if (webhookData.webhookId !== undefined) { + const endpoint = `/webhooks/${webhookData.webhookId}`; + const body = {}; + + try { + await postmarkApiRequest.call(this, 'DELETE', endpoint, body); + } catch (e) { + return false; + } + + // Remove from the static workflow data so that it is clear + // that no webhooks are registred anymore + delete webhookData.webhookId; + delete webhookData.webhookEvents; + } + + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const req = this.getRequestObject(); + return { + workflowData: [ + this.helpers.returnJsonArray(req.body) + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Postmark/postmark.png b/packages/nodes-base/nodes/Postmark/postmark.png new file mode 100644 index 0000000000000000000000000000000000000000..6298b4ae94bdbd1b930a5bdd870200fa9f8f157a GIT binary patch literal 1297 zcmV+s1@8KZP)GpMpc|zYbfeUQZj@Tk zYlwi><%<$P6JYrvE(Ena8Xy!MunS^D_jD*dWl}+oHR-F7Mm3bno_DTVSD@AZL>0w2}~` zrk0DWFvSwaFL>iqZQuQzdF1}wGY4h{HssYQYdn3ziRcv8!j{v6FjWy(sCD2qBqT@( z6^1K$|M1bC^E2M7&z~ReTZYyaY}URApbAf(>HOr2t|%hO#hm}+u}mo>K~;2jl}2}5 z^8BiNcU&o&v(v#p=Yo7b5UC(K=k`Ci@Y-{e>9!IS@$)1BG?auIE#nBrPG?_yV|Zc7 zC*d5oZ(Vxl^@&Vp@d|^|DRax-v9XR14zK&|c&1WIltB<4eCgbyyXRqRNbV%*K~*4H zz5IkFD%q`YsuVauRlXBKbZ4W7M#GVz(!qE6>sZE!lA14gUw)t6bI(#+5Hfa-)TOyp zpN-cXSsg{^Fg&>O^y70U-fqhmeWi-v&r|8CX@6u>82@~Y?1)MzoZGo=`SzjwA16E^ zNG#;M@yYg);T6QI`e?BCpgI7Vj&OKm`1@a#gJCE$vvKQvSxqoZhMH-&M5VVoMO!x)w%w86JW%w!2oB05OY}NJ zJBKX;`8QuVn@LyvGzk%U2EMPpXCV#>xJvtD?O{gUp%pY9y0fKBM_3g>LJ2CM@vYo- zRQL2yRfPlo)0h>|I&IVdt!=T!=@x>%Zk?jW9hp=~HXs!w2oh8fq^aymLa9O(h=B+c z0O9niS=kh5K=AiO=E%2Qk)r_fi(aAVl|2x}o}csI`>5X!EEIv~9@{%VRHO5}5ztCv zays?lk)ALV03<~!X+UyzA^7<7?()!dDjMB+X=r2fp#gw|F>r3xV^lX17*lr35VN|f zs;3f8LjVLbb^Ahj zxpXwWdO!ht1L|*F-9jC)$!bA2N-gL{sRi9AwV)fNn-cn8xOMsu?|pBW00000NkvXX Hu0mjfX*6rT literal 0 HcmV?d00001 diff --git a/packages/nodes-base/nodes/QuestDb/QuestDb.node.ts b/packages/nodes-base/nodes/QuestDb/QuestDb.node.ts new file mode 100644 index 0000000000..b3ae15efee --- /dev/null +++ b/packages/nodes-base/nodes/QuestDb/QuestDb.node.ts @@ -0,0 +1,246 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { IDataObject, INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow'; + +import * as pgPromise from 'pg-promise'; + +import { pgInsert, pgQuery, pgUpdate } from '../Postgres/Postgres.node.functions'; +import { table } from 'console'; + +export class QuestDb implements INodeType { + description: INodeTypeDescription = { + displayName: 'QuestDB', + name: 'questDb', + icon: 'file:questdb.png', + group: ['input'], + version: 1, + description: 'Gets, add and update data in QuestDB.', + defaults: { + name: 'QuestDB', + color: '#2C4A79', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'questDb', + required: true, + }, + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Execute Query', + value: 'executeQuery', + description: 'Executes a SQL query.', + }, + { + name: 'Insert', + value: 'insert', + description: 'Insert rows in database.', + } + ], + default: 'insert', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // executeQuery + // ---------------------------------- + { + displayName: 'Query', + name: 'query', + type: 'string', + typeOptions: { + rows: 5, + }, + displayOptions: { + show: { + operation: ['executeQuery'], + }, + }, + default: '', + placeholder: 'SELECT id, name FROM product WHERE id < 40', + required: true, + description: 'The SQL query to execute.', + }, + + // ---------------------------------- + // insert + // ---------------------------------- + { + displayName: 'Schema', + name: 'schema', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: 'public', + required: true, + description: 'Name of the schema the table belongs to', + }, + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to insert data to.', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '', + placeholder: 'id,name,description', + description: + 'Comma separated list of the properties which should used as columns for the new rows.', + }, + { + displayName: 'Return Fields', + name: 'returnFields', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '*', + description: 'Comma separated list of the fields that the operation will return', + }, + + // ---------------------------------- + // update + // ---------------------------------- + // { + // displayName: 'Table', + // name: 'table', + // type: 'string', + // displayOptions: { + // show: { + // operation: ['update'], + // }, + // }, + // default: '', + // required: true, + // description: 'Name of the table in which to update data in', + // }, + // { + // displayName: 'Update Key', + // name: 'updateKey', + // type: 'string', + // displayOptions: { + // show: { + // operation: ['update'], + // }, + // }, + // default: 'id', + // required: true, + // description: + // 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + // }, + // { + // displayName: 'Columns', + // name: 'columns', + // type: 'string', + // displayOptions: { + // show: { + // operation: ['update'], + // }, + // }, + // default: '', + // placeholder: 'name,description', + // description: + // 'Comma separated list of the properties which should used as columns for rows to update.', + // }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const credentials = this.getCredentials('questDb'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const pgp = pgPromise(); + + const config = { + host: credentials.host as string, + port: credentials.port as number, + database: credentials.database as string, + user: credentials.user as string, + password: credentials.password as string, + ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), + sslmode: (credentials.ssl as string) || 'disable', + }; + + const db = pgp(config); + + let returnItems = []; + + const items = this.getInputData(); + const operation = this.getNodeParameter('operation', 0) as string; + + if (operation === 'executeQuery') { + // ---------------------------------- + // executeQuery + // ---------------------------------- + + const queryResult = await pgQuery(this.getNodeParameter, pgp, db, items); + + returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); + } else if (operation === 'insert') { + // ---------------------------------- + // insert + // ---------------------------------- + const tableName = this.getNodeParameter('table', 0) as string; + const returnFields = this.getNodeParameter('returnFields', 0) as string; + + let queries : string[] = []; + items.map(item => { + let columns = Object.keys(item.json); + + let values : string = columns.map((col : string) => { + if (typeof item.json[col] === 'string') { + return `\'${item.json[col]}\'`; + } else { + return item.json[col]; + } + }).join(','); + + let query = `INSERT INTO ${tableName} (${columns.join(',')}) VALUES (${values});`; + queries.push(query); + }); + + await db.any(pgp.helpers.concat(queries)); + + let returnedItems = await db.any(`SELECT ${returnFields} from ${tableName}`); + + returnItems = this.helpers.returnJsonArray(returnedItems as IDataObject[]); + } else { + await pgp.end(); + throw new Error(`The operation "${operation}" is not supported!`); + } + + // Close the connection + await pgp.end(); + + return this.prepareOutputData(returnItems); + } +} diff --git a/packages/nodes-base/nodes/QuestDb/questdb.png b/packages/nodes-base/nodes/QuestDb/questdb.png new file mode 100644 index 0000000000000000000000000000000000000000..5be1906e5d9d408257f4d2831489dc310126ecaf GIT binary patch literal 2835 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 literal 0 HcmV?d00001 diff --git a/packages/nodes-base/nodes/Redis/Redis.node.ts b/packages/nodes-base/nodes/Redis/Redis.node.ts index 68b7401205..12263b1d64 100644 --- a/packages/nodes-base/nodes/Redis/Redis.node.ts +++ b/packages/nodes-base/nodes/Redis/Redis.node.ts @@ -402,6 +402,7 @@ export class Redis implements INodeType { } else if (type === 'hash') { const clientHset = util.promisify(client.hset).bind(client); for (const key of Object.keys(value)) { + // @ts-ignore await clientHset(keyName, key, (value as IDataObject)[key]!.toString()); } } else if (type === 'list') { diff --git a/packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts b/packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts index e83a3afbfe..65dd1a6820 100644 --- a/packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts +++ b/packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts @@ -48,15 +48,15 @@ interface IPostMessageBody { export class Rocketchat implements INodeType { description: INodeTypeDescription = { - displayName: 'Rocketchat', + displayName: 'RocketChat', name: 'rocketchat', icon: 'file:rocketchat.png', group: ['output'], version: 1, subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}', - description: 'Consume Rocketchat API', + description: 'Consume RocketChat API', defaults: { - name: 'Rocketchat', + name: 'RocketChat', color: '#c02428', }, inputs: ['main'], diff --git a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts index 648735feb2..4c81432238 100644 --- a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts @@ -30,7 +30,7 @@ export async function salesforceApiRequest(this: IExecuteFunctions | IExecuteSin } } -export async function salesforceApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string ,method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any +export async function salesforceApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any const returnData: IDataObject[] = []; diff --git a/packages/nodes-base/nodes/Signl4/GenericFunctions.ts b/packages/nodes-base/nodes/Signl4/GenericFunctions.ts new file mode 100644 index 0000000000..5281ae8c25 --- /dev/null +++ b/packages/nodes-base/nodes/Signl4/GenericFunctions.ts @@ -0,0 +1,52 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +import { + OptionsWithUri, + } from 'request'; + +/** + * Make an API request to SIGNL4 + * + * @param {IHookFunctions | IExecuteFunctions} this + * @param {object} message + * @returns {Promise} + */ + +export async function SIGNL4ApiRequest(this: IExecuteFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + + let options: OptionsWithUri = { + headers: { + 'Accept': '*/*', + }, + method, + body, + qs: query, + uri: uri || ``, + json: true, + }; + + if (!Object.keys(body).length) { + delete options.body; + } + if (!Object.keys(query).length) { + delete options.qs; + } + options = Object.assign({}, options, option); + + try { + return await this.helpers.request!(options); + } catch (error) { + + if (error.response && error.response.body && error.response.body.details) { + throw new Error(`SIGNL4 error response [${error.statusCode}]: ${error.response.body.details}`); + } + + throw error; + } +} diff --git a/packages/nodes-base/nodes/Signl4/Signl4.node.ts b/packages/nodes-base/nodes/Signl4/Signl4.node.ts new file mode 100644 index 0000000000..29d3917cf6 --- /dev/null +++ b/packages/nodes-base/nodes/Signl4/Signl4.node.ts @@ -0,0 +1,325 @@ +import { + IExecuteFunctions, + BINARY_ENCODING, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, + IBinaryKeyData, +} from 'n8n-workflow'; + +import { + SIGNL4ApiRequest, +} from './GenericFunctions'; + +export class Signl4 implements INodeType { + description: INodeTypeDescription = { + displayName: 'SIGNL4', + name: 'signl4', + icon: 'file:signl4.png', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume SIGNL4 API.', + defaults: { + name: 'SIGNL4', + color: '#53afe8', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'signl4Api', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Alert', + value: 'alert', + }, + ], + default: 'alert', + description: 'The resource to operate on.', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'alert', + ], + }, + }, + options: [ + { + name: 'Send', + value: 'send', + description: 'Send an alert.', + }, + ], + default: 'send', + description: 'The operation to perform.', + }, + { + displayName: 'Message', + name: 'message', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + required: false, + displayOptions: { + show: { + operation: [ + 'send', + ], + resource: [ + 'alert', + ], + }, + }, + description: 'A more detailed description for the alert.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'send', + ], + resource: [ + 'alert', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Alerting Scenario', + name: 'alertingScenario', + type: 'options', + options: [ + { + name: 'Single ACK', + value: 'single_ack', + description: 'In case only one person needs to confirm this Signl.' + }, + { + name: 'Multi ACK', + value: 'multi_ack', + description: 'in case this alert must be confirmed by the number of people who are on duty at the time this Singl is raised', + }, + ], + default: 'single_ack', + required: false, + }, + { + displayName: 'Attachments', + name: 'attachmentsUi', + placeholder: 'Add Attachments', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + options: [ + { + name: 'attachmentsBinary', + displayName: 'Attachments Binary', + values: [ + { + displayName: 'Property Name', + name: 'property', + type: 'string', + placeholder: 'data', + default: '', + description: 'Name of the binary properties which contain data which should be added as attachment', + }, + ], + }, + ], + default: {}, + }, + { + displayName: 'External ID', + name: 'externalId', + type: 'string', + default: '', + description: `If the event originates from a record in a 3rd party system, use this parameter to pass
+ the unique ID of that record. That ID will be communicated in outbound webhook notifications from SIGNL4,
+ which is great for correlation/synchronization of that record with the alert.`, + }, + { + displayName: 'Filtering', + name: 'filtering', + type: 'boolean', + default: 'false', + description: `Specify a boolean value of true or false to apply event filtering for this event, or not.
+ If set to true, the event will only trigger a notification to the team, if it contains at least one keyword
+ from one of your services and system categories (i.e. it is whitelisted)`, + }, + { + displayName: 'Location', + name: 'locationFieldsUi', + type: 'fixedCollection', + placeholder: 'Add Location', + default: {}, + description: 'Transmit location information (\'latitude, longitude\') with your event and display a map in the mobile app.', + options: [ + { + name: 'locationFieldsValues', + displayName: 'Location', + values: [ + { + displayName: 'Latitude', + name: 'latitude', + type: 'string', + required: true, + description: 'The location latitude.', + default: '', + }, + { + displayName: 'Longitude', + name: 'longitude', + type: 'string', + required: true, + description: 'The location longitude.', + default: '', + }, + ], + } + ], + }, + { + displayName: 'Service', + name: 'service', + type: 'string', + default: '', + description: 'Assigns the alert to the service/system category with the specified name.', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + }, + ], + }, + ], + }; + + 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; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < length; i++) { + if (resource === 'alert') { + //https://connect.signl4.com/webhook/docs/index.html + if (operation === 'send') { + const message = this.getNodeParameter('message', i) as string; + const additionalFields = this.getNodeParameter('additionalFields',i) as IDataObject; + + const data: IDataObject = { + message, + }; + + if (additionalFields.alertingScenario) { + data['X-S4-AlertingScenario'] = additionalFields.alertingScenario as string; + } + if (additionalFields.externalId) { + data['X-S4-ExternalID'] = additionalFields.externalId as string; + } + if (additionalFields.filtering) { + data['X-S4-Filtering'] = (additionalFields.filtering as boolean).toString(); + } + if (additionalFields.locationFieldsUi) { + const locationUi = (additionalFields.locationFieldsUi as IDataObject).locationFieldsValues as IDataObject; + if (locationUi) { + data['X-S4-Location'] = `${locationUi.latitude},${locationUi.longitude}`; + } + } + if (additionalFields.service) { + data['X-S4-Service'] = additionalFields.service as string; + } + if (additionalFields.title) { + data['title'] = additionalFields.title as string; + } + + const attachments = additionalFields.attachmentsUi as IDataObject; + + if (attachments) { + if (attachments.attachmentsBinary && items[i].binary) { + + const propertyName = (attachments.attachmentsBinary as IDataObject).property as string; + + const binaryProperty = (items[i].binary as IBinaryKeyData)[propertyName]; + + if (binaryProperty) { + + const supportedFileExtension = ['png', 'jpg', 'txt']; + + if (!supportedFileExtension.includes(binaryProperty.fileExtension as string)) { + + throw new Error(`Invalid extension, just ${supportedFileExtension.join(',')} are supported}`); + } + + data['file'] = { + value: Buffer.from(binaryProperty.data, BINARY_ENCODING), + options: { + filename: binaryProperty.fileName, + contentType: binaryProperty.mimeType, + }, + }; + + } else { + throw new Error(`Binary property ${propertyName} does not exist on input`); + } + } + } + + const credentials = this.getCredentials('signl4Api'); + + const endpoint = `https://connect.signl4.com/webhook/${credentials?.teamSecret}`; + + responseData = await SIGNL4ApiRequest.call( + this, + 'POST', + '', + {}, + {}, + endpoint, + { + formData: { + ...data, + }, + }, + ); + } + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else if (responseData !== undefined) { + returnData.push(responseData as IDataObject); + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Signl4/signl4.png b/packages/nodes-base/nodes/Signl4/signl4.png new file mode 100644 index 0000000000000000000000000000000000000000..205c94a5d2ba0e8d25d7b792050e341a8c8ab29b GIT binary patch literal 3045 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 + */ +export async function spotifyApiRequest(this: IHookFunctions | IExecuteFunctions, + method: string, endpoint: string, body: object, query?: object, uri?: string): Promise { // tslint:disable-line:no-any + + const options: OptionsWithUri = { + method, + headers: { + 'User-Agent': 'n8n', + 'Content-Type': 'text/plain', + 'Accept': ' application/json', + }, + body, + qs: query, + uri: uri || `https://api.spotify.com/v1${endpoint}`, + json: true + }; + + try { + const credentials = this.getCredentials('spotifyOAuth2Api'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + if (Object.keys(body).length === 0) { + delete options.body; + } + + return await this.helpers.requestOAuth2.call(this, 'spotifyOAuth2Api', options); + } catch (error) { + if (error.statusCode === 401) { + // Return a clear error + throw new Error('The Spotify credentials are not valid!'); + } + + if (error.error && error.error.error && error.error.error.message) { + // Try to return the error prettier + throw new Error(`Spotify error response [${error.error.error.status}]: ${error.error.error.message}`); + } + + // If that data does not exist for some reason return the actual error + throw error; + } +} + +export async function spotifyApiRequestAllItems(this: IHookFunctions | IExecuteFunctions, + propertyName: string, method: string, endpoint: string, body: object, query?: object): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + let uri: string | undefined; + + do { + responseData = await spotifyApiRequest.call(this, method, endpoint, body, query, uri); + returnData.push.apply(returnData, responseData[propertyName]); + uri = responseData.next; + + } while ( + responseData['next'] !== null + ); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Spotify/Spotify.node.ts b/packages/nodes-base/nodes/Spotify/Spotify.node.ts new file mode 100644 index 0000000000..797020c392 --- /dev/null +++ b/packages/nodes-base/nodes/Spotify/Spotify.node.ts @@ -0,0 +1,816 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + spotifyApiRequest, + spotifyApiRequestAllItems, +} from './GenericFunctions'; + +export class Spotify implements INodeType { + description: INodeTypeDescription = { + displayName: 'Spotify', + name: 'spotify', + icon: 'file:spotify.png', + group: ['input'], + version: 1, + description: 'Access public song data via the Spotify API.', + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + defaults: { + name: 'Spotify', + color: '#1DB954', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'spotifyOAuth2Api', + required: true, + }, + ], + properties: [ + // ---------------------------------------------------------- + // Resource to Operate on + // Player, Album, Artisits, Playlists, Tracks + // ---------------------------------------------------------- + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Album', + value: 'album', + }, + { + name: 'Artist', + value: 'artist', + }, + { + name: 'Player', + value: 'player', + }, + { + name: 'Playlist', + value: 'playlist', + }, + { + name: 'Track', + value: 'track', + }, + ], + default: 'player', + description: 'The resource to operate on.', + }, + // -------------------------------------------------------------------------------------------------------- + // Player Operations + // Pause, Play, Get Recently Played, Get Currently Playing, Next Song, Previous Song, Add to Queue + // -------------------------------------------------------------------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'player', + ], + }, + }, + options: [ + { + name: 'Add Song to Queue', + value: 'addSongToQueue', + description: 'Add a song to your queue.' + }, + { + name: 'Currently Playing', + value: 'currentlyPlaying', + description: 'Get your currently playing track.' + }, + { + name: 'Next Song', + value: 'nextSong', + description: 'Skip to your next track.' + }, + { + name: 'Pause', + value: 'pause', + description: 'Pause your music.', + }, + { + name: 'Previous Song', + value: 'previousSong', + description: 'Skip to your previous song.' + }, + { + name: 'Recently Played', + value: 'recentlyPlayed', + description: 'Get your recently played tracks.' + }, + { + name: 'Start Music', + value: 'startMusic', + description: 'Start playing a playlist, artist, or album.' + }, + ], + default: 'addSongToQueue', + description: 'The operation to perform.', + }, + { + displayName: 'Resource ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'player' + ], + operation: [ + 'startMusic', + ], + }, + }, + placeholder: 'spotify:album:1YZ3k65Mqw3G8FzYlW1mmp', + description: `Enter a playlist, artist, or album URI or ID.`, + }, + { + displayName: 'Track ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'player' + ], + operation: [ + 'addSongToQueue', + ], + }, + }, + placeholder: 'spotify:track:0xE4LEFzSNGsz1F6kvXsHU', + description: `Enter a track URI or ID.`, + }, + // ----------------------------------------------- + // Album Operations + // Get an Album, Get an Album's Tracks + // ----------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'album', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get an album by URI or ID.', + }, + { + name: `Get Tracks`, + value: 'getTracks', + description: `Get an album's tracks by URI or ID.`, + }, + ], + default: 'get', + description: 'The operation to perform.', + }, + { + displayName: 'Album ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'album', + ], + }, + }, + placeholder: 'spotify:album:1YZ3k65Mqw3G8FzYlW1mmp', + description: `The album's Spotify URI or ID.`, + }, + // ------------------------------------------------------------------------------------------------------------- + // Artist Operations + // Get an Artist, Get an Artist's Related Artists, Get an Artist's Top Tracks, Get an Artist's Albums + // ------------------------------------------------------------------------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'artist', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get an artist by URI or ID.', + }, + { + name: `Get Albums`, + value: 'getAlbums', + description: `Get an artist's albums by URI or ID.`, + }, + { + name: `Get Related Artists`, + value: 'getRelatedArtists', + description: `Get an artist's related artists by URI or ID.`, + }, + { + name: `Get Top Tracks`, + value: 'getTopTracks', + description: `Get an artist's top tracks by URI or ID.`, + }, + ], + default: 'get', + description: 'The operation to perform.', + }, + { + displayName: 'Artist ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'artist', + ], + }, + }, + placeholder: 'spotify:artist:4LLpKhyESsyAXpc4laK94U', + description: `The artist's Spotify URI or ID.`, + }, + { + displayName: 'Country', + name: 'country', + type: 'string', + default: 'US', + required: true, + displayOptions: { + show: { + resource: [ + 'artist' + ], + operation: [ + 'getTopTracks', + ], + }, + }, + placeholder: 'US', + description: `Top tracks in which country? Enter the postal abbriviation.`, + }, + // ------------------------------------------------------------------------------------------------------------- + // Playlist Operations + // Get a Playlist, Get a Playlist's Tracks, Add/Remove a Song from a Playlist, Get a User's Playlists + // ------------------------------------------------------------------------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'playlist', + ], + }, + }, + options: [ + { + name: 'Add an Item', + value: 'add', + description: 'Add tracks from a playlist by track and playlist URI or ID.', + }, + { + name: 'Get', + value: 'get', + description: 'Get a playlist by URI or ID.', + }, + { + name: 'Get Tracks', + value: 'getTracks', + description: `Get a playlist's tracks by URI or ID.`, + }, + { + name: `Get the User's Playlists`, + value: 'getUserPlaylists', + description: `Get a user's playlists.`, + }, + { + name: 'Remove an Item', + value: 'delete', + description: 'Remove tracks from a playlist by track and playlist URI or ID.', + }, + ], + default: 'add', + description: 'The operation to perform.', + }, + { + displayName: 'Playlist ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'playlist', + ], + operation: [ + 'add', + 'delete', + 'get', + 'getTracks', + ], + }, + }, + placeholder: 'spotify:playlist:37i9dQZF1DWUhI3iC1khPH', + description: `The playlist's Spotify URI or its ID.`, + }, + { + displayName: 'Track ID', + name: 'trackID', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'playlist', + ], + operation: [ + 'add', + 'delete', + ], + }, + }, + placeholder: 'spotify:track:0xE4LEFzSNGsz1F6kvXsHU', + description: `The track's Spotify URI or its ID. The track to add/delete from the playlist.`, + }, + // ----------------------------------------------------- + // Track Operations + // Get a Track, Get a Track's Audio Features + // ----------------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'track', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a track by its URI or ID.', + }, + { + name: 'Get Audio Features', + value: 'getAudioFeatures', + description: 'Get audio features for a track by URI or ID.', + }, + ], + default: 'track', + description: 'The operation to perform.', + }, + { + displayName: 'Track ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'track', + ], + }, + }, + placeholder: 'spotify:track:0xE4LEFzSNGsz1F6kvXsHU', + description: `The track's Spotify URI or ID.`, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + required: true, + displayOptions: { + show: { + resource: [ + 'album', + 'artist', + 'playlist', + ], + operation: [ + 'getTracks', + 'getAlbums', + 'getUserPlaylists', + ], + }, + }, + description: `The number of items to return.`, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + required: true, + displayOptions: { + show: { + resource: [ + 'album', + 'artist', + 'playlist' + ], + operation: [ + 'getTracks', + 'getAlbums', + 'getUserPlaylists', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + description: `The number of items to return.`, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + required: true, + displayOptions: { + show: { + resource: [ + 'player', + ], + operation: [ + 'recentlyPlayed', + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 50, + }, + description: `The number of items to return.`, + }, + ] + }; + + + async execute(this: IExecuteFunctions): Promise { + // Get all of the incoming input data to loop through + const items = this.getInputData(); + const returnData: IDataObject[] = []; + + // For Post + let body: IDataObject; + // For Query string + let qs: IDataObject; + + let requestMethod: string; + let endpoint: string; + let returnAll: boolean; + let propertyName = ''; + let responseData; + + const operation = this.getNodeParameter('operation', 0) as string; + const resource = this.getNodeParameter('resource', 0) as string; + + // Set initial values + requestMethod = 'GET'; + endpoint = ''; + body = {}; + qs = {}; + returnAll = false; + + for(let i = 0; i < items.length; i++) { + // ----------------------------- + // Player Operations + // ----------------------------- + if( resource === 'player' ) { + if(operation === 'pause') { + requestMethod = 'PUT'; + + endpoint = `/me/player/pause`; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = { success: true }; + + } else if(operation === 'recentlyPlayed') { + requestMethod = 'GET'; + + endpoint = `/me/player/recently-played`; + + const limit = this.getNodeParameter('limit', i) as number; + + qs = { + limit, + }; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.items; + + } else if(operation === 'currentlyPlaying') { + requestMethod = 'GET'; + + endpoint = `/me/player/currently-playing`; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + } else if(operation === 'nextSong') { + requestMethod = 'POST'; + + endpoint = `/me/player/next`; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = { success: true }; + + } else if(operation === 'previousSong') { + requestMethod = 'POST'; + + endpoint = `/me/player/previous`; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = { success: true }; + + } else if(operation === 'startMusic') { + requestMethod = 'PUT'; + + endpoint = `/me/player/play`; + + const id = this.getNodeParameter('id', i) as string; + + body.context_uri = id; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = { success: true }; + + } else if(operation === 'addSongToQueue') { + requestMethod = 'POST'; + + endpoint = `/me/player/queue`; + + const id = this.getNodeParameter('id', i) as string; + + qs = { + uri: id + }; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = { success: true }; + } + // ----------------------------- + // Album Operations + // ----------------------------- + } else if( resource === 'album') { + const uri = this.getNodeParameter('id', i) as string; + + const id = uri.replace('spotify:album:', ''); + + requestMethod = 'GET'; + + if(operation === 'get') { + endpoint = `/albums/${id}`; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + } else if(operation === 'getTracks') { + endpoint = `/albums/${id}/tracks`; + + propertyName = 'tracks'; + + returnAll = this.getNodeParameter('returnAll', i) as boolean; + + propertyName = 'items'; + + if(!returnAll) { + const limit = this.getNodeParameter('limit', i) as number; + + qs = { + limit, + }; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.items; + } + } + // ----------------------------- + // Artist Operations + // ----------------------------- + } else if( resource === 'artist') { + const uri = this.getNodeParameter('id', i) as string; + + const id = uri.replace('spotify:artist:', ''); + + if(operation === 'getAlbums') { + + endpoint = `/artists/${id}/albums`; + + returnAll = this.getNodeParameter('returnAll', i) as boolean; + + propertyName = 'items'; + + if(!returnAll) { + const limit = this.getNodeParameter('limit', i) as number; + + qs = { + limit, + }; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.items; + } + } else if(operation === 'getRelatedArtists') { + + endpoint = `/artists/${id}/related-artists`; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.artists; + + } else if(operation === 'getTopTracks'){ + const country = this.getNodeParameter('country', i) as string; + + qs = { + country, + }; + + endpoint = `/artists/${id}/top-tracks`; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.tracks; + + } else if (operation === 'get') { + + requestMethod = 'GET'; + + endpoint = `/artists/${id}`; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + } + // ----------------------------- + // Playlist Operations + // ----------------------------- + } else if( resource === 'playlist') { + if(['delete', 'get', 'getTracks', 'add'].includes(operation)) { + const uri = this.getNodeParameter('id', i) as string; + + const id = uri.replace('spotify:playlist:', ''); + + if(operation === 'delete') { + requestMethod = 'DELETE'; + const trackId = this.getNodeParameter('trackID', i) as string; + + body.tracks = [ + { + uri: `${trackId}`, + positions: [ 0 ], + }, + ]; + + endpoint = `/playlists/${id}/tracks`; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = { success: true }; + + } else if(operation === 'get') { + requestMethod = 'GET'; + + endpoint = `/playlists/${id}`; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + } else if(operation === 'getTracks') { + requestMethod = 'GET'; + + endpoint = `/playlists/${id}/tracks`; + + returnAll = this.getNodeParameter('returnAll', i) as boolean; + + propertyName = 'items'; + + if(!returnAll) { + const limit = this.getNodeParameter('limit', i) as number; + + qs = { + 'limit': limit + }; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.items; + } + } else if(operation === 'add') { + requestMethod = 'POST'; + + const trackId = this.getNodeParameter('trackID', i) as string; + + qs = { + uris: trackId + }; + + endpoint = `/playlists/${id}/tracks`; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + } + } else if(operation === 'getUserPlaylists') { + requestMethod = 'GET'; + + endpoint = '/me/playlists'; + + returnAll = this.getNodeParameter('returnAll', i) as boolean; + + propertyName = 'items'; + + if(!returnAll) { + const limit = this.getNodeParameter('limit', i) as number; + + qs = { + limit, + }; + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.items; + } + } + // ----------------------------- + // Track Operations + // ----------------------------- + } else if( resource === 'track') { + const uri = this.getNodeParameter('id', i) as string; + + const id = uri.replace('spotify:track:', ''); + + requestMethod = 'GET'; + + if(operation === 'getAudioFeatures') { + endpoint = `/audio-features/${id}`; + } else if(operation === 'get') { + endpoint = `/tracks/${id}`; + } + + responseData = await spotifyApiRequest.call(this, requestMethod, endpoint, body, qs); + } + + if(returnAll) { + responseData = await spotifyApiRequestAllItems.call(this, propertyName, requestMethod, endpoint, body, qs); + } + + 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/Spotify/spotify.png b/packages/nodes-base/nodes/Spotify/spotify.png new file mode 100644 index 0000000000000000000000000000000000000000..2feeb78bbf25c43b2e1ceab5ee5f382eb790b207 GIT binary patch literal 6664 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 yuDUBI#tL~Id;c)#1LY@6KIaUBA^!o!Zi|xu literal 0 HcmV?d00001 diff --git a/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts b/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts index 86f999b578..bfca4dde1b 100644 --- a/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts +++ b/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts @@ -14,19 +14,13 @@ import { } from 'n8n-workflow'; export async function surveyMonkeyApiRequest(this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any - - const credentials = this.getCredentials('surveyMonkeyApi'); - - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } + const authenticationMethod = this.getNodeParameter('authentication', 0); const endpoint = 'https://api.surveymonkey.com/v3'; let options: OptionsWithUri = { headers: { 'Content-Type': 'application/json', - 'Authorization': `bearer ${credentials.accessToken}`, }, method, body, @@ -34,6 +28,7 @@ export async function surveyMonkeyApiRequest(this: IExecuteFunctions | IWebhookF uri: uri || `${endpoint}${resource}`, json: true }; + if (!Object.keys(body).length) { delete options.body; } @@ -41,8 +36,22 @@ export async function surveyMonkeyApiRequest(this: IExecuteFunctions | IWebhookF delete options.qs; } options = Object.assign({}, options, option); + try { - return await this.helpers.request!(options); + if ( authenticationMethod === 'accessToken') { + const credentials = this.getCredentials('surveyMonkeyApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + // @ts-ignore + options.headers['Authorization'] = `bearer ${credentials.accessToken}`; + + return await this.helpers.request!(options); + + } else { + return await this.helpers.requestOAuth2?.call(this, 'surveyMonkeyOAuth2Api', options); + } } catch (error) { const errorMessage = error.response.body.error.message; if (errorMessage !== undefined) { diff --git a/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts b/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts index efdc8dba5a..c0f722dd8c 100644 --- a/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts +++ b/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts @@ -49,6 +49,24 @@ export class SurveyMonkeyTrigger implements INodeType { { name: 'surveyMonkeyApi', required: true, + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'surveyMonkeyOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, }, ], webhooks: [ @@ -66,6 +84,23 @@ export class SurveyMonkeyTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'Method of authentication.', + }, { displayName: 'Type', name: 'objectType', @@ -453,11 +488,18 @@ export class SurveyMonkeyTrigger implements INodeType { async webhook(this: IWebhookFunctions): Promise { const event = this.getNodeParameter('event') as string; const objectType = this.getNodeParameter('objectType') as string; - const credentials = this.getCredentials('surveyMonkeyApi') as IDataObject; + const authenticationMethod = this.getNodeParameter('authentication') as string; + let credentials : IDataObject; const headerData = this.getHeaderData() as IDataObject; const req = this.getRequestObject(); const webhookName = this.getWebhookName(); + if (authenticationMethod === 'accessToken') { + credentials = this.getCredentials('surveyMonkeyApi') as IDataObject; + } else { + credentials = this.getCredentials('surveyMonkeyOAuth2Api') as IDataObject; + } + if (webhookName === 'setup') { // It is a create webhook confirmation request return {}; diff --git a/packages/nodes-base/nodes/Trello/AttachmentDescription.ts b/packages/nodes-base/nodes/Trello/AttachmentDescription.ts index a04f507518..4306b2ed11 100644 --- a/packages/nodes-base/nodes/Trello/AttachmentDescription.ts +++ b/packages/nodes-base/nodes/Trello/AttachmentDescription.ts @@ -29,7 +29,7 @@ export const attachmentOperations = [ { name: 'Get', value: 'get', - description: 'Get the data of an attachments', + description: 'Get the data of an attachment', }, { name: 'Get All', diff --git a/packages/nodes-base/nodes/Trello/ChecklistDescription.ts b/packages/nodes-base/nodes/Trello/ChecklistDescription.ts index 34036f83e2..be11ba6f17 100644 --- a/packages/nodes-base/nodes/Trello/ChecklistDescription.ts +++ b/packages/nodes-base/nodes/Trello/ChecklistDescription.ts @@ -44,17 +44,17 @@ export const checklistOperations = [ { name: 'Get Checklist Items', value: 'getCheckItem', - description: 'Get a specific Checklist on a card', + description: 'Get a specific checklist on a card', }, { name: 'Get Completed Checklist Items', value: 'completedCheckItems', - description: 'Get the completed Checklist items on a card', + description: 'Get the completed checklist items on a card', }, { name: 'Update Checklist Item', value: 'updateCheckItem', - description: 'Update an item in a checklist on a card.', + description: 'Update an item in a checklist on a card', }, ], default: 'getAll', diff --git a/packages/nodes-base/nodes/Trello/LabelDescription.ts b/packages/nodes-base/nodes/Trello/LabelDescription.ts index 9c23053282..2b938ae5de 100644 --- a/packages/nodes-base/nodes/Trello/LabelDescription.ts +++ b/packages/nodes-base/nodes/Trello/LabelDescription.ts @@ -39,7 +39,7 @@ export const labelOperations = [ { name: 'Get All', value: 'getAll', - description: 'Returns all label for the board', + description: 'Returns all labels for the board', }, { name: 'Remove From Card', diff --git a/packages/nodes-base/nodes/Twitter/TweetDescription.ts b/packages/nodes-base/nodes/Twitter/TweetDescription.ts index edd12bbf5e..12cb87ebfe 100644 --- a/packages/nodes-base/nodes/Twitter/TweetDescription.ts +++ b/packages/nodes-base/nodes/Twitter/TweetDescription.ts @@ -219,7 +219,7 @@ export const tweetFields = [ description: 'The entities node will not be included when set to false', }, { - displayName: 'Lang', + displayName: 'Language', name: 'lang', type: 'options', typeOptions: { diff --git a/packages/nodes-base/nodes/Twitter/Twitter.node.ts b/packages/nodes-base/nodes/Twitter/Twitter.node.ts index 60f95e3798..a62f9fd95b 100644 --- a/packages/nodes-base/nodes/Twitter/Twitter.node.ts +++ b/packages/nodes-base/nodes/Twitter/Twitter.node.ts @@ -133,7 +133,15 @@ export class Twitter implements INodeType { let attachmentBody = {}; let response: IDataObject = {}; - if (binaryData[binaryPropertyName].mimeType.includes('image')) { + const isAnimatedWebp = (Buffer.from(binaryData[binaryPropertyName].data, 'base64').toString().indexOf('ANMF') !== -1); + + const isImage = binaryData[binaryPropertyName].mimeType.includes('image'); + + if (isImage && isAnimatedWebp) { + throw new Error('Animated .webp images are not supported use .gif instead'); + } + + if (isImage) { const attachmentBody = { media_data: binaryData[binaryPropertyName].data, diff --git a/packages/nodes-base/nodes/Typeform/GenericFunctions.ts b/packages/nodes-base/nodes/Typeform/GenericFunctions.ts index c5e9242465..83ca713afe 100644 --- a/packages/nodes-base/nodes/Typeform/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Typeform/GenericFunctions.ts @@ -45,18 +45,10 @@ export interface ITypeformAnswerField { * @returns {Promise} */ export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, query?: IDataObject): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('typeformApi'); - - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - - query = query || {}; + const authenticationMethod = this.getNodeParameter('authentication', 0); const options: OptionsWithUri = { - headers: { - 'Authorization': `bearer ${credentials.accessToken}`, - }, + headers: {}, method, body, qs: query, @@ -64,8 +56,23 @@ export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoa json: true, }; + query = query || {}; + try { - return await this.helpers.request!(options); + if (authenticationMethod === 'accessToken') { + + const credentials = this.getCredentials('typeformApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.headers!['Authorization'] = `bearer ${credentials.accessToken}`; + + return await this.helpers.request!(options); + } else { + return await this.helpers.requestOAuth2!.call(this, 'typeformOAuth2Api', options); + } } catch (error) { if (error.statusCode === 401) { // Return a clear error diff --git a/packages/nodes-base/nodes/Typeform/TypeformTrigger.node.ts b/packages/nodes-base/nodes/Typeform/TypeformTrigger.node.ts index b127a1f594..9c8f6e16ff 100644 --- a/packages/nodes-base/nodes/Typeform/TypeformTrigger.node.ts +++ b/packages/nodes-base/nodes/Typeform/TypeformTrigger.node.ts @@ -37,7 +37,25 @@ export class TypeformTrigger implements INodeType { { name: 'typeformApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'typeformOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], webhooks: [ { @@ -48,6 +66,23 @@ export class TypeformTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Form', name: 'formId', 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/Webflow/GenericFunctions.ts b/packages/nodes-base/nodes/Webflow/GenericFunctions.ts index 030e47f903..783b674808 100644 --- a/packages/nodes-base/nodes/Webflow/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Webflow/GenericFunctions.ts @@ -1,4 +1,7 @@ -import { OptionsWithUri } from 'request'; +import { + OptionsWithUri, +} from 'request'; + import { IExecuteFunctions, IExecuteSingleFunctions, @@ -6,17 +9,16 @@ import { ILoadOptionsFunctions, IWebhookFunctions, } from 'n8n-core'; -import { IDataObject } from 'n8n-workflow'; + +import { + IDataObject, + } from 'n8n-workflow'; export async function webflowApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('webflowApi'); - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } + const authenticationMethod = this.getNodeParameter('authentication', 0); let options: OptionsWithUri = { headers: { - authorization: `Bearer ${credentials.accessToken}`, 'accept-version': '1.0.0', }, method, @@ -31,14 +33,22 @@ export async function webflowApiRequest(this: IHookFunctions | IExecuteFunctions } try { - return await this.helpers.request!(options); - } catch (error) { + if (authenticationMethod === 'accessToken') { + const credentials = this.getCredentials('webflowApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } - let errorMessage = error.message; - if (error.response.body && error.response.body.err) { - errorMessage = error.response.body.err; + options.headers!['authorization'] = `Bearer ${credentials.accessToken}`; + + return await this.helpers.request!(options); + } else { + return await this.helpers.requestOAuth2!.call(this, 'webflowOAuth2Api', options); } - - throw new Error('Webflow Error: ' + errorMessage); + } catch (error) { + if (error.response.body.err) { + throw new Error(`Webflow Error: [${error.statusCode}]: ${error.response.body.err}`); + } + return error; } } diff --git a/packages/nodes-base/nodes/Webflow/WebflowTrigger.node.ts b/packages/nodes-base/nodes/Webflow/WebflowTrigger.node.ts index 709b9858cd..73b2efef61 100644 --- a/packages/nodes-base/nodes/Webflow/WebflowTrigger.node.ts +++ b/packages/nodes-base/nodes/Webflow/WebflowTrigger.node.ts @@ -34,7 +34,25 @@ export class WebflowTrigger implements INodeType { { name: 'webflowApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'webflowOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], webhooks: [ { @@ -45,6 +63,23 @@ export class WebflowTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'Method of authentication.', + }, { displayName: 'Site', name: 'site', diff --git a/packages/nodes-base/nodes/Webhook.node.ts b/packages/nodes-base/nodes/Webhook.node.ts index 135960ea4e..1778d44290 100644 --- a/packages/nodes-base/nodes/Webhook.node.ts +++ b/packages/nodes-base/nodes/Webhook.node.ts @@ -77,6 +77,7 @@ export class Webhook implements INodeType { { name: 'default', httpMethod: '={{$parameter["httpMethod"]}}', + isFullPath: true, responseCode: '={{$parameter["responseCode"]}}', responseMode: '={{$parameter["responseMode"]}}', responseData: '={{$parameter["responseData"]}}', @@ -133,7 +134,7 @@ export class Webhook implements INodeType { default: '', placeholder: 'webhook', required: true, - description: 'The path to listen to. Slashes("/") in the path are not allowed.', + description: 'The path to listen to.', }, { displayName: 'Response Code', 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 0000000000000000000000000000000000000000..a9d46c10aacd655047a0039b75024aebcab3cf84 GIT binary patch literal 9587 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 { // tslint:disable-line:no-any - const credentials = this.getCredentials('zendeskApi'); - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - const base64Key = Buffer.from(`${credentials.email}/token:${credentials.apiToken}`).toString('base64'); + const authenticationMethod = this.getNodeParameter('authentication', 0); + let options: OptionsWithUri = { - headers: { 'Authorization': `Basic ${base64Key}`}, + headers: {}, method, qs, body, - uri: uri ||`${credentials.url}/api/v2${resource}.json`, + //@ts-ignore + uri, json: true }; + options = Object.assign({}, options, option); if (Object.keys(options.body).length === 0) { delete options.body; } + try { - return await this.helpers.request!(options); - } catch (err) { + if (authenticationMethod === 'apiToken') { + const credentials = this.getCredentials('zendeskApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const base64Key = Buffer.from(`${credentials.email}/token:${credentials.apiToken}`).toString('base64'); + options.uri = `https://${credentials.subdomain}.zendesk.com/api/v2${resource}.json`; + options.headers!['Authorization'] = `Basic ${base64Key}`; + + return await this.helpers.request!(options); + } else { + const credentials = this.getCredentials('zendeskOAuth2Api'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.uri = `https://${credentials.subdomain}.zendesk.com/api/v2${resource}.json`; + + return await this.helpers.requestOAuth2!.call(this, 'zendeskOAuth2Api', options); + } + } catch(err) { let errorMessage = err.message; if (err.response && err.response.body && err.response.body.error) { errorMessage = err.response.body.error; diff --git a/packages/nodes-base/nodes/Zendesk/Zendesk.node.ts b/packages/nodes-base/nodes/Zendesk/Zendesk.node.ts index 8a2586d8d8..d0c72c335f 100644 --- a/packages/nodes-base/nodes/Zendesk/Zendesk.node.ts +++ b/packages/nodes-base/nodes/Zendesk/Zendesk.node.ts @@ -52,9 +52,44 @@ export class Zendesk implements INodeType { { name: 'zendeskApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'apiToken', + ], + }, + }, + }, + { + name: 'zendeskOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'API Token', + value: 'apiToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'apiToken', + description: 'The resource to operate on.', + }, { displayName: 'Resource', name: 'resource', diff --git a/packages/nodes-base/nodes/Zendesk/ZendeskTrigger.node.ts b/packages/nodes-base/nodes/Zendesk/ZendeskTrigger.node.ts index cc4e26dbd4..421d9e4b32 100644 --- a/packages/nodes-base/nodes/Zendesk/ZendeskTrigger.node.ts +++ b/packages/nodes-base/nodes/Zendesk/ZendeskTrigger.node.ts @@ -42,7 +42,25 @@ export class ZendeskTrigger implements INodeType { { name: 'zendeskApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'apiToken', + ], + }, + }, + }, + { + name: 'zendeskOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], webhooks: [ { @@ -53,6 +71,23 @@ export class ZendeskTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'API Token', + value: 'apiToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'apiToken', + description: 'The resource to operate on.', + }, { displayName: 'Service', name: 'service', diff --git a/packages/nodes-base/nodes/Zoom/GenericFunctions.ts b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts new file mode 100644 index 0000000000..3d036f5f39 --- /dev/null +++ b/packages/nodes-base/nodes/Zoom/GenericFunctions.ts @@ -0,0 +1,104 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function zoomApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: object = {}, query: object = {}, headers: {} | undefined = undefined, option: {} = {}): Promise { // tslint:disable-line:no-any + + const authenticationMethod = this.getNodeParameter('authentication', 0, 'accessToken') as string; + + let options: OptionsWithUri = { + method, + headers: headers || { + 'Content-Type': 'application/json' + }, + body, + qs: query, + uri: `https://api.zoom.us/v2${resource}`, + json: true + }; + options = Object.assign({}, options, option); + if (Object.keys(body).length === 0) { + delete options.body; + } + if (Object.keys(query).length === 0) { + delete options.qs; + } + + try { + if (authenticationMethod === 'accessToken') { + const credentials = this.getCredentials('zoomApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + options.headers!.Authorization = `Bearer ${credentials.accessToken}`; + + //@ts-ignore + return await this.helpers.request(options); + } else { + //@ts-ignore + return await this.helpers.requestOAuth2.call(this, 'zoomOAuth2Api', options); + } + } catch (error) { + if (error.statusCode === 401) { + // Return a clear error + throw new Error('The Zoom credentials are not valid!'); + } + + if (error.response && error.response.body && error.response.body.message) { + // Try to return the error prettier + throw new Error(`Zoom error response [${error.statusCode}]: ${error.response.body.message}`); + } + + // If that data does not exist for some reason return the actual error + throw error; + } +} + + +export async function zoomApiRequestAllItems( + this: IExecuteFunctions | ILoadOptionsFunctions, + propertyName: string, + method: string, + endpoint: string, + body: IDataObject = {}, + query: IDataObject = {} +): Promise { // tslint:disable-line:no-any + const returnData: IDataObject[] = []; + let responseData; + query.page_number = 0; + do { + responseData = await zoomApiRequest.call( + this, + method, + endpoint, + body, + query + ); + query.page_number++; + returnData.push.apply(returnData, responseData[propertyName]); + // zoom free plan rate limit is 1 request/second + // TODO just wait when the plan is free + await wait(); + } while ( + responseData.page_count !== responseData.page_number + ); + + return returnData; +} +function wait() { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve(true); + }, 1000); + }); +} diff --git a/packages/nodes-base/nodes/Zoom/MeetingDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts new file mode 100644 index 0000000000..f412235396 --- /dev/null +++ b/packages/nodes-base/nodes/Zoom/MeetingDescription.ts @@ -0,0 +1,751 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const meetingOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'meeting', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a meeting', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a meeting', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a meeting', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all meetings', + }, + { + name: 'Update', + value: 'update', + description: 'Update a meeting', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const meetingFields = [ + /* -------------------------------------------------------------------------- */ + /* meeting:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Topic', + name: 'topic', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + displayOptions: { + show: { + operation: [ + 'create', + + ], + resource: [ + 'meeting', + ], + }, + }, + description: `Topic of the meeting.`, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'create', + + ], + resource: [ + 'meeting', + ], + }, + }, + options: [ + { + displayName: 'Agenda', + name: 'agenda', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Meeting agenda.', + }, + { + displayName: 'Duration', + name: 'duration', + type: 'number', + typeOptions: { + minValue: 0, + }, + default: 0, + description: 'Meeting duration (minutes).', + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: 'Password to join the meeting with maximum 10 characters.', + }, + { + displayName: 'Schedule For', + name: 'scheduleFor', + type: 'string', + default: '', + description: 'Schedule meeting for someone else from your account, provide their email ID.', + }, + { + displayName: 'Settings', + name: 'settings', + type: 'collection', + placeholder: 'Add Setting', + default: {}, + options: [ + { + displayName: 'Audio', + name: 'audio', + type: 'options', + options: [ + { + name: 'Both Telephony and VoiP', + value: 'both', + }, + { + name: 'Telephony', + value: 'telephony', + }, + { + name: 'VOIP', + value: 'voip', + }, + + ], + default: 'both', + description: 'Determine how participants can join audio portion of the meeting.', + }, + { + displayName: 'Alternative Hosts', + name: 'alternativeHosts', + type: 'string', + default: '', + description: 'Alternative hosts email IDs.', + }, + { + displayName: 'Auto Recording', + name: 'autoRecording', + type: 'options', + options: [ + { + name: 'Record on Local', + value: 'local', + }, + { + name: 'Record on Cloud', + value: 'cloud', + }, + { + name: 'Disabled', + value: 'none', + }, + ], + default: 'none', + description: 'Auto recording.', + }, + { + displayName: 'Host Meeting in China', + name: 'cnMeeting', + type: 'boolean', + default: false, + description: 'Host Meeting in China.', + }, + { + displayName: 'Host Meeting in India', + name: 'inMeeting', + type: 'boolean', + default: false, + description: 'Host Meeting in India.', + }, + { + displayName: 'Host Video', + name: 'hostVideo', + type: 'boolean', + default: false, + description: 'Start video when host joins the meeting.', + }, + { + displayName: 'Join Before Host', + name: 'joinBeforeHost', + type: 'boolean', + default: false, + description: 'Allow participants to join the meeting before host starts it.', + }, + { + displayName: 'Muting Upon Entry', + name: 'muteUponEntry', + type: 'boolean', + default: false, + description: 'Mute participants upon entry.', + }, + { + displayName: 'Participant Video', + name: 'participantVideo', + type: 'boolean', + default: false, + description: 'Start video when participant joins the meeting.', + }, + { + displayName: 'Registration Type', + name: 'registrationType', + type: 'options', + options: [ + { + name: 'Attendees register once and can attend any of the occurences', + value: 1, + }, + { + name: 'Attendees need to register for every occurrence', + value: 2, + }, + { + name: 'Attendees register once and can choose one or more occurrences to attend', + value: 3, + }, + ], + default: 1, + description: 'Registration type. Used for recurring meetings with fixed time only', + }, + { + displayName: 'Watermark', + name: 'watermark', + type: 'boolean', + default: false, + description: 'Adds watermark when viewing a shared screen.', + }, + ], + }, + { + displayName: 'Start Time', + name: 'startTime', + type: 'dateTime', + default: '', + description: 'Start time should be used only for scheduled or recurring meetings with fixed time', + }, + { + displayName: 'Timezone', + name: 'timeZone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: '', + description: `Time zone used in the response. The default is the time zone of the calendar.`, + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Instant Meeting', + value: 1, + }, + { + name: 'Scheduled Meeting', + value: 2, + }, + { + name: 'Recurring Meeting with no fixed time', + value: 3, + }, + { + name: 'Recurring Meeting with fixed time', + value: 8, + }, + + ], + default: 2, + description: 'Meeting type.', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* meeting:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'ID', + name: 'meetingId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'meeting', + ], + }, + }, + description: 'Meeting ID.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'get', + + ], + resource: [ + 'meeting', + ], + }, + }, + options: [ + { + displayName: 'Occurrence ID', + name: 'occurrenceId', + type: 'string', + default: '', + description: 'To view meeting details of a particular occurrence of the recurring meeting.', + }, + { + displayName: 'Show Previous Occurrences', + name: 'showPreviousOccurrences', + type: 'boolean', + default: '', + description: 'To view meeting details of all previous occurrences of the recurring meeting.', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* meeting:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'meeting', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'meeting', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 300, + }, + default: 30, + description: 'How many results to return.', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + operation: [ + 'getAll', + + ], + resource: [ + 'meeting', + ], + }, + }, + options: [ + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Scheduled', + value: 'scheduled', + description: 'This includes all valid past meetings, live meetings and upcoming scheduled meetings' + }, + { + name: 'Live', + value: 'live', + description: 'All ongoing meetings', + }, + { + name: 'Upcoming', + value: 'upcoming', + description: 'All upcoming meetings including live meetings', + }, + ], + default: 'live', + description: `Meeting type.`, + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* meeting:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'ID', + name: 'meetingId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'meeting', + ], + }, + }, + description: 'Meeting ID.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'meeting', + ], + }, + }, + options: [ + { + displayName: 'Occurence ID', + name: 'occurrenceId', + type: 'string', + default: '', + description: 'Meeting occurrence ID.', + }, + { + displayName: 'Schedule Reminder', + name: 'scheduleForReminder', + type: 'boolean', + default: false, + description: 'Notify hosts and alternative hosts about meeting cancellation via email', + }, + ], + + }, + /* -------------------------------------------------------------------------- */ + /* meeting:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'ID', + name: 'meetingId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'meeting', + ], + }, + }, + description: 'Meeting ID.', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'meeting', + ], + }, + }, + options: [ + { + displayName: 'Agenda', + name: 'agenda', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Meeting agenda.', + }, + { + displayName: 'Duration', + name: 'duration', + type: 'number', + typeOptions: { + minValue: 0, + }, + default: 0, + description: 'Meeting duration (minutes).', + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: 'Password to join the meeting with maximum 10 characters.', + }, + { + displayName: 'Schedule For', + name: 'scheduleFor', + type: 'string', + default: '', + description: 'Schedule meeting for someone else from your account, provide their email ID.', + }, + { + displayName: 'Settings', + name: 'settings', + type: 'collection', + placeholder: 'Add Setting', + default: {}, + options: [ + { + displayName: 'Audio', + name: 'audio', + type: 'options', + options: [ + { + name: 'Both Telephony and VoiP', + value: 'both', + }, + { + name: 'Telephony', + value: 'telephony', + }, + { + name: 'VOIP', + value: 'voip', + }, + + ], + default: 'both', + description: 'Determine how participants can join audio portion of the meeting.', + }, + { + displayName: 'Alternative Hosts', + name: 'alternativeHosts', + type: 'string', + default: '', + description: 'Alternative hosts email IDs.', + }, + { + displayName: 'Auto Recording', + name: 'autoRecording', + type: 'options', + options: [ + { + name: 'Record on Local', + value: 'local', + }, + { + name: 'Record on Cloud', + value: 'cloud', + }, + { + name: 'Disabled', + value: 'none', + }, + ], + default: 'none', + description: 'Auto recording.', + }, + { + displayName: 'Host Meeting in China', + name: 'cnMeeting', + type: 'boolean', + default: false, + description: 'Host Meeting in China.', + }, + { + displayName: 'Host Meeting in India', + name: 'inMeeting', + type: 'boolean', + default: false, + description: 'Host Meeting in India.', + }, + { + displayName: 'Host Video', + name: 'hostVideo', + type: 'boolean', + default: false, + description: 'Start video when host joins the meeting.', + }, + { + displayName: 'Join Before Host', + name: 'joinBeforeHost', + type: 'boolean', + default: false, + description: 'Allow participants to join the meeting before host starts it.', + }, + { + displayName: 'Muting Upon Entry', + name: 'muteUponEntry', + type: 'boolean', + default: false, + description: 'Mute participants upon entry.', + }, + { + displayName: 'Participant Video', + name: 'participantVideo', + type: 'boolean', + default: false, + description: 'Start video when participant joins the meeting.', + }, + { + displayName: 'Registration Type', + name: 'registrationType', + type: 'options', + options: [ + { + name: 'Attendees register once and can attend any of the occurences', + value: 1, + }, + { + name: 'Attendees need to register for every occurrence', + value: 2, + }, + { + name: 'Attendees register once and can choose one or more occurrences to attend', + value: 3, + }, + ], + default: 1, + description: 'Registration type. Used for recurring meetings with fixed time only', + }, + { + displayName: 'Watermark', + name: 'watermark', + type: 'boolean', + default: false, + description: 'Adds watermark when viewing a shared screen.', + }, + ], + }, + { + displayName: 'Start Time', + name: 'startTime', + type: 'dateTime', + default: '', + description: 'Start time should be used only for scheduled or recurring meetings with fixed time', + }, + { + displayName: 'Timezone', + name: 'timeZone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: '', + description: `Time zone used in the response. The default is the time zone of the calendar.`, + }, + { + displayName: 'Topic', + name: 'topic', + type: 'string', + default: '', + description: `Meeting topic.`, + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Instant Meeting', + value: 1, + }, + { + name: 'Scheduled Meeting', + value: 2, + }, + { + name: 'Recurring Meeting with no fixed time', + value: 3, + }, + { + name: 'Recurring Meeting with fixed time', + value: 8, + }, + + ], + default: 2, + description: 'Meeting type.', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts new file mode 100644 index 0000000000..5438f18fa3 --- /dev/null +++ b/packages/nodes-base/nodes/Zoom/MeetingRegistrantDescription.ts @@ -0,0 +1,443 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const meetingRegistrantOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'meetingRegistrant', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create Meeting Registrants', + }, + { + name: 'Update', + value: 'update', + description: 'Update Meeting Registrant Status', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all Meeting Registrants', + }, + + ], + default: 'create', + description: 'The operation to perform.', + } +] as INodeProperties[]; + +export const meetingRegistrantFields = [ + /* -------------------------------------------------------------------------- */ + /* meetingRegistrant:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Meeting Id', + name: 'meetingId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'meetingRegistrant', + ], + }, + }, + description: 'Meeting ID.', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'meetingRegistrant', + ], + }, + }, + description: 'Valid Email-ID.', + }, + { + displayName: 'First Name', + name: 'firstName', + required: true, + type: 'string', + default: '', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'meetingRegistrant', + ], + }, + }, + description: 'First Name.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'create', + + ], + resource: [ + 'meetingRegistrant', + ], + }, + }, + options: [ + { + displayName: 'Address', + name: 'address', + type: 'string', + default: '', + description: 'Valid address of registrant.', + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + description: 'Valid city of registrant.', + }, + { + displayName: 'Comments', + name: 'comments', + type: 'string', + default: '', + description: 'Allows registrants to provide any questions they have.', + }, + { + displayName: 'Country', + name: 'country', + type: 'string', + default: '', + description: 'Valid country of registrant.', + }, + { + displayName: 'Job Title', + name: 'jobTitle', + type: 'string', + default: '', + description: 'Job title of registrant.', + }, + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + default: '', + description: 'Last Name.', + }, + { + displayName: 'Occurrence IDs', + name: 'occurrenceId', + type: 'string', + default: '', + description: 'Occurrence IDs separated by comma.', + }, + { + displayName: 'Organization', + name: 'org', + type: 'string', + default: '', + description: 'Organization of registrant.', + }, + { + displayName: 'Phone Number', + name: 'phone', + type: 'string', + default: '', + description: 'Valid phone number of registrant.', + }, + { + displayName: 'Purchasing Time Frame', + name: 'purchasingTimeFrame', + type: 'options', + options: [ + { + name: 'Within a month', + value: 'Within a month', + }, + { + name: '1-3 months', + value: '1-3 months', + }, + { + name: '4-6 months', + value: '4-6 months', + }, + { + name: 'More than 6 months', + value: 'More than 6 months', + }, + { + name: 'No timeframe', + value: 'No timeframe', + }, + ], + default: '', + description: 'Meeting type.', + }, + { + displayName: 'Role in Purchase Process', + name: 'roleInPurchaseProcess', + type: 'options', + options: [ + { + name: 'Decision Maker', + value: 'Decision Maker', + }, + { + name: 'Evaluator/Recommender', + value: 'Evaluator/Recommender', + }, + { + name: 'Influener', + value: 'Influener', + }, + { + name: 'Not Involved', + value: 'Not Involved', + }, + + ], + default: '', + description: 'Role in purchase process.', + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + description: 'Valid state of registrant.', + }, + { + displayName: 'Zip Code', + name: 'zip', + type: 'string', + default: '', + description: 'Valid zip-code of registrant.', + }, + + ], + }, + /* -------------------------------------------------------------------------- */ + /* meetingRegistrant:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Meeting ID', + name: 'meetingId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'meetingRegistrant', + ], + }, + }, + description: 'Meeting ID.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'meetingRegistrant', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'meetingRegistrant', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 300, + }, + default: 30, + description: 'How many results to return.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'getAll', + + ], + resource: [ + 'meetingRegistrant', + ], + }, + }, + options: [ + { + displayName: 'Occurrence ID', + name: 'occurrenceId', + type: 'string', + default: '', + description: `Occurrence ID.`, + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + options: [ + { + name: 'Pending', + value: 'pending', + }, + { + name: 'Approved', + value: 'approved', + }, + { + name: 'Denied', + value: 'denied', + }, + ], + default: 'approved', + description: `Registrant Status.`, + }, + + ] + }, + /* -------------------------------------------------------------------------- */ + /* meetingRegistrant:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Meeting ID', + name: 'meetingId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'meetingRegistrant', + ], + }, + }, + description: 'Meeting ID.', + }, + { + displayName: 'Action', + name: 'action', + type: 'options', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'meetingRegistrant', + ], + }, + }, + options: [ + { + name: 'Cancel', + value: 'cancel', + }, + { + name: 'Approved', + value: 'approve', + }, + { + name: 'Deny', + value: 'deny', + }, + ], + default: '', + description: `Registrant Status.`, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'meetingRegistrant', + ], + }, + }, + options: [ + { + displayName: 'Occurrence ID', + name: 'occurrenceId', + type: 'string', + default: '', + description: 'Occurrence ID.', + }, + + ], + } + +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Zoom/WebinarDescription.ts b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts new file mode 100644 index 0000000000..421eb2cf50 --- /dev/null +++ b/packages/nodes-base/nodes/Zoom/WebinarDescription.ts @@ -0,0 +1,665 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const webinarOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'webinar', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a webinar', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a webinar', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a webinar', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all webinars', + }, + { + name: 'Update', + value: 'update', + description: 'Update a webinar', + } + ], + default: 'create', + description: 'The operation to perform.', + } +] as INodeProperties[]; + +export const webinarFields = [ + /* -------------------------------------------------------------------------- */ + /* webinar:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'webinar', + ], + }, + }, + description: 'User ID or email ID.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'create', + + ], + resource: [ + 'webinar', + ], + } + }, + options: [ + { + displayName: 'Agenda', + name: 'agenda', + type: 'string', + default: '', + description: 'Webinar agenda.', + }, + { + displayName: 'Alternative Hosts', + name: 'alternativeHosts', + type: 'string', + default: '', + description: 'Alternative hosts email IDs.', + }, + { + displayName: 'Approval Type', + name: 'approvalType', + type: 'options', + options: [ + { + name: 'Automatically Approve', + value: 0, + }, + { + name: 'Manually Approve', + value: 1, + }, + { + name: 'No Registration Required', + value: 2, + }, + ], + default: 2, + description: 'Approval type.', + }, + { + displayName: 'Audio', + name: 'audio', + type: 'options', + options: [ + { + name: 'Both Telephony and VoiP', + value: 'both', + }, + { + name: 'Telephony', + value: 'telephony', + }, + { + name: 'VOIP', + value: 'voip', + }, + + ], + default: 'both', + description: 'Determine how participants can join audio portion of the webinar.', + }, + { + displayName: 'Auto Recording', + name: 'autoRecording', + type: 'options', + options: [ + { + name: 'Record on Local', + value: 'local', + }, + { + name: 'Record on Cloud', + value: 'cloud', + }, + { + name: 'Disabled', + value: 'none', + }, + ], + default: 'none', + description: 'Auto recording.', + }, + { + displayName: 'Duration', + name: 'duration', + type: 'string', + default: '', + description: 'Duration.', + }, + { + displayName: 'Host Video', + name: 'hostVideo', + type: 'boolean', + default: false, + description: 'Start video when host joins the webinar.', + }, + { + displayName: 'Panelists Video', + name: 'panelistsVideo', + type: 'boolean', + default: false, + description: 'Start video when panelists joins the webinar.', + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: 'Password to join the webinar with maximum 10 characters.', + }, + { + displayName: 'Practice Session', + name: 'practiceSession', + type: 'boolean', + default: false, + description: 'Enable Practice session.', + }, + { + displayName: 'Registration Type', + name: 'registrationType', + type: 'options', + options: [ + { + name: 'Attendees register once and can attend any of the occurences', + value: 1, + }, + { + name: 'Attendees need to register for every occurrence', + value: 2, + }, + { + name: 'Attendees register once and can choose one or more occurrences to attend', + value: 3, + }, + ], + default: 1, + description: 'Registration type. Used for recurring webinar with fixed time only', + }, + { + displayName: 'Start Time', + name: 'startTime', + type: 'dateTime', + default: '', + description: 'Start time should be used only for scheduled or recurring webinar with fixed time', + }, + { + displayName: 'Timezone', + name: 'timeZone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: '', + description: `Time zone used in the response. The default is the time zone of the calendar.`, + }, + { + displayName: 'Webinar Topic', + name: 'topic', + type: 'string', + default: '', + description: `Webinar topic.`, + }, + { + displayName: 'Webinar Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Webinar', + value: 5, + }, + { + name: 'Recurring webinar with no fixed time', + value: 6, + }, + { + name: 'Recurring webinar with fixed time', + value: 9, + }, + ], + default: 5, + description: 'Webinar type.', + }, + + ], + }, + /* -------------------------------------------------------------------------- */ + /* webinar:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Webinar ID', + name: 'webinarId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'webinar', + ], + }, + }, + description: 'Webinar ID.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'get', + + ], + resource: [ + 'webinar', + ], + }, + }, + options: [ + { + displayName: 'Occurence ID', + name: 'occurenceId', + type: 'string', + default: '', + description: 'To view webinar details of a particular occurrence of the recurring webinar.', + }, + { + displayName: 'Show Previous Occurrences', + name: 'showPreviousOccurrences', + type: 'boolean', + default: '', + description: 'To view webinar details of all previous occurrences of the recurring webinar.', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* webinar:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'webinar', + ], + }, + }, + description: 'User ID or email-ID.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'webinar', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'webinar', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 300, + }, + default: 30, + description: 'How many results to return.', + }, + /* -------------------------------------------------------------------------- */ + /* webinar:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Webinar ID', + name: 'webinarId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'webinarId', + ], + }, + }, + description: 'Webinar ID.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'webinar', + ], + }, + }, + options: [ + { + displayName: 'Occurrence ID', + name: 'occurrenceId', + type: 'string', + default: '', + description: 'Webinar occurrence ID.', + }, + + ], + + }, + /* -------------------------------------------------------------------------- */ + /* webinar:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Webinar ID', + name: 'webinarId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'webinar', + ], + }, + }, + description: 'User ID or email address of user.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'update', + + ], + resource: [ + 'webinar', + ], + }, + }, + options: [ + { + displayName: 'Agenda', + name: 'agenda', + type: 'string', + default: '', + description: 'Webinar agenda.', + }, + { + displayName: 'Alternative Hosts', + name: 'alternativeHosts', + type: 'string', + default: '', + description: 'Alternative hosts email IDs.', + }, + { + displayName: 'Approval Type', + name: 'approvalType', + type: 'options', + options: [ + { + name: 'Automatically Approve', + value: 0, + }, + { + name: 'Manually Approve', + value: 1, + }, + { + name: 'No Registration Required', + value: 2, + }, + ], + default: 2, + description: 'Approval type.', + }, + { + displayName: 'Auto Recording', + name: 'autoRecording', + type: 'options', + options: [ + { + name: 'Record on Local', + value: 'local', + }, + { + name: 'Record on Cloud', + value: 'cloud', + }, + { + name: 'Disabled', + value: 'none', + }, + ], + default: 'none', + description: 'Auto recording.', + }, + { + displayName: 'Audio', + name: 'audio', + type: 'options', + options: [ + { + name: 'Both Telephony and VoiP', + value: 'both', + }, + { + name: 'Telephony', + value: 'telephony', + }, + { + name: 'VOIP', + value: 'voip', + }, + + ], + default: 'both', + description: 'Determine how participants can join audio portion of the webinar.', + }, + { + displayName: 'Duration', + name: 'duration', + type: 'string', + default: '', + description: 'Duration.', + }, + { + displayName: 'Host Video', + name: 'hostVideo', + type: 'boolean', + default: false, + description: 'Start video when host joins the webinar.', + }, + { + displayName: 'Occurrence ID', + name: 'occurrenceId', + type: 'string', + default: '', + description: `Webinar occurrence ID.`, + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: 'Password to join the webinar with maximum 10 characters.', + }, + { + displayName: 'Panelists Video', + name: 'panelistsVideo', + type: 'boolean', + default: false, + description: 'Start video when panelists joins the webinar.', + }, + { + displayName: 'Practice Session', + name: 'practiceSession', + type: 'boolean', + default: false, + description: 'Enable Practice session.', + }, + { + displayName: 'Registration Type', + name: 'registrationType', + type: 'options', + options: [ + { + name: 'Attendees register once and can attend any of the occurrences', + value: 1, + }, + { + name: 'Attendees need to register for every occurrence', + value: 2, + }, + { + name: 'Attendees register once and can choose one or more occurrences to attend', + value: 3, + }, + ], + default: 1, + description: 'Registration type. Used for recurring webinars with fixed time only.', + }, + { + displayName: 'Start Time', + name: 'startTime', + type: 'dateTime', + default: '', + description: 'Start time should be used only for scheduled or recurring webinar with fixed time.', + }, + { + displayName: 'Timezone', + name: 'timeZone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: '', + description: `Time zone used in the response. The default is the time zone of the calendar.`, + }, + { + displayName: 'Webinar Topic', + name: 'topic', + type: 'string', + default: '', + description: `Webinar topic.`, + }, + { + displayName: 'Webinar Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Webinar', + value: 5, + }, + { + name: 'Recurring webinar with no fixed time', + value: 6, + }, + { + name: 'Recurring webinar with fixed time', + value: 9, + }, + ], + default: 5, + description: 'Webinar type.' + }, + ], + }, + +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Zoom/Zoom.node.ts b/packages/nodes-base/nodes/Zoom/Zoom.node.ts new file mode 100644 index 0000000000..f7ecff0de8 --- /dev/null +++ b/packages/nodes-base/nodes/Zoom/Zoom.node.ts @@ -0,0 +1,821 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + zoomApiRequest, + zoomApiRequestAllItems, +} from './GenericFunctions'; + +import { + meetingOperations, + meetingFields, +} from './MeetingDescription'; + +// import { +// meetingRegistrantOperations, +// meetingRegistrantFields, +// } from './MeetingRegistrantDescription'; + +// import { +// webinarOperations, +// webinarFields, +// } from './WebinarDescription'; + +import * as moment from 'moment-timezone'; + +interface Settings { + host_video?: boolean; + participant_video?: boolean; + panelists_video?: boolean; + cn_meeting?: boolean; + in_meeting?: boolean; + join_before_host?: boolean; + mute_upon_entry?: boolean; + watermark?: boolean; + waiting_room?: boolean; + audio?: string; + alternative_hosts?: string; + auto_recording?: string; + registration_type?: number; + approval_type?: number; + practice_session?: boolean; +} + +export class Zoom implements INodeType { + description: INodeTypeDescription = { + displayName: 'Zoom', + name: 'zoom', + group: ['input'], + version: 1, + description: 'Consume Zoom API', + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + defaults: { + name: 'Zoom', + color: '#0B6CF9' + }, + icon: 'file:zoom.png', + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + // create a JWT app on Zoom Marketplace + //https://marketplace.zoom.us/develop/create + //get the JWT token as access token + name: 'zoomApi', + required: true, + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + //create a account level OAuth app + //https://marketplace.zoom.us/develop/create + name: 'zoomOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Meeting', + value: 'meeting' + }, + // { + // name: 'Meeting Registrant', + // value: 'meetingRegistrant' + // }, + // { + // name: 'Webinar', + // value: 'webinar' + // } + ], + default: 'meeting', + description: 'The resource to operate on.' + }, + //MEETINGS + ...meetingOperations, + ...meetingFields, + + // //MEETING REGISTRANTS + // ...meetingRegistrantOperations, + // ...meetingRegistrantFields, + + // //WEBINARS + // ...webinarOperations, + // ...webinarFields, + ] + + }; + methods = { + loadOptions: { + // Get all the timezones to display them to user so that he can select them easily + async getTimezones( + this: ILoadOptionsFunctions + ): Promise { + const returnData: INodePropertyOptions[] = []; + for (const timezone of moment.tz.names()) { + const timezoneName = timezone; + const timezoneId = timezone; + returnData.push({ + name: timezoneName, + value: timezoneId + }); + } + return returnData; + } + } + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + let qs: IDataObject = {}; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < items.length; i++) { + qs = {}; + //https://marketplace.zoom.us/docs/api-reference/zoom-api/ + if (resource === 'meeting') { + + if (operation === 'get') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meeting + const meetingId = this.getNodeParameter('meetingId', i) as string; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + + if (additionalFields.showPreviousOccurrences) { + qs.show_previous_occurrences = additionalFields.showPreviousOccurrences as boolean; + } + + if (additionalFields.occurrenceId) { + qs.occurrence_id = additionalFields.occurrenceId as string; + } + + responseData = await zoomApiRequest.call( + this, + 'GET', + `/meetings/${meetingId}`, + {}, + qs + ); + } + if (operation === 'getAll') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetings + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + const filters = this.getNodeParameter( + 'filters', + i + ) as IDataObject; + if (filters.type) { + qs.type = filters.type as string; + } + + if (returnAll) { + responseData = await zoomApiRequestAllItems.call(this, 'meetings', 'GET', '/users/me/meetings', {}, qs); + } else { + qs.page_size = this.getNodeParameter('limit', i) as number; + responseData = await zoomApiRequest.call(this, 'GET', '/users/me/meetings', {}, qs); + responseData = responseData.meetings; + } + + } + if (operation === 'delete') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingdelete + const meetingId = this.getNodeParameter('meetingId', i) as string; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + if (additionalFields.scheduleForReminder) { + qs.schedule_for_reminder = additionalFields.scheduleForReminder as boolean; + } + + if (additionalFields.occurrenceId) { + qs.occurrence_id = additionalFields.occurrenceId; + } + + responseData = await zoomApiRequest.call( + this, + 'DELETE', + `/meetings/${meetingId}`, + {}, + qs + ); + responseData = { success: true }; + } + if (operation === 'create') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const body: IDataObject = {}; + + if (additionalFields.settings) { + const settingValues: Settings = {}; + const settings = additionalFields.settings as IDataObject; + + if (settings.cnMeeting) { + settingValues.cn_meeting = settings.cnMeeting as boolean; + } + + if (settings.inMeeting) { + settingValues.in_meeting = settings.inMeeting as boolean; + } + + if (settings.joinBeforeHost) { + settingValues.join_before_host = settings.joinBeforeHost as boolean; + } + + if (settings.muteUponEntry) { + settingValues.mute_upon_entry = settings.muteUponEntry as boolean; + } + + if (settings.watermark) { + settingValues.watermark = settings.watermark as boolean; + } + + if (settings.audio) { + settingValues.audio = settings.audio as string; + } + + if (settings.alternativeHosts) { + settingValues.alternative_hosts = settings.alternativeHosts as string; + } + + if (settings.participantVideo) { + settingValues.participant_video = settings.participantVideo as boolean; + } + + if (settings.hostVideo) { + settingValues.host_video = settings.hostVideo as boolean; + } + + if (settings.autoRecording) { + settingValues.auto_recording = settings.autoRecording as string; + } + + if (settings.registrationType) { + settingValues.registration_type = settings.registrationType as number; + } + + body.settings = settingValues; + } + + body.topic = this.getNodeParameter('topic', i) as string; + + if (additionalFields.type) { + body.type = additionalFields.type as string; + } + + if (additionalFields.startTime) { + if (additionalFields.timeZone) { + body.start_time = moment(additionalFields.startTime as string).format('YYYY-MM-DDTHH:mm:ss'); + } else { + // if none timezone it's defined used n8n timezone + body.start_time = moment.tz(additionalFields.startTime as string, this.getTimezone()).format(); + } + } + + if (additionalFields.duration) { + body.duration = additionalFields.duration as number; + } + + if (additionalFields.scheduleFor) { + body.schedule_for = additionalFields.scheduleFor as string; + } + + if (additionalFields.timeZone) { + body.timezone = additionalFields.timeZone as string; + } + + if (additionalFields.password) { + body.password = additionalFields.password as string; + } + + if (additionalFields.agenda) { + body.agenda = additionalFields.agenda as string; + } + + responseData = await zoomApiRequest.call( + this, + 'POST', + `/users/me/meetings`, + body, + qs + ); + } + if (operation === 'update') { + //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingupdate + const meetingId = this.getNodeParameter('meetingId', i) as string; + const updateFields = this.getNodeParameter( + 'updateFields', + i + ) as IDataObject; + + const body: IDataObject = {}; + + if (updateFields.settings) { + const settingValues: Settings = {}; + const settings = updateFields.settings as IDataObject; + + if (settings.cnMeeting) { + settingValues.cn_meeting = settings.cnMeeting as boolean; + } + + if (settings.inMeeting) { + settingValues.in_meeting = settings.inMeeting as boolean; + } + + if (settings.joinBeforeHost) { + settingValues.join_before_host = settings.joinBeforeHost as boolean; + } + + if (settings.muteUponEntry) { + settingValues.mute_upon_entry = settings.muteUponEntry as boolean; + } + + if (settings.watermark) { + settingValues.watermark = settings.watermark as boolean; + } + + if (settings.audio) { + settingValues.audio = settings.audio as string; + } + + if (settings.alternativeHosts) { + settingValues.alternative_hosts = settings.alternativeHosts as string; + } + + if (settings.participantVideo) { + settingValues.participant_video = settings.participantVideo as boolean; + } + + if (settings.hostVideo) { + settingValues.host_video = settings.hostVideo as boolean; + } + + if (settings.autoRecording) { + settingValues.auto_recording = settings.autoRecording as string; + } + + if (settings.registrationType) { + settingValues.registration_type = settings.registrationType as number; + } + + body.settings = settingValues; + } + + if (updateFields.topic) { + body.topic = updateFields.topic as string; + } + + if (updateFields.type) { + body.type = updateFields.type as string; + } + + if (updateFields.startTime) { + body.start_time = updateFields.startTime as string; + } + + if (updateFields.duration) { + body.duration = updateFields.duration as number; + } + + if (updateFields.scheduleFor) { + body.schedule_for = updateFields.scheduleFor as string; + } + + if (updateFields.timeZone) { + body.timezone = updateFields.timeZone as string; + } + + if (updateFields.password) { + body.password = updateFields.password as string; + } + + if (updateFields.agenda) { + body.agenda = updateFields.agenda as string; + } + + responseData = await zoomApiRequest.call( + this, + 'PATCH', + `/meetings/${meetingId}`, + body, + qs + ); + + responseData = { success: true }; + + } + } + // if (resource === 'meetingRegistrant') { + // if (operation === 'create') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrantcreate + // const meetingId = this.getNodeParameter('meetingId', i) as string; + // const emailId = this.getNodeParameter('email', i) as string; + // body.email = emailId; + // const firstName = this.getNodeParameter('firstName', i) as string; + // body.first_name = firstName; + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + // if (additionalFields.occurrenceId) { + // qs.occurrence_ids = additionalFields.occurrenceId as string; + // } + // if (additionalFields.lastName) { + // body.last_name = additionalFields.lastName as string; + // } + // if (additionalFields.address) { + // body.address = additionalFields.address as string; + // } + // if (additionalFields.city) { + // body.city = additionalFields.city as string; + // } + // if (additionalFields.state) { + // body.state = additionalFields.state as string; + // } + // if (additionalFields.country) { + // body.country = additionalFields.country as string; + // } + // if (additionalFields.zip) { + // body.zip = additionalFields.zip as string; + // } + // if (additionalFields.phone) { + // body.phone = additionalFields.phone as string; + // } + // if (additionalFields.comments) { + // body.comments = additionalFields.comments as string; + // } + // if (additionalFields.org) { + // body.org = additionalFields.org as string; + // } + // if (additionalFields.jobTitle) { + // body.job_title = additionalFields.jobTitle as string; + // } + // if (additionalFields.purchasingTimeFrame) { + // body.purchasing_time_frame = additionalFields.purchasingTimeFrame as string; + // } + // if (additionalFields.roleInPurchaseProcess) { + // body.role_in_purchase_process = additionalFields.roleInPurchaseProcess as string; + // } + // responseData = await zoomApiRequest.call( + // this, + // 'POST', + // `/meetings/${meetingId}/registrants`, + // body, + // qs + // ); + // } + // if (operation === 'getAll') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrants + // const meetingId = this.getNodeParameter('meetingId', i) as string; + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + // if (additionalFields.occurrenceId) { + // qs.occurrence_id = additionalFields.occurrenceId as string; + // } + // if (additionalFields.status) { + // qs.status = additionalFields.status as string; + // } + // const returnAll = this.getNodeParameter('returnAll', i) as boolean; + // if (returnAll) { + // responseData = await zoomApiRequestAllItems.call(this, 'results', 'GET', `/meetings/${meetingId}/registrants`, {}, qs); + // } else { + // qs.page_size = this.getNodeParameter('limit', i) as number; + // responseData = await zoomApiRequest.call(this, 'GET', `/meetings/${meetingId}/registrants`, {}, qs); + + // } + + // } + // if (operation === 'update') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingregistrantstatus + // const meetingId = this.getNodeParameter('meetingId', i) as string; + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + // if (additionalFields.occurenceId) { + // qs.occurrence_id = additionalFields.occurrenceId as string; + // } + // if (additionalFields.action) { + // body.action = additionalFields.action as string; + // } + // responseData = await zoomApiRequest.call( + // this, + // 'PUT', + // `/meetings/${meetingId}/registrants/status`, + // body, + // qs + // ); + // } + // } + // if (resource === 'webinar') { + // if (operation === 'create') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinarcreate + // const userId = this.getNodeParameter('userId', i) as string; + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + // const settings: Settings = {}; + + + // if (additionalFields.audio) { + // settings.audio = additionalFields.audio as string; + + // } + + // if (additionalFields.alternativeHosts) { + // settings.alternative_hosts = additionalFields.alternativeHosts as string; + + // } + + // if (additionalFields.panelistsVideo) { + // settings.panelists_video = additionalFields.panelistsVideo as boolean; + + // } + // if (additionalFields.hostVideo) { + // settings.host_video = additionalFields.hostVideo as boolean; + + // } + // if (additionalFields.practiceSession) { + // settings.practice_session = additionalFields.practiceSession as boolean; + + // } + // if (additionalFields.autoRecording) { + // settings.auto_recording = additionalFields.autoRecording as string; + + // } + + // if (additionalFields.registrationType) { + // settings.registration_type = additionalFields.registrationType as number; + + // } + // if (additionalFields.approvalType) { + // settings.approval_type = additionalFields.approvalType as number; + + // } + + // body = { + // settings, + // }; + + // if (additionalFields.topic) { + // body.topic = additionalFields.topic as string; + + // } + + // if (additionalFields.type) { + // body.type = additionalFields.type as string; + + // } + + // if (additionalFields.startTime) { + // body.start_time = additionalFields.startTime as string; + + // } + + // if (additionalFields.duration) { + // body.duration = additionalFields.duration as number; + + // } + + + // if (additionalFields.timeZone) { + // body.timezone = additionalFields.timeZone as string; + + // } + + // if (additionalFields.password) { + // body.password = additionalFields.password as string; + + // } + + // if (additionalFields.agenda) { + // body.agenda = additionalFields.agenda as string; + + // } + // responseData = await zoomApiRequest.call( + // this, + // 'POST', + // `/users/${userId}/webinars`, + // body, + // qs + // ); + // } + // if (operation === 'get') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinar + // const webinarId = this.getNodeParameter('webinarId', i) as string; + + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + // if (additionalFields.showPreviousOccurrences) { + // qs.show_previous_occurrences = additionalFields.showPreviousOccurrences as boolean; + + // } + + // if (additionalFields.occurrenceId) { + // qs.occurrence_id = additionalFields.occurrenceId as string; + + // } + + // responseData = await zoomApiRequest.call( + // this, + // 'GET', + // `/webinars/${webinarId}`, + // {}, + // qs + // ); + // } + // if (operation === 'getAll') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinars + // const userId = this.getNodeParameter('userId', i) as string; + // const returnAll = this.getNodeParameter('returnAll', i) as boolean; + // if (returnAll) { + // responseData = await zoomApiRequestAllItems.call(this, 'results', 'GET', `/users/${userId}/webinars`, {}, qs); + // } else { + // qs.page_size = this.getNodeParameter('limit', i) as number; + // responseData = await zoomApiRequest.call(this, 'GET', `/users/${userId}/webinars`, {}, qs); + + // } + // } + // if (operation === 'delete') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinardelete + // const webinarId = this.getNodeParameter('webinarId', i) as string; + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + + + // if (additionalFields.occurrenceId) { + // qs.occurrence_id = additionalFields.occurrenceId; + + // } + + // responseData = await zoomApiRequest.call( + // this, + // 'DELETE', + // `/webinars/${webinarId}`, + // {}, + // qs + // ); + // responseData = { success: true }; + // } + // if (operation === 'update') { + // //https://marketplace.zoom.us/docs/api-reference/zoom-api/webinars/webinarupdate + // const webinarId = this.getNodeParameter('webinarId', i) as string; + // const additionalFields = this.getNodeParameter( + // 'additionalFields', + // i + // ) as IDataObject; + // if (additionalFields.occurrenceId) { + // qs.occurrence_id = additionalFields.occurrenceId as string; + + // } + // const settings: Settings = {}; + // if (additionalFields.audio) { + // settings.audio = additionalFields.audio as string; + + // } + // if (additionalFields.alternativeHosts) { + // settings.alternative_hosts = additionalFields.alternativeHosts as string; + + // } + + // if (additionalFields.panelistsVideo) { + // settings.panelists_video = additionalFields.panelistsVideo as boolean; + + // } + // if (additionalFields.hostVideo) { + // settings.host_video = additionalFields.hostVideo as boolean; + + // } + // if (additionalFields.practiceSession) { + // settings.practice_session = additionalFields.practiceSession as boolean; + + // } + // if (additionalFields.autoRecording) { + // settings.auto_recording = additionalFields.autoRecording as string; + + // } + + // if (additionalFields.registrationType) { + // settings.registration_type = additionalFields.registrationType as number; + + // } + // if (additionalFields.approvalType) { + // settings.approval_type = additionalFields.approvalType as number; + + // } + + // body = { + // settings, + // }; + + // if (additionalFields.topic) { + // body.topic = additionalFields.topic as string; + + // } + + // if (additionalFields.type) { + // body.type = additionalFields.type as string; + + // } + + // if (additionalFields.startTime) { + // body.start_time = additionalFields.startTime as string; + + // } + + // if (additionalFields.duration) { + // body.duration = additionalFields.duration as number; + + // } + + + // if (additionalFields.timeZone) { + // body.timezone = additionalFields.timeZone as string; + + // } + + // if (additionalFields.password) { + // body.password = additionalFields.password as string; + + // } + + // if (additionalFields.agenda) { + // body.agenda = additionalFields.agenda as string; + + // } + // responseData = await zoomApiRequest.call( + // this, + // 'PATCH', + // `webinars/${webinarId}`, + // body, + // qs + // ); + // } + // } + } + 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/Zoom/zoom.png b/packages/nodes-base/nodes/Zoom/zoom.png new file mode 100644 index 0000000000000000000000000000000000000000..dfc72331ebddb8f4f2bfded903500be639581567 GIT binary patch literal 1848 zcmV-82gmq{P)Px%JbF}EbW&k=AaHVTW@&6?Aar?fWgvKMZ~y=}jg?hP zvfLmH+;fT?0ZT$M$MFnim#Qpt{B*;7-uQVkiCtx5B-E{z!0GqTU+D+0SS9IId#sUt zarKLrFv%`nJiPj@O=Rzv%cYB8zPfRygcfU>twlD4@9H(#8e3pt(b#M+=6EBZDi(=o z*Ilw+W7REI$3kxe^F=bh{px!tZHWkAW8TR_w`q8|4<^qrn7eR=Jyu9AtF@AokX(;3 zrA2mCY2cG}QJ^aF#oVH%g=5TJF)PC^((#^8Ptu0lHXJv&gyh35xAX)o;8VoR{E(WY zVPtx181Aea(&;fS*#lm(55@uHMJw+I6T!1h05A#-)Q5_oij4@gzp?-YlnR9quS2N{ zh2U|w`;s774+u+EQ`oPypupex4zT@MB8)X**pSK!KAGepZNAs)7}6TL=S|r38uvUb z0=ARj8dli4L*w+B^m%YLcR(-rbcRczLnFb0GUHR~iOujvfeV5dYgY)+{rasbGZ?{v zPC7=J4O$d!m<1g>cHpe(@R0fh8H#O2E0Ux}q+hJssN8fyN_A|}O?d@c5S@;VSkb)I zgnf^1Y*1UUE^AISVpxZ{D*QlbK|V)hrlC=|3(4>Z%+5YRw&fl2JK*%_;aBuBdf0)E z4~$v4{H5TV-?X_)lm1bg_{1=sYV#HOgEn_*(gAt>Uu}e^0K~zWE&cC%d}{dgn&;7D z_I7%v=P5i*C%*!uG!K2k^At~i0DWd{JA-D?0ssI232;bRa{vGU0RR910RT@W#MS@+ z0Kia8R7L;)|0#0+DY^eCYXATL{V96?D7OFq{{Q{{|0uBkDyjb|ZvQB%|0{t1D5d`> zp#LbW|0{FUP>}zY$o^Hp|AxN*aHamW+Wp(+{&2?rNUr}pa{YDmg%SV&1Gz~= zK~zY`)t75ysxS#S=h0`rOkJY$Xx#LekY=iKf8|ErU$BL)MyWD&~x4T#m6|#L%aLQ;3GC zb4sdyc&(v7JRu(z95Jor$4Ro@{W+`MI^mp`L-X~`>ZXQPH*2ok8Y*u7*Xp8%ZeUq` z@%(`^Tsd9WVx?AVo<(MIyqA}k7qAX4&*=hp$sunDOd#+e2M5|Ex8b&0t3}IptHCdH zwNoB18@^s&{YJ08@`!ze1z+nT*RMXy%Z>otxdyebype25SWH1kAd~(DUe*H?zIxn8ifElSQGzW)pxRvbF(oHlIxF4}vWOU~rxJ z2S))5X<$mBGe(o|1TzHq$p*ifpS4eVgr9Vg(Vl$8!UAbOO$1zue0QgXvAwh84LHsi zrvTq6ktKX5q#ZN(0gUaMV%q3eiX7lMU1We5z*Mwb&VL8XF~4E2qWGu~4)U}qCWCMg z1W$9SsW6hIjgb)!5LP2kgFwLYT=C~ujG4wn!XY|NBYK1*3BMpLNv8ZHz?k>=s9*pz zgGWS#UtHIfaCJVwNaSu4DvIPj48U(1RpAhZ#*TA{V|hl?(EVWep4^Av=xSj1Wh)D* zG&|vRG)5cRm{a(K)JlV=E(xtx~ z9oi5BxY0kYu6ocYMD~}BZVffEU1RCAETcdei;A0q@R7`!d62lVRZ<=*z&}I(|JN63T z<8-_k*U)C1#)r8lHfB7C$!EI#xjmzwZTg8n97>lFb9`uh%paXm#Ico8{I^01|5Z3Y m^XEj^W09%Wm7f*)|MeG&CZon#`=kW`0000 [['a', 'b'], ['c', 'd']] + * + * chunk(['a', 'b', 'c', 'd'], 3) + * // => [['a', 'b', 'c'], ['d']] + */ +export function chunk(array: any[], size = 1) { // tslint:disable-line:no-any + const length = array == null ? 0 : array.length; + if (!length || size < 1) { + return []; + } + let index = 0; + let resIndex = 0; + const result = new Array(Math.ceil(length / size)); + + while (index < length) { + result[resIndex++] = array.slice(index, (index += size)); + } + return result; +} + +/** + * Takes a multidimensional array and converts it to a one-dimensional array. + * + * @param {Array} nestedArray The array to be flattened. + * @returns {Array} Returns the new flattened array. + * @example + * + * flatten([['a', 'b'], ['c', 'd']]) + * // => ['a', 'b', 'c', 'd'] + * + */ +export function flatten(nestedArray: any[][]) { // tslint:disable-line:no-any + const result = []; + + (function loop(array: any[]) { // tslint:disable-line:no-any + for (let i = 0; i < array.length; i++) { + if (Array.isArray(array[i])) { + loop(array[i]); + } else { + result.push(array[i]); + } + } + })(nestedArray); + + return result; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 57332957ee..5513c78130 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,29 +1,160 @@ { - "name": "n8n-nodes-base", - "version": "0.63.1", - "description": "Base nodes of n8n", - "license": "SEE LICENSE IN LICENSE.md", - "homepage": "https://n8n.io", - "author": { - "name": "Jan Oberhauser", - "email": "jan@n8n.io" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/n8n-io/n8n.git" - }, - "main": "dist/src/index", - "types": "dist/src/index.d.ts", - "scripts": { - "dev": "npm run watch", - "build": "tsc && gulp", - "tslint": "tslint -p tsconfig.json -c tslint.json", - "watch": "tsc --watch", - "test": "jest" - }, - "files": [ - "dist" + "name": "n8n-nodes-base", + "version": "0.69.0", + "description": "Base nodes of n8n", + "license": "SEE LICENSE IN LICENSE.md", + "homepage": "https://n8n.io", + "author": { + "name": "Jan Oberhauser", + "email": "jan@n8n.io" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/n8n-io/n8n.git" + }, + "main": "dist/src/index", + "types": "dist/src/index.d.ts", + "scripts": { + "dev": "npm run watch", + "build": "tsc && gulp", + "tslint": "tslint -p tsconfig.json -c tslint.json", + "watch": "tsc --watch", + "test": "jest" + }, + "files": [ + "dist" + ], + "n8n": { + "credentials": [ + "dist/credentials/ActiveCampaignApi.credentials.js", + "dist/credentials/AgileCrmApi.credentials.js", + "dist/credentials/AcuitySchedulingApi.credentials.js", + "dist/credentials/AirtableApi.credentials.js", + "dist/credentials/Amqp.credentials.js", + "dist/credentials/AsanaApi.credentials.js", + "dist/credentials/Aws.credentials.js", + "dist/credentials/AffinityApi.credentials.js", + "dist/credentials/BannerbearApi.credentials.js", + "dist/credentials/BitbucketApi.credentials.js", + "dist/credentials/BitlyApi.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", + "dist/credentials/CockpitApi.credentials.js", + "dist/credentials/CodaApi.credentials.js", + "dist/credentials/CopperApi.credentials.js", + "dist/credentials/CalendlyApi.credentials.js", + "dist/credentials/CrateDb.credentials.js", + "dist/credentials/DisqusApi.credentials.js", + "dist/credentials/DriftApi.credentials.js", + "dist/credentials/DriftOAuth2Api.credentials.js", + "dist/credentials/DropboxApi.credentials.js", + "dist/credentials/EventbriteApi.credentials.js", + "dist/credentials/EventbriteOAuth2Api.credentials.js", + "dist/credentials/FacebookGraphApi.credentials.js", + "dist/credentials/FreshdeskApi.credentials.js", + "dist/credentials/FileMaker.credentials.js", + "dist/credentials/FlowApi.credentials.js", + "dist/credentials/GithubApi.credentials.js", + "dist/credentials/GithubOAuth2Api.credentials.js", + "dist/credentials/GitlabApi.credentials.js", + "dist/credentials/GitlabOAuth2Api.credentials.js", + "dist/credentials/GoogleApi.credentials.js", + "dist/credentials/GoogleCalendarOAuth2Api.credentials.js", + "dist/credentials/GoogleDriveOAuth2Api.credentials.js", + "dist/credentials/GoogleOAuth2Api.credentials.js", + "dist/credentials/GoogleSheetsOAuth2Api.credentials.js", + "dist/credentials/GoogleTasksOAuth2Api.credentials.js", + "dist/credentials/GumroadApi.credentials.js", + "dist/credentials/HarvestApi.credentials.js", + "dist/credentials/HelpScoutOAuth2Api.credentials.js", + "dist/credentials/HttpBasicAuth.credentials.js", + "dist/credentials/HttpDigestAuth.credentials.js", + "dist/credentials/HttpHeaderAuth.credentials.js", + "dist/credentials/HubspotApi.credentials.js", + "dist/credentials/HubspotDeveloperApi.credentials.js", + "dist/credentials/HubspotOAuth2Api.credentials.js", + "dist/credentials/HunterApi.credentials.js", + "dist/credentials/Imap.credentials.js", + "dist/credentials/IntercomApi.credentials.js", + "dist/credentials/InvoiceNinjaApi.credentials.js", + "dist/credentials/JiraSoftwareCloudApi.credentials.js", + "dist/credentials/JiraSoftwareServerApi.credentials.js", + "dist/credentials/JotFormApi.credentials.js", + "dist/credentials/KeapOAuth2Api.credentials.js", + "dist/credentials/LinkFishApi.credentials.js", + "dist/credentials/MailchimpApi.credentials.js", + "dist/credentials/MailchimpOAuth2Api.credentials.js", + "dist/credentials/MailgunApi.credentials.js", + "dist/credentials/MailjetEmailApi.credentials.js", + "dist/credentials/MailjetSmsApi.credentials.js", + "dist/credentials/MandrillApi.credentials.js", + "dist/credentials/MattermostApi.credentials.js", + "dist/credentials/MauticApi.credentials.js", + "dist/credentials/MauticOAuth2Api.credentials.js", + "dist/credentials/MessageBirdApi.credentials.js", + "dist/credentials/MicrosoftExcelOAuth2Api.credentials.js", + "dist/credentials/MicrosoftOAuth2Api.credentials.js", + "dist/credentials/MicrosoftOneDriveOAuth2Api.credentials.js", + "dist/credentials/MicrosoftSql.credentials.js", + "dist/credentials/MoceanApi.credentials.js", + "dist/credentials/MondayComApi.credentials.js", + "dist/credentials/MongoDb.credentials.js", + "dist/credentials/Msg91Api.credentials.js", + "dist/credentials/MySql.credentials.js", + "dist/credentials/NextCloudApi.credentials.js", + "dist/credentials/NextCloudOAuth2Api.credentials.js", + "dist/credentials/OAuth1Api.credentials.js", + "dist/credentials/OAuth2Api.credentials.js", + "dist/credentials/OpenWeatherMapApi.credentials.js", + "dist/credentials/PagerDutyApi.credentials.js", + "dist/credentials/PagerDutyOAuth2Api.credentials.js", + "dist/credentials/PayPalApi.credentials.js", + "dist/credentials/PipedriveApi.credentials.js", + "dist/credentials/Postgres.credentials.js", + "dist/credentials/PostmarkApi.credentials.js", + "dist/credentials/QuestDb.credentials.js", + "dist/credentials/Redis.credentials.js", + "dist/credentials/RocketchatApi.credentials.js", + "dist/credentials/RundeckApi.credentials.js", + "dist/credentials/ShopifyApi.credentials.js", + "dist/credentials/SalesforceOAuth2Api.credentials.js", + "dist/credentials/SlackApi.credentials.js", + "dist/credentials/SlackOAuth2Api.credentials.js", + "dist/credentials/Sms77Api.credentials.js", + "dist/credentials/Smtp.credentials.js", + "dist/credentials/StripeApi.credentials.js", + "dist/credentials/SalesmateApi.credentials.js", + "dist/credentials/SegmentApi.credentials.js", + "dist/credentials/Signl4Api.credentials.js", + "dist/credentials/SpotifyOAuth2Api.credentials.js", + "dist/credentials/SurveyMonkeyApi.credentials.js", + "dist/credentials/SurveyMonkeyOAuth2Api.credentials.js", + "dist/credentials/TelegramApi.credentials.js", + "dist/credentials/TodoistApi.credentials.js", + "dist/credentials/TrelloApi.credentials.js", + "dist/credentials/TwilioApi.credentials.js", + "dist/credentials/TwitterOAuth1Api.credentials.js", + "dist/credentials/TypeformApi.credentials.js", + "dist/credentials/TypeformOAuth2Api.credentials.js", + "dist/credentials/TogglApi.credentials.js", + "dist/credentials/UpleadApi.credentials.js", + "dist/credentials/VeroApi.credentials.js", + "dist/credentials/WebflowApi.credentials.js", + "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", + "dist/credentials/ZoomApi.credentials.js", + "dist/credentials/ZoomOAuth2Api.credentials.js", + "dist/credentials/ZulipApi.credentials.js" ], +<<<<<<< HEAD "n8n": { "credentials": [ "dist/credentials/ActiveCampaignApi.credentials.js", @@ -282,83 +413,250 @@ "dist/nodes/Zoho/ZohoCrm.node.js", "dist/nodes/Zulip/Zulip.node.js" ] +======= + "nodes": [ + "dist/nodes/ActiveCampaign/ActiveCampaign.node.js", + "dist/nodes/ActiveCampaign/ActiveCampaignTrigger.node.js", + "dist/nodes/AgileCrm/AgileCrm.node.js", + "dist/nodes/Airtable/Airtable.node.js", + "dist/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.js", + "dist/nodes/Amqp/Amqp.node.js", + "dist/nodes/Amqp/AmqpTrigger.node.js", + "dist/nodes/Asana/Asana.node.js", + "dist/nodes/Asana/AsanaTrigger.node.js", + "dist/nodes/Affinity/Affinity.node.js", + "dist/nodes/Affinity/AffinityTrigger.node.js", + "dist/nodes/Aws/AwsLambda.node.js", + "dist/nodes/Aws/S3/AwsS3.node.js", + "dist/nodes/Aws/AwsSes.node.js", + "dist/nodes/Aws/AwsSns.node.js", + "dist/nodes/Aws/AwsSnsTrigger.node.js", + "dist/nodes/Bannerbear/Bannerbear.node.js", + "dist/nodes/Bitbucket/BitbucketTrigger.node.js", + "dist/nodes/Bitly/Bitly.node.js", + "dist/nodes/Calendly/CalendlyTrigger.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", + "dist/nodes/Clockify/ClockifyTrigger.node.js", + "dist/nodes/Cockpit/Cockpit.node.js", + "dist/nodes/Coda/Coda.node.js", + "dist/nodes/Copper/CopperTrigger.node.js", + "dist/nodes/CrateDb/CrateDb.node.js", + "dist/nodes/Cron.node.js", + "dist/nodes/Crypto.node.js", + "dist/nodes/DateTime.node.js", + "dist/nodes/Discord/Discord.node.js", + "dist/nodes/Disqus/Disqus.node.js", + "dist/nodes/Drift/Drift.node.js", + "dist/nodes/Dropbox/Dropbox.node.js", + "dist/nodes/EditImage.node.js", + "dist/nodes/EmailReadImap.node.js", + "dist/nodes/EmailSend.node.js", + "dist/nodes/ErrorTrigger.node.js", + "dist/nodes/Eventbrite/EventbriteTrigger.node.js", + "dist/nodes/ExecuteCommand.node.js", + "dist/nodes/ExecuteWorkflow.node.js", + "dist/nodes/Facebook/FacebookGraphApi.node.js", + "dist/nodes/FileMaker/FileMaker.node.js", + "dist/nodes/Freshdesk/Freshdesk.node.js", + "dist/nodes/Flow/Flow.node.js", + "dist/nodes/Flow/FlowTrigger.node.js", + "dist/nodes/Function.node.js", + "dist/nodes/FunctionItem.node.js", + "dist/nodes/Github/Github.node.js", + "dist/nodes/Github/GithubTrigger.node.js", + "dist/nodes/Gitlab/Gitlab.node.js", + "dist/nodes/Gitlab/GitlabTrigger.node.js", + "dist/nodes/Google/Calendar/GoogleCalendar.node.js", + "dist/nodes/Google/Drive/GoogleDrive.node.js", + "dist/nodes/Google/Sheet/GoogleSheets.node.js", + "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", + "dist/nodes/HtmlExtract/HtmlExtract.node.js", + "dist/nodes/HttpRequest.node.js", + "dist/nodes/Hubspot/Hubspot.node.js", + "dist/nodes/Hubspot/HubspotTrigger.node.js", + "dist/nodes/Hunter/Hunter.node.js", + "dist/nodes/If.node.js", + "dist/nodes/Intercom/Intercom.node.js", + "dist/nodes/Interval.node.js", + "dist/nodes/InvoiceNinja/InvoiceNinja.node.js", + "dist/nodes/InvoiceNinja/InvoiceNinjaTrigger.node.js", + "dist/nodes/Jira/Jira.node.js", + "dist/nodes/JotForm/JotFormTrigger.node.js", + "dist/nodes/Keap/Keap.node.js", + "dist/nodes/Keap/KeapTrigger.node.js", + "dist/nodes/LinkFish/LinkFish.node.js", + "dist/nodes/Mailchimp/Mailchimp.node.js", + "dist/nodes/Mailchimp/MailchimpTrigger.node.js", + "dist/nodes/Mailgun/Mailgun.node.js", + "dist/nodes/Mailjet/Mailjet.node.js", + "dist/nodes/Mailjet/MailjetTrigger.node.js", + "dist/nodes/Mandrill/Mandrill.node.js", + "dist/nodes/Mattermost/Mattermost.node.js", + "dist/nodes/Mautic/Mautic.node.js", + "dist/nodes/Mautic/MauticTrigger.node.js", + "dist/nodes/Merge.node.js", + "dist/nodes/MessageBird/MessageBird.node.js", + "dist/nodes/Microsoft/Excel/MicrosoftExcel.node.js", + "dist/nodes/Microsoft/OneDrive/MicrosoftOneDrive.node.js", + "dist/nodes/Microsoft/Sql/MicrosoftSql.node.js", + "dist/nodes/MoveBinaryData.node.js", + "dist/nodes/Mocean/Mocean.node.js", + "dist/nodes/MondayCom/MondayCom.node.js", + "dist/nodes/MongoDb/MongoDb.node.js", + "dist/nodes/MoveBinaryData.node.js", + "dist/nodes/Msg91/Msg91.node.js", + "dist/nodes/MySql/MySql.node.js", + "dist/nodes/NextCloud/NextCloud.node.js", + "dist/nodes/NoOp.node.js", + "dist/nodes/OpenWeatherMap.node.js", + "dist/nodes/PagerDuty/PagerDuty.node.js", + "dist/nodes/PayPal/PayPal.node.js", + "dist/nodes/PayPal/PayPalTrigger.node.js", + "dist/nodes/Pipedrive/Pipedrive.node.js", + "dist/nodes/Pipedrive/PipedriveTrigger.node.js", + "dist/nodes/Postgres/Postgres.node.js", + "dist/nodes/Postmark/PostmarkTrigger.node.js", + "dist/nodes/QuestDb/QuestDb.node.js", + "dist/nodes/ReadBinaryFile.node.js", + "dist/nodes/ReadBinaryFiles.node.js", + "dist/nodes/ReadPdf.node.js", + "dist/nodes/Redis/Redis.node.js", + "dist/nodes/RenameKeys.node.js", + "dist/nodes/Rocketchat/Rocketchat.node.js", + "dist/nodes/RssFeedRead.node.js", + "dist/nodes/Rundeck/Rundeck.node.js", + "dist/nodes/Salesforce/Salesforce.node.js", + "dist/nodes/Set.node.js", + "dist/nodes/Shopify/Shopify.node.js", + "dist/nodes/Shopify/ShopifyTrigger.node.js", + "dist/nodes/Signl4/Signl4.node.js", + "dist/nodes/Slack/Slack.node.js", + "dist/nodes/Sms77/Sms77.node.js", + "dist/nodes/SplitInBatches.node.js", + "dist/nodes/Spotify/Spotify.node.js", + "dist/nodes/SpreadsheetFile.node.js", + "dist/nodes/SseTrigger.node.js", + "dist/nodes/Start.node.js", + "dist/nodes/Stripe/StripeTrigger.node.js", + "dist/nodes/Switch.node.js", + "dist/nodes/Salesmate/Salesmate.node.js", + "dist/nodes/Segment/Segment.node.js", + "dist/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.js", + "dist/nodes/Telegram/Telegram.node.js", + "dist/nodes/Telegram/TelegramTrigger.node.js", + "dist/nodes/Todoist/Todoist.node.js", + "dist/nodes/Toggl/TogglTrigger.node.js", + "dist/nodes/Trello/Trello.node.js", + "dist/nodes/Trello/TrelloTrigger.node.js", + "dist/nodes/Twilio/Twilio.node.js", + "dist/nodes/Twitter/Twitter.node.js", + "dist/nodes/Typeform/TypeformTrigger.node.js", + "dist/nodes/Uplead/Uplead.node.js", + "dist/nodes/Vero/Vero.node.js", + "dist/nodes/Webflow/WebflowTrigger.node.js", + "dist/nodes/Webhook.node.js", + "dist/nodes/Wordpress/Wordpress.node.js", + "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", + "dist/nodes/Zoho/ZohoCrm.node.js", + "dist/nodes/Zoom/Zoom.node.js", + "dist/nodes/Zulip/Zulip.node.js" + ] + }, + "devDependencies": { + "@types/aws4": "^1.5.1", + "@types/basic-auth": "^1.1.2", + "@types/cheerio": "^0.22.15", + "@types/cron": "^1.6.1", + "@types/eventsource": "^1.1.2", + "@types/express": "^4.16.1", + "@types/formidable": "^1.0.31", + "@types/gm": "^1.18.2", + "@types/imap-simple": "^4.2.0", + "@types/jest": "^24.0.18", + "@types/lodash.set": "^4.3.6", + "@types/moment-timezone": "^0.5.12", + "@types/mongodb": "^3.5.4", + "@types/mssql": "^6.0.2", + "@types/node": "^10.10.1", + "@types/nodemailer": "^6.4.0", + "@types/redis": "^2.8.11", + "@types/request-promise-native": "~1.0.15", + "@types/uuid": "^3.4.6", + "@types/xml2js": "^0.4.3", + "gulp": "^4.0.0", + "jest": "^24.9.0", + "n8n-workflow": "~0.35.0", + "ts-jest": "^24.0.2", + "tslint": "^5.17.0", + "typescript": "~3.7.4" + }, + "dependencies": { + "aws4": "^1.8.0", + "basic-auth": "^2.0.1", + "change-case": "^4.1.1", + "cheerio": "^1.0.0-rc.3", + "cron": "^1.7.2", + "eventsource": "^1.0.7", + "formidable": "^1.2.1", + "glob-promise": "^3.4.0", + "gm": "^1.23.1", + "imap-simple": "^4.3.0", + "iso-639-1": "^2.1.3", + "jsonwebtoken": "^8.5.1", + "lodash.get": "^4.4.2", + "lodash.set": "^4.3.2", + "lodash.unset": "^4.5.2", + "moment": "2.24.0", + "moment-timezone": "^0.5.28", + "mongodb": "^3.5.5", + "mssql": "^6.2.0", + "mysql2": "^2.0.1", + "n8n-core": "~0.39.0", + "nodemailer": "^6.4.6", + "pdf-parse": "^1.1.1", + "pg-promise": "^9.0.3", + "redis": "^2.8.0", + "request": "^2.88.2", + "rhea": "^1.0.11", + "rss-parser": "^3.7.0", + "uuid": "^3.4.0", + "vm2": "^3.6.10", + "xlsx": "^0.14.3", + "xml2js": "^0.4.22" + }, + "jest": { + "transform": { + "^.+\\.tsx?$": "ts-jest" +>>>>>>> master }, - "devDependencies": { - "@types/aws4": "^1.5.1", - "@types/basic-auth": "^1.1.2", - "@types/cheerio": "^0.22.15", - "@types/cron": "^1.6.1", - "@types/eventsource": "^1.1.2", - "@types/express": "^4.16.1", - "@types/formidable": "^1.0.31", - "@types/gm": "^1.18.2", - "@types/imap-simple": "^4.2.0", - "@types/jest": "^24.0.18", - "@types/lodash.set": "^4.3.6", - "@types/moment-timezone": "^0.5.12", - "@types/mongodb": "^3.5.4", - "@types/node": "^10.10.1", - "@types/nodemailer": "^6.4.0", - "@types/redis": "^2.8.11", - "@types/request-promise-native": "~1.0.15", - "@types/uuid": "^3.4.6", - "@types/xml2js": "^0.4.3", - "gulp": "^4.0.0", - "jest": "^24.9.0", - "n8n-workflow": "~0.32.0", - "ts-jest": "^24.0.2", - "tslint": "^5.17.0", - "typescript": "~3.7.4" - }, - "dependencies": { - "aws4": "^1.8.0", - "basic-auth": "^2.0.1", - "change-case": "^4.1.1", - "cheerio": "^1.0.0-rc.3", - "cron": "^1.7.2", - "eventsource": "^1.0.7", - "formidable": "^1.2.1", - "glob-promise": "^3.4.0", - "gm": "^1.23.1", - "googleapis": "~50.0.0", - "imap-simple": "^4.3.0", - "iso-639-1": "^2.1.3", - "jsonwebtoken": "^8.5.1", - "lodash.get": "^4.4.2", - "lodash.set": "^4.3.2", - "lodash.unset": "^4.5.2", - "moment": "2.24.0", - "moment-timezone": "^0.5.28", - "mongodb": "^3.5.5", - "mysql2": "^2.0.1", - "n8n-core": "~0.35.0", - "nodemailer": "^6.4.6", - "pdf-parse": "^1.1.1", - "pg-promise": "^9.0.3", - "redis": "^2.8.0", - "request": "^2.88.2", - "rhea": "^1.0.11", - "rss-parser": "^3.7.0", - "uuid": "^3.4.0", - "vm2": "^3.6.10", - "xlsx": "^0.14.3", - "xml2js": "^0.4.22" - }, - "jest": { - "transform": { - "^.+\\.tsx?$": "ts-jest" - }, - "testURL": "http://localhost/", - "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", - "testPathIgnorePatterns": [ - "/dist/", - "/node_modules/" - ], - "moduleFileExtensions": [ - "ts", - "tsx", - "js", - "json" - ] - } + "testURL": "http://localhost/", + "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", + "testPathIgnorePatterns": [ + "/dist/", + "/node_modules/" + ], + "moduleFileExtensions": [ + "ts", + "tsx", + "js", + "json" + ] + } } diff --git a/packages/workflow/LICENSE.md b/packages/workflow/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/workflow/LICENSE.md +++ b/packages/workflow/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. 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 diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 969270199b..aed6e160ba 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "0.32.0", + "version": "0.35.0", "description": "Workflow base code of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index f8a6cc5e82..fa3628e379 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -336,6 +336,7 @@ export interface INode { continueOnFail?: boolean; parameters: INodeParameters; credentials?: INodeCredentials; + webhookId?: string; } @@ -558,8 +559,9 @@ export interface IWebhookData { } export interface IWebhookDescription { - [key: string]: WebhookHttpMethod | WebhookResponseMode | string | undefined; + [key: string]: WebhookHttpMethod | WebhookResponseMode | boolean | string | undefined; httpMethod: WebhookHttpMethod | string; + isFullPath?: boolean; name: string; path: string; responseBinaryPropertyName?: string; diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index fcaca55a0a..3e38931ae8 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -755,7 +755,7 @@ export function getNodeWebhooks(workflow: Workflow, node: INode, additionalData: const returnData: IWebhookData[] = []; for (const webhookDescription of nodeType.description.webhooks) { - let nodeWebhookPath = workflow.getSimpleParameterValue(node, webhookDescription['path'], 'GET'); + let nodeWebhookPath = workflow.getSimpleParameterValue(node, webhookDescription['path']); if (nodeWebhookPath === undefined) { // TODO: Use a proper logger console.error(`No webhook path could be found for node "${node.name}" in workflow "${workflowId}".`); @@ -768,7 +768,8 @@ export function getNodeWebhooks(workflow: Workflow, node: INode, additionalData: nodeWebhookPath = nodeWebhookPath.slice(1); } - const path = getNodeWebhookPath(workflowId, node, nodeWebhookPath); + const isFullPath: boolean = workflow.getSimpleParameterValue(node, webhookDescription['isFullPath'], false) as boolean; + const path = getNodeWebhookPath(workflowId, node, nodeWebhookPath, isFullPath); const httpMethod = workflow.getSimpleParameterValue(node, webhookDescription['httpMethod'], 'GET'); @@ -791,6 +792,61 @@ export function getNodeWebhooks(workflow: Workflow, node: INode, additionalData: return returnData; } +export function getNodeWebhooksBasic(workflow: Workflow, node: INode): IWebhookData[] { + if (node.disabled === true) { + // Node is disabled so webhooks will also not be enabled + return []; + } + + const nodeType = workflow.nodeTypes.getByName(node.type) as INodeType; + + if (nodeType.description.webhooks === undefined) { + // Node does not have any webhooks so return + return []; + } + + const workflowId = workflow.id || '__UNSAVED__'; + + const returnData: IWebhookData[] = []; + for (const webhookDescription of nodeType.description.webhooks) { + let nodeWebhookPath = workflow.getSimpleParameterValue(node, webhookDescription['path']); + if (nodeWebhookPath === undefined) { + // TODO: Use a proper logger + console.error(`No webhook path could be found for node "${node.name}" in workflow "${workflowId}".`); + continue; + } + + nodeWebhookPath = nodeWebhookPath.toString(); + + if (nodeWebhookPath.charAt(0) === '/') { + nodeWebhookPath = nodeWebhookPath.slice(1); + } + + const isFullPath: boolean = workflow.getSimpleParameterValue(node, webhookDescription['isFullPath'], false) as boolean; + + const path = getNodeWebhookPath(workflowId, node, nodeWebhookPath, isFullPath); + + const httpMethod = workflow.getSimpleParameterValue(node, webhookDescription['httpMethod']); + + if (httpMethod === undefined) { + // TODO: Use a proper logger + console.error(`The webhook "${path}" for node "${node.name}" in workflow "${workflowId}" could not be added because the httpMethod is not defined.`); + continue; + } + + //@ts-ignore + returnData.push({ + httpMethod: httpMethod.toString() as WebhookHttpMethod, + node: node.name, + path, + webhookDescription, + workflowId, + }); + } + + return returnData; +} + /** * Returns the webhook path @@ -801,8 +857,17 @@ export function getNodeWebhooks(workflow: Workflow, node: INode, additionalData: * @param {string} path * @returns {string} */ -export function getNodeWebhookPath(workflowId: string, node: INode, path: string): string { - return `${workflowId}/${encodeURIComponent(node.name.toLowerCase())}/${path}`; +export function getNodeWebhookPath(workflowId: string, node: INode, path: string, isFullPath?: boolean): string { + let webhookPath = ''; + if (node.webhookId === undefined) { + webhookPath = `${workflowId}/${encodeURIComponent(node.name.toLowerCase())}/${path}`; + } else { + if (isFullPath === true) { + return path; + } + webhookPath = `${node.webhookId}/${path}`; + } + return webhookPath; } @@ -814,11 +879,11 @@ export function getNodeWebhookPath(workflowId: string, node: INode, path: string * @param {string} workflowId * @param {string} nodeTypeName * @param {string} path + * @param {boolean} isFullPath * @returns {string} */ -export function getNodeWebhookUrl(baseUrl: string, workflowId: string, node: INode, path: string): string { - // return `${baseUrl}/${workflowId}/${nodeTypeName}/${path}`; - return `${baseUrl}/${getNodeWebhookPath(workflowId, node, path)}`; +export function getNodeWebhookUrl(baseUrl: string, workflowId: string, node: INode, path: string, isFullPath?: boolean): string { + return `${baseUrl}/${getNodeWebhookPath(workflowId, node, path, isFullPath)}`; } diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 9a7d3ef1eb..9306b68492 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -715,7 +715,7 @@ export class Workflow { * @returns {(string | undefined)} * @memberof Workflow */ - getSimpleParameterValue(node: INode, parameterValue: string | undefined, defaultValue?: boolean | number | string): boolean | number | string | undefined { + getSimpleParameterValue(node: INode, parameterValue: string | boolean | undefined, defaultValue?: boolean | number | string): boolean | number | string | undefined { if (parameterValue === undefined) { // Value is not set so return the default return defaultValue; @@ -1085,18 +1085,18 @@ export class Workflow { * @returns {(Promise)} * @memberof Workflow */ - async runNode(node: INode, inputData: ITaskDataConnections, runExecutionData: IRunExecutionData, runIndex: number, additionalData: IWorkflowExecuteAdditionalData, nodeExecuteFunctions: INodeExecuteFunctions, mode: WorkflowExecuteMode): Promise { + async runNode(node: INode, inputData: ITaskDataConnections, runExecutionData: IRunExecutionData, runIndex: number, additionalData: IWorkflowExecuteAdditionalData, nodeExecuteFunctions: INodeExecuteFunctions, mode: WorkflowExecuteMode): Promise { if (node.disabled === true) { // If node is disabled simply pass the data through // return NodeRunHelpers. if (inputData.hasOwnProperty('main') && inputData.main.length > 0) { // If the node is disabled simply return the data from the first main input if (inputData.main[0] === null) { - return null; + return undefined; } return [(inputData.main[0] as INodeExecutionData[])]; } - return null; + return undefined; } const nodeType = this.nodeTypes.getByName(node.type); @@ -1112,7 +1112,7 @@ export class Workflow { if (connectionInputData.length === 0) { // No data for node so return - return null; + return undefined; } if (runExecutionData.resultData.lastNodeExecuted === node.name && runExecutionData.resultData.error !== undefined) { From 28fcaf79ae266571a6fc837241998117ca7f0aa8 Mon Sep 17 00:00:00 2001 From: ricardo Date: Thu, 23 Jul 2020 17:10:20 -0400 Subject: [PATCH 154/155] :bug: Fix merge issues --- .../nodes/Pipedrive/GenericFunctions.ts | 26 +- .../nodes/Pipedrive/Pipedrive.node.ts | 7 +- packages/nodes-base/package.json | 262 +----------------- 3 files changed, 4 insertions(+), 291 deletions(-) diff --git a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts index 23ef841bee..17033eaf76 100644 --- a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts @@ -11,10 +11,6 @@ import { import { OptionsWithUri, } from 'request'; -<<<<<<< HEAD - -======= ->>>>>>> master export interface ICustomInterface { name: string; @@ -38,22 +34,8 @@ export interface ICustomProperties { * @param {object} body * @returns {Promise} */ -<<<<<<< HEAD -export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctions, method: string, endpoint: string, body: IDataObject, query?: IDataObject, formData?: IDataObject, downloadFile?: boolean): Promise { // tslint:disable-line:no-any - const authenticationMethod = this.getNodeParameter('authentication', 0); -======= export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: IDataObject, query?: IDataObject, formData?: IDataObject, downloadFile?: boolean): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('pipedriveApi'); - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - - if (query === undefined) { - query = {}; - } - - query.api_token = credentials.apiToken; ->>>>>>> master + const authenticationMethod = this.getNodeParameter('authentication', 0); const options: OptionsWithUri = { headers: { @@ -85,7 +67,6 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio let responseData; try { -<<<<<<< HEAD if (authenticationMethod === 'basicAuth' || authenticationMethod === 'apiToken') { const credentials = this.getCredentials('pipedriveApi'); @@ -95,15 +76,12 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio query.api_token = credentials.apiToken; + //@ts-ignore responseData = await this.helpers.request(options); } else { responseData = await this.helpers.requestOAuth2!.call(this, 'pipedriveOAuth2Api', options); } -======= - //@ts-ignore - const responseData = await this.helpers.request(options); ->>>>>>> master if (downloadFile === true) { return { diff --git a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts index 9a0da8d25f..a1f48305aa 100644 --- a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts @@ -2137,6 +2137,7 @@ export class Pipedrive implements INodeType { displayName: 'Term', name: 'term', type: 'string', + required: true, displayOptions: { show: { operation: [ @@ -2722,12 +2723,6 @@ export class Pipedrive implements INodeType { responseData = await pipedriveApiRequest.call(this, requestMethod, endpoint, body, qs, formData, downloadFile); -<<<<<<< HEAD - if (responseData.data === null) { - responseData.data = []; - } -======= ->>>>>>> master } if (resource === 'file' && operation === 'download') { diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 5513c78130..e9d965aa21 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -113,6 +113,7 @@ "dist/credentials/PagerDutyOAuth2Api.credentials.js", "dist/credentials/PayPalApi.credentials.js", "dist/credentials/PipedriveApi.credentials.js", + "dist/credentials/PipedriveOAuth2Api.credentials.js", "dist/credentials/Postgres.credentials.js", "dist/credentials/PostmarkApi.credentials.js", "dist/credentials/QuestDb.credentials.js", @@ -154,266 +155,6 @@ "dist/credentials/ZoomOAuth2Api.credentials.js", "dist/credentials/ZulipApi.credentials.js" ], -<<<<<<< HEAD - "n8n": { - "credentials": [ - "dist/credentials/ActiveCampaignApi.credentials.js", - "dist/credentials/AgileCrmApi.credentials.js", - "dist/credentials/AcuitySchedulingApi.credentials.js", - "dist/credentials/AirtableApi.credentials.js", - "dist/credentials/Amqp.credentials.js", - "dist/credentials/AsanaApi.credentials.js", - "dist/credentials/Aws.credentials.js", - "dist/credentials/AffinityApi.credentials.js", - "dist/credentials/BannerbearApi.credentials.js", - "dist/credentials/BitbucketApi.credentials.js", - "dist/credentials/BitlyApi.credentials.js", - "dist/credentials/ChargebeeApi.credentials.js", - "dist/credentials/ClearbitApi.credentials.js", - "dist/credentials/ClickUpApi.credentials.js", - "dist/credentials/ClockifyApi.credentials.js", - "dist/credentials/CockpitApi.credentials.js", - "dist/credentials/CodaApi.credentials.js", - "dist/credentials/CopperApi.credentials.js", - "dist/credentials/CalendlyApi.credentials.js", - "dist/credentials/DisqusApi.credentials.js", - "dist/credentials/DriftApi.credentials.js", - "dist/credentials/DropboxApi.credentials.js", - "dist/credentials/EventbriteApi.credentials.js", - "dist/credentials/FacebookGraphApi.credentials.js", - "dist/credentials/FreshdeskApi.credentials.js", - "dist/credentials/FileMaker.credentials.js", - "dist/credentials/FlowApi.credentials.js", - "dist/credentials/GithubApi.credentials.js", - "dist/credentials/GithubOAuth2Api.credentials.js", - "dist/credentials/GitlabApi.credentials.js", - "dist/credentials/GoogleApi.credentials.js", - "dist/credentials/GoogleCalendarOAuth2Api.credentials.js", - "dist/credentials/GoogleOAuth2Api.credentials.js", - "dist/credentials/GoogleSheetsOAuth2Api.credentials.js", - "dist/credentials/GumroadApi.credentials.js", - "dist/credentials/HarvestApi.credentials.js", - "dist/credentials/HelpScoutOAuth2Api.credentials.js", - "dist/credentials/HttpBasicAuth.credentials.js", - "dist/credentials/HttpDigestAuth.credentials.js", - "dist/credentials/HttpHeaderAuth.credentials.js", - "dist/credentials/HubspotApi.credentials.js", - "dist/credentials/HubspotDeveloperApi.credentials.js", - "dist/credentials/HunterApi.credentials.js", - "dist/credentials/Imap.credentials.js", - "dist/credentials/IntercomApi.credentials.js", - "dist/credentials/InvoiceNinjaApi.credentials.js", - "dist/credentials/JiraSoftwareCloudApi.credentials.js", - "dist/credentials/JiraSoftwareServerApi.credentials.js", - "dist/credentials/JotFormApi.credentials.js", - "dist/credentials/KeapOAuth2Api.credentials.js", - "dist/credentials/LinkFishApi.credentials.js", - "dist/credentials/MailchimpApi.credentials.js", - "dist/credentials/MailgunApi.credentials.js", - "dist/credentials/MailjetEmailApi.credentials.js", - "dist/credentials/MailjetSmsApi.credentials.js", - "dist/credentials/MandrillApi.credentials.js", - "dist/credentials/MattermostApi.credentials.js", - "dist/credentials/MauticApi.credentials.js", - "dist/credentials/MicrosoftExcelOAuth2Api.credentials.js", - "dist/credentials/MicrosoftOAuth2Api.credentials.js", - "dist/credentials/MicrosoftOneDriveOAuth2Api.credentials.js", - "dist/credentials/MoceanApi.credentials.js", - "dist/credentials/MondayComApi.credentials.js", - "dist/credentials/MongoDb.credentials.js", - "dist/credentials/Msg91Api.credentials.js", - "dist/credentials/MySql.credentials.js", - "dist/credentials/NextCloudApi.credentials.js", - "dist/credentials/OAuth1Api.credentials.js", - "dist/credentials/OAuth2Api.credentials.js", - "dist/credentials/OpenWeatherMapApi.credentials.js", - "dist/credentials/PagerDutyApi.credentials.js", - "dist/credentials/PayPalApi.credentials.js", - "dist/credentials/PipedriveApi.credentials.js", - "dist/credentials/PipedriveOAuth2Api.credentials.js", - "dist/credentials/Postgres.credentials.js", - "dist/credentials/Redis.credentials.js", - "dist/credentials/RocketchatApi.credentials.js", - "dist/credentials/RundeckApi.credentials.js", - "dist/credentials/ShopifyApi.credentials.js", - "dist/credentials/SalesforceOAuth2Api.credentials.js", - "dist/credentials/SlackApi.credentials.js", - "dist/credentials/SlackOAuth2Api.credentials.js", - "dist/credentials/Sms77Api.credentials.js", - "dist/credentials/Smtp.credentials.js", - "dist/credentials/StripeApi.credentials.js", - "dist/credentials/SalesmateApi.credentials.js", - "dist/credentials/SegmentApi.credentials.js", - "dist/credentials/SurveyMonkeyApi.credentials.js", - "dist/credentials/TelegramApi.credentials.js", - "dist/credentials/TodoistApi.credentials.js", - "dist/credentials/TrelloApi.credentials.js", - "dist/credentials/TwilioApi.credentials.js", - "dist/credentials/TwitterOAuth1Api.credentials.js", - "dist/credentials/TypeformApi.credentials.js", - "dist/credentials/TogglApi.credentials.js", - "dist/credentials/UpleadApi.credentials.js", - "dist/credentials/VeroApi.credentials.js", - "dist/credentials/WebflowApi.credentials.js", - "dist/credentials/WooCommerceApi.credentials.js", - "dist/credentials/WordpressApi.credentials.js", - "dist/credentials/ZendeskApi.credentials.js", - "dist/credentials/ZohoOAuth2Api.credentials.js", - "dist/credentials/ZulipApi.credentials.js" - ], - "nodes": [ - "dist/nodes/ActiveCampaign/ActiveCampaign.node.js", - "dist/nodes/ActiveCampaign/ActiveCampaignTrigger.node.js", - "dist/nodes/AgileCrm/AgileCrm.node.js", - "dist/nodes/Airtable/Airtable.node.js", - "dist/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.js", - "dist/nodes/Amqp/Amqp.node.js", - "dist/nodes/Amqp/AmqpTrigger.node.js", - "dist/nodes/Asana/Asana.node.js", - "dist/nodes/Asana/AsanaTrigger.node.js", - "dist/nodes/Affinity/Affinity.node.js", - "dist/nodes/Affinity/AffinityTrigger.node.js", - "dist/nodes/Aws/AwsLambda.node.js", - "dist/nodes/Aws/S3/AwsS3.node.js", - "dist/nodes/Aws/AwsSes.node.js", - "dist/nodes/Aws/AwsSns.node.js", - "dist/nodes/Aws/AwsSnsTrigger.node.js", - "dist/nodes/Bannerbear/Bannerbear.node.js", - "dist/nodes/Bitbucket/BitbucketTrigger.node.js", - "dist/nodes/Bitly/Bitly.node.js", - "dist/nodes/Calendly/CalendlyTrigger.node.js", - "dist/nodes/Chargebee/Chargebee.node.js", - "dist/nodes/Chargebee/ChargebeeTrigger.node.js", - "dist/nodes/Clearbit/Clearbit.node.js", - "dist/nodes/ClickUp/ClickUp.node.js", - "dist/nodes/ClickUp/ClickUpTrigger.node.js", - "dist/nodes/Clockify/ClockifyTrigger.node.js", - "dist/nodes/Cockpit/Cockpit.node.js", - "dist/nodes/Coda/Coda.node.js", - "dist/nodes/Copper/CopperTrigger.node.js", - "dist/nodes/Cron.node.js", - "dist/nodes/Crypto.node.js", - "dist/nodes/DateTime.node.js", - "dist/nodes/Discord/Discord.node.js", - "dist/nodes/Disqus/Disqus.node.js", - "dist/nodes/Drift/Drift.node.js", - "dist/nodes/Dropbox/Dropbox.node.js", - "dist/nodes/EditImage.node.js", - "dist/nodes/EmailReadImap.node.js", - "dist/nodes/EmailSend.node.js", - "dist/nodes/ErrorTrigger.node.js", - "dist/nodes/Eventbrite/EventbriteTrigger.node.js", - "dist/nodes/ExecuteCommand.node.js", - "dist/nodes/ExecuteWorkflow.node.js", - "dist/nodes/Facebook/FacebookGraphApi.node.js", - "dist/nodes/FileMaker/FileMaker.node.js", - "dist/nodes/Freshdesk/Freshdesk.node.js", - "dist/nodes/Flow/Flow.node.js", - "dist/nodes/Flow/FlowTrigger.node.js", - "dist/nodes/Function.node.js", - "dist/nodes/FunctionItem.node.js", - "dist/nodes/Github/Github.node.js", - "dist/nodes/Github/GithubTrigger.node.js", - "dist/nodes/Gitlab/Gitlab.node.js", - "dist/nodes/Gitlab/GitlabTrigger.node.js", - "dist/nodes/Google/Calendar/GoogleCalendar.node.js", - "dist/nodes/Google/Drive/GoogleDrive.node.js", - "dist/nodes/Google/Sheet/GoogleSheets.node.js", - "dist/nodes/GraphQL/GraphQL.node.js", - "dist/nodes/Gumroad/GumroadTrigger.node.js", - "dist/nodes/Harvest/Harvest.node.js", - "dist/nodes/HelpScout/HelpScout.node.js", - "dist/nodes/HelpScout/HelpScoutTrigger.node.js", - "dist/nodes/HtmlExtract/HtmlExtract.node.js", - "dist/nodes/HttpRequest.node.js", - "dist/nodes/Hubspot/Hubspot.node.js", - "dist/nodes/Hubspot/HubspotTrigger.node.js", - "dist/nodes/Hunter/Hunter.node.js", - "dist/nodes/If.node.js", - "dist/nodes/Intercom/Intercom.node.js", - "dist/nodes/Interval.node.js", - "dist/nodes/InvoiceNinja/InvoiceNinja.node.js", - "dist/nodes/InvoiceNinja/InvoiceNinjaTrigger.node.js", - "dist/nodes/Jira/Jira.node.js", - "dist/nodes/JotForm/JotFormTrigger.node.js", - "dist/nodes/Keap/Keap.node.js", - "dist/nodes/Keap/KeapTrigger.node.js", - "dist/nodes/LinkFish/LinkFish.node.js", - "dist/nodes/Mailchimp/Mailchimp.node.js", - "dist/nodes/Mailchimp/MailchimpTrigger.node.js", - "dist/nodes/Mailgun/Mailgun.node.js", - "dist/nodes/Mailjet/Mailjet.node.js", - "dist/nodes/Mailjet/MailjetTrigger.node.js", - "dist/nodes/Mandrill/Mandrill.node.js", - "dist/nodes/Mattermost/Mattermost.node.js", - "dist/nodes/Mautic/Mautic.node.js", - "dist/nodes/Mautic/MauticTrigger.node.js", - "dist/nodes/Merge.node.js", - "dist/nodes/Microsoft/Excel/MicrosoftExcel.node.js", - "dist/nodes/Microsoft/OneDrive/MicrosoftOneDrive.node.js", - "dist/nodes/MoveBinaryData.node.js", - "dist/nodes/Mocean/Mocean.node.js", - "dist/nodes/MondayCom/MondayCom.node.js", - "dist/nodes/MongoDb/MongoDb.node.js", - "dist/nodes/MoveBinaryData.node.js", - "dist/nodes/Msg91/Msg91.node.js", - "dist/nodes/MySql/MySql.node.js", - "dist/nodes/NextCloud/NextCloud.node.js", - "dist/nodes/NoOp.node.js", - "dist/nodes/OpenWeatherMap.node.js", - "dist/nodes/PagerDuty/PagerDuty.node.js", - "dist/nodes/PayPal/PayPal.node.js", - "dist/nodes/PayPal/PayPalTrigger.node.js", - "dist/nodes/Pipedrive/Pipedrive.node.js", - "dist/nodes/Pipedrive/PipedriveTrigger.node.js", - "dist/nodes/Postgres/Postgres.node.js", - "dist/nodes/ReadBinaryFile.node.js", - "dist/nodes/ReadBinaryFiles.node.js", - "dist/nodes/ReadPdf.node.js", - "dist/nodes/Redis/Redis.node.js", - "dist/nodes/RenameKeys.node.js", - "dist/nodes/Rocketchat/Rocketchat.node.js", - "dist/nodes/RssFeedRead.node.js", - "dist/nodes/Rundeck/Rundeck.node.js", - "dist/nodes/Salesforce/Salesforce.node.js", - "dist/nodes/Set.node.js", - "dist/nodes/Shopify/Shopify.node.js", - "dist/nodes/Shopify/ShopifyTrigger.node.js", - "dist/nodes/Slack/Slack.node.js", - "dist/nodes/Sms77/Sms77.node.js", - "dist/nodes/SplitInBatches.node.js", - "dist/nodes/SpreadsheetFile.node.js", - "dist/nodes/SseTrigger.node.js", - "dist/nodes/Start.node.js", - "dist/nodes/Stripe/StripeTrigger.node.js", - "dist/nodes/Switch.node.js", - "dist/nodes/Salesmate/Salesmate.node.js", - "dist/nodes/Segment/Segment.node.js", - "dist/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.js", - "dist/nodes/Telegram/Telegram.node.js", - "dist/nodes/Telegram/TelegramTrigger.node.js", - "dist/nodes/Todoist/Todoist.node.js", - "dist/nodes/Toggl/TogglTrigger.node.js", - "dist/nodes/Trello/Trello.node.js", - "dist/nodes/Trello/TrelloTrigger.node.js", - "dist/nodes/Twilio/Twilio.node.js", - "dist/nodes/Twitter/Twitter.node.js", - "dist/nodes/Typeform/TypeformTrigger.node.js", - "dist/nodes/Uplead/Uplead.node.js", - "dist/nodes/Vero/Vero.node.js", - "dist/nodes/Webflow/WebflowTrigger.node.js", - "dist/nodes/Webhook.node.js", - "dist/nodes/Wordpress/Wordpress.node.js", - "dist/nodes/WooCommerce/WooCommerce.node.js", - "dist/nodes/WooCommerce/WooCommerceTrigger.node.js", - "dist/nodes/WriteBinaryFile.node.js", - "dist/nodes/Xml.node.js", - "dist/nodes/Zendesk/Zendesk.node.js", - "dist/nodes/Zendesk/ZendeskTrigger.node.js", - "dist/nodes/Zoho/ZohoCrm.node.js", - "dist/nodes/Zulip/Zulip.node.js" - ] -======= "nodes": [ "dist/nodes/ActiveCampaign/ActiveCampaign.node.js", "dist/nodes/ActiveCampaign/ActiveCampaignTrigger.node.js", @@ -644,7 +385,6 @@ "jest": { "transform": { "^.+\\.tsx?$": "ts-jest" ->>>>>>> master }, "testURL": "http://localhost/", "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", From e387229642b1d954bd8f1933a92590936885d53f Mon Sep 17 00:00:00 2001 From: ricardo Date: Thu, 23 Jul 2020 19:49:07 -0400 Subject: [PATCH 155/155] :zap: Small improvement --- .../AcuityScheduling/AcuitySchedulingTrigger.node.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/nodes-base/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts b/packages/nodes-base/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts index 079b4e65b3..75ab74699f 100644 --- a/packages/nodes-base/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts +++ b/packages/nodes-base/nodes/AcuityScheduling/AcuitySchedulingTrigger.node.ts @@ -35,7 +35,7 @@ export class AcuitySchedulingTrigger implements INodeType { displayOptions: { show: { authentication: [ - 'accessToken', + 'apiKey', ], }, }, @@ -67,15 +67,15 @@ export class AcuitySchedulingTrigger implements INodeType { type: 'options', options: [ { - name: 'Access Token', - value: 'accessToken', + name: 'API Key', + value: 'apiKey', }, { name: 'OAuth2', value: 'oAuth2', }, ], - default: 'accessToken', + default: 'apiKey', description: 'Method of authentication.', }, {