From 443c2a4d51882ce975b3f8ba4350dd5d912fe1bb Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Tue, 21 Sep 2021 19:38:24 +0200 Subject: [PATCH] :sparkles: Updated node design and node versioning (#1961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :zap: introduce versioned nodes * Export versioned nodes for separate process run * Add bse node for versioned nodes * fix node name for versioned nodes * extend node from nodeVersionedType * improve nodes base and flow to FE * revert lib es2019 to es2017 * include version in key to prevent duplicate key * handle type versions on FE * clean up * cleanup nodes base * add type versions in getNodeParameterOptions * cleanup * code review * code review + add default version to node type description * remove node default types from store * :lipstick: cleanups * Draft for migrated Mattermost node * First version of Mattermost node versioned according to node standards * Correcting deactivate operations name to match currently used one * :sparkles: Create utility types * :zap: Simplify Mattermost types * :zap: Rename exports for consistency * :zap: Type channel properties * :zap: Type message properties * :zap: Type reaction properties * :zap: Type user properties * :zap: Add type import to router * :bug: Add missing key * :hammer: Adjust typo in operation name * :hammer: Inline exports for channel properties * :hammer: Inline exports for message properties * :hammer: Inline exports for reaction properties * :hammer: Inline exports for user properties * :hammer: Inline exports for load options * :shirt: Fix lint issue * :hammer: Inline export for description * :hammer: Rename descriptions for clarity * :hammer: Refactor imports/exports for methods * :hammer: Refactor latest version retrieval * :fire: Remove unneeded else clause When the string literal union is exhausted, the resource key becomes never, so TS disallows wrong key usage. * :sparkles: Add overloads to getNodeParameter * :zap: Improve overload * :fire: Remove superfluous INodeVersions type * :hammer: Relocate pre-existing interface * :fire: Remove JSDoc arg descriptions * :zap: Minor reformatting in transport file * :zap: Fix API call function type * Created first draft for Axios requests * Working version of mattermost node with Axios * Work in progress for replacing request library * Improvements to request translations * Fixed sending files via multipart / form-data * Fixing translation from request to axios and loading node parameter options * Improved typing for new http helper * Added ignore any for specific lines for linting * Fixed follow redirects changes on http request node and manual execution of previously existing workflow with older node versions * Adding default headers according to body on httpRequest helper * Spec error handling and fixed workflows with older node versions * Showcase how to export errors in a standard format * Merging master * Refactored mattermost node to keep files in a uniform structure. Also fix bugs with merges * Reverting changes to http request node * Changed nullish comparison and removed repeated code from nodes * Renamed queryString back to qs and simplified node output * Simplified some comparisons * Changed header names to be uc first * Added default user agent to requests and patch http method support * Fixed indentation, remove unnecessary file and console log * Fixed mattermost node name * Fixed lint issues * Further fix linting issues * Further fix lint issues * Fixed http request helper's return type Co-authored-by: ahsan-virani Co-authored-by: Iván Ovejero --- packages/cli/commands/executeBatch.ts | 1 + packages/cli/src/CredentialsHelper.ts | 3 + packages/cli/src/LoadNodesAndCredentials.ts | 41 +- packages/cli/src/NodeTypes.ts | 29 +- packages/cli/src/Server.ts | 111 +- packages/cli/src/WebhookHelpers.ts | 5 +- packages/cli/src/WorkflowHelpers.ts | 21 +- packages/cli/src/WorkflowRunnerProcess.ts | 10 +- packages/core/package.json | 3 + packages/core/src/Interfaces.ts | 22 +- packages/core/src/LoadNodeParameterOptions.ts | 34 +- packages/core/src/NodeExecuteFunctions.ts | 453 ++- packages/core/src/WorkflowExecute.ts | 30 +- packages/core/test/Helpers.ts | 9 +- packages/editor-ui/src/Interface.ts | 7 +- .../src/components/NodeCreator/MainPanel.vue | 1 - .../components/NodeCreator/NodeCreator.vue | 14 +- .../editor-ui/src/components/NodeSettings.vue | 2 +- .../src/components/ParameterInput.vue | 2 +- .../src/components/mixins/restApi.ts | 13 +- .../src/components/mixins/workflowHelpers.ts | 20 +- packages/editor-ui/src/constants.ts | 1 + packages/editor-ui/src/store.ts | 15 +- packages/editor-ui/src/views/NodeView.vue | 24 +- packages/nodes-base/nodes/DeepL/DeepL.node.ts | 2 +- .../nodes-base/nodes/HttpRequest.node.json | 1 + packages/nodes-base/nodes/HttpRequest.node.ts | 2 +- .../nodes/Mattermost/GenericFunctions.ts | 78 - .../nodes/Mattermost/Mattermost.node.ts | 2421 +---------------- .../nodes/Mattermost/v1/MattermostV1.node.ts | 34 + .../nodes/Mattermost/v1/actions/Interfaces.ts | 33 + .../v1/actions/channel/addUser/description.ts | 50 + .../v1/actions/channel/addUser/execute.ts | 27 + .../v1/actions/channel/addUser/index.ts | 7 + .../v1/actions/channel/create/description.ts | 93 + .../v1/actions/channel/create/execute.ts | 30 + .../v1/actions/channel/create/index.ts | 7 + .../v1/actions/channel/del/description.ts | 28 + .../v1/actions/channel/del/execute.ts | 25 + .../v1/actions/channel/del/index.ts | 7 + .../Mattermost/v1/actions/channel/index.ts | 72 + .../v1/actions/channel/members/description.ts | 112 + .../v1/actions/channel/members/execute.ts | 51 + .../v1/actions/channel/members/index.ts | 7 + .../v1/actions/channel/restore/description.ts | 24 + .../v1/actions/channel/restore/execute.ts | 27 + .../v1/actions/channel/restore/index.ts | 7 + .../actions/channel/statistics/description.ts | 29 + .../v1/actions/channel/statistics/execute.ts | 25 + .../v1/actions/channel/statistics/index.ts | 7 + .../v1/actions/message/del/description.ts | 24 + .../v1/actions/message/del/execute.ts | 25 + .../v1/actions/message/del/index.ts | 7 + .../Mattermost/v1/actions/message/index.ts | 48 + .../v1/actions/message/post/description.ts | 440 +++ .../v1/actions/message/post/execute.ts | 98 + .../v1/actions/message/post/index.ts | 7 + .../message/postEphemeral/description.ts | 69 + .../actions/message/postEphemeral/execute.ts | 30 + .../v1/actions/message/postEphemeral/index.ts | 7 + .../v1/actions/reaction/create/description.ts | 65 + .../v1/actions/reaction/create/execute.ts | 29 + .../v1/actions/reaction/create/index.ts | 7 + .../v1/actions/reaction/del/description.ts | 65 + .../v1/actions/reaction/del/execute.ts | 27 + .../v1/actions/reaction/del/index.ts | 7 + .../v1/actions/reaction/getAll/description.ts | 65 + .../v1/actions/reaction/getAll/execute.ts | 28 + .../v1/actions/reaction/getAll/index.ts | 7 + .../Mattermost/v1/actions/reaction/index.ts | 49 + .../nodes/Mattermost/v1/actions/router.ts | 53 + .../v1/actions/user/create/description.ts | 270 ++ .../v1/actions/user/create/execute.ts | 43 + .../v1/actions/user/create/index.ts | 7 + .../v1/actions/user/deactive/description.ts | 24 + .../v1/actions/user/deactive/execute.ts | 24 + .../v1/actions/user/deactive/index.ts | 7 + .../v1/actions/user/getAll/description.ts | 119 + .../v1/actions/user/getAll/execute.ts | 98 + .../v1/actions/user/getAll/index.ts | 7 + .../v1/actions/user/getByEmail/description.ts | 24 + .../v1/actions/user/getByEmail/execute.ts | 25 + .../v1/actions/user/getByEmail/index.ts | 7 + .../v1/actions/user/getById/description.ts | 50 + .../v1/actions/user/getById/execute.ts | 29 + .../v1/actions/user/getById/index.ts | 7 + .../nodes/Mattermost/v1/actions/user/index.ts | 74 + .../v1/actions/user/invite/description.ts | 44 + .../v1/actions/user/invite/execute.ts | 28 + .../v1/actions/user/invite/index.ts | 7 + .../v1/actions/versionDescription.ts | 61 + .../nodes/Mattermost/v1/mattermost.svg | 1 + .../nodes/Mattermost/v1/methods/index.ts | 1 + .../Mattermost/v1/methods/loadOptions.ts | 148 + .../nodes/Mattermost/v1/transport/index.ts | 72 + packages/nodes-base/src/NodeVersionedType.ts | 25 + packages/nodes-base/src/index.ts | 3 + packages/workflow/src/Interfaces.ts | 139 +- packages/workflow/src/NodeHelpers.ts | 38 +- packages/workflow/src/Workflow.ts | 28 +- packages/workflow/test/Helpers.ts | 15 +- 101 files changed, 4016 insertions(+), 2643 deletions(-) delete mode 100644 packages/nodes-base/nodes/Mattermost/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/MattermostV1.node.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/Interfaces.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/addUser/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/addUser/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/addUser/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/create/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/create/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/create/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/del/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/del/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/del/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/members/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/members/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/members/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/restore/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/restore/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/restore/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/statistics/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/statistics/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/channel/statistics/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/message/del/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/message/del/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/message/del/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/message/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/message/post/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/message/post/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/message/post/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/message/postEphemeral/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/message/postEphemeral/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/message/postEphemeral/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/reaction/create/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/reaction/create/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/reaction/create/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/reaction/del/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/reaction/del/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/reaction/del/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/reaction/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/router.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/create/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/create/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/create/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/deactive/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/deactive/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/deactive/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/getAll/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/getAll/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/getAll/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/getByEmail/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/getByEmail/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/getByEmail/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/getById/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/getById/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/getById/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/invite/description.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/invite/execute.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/user/invite/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/actions/versionDescription.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/mattermost.svg create mode 100644 packages/nodes-base/nodes/Mattermost/v1/methods/index.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/methods/loadOptions.ts create mode 100644 packages/nodes-base/nodes/Mattermost/v1/transport/index.ts create mode 100644 packages/nodes-base/src/NodeVersionedType.ts diff --git a/packages/cli/commands/executeBatch.ts b/packages/cli/commands/executeBatch.ts index 1abcd916a5..ec4d68dd03 100644 --- a/packages/cli/commands/executeBatch.ts +++ b/packages/cli/commands/executeBatch.ts @@ -170,6 +170,7 @@ export class ExecuteBatch extends Command { 'missing a required parameter', 'insufficient credit balance', 'request timed out', + 'status code 401', ]; // eslint-disable-next-line no-param-reassign diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index 7f7330b75f..5a101e7984 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -30,6 +30,9 @@ const mockNodeTypes: INodeTypes = { getByName: (nodeType: string): INodeType | undefined => { return undefined; }, + getByNameAndVersion: (): INodeType | undefined => { + return undefined; + }, }; export class CredentialsHelper extends ICredentialsHelper { diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index 68715fe47a..bc1e15ce40 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -15,6 +15,7 @@ import { ILogger, INodeType, INodeTypeData, + INodeVersionedType, LoggerProxy, } from 'n8n-workflow'; @@ -181,13 +182,14 @@ class LoadNodesAndCredentialsClass { * @returns {Promise} */ async loadNodeFromFile(packageName: string, nodeName: string, filePath: string): Promise { - let tempNode: INodeType; + let tempNode: INodeType | INodeVersionedType; let fullNodeName: string; // eslint-disable-next-line import/no-dynamic-require, global-require, @typescript-eslint/no-var-requires const tempModule = require(filePath); + try { - tempNode = new tempModule[nodeName]() as INodeType; + tempNode = new tempModule[nodeName](); this.addCodex({ node: tempNode, filePath, isCustom: packageName === 'CUSTOM' }); } catch (error) { // eslint-disable-next-line no-console @@ -207,13 +209,36 @@ class LoadNodesAndCredentialsClass { )}`; } - if (tempNode.executeSingle) { + if (tempNode.hasOwnProperty('executeSingle')) { this.logger.warn( `"executeSingle" will get deprecated soon. Please update the code of node "${packageName}.${nodeName}" to use "execute" instead!`, { filePath }, ); } + if (tempNode.hasOwnProperty('nodeVersions')) { + const versionedNodeType = (tempNode as INodeVersionedType).getNodeType(); + this.addCodex({ node: versionedNodeType, filePath, isCustom: packageName === 'CUSTOM' }); + + if ( + versionedNodeType.description.icon !== undefined && + versionedNodeType.description.icon.startsWith('file:') + ) { + // If a file icon gets used add the full path + versionedNodeType.description.icon = `file:${path.join( + path.dirname(filePath), + versionedNodeType.description.icon.substr(5), + )}`; + } + + if (versionedNodeType.hasOwnProperty('executeSingle')) { + this.logger.warn( + `"executeSingle" will get deprecated soon. Please update the code of node "${packageName}.${nodeName}" to use "execute" instead!`, + { filePath }, + ); + } + } + if (this.includeNodes !== undefined && !this.includeNodes.includes(fullNodeName)) { return; } @@ -257,7 +282,15 @@ class LoadNodesAndCredentialsClass { * @param obj.isCustom Whether the node is custom * @returns {void} */ - addCodex({ node, filePath, isCustom }: { node: INodeType; filePath: string; isCustom: boolean }) { + addCodex({ + node, + filePath, + isCustom, + }: { + node: INodeType | INodeVersionedType; + filePath: string; + isCustom: boolean; + }) { try { const codex = this.getCodex(filePath); diff --git a/packages/cli/src/NodeTypes.ts b/packages/cli/src/NodeTypes.ts index ce74fe5a0a..b6ed97511d 100644 --- a/packages/cli/src/NodeTypes.ts +++ b/packages/cli/src/NodeTypes.ts @@ -1,4 +1,14 @@ -import { INodeType, INodeTypeData, INodeTypes, NodeHelpers } from 'n8n-workflow'; +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { + INodeType, + INodeTypeData, + INodeTypes, + INodeVersionedType, + NodeHelpers, +} from 'n8n-workflow'; class NodeTypesClass implements INodeTypes { nodeTypes: INodeTypeData = {}; @@ -8,29 +18,30 @@ class NodeTypesClass implements INodeTypes { // polling nodes the polling times // eslint-disable-next-line no-restricted-syntax for (const nodeTypeData of Object.values(nodeTypes)) { - const applyParameters = NodeHelpers.getSpecialNodeParameters(nodeTypeData.type); + const nodeType = NodeHelpers.getVersionedTypeNode(nodeTypeData.type); + const applyParameters = NodeHelpers.getSpecialNodeParameters(nodeType); if (applyParameters.length) { - // eslint-disable-next-line prefer-spread - nodeTypeData.type.description.properties.unshift.apply( - nodeTypeData.type.description.properties, - applyParameters, - ); + nodeType.description.properties.unshift(...applyParameters); } } this.nodeTypes = nodeTypes; } - getAll(): INodeType[] { + getAll(): Array { return Object.values(this.nodeTypes).map((data) => data.type); } - getByName(nodeType: string): INodeType | undefined { + getByName(nodeType: string): INodeType | INodeVersionedType | undefined { if (this.nodeTypes[nodeType] === undefined) { throw new Error(`The node-type "${nodeType}" is not known!`); } return this.nodeTypes[nodeType].type; } + + getByNameAndVersion(nodeType: string, version?: number): INodeType { + return NodeHelpers.getVersionedTypeNode(this.nodeTypes[nodeType].type, version); + } } let nodeTypesInstance: NodeTypesClass | undefined; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 5ce0426811..a23c5eedb9 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -68,17 +68,23 @@ import { INodeCredentials, INodeParameters, INodePropertyOptions, + INodeType, INodeTypeDescription, + INodeTypeNameVersion, IRunData, + INodeVersionedType, IWorkflowBase, IWorkflowCredentials, LoggerProxy, NodeCredentialTestRequest, NodeCredentialTestResult, + NodeHelpers, Workflow, WorkflowExecuteMode, } from 'n8n-workflow'; +import { NodeVersionedType } from 'n8n-nodes-base'; + import * as basicAuth from 'basic-auth'; import * as compression from 'compression'; import * as jwt from 'jsonwebtoken'; @@ -882,7 +888,6 @@ class App { 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); @@ -1060,7 +1065,9 @@ class App { `/${this.restEndpoint}/node-parameter-options`, ResponseHelper.send( async (req: express.Request, res: express.Response): Promise => { - const nodeType = req.query.nodeType as string; + const nodeTypeAndVersion = JSON.parse( + `${req.query.nodeTypeAndVersion}`, + ) as INodeTypeNameVersion; const path = req.query.path as string; let credentials: INodeCredentials | undefined; const currentNodeParameters = JSON.parse( @@ -1075,10 +1082,10 @@ class App { // @ts-ignore const loadDataInstance = new LoadNodeParameterOptions( - nodeType, + nodeTypeAndVersion, nodeTypes, path, - JSON.parse(`${req.query.currentNodeParameters}`), + currentNodeParameters, credentials, ); @@ -1095,46 +1102,58 @@ class App { ResponseHelper.send( async (req: express.Request, res: express.Response): Promise => { const returnData: INodeTypeDescription[] = []; + const onlyLatest = req.query.onlyLatest === 'true'; const nodeTypes = NodeTypes(); - const allNodes = nodeTypes.getAll(); - allNodes.forEach((nodeData) => { - // Make a copy of the object. If we don't do this, then when - // The method below is called the properties are removed for good - // This happens because nodes are returned as reference. - const nodeInfo: INodeTypeDescription = { ...nodeData.description }; + const getNodeDescription = (nodeType: INodeType): INodeTypeDescription => { + const nodeInfo: INodeTypeDescription = { ...nodeType.description }; if (req.query.includeProperties !== 'true') { // @ts-ignore delete nodeInfo.properties; } - returnData.push(nodeInfo); - }); + return nodeInfo; + }; + + if (onlyLatest) { + allNodes.forEach((nodeData) => { + const nodeType = NodeHelpers.getVersionedTypeNode(nodeData); + const nodeInfo: INodeTypeDescription = getNodeDescription(nodeType); + returnData.push(nodeInfo); + }); + } else { + allNodes.forEach((nodeData) => { + const allNodeTypes = NodeHelpers.getVersionedTypeNodeAll(nodeData); + allNodeTypes.forEach((element) => { + const nodeInfo: INodeTypeDescription = getNodeDescription(element); + returnData.push(nodeInfo); + }); + }); + } return returnData; }, ), ); - // Returns node information baesd on namese + // Returns node information based on node names and versions this.app.post( `/${this.restEndpoint}/node-types`, ResponseHelper.send( async (req: express.Request, res: express.Response): Promise => { - const nodeNames = _.get(req, 'body.nodeNames', []) as string[]; + const nodeInfos = _.get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[]; const nodeTypes = NodeTypes(); - return nodeNames - .map((name) => { - try { - return nodeTypes.getByName(name); - } catch (e) { - return undefined; - } - }) - .filter((nodeData) => !!nodeData) - .map((nodeData) => nodeData!.description); + const returnData: INodeTypeDescription[] = []; + nodeInfos.forEach((nodeInfo) => { + const nodeType = nodeTypes.getByNameAndVersion(nodeInfo.name, nodeInfo.version); + if (nodeType?.description) { + returnData.push(nodeType.description); + } + }); + + return returnData; }, ), ); @@ -1156,7 +1175,7 @@ class App { }`; const nodeTypes = NodeTypes(); - const nodeType = nodeTypes.getByName(nodeTypeName); + const nodeType = nodeTypes.getByNameAndVersion(nodeTypeName); if (nodeType === undefined) { res.status(404).send('The nodeType is not known.'); @@ -1342,14 +1361,42 @@ class App { ) { return false; } - const credentialTestable = node.description.credentials?.find((credential) => { - const testFunctionSearch = - credential.name === credentialType && !!credential.testedBy; - if (testFunctionSearch) { - foundTestFunction = node.methods!.credentialTest![credential.testedBy!]; + + if (node instanceof NodeVersionedType) { + const versionNames = Object.keys((node as INodeVersionedType).nodeVersions); + for (const versionName of versionNames) { + const nodeType = (node as INodeVersionedType).nodeVersions[ + versionName as unknown as number + ]; + // eslint-disable-next-line @typescript-eslint/no-loop-func + const credentialTestable = nodeType.description.credentials?.find((credential) => { + const testFunctionSearch = + credential.name === credentialType && !!credential.testedBy; + if (testFunctionSearch) { + foundTestFunction = (node as unknown as INodeType).methods!.credentialTest![ + credential.testedBy! + ]; + } + return testFunctionSearch; + }); + if (credentialTestable) { + return true; + } } - return testFunctionSearch; - }); + return false; + } + const credentialTestable = (node as INodeType).description.credentials?.find( + (credential) => { + const testFunctionSearch = + credential.name === credentialType && !!credential.testedBy; + if (testFunctionSearch) { + foundTestFunction = (node as INodeType).methods!.credentialTest![ + credential.testedBy! + ]; + } + return testFunctionSearch; + }, + ); return !!credentialTestable; }); diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 79f875bdea..ef0c47ac21 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -139,7 +139,10 @@ export async function executeWebhook( responseCallback: (error: Error | null, data: IResponseCallbackData) => void, ): Promise { // Get the nodeType to know which responseMode is set - const nodeType = workflow.nodeTypes.getByName(workflowStartNode.type); + const nodeType = workflow.nodeTypes.getByNameAndVersion( + workflowStartNode.type, + workflowStartNode.typeVersion, + ); if (nodeType === undefined) { const errorMessage = `The type of the webhook node "${workflowStartNode.name}" is not known.`; responseCallback(new Error(errorMessage), {}); diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index 2a1a33edee..97199650b7 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable no-underscore-dangle */ /* eslint-disable no-continue */ @@ -226,13 +229,13 @@ export function getNodeTypeData(nodes: INode[]): ITransferNodeTypes { // can be loaded again in the process const returnData: ITransferNodeTypes = {}; for (const nodeTypeName of neededNodeTypes) { - if (nodeTypes.nodeTypes[nodeTypeName] === undefined) { - throw new Error(`The NodeType "${nodeTypeName}" could not be found!`); + if (nodeTypes.nodeTypes[nodeTypeName.type] === undefined) { + throw new Error(`The NodeType "${nodeTypeName.type}" could not be found!`); } - returnData[nodeTypeName] = { - className: nodeTypes.nodeTypes[nodeTypeName].type.constructor.name, - sourcePath: nodeTypes.nodeTypes[nodeTypeName].sourcePath, + returnData[nodeTypeName.type] = { + className: nodeTypes.nodeTypes[nodeTypeName.type].type.constructor.name, + sourcePath: nodeTypes.nodeTypes[nodeTypeName.type].sourcePath, }; } @@ -306,12 +309,12 @@ export function getCredentialsDataByNodes(nodes: INode[]): ICredentialsTypeData * @param {INode[]} nodes * @returns {string[]} */ -export function getNeededNodeTypes(nodes: INode[]): string[] { +export function getNeededNodeTypes(nodes: INode[]): Array<{ type: string; version: number }> { // Check which node-types have to be loaded - const neededNodeTypes: string[] = []; + const neededNodeTypes: Array<{ type: string; version: number }> = []; for (const node of nodes) { - if (!neededNodeTypes.includes(node.type)) { - neededNodeTypes.push(node.type); + if (neededNodeTypes.find((neededNodes) => node.type === neededNodes.type) === undefined) { + neededNodeTypes.push({ type: node.type, version: node.typeVersion }); } } diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index 731854572e..bf45881cae 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -98,7 +98,15 @@ export class WorkflowRunnerProcess { const tempModule = require(filePath); try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const nodeObject = new tempModule[className](); + if (nodeObject.getNodeType !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + tempNode = nodeObject.getNodeType(); + } else { + tempNode = nodeObject; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-call tempNode = new tempModule[className]() as INodeType; } catch (error) { throw new Error(`Error loading node "${nodeTypeName}" from: "${filePath}"`); diff --git a/packages/core/package.json b/packages/core/package.json index bc94d96e93..dc7c29beaa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -42,15 +42,18 @@ "typescript": "~4.3.5" }, "dependencies": { + "axios": "^0.21.1", "client-oauth2": "^4.2.5", "cron": "^1.7.2", "crypto-js": "~4.1.1", "file-type": "^14.6.2", + "form-data": "^4.0.0", "lodash.get": "^4.4.2", "mime-types": "^2.1.27", "n8n-workflow": "~0.69.0", "oauth-1.0a": "^2.2.6", "p-cancelable": "^2.0.0", + "qs": "^6.10.1", "request": "^2.88.2", "request-promise-native": "^1.0.7" }, diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index 79db584e49..94f940ed0a 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -8,6 +8,7 @@ import { IExecuteFunctions as IExecuteFunctionsBase, IExecuteSingleFunctions as IExecuteSingleFunctionsBase, IHookFunctions as IHookFunctionsBase, + IHttpRequestOptions, ILoadOptionsFunctions as ILoadOptionsFunctionsBase, INodeExecutionData, INodeType, @@ -34,13 +35,14 @@ export interface IProcessMessage { export interface IExecuteFunctions extends IExecuteFunctionsBase { helpers: { + httpRequest(requestOptions: IHttpRequestOptions): Promise; // tslint:disable-line:no-any prepareBinaryData( binaryData: Buffer, filePath?: string, mimeType?: string, ): Promise; getBinaryDataBuffer(itemIndex: number, propertyName: string): Promise; - request: requestPromise.RequestPromiseAPI; + request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise; // tslint:disable-line:no-any requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, @@ -58,12 +60,13 @@ export interface IExecuteFunctions extends IExecuteFunctionsBase { export interface IExecuteSingleFunctions extends IExecuteSingleFunctionsBase { helpers: { + httpRequest(requestOptions: IHttpRequestOptions): Promise; // tslint:disable-line:no-any prepareBinaryData( binaryData: Buffer, filePath?: string, mimeType?: string, ): Promise; - request: requestPromise.RequestPromiseAPI; + request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise; // tslint:disable-line:no-any requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, @@ -80,12 +83,13 @@ export interface IExecuteSingleFunctions extends IExecuteSingleFunctionsBase { export interface IPollFunctions extends IPollFunctionsBase { helpers: { + httpRequest(requestOptions: IHttpRequestOptions): Promise; // tslint:disable-line:no-any prepareBinaryData( binaryData: Buffer, filePath?: string, mimeType?: string, ): Promise; - request: requestPromise.RequestPromiseAPI; + request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise; // tslint:disable-line:no-any requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, @@ -107,12 +111,13 @@ export interface IResponseError extends Error { export interface ITriggerFunctions extends ITriggerFunctionsBase { helpers: { + httpRequest(requestOptions: IHttpRequestOptions): Promise; // tslint:disable-line:no-any prepareBinaryData( binaryData: Buffer, filePath?: string, mimeType?: string, ): Promise; - request: requestPromise.RequestPromiseAPI; + request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise; // tslint:disable-line:no-any requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, @@ -144,7 +149,8 @@ export interface IUserSettings { export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase { helpers: { - request?: requestPromise.RequestPromiseAPI; + httpRequest(requestOptions: IHttpRequestOptions): Promise; // tslint:disable-line:no-any + request?: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise; // tslint:disable-line:no-any requestOAuth2?: ( this: IAllExecuteFunctions, credentialsType: string, @@ -167,7 +173,8 @@ export interface ICredentialTestFunctions extends ICredentialTestFunctionsBase { export interface IHookFunctions extends IHookFunctionsBase { helpers: { - request: requestPromise.RequestPromiseAPI; + httpRequest(requestOptions: IHttpRequestOptions): Promise; // tslint:disable-line:no-any + request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise; // tslint:disable-line:no-any requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, @@ -184,12 +191,13 @@ export interface IHookFunctions extends IHookFunctionsBase { export interface IWebhookFunctions extends IWebhookFunctionsBase { helpers: { + httpRequest(requestOptions: IHttpRequestOptions): Promise; // tslint:disable-line:no-any prepareBinaryData( binaryData: Buffer, filePath?: string, mimeType?: string, ): Promise; - request: requestPromise.RequestPromiseAPI; + request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise; // tslint:disable-line:no-any requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, diff --git a/packages/core/src/LoadNodeParameterOptions.ts b/packages/core/src/LoadNodeParameterOptions.ts index 3ff1ad99d1..3b71cb9008 100644 --- a/packages/core/src/LoadNodeParameterOptions.ts +++ b/packages/core/src/LoadNodeParameterOptions.ts @@ -1,9 +1,15 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { INode, INodeCredentials, INodeParameters, INodePropertyOptions, + INodeTypeNameVersion, INodeTypes, IWorkflowExecuteAdditionalData, Workflow, @@ -21,27 +27,30 @@ export class LoadNodeParameterOptions { workflow: Workflow; constructor( - nodeTypeName: string, + nodeTypeNameAndVersion: INodeTypeNameVersion, nodeTypes: INodeTypes, path: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials, ) { + const nodeType = nodeTypes.getByNameAndVersion( + nodeTypeNameAndVersion.name, + nodeTypeNameAndVersion.version, + ); this.path = path; - const nodeType = nodeTypes.getByName(nodeTypeName); - if (nodeType === undefined) { - throw new Error(`The node-type "${nodeTypeName}" is not known!`); + throw new Error( + `The node-type "${nodeTypeNameAndVersion.name} v${nodeTypeNameAndVersion.version}" is not known!`, + ); } const nodeData: INode = { parameters: currentNodeParameters, name: TEMP_NODE_NAME, - type: nodeTypeName, - typeVersion: 1, + type: nodeTypeNameAndVersion.name, + typeVersion: nodeTypeNameAndVersion.version, position: [0, 0], }; - if (credentials) { nodeData.credentials = credentials; } @@ -91,12 +100,13 @@ export class LoadNodeParameterOptions { ): Promise { const node = this.workflow.getNode(TEMP_NODE_NAME); - const nodeType = this.workflow.nodeTypes.getByName(node!.type); + const nodeType = this.workflow.nodeTypes.getByNameAndVersion(node!.type, node?.typeVersion); if ( - nodeType!.methods === undefined || - nodeType!.methods.loadOptions === undefined || - nodeType!.methods.loadOptions[methodName] === undefined + !nodeType || + nodeType.methods === undefined || + nodeType.methods.loadOptions === undefined || + nodeType.methods.loadOptions[methodName] === undefined ) { throw new Error( `The node-type "${node!.type}" does not have the method "${methodName}" defined!`, @@ -110,6 +120,6 @@ export class LoadNodeParameterOptions { additionalData, ); - return nodeType!.methods.loadOptions[methodName].call(thisArgs); + return nodeType.methods.loadOptions[methodName].call(thisArgs); } } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 0ed1d6670f..a0bef7ac0a 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-lonely-if */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable no-prototype-builtins */ /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -13,6 +14,7 @@ /* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable no-param-reassign */ import { + GenericValue, IAllExecuteFunctions, IBinaryData, IContextObject, @@ -22,6 +24,9 @@ import { IExecuteFunctions, IExecuteSingleFunctions, IExecuteWorkflowInfo, + IHttpRequestOptions, + IN8nHttpFullResponse, + IN8nHttpResponse, INode, INodeExecutionData, INodeParameters, @@ -48,6 +53,8 @@ import { LoggerProxy as Logger, } from 'n8n-workflow'; +import { Agent } from 'https'; +import { stringify } from 'qs'; import * as clientOAuth1 from 'oauth-1.0a'; import { Token } from 'oauth-1.0a'; import * as clientOAuth2 from 'client-oauth2'; @@ -55,6 +62,7 @@ import * as clientOAuth2 from 'client-oauth2'; import { get } from 'lodash'; // eslint-disable-next-line import/no-extraneous-dependencies import * as express from 'express'; +import * as FormData from 'form-data'; import * as path from 'path'; import { OptionsWithUri, OptionsWithUrl } from 'request'; import * as requestPromise from 'request-promise-native'; @@ -62,6 +70,8 @@ import { createHmac } from 'crypto'; import { fromBuffer } from 'file-type'; import { lookup } from 'mime-types'; +import axios, { AxiosProxyConfig, AxiosRequestConfig, Method } from 'axios'; +import { URLSearchParams } from 'url'; // eslint-disable-next-line import/no-cycle import { BINARY_ENCODING, @@ -73,10 +83,425 @@ import { PLACEHOLDER_EMPTY_EXECUTION_ID, } from '.'; +axios.defaults.timeout = 300000; +// Prevent axios from adding x-form-www-urlencoded headers by default +axios.defaults.headers.post = {}; + const requestPromiseWithDefaults = requestPromise.defaults({ timeout: 300000, // 5 minutes }); +async function parseRequestObject(requestObject: IDataObject) { + // This function is a temporary implementation + // That translates all http requests done via + // the request library to axios directly + // We are not using n8n's interface as it would + // an unnecessary step, considering the `request` + // helper can be deprecated and removed. + const axiosConfig: AxiosRequestConfig = {}; + + if (requestObject.headers !== undefined) { + axiosConfig.headers = requestObject.headers as string; + } + + // Let's start parsing the hardest part, which is the request body. + // The process here is as following? + // - Check if we have a `content-type` header. If this was set, + // we will follow + // - Check if the `form` property was set. If yes, then it's x-www-form-urlencoded + // - Check if the `formData` property exists. If yes, then it's multipart/form-data + // - Lastly, we should have a regular `body` that is probably a JSON. + + const contentTypeHeaderKeyName = + axiosConfig.headers && + Object.keys(axiosConfig.headers).find( + (headerName) => headerName.toLowerCase() === 'content-type', + ); + const contentType = + contentTypeHeaderKeyName && + (axiosConfig.headers[contentTypeHeaderKeyName] as string | undefined); + if (contentType === 'application/x-www-form-urlencoded' && requestObject.formData === undefined) { + // there are nodes incorrectly created, informing the content type header + // and also using formData. Request lib takes precedence for the formData. + // We will do the same. + // Merge body and form properties. + // @ts-ignore + axiosConfig.data = + typeof requestObject.body === 'string' + ? requestObject.body + : new URLSearchParams( + Object.assign(requestObject.body || {}, requestObject.form || {}) as Record< + string, + string + >, + ); + } else if (contentType && contentType.includes('multipart/form-data') !== false) { + if (requestObject.formData !== undefined && requestObject.formData instanceof FormData) { + axiosConfig.data = requestObject.formData; + } else { + const allData = Object.assign(requestObject.body || {}, requestObject.formData || {}); + + const objectKeys = Object.keys(allData); + if (objectKeys.length > 0) { + // Should be a standard object. We must convert to formdata + const form = new FormData(); + + objectKeys.forEach((key) => { + const formField = (allData as IDataObject)[key] as IDataObject; + if (formField.hasOwnProperty('value') && formField.value instanceof Buffer) { + let filename; + // @ts-ignore + if (!!formField.options && formField.options.filename !== undefined) { + filename = (formField.options as IDataObject).filename as string; + } + form.append(key, formField.value, filename); + } else { + form.append(key, formField); + } + }); + axiosConfig.data = form; + } + } + // replace the existing header with a new one that + // contains the boundary property. + // @ts-ignore + delete axiosConfig.headers[contentTypeHeaderKeyName]; + const headers = axiosConfig.data.getHeaders(); + axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers); + } else { + // When using the `form` property it means the content should be x-www-form-urlencoded. + if (requestObject.form !== undefined && requestObject.body === undefined) { + // If we have only form + axiosConfig.data = new URLSearchParams(requestObject.form as Record); + if (axiosConfig.headers !== undefined) { + // remove possibly existing content-type headers + const headers = Object.keys(axiosConfig.headers); + headers.forEach((header) => + header.toLowerCase() === 'content-type' ? delete axiosConfig.headers[header] : null, + ); + axiosConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } else { + axiosConfig.headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + } + } else if (requestObject.formData !== undefined) { + // remove any "content-type" that might exist. + if (axiosConfig.headers !== undefined) { + const headers = Object.keys(axiosConfig.headers); + headers.forEach((header) => + header.toLowerCase() === 'content-type' ? delete axiosConfig.headers[header] : null, + ); + } + + if (requestObject.formData instanceof FormData) { + axiosConfig.data = requestObject.formData; + } else { + const objectKeys = Object.keys(requestObject.formData as object); + if (objectKeys.length > 0) { + // Should be a standard object. We must convert to formdata + const form = new FormData(); + + objectKeys.forEach((key) => { + const formField = (requestObject.formData as IDataObject)[key] as IDataObject; + if (formField.hasOwnProperty('value') && formField.value instanceof Buffer) { + let filename; + // @ts-ignore + if (!!formField.options && formField.options.filename !== undefined) { + filename = (formField.options as IDataObject).filename as string; + } + form.append(key, formField.value, filename); + } else { + form.append(key, formField); + } + }); + axiosConfig.data = form; + } + } + // Mix in headers as FormData creates the boundary. + const headers = axiosConfig.data.getHeaders(); + axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers); + } else if (requestObject.body !== undefined) { + // If we have body and possibly form + if (requestObject.form !== undefined) { + // merge both objects when exist. + requestObject.body = Object.assign(requestObject.body, requestObject.form); + } + axiosConfig.data = requestObject.body as FormData | GenericValue | GenericValue[]; + } + } + + if (requestObject.uri !== undefined) { + axiosConfig.url = requestObject.uri as string; + } + + if (requestObject.url !== undefined) { + axiosConfig.url = requestObject.url as string; + } + + if (requestObject.method !== undefined) { + axiosConfig.method = requestObject.method as Method; + } + + if (requestObject.qs !== undefined && Object.keys(requestObject.qs as object).length > 0) { + axiosConfig.params = requestObject.qs as IDataObject; + } + + if (requestObject.useQuerystring === true) { + axiosConfig.paramsSerializer = (params) => { + return stringify(params, { arrayFormat: 'repeat' }); + }; + } + + if (requestObject.auth !== undefined) { + // Check support for sendImmediately + if ((requestObject.auth as IDataObject).bearer !== undefined) { + axiosConfig.headers = Object.assign(axiosConfig.headers || {}, { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${(requestObject.auth as IDataObject).bearer}`, + }); + } else { + const authObj = requestObject.auth as IDataObject; + // Request accepts both user/username and pass/password + axiosConfig.auth = { + username: (authObj.user || authObj.username) as string, + password: (authObj.password || authObj.pass) as string, + }; + } + } + + // Only set header if we have a body, otherwise it may fail + if (requestObject.json === true) { + // Add application/json headers - do not set charset as it breaks a lot of stuff + // only add if no other accept headers was sent. + const acceptHeaderExists = + axiosConfig.headers === undefined + ? false + : Object.keys(axiosConfig.headers) + .map((headerKey) => headerKey.toLowerCase()) + .includes('accept'); + if (!acceptHeaderExists) { + axiosConfig.headers = Object.assign(axiosConfig.headers || {}, { + Accept: 'application/json', + }); + } + } + if (requestObject.json === false) { + // Prevent json parsing + axiosConfig.transformResponse = (res) => res; + } + + // Axios will follow redirects by default, so we simply tell it otherwise if needed. + if ( + requestObject.followRedirect === false && + ((requestObject.method as string | undefined) || 'get').toLowerCase() === 'get' + ) { + axiosConfig.maxRedirects = 0; + } + if ( + requestObject.followAllRedirect === false && + ((requestObject.method as string | undefined) || 'get').toLowerCase() !== 'get' + ) { + axiosConfig.maxRedirects = 0; + } + + if (requestObject.rejectUnauthorized === false) { + axiosConfig.httpsAgent = new Agent({ + rejectUnauthorized: false, + }); + } + + if (requestObject.timeout !== undefined) { + axiosConfig.timeout = requestObject.timeout as number; + } + + if (requestObject.proxy !== undefined) { + axiosConfig.proxy = requestObject.proxy as AxiosProxyConfig; + } + + if (requestObject.encoding === null) { + // When downloading files, return an arrayBuffer. + axiosConfig.responseType = 'arraybuffer'; + } + + // If we don't set an accept header + // Axios forces "application/json, text/plan, */*" + // Which causes some nodes like NextCloud to break + // as the service returns XML unless requested otherwise. + const allHeaders = axiosConfig.headers ? Object.keys(axiosConfig.headers) : []; + if (!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'accept')) { + axiosConfig.headers = Object.assign(axiosConfig.headers || {}, { accept: '*/*' }); + } + if ( + axiosConfig.data !== undefined && + !(axiosConfig.data instanceof Buffer) && + !allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type') + ) { + // Use default header for application/json + // If we don't specify this here, axios will add + // application/json; charset=utf-8 + // and this breaks a lot of stuff + axiosConfig.headers = Object.assign(axiosConfig.headers || {}, { + 'content-type': 'application/json', + }); + } + + /** + * Missing properties: + * encoding (need testing) + * gzip (ignored - default already works) + * resolveWithFullResponse (implemented elsewhere) + * simple (???) + */ + + return axiosConfig; +} + +async function proxyRequestToAxios( + uriOrObject: string | IDataObject, + options?: IDataObject, +): Promise { + // tslint:disable-line:no-any + + // Check if there's a better way of getting this config here + if (process.env.N8N_USE_DEPRECATED_REQUEST_LIB) { + // @ts-ignore + return requestPromiseWithDefaults.call(null, uriOrObject, options); + } + + let axiosConfig: AxiosRequestConfig = {}; + + let configObject: IDataObject; + if (uriOrObject !== undefined && typeof uriOrObject === 'string') { + axiosConfig.url = uriOrObject; + } + if (uriOrObject !== undefined && typeof uriOrObject === 'object') { + configObject = uriOrObject; + } else { + configObject = options || {}; + } + + axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject)); + + return new Promise((resolve, reject) => { + axios(axiosConfig) + .then((response) => { + if (configObject.resolveWithFullResponse === true) { + resolve({ + body: response.data, + headers: response.headers, + statusCode: response.status, + statusMessage: response.statusText, + request: response.request, + }); + } else { + resolve(response.data); + } + }) + .catch((error) => { + reject(error); + }); + }); +} + +function searchForHeader(headers: IDataObject, headerName: string) { + if (headers === undefined) { + return undefined; + } + + const headerNames = Object.keys(headers); + headerName = headerName.toLowerCase(); + return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName); +} + +function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequestConfig { + // Destructure properties with the same name first. + const { headers, method, timeout, auth, proxy, url } = n8nRequest; + + const axiosRequest = { + headers: headers ?? {}, + method, + timeout, + auth, + proxy, + url, + } as AxiosRequestConfig; + + axiosRequest.params = n8nRequest.qs; + + if (n8nRequest.disableFollowRedirect === true) { + axiosRequest.maxRedirects = 0; + } + + if (n8nRequest.encoding !== undefined) { + axiosRequest.responseType = n8nRequest.encoding; + } + + if (n8nRequest.skipSslCertificateValidation === true) { + axiosRequest.httpsAgent = new Agent({ + rejectUnauthorized: false, + }); + } + + if (n8nRequest.arrayFormat !== undefined) { + axiosRequest.paramsSerializer = (params) => { + return stringify(params, { arrayFormat: n8nRequest.arrayFormat }); + }; + } + + if (n8nRequest.body) { + axiosRequest.data = n8nRequest.body; + // Let's add some useful header standards here. + const existingContentTypeHeaderKey = searchForHeader(axiosRequest.headers, 'content-type'); + if (existingContentTypeHeaderKey === undefined) { + // We are only setting content type headers if the user did + // not set it already manually. We're not overriding, even if it's wrong. + if (axiosRequest.data instanceof FormData) { + axiosRequest.headers = axiosRequest.headers || {}; + axiosRequest.headers['Content-Type'] = 'multipart/form-data'; + } else if (axiosRequest.data instanceof URLSearchParams) { + axiosRequest.headers = axiosRequest.headers || {}; + axiosRequest.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + } + } + + if (n8nRequest.json) { + const key = searchForHeader(axiosRequest.headers, 'accept'); + // If key exists, then the user has set both accept + // header and the json flag. Header should take precedence. + if (!key) { + axiosRequest.headers.Accept = 'application/json'; + } + } + + const userAgentHeader = searchForHeader(axiosRequest.headers, 'user-agent'); + // If key exists, then the user has set both accept + // header and the json flag. Header should take precedence. + if (!userAgentHeader) { + axiosRequest.headers['User-Agent'] = 'n8n'; + } + + return axiosRequest; +} + +async function httpRequest( + requestParams: IHttpRequestOptions, +): Promise { + // tslint:disable-line:no-any + const axiosRequest = convertN8nRequestToAxios(requestParams); + const result = await axios(axiosRequest); + if (requestParams.returnFullResponse) { + return { + body: result.data, + headers: result.headers, + statusCode: result.status, + statusMessage: result.statusText, + }; + } + return result.data; +} + /** * Returns binary data buffer for given item index and property name. * @@ -412,7 +837,7 @@ export async function getCredentials( itemIndex?: number, ): Promise { // Get the NodeType as it has the information if the credentials are required - const nodeType = workflow.nodeTypes.getByName(node.type); + const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType === undefined) { throw new NodeOperationError( node, @@ -543,7 +968,7 @@ export function getNodeParameter( additionalKeys: IWorkflowDataProxyAdditionalKeys, fallbackValue?: any, ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object { - const nodeType = workflow.nodeTypes.getByName(node.type); + const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType === undefined) { throw new Error(`Node type "${node.type}" is not known so can not return paramter value!`); } @@ -669,7 +1094,7 @@ export function getWebhookDescription( workflow: Workflow, node: INode, ): IWebhookDescription | undefined { - const nodeType = workflow.nodeTypes.getByName(node.type) as INodeType; + const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion) as INodeType; if (nodeType.description.webhooks === undefined) { // Node does not have any webhooks so return @@ -776,8 +1201,9 @@ export function getExecutePollFunctions( return workflow.getStaticData(type, node); }, helpers: { + httpRequest, prepareBinaryData, - request: requestPromiseWithDefaults, + request: proxyRequestToAxios, async requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, @@ -881,8 +1307,10 @@ export function getExecuteTriggerFunctions( return workflow.getStaticData(type, node); }, helpers: { + httpRequest, prepareBinaryData, - request: requestPromiseWithDefaults, + + request: proxyRequestToAxios, async requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, @@ -1072,6 +1500,7 @@ export function getExecuteFunctions( } }, helpers: { + httpRequest, prepareBinaryData, async getBinaryDataBuffer( itemIndex: number, @@ -1080,7 +1509,7 @@ export function getExecuteFunctions( ): Promise { return getBinaryDataBuffer.call(this, inputData, itemIndex, propertyName, inputIndex); }, - request: requestPromiseWithDefaults, + request: proxyRequestToAxios, async requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, @@ -1252,8 +1681,9 @@ export function getExecuteSingleFunctions( return workflow.getStaticData(type, node); }, helpers: { + httpRequest, prepareBinaryData, - request: requestPromiseWithDefaults, + request: proxyRequestToAxios, async requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, @@ -1366,7 +1796,8 @@ export function getLoadOptionsFunctions( return additionalData.restApiUrl; }, helpers: { - request: requestPromiseWithDefaults, + httpRequest, + request: proxyRequestToAxios, async requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, @@ -1485,7 +1916,8 @@ export function getExecuteHookFunctions( return workflow.getStaticData(type, node); }, helpers: { - request: requestPromiseWithDefaults, + httpRequest, + request: proxyRequestToAxios, async requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, @@ -1630,8 +2062,9 @@ export function getExecuteWebhookFunctions( }, prepareOutputData: NodeHelpers.prepareOutputData, helpers: { + httpRequest, prepareBinaryData, - request: requestPromiseWithDefaults, + request: proxyRequestToAxios, async requestOAuth2( this: IAllExecuteFunctions, credentialsType: string, diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index c9c00fbf1e..7ebe4dcda7 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -27,6 +27,8 @@ import { IWaitingForExecution, IWorkflowExecuteAdditionalData, LoggerProxy as Logger, + NodeApiError, + NodeOperationError, Workflow, WorkflowExecuteMode, WorkflowOperationError, @@ -624,9 +626,9 @@ export class WorkflowExecute { } catch (error) { // Set the error that it can be saved correctly executionError = { - ...error, - message: error.message, - stack: error.stack, + ...(error as NodeOperationError | NodeApiError), + message: (error as NodeOperationError | NodeApiError).message, + stack: (error as NodeOperationError | NodeApiError).stack, }; // Set the incoming data of the node that it can be saved correctly @@ -837,9 +839,9 @@ export class WorkflowExecute { this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name; executionError = { - ...error, - message: error.message, - stack: error.stack, + ...(error as NodeOperationError | NodeApiError), + message: (error as NodeOperationError | NodeApiError).message, + stack: (error as NodeOperationError | NodeApiError).stack, }; Logger.debug(`Running node "${executionNode.name}" finished with error`, { @@ -889,6 +891,22 @@ export class WorkflowExecute { } } + // Merge error information to default output for now + // As the new nodes can report the errors in + // the `error` property. + for (const execution of nodeSuccessData!) { + for (const lineResult of execution) { + if (lineResult.json.$error !== undefined && lineResult.json.$json !== undefined) { + lineResult.error = lineResult.json.$error as NodeApiError | NodeOperationError; + lineResult.json = { + error: (lineResult.json.$error as NodeApiError | NodeOperationError).message, + }; + } else if (lineResult.error !== undefined) { + lineResult.json = { error: lineResult.error.message }; + } + } + } + // Node executed successfully. So add data and go on. taskData.data = { main: nodeSuccessData, diff --git a/packages/core/test/Helpers.ts b/packages/core/test/Helpers.ts index 5af6b8793e..74305631d1 100644 --- a/packages/core/test/Helpers.ts +++ b/packages/core/test/Helpers.ts @@ -14,6 +14,7 @@ import { ITaskData, IWorkflowBase, IWorkflowExecuteAdditionalData, + NodeHelpers, NodeParameterValue, WorkflowHooks, } from 'n8n-workflow'; @@ -720,11 +721,15 @@ class NodeTypesClass implements INodeTypes { async init(nodeTypes: INodeTypeData): Promise {} getAll(): INodeType[] { - return Object.values(this.nodeTypes).map((data) => data.type); + return Object.values(this.nodeTypes).map((data) => NodeHelpers.getVersionedTypeNode(data.type)); } getByName(nodeType: string): INodeType { - return this.nodeTypes[nodeType].type; + return this.getByNameAndVersion(nodeType); + } + + getByNameAndVersion(nodeType: string, version?: number): INodeType { + return NodeHelpers.getVersionedTypeNode(this.nodeTypes[nodeType].type, version); } } diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 57c0bd59b6..8181647da4 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -13,6 +13,7 @@ import { INodeParameters, INodePropertyOptions, INodeTypeDescription, + INodeTypeNameVersion, IRunExecutionData, IRun, IRunData, @@ -129,9 +130,9 @@ export interface IRestApi { stopCurrentExecution(executionId: string): Promise; makeRestApiRequest(method: string, endpoint: string, data?: any): Promise; // tslint:disable-line:no-any getSettings(): Promise; - getNodeTypes(): Promise; - getNodesInformation(nodeList: string[]): Promise; - getNodeParameterOptions(nodeType: string, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise; + getNodeTypes(onlyLatest?: boolean): Promise; + getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise; + getNodeParameterOptions(nodeTypeAndVersion: INodeTypeNameVersion, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise; removeTestWebhook(workflowId: string): Promise; runWorkflow(runData: IStartRunData): Promise; createNewWorkflow(sendData: IWorkflowDataUpdate): Promise; diff --git a/packages/editor-ui/src/components/NodeCreator/MainPanel.vue b/packages/editor-ui/src/components/NodeCreator/MainPanel.vue index c6475d3695..98c00f8ff0 100644 --- a/packages/editor-ui/src/components/NodeCreator/MainPanel.vue +++ b/packages/editor-ui/src/components/NodeCreator/MainPanel.vue @@ -88,7 +88,6 @@ export default mixins(externalHooks).extend({ filteredNodeTypes(): INodeCreateElement[] { const nodeTypes: INodeCreateElement[] = this.searchItems; const filter = this.searchFilter; - const returnData = nodeTypes.filter((el: INodeCreateElement) => { const nodeType = (el.properties as INodeItemProps).nodeType; return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter); diff --git a/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue b/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue index d4aab126d1..79596fe6c4 100644 --- a/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue +++ b/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue @@ -42,7 +42,19 @@ export default Vue.extend({ return this.allNodeTypes .filter((nodeType: INodeTypeDescription) => { return !HIDDEN_NODES.includes(nodeType.name); - }); + }).reduce((accumulator: INodeTypeDescription[], currentValue: INodeTypeDescription) => { + // keep only latest version of the nodes + // accumulator starts as an empty array. + const exists = accumulator.findIndex(nodes => nodes.name === currentValue.name); + if (exists >= 0 && accumulator[exists].version < currentValue.version) { + // This must be a versioned node and we've found a newer version. + // Replace the previous one with this one. + accumulator[exists] = currentValue; + } else { + accumulator.push(currentValue); + } + return accumulator; + }, []); }, categoriesWithNodes(): ICategoriesWithNodes { return getCategoriesWithNodes(this.visibleNodeTypes); diff --git a/packages/editor-ui/src/components/NodeSettings.vue b/packages/editor-ui/src/components/NodeSettings.vue index 14b5e404c6..3fabe545bc 100644 --- a/packages/editor-ui/src/components/NodeSettings.vue +++ b/packages/editor-ui/src/components/NodeSettings.vue @@ -80,7 +80,7 @@ export default mixins( computed: { nodeType (): INodeTypeDescription | null { if (this.node) { - return this.$store.getters.nodeType(this.node.type); + return this.$store.getters.nodeType(this.node.type, this.node.typeVersion); } return null; diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index b1e7874b02..ac5fe82d8d 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -571,7 +571,7 @@ export default mixins( const resolvedNodeParameters = this.resolveParameter(currentNodeParameters) as INodeParameters; try { - const options = await this.restApi().getNodeParameterOptions(this.node.type, this.path, this.remoteMethod, resolvedNodeParameters, this.node.credentials); + const options = await this.restApi().getNodeParameterOptions({name: this.node.type, version: this.node.typeVersion}, this.path, this.remoteMethod, resolvedNodeParameters, this.node.credentials); this.remoteParameterOptions.push.apply(this.remoteParameterOptions, options); } catch (error) { this.remoteParameterOptionsLoadingIssues = error.message; diff --git a/packages/editor-ui/src/components/mixins/restApi.ts b/packages/editor-ui/src/components/mixins/restApi.ts index 1c696880e5..8c17a407bc 100644 --- a/packages/editor-ui/src/components/mixins/restApi.ts +++ b/packages/editor-ui/src/components/mixins/restApi.ts @@ -24,6 +24,7 @@ import { INodeParameters, INodePropertyOptions, INodeTypeDescription, + INodeTypeNameVersion, } from 'n8n-workflow'; import { makeRestApiRequest } from '@/api/helpers'; @@ -82,18 +83,18 @@ export const restApi = Vue.extend({ }, // Returns all node-types - getNodeTypes: (): Promise => { - return self.restApi().makeRestApiRequest('GET', `/node-types`); + getNodeTypes: (onlyLatest = false): Promise => { + return self.restApi().makeRestApiRequest('GET', `/node-types`, {onlyLatest}); }, - getNodesInformation: (nodeList: string[]): Promise => { - return self.restApi().makeRestApiRequest('POST', `/node-types`, {nodeNames: nodeList}); + getNodesInformation: (nodeInfos: INodeTypeNameVersion[]): Promise => { + return self.restApi().makeRestApiRequest('POST', `/node-types`, {nodeInfos}); }, // Returns all the parameter options from the server - getNodeParameterOptions: (nodeType: string, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise => { + getNodeParameterOptions: (nodeTypeAndVersion: INodeTypeNameVersion, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise => { const sendData = { - nodeType, + nodeTypeAndVersion, path, methodName, credentials, diff --git a/packages/editor-ui/src/components/mixins/workflowHelpers.ts b/packages/editor-ui/src/components/mixins/workflowHelpers.ts index 64d8028ba3..23c7e53f4b 100644 --- a/packages/editor-ui/src/components/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/components/mixins/workflowHelpers.ts @@ -16,6 +16,7 @@ import { INodeTypes, INodeTypeData, INodeTypeDescription, + INodeVersionedType, IRunData, IRunExecutionData, IWorfklowIssues, @@ -158,7 +159,7 @@ export const workflowHelpers = mixins( continue; } - nodeType = workflow.nodeTypes.getByName(node.type); + nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType === undefined) { // Node type is not known @@ -189,17 +190,28 @@ export const workflowHelpers = mixins( const nodeTypes: INodeTypes = { nodeTypes: {}, init: async (nodeTypes?: INodeTypeData): Promise => { }, - getAll: (): INodeType[] => { + getAll: (): Array => { // Does not get used in Workflow so no need to return it return []; }, - getByName: (nodeType: string): INodeType | undefined => { + getByName: (nodeType: string): INodeType | INodeVersionedType | undefined => { const nodeTypeDescription = this.$store.getters.nodeType(nodeType); if (nodeTypeDescription === null) { return undefined; } + return { + description: nodeTypeDescription, + }; + }, + getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => { + const nodeTypeDescription = this.$store.getters.nodeType(nodeType, version); + + if (nodeTypeDescription === null) { + return undefined; + } + return { description: nodeTypeDescription, }; @@ -283,7 +295,7 @@ export const workflowHelpers = mixins( // Get the data of the node type that we can get the default values // TODO: Later also has to care about the node-type-version as defaults could be different - const nodeType = this.$store.getters.nodeType(node.type) as INodeTypeDescription; + const nodeType = this.$store.getters.nodeType(node.type, node.typeVersion) as INodeTypeDescription; if (nodeType !== null) { // Node-Type is known so we can save the parameters correctly diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index be59a76a69..094c6b6c7c 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -6,6 +6,7 @@ export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]' // workflows export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__'; +export const DEFAULT_NODETYPE_VERSION = 1; export const DEFAULT_NEW_WORKFLOW_NAME = 'My workflow'; export const MIN_WORKFLOW_NAME_LENGTH = 1; export const MAX_WORKFLOW_NAME_LENGTH = 128; diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index eeebdddd9b..17003043d9 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -2,7 +2,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; -import { PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants'; +import { PLACEHOLDER_EMPTY_WORKFLOW_ID, DEFAULT_NODETYPE_VERSION } from '@/constants'; import { IConnection, @@ -589,11 +589,10 @@ export const store = new Vuex.Store({ }, updateNodeTypes (state, nodeTypes: INodeTypeDescription[]) { - const updatedNodeNames = nodeTypes.map(node => node.name) as string[]; - const oldNodesNotChanged = state.nodeTypes.filter(node => !updatedNodeNames.includes(node.name)); - const updatedNodes = [...oldNodesNotChanged, ...nodeTypes]; - Vue.set(state, 'nodeTypes', updatedNodes); - state.nodeTypes = updatedNodes; + const oldNodesToKeep = state.nodeTypes.filter(node => !nodeTypes.find(n => n.name === node.name && n.version === node.version)); + const newNodesState = [...oldNodesToKeep, ...nodeTypes]; + Vue.set(state, 'nodeTypes', newNodesState); + state.nodeTypes = newNodesState; }, addSidebarMenuItems (state, menuItems: IMenuItem[]) { @@ -754,9 +753,9 @@ export const store = new Vuex.Store({ allNodeTypes: (state): INodeTypeDescription[] => { return state.nodeTypes; }, - nodeType: (state) => (nodeType: string): INodeTypeDescription | null => { + nodeType: (state, getters) => (nodeType: string, typeVersion?: number): INodeTypeDescription | null => { const foundType = state.nodeTypes.find(typeData => { - return typeData.name === nodeType; + return typeData.name === nodeType && typeData.version === (typeVersion || typeData.defaultVersion || DEFAULT_NODETYPE_VERSION); }); if (foundType === undefined) { diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index ebe7a8dbe0..780759f7c6 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -142,6 +142,8 @@ import { INodeConnections, INodeIssues, INodeTypeDescription, + INodeTypeNameVersion, + NodeInputConnections, NodeHelpers, Workflow, IRun, @@ -1867,13 +1869,13 @@ export default mixins( // Before proceeding we must check if all nodes contain the `properties` attribute. // Nodes are loaded without this information so we must make sure that all nodes // being added have this information. - await this.loadNodesProperties(nodes.map(node => node.type)); + await this.loadNodesProperties(nodes.map(node => ({name: node.type, version: node.typeVersion}))); // Add the node to the node-list let nodeType: INodeTypeDescription | null; let foundNodeIssues: INodeIssues | null; nodes.forEach((node) => { - nodeType = this.$store.getters.nodeType(node.type); + nodeType = this.$store.getters.nodeType(node.type, node.typeVersion); // Make sure that some properties always exist if (!node.hasOwnProperty('disabled')) { @@ -1980,7 +1982,7 @@ export default mixins( let newName: string; const createNodes: INode[] = []; - await this.loadNodesProperties(data.nodes.map(node => node.type)); + await this.loadNodesProperties(data.nodes.map(node => ({name: node.type, version: node.typeVersion}))); data.nodes.forEach(node => { if (nodeTypesCount[node.type] !== undefined) { @@ -2206,9 +2208,19 @@ export default mixins( async loadCredentials (): Promise { await this.$store.dispatch('credentials/fetchAllCredentials'); }, - async loadNodesProperties(nodeNames: string[]): Promise { - const allNodes = this.$store.getters.allNodeTypes; - const nodesToBeFetched = allNodes.filter((node: INodeTypeDescription) => nodeNames.includes(node.name) && !node.hasOwnProperty('properties')).map((node: INodeTypeDescription) => node.name) as string[]; + async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise { + const allNodes:INodeTypeDescription[] = this.$store.getters.allNodeTypes; + + const nodesToBeFetched:INodeTypeNameVersion[] = []; + allNodes.forEach(node => { + if(!!nodeInfos.find(n => n.name === node.name && n.version === node.version) && !node.hasOwnProperty('properties')) { + nodesToBeFetched.push({ + name: node.name, + version: node.version, + }); + } + }); + if (nodesToBeFetched.length > 0) { // Only call API if node information is actually missing this.startLoading(); diff --git a/packages/nodes-base/nodes/DeepL/DeepL.node.ts b/packages/nodes-base/nodes/DeepL/DeepL.node.ts index 30169b2224..d4e4185719 100644 --- a/packages/nodes-base/nodes/DeepL/DeepL.node.ts +++ b/packages/nodes-base/nodes/DeepL/DeepL.node.ts @@ -133,7 +133,7 @@ export class DeepL implements INodeType { } } catch (error) { if (this.continueOnFail()) { - responseData.push({ error: error.message }); + responseData.push({ $error: error, $json: this.getInputData(i)}); continue; } throw error; diff --git a/packages/nodes-base/nodes/HttpRequest.node.json b/packages/nodes-base/nodes/HttpRequest.node.json index 7aca5bcb2a..1f31916faf 100644 --- a/packages/nodes-base/nodes/HttpRequest.node.json +++ b/packages/nodes-base/nodes/HttpRequest.node.json @@ -1,3 +1,4 @@ + { "node": "n8n-nodes-base.httpRequest", "nodeVersion": "1.0", diff --git a/packages/nodes-base/nodes/HttpRequest.node.ts b/packages/nodes-base/nodes/HttpRequest.node.ts index 563c53490c..00765ad3b3 100644 --- a/packages/nodes-base/nodes/HttpRequest.node.ts +++ b/packages/nodes-base/nodes/HttpRequest.node.ts @@ -1057,4 +1057,4 @@ export class HttpRequest implements INodeType { return this.prepareOutputData(returnItems); } -} +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/GenericFunctions.ts b/packages/nodes-base/nodes/Mattermost/GenericFunctions.ts deleted file mode 100644 index a6bcacd8fe..0000000000 --- a/packages/nodes-base/nodes/Mattermost/GenericFunctions.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - IExecuteFunctions, - IHookFunctions, - ILoadOptionsFunctions, -} from 'n8n-core'; - -import { - OptionsWithUri, -} from 'request'; - -import { - IDataObject, NodeApiError, NodeOperationError, -} from 'n8n-workflow'; - -export interface IAttachment { - fields: { - item?: object[]; - }; - actions: { - item?: object[]; - }; -} - -/** - * Make an API request to Telegram - * - * @param {IHookFunctions} this - * @param {string} method - * @param {string} url - * @param {object} body - * @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 = await this.getCredentials('mattermostApi'); - - if (credentials === undefined) { - throw new NodeOperationError(this.getNode(), 'No credentials got returned!'); - } - - query = query || {}; - - const options: OptionsWithUri = { - method, - body, - qs: query, - uri: `${credentials.baseUrl}/api/v4/${endpoint}`, - headers: { - Authorization: `Bearer ${credentials.accessToken}`, - 'content-type': 'application/json; charset=utf-8', - }, - json: true, - }; - - try { - return await this.helpers.request!(options); - } catch (error) { - throw new NodeApiError(this.getNode(), error); - } -} - -export async function apiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any - - const returnData: IDataObject[] = []; - - let responseData; - query.page = 0; - query.per_page = 100; - - do { - responseData = await apiRequest.call(this, method, endpoint, body, query); - query.page++; - returnData.push.apply(returnData, responseData); - } while ( - responseData.length !== 0 - ); - - return returnData; -} diff --git a/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts b/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts index 87c2ba30ea..f8d45d5cd9 100644 --- a/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts +++ b/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts @@ -1,2404 +1,27 @@ import { - IExecuteFunctions, -} from 'n8n-core'; - -import { - IDataObject, - ILoadOptionsFunctions, - INodeExecutionData, - INodePropertyOptions, - INodeType, - INodeTypeDescription, - NodeOperationError, + INodeTypeBaseDescription, + INodeVersionedType, } from 'n8n-workflow'; -import { - apiRequest, - apiRequestAllItems, - IAttachment, -} from './GenericFunctions'; - -import { - snakeCase, -} from 'change-case'; - -export class Mattermost implements INodeType { - description: INodeTypeDescription = { - displayName: 'Mattermost', - name: 'mattermost', - icon: 'file:mattermost.svg', - group: ['output'], - version: 1, - subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', - description: 'Sends data to Mattermost', - defaults: { - name: 'Mattermost', - color: '#000000', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - name: 'mattermostApi', - required: true, - }, - ], - properties: [ - { - displayName: 'Resource', - name: 'resource', - type: 'options', - options: [ - { - name: 'Channel', - value: 'channel', - }, - { - name: 'Message', - value: 'message', - }, - { - name: 'Reaction', - value: 'reaction', - }, - { - name: 'User', - value: 'user', - }, - ], - default: 'message', - description: 'The resource to operate on', - }, - - - - // ---------------------------------- - // operations - // ---------------------------------- - { - displayName: 'Operation', - name: 'operation', - type: 'options', - displayOptions: { - show: { - resource: [ - 'channel', - ], - }, - }, - options: [ - { - name: 'Add User', - value: 'addUser', - description: 'Add a user to a channel', - }, - { - name: 'Create', - value: 'create', - description: 'Create a new channel', - }, - { - name: 'Delete', - value: 'delete', - description: 'Soft delete a channel', - }, - { - name: 'Member', - value: 'members', - description: 'Get a page of members for a channel', - }, - { - name: 'Restore', - value: 'restore', - description: 'Restores a soft deleted channel', - }, - { - name: 'Statistics', - value: 'statistics', - description: 'Get statistics for a channel', - }, - ], - default: 'create', - description: 'The operation to perform.', - }, - { - displayName: 'Operation', - name: 'operation', - type: 'options', - displayOptions: { - show: { - resource: [ - 'message', - ], - }, - }, - options: [ - { - name: 'Delete', - value: 'delete', - description: 'Soft delete a post, by marking the post as deleted in the database', - }, - { - name: 'Post', - value: 'post', - description: 'Post a message into a channel', - }, - { - name: 'Post Ephemeral', - value: 'postEphemeral', - description: 'Post an ephemeral message into a channel', - }, - ], - default: 'post', - description: 'The operation to perform', - }, - { - displayName: 'Operation', - name: 'operation', - type: 'options', - displayOptions: { - show: { - resource: [ - 'reaction', - ], - }, - }, - options: [ - { - name: 'Create', - value: 'create', - description: 'Add a reaction to a post.', - }, - { - name: 'Delete', - value: 'delete', - description: 'Remove a reaction from a post', - }, - { - name: 'Get All', - value: 'getAll', - description: 'Get all the reactions to one or more posts', - }, - ], - default: 'create', - description: 'The operation to perform', - }, - - - - // ---------------------------------- - // channel - // ---------------------------------- - - // ---------------------------------- - // channel:create - // ---------------------------------- - { - displayName: 'Team ID', - name: 'teamId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getTeams', - }, - options: [], - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'create', - ], - resource: [ - 'channel', - ], - }, - }, - description: 'The Mattermost Team.', - }, - { - displayName: 'Display Name', - name: 'displayName', - type: 'string', - default: '', - placeholder: 'Announcements', - displayOptions: { - show: { - operation: [ - 'create', - ], - resource: [ - 'channel', - ], - }, - }, - required: true, - description: 'The non-unique UI name for the channel', - }, - { - displayName: 'Name', - name: 'channel', - type: 'string', - default: '', - placeholder: 'announcements', - displayOptions: { - show: { - operation: [ - 'create', - ], - resource: [ - 'channel', - ], - }, - }, - required: true, - description: 'The unique handle for the channel, will be present in the channel URL', - }, - { - displayName: 'Type', - name: 'type', - type: 'options', - displayOptions: { - show: { - operation: [ - 'create', - ], - resource: [ - 'channel', - ], - }, - }, - options: [ - { - name: 'Private', - value: 'private', - }, - { - name: 'Public', - value: 'public', - }, - ], - default: 'public', - description: 'The type of channel to create.', - }, - - - // ---------------------------------- - // channel:delete - // ---------------------------------- - { - displayName: 'Channel ID', - name: 'channelId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getChannels', - }, - options: [], - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'delete', - ], - resource: [ - 'channel', - ], - }, - }, - description: 'The ID of the channel to soft delete', - }, - - // ---------------------------------- - // channel:members - // ---------------------------------- - { - displayName: 'Team ID', - name: 'teamId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getTeams', - }, - options: [], - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'members', - ], - resource: [ - 'channel', - ], - }, - }, - description: 'The Mattermost Team.', - }, - { - displayName: 'Channel ID', - name: 'channelId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getChannelsInTeam', - loadOptionsDependsOn: [ - 'teamId', - ], - }, - options: [], - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'members', - ], - resource: [ - 'channel', - ], - }, - }, - description: 'The Mattermost Team.', - }, - { - displayName: 'Resolve Data', - name: 'resolveData', - type: 'boolean', - displayOptions: { - show: { - resource: [ - 'channel', - ], - operation: [ - 'members', - ], - }, - }, - default: true, - description: 'By default the response only contain the ID of the user.
If this option gets activated it will resolve the user automatically.', - }, - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: { - operation: [ - 'members', - ], - resource: [ - 'channel', - ], - }, - }, - default: true, - description: 'If all results should be returned or only up to a given limit.', - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - operation: [ - 'members', - ], - resource: [ - 'channel', - ], - returnAll: [ - false, - ], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 100, - }, - default: 100, - description: 'How many results to return.', - }, - - // ---------------------------------- - // channel:restore - // ---------------------------------- - { - displayName: 'Channel ID', - name: 'channelId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'restore', - ], - resource: [ - 'channel', - ], - }, - }, - description: 'The ID of the channel to restore.', - }, - - - // ---------------------------------- - // channel:addUser - // ---------------------------------- - { - displayName: 'Channel ID', - name: 'channelId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getChannels', - }, - options: [], - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'addUser', - ], - resource: [ - 'channel', - ], - }, - }, - description: 'The ID of the channel to invite user to.', - }, - { - displayName: 'User ID', - name: 'userId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - options: [], - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'addUser', - ], - resource: [ - 'channel', - ], - }, - }, - description: 'The ID of the user to invite into channel.', - }, - - - // ---------------------------------- - // channel:statistics - // ---------------------------------- - { - displayName: 'Channel ID', - name: 'channelId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getChannels', - }, - options: [], - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'statistics', - ], - resource: [ - 'channel', - ], - }, - }, - description: 'The ID of the channel to get the statistics from.', - }, - - // ---------------------------------- - // message - // ---------------------------------- - - // ---------------------------------- - // message:delete - // ---------------------------------- - { - displayName: 'Post ID', - name: 'postId', - type: 'string', - required: true, - displayOptions: { - show: { - resource: [ - 'message', - ], - operation: [ - 'delete', - ], - }, - }, - default: '', - description: 'ID of the post to delete', - }, - - // ---------------------------------- - // message:post - // ---------------------------------- - { - displayName: 'Channel ID', - name: 'channelId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getChannels', - }, - options: [], - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'post', - ], - resource: [ - 'message', - ], - }, - }, - description: 'The ID of the channel to post to.', - }, - { - displayName: 'Message', - name: 'message', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - displayOptions: { - show: { - operation: [ - 'post', - ], - resource: [ - 'message', - ], - }, - }, - description: 'The text to send.', - }, - { - displayName: 'Attachments', - name: 'attachments', - type: 'collection', - typeOptions: { - multipleValues: true, - multipleValueButtonText: 'Add attachment', - }, - displayOptions: { - show: { - operation: [ - 'post', - ], - resource: [ - 'message', - ], - }, - }, - default: {}, - description: 'The attachment to add', - placeholder: 'Add attachment item', - options: [ - { - displayName: 'Actions', - name: 'actions', - placeholder: 'Add Actions', - description: 'Actions to add to message. More information can be found here', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - default: {}, - options: [ - { - displayName: 'Item', - name: 'item', - values: [ - { - displayName: 'Type', - name: 'type', - type: 'options', - options: [ - { - name: 'Button', - value: 'button', - }, - { - name: 'Select', - value: 'select', - }, - ], - default: 'button', - description: 'The type of the action.', - }, - { - displayName: 'Data Source', - name: 'data_source', - type: 'options', - displayOptions: { - show: { - type: [ - 'select', - ], - }, - }, - options: [ - { - name: 'Channels', - value: 'channels', - }, - { - name: 'Custom', - value: 'custom', - }, - { - name: 'Users', - value: 'users', - }, - - ], - default: 'custom', - description: 'The type of the action.', - }, - { - displayName: 'Options', - name: 'options', - placeholder: 'Add Option', - description: 'Adds a new option to select field.', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - displayOptions: { - show: { - data_source: [ - 'custom', - ], - type: [ - 'select', - ], - }, - }, - default: {}, - options: [ - { - name: 'option', - displayName: 'Option', - default: {}, - values: [ - { - displayName: 'Option Text', - name: 'text', - type: 'string', - default: '', - description: 'Text of the option.', - }, - { - displayName: 'Option Value', - name: 'value', - type: 'string', - default: '', - description: 'Value of the option.', - }, - ], - }, - ], - }, - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - description: 'Name of the Action.', - }, - { - displayName: 'Integration', - name: 'integration', - placeholder: 'Add Integration', - description: 'Integration to add to message.', - type: 'fixedCollection', - typeOptions: { - multipleValues: false, - }, - default: {}, - options: [ - { - displayName: 'Item', - name: 'item', - default: {}, - values: [ - { - displayName: 'URL', - name: 'url', - type: 'string', - default: '', - description: 'URL of the Integration.', - }, - { - displayName: 'Context', - name: 'context', - placeholder: 'Add Context to Integration', - description: 'Adds a Context values set.', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - default: {}, - options: [ - { - name: 'property', - displayName: 'Property', - default: {}, - values: [ - { - displayName: 'Property Name', - name: 'name', - type: 'string', - default: '', - description: 'Name of the property to set.', - }, - { - displayName: 'Property Value', - name: 'value', - type: 'string', - default: '', - description: 'Value of the property to set.', - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - { - displayName: 'Author Icon', - name: 'author_icon', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - description: 'Icon which should appear for the user.', - }, - { - displayName: 'Author Link', - name: 'author_link', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - description: 'Link for the author.', - }, - { - displayName: 'Author Name', - name: 'author_name', - type: 'string', - default: '', - description: 'Name that should appear.', - }, - { - displayName: 'Color', - name: 'color', - type: 'color', - default: '#ff0000', - description: 'Color of the line left of text.', - }, - { - displayName: 'Fallback Text', - name: 'fallback', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - description: 'Required plain-text summary of the attachment.', - }, - { - displayName: 'Fields', - name: 'fields', - placeholder: 'Add Fields', - description: 'Fields to add to message.', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - default: {}, - options: [ - { - name: 'item', - displayName: 'Item', - values: [ - { - displayName: 'Title', - name: 'title', - type: 'string', - default: '', - description: 'Title of the item.', - }, - { - displayName: 'Value', - name: 'value', - type: 'string', - default: '', - description: 'Value of the item.', - }, - { - displayName: 'Short', - name: 'short', - type: 'boolean', - default: true, - description: 'If items can be displayed next to each other.', - }, - ], - }, - ], - }, - { - displayName: 'Footer', - name: 'footer', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - description: 'Text of footer to add.', - }, - { - displayName: 'Footer Icon', - name: 'footer_icon', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - description: 'Icon which should appear next to footer.', - }, - { - displayName: 'Image URL', - name: 'image_url', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - description: 'URL of image.', - }, - { - displayName: 'Pretext', - name: 'pretext', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - description: 'Text which appears before the message block.', - }, - { - displayName: 'Text', - name: 'text', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - description: 'Text to send.', - }, - { - displayName: 'Thumbnail URL', - name: 'thumb_url', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - description: 'URL of thumbnail.', - }, - { - displayName: 'Title', - name: 'title', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - description: 'Title of the message.', - }, - { - displayName: 'Title Link', - name: 'title_link', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - description: 'Link of the title.', - }, - ], - }, - - // ---------------------------------- - // message:post (ephemeral) - // ---------------------------------- - { - displayName: 'User ID', - name: 'userId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - options: [], - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'postEphemeral', - ], - resource: [ - 'message', - ], - }, - }, - description: 'ID of the user to send the ephemeral message to.', - }, - { - displayName: 'Channel ID', - name: 'channelId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getChannels', - }, - default: '', - required: true, - displayOptions: { - show: { - operation: [ - 'postEphemeral', - ], - resource: [ - 'message', - ], - }, - }, - description: 'ID of the channel to send the ephemeral message in.', - }, - { - displayName: 'Message', - name: 'message', - type: 'string', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - displayOptions: { - show: { - operation: [ - 'postEphemeral', - ], - resource: [ - 'message', - ], - }, - }, - description: 'Text to send in the ephemeral message.', - }, - { - displayName: 'Other Options', - name: 'otherOptions', - type: 'collection', - displayOptions: { - show: { - operation: [ - 'post', - ], - resource: [ - 'message', - ], - }, - }, - default: {}, - description: 'Other options to set', - placeholder: 'Add options', - options: [ - { - displayName: 'Make Comment', - name: 'root_id', - type: 'string', - default: '', - description: 'The post ID to comment on', - }, - ], - }, - - // ---------------------------------- - // reaction - // ---------------------------------- - { - displayName: 'User ID', - name: 'userId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - options: [], - default: '', - required: true, - displayOptions: { - show: { - resource: [ - 'reaction', - ], - operation: [ - 'create', - ], - }, - }, - description: 'ID of the user sending the reaction.', - }, - { - displayName: 'Post ID', - name: 'postId', - type: 'string', - default: '', - placeholder: '3moacfqxmbdw38r38fjprh6zsr', - required: true, - displayOptions: { - show: { - resource: [ - 'reaction', - ], - operation: [ - 'create', - ], - }, - }, - description: 'ID of the post to react to.
Obtainable from the post link:
https://mattermost.internal.n8n.io/[server]/pl/[postId]', - }, - { - displayName: 'Emoji Name', - name: 'emojiName', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - resource: [ - 'reaction', - ], - operation: [ - 'create', - ], - }, - }, - description: 'Emoji to use for this reaction.', - }, - { - displayName: 'User ID', - name: 'userId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - options: [], - default: '', - required: true, - displayOptions: { - show: { - resource: [ - 'reaction', - ], - operation: [ - 'delete', - ], - }, - }, - description: 'ID of the user whose reaction to delete.', - }, - { - displayName: 'Post ID', - name: 'postId', - type: 'string', - default: '', - placeholder: '3moacfqxmbdw38r38fjprh6zsr', - required: true, - displayOptions: { - show: { - resource: [ - 'reaction', - ], - operation: [ - 'delete', - ], - }, - }, - description: 'ID of the post whose reaction to delete.
Obtainable from the post link:
https://mattermost.internal.n8n.io/[server]/pl/[postId]', - }, - { - displayName: 'Emoji Name', - name: 'emojiName', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - resource: [ - 'reaction', - ], - operation: [ - 'delete', - ], - }, - }, - description: 'Name of the emoji to delete.', - }, - { - displayName: 'Post ID', - name: 'postId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - resource: [ - 'reaction', - ], - operation: [ - 'getAll', - ], - }, - }, - description: 'One or more (comma-separated) posts to retrieve reactions from.', - }, - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: { - operation: [ - 'getAll', - ], - resource: [ - 'reaction', - ], - }, - }, - default: true, - 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: [ - 'reaction', - ], - returnAll: [ - false, - ], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 100, - }, - default: 100, - description: 'How many results to return.', - }, - - // ---------------------------------- - // user - // ---------------------------------- - { - displayName: 'Operation', - name: 'operation', - type: 'options', - displayOptions: { - show: { - resource: [ - 'user', - ], - }, - }, - options: [ - { - name: 'Create', - value: 'create', - description: 'Create a new user', - }, - { - name: 'Deactive', - value: 'deactive', - description: 'Deactivates the user and revokes all its sessions by archiving its user object.', - }, - { - name: 'Get All', - value: 'getAll', - description: 'Retrieve all users', - }, - { - name: 'Get By Email', - value: 'getByEmail', - description: 'Get a user by email', - }, - { - name: 'Get By ID', - value: 'getById', - description: 'Get a user by id', - }, - { - name: 'Invite', - value: 'invite', - description: 'Invite user to team', - }, - ], - default: '', - description: 'The operation to perform.', - }, - // ---------------------------------- - // user:create - // ---------------------------------- - { - displayName: 'Username', - name: 'username', - type: 'string', - required: true, - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'create', - ], - }, - }, - default: '', - }, - { - displayName: 'Auth Service', - name: 'authService', - type: 'options', - options: [ - { - name: 'Email', - value: 'email', - }, - { - name: 'Gitlab', - value: 'gitlab', - }, - { - name: 'Google', - value: 'google', - }, - { - name: 'LDAP', - value: 'ldap', - }, - { - name: 'Office365', - value: 'office365', - }, - { - name: 'SAML', - value: 'saml', - }, - ], - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'create', - ], - }, - }, - default: '', - }, - { - displayName: 'Auth Data', - name: 'authData', - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'create', - ], - }, - hide: { - authService: [ - 'email', - ], - }, - }, - type: 'string', - default: '', - }, - { - displayName: 'Email', - name: 'email', - type: 'string', - default: '', - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'create', - ], - authService: [ - 'email', - ], - }, - }, - }, - { - displayName: 'Password', - name: 'password', - type: 'string', - typeOptions: { - password: true, - }, - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'create', - ], - authService: [ - 'email', - ], - }, - }, - default: '', - description: 'The password used for email authentication.', - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - displayOptions: { - show: { - operation: [ - 'create', - ], - resource: [ - 'user', - ], - }, - }, - default: {}, - options: [ - { - displayName: 'First Name', - name: 'first_name', - type: 'string', - default: '', - }, - { - displayName: 'Last Name', - name: 'last_name', - type: 'string', - default: '', - }, - { - displayName: 'Locale', - name: 'locale', - type: 'string', - default: '', - }, - { - displayName: 'Nickname', - name: 'nickname', - type: 'string', - default: '', - }, - { - displayName: 'Notification Settings', - name: 'notificationUi', - type: 'fixedCollection', - placeholder: 'Add Notification Setting', - default: {}, - typeOptions: { - multipleValues: false, - }, - options: [ - { - displayName: 'Notify', - name: 'notificationValues', - values: [ - { - displayName: 'Channel', - name: 'channel', - type: 'boolean', - default: true, - description: `Set to "true" to enable channel-wide notifications (@channel, @all, etc.), "false" to disable. Defaults to "true".`, - }, - { - displayName: 'Desktop', - name: 'desktop', - type: 'options', - options: [ - { - name: 'All', - value: 'all', - description: 'Notifications for all activity', - }, - { - name: 'Mention', - value: 'mention', - description: 'Mentions and direct messages only', - }, - { - name: 'None', - value: 'none', - description: 'Mentions and direct messages only', - }, - ], - default: 'all', - }, - { - displayName: 'Desktop Sound', - name: 'desktop_sound', - type: 'boolean', - default: true, - description: `Set to "true" to enable sound on desktop notifications, "false" to disable. Defaults to "true".`, - }, - { - displayName: 'Email', - name: 'email', - type: 'boolean', - default: false, - description: `Set to "true" to enable email notifications, "false" to disable. Defaults to "true".`, - }, - { - displayName: 'First Name', - name: 'first_name', - type: 'boolean', - default: false, - description: `Set to "true" to enable mentions for first name. Defaults to "true" if a first name is set, "false" otherwise.`, - }, - { - displayName: 'Mention Keys', - name: 'mention_keys', - type: 'string', - default: '', - description: `A comma-separated list of words to count as mentions. Defaults to username and @username.`, - }, - { - displayName: 'Push', - name: 'push', - type: 'options', - options: [ - { - name: 'All', - value: 'all', - description: 'Notifications for all activity', - }, - { - name: 'Mention', - value: 'mention', - description: 'Mentions and direct messages only', - }, - { - name: 'None', - value: 'none', - description: 'Mentions and direct messages only', - }, - ], - default: 'mention', - }, - ], - }, - ], - }, - ], - }, - - // ---------------------------------- - // user:deactivate - // ---------------------------------- - { - displayName: 'User ID', - name: 'userId', - type: 'string', - required: true, - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'deactive', - ], - }, - }, - default: '', - description: 'User GUID', - }, - - // ---------------------------------- - // user:invite - // ---------------------------------- - { - displayName: 'Team ID', - name: 'teamId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getTeams', - }, - required: true, - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'invite', - ], - }, - }, - default: '', - }, - { - displayName: 'Emails', - name: 'emails', - type: 'string', - required: true, - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'invite', - ], - }, - }, - default: '', - description: `User's email. Multiple can be set separated by comma.`, - }, - - // ---------------------------------- - // user:getAll - // ---------------------------------- - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'getAll', - ], - }, - }, - default: true, - description: 'If all results should be returned or only up to a given limit.', - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'getAll', - ], - returnAll: [ - false, - ], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 100, - }, - default: 100, - description: 'How many results to return.', - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'getAll', - ], - }, - }, - default: {}, - options: [ - { - displayName: 'In Channel', - name: 'inChannel', - type: 'string', - default: '', - description: 'The ID of the channel to get users for.', - }, - { - displayName: 'In Team', - name: 'inTeam', - type: 'string', - default: '', - description: 'The ID of the team to get users for.', - }, - { - displayName: 'Not In Team', - name: 'notInTeam', - type: 'string', - default: '', - description: 'The ID of the team to exclude users for.', - }, - { - displayName: 'Not In Channel', - name: 'notInChannel', - type: 'string', - default: '', - description: 'The ID of the channel to exclude users for.', - }, - { - displayName: 'Sort', - name: 'sort', - type: 'options', - options: [ - { - name: 'Created At', - value: 'createdAt', - }, - { - name: 'Last Activity At', - value: 'lastActivityAt', - }, - { - name: 'Status', - value: 'status', - }, - { - name: 'username', - value: 'username', - }, - ], - default: 'username', - description: 'The ID of the channel to exclude users for.', - }, - ], - }, - // ---------------------------------- - // user:getByEmail - // ---------------------------------- - { - displayName: 'Email', - name: 'email', - type: 'string', - required: true, - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'getByEmail', - ], - }, - }, - default: '', - description: `User's email`, - }, - - // ---------------------------------- - // user:getById - // ---------------------------------- - { - displayName: 'User IDs', - name: 'userIds', - type: 'string', - required: true, - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'getById', - ], - }, - }, - default: '', - description: `User's ID`, - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'getById', - ], - }, - }, - default: {}, - options: [ - { - displayName: 'Since', - name: 'since', - type: 'dateTime', - default: '', - description: 'Only return users that have been modified since the given Unix timestamp (in milliseconds).', - }, - ], - }, - ], - }; - - methods = { - loadOptions: { - // Get all the available channels - async getChannels(this: ILoadOptionsFunctions): Promise { - const endpoint = 'channels'; - const responseData = await apiRequest.call(this, 'GET', endpoint, {}); - - if (responseData === undefined) { - throw new NodeOperationError(this.getNode(), 'No data got returned'); - } - - const returnData: INodePropertyOptions[] = []; - let name: string; - for (const data of responseData) { - if (data.delete_at !== 0 || (!data.display_name || !data.name)) { - continue; - } - - name = `${data.team_display_name} - ${data.display_name || data.name} (${data.type === 'O' ? 'public' : 'private'})`; - - returnData.push({ - name, - value: data.id, - }); - } - - returnData.sort((a, b) => { - if (a.name < b.name) { return -1; } - if (a.name > b.name) { return 1; } - return 0; - }); - - return returnData; - }, - - // Get all the channels in a team - async getChannelsInTeam(this: ILoadOptionsFunctions): Promise { - const teamId = this.getCurrentNodeParameter('teamId'); - const endpoint = `users/me/teams/${teamId}/channels`; - const responseData = await apiRequest.call(this, 'GET', endpoint, {}); - - if (responseData === undefined) { - throw new NodeOperationError(this.getNode(), 'No data got returned'); - } - - const returnData: INodePropertyOptions[] = []; - let name: string; - for (const data of responseData) { - if (data.delete_at !== 0 || (!data.display_name || !data.name)) { - continue; - } - - const channelTypes: IDataObject = { - 'D': 'direct', - 'G': 'group', - 'O': 'public', - 'P': 'private', - }; - - name = `${data.display_name} (${channelTypes[data.type as string]})`; - - returnData.push({ - name, - value: data.id, - }); - } - - returnData.sort((a, b) => { - if (a.name < b.name) { return -1; } - if (a.name > b.name) { return 1; } - return 0; - }); - - return returnData; - }, - - async getTeams(this: ILoadOptionsFunctions): Promise { - const endpoint = 'users/me/teams'; - const responseData = await apiRequest.call(this, 'GET', endpoint, {}); - - if (responseData === undefined) { - throw new NodeOperationError(this.getNode(), 'No data got returned'); - } - - const returnData: INodePropertyOptions[] = []; - let name: string; - for (const data of responseData) { - - if (data.delete_at !== 0) { - continue; - } - - name = `${data.display_name} (${data.type === 'O' ? 'public' : 'private'})`; - - returnData.push({ - name, - value: data.id, - }); - } - - returnData.sort((a, b) => { - if (a.name < b.name) { return -1; } - if (a.name > b.name) { return 1; } - return 0; - }); - - return returnData; - }, - async getUsers(this: ILoadOptionsFunctions): Promise { - const endpoint = 'users'; - const responseData = await apiRequest.call(this, 'GET', endpoint, {}); - - if (responseData === undefined) { - throw new NodeOperationError(this.getNode(), 'No data got returned'); - } - - const returnData: INodePropertyOptions[] = []; - for (const data of responseData) { - - if (data.delete_at !== 0) { - continue; - } - - returnData.push({ - name: data.username, - value: data.id, - }); - } - - returnData.sort((a, b) => { - if (a.name < b.name) { return -1; } - if (a.name > b.name) { return 1; } - return 0; - }); - - return returnData; - }, - }, - }; - - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - const returnData: IDataObject[] = []; - - const credentials = await this.getCredentials('mattermostApi'); - - if (credentials === undefined) { - throw new NodeOperationError(this.getNode(), 'No credentials got returned!'); - } - - let operation: string; - let resource: string; - let requestMethod = 'POST'; - let returnAll = false; - let userIds: string[] = []; - - resource = this.getNodeParameter('resource', 0) as string; - operation = this.getNodeParameter('operation', 0) as string; - - // For Post - let body: IDataObject; - // For Query string - let qs: IDataObject; - - for (let i = 0; i < items.length; i++) { - try { - let endpoint = ''; - body = {}; - qs = {}; - - if (resource === 'channel') { - if (operation === 'create') { - // ---------------------------------- - // channel:create - // ---------------------------------- - - requestMethod = 'POST'; - endpoint = 'channels'; - - body.team_id = this.getNodeParameter('teamId', i) as string; - body.display_name = this.getNodeParameter('displayName', i) as string; - body.name = this.getNodeParameter('channel', i) as string; - - const type = this.getNodeParameter('type', i) as string; - body.type = type === 'public' ? 'O' : 'P'; - - } else if (operation === 'delete') { - // ---------------------------------- - // channel:delete - // ---------------------------------- - - requestMethod = 'DELETE'; - const channelId = this.getNodeParameter('channelId', i) as string; - endpoint = `channels/${channelId}`; - - } else if (operation === 'members') { - // ---------------------------------- - // channel:members - // ---------------------------------- - - requestMethod = 'GET'; - const channelId = this.getNodeParameter('channelId', i) as string; - const returnAll = this.getNodeParameter('returnAll', i) as boolean; - endpoint = `channels/${channelId}/members`; - if (returnAll === false) { - qs.per_page = this.getNodeParameter('limit', i) as number; - } - - } else if (operation === 'restore') { - // ---------------------------------- - // channel:restore - // ---------------------------------- - - requestMethod = 'POST'; - const channelId = this.getNodeParameter('channelId', i) as string; - endpoint = `channels/${channelId}/restore`; - - } else if (operation === 'addUser') { - // ---------------------------------- - // channel:addUser - // ---------------------------------- - - requestMethod = 'POST'; - - const channelId = this.getNodeParameter('channelId', i) as string; - body.user_id = this.getNodeParameter('userId', i) as string; - - endpoint = `channels/${channelId}/members`; - - } else if (operation === 'statistics') { - // ---------------------------------- - // channel:statistics - // ---------------------------------- - - requestMethod = 'GET'; - const channelId = this.getNodeParameter('channelId', i) as string; - endpoint = `channels/${channelId}/stats`; - } - } else if (resource === 'message') { - if (operation === 'delete') { - // ---------------------------------- - // message:delete - // ---------------------------------- - - const postId = this.getNodeParameter('postId', i) as string; - requestMethod = 'DELETE'; - endpoint = `posts/${postId}`; - } else if (operation === 'post') { - // ---------------------------------- - // message:post - // ---------------------------------- - - requestMethod = 'POST'; - endpoint = 'posts'; - - body.channel_id = this.getNodeParameter('channelId', i) as string; - body.message = this.getNodeParameter('message', i) as string; - - const attachments = this.getNodeParameter('attachments', i, []) as unknown as IAttachment[]; - - // The node does save the fields data differently than the API - // expects so fix the data befre we send the request - for (const attachment of attachments) { - if (attachment.fields !== undefined) { - if (attachment.fields.item !== undefined) { - // Move the field-content up - // @ts-ignore - attachment.fields = attachment.fields.item; - } else { - // If it does not have any items set remove it - // @ts-ignore - delete attachment.fields; - } - } - } - for (const attachment of attachments) { - if (attachment.actions !== undefined) { - if (attachment.actions.item !== undefined) { - // Move the field-content up - // @ts-ignore - attachment.actions = attachment.actions.item; - } else { - // If it does not have any items set remove it - // @ts-ignore - delete attachment.actions; - } - } - } - - for (const attachment of attachments) { - if (Array.isArray(attachment.actions)) { - for (const attaction of attachment.actions) { - - if (attaction.type === 'button') { - delete attaction.type; - } - if (attaction.data_source === 'custom') { - delete attaction.data_source; - } - if (attaction.options) { - attaction.options = attaction.options.option; - } - - if (attaction.integration.item !== undefined) { - attaction.integration = attaction.integration.item; - if (Array.isArray(attaction.integration.context.property)) { - const tmpcontex = {}; - for (const attactionintegprop of attaction.integration.context.property) { - Object.assign(tmpcontex, { [attactionintegprop.name]: attactionintegprop.value }); - } - delete attaction.integration.context; - attaction.integration.context = tmpcontex; - } - } - } - } - } - - body.props = { - attachments, - }; - - // Add all the other options to the request - const otherOptions = this.getNodeParameter('otherOptions', i) as IDataObject; - Object.assign(body, otherOptions); - - } else if (operation === 'postEphemeral') { - - // ---------------------------------- - // message:post (ephemeral) - // ---------------------------------- - - // https://api.mattermost.com/#tag/posts/paths/~1posts~1ephemeral/post - - body = { - user_id: this.getNodeParameter('userId', i), - post: { - channel_id: this.getNodeParameter('channelId', i), - message: this.getNodeParameter('message', i), - }, - } as IDataObject; - - requestMethod = 'POST'; - endpoint = 'posts/ephemeral'; - - } - - } else if (resource === 'reaction') { - - // ---------------------------------- - // reaction:create - // ---------------------------------- - - // https://api.mattermost.com/#tag/reactions/paths/~1reactions/post - - if (operation === 'create') { - - body = { - user_id: this.getNodeParameter('userId', i), - post_id: this.getNodeParameter('postId', i), - emoji_name: (this.getNodeParameter('emojiName', i) as string).replace(/:/g, ''), - create_at: Date.now(), - } as { user_id: string; post_id: string; emoji_name: string; create_at: number }; - - requestMethod = 'POST'; - endpoint = 'reactions'; - - } else if (operation === 'delete') { - - // ---------------------------------- - // reaction:delete - // ---------------------------------- - - // https://api.mattermost.com/#tag/reactions/paths/~1users~1{user_id}~1posts~1{post_id}~1reactions~1{emoji_name}/delete - - const userId = this.getNodeParameter('userId', i) as string; - const postId = this.getNodeParameter('postId', i) as string; - const emojiName = (this.getNodeParameter('emojiName', i) as string).replace(/:/g, ''); - - requestMethod = 'DELETE'; - endpoint = `users/${userId}/posts/${postId}/reactions/${emojiName}`; - - } else if (operation === 'getAll') { - - // ---------------------------------- - // reaction:getAll - // ---------------------------------- - - // https://api.mattermost.com/#tag/reactions/paths/~1posts~1ids~1reactions/post - - const postId = this.getNodeParameter('postId', i) as string; - - requestMethod = 'GET'; - endpoint = `posts/${postId}/reactions`; - - qs.limit = this.getNodeParameter('limit', 0, 0) as number; - } - - } else if (resource === 'user') { - - if (operation === 'create') { - // ---------------------------------- - // user:create - // ---------------------------------- - - const username = this.getNodeParameter('username', i) as string; - - const authService = this.getNodeParameter('authService', i) as string; - - body.auth_service = authService; - - if (authService === 'email') { - body.email = this.getNodeParameter('email', i) as string; - body.password = this.getNodeParameter('password', i) as string; - } else { - body.auth_data = this.getNodeParameter('authData', i) as string; - } - - const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; - - body.username = username; - - Object.assign(body, additionalFields); - - if (body.notificationUi) { - body.notify_props = (body.notificationUi as IDataObject).notificationValues; - } - - requestMethod = 'POST'; - - endpoint = 'users'; - } - - // TODO: Remove the "deactive" again in the future. In here temporary - // to not break workflows for people which set the option before - // typo got fixed. JO 2020-01-17 - if (operation === 'deactive' || operation === 'desactive') { - // ---------------------------------- - // user:deactive - // ---------------------------------- - const userId = this.getNodeParameter('userId', i) as string; - requestMethod = 'DELETE'; - endpoint = `users/${userId}`; - } - - if (operation === 'getAll') { - // ---------------------------------- - // user:getAll - // ---------------------------------- - - requestMethod = 'GET'; - - returnAll = this.getNodeParameter('returnAll', i) as boolean; - const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; - - if (additionalFields.inTeam) { - qs.in_team = additionalFields.inTeam; - } - - if (additionalFields.notInTeam) { - qs.not_in_team = additionalFields.notInTeam; - } - - if (additionalFields.inChannel) { - qs.in_channel = additionalFields.inChannel; - } - - if (additionalFields.notInChannel) { - qs.not_in_channel = additionalFields.notInChannel; - } - - if (additionalFields.sort) { - qs.sort = snakeCase(additionalFields.sort as string); - } - - const validRules = { - inTeam: ['last_activity_at', 'created_at', 'username'], - inChannel: ['status', 'username'], - }; - - if (additionalFields.sort) { - if (additionalFields.inTeam !== undefined || additionalFields.inChannel !== undefined) { - - if (additionalFields.inTeam !== undefined - && !validRules.inTeam.includes(snakeCase(additionalFields.sort as string))) { - throw new NodeOperationError(this.getNode(), `When In Team is set the only valid values for sorting are ${validRules.inTeam.join(',')}`); - } - if (additionalFields.inChannel !== undefined - && !validRules.inChannel.includes(snakeCase(additionalFields.sort as string))) { - throw new NodeOperationError(this.getNode(), `When In Channel is set the only valid values for sorting are ${validRules.inChannel.join(',')}`); - } - if (additionalFields.inChannel !== undefined - && additionalFields.inChannel === '' - && additionalFields.sort !== 'username') { - throw new NodeOperationError(this.getNode(), 'When sort is different than username In Channel must be set'); - } - - if (additionalFields.inTeam !== undefined - && additionalFields.inTeam === '' - && additionalFields.sort !== 'username') { - throw new NodeOperationError(this.getNode(), 'When sort is different than username In Team must be set'); - } - - } else { - throw new NodeOperationError(this.getNode(), `When sort is defined either 'in team' or 'in channel' must be defined`); - } - } - - if (additionalFields.sort === 'username') { - qs.sort = ''; - } - - if (returnAll === false) { - qs.per_page = this.getNodeParameter('limit', i) as number; - } - - endpoint = `/users`; - } - - if (operation === 'getByEmail') { - // ---------------------------------- - // user:getByEmail - // ---------------------------------- - const email = this.getNodeParameter('email', i) as string; - requestMethod = 'GET'; - endpoint = `users/email/${email}`; - } - - if (operation === 'getById') { - // ---------------------------------- - // user:getById - // ---------------------------------- - userIds = (this.getNodeParameter('userIds', i) as string).split(',') as string[]; - const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; - - if (additionalFields.since) { - qs.since = new Date(additionalFields.since as string).getTime(); - } - - requestMethod = 'POST'; - - endpoint = 'users/ids'; - - //@ts-ignore - body = userIds; - - } - - if (operation === 'invite') { - // ---------------------------------- - // user:invite - // ---------------------------------- - const teamId = this.getNodeParameter('teamId', i) as string; - - const emails = (this.getNodeParameter('emails', i) as string).split(','); - - //@ts-ignore - body = emails; - - requestMethod = 'POST'; - - endpoint = `teams/${teamId}/invite/email`; - } - } - else { - throw new NodeOperationError(this.getNode(), `The resource "${resource}" is not known!`); - } - - let responseData; - if (returnAll) { - responseData = await apiRequestAllItems.call(this, requestMethod, endpoint, body, qs); - } else { - responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); - if (qs.limit) { - responseData = responseData.slice(0, qs.limit); - } - if (resource === 'channel' && operation === 'members') { - const resolveData = this.getNodeParameter('resolveData', i) as boolean; - if (resolveData) { - const userIds: string[] = []; - for (const data of responseData) { - userIds.push(data.user_id); - } - if (userIds.length > 0) { - responseData = await apiRequest.call(this, 'POST', 'users/ids', userIds, qs); - } - } - } - } - if (Array.isArray(responseData)) { - returnData.push.apply(returnData, responseData); - } else { - returnData.push(responseData); - } - } catch (error) { - if (this.continueOnFail()) { - returnData.push({ error: error.message }); - continue; - } - throw error; - } - } - - return [this.helpers.returnJsonArray(returnData)]; +import { MattermostV1 } from './v1/MattermostV1.node'; +import { NodeVersionedType } from '../../src/NodeVersionedType'; + +export class Mattermost extends NodeVersionedType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Mattermost', + name: 'mattermost', + icon: 'file:mattermost.svg', + group: ['output'], + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Sends data to Mattermost', + defaultVersion: 1, + }; + + const nodeVersions: INodeVersionedType['nodeVersions'] = { + 1: new MattermostV1(baseDescription), + }; + + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/Mattermost/v1/MattermostV1.node.ts b/packages/nodes-base/nodes/Mattermost/v1/MattermostV1.node.ts new file mode 100644 index 0000000000..bb22d341a7 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/MattermostV1.node.ts @@ -0,0 +1,34 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { versionDescription } from './actions/versionDescription'; +import { loadOptions } from './methods'; +import { router } from './actions/router'; + +export class MattermostV1 implements INodeType { + + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { loadOptions }; + + async execute(this: IExecuteFunctions) { + // Router returns INodeExecutionData[] + // We need to output INodeExecutionData[][] + // So we wrap in [] + return [await router.call(this)]; + } +} diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/Interfaces.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/Interfaces.ts new file mode 100644 index 0000000000..b9d6dd7efa --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/Interfaces.ts @@ -0,0 +1,33 @@ +import { + AllEntities, + Entity, + PropertiesOf, +} from 'n8n-workflow'; + +type MattermostMap = { + channel: 'addUser' | 'create' | 'delete' | 'members' | 'restore' | 'statistics'; + message: 'delete' | 'post' | 'postEphemeral'; + reaction: 'create' | 'delete' | 'getAll'; + user: 'create' | 'deactive' | 'getAll' | 'getByEmail' | 'getById' | 'invite'; +}; + +export type Mattermost = AllEntities; + +export type MattermostChannel = Entity; +export type MattermostMessage = Entity; +export type MattermostReaction = Entity; +export type MattermostUser = Entity; + +export type ChannelProperties = PropertiesOf; +export type MessageProperties = PropertiesOf; +export type ReactionProperties = PropertiesOf; +export type UserProperties = PropertiesOf; + +export interface IAttachment { + fields: { + item?: object[]; + }; + actions: { + item?: object[]; + }; +} diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/addUser/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/addUser/description.ts new file mode 100644 index 0000000000..f1309232a4 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/addUser/description.ts @@ -0,0 +1,50 @@ +import { + ChannelProperties, +} from '../../Interfaces'; + +export const channelAddUserDescription: ChannelProperties = [ + { + displayName: 'Channel ID', + name: 'channelId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getChannels', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'addUser', + ], + resource: [ + 'channel', + ], + }, + }, + description: 'The ID of the channel to invite user to.', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'addUser', + ], + resource: [ + 'channel', + ], + }, + }, + description: 'The ID of the user to invite into channel.', + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/addUser/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/addUser/execute.ts new file mode 100644 index 0000000000..be11d494d8 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/addUser/execute.ts @@ -0,0 +1,27 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function addUser(this: IExecuteFunctions, index: number): Promise { + const channelId = this.getNodeParameter('channelId', index) as string; + + const body = {} as IDataObject; + const qs = {} as IDataObject; + const requestMethod = 'POST'; + const endpoint = `channels/${channelId}/members`; + + body.user_id = this.getNodeParameter('userId', index) as string; + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/addUser/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/addUser/index.ts new file mode 100644 index 0000000000..b04def732e --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/addUser/index.ts @@ -0,0 +1,7 @@ +import { addUser as execute } from './execute'; +import { channelAddUserDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/create/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/create/description.ts new file mode 100644 index 0000000000..4eff6cf050 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/create/description.ts @@ -0,0 +1,93 @@ +import { + ChannelProperties, +} from '../../Interfaces'; + +export const channelCreateDescription: ChannelProperties = [ + { + displayName: 'Team ID', + name: 'teamId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTeams', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'channel', + ], + }, + }, + description: 'The Mattermost Team.', + }, + { + displayName: 'Display Name', + name: 'displayName', + type: 'string', + default: '', + placeholder: 'Announcements', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'channel', + ], + }, + }, + required: true, + description: 'The non-unique UI name for the channel', + }, + { + displayName: 'Name', + name: 'channel', + type: 'string', + default: '', + placeholder: 'announcements', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'channel', + ], + }, + }, + required: true, + description: 'The unique handle for the channel, will be present in the channel URL', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'channel', + ], + }, + }, + options: [ + { + name: 'Private', + value: 'private', + }, + { + name: 'Public', + value: 'public', + }, + ], + default: 'public', + description: 'The type of channel to create.', + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/create/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/create/execute.ts new file mode 100644 index 0000000000..410b96f87d --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/create/execute.ts @@ -0,0 +1,30 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function create(this: IExecuteFunctions, index: number): Promise { + const body = {} as IDataObject; + const qs = {} as IDataObject; + const requestMethod = 'POST'; + const endpoint = 'channels'; + + const type = this.getNodeParameter('type', index) as string; + + body.team_id = this.getNodeParameter('teamId', index) as string; + body.display_name = this.getNodeParameter('displayName', index) as string; + body.name = this.getNodeParameter('channel', index) as string; + body.type = type === 'public' ? 'O' : 'P'; + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/create/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/create/index.ts new file mode 100644 index 0000000000..01b053ba95 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/create/index.ts @@ -0,0 +1,7 @@ +import { create as execute } from './execute'; +import { channelCreateDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/del/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/del/description.ts new file mode 100644 index 0000000000..6556c21522 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/del/description.ts @@ -0,0 +1,28 @@ +import { + ChannelProperties, +} from '../../Interfaces'; + +export const channelDeleteDescription: ChannelProperties = [ + { + displayName: 'Channel ID', + name: 'channelId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getChannels', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'channel', + ], + }, + }, + description: 'The ID of the channel to soft delete', + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/del/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/del/execute.ts new file mode 100644 index 0000000000..710f9a5cca --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/del/execute.ts @@ -0,0 +1,25 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function del(this: IExecuteFunctions, index: number): Promise { + const channelId = this.getNodeParameter('channelId', index) as string; + + const body = {} as IDataObject; + const qs = {} as IDataObject; + const requestMethod = 'DELETE'; + const endpoint = `channels/${channelId}`; + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/del/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/del/index.ts new file mode 100644 index 0000000000..ceaefda672 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/del/index.ts @@ -0,0 +1,7 @@ +import { del as execute } from './execute'; +import { channelDeleteDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/index.ts new file mode 100644 index 0000000000..fc47a38e72 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/index.ts @@ -0,0 +1,72 @@ +import * as create from './create'; +import * as del from './del'; +import * as members from './members'; +import * as restore from './restore'; +import * as addUser from './addUser'; +import * as statistics from './statistics'; +import { INodeProperties } from 'n8n-workflow'; + +export { + create, + del as delete, + members, + restore, + addUser, + statistics, +}; + + +export const descriptions = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'channel', + ], + }, + }, + options: [ + { + name: 'Add User', + value: 'addUser', + description: 'Add a user to a channel', + }, + { + name: 'Create', + value: 'create', + description: 'Create a new channel', + }, + { + name: 'Delete', + value: 'delete', + description: 'Soft delete a channel', + }, + { + name: 'Member', + value: 'members', + description: 'Get a page of members for a channel', + }, + { + name: 'Restore', + value: 'restore', + description: 'Restores a soft deleted channel', + }, + { + name: 'Statistics', + value: 'statistics', + description: 'Get statistics for a channel', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, + ...create.description, + ...del.description, + ...members.description, + ...restore.description, + ...addUser.description, + ...statistics.description, +] as INodeProperties[]; \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/members/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/members/description.ts new file mode 100644 index 0000000000..1876e916c0 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/members/description.ts @@ -0,0 +1,112 @@ +import { + ChannelProperties, +} from '../../Interfaces'; + +export const channelMembersDescription: ChannelProperties = [ + { + displayName: 'Team ID', + name: 'teamId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTeams', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'members', + ], + resource: [ + 'channel', + ], + }, + }, + description: 'The Mattermost Team.', + }, + { + displayName: 'Channel ID', + name: 'channelId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getChannelsInTeam', + loadOptionsDependsOn: [ + 'teamId', + ], + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'members', + ], + resource: [ + 'channel', + ], + }, + }, + description: 'The Mattermost Team.', + }, + { + displayName: 'Resolve Data', + name: 'resolveData', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'channel', + ], + operation: [ + 'members', + ], + }, + }, + default: true, + description: 'By default the response only contain the ID of the user.
If this option gets activated it will resolve the user automatically.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'members', + ], + resource: [ + 'channel', + ], + }, + }, + default: true, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'members', + ], + resource: [ + 'channel', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'How many results to return.', + }, +]; + diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/members/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/members/execute.ts new file mode 100644 index 0000000000..e67059ed06 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/members/execute.ts @@ -0,0 +1,51 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, + apiRequestAllItems, +} from '../../../transport'; + +export async function members(this: IExecuteFunctions, index: number): Promise { + const channelId = this.getNodeParameter('channelId', index) as string; + const returnAll = this.getNodeParameter('returnAll', index) as boolean; + const resolveData = this.getNodeParameter('resolveData', index) as boolean; + const limit = this.getNodeParameter('limit', index, 0) as number; + + const body = {} as IDataObject; + const qs = {} as IDataObject; + const requestMethod = 'GET'; + const endpoint = `channels/${channelId}/members`; + + if (returnAll === false) { + qs.per_page = this.getNodeParameter('limit', index) as number; + } + + let responseData; + + if (returnAll) { + responseData = await apiRequestAllItems.call(this, requestMethod, endpoint, body, qs); + } else { + responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + if (limit) { + responseData = responseData.slice(0, limit); + } + if (resolveData) { + const userIds: string[] = []; + for (const data of responseData) { + userIds.push(data.user_id); + } + if (userIds.length > 0) { + responseData = await apiRequest.call(this, 'POST', 'users/ids', userIds, qs); + } + } + } + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/members/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/members/index.ts new file mode 100644 index 0000000000..b188725d9f --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/members/index.ts @@ -0,0 +1,7 @@ +import { members as execute } from './execute'; +import { channelMembersDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/restore/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/restore/description.ts new file mode 100644 index 0000000000..488d31e805 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/restore/description.ts @@ -0,0 +1,24 @@ +import { + ChannelProperties, +} from '../../Interfaces'; + +export const channelRestoreDescription: ChannelProperties = [ + { + displayName: 'Channel ID', + name: 'channelId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'restore', + ], + resource: [ + 'channel', + ], + }, + }, + description: 'The ID of the channel to restore.', + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/restore/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/restore/execute.ts new file mode 100644 index 0000000000..f12f84b78c --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/restore/execute.ts @@ -0,0 +1,27 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, + apiRequestAllItems, +} from '../../../transport'; + +export async function restore(this: IExecuteFunctions, index: number): Promise { + const channelId = this.getNodeParameter('channelId', index) as string; + + const body = {} as IDataObject; + const qs = {} as IDataObject; + const requestMethod = 'POST'; + const endpoint = `channels/${channelId}/restore`; + + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/restore/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/restore/index.ts new file mode 100644 index 0000000000..f765114c9f --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/restore/index.ts @@ -0,0 +1,7 @@ +import { restore as execute } from './execute'; +import { channelRestoreDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/statistics/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/statistics/description.ts new file mode 100644 index 0000000000..3e6e27d867 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/statistics/description.ts @@ -0,0 +1,29 @@ +import { + ChannelProperties, +} from '../../Interfaces'; + +export const channelStatisticsDescription: ChannelProperties = [ + { + displayName: 'Channel ID', + name: 'channelId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getChannels', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'statistics', + ], + resource: [ + 'channel', + ], + }, + }, + description: 'The ID of the channel to get the statistics from.', + }, +]; + diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/statistics/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/statistics/execute.ts new file mode 100644 index 0000000000..dd923e8381 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/statistics/execute.ts @@ -0,0 +1,25 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function statistics(this: IExecuteFunctions, index: number): Promise { + const channelId = this.getNodeParameter('channelId', index) as string; + + const body = {} as IDataObject; + const qs = {} as IDataObject; + const requestMethod = 'GET'; + const endpoint = `channels/${channelId}/stats`; + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/channel/statistics/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/statistics/index.ts new file mode 100644 index 0000000000..69461b7729 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/channel/statistics/index.ts @@ -0,0 +1,7 @@ +import { statistics as execute } from './execute'; +import { channelStatisticsDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/message/del/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/message/del/description.ts new file mode 100644 index 0000000000..2586aadb5e --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/message/del/description.ts @@ -0,0 +1,24 @@ +import { + MessageProperties, +} from '../../Interfaces'; + +export const messageDeleteDescription: MessageProperties = [ + { + displayName: 'Post ID', + name: 'postId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'delete', + ], + }, + }, + default: '', + description: 'ID of the post to delete', + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/message/del/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/message/del/execute.ts new file mode 100644 index 0000000000..28f074c225 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/message/del/execute.ts @@ -0,0 +1,25 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function del(this: IExecuteFunctions, index: number): Promise { + const postId = this.getNodeParameter('postId', index) as string; + + const body = {} as IDataObject; + const qs = {} as IDataObject; + const requestMethod = 'DELETE'; + const endpoint = `posts/${postId}`; + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/message/del/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/message/del/index.ts new file mode 100644 index 0000000000..ac800c1ce0 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/message/del/index.ts @@ -0,0 +1,7 @@ +import { del as execute } from './execute'; +import { messageDeleteDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/message/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/message/index.ts new file mode 100644 index 0000000000..f6e1e7657e --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/message/index.ts @@ -0,0 +1,48 @@ +import * as del from './del'; +import * as post from './post'; +import * as postEphemeral from './postEphemeral'; + +import { INodeProperties } from 'n8n-workflow'; + +export { + del as delete, + post, + postEphemeral, +}; + +export const descriptions = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'message', + ], + }, + }, + options: [ + { + name: 'Delete', + value: 'delete', + description: 'Soft delete a post, by marking the post as deleted in the database', + }, + { + name: 'Post', + value: 'post', + description: 'Post a message into a channel', + }, + { + name: 'Post Ephemeral', + value: 'postEphemeral', + description: 'Post an ephemeral message into a channel', + }, + ], + default: 'post', + description: 'The operation to perform', + }, + ...del.description, + ...post.description, + ...postEphemeral.description, +] as INodeProperties[]; \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/message/post/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/message/post/description.ts new file mode 100644 index 0000000000..ea00997f49 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/message/post/description.ts @@ -0,0 +1,440 @@ +import { + MessageProperties, +} from '../../Interfaces'; + +export const messagePostDescription: MessageProperties = [ + { + displayName: 'Channel ID', + name: 'channelId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getChannels', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'post', + ], + resource: [ + 'message', + ], + }, + }, + description: 'The ID of the channel to post to.', + }, + { + displayName: 'Message', + name: 'message', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + displayOptions: { + show: { + operation: [ + 'post', + ], + resource: [ + 'message', + ], + }, + }, + description: 'The text to send.', + }, + { + displayName: 'Attachments', + name: 'attachments', + type: 'collection', + typeOptions: { + multipleValues: true, + multipleValueButtonText: 'Add attachment', + }, + displayOptions: { + show: { + operation: [ + 'post', + ], + resource: [ + 'message', + ], + }, + }, + default: {}, + description: 'The attachment to add', + placeholder: 'Add attachment item', + options: [ + { + displayName: 'Actions', + name: 'actions', + placeholder: 'Add Actions', + description: 'Actions to add to message. More information can be found here', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Item', + name: 'item', + values: [ + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Button', + value: 'button', + }, + { + name: 'Select', + value: 'select', + }, + ], + default: 'button', + description: 'The type of the action.', + }, + { + displayName: 'Data Source', + name: 'data_source', + type: 'options', + displayOptions: { + show: { + type: [ + 'select', + ], + }, + }, + options: [ + { + name: 'Channels', + value: 'channels', + }, + { + name: 'Custom', + value: 'custom', + }, + { + name: 'Users', + value: 'users', + }, + + ], + default: 'custom', + description: 'The type of the action.', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + description: 'Adds a new option to select field.', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + data_source: [ + 'custom', + ], + type: [ + 'select', + ], + }, + }, + default: {}, + options: [ + { + name: 'option', + displayName: 'Option', + default: {}, + values: [ + { + displayName: 'Option Text', + name: 'text', + type: 'string', + default: '', + description: 'Text of the option.', + }, + { + displayName: 'Option Value', + name: 'value', + type: 'string', + default: '', + description: 'Value of the option.', + }, + ], + }, + ], + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of the Action.', + }, + { + displayName: 'Integration', + name: 'integration', + placeholder: 'Add Integration', + description: 'Integration to add to message.', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: {}, + options: [ + { + displayName: 'Item', + name: 'item', + default: {}, + values: [ + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + description: 'URL of the Integration.', + }, + { + displayName: 'Context', + name: 'context', + placeholder: 'Add Context to Integration', + description: 'Adds a Context values set.', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'property', + displayName: 'Property', + default: {}, + values: [ + { + displayName: 'Property Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of the property to set.', + }, + { + displayName: 'Property Value', + name: 'value', + type: 'string', + default: '', + description: 'Value of the property to set.', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + displayName: 'Author Icon', + name: 'author_icon', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Icon which should appear for the user.', + }, + { + displayName: 'Author Link', + name: 'author_link', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Link for the author.', + }, + { + displayName: 'Author Name', + name: 'author_name', + type: 'string', + default: '', + description: 'Name that should appear.', + }, + { + displayName: 'Color', + name: 'color', + type: 'color', + default: '#ff0000', + description: 'Color of the line left of text.', + }, + { + displayName: 'Fallback Text', + name: 'fallback', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Required plain-text summary of the attachment.', + }, + { + displayName: 'Fields', + name: 'fields', + placeholder: 'Add Fields', + description: 'Fields to add to message.', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'item', + displayName: 'Item', + values: [ + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'Title of the item.', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value of the item.', + }, + { + displayName: 'Short', + name: 'short', + type: 'boolean', + default: true, + description: 'If items can be displayed next to each other.', + }, + ], + }, + ], + }, + { + displayName: 'Footer', + name: 'footer', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Text of footer to add.', + }, + { + displayName: 'Footer Icon', + name: 'footer_icon', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Icon which should appear next to footer.', + }, + { + displayName: 'Image URL', + name: 'image_url', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'URL of image.', + }, + { + displayName: 'Pretext', + name: 'pretext', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Text which appears before the message block.', + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Text to send.', + }, + { + displayName: 'Thumbnail URL', + name: 'thumb_url', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'URL of thumbnail.', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Title of the message.', + }, + { + displayName: 'Title Link', + name: 'title_link', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Link of the title.', + }, + ], + }, + { + displayName: 'Other Options', + name: 'otherOptions', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'post', + ], + resource: [ + 'message', + ], + }, + }, + default: {}, + description: 'Other options to set', + placeholder: 'Add options', + options: [ + { + displayName: 'Make Comment', + name: 'root_id', + type: 'string', + default: '', + description: 'The post ID to comment on', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/message/post/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/message/post/execute.ts new file mode 100644 index 0000000000..16ca22f48a --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/message/post/execute.ts @@ -0,0 +1,98 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +import { + IAttachment, +} from '../../Interfaces'; + +export async function post(this: IExecuteFunctions, index: number): Promise { + const body = {} as IDataObject; + const qs = {} as IDataObject; + const requestMethod = 'POST'; + const endpoint = `posts`; + + body.channel_id = this.getNodeParameter('channelId', index) as string; + body.message = this.getNodeParameter('message', index) as string; + + const attachments = this.getNodeParameter('attachments', index, []) as unknown as IAttachment[]; + // The node does save the fields data differently than the API + // expects so fix the data befre we send the request + for (const attachment of attachments) { + if (attachment.fields !== undefined) { + if (attachment.fields.item !== undefined) { + // Move the field-content up + // @ts-ignore + attachment.fields = attachment.fields.item; + } else { + // If it does not have any items set remove it + // @ts-ignore + delete attachment.fields; + } + } + } + + for (const attachment of attachments) { + if (attachment.actions !== undefined) { + if (attachment.actions.item !== undefined) { + // Move the field-content up + // @ts-ignore + attachment.actions = attachment.actions.item; + } else { + // If it does not have any items set remove it + // @ts-ignore + delete attachment.actions; + } + } + } + + for (const attachment of attachments) { + if (Array.isArray(attachment.actions)) { + for (const attaction of attachment.actions) { + + if (attaction.type === 'button') { + delete attaction.type; + } + if (attaction.data_source === 'custom') { + delete attaction.data_source; + } + if (attaction.options) { + attaction.options = attaction.options.option; + } + + if (attaction.integration.item !== undefined) { + attaction.integration = attaction.integration.item; + if (Array.isArray(attaction.integration.context.property)) { + const tmpcontex = {}; + for (const attactionintegprop of attaction.integration.context.property) { + Object.assign(tmpcontex, { [attactionintegprop.name]: attactionintegprop.value }); + } + delete attaction.integration.context; + attaction.integration.context = tmpcontex; + } + } + } + } + } + + body.props = { + attachments, + }; + + // Add all the other options to the request + const otherOptions = this.getNodeParameter('otherOptions', index) as IDataObject; + Object.assign(body, otherOptions); + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/message/post/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/message/post/index.ts new file mode 100644 index 0000000000..245c557c65 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/message/post/index.ts @@ -0,0 +1,7 @@ +import { post as execute } from './execute'; +import { messagePostDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/message/postEphemeral/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/message/postEphemeral/description.ts new file mode 100644 index 0000000000..cd0e89da94 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/message/postEphemeral/description.ts @@ -0,0 +1,69 @@ +import { + MessageProperties, +} from '../../Interfaces'; + +export const messagePostEphemeralDescription: MessageProperties = [ + { + displayName: 'User ID', + name: 'userId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'postEphemeral', + ], + resource: [ + 'message', + ], + }, + }, + description: 'ID of the user to send the ephemeral message to.', + }, + { + displayName: 'Channel ID', + name: 'channelId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getChannels', + }, + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'postEphemeral', + ], + resource: [ + 'message', + ], + }, + }, + description: 'ID of the channel to send the ephemeral message in.', + }, + { + displayName: 'Message', + name: 'message', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + displayOptions: { + show: { + operation: [ + 'postEphemeral', + ], + resource: [ + 'message', + ], + }, + }, + description: 'Text to send in the ephemeral message.', + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/message/postEphemeral/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/message/postEphemeral/execute.ts new file mode 100644 index 0000000000..3f48f0df1c --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/message/postEphemeral/execute.ts @@ -0,0 +1,30 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function postEphemeral(this: IExecuteFunctions, index: number): Promise { + const qs = {} as IDataObject; + const requestMethod = 'POST'; + const endpoint = `posts/ephemeral`; + + const body = { + user_id: this.getNodeParameter('userId', index), + post: { + channel_id: this.getNodeParameter('channelId', index), + message: this.getNodeParameter('message', index), + }, + } as IDataObject; + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/message/postEphemeral/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/message/postEphemeral/index.ts new file mode 100644 index 0000000000..5683df6fe6 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/message/postEphemeral/index.ts @@ -0,0 +1,7 @@ +import { postEphemeral as execute } from './execute'; +import { messagePostEphemeralDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/create/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/create/description.ts new file mode 100644 index 0000000000..0ddee8971f --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/create/description.ts @@ -0,0 +1,65 @@ +import { + ReactionProperties, +} from '../../Interfaces'; + +export const reactionCreateDescription: ReactionProperties = [ + { + displayName: 'User ID', + name: 'userId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'reaction', + ], + operation: [ + 'create', + ], + }, + }, + description: 'ID of the user sending the reaction.', + }, + { + displayName: 'Post ID', + name: 'postId', + type: 'string', + default: '', + placeholder: '3moacfqxmbdw38r38fjprh6zsr', + required: true, + displayOptions: { + show: { + resource: [ + 'reaction', + ], + operation: [ + 'create', + ], + }, + }, + description: 'ID of the post to react to.
Obtainable from the post link:
https://mattermost.internal.n8n.io/[server]/pl/[postId]', + }, + { + displayName: 'Emoji Name', + name: 'emojiName', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'reaction', + ], + operation: [ + 'create', + ], + }, + }, + description: 'Emoji to use for this reaction.', + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/create/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/create/execute.ts new file mode 100644 index 0000000000..7c8d3d3172 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/create/execute.ts @@ -0,0 +1,29 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function create(this: IExecuteFunctions, index: number): Promise { + const qs = {} as IDataObject; + const requestMethod = 'POST'; + const endpoint = 'reactions'; + const body = { + user_id: this.getNodeParameter('userId', index), + post_id: this.getNodeParameter('postId', index), + emoji_name: (this.getNodeParameter('emojiName', index) as string).replace(/:/g, ''), + create_at: Date.now(), + } as { user_id: string; post_id: string; emoji_name: string; create_at: number }; + + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/create/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/create/index.ts new file mode 100644 index 0000000000..bffb8474b3 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/create/index.ts @@ -0,0 +1,7 @@ +import { create as execute } from './execute'; +import { reactionCreateDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/del/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/del/description.ts new file mode 100644 index 0000000000..fd8b36fc36 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/del/description.ts @@ -0,0 +1,65 @@ +import { + ReactionProperties, +} from '../../Interfaces'; + +export const reactionDeleteDescription: ReactionProperties = [ + { + displayName: 'User ID', + name: 'userId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'reaction', + ], + operation: [ + 'delete', + ], + }, + }, + description: 'ID of the user whose reaction to delete.', + }, + { + displayName: 'Post ID', + name: 'postId', + type: 'string', + default: '', + placeholder: '3moacfqxmbdw38r38fjprh6zsr', + required: true, + displayOptions: { + show: { + resource: [ + 'reaction', + ], + operation: [ + 'delete', + ], + }, + }, + description: 'ID of the post whose reaction to delete.
Obtainable from the post link:
https://mattermost.internal.n8n.io/[server]/pl/[postId]', + }, + { + displayName: 'Emoji Name', + name: 'emojiName', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'reaction', + ], + operation: [ + 'delete', + ], + }, + }, + description: 'Name of the emoji to delete.', + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/del/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/del/execute.ts new file mode 100644 index 0000000000..eabd514c4e --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/del/execute.ts @@ -0,0 +1,27 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function del(this: IExecuteFunctions, index: number): Promise { + const userId = this.getNodeParameter('userId', index) as string; + const postId = this.getNodeParameter('postId', index) as string; + const emojiName = (this.getNodeParameter('emojiName', index) as string).replace(/:/g, ''); + + const qs = {} as IDataObject; + const requestMethod = 'DELETE'; + const endpoint = `users/${userId}/posts/${postId}/reactions/${emojiName}`; + const body = {} as IDataObject; + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/del/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/del/index.ts new file mode 100644 index 0000000000..48b8310864 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/del/index.ts @@ -0,0 +1,7 @@ +import { del as execute } from './execute'; +import { reactionDeleteDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/description.ts new file mode 100644 index 0000000000..2e318927dd --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/description.ts @@ -0,0 +1,65 @@ +import { + ReactionProperties, +} from '../../Interfaces'; + +export const reactionGetAllDescription: ReactionProperties = [ + { + displayName: 'Post ID', + name: 'postId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'reaction', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'One or more (comma-separated) posts to retrieve reactions from.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'reaction', + ], + }, + }, + default: true, + 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: [ + 'reaction', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'How many results to return.', + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/execute.ts new file mode 100644 index 0000000000..433c30f710 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/execute.ts @@ -0,0 +1,28 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function getAll(this: IExecuteFunctions, index: number): Promise { + const postId = this.getNodeParameter('postId', index) as string; + const limit = this.getNodeParameter('limit', 0, 0) as number; + + const qs = {} as IDataObject; + const requestMethod = 'GET'; + const endpoint = `posts/${postId}/reactions`; + const body = {} as IDataObject; + + let responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + if (limit > 0) { + responseData = responseData.slice(0, limit); + } + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/index.ts new file mode 100644 index 0000000000..1836e1d530 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/index.ts @@ -0,0 +1,7 @@ +import { getAll as execute } from './execute'; +import { reactionGetAllDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/index.ts new file mode 100644 index 0000000000..34a7a2bd14 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/index.ts @@ -0,0 +1,49 @@ +import * as create from './create'; +import * as del from './del'; +import * as getAll from './getAll'; + +import { INodeProperties } from 'n8n-workflow'; + + +export { + create, + del as delete, + getAll, +}; + +export const descriptions = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'reaction', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Add a reaction to a post.', + }, + { + name: 'Delete', + value: 'delete', + description: 'Remove a reaction from a post', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all the reactions to one or more posts', + }, + ], + default: 'create', + description: 'The operation to perform', + }, + ...create.description, + ...del.description, + ...getAll.description, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/router.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/router.ts new file mode 100644 index 0000000000..e93d3b5b8c --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/router.ts @@ -0,0 +1,53 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + INodeExecutionData, +} from 'n8n-workflow'; + +import * as channel from './channel'; +import * as message from './message'; +import * as reaction from './reaction'; +import * as user from './user'; +import { Mattermost } from './Interfaces'; + +export async function router(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const operationResult: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + const resource = this.getNodeParameter('resource', i); + let operation = this.getNodeParameter('operation', i); + if (operation === 'del') { + operation = 'delete'; + } else if (operation === 'desactive') { + operation = 'deactive'; + } + + const mattermost = { + resource, + operation, + } as Mattermost; + + try { + if (mattermost.resource === 'channel') { + operationResult.push(...await channel[mattermost.operation].execute.call(this, i)); + } else if (mattermost.resource === 'message') { + operationResult.push(...await message[mattermost.operation].execute.call(this, i)); + } else if (mattermost.resource === 'reaction') { + operationResult.push(...await reaction[mattermost.operation].execute.call(this, i)); + } else if (mattermost.resource === 'user') { + operationResult.push(...await user[mattermost.operation].execute.call(this, i)); + } + } catch (err) { + if (this.continueOnFail()) { + operationResult.push({json: this.getInputData(i)[0].json, error: err}); + } else { + throw err; + } + } + } + + return operationResult; +} diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/create/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/create/description.ts new file mode 100644 index 0000000000..f9e71c0e9f --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/create/description.ts @@ -0,0 +1,270 @@ +import { + UserProperties, +} from '../../Interfaces'; + +export const userCreateDescription: UserProperties = [ + { + displayName: 'Username', + name: 'username', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'create', + ], + }, + }, + default: '', + }, + { + displayName: 'Auth Service', + name: 'authService', + type: 'options', + options: [ + { + name: 'Email', + value: 'email', + }, + { + name: 'Gitlab', + value: 'gitlab', + }, + { + name: 'Google', + value: 'google', + }, + { + name: 'LDAP', + value: 'ldap', + }, + { + name: 'Office365', + value: 'office365', + }, + { + name: 'SAML', + value: 'saml', + }, + ], + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'create', + ], + }, + }, + default: '', + }, + { + displayName: 'Auth Data', + name: 'authData', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'create', + ], + }, + hide: { + authService: [ + 'email', + ], + }, + }, + type: 'string', + default: '', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'create', + ], + authService: [ + 'email', + ], + }, + }, + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + typeOptions: { + password: true, + }, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'create', + ], + authService: [ + 'email', + ], + }, + }, + default: '', + description: 'The password used for email authentication.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'user', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'First Name', + name: 'first_name', + type: 'string', + default: '', + }, + { + displayName: 'Last Name', + name: 'last_name', + type: 'string', + default: '', + }, + { + displayName: 'Locale', + name: 'locale', + type: 'string', + default: '', + }, + { + displayName: 'Nickname', + name: 'nickname', + type: 'string', + default: '', + }, + { + displayName: 'Notification Settings', + name: 'notificationUi', + type: 'fixedCollection', + placeholder: 'Add Notification Setting', + default: {}, + typeOptions: { + multipleValues: false, + }, + options: [ + { + displayName: 'Notify', + name: 'notificationValues', + values: [ + { + displayName: 'Channel', + name: 'channel', + type: 'boolean', + default: true, + description: `Set to "true" to enable channel-wide notifications (@channel, @all, etc.), "false" to disable. Defaults to "true".`, + }, + { + displayName: 'Desktop', + name: 'desktop', + type: 'options', + options: [ + { + name: 'All', + value: 'all', + description: 'Notifications for all activity', + }, + { + name: 'Mention', + value: 'mention', + description: 'Mentions and direct messages only', + }, + { + name: 'None', + value: 'none', + description: 'Mentions and direct messages only', + }, + ], + default: 'all', + }, + { + displayName: 'Desktop Sound', + name: 'desktop_sound', + type: 'boolean', + default: true, + description: `Set to "true" to enable sound on desktop notifications, "false" to disable. Defaults to "true".`, + }, + { + displayName: 'Email', + name: 'email', + type: 'boolean', + default: false, + description: `Set to "true" to enable email notifications, "false" to disable. Defaults to "true".`, + }, + { + displayName: 'First Name', + name: 'first_name', + type: 'boolean', + default: false, + description: `Set to "true" to enable mentions for first name. Defaults to "true" if a first name is set, "false" otherwise.`, + }, + { + displayName: 'Mention Keys', + name: 'mention_keys', + type: 'string', + default: '', + description: `A comma-separated list of words to count as mentions. Defaults to username and @username.`, + }, + { + displayName: 'Push', + name: 'push', + type: 'options', + options: [ + { + name: 'All', + value: 'all', + description: 'Notifications for all activity', + }, + { + name: 'Mention', + value: 'mention', + description: 'Mentions and direct messages only', + }, + { + name: 'None', + value: 'none', + description: 'Mentions and direct messages only', + }, + ], + default: 'mention', + }, + ], + }, + ], + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/create/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/create/execute.ts new file mode 100644 index 0000000000..6f39d74c37 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/create/execute.ts @@ -0,0 +1,43 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function create(this: IExecuteFunctions, index: number): Promise { + const username = this.getNodeParameter('username', index) as string; + const authService = this.getNodeParameter('authService', index) as string; + const additionalFields = this.getNodeParameter('additionalFields', index) as IDataObject; + + const qs = {} as IDataObject; + const requestMethod = 'POST'; + const endpoint = 'users'; + const body = {} as IDataObject; + + body.auth_service = authService; + + body.username = username; + Object.assign(body, additionalFields); + + if (body.notificationUi) { + body.notify_props = (body.notificationUi as IDataObject).notificationValues; + } + + if (authService === 'email') { + body.email = this.getNodeParameter('email', index) as string; + body.password = this.getNodeParameter('password', index) as string; + } else { + body.auth_data = this.getNodeParameter('authData', index) as string; + } + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/create/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/create/index.ts new file mode 100644 index 0000000000..d6a06fcad0 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/create/index.ts @@ -0,0 +1,7 @@ +import { create as execute } from './execute'; +import { userCreateDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/deactive/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/deactive/description.ts new file mode 100644 index 0000000000..20d47a9cf6 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/deactive/description.ts @@ -0,0 +1,24 @@ +import { + UserProperties, +} from '../../Interfaces'; + +export const userDeactiveDescription: UserProperties = [ + { + displayName: 'User ID', + name: 'userId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'deactive', + ], + }, + }, + default: '', + description: 'User GUID', + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/deactive/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/deactive/execute.ts new file mode 100644 index 0000000000..235234dcfa --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/deactive/execute.ts @@ -0,0 +1,24 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function deactive(this: IExecuteFunctions, index: number): Promise { + const userId = this.getNodeParameter('userId', index) as string; + const qs = {} as IDataObject; + const requestMethod = 'DELETE'; + const endpoint = `users/${userId}`; + const body = {} as IDataObject; + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/deactive/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/deactive/index.ts new file mode 100644 index 0000000000..b1718aa1f5 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/deactive/index.ts @@ -0,0 +1,7 @@ +import { deactive as execute } from './execute'; +import { userDeactiveDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/getAll/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getAll/description.ts new file mode 100644 index 0000000000..4b704dd93e --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getAll/description.ts @@ -0,0 +1,119 @@ +import { + UserProperties, +} from '../../Interfaces'; + +export const userGetAllDescription: UserProperties = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + }, + }, + default: true, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'In Channel', + name: 'inChannel', + type: 'string', + default: '', + description: 'The ID of the channel to get users for.', + }, + { + displayName: 'In Team', + name: 'inTeam', + type: 'string', + default: '', + description: 'The ID of the team to get users for.', + }, + { + displayName: 'Not In Team', + name: 'notInTeam', + type: 'string', + default: '', + description: 'The ID of the team to exclude users for.', + }, + { + displayName: 'Not In Channel', + name: 'notInChannel', + type: 'string', + default: '', + description: 'The ID of the channel to exclude users for.', + }, + { + displayName: 'Sort', + name: 'sort', + type: 'options', + options: [ + { + name: 'Created At', + value: 'createdAt', + }, + { + name: 'Last Activity At', + value: 'lastActivityAt', + }, + { + name: 'Status', + value: 'status', + }, + { + name: 'username', + value: 'username', + }, + ], + default: 'username', + description: 'The ID of the channel to exclude users for.', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/getAll/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getAll/execute.ts new file mode 100644 index 0000000000..686ae486e6 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getAll/execute.ts @@ -0,0 +1,98 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + NodeOperationError, +} from 'n8n-workflow'; + +import { + apiRequest, + apiRequestAllItems, +} from '../../../transport'; + +import { + snakeCase, +} from 'change-case'; + +export async function getAll(this: IExecuteFunctions, index: number): Promise { + const returnAll = this.getNodeParameter('returnAll', index) as boolean; + const additionalFields = this.getNodeParameter('additionalFields', index) as IDataObject; + + const qs = {} as IDataObject; + const requestMethod = 'GET'; + const endpoint = '/users'; + const body = {} as IDataObject; + + if (additionalFields.inTeam) { + qs.in_team = additionalFields.inTeam; + } + + if (additionalFields.notInTeam) { + qs.not_in_team = additionalFields.notInTeam; + } + + if (additionalFields.inChannel) { + qs.in_channel = additionalFields.inChannel; + } + + if (additionalFields.notInChannel) { + qs.not_in_channel = additionalFields.notInChannel; + } + + if (additionalFields.sort) { + qs.sort = snakeCase(additionalFields.sort as string); + } + + const validRules = { + inTeam: ['last_activity_at', 'created_at', 'username'], + inChannel: ['status', 'username'], + }; + + if (additionalFields.sort) { + if (additionalFields.inTeam !== undefined || additionalFields.inChannel !== undefined) { + + if (additionalFields.inTeam !== undefined + && !validRules.inTeam.includes(snakeCase(additionalFields.sort as string))) { + throw new NodeOperationError(this.getNode(), `When In Team is set the only valid values for sorting are ${validRules.inTeam.join(',')}`); + } + if (additionalFields.inChannel !== undefined + && !validRules.inChannel.includes(snakeCase(additionalFields.sort as string))) { + throw new NodeOperationError(this.getNode(), `When In Channel is set the only valid values for sorting are ${validRules.inChannel.join(',')}`); + } + if (additionalFields.inChannel === '' + && additionalFields.sort !== 'username') { + throw new NodeOperationError(this.getNode(), 'When sort is different than username In Channel must be set'); + } + + if (additionalFields.inTeam === '' + && additionalFields.sort !== 'username') { + throw new NodeOperationError(this.getNode(), 'When sort is different than username In Team must be set'); + } + + } else { + throw new NodeOperationError(this.getNode(), `When sort is defined either 'in team' or 'in channel' must be defined`); + } + } + + if (additionalFields.sort === 'username') { + qs.sort = ''; + } + + if (returnAll === false) { + qs.per_page = this.getNodeParameter('limit', index) as number; + } + + + let responseData; + + if (returnAll) { + responseData = await apiRequestAllItems.call(this, requestMethod, endpoint, body, qs); + } else { + responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + } + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/getAll/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getAll/index.ts new file mode 100644 index 0000000000..e1e1b5ddd6 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getAll/index.ts @@ -0,0 +1,7 @@ +import { getAll as execute } from './execute'; +import { userGetAllDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/getByEmail/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getByEmail/description.ts new file mode 100644 index 0000000000..f9131db01b --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getByEmail/description.ts @@ -0,0 +1,24 @@ +import { + UserProperties, +} from '../../Interfaces'; + +export const userGetByEmailDescription: UserProperties = [ + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getByEmail', + ], + }, + }, + default: '', + description: `User's email`, + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/getByEmail/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getByEmail/execute.ts new file mode 100644 index 0000000000..ec36949824 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getByEmail/execute.ts @@ -0,0 +1,25 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function getByEmail(this: IExecuteFunctions, index: number): Promise { + const email = this.getNodeParameter('email', index) as string; + + const qs = {} as IDataObject; + const requestMethod = 'GET'; + const endpoint = `users/email/${email}`; + const body = {} as IDataObject; + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/getByEmail/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getByEmail/index.ts new file mode 100644 index 0000000000..bf29668fa0 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getByEmail/index.ts @@ -0,0 +1,7 @@ +import { getByEmail as execute } from './execute'; +import { userGetByEmailDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/getById/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getById/description.ts new file mode 100644 index 0000000000..8f0f8e0368 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getById/description.ts @@ -0,0 +1,50 @@ +import { + UserProperties, +} from '../../Interfaces'; + +export const userGetByIdDescription: UserProperties = [ + { + displayName: 'User IDs', + name: 'userIds', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getById', + ], + }, + }, + default: '', + description: `User's ID`, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getById', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Since', + name: 'since', + type: 'dateTime', + default: '', + description: 'Only return users that have been modified since the given Unix timestamp (in milliseconds).', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/getById/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getById/execute.ts new file mode 100644 index 0000000000..7ad792c658 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getById/execute.ts @@ -0,0 +1,29 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function getById(this: IExecuteFunctions, index: number): Promise { + const qs = {} as IDataObject; + const requestMethod = 'POST'; + const endpoint = 'users/ids'; + const userIds = (this.getNodeParameter('userIds', index) as string).split(',') as string[]; + const additionalFields = this.getNodeParameter('additionalFields', index) as IDataObject; + const body = userIds; + + if (additionalFields.since) { + qs.since = new Date(additionalFields.since as string).getTime(); + } + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/getById/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getById/index.ts new file mode 100644 index 0000000000..93a3db8d72 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/getById/index.ts @@ -0,0 +1,7 @@ +import { getById as execute } from './execute'; +import { userGetByIdDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/index.ts new file mode 100644 index 0000000000..346fff3ba0 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/index.ts @@ -0,0 +1,74 @@ +import * as create from './create'; +import * as deactive from './deactive'; +import * as getAll from './getAll'; +import * as getByEmail from './getByEmail'; +import * as getById from './getById'; +import * as invite from './invite'; + +import { INodeProperties } from 'n8n-workflow'; + +export { + create, + deactive, + getAll, + getByEmail, + getById, + invite, +}; + + +export const descriptions = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'user', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new user', + }, + { + name: 'Deactive', + value: 'deactive', + description: 'Deactivates the user and revokes all its sessions by archiving its user object.', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all users', + }, + { + name: 'Get By Email', + value: 'getByEmail', + description: 'Get a user by email', + }, + { + name: 'Get By ID', + value: 'getById', + description: 'Get a user by id', + }, + { + name: 'Invite', + value: 'invite', + description: 'Invite user to team', + }, + ], + default: '', + description: 'The operation to perform.', + }, + ...create.description, + ...deactive.description, + ...getAll.description, + ...getByEmail.description, + ...getById.description, + ...invite.description, +] as INodeProperties[]; + diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/invite/description.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/invite/description.ts new file mode 100644 index 0000000000..b4c0d0c6ee --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/invite/description.ts @@ -0,0 +1,44 @@ +import { + UserProperties, +} from '../../Interfaces'; + +export const userInviteDescription: UserProperties = [ + { + displayName: 'Team ID', + name: 'teamId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTeams', + }, + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'invite', + ], + }, + }, + default: '', + }, + { + displayName: 'Emails', + name: 'emails', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'invite', + ], + }, + }, + default: '', + description: `User's email. Multiple can be set separated by comma.`, + }, +]; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/invite/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/invite/execute.ts new file mode 100644 index 0000000000..03312da773 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/invite/execute.ts @@ -0,0 +1,28 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../../../transport'; + +export async function invite(this: IExecuteFunctions, index: number): Promise { + + const teamId = this.getNodeParameter('teamId', index) as string; + + const emails = (this.getNodeParameter('emails', index) as string).split(','); + + const qs = {} as IDataObject; + const requestMethod = 'POST'; + const endpoint = `teams/${teamId}/invite/email`; + const body = emails; + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + return this.helpers.returnJsonArray(responseData); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/user/invite/index.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/user/invite/index.ts new file mode 100644 index 0000000000..5b26597c73 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/user/invite/index.ts @@ -0,0 +1,7 @@ +import { invite as execute } from './execute'; +import { userInviteDescription as description } from './description'; + +export { + description, + execute, +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/versionDescription.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/versionDescription.ts new file mode 100644 index 0000000000..76d8f4ff9a --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/versionDescription.ts @@ -0,0 +1,61 @@ +import { + INodeProperties, + INodeTypeDescription, +} from 'n8n-workflow'; +import * as channel from './channel'; +import * as message from './message'; +import * as reaction from './reaction'; +import * as user from './user'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Mattermost', + name: 'mattermost', + icon: 'file:mattermost.svg', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Sends data to Mattermost', + defaults: { + name: 'Mattermost', + color: '#000000', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'mattermostApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Channel', + value: 'channel', + }, + { + name: 'Message', + value: 'message', + }, + { + name: 'Reaction', + value: 'reaction', + }, + { + name: 'User', + value: 'user', + }, + ], + default: 'message', + description: 'The resource to operate on', + }, + ...channel.descriptions, + ...message.descriptions, + ...reaction.descriptions, + ...user.descriptions, + ], +}; diff --git a/packages/nodes-base/nodes/Mattermost/v1/mattermost.svg b/packages/nodes-base/nodes/Mattermost/v1/mattermost.svg new file mode 100644 index 0000000000..9dc28e2687 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/mattermost.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Mattermost/v1/methods/index.ts b/packages/nodes-base/nodes/Mattermost/v1/methods/index.ts new file mode 100644 index 0000000000..65ff6192a3 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/methods/index.ts @@ -0,0 +1 @@ +export * as loadOptions from './loadOptions'; diff --git a/packages/nodes-base/nodes/Mattermost/v1/methods/loadOptions.ts b/packages/nodes-base/nodes/Mattermost/v1/methods/loadOptions.ts new file mode 100644 index 0000000000..72d27f4fa1 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/methods/loadOptions.ts @@ -0,0 +1,148 @@ +import { + IDataObject, + ILoadOptionsFunctions, + INodePropertyOptions, + NodeOperationError, +} from 'n8n-workflow'; + +import { + apiRequest, +} from '../transport'; + +// Get all the available channels +export async function getChannels(this: ILoadOptionsFunctions): Promise { + const endpoint = 'channels'; + const responseData = await apiRequest.call(this, 'GET', endpoint, {}); + + if (responseData === undefined) { + throw new NodeOperationError(this.getNode(), 'No data got returned'); + } + + const returnData: INodePropertyOptions[] = []; + let name: string; + for (const data of responseData) { + if (data.delete_at !== 0 || (!data.display_name || !data.name)) { + continue; + } + + name = `${data.team_display_name} - ${data.display_name || data.name} (${data.type === 'O' ? 'public' : 'private'})`; + + returnData.push({ + name, + value: data.id, + }); + } + + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + + return returnData; +} + +// Get all the channels in a team +export async function getChannelsInTeam(this: ILoadOptionsFunctions): Promise { + const teamId = this.getCurrentNodeParameter('teamId'); + const endpoint = `users/me/teams/${teamId}/channels`; + const responseData = await apiRequest.call(this, 'GET', endpoint, {}); + + if (responseData === undefined) { + throw new NodeOperationError(this.getNode(), 'No data got returned'); + } + + const returnData: INodePropertyOptions[] = []; + let name: string; + for (const data of responseData) { + if (data.delete_at !== 0 || (!data.display_name || !data.name)) { + continue; + } + + const channelTypes: IDataObject = { + 'D': 'direct', + 'G': 'group', + 'O': 'public', + 'P': 'private', + }; + + name = `${data.display_name} (${channelTypes[data.type as string]})`; + + returnData.push({ + name, + value: data.id, + }); + } + + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + + return returnData; +} + +export async function getTeams(this: ILoadOptionsFunctions): Promise { + const endpoint = 'users/me/teams'; + const responseData = await apiRequest.call(this, 'GET', endpoint, {}); + + if (responseData === undefined) { + throw new NodeOperationError(this.getNode(), 'No data got returned'); + } + + const returnData: INodePropertyOptions[] = []; + let name: string; + for (const data of responseData) { + + if (data.delete_at !== 0) { + continue; + } + + name = `${data.display_name} (${data.type === 'O' ? 'public' : 'private'})`; + + returnData.push({ + name, + value: data.id, + }); + } + + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + + return returnData; +} + +export async function getUsers(this: ILoadOptionsFunctions): Promise { + const endpoint = 'users'; + const responseData = await apiRequest.call(this, 'GET', endpoint, {}); + + if (responseData === undefined) { + throw new NodeOperationError(this.getNode(), 'No data got returned'); + } + + const returnData: INodePropertyOptions[] = []; + for (const data of responseData) { + + if (data.delete_at !== 0) { + continue; + } + + returnData.push({ + name: data.username, + value: data.id, + }); + } + + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + + return returnData; +} + diff --git a/packages/nodes-base/nodes/Mattermost/v1/transport/index.ts b/packages/nodes-base/nodes/Mattermost/v1/transport/index.ts new file mode 100644 index 0000000000..3ff3cb2b1b --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/v1/transport/index.ts @@ -0,0 +1,72 @@ +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + GenericValue, + IDataObject, + IHttpRequestOptions, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +/** + * Make an API request to Mattermost + */ +export async function apiRequest( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD', + endpoint: string, + body: IDataObject | GenericValue | GenericValue[] = {}, + query: IDataObject = {}, +) { + const credentials = await this.getCredentials('mattermostApi'); + + if (!credentials) { + throw new NodeOperationError(this.getNode(), 'No credentials returned!'); + } + + const options: IHttpRequestOptions = { + method, + body, + qs: query, + url: `${credentials.baseUrl}/api/v4/${endpoint}`, + headers: { + authorization: `Bearer ${credentials.accessToken}`, + 'content-type': 'application/json; charset=utf-8', + }, + }; + + try { + return await this.helpers.httpRequest(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export async function apiRequestAllItems( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD', + endpoint: string, + body: IDataObject = {}, + query: IDataObject = {}, +) { + + const returnData: IDataObject[] = []; + + let responseData; + query.page = 0; + query.per_page = 100; + + do { + responseData = await apiRequest.call(this, method, endpoint, body, query); + query.page++; + returnData.push.apply(returnData, responseData); + } while ( + responseData.length !== 0 + ); + + return returnData; +} diff --git a/packages/nodes-base/src/NodeVersionedType.ts b/packages/nodes-base/src/NodeVersionedType.ts new file mode 100644 index 0000000000..bdb20ef41d --- /dev/null +++ b/packages/nodes-base/src/NodeVersionedType.ts @@ -0,0 +1,25 @@ +import { INodeType, INodeTypeBaseDescription, INodeVersionedType } from 'n8n-workflow'; + +export class NodeVersionedType implements INodeVersionedType { + currentVersion: number; + nodeVersions: INodeVersionedType['nodeVersions']; + description: INodeTypeBaseDescription; + + constructor(nodeVersions: INodeVersionedType['nodeVersions'], description: INodeTypeBaseDescription) { + this.nodeVersions = nodeVersions; + this.currentVersion = description.defaultVersion ?? this.getLatestVersion(); + this.description = description; + } + + getLatestVersion() { + return Math.max(...Object.keys(this.nodeVersions).map(Number)); + } + + getNodeType(version?: number): INodeType { + if (version) { + return this.nodeVersions[version]; + } else { + return this.nodeVersions[this.currentVersion]; + } + } +} diff --git a/packages/nodes-base/src/index.ts b/packages/nodes-base/src/index.ts index e69de29bb2..a2d22dd770 100644 --- a/packages/nodes-base/src/index.ts +++ b/packages/nodes-base/src/index.ts @@ -0,0 +1,3 @@ +import { NodeVersionedType } from './NodeVersionedType'; + +export { NodeVersionedType }; \ No newline at end of file diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 7d66ffa438..3c9d5e4fe7 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -4,6 +4,8 @@ // eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line max-classes-per-file import * as express from 'express'; +import * as FormData from 'form-data'; +import { URLSearchParams } from 'url'; import { Workflow } from './Workflow'; import { WorkflowHooks } from './WorkflowHooks'; import { WorkflowOperationError } from './WorkflowErrors'; @@ -191,6 +193,11 @@ export interface IDataObject { [key: string]: GenericValue | IDataObject | GenericValue[] | IDataObject[]; } +export interface INodeTypeNameVersion { + name: string; + version: number; +} + export interface IGetExecutePollFunctions { ( workflow: Workflow, @@ -274,6 +281,43 @@ export interface IExecuteContextData { [key: string]: IContextObject; } +export interface IHttpRequestOptions { + url: string; + headers?: IDataObject; + method?: 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT'; + body?: FormData | GenericValue | GenericValue[] | Buffer | URLSearchParams; + qs?: IDataObject; + arrayFormat?: 'indices' | 'brackets' | 'repeat' | 'comma'; + auth?: { + username: string; + password: string; + }; + disableFollowRedirect?: boolean; + encoding?: 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream'; + skipSslCertificateValidation?: boolean; + returnFullResponse?: boolean; + proxy?: { + host: string; + port: number; + auth?: { + username: string; + password: string; + }; + protocol?: string; + }; + timeout?: number; + json?: boolean; +} + +export type IN8nHttpResponse = IDataObject | Buffer | GenericValue | GenericValue[]; + +export interface IN8nHttpFullResponse { + body: IN8nHttpResponse; + headers: IDataObject; + statusCode: number; + statusMessage: string; +} + export interface IExecuteFunctions { continueOnFail(): boolean; evaluateExpression( @@ -292,6 +336,11 @@ export interface IExecuteFunctions { getInputData(inputIndex?: number, inputName?: string): INodeExecutionData[]; getMode(): WorkflowExecuteMode; getNode(): INode; + getNodeParameter( + parameterName: 'resource', + itemIndex?: number, + ): T['resource']; + // getNodeParameter(parameterName: 'operation', itemIndex?: number): string; getNodeParameter( parameterName: string, itemIndex: number, @@ -309,7 +358,10 @@ export interface IExecuteFunctions { putExecutionToWait(waitTill: Date): Promise; sendMessageToUI(message: any): void; // tslint:disable-line:no-any helpers: { - [key: string]: (...args: any[]) => any; + httpRequest( + requestOptions: IHttpRequestOptions, + ): Promise; + [key: string]: (...args: any[]) => any; // tslint:disable-line:no-any }; } @@ -334,7 +386,10 @@ export interface IExecuteSingleFunctions { getWorkflowDataProxy(): IWorkflowDataProxyData; getWorkflowStaticData(type: string): IDataObject; helpers: { - [key: string]: (...args: any[]) => any; + httpRequest( + requestOptions: IHttpRequestOptions, + ): Promise; + [key: string]: (...args: any[]) => any; // tslint:disable-line:no-any }; } @@ -369,7 +424,10 @@ export interface ILoadOptionsFunctions { getTimezone(): string; getRestApiUrl(): string; helpers: { - [key: string]: ((...args: any[]) => any) | undefined; + httpRequest( + requestOptions: IHttpRequestOptions, + ): Promise; + [key: string]: ((...args: any[]) => any) | undefined; // tslint:disable-line:no-any }; } @@ -389,7 +447,10 @@ export interface IHookFunctions { getWorkflow(): IWorkflowMetadata; getWorkflowStaticData(type: string): IDataObject; helpers: { - [key: string]: (...args: any[]) => any; + httpRequest( + requestOptions: IHttpRequestOptions, + ): Promise; + [key: string]: (...args: any[]) => any; // tslint:disable-line:no-any }; } @@ -408,7 +469,10 @@ export interface IPollFunctions { getWorkflow(): IWorkflowMetadata; getWorkflowStaticData(type: string): IDataObject; helpers: { - [key: string]: (...args: any[]) => any; + httpRequest( + requestOptions: IHttpRequestOptions, + ): Promise; + [key: string]: (...args: any[]) => any; // tslint:disable-line:no-any }; } @@ -427,7 +491,10 @@ export interface ITriggerFunctions { getWorkflow(): IWorkflowMetadata; getWorkflowStaticData(type: string): IDataObject; helpers: { - [key: string]: (...args: any[]) => any; + httpRequest( + requestOptions: IHttpRequestOptions, + ): Promise; + [key: string]: (...args: any[]) => any; // tslint:disable-line:no-any }; } @@ -455,7 +522,10 @@ export interface IWebhookFunctions { outputIndex?: number, ): Promise; helpers: { - [key: string]: (...args: any[]) => any; + httpRequest( + requestOptions: IHttpRequestOptions, + ): Promise; + [key: string]: (...args: any[]) => any; // tslint:disable-line:no-any }; } @@ -496,12 +566,10 @@ export interface IBinaryKeyData { } export interface INodeExecutionData { - [key: string]: IDataObject | IBinaryKeyData | undefined; - // TODO: Rename this one as json does not really fit as it is not json (which is a string) it is actually a JS object + [key: string]: IDataObject | IBinaryKeyData | NodeApiError | NodeOperationError | undefined; json: IDataObject; - // json: object; - // json?: object; binary?: IBinaryKeyData; + error?: NodeApiError | NodeOperationError; } export interface INodeExecuteFunctions { @@ -557,10 +625,10 @@ export interface INodePropertyTypeOptions { export interface IDisplayOptions { hide?: { - [key: string]: NodeParameterValue[]; + [key: string]: NodeParameterValue[] | undefined; }; show?: { - [key: string]: NodeParameterValue[]; + [key: string]: NodeParameterValue[] | undefined; }; } @@ -634,6 +702,14 @@ export interface INodeType { }; } +export interface INodeVersionedType { + nodeVersions: { + [key: number]: INodeType; + }; + currentVersion: number; + description: INodeTypeBaseDescription; + getNodeType: (version?: number) => INodeType; +} export interface NodeCredentialTestResult { status: 'OK' | 'Error'; message: string; @@ -684,15 +760,21 @@ export interface IWorfklowIssues { [key: string]: INodeIssues; } -export interface INodeTypeDescription { +export interface INodeTypeBaseDescription { displayName: string; name: string; icon?: string; group: string[]; - version: number; description: string; - defaults: INodeParameters; documentationUrl?: string; + subtitle?: string; + defaultVersion?: number; + codex?: CodexData; +} + +export interface INodeTypeDescription extends INodeTypeBaseDescription { + version: number; + defaults: INodeParameters; inputs: string[]; inputNames?: string[]; outputs: string[]; @@ -701,14 +783,12 @@ export interface INodeTypeDescription { credentials?: INodeCredentialDescription[]; maxNodes?: number; // How many nodes of that type can be created in a workflow polling?: boolean; - subtitle?: string; hooks?: { [key: string]: INodeHookDescription[] | undefined; activate?: INodeHookDescription[]; deactivate?: INodeHookDescription[]; }; webhooks?: IWebhookDescription[]; - codex?: CodexData; } export interface INodeHookDescription { @@ -777,13 +857,14 @@ export type WebhookResponseMode = 'onReceived' | 'lastNode'; export interface INodeTypes { nodeTypes: INodeTypeData; init(nodeTypes?: INodeTypeData): Promise; - getAll(): INodeType[]; - getByName(nodeType: string): INodeType | undefined; + getAll(): Array; + getByName(nodeType: string): INodeType | INodeVersionedType | undefined; + getByNameAndVersion(nodeType: string, version?: number): INodeType | undefined; } export interface INodeTypeData { [key: string]: { - type: INodeType; + type: INodeType | INodeVersionedType; sourcePath: string; }; } @@ -949,3 +1030,19 @@ export type CodexData = { export type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; export type JsonObject = { [key: string]: JsonValue }; + +export type AllEntities = M extends { [key: string]: string } ? Entity : never; + +export type Entity = K extends keyof M ? { resource: K; operation: M[K] } : never; + +export type PropertiesOf = Array< + Omit & { + displayOptions?: { + [key in 'show' | 'hide']?: { + resource?: Array; + operation?: Array; + [otherKey: string]: NodeParameterValue[] | undefined; + }; + }; + } +>; diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 5e226c5a48..db3c8a3248 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-use-before-define */ @@ -23,6 +26,7 @@ import { INodeProperties, INodePropertyCollection, INodeType, + INodeVersionedType, IParameterDependencies, IRunExecutionData, IWebhookData, @@ -41,7 +45,7 @@ import { Workflow } from './Workflow'; * @param {INodeType} nodeType * @returns */ -export function getSpecialNodeParameters(nodeType: INodeType) { +export function getSpecialNodeParameters(nodeType: INodeType): INodeProperties[] { if (nodeType.description.polling === true) { return [ { @@ -296,7 +300,7 @@ export function displayParameter( if ( values.length === 0 || - !parameter.displayOptions.show[propertyName].some((v) => values.includes(v)) + !parameter.displayOptions.show[propertyName]!.some((v) => values.includes(v)) ) { return false; } @@ -323,7 +327,7 @@ export function displayParameter( if ( values.length !== 0 && - parameter.displayOptions.hide[propertyName].some((v) => values.includes(v)) + parameter.displayOptions.hide[propertyName]!.some((v) => values.includes(v)) ) { return false; } @@ -844,7 +848,7 @@ export function getNodeWebhooks( return []; } - const nodeType = workflow.nodeTypes.getByName(node.type) as INodeType; + const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion) as INodeType; if (nodeType.description.webhooks === undefined) { // Node does not have any webhooks so return @@ -940,7 +944,7 @@ export function getNodeWebhooksBasic(workflow: Workflow, node: INode): IWebhookD return []; } - const nodeType = workflow.nodeTypes.getByName(node.type) as INodeType; + const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion) as INodeType; if (nodeType.description.webhooks === undefined) { // Node does not have any webhooks so return @@ -1385,3 +1389,27 @@ export function mergeNodeProperties( } } } + +export function getVersionedTypeNode( + object: INodeVersionedType | INodeType, + version?: number, +): INodeType { + if (isNodeTypeVersioned(object)) { + return (object as INodeVersionedType).getNodeType(version); + } + return object as INodeType; +} + +export function getVersionedTypeNodeAll(object: INodeVersionedType | INodeType): INodeType[] { + if (isNodeTypeVersioned(object)) { + return Object.values((object as INodeVersionedType).nodeVersions).map((element) => { + element.description.name = object.description.name; + return element; + }); + } + return [object as INodeType]; +} + +export function isNodeTypeVersioned(object: INodeVersionedType | INodeType): boolean { + return !!('getNodeType' in object); +} diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index d0731a0e1a..36ed377bb4 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ @@ -85,7 +88,8 @@ export class Workflow { let nodeType: INodeType | undefined; for (const node of parameters.nodes) { this.nodes[node.name] = node; - nodeType = this.nodeTypes.getByName(node.type); + + nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType === undefined) { // Go on to next node when its type is not known. @@ -197,7 +201,7 @@ export class Workflow { continue; } - nodeType = this.nodeTypes.getByName(node.type); + nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType === undefined) { // Type is not known so check is not possible @@ -241,7 +245,7 @@ export class Workflow { continue; } - nodeType = this.nodeTypes.getByName(node.type); + nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType === undefined) { // Node type is not known @@ -342,7 +346,7 @@ export class Workflow { continue; } - nodeType = this.nodeTypes.getByName(node.type); + nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType !== undefined && checkFunction(nodeType)) { returnNodes.push(node); @@ -712,7 +716,7 @@ export class Workflow { if (node === null) { return undefined; } - const nodeType = this.nodeTypes.getByName(node.type) as INodeType; + const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion) as INodeType; if (nodeType.description.outputs.length === 1) { // If the parent node has only one output, it can only be connected // to that one. So no further checking is required. @@ -787,7 +791,8 @@ export class Workflow { let nodeType: INodeType; for (const nodeName of nodeNames) { node = this.nodes[nodeName]; - nodeType = this.nodeTypes.getByName(node.type) as INodeType; + + nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion) as INodeType; if (nodeType.trigger !== undefined || nodeType.poll !== undefined) { if (node.disabled === true) { @@ -860,7 +865,7 @@ export class Workflow { isTest?: boolean, ): Promise { const node = this.getNode(webhookData.node) as INode; - const nodeType = this.nodeTypes.getByName(node.type) as INodeType; + const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion) as INodeType; if (nodeType.webhookMethods === undefined) { return; @@ -907,7 +912,7 @@ export class Workflow { ): Promise { const triggerFunctions = getTriggerFunctions(this, node, additionalData, mode, activation); - const nodeType = this.nodeTypes.getByName(node.type); + const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType === undefined) { throw new Error(`The node type "${node.type}" of node "${node.name}" is not known.`); @@ -947,11 +952,12 @@ export class Workflow { * @returns * @memberof Workflow */ + async runPoll( node: INode, pollFunctions: IPollFunctions, ): Promise { - const nodeType = this.nodeTypes.getByName(node.type); + const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType === undefined) { throw new Error(`The node type "${node.type}" of node "${node.name}" is not known.`); @@ -984,7 +990,7 @@ export class Workflow { nodeExecuteFunctions: INodeExecuteFunctions, mode: WorkflowExecuteMode, ): Promise { - const nodeType = this.nodeTypes.getByName(node.type); + const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType === undefined) { throw new Error(`The type of the webhook node "${node.name}" is not known.`); } else if (nodeType.webhook === undefined) { @@ -1036,7 +1042,7 @@ export class Workflow { return undefined; } - const nodeType = this.nodeTypes.getByName(node.type); + const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType === undefined) { throw new Error(`Node type "${node.type}" is not known so can not run it!`); } diff --git a/packages/workflow/test/Helpers.ts b/packages/workflow/test/Helpers.ts index 50e04e7a11..abc2b1b64b 100644 --- a/packages/workflow/test/Helpers.ts +++ b/packages/workflow/test/Helpers.ts @@ -1,4 +1,9 @@ -import { INodeType, INodeTypeData, INodeTypes } from '../src'; +import { + INodeType, + INodeTypeData, + INodeTypes, + NodeHelpers, +} from '../src'; export interface INodeTypesObject { [key: string]: INodeType; @@ -94,11 +99,15 @@ class NodeTypesClass implements INodeTypes { async init(nodeTypes: INodeTypeData): Promise {} getAll(): INodeType[] { - return Object.values(this.nodeTypes).map((data) => data.type); + return Object.values(this.nodeTypes).map((data) => NodeHelpers.getVersionedTypeNode(data.type)); } getByName(nodeType: string): INodeType { - return this.nodeTypes[nodeType].type; + return this.getByNameAndVersion(nodeType); + } + + getByNameAndVersion(nodeType: string, version?: number): INodeType { + return NodeHelpers.getVersionedTypeNode(this.nodeTypes[nodeType].type, version); } }