diff --git a/.editorconfig b/.editorconfig index bec7553240..5d02a5688b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,3 +6,10 @@ indent_style = tab end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true + +[package.json] +indent_style = space +indent_size = 2 + +[*.ts] +quote_type = single \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md index b3aadc2a0f..aac54547eb 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -21,7 +21,7 @@ Software: n8n License: Apache 2.0 -Licensor: Jan Oberhauser +Licensor: n8n GmbH --------------------------------------------------------------------- diff --git a/package.json b/package.json index b045830f51..0c7d8dac15 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "bootstrap": "lerna bootstrap --hoist --no-ci", "build": "lerna exec npm run build", "dev": "lerna exec npm run dev --parallel", + "clean:dist": "lerna exec -- rimraf ./dist", "start": "run-script-os", "start:default": "cd packages/cli/bin && ./n8n", "start:windows": "cd packages/cli/bin && n8n", @@ -14,6 +15,7 @@ }, "devDependencies": { "lerna": "^3.13.1", + "rimraf": "^3.0.2", "run-script-os": "^1.0.7" }, "postcss": {} diff --git a/packages/cli/LICENSE.md b/packages/cli/LICENSE.md index b3aadc2a0f..aac54547eb 100644 --- a/packages/cli/LICENSE.md +++ b/packages/cli/LICENSE.md @@ -21,7 +21,7 @@ Software: n8n License: Apache 2.0 -Licensor: Jan Oberhauser +Licensor: n8n GmbH --------------------------------------------------------------------- diff --git a/packages/cli/package.json b/packages/cli/package.json index 11a69c245c..bb18b70179 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.65.0", + "version": "0.66.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -93,12 +93,12 @@ "jwks-rsa": "^1.6.0", "localtunnel": "^2.0.0", "lodash.get": "^4.4.2", - "mongodb": "^3.2.3", + "mongodb": "^3.5.5", "mysql2": "^2.0.1", - "n8n-core": "~0.32.0", - "n8n-editor-ui": "~0.43.0", - "n8n-nodes-base": "~0.60.0", - "n8n-workflow": "~0.29.0", + "n8n-core": "~0.33.0", + "n8n-editor-ui": "~0.44.0", + "n8n-nodes-base": "~0.61.0", + "n8n-workflow": "~0.30.0", "open": "^7.0.0", "pg": "^7.11.0", "request-promise-native": "^1.0.7", diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index a98def2cbe..ad2a3c1de5 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -512,11 +512,8 @@ class App { const sessionId = GenericHelpers.getSessionId(req); - // Check if workflow is saved as webhooks can only be tested with saved workflows. - // If that is the case check if any webhooks calls are present we have to wait for and - // if that is the case wait till we receive it. - if (WorkflowHelpers.isWorkflowIdValid(workflowData.id) === true && (runData === undefined || startNodes === undefined || startNodes.length === 0 || destinationNode === undefined)) { - // Webhooks can only be tested with saved workflows + // If webhooks nodes exist and are active we have to wait for till we receive a call + if (runData === undefined || startNodes === undefined || startNodes.length === 0 || destinationNode === undefined) { const credentials = await WorkflowCredentials(workflowData.nodes); const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials); const nodeTypes = NodeTypes(); @@ -1083,7 +1080,6 @@ class App { return returnData; })); - // Forces the execution to stop this.app.post('/rest/executions-current/:id/stop', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const executionId = req.params.id; @@ -1151,6 +1147,26 @@ class App { // Webhooks // ---------------------------------------- + // HEAD webhook requests + this.app.head(`/${this.endpointWebhook}/*`, async (req: express.Request, res: express.Response) => { + // Cut away the "/webhook/" to get the registred part of the url + const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(this.endpointWebhook.length + 2); + + let response; + try { + response = await this.activeWorkflowRunner.executeWebhook('HEAD', requestUrl, req, res); + } catch (error) { + ResponseHelper.sendErrorResponse(res, error); + return; + } + + if (response.noWebhookResponse === true) { + // Nothing else to do as the response got already sent + return; + } + + ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); + }); // GET webhook requests this.app.get(`/${this.endpointWebhook}/*`, async (req: express.Request, res: express.Response) => { @@ -1173,7 +1189,6 @@ class App { ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); }); - // POST webhook requests this.app.post(`/${this.endpointWebhook}/*`, async (req: express.Request, res: express.Response) => { // Cut away the "/webhook/" to get the registred part of the url @@ -1195,6 +1210,26 @@ class App { ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); }); + // HEAD webhook requests (test for UI) + this.app.head(`/${this.endpointWebhookTest}/*`, async (req: express.Request, res: express.Response) => { + // Cut away the "/webhook-test/" to get the registred part of the url + const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(this.endpointWebhookTest.length + 2); + + let response; + try { + response = await this.testWebhooks.callTestWebhook('HEAD', requestUrl, req, res); + } catch (error) { + ResponseHelper.sendErrorResponse(res, error); + return; + } + + if (response.noWebhookResponse === true) { + // Nothing else to do as the response got already sent + return; + } + + ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); + }); // GET webhook requests (test for UI) this.app.get(`/${this.endpointWebhookTest}/*`, async (req: express.Request, res: express.Response) => { @@ -1217,7 +1252,6 @@ class App { ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); }); - // POST webhook requests (test for UI) this.app.post(`/${this.endpointWebhookTest}/*`, async (req: express.Request, res: express.Response) => { // Cut away the "/webhook-test/" to get the registred part of the url diff --git a/packages/cli/src/TestWebhooks.ts b/packages/cli/src/TestWebhooks.ts index 61d7213305..45ae624e2b 100644 --- a/packages/cli/src/TestWebhooks.ts +++ b/packages/cli/src/TestWebhooks.ts @@ -129,6 +129,10 @@ export class TestWebhooks { return false; } + if (workflow.id === undefined) { + throw new Error('Webhooks can only be added for saved workflows as an id is needed!'); + } + // Remove test-webhooks automatically if they do not get called (after 120 seconds) const timeout = setTimeout(() => { this.cancelTestWebhook(workflowData.id.toString()); diff --git a/packages/core/LICENSE.md b/packages/core/LICENSE.md index b3aadc2a0f..aac54547eb 100644 --- a/packages/core/LICENSE.md +++ b/packages/core/LICENSE.md @@ -21,7 +21,7 @@ Software: n8n License: Apache 2.0 -Licensor: Jan Oberhauser +Licensor: n8n GmbH --------------------------------------------------------------------- diff --git a/packages/core/package.json b/packages/core/package.json index 8ee7217eb4..b58064344b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.32.0", + "version": "0.33.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -44,7 +44,7 @@ "crypto-js": "3.1.9-1", "lodash.get": "^4.4.2", "mmmagic": "^0.5.2", - "n8n-workflow": "~0.29.0", + "n8n-workflow": "~0.30.0", "p-cancelable": "^2.0.0", "request": "^2.88.2", "request-promise-native": "^1.0.7" diff --git a/packages/editor-ui/LICENSE.md b/packages/editor-ui/LICENSE.md index b3aadc2a0f..aac54547eb 100644 --- a/packages/editor-ui/LICENSE.md +++ b/packages/editor-ui/LICENSE.md @@ -21,7 +21,7 @@ Software: n8n License: Apache 2.0 -Licensor: Jan Oberhauser +Licensor: n8n GmbH --------------------------------------------------------------------- diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index f99eecd94d..f25c05530f 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.43.0", + "version": "0.44.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -64,7 +64,7 @@ "lodash.debounce": "^4.0.8", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", - "n8n-workflow": "~0.29.0", + "n8n-workflow": "~0.30.0", "node-sass": "^4.12.0", "prismjs": "^1.17.1", "quill": "^2.0.0-dev.3", diff --git a/packages/editor-ui/src/components/CredentialsInput.vue b/packages/editor-ui/src/components/CredentialsInput.vue index 0830b2a2a0..064d88fc71 100644 --- a/packages/editor-ui/src/components/CredentialsInput.vue +++ b/packages/editor-ui/src/components/CredentialsInput.vue @@ -21,18 +21,20 @@ - - - {{parameter.displayName}}: - -
- -
-
- - - -
+ + + + {{parameter.displayName}}: + +
+ +
+
+ + + +
+
@@ -85,6 +87,7 @@ import { ICredentialType, ICredentialNodeAccess, INodeCredentialDescription, + INodeProperties, INodeTypeDescription, } from 'n8n-workflow'; @@ -162,6 +165,14 @@ export default mixins( tempValue[name] = parameterData.value; Vue.set(this, 'propertyValue', tempValue); }, + displayCredentialParameter (parameter: INodeProperties): boolean { + if (parameter.displayOptions === undefined) { + // If it is not defined no need to do a proper check + return true; + } + + return this.displayParameter(this.propertyValue, parameter, ''); + }, async createCredentials (): Promise { const nodesAccess = this.nodesAccess.map((nodeType) => { return { diff --git a/packages/editor-ui/src/components/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsList.vue index 541e77d1b7..e8721bcf9b 100644 --- a/packages/editor-ui/src/components/ExecutionsList.vue +++ b/packages/editor-ui/src/components/ExecutionsList.vue @@ -504,11 +504,11 @@ export default mixins( } else if (entry.finished === true) { return 'The worklow execution was successful.'; } else if (entry.retryOf !== undefined) { - return `The workflow execution was a retry of "${entry.retryOf}" and did fail.
New retries have to be started from the original execution.`; + return `The workflow execution was a retry of "${entry.retryOf}" and failed.
New retries have to be started from the original execution.`; } else if (entry.retrySuccessId !== undefined) { - return `The workflow execution did fail but the retry "${entry.retrySuccessId}" was successful.`; + return `The workflow execution failed but the retry "${entry.retrySuccessId}" was successful.`; } else { - return 'The workflow execution did fail.'; + return 'The workflow execution failed.'; } }, async stopExecution (activeExecutionId: string) { diff --git a/packages/editor-ui/src/components/mixins/pushConnection.ts b/packages/editor-ui/src/components/mixins/pushConnection.ts index f32423f6c9..d9e77d929c 100644 --- a/packages/editor-ui/src/components/mixins/pushConnection.ts +++ b/packages/editor-ui/src/components/mixins/pushConnection.ts @@ -22,6 +22,8 @@ export const pushConnection = mixins( return { eventSource: null as EventSource | null, reconnectTimeout: null as NodeJS.Timeout | null, + retryTimeout: null as NodeJS.Timeout | null, + pushMessageQueue: [] as Array<{ event: Event, retriesLeft: number }>, }; }, computed: { @@ -96,47 +98,84 @@ export const pushConnection = mixins( * @param {number} retryAttempts * @returns */ - retryPushMessage (event: Event, retryAttempts: number) { - retryAttempts = retryAttempts - 1; + queuePushMessage (event: Event, retryAttempts: number) { + this.pushMessageQueue.push({ event, retriesLeft: retryAttempts }); - if (retryAttempts <= 0) { - return; + if (this.retryTimeout === null) { + this.retryTimeout = setTimeout(this.processWaitingPushMessages, 20); + } + }, + + + /** + * Process the push messages which are waiting in the queue + */ + processWaitingPushMessages () { + if (this.retryTimeout !== null) { + clearTimeout(this.retryTimeout); + this.retryTimeout = null; } - setTimeout(() => { - this.pushMessageReceived(event, retryAttempts); - }, 200); + const queueLength = this.pushMessageQueue.length; + for (let i = 0; i < queueLength; i++) { + const messageData = this.pushMessageQueue.shift(); + + if (this.pushMessageReceived(messageData!.event, true) === false) { + // Was not successful + messageData!.retriesLeft -= 1; + + if (messageData!.retriesLeft > 0) { + // If still retries are left add it back and stop execution + this.pushMessageQueue.unshift(messageData!); + } + break; + } + } + + if (this.pushMessageQueue.length !== 0 && this.retryTimeout === null) { + this.retryTimeout = setTimeout(this.processWaitingPushMessages, 25); + } }, + /** * Process a newly received message * * @param {Event} event The event data with the message data - * @returns {void} + * @param {boolean} [isRetry] If it is a retry + * @returns {boolean} If message could be processed */ - pushMessageReceived (event: Event, retryAttempts?: number): void { - retryAttempts = retryAttempts || 5; + pushMessageReceived (event: Event, isRetry?: boolean): boolean { + const retryAttempts = 5; let receivedData: IPushData; try { // @ts-ignore receivedData = JSON.parse(event.data); } catch (error) { - console.error('The received push data is not valid JSON.'); // eslint-disable-line no-console - return; + return false; + } + + if (!['testWebhookReceived'].includes(receivedData.type) && isRetry !== true && this.pushMessageQueue.length) { + // If there are already messages in the queue add the new one that all of them + // get executed in order + this.queuePushMessage(event, retryAttempts); + return false; } if (['nodeExecuteAfter', 'nodeExecuteBefore'].includes(receivedData.type)) { if (this.$store.getters.isActionActive('workflowRunning') === false) { // No workflow is running so ignore the messages - return; + return false; } const pushData = receivedData.data as IPushDataNodeExecuteBefore; if (this.$store.getters.activeExecutionId !== pushData.executionId) { // The data is not for the currently active execution or // we do not have the execution id yet. - this.retryPushMessage(event, retryAttempts); - return; + if (isRetry !== true) { + this.queuePushMessage(event, retryAttempts); + } + return false; } } @@ -148,14 +187,16 @@ export const pushConnection = mixins( if (this.$store.getters.isActionActive('workflowRunning') === false) { // No workflow is running so ignore the messages - return; + return false; } if (this.$store.getters.activeExecutionId !== pushData.executionIdActive) { // The workflow which did finish execution did either not get started // by this session or we do not have the execution id yet. - this.retryPushMessage(event, retryAttempts); - return; + if (isRetry !== true) { + this.queuePushMessage(event, retryAttempts); + } + return false; } const runDataExecuted = pushData.data; @@ -231,7 +272,10 @@ export const pushConnection = mixins( this.$store.commit('setExecutionWaitingForWebhook', false); this.$store.commit('setActiveExecutionId', pushData.executionId); } + + this.processWaitingPushMessages(); } + return true; }, }, }); diff --git a/packages/editor-ui/src/main.ts b/packages/editor-ui/src/main.ts index f9a7f879a0..e1b30cd166 100644 --- a/packages/editor-ui/src/main.ts +++ b/packages/editor-ui/src/main.ts @@ -179,7 +179,7 @@ if (process.env.NODE_ENV !== 'production') { // not do anything about it anyway return; } - console.error('error cought in main.ts'); // eslint-disable-line no-console + console.error('error caught in main.ts'); // eslint-disable-line no-console console.error(message); // eslint-disable-line no-console console.error(error); // eslint-disable-line no-console }; diff --git a/packages/node-dev/LICENSE.md b/packages/node-dev/LICENSE.md index b3aadc2a0f..aac54547eb 100644 --- a/packages/node-dev/LICENSE.md +++ b/packages/node-dev/LICENSE.md @@ -21,7 +21,7 @@ Software: n8n License: Apache 2.0 -Licensor: Jan Oberhauser +Licensor: n8n GmbH --------------------------------------------------------------------- diff --git a/packages/nodes-base/LICENSE.md b/packages/nodes-base/LICENSE.md index b3aadc2a0f..aac54547eb 100644 --- a/packages/nodes-base/LICENSE.md +++ b/packages/nodes-base/LICENSE.md @@ -21,7 +21,7 @@ Software: n8n License: Apache 2.0 -Licensor: Jan Oberhauser +Licensor: n8n GmbH --------------------------------------------------------------------- diff --git a/packages/nodes-base/credentials/FacebookGraphApi.credentials.ts b/packages/nodes-base/credentials/FacebookGraphApi.credentials.ts new file mode 100644 index 0000000000..b779ca96e6 --- /dev/null +++ b/packages/nodes-base/credentials/FacebookGraphApi.credentials.ts @@ -0,0 +1,18 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class FacebookGraphApi implements ICredentialType { + name = 'facebookGraphApi'; + displayName = 'Facebook Graph API'; + properties = [ + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/credentials/MongoDb.credentials.ts b/packages/nodes-base/credentials/MongoDb.credentials.ts index b39c7016de..a6de302172 100644 --- a/packages/nodes-base/credentials/MongoDb.credentials.ts +++ b/packages/nodes-base/credentials/MongoDb.credentials.ts @@ -1,45 +1,106 @@ -import { - ICredentialType, - NodePropertyTypes, -} from 'n8n-workflow'; - +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; export class MongoDb implements ICredentialType { name = 'mongoDb'; displayName = 'MongoDB'; properties = [ + { + displayName: 'Configuration Type', + name: 'configurationType', + type: 'options' as NodePropertyTypes, + options: [ + { + name: 'Connection String', + value: 'connectionString', + description: 'Provide connection data via string', + }, + { + name: 'Values', + value: 'values', + description: 'Provide connection data via values', + }, + ], + default: 'values', + description: 'The operation to perform.', + }, + { + displayName: 'Connection String', + name: 'connectionString', + type: 'string' as NodePropertyTypes, + displayOptions: { + show: { + configurationType: [ + 'connectionString', + ], + }, + }, + default: '', + placeholder: 'mongodb://:@localhost:27017/?authSource=admin&readPreference=primary&appname=n8n&ssl=false', + required: false, + description: `If provided, the value here will be used as a MongoDB connection string,
+ and the MongoDB credentials will be ignored` + }, { displayName: 'Host', name: 'host', type: 'string' as NodePropertyTypes, - default: 'localhost', + displayOptions: { + show: { + configurationType: [ + 'values', + ], + }, + }, + default: 'localhost' }, { displayName: 'Database', name: 'database', type: 'string' as NodePropertyTypes, default: '', + description: 'Note: the database should still be provided even if using an override connection string' }, { displayName: 'User', name: 'user', type: 'string' as NodePropertyTypes, - default: '', + displayOptions: { + show: { + configurationType: [ + 'values', + ], + }, + }, + default: '' }, { displayName: 'Password', name: 'password', type: 'string' as NodePropertyTypes, typeOptions: { - password: true, + password: true }, - default: '', + displayOptions: { + show: { + configurationType: [ + 'values', + ], + }, + }, + default: '' }, { displayName: 'Port', name: 'port', type: 'number' as NodePropertyTypes, - default: 27017, + displayOptions: { + show: { + configurationType: [ + 'values', + ], + }, + }, + default: 27017 }, ]; } diff --git a/packages/nodes-base/credentials/Sms77Api.credentials.ts b/packages/nodes-base/credentials/Sms77Api.credentials.ts new file mode 100644 index 0000000000..e560a9f124 --- /dev/null +++ b/packages/nodes-base/credentials/Sms77Api.credentials.ts @@ -0,0 +1,17 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class Sms77Api implements ICredentialType { + name = 'sms77Api'; + displayName = 'Sms77 API'; + properties = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/credentials/SurveyMonkeyApi.credentials.ts b/packages/nodes-base/credentials/SurveyMonkeyApi.credentials.ts new file mode 100644 index 0000000000..66614595eb --- /dev/null +++ b/packages/nodes-base/credentials/SurveyMonkeyApi.credentials.ts @@ -0,0 +1,36 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class SurveyMonkeyApi implements ICredentialType { + name = 'surveyMonkeyApi'; + displayName = 'SurveyMonkey API'; + properties = [ + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '', + description: `The access token must have the following scopes:
+ - Create/modify webhooks
+ - View webhooks
+ - View surveys
+ - View collectors
+ - View responses
+ - View response details`, + }, + { + displayName: 'Client ID', + name: 'clientId', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Client Secret', + name: 'clientSecret', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts new file mode 100644 index 0000000000..02c9bf40c2 --- /dev/null +++ b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts @@ -0,0 +1,418 @@ +import { + BINARY_ENCODING, + IExecuteFunctions, +} from 'n8n-core'; +import { + IBinaryData, + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { OptionsWithUri } from 'request'; + +export class FacebookGraphApi implements INodeType { + description: INodeTypeDescription = { + displayName: 'Facebook Graph API', + name: 'facebookGraphApi', + icon: 'file:facebook.png', + group: ['transform'], + version: 1, + description: 'Interacts with Facebook using the Graph API', + defaults: { + name: 'Facebook Graph API', + color: '#772244', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'facebookGraphApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Host URL', + name: 'hostUrl', + type: 'options', + options: [ + { + name: 'Default', + value: 'graph.facebook.com', + }, + { + name: 'Video Uploads', + value: 'graph-video.facebook.com', + } + ], + default: 'graph.facebook.com', + description: 'The Host URL of the request. Almost all requests are passed to the graph.facebook.com host URL. The single exception is video uploads, which use graph-video.facebook.com.', + required: true, + }, + { + displayName: 'HTTP Request Method', + name: 'httpRequestMethod', + type: 'options', + options: [ + { + name: 'GET', + value: 'GET', + }, + { + name: 'POST', + value: 'POST', + }, + { + name: 'DELETE', + value: 'DELETE', + }, + ], + default: 'GET', + description: 'The HTTP Method to be used for the request.', + required: true, + }, + { + displayName: 'Graph API Version', + name: 'graphApiVersion', + type: 'options', + options: [ + { + name: 'Latest', + value: '', + }, + { + name: 'v6.0', + value: 'v6.0', + }, + { + name: 'v5.0', + value: 'v5.0', + }, + { + name: 'v4.0', + value: 'v4.0', + }, + { + name: 'v3.3', + value: 'v3.3', + }, + { + name: 'v3.2', + value: 'v3.2', + }, + { + name: 'v3.1', + value: 'v3.1', + }, + { + name: 'v3.0', + value: 'v3.0', + }, + { + name: 'v2.12', + value: 'v2.12', + }, + ], + default: '', + description: 'The version of the Graph API to be used in the request.', + required: true, + }, + { + displayName: 'Node', + name: 'node', + type: 'string', + default: '', + description: 'The node on which to operate. A node is an individual object with a unique ID. For example, there are many User node objects, each with a unique ID representing a person on Facebook.', + placeholder: 'me', + required: true, + }, + { + displayName: 'Edge', + name: 'edge', + type: 'string', + default: '', + description: 'Edge of the node on which to operate. Edges represent collections of objects wich are attached to the node.', + placeholder: 'videos', + required: false, + }, + { + displayName: 'Send Binary Data', + name: 'sendBinaryData', + type: 'boolean', + displayOptions: { + show: { + httpRequestMethod: [ + 'POST', + 'PUT', + ], + }, + }, + default: false, + required: true, + description: 'If binary data should be send as body.', + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + required: false, + default: '', + placeholder: 'file:data', + displayOptions: { + hide: { + sendBinaryData: [ + false, + ], + }, + show: { + httpRequestMethod: [ + 'POST', + 'PUT', + ], + }, + }, + description: `Name of the binary property which contains the data for the file to be uploaded.
+ For Form-Data Multipart, multiple can be provided in the format:
+ "sendKey1:binaryProperty1,sendKey2:binaryProperty2`, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Fields', + name: 'fields', + placeholder: 'Add Field', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + '/httpRequestMethod': [ + 'GET', + ], + }, + }, + description: 'The list of fields to request in the GET request.', + default: {}, + options: [ + { + name: 'field', + displayName: 'Field', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of the field.', + }, + ], + }, + ], + }, + { + displayName: 'Query Parameters', + name: 'queryParameters', + placeholder: 'Add Parameter', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + description: 'The query parameters to send', + default: {}, + options: [ + { + name: 'parameter', + displayName: 'Parameter', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of the parameter.', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value of the parameter.', + }, + ], + }, + ], + }, + { + displayName: 'Query Parameters JSON', + name: 'queryParametersJson', + type: 'json', + default: '{}', + placeholder: '{\"field_name\": \"field_value\"}', + description: 'The query parameters to send, defined as a JSON object', + required: false, + } + ], + }, + ], + }; + + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + let response: any; // tslint:disable-line:no-any + const returnItems: INodeExecutionData[] = []; + + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + const graphApiCredentials = this.getCredentials('facebookGraphApi'); + + const hostUrl = this.getNodeParameter('hostUrl', itemIndex) as string; + const httpRequestMethod = this.getNodeParameter('httpRequestMethod', itemIndex) as string; + let graphApiVersion = this.getNodeParameter('graphApiVersion', itemIndex) as string; + const node = this.getNodeParameter('node', itemIndex) as string; + const edge = this.getNodeParameter('edge', itemIndex) as string; + const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject; + + if (graphApiVersion !== '') { + graphApiVersion += '/'; + } + + let uri = `https://${hostUrl}/${graphApiVersion}${node}`; + if (edge) { + uri = `${uri}/${edge}`; + } + + const requestOptions : OptionsWithUri = { + headers: { + accept: 'application/json,text/*;q=0.99', + }, + method: httpRequestMethod, + uri, + json: true, + gzip: true, + qs: { + access_token: graphApiCredentials!.accessToken, + }, + }; + + if (options !== undefined) { + // Build fields query parameter as a comma separated list + if (options.fields !== undefined) { + const fields = options.fields as IDataObject; + if (fields.field !== undefined) { + const fieldsCsv = (fields.field as IDataObject[]).map(field => field.name).join(','); + requestOptions.qs.fields = fieldsCsv; + } + } + + // Add the query parameters defined in the UI + if (options.queryParameters !== undefined) { + const queryParameters = options.queryParameters as IDataObject; + + if (queryParameters.parameter !== undefined) { + for (const queryParameter of queryParameters.parameter as IDataObject[]) { + requestOptions.qs[queryParameter.name as string] = queryParameter.value; + } + } + } + + // Add the query parameters defined as a JSON object + if (options.queryParametersJson) { + let queryParametersJsonObj = {}; + try + { + queryParametersJsonObj = JSON.parse(options.queryParametersJson as string); + } catch { /* Do nothing, at least for now */} + const qs = requestOptions.qs; + requestOptions.qs = { + ...qs, + ...queryParametersJsonObj, + }; + } + } + + const sendBinaryData = this.getNodeParameter('sendBinaryData', itemIndex, false) as boolean; + if (sendBinaryData) { + const item = items[itemIndex]; + if (item.binary === undefined) { + throw new Error('No binary data exists on item!'); + } + + const binaryPropertyNameFull = this.getNodeParameter('binaryPropertyName', itemIndex) as string; + + let propertyName = 'file'; + let binaryPropertyName = binaryPropertyNameFull; + if (binaryPropertyNameFull.includes(':')) { + const binaryPropertyNameParts = binaryPropertyNameFull.split(':'); + propertyName = binaryPropertyNameParts[0]; + binaryPropertyName = binaryPropertyNameParts[1]; + } + + if (item.binary[binaryPropertyName] === undefined) { + throw new Error(`No binary data property "${binaryPropertyName}" does not exists on item!`); + } + + const binaryProperty = item.binary[binaryPropertyName] as IBinaryData; + + requestOptions.formData = { + [propertyName]: { + value: Buffer.from(binaryProperty.data, BINARY_ENCODING), + options: { + filename: binaryProperty.fileName, + contentType: binaryProperty.mimeType, + }, + }, + }; + } + + try { + // Now that the options are all set make the actual http request + response = await this.helpers.request(requestOptions); + } catch (error) { + if (this.continueOnFail() === false) { + throw error; + } + + let errorItem; + if (error.response !== undefined) { + // Since this is a Graph API node and we already know the request was + // not successful, we'll go straight to the error details. + const graphApiErrors = error.response.body?.error ?? {}; + + errorItem = { + statusCode: error.statusCode, + ...graphApiErrors, + headers: error.response.headers, + }; + } else { + // Unknown Graph API response, we'll dump everything in the response item + errorItem = error; + } + returnItems.push({ json: { ...errorItem } }); + + continue; + } + + if (typeof response === 'string') { + if (this.continueOnFail() === false) { + throw new Error('Response body is not valid JSON.'); + } + + returnItems.push({ json: { message: response } }); + continue; + } + + returnItems.push({json: response}); + } + + return [returnItems]; + } +} diff --git a/packages/nodes-base/nodes/Facebook/facebook.png b/packages/nodes-base/nodes/Facebook/facebook.png new file mode 100644 index 0000000000..e0cc044609 Binary files /dev/null and b/packages/nodes-base/nodes/Facebook/facebook.png differ diff --git a/packages/nodes-base/nodes/Github/Github.node.ts b/packages/nodes-base/nodes/Github/Github.node.ts index 59df672290..b61c6d373f 100644 --- a/packages/nodes-base/nodes/Github/Github.node.ts +++ b/packages/nodes-base/nodes/Github/Github.node.ts @@ -212,7 +212,7 @@ export class Github implements INodeType { { name: 'Get Emails', value: 'getEmails', - description: 'Returns the repositories of a user', + description: 'Returns the email addresses of a user', }, { name: 'Get Repositories', diff --git a/packages/nodes-base/nodes/Jira/IssueDescription.ts b/packages/nodes-base/nodes/Jira/IssueDescription.ts index 5f3c18c9d6..cd788c886e 100644 --- a/packages/nodes-base/nodes/Jira/IssueDescription.ts +++ b/packages/nodes-base/nodes/Jira/IssueDescription.ts @@ -81,6 +81,9 @@ export const issueFields = [ }, typeOptions: { loadOptionsMethod: 'getProjects', + loadOptionsDependsOn: [ + 'jiraVersion', + ], }, description: 'Project', }, diff --git a/packages/nodes-base/nodes/Jira/JiraSoftwareCloud.node.ts b/packages/nodes-base/nodes/Jira/JiraSoftwareCloud.node.ts index 323993e7a1..eff27bad6b 100644 --- a/packages/nodes-base/nodes/Jira/JiraSoftwareCloud.node.ts +++ b/packages/nodes-base/nodes/Jira/JiraSoftwareCloud.node.ts @@ -111,9 +111,10 @@ export class JiraSoftwareCloud implements INodeType { // select them easily async getProjects(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; - const jiraCloudCredentials = this.getCredentials('jiraSoftwareCloudApi'); + const jiraVersion = this.getCurrentNodeParameter('jiraVersion') as string; + let endpoint = '/project/search'; - if (jiraCloudCredentials === undefined) { + if (jiraVersion === 'server') { endpoint = '/project'; } let projects = await jiraSoftwareCloudApiRequest.call(this, endpoint, 'GET'); diff --git a/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts b/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts index 0aa6d603ac..dbd7ab2929 100644 --- a/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts +++ b/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts @@ -3,192 +3,29 @@ import { IDataObject, INodeExecutionData, INodeType, - INodeTypeDescription, + INodeTypeDescription } from 'n8n-workflow'; - +import { nodeDescription } from './mongo.node.options'; import { MongoClient } from 'mongodb'; - - -/** - * Returns of copy of the items which only contains the json data and - * of that only the define properties - * - * @param {INodeExecutionData[]} items The items to copy - * @param {string[]} properties The properties it should include - * @returns - */ -function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataObject[] { - // Prepare the data to insert and copy it to be returned - let newItem: IDataObject; - return items.map((item) => { - newItem = {}; - for (const property of properties) { - if (item.json[property] === undefined) { - newItem[property] = null; - } else { - newItem[property] = JSON.parse(JSON.stringify(item.json[property])); - } - } - return newItem; - }); -} - +import { + getItemCopy, + validateAndResolveMongoCredentials +} from './mongo.node.utils'; export class MongoDb implements INodeType { - description: INodeTypeDescription = { - displayName: 'MongoDB', - name: 'mongoDb', - icon: 'file:mongoDb.png', - group: ['input'], - version: 1, - description: 'Find, insert and update documents in MongoDB.', - defaults: { - name: 'MongoDB', - color: '#13AA52', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - name: 'mongoDb', - required: true, - } - ], - properties: [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - options: [ - { - name: 'Find', - value: 'find', - description: 'Find documents.', - }, - { - name: 'Insert', - value: 'insert', - description: 'Insert documents.', - }, - { - name: 'Update', - value: 'update', - description: 'Updates documents.', - }, - ], - default: 'find', - description: 'The operation to perform.', - }, - - { - displayName: 'Collection', - name: 'collection', - type: 'string', - required: true, - default: '', - description: 'MongoDB Collection' - }, - - // ---------------------------------- - // find - // ---------------------------------- - { - displayName: 'Query (JSON format)', - name: 'query', - type: 'string', - typeOptions: { - rows: 5, - }, - displayOptions: { - show: { - operation: [ - 'find' - ], - }, - }, - default: '{}', - placeholder: `{ "birth": { "$gt": "1950-01-01" } }`, - required: true, - description: 'MongoDB Find query.', - }, - - - // ---------------------------------- - // insert - // ---------------------------------- - { - displayName: 'Fields', - name: 'fields', - type: 'string', - displayOptions: { - show: { - operation: [ - 'insert' - ], - }, - }, - default: '', - placeholder: 'name,description', - description: 'Comma separated list of the fields to be included into the new document.', - }, - - - // ---------------------------------- - // update - // ---------------------------------- - { - displayName: 'Update Key', - name: 'updateKey', - type: 'string', - displayOptions: { - show: { - operation: [ - 'update' - ], - }, - }, - default: 'id', - required: true, - description: 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', - }, - { - displayName: 'Fields', - name: 'fields', - type: 'string', - displayOptions: { - show: { - operation: [ - 'update' - ], - }, - }, - default: '', - placeholder: 'name,description', - description: 'Comma separated list of the fields to be included into the new document.', - }, - - ] - }; - + description: INodeTypeDescription = nodeDescription; async execute(this: IExecuteFunctions): Promise { + const { database, connectionString } = validateAndResolveMongoCredentials( + this.getCredentials('mongoDb') + ); - const credentials = this.getCredentials('mongoDb'); + const client: MongoClient = await MongoClient.connect(connectionString, { + useNewUrlParser: true, + useUnifiedTopology: true + }); - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - - let connectionUri = ''; - - if (credentials.port) { - connectionUri = `mongodb://${credentials.user}:${credentials.password}@${credentials.host}:${credentials.port}`; - } else { - connectionUri = `mongodb+srv://${credentials.user}:${credentials.password}@${credentials.host}`; - } - - const client = await MongoClient.connect(connectionUri, { useNewUrlParser: true, useUnifiedTopology: true }); - const mdb = client.db(credentials.database as string); + const mdb = client.db(database as string); let returnItems = []; @@ -206,7 +43,6 @@ export class MongoDb implements INodeType { .toArray(); returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); - } else if (operation === 'insert') { // ---------------------------------- // insert @@ -229,7 +65,7 @@ export class MongoDb implements INodeType { returnItems.push({ json: { ...insertItems[parseInt(i, 10)], - id: insertedIds[parseInt(i, 10)] as string, + id: insertedIds[parseInt(i, 10)] as string } }); } @@ -258,7 +94,7 @@ export class MongoDb implements INodeType { continue; } - const filter: { [key: string] :string } = {}; + const filter: { [key: string]: string } = {}; filter[updateKey] = item[updateKey] as string; await mdb @@ -267,7 +103,6 @@ export class MongoDb implements INodeType { } returnItems = this.helpers.returnJsonArray(updateItems as IDataObject[]); - } else { throw new Error(`The operation "${operation}" is not supported!`); } diff --git a/packages/nodes-base/nodes/MongoDb/mongo.node.options.ts b/packages/nodes-base/nodes/MongoDb/mongo.node.options.ts new file mode 100644 index 0000000000..190ff3de88 --- /dev/null +++ b/packages/nodes-base/nodes/MongoDb/mongo.node.options.ts @@ -0,0 +1,131 @@ +import { INodeTypeDescription } from 'n8n-workflow'; + +/** + * Options to be displayed + */ +export const nodeDescription: INodeTypeDescription = { + displayName: 'MongoDB', + name: 'mongoDb', + icon: 'file:mongoDb.png', + group: ['input'], + version: 1, + description: 'Find, insert and update documents in MongoDB.', + defaults: { + name: 'MongoDB', + color: '#13AA52' + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'mongoDb', + required: true + } + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Find', + value: 'find', + description: 'Find documents.' + }, + { + name: 'Insert', + value: 'insert', + description: 'Insert documents.' + }, + { + name: 'Update', + value: 'update', + description: 'Updates documents.' + } + ], + default: 'find', + description: 'The operation to perform.' + }, + + { + displayName: 'Collection', + name: 'collection', + type: 'string', + required: true, + default: '', + description: 'MongoDB Collection' + }, + + // ---------------------------------- + // find + // ---------------------------------- + { + displayName: 'Query (JSON format)', + name: 'query', + type: 'string', + typeOptions: { + rows: 5 + }, + displayOptions: { + show: { + operation: ['find'] + } + }, + default: '{}', + placeholder: `{ "birth": { "$gt": "1950-01-01" } }`, + required: true, + description: 'MongoDB Find query.' + }, + + // ---------------------------------- + // insert + // ---------------------------------- + { + displayName: 'Fields', + name: 'fields', + type: 'string', + displayOptions: { + show: { + operation: ['insert'] + } + }, + default: '', + placeholder: 'name,description', + description: + 'Comma separated list of the fields to be included into the new document.' + }, + + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Update Key', + name: 'updateKey', + type: 'string', + displayOptions: { + show: { + operation: ['update'] + } + }, + default: 'id', + required: true, + description: + 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".' + }, + { + displayName: 'Fields', + name: 'fields', + type: 'string', + displayOptions: { + show: { + operation: ['update'] + } + }, + default: '', + placeholder: 'name,description', + description: + 'Comma separated list of the fields to be included into the new document.' + } + ] +}; diff --git a/packages/nodes-base/nodes/MongoDb/mongo.node.types.ts b/packages/nodes-base/nodes/MongoDb/mongo.node.types.ts new file mode 100644 index 0000000000..212ebc159d --- /dev/null +++ b/packages/nodes-base/nodes/MongoDb/mongo.node.types.ts @@ -0,0 +1,53 @@ +import { CredentialInformation } from 'n8n-workflow'; + +/** + * Credentials object for Mongo, if using individual parameters + */ +export interface IMongoParametricCredentials { + /** + * Whether to allow overriding the parametric credentials with a connection string + */ + configurationType: 'values'; + + host: string; + database: string; + user: string; + password: string; + port?: number; +} + +/** + * Credentials object for Mongo, if using override connection string + */ +export interface IMongoOverrideCredentials { + /** + * Whether to allow overriding the parametric credentials with a connection string + */ + configurationType: 'connectionString'; + /** + * If using an override connection string, this is where it will be. + */ + connectionString: string; + database: string; +} + +/** + * Unified credential object type (whether params are overridden with a connection string or not) + */ +export type IMongoCredentialsType = + | IMongoParametricCredentials + | IMongoOverrideCredentials; + +/** + * Resolve the database and connection string from input credentials + */ +export type IMongoCredentials = { + /** + * Database name (used to create the Mongo client) + */ + database: string; + /** + * Generated connection string (after validating and figuring out overrides) + */ + connectionString: string; +}; diff --git a/packages/nodes-base/nodes/MongoDb/mongo.node.utils.ts b/packages/nodes-base/nodes/MongoDb/mongo.node.utils.ts new file mode 100644 index 0000000000..17ccc0dc4a --- /dev/null +++ b/packages/nodes-base/nodes/MongoDb/mongo.node.utils.ts @@ -0,0 +1,104 @@ +import { + IDataObject, + INodeExecutionData, + ICredentialDataDecryptedObject +} from 'n8n-workflow'; +import { + IMongoCredentialsType, + IMongoParametricCredentials, + IMongoCredentials +} from './mongo.node.types'; + +/** + * Standard way of building the MongoDB connection string, unless overridden with a provided string + * + * @param {ICredentialDataDecryptedObject} credentials MongoDB credentials to use, unless conn string is overridden + */ +function buildParameterizedConnString( + credentials: IMongoParametricCredentials +): string { + if (credentials.port) { + return `mongodb://${credentials.user}:${credentials.password}@${credentials.host}:${credentials.port}`; + } else { + return `mongodb+srv://${credentials.user}:${credentials.password}@${credentials.host}`; + } +} + +/** + * Build mongoDb connection string and resolve database name. + * If a connection string override value is provided, that will be used in place of individual args + * + * @param {ICredentialDataDecryptedObject} credentials raw/input MongoDB credentials to use + */ +function buildMongoConnectionParams( + credentials: IMongoCredentialsType +): IMongoCredentials { + const sanitizedDbName = + credentials.database && credentials.database.trim().length > 0 + ? credentials.database.trim() + : ''; + if (credentials.configurationType === 'connectionString') { + if ( + credentials.connectionString && + credentials.connectionString.trim().length > 0 + ) { + return { + connectionString: credentials.connectionString.trim(), + database: sanitizedDbName + }; + } else { + throw new Error( + 'Cannot override credentials: valid MongoDB connection string not provided ' + ); + } + } else { + return { + connectionString: buildParameterizedConnString(credentials), + database: sanitizedDbName + }; + } +} + +/** + * Verify credentials. If ok, build mongoDb connection string and resolve database name. + * + * @param {ICredentialDataDecryptedObject} credentials raw/input MongoDB credentials to use + */ +export function validateAndResolveMongoCredentials( + credentials?: ICredentialDataDecryptedObject +): IMongoCredentials { + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } else { + return buildMongoConnectionParams( + credentials as unknown as IMongoCredentialsType, + ); + } +} + +/** + * Returns of copy of the items which only contains the json data and + * of that only the define properties + * + * @param {INodeExecutionData[]} items The items to copy + * @param {string[]} properties The properties it should include + * @returns + */ +export function getItemCopy( + items: INodeExecutionData[], + properties: string[] +): IDataObject[] { + // Prepare the data to insert and copy it to be returned + let newItem: IDataObject; + return items.map(item => { + newItem = {}; + for (const property of properties) { + if (item.json[property] === undefined) { + newItem[property] = null; + } else { + newItem[property] = JSON.parse(JSON.stringify(item.json[property])); + } + } + return newItem; + }); +} diff --git a/packages/nodes-base/nodes/Sms77/GenericFunctions.ts b/packages/nodes-base/nodes/Sms77/GenericFunctions.ts new file mode 100644 index 0000000000..30499b3b29 --- /dev/null +++ b/packages/nodes-base/nodes/Sms77/GenericFunctions.ts @@ -0,0 +1,58 @@ +import { + IExecuteFunctions, + IHookFunctions, +} from 'n8n-core'; + +import { + ICredentialDataDecryptedObject, + IDataObject, +} from 'n8n-workflow'; + +/** + * Make an API request to MSG91 + * + * @param {IHookFunctions | IExecuteFunctions} this + * @param {string} method + * @param {string} endpoint + * @param {object} form + * @param {object | undefined} qs + * @returns {Promise} + */ +export async function sms77ApiRequest(this: IHookFunctions | IExecuteFunctions, method: string, endpoint: string, form: IDataObject, qs?: IDataObject): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('sms77Api'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + if ('GET' === method) { + qs = setPayload(credentials, qs); + } else { + form = setPayload(credentials, form); + } + const response = await this.helpers.request({ + form, + json: true, + method, + qs, + uri: `https://gateway.sms77.io/api/${endpoint}`, + }); + + if ('100' !== response.success) { + throw new Error('Invalid sms77 credentials or API error!'); + } + + return response; +} + + +function setPayload(credentials: ICredentialDataDecryptedObject, o?: IDataObject) { + if (!o) { + o = {}; + } + + o.p = credentials!.apiKey as string; + o.json = 1; + o.sendwith = 'n8n'; + + return o; +} diff --git a/packages/nodes-base/nodes/Sms77/Sms77.node.ts b/packages/nodes-base/nodes/Sms77/Sms77.node.ts new file mode 100644 index 0000000000..ac8a10f4b6 --- /dev/null +++ b/packages/nodes-base/nodes/Sms77/Sms77.node.ts @@ -0,0 +1,144 @@ +import {IExecuteFunctions,} from 'n8n-core'; +import {IDataObject, INodeExecutionData, INodeType, INodeTypeDescription,} from 'n8n-workflow'; +import {sms77ApiRequest} from './GenericFunctions'; + +export class Sms77 implements INodeType { + description: INodeTypeDescription = { + displayName: 'Sms77', + name: 'sms77', + icon: 'file:sms77.png', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Send SMS', + defaults: { + name: 'Sms77', + color: '#18D46A', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'sms77Api', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'SMS', + value: 'sms', + }, + ], + default: 'sms', + description: 'The resource to operate on.', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'sms', + ], + }, + }, + options: [ + { + name: 'Send', + value: 'send', + description: 'Send SMS', + }, + ], + default: 'send', + description: 'The operation to perform.', + }, + { + displayName: 'From', + name: 'from', + type: 'string', + default: '', + placeholder: '+4901234567890', + required: false, + displayOptions: { + show: { + operation: [ + 'send', + ], + resource: [ + 'sms', + ], + }, + }, + description: 'The number from which to send the message.', + }, + { + displayName: 'To', + name: 'to', + type: 'string', + default: '', + placeholder: '+49876543210', + required: true, + displayOptions: { + show: { + operation: [ + 'send', + ], + resource: [ + 'sms', + ], + }, + }, + description: 'The number, with coutry code, to which to send the message.', + }, + { + displayName: 'Message', + name: 'message', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'send', + ], + resource: [ + 'sms', + ], + }, + }, + description: 'The message to send', + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const returnData: IDataObject[] = []; + + for (let i = 0; i < this.getInputData().length; i++) { + const resource = this.getNodeParameter('resource', i); + if ('sms' !== resource) { + throw new Error(`The resource "${resource}" is not known!`); + } + + const operation = this.getNodeParameter('operation', i); + if ('send' !== operation) { + throw new Error(`The operation "${operation}" is not known!`); + } + + const responseData = await sms77ApiRequest.call(this, 'POST', 'sms', {}, { + from: this.getNodeParameter('from', i), + to: this.getNodeParameter('to', i), + text: this.getNodeParameter('message', i), + }); + + returnData.push(responseData); + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Sms77/sms77.png b/packages/nodes-base/nodes/Sms77/sms77.png new file mode 100644 index 0000000000..500ba005ae Binary files /dev/null and b/packages/nodes-base/nodes/Sms77/sms77.png differ diff --git a/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts b/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts new file mode 100644 index 0000000000..86f999b578 --- /dev/null +++ b/packages/nodes-base/nodes/SurveyMonkey/GenericFunctions.ts @@ -0,0 +1,82 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + IHookFunctions, + IWebhookFunctions +} from 'n8n-workflow'; + +export async function surveyMonkeyApiRequest(this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const credentials = this.getCredentials('surveyMonkeyApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const endpoint = 'https://api.surveymonkey.com/v3'; + + let options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `bearer ${credentials.accessToken}`, + }, + method, + body, + qs: query, + uri: uri || `${endpoint}${resource}`, + json: true + }; + if (!Object.keys(body).length) { + delete options.body; + } + if (!Object.keys(query).length) { + delete options.qs; + } + options = Object.assign({}, options, option); + try { + return await this.helpers.request!(options); + } catch (error) { + const errorMessage = error.response.body.error.message; + if (errorMessage !== undefined) { + throw new Error(`SurveyMonkey error response [${error.statusCode}]: ${errorMessage}`); + } + throw error; + } +} + +export async function surveyMonkeyRequestAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + query.page = 1; + query.per_page = 100; + let uri: string | undefined; + + do { + responseData = await surveyMonkeyApiRequest.call(this, method, endpoint, body, query, uri); + uri = responseData.links.next; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + responseData.links.next + ); + + return returnData; +} + +export function idsExist(ids: string[], surveyIds: string[]) { + for (const surveyId of surveyIds) { + if (!ids.includes(surveyId)) { + return false; + } + } + return true; +} diff --git a/packages/nodes-base/nodes/SurveyMonkey/Interfaces.ts b/packages/nodes-base/nodes/SurveyMonkey/Interfaces.ts new file mode 100644 index 0000000000..56c4e03a29 --- /dev/null +++ b/packages/nodes-base/nodes/SurveyMonkey/Interfaces.ts @@ -0,0 +1,47 @@ +import { + IDataObject, + } from 'n8n-workflow'; + +export interface IImage { + url: string; +} + +export interface IChoice { + position: number; + visible: boolean; + text: string; + id: string; + weight: number; + description: string; + image?: IImage; +} + +export interface IRow { + position: number; + visible: boolean; + text: string; + id: string; +} + +export interface IOther { + text: string; + visible: boolean; + is_answer_choice: boolean; + id: string; +} + +export interface IQuestion { + id: string; + family?: string; + subtype?: string; + headings?: IDataObject[]; + answers: IDataObject; + rows?: IDataObject; +} + +export interface IAnswer { + choice_id: string; + row_id?: string; + text?: string; + other_id?: string; +} diff --git a/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts b/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts new file mode 100644 index 0000000000..efdc8dba5a --- /dev/null +++ b/packages/nodes-base/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.ts @@ -0,0 +1,703 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + idsExist, + surveyMonkeyApiRequest, + surveyMonkeyRequestAllItems, +} from './GenericFunctions'; + +import { + IAnswer, + IChoice, + IQuestion, + IRow, + IOther, +} from './Interfaces'; + +import { + createHmac, +} from 'crypto'; + +export class SurveyMonkeyTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'SurveyMonkey Trigger', + name: 'surveyMonkeyTrigger', + icon: 'file:surveyMonkey.png', + group: ['trigger'], + version: 1, + description: 'Starts the workflow when Survey Monkey events occure.', + defaults: { + name: 'SurveyMonkey Trigger', + color: '#53b675', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'surveyMonkeyApi', + required: true, + }, + ], + webhooks: [ + { + name: 'setup', + httpMethod: 'HEAD', + responseMode: 'onReceived', + path: 'webhook', + }, + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Type', + name: 'objectType', + type: 'options', + options: [ + { + name: 'Collector', + value: 'collector', + }, + { + name: 'Survey', + value: 'survey', + }, + ], + default: '', + required: true, + }, + { + displayName: 'Event', + name: 'event', + displayOptions: { + show: { + objectType: [ + 'survey' + ], + }, + }, + type: 'options', + options: [ + { + name: 'Collector Created', + value: 'collector_created', + description: 'A collector is created', + }, + { + name: 'Collector Updated', + value: 'collector_updated', + description: 'A collector is updated', + }, + { + name: 'Collector Deleted', + value: 'collector_deleted', + description: 'A collector is deleted', + }, + { + name: 'Response Completed', + value: 'response_completed', + description: 'A survey response is completed', + }, + { + name: 'Response Created', + value: 'response_created', + description: 'A respondent begins a survey', + }, + { + name: 'Response Deleted', + value: 'response_deleted', + description: 'A response is deleted', + }, + { + name: 'Response Disqualified', + value: 'response_disqualified', + description: 'A survey response is disqualified ', + }, + { + name: 'Response Overquota', + value: 'response_overquota', + description: `A response is over a survey’s quota`, + }, + { + name: 'Response Updated', + value: 'response_updated', + description: 'A survey response is updated', + }, + { + name: 'Survey Created', + value: 'survey_created', + description: 'A survey is created', + }, + { + name: 'Survey Deleted', + value: 'survey_deleted', + description: 'A survey is deleted', + }, + { + name: 'Survey Updated', + value: 'survey_updated', + description: 'A survey is updated', + }, + ], + default: '', + required: true, + }, + { + displayName: 'Event', + name: 'event', + type: 'options', + displayOptions: { + show: { + objectType: [ + 'collector', + ], + }, + }, + options: [ + { + name: 'Collector Updated', + value: 'collector_updated', + description: 'A collector is updated', + }, + { + name: 'Collector Deleted', + value: 'collector_deleted', + description: 'A collector is deleted', + }, + { + name: 'Response Completed', + value: 'response_completed', + description: 'A survey response is completed', + }, + { + name: 'Response Created', + value: 'response_created', + description: 'A respondent begins a survey', + }, + { + name: 'Response Deleted', + value: 'response_deleted', + description: 'A response is deleted', + }, + { + name: 'Response Disqualified', + value: 'response_disqualified', + description: 'A survey response is disqualified ', + }, + { + name: 'Response Overquota', + value: 'response_overquota', + description: `A response is over a survey’s quota`, + }, + { + name: 'Response Updated', + value: 'response_updated', + description: 'A survey response is updated', + }, + ], + default: '', + required: true, + }, + { + displayName: 'Survey IDs', + name: 'surveyIds', + type: 'multiOptions', + displayOptions: { + show: { + objectType: [ + 'survey', + ], + }, + hide: { + event: [ + 'survey_created', + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getSurveys', + }, + options: [], + default: [], + required: true, + }, + { + displayName: 'Survey ID', + name: 'surveyId', + type: 'options', + displayOptions: { + show: { + objectType: [ + 'collector', + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getSurveys', + }, + default: [], + required: true, + }, + { + displayName: 'Collector IDs', + name: 'collectorIds', + type: 'multiOptions', + displayOptions: { + show: { + objectType: [ + 'collector', + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getCollectors', + loadOptionsDependsOn: [ + 'surveyId', + ], + }, + options: [], + default: [], + required: true, + }, + { + displayName: 'Resolve Data', + name: 'resolveData', + type: 'boolean', + displayOptions: { + show: { + event: [ + 'response_completed', + ], + }, + }, + default: true, + description: 'By default the webhook-data only contain the IDs. If this option gets activated it
will resolve the data automatically.', + }, + { + displayName: 'Only Answers', + name: 'onlyAnswers', + displayOptions: { + show: { + resolveData: [ + true, + ], + event: [ + 'response_completed', + ], + }, + }, + type: 'boolean', + default: true, + description: 'Returns only the answers of the form and not any of the other data.', + }, + ], + }; + + methods = { + loadOptions: { + // Get all the survey's collectors to display them to user so that he can + // select them easily + async getCollectors(this: ILoadOptionsFunctions): Promise { + const surveyId = this.getCurrentNodeParameter('surveyId'); + const returnData: INodePropertyOptions[] = []; + const collectors = await surveyMonkeyRequestAllItems.call(this, 'data', 'GET', `/surveys/${surveyId}/collectors`); + for (const collector of collectors) { + const collectorName = collector.name; + const collectorId = collector.id; + returnData.push({ + name: collectorName, + value: collectorId, + }); + } + return returnData; + }, + + // Get all the surveys to display them to user so that he can + // select them easily + async getSurveys(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const surveys = await surveyMonkeyRequestAllItems.call(this, 'data', 'GET', '/surveys'); + for (const survey of surveys) { + const surveyName = survey.title; + const surveyId = survey.id; + returnData.push({ + name: surveyName, + value: surveyId, + }); + } + return returnData; + }, + }, + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const objectType = this.getNodeParameter('objectType') as string; + const event = this.getNodeParameter('event') as string; + // Check all the webhooks which exist already if it is identical to the + // one that is supposed to get created. + const endpoint = '/webhooks'; + + const responseData = await surveyMonkeyRequestAllItems.call(this, 'data', 'GET', endpoint, {}); + + const webhookUrl = this.getNodeWebhookUrl('default'); + + const ids: string[] = []; + + if (objectType === 'survey' && event !== 'survey_created') { + const surveyIds = this.getNodeParameter('surveyIds') as string[]; + ids.push.apply(ids, surveyIds); + } else if (objectType === 'collector') { + const collectorIds = this.getNodeParameter('collectorIds') as string[]; + ids.push.apply(ids, collectorIds); + } + + for (const webhook of responseData) { + const webhookDetails = await surveyMonkeyApiRequest.call(this, 'GET', `/webhooks/${webhook.id}`); + if (webhookDetails.subscription_url === webhookUrl + && idsExist(webhookDetails.object_ids as string[], ids as string[]) + && webhookDetails.event_type === event) { + // Set webhook-id to be sure that it can be deleted + const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = webhook.id as string; + return true; + } + } + + return false; + }, + + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + const event = this.getNodeParameter('event') as string; + const objectType = this.getNodeParameter('objectType') as string; + const endpoint = '/webhooks'; + const ids: string[] = []; + + if (objectType === 'survey' && event !== 'survey_created') { + const surveyIds = this.getNodeParameter('surveyIds') as string[]; + ids.push.apply(ids, surveyIds); + } else if (objectType === 'collector') { + const collectorIds = this.getNodeParameter('collectorIds') as string[]; + ids.push.apply(ids, collectorIds); + } + + const body: IDataObject = { + name: `n8n - Webhook [${event}]`, + object_type: objectType, + object_ids: ids, + subscription_url: webhookUrl, + event_type: event, + }; + + if (objectType === 'survey' && event === 'survey_created') { + delete body.object_type; + delete body.object_ids; + } + + let responseData: IDataObject = {}; + + responseData = await surveyMonkeyApiRequest.call(this, 'POST', endpoint, body); + + if (responseData.id === undefined) { + // Required data is missing so was not successful + return false; + } + + const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = responseData.id as string; + + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId !== undefined) { + + const endpoint = `/webhooks/${webhookData.webhookId}`; + + try { + await surveyMonkeyApiRequest.call(this, 'DELETE', endpoint); + } catch (e) { + return false; + } + + // Remove from the static workflow data so that it is clear + // that no webhooks are registred anymore + delete webhookData.webhookId; + } + + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const event = this.getNodeParameter('event') as string; + const objectType = this.getNodeParameter('objectType') as string; + const credentials = this.getCredentials('surveyMonkeyApi') as IDataObject; + const headerData = this.getHeaderData() as IDataObject; + const req = this.getRequestObject(); + const webhookName = this.getWebhookName(); + + if (webhookName === 'setup') { + // It is a create webhook confirmation request + return {}; + } + + if (headerData['sm-signature'] === undefined) { + return {}; + } + + return new Promise((resolve, reject) => { + const data: Buffer[] = []; + + req.on('data', (chunk) => { + data.push(chunk); + }); + + req.on('end', async () => { + const computedSignature = createHmac('sha1', `${credentials.clientId}&${credentials.clientSecret}`).update(data.join('')).digest('base64'); + if (headerData['sm-signature'] !== computedSignature) { + // Signature is not valid so ignore call + return {}; + } + + let responseData = JSON.parse(data.join('')); + let endpoint = ''; + + let returnItem: INodeExecutionData[] = [ + { + json: responseData, + } + ]; + + if (event === 'response_completed') { + const resolveData = this.getNodeParameter('resolveData') as boolean; + if (resolveData) { + if (objectType === 'survey') { + endpoint = `/surveys/${responseData.resources.survey_id}/responses/${responseData.object_id}/details`; + } else { + endpoint = `/collectors/${responseData.resources.collector_id}/responses/${responseData.object_id}/details`; + } + responseData = await surveyMonkeyApiRequest.call(this, 'GET', endpoint); + const surveyId = responseData.survey_id; + + const questions: IQuestion[] = []; + const answers = new Map(); + + const { pages } = await surveyMonkeyApiRequest.call(this, 'GET', `/surveys/${surveyId}/details`); + + for (const page of pages) { + questions.push.apply(questions, page.questions); + } + + for (const page of responseData.pages as IDataObject[]) { + for (const question of page.questions as IDataObject[]) { + answers.set(question.id as string, question.answers as IAnswer[]); + } + } + + const responseQuestions = new Map(); + + for (const question of questions) { + + /* + TODO: add support for premium components + - File Upload + - Matrix of dropdowm menus + */ + + // if question does not have an answer ignore it + if (!answers.get(question.id)) { + continue; + } + + const heading = question.headings![0].heading as string; + + if (question.family === 'open_ended' || question.family === 'datetime') { + if (question.subtype !== 'multi') { + responseQuestions.set(heading, answers.get(question.id)![0].text as string); + } else { + + const results: IDataObject = {}; + const keys = (question.answers.rows as IRow[]).map(e => e.text) as string[]; + const values = answers.get(question.id)?.map(e => e.text) as string[]; + for (let i = 0; i < keys.length; i++) { + // if for some reason there are questions texts repeted add the index to the key + if (results[keys[i]] !== undefined) { + results[`${keys[i]}(${i})`] = values[i] || ''; + } else { + results[keys[i]] = values[i] || ''; + } + } + responseQuestions.set(heading, results); + } + } + + if (question.family === 'single_choice') { + const other = question.answers.other as IOther; + if (other && other.visible && other.is_answer_choice && answers.get(question.id)![0].other_id) { + responseQuestions.set(heading, answers.get(question.id)![0].text as string); + + } else if (other && other.visible && !other.is_answer_choice){ + const choiceId = answers.get(question.id)![0].choice_id; + + const choice = (question.answers.choices as IChoice[]) + .filter(e => e.id === choiceId)[0]; + + const comment = answers.get(question.id) + ?.find(e => e.other_id === other.id)?.text as string; + responseQuestions.set(heading, { value: choice.text, comment }); + + } else { + const choiceId = answers.get(question.id)![0].choice_id; + const choice = (question.answers.choices as IChoice[]) + .filter(e => e.id === choiceId)[0]; + responseQuestions.set(heading, choice.text); + } + } + + if (question.family === 'multiple_choice') { + const other = question.answers.other as IOther; + const choiceIds = answers.get(question.id)?.map((e) => e.choice_id); + const value = (question.answers.choices as IChoice[]) + .filter(e => choiceIds?.includes(e.id)) + .map(e => e.text) as string[]; + // if "Add an "Other" Answer Option for Comments" is active and was selected + if (other && other.is_answer_choice && other.visible) { + const text = answers.get(question.id) + ?.find(e => e.other_id === other.id)?.text as string; + value.push(text); + } + responseQuestions.set(heading, value); + } + + if (question.family === 'matrix') { + // if more than one row it's a matrix/rating-scale + const rows = question.answers.rows as IRow[]; + + if (rows.length > 1) { + + const results: IDataObject = {}; + const choiceIds = answers.get(question.id)?.map(e => e.choice_id) as string[]; + const rowIds = answers.get(question.id)?.map(e => e.row_id) as string[]; + + const rowsValues = (question.answers.rows as IRow[]) + .filter(e => rowIds!.includes(e.id as string)) + .map(e => e.text); + + const choicesValues = (question.answers.choices as IChoice[]) + .filter(e => choiceIds!.includes(e.id as string)) + .map(e => e.text); + + for (let i = 0; i < rowsValues.length; i++) { + results[rowsValues[i]] = choicesValues[i] || ''; + } + + // add the rows that were not answered + for (const row of question.answers.rows as IDataObject[]) { + if (!rowIds.includes(row.id as string)) { + results[row.text as string] = ''; + } + } + // the comment then add the comment + const other = question.answers.other as IOther; + if (other !== undefined && other.visible) { + results.comment = answers.get(question.id)?.filter((e) => e.other_id)[0].text; + } + + responseQuestions.set(heading, results); + + } else { + const choiceIds = answers.get(question.id)?.map((e) => e.choice_id); + const value = (question.answers.choices as IChoice[]) + .filter(e => choiceIds!.includes(e.id as string)) + .map(e => (e.text === '') ? e.weight : e.text)[0]; + responseQuestions.set(heading, value); + + // if "Add an Other Answer Option for Comments" is active then add comment to the answer + const other = question.answers.other as IOther; + if (other !== undefined && other.visible) { + const response: IDataObject = {}; + //const questionName = (question.answers.other as IOther).text as string; + const text = answers.get(question.id)?.filter((e) => e.other_id)[0].text; + response.value = value; + response.comment = text; + responseQuestions.set(heading, response); + } + } + } + + if (question.family === 'demographic') { + const rows: IDataObject = {}; + for (const row of answers.get(question.id) as IAnswer[]) { + rows[row.row_id as string] = row.text; + } + const addressInfo: IDataObject = {}; + for (const answer of question.answers.rows as IDataObject[]) { + addressInfo[answer.type as string] = rows[answer.id as string] || ''; + } + responseQuestions.set(heading, addressInfo); + } + + if (question.family === 'presentation') { + if (question.subtype === 'image') { + const { url } = question.headings![0].image as IDataObject; + responseQuestions.set(heading, url as string); + } + } + } + delete responseData.pages; + responseData.questions = {}; + + // Map the "Map" to JSON + const tuples = JSON.parse(JSON.stringify([...responseQuestions])); + for (const [key, value] of tuples) { + responseData.questions[key] = value; + } + + const onlyAnswers = this.getNodeParameter('onlyAnswers') as boolean; + if (onlyAnswers) { + responseData = responseData.questions; + } + + returnItem = [ + { + json: responseData, + } + ]; + } + } + + return resolve({ + workflowData: [ + returnItem, + ], + }); + }); + + req.on('error', (err) => { + throw new Error(err.message); + }); + }); + } +} diff --git a/packages/nodes-base/nodes/SurveyMonkey/surveyMonkey.png b/packages/nodes-base/nodes/SurveyMonkey/surveyMonkey.png new file mode 100644 index 0000000000..e5337accd6 Binary files /dev/null and b/packages/nodes-base/nodes/SurveyMonkey/surveyMonkey.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index fcdbde4675..2bb3628c13 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.60.0", + "version": "0.61.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -48,6 +48,7 @@ "dist/credentials/DriftApi.credentials.js", "dist/credentials/DropboxApi.credentials.js", "dist/credentials/EventbriteApi.credentials.js", + "dist/credentials/FacebookGraphApi.credentials.js", "dist/credentials/FreshdeskApi.credentials.js", "dist/credentials/FileMaker.credentials.js", "dist/credentials/FlowApi.credentials.js", @@ -92,10 +93,12 @@ "dist/credentials/RundeckApi.credentials.js", "dist/credentials/ShopifyApi.credentials.js", "dist/credentials/SlackApi.credentials.js", + "dist/credentials/Sms77Api.credentials.js", "dist/credentials/Smtp.credentials.js", "dist/credentials/StripeApi.credentials.js", "dist/credentials/SalesmateApi.credentials.js", "dist/credentials/SegmentApi.credentials.js", + "dist/credentials/SurveyMonkeyApi.credentials.js", "dist/credentials/TelegramApi.credentials.js", "dist/credentials/TodoistApi.credentials.js", "dist/credentials/TrelloApi.credentials.js", @@ -153,6 +156,7 @@ "dist/nodes/Eventbrite/EventbriteTrigger.node.js", "dist/nodes/ExecuteCommand.node.js", "dist/nodes/ExecuteWorkflow.node.js", + "dist/nodes/Facebook/FacebookGraphApi.node.js", "dist/nodes/FileMaker/FileMaker.node.js", "dist/nodes/Freshdesk/Freshdesk.node.js", "dist/nodes/Flow/Flow.node.js", @@ -218,6 +222,7 @@ "dist/nodes/Shopify/Shopify.node.js", "dist/nodes/Shopify/ShopifyTrigger.node.js", "dist/nodes/Slack/Slack.node.js", + "dist/nodes/Sms77/Sms77.node.js", "dist/nodes/SplitInBatches.node.js", "dist/nodes/SpreadsheetFile.node.js", "dist/nodes/SseTrigger.node.js", @@ -226,6 +231,7 @@ "dist/nodes/Switch.node.js", "dist/nodes/Salesmate/Salesmate.node.js", "dist/nodes/Segment/Segment.node.js", + "dist/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.js", "dist/nodes/Telegram/Telegram.node.js", "dist/nodes/Telegram/TelegramTrigger.node.js", "dist/nodes/Todoist/Todoist.node.js", @@ -261,7 +267,7 @@ "@types/jest": "^24.0.18", "@types/lodash.set": "^4.3.6", "@types/moment-timezone": "^0.5.12", - "@types/mongodb": "^3.3.6", + "@types/mongodb": "^3.5.4", "@types/node": "^10.10.1", "@types/nodemailer": "^4.6.5", "@types/redis": "^2.8.11", @@ -270,7 +276,7 @@ "@types/xml2js": "^0.4.3", "gulp": "^4.0.0", "jest": "^24.9.0", - "n8n-workflow": "~0.29.0", + "n8n-workflow": "~0.30.0", "ts-jest": "^24.0.2", "tslint": "^5.17.0", "typescript": "~3.7.4" @@ -290,9 +296,11 @@ "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", "lodash.unset": "^4.5.2", - "mongodb": "^3.3.2", + "moment": "2.24.0", + "moment-timezone": "^0.5.28", + "mongodb": "^3.5.5", "mysql2": "^2.0.1", - "n8n-core": "~0.32.0", + "n8n-core": "~0.33.0", "nodemailer": "^5.1.1", "pdf-parse": "^1.1.1", "pg-promise": "^9.0.3", diff --git a/packages/workflow/LICENSE.md b/packages/workflow/LICENSE.md index b3aadc2a0f..aac54547eb 100644 --- a/packages/workflow/LICENSE.md +++ b/packages/workflow/LICENSE.md @@ -21,7 +21,7 @@ Software: n8n License: Apache 2.0 -Licensor: Jan Oberhauser +Licensor: n8n GmbH --------------------------------------------------------------------- diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 7e90bfb26f..72af56092d 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "0.29.0", + "version": "0.30.0", "description": "Workflow base code of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index ad64dc160b..6a1500e0d7 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -548,7 +548,7 @@ export interface IWorkflowMetadata { active: boolean; } -export type WebhookHttpMethod = 'GET' | 'POST'; +export type WebhookHttpMethod = 'GET' | 'POST' | 'HEAD'; export interface IWebhookResponseData { workflowData?: INodeExecutionData[][]; diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 76d8f8b55f..08e9660b99 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -728,12 +728,6 @@ export function getNodeWebhooks(workflow: Workflow, node: INode, additionalData: return []; } - if (workflow.id === undefined) { - // Workflow has no id which means it is not saved and so webhooks - // will not be enabled - return []; - } - const nodeType = workflow.nodeTypes.getByName(node.type) as INodeType; if (nodeType.description.webhooks === undefined) { @@ -741,12 +735,14 @@ export function getNodeWebhooks(workflow: Workflow, node: INode, additionalData: return []; } + const workflowId = workflow.id || '__UNSAVED__'; + const returnData: IWebhookData[] = []; for (const webhookDescription of nodeType.description.webhooks) { let nodeWebhookPath = workflow.getSimpleParameterValue(node, webhookDescription['path'], 'GET'); if (nodeWebhookPath === undefined) { // TODO: Use a proper logger - console.error(`No webhook path could be found for node "${node.name}" in workflow "${workflow.id}".`); + console.error(`No webhook path could be found for node "${node.name}" in workflow "${workflowId}".`); continue; } @@ -756,13 +752,13 @@ export function getNodeWebhooks(workflow: Workflow, node: INode, additionalData: nodeWebhookPath = nodeWebhookPath.slice(1); } - const path = getNodeWebhookPath(workflow.id, node, nodeWebhookPath); + const path = getNodeWebhookPath(workflowId, node, nodeWebhookPath); const httpMethod = workflow.getSimpleParameterValue(node, webhookDescription['httpMethod'], 'GET'); if (httpMethod === undefined) { // TODO: Use a proper logger - console.error(`The webhook "${path}" for node "${node.name}" in workflow "${workflow.id}" could not be added because the httpMethod is not defined.`); + console.error(`The webhook "${path}" for node "${node.name}" in workflow "${workflowId}" could not be added because the httpMethod is not defined.`); continue; } @@ -771,7 +767,7 @@ export function getNodeWebhooks(workflow: Workflow, node: INode, additionalData: node: node.name, path, webhookDescription, - workflowId: workflow.id, + workflowId, workflowExecuteAdditionalData: additionalData, }); }