diff --git a/.gitignore b/.gitignore index b3eac3920..0441d445b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ _START_PACKAGE .env .vscode .idea +.prettierrc.js diff --git a/docker/compose/subfolderWithSSL/.env b/docker/compose/subfolderWithSSL/.env new file mode 100644 index 000000000..7008bd631 --- /dev/null +++ b/docker/compose/subfolderWithSSL/.env @@ -0,0 +1,25 @@ +# Folder where data should be saved +DATA_FOLDER=/root/n8n/ + +# The top level domain to serve from +DOMAIN_NAME=example.com + +# The subfolder to serve from +SUBFOLDER=app1 +N8N_PATH=/app1/ + +# DOMAIN_NAME and SUBDOMAIN combined decide where n8n will be reachable from +# above example would result in: https://example.com/n8n/ + +# The user name to use for autentication - IMPORTANT ALWAYS CHANGE! +N8N_BASIC_AUTH_USER=user + +# The password to use for autentication - IMPORTANT ALWAYS CHANGE! +N8N_BASIC_AUTH_PASSWORD=password + +# Optional timezone to set which gets used by Cron-Node by default +# If not set New York time will be used +GENERIC_TIMEZONE=Europe/Berlin + +# The email address to use for the SSL certificate creation +SSL_EMAIL=user@example.com diff --git a/docker/compose/subfolderWithSSL/README.md b/docker/compose/subfolderWithSSL/README.md new file mode 100644 index 000000000..61fcb5b7e --- /dev/null +++ b/docker/compose/subfolderWithSSL/README.md @@ -0,0 +1,26 @@ +# n8n on Subfolder with SSL + +Starts n8n and deployes it on a subfolder + + +## Start + +To start n8n in a subfolder simply start docker-compose by executing the following +command in the current folder. + + +**IMPORTANT:** But before you do that change the default users and passwords in the `.env` file! + +``` +docker-compose up -d +``` + +To stop it execute: + +``` +docker-compose stop +``` + +## Configuration + +The default name of the database, user and password for MongoDB can be changed in the `.env` file in the current directory. diff --git a/docker/compose/subfolderWithSSL/docker-compose.yml b/docker/compose/subfolderWithSSL/docker-compose.yml new file mode 100644 index 000000000..5e540abbb --- /dev/null +++ b/docker/compose/subfolderWithSSL/docker-compose.yml @@ -0,0 +1,57 @@ +version: "3" + +services: + traefik: + image: "traefik" + command: + - "--api=true" + - "--api.insecure=true" + - "--api.dashboard=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.websecure.address=:443" + - "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" + - "--certificatesresolvers.mytlschallenge.acme.email=${SSL_EMAIL}" + - "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json" + - /home/jan/www/n8n/n8n:/data + ports: + - "443:443" + - "80:80" + volumes: + - ${DATA_FOLDER}/letsencrypt:/letsencrypt + - /var/run/docker.sock:/var/run/docker.sock:ro + n8n: + image: n8nio/n8n + ports: + - "127.0.0.1:5678:5678" + labels: + - traefik.enable=true + - traefik.http.routers.n8n.rule=Host(`${DOMAIN_NAME}`) + - traefik.http.routers.n8n.tls=true + - traefik.http.routers.n8n.entrypoints=websecure + - "traefik.http.routers.n8n.rule=PathPrefix(`/${SUBFOLDER}{regex:$$|/.*}`)" + - "traefik.http.middlewares.n8n-stripprefix.stripprefix.prefixes=/${SUBFOLDER}" + - "traefik.http.routers.n8n.middlewares=n8n-stripprefix" + - traefik.http.routers.n8n.tls.certresolver=mytlschallenge + - traefik.http.middlewares.n8n.headers.SSLRedirect=true + - traefik.http.middlewares.n8n.headers.STSSeconds=315360000 + - traefik.http.middlewares.n8n.headers.browserXSSFilter=true + - traefik.http.middlewares.n8n.headers.contentTypeNosniff=true + - traefik.http.middlewares.n8n.headers.forceSTSHeader=true + - traefik.http.middlewares.n8n.headers.SSLHost=${DOMAIN_NAME} + - traefik.http.middlewares.n8n.headers.STSIncludeSubdomains=true + - traefik.http.middlewares.n8n.headers.STSPreload=true + environment: + - N8N_BASIC_AUTH_ACTIVE=true + - N8N_BASIC_AUTH_USER + - N8N_BASIC_AUTH_PASSWORD + - N8N_HOST=${DOMAIN_NAME} + - N8N_PORT=5678 + - N8N_PROTOCOL=https + - NODE_ENV=production + - N8N_PATH + - WEBHOOK_TUNNEL_URL=http://${DOMAIN_NAME}${N8N_PATH} + - VUE_APP_URL_BASE_API=http://${DOMAIN_NAME}${N8N_PATH} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${DATA_FOLDER}/.n8n:/root/.n8n diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md index 7170dda59..977c53fef 100644 --- a/docker/images/n8n/README.md +++ b/docker/images/n8n/README.md @@ -6,25 +6,23 @@ n8n is a free and open [fair-code](http://faircode.io) licensed node based Workf n8n.io - Screenshot - ## Contents -- [Demo](#demo) -- [Available integrations](#available-integrations) -- [Documentation](#documentation) -- [Start n8n in Docker](#start-n8n-in-docker) -- [Start with tunnel](#start-with-tunnel) -- [Securing n8n](#securing-n8n) -- [Persist data](#persist-data) -- [Passing Sensitive Data via File](#passing-sensitive-data-via-file) -- [Updating a Running docker-compose Instance](#updating-a-running-docker-compose-instance) -- [Example Setup with Lets Encrypt](#example-setup-with-lets-encrypt) -- [What does n8n mean and how do you pronounce it](#what-does-n8n-mean-and-how-do-you-pronounce-it) -- [Support](#support) -- [Jobs](#jobs) -- [Upgrading](#upgrading) -- [License](#license) - + - [Demo](#demo) + - [Available integrations](#available-integrations) + - [Documentation](#documentation) + - [Start n8n in Docker](#start-n8n-in-docker) + - [Start with tunnel](#start-with-tunnel) + - [Securing n8n](#securing-n8n) + - [Persist data](#persist-data) + - [Passing Sensitive Data via File](#passing-sensitive-data-via-file) + - [Updating a Running docker-compose Instance](#updating-a-running-docker-compose-instance) + - [Example Setup with Lets Encrypt](#example-setup-with-lets-encrypt) + - [What does n8n mean and how do you pronounce it](#what-does-n8n-mean-and-how-do-you-pronounce-it) + - [Support](#support) + - [Jobs](#jobs) + - [Upgrading](#upgrading) + - [License](#license) ## Demo @@ -49,9 +47,9 @@ Additional information and example workflows on the n8n.io website: [https://n8n ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - n8nio/n8n + --name n8n \ + -p 5678:5678 \ + n8nio/n8n ``` You can then access n8n by opening: @@ -71,14 +69,13 @@ To use it simply start n8n with `--tunnel` ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n \ - n8n start --tunnel + --name n8n \ + -p 5678:5678 \ + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start --tunnel ``` - ## Securing n8n By default n8n can be accessed by everybody. This is OK if you have it only running @@ -93,7 +90,6 @@ N8N_BASIC_AUTH_USER= N8N_BASIC_AUTH_PASSWORD= ``` - ## Persist data The workflow data gets by default saved in an SQLite database in the user @@ -102,10 +98,10 @@ settings like webhook URL and encryption key. ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n + --name n8n \ + -p 5678:5678 \ + -v ~/.n8n:/root/.n8n \ + n8nio/n8n ``` ### Start with other Database @@ -121,7 +117,6 @@ for the credentials. If none gets found n8n creates automatically one on startup. In case credentials are already saved with a different encryption key it can not be used anymore as encrypting it is not possible anymore. - #### Use with MongoDB > **WARNING**: Use Postgres if possible! Mongo has problems with saving large @@ -129,40 +124,39 @@ it can not be used anymore as encrypting it is not possible anymore. > may be dropped in the future. Replace the following placeholders with the actual data: - - - - - - - - - - + - MONGO_DATABASE + - MONGO_HOST + - MONGO_PORT + - MONGO_USER + - MONGO_PASSWORD ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ + --name n8n \ + -p 5678:5678 \ -e DB_TYPE=mongodb \ -e DB_MONGODB_CONNECTION_URL="mongodb://:@:/" \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n \ - n8n start + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start ``` A full working setup with docker-compose can be found [here](https://github.com/n8n-io/n8n/blob/master/docker/compose/withMongo/README.md) - #### Use with PostgresDB Replace the following placeholders with the actual data: - - - - - - - - - - - - + - POSTGRES_DATABASE + - POSTGRES_HOST + - POSTGRES_PASSWORD + - POSTGRES_PORT + - POSTGRES_USER + - POSTGRES_SCHEMA ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ + --name n8n \ + -p 5678:5678 \ -e DB_TYPE=postgresdb \ -e DB_POSTGRESDB_DATABASE= \ -e DB_POSTGRESDB_HOST= \ @@ -170,39 +164,37 @@ docker run -it --rm \ -e DB_POSTGRESDB_USER= \ -e DB_POSTGRESDB_SCHEMA= \ -e DB_POSTGRESDB_PASSWORD= \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n \ - n8n start + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start ``` A full working setup with docker-compose can be found [here](https://github.com/n8n-io/n8n/blob/master/docker/compose/withPostgres/README.md) - #### Use with MySQL Replace the following placeholders with the actual data: - - - - - - - - - - + - MYSQLDB_DATABASE + - MYSQLDB_HOST + - MYSQLDB_PASSWORD + - MYSQLDB_PORT + - MYSQLDB_USER ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ + --name n8n \ + -p 5678:5678 \ -e DB_TYPE=mysqldb \ -e DB_MYSQLDB_DATABASE= \ -e DB_MYSQLDB_HOST= \ -e DB_MYSQLDB_PORT= \ -e DB_MYSQLDB_USER= \ -e DB_MYSQLDB_PASSWORD= \ - -v ~/.n8n:/root/.n8n \ - n8nio/n8n \ - n8n start + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start ``` - ## Passing Sensitive Data via File To avoid passing sensitive information via environment variables "_FILE" may be @@ -211,16 +203,15 @@ with the given name. That makes it possible to load data easily from Docker- and Kubernetes-Secrets. The following environment variables support file input: - - DB_MONGODB_CONNECTION_URL_FILE - - DB_POSTGRESDB_DATABASE_FILE - - DB_POSTGRESDB_HOST_FILE - - DB_POSTGRESDB_PASSWORD_FILE - - DB_POSTGRESDB_PORT_FILE - - DB_POSTGRESDB_USER_FILE - - DB_POSTGRESDB_SCHEMA_FILE - - N8N_BASIC_AUTH_PASSWORD_FILE - - N8N_BASIC_AUTH_USER_FILE - + - DB_MONGODB_CONNECTION_URL_FILE + - DB_POSTGRESDB_DATABASE_FILE + - DB_POSTGRESDB_HOST_FILE + - DB_POSTGRESDB_PASSWORD_FILE + - DB_POSTGRESDB_PORT_FILE + - DB_POSTGRESDB_USER_FILE + - DB_POSTGRESDB_SCHEMA_FILE + - N8N_BASIC_AUTH_PASSWORD_FILE + - N8N_BASIC_AUTH_USER_FILE ## Example Setup with Lets Encrypt @@ -235,7 +226,7 @@ docker pull n8nio/n8n # Stop current setup sudo docker-compose stop # Delete it (will only delete the docker-containers, data is stored separately) -sudo docker-compose rm +sudo docker-compose rm # Then start it again sudo docker-compose up -d ``` @@ -251,11 +242,11 @@ the environment variable `TZ`. Example to use the same timezone for both: ``` docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ + --name n8n \ + -p 5678:5678 \ -e GENERIC_TIMEZONE="Europe/Berlin" \ -e TZ="Europe/Berlin" \ - n8nio/n8n + n8nio/n8n ``` diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 0022c08b7..239061746 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -225,6 +225,13 @@ const config = convict({ }, // How n8n can be reached (Editor & REST-API) + path: { + format: String, + default: '/', + arg: 'path', + env: 'N8N_PATH', + doc: 'Path n8n is deployed to' + }, host: { format: String, default: 'localhost', diff --git a/packages/cli/package.json b/packages/cli/package.json index 0bda408ec..e9b16ad9e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.73.0", + "version": "0.73.1", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -102,7 +102,7 @@ "mysql2": "^2.0.1", "n8n-core": "~0.38.0", "n8n-editor-ui": "~0.49.0", - "n8n-nodes-base": "~0.68.0", + "n8n-nodes-base": "~0.68.1", "n8n-workflow": "~0.34.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", diff --git a/packages/cli/src/GenericHelpers.ts b/packages/cli/src/GenericHelpers.ts index 8b02b73e8..cab67f7bc 100644 --- a/packages/cli/src/GenericHelpers.ts +++ b/packages/cli/src/GenericHelpers.ts @@ -40,11 +40,12 @@ export function getBaseUrl(): string { const protocol = config.get('protocol') as string; const host = config.get('host') as string; const port = config.get('port') as number; + const path = config.get('path') as string; if (protocol === 'http' && port === 80 || protocol === 'https' && port === 443) { - return `${protocol}://${host}/`; + return `${protocol}://${host}${path}`; } - return `${protocol}://${host}:${port}/`; + return `${protocol}://${host}:${port}${path}`; } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 2507b6b13..1a0b75842 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -931,7 +931,8 @@ class App { // Authorize OAuth Data this.app.get(`/${this.restEndpoint}/oauth1-credential/auth`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { if (req.query.id === undefined) { - throw new Error('Required credential id is missing!'); + res.status(500).send('Required credential id is missing!'); + return ''; } const result = await Db.collections.Credentials!.findOne(req.query.id as string); @@ -943,7 +944,8 @@ class App { let encryptionKey = undefined; encryptionKey = await UserSettings.getEncryptionKey(); if (encryptionKey === undefined) { - throw new Error('No encryption key got found to decrypt the credentials!'); + res.status(500).send('No encryption key got found to decrypt the credentials!'); + return ''; } // Decrypt the currently saved credentials @@ -1015,7 +1017,8 @@ class App { const { oauth_verifier, oauth_token, cid } = req.query; if (oauth_verifier === undefined || oauth_token === undefined) { - throw new Error('Insufficient parameters for OAuth1 callback'); + const errorResponse = new ResponseHelper.ResponseError('Insufficient parameters for OAuth1 callback. Received following query parameters: ' + JSON.stringify(req.query), undefined, 503); + return ResponseHelper.sendErrorResponse(res, errorResponse); } const result = await Db.collections.Credentials!.findOne(cid as any); // tslint:disable-line:no-any @@ -1085,7 +1088,8 @@ class App { // Authorize OAuth Data this.app.get(`/${this.restEndpoint}/oauth2-credential/auth`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { if (req.query.id === undefined) { - throw new Error('Required credential id is missing!'); + res.status(500).send('Required credential id is missing.'); + return ''; } const result = await Db.collections.Credentials!.findOne(req.query.id as string); @@ -1097,7 +1101,8 @@ class App { let encryptionKey = undefined; encryptionKey = await UserSettings.getEncryptionKey(); if (encryptionKey === undefined) { - throw new Error('No encryption key got found to decrypt the credentials!'); + res.status(500).send('No encryption key got found to decrypt the credentials!'); + return ''; } // Decrypt the currently saved credentials @@ -1161,7 +1166,8 @@ class App { const {code, state: stateEncoded } = req.query; if (code === undefined || stateEncoded === undefined) { - throw new Error('Insufficient parameters for OAuth2 callback'); + const errorResponse = new ResponseHelper.ResponseError('Insufficient parameters for OAuth2 callback. Received following query parameters: ' + JSON.stringify(req.query), undefined, 503); + return ResponseHelper.sendErrorResponse(res, errorResponse); } let state; @@ -1211,17 +1217,20 @@ class App { }, }; } + const redirectUri = `${WebhookHelpers.getWebhookBaseUrl()}${this.restEndpoint}/oauth2-credential/callback`; const oAuthObj = new clientOAuth2({ clientId: _.get(oauthCredentials, 'clientId') as string, clientSecret: _.get(oauthCredentials, 'clientSecret', '') as string, accessTokenUri: _.get(oauthCredentials, 'accessTokenUrl', '') as string, authorizationUri: _.get(oauthCredentials, 'authUrl', '') as string, - redirectUri: `${WebhookHelpers.getWebhookBaseUrl()}${this.restEndpoint}/oauth2-credential/callback`, + redirectUri, scopes: _.split(_.get(oauthCredentials, 'scope', 'openid,') as string, ',') }); - const oauthToken = await oAuthObj.code.getToken(req.originalUrl, options); + const queryParameters = req.originalUrl.split('?').splice(1, 1).join(''); + + const oauthToken = await oAuthObj.code.getToken(`${redirectUri}?${queryParameters}`, options); if (oauthToken === undefined) { const errorResponse = new ResponseHelper.ResponseError('Unable to get access tokens!', undefined, 404); @@ -1693,9 +1702,21 @@ class App { }); } + + // Read the index file and replace the path placeholder + const editorUiPath = require.resolve('n8n-editor-ui'); + const filePath = pathJoin(pathDirname(editorUiPath), 'dist', 'index.html'); + let readIndexFile = readFileSync(filePath, 'utf8'); + const n8nPath = config.get('path'); + readIndexFile = readIndexFile.replace(/\/%BASE_PATH%\//g, n8nPath); + + // Serve the altered index.html file separately + this.app.get(`/index.html`, async (req: express.Request, res: express.Response) => { + res.send(readIndexFile); + }); + // Serve the website const startTime = (new Date()).toUTCString(); - const editorUiPath = require.resolve('n8n-editor-ui'); this.app.use('/', express.static(pathJoin(pathDirname(editorUiPath), 'dist'), { index: 'index.html', setHeaders: (res, path) => { diff --git a/packages/editor-ui/public/index.html b/packages/editor-ui/public/index.html index 2f2450023..9193fda97 100644 --- a/packages/editor-ui/public/index.html +++ b/packages/editor-ui/public/index.html @@ -4,7 +4,8 @@ - + + n8n.io - Workflow Automation diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index 71388c2cd..a9cf787ef 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -16,7 +16,7 @@ @@ -208,6 +208,8 @@ export default mixins( data () { return { aboutDialogVisible: false, + // @ts-ignore + basePath: window.BASE_PATH, isCollapsed: true, credentialNewDialogVisible: false, credentialOpenDialogVisible: false, diff --git a/packages/editor-ui/src/components/RunData.vue b/packages/editor-ui/src/components/RunData.vue index b3729e4f6..42fbaa5b1 100644 --- a/packages/editor-ui/src/components/RunData.vue +++ b/packages/editor-ui/src/components/RunData.vue @@ -19,7 +19,7 @@
- + Results: {{ dataCount }} Results: @@ -248,7 +248,11 @@ export default mixins( return executionData.resultData.runData; }, maxDisplayItemsOptions (): number[] { - return [25, 50, 100, 250, 500, 1000, this.dataCount].filter(option => option <= this.dataCount); + const options = [25, 50, 100, 250, 500, 1000].filter(option => option <= this.dataCount); + if (!options.includes(this.dataCount)) { + options.push(this.dataCount); + } + return options; }, node (): INodeUi | null { return this.$store.getters.activeNode; diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index e82b30b58..4754098c9 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -8,7 +8,8 @@ Vue.use(Router); export default new Router({ mode: 'history', - base: process.env.BASE_URL, + // @ts-ignore + base: window.BASE_PATH, routes: [ { path: '/execution/:id', diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index 80454fc9e..0e1e5f14c 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -38,7 +38,8 @@ export const store = new Vuex.Store({ activeWorkflows: [] as string[], activeActions: [] as string[], activeNode: null as string | null, - baseUrl: process.env.VUE_APP_URL_BASE_API ? process.env.VUE_APP_URL_BASE_API : '/', + // @ts-ignore + baseUrl: window.BASE_PATH ? window.BASE_PATH : '/', credentials: null as ICredentialsResponse[] | null, credentialTypes: null as ICredentialType[] | null, endpointWebhook: 'webhook', diff --git a/packages/editor-ui/vue.config.js b/packages/editor-ui/vue.config.js index cdcd8259f..c5ffc5fed 100644 --- a/packages/editor-ui/vue.config.js +++ b/packages/editor-ui/vue.config.js @@ -29,5 +29,5 @@ module.exports = { }, }, }, - publicPath: process.env.VUE_APP_PUBLIC_PATH ? process.env.VUE_APP_PUBLIC_PATH : '/', + publicPath: process.env.VUE_APP_PUBLIC_PATH ? process.env.VUE_APP_PUBLIC_PATH : '/%BASE_PATH%/', }; diff --git a/packages/nodes-base/credentials/QuestDb.credentials.ts b/packages/nodes-base/credentials/QuestDb.credentials.ts new file mode 100644 index 000000000..24c152273 --- /dev/null +++ b/packages/nodes-base/credentials/QuestDb.credentials.ts @@ -0,0 +1,69 @@ +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; + +export class QuestDb implements ICredentialType { + name = 'questDb'; + displayName = 'QuestDB'; + properties = [ + { + displayName: 'Host', + name: 'host', + type: 'string' as NodePropertyTypes, + default: 'localhost', + }, + { + displayName: 'Database', + name: 'database', + type: 'string' as NodePropertyTypes, + default: 'qdb', + }, + { + displayName: 'User', + name: 'user', + type: 'string' as NodePropertyTypes, + default: 'admin', + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true, + }, + default: 'quest', + }, + { + displayName: 'SSL', + name: 'ssl', + type: 'options' as NodePropertyTypes, + options: [ + { + name: 'disable', + value: 'disable', + }, + { + name: 'allow', + value: 'allow', + }, + { + name: 'require', + value: 'require', + }, + { + name: 'verify (not implemented)', + value: 'verify', + }, + { + name: 'verify-full (not implemented)', + value: 'verify-full', + }, + ], + default: 'disable', + }, + { + displayName: 'Port', + name: 'port', + type: 'number' as NodePropertyTypes, + default: 8812, + }, + ]; +} diff --git a/packages/nodes-base/credentials/ZoomApi.credentials.ts b/packages/nodes-base/credentials/ZoomApi.credentials.ts index dbef99642..6efd85756 100644 --- a/packages/nodes-base/credentials/ZoomApi.credentials.ts +++ b/packages/nodes-base/credentials/ZoomApi.credentials.ts @@ -5,7 +5,7 @@ export class ZoomApi implements ICredentialType { displayName = 'Zoom API'; properties = [ { - displayName: 'JTW Token', + displayName: 'JWT Token', name: 'accessToken', type: 'string' as NodePropertyTypes, default: '' diff --git a/packages/nodes-base/nodes/Affinity/AffinityTrigger.node.ts b/packages/nodes-base/nodes/Affinity/AffinityTrigger.node.ts index 6b57d4680..3d387e7b4 100644 --- a/packages/nodes-base/nodes/Affinity/AffinityTrigger.node.ts +++ b/packages/nodes-base/nodes/Affinity/AffinityTrigger.node.ts @@ -52,10 +52,10 @@ export class AffinityTrigger implements INodeType { options: [ { name: 'file.created', - value: 'file.deleted', + value: 'file.created', }, { - name: 'file.created', + name: 'file.deleted', value: 'file.deleted', }, { @@ -136,7 +136,7 @@ export class AffinityTrigger implements INodeType { }, { name: 'opportunity.deleted', - value: 'organization.deleted', + value: 'opportunity.deleted', }, { name: 'person.created', diff --git a/packages/nodes-base/nodes/HttpRequest.node.ts b/packages/nodes-base/nodes/HttpRequest.node.ts index 417d80eee..08d3a0ac1 100644 --- a/packages/nodes-base/nodes/HttpRequest.node.ts +++ b/packages/nodes-base/nodes/HttpRequest.node.ts @@ -802,7 +802,7 @@ export class HttpRequest implements INodeType { if (oAuth2Api !== undefined) { //@ts-ignore - response = await this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions); + response = await this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions, 'Bearer'); } else { response = await this.helpers.request(requestOptions); } diff --git a/packages/nodes-base/nodes/Jira/IssueDescription.ts b/packages/nodes-base/nodes/Jira/IssueDescription.ts index bdb06179d..d29c9a535 100644 --- a/packages/nodes-base/nodes/Jira/IssueDescription.ts +++ b/packages/nodes-base/nodes/Jira/IssueDescription.ts @@ -41,12 +41,12 @@ export const issueOperations = [ { name: 'Notify', value: 'notify', - description: 'Creates an email notification for an issue and adds it to the mail queue.', + description: 'Create an email notification for an issue and add it to the mail queue', }, { name: 'Status', value: 'transitions', - description: `Returns either all transitions or a transition that can be performed by the user on an issue, based on the issue's status.`, + description: `Return either all transitions or a transition that can be performed by the user on an issue, based on the issue's status`, }, { name: 'Delete', diff --git a/packages/nodes-base/nodes/Mailchimp/GenericFunctions.ts b/packages/nodes-base/nodes/Mailchimp/GenericFunctions.ts index 59362b50d..99c0af67f 100644 --- a/packages/nodes-base/nodes/Mailchimp/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Mailchimp/GenericFunctions.ts @@ -49,7 +49,6 @@ export async function mailchimpApiRequest(this: IHookFunctions | IExecuteFunctio const datacenter = (credentials.apiKey as string).split('-').pop(); options.url = `https://${datacenter}.${host}${endpoint}`; - return await this.helpers.request!(options); } else { const credentials = this.getCredentials('mailchimpOAuth2Api') as IDataObject; diff --git a/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts b/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts index 25dbeb998..ff5ee6364 100644 --- a/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts +++ b/packages/nodes-base/nodes/Mailchimp/Mailchimp.node.ts @@ -47,6 +47,7 @@ interface ICreateMemberBody { timestamp_opt?: string; tags?: string[]; merge_fields?: IDataObject; + interests?: IDataObject; } export class Mailchimp implements INodeType { @@ -112,6 +113,10 @@ export class Mailchimp implements INodeType { name: 'resource', type: 'options', options: [ + { + name: 'List Group', + value: 'listGroup', + }, { name: 'Member', value: 'member', @@ -194,6 +199,28 @@ export class Mailchimp implements INodeType { default: 'create', description: 'The operation to perform.', }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + required: true, + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'Get all groups', + }, + ], + default: 'getAll', + description: 'The operation to perform.', + }, /* -------------------------------------------------------------------------- */ /* member:create */ /* -------------------------------------------------------------------------- */ @@ -256,27 +283,22 @@ export class Mailchimp implements INodeType { { name: 'Subscribed', value: 'subscribed', - description: '', }, { name: 'Unsubscribed', value: 'unsubscribed', - description: '', }, { name: 'Cleaned', value: 'cleaned', - description: '', }, { name: 'Pending', value: 'pending', - description: '', }, { name: 'Transactional', value: 'transactional', - description: '', }, ], default: '', @@ -287,7 +309,6 @@ export class Mailchimp implements INodeType { name: 'jsonParameters', type: 'boolean', default: false, - description: '', displayOptions: { show: { resource:[ @@ -324,12 +345,10 @@ export class Mailchimp implements INodeType { { name: 'HTML', value: 'html', - description: '', }, { name: 'Text', value: 'text', - description: '', }, ], default: '', @@ -496,7 +515,6 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', displayOptions: { show: { resource:[ @@ -519,7 +537,86 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', + displayOptions: { + show: { + resource:[ + 'member', + ], + operation: [ + 'create', + ], + jsonParameters: [ + true, + ], + }, + }, + }, + { + displayName: 'Interest Groups', + name: 'groupsUi', + placeholder: 'Add Interest Group', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource:[ + 'member' + ], + operation: [ + 'create', + ], + jsonParameters: [ + false, + ], + }, + }, + options: [ + { + name: 'groupsValues', + displayName: 'Group', + typeOptions: { + multipleValueButtonText: 'Add Interest Group', + }, + values: [ + { + displayName: 'Category ID', + name: 'categoryId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getGroupCategories', + loadOptionsDependsOn: [ + 'list', + ], + }, + default: '', + }, + { + displayName: 'Category Field ID', + name: 'categoryFieldId', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'boolean', + default: false, + }, + ], + }, + ], + }, + { + displayName: 'Interest Groups', + name: 'groupJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', displayOptions: { show: { resource:[ @@ -772,12 +869,10 @@ export class Mailchimp implements INodeType { { name: 'HTML', value: 'html', - description: '', }, { name: 'Text', value: 'text', - description: '', }, ], default: '', @@ -791,27 +886,22 @@ export class Mailchimp implements INodeType { { name: 'Subscribed', value: 'subscribed', - description: '', }, { name: 'Unsubscribed', value: 'unsubscribed', - description: '', }, { name: 'Cleaned', value: 'cleaned', - description: '', }, { name: 'Pending', value: 'pending', - description: '', }, { name: 'Transactional', value: 'transactional', - description: '', }, ], default: '', @@ -874,7 +964,6 @@ export class Mailchimp implements INodeType { name: 'jsonParameters', type: 'boolean', default: false, - description: '', displayOptions: { show: { resource:[ @@ -911,17 +1000,73 @@ export class Mailchimp implements INodeType { { name: 'HTML', value: 'html', - description: '', }, { name: 'Text', value: 'text', - description: '', }, ], default: '', description: 'Type of email this member asked to get', }, + { + displayName: 'Interest Groups', + name: 'groupsUi', + placeholder: 'Add Interest Group', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + '/resource':[ + 'member' + ], + '/operation':[ + 'update', + ], + '/jsonParameters': [ + false, + ], + }, + }, + options: [ + { + name: 'groupsValues', + displayName: 'Group', + typeOptions: { + multipleValueButtonText: 'Add Interest Group', + }, + values: [ + { + displayName: 'Category ID', + name: 'categoryId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getGroupCategories', + loadOptionsDependsOn: [ + 'list', + ], + }, + default: '', + }, + { + displayName: 'Category Field ID', + name: 'categoryFieldId', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'boolean', + default: false, + }, + ], + }, + ], + }, { displayName: 'Language', name: 'language', @@ -1024,27 +1169,22 @@ export class Mailchimp implements INodeType { { name: 'Subscribed', value: 'subscribed', - description: '', }, { name: 'Unsubscribed', value: 'unsubscribed', - description: '', }, { name: 'Cleaned', value: 'cleaned', - description: '', }, { name: 'Pending', value: 'pending', - description: '', }, { name: 'Transactional', value: 'transactional', - description: '', }, ], default: '', @@ -1119,7 +1259,6 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', displayOptions: { show: { resource:[ @@ -1142,7 +1281,28 @@ export class Mailchimp implements INodeType { alwaysOpenEditWindow: true, }, default: '', - description: '', + displayOptions: { + show: { + resource:[ + 'member', + ], + operation: [ + 'update', + ], + jsonParameters: [ + true, + ], + }, + }, + }, + { + displayName: 'Interest Groups', + name: 'groupJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', displayOptions: { show: { resource:[ @@ -1250,6 +1410,96 @@ export class Mailchimp implements INodeType { }, ], }, +/* -------------------------------------------------------------------------- */ +/* member:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'List', + name: 'list', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getLists', + }, + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + operation: [ + 'getAll', + ], + }, + }, + default: '', + options: [], + required: true, + description: 'List of lists', + }, + { + displayName: 'Group Category', + name: 'groupCategory', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getGroupCategories', + loadOptionsDependsOn: [ + 'list', + ], + }, + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + operation: [ + 'getAll', + ], + }, + }, + default: '', + options: [], + required: true, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'listGroup', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 1000, + }, + default: 500, + description: 'How many results to return.', + }, ], }; @@ -1261,7 +1511,7 @@ export class Mailchimp implements INodeType { // select them easily async getLists(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; - const { lists } = await mailchimpApiRequest.call(this, '/lists', 'GET'); + const lists = await mailchimpApiRequestAllItems.call(this, '/lists', 'GET', 'lists'); for (const list of lists) { const listName = list.name; const listId = list.id; @@ -1289,6 +1539,23 @@ export class Mailchimp implements INodeType { } return returnData; }, + + // Get all the interest fields to display them to user so that he can + // select them easily + async getGroupCategories(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const listId = this.getCurrentNodeParameter('list'); + const { categories } = await mailchimpApiRequest.call(this, `/lists/${listId}/interest-categories`, 'GET'); + for (const category of categories) { + const categoryName = category.title; + const categoryId = category.id; + returnData.push({ + name: categoryName, + value: categoryId, + }); + } + return returnData; + }, } }; @@ -1302,6 +1569,22 @@ export class Mailchimp implements INodeType { const operation = this.getNodeParameter('operation', 0) as string; for (let i = 0; i < length; i++) { + if (resource === 'listGroup') { + //https://mailchimp.com/developer/reference/lists/interest-categories/#get_/lists/-list_id-/interest-categories/-interest_category_id- + if (operation === 'getAll') { + const listId = this.getNodeParameter('list', i) as string; + const categoryId = this.getNodeParameter('groupCategory', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (returnAll === true) { + responseData = await mailchimpApiRequestAllItems.call(this, `/lists/${listId}/interest-categories/${categoryId}/interests`, 'GET', 'interests', {}, qs); + } else { + qs.count = this.getNodeParameter('limit', i) as number; + responseData = await mailchimpApiRequest.call(this, `/lists/${listId}/interest-categories/${categoryId}/interests`, 'GET', {}, qs); + responseData = responseData.interests; + } + } + } if (resource === 'member') { //https://mailchimp.com/developer/reference/lists/list-members/#post_/lists/-list_id-/members if (operation === 'create') { @@ -1363,15 +1646,29 @@ export class Mailchimp implements INodeType { } body.merge_fields = mergeFields; } + + const groupsValues = (this.getNodeParameter('groupsUi', i) as IDataObject).groupsValues as IDataObject[]; + if (groupsValues) { + const groups = {}; + for (let i = 0; i < groupsValues.length; i++) { + // @ts-ignore + groups[groupsValues[i].categoryFieldId] = groupsValues[i].value; + } + body.interests = groups; + } } else { const locationJson = validateJSON(this.getNodeParameter('locationJson', i) as string); const mergeFieldsJson = validateJSON(this.getNodeParameter('mergeFieldsJson', i) as string); + const groupJson = validateJSON(this.getNodeParameter('groupJson', i) as string); if (locationJson) { body.location = locationJson; } if (mergeFieldsJson) { body.merge_fields = mergeFieldsJson; } + if (groupJson) { + body.interests = groupJson; + } } responseData = await mailchimpApiRequest.call(this, `/lists/${listId}/members`, 'POST', body); } @@ -1504,15 +1801,31 @@ export class Mailchimp implements INodeType { body.merge_fields = mergeFields; } } + if (updateFields.groupsUi) { + const groupsValues = (updateFields.groupsUi as IDataObject).groupsValues as IDataObject[]; + if (groupsValues) { + const groups = {}; + for (let i = 0; i < groupsValues.length; i++) { + // @ts-ignore + groups[groupsValues[i].categoryFieldId] = groupsValues[i].value; + } + body.interests = groups; + } + } } else { const locationJson = validateJSON(this.getNodeParameter('locationJson', i) as string); const mergeFieldsJson = validateJSON(this.getNodeParameter('mergeFieldsJson', i) as string); + const groupJson = validateJSON(this.getNodeParameter('groupJson', i) as string); + if (locationJson) { body.location = locationJson; } if (mergeFieldsJson) { body.merge_fields = mergeFieldsJson; } + if (groupJson) { + body.interests = groupJson; + } } responseData = await mailchimpApiRequest.call(this, `/lists/${listId}/members/${email}`, 'PUT', body); } diff --git a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts index 33bf215b1..069ff5fe5 100644 --- a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts @@ -48,6 +48,9 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio query.api_token = credentials.apiToken; const options: OptionsWithUri = { + headers: { + Accept: 'application/json', + }, method, qs: query, uri: `https://api.pipedrive.com/v1${endpoint}`, @@ -93,7 +96,7 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio if (error.response && error.response.body && error.response.body.error) { // Try to return the error prettier - let errorMessage = `Pipedrive error response [${error.statusCode}]: ${error.response.body.error}`; + let errorMessage = `Pipedrive error response [${error.statusCode}]: ${error.response.body.error.message}`; if (error.response.body.error_info) { errorMessage += ` - ${error.response.body.error_info}`; } @@ -124,7 +127,7 @@ export async function pipedriveApiRequestAllItems(this: IHookFunctions | IExecut if (query === undefined) { query = {}; } - query.limit = 500; + query.limit = 100; query.start = 0; const returnData: IDataObject[] = []; @@ -133,7 +136,12 @@ export async function pipedriveApiRequestAllItems(this: IHookFunctions | IExecut do { responseData = await pipedriveApiRequest.call(this, method, endpoint, body, query); - returnData.push.apply(returnData, responseData.data); + // the search path returns data diferently + if (responseData.data.items) { + returnData.push.apply(returnData, responseData.data.items); + } else { + returnData.push.apply(returnData, responseData.data); + } query.start = responseData.additionalData.pagination.next_start; } while ( diff --git a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts index a5cd93ab9..13786ba94 100644 --- a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts @@ -25,7 +25,6 @@ interface CustomProperty { value: string; } - /** * Add the additional fields to the body * @@ -362,6 +361,11 @@ export class Pipedrive implements INodeType { value: 'getAll', description: 'Get data of all persons', }, + { + name: 'Search', + value: 'search', + description: 'Search all persons', + }, { name: 'Update', value: 'update', @@ -2021,6 +2025,7 @@ export class Pipedrive implements INodeType { show: { operation: [ 'getAll', + 'search', ], }, }, @@ -2035,6 +2040,7 @@ export class Pipedrive implements INodeType { show: { operation: [ 'getAll', + 'search', ], returnAll: [ false, @@ -2088,6 +2094,81 @@ export class Pipedrive implements INodeType { }, ], }, + + // ---------------------------------- + // person:search + // ---------------------------------- + { + displayName: 'Term', + name: 'term', + type: 'string', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'person', + ], + }, + }, + default: '', + description: 'The search term to look for. Minimum 2 characters (or 1 if using exact_match).', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'person', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Exact Match', + name: 'exactMatch', + type: 'boolean', + default: false, + description: 'When enabled, only full exact matches against the given term are returned. It is not case sensitive.', + }, + { + displayName: 'Fields', + name: 'fields', + type: 'string', + default: '', + description: 'A comma-separated string array. The fields to perform the search from. Defaults to all of them.', + }, + { + displayName: 'Include Fields', + name: 'includeFields', + type: 'string', + default: '', + description: 'Supports including optional fields in the results which are not provided by default.', + }, + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + default: '', + description: 'Will filter Deals by the provided Organization ID.', + }, + { + displayName: 'RAW Data', + name: 'rawData', + type: 'boolean', + default: false, + description: `Returns the data exactly in the way it got received from the API.`, + }, + ], + }, ], }; @@ -2526,6 +2607,39 @@ export class Pipedrive implements INodeType { endpoint = `/persons`; + } else if (operation === 'search') { + // ---------------------------------- + // persons:search + // ---------------------------------- + + requestMethod = 'GET'; + + qs.term = this.getNodeParameter('term', i) as string; + returnAll = this.getNodeParameter('returnAll', i) as boolean; + if (returnAll === false) { + qs.limit = this.getNodeParameter('limit', i) as number; + } + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.fields) { + qs.fields = additionalFields.fields as string; + } + + if (additionalFields.exactMatch) { + qs.exact_match = additionalFields.exactMatch as boolean; + } + + if (additionalFields.organizationId) { + qs.organization_id = parseInt(additionalFields.organizationId as string, 10); + } + + if (additionalFields.includeFields) { + qs.include_fields = additionalFields.includeFields as string; + } + + endpoint = `/persons/search`; + } else if (operation === 'update') { // ---------------------------------- // person:update @@ -2562,7 +2676,9 @@ export class Pipedrive implements INodeType { let responseData; if (returnAll === true) { + responseData = await pipedriveApiRequestAllItems.call(this, requestMethod, endpoint, body, qs); + } else { if (customProperties !== undefined) { @@ -2597,6 +2713,19 @@ export class Pipedrive implements INodeType { responseData.data = []; } + if (operation === 'search' && responseData.data && responseData.data.items) { + responseData.data = responseData.data.items; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + if (additionalFields.rawData !== true) { + responseData.data = responseData.data.map((item: { result_score: number, item: object }) => { + return { + result_score: item.result_score, + ...item.item, + }; + }); + } + } + if (Array.isArray(responseData.data)) { returnData.push.apply(returnData, responseData.data as IDataObject[]); } else { diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts new file mode 100644 index 000000000..fb4c50051 --- /dev/null +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.functions.ts @@ -0,0 +1,129 @@ +import { IDataObject, INodeExecutionData } from 'n8n-workflow'; +import pgPromise = require('pg-promise'); +import pg = require('pg-promise/typescript/pg-subset'); + +/** + * Returns of copy of the items which only contains the json data and + * of that only the define properties + * + * @param {INodeExecutionData[]} items The items to copy + * @param {string[]} properties The properties it should include + * @returns + */ +function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataObject[] { + // Prepare the data to insert and copy it to be returned + let newItem: IDataObject; + return items.map(item => { + newItem = {}; + for (const property of properties) { + if (item.json[property] === undefined) { + newItem[property] = null; + } else { + newItem[property] = JSON.parse(JSON.stringify(item.json[property])); + } + } + return newItem; + }); +} + +/** + * Executes the given SQL query on the database. + * + * @param {Function} getNodeParam The getter for the Node's parameters + * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance + * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection + * @param {input[]} input The Node's input data + * @returns Promise> + */ +export function pgQuery( + getNodeParam: Function, + pgp: pgPromise.IMain<{}, pg.IClient>, + db: pgPromise.IDatabase<{}, pg.IClient>, + input: INodeExecutionData[], +): Promise> { + const queries: string[] = []; + for (let i = 0; i < input.length; i++) { + queries.push(getNodeParam('query', i) as string); + } + + return db.any(pgp.helpers.concat(queries)); +} + +/** + * Inserts the given items into the database. + * + * @param {Function} getNodeParam The getter for the Node's parameters + * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance + * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection + * @param {INodeExecutionData[]} items The items to be inserted + * @returns Promise> + */ +export async function pgInsert( + getNodeParam: Function, + pgp: pgPromise.IMain<{}, pg.IClient>, + db: pgPromise.IDatabase<{}, pg.IClient>, + items: INodeExecutionData[], +): Promise> { + const table = getNodeParam('table', 0) as string; + const schema = getNodeParam('schema', 0) as string; + let returnFields = (getNodeParam('returnFields', 0) as string).split(',') as string[]; + const columnString = getNodeParam('columns', 0) as string; + const columns = columnString.split(',').map(column => column.trim()); + + const cs = new pgp.helpers.ColumnSet(columns); + + const te = new pgp.helpers.TableName({ table, schema }); + + // Prepare the data to insert and copy it to be returned + const insertItems = getItemCopy(items, columns); + + // Generate the multi-row insert query and return the id of new row + returnFields = returnFields.map(value => value.trim()).filter(value => !!value); + const query = + pgp.helpers.insert(insertItems, cs, te) + + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); + + // Executing the query to insert the data + const insertData = await db.manyOrNone(query); + + return [insertData, insertItems]; +} + +/** + * Updates the given items in the database. + * + * @param {Function} getNodeParam The getter for the Node's parameters + * @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance + * @param {pgPromise.IDatabase<{}, pg.IClient>} db The pgPromise database connection + * @param {INodeExecutionData[]} items The items to be updated + * @returns Promise> + */ +export async function pgUpdate( + getNodeParam: Function, + pgp: pgPromise.IMain<{}, pg.IClient>, + db: pgPromise.IDatabase<{}, pg.IClient>, + items: INodeExecutionData[], +): Promise> { + const table = getNodeParam('table', 0) as string; + const updateKey = getNodeParam('updateKey', 0) as string; + const columnString = getNodeParam('columns', 0) as string; + + const columns = columnString.split(',').map(column => column.trim()); + + // Make sure that the updateKey does also get queried + if (!columns.includes(updateKey)) { + columns.unshift(updateKey); + } + + // Prepare the data to update and copy it to be returned + const updateItems = getItemCopy(items, columns); + + // Generate the multi-row update query + const query = + pgp.helpers.update(updateItems, columns, table) + ' WHERE v.' + updateKey + ' = t.' + updateKey; + + // Executing the query to update the data + await db.none(query); + + return updateItems; +} diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.ts index 2fa010576..f92234eb0 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.ts @@ -3,36 +3,12 @@ import { IDataObject, INodeExecutionData, INodeType, - INodeTypeDescription, + INodeTypeDescription } from 'n8n-workflow'; import * as pgPromise from 'pg-promise'; - -/** - * Returns of copy of the items which only contains the json data and - * of that only the define properties - * - * @param {INodeExecutionData[]} items The items to copy - * @param {string[]} properties The properties it should include - * @returns - */ -function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataObject[] { - // Prepare the data to insert and copy it to be returned - let newItem: IDataObject; - return items.map((item) => { - newItem = {}; - for (const property of properties) { - if (item.json[property] === undefined) { - newItem[property] = null; - } else { - newItem[property] = JSON.parse(JSON.stringify(item.json[property])); - } - } - return newItem; - }); -} - +import { pgInsert, pgQuery, pgUpdate } from './Postgres.node.functions'; export class Postgres implements INodeType { description: INodeTypeDescription = { @@ -52,7 +28,7 @@ export class Postgres implements INodeType { { name: 'postgres', required: true, - } + }, ], properties: [ { @@ -63,17 +39,17 @@ export class Postgres implements INodeType { { name: 'Execute Query', value: 'executeQuery', - description: 'Executes a SQL query.', + description: 'Execute an SQL query', }, { name: 'Insert', value: 'insert', - description: 'Insert rows in database.', + description: 'Insert rows in database', }, { name: 'Update', value: 'update', - description: 'Updates rows in database.', + description: 'Update rows in database', }, ], default: 'insert', @@ -92,9 +68,7 @@ export class Postgres implements INodeType { }, displayOptions: { show: { - operation: [ - 'executeQuery' - ], + operation: ['executeQuery'], }, }, default: '', @@ -103,7 +77,6 @@ export class Postgres implements INodeType { description: 'The SQL query to execute.', }, - // ---------------------------------- // insert // ---------------------------------- @@ -113,9 +86,7 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: 'public', @@ -128,9 +99,7 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: '', @@ -143,14 +112,13 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: '', placeholder: 'id,name,description', - description: 'Comma separated list of the properties which should used as columns for the new rows.', + description: + 'Comma separated list of the properties which should used as columns for the new rows.', }, { displayName: 'Return Fields', @@ -158,16 +126,13 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'insert' - ], + operation: ['insert'], }, }, default: '*', description: 'Comma separated list of the fields that the operation will return', }, - // ---------------------------------- // update // ---------------------------------- @@ -177,9 +142,7 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'update' - ], + operation: ['update'], }, }, default: '', @@ -192,14 +155,13 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'update' - ], + operation: ['update'], }, }, default: 'id', required: true, - description: 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + description: + 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', }, { displayName: 'Columns', @@ -207,22 +169,18 @@ export class Postgres implements INodeType { type: 'string', displayOptions: { show: { - operation: [ - 'update' - ], + operation: ['update'], }, }, default: '', placeholder: 'name,description', - description: 'Comma separated list of the properties which should used as columns for rows to update.', + description: + 'Comma separated list of the properties which should used as columns for rows to update.', }, - - ] + ], }; - async execute(this: IExecuteFunctions): Promise { - const credentials = this.getCredentials('postgres'); if (credentials === undefined) { @@ -238,7 +196,7 @@ export class Postgres implements INodeType { user: credentials.user as string, password: credentials.password as string, ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), - sslmode: credentials.ssl as string || 'disable', + sslmode: (credentials.ssl as string) || 'disable', }; const db = pgp(config); @@ -253,39 +211,15 @@ export class Postgres implements INodeType { // executeQuery // ---------------------------------- - const queries: string[] = []; - for (let i = 0; i < items.length; i++) { - queries.push(this.getNodeParameter('query', i) as string); - } - - const queryResult = await db.any(pgp.helpers.concat(queries)); + const queryResult = await pgQuery(this.getNodeParameter, pgp, db, items); returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); - } else if (operation === 'insert') { // ---------------------------------- // insert // ---------------------------------- - const table = this.getNodeParameter('table', 0) as string; - const schema = this.getNodeParameter('schema', 0) as string; - let returnFields = (this.getNodeParameter('returnFields', 0) as string).split(',') as string[]; - const columnString = this.getNodeParameter('columns', 0) as string; - const columns = columnString.split(',').map(column => column.trim()); - - const cs = new pgp.helpers.ColumnSet(columns); - - const te = new pgp.helpers.TableName({ table, schema }); - - // Prepare the data to insert and copy it to be returned - const insertItems = getItemCopy(items, columns); - - // Generate the multi-row insert query and return the id of new row - returnFields = returnFields.map(value => value.trim()).filter(value => !!value); - const query = pgp.helpers.insert(insertItems, cs, te) + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); - - // Executing the query to insert the data - const insertData = await db.manyOrNone(query); + const [insertData, insertItems] = await pgInsert(this.getNodeParameter, pgp, db, items); // Add the id to the data for (let i = 0; i < insertData.length; i++) { @@ -293,37 +227,17 @@ export class Postgres implements INodeType { json: { ...insertData[i], ...insertItems[i], - } + }, }); } - } else if (operation === 'update') { // ---------------------------------- // update // ---------------------------------- - const table = this.getNodeParameter('table', 0) as string; - const updateKey = this.getNodeParameter('updateKey', 0) as string; - const columnString = this.getNodeParameter('columns', 0) as string; - - const columns = columnString.split(',').map(column => column.trim()); - - // Make sure that the updateKey does also get queried - if (!columns.includes(updateKey)) { - columns.unshift(updateKey); - } - - // Prepare the data to update and copy it to be returned - const updateItems = getItemCopy(items, columns); - - // Generate the multi-row update query - const query = pgp.helpers.update(updateItems, columns, table) + ' WHERE v.' + updateKey + ' = t.' + updateKey; - - // Executing the query to update the data - await db.none(query); - - returnItems = this.helpers.returnJsonArray(updateItems as IDataObject[]); + const updateItems = await pgUpdate(this.getNodeParameter, pgp, db, items); + returnItems = this.helpers.returnJsonArray(updateItems); } else { await pgp.end(); throw new Error(`The operation "${operation}" is not supported!`); diff --git a/packages/nodes-base/nodes/QuestDb/QuestDb.node.ts b/packages/nodes-base/nodes/QuestDb/QuestDb.node.ts new file mode 100644 index 000000000..20b6d4861 --- /dev/null +++ b/packages/nodes-base/nodes/QuestDb/QuestDb.node.ts @@ -0,0 +1,246 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { IDataObject, INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow'; + +import * as pgPromise from 'pg-promise'; + +import { pgInsert, pgQuery, pgUpdate } from '../Postgres/Postgres.node.functions'; + +export class QuestDb implements INodeType { + description: INodeTypeDescription = { + displayName: 'QuestDB', + name: 'questDb', + icon: 'file:questdb.png', + group: ['input'], + version: 1, + description: 'Gets, add and update data in QuestDB.', + defaults: { + name: 'QuestDB', + color: '#2C4A79', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'questDb', + required: true, + }, + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Execute Query', + value: 'executeQuery', + description: 'Executes a SQL query.', + }, + { + name: 'Insert', + value: 'insert', + description: 'Insert rows in database.', + }, + { + name: 'Update', + value: 'update', + description: 'Updates rows in database.', + }, + ], + default: 'insert', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // executeQuery + // ---------------------------------- + { + displayName: 'Query', + name: 'query', + type: 'string', + typeOptions: { + rows: 5, + }, + displayOptions: { + show: { + operation: ['executeQuery'], + }, + }, + default: '', + placeholder: 'SELECT id, name FROM product WHERE id < 40', + required: true, + description: 'The SQL query to execute.', + }, + + // ---------------------------------- + // insert + // ---------------------------------- + { + displayName: 'Schema', + name: 'schema', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: 'public', + required: true, + description: 'Name of the schema the table belongs to', + }, + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to insert data to.', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '', + placeholder: 'id,name,description', + description: + 'Comma separated list of the properties which should used as columns for the new rows.', + }, + { + displayName: 'Return Fields', + name: 'returnFields', + type: 'string', + displayOptions: { + show: { + operation: ['insert'], + }, + }, + default: '*', + description: 'Comma separated list of the fields that the operation will return', + }, + + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Table', + name: 'table', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: '', + required: true, + description: 'Name of the table in which to update data in', + }, + { + displayName: 'Update Key', + name: 'updateKey', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: 'id', + required: true, + description: + 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + operation: ['update'], + }, + }, + default: '', + placeholder: 'name,description', + description: + 'Comma separated list of the properties which should used as columns for rows to update.', + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const credentials = this.getCredentials('questDb'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const pgp = pgPromise(); + + const config = { + host: credentials.host as string, + port: credentials.port as number, + database: credentials.database as string, + user: credentials.user as string, + password: credentials.password as string, + ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), + sslmode: (credentials.ssl as string) || 'disable', + }; + + const db = pgp(config); + + let returnItems = []; + + const items = this.getInputData(); + const operation = this.getNodeParameter('operation', 0) as string; + + if (operation === 'executeQuery') { + // ---------------------------------- + // executeQuery + // ---------------------------------- + + const queryResult = await pgQuery(this.getNodeParameter, pgp, db, items); + + returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); + } else if (operation === 'insert') { + // ---------------------------------- + // insert + // ---------------------------------- + + const [insertData, insertItems] = await pgInsert(this.getNodeParameter, pgp, db, items); + + // Add the id to the data + for (let i = 0; i < insertData.length; i++) { + returnItems.push({ + json: { + ...insertData[i], + ...insertItems[i], + }, + }); + } + } else if (operation === 'update') { + // ---------------------------------- + // update + // ---------------------------------- + + const updateItems = await pgUpdate(this.getNodeParameter, pgp, db, items); + + returnItems = this.helpers.returnJsonArray(updateItems); + } else { + await pgp.end(); + throw new Error(`The operation "${operation}" is not supported!`); + } + + // Close the connection + await pgp.end(); + + return this.prepareOutputData(returnItems); + } +} diff --git a/packages/nodes-base/nodes/QuestDb/questdb.png b/packages/nodes-base/nodes/QuestDb/questdb.png new file mode 100644 index 000000000..5be1906e5 Binary files /dev/null and b/packages/nodes-base/nodes/QuestDb/questdb.png differ diff --git a/packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts b/packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts index e83a3afbf..65dd1a682 100644 --- a/packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts +++ b/packages/nodes-base/nodes/Rocketchat/Rocketchat.node.ts @@ -48,15 +48,15 @@ interface IPostMessageBody { export class Rocketchat implements INodeType { description: INodeTypeDescription = { - displayName: 'Rocketchat', + displayName: 'RocketChat', name: 'rocketchat', icon: 'file:rocketchat.png', group: ['output'], version: 1, subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}', - description: 'Consume Rocketchat API', + description: 'Consume RocketChat API', defaults: { - name: 'Rocketchat', + name: 'RocketChat', color: '#c02428', }, inputs: ['main'], diff --git a/packages/nodes-base/nodes/Trello/AttachmentDescription.ts b/packages/nodes-base/nodes/Trello/AttachmentDescription.ts index a04f50751..4306b2ed1 100644 --- a/packages/nodes-base/nodes/Trello/AttachmentDescription.ts +++ b/packages/nodes-base/nodes/Trello/AttachmentDescription.ts @@ -29,7 +29,7 @@ export const attachmentOperations = [ { name: 'Get', value: 'get', - description: 'Get the data of an attachments', + description: 'Get the data of an attachment', }, { name: 'Get All', diff --git a/packages/nodes-base/nodes/Trello/ChecklistDescription.ts b/packages/nodes-base/nodes/Trello/ChecklistDescription.ts index 34036f83e..be11ba6f1 100644 --- a/packages/nodes-base/nodes/Trello/ChecklistDescription.ts +++ b/packages/nodes-base/nodes/Trello/ChecklistDescription.ts @@ -44,17 +44,17 @@ export const checklistOperations = [ { name: 'Get Checklist Items', value: 'getCheckItem', - description: 'Get a specific Checklist on a card', + description: 'Get a specific checklist on a card', }, { name: 'Get Completed Checklist Items', value: 'completedCheckItems', - description: 'Get the completed Checklist items on a card', + description: 'Get the completed checklist items on a card', }, { name: 'Update Checklist Item', value: 'updateCheckItem', - description: 'Update an item in a checklist on a card.', + description: 'Update an item in a checklist on a card', }, ], default: 'getAll', diff --git a/packages/nodes-base/nodes/Trello/LabelDescription.ts b/packages/nodes-base/nodes/Trello/LabelDescription.ts index 9c2305328..2b938ae5d 100644 --- a/packages/nodes-base/nodes/Trello/LabelDescription.ts +++ b/packages/nodes-base/nodes/Trello/LabelDescription.ts @@ -39,7 +39,7 @@ export const labelOperations = [ { name: 'Get All', value: 'getAll', - description: 'Returns all label for the board', + description: 'Returns all labels for the board', }, { name: 'Remove From Card', diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 1141485b4..94716b7f4 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.68.0", + "version": "0.68.1", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -112,6 +112,7 @@ "dist/credentials/PipedriveApi.credentials.js", "dist/credentials/Postgres.credentials.js", "dist/credentials/PostmarkApi.credentials.js", + "dist/credentials/QuestDb.credentials.js", "dist/credentials/Redis.credentials.js", "dist/credentials/RocketchatApi.credentials.js", "dist/credentials/RundeckApi.credentials.js", @@ -260,6 +261,7 @@ "dist/nodes/Pipedrive/PipedriveTrigger.node.js", "dist/nodes/Postgres/Postgres.node.js", "dist/nodes/Postmark/PostmarkTrigger.node.js", + "dist/nodes/QuestDb/QuestDb.node.js", "dist/nodes/ReadBinaryFile.node.js", "dist/nodes/ReadBinaryFiles.node.js", "dist/nodes/ReadPdf.node.js",