From aa3c0dd226f668d53b2abf404a61d6e21cf312e5 Mon Sep 17 00:00:00 2001 From: Val <68596159+valya@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:06:39 +0100 Subject: [PATCH 01/57] refactor: Move secrets provider imports (no-changelog) (#11272) --- .../providers/azure-key-vault/azure-key-vault.ts | 6 ++++-- .../providers/gcp-secrets-manager/gcp-secrets-manager.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/external-secrets/providers/azure-key-vault/azure-key-vault.ts b/packages/cli/src/external-secrets/providers/azure-key-vault/azure-key-vault.ts index c87c5e07d0..e753f0abbf 100644 --- a/packages/cli/src/external-secrets/providers/azure-key-vault/azure-key-vault.ts +++ b/packages/cli/src/external-secrets/providers/azure-key-vault/azure-key-vault.ts @@ -1,5 +1,4 @@ -import { ClientSecretCredential } from '@azure/identity'; -import { SecretClient } from '@azure/keyvault-secrets'; +import type { SecretClient } from '@azure/keyvault-secrets'; import type { INodeProperties } from 'n8n-workflow'; import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets/constants'; @@ -72,6 +71,9 @@ export class AzureKeyVault implements SecretsProvider { async connect() { const { vaultName, tenantId, clientId, clientSecret } = this.settings; + const { ClientSecretCredential } = await import('@azure/identity'); + const { SecretClient } = await import('@azure/keyvault-secrets'); + try { const credential = new ClientSecretCredential(tenantId, clientId, clientSecret); this.client = new SecretClient(`https://${vaultName}.vault.azure.net/`, credential); diff --git a/packages/cli/src/external-secrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts b/packages/cli/src/external-secrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts index c562139105..e6bcd11209 100644 --- a/packages/cli/src/external-secrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts +++ b/packages/cli/src/external-secrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts @@ -1,4 +1,4 @@ -import { SecretManagerServiceClient as GcpClient } from '@google-cloud/secret-manager'; +import type { SecretManagerServiceClient as GcpClient } from '@google-cloud/secret-manager'; import { jsonParse, type INodeProperties } from 'n8n-workflow'; import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets/constants'; @@ -45,6 +45,8 @@ export class GcpSecretsManager implements SecretsProvider { async connect() { const { projectId, privateKey, clientEmail } = this.settings; + const { SecretManagerServiceClient: GcpClient } = await import('@google-cloud/secret-manager'); + try { this.client = new GcpClient({ credentials: { client_email: clientEmail, private_key: privateKey }, From 785b47feb3b83cf36aaed57123f8baca2bbab307 Mon Sep 17 00:00:00 2001 From: feelgood-interface <78543720+feelgood-interface@users.noreply.github.com> Date: Wed, 16 Oct 2024 11:23:09 +0200 Subject: [PATCH 02/57] feat(Gong Node): New node (#10777) --- .../credentials/GongApi.credentials.ts | 58 + .../credentials/GongOAuth2Api.credentials.ts | 59 + .../nodes-base/nodes/Gong/GenericFunctions.ts | 227 ++++ packages/nodes-base/nodes/Gong/Gong.node.json | 18 + packages/nodes-base/nodes/Gong/Gong.node.ts | 171 +++ .../Gong/descriptions/CallDescription.ts | 603 +++++++++ .../Gong/descriptions/UserDescription.ts | 288 +++++ .../nodes/Gong/descriptions/index.ts | 2 + packages/nodes-base/nodes/Gong/gong.svg | 4 + .../nodes/Gong/test/Gong.node.test.ts | 1079 +++++++++++++++++ packages/nodes-base/nodes/Gong/test/mocks.ts | 781 ++++++++++++ packages/nodes-base/package.json | 3 + .../test/nodes/FakeCredentialsMap.ts | 26 + packages/nodes-base/test/nodes/Helpers.ts | 4 +- packages/workflow/src/Interfaces.ts | 2 +- packages/workflow/src/RoutingNode.ts | 45 +- 16 files changed, 3354 insertions(+), 16 deletions(-) create mode 100644 packages/nodes-base/credentials/GongApi.credentials.ts create mode 100644 packages/nodes-base/credentials/GongOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/Gong/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Gong/Gong.node.json create mode 100644 packages/nodes-base/nodes/Gong/Gong.node.ts create mode 100644 packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts create mode 100644 packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts create mode 100644 packages/nodes-base/nodes/Gong/descriptions/index.ts create mode 100644 packages/nodes-base/nodes/Gong/gong.svg create mode 100644 packages/nodes-base/nodes/Gong/test/Gong.node.test.ts create mode 100644 packages/nodes-base/nodes/Gong/test/mocks.ts diff --git a/packages/nodes-base/credentials/GongApi.credentials.ts b/packages/nodes-base/credentials/GongApi.credentials.ts new file mode 100644 index 0000000000..19c56a65de --- /dev/null +++ b/packages/nodes-base/credentials/GongApi.credentials.ts @@ -0,0 +1,58 @@ +import type { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class GongApi implements ICredentialType { + name = 'gongApi'; + + displayName = 'Gong API'; + + documentationUrl = 'gong'; + + properties: INodeProperties[] = [ + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'string', + default: 'https://api.gong.io', + }, + { + displayName: 'Access Key', + name: 'accessKey', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + }, + { + displayName: 'Access Key Secret', + name: 'accessKeySecret', + type: 'string', + default: '', + typeOptions: { + password: true, + }, + }, + ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + auth: { + username: '={{ $credentials.accessKey }}', + password: '={{ $credentials.accessKeySecret }}', + }, + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: '={{ $credentials.baseUrl.replace(new RegExp("/$"), "") }}', + url: '/v2/users', + }, + }; +} diff --git a/packages/nodes-base/credentials/GongOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GongOAuth2Api.credentials.ts new file mode 100644 index 0000000000..bea935c4f1 --- /dev/null +++ b/packages/nodes-base/credentials/GongOAuth2Api.credentials.ts @@ -0,0 +1,59 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class GongOAuth2Api implements ICredentialType { + name = 'gongOAuth2Api'; + + extends = ['oAuth2Api']; + + displayName = 'Gong OAuth2 API'; + + documentationUrl = 'gong'; + + properties: INodeProperties[] = [ + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'string', + default: 'https://api.gong.io', + }, + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://app.gong.io/oauth2/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://app.gong.io/oauth2/generate-customer-token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: + 'api:calls:read:transcript api:provisioning:read api:workspaces:read api:meetings:user:delete api:crm:get-objects api:data-privacy:delete api:crm:schema api:flows:write api:crm:upload api:meetings:integration:status api:calls:read:extensive api:meetings:user:update api:integration-settings:write api:settings:scorecards:read api:stats:scorecards api:stats:interaction api:stats:user-actions api:crm:integration:delete api:calls:read:basic api:calls:read:media-url api:digital-interactions:write api:crm:integrations:read api:library:read api:data-privacy:read api:users:read api:logs:read api:calls:create api:meetings:user:create api:stats:user-actions:detailed api:settings:trackers:read api:crm:integration:register api:provisioning:read-write api:engagement-data:write api:permission-profile:read api:permission-profile:write api:flows:read api:crm-calls:manual-association:read', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'header', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Gong/GenericFunctions.ts b/packages/nodes-base/nodes/Gong/GenericFunctions.ts new file mode 100644 index 0000000000..8c9069959b --- /dev/null +++ b/packages/nodes-base/nodes/Gong/GenericFunctions.ts @@ -0,0 +1,227 @@ +import get from 'lodash/get'; +import type { + DeclarativeRestApiSettings, + IDataObject, + IExecuteFunctions, + IExecutePaginationFunctions, + IExecuteSingleFunctions, + IHttpRequestMethods, + IHttpRequestOptions, + ILoadOptionsFunctions, + IN8nHttpFullResponse, + INodeExecutionData, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +export async function gongApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + endpoint: string, + body: IDataObject = {}, + query: IDataObject = {}, +) { + const authentication = this.getNodeParameter('authentication', 0) as 'accessToken' | 'oAuth2'; + const credentialsType = authentication === 'oAuth2' ? 'gongOAuth2Api' : 'gongApi'; + const { baseUrl } = await this.getCredentials<{ + baseUrl: string; + }>(credentialsType); + + const options: IHttpRequestOptions = { + method, + url: baseUrl.replace(new RegExp('/$'), '') + endpoint, + json: true, + headers: { + 'Content-Type': 'application/json', + }, + body, + qs: query, + }; + + if (Object.keys(body).length === 0) { + delete options.body; + } + + return await this.helpers.requestWithAuthentication.call(this, credentialsType, options); +} + +export async function gongApiPaginateRequest( + this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + endpoint: string, + body: IDataObject = {}, + query: IDataObject = {}, + itemIndex: number = 0, + rootProperty: string | undefined = undefined, +): Promise { + const authentication = this.getNodeParameter('authentication', 0) as 'accessToken' | 'oAuth2'; + const credentialsType = authentication === 'oAuth2' ? 'gongOAuth2Api' : 'gongApi'; + const { baseUrl } = await this.getCredentials<{ + baseUrl: string; + }>(credentialsType); + + const options: IHttpRequestOptions = { + method, + url: baseUrl.replace(new RegExp('/$'), '') + endpoint, + json: true, + headers: { + 'Content-Type': 'application/json', + }, + body, + qs: query, + }; + + if (Object.keys(body).length === 0) { + delete options.body; + } + + const pages = await this.helpers.requestWithAuthenticationPaginated.call( + this, + options, + itemIndex, + { + requestInterval: 340, // Rate limit 3 calls per second + continue: '={{ $response.body.records.cursor }}', + request: { + [method === 'POST' ? 'body' : 'qs']: + '={{ $if($response.body?.records.cursor, { cursor: $response.body.records.cursor }, {}) }}', + url: options.url, + }, + }, + credentialsType, + ); + + if (rootProperty) { + let results: IDataObject[] = []; + for (const page of pages) { + const items = page.body[rootProperty]; + if (items) { + results = results.concat(items); + } + } + return results; + } else { + return pages.flat(); + } +} + +const getCursorPaginator = ( + extractItems: (items: INodeExecutionData[]) => INodeExecutionData[], +) => { + return async function cursorPagination( + this: IExecutePaginationFunctions, + requestOptions: DeclarativeRestApiSettings.ResultOptions, + ): Promise { + let executions: INodeExecutionData[] = []; + let responseData: INodeExecutionData[]; + let nextCursor: string | undefined = undefined; + const returnAll = this.getNodeParameter('returnAll', true) as boolean; + + do { + (requestOptions.options.body as IDataObject).cursor = nextCursor; + responseData = await this.makeRoutingRequest(requestOptions); + const lastItem = responseData[responseData.length - 1].json; + nextCursor = (lastItem.records as IDataObject)?.cursor as string | undefined; + executions = executions.concat(extractItems(responseData)); + } while (returnAll && nextCursor); + + return executions; + }; +}; + +export const extractCalls = (items: INodeExecutionData[]): INodeExecutionData[] => { + const calls: IDataObject[] = items.flatMap((item) => get(item.json, 'calls') as IDataObject[]); + return calls.map((call) => { + const { metaData, ...rest } = call ?? {}; + return { json: { ...(metaData as IDataObject), ...rest } }; + }); +}; + +export const extractUsers = (items: INodeExecutionData[]): INodeExecutionData[] => { + const users: IDataObject[] = items.flatMap((item) => get(item.json, 'users') as IDataObject[]); + return users.map((user) => ({ json: user })); +}; + +export const getCursorPaginatorCalls = () => { + return getCursorPaginator(extractCalls); +}; + +export const getCursorPaginatorUsers = () => { + return getCursorPaginator(extractUsers); +}; + +export async function handleErrorPostReceive( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { + const { resource, operation } = this.getNode().parameters; + + if (resource === 'call') { + if (operation === 'get') { + if (response.statusCode === 404) { + throw new NodeApiError(this.getNode(), response as unknown as JsonObject, { + message: "The required call doesn't match any existing one", + description: "Double-check the value in the parameter 'Call to Get' and try again", + }); + } + } else if (operation === 'getAll') { + if (response.statusCode === 404) { + const primaryUserId = this.getNodeParameter('filters.primaryUserIds', {}) as IDataObject; + if (Object.keys(primaryUserId).length !== 0) { + return [{ json: {} }]; + } + } else if (response.statusCode === 400 || response.statusCode === 500) { + throw new NodeApiError(this.getNode(), response as unknown as JsonObject, { + description: 'Double-check the value(s) in the parameter(s)', + }); + } + } + } else if (resource === 'user') { + if (operation === 'get') { + if (response.statusCode === 404) { + throw new NodeApiError(this.getNode(), response as unknown as JsonObject, { + message: "The required user doesn't match any existing one", + description: "Double-check the value in the parameter 'User to Get' and try again", + }); + } + } else if (operation === 'getAll') { + if (response.statusCode === 404) { + const userIds = this.getNodeParameter('filters.userIds', '') as string; + if (userIds) { + throw new NodeApiError(this.getNode(), response as unknown as JsonObject, { + message: "The Users IDs don't match any existing user", + description: "Double-check the values in the parameter 'Users IDs' and try again", + }); + } + } + } + } + + throw new NodeApiError(this.getNode(), response as unknown as JsonObject); + } + + return data; +} + +export function isValidNumberIds(value: number | number[] | string | string[]): boolean { + if (typeof value === 'number') { + return true; + } + + if (Array.isArray(value) && value.every((item) => typeof item === 'number')) { + return true; + } + + if (typeof value === 'string') { + const parts = value.split(','); + return parts.every((part) => !isNaN(Number(part.trim()))); + } + + if (Array.isArray(value) && value.every((item) => typeof item === 'string')) { + return true; + } + + return false; +} diff --git a/packages/nodes-base/nodes/Gong/Gong.node.json b/packages/nodes-base/nodes/Gong/Gong.node.json new file mode 100644 index 0000000000..be07bbb330 --- /dev/null +++ b/packages/nodes-base/nodes/Gong/Gong.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.gong", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Development", "Developer Tools"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.gong/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.gong/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Gong/Gong.node.ts b/packages/nodes-base/nodes/Gong/Gong.node.ts new file mode 100644 index 0000000000..81b678f283 --- /dev/null +++ b/packages/nodes-base/nodes/Gong/Gong.node.ts @@ -0,0 +1,171 @@ +import { + NodeConnectionType, + type IDataObject, + type ILoadOptionsFunctions, + type INodeListSearchItems, + type INodeListSearchResult, + type INodeType, + type INodeTypeDescription, +} from 'n8n-workflow'; + +import { callFields, callOperations, userFields, userOperations } from './descriptions'; +import { gongApiRequest } from './GenericFunctions'; + +export class Gong implements INodeType { + description: INodeTypeDescription = { + displayName: 'Gong', + name: 'gong', + icon: 'file:gong.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Interact with Gong API', + defaults: { + name: 'Gong', + }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + credentials: [ + { + name: 'gongApi', + required: true, + displayOptions: { + show: { + authentication: ['accessToken'], + }, + }, + }, + { + name: 'gongOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + }, + ], + requestDefaults: { + baseURL: '={{ $credentials.baseUrl.replace(new RegExp("/$"), "") }}', + }, + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Call', + value: 'call', + }, + { + name: 'User', + value: 'user', + }, + ], + default: 'call', + }, + ...callOperations, + ...callFields, + ...userOperations, + ...userFields, + ], + }; + + methods = { + listSearch: { + async getCalls( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, + ): Promise { + const query: IDataObject = {}; + if (paginationToken) { + query.cursor = paginationToken; + } + + const responseData = await gongApiRequest.call(this, 'GET', '/v2/calls', {}, query); + + const calls: Array<{ + id: string; + title: string; + }> = responseData.calls; + + const results: INodeListSearchItems[] = calls + .map((c) => ({ + name: c.title, + value: c.id, + })) + .filter( + (c) => + !filter || + c.name.toLowerCase().includes(filter.toLowerCase()) || + c.value?.toString() === filter, + ) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + + return { results, paginationToken: responseData.records.cursor }; + }, + + async getUsers( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, + ): Promise { + const query: IDataObject = {}; + if (paginationToken) { + query.cursor = paginationToken; + } + + const responseData = await gongApiRequest.call(this, 'GET', '/v2/users', {}, query); + + const users: Array<{ + id: string; + emailAddress: string; + firstName: string; + lastName: string; + }> = responseData.users; + + const results: INodeListSearchItems[] = users + .map((u) => ({ + name: `${u.firstName} ${u.lastName} (${u.emailAddress})`, + value: u.id, + })) + .filter( + (u) => + !filter || + u.name.toLowerCase().includes(filter.toLowerCase()) || + u.value?.toString() === filter, + ) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + + return { results, paginationToken: responseData.records.cursor }; + }, + }, + }; +} diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts new file mode 100644 index 0000000000..ab6df9626d --- /dev/null +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -0,0 +1,603 @@ +import type { + IDataObject, + IExecuteSingleFunctions, + IHttpRequestOptions, + IN8nHttpFullResponse, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import { + getCursorPaginatorCalls, + gongApiPaginateRequest, + isValidNumberIds, + handleErrorPostReceive, + extractCalls, +} from '../GenericFunctions'; + +export const callOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['call'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Retrieve data for a specific call', + routing: { + request: { + method: 'POST', + url: '/v2/calls/extensive', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorPostReceive], + }, + }, + action: 'Get call', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve a list of calls', + routing: { + request: { + method: 'POST', + url: '/v2/calls/extensive', + body: { + filter: {}, + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorPostReceive], + }, + }, + action: 'Get many calls', + }, + ], + default: 'getAll', + }, +]; + +const getFields: INodeProperties[] = [ + { + displayName: 'Call to Get', + name: 'call', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + show: { + resource: ['call'], + operation: ['get'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getCalls', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + placeholder: 'e.g. 7782342274025937895', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]{1,20}', + errorMessage: 'Not a valid Gong Call ID', + }, + }, + ], + }, + { + displayName: 'By URL', + name: 'url', + extractValue: { + type: 'regex', + regex: 'https:\\/\\/[a-zA-Z0-9-]+\\.app\\.gong\\.io\\/call\\?id=([0-9]{1,20})', + }, + placeholder: 'e.g. https://subdomain.app.gong.io/call?id=7782342274025937895', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'https:\\/\\/[a-zA-Z0-9-]+\\.app\\.gong\\.io\\/call\\?id=([0-9]{1,20})', + errorMessage: 'Not a valid Gong URL', + }, + }, + ], + }, + ], + required: true, + routing: { + send: { + type: 'body', + property: 'filter.callIds', + propertyInDotNotation: true, + value: '={{ [$value] }}', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'calls', + }, + }, + ], + }, + }, + type: 'resourceLocator', + }, + { + displayName: 'Options', + name: 'options', + default: {}, + displayOptions: { + show: { + resource: ['call'], + operation: ['get'], + }, + }, + options: [ + { + displayName: 'Call Data to Include', + name: 'properties', + type: 'multiOptions', + default: [], + description: + 'The Call properties to include in the returned results. Choose from a list, or specify IDs using an expression.', + options: [ + { + name: 'Action Items', + value: 'pointsOfInterest', + description: 'Call points of interest', + }, + { + name: 'Audio and Video URLs', + value: 'media', + description: 'Audio and video URL of the call. The URLs will be available for 8 hours.', + }, + { + name: 'Brief', + value: 'brief', + description: 'Spotlight call brief', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.content.brief', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + }, + { + name: 'Comments', + value: 'publicComments', + description: 'Public comments made for this call', + }, + { + name: 'Highlights', + value: 'highlights', + description: 'Call highlights', + }, + { + name: 'Keypoints', + value: 'keyPoints', + description: 'Key points of the call', + }, + { + name: 'Outcome', + value: 'callOutcome', + description: 'Outcome of the call', + }, + { + name: 'Outline', + value: 'outline', + description: 'Call outline', + }, + { + name: 'Participants', + value: 'parties', + description: 'Information about the participants of the call', + }, + { + name: 'Structure', + value: 'structure', + description: 'Call agenda', + }, + { + name: 'Topics', + value: 'topics', + description: 'Duration of call topics', + }, + { + name: 'Trackers', + value: 'trackers', + description: 'Smart tracker and keyword tracker information for the call', + }, + { + name: 'Transcript', + value: 'transcript', + description: 'Information about the participants', + }, + ], + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const contentProperties = [ + 'pointsOfInterest', + 'brief', + 'highlights', + 'keyPoints', + 'outline', + 'callOutcome', + 'structure', + 'trackers', + 'topics', + ]; + const exposedFieldsProperties = ['media', 'parties']; + const collaborationProperties = ['publicComments']; + + const properties = this.getNodeParameter('options.properties') as string[]; + const contentSelector = { exposedFields: {} } as any; + for (const property of properties) { + if (exposedFieldsProperties.includes(property)) { + contentSelector.exposedFields[property] = true; + } else if (contentProperties.includes(property)) { + contentSelector.exposedFields.content ??= {}; + contentSelector.exposedFields.content[property] = true; + } else if (collaborationProperties.includes(property)) { + contentSelector.exposedFields.collaboration ??= {}; + contentSelector.exposedFields.collaboration[property] = true; + } + } + + requestOptions.body ||= {}; + Object.assign(requestOptions.body, { contentSelector }); + return requestOptions; + }, + ], + }, + output: { + postReceive: [ + async function ( + this: IExecuteSingleFunctions, + items: INodeExecutionData[], + _responseData: IN8nHttpFullResponse, + ): Promise { + const properties = this.getNodeParameter('options.properties') as string[]; + if (properties.includes('transcript')) { + for (const item of items) { + const callTranscripts = await gongApiPaginateRequest.call( + this, + 'POST', + '/v2/calls/transcript', + { filter: { callIds: [(item.json.metaData as IDataObject).id] } }, + {}, + item.index ?? 0, + 'callTranscripts', + ); + item.json.transcript = callTranscripts?.length + ? callTranscripts[0].transcript + : []; + } + } + return items; + }, + ], + }, + }, + }, + ], + placeholder: 'Add Option', + type: 'collection', + }, +]; + +const getAllFields: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: ['call'], + operation: ['getAll'], + }, + }, + routing: { + send: { + paginate: '={{ $value }}', + }, + operations: { + pagination: getCursorPaginatorCalls(), + }, + }, + type: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + default: 50, + description: 'Max number of results to return', + displayOptions: { + show: { + resource: ['call'], + operation: ['getAll'], + returnAll: [false], + }, + }, + routing: { + output: { + postReceive: [ + async function ( + this: IExecuteSingleFunctions, + items: INodeExecutionData[], + _response: IN8nHttpFullResponse, + ): Promise { + return extractCalls(items); + }, + { + type: 'limit', + properties: { + maxResults: '={{ $value }}', + }, + }, + ], + }, + }, + type: 'number', + typeOptions: { + minValue: 1, + }, + validateType: 'number', + }, + { + displayName: 'Filters', + name: 'filters', + default: {}, + displayOptions: { + show: { + resource: ['call'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'After', + name: 'fromDateTime', + default: '', + description: + 'Returns calls that started on or after the specified date and time. If not provided, list starts with earliest call. For web-conference calls recorded by Gong, the date denotes its scheduled time, otherwise, it denotes its actual start time.', + placeholder: 'e.g. 2018-02-18T02:30:00-07:00 or 2018-02-18T08:00:00Z', + routing: { + send: { + type: 'body', + property: 'filter.fromDateTime', + propertyInDotNotation: true, + value: '={{ new Date($value).toISOString() }}', + }, + }, + type: 'dateTime', + validateType: 'dateTime', + }, + { + displayName: 'Before', + name: 'toDateTime', + default: '', + description: + 'Returns calls that started up to but excluding specified date and time. If not provided, list ends with most recent call. For web-conference calls recorded by Gong, the date denotes its scheduled time, otherwise, it denotes its actual start time.', + placeholder: 'e.g. 2018-02-18T02:30:00-07:00 or 2018-02-18T08:00:00Z', + routing: { + send: { + type: 'body', + property: 'filter.toDateTime', + propertyInDotNotation: true, + value: '={{ new Date($value).toISOString() }}', + }, + }, + type: 'dateTime', + validateType: 'dateTime', + }, + { + displayName: 'Workspace ID', + name: 'workspaceId', + default: '', + description: 'Return only the calls belonging to this workspace', + placeholder: 'e.g. 623457276584334', + routing: { + send: { + type: 'body', + property: 'filter.workspaceId', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'string', + validateType: 'number', + }, + { + displayName: 'Call IDs', + name: 'callIds', + default: '', + description: 'List of calls IDs to be filtered', + hint: 'Comma separated list of IDs, array of strings can be set in expression', + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const callIdsParam = this.getNodeParameter('filters.callIds') as + | number + | number[] + | string + | string[]; + if (callIdsParam && !isValidNumberIds(callIdsParam)) { + throw new NodeApiError(this.getNode(), { + message: 'Call IDs must be numeric', + description: "Double-check the value in the parameter 'Call IDs' and try again", + }); + } + + const callIds = Array.isArray(callIdsParam) + ? callIdsParam.map((x) => x.toString()) + : callIdsParam + .toString() + .split(',') + .map((x) => x.trim()); + + requestOptions.body ||= {}; + (requestOptions.body as IDataObject).filter ||= {}; + Object.assign((requestOptions.body as IDataObject).filter as IDataObject, { + callIds, + }); + + return requestOptions; + }, + ], + }, + }, + placeholder: 'e.g. 7782342274025937895', + type: 'string', + }, + { + displayName: 'Organizer', + name: 'primaryUserIds', + default: { + mode: 'list', + value: '', + }, + description: 'Return only the calls hosted by the specified user', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + placeholder: 'e.g. 7782342274025937895', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]{1,20}', + errorMessage: 'Not a valid Gong User ID', + }, + }, + ], + }, + ], + routing: { + send: { + type: 'body', + property: 'filter.primaryUserIds', + propertyInDotNotation: true, + value: '={{ [$value] }}', + }, + }, + type: 'resourceLocator', + }, + ], + placeholder: 'Add Filter', + type: 'collection', + }, + { + displayName: 'Options', + name: 'options', + default: {}, + displayOptions: { + show: { + resource: ['call'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Call Data to Include', + name: 'properties', + type: 'multiOptions', + default: [], + description: + 'The Call properties to include in the returned results. Choose from a list, or specify IDs using an expression.', + options: [ + { + name: 'Participants', + value: 'parties', + description: 'Information about the participants of the call', + }, + { + name: 'Topics', + value: 'topics', + description: 'Information about the topics of the call', + }, + ], + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const contentProperties = ['topics']; + const exposedFieldsProperties = ['parties']; + + const properties = this.getNodeParameter('options.properties') as string[]; + const contentSelector = { exposedFields: {} } as any; + for (const property of properties) { + if (exposedFieldsProperties.includes(property)) { + contentSelector.exposedFields[property] = true; + } else if (contentProperties.includes(property)) { + contentSelector.exposedFields.content ??= {}; + contentSelector.exposedFields.content[property] = true; + } + } + + requestOptions.body ||= {}; + Object.assign(requestOptions.body, { contentSelector }); + return requestOptions; + }, + ], + }, + }, + }, + ], + placeholder: 'Add Option', + type: 'collection', + }, +]; + +export const callFields: INodeProperties[] = [...getFields, ...getAllFields]; diff --git a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts new file mode 100644 index 0000000000..38fb847b90 --- /dev/null +++ b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts @@ -0,0 +1,288 @@ +import type { + IDataObject, + IExecuteSingleFunctions, + IHttpRequestOptions, + INodeProperties, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import { + getCursorPaginatorUsers, + isValidNumberIds, + handleErrorPostReceive, +} from '../GenericFunctions'; + +export const userOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Retrieve data for a specific user', + action: 'Get user', + routing: { + request: { + method: 'POST', + url: '/v2/users/extensive', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorPostReceive], + }, + }, + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve a list of users', + action: 'Get many users', + routing: { + request: { + method: 'POST', + url: '/v2/users/extensive', + body: { + filter: {}, + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorPostReceive], + }, + }, + }, + ], + default: 'get', + }, +]; + +const getOperation: INodeProperties[] = [ + { + displayName: 'User to Get', + name: 'user', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + show: { + resource: ['user'], + operation: ['get'], + }, + }, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + placeholder: 'e.g. 7782342274025937895', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]{1,20}', + errorMessage: 'Not a valid Gong User ID', + }, + }, + ], + }, + ], + routing: { + send: { + type: 'body', + property: 'filter.userIds', + propertyInDotNotation: true, + value: '={{ [$value] }}', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'users', + }, + }, + ], + }, + }, + type: 'resourceLocator', + }, +]; + +const getAllOperation: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: ['user'], + operation: ['getAll'], + }, + }, + routing: { + send: { + paginate: '={{ $value }}', + }, + operations: { + pagination: getCursorPaginatorUsers(), + }, + }, + type: 'boolean', + validateType: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + default: 50, + description: 'Max number of results to return', + displayOptions: { + show: { + resource: ['user'], + operation: ['getAll'], + returnAll: [false], + }, + }, + routing: { + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'users', + }, + }, + { + type: 'limit', + properties: { + maxResults: '={{ $value }}', + }, + }, + ], + }, + }, + type: 'number', + typeOptions: { + minValue: 1, + }, + validateType: 'number', + }, + { + displayName: 'Filters', + name: 'filters', + default: {}, + displayOptions: { + show: { + resource: ['user'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Created After', + name: 'createdFromDateTime', + default: '', + description: + 'An optional user creation time lower limit, if supplied the API will return only the users created at or after this time', + placeholder: 'e.g. 2018-02-18T02:30:00-07:00 or 2018-02-18T08:00:00Z', + routing: { + send: { + type: 'body', + property: 'filter.createdFromDateTime', + propertyInDotNotation: true, + value: '={{ new Date($value).toISOString() }}', + }, + }, + type: 'dateTime', + validateType: 'dateTime', + }, + { + displayName: 'Created Before', + name: 'createdToDateTime', + default: '', + description: + 'An optional user creation time upper limit, if supplied the API will return only the users created before this time', + placeholder: 'e.g. 2018-02-18T02:30:00-07:00 or 2018-02-18T08:00:00Z', + routing: { + send: { + type: 'body', + property: 'filter.createdToDateTime', + propertyInDotNotation: true, + value: '={{ new Date($value).toISOString() }}', + }, + }, + type: 'dateTime', + validateType: 'dateTime', + }, + { + displayName: 'User IDs', + name: 'userIds', + default: '', + description: "Set of Gong's unique numeric identifiers for the users (up to 20 digits)", + hint: 'Comma separated list of IDs, array of strings can be set in expression', + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const userIdsParam = this.getNodeParameter('filters.userIds') as + | number + | number[] + | string + | string[]; + if (userIdsParam && !isValidNumberIds(userIdsParam)) { + throw new NodeApiError(this.getNode(), { + message: 'User IDs must be numeric', + description: "Double-check the value in the parameter 'User IDs' and try again", + }); + } + + const userIds = Array.isArray(userIdsParam) + ? userIdsParam.map((x) => x.toString()) + : userIdsParam + .toString() + .split(',') + .map((x) => x.trim()); + + requestOptions.body ||= {}; + (requestOptions.body as IDataObject).filter ||= {}; + Object.assign((requestOptions.body as IDataObject).filter as IDataObject, { + userIds, + }); + + return requestOptions; + }, + ], + }, + }, + placeholder: 'e.g. 7782342274025937895', + type: 'string', + }, + ], + placeholder: 'Add Filter', + type: 'collection', + }, +]; + +export const userFields: INodeProperties[] = [...getOperation, ...getAllOperation]; diff --git a/packages/nodes-base/nodes/Gong/descriptions/index.ts b/packages/nodes-base/nodes/Gong/descriptions/index.ts new file mode 100644 index 0000000000..ff9bc4319b --- /dev/null +++ b/packages/nodes-base/nodes/Gong/descriptions/index.ts @@ -0,0 +1,2 @@ +export * from './CallDescription'; +export * from './UserDescription'; diff --git a/packages/nodes-base/nodes/Gong/gong.svg b/packages/nodes-base/nodes/Gong/gong.svg new file mode 100644 index 0000000000..044aba4e03 --- /dev/null +++ b/packages/nodes-base/nodes/Gong/gong.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts new file mode 100644 index 0000000000..d4e3e307fd --- /dev/null +++ b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts @@ -0,0 +1,1079 @@ +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; +import * as Helpers from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import type { + ICredentialDataDecryptedObject, + IDataObject, + IHttpRequestOptions, +} from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; +import nock from 'nock'; + +import { gongApiResponse, gongNodeResponse } from './mocks'; +import { FAKE_CREDENTIALS_DATA } from '../../../test/nodes/FakeCredentialsMap'; + +describe('Gong Node', () => { + const baseUrl = 'https://api.gong.io'; + + beforeEach(() => { + // https://github.com/nock/nock/issues/2057#issuecomment-663665683 + if (!nock.isActive()) { + nock.activate(); + } + }); + + describe('Credentials', () => { + const tests: WorkflowTestData[] = [ + { + description: 'should use correct credentials', + input: { + workflowData: { + nodes: [ + { + parameters: {}, + id: '416e4fc1-5055-4e61-854e-a6265256ac26', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + position: [820, 380], + typeVersion: 1, + }, + { + parameters: { + operation: 'get', + call: { + __rl: true, + value: '7782342274025937895', + mode: 'id', + }, + options: {}, + requestOptions: {}, + }, + id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', + name: 'Gong gongApi', + type: 'n8n-nodes-base.gong', + typeVersion: 1, + position: [1040, 380], + credentials: { + gongApi: { + id: '1', + name: 'Gong account', + }, + }, + }, + { + parameters: { + authentication: 'oAuth2', + operation: 'get', + call: { + __rl: true, + value: '7782342274025937896', + mode: 'id', + }, + options: {}, + requestOptions: {}, + }, + id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', + name: 'Gong gongOAuth2Api', + type: 'n8n-nodes-base.gong', + typeVersion: 1, + position: [1040, 380], + credentials: { + gongOAuth2Api: { + id: '2', + name: 'Gong account2', + }, + }, + }, + ], + connections: { + "When clicking 'Test workflow'": { + main: [ + [ + { + node: 'Gong gongApi', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + 'Gong gongApi': { + main: [ + [ + { + node: 'Gong gongOAuth2Api', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start'], + nodeData: { + 'Gong gongApi': [[{ json: { metaData: gongNodeResponse.getCall[0].json.metaData } }]], + 'Gong gongOAuth2Api': [ + [{ json: { metaData: gongNodeResponse.getCall[0].json.metaData } }], + ], + }, + }, + }, + ]; + + beforeAll(() => { + nock.disableNetConnect(); + + jest + .spyOn(Helpers.CredentialsHelper.prototype, 'authenticate') + .mockImplementation( + async ( + credentials: ICredentialDataDecryptedObject, + typeName: string, + requestParams: IHttpRequestOptions, + ): Promise => { + if (typeName === 'gongApi') { + return { + ...requestParams, + headers: { + authorization: + 'basic ' + + Buffer.from(`${credentials.accessKey}:${credentials.accessKeySecret}`).toString( + 'base64', + ), + }, + }; + } else if (typeName === 'gongOAuth2Api') { + return { + ...requestParams, + headers: { + authorization: + 'bearer ' + (credentials.oauthTokenData as IDataObject).access_token, + }, + }; + } else { + return requestParams; + } + }, + ); + }); + + afterAll(() => { + nock.restore(); + jest.restoreAllMocks(); + }); + + nock(baseUrl) + .post('/v2/calls/extensive', { filter: { callIds: ['7782342274025937895'] } }) + .matchHeader( + 'authorization', + 'basic ' + + Buffer.from( + `${FAKE_CREDENTIALS_DATA.gongApi.accessKey}:${FAKE_CREDENTIALS_DATA.gongApi.accessKeySecret}`, + ).toString('base64'), + ) + .reply(200, { + ...gongApiResponse.postCallsExtensive, + records: {}, + calls: [{ metaData: gongApiResponse.postCallsExtensive.calls[0].metaData }], + }) + .post('/v2/calls/extensive', { filter: { callIds: ['7782342274025937896'] } }) + .matchHeader( + 'authorization', + 'bearer ' + FAKE_CREDENTIALS_DATA.gongOAuth2Api.oauthTokenData.access_token, + ) + .reply(200, { + ...gongApiResponse.postCallsExtensive, + records: {}, + calls: [{ metaData: gongApiResponse.postCallsExtensive.calls[0].metaData }], + }); + + const nodeTypes = Helpers.setup(tests); + + test.each(tests)('$description', async (testData) => { + const { result } = await executeWorkflow(testData, nodeTypes); + const resultNodeData = Helpers.getResultNodeData(result, testData); + resultNodeData.forEach(({ nodeName, resultData }) => + expect(resultData).toEqual(testData.output.nodeData[nodeName]), + ); + expect(result.finished).toEqual(true); + }); + }); + + describe('Call description', () => { + const tests: WorkflowTestData[] = [ + { + description: 'should get call with no options true', + input: { + workflowData: { + nodes: [ + { + parameters: {}, + id: '416e4fc1-5055-4e61-854e-a6265256ac26', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + position: [820, 380], + typeVersion: 1, + }, + { + parameters: { + operation: 'get', + call: { + __rl: true, + value: '7782342274025937895', + mode: 'id', + }, + options: {}, + requestOptions: {}, + }, + id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', + name: 'Gong', + type: 'n8n-nodes-base.gong', + typeVersion: 1, + position: [1040, 380], + credentials: { + gongApi: { + id: '1', + name: 'Gong account', + }, + }, + }, + ], + connections: { + "When clicking 'Test workflow'": { + main: [ + [ + { + node: 'Gong', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start'], + nodeData: { + Gong: [[{ json: { metaData: gongNodeResponse.getCall[0].json.metaData } }]], + }, + }, + nock: { + baseUrl, + mocks: [ + { + method: 'post', + path: '/v2/calls/extensive', + statusCode: 200, + requestBody: { filter: { callIds: ['7782342274025937895'] } }, + responseBody: { + ...gongApiResponse.postCallsExtensive, + records: {}, + calls: [{ metaData: gongApiResponse.postCallsExtensive.calls[0].metaData }], + }, + }, + ], + }, + }, + { + description: 'should get call with all options true', + input: { + workflowData: { + nodes: [ + { + parameters: {}, + id: '416e4fc1-5055-4e61-854e-a6265256ac26', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + position: [820, 380], + typeVersion: 1, + }, + { + parameters: { + operation: 'get', + call: { + __rl: true, + value: '7782342274025937895', + mode: 'id', + }, + options: { + properties: [ + 'pointsOfInterest', + 'transcript', + 'media', + 'brief', + 'publicComments', + 'highlights', + 'trackers', + 'topics', + 'structure', + 'parties', + 'callOutcome', + 'outline', + 'keyPoints', + ], + }, + requestOptions: {}, + }, + id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', + name: 'Gong', + type: 'n8n-nodes-base.gong', + typeVersion: 1, + position: [1040, 380], + credentials: { + gongApi: { + id: '1', + name: 'Gong account', + }, + }, + }, + ], + connections: { + "When clicking 'Test workflow'": { + main: [ + [ + { + node: 'Gong', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start'], + nodeData: { + Gong: [gongNodeResponse.getCall], + }, + }, + nock: { + baseUrl, + mocks: [ + { + method: 'post', + path: '/v2/calls/extensive', + statusCode: 200, + requestBody: { + filter: { + callIds: ['7782342274025937895'], + }, + contentSelector: { + exposedFields: { + content: { + pointsOfInterest: true, + brief: true, + highlights: true, + keyPoints: true, + outline: true, + callOutcome: true, + structure: true, + trackers: true, + topics: true, + }, + media: true, + collaboration: { + publicComments: true, + }, + parties: true, + }, + }, + }, + responseBody: { + ...gongApiResponse.postCallsExtensive, + records: {}, + }, + }, + { + method: 'post', + path: '/v2/calls/transcript', + statusCode: 200, + requestBody: { + filter: { + callIds: ['7782342274025937895'], + }, + }, + responseBody: gongApiResponse.postCallsTranscript, + }, + ], + }, + }, + { + description: 'should get all calls with filters', + input: { + workflowData: { + nodes: [ + { + parameters: {}, + id: '416e4fc1-5055-4e61-854e-a6265256ac26', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + position: [820, 380], + typeVersion: 1, + }, + { + parameters: { + returnAll: true, + filters: { + fromDateTime: '2024-01-01T00:00:00Z', + toDateTime: '2024-12-31T00:00:00Z', + workspaceId: '3662366901393371750', + callIds: "={{ ['3662366901393371750', '3662366901393371751'] }}", + primaryUserIds: { + __rl: true, + value: '234599484848423', + mode: 'id', + }, + }, + options: { + properties: ['parties', 'topics'], + }, + requestOptions: {}, + }, + id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', + name: 'Gong', + type: 'n8n-nodes-base.gong', + typeVersion: 1, + position: [1040, 380], + credentials: { + gongApi: { + id: '1', + name: 'Gong account', + }, + }, + }, + ], + connections: { + "When clicking 'Test workflow'": { + main: [ + [ + { + node: 'Gong', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start'], + nodeData: { + Gong: [gongNodeResponse.getAllCall], + }, + }, + nock: { + baseUrl, + mocks: [ + { + method: 'post', + path: '/v2/calls/extensive', + statusCode: 200, + requestBody: { + filter: { + fromDateTime: '2024-01-01T00:00:00.000Z', + toDateTime: '2024-12-31T00:00:00.000Z', + workspaceId: '3662366901393371750', + callIds: ['3662366901393371750', '3662366901393371751'], + primaryUserIds: ['234599484848423'], + }, + contentSelector: { + exposedFields: { + parties: true, + content: { + topics: true, + }, + }, + }, + cursor: undefined, + }, + responseBody: { + ...gongApiResponse.postCallsExtensive, + calls: [ + { + metaData: { + ...gongApiResponse.postCallsExtensive.calls[0].metaData, + parties: [...gongApiResponse.postCallsExtensive.calls[0].parties], + content: { + topics: [...gongApiResponse.postCallsExtensive.calls[0].content.topics], + }, + }, + }, + ], + }, + }, + { + method: 'post', + path: '/v2/calls/extensive', + statusCode: 200, + requestBody: { + filter: { + fromDateTime: '2024-01-01T00:00:00.000Z', + toDateTime: '2024-12-31T00:00:00.000Z', + workspaceId: '3662366901393371750', + callIds: ['3662366901393371750', '3662366901393371751'], + primaryUserIds: ['234599484848423'], + }, + contentSelector: { + exposedFields: { + parties: true, + content: { + topics: true, + }, + }, + }, + cursor: + 'eyJhbGciOiJIUzI1NiJ9.eyJjYWxsSWQiM1M30.6qKwpOcvnuweTZmFRzYdtjs_YwJphJU4QIwWFM', + }, + responseBody: { + ...gongApiResponse.postCallsExtensive, + records: {}, + calls: [ + { + metaData: { + ...gongApiResponse.postCallsExtensive.calls[0].metaData, + id: '7782342274025937896', + url: 'https://app.gong.io/call?id=7782342274025937896', + }, + parties: [...gongApiResponse.postCallsExtensive.calls[0].parties], + content: { + topics: [...gongApiResponse.postCallsExtensive.calls[0].content.topics], + }, + }, + ], + }, + }, + ], + }, + }, + { + description: 'should get limit 50 calls with no options and filters', + input: { + workflowData: { + nodes: [ + { + parameters: {}, + id: '416e4fc1-5055-4e61-854e-a6265256ac26', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + position: [820, 380], + typeVersion: 1, + }, + { + parameters: { + filters: {}, + options: {}, + requestOptions: {}, + }, + id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', + name: 'Gong', + type: 'n8n-nodes-base.gong', + typeVersion: 1, + position: [1040, 380], + credentials: { + gongApi: { + id: '1', + name: 'Gong account', + }, + }, + }, + ], + connections: { + "When clicking 'Test workflow'": { + main: [ + [ + { + node: 'Gong', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start'], + nodeData: { + Gong: [ + Array.from({ length: 50 }, () => ({ ...gongNodeResponse.getAllCallNoOptions[0] })), + ], + }, + }, + nock: { + baseUrl, + mocks: [ + { + method: 'post', + path: '/v2/calls/extensive', + statusCode: 200, + requestBody: { + filter: {}, + }, + responseBody: { + ...gongApiResponse.postCallsExtensive, + calls: Array.from({ length: 100 }, () => ({ + metaData: { ...gongApiResponse.postCallsExtensive.calls[0].metaData }, + })), + }, + }, + ], + }, + }, + { + description: 'should return empty result if no calls found for user', + input: { + workflowData: { + nodes: [ + { + parameters: {}, + id: '416e4fc1-5055-4e61-854e-a6265256ac26', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + position: [820, 380], + typeVersion: 1, + }, + { + parameters: { + filters: { + primaryUserIds: { + __rl: true, + value: '234599484848423', + mode: 'id', + }, + }, + options: {}, + requestOptions: {}, + }, + id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', + name: 'Gong', + type: 'n8n-nodes-base.gong', + typeVersion: 1, + position: [1040, 380], + credentials: { + gongApi: { + id: '1', + name: 'Gong account', + }, + }, + }, + ], + connections: { + "When clicking 'Test workflow'": { + main: [ + [ + { + node: 'Gong', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start'], + nodeData: { + Gong: [[{ json: {} }]], + }, + }, + nock: { + baseUrl, + mocks: [ + { + method: 'post', + path: '/v2/calls/extensive', + statusCode: 404, + requestBody: { + filter: { + primaryUserIds: ['234599484848423'], + }, + cursor: undefined, + }, + responseBody: { + requestId: 'thrhbxbkqiw41ma1cl', + errors: ['No calls found corresponding to the provided filters'], + }, + }, + ], + }, + }, + { + description: 'should handle error response', + input: { + workflowData: { + nodes: [ + { + parameters: {}, + id: '416e4fc1-5055-4e61-854e-a6265256ac26', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + position: [820, 380], + typeVersion: 1, + }, + { + parameters: { + filters: { + workspaceId: '623457276584335', + }, + options: {}, + requestOptions: {}, + }, + id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', + name: 'Gong', + type: 'n8n-nodes-base.gong', + typeVersion: 1, + position: [1040, 380], + credentials: { + gongApi: { + id: '1', + name: 'Gong account', + }, + }, + }, + ], + connections: { + "When clicking 'Test workflow'": { + main: [ + [ + { + node: 'Gong', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start'], + nodeData: { + Gong: [], + }, + }, + nock: { + baseUrl, + mocks: [ + { + method: 'post', + path: '/v2/calls/extensive', + statusCode: 404, + requestBody: { + filter: { + workspaceId: '623457276584335', + }, + }, + responseBody: { + requestId: 'thrhbxbkqiw41ma1cl', + errors: ['No calls found corresponding to the provided filters'], + }, + }, + ], + }, + }, + ]; + + const nodeTypes = Helpers.setup(tests); + + test.each(tests)('$description', async (testData) => { + const { result } = await executeWorkflow(testData, nodeTypes); + + if (testData.description === 'should handle error response') { + // Only matches error message + expect(() => Helpers.getResultNodeData(result, testData)).toThrowError( + 'The resource you are requesting could not be found', + ); + return; + } + + const resultNodeData = Helpers.getResultNodeData(result, testData); + resultNodeData.forEach(({ nodeName, resultData }) => + expect(resultData).toEqual(testData.output.nodeData[nodeName]), + ); + expect(result.finished).toEqual(true); + }); + }); + + describe('User description', () => { + const tests: WorkflowTestData[] = [ + { + description: 'should get user', + input: { + workflowData: { + nodes: [ + { + parameters: {}, + id: '416e4fc1-5055-4e61-854e-a6265256ac26', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + position: [820, 380], + typeVersion: 1, + }, + { + parameters: { + resource: 'user', + user: { + __rl: true, + value: '234599484848423', + mode: 'id', + }, + requestOptions: {}, + }, + id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', + name: 'Gong', + type: 'n8n-nodes-base.gong', + typeVersion: 1, + position: [1040, 380], + credentials: { + gongApi: { + id: '1', + name: 'Gong account', + }, + }, + }, + ], + connections: { + "When clicking 'Test workflow'": { + main: [ + [ + { + node: 'Gong', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start'], + nodeData: { + Gong: [gongNodeResponse.getUser], + }, + }, + nock: { + baseUrl, + mocks: [ + { + method: 'post', + path: '/v2/users/extensive', + statusCode: 200, + requestBody: { filter: { userIds: ['234599484848423'] } }, + responseBody: { + ...gongApiResponse.postUsersExtensive, + records: {}, + }, + }, + ], + }, + }, + { + description: 'should get all users', + input: { + workflowData: { + nodes: [ + { + parameters: {}, + id: '416e4fc1-5055-4e61-854e-a6265256ac26', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + position: [820, 380], + typeVersion: 1, + }, + { + parameters: { + resource: 'user', + operation: 'getAll', + returnAll: true, + filters: { + createdFromDateTime: '2024-01-01T00:00:00Z', + createdToDateTime: '2024-12-31T00:00:00Z', + userIds: '234599484848423, 234599484848424', + }, + requestOptions: {}, + }, + id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', + name: 'Gong', + type: 'n8n-nodes-base.gong', + typeVersion: 1, + position: [1040, 380], + credentials: { + gongApi: { + id: '1', + name: 'Gong account', + }, + }, + }, + ], + connections: { + "When clicking 'Test workflow'": { + main: [ + [ + { + node: 'Gong', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start'], + nodeData: { + Gong: [gongNodeResponse.getAllUser], + }, + }, + nock: { + baseUrl, + mocks: [ + { + method: 'post', + path: '/v2/users/extensive', + statusCode: 200, + requestBody: { + filter: { + createdFromDateTime: '2024-01-01T00:00:00.000Z', + createdToDateTime: '2024-12-31T00:00:00.000Z', + userIds: ['234599484848423', '234599484848424'], + }, + }, + responseBody: gongApiResponse.postUsersExtensive, + }, + { + method: 'post', + path: '/v2/users/extensive', + statusCode: 200, + requestBody: { + filter: { + createdFromDateTime: '2024-01-01T00:00:00.000Z', + createdToDateTime: '2024-12-31T00:00:00.000Z', + userIds: ['234599484848423', '234599484848424'], + }, + cursor: + 'eyJhbGciOiJIUzI1NiJ9.eyJjYWxsSWQiM1M30.6qKwpOcvnuweTZmFRzYdtjs_YwJphJU4QIwWFM', + }, + responseBody: { + ...gongApiResponse.postUsersExtensive, + records: {}, + users: [{ ...gongApiResponse.postUsersExtensive.users[0], id: '234599484848424' }], + }, + }, + ], + }, + }, + { + description: 'should handle error response', + input: { + workflowData: { + nodes: [ + { + parameters: {}, + id: '416e4fc1-5055-4e61-854e-a6265256ac26', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + position: [820, 380], + typeVersion: 1, + }, + { + parameters: { + resource: 'user', + operation: 'getAll', + filters: { + userIds: '234599484848423', + }, + requestOptions: {}, + }, + id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', + name: 'Gong', + type: 'n8n-nodes-base.gong', + typeVersion: 1, + position: [1040, 380], + credentials: { + gongApi: { + id: '1', + name: 'Gong account', + }, + }, + }, + ], + connections: { + "When clicking 'Test workflow'": { + main: [ + [ + { + node: 'Gong', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start'], + nodeData: { + Gong: [], + }, + }, + nock: { + baseUrl, + mocks: [ + { + method: 'post', + path: '/v2/users/extensive', + statusCode: 404, + requestBody: { + filter: { + userIds: ['234599484848423'], + }, + }, + responseBody: { + requestId: '26r8maav84ehguoddd7', + errors: ['The following userIds were not found: 234599484848423'], + }, + }, + ], + }, + }, + ]; + + const nodeTypes = Helpers.setup(tests); + + test.each(tests)('$description', async (testData) => { + const { result } = await executeWorkflow(testData, nodeTypes); + + if (testData.description === 'should handle error response') { + expect(() => Helpers.getResultNodeData(result, testData)).toThrow( + "The Users IDs don't match any existing user", + ); + return; + } + + const resultNodeData = Helpers.getResultNodeData(result, testData); + resultNodeData.forEach(({ nodeName, resultData }) => + expect(resultData).toEqual(testData.output.nodeData[nodeName]), + ); + expect(result.finished).toEqual(true); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Gong/test/mocks.ts b/packages/nodes-base/nodes/Gong/test/mocks.ts new file mode 100644 index 0000000000..621e6c0d72 --- /dev/null +++ b/packages/nodes-base/nodes/Gong/test/mocks.ts @@ -0,0 +1,781 @@ +/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */ +export const gongApiResponse = { + // https://gong.app.gong.io/settings/api/documentation#post-/v2/calls + postCalls: { + requestId: '4al018gzaztcr8nbukw', + callId: '7782342274025937895', + }, + // https://gong.app.gong.io/settings/api/documentation#put-/v2/calls/-id-/media + postCallsMedia: { + requestId: '4al018gzaztcr8nbukw', + callId: '7782342274025937895', + url: 'https://app.gong.io/call?id=7782342274025937895', + }, + // https://gong.app.gong.io/settings/api/documentation#post-/v2/calls/extensive + postCallsExtensive: { + requestId: '4al018gzaztcr8nbukw', + records: { + totalRecords: 263, + currentPageSize: 100, + currentPageNumber: 0, + cursor: 'eyJhbGciOiJIUzI1NiJ9.eyJjYWxsSWQiM1M30.6qKwpOcvnuweTZmFRzYdtjs_YwJphJU4QIwWFM', + }, + calls: [ + { + metaData: { + id: '7782342274025937895', + url: 'https://app.gong.io/call?id=7782342274025937895', + title: 'Example call', + scheduled: 1518863400, + started: 1518863400, + duration: 460, + primaryUserId: '234599484848423', + direction: 'Inbound', + system: 'Outreach', + scope: 'Internal', + media: 'Video', + language: 'eng', + workspaceId: '623457276584334', + sdrDisposition: 'Got the gatekeeper', + clientUniqueId: '7JEHFRGXDDZFEW2FC4U', + customData: 'Conference Call', + purpose: 'Demo Call', + meetingUrl: 'https://zoom.us/j/123', + isPrivate: false, + calendarEventId: 'abcde@google.com', + }, + context: [ + { + system: 'Salesforce', + objects: [ + { + objectType: 'Opportunity', + objectId: '0013601230sV7grAAC', + fields: [ + { + name: 'name', + value: 'Gong Inc.', + }, + ], + timing: 'Now', + }, + ], + }, + ], + parties: [ + { + id: '56825452554556', + emailAddress: 'test@test.com', + name: 'Test User', + title: 'Enterprise Account Executive', + userId: '234599484848423', + speakerId: '6432345678555530067', + context: [ + { + system: 'Salesforce', + objects: [ + { + objectType: 'Contact', + objectId: '0013601230sV7grAAC', + fields: [ + { + name: 'name', + value: 'Gong Inc.', + }, + ], + timing: 'Now', + }, + ], + }, + ], + affiliation: 'Internal', + phoneNumber: '+1 123-567-8989', + methods: ['Invitee'], + }, + ], + content: { + structure: [ + { + name: 'Meeting Setup', + duration: 67, + }, + ], + trackers: [ + { + id: '56825452554556', + name: 'Competitors', + count: 7, + type: 'KEYWORD', + occurrences: [ + { + startTime: 32.56, + speakerId: '234599484848423', + }, + ], + phrases: [ + { + count: 5, + occurrences: [ + { + startTime: 32.56, + speakerId: '234599484848423', + }, + ], + phrase: 'Walmart', + }, + ], + }, + ], + topics: [ + { + name: 'Objections', + duration: 86, + }, + ], + pointsOfInterest: { + actionItems: [ + { + snippetStartTime: 26, + snippetEndTime: 26, + speakerID: '56825452554556', + snippet: + "And I'll send you an invite with a link that you can use at that time as well.", + }, + ], + }, + brief: 'string', + outline: [ + { + section: 'string', + startTime: 0.5, + duration: 0.5, + items: [ + { + text: 'string', + startTime: 0.5, + }, + ], + }, + ], + highlights: [ + { + title: 'string', + items: [ + { + text: 'string', + startTimes: [0.5], + }, + ], + }, + ], + callOutcome: { + id: 'MEETING_BOOKED', + category: 'Answered', + name: 'Meeting booked', + }, + keyPoints: [ + { + text: 'string', + }, + ], + }, + interaction: { + speakers: [ + { + id: '56825452554556', + userId: '234599484848423', + talkTime: 145, + }, + ], + interactionStats: [ + { + name: 'Interactivity', + value: 56, + }, + ], + video: [ + { + name: 'Browser', + duration: 218, + }, + ], + questions: { + companyCount: 0, + nonCompanyCount: 0, + }, + }, + collaboration: { + publicComments: [ + { + id: '6843152929075440037', + audioStartTime: 26, + audioEndTime: 26, + commenterUserId: '234599484848423', + comment: 'new comment', + posted: 1518863400, + inReplyTo: '792390015966656336', + duringCall: false, + }, + ], + }, + media: { + audioUrl: 'http://example.com', + videoUrl: 'http://example.com', + }, + }, + ], + }, + // https://gong.app.gong.io/settings/api/documentation#post-/v2/calls/transcript + postCallsTranscript: { + requestId: '4al018gzaztcr8nbukw', + records: { + totalRecords: 1, + currentPageSize: 1, + currentPageNumber: 0, + }, + callTranscripts: [ + { + callId: '7782342274025937895', + transcript: [ + { + speakerId: '6432345678555530067', + topic: 'Objections', + sentences: [ + { + start: 460230, + end: 462343, + text: 'No wait, I think we should check that out first.', + }, + ], + }, + ], + }, + ], + }, + // https://gong.app.gong.io/settings/api/documentation#post-/v2/users/extensive + postUsersExtensive: { + requestId: '4al018gzaztcr8nbukw', + records: { + totalRecords: 263, + currentPageSize: 100, + currentPageNumber: 0, + cursor: 'eyJhbGciOiJIUzI1NiJ9.eyJjYWxsSWQiM1M30.6qKwpOcvnuweTZmFRzYdtjs_YwJphJU4QIwWFM', + }, + users: [ + { + id: '234599484848423', + emailAddress: 'test@test.com', + created: '2018-02-17T02:30:00-08:00', + active: true, + emailAliases: ['testAlias@test.com'], + trustedEmailAddress: 'test@test.com', + firstName: 'Jon', + lastName: 'Snow', + title: 'Enterprise Account Executive', + phoneNumber: '+1 123-567-8989', + extension: '123', + personalMeetingUrls: ['https://zoom.us/j/123'], + settings: { + webConferencesRecorded: true, + preventWebConferenceRecording: false, + telephonyCallsImported: false, + emailsImported: true, + preventEmailImport: false, + nonRecordedMeetingsImported: true, + gongConnectEnabled: true, + }, + managerId: '563515258458745', + meetingConsentPageUrl: + 'https://join.gong.io/my-company/jon.snow?tkn=MoNpS9tMNt8BK7EZxQpSJl', + spokenLanguages: [ + { + language: 'es-ES', + primary: true, + }, + ], + }, + ], + }, +}; + +export const gongNodeResponse = { + getCall: [ + { + json: { + metaData: { + id: '7782342274025937895', + url: 'https://app.gong.io/call?id=7782342274025937895', + title: 'Example call', + scheduled: 1518863400, + started: 1518863400, + duration: 460, + primaryUserId: '234599484848423', + direction: 'Inbound', + system: 'Outreach', + scope: 'Internal', + media: 'Video', + language: 'eng', + workspaceId: '623457276584334', + sdrDisposition: 'Got the gatekeeper', + clientUniqueId: '7JEHFRGXDDZFEW2FC4U', + customData: 'Conference Call', + purpose: 'Demo Call', + meetingUrl: 'https://zoom.us/j/123', + isPrivate: false, + calendarEventId: 'abcde@google.com', + }, + context: [ + { + system: 'Salesforce', + objects: [ + { + objectType: 'Opportunity', + objectId: '0013601230sV7grAAC', + fields: [ + { + name: 'name', + value: 'Gong Inc.', + }, + ], + timing: 'Now', + }, + ], + }, + ], + parties: [ + { + id: '56825452554556', + emailAddress: 'test@test.com', + name: 'Test User', + title: 'Enterprise Account Executive', + userId: '234599484848423', + speakerId: '6432345678555530067', + context: [ + { + system: 'Salesforce', + objects: [ + { + objectType: 'Contact', + objectId: '0013601230sV7grAAC', + fields: [ + { + name: 'name', + value: 'Gong Inc.', + }, + ], + timing: 'Now', + }, + ], + }, + ], + affiliation: 'Internal', + phoneNumber: '+1 123-567-8989', + methods: ['Invitee'], + }, + ], + content: { + structure: [ + { + name: 'Meeting Setup', + duration: 67, + }, + ], + trackers: [ + { + id: '56825452554556', + name: 'Competitors', + count: 7, + type: 'KEYWORD', + occurrences: [ + { + startTime: 32.56, + speakerId: '234599484848423', + }, + ], + phrases: [ + { + count: 5, + occurrences: [ + { + startTime: 32.56, + speakerId: '234599484848423', + }, + ], + phrase: 'Walmart', + }, + ], + }, + ], + topics: [ + { + name: 'Objections', + duration: 86, + }, + ], + pointsOfInterest: { + actionItems: [ + { + snippetStartTime: 26, + snippetEndTime: 26, + speakerID: '56825452554556', + snippet: + "And I'll send you an invite with a link that you can use at that time as well.", + }, + ], + }, + brief: 'string', + outline: [ + { + section: 'string', + startTime: 0.5, + duration: 0.5, + items: [ + { + text: 'string', + startTime: 0.5, + }, + ], + }, + ], + highlights: [ + { + title: 'string', + items: [ + { + text: 'string', + startTimes: [0.5], + }, + ], + }, + ], + callOutcome: { + id: 'MEETING_BOOKED', + category: 'Answered', + name: 'Meeting booked', + }, + keyPoints: [ + { + text: 'string', + }, + ], + }, + interaction: { + speakers: [ + { + id: '56825452554556', + userId: '234599484848423', + talkTime: 145, + }, + ], + interactionStats: [ + { + name: 'Interactivity', + value: 56, + }, + ], + video: [ + { + name: 'Browser', + duration: 218, + }, + ], + questions: { + companyCount: 0, + nonCompanyCount: 0, + }, + }, + collaboration: { + publicComments: [ + { + id: '6843152929075440037', + audioStartTime: 26, + audioEndTime: 26, + commenterUserId: '234599484848423', + comment: 'new comment', + posted: 1518863400, + inReplyTo: '792390015966656336', + duringCall: false, + }, + ], + }, + media: { + audioUrl: 'http://example.com', + videoUrl: 'http://example.com', + }, + transcript: [ + { + speakerId: '6432345678555530067', + topic: 'Objections', + sentences: [ + { + start: 460230, + end: 462343, + text: 'No wait, I think we should check that out first.', + }, + ], + }, + ], + }, + }, + ], + getAllCall: [ + { + json: { + id: '7782342274025937895', + url: 'https://app.gong.io/call?id=7782342274025937895', + title: 'Example call', + scheduled: 1518863400, + started: 1518863400, + duration: 460, + primaryUserId: '234599484848423', + direction: 'Inbound', + system: 'Outreach', + scope: 'Internal', + media: 'Video', + language: 'eng', + workspaceId: '623457276584334', + sdrDisposition: 'Got the gatekeeper', + clientUniqueId: '7JEHFRGXDDZFEW2FC4U', + customData: 'Conference Call', + purpose: 'Demo Call', + meetingUrl: 'https://zoom.us/j/123', + isPrivate: false, + calendarEventId: 'abcde@google.com', + content: { + topics: [ + { + name: 'Objections', + duration: 86, + }, + ], + }, + parties: [ + { + id: '56825452554556', + emailAddress: 'test@test.com', + name: 'Test User', + title: 'Enterprise Account Executive', + userId: '234599484848423', + speakerId: '6432345678555530067', + context: [ + { + system: 'Salesforce', + objects: [ + { + objectType: 'Contact', + objectId: '0013601230sV7grAAC', + fields: [ + { + name: 'name', + value: 'Gong Inc.', + }, + ], + timing: 'Now', + }, + ], + }, + ], + affiliation: 'Internal', + phoneNumber: '+1 123-567-8989', + methods: ['Invitee'], + }, + ], + }, + }, + { + json: { + id: '7782342274025937896', + url: 'https://app.gong.io/call?id=7782342274025937896', + title: 'Example call', + scheduled: 1518863400, + started: 1518863400, + duration: 460, + primaryUserId: '234599484848423', + direction: 'Inbound', + system: 'Outreach', + scope: 'Internal', + media: 'Video', + language: 'eng', + workspaceId: '623457276584334', + sdrDisposition: 'Got the gatekeeper', + clientUniqueId: '7JEHFRGXDDZFEW2FC4U', + customData: 'Conference Call', + purpose: 'Demo Call', + meetingUrl: 'https://zoom.us/j/123', + isPrivate: false, + calendarEventId: 'abcde@google.com', + content: { + topics: [ + { + name: 'Objections', + duration: 86, + }, + ], + }, + parties: [ + { + id: '56825452554556', + emailAddress: 'test@test.com', + name: 'Test User', + title: 'Enterprise Account Executive', + userId: '234599484848423', + speakerId: '6432345678555530067', + context: [ + { + system: 'Salesforce', + objects: [ + { + objectType: 'Contact', + objectId: '0013601230sV7grAAC', + fields: [ + { + name: 'name', + value: 'Gong Inc.', + }, + ], + timing: 'Now', + }, + ], + }, + ], + affiliation: 'Internal', + phoneNumber: '+1 123-567-8989', + methods: ['Invitee'], + }, + ], + }, + }, + ], + getAllCallNoOptions: [ + { + json: { + id: '7782342274025937895', + url: 'https://app.gong.io/call?id=7782342274025937895', + title: 'Example call', + scheduled: 1518863400, + started: 1518863400, + duration: 460, + primaryUserId: '234599484848423', + direction: 'Inbound', + system: 'Outreach', + scope: 'Internal', + media: 'Video', + language: 'eng', + workspaceId: '623457276584334', + sdrDisposition: 'Got the gatekeeper', + clientUniqueId: '7JEHFRGXDDZFEW2FC4U', + customData: 'Conference Call', + purpose: 'Demo Call', + meetingUrl: 'https://zoom.us/j/123', + isPrivate: false, + calendarEventId: 'abcde@google.com', + }, + }, + ], + getUser: [ + { + json: { + id: '234599484848423', + emailAddress: 'test@test.com', + created: '2018-02-17T02:30:00-08:00', + active: true, + emailAliases: ['testAlias@test.com'], + trustedEmailAddress: 'test@test.com', + firstName: 'Jon', + lastName: 'Snow', + title: 'Enterprise Account Executive', + phoneNumber: '+1 123-567-8989', + extension: '123', + personalMeetingUrls: ['https://zoom.us/j/123'], + settings: { + webConferencesRecorded: true, + preventWebConferenceRecording: false, + telephonyCallsImported: false, + emailsImported: true, + preventEmailImport: false, + nonRecordedMeetingsImported: true, + gongConnectEnabled: true, + }, + managerId: '563515258458745', + meetingConsentPageUrl: + 'https://join.gong.io/my-company/jon.snow?tkn=MoNpS9tMNt8BK7EZxQpSJl', + spokenLanguages: [ + { + language: 'es-ES', + primary: true, + }, + ], + }, + }, + ], + getAllUser: [ + { + json: { + id: '234599484848423', + emailAddress: 'test@test.com', + created: '2018-02-17T02:30:00-08:00', + active: true, + emailAliases: ['testAlias@test.com'], + trustedEmailAddress: 'test@test.com', + firstName: 'Jon', + lastName: 'Snow', + title: 'Enterprise Account Executive', + phoneNumber: '+1 123-567-8989', + extension: '123', + personalMeetingUrls: ['https://zoom.us/j/123'], + settings: { + webConferencesRecorded: true, + preventWebConferenceRecording: false, + telephonyCallsImported: false, + emailsImported: true, + preventEmailImport: false, + nonRecordedMeetingsImported: true, + gongConnectEnabled: true, + }, + managerId: '563515258458745', + meetingConsentPageUrl: + 'https://join.gong.io/my-company/jon.snow?tkn=MoNpS9tMNt8BK7EZxQpSJl', + spokenLanguages: [ + { + language: 'es-ES', + primary: true, + }, + ], + }, + }, + { + json: { + id: '234599484848424', + emailAddress: 'test@test.com', + created: '2018-02-17T02:30:00-08:00', + active: true, + emailAliases: ['testAlias@test.com'], + trustedEmailAddress: 'test@test.com', + firstName: 'Jon', + lastName: 'Snow', + title: 'Enterprise Account Executive', + phoneNumber: '+1 123-567-8989', + extension: '123', + personalMeetingUrls: ['https://zoom.us/j/123'], + settings: { + webConferencesRecorded: true, + preventWebConferenceRecording: false, + telephonyCallsImported: false, + emailsImported: true, + preventEmailImport: false, + nonRecordedMeetingsImported: true, + gongConnectEnabled: true, + }, + managerId: '563515258458745', + meetingConsentPageUrl: + 'https://join.gong.io/my-company/jon.snow?tkn=MoNpS9tMNt8BK7EZxQpSJl', + spokenLanguages: [ + { + language: 'es-ES', + primary: true, + }, + ], + }, + }, + ], +}; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 5d34b070de..f9a78a21a1 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -125,6 +125,8 @@ "dist/credentials/GitlabOAuth2Api.credentials.js", "dist/credentials/GitPassword.credentials.js", "dist/credentials/GmailOAuth2Api.credentials.js", + "dist/credentials/GongApi.credentials.js", + "dist/credentials/GongOAuth2Api.credentials.js", "dist/credentials/GoogleAdsOAuth2Api.credentials.js", "dist/credentials/GoogleAnalyticsOAuth2Api.credentials.js", "dist/credentials/GoogleApi.credentials.js", @@ -517,6 +519,7 @@ "dist/nodes/Github/GithubTrigger.node.js", "dist/nodes/Gitlab/Gitlab.node.js", "dist/nodes/Gitlab/GitlabTrigger.node.js", + "dist/nodes/Gong/Gong.node.js", "dist/nodes/Google/Ads/GoogleAds.node.js", "dist/nodes/Google/Analytics/GoogleAnalytics.node.js", "dist/nodes/Google/BigQuery/GoogleBigQuery.node.js", diff --git a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts index fbf6d00b71..eaccd22fdc 100644 --- a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts +++ b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts @@ -54,6 +54,32 @@ BQIDAQAB airtableApi: { apiKey: 'key123', }, + gongApi: { + baseUrl: 'https://api.gong.io', + accessKey: 'accessKey123', + accessKeySecret: 'accessKeySecret123', + }, + gongOAuth2Api: { + grantType: 'authorizationCode', + authUrl: 'https://app.gong.io/oauth2/authorize', + accessTokenUrl: 'https://app.gong.io/oauth2/generate-customer-token', + clientId: 'CLIENTID', + clientSecret: 'CLIENTSECRET', + scope: + 'api:calls:read:transcript api:provisioning:read api:workspaces:read api:meetings:user:delete api:crm:get-objects api:data-privacy:delete api:crm:schema api:flows:write api:crm:upload api:meetings:integration:status api:calls:read:extensive api:meetings:user:update api:integration-settings:write api:settings:scorecards:read api:stats:scorecards api:stats:interaction api:stats:user-actions api:crm:integration:delete api:calls:read:basic api:calls:read:media-url api:digital-interactions:write api:crm:integrations:read api:library:read api:data-privacy:read api:users:read api:logs:read api:calls:create api:meetings:user:create api:stats:user-actions:detailed api:settings:trackers:read api:crm:integration:register api:provisioning:read-write api:engagement-data:write api:permission-profile:read api:permission-profile:write api:flows:read api:crm-calls:manual-association:read', + authQueryParameters: '', + authentication: 'header', + oauthTokenData: { + access_token: 'ACCESSTOKEN', + refresh_token: 'REFRESHTOKEN', + scope: + 'api:calls:read:transcript api:provisioning:read api:workspaces:read api:meetings:user:delete api:crm:get-objects api:data-privacy:delete api:crm:schema api:flows:write api:crm:upload api:meetings:integration:status api:calls:read:extensive api:meetings:user:update api:integration-settings:write api:settings:scorecards:read api:stats:scorecards api:stats:interaction api:stats:user-actions api:crm:integration:delete api:calls:read:basic api:calls:read:media-url api:digital-interactions:write api:crm:integrations:read api:library:read api:data-privacy:read api:users:read api:logs:read api:calls:create api:meetings:user:create api:stats:user-actions:detailed api:settings:trackers:read api:crm:integration:register api:provisioning:read-write api:engagement-data:write api:permission-profile:read api:permission-profile:write api:flows:read api:crm-calls:manual-association:read', + token_type: 'bearer', + expires_in: 86400, + api_base_url_for_customer: 'https://api.gong.io', + }, + baseUrl: 'https://api.gong.io', + }, n8nApi: { apiKey: 'key123', baseUrl: 'https://test.app.n8n.cloud/api/v1', diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index 14d9494e03..98e8ebf918 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -94,7 +94,7 @@ class CredentialType implements ICredentialTypes { const credentialTypes = new CredentialType(); -class CredentialsHelper extends ICredentialsHelper { +export class CredentialsHelper extends ICredentialsHelper { getCredentialsProperties() { return []; } @@ -167,6 +167,8 @@ export function WorkflowExecuteAdditionalData( return mock({ credentialsHelper: new CredentialsHelper(), hooks: new WorkflowHooks(hookFunctions, 'trigger', '1', mock()), + // Get from node.parameters + currentNodeParameters: undefined, }); } diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index a3e3fda2e8..3d7c126e90 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2377,7 +2377,7 @@ export interface WorkflowTestData { nock?: { baseUrl: string; mocks: Array<{ - method: 'get' | 'post'; + method: 'delete' | 'get' | 'post' | 'put'; path: string; requestBody?: RequestBodyMatcher; statusCode: number; diff --git a/packages/workflow/src/RoutingNode.ts b/packages/workflow/src/RoutingNode.ts index fe7fac6790..4b3ed7f597 100644 --- a/packages/workflow/src/RoutingNode.ts +++ b/packages/workflow/src/RoutingNode.ts @@ -41,6 +41,7 @@ import type { PostReceiveAction, JsonObject, CloseFunction, + INodeCredentialDescription, } from './Interfaces'; import * as NodeHelpers from './NodeHelpers'; import { sleep } from './utils'; @@ -88,11 +89,6 @@ export class RoutingNode { const items = inputData.main[0] as INodeExecutionData[]; const returnData: INodeExecutionData[] = []; - let credentialType: string | undefined; - - if (nodeType.description.credentials?.length) { - credentialType = nodeType.description.credentials[0].name; - } const closeFunctions: CloseFunction[] = []; const executeFunctions = nodeExecuteFunctions.getExecuteFunctions( this.workflow, @@ -108,24 +104,45 @@ export class RoutingNode { abortSignal, ); + let credentialDescription: INodeCredentialDescription | undefined; + + if (nodeType.description.credentials?.length) { + if (nodeType.description.credentials.length === 1) { + credentialDescription = nodeType.description.credentials[0]; + } else { + const authenticationMethod = executeFunctions.getNodeParameter( + 'authentication', + 0, + ) as string; + credentialDescription = nodeType.description.credentials.find((x) => + x.displayOptions?.show?.authentication?.includes(authenticationMethod), + ); + if (!credentialDescription) { + throw new NodeOperationError( + this.node, + `Node type "${this.node.type}" does not have any credentials of type "${authenticationMethod}" defined`, + { level: 'warning' }, + ); + } + } + } + let credentials: ICredentialDataDecryptedObject | undefined; if (credentialsDecrypted) { credentials = credentialsDecrypted.data; - } else if (credentialType) { + } else if (credentialDescription) { try { credentials = - (await executeFunctions.getCredentials(credentialType)) || - {}; + (await executeFunctions.getCredentials( + credentialDescription.name, + )) || {}; } catch (error) { - if ( - nodeType.description.credentials?.length && - nodeType.description.credentials[0].required - ) { + if (credentialDescription.required) { // Only throw error if credential is mandatory throw error; } else { // Do not request cred type since it doesn't exist - credentialType = undefined; + credentialDescription = undefined; } } } @@ -282,7 +299,7 @@ export class RoutingNode { itemContext[itemIndex].thisArgs, itemIndex, runIndex, - credentialType, + credentialDescription?.name, itemContext[itemIndex].requestData.requestOperations, credentialsDecrypted, ), From e7a4b0da01aff146f760c99d6676f6453b1d945f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 16 Oct 2024 11:47:05 +0200 Subject: [PATCH 03/57] chore: Add `server_type` tag to Sentry errors (no-changelog) (#11277) --- packages/cli/src/error-reporting.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/error-reporting.ts b/packages/cli/src/error-reporting.ts index e429bdbd30..d90229130f 100644 --- a/packages/cli/src/error-reporting.ts +++ b/packages/cli/src/error-reporting.ts @@ -3,6 +3,7 @@ import { GlobalConfig } from '@n8n/config'; import { QueryFailedError } from '@n8n/typeorm'; import { AxiosError } from 'axios'; import { createHash } from 'crypto'; +import { InstanceSettings } from 'n8n-core'; import { ErrorReporterProxy, ApplicationError } from 'n8n-workflow'; import Container from 'typedi'; @@ -30,7 +31,7 @@ export const initErrorHandling = async () => { DEPLOYMENT_NAME: serverName, } = process.env; - const { init, captureException } = await import('@sentry/node'); + const { init, captureException, setTag } = await import('@sentry/node'); const { RewriteFrames } = await import('@sentry/integrations'); const { Integrations } = await import('@sentry/node'); @@ -95,6 +96,8 @@ export const initErrorHandling = async () => { }, }); + setTag('server_type', Container.get(InstanceSettings).instanceType); + ErrorReporterProxy.init({ report: (error, options) => captureException(error, options), }); From 7f5f0a9df3b3fae6e2f9787443ac1cf9415d5932 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 16 Oct 2024 13:27:00 +0300 Subject: [PATCH 04/57] fix: Expressions display actual result of evaluating expression inside string (#11257) Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com> --- .../src/composables/useExpressionEditor.ts | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/editor-ui/src/composables/useExpressionEditor.ts b/packages/editor-ui/src/composables/useExpressionEditor.ts index 9b52589a7a..0393618dfa 100644 --- a/packages/editor-ui/src/composables/useExpressionEditor.ts +++ b/packages/editor-ui/src/composables/useExpressionEditor.ts @@ -352,6 +352,12 @@ export const useExpressionEditor = ({ * - `This is a {{ [] }} test` displays as `This is a test`. * - `{{ [] }}` displays as `[Array: []]`. * + * - `This is a {{ {} }} test` displays as `This is a [object Object] test`. + * - `{{ {} }}` displays as `[Object: {}]`. + * + * - `This is a {{ [{}] }} test` displays as `This is a [object Object] test`. + * - `{{ [] }}` displays as `[Array: []]`. + * * Some segments display differently based on context: * * Date displays as @@ -366,13 +372,29 @@ export const useExpressionEditor = ({ .map((s) => { if (cachedSegments.length <= 1 || s.kind !== 'resolvable') return s; - if (typeof s.resolved === 'string' && /\[Object: "\d{4}-\d{2}-\d{2}T/.test(s.resolved)) { - const utcDateString = s.resolved.replace(/(\[Object: "|\"\])/g, ''); - s.resolved = new Date(utcDateString).toString(); - } + if (typeof s.resolved === 'string') { + let resolved = s.resolved; - if (typeof s.resolved === 'string' && /\[Array:\s\[.+\]\]/.test(s.resolved)) { - s.resolved = s.resolved.replace(/(\[Array: \[|\])/g, ''); + if (/\[Object: "\d{4}-\d{2}-\d{2}T/.test(resolved)) { + const utcDateString = resolved.replace(/(\[Object: "|\"\])/g, ''); + resolved = new Date(utcDateString).toString(); + } + + if (/\[Object:\s(\{.+\}|\{\})\]/.test(resolved)) { + resolved = resolved.replace(/(\[Object: |\]$)/g, ''); + try { + resolved = String(JSON.parse(resolved)); + } catch (error) {} + } + + if (/\[Array:\s\[.+\]\]/.test(resolved)) { + resolved = resolved.replace(/(\[Array: |\]$)/g, ''); + try { + resolved = String(JSON.parse(resolved)); + } catch (error) {} + } + + s.resolved = resolved; } return s; From c090fcb3406bbb6bb38a4f6072db3724b03ace3b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 16 Oct 2024 14:26:07 +0300 Subject: [PATCH 05/57] :rocket: Release 1.64.0 (#11281) Co-authored-by: michael-radency <88898367+michael-radency@users.noreply.github.com> --- CHANGELOG.md | 39 ++++++++++++++++++++++ package.json | 2 +- packages/@n8n/api-types/package.json | 2 +- packages/@n8n/chat/package.json | 2 +- packages/@n8n/config/package.json | 2 +- packages/@n8n/nodes-langchain/package.json | 2 +- packages/@n8n/task-runner/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/design-system/package.json | 2 +- packages/editor-ui/package.json | 2 +- packages/node-dev/package.json | 2 +- packages/nodes-base/package.json | 2 +- packages/workflow/package.json | 2 +- 14 files changed, 52 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab14dc462e..b1558420d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,42 @@ +# [1.64.0](https://github.com/n8n-io/n8n/compare/n8n@1.63.0...n8n@1.64.0) (2024-10-16) + + +### Bug Fixes + +* Adjust arrow button colors in dark mode ([#11248](https://github.com/n8n-io/n8n/issues/11248)) ([439132c](https://github.com/n8n-io/n8n/commit/439132c291a812d57702c94eaa12878394ac4c69)) +* **core:** Ensure error reporter does not promote `info` to `error` messages ([#11245](https://github.com/n8n-io/n8n/issues/11245)) ([a7fc7fc](https://github.com/n8n-io/n8n/commit/a7fc7fc22997acec86dc94386c95349fd018f4ae)) +* **core:** Override executions mode if `regular` during worker startup ([#11250](https://github.com/n8n-io/n8n/issues/11250)) ([c0aa28c](https://github.com/n8n-io/n8n/commit/c0aa28c6cf3f77b04e04663217c9df8e3803ed3f)) +* **core:** Wrap nodes for tool use at a suitable time ([#11238](https://github.com/n8n-io/n8n/issues/11238)) ([c2fb881](https://github.com/n8n-io/n8n/commit/c2fb881d61291209802438d95892d052f5c82d43)) +* Don't show pinned data tooltip for pinned nodes ([#11249](https://github.com/n8n-io/n8n/issues/11249)) ([c2ad156](https://github.com/n8n-io/n8n/commit/c2ad15646d326a8f71e314d54efe202a5bcdd296)) +* **editor:** Bring back the "Forgot password" link on SigninView ([#11216](https://github.com/n8n-io/n8n/issues/11216)) ([4e78c46](https://github.com/n8n-io/n8n/commit/4e78c46a7450c7fc0694369944d4fb446cef2348)) +* **editor:** Fix chat crashing when rendering output-parsed content ([#11210](https://github.com/n8n-io/n8n/issues/11210)) ([4aaebfd](https://github.com/n8n-io/n8n/commit/4aaebfd4358f590e98c453ad4e65cc2c9d0f76f8)) +* **editor:** Make submit in ChangePasswordView work again ([#11227](https://github.com/n8n-io/n8n/issues/11227)) ([4f27b39](https://github.com/n8n-io/n8n/commit/4f27b39b45b58779d363980241e6e5e11b58f5da)) +* Expressions display actual result of evaluating expression inside string ([#11257](https://github.com/n8n-io/n8n/issues/11257)) ([7f5f0a9](https://github.com/n8n-io/n8n/commit/7f5f0a9df3b3fae6e2f9787443ac1cf9415d5932)) +* **Google Ads Node:** Update to use v17 api ([#11243](https://github.com/n8n-io/n8n/issues/11243)) ([3d97f02](https://github.com/n8n-io/n8n/commit/3d97f02a8d2b6e5bc7c97c5271bed97417ecacd2)) +* **Google Calendar Node:** Fix issue with conference data types not loading ([#11185](https://github.com/n8n-io/n8n/issues/11185)) ([4012758](https://github.com/n8n-io/n8n/commit/401275884e5db0287e4eeffb3c7497dd5e024880)) +* **Google Calendar Node:** Mode to add or replace attendees in event update ([#11132](https://github.com/n8n-io/n8n/issues/11132)) ([6c6a8ef](https://github.com/n8n-io/n8n/commit/6c6a8efdea83cf7194304ce089d7b72d8f6c1a9d)) +* **HTTP Request Tool Node:** Respond with an error when receive binary response ([#11219](https://github.com/n8n-io/n8n/issues/11219)) ([0d23a7f](https://github.com/n8n-io/n8n/commit/0d23a7fb5ba41545f70c4848d30b90af91b1e7e6)) +* **MySQL Node:** Fix "Maximum call stack size exceeded" error when handling a large number of rows ([#11242](https://github.com/n8n-io/n8n/issues/11242)) ([b7ee0c4](https://github.com/n8n-io/n8n/commit/b7ee0c4087eae346bc7e5360130d6c812dbe99db)) +* **n8n Trigger Node:** Merge with Workflow Trigger node ([#11174](https://github.com/n8n-io/n8n/issues/11174)) ([6ec6b51](https://github.com/n8n-io/n8n/commit/6ec6b5197ae97eb86496effd458fcc0b9b223ef3)) +* **OpenAI Node:** Fix tool parameter parsing issue ([#11201](https://github.com/n8n-io/n8n/issues/11201)) ([5a1d81a](https://github.com/n8n-io/n8n/commit/5a1d81ad917fde5cd6a387fe2d4ec6aab6b71349)) +* **Set Node:** Fix issue with UI properties not being hidden ([#11263](https://github.com/n8n-io/n8n/issues/11263)) ([1affc27](https://github.com/n8n-io/n8n/commit/1affc27b6bf9a559061a06f92bebe8167d938665)) +* **Strava Trigger Node:** Fix issue with webhook not being deleted ([#11226](https://github.com/n8n-io/n8n/issues/11226)) ([566529c](https://github.com/n8n-io/n8n/commit/566529ca1149988a54a58b3c34bbe4d9f1add6db)) + + +### Features + +* Add tracking for node errors and update node graph ([#11060](https://github.com/n8n-io/n8n/issues/11060)) ([d3b05f1](https://github.com/n8n-io/n8n/commit/d3b05f1c54e62440666297d8e484ccd22168da48)) +* **core:** Dedupe ([#10101](https://github.com/n8n-io/n8n/issues/10101)) ([52dd2c7](https://github.com/n8n-io/n8n/commit/52dd2c76196c6895b47145c2b85a6895ce2874d4)) +* **editor:** Send workflow context to assistant store ([#11135](https://github.com/n8n-io/n8n/issues/11135)) ([fade9e4](https://github.com/n8n-io/n8n/commit/fade9e43c84a0ae1fbc80f3ee546a418970e2380)) +* **Gong Node:** New node ([#10777](https://github.com/n8n-io/n8n/issues/10777)) ([785b47f](https://github.com/n8n-io/n8n/commit/785b47feb3b83cf36aaed57123f8baca2bbab307)) + + +### Performance Improvements + +* **Google Sheets Node:** Don't load whole spreadsheet dataset to determine columns when appending data ([#11235](https://github.com/n8n-io/n8n/issues/11235)) ([26ad091](https://github.com/n8n-io/n8n/commit/26ad091f473bca4e5d3bdc257e0818be02e52db5)) + + + # [1.63.0](https://github.com/n8n-io/n8n/compare/n8n@1.62.1...n8n@1.63.0) (2024-10-09) diff --git a/package.json b/package.json index ee888f53dd..d25f0a13b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.63.0", + "version": "1.64.0", "private": true, "engines": { "node": ">=20.15", diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index e2614bcf68..0c4440eb6b 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/api-types", - "version": "0.4.0", + "version": "0.5.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index 24d6cf6f1c..5dc5881181 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.28.0", + "version": "0.29.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index 10c8cbcf5b..627e9b7ef3 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.13.0", + "version": "1.14.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index d3da518f10..4ac31893c1 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.63.0", + "version": "1.64.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index a82b97975d..38c7abba66 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/task-runner", - "version": "1.1.0", + "version": "1.2.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index d7d92d96bc..1bdbbc6add 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.63.0", + "version": "1.64.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/package.json b/packages/core/package.json index aec9b34891..89b96d3c8c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.63.0", + "version": "1.64.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 36fcf31528..6b59977d56 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.53.0", + "version": "1.54.0", "main": "src/main.ts", "import": "src/main.ts", "scripts": { diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index cdb9814101..d3a4eb79d1 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.63.0", + "version": "1.64.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 80c0dd4f56..a4099626b9 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "1.63.0", + "version": "1.64.0", "description": "CLI to simplify n8n credentials/node development", "main": "dist/src/index", "types": "dist/src/index.d.ts", diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index f9a78a21a1..93a4c6c056 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "1.63.0", + "version": "1.64.0", "description": "Base nodes of n8n", "main": "index.js", "scripts": { diff --git a/packages/workflow/package.json b/packages/workflow/package.json index e16d5c6579..d3db16a7d3 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "1.62.0", + "version": "1.63.0", "description": "Workflow base code of n8n", "main": "dist/index.js", "module": "src/index.ts", From bf28fbefe5e8ba648cba1555a2d396b75ee32bbb Mon Sep 17 00:00:00 2001 From: Valentina Lilova Date: Wed, 16 Oct 2024 17:18:53 +0200 Subject: [PATCH 06/57] feat(n8n Google My Business Node): New node (#10504) --- .../GoogleMyBusinessOAuth2Api.credentials.ts | 29 + .../Google/MyBusiness/GenericFunctions.ts | 520 +++++++++ .../MyBusiness/GoogleMyBusiness.node.json | 18 + .../MyBusiness/GoogleMyBusiness.node.ts | 77 ++ .../GoogleMyBusinessTrigger.node.json | 18 + .../GoogleMyBusinessTrigger.node.ts | 192 ++++ .../nodes/Google/MyBusiness/Interfaces.ts | 23 + .../Google/MyBusiness/PostDescription.ts | 1000 +++++++++++++++++ .../Google/MyBusiness/ReviewDescription.ts | 574 ++++++++++ .../Google/MyBusiness/googleMyBusines.svg | 1 + .../MyBusiness/test/AddUpdateMask.test.ts | 84 ++ .../MyBusiness/test/GoogleApiRequest.test.ts | 84 ++ .../test/HandleDatesPresend.test.ts | 131 +++ .../MyBusiness/test/HandlePagination.test.ts | 123 ++ .../MyBusiness/test/SearchAccounts.test.ts | 65 ++ .../MyBusiness/test/SearchLocations.test.ts | 68 ++ .../MyBusiness/test/SearchPosts.test.ts | 72 ++ .../MyBusiness/test/SearchReviews.test.ts | 73 ++ packages/nodes-base/package.json | 3 + 19 files changed, 3155 insertions(+) create mode 100644 packages/nodes-base/credentials/GoogleMyBusinessOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.json create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.json create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/Interfaces.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/PostDescription.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/ReviewDescription.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/googleMyBusines.svg create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/test/AddUpdateMask.test.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/test/GoogleApiRequest.test.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/test/HandleDatesPresend.test.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/test/HandlePagination.test.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/test/SearchAccounts.test.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/test/SearchLocations.test.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/test/SearchPosts.test.ts create mode 100644 packages/nodes-base/nodes/Google/MyBusiness/test/SearchReviews.test.ts diff --git a/packages/nodes-base/credentials/GoogleMyBusinessOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GoogleMyBusinessOAuth2Api.credentials.ts new file mode 100644 index 0000000000..b837303ca3 --- /dev/null +++ b/packages/nodes-base/credentials/GoogleMyBusinessOAuth2Api.credentials.ts @@ -0,0 +1,29 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +const scopes = ['https://www.googleapis.com/auth/business.manage']; + +export class GoogleMyBusinessOAuth2Api implements ICredentialType { + name = 'googleMyBusinessOAuth2Api'; + + extends = ['googleOAuth2Api']; + + displayName = 'Google My Business OAuth2 API'; + + documentationUrl = 'google/oauth-single-service'; + + properties: INodeProperties[] = [ + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: scopes.join(' '), + }, + { + displayName: + 'Make sure that you have fulfilled the prerequisites and requested access to Google My Business API. More info. Also, make sure that you have enabled the following APIs & Services in the Google Cloud Console: Google My Business API, Google My Business Management API. More info.', + name: 'notice', + type: 'notice', + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/GenericFunctions.ts b/packages/nodes-base/nodes/Google/MyBusiness/GenericFunctions.ts new file mode 100644 index 0000000000..9813b3a3e8 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/GenericFunctions.ts @@ -0,0 +1,520 @@ +import { + NodeApiError, + NodeOperationError, + type DeclarativeRestApiSettings, + type IDataObject, + type IExecutePaginationFunctions, + type IExecuteSingleFunctions, + type IHttpRequestMethods, + type IHttpRequestOptions, + type ILoadOptionsFunctions, + type IN8nHttpFullResponse, + type INodeExecutionData, + type INodeListSearchItems, + type INodeListSearchResult, + type IPollFunctions, + type JsonObject, +} from 'n8n-workflow'; + +import type { ITimeInterval } from './Interfaces'; + +const addOptName = 'additionalOptions'; +const possibleRootProperties = ['localPosts', 'reviews']; + +const getAllParams = (execFns: IExecuteSingleFunctions): Record => { + const params = execFns.getNode().parameters; + const additionalOptions = execFns.getNodeParameter(addOptName, {}) as Record; + + // Merge standard parameters with additional options from the node parameters + return { ...params, ...additionalOptions }; +}; + +/* Helper function to adjust date-time parameters for API requests */ +export async function handleDatesPresend( + this: IExecuteSingleFunctions, + opts: IHttpRequestOptions, +): Promise { + const params = getAllParams(this); + const body = Object.assign({}, opts.body) as IDataObject; + const event = (body.event as IDataObject) ?? ({} as IDataObject); + + if (!params.startDateTime && !params.startDate && !params.endDateTime && !params.endDate) { + return opts; + } + + const createDateTimeObject = (dateString: string) => { + const date = new Date(dateString); + return { + date: { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + }, + time: dateString.includes('T') + ? { + hours: date.getUTCHours(), + minutes: date.getUTCMinutes(), + seconds: date.getUTCSeconds(), + nanos: 0, + } + : undefined, + }; + }; + + // Convert start and end date-time parameters if provided + const startDateTime = + params.startDateTime || params.startDate + ? createDateTimeObject((params.startDateTime || params.startDate) as string) + : null; + const endDateTime = + params.endDateTime || params.endDate + ? createDateTimeObject((params.endDateTime || params.endDate) as string) + : null; + + const schedule: Partial = { + startDate: startDateTime?.date, + endDate: endDateTime?.date, + startTime: startDateTime?.time, + endTime: endDateTime?.time, + }; + + event.schedule = schedule; + Object.assign(body, { event }); + opts.body = body; + return opts; +} + +/* Helper function adding update mask to the request */ +export async function addUpdateMaskPresend( + this: IExecuteSingleFunctions, + opts: IHttpRequestOptions, +): Promise { + const additionalOptions = this.getNodeParameter('additionalOptions') as IDataObject; + const propertyMapping: { [key: string]: string } = { + postType: 'topicType', + actionType: 'actionType', + callToActionType: 'callToAction.actionType', + url: 'callToAction.url', + startDateTime: 'event.schedule.startDate,event.schedule.startTime', + endDateTime: 'event.schedule.endDate,event.schedule.endTime', + title: 'event.title', + startDate: 'event.schedule.startDate', + endDate: 'event.schedule.endDate', + couponCode: 'offer.couponCode', + redeemOnlineUrl: 'offer.redeemOnlineUrl', + termsAndConditions: 'offer.termsAndConditions', + }; + + if (Object.keys(additionalOptions).length) { + const updateMask = Object.keys(additionalOptions) + .map((key) => propertyMapping[key] || key) + .join(','); + opts.qs = { + ...opts.qs, + updateMask, + }; + } + + return opts; +} + +/* Helper function to handle pagination */ +export async function handlePagination( + this: IExecutePaginationFunctions, + resultOptions: DeclarativeRestApiSettings.ResultOptions, +): Promise { + const aggregatedResult: IDataObject[] = []; + let nextPageToken: string | undefined; + const returnAll = this.getNodeParameter('returnAll') as boolean; + let limit = 100; + if (!returnAll) { + limit = this.getNodeParameter('limit') as number; + resultOptions.maxResults = limit; + } + resultOptions.paginate = true; + + do { + if (nextPageToken) { + resultOptions.options.qs = { ...resultOptions.options.qs, pageToken: nextPageToken }; + } + + const responseData = await this.makeRoutingRequest(resultOptions); + + for (const page of responseData) { + for (const prop of possibleRootProperties) { + if (page.json[prop]) { + const currentData = page.json[prop] as IDataObject[]; + aggregatedResult.push(...currentData); + } + } + + if (!returnAll && aggregatedResult.length >= limit) { + return aggregatedResult.slice(0, limit).map((item) => ({ json: item })); + } + + nextPageToken = page.json.nextPageToken as string | undefined; + } + } while (nextPageToken); + + return aggregatedResult.map((item) => ({ json: item })); +} + +/* Helper functions to handle errors */ + +export async function handleErrorsDeletePost( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const post = this.getNodeParameter('post', undefined) as IDataObject; + + // Provide a better error message + if (post && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The post you are deleting could not be found. Adjust the "post" parameter setting to delete the post correctly.', + ); + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +export async function handleErrorsGetPost( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const post = this.getNodeParameter('post', undefined) as IDataObject; + + // Provide a better error message + if (post && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The post you are requesting could not be found. Adjust the "post" parameter setting to retrieve the post correctly.', + ); + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +export async function handleErrorsUpdatePost( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const post = this.getNodeParameter('post') as IDataObject; + const additionalOptions = this.getNodeParameter('additionalOptions') as IDataObject; + + // Provide a better error message + if (post && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The post you are updating could not be found. Adjust the "post" parameter setting to update the post correctly.', + ); + } + + // Do not throw an error if the user didn't set additional options (a hint will be shown) + if (response.statusCode === 400 && Object.keys(additionalOptions).length === 0) { + return [{ json: { success: true } }]; + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +export async function handleErrorsDeleteReply( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const review = this.getNodeParameter('review', undefined) as IDataObject; + + // Provide a better error message + if (review && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The review you are deleting could not be found. Adjust the "review" parameter setting to update the review correctly.', + ); + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +export async function handleErrorsGetReview( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const review = this.getNodeParameter('review', undefined) as IDataObject; + + // Provide a better error message + if (review && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The review you are requesting could not be found. Adjust the "review" parameter setting to update the review correctly.', + ); + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +export async function handleErrorsReplyToReview( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const review = this.getNodeParameter('review', undefined) as IDataObject; + + // Provide a better error message + if (review && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The review you are replying to could not be found. Adjust the "review" parameter setting to reply to the review correctly.', + ); + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +/* Helper function used in listSearch methods */ +export async function googleApiRequest( + this: ILoadOptionsFunctions | IPollFunctions, + method: IHttpRequestMethods, + resource: string, + body: IDataObject = {}, + qs: IDataObject = {}, + url?: string, +): Promise { + const options: IHttpRequestOptions = { + headers: { + 'Content-Type': 'application/json', + }, + method, + body, + qs, + url: url ?? `https://mybusiness.googleapis.com/v4${resource}`, + json: true, + }; + try { + if (Object.keys(body).length === 0) { + delete options.body; + } + + return (await this.helpers.httpRequestWithAuthentication.call( + this, + 'googleMyBusinessOAuth2Api', + options, + )) as IDataObject; + } catch (error) { + throw new NodeApiError(this.getNode(), error as JsonObject); + } +} + +/* listSearch methods */ + +export async function searchAccounts( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + // Docs for this API call can be found here: + // https://developers.google.com/my-business/reference/accountmanagement/rest/v1/accounts/list + const query: IDataObject = {}; + if (paginationToken) { + query.pageToken = paginationToken; + } + + const responseData: IDataObject = await googleApiRequest.call( + this, + 'GET', + '', + {}, + { + pageSize: 20, + ...query, + }, + 'https://mybusinessaccountmanagement.googleapis.com/v1/accounts', + ); + + const accounts = responseData.accounts as Array<{ name: string; accountName: string }>; + + const results: INodeListSearchItems[] = accounts + .map((a) => ({ + name: a.accountName, + value: a.name, + })) + .filter( + (a) => + !filter || + a.name.toLowerCase().includes(filter.toLowerCase()) || + a.value.toLowerCase().includes(filter.toLowerCase()), + ) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + + return { results, paginationToken: responseData.nextPageToken }; +} + +export async function searchLocations( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + // Docs for this API call can be found here: + // https://developers.google.com/my-business/reference/businessinformation/rest/v1/accounts.locations/list + const query: IDataObject = {}; + if (paginationToken) { + query.pageToken = paginationToken; + } + + const account = (this.getNodeParameter('account') as IDataObject).value as string; + + const responseData: IDataObject = await googleApiRequest.call( + this, + 'GET', + '', + {}, + { + readMask: 'name', + pageSize: 100, + ...query, + }, + `https://mybusinessbusinessinformation.googleapis.com/v1/${account}/locations`, + ); + + const locations = responseData.locations as Array<{ name: string }>; + + const results: INodeListSearchItems[] = locations + .map((a) => ({ + name: a.name, + value: a.name, + })) + .filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase())) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + + return { results, paginationToken: responseData.nextPageToken }; +} + +export async function searchReviews( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const query: IDataObject = {}; + if (paginationToken) { + query.pageToken = paginationToken; + } + + const account = (this.getNodeParameter('account') as IDataObject).value as string; + const location = (this.getNodeParameter('location') as IDataObject).value as string; + + const responseData: IDataObject = await googleApiRequest.call( + this, + 'GET', + `/${account}/${location}/reviews`, + {}, + { + pageSize: 50, + ...query, + }, + ); + + const reviews = responseData.reviews as Array<{ name: string; comment: string }>; + + const results: INodeListSearchItems[] = reviews + .map((a) => ({ + name: a.comment, + value: a.name, + })) + .filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase())) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + + return { results, paginationToken: responseData.nextPageToken }; +} + +export async function searchPosts( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const query: IDataObject = {}; + if (paginationToken) { + query.pageToken = paginationToken; + } + + const account = (this.getNodeParameter('account') as IDataObject).value as string; + const location = (this.getNodeParameter('location') as IDataObject).value as string; + + const responseData: IDataObject = await googleApiRequest.call( + this, + 'GET', + `/${account}/${location}/localPosts`, + {}, + { + pageSize: 100, + ...query, + }, + ); + + const localPosts = responseData.localPosts as Array<{ name: string; summary: string }>; + + const results: INodeListSearchItems[] = localPosts + .map((a) => ({ + name: a.summary, + value: a.name, + })) + .filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase())) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + + return { results, paginationToken: responseData.nextPageToken }; +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.json b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.json new file mode 100644 index 0000000000..aeff09c9e9 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.googleMyBusiness", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Marketing", "Productivity"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlemybusiness/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.ts b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.ts new file mode 100644 index 0000000000..5cafd94375 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.ts @@ -0,0 +1,77 @@ +import { NodeConnectionType, type INodeType, type INodeTypeDescription } from 'n8n-workflow'; + +import { searchAccounts, searchLocations, searchPosts, searchReviews } from './GenericFunctions'; +import { postFields, postOperations } from './PostDescription'; +import { reviewFields, reviewOperations } from './ReviewDescription'; + +export class GoogleMyBusiness implements INodeType { + description: INodeTypeDescription = { + displayName: 'Google My Business', + name: 'googleMyBusiness', + icon: 'file:googleMyBusines.svg', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Google My Business API', + defaults: { + name: 'Google My Business', + }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + hints: [ + { + message: 'Please select a parameter in the options to modify the post', + displayCondition: + '={{$parameter["resource"] === "post" && $parameter["operation"] === "update" && Object.keys($parameter["additionalOptions"]).length === 0}}', + whenToDisplay: 'always', + location: 'outputPane', + type: 'warning', + }, + ], + credentials: [ + { + name: 'googleMyBusinessOAuth2Api', + required: true, + }, + ], + requestDefaults: { + baseURL: 'https://mybusiness.googleapis.com/v4', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Post', + value: 'post', + }, + { + name: 'Review', + value: 'review', + }, + ], + default: 'post', + }, + ...postOperations, + ...postFields, + ...reviewOperations, + ...reviewFields, + ], + }; + + methods = { + listSearch: { + searchAccounts, + searchLocations, + searchReviews, + searchPosts, + }, + }; +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.json b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.json new file mode 100644 index 0000000000..a456fdd38c --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.googleMyBusinessTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Communication"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.googlemybusinesstrigger/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.ts b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.ts new file mode 100644 index 0000000000..07333d377b --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.ts @@ -0,0 +1,192 @@ +import { + NodeApiError, + NodeConnectionType, + type IPollFunctions, + type IDataObject, + type INodeExecutionData, + type INodeType, + type INodeTypeDescription, +} from 'n8n-workflow'; + +import { googleApiRequest, searchAccounts, searchLocations } from './GenericFunctions'; + +export class GoogleMyBusinessTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Google My Business Trigger', + name: 'googleMyBusinessTrigger', + icon: 'file:googleMyBusines.svg', + group: ['trigger'], + version: 1, + description: + 'Fetches reviews from Google My Business and starts the workflow on specified polling intervals.', + subtitle: '={{"Google My Business Trigger"}}', + defaults: { + name: 'Google My Business Trigger', + }, + credentials: [ + { + name: 'googleMyBusinessOAuth2Api', + required: true, + }, + ], + polling: true, + inputs: [], + outputs: [NodeConnectionType.Main], + properties: [ + { + displayName: 'Event', + name: 'event', + required: true, + type: 'options', + noDataExpression: true, + default: 'reviewAdded', + options: [ + { + name: 'Review Added', + value: 'reviewAdded', + }, + ], + }, + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { event: ['reviewAdded'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { event: ['reviewAdded'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + ], + }; + + methods = { + listSearch: { + searchAccounts, + searchLocations, + }, + }; + + async poll(this: IPollFunctions): Promise { + const nodeStaticData = this.getWorkflowStaticData('node'); + let responseData; + const qs: IDataObject = {}; + + const account = (this.getNodeParameter('account') as { value: string; mode: string }).value; + const location = (this.getNodeParameter('location') as { value: string; mode: string }).value; + + const manualMode = this.getMode() === 'manual'; + if (manualMode) { + qs.pageSize = 1; // In manual mode we only want to fetch the latest review + } else { + qs.pageSize = 50; // Maximal page size for the get reviews endpoint + } + + try { + responseData = (await googleApiRequest.call( + this, + 'GET', + `/${account}/${location}/reviews`, + {}, + qs, + )) as { reviews: IDataObject[]; totalReviewCount: number; nextPageToken?: string }; + + if (manualMode) { + responseData = responseData.reviews; + } else { + // During the first execution there is no delta + if (!nodeStaticData.totalReviewCountLastTimeChecked) { + nodeStaticData.totalReviewCountLastTimeChecked = responseData.totalReviewCount; + return null; + } + + // When count did't change the node shouldn't trigger + if ( + !responseData?.reviews?.length || + nodeStaticData?.totalReviewCountLastTimeChecked === responseData?.totalReviewCount + ) { + return null; + } + + const numNewReviews = + // @ts-ignore + responseData.totalReviewCount - nodeStaticData.totalReviewCountLastTimeChecked; + nodeStaticData.totalReviewCountLastTimeChecked = responseData.totalReviewCount; + + // By default the reviews will be sorted by updateTime in descending order + // Return only the delta reviews since last pooling + responseData = responseData.reviews.slice(0, numNewReviews); + } + + if (Array.isArray(responseData) && responseData.length) { + return [this.helpers.returnJsonArray(responseData)]; + } + + return null; + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } + } +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/Interfaces.ts b/packages/nodes-base/nodes/Google/MyBusiness/Interfaces.ts new file mode 100644 index 0000000000..481fcb9bc6 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/Interfaces.ts @@ -0,0 +1,23 @@ +interface IDate { + year: number; + month: number; + day: number; +} + +interface ITimeOfDay { + hours: number; + minutes: number; + seconds: number; + nanos: number; +} + +export interface ITimeInterval { + startDate: IDate; + startTime: ITimeOfDay; + endDate: IDate; + endTime: ITimeOfDay; +} + +export interface IReviewReply { + comment: string; +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/PostDescription.ts b/packages/nodes-base/nodes/Google/MyBusiness/PostDescription.ts new file mode 100644 index 0000000000..b6b250728f --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/PostDescription.ts @@ -0,0 +1,1000 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { + addUpdateMaskPresend, + handleDatesPresend, + handleErrorsDeletePost, + handleErrorsGetPost, + handleErrorsUpdatePost, + handlePagination, +} from './GenericFunctions'; + +export const postOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + default: 'create', + noDataExpression: true, + displayOptions: { show: { resource: ['post'] } }, + options: [ + { + name: 'Create', + value: 'create', + action: 'Create post', + description: 'Create a new post on Google My Business', + routing: { + send: { preSend: [handleDatesPresend] }, + request: { + method: 'POST', + url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/localPosts', + }, + }, + }, + { + name: 'Delete', + value: 'delete', + action: 'Delete post', + description: 'Delete an existing post', + routing: { + request: { + method: 'DELETE', + url: '=/{{$parameter["post"]}}', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorsDeletePost], + }, + }, + }, + { + name: 'Get', + value: 'get', + action: 'Get post', + description: 'Retrieve details of a specific post', + routing: { + request: { + method: 'GET', + url: '=/{{$parameter["post"]}}', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorsGetPost], + }, + }, + }, + { + name: 'Get Many', + value: 'getAll', + action: 'Get many posts', + description: 'Retrieve multiple posts', + routing: { + send: { paginate: true }, + operations: { pagination: handlePagination }, + request: { + method: 'GET', + url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/localPosts', + qs: { + pageSize: + '={{ $parameter["limit"] ? ($parameter["limit"] < 100 ? $parameter["limit"] : 100) : 100 }}', // Google allows maximum 100 results per page + }, + }, + }, + }, + { + name: 'Update', + value: 'update', + action: 'Update a post', + description: 'Update an existing post', + routing: { + send: { + preSend: [handleDatesPresend, addUpdateMaskPresend], + }, + request: { + method: 'PATCH', + url: '=/{{$parameter["post"]}}', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorsUpdatePost], + }, + }, + }, + ], + }, +]; + +export const postFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* post:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['post'], operation: ['create'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['post'], operation: ['create'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Post Type', + name: 'postType', + required: true, + type: 'options', + default: 'STANDARD', + description: 'The type of post to create (standard, event, offer, or alert)', + displayOptions: { show: { resource: ['post'], operation: ['create'] } }, + routing: { send: { type: 'body', property: 'topicType' } }, + options: [ + { + name: 'Standard', + value: 'STANDARD', + }, + { + name: 'Event', + value: 'EVENT', + }, + { + name: 'Offer', + value: 'OFFER', + }, + { + name: 'Alert', + value: 'ALERT', + }, + ], + }, + { + displayName: 'Summary', + name: 'summary', + required: true, + type: 'string', + default: '', + description: 'The main text of the post', + displayOptions: { show: { resource: ['post'], operation: ['create'] } }, + routing: { send: { type: 'body', property: 'summary' } }, + }, + { + displayName: 'Title', + name: 'title', + required: true, + type: 'string', + default: '', + description: 'E.g. Sales this week.', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['EVENT'] } }, + routing: { send: { type: 'body', property: 'event.title' } }, + }, + { + displayName: 'Start Date and Time', + name: 'startDateTime', + required: true, + type: 'dateTime', + default: '', + description: 'The start date and time of the event', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['EVENT'] } }, + }, + { + displayName: 'End Date and Time', + name: 'endDateTime', + required: true, + type: 'dateTime', + default: '', + description: 'The end date and time of the event', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['EVENT'] } }, + }, + { + displayName: 'Title', + name: 'title', + required: true, + type: 'string', + default: '', + description: 'E.g. 20% off in store or online.', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['OFFER'] } }, + routing: { send: { type: 'body', property: 'event.title' } }, + }, + { + displayName: 'Start Date', + name: 'startDate', + required: true, + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'The start date of the offer', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['OFFER'] } }, + }, + { + displayName: 'End Date', + name: 'endDate', + required: true, + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'The end date of the offer', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['OFFER'] } }, + }, + { + displayName: 'Alert Type', + name: 'alertType', + required: true, + type: 'options', + default: 'COVID_19', + description: 'The sub-type of the alert', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['ALERT'] } }, + routing: { + send: { type: 'body', property: 'alertType' }, + }, + options: [ + { + name: 'Covid 19', + value: 'COVID_19', + description: 'This alert is related to the 2019 Coronavirus Disease pandemic', + }, + ], + }, + { + displayName: 'Options', + name: 'additionalOptions', + type: 'collection', + default: {}, + placeholder: 'Add Option', + displayOptions: { show: { resource: ['post'], operation: ['create'] } }, + options: [ + { + displayName: 'Language', + name: 'languageCode', + type: 'string', + default: '', + placeholder: 'e.g. en', + description: + 'The language code of the post content. More info.', + routing: { send: { type: 'body', property: 'languageCode' } }, + }, + { + displayName: 'Call to Action Type', + name: 'callToActionType', + type: 'options', + default: 'ACTION_TYPE_UNSPECIFIED', + description: 'The type of call to action', + displayOptions: { show: { '/postType': ['STANDARD', 'EVENT', 'ALERT'] } }, + routing: { + send: { type: 'body', property: 'callToAction.actionType' }, + }, + options: [ + { + name: 'Action Type Unspecified', + value: 'ACTION_TYPE_UNSPECIFIED', + description: 'Type unspecified', + }, + { + name: 'Book', + value: 'BOOK', + description: 'This post wants a user to book an appointment/table/etc', + }, + { + name: 'Call', + value: 'CALL', + description: 'This post wants a user to call the business', + }, + { + name: 'Learn More', + value: 'LEARN_MORE', + description: 'This post wants a user to learn more (at their website)', + }, + { + name: 'Order', + value: 'ORDER', + description: 'This post wants a user to order something', + }, + { + name: 'Shop', + value: 'SHOP', + description: 'This post wants a user to browse a product catalog', + }, + { + name: 'Sign Up', + value: 'SIGN_UP', + description: 'This post wants a user to register/sign up/join something', + }, + ], + }, + { + displayName: 'Call to Action Url', + name: 'url', + type: 'string', + default: '', + description: 'The URL that users are sent to when clicking through the promotion', + displayOptions: { show: { '/postType': ['STANDARD', 'EVENT', 'ALERT'] } }, + routing: { + send: { type: 'body', property: 'callToAction.url' }, + }, + }, + { + displayName: 'Coupon Code', + name: 'couponCode', + type: 'string', + default: '', + description: 'The coupon code for the offer', + displayOptions: { show: { '/postType': ['OFFER'] } }, + routing: { + send: { type: 'body', property: 'offer.couponCode' }, + }, + }, + { + displayName: 'Redeem Online Url', + name: 'redeemOnlineUrl', + type: 'string', + default: '', + description: 'Link to redeem the offer', + displayOptions: { show: { '/postType': ['OFFER'] } }, + routing: { + send: { type: 'body', property: 'offer.redeemOnlineUrl' }, + }, + }, + { + displayName: 'Terms and Conditions', + name: 'termsConditions', + type: 'string', + default: '', + description: 'The terms and conditions of the offer', + displayOptions: { show: { '/postType': ['OFFER'] } }, + routing: { + send: { type: 'body', property: 'offer.termsConditions' }, + }, + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* post:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['post'], operation: ['delete'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['post'], operation: ['delete'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Post', + name: 'post', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the post to retrieve its details', + displayOptions: { show: { resource: ['post'], operation: ['delete'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchPosts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+/locations/[0-9]+/localPosts/[0-9]+', + errorMessage: + 'The name must be in the format "accounts/123/locations/123/localPosts/123"', + }, + }, + ], + placeholder: 'e.g. accounts/123/locations/123/localPosts/123', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* post:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['post'], operation: ['get'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['post'], operation: ['get'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Post', + name: 'post', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the post to retrieve its details', + displayOptions: { show: { resource: ['post'], operation: ['get'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchPosts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+/locations/[0-9]+/localPosts/[0-9]+', + errorMessage: + 'The name must be in the format "accounts/123/locations/123/localPosts/123"', + }, + }, + ], + placeholder: 'e.g. accounts/123/locations/123/localPosts/123', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* post:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['post'], operation: ['getAll'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['post'], operation: ['getAll'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Return All', + name: 'returnAll', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { show: { resource: ['post'], operation: ['getAll'] } }, + type: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + typeOptions: { + minValue: 1, + }, + default: 20, + description: 'Max number of results to return', + displayOptions: { show: { resource: ['post'], operation: ['getAll'], returnAll: [false] } }, + }, + + /* -------------------------------------------------------------------------- */ + /* post:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['post'], operation: ['update'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['post'], operation: ['update'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Post', + name: 'post', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the post to retrieve its details', + displayOptions: { show: { resource: ['post'], operation: ['update'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchPosts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+/locations/[0-9]+/localPosts/[0-9]+', + errorMessage: + 'The name must be in the format "accounts/123/locations/123/localPosts/123"', + }, + }, + ], + placeholder: 'e.g. accounts/123/locations/123/localPosts/123', + }, + ], + }, + { + displayName: + "Make sure that the updated options are supported by the post type. More info.", + name: 'notice', + type: 'notice', + default: '', + displayOptions: { show: { resource: ['post'], operation: ['update'] } }, + }, + { + displayName: 'Options', + name: 'additionalOptions', + type: 'collection', + default: {}, + placeholder: 'Add Option', + displayOptions: { show: { resource: ['post'], operation: ['update'] } }, + options: [ + { + displayName: 'Summary', + name: 'summary', + type: 'string', + default: '', + description: 'The main text of the post', + routing: { send: { type: 'body', property: 'summary' } }, + }, + { + displayName: 'Language', + name: 'languageCode', + type: 'string', + default: '', + placeholder: 'e.g. en', + description: + 'The language code of the post content. More info.', + routing: { send: { type: 'body', property: 'languageCode' } }, + }, + { + displayName: 'Call to Action Type', + name: 'callToActionType', + type: 'options', + default: 'ACTION_TYPE_UNSPECIFIED', + description: 'The type of call to action', + routing: { + send: { type: 'body', property: 'callToAction.actionType' }, + }, + options: [ + { + name: 'Action Type Unspecified', + value: 'ACTION_TYPE_UNSPECIFIED', + description: 'Type unspecified', + }, + { + name: 'Book', + value: 'BOOK', + description: 'This post wants a user to book an appointment/table/etc', + }, + { + name: 'Get Offer', + value: 'GET_OFFER', + description: + 'Deprecated. Use OFFER in LocalPostTopicType to create a post with offer content.', + }, + { + name: 'Learn More', + value: 'LEARN_MORE', + description: 'This post wants a user to learn more (at their website)', + }, + { + name: 'Order', + value: 'ORDER', + description: 'This post wants a user to order something', + }, + { + name: 'Shop', + value: 'SHOP', + description: 'This post wants a user to browse a product catalog', + }, + { + name: 'Sign Up', + value: 'SIGN_UP', + description: 'This post wants a user to register/sign up/join something', + }, + ], + }, + { + displayName: 'Call to Action Url', + name: 'url', + type: 'string', + default: '', + description: 'The URL that users are sent to when clicking through the promotion', + routing: { + send: { type: 'body', property: 'callToAction.url' }, + }, + }, + { + displayName: 'Start Date and Time', + name: 'startDateTime', + type: 'dateTime', + default: '', + description: 'The start date and time of the event', + }, + { + displayName: 'End Date and Time', + name: 'endDateTime', + type: 'dateTime', + default: '', + description: 'The end date and time of the event', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'E.g. 20% off in store or online.', + routing: { send: { type: 'body', property: 'event.title' } }, + }, + { + displayName: 'Start Date', + name: 'startDate', + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'The start date of the offer', + }, + { + displayName: 'End Date', + name: 'endDate', + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'The end date of the offer', + }, + { + displayName: 'Coupon Code', + name: 'couponCode', + type: 'string', + default: '', + description: 'The coupon code for the offer', + routing: { + send: { type: 'body', property: 'offer.couponCode' }, + }, + }, + { + displayName: 'Redeem Online Url', + name: 'redeemOnlineUrl', + type: 'string', + default: '', + description: 'Link to redeem the offer', + routing: { + send: { type: 'body', property: 'offer.redeemOnlineUrl' }, + }, + }, + { + displayName: 'Terms and Conditions', + name: 'termsConditions', + type: 'string', + default: '', + description: 'The terms and conditions of the offer', + routing: { + send: { type: 'body', property: 'offer.termsConditions' }, + }, + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Google/MyBusiness/ReviewDescription.ts b/packages/nodes-base/nodes/Google/MyBusiness/ReviewDescription.ts new file mode 100644 index 0000000000..7d3430ffad --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/ReviewDescription.ts @@ -0,0 +1,574 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { + handleErrorsDeleteReply, + handleErrorsGetReview, + handleErrorsReplyToReview, + handlePagination, +} from './GenericFunctions'; + +export const reviewOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + default: 'get', + noDataExpression: true, + displayOptions: { show: { resource: ['review'] } }, + options: [ + { + name: 'Delete Reply', + value: 'delete', + action: 'Delete a reply to a review', + description: 'Delete a reply to a review', + routing: { + request: { + method: 'DELETE', + url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews/{{$parameter["review"].split("reviews/").pop().split("/reply")[0]}}/reply', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorsDeleteReply], + }, + }, + }, + { + name: 'Get', + value: 'get', + action: 'Get review', + description: 'Retrieve details of a specific review on Google My Business', + routing: { + request: { + method: 'GET', + url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews/{{$parameter["review"].split("reviews/").pop().split("/reply")[0]}}', + ignoreHttpStatusErrors: true, + }, + + output: { + postReceive: [handleErrorsGetReview], + }, + }, + }, + { + name: 'Get Many', + value: 'getAll', + action: 'Get many reviews', + description: 'Retrieve multiple reviews', + routing: { + send: { paginate: true }, + operations: { pagination: handlePagination }, + request: { + method: 'GET', + url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews', + qs: { + pageSize: + '={{ $parameter["limit"] ? ($parameter["limit"] < 50 ? $parameter["limit"] : 50) : 50 }}', // Google allows maximum 50 results per page + }, + }, + }, + }, + { + name: 'Reply', + value: 'reply', + action: 'Reply to review', + description: 'Reply to a review', + routing: { + request: { + method: 'PUT', + url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews/{{$parameter["review"].split("reviews/").pop().split("/reply")[0]}}/reply', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorsReplyToReview], + }, + }, + }, + ], + }, +]; + +export const reviewFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* review:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['review'], operation: ['get'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['review'], operation: ['get'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Review', + name: 'review', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the review to retrieve its details', + displayOptions: { show: { resource: ['review'], operation: ['get'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchReviews', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?!accounts/[0-9]+/locations/[0-9]+/reviews/).*', + errorMessage: 'The name must not start with "accounts/123/locations/123/reviews/"', + }, + }, + ], + placeholder: 'e.g. ABC123_review-ID_456xyz', + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+/locations/[0-9]+/reviews/.*$', + errorMessage: 'The name must start with "accounts/123/locations/123/reviews/"', + }, + }, + ], + placeholder: 'e.g. accounts/123/locations/123/reviews/ABC123_review-ID_456xyz', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* review:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['review'], operation: ['delete'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['review'], operation: ['delete'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Review', + name: 'review', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the review to retrieve its details', + displayOptions: { show: { resource: ['review'], operation: ['delete'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchReviews', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?!accounts/[0-9]+/locations/[0-9]+/reviews/).*', + errorMessage: 'The name must not start with "accounts/123/locations/123/reviews/"', + }, + }, + ], + placeholder: 'e.g. ABC123_review-ID_456xyz', + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+/locations/[0-9]+/reviews/.*$', + errorMessage: 'The name must start with "accounts/123/locations/123/reviews/"', + }, + }, + ], + placeholder: 'e.g. accounts/123/locations/123/reviews/ABC123_review-ID_456xyz', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* review:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['review'], operation: ['getAll'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['review'], operation: ['getAll'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Return All', + name: 'returnAll', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { show: { resource: ['review'], operation: ['getAll'] } }, + type: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + required: true, + type: 'number', + typeOptions: { + minValue: 1, + }, + default: 20, + description: 'Max number of results to return', + displayOptions: { show: { resource: ['review'], operation: ['getAll'], returnAll: [false] } }, + }, + + /* -------------------------------------------------------------------------- */ + /* review:reply */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['review'], operation: ['reply'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['review'], operation: ['reply'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Review', + name: 'review', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the review to retrieve its details', + displayOptions: { show: { resource: ['review'], operation: ['reply'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchReviews', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?!accounts/[0-9]+/locations/[0-9]+/reviews/).*', + errorMessage: 'The name must not start with "accounts/123/locations/123/reviews/"', + }, + }, + ], + placeholder: 'e.g. ABC123_review-ID_456xyz', + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+/locations/[0-9]+/reviews/.*$', + errorMessage: 'The name must start with "accounts/123/locations/123/reviews/"', + }, + }, + ], + placeholder: 'e.g. accounts/123/locations/123/reviews/ABC123_review-ID_456xyz', + }, + ], + }, + { + displayName: 'Reply', + name: 'reply', + type: 'string', + default: '', + description: 'The body of the reply (up to 4096 characters)', + displayOptions: { show: { resource: ['review'], operation: ['reply'] } }, + typeOptions: { rows: 5 }, + routing: { send: { type: 'body', property: 'comment' } }, + }, +]; diff --git a/packages/nodes-base/nodes/Google/MyBusiness/googleMyBusines.svg b/packages/nodes-base/nodes/Google/MyBusiness/googleMyBusines.svg new file mode 100644 index 0000000000..c42fe66906 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/googleMyBusines.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/AddUpdateMask.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/AddUpdateMask.test.ts new file mode 100644 index 0000000000..94f37b9034 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/AddUpdateMask.test.ts @@ -0,0 +1,84 @@ +import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow'; + +import { addUpdateMaskPresend } from '../GenericFunctions'; + +describe('GenericFunctions - addUpdateMask', () => { + const mockGetNodeParameter = jest.fn(); + + const mockContext = { + getNodeParameter: mockGetNodeParameter, + } as unknown as IExecuteSingleFunctions; + + beforeEach(() => { + mockGetNodeParameter.mockClear(); + }); + + it('should add updateMask with mapped properties to the query string', async () => { + mockGetNodeParameter.mockReturnValue({ + postType: 'postTypeValue', + url: 'https://example.com', + startDateTime: '2023-09-15T10:00:00.000Z', + couponCode: 'DISCOUNT123', + }); + + const opts: Partial = { + qs: {}, + }; + + const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.qs).toEqual({ + updateMask: + 'topicType,callToAction.url,event.schedule.startDate,event.schedule.startTime,offer.couponCode', + }); + }); + + it('should handle empty additionalOptions and not add updateMask', async () => { + mockGetNodeParameter.mockReturnValue({}); + + const opts: Partial = { + qs: {}, + }; + + const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.qs).toEqual({}); + }); + + it('should include unmapped properties in the updateMask', async () => { + mockGetNodeParameter.mockReturnValue({ + postType: 'postTypeValue', + unmappedProperty: 'someValue', + }); + + const opts: Partial = { + qs: {}, + }; + + const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.qs).toEqual({ + updateMask: 'topicType,unmappedProperty', + }); + }); + + it('should merge updateMask with existing query string', async () => { + mockGetNodeParameter.mockReturnValue({ + postType: 'postTypeValue', + redeemOnlineUrl: 'https://google.example.com', + }); + + const opts: Partial = { + qs: { + existingQuery: 'existingValue', + }, + }; + + const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.qs).toEqual({ + existingQuery: 'existingValue', + updateMask: 'topicType,offer.redeemOnlineUrl', + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/GoogleApiRequest.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/GoogleApiRequest.test.ts new file mode 100644 index 0000000000..6e56b1ab65 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/GoogleApiRequest.test.ts @@ -0,0 +1,84 @@ +import { NodeApiError, type ILoadOptionsFunctions, type IPollFunctions } from 'n8n-workflow'; + +import { googleApiRequest } from '../GenericFunctions'; + +describe('googleApiRequest', () => { + const mockHttpRequestWithAuthentication = jest.fn(); + const mockContext = { + helpers: { + httpRequestWithAuthentication: mockHttpRequestWithAuthentication, + }, + getNode: jest.fn(), + } as unknown as ILoadOptionsFunctions | IPollFunctions; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should make a GET request and return data', async () => { + const mockResponse = { success: true }; + mockHttpRequestWithAuthentication.mockResolvedValue(mockResponse); + + const result = await googleApiRequest.call(mockContext, 'GET', '/test-resource'); + + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith( + 'googleMyBusinessOAuth2Api', + expect.objectContaining({ + method: 'GET', + url: 'https://mybusiness.googleapis.com/v4/test-resource', + qs: {}, + json: true, + }), + ); + + expect(result).toEqual(mockResponse); + }); + + it('should make a POST request with body and return data', async () => { + const mockResponse = { success: true }; + mockHttpRequestWithAuthentication.mockResolvedValue(mockResponse); + + const requestBody = { key: 'value' }; + const result = await googleApiRequest.call(mockContext, 'POST', '/test-resource', requestBody); + + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith( + 'googleMyBusinessOAuth2Api', + expect.objectContaining({ + method: 'POST', + body: requestBody, + url: 'https://mybusiness.googleapis.com/v4/test-resource', + qs: {}, + json: true, + }), + ); + + expect(result).toEqual(mockResponse); + }); + + it('should remove the body for GET requests', async () => { + const mockResponse = { success: true }; + mockHttpRequestWithAuthentication.mockResolvedValue(mockResponse); + + const result = await googleApiRequest.call(mockContext, 'GET', '/test-resource', {}); + + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith( + 'googleMyBusinessOAuth2Api', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.not.objectContaining({ body: expect.anything() }), + ); + + expect(result).toEqual(mockResponse); + }); + + it('should throw NodeApiError on API failure', async () => { + const mockError = new Error('API request failed'); + mockHttpRequestWithAuthentication.mockRejectedValue(mockError); + + await expect(googleApiRequest.call(mockContext, 'GET', '/test-resource')).rejects.toThrow( + NodeApiError, + ); + + expect(mockContext.getNode).toHaveBeenCalled(); + expect(mockHttpRequestWithAuthentication).toHaveBeenCalled(); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/HandleDatesPresend.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/HandleDatesPresend.test.ts new file mode 100644 index 0000000000..6ef15ab8c6 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/HandleDatesPresend.test.ts @@ -0,0 +1,131 @@ +import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow'; + +import { handleDatesPresend } from '../GenericFunctions'; + +describe('GenericFunctions - handleDatesPresend', () => { + const mockGetNode = jest.fn(); + const mockGetNodeParameter = jest.fn(); + + const mockContext = { + getNode: mockGetNode, + getNodeParameter: mockGetNodeParameter, + } as unknown as IExecuteSingleFunctions; + + beforeEach(() => { + mockGetNode.mockClear(); + mockGetNodeParameter.mockClear(); + }); + + it('should return options unchanged if no date-time parameters are provided', async () => { + mockGetNode.mockReturnValue({ + parameters: {}, + }); + mockGetNodeParameter.mockReturnValue({}); + + const opts: Partial = { + body: {}, + }; + + const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result).toEqual(opts); + }); + + it('should merge startDateTime parameter into event schedule', async () => { + mockGetNode.mockReturnValue({ + parameters: { + startDateTime: '2023-09-15T10:00:00.000Z', + }, + }); + mockGetNodeParameter.mockReturnValue({}); + + const opts: Partial = { + body: {}, + }; + + const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.body).toEqual({ + event: { + schedule: { + startDate: { year: 2023, month: 9, day: 15 }, + startTime: { hours: 10, minutes: 0, seconds: 0, nanos: 0 }, + }, + }, + }); + }); + + it('should merge startDate and endDateTime parameters into event schedule', async () => { + mockGetNode.mockReturnValue({ + parameters: { + startDate: '2023-09-15', + endDateTime: '2023-09-16T12:30:00.000Z', + }, + }); + mockGetNodeParameter.mockReturnValue({}); + + const opts: Partial = { + body: {}, + }; + + const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.body).toEqual({ + event: { + schedule: { + startDate: { year: 2023, month: 9, day: 15 }, + endDate: { year: 2023, month: 9, day: 16 }, + endTime: { hours: 12, minutes: 30, seconds: 0, nanos: 0 }, + }, + }, + }); + }); + + it('should merge additional options into event schedule', async () => { + mockGetNode.mockReturnValue({ + parameters: { + startDate: '2023-09-15', + }, + }); + mockGetNodeParameter.mockReturnValue({ + additionalOption: 'someValue', + }); + + const opts: Partial = { + body: {}, + }; + + const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.body).toEqual({ + event: { + schedule: { + startDate: { year: 2023, month: 9, day: 15 }, + }, + }, + }); + }); + + it('should modify the body with event schedule containing only date', async () => { + mockGetNode.mockReturnValue({ + parameters: { + startDate: '2023-09-15', + }, + }); + mockGetNodeParameter.mockReturnValue({}); + + const opts: Partial = { + body: { event: {} }, + }; + + const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.body).toEqual({ + event: { + schedule: { + startDate: { year: 2023, month: 9, day: 15 }, + }, + }, + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/HandlePagination.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/HandlePagination.test.ts new file mode 100644 index 0000000000..ad9b0755a5 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/HandlePagination.test.ts @@ -0,0 +1,123 @@ +import type { DeclarativeRestApiSettings, IExecutePaginationFunctions } from 'n8n-workflow'; + +import { handlePagination } from '../GenericFunctions'; + +describe('GenericFunctions - handlePagination', () => { + const mockMakeRoutingRequest = jest.fn(); + const mockGetNodeParameter = jest.fn(); + + const mockContext = { + makeRoutingRequest: mockMakeRoutingRequest, + getNodeParameter: mockGetNodeParameter, + } as unknown as IExecutePaginationFunctions; + + beforeEach(() => { + mockMakeRoutingRequest.mockClear(); + mockGetNodeParameter.mockClear(); + }); + + it('should stop fetching when the limit is reached and returnAll is false', async () => { + mockMakeRoutingRequest + .mockResolvedValueOnce([ + { + json: { + localPosts: [{ id: 1 }, { id: 2 }], + nextPageToken: 'nextToken1', + }, + }, + ]) + .mockResolvedValueOnce([ + { + json: { + localPosts: [{ id: 3 }, { id: 4 }], + }, + }, + ]); + + mockGetNodeParameter.mockReturnValueOnce(false); + mockGetNodeParameter.mockReturnValueOnce(3); + + const requestOptions = { + options: { + qs: {}, + }, + } as unknown as DeclarativeRestApiSettings.ResultOptions; + + const result = await handlePagination.call(mockContext, requestOptions); + + expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(2); + expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }, { json: { id: 3 } }]); + }); + + it('should handle empty results', async () => { + mockMakeRoutingRequest.mockResolvedValueOnce([ + { + json: { + localPosts: [], + }, + }, + ]); + + mockGetNodeParameter.mockReturnValueOnce(false); + mockGetNodeParameter.mockReturnValueOnce(5); + + const requestOptions = { + options: { + qs: {}, + }, + } as unknown as DeclarativeRestApiSettings.ResultOptions; + + const result = await handlePagination.call(mockContext, requestOptions); + + expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1); + + expect(result).toEqual([]); + }); + + it('should fetch all items when returnAll is true', async () => { + mockMakeRoutingRequest + .mockResolvedValueOnce([ + { + json: { + localPosts: [{ id: 1 }, { id: 2 }], + nextPageToken: 'nextToken1', + }, + }, + ]) + .mockResolvedValueOnce([ + { + json: { + localPosts: [{ id: 3 }, { id: 4 }], + nextPageToken: 'nextToken2', + }, + }, + ]) + .mockResolvedValueOnce([ + { + json: { + localPosts: [{ id: 5 }], + }, + }, + ]); + + mockGetNodeParameter.mockReturnValueOnce(true); + + const requestOptions = { + options: { + qs: {}, + }, + } as unknown as DeclarativeRestApiSettings.ResultOptions; + + const result = await handlePagination.call(mockContext, requestOptions); + + expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(3); + + expect(result).toEqual([ + { json: { id: 1 } }, + { json: { id: 2 } }, + { json: { id: 3 } }, + { json: { id: 4 } }, + { json: { id: 5 } }, + ]); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/SearchAccounts.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchAccounts.test.ts new file mode 100644 index 0000000000..01e08f3732 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchAccounts.test.ts @@ -0,0 +1,65 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchAccounts } from '../GenericFunctions'; + +describe('GenericFunctions - searchAccounts', () => { + const mockGoogleApiRequest = jest.fn(); + + const mockContext = { + helpers: { + httpRequestWithAuthentication: mockGoogleApiRequest, + }, + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + mockGoogleApiRequest.mockClear(); + }); + + it('should return accounts with filtering', async () => { + mockGoogleApiRequest.mockResolvedValue({ + accounts: [ + { name: 'accounts/123', accountName: 'Test Account 1' }, + { name: 'accounts/234', accountName: 'Test Account 2' }, + ], + }); + + const filter = '123'; + const result = await searchAccounts.call(mockContext, filter); + + expect(result).toEqual({ + results: [{ name: 'Test Account 1', value: 'accounts/123' }], + paginationToken: undefined, + }); + }); + + it('should handle empty results', async () => { + mockGoogleApiRequest.mockResolvedValue({ accounts: [] }); + + const result = await searchAccounts.call(mockContext); + + expect(result).toEqual({ results: [], paginationToken: undefined }); + }); + + it('should handle pagination', async () => { + mockGoogleApiRequest.mockResolvedValue({ + accounts: [{ name: 'accounts/123', accountName: 'Test Account 1' }], + nextPageToken: 'nextToken1', + }); + mockGoogleApiRequest.mockResolvedValue({ + accounts: [{ name: 'accounts/234', accountName: 'Test Account 2' }], + nextPageToken: 'nextToken2', + }); + mockGoogleApiRequest.mockResolvedValue({ + accounts: [{ name: 'accounts/345', accountName: 'Test Account 3' }], + }); + + const result = await searchAccounts.call(mockContext); + + // The request would only return the last result + // N8N handles the pagination and adds the previous results to the results array + expect(result).toEqual({ + results: [{ name: 'Test Account 3', value: 'accounts/345' }], + paginationToken: undefined, + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/SearchLocations.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchLocations.test.ts new file mode 100644 index 0000000000..2279840d26 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchLocations.test.ts @@ -0,0 +1,68 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchLocations } from '../GenericFunctions'; + +describe('GenericFunctions - searchLocations', () => { + const mockGoogleApiRequest = jest.fn(); + const mockGetNodeParameter = jest.fn(); + + const mockContext = { + helpers: { + httpRequestWithAuthentication: mockGoogleApiRequest, + }, + getNodeParameter: mockGetNodeParameter, + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + mockGoogleApiRequest.mockClear(); + mockGetNodeParameter.mockClear(); + mockGetNodeParameter.mockReturnValue('parameterValue'); + }); + + it('should return locations with filtering', async () => { + mockGoogleApiRequest.mockResolvedValue({ + locations: [{ name: 'locations/123' }, { name: 'locations/234' }], + }); + + const filter = '123'; + const result = await searchLocations.call(mockContext, filter); + + expect(result).toEqual({ + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + results: [{ name: 'locations/123', value: 'locations/123' }], + paginationToken: undefined, + }); + }); + + it('should handle empty results', async () => { + mockGoogleApiRequest.mockResolvedValue({ locations: [] }); + + const result = await searchLocations.call(mockContext); + + expect(result).toEqual({ results: [], paginationToken: undefined }); + }); + + it('should handle pagination', async () => { + mockGoogleApiRequest.mockResolvedValue({ + locations: [{ name: 'locations/123' }], + nextPageToken: 'nextToken1', + }); + mockGoogleApiRequest.mockResolvedValue({ + locations: [{ name: 'locations/234' }], + nextPageToken: 'nextToken2', + }); + mockGoogleApiRequest.mockResolvedValue({ + locations: [{ name: 'locations/345' }], + }); + + const result = await searchLocations.call(mockContext); + + // The request would only return the last result + // N8N handles the pagination and adds the previous results to the results array + expect(result).toEqual({ + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + results: [{ name: 'locations/345', value: 'locations/345' }], + paginationToken: undefined, + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/SearchPosts.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchPosts.test.ts new file mode 100644 index 0000000000..9a542d721c --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchPosts.test.ts @@ -0,0 +1,72 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchPosts } from '../GenericFunctions'; + +describe('GenericFunctions - searchPosts', () => { + const mockGoogleApiRequest = jest.fn(); + const mockGetNodeParameter = jest.fn(); + + const mockContext = { + helpers: { + httpRequestWithAuthentication: mockGoogleApiRequest, + }, + getNodeParameter: mockGetNodeParameter, + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + mockGoogleApiRequest.mockClear(); + mockGetNodeParameter.mockClear(); + mockGetNodeParameter.mockReturnValue('parameterValue'); + }); + + it('should return posts with filtering', async () => { + mockGoogleApiRequest.mockResolvedValue({ + localPosts: [ + { name: 'accounts/123/locations/123/localPosts/123', summary: 'First Post' }, + { name: 'accounts/123/locations/123/localPosts/234', summary: 'Second Post' }, + ], + }); + + const filter = 'First'; + const result = await searchPosts.call(mockContext, filter); + + expect(result).toEqual({ + results: [ + { + name: 'First Post', + value: 'accounts/123/locations/123/localPosts/123', + }, + ], + paginationToken: undefined, + }); + }); + + it('should handle empty results', async () => { + mockGoogleApiRequest.mockResolvedValue({ localPosts: [] }); + + const result = await searchPosts.call(mockContext); + + expect(result).toEqual({ results: [], paginationToken: undefined }); + }); + + it('should handle pagination', async () => { + mockGoogleApiRequest.mockResolvedValue({ + localPosts: [{ name: 'accounts/123/locations/123/localPosts/123', summary: 'First Post' }], + nextPageToken: 'nextToken1', + }); + mockGoogleApiRequest.mockResolvedValue({ + localPosts: [{ name: 'accounts/123/locations/123/localPosts/234', summary: 'Second Post' }], + nextPageToken: 'nextToken2', + }); + mockGoogleApiRequest.mockResolvedValue({ + localPosts: [{ name: 'accounts/123/locations/123/localPosts/345', summary: 'Third Post' }], + }); + + const result = await searchPosts.call(mockContext); + + expect(result).toEqual({ + results: [{ name: 'Third Post', value: 'accounts/123/locations/123/localPosts/345' }], + paginationToken: undefined, + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/SearchReviews.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchReviews.test.ts new file mode 100644 index 0000000000..6a5b8e3daf --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchReviews.test.ts @@ -0,0 +1,73 @@ +/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchReviews } from '../GenericFunctions'; + +describe('GenericFunctions - searchReviews', () => { + const mockGoogleApiRequest = jest.fn(); + const mockGetNodeParameter = jest.fn(); + + const mockContext = { + helpers: { + httpRequestWithAuthentication: mockGoogleApiRequest, + }, + getNodeParameter: mockGetNodeParameter, + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + mockGoogleApiRequest.mockClear(); + mockGetNodeParameter.mockClear(); + mockGetNodeParameter.mockReturnValue('parameterValue'); + }); + + it('should return reviews with filtering', async () => { + mockGoogleApiRequest.mockResolvedValue({ + reviews: [ + { name: 'accounts/123/locations/123/reviews/123', comment: 'Great service!' }, + { name: 'accounts/123/locations/123/reviews/234', comment: 'Good experience.' }, + ], + }); + + const filter = 'Great'; + const result = await searchReviews.call(mockContext, filter); + + expect(result).toEqual({ + results: [ + { + name: 'Great service!', + value: 'accounts/123/locations/123/reviews/123', + }, + ], + paginationToken: undefined, + }); + }); + + it('should handle empty results', async () => { + mockGoogleApiRequest.mockResolvedValue({ reviews: [] }); + + const result = await searchReviews.call(mockContext); + + expect(result).toEqual({ results: [], paginationToken: undefined }); + }); + + it('should handle pagination', async () => { + mockGoogleApiRequest.mockResolvedValue({ + reviews: [{ name: 'accounts/123/locations/123/reviews/123', comment: 'First Review' }], + nextPageToken: 'nextToken1', + }); + mockGoogleApiRequest.mockResolvedValue({ + reviews: [{ name: 'accounts/123/locations/123/reviews/234', comment: 'Second Review' }], + nextPageToken: 'nextToken2', + }); + mockGoogleApiRequest.mockResolvedValue({ + reviews: [{ name: 'accounts/123/locations/123/reviews/345', comment: 'Third Review' }], + }); + + const result = await searchReviews.call(mockContext); + + expect(result).toEqual({ + results: [{ name: 'Third Review', value: 'accounts/123/locations/123/reviews/345' }], + paginationToken: undefined, + }); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 93a4c6c056..a40f7f2cfa 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -140,6 +140,7 @@ "dist/credentials/GoogleDriveOAuth2Api.credentials.js", "dist/credentials/GoogleFirebaseCloudFirestoreOAuth2Api.credentials.js", "dist/credentials/GoogleFirebaseRealtimeDatabaseOAuth2Api.credentials.js", + "dist/credentials/GoogleMyBusinessOAuth2Api.credentials.js", "dist/credentials/GoogleOAuth2Api.credentials.js", "dist/credentials/GooglePerspectiveOAuth2Api.credentials.js", "dist/credentials/GoogleSheetsOAuth2Api.credentials.js", @@ -538,6 +539,8 @@ "dist/nodes/Google/Gmail/Gmail.node.js", "dist/nodes/Google/Gmail/GmailTrigger.node.js", "dist/nodes/Google/GSuiteAdmin/GSuiteAdmin.node.js", + "dist/nodes/Google/MyBusiness/GoogleMyBusiness.node.js", + "dist/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.js", "dist/nodes/Google/Perspective/GooglePerspective.node.js", "dist/nodes/Google/Sheet/GoogleSheets.node.js", "dist/nodes/Google/Sheet/GoogleSheetsTrigger.node.js", From d37acdb8738115a55ce7ce6caf806ec4583deb8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 16 Oct 2024 17:34:32 +0200 Subject: [PATCH 07/57] refactor(core): Make orchestration service smaller (#11275) --- .../cli/src/__tests__/wait-tracker.test.ts | 2 +- packages/cli/src/active-workflow-manager.ts | 41 +++++++++-- .../controllers/orchestration.controller.ts | 18 ++--- .../message-event-bus/message-event-bus.ts | 8 +-- .../external-secrets-manager.ee.test.ts | 1 + .../external-secrets-manager.ee.ts | 15 ++-- packages/cli/src/push/__tests__/index.test.ts | 4 +- packages/cli/src/push/index.ts | 12 +++- packages/cli/src/requests.ts | 9 --- .../__tests__/orchestration.service.test.ts | 32 --------- .../services/community-packages.service.ts | 18 ++--- .../cli/src/services/orchestration.service.ts | 68 ------------------ .../webhooks/__tests__/test-webhooks.test.ts | 2 +- packages/cli/src/webhooks/test-webhooks.ts | 8 ++- .../active-workflow-manager.test.ts | 70 +++++++++++++++++++ .../collaboration.service.test.ts | 2 +- .../cli/test/integration/eventbus.ee.test.ts | 3 + .../external-secrets.api.test.ts | 1 + .../test/integration/shared/utils/index.ts | 1 - 19 files changed, 158 insertions(+), 157 deletions(-) diff --git a/packages/cli/src/__tests__/wait-tracker.test.ts b/packages/cli/src/__tests__/wait-tracker.test.ts index e51cd88ccb..2473713891 100644 --- a/packages/cli/src/__tests__/wait-tracker.test.ts +++ b/packages/cli/src/__tests__/wait-tracker.test.ts @@ -13,7 +13,7 @@ jest.useFakeTimers(); describe('WaitTracker', () => { const executionRepository = mock(); const multiMainSetup = mock(); - const orchestrationService = new OrchestrationService(mock(), mock(), multiMainSetup); + const orchestrationService = new OrchestrationService(mock(), multiMainSetup); const instanceSettings = mock({ isLeader: true }); const execution = mock({ diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index 4127909e49..189c446b65 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -48,6 +48,7 @@ import { WorkflowExecutionService } from '@/workflows/workflow-execution.service import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; import { ExecutionService } from './executions/execution.service'; +import { Publisher } from './scaling/pubsub/publisher.service'; interface QueuedActivation { activationMode: WorkflowActivateMode; @@ -75,6 +76,7 @@ export class ActiveWorkflowManager { private readonly activeWorkflowsService: ActiveWorkflowsService, private readonly workflowExecutionService: WorkflowExecutionService, private readonly instanceSettings: InstanceSettings, + private readonly publisher: Publisher, ) {} async init() { @@ -517,8 +519,9 @@ export class ActiveWorkflowManager { { shouldPublish } = { shouldPublish: true }, ) { if (this.orchestrationService.isMultiMainSetupEnabled && shouldPublish) { - await this.orchestrationService.publish('add-webhooks-triggers-and-pollers', { - workflowId, + void this.publisher.publishCommand({ + command: 'add-webhooks-triggers-and-pollers', + payload: { workflowId }, }); return; @@ -526,8 +529,8 @@ export class ActiveWorkflowManager { let workflow: Workflow; - const shouldAddWebhooks = this.orchestrationService.shouldAddWebhooks(activationMode); - const shouldAddTriggersAndPollers = this.orchestrationService.shouldAddTriggersAndPollers(); + const shouldAddWebhooks = this.shouldAddWebhooks(activationMode); + const shouldAddTriggersAndPollers = this.shouldAddTriggersAndPollers(); const shouldDisplayActivationMessage = (shouldAddWebhooks || shouldAddTriggersAndPollers) && @@ -717,7 +720,10 @@ export class ActiveWorkflowManager { ); } - await this.orchestrationService.publish('remove-triggers-and-pollers', { workflowId }); + void this.publisher.publishCommand({ + command: 'remove-triggers-and-pollers', + payload: { workflowId }, + }); return; } @@ -810,4 +816,29 @@ export class ActiveWorkflowManager { async removeActivationError(workflowId: string) { await this.activationErrorsService.deregister(workflowId); } + + /** + * Whether this instance may add webhooks to the `webhook_entity` table. + */ + shouldAddWebhooks(activationMode: WorkflowActivateMode) { + // Always try to populate the webhook entity table as well as register the webhooks + // to prevent issues with users upgrading from a version < 1.15, where the webhook entity + // was cleared on shutdown to anything past 1.28.0, where we stopped populating it on init, + // causing all webhooks to break + if (activationMode === 'init') return true; + + if (activationMode === 'leadershipChange') return false; + + return this.instanceSettings.isLeader; // 'update' or 'activate' + } + + /** + * Whether this instance may add triggers and pollers to memory. + * + * In both single- and multi-main setup, only the leader is allowed to manage + * triggers and pollers in memory, to ensure they are not duplicated. + */ + shouldAddTriggersAndPollers() { + return this.instanceSettings.isLeader; + } } diff --git a/packages/cli/src/controllers/orchestration.controller.ts b/packages/cli/src/controllers/orchestration.controller.ts index db1d690a3e..14d38cfa43 100644 --- a/packages/cli/src/controllers/orchestration.controller.ts +++ b/packages/cli/src/controllers/orchestration.controller.ts @@ -1,31 +1,23 @@ import { Post, RestController, GlobalScope } from '@/decorators'; import { License } from '@/license'; -import { OrchestrationRequest } from '@/requests'; -import { OrchestrationService } from '@/services/orchestration.service'; +import { Publisher } from '@/scaling/pubsub/publisher.service'; @RestController('/orchestration') export class OrchestrationController { constructor( - private readonly orchestrationService: OrchestrationService, private readonly licenseService: License, + private readonly publisher: Publisher, ) {} /** - * These endpoints do not return anything, they just trigger the message to + * This endpoint does not return anything, it just triggers the message to * the workers to respond on Redis with their status. */ - @GlobalScope('orchestration:read') - @Post('/worker/status/:id') - async getWorkersStatus(req: OrchestrationRequest.Get) { - if (!this.licenseService.isWorkerViewLicensed()) return; - const id = req.params.id; - return await this.orchestrationService.getWorkerStatus(id); - } - @GlobalScope('orchestration:read') @Post('/worker/status') async getWorkersStatusAll() { if (!this.licenseService.isWorkerViewLicensed()) return; - return await this.orchestrationService.getWorkerStatus(); + + return await this.publisher.publishCommand({ command: 'get-worker-status' }); } } diff --git a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts index 0f622c2317..3cf5a5a5d0 100644 --- a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts +++ b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts @@ -14,7 +14,7 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { License } from '@/license'; import { Logger } from '@/logging/logger.service'; -import { OrchestrationService } from '@/services/orchestration.service'; +import { Publisher } from '@/scaling/pubsub/publisher.service'; import { ExecutionRecoveryService } from '../../executions/execution-recovery.service'; import type { EventMessageTypes } from '../event-message-classes/'; @@ -70,7 +70,7 @@ export class MessageEventBus extends EventEmitter { private readonly executionRepository: ExecutionRepository, private readonly eventDestinationsRepository: EventDestinationsRepository, private readonly workflowRepository: WorkflowRepository, - private readonly orchestrationService: OrchestrationService, + private readonly publisher: Publisher, private readonly recoveryService: ExecutionRecoveryService, private readonly license: License, private readonly globalConfig: GlobalConfig, @@ -210,7 +210,7 @@ export class MessageEventBus extends EventEmitter { this.destinations[destination.getId()] = destination; this.destinations[destination.getId()].startListening(); if (notifyWorkers) { - await this.orchestrationService.publish('restart-event-bus'); + void this.publisher.publishCommand({ command: 'restart-event-bus' }); } return destination; } @@ -236,7 +236,7 @@ export class MessageEventBus extends EventEmitter { delete this.destinations[id]; } if (notifyWorkers) { - await this.orchestrationService.publish('restart-event-bus'); + void this.publisher.publishCommand({ command: 'restart-event-bus' }); } return result; } diff --git a/packages/cli/src/external-secrets/__tests__/external-secrets-manager.ee.test.ts b/packages/cli/src/external-secrets/__tests__/external-secrets-manager.ee.test.ts index 97547ecf13..b1a87271f9 100644 --- a/packages/cli/src/external-secrets/__tests__/external-secrets-manager.ee.test.ts +++ b/packages/cli/src/external-secrets/__tests__/external-secrets-manager.ee.test.ts @@ -55,6 +55,7 @@ describe('External Secrets Manager', () => { providersMock, cipher, mock(), + mock(), ); }); diff --git a/packages/cli/src/external-secrets/external-secrets-manager.ee.ts b/packages/cli/src/external-secrets/external-secrets-manager.ee.ts index e175f2969c..ec7c3ed0cf 100644 --- a/packages/cli/src/external-secrets/external-secrets-manager.ee.ts +++ b/packages/cli/src/external-secrets/external-secrets-manager.ee.ts @@ -1,6 +1,6 @@ import { Cipher } from 'n8n-core'; import { jsonParse, type IDataObject, ApplicationError } from 'n8n-workflow'; -import Container, { Service } from 'typedi'; +import { Service } from 'typedi'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; import { EventService } from '@/events/event.service'; @@ -11,7 +11,7 @@ import type { } from '@/interfaces'; import { License } from '@/license'; import { Logger } from '@/logging/logger.service'; -import { OrchestrationService } from '@/services/orchestration.service'; +import { Publisher } from '@/scaling/pubsub/publisher.service'; import { EXTERNAL_SECRETS_INITIAL_BACKOFF, EXTERNAL_SECRETS_MAX_BACKOFF } from './constants'; import { updateIntervalTime } from './external-secrets-helper.ee'; @@ -38,6 +38,7 @@ export class ExternalSecretsManager { private readonly secretsProviders: ExternalSecretsProviders, private readonly cipher: Cipher, private readonly eventService: EventService, + private readonly publisher: Publisher, ) {} async init(): Promise { @@ -78,8 +79,8 @@ export class ExternalSecretsManager { } } - async broadcastReloadExternalSecretsProviders() { - await Container.get(OrchestrationService).publish('reload-external-secrets-providers'); + broadcastReloadExternalSecretsProviders() { + void this.publisher.publishCommand({ command: 'reload-external-secrets-providers' }); } private decryptSecretsSettings(value: string): ExternalSecretsSettings { @@ -280,7 +281,7 @@ export class ExternalSecretsManager { await this.saveAndSetSettings(settings, this.settingsRepo); this.cachedSettings = settings; await this.reloadProvider(provider); - await this.broadcastReloadExternalSecretsProviders(); + this.broadcastReloadExternalSecretsProviders(); void this.trackProviderSave(provider, isNewProvider, userId); } @@ -300,7 +301,7 @@ export class ExternalSecretsManager { this.cachedSettings = settings; await this.reloadProvider(provider); await this.updateSecrets(); - await this.broadcastReloadExternalSecretsProviders(); + this.broadcastReloadExternalSecretsProviders(); } private async trackProviderSave(vaultType: string, isNew: boolean, userId?: string) { @@ -380,7 +381,7 @@ export class ExternalSecretsManager { } try { await this.providers[provider].update(); - await this.broadcastReloadExternalSecretsProviders(); + this.broadcastReloadExternalSecretsProviders(); return true; } catch { return false; diff --git a/packages/cli/src/push/__tests__/index.test.ts b/packages/cli/src/push/__tests__/index.test.ts index 6230c63397..03457926b1 100644 --- a/packages/cli/src/push/__tests__/index.test.ts +++ b/packages/cli/src/push/__tests__/index.test.ts @@ -20,7 +20,7 @@ describe('Push', () => { test('should validate pushRef on requests for websocket backend', () => { config.set('push.backend', 'websocket'); - const push = new Push(mock()); + const push = new Push(mock(), mock()); const ws = mock(); const request = mock({ user, ws }); request.query = { pushRef: '' }; @@ -33,7 +33,7 @@ describe('Push', () => { test('should validate pushRef on requests for SSE backend', () => { config.set('push.backend', 'sse'); - const push = new Push(mock()); + const push = new Push(mock(), mock()); const request = mock({ user, ws: undefined }); request.query = { pushRef: '' }; expect(() => push.handleRequest(request, mock())).toThrow(BadRequestError); diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index 232864968d..bfbfb43a51 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -12,6 +12,7 @@ import config from '@/config'; import type { User } from '@/databases/entities/user'; import { OnShutdown } from '@/decorators/on-shutdown'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { Publisher } from '@/scaling/pubsub/publisher.service'; import { OrchestrationService } from '@/services/orchestration.service'; import { TypedEmitter } from '@/typed-emitter'; @@ -39,7 +40,10 @@ export class Push extends TypedEmitter { private backend = useWebSockets ? Container.get(WebSocketPush) : Container.get(SSEPush); - constructor(private readonly orchestrationService: OrchestrationService) { + constructor( + private readonly orchestrationService: OrchestrationService, + private readonly publisher: Publisher, + ) { super(); if (useWebSockets) this.backend.on('message', (msg) => this.emit('message', msg)); @@ -89,8 +93,10 @@ export class Push extends TypedEmitter { * relay the former's execution lifecycle events to the creator's frontend. */ if (this.orchestrationService.isMultiMainSetupEnabled && !this.backend.hasPushRef(pushRef)) { - const payload = { type, args: data, pushRef }; - void this.orchestrationService.publish('relay-execution-lifecycle-event', payload); + void this.publisher.publishCommand({ + command: 'relay-execution-lifecycle-event', + payload: { type, args: data, pushRef }, + }); return; } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index e25a244f5f..ffc04925a3 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -478,15 +478,6 @@ export declare namespace ExternalSecretsRequest { type UpdateProvider = AuthenticatedRequest<{ provider: string }>; } -// ---------------------------------- -// /orchestration -// ---------------------------------- -// -export declare namespace OrchestrationRequest { - type GetAll = AuthenticatedRequest; - type Get = AuthenticatedRequest<{ id: string }, {}, {}, {}>; -} - // ---------------------------------- // /workflow-history // ---------------------------------- diff --git a/packages/cli/src/services/__tests__/orchestration.service.test.ts b/packages/cli/src/services/__tests__/orchestration.service.test.ts index 0169462891..a8e72c49bf 100644 --- a/packages/cli/src/services/__tests__/orchestration.service.test.ts +++ b/packages/cli/src/services/__tests__/orchestration.service.test.ts @@ -1,7 +1,6 @@ import type Redis from 'ioredis'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; -import type { WorkflowActivateMode } from 'n8n-workflow'; import Container from 'typedi'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; @@ -45,35 +44,4 @@ describe('Orchestration Service', () => { // @ts-expect-error Private field expect(os.publisher).toBeDefined(); }); - - describe('shouldAddWebhooks', () => { - test('should return true for init', () => { - // We want to ensure that webhooks are populated on init - // more https://github.com/n8n-io/n8n/pull/8830 - const result = os.shouldAddWebhooks('init'); - expect(result).toBe(true); - }); - - test('should return false for leadershipChange', () => { - const result = os.shouldAddWebhooks('leadershipChange'); - expect(result).toBe(false); - }); - - test('should return true for update or activate when is leader', () => { - const modes = ['update', 'activate'] as WorkflowActivateMode[]; - for (const mode of modes) { - const result = os.shouldAddWebhooks(mode); - expect(result).toBe(true); - } - }); - - test('should return false for update or activate when not leader', () => { - instanceSettings.markAsFollower(); - const modes = ['update', 'activate'] as WorkflowActivateMode[]; - for (const mode of modes) { - const result = os.shouldAddWebhooks(mode); - expect(result).toBe(false); - } - }); - }); }); diff --git a/packages/cli/src/services/community-packages.service.ts b/packages/cli/src/services/community-packages.service.ts index b157119cf2..4906a6ef33 100644 --- a/packages/cli/src/services/community-packages.service.ts +++ b/packages/cli/src/services/community-packages.service.ts @@ -23,10 +23,9 @@ import type { CommunityPackages } from '@/interfaces'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { Logger } from '@/logging/logger.service'; +import { Publisher } from '@/scaling/pubsub/publisher.service'; import { toError } from '@/utils'; -import { OrchestrationService } from './orchestration.service'; - const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; const { @@ -60,7 +59,7 @@ export class CommunityPackagesService { private readonly logger: Logger, private readonly installedPackageRepository: InstalledPackagesRepository, private readonly loadNodesAndCredentials: LoadNodesAndCredentials, - private readonly orchestrationService: OrchestrationService, + private readonly publisher: Publisher, private readonly license: License, private readonly globalConfig: GlobalConfig, ) {} @@ -322,7 +321,10 @@ export class CommunityPackagesService { async removePackage(packageName: string, installedPackage: InstalledPackages): Promise { await this.removeNpmPackage(packageName); await this.removePackageFromDatabase(installedPackage); - await this.orchestrationService.publish('community-package-uninstall', { packageName }); + void this.publisher.publishCommand({ + command: 'community-package-uninstall', + payload: { packageName }, + }); } private getNpmRegistry() { @@ -368,10 +370,10 @@ export class CommunityPackagesService { await this.removePackageFromDatabase(options.installedPackage); } const installedPackage = await this.persistInstalledPackage(loader); - await this.orchestrationService.publish( - isUpdate ? 'community-package-update' : 'community-package-install', - { packageName, packageVersion }, - ); + void this.publisher.publishCommand({ + command: isUpdate ? 'community-package-update' : 'community-package-install', + payload: { packageName, packageVersion }, + }); await this.loadNodesAndCredentials.postProcessLoaders(); this.logger.info(`Community package installed: ${packageName}`); return installedPackage; diff --git a/packages/cli/src/services/orchestration.service.ts b/packages/cli/src/services/orchestration.service.ts index 64dbd0ddae..61a2aff540 100644 --- a/packages/cli/src/services/orchestration.service.ts +++ b/packages/cli/src/services/orchestration.service.ts @@ -1,10 +1,7 @@ import { InstanceSettings } from 'n8n-core'; -import type { WorkflowActivateMode } from 'n8n-workflow'; import Container, { Service } from 'typedi'; import config from '@/config'; -import type { PubSubCommandMap } from '@/events/maps/pub-sub.event-map'; -import { Logger } from '@/logging/logger.service'; import type { Publisher } from '@/scaling/pubsub/publisher.service'; import type { Subscriber } from '@/scaling/pubsub/subscriber.service'; @@ -13,7 +10,6 @@ import { MultiMainSetup } from './orchestration/main/multi-main-setup.ee'; @Service() export class OrchestrationService { constructor( - private readonly logger: Logger, readonly instanceSettings: InstanceSettings, readonly multiMainSetup: MultiMainSetup, ) {} @@ -78,68 +74,4 @@ export class OrchestrationService { this.isInitialized = false; } - - // ---------------------------------- - // pubsub - // ---------------------------------- - - async publish( - commandKey: CommandKey, - payload?: PubSubCommandMap[CommandKey], - ) { - if (!this.sanityCheck()) return; - - this.logger.debug( - `[Instance ID ${this.instanceSettings.hostId}] Publishing command "${commandKey}"`, - payload, - ); - - await this.publisher.publishCommand({ command: commandKey, payload }); - } - - // ---------------------------------- - // workers status - // ---------------------------------- - - async getWorkerStatus(id?: string) { - if (!this.sanityCheck()) return; - - const command = 'get-worker-status'; - - this.logger.debug(`Sending "${command}" to command channel`); - - await this.publisher.publishCommand({ - command, - targets: id ? [id] : undefined, - }); - } - - // ---------------------------------- - // activations - // ---------------------------------- - - /** - * Whether this instance may add webhooks to the `webhook_entity` table. - */ - shouldAddWebhooks(activationMode: WorkflowActivateMode) { - // Always try to populate the webhook entity table as well as register the webhooks - // to prevent issues with users upgrading from a version < 1.15, where the webhook entity - // was cleared on shutdown to anything past 1.28.0, where we stopped populating it on init, - // causing all webhooks to break - if (activationMode === 'init') return true; - - if (activationMode === 'leadershipChange') return false; - - return this.instanceSettings.isLeader; // 'update' or 'activate' - } - - /** - * Whether this instance may add triggers and pollers to memory. - * - * In both single- and multi-main setup, only the leader is allowed to manage - * triggers and pollers in memory, to ensure they are not duplicated. - */ - shouldAddTriggersAndPollers() { - return this.instanceSettings.isLeader; - } } diff --git a/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts index d9228bcb0d..3f8972ad9a 100644 --- a/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts @@ -39,7 +39,7 @@ let testWebhooks: TestWebhooks; describe('TestWebhooks', () => { beforeAll(() => { - testWebhooks = new TestWebhooks(mock(), mock(), registrations, mock()); + testWebhooks = new TestWebhooks(mock(), mock(), registrations, mock(), mock()); jest.useFakeTimers(); }); diff --git a/packages/cli/src/webhooks/test-webhooks.ts b/packages/cli/src/webhooks/test-webhooks.ts index 21511d4843..bf2fa6c9d8 100644 --- a/packages/cli/src/webhooks/test-webhooks.ts +++ b/packages/cli/src/webhooks/test-webhooks.ts @@ -16,6 +16,7 @@ import { WorkflowMissingIdError } from '@/errors/workflow-missing-id.error'; import type { IWorkflowDb } from '@/interfaces'; import { NodeTypes } from '@/node-types'; import { Push } from '@/push'; +import { Publisher } from '@/scaling/pubsub/publisher.service'; import { OrchestrationService } from '@/services/orchestration.service'; import { removeTrailingSlash } from '@/utils'; import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrations.service'; @@ -41,6 +42,7 @@ export class TestWebhooks implements IWebhookManager { private readonly nodeTypes: NodeTypes, private readonly registrations: TestWebhookRegistrationsService, private readonly orchestrationService: OrchestrationService, + private readonly publisher: Publisher, ) {} private timeouts: { [webhookKey: string]: NodeJS.Timeout } = {}; @@ -156,8 +158,10 @@ export class TestWebhooks implements IWebhookManager { pushRef && !this.push.getBackend().hasPushRef(pushRef) ) { - const payload = { webhookKey: key, workflowEntity, pushRef }; - void this.orchestrationService.publish('clear-test-webhooks', payload); + void this.publisher.publishCommand({ + command: 'clear-test-webhooks', + payload: { webhookKey: key, workflowEntity, pushRef }, + }); return; } diff --git a/packages/cli/test/integration/active-workflow-manager.test.ts b/packages/cli/test/integration/active-workflow-manager.test.ts index d5d471ba60..8ea790ade7 100644 --- a/packages/cli/test/integration/active-workflow-manager.test.ts +++ b/packages/cli/test/integration/active-workflow-manager.test.ts @@ -1,4 +1,5 @@ import { mock } from 'jest-mock-extended'; +import type { InstanceSettings } from 'n8n-core'; import { NodeApiError, NodeOperationError, Workflow } from 'n8n-workflow'; import type { IWebhookData, WorkflowActivateMode } from 'n8n-workflow'; import { Container } from 'typedi'; @@ -278,3 +279,72 @@ describe('addWebhooks()', () => { expect(webhookService.storeWebhook).toHaveBeenCalledTimes(1); }); }); + +describe('shouldAddWebhooks', () => { + describe('if leader', () => { + const activeWorkflowManager = new ActiveWorkflowManager( + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock({ isLeader: true, isFollower: false }), + mock(), + ); + + test('should return `true` for `init`', () => { + // ensure webhooks are populated on init: https://github.com/n8n-io/n8n/pull/8830 + const result = activeWorkflowManager.shouldAddWebhooks('init'); + expect(result).toBe(true); + }); + + test('should return `false` for `leadershipChange`', () => { + const result = activeWorkflowManager.shouldAddWebhooks('leadershipChange'); + expect(result).toBe(false); + }); + + test('should return `true` for `update` or `activate`', () => { + const modes = ['update', 'activate'] as WorkflowActivateMode[]; + for (const mode of modes) { + const result = activeWorkflowManager.shouldAddWebhooks(mode); + expect(result).toBe(true); + } + }); + }); + + describe('if follower', () => { + const activeWorkflowManager = new ActiveWorkflowManager( + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock({ isLeader: false, isFollower: true }), + mock(), + ); + + test('should return `false` for `update` or `activate`', () => { + const modes = ['update', 'activate'] as WorkflowActivateMode[]; + for (const mode of modes) { + const result = activeWorkflowManager.shouldAddWebhooks(mode); + expect(result).toBe(false); + } + }); + }); +}); diff --git a/packages/cli/test/integration/collaboration/collaboration.service.test.ts b/packages/cli/test/integration/collaboration/collaboration.service.test.ts index a90424de87..df5f901f28 100644 --- a/packages/cli/test/integration/collaboration/collaboration.service.test.ts +++ b/packages/cli/test/integration/collaboration/collaboration.service.test.ts @@ -16,7 +16,7 @@ import { createWorkflow, shareWorkflowWithUsers } from '@test-integration/db/wor import * as testDb from '@test-integration/test-db'; describe('CollaborationService', () => { - mockInstance(Push, new Push(mock())); + mockInstance(Push, new Push(mock(), mock())); let pushService: Push; let collaborationService: CollaborationService; let owner: User; diff --git a/packages/cli/test/integration/eventbus.ee.test.ts b/packages/cli/test/integration/eventbus.ee.test.ts index 9b12cc53d5..c2b6a7f23c 100644 --- a/packages/cli/test/integration/eventbus.ee.test.ts +++ b/packages/cli/test/integration/eventbus.ee.test.ts @@ -22,6 +22,7 @@ import type { MessageEventBusDestinationSentry } from '@/eventbus/message-event- import type { MessageEventBusDestinationSyslog } from '@/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee'; import type { MessageEventBusDestinationWebhook } from '@/eventbus/message-event-bus-destination/message-event-bus-destination-webhook.ee'; import { ExecutionRecoveryService } from '@/executions/execution-recovery.service'; +import { Publisher } from '@/scaling/pubsub/publisher.service'; import { createUser } from './shared/db/users'; import type { SuperAgentTest } from './shared/types'; @@ -34,6 +35,8 @@ const mockedAxios = axios as jest.Mocked; jest.mock('syslog-client'); const mockedSyslog = syslog as jest.Mocked; +mockInstance(Publisher); + let owner: User; let authOwnerAgent: SuperAgentTest; diff --git a/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts b/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts index b3560b9262..3418576be1 100644 --- a/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts +++ b/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts @@ -63,6 +63,7 @@ const resetManager = async () => { mockProvidersInstance, Container.get(Cipher), eventService, + mock(), ), ); diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index 4d4a207f94..78de2c1b25 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -32,7 +32,6 @@ export { setupTestServer } from './test-server'; export async function initActiveWorkflowManager() { mockInstance(OrchestrationService, { isMultiMainSetupEnabled: false, - shouldAddWebhooks: jest.fn().mockReturnValue(true), }); mockInstance(Push); From 5c370c923585ee99c020b5971b8ec8d447ca8fca Mon Sep 17 00:00:00 2001 From: Val <68596159+valya@users.noreply.github.com> Date: Wed, 16 Oct 2024 17:51:06 +0100 Subject: [PATCH 08/57] fix: JS Task Runner not having access to Buffer (no-changelog) (#11285) --- .../__tests__/js-task-runner.test.ts | 16 ++++++++++++++-- .../src/js-task-runner/js-task-runner.ts | 13 ++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts index 499105f39d..d6f65e2cfa 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts @@ -281,6 +281,20 @@ describe('JsTaskRunner', () => { expect(outcome.result).toEqual([wrapIntoJson({ val: undefined })]); }); }); + + it('should allow access to Node.js Buffers', async () => { + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: 'return { val: Buffer.from("test-buffer").toString() }', + nodeMode: 'runOnceForAllItems', + }), + taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), { + envProviderState: undefined, + }), + }); + + expect(outcome.result).toEqual([wrapIntoJson({ val: 'test-buffer' })]); + }); }); describe('runOnceForAllItems', () => { @@ -755,8 +769,6 @@ describe('JsTaskRunner', () => { }, }), ); - - console.log('DONE'); }, 1000); }); }); diff --git a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts index 5bf2e06f26..4a7235f5d1 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts @@ -181,6 +181,17 @@ export class JsTaskRunner extends TaskRunner { module: {}, console: customConsole, + // Exposed Node.js globals in vm2 + Buffer, + Function, + eval, + setTimeout, + setInterval, + setImmediate, + clearTimeout, + clearInterval, + clearImmediate, + items: inputItems, ...dataProxy, ...this.buildRpcCallObject(taskId), @@ -188,7 +199,7 @@ export class JsTaskRunner extends TaskRunner { try { const result = (await runInNewContext( - `module.exports = async function VmCodeWrapper() {${settings.code}\n}()`, + `globalThis.global = globalThis; module.exports = async function VmCodeWrapper() {${settings.code}\n}()`, context, )) as TaskResultData['result']; From 1f441f97528f58e905eaf8930577bbcd08debf06 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Thu, 17 Oct 2024 09:25:32 +0200 Subject: [PATCH 09/57] fix(editor): Add Personal project to main navigation (#11161) --- .../src/components/N8nMenuItem/MenuItem.vue | 2 +- .../Projects/ProjectNavigation.test.ts | 175 ++++++++++++++++++ .../components/Projects/ProjectNavigation.vue | 52 ++++-- .../src/plugins/i18n/locales/en.json | 2 + packages/editor-ui/src/plugins/icons/index.ts | 2 + .../editor-ui/src/stores/projects.store.ts | 8 +- 6 files changed, 222 insertions(+), 19 deletions(-) create mode 100644 packages/editor-ui/src/components/Projects/ProjectNavigation.test.ts diff --git a/packages/design-system/src/components/N8nMenuItem/MenuItem.vue b/packages/design-system/src/components/N8nMenuItem/MenuItem.vue index 03fdb7e182..e9a34c7e04 100644 --- a/packages/design-system/src/components/N8nMenuItem/MenuItem.vue +++ b/packages/design-system/src/components/N8nMenuItem/MenuItem.vue @@ -104,7 +104,7 @@ const isItemActive = (item: IMenuItem): boolean => { diff --git a/packages/editor-ui/src/components/Projects/ProjectNavigation.test.ts b/packages/editor-ui/src/components/Projects/ProjectNavigation.test.ts new file mode 100644 index 0000000000..5ad62ed8e9 --- /dev/null +++ b/packages/editor-ui/src/components/Projects/ProjectNavigation.test.ts @@ -0,0 +1,175 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import { createRouter, createMemoryHistory, useRouter } from 'vue-router'; +import { createProjectListItem } from '@/__tests__/data/projects'; +import ProjectsNavigation from '@/components/Projects//ProjectNavigation.vue'; +import { useProjectsStore } from '@/stores/projects.store'; +import { useUIStore } from '@/stores/ui.store'; +import { mockedStore } from '@/__tests__/utils'; +import type { Project } from '@/types/projects.types'; +import { VIEWS } from '@/constants'; +import { useToast } from '@/composables/useToast'; + +vi.mock('vue-router', async () => { + const actual = await vi.importActual('vue-router'); + const push = vi.fn(); + return { + ...actual, + useRouter: () => ({ + push, + }), + RouterLink: { + template: '', + }, + }; +}); + +vi.mock('@/composables/useToast', () => { + const showMessage = vi.fn(); + const showError = vi.fn(); + return { + useToast: () => ({ + showMessage, + showError, + }), + }; +}); + +const renderComponent = createComponentRenderer(ProjectsNavigation, { + global: { + plugins: [ + createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/', + name: 'home', + component: { template: '
Home
' }, + }, + ], + }), + ], + }, +}); + +let router: ReturnType; +let toast: ReturnType; +let projectsStore: ReturnType>; +let uiStore: ReturnType>; + +const personalProjects = Array.from({ length: 3 }, createProjectListItem); +const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team')); + +describe('ProjectsNavigation', () => { + beforeEach(() => { + createTestingPinia(); + + router = useRouter(); + toast = useToast(); + + projectsStore = mockedStore(useProjectsStore); + uiStore = mockedStore(useUIStore); + }); + + it('should not throw an error', () => { + projectsStore.teamProjectsLimit = -1; + expect(() => { + renderComponent({ + props: { + collapsed: false, + }, + }); + }).not.toThrow(); + }); + + it('should not show "Add project" button when conditions are not met', async () => { + projectsStore.teamProjectsLimit = 0; + projectsStore.hasPermissionToCreateProjects = false; + + const { queryByText } = renderComponent({ + props: { + collapsed: false, + }, + }); + + expect(queryByText('Add project')).not.toBeInTheDocument(); + }); + + it('should show "Add project" button when conditions met', async () => { + projectsStore.teamProjectsLimit = -1; + projectsStore.hasPermissionToCreateProjects = true; + projectsStore.createProject.mockResolvedValue({ + id: '1', + name: 'My project 1', + } as Project); + + const { getByText } = renderComponent({ + props: { + collapsed: false, + }, + }); + + expect(getByText('Add project')).toBeVisible(); + await userEvent.click(getByText('Add project')); + + expect(projectsStore.createProject).toHaveBeenCalledWith({ + name: 'My project', + }); + expect(router.push).toHaveBeenCalledWith({ + name: VIEWS.PROJECT_SETTINGS, + params: { projectId: '1' }, + }); + expect(toast.showMessage).toHaveBeenCalledWith({ + title: 'Project My project 1 saved successfully', + type: 'success', + }); + }); + + it('should show "Add project" button tooltip when project creation limit reached', async () => { + projectsStore.teamProjectsLimit = 3; + projectsStore.hasPermissionToCreateProjects = true; + projectsStore.canCreateProjects = false; + + const { getByText } = renderComponent({ + props: { + collapsed: false, + planName: 'Free', + }, + }); + + expect(getByText('Add project')).toBeVisible(); + await userEvent.hover(getByText('Add project')); + + expect(getByText(/You have reached the Free plan limit of 3/)).toBeVisible(); + await userEvent.click(getByText('View plans')); + + expect(uiStore.goToUpgrade).toHaveBeenCalledWith('rbac', 'upgrade-rbac'); + }); + + it('should show "Projects" title and projects if the user has access to any team project', async () => { + projectsStore.myProjects = [...personalProjects, ...teamProjects]; + + const { getByRole, getAllByTestId, getByTestId } = renderComponent({ + props: { + collapsed: false, + }, + }); + + expect(getByRole('heading', { level: 3, name: 'Projects' })).toBeVisible(); + expect(getByTestId('project-personal-menu-item')).toBeVisible(); + expect(getAllByTestId('project-menu-item')).toHaveLength(teamProjects.length); + }); + + it('should not show "Projects" title when the menu is collapsed', async () => { + projectsStore.myProjects = [...personalProjects, ...teamProjects]; + + const { queryByRole } = renderComponent({ + props: { + collapsed: true, + }, + }); + + expect(queryByRole('heading', { level: 3, name: 'Projects' })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/components/Projects/ProjectNavigation.vue b/packages/editor-ui/src/components/Projects/ProjectNavigation.vue index 2b4909e0f2..e14328b01e 100644 --- a/packages/editor-ui/src/components/Projects/ProjectNavigation.vue +++ b/packages/editor-ui/src/components/Projects/ProjectNavigation.vue @@ -44,6 +44,7 @@ const addProject = computed(() => ({ const getProjectMenuItem = (project: ProjectListItem) => ({ id: project.id, label: project.name, + icon: 'layer-group', route: { to: { name: VIEWS.PROJECTS_WORKFLOWS, @@ -52,8 +53,18 @@ const getProjectMenuItem = (project: ProjectListItem) => ({ }, }); -const homeClicked = () => {}; -const projectClicked = () => {}; +const personalProject = computed(() => ({ + id: projectsStore.personalProject?.id ?? '', + label: locale.baseText('projects.menu.personal'), + icon: 'user', + route: { + to: { + name: VIEWS.PROJECTS_WORKFLOWS, + params: { projectId: projectsStore.personalProject?.id }, + }, + }, +})); + const addProjectClicked = async () => { isCreatingProject.value = true; @@ -107,7 +118,6 @@ onMounted(async () => { { " class="mt-m mb-m" /> + + {{ locale.baseText('projects.menu.title') }} + + { }" :item="getProjectMenuItem(project)" :compact="props.collapsed" - :handle-select="projectClicked" :active-tab="projectsStore.projectNavActiveId" mode="tabs" data-test-id="project-menu-item" /> - - + + { .collapsed { text-transform: uppercase; } + +.projectsLabel { + margin: 0 var(--spacing-xs) var(--spacing-s); + padding: 0 var(--spacing-s); + text-overflow: ellipsis; + overflow: hidden; + box-sizing: border-box; + color: var(--color-text-base); +} + + + +
+
+
+
+

{{title}}

+

{{message}}

+
+
+ {{#if appendAttribution}} + + {{/if}} +
+
+ + + \ No newline at end of file diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 5493a76e7f..39627c0204 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -315,7 +315,7 @@
{{#if testRun}}
-

This is test version of your form. Use it only for testing your Form Trigger.

+

This is test version of your form


{{/if}} @@ -428,7 +428,7 @@ d='M304 48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm0 416a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM48 304a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm464-48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM142.9 437A48 48 0 1 0 75 369.1 48 48 0 1 0 142.9 437zm0-294.2A48 48 0 1 0 75 75a48 48 0 1 0 67.9 67.9zM369.1 437A48 48 0 1 0 437 369.1 48 48 0 1 0 369.1 437z' /> - Submit form + {{ buttonLabel }} {{else}} @@ -719,6 +719,10 @@ } if (response.status === 200) { + if(response.redirected) { + window.location.replace(response.url); + return; + } const redirectUrl = document.getElementById("redirectUrl"); if (redirectUrl) { window.location.replace(redirectUrl.href); @@ -731,7 +735,7 @@ document.querySelector('#submitted-form').style.display = 'block'; document.querySelector('#submitted-header').textContent = 'Problem submitting response'; document.querySelector('#submitted-content').textContent = - 'An error occurred in the workflow handling this form'; + 'Please try again or contact support if the problem persists'; } return; @@ -747,6 +751,15 @@ .catch(function (error) { console.error('Error:', error); }); + + const interval = setInterval(function() { + const isSubmited = document.querySelector('#submitted-form').style.display; + if(isSubmited === 'block') { + clearInterval(interval); + return; + } + window.location.reload(); + }, 2000); } }); diff --git a/packages/cli/test/integration/webhooks.test.ts b/packages/cli/test/integration/webhooks.test.ts index 165822aa84..7d7b5105cb 100644 --- a/packages/cli/test/integration/webhooks.test.ts +++ b/packages/cli/test/integration/webhooks.test.ts @@ -5,9 +5,9 @@ import type SuperAgentTest from 'supertest/lib/agent'; import Container from 'typedi'; import { ExternalHooks } from '@/external-hooks'; -import { WaitingForms } from '@/waiting-forms'; import { LiveWebhooks } from '@/webhooks/live-webhooks'; import { TestWebhooks } from '@/webhooks/test-webhooks'; +import { WaitingForms } from '@/webhooks/waiting-forms'; import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; import { WebhookServer } from '@/webhooks/webhook-server'; import type { IWebhookResponseCallbackData } from '@/webhooks/webhook.types'; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 6cbef1e1b8..232442be98 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -4436,6 +4436,36 @@ export function getExecuteWebhookFunctions( ); }, getMode: () => mode, + evaluateExpression: (expression: string, evaluateItemIndex?: number) => { + const itemIndex = evaluateItemIndex === undefined ? 0 : evaluateItemIndex; + const runIndex = 0; + + let connectionInputData: INodeExecutionData[] = []; + let executionData: IExecuteData | undefined; + + if (runExecutionData?.executionData !== undefined) { + executionData = runExecutionData.executionData.nodeExecutionStack[0]; + + if (executionData !== undefined) { + connectionInputData = executionData.data.main[0]!; + } + } + + const additionalKeys = getAdditionalKeys(additionalData, mode, runExecutionData); + + return workflow.expression.resolveSimpleParameterValue( + `=${expression}`, + {}, + runExecutionData, + runIndex, + itemIndex, + node.name, + connectionInputData, + mode, + additionalKeys, + executionData, + ); + }, getNodeParameter: ( parameterName: string, fallbackValue?: any, diff --git a/packages/editor-ui/src/components/JsonEditor/JsonEditor.vue b/packages/editor-ui/src/components/JsonEditor/JsonEditor.vue index 650aa8f48b..a24bf2373f 100644 --- a/packages/editor-ui/src/components/JsonEditor/JsonEditor.vue +++ b/packages/editor-ui/src/components/JsonEditor/JsonEditor.vue @@ -111,18 +111,18 @@ function destroyEditor() { diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index ae46c79c6f..fa9e0cb069 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -2,6 +2,7 @@ import { useStorage } from '@/composables/useStorage'; import { CUSTOM_API_CALL_KEY, + FORM_NODE_TYPE, LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, MANUAL_TRIGGER_NODE_TYPE, NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS, @@ -339,6 +340,9 @@ const waiting = computed(() => { if (node?.parameters.operation === SEND_AND_WAIT_OPERATION) { return i18n.baseText('node.theNodeIsWaitingUserInput'); } + if (node?.type === FORM_NODE_TYPE) { + return i18n.baseText('node.theNodeIsWaitingFormCall'); + } const waitDate = new Date(workflowExecution.waitTill); if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { return i18n.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall'); diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index 2b71b523b3..288dad84c0 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -1142,7 +1142,7 @@ onUpdated(async () => { :model-value="modelValueString" :is-read-only="isReadOnly" :rows="editorRows" - fill-parent + fullscreen @update:model-value="valueChangedDebounced" /> diff --git a/packages/editor-ui/src/components/ParameterInputList.vue b/packages/editor-ui/src/components/ParameterInputList.vue index 146e7c0961..78932fb9b0 100644 --- a/packages/editor-ui/src/components/ParameterInputList.vue +++ b/packages/editor-ui/src/components/ParameterInputList.vue @@ -5,7 +5,7 @@ import type { NodeParameterValue, NodeParameterValueType, } from 'n8n-workflow'; -import { deepCopy } from 'n8n-workflow'; +import { deepCopy, ADD_FORM_NOTICE } from 'n8n-workflow'; import { computed, defineAsyncComponent, onErrorCaptured, ref, watch } from 'vue'; import type { IUpdateInformation } from '@/Interface'; @@ -19,7 +19,12 @@ import ParameterInputFull from '@/components/ParameterInputFull.vue'; import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; -import { KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants'; +import { + FORM_NODE_TYPE, + FORM_TRIGGER_NODE_TYPE, + KEEP_AUTH_IN_NDV_FOR_NODES, + WAIT_NODE_TYPE, +} from '@/constants'; import { useNDVStore } from '@/stores/ndv.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { @@ -91,7 +96,20 @@ const nodeType = computed(() => { }); const filteredParameters = computed(() => { - return props.parameters.filter((parameter: INodeProperties) => displayNodeParameter(parameter)); + const parameters = props.parameters.filter((parameter: INodeProperties) => + displayNodeParameter(parameter), + ); + + const activeNode = ndvStore.activeNode; + + if (activeNode && activeNode.type === FORM_TRIGGER_NODE_TYPE) { + return updateFormTriggerParameters(parameters, activeNode.name); + } + if (activeNode && activeNode.type === WAIT_NODE_TYPE && activeNode.parameters.resume === 'form') { + return updateWaitParameters(parameters, activeNode.name); + } + + return parameters; }); const filteredParameterNames = computed(() => { @@ -151,6 +169,90 @@ watch(filteredParameterNames, (newValue, oldValue) => { } }); +function updateFormTriggerParameters(parameters: INodeProperties[], triggerName: string) { + const workflow = workflowHelpers.getCurrentWorkflow(); + const connectedNodes = workflow.getChildNodes(triggerName); + + const hasFormPage = connectedNodes.some((nodeName) => { + const node = workflow.getNode(nodeName); + return node && node.type === FORM_NODE_TYPE; + }); + + if (hasFormPage) { + const triggerParameters: INodeProperties[] = []; + + for (const parameter of parameters) { + if (parameter.name === 'responseMode') { + triggerParameters.push({ + displayName: 'On submission, the user will be taken to the next form node', + name: 'formResponseModeNotice', + type: 'notice', + default: '', + }); + + continue; + } + + if (parameter.name === ADD_FORM_NOTICE) continue; + + if (parameter.name === 'options') { + const options = (parameter.options as INodeProperties[]).filter( + (option) => option.name !== 'respondWithOptions', + ); + triggerParameters.push({ + ...parameter, + options, + }); + continue; + } + + triggerParameters.push(parameter); + } + return triggerParameters; + } + + return parameters; +} + +function updateWaitParameters(parameters: INodeProperties[], nodeName: string) { + const workflow = workflowHelpers.getCurrentWorkflow(); + const parentNodes = workflow.getParentNodes(nodeName); + + const formTriggerName = parentNodes.find( + (node) => workflow.nodes[node].type === FORM_TRIGGER_NODE_TYPE, + ); + if (!formTriggerName) return parameters; + + const connectedNodes = workflow.getChildNodes(formTriggerName); + + const hasFormPage = connectedNodes.some((nodeName) => { + const node = workflow.getNode(nodeName); + return node && node.type === FORM_NODE_TYPE; + }); + + if (hasFormPage) { + const waitNodeParameters: INodeProperties[] = []; + + for (const parameter of parameters) { + if (parameter.name === 'options') { + const options = (parameter.options as INodeProperties[]).filter( + (option) => option.name !== 'respondWithOptions' && option.name !== 'webhookSuffix', + ); + waitNodeParameters.push({ + ...parameter, + options, + }); + continue; + } + + waitNodeParameters.push(parameter); + } + return waitNodeParameters; + } + + return parameters; +} + function onParameterBlur(parameterName: string) { emit('parameterBlur', parameterName); } diff --git a/packages/editor-ui/src/composables/__tests__/useRunWorkflow.spec.ts b/packages/editor-ui/src/composables/__tests__/useRunWorkflow.spec.ts index 0004354f50..608fc36e87 100644 --- a/packages/editor-ui/src/composables/__tests__/useRunWorkflow.spec.ts +++ b/packages/editor-ui/src/composables/__tests__/useRunWorkflow.spec.ts @@ -333,6 +333,7 @@ describe('useRunWorkflow({ router })', () => { vi.mocked(workflowsStore).allNodes = []; vi.mocked(workflowsStore).getExecution.mockResolvedValue({ finished: true, + workflowData: { nodes: [] }, } as unknown as IExecutionResponse); vi.mocked(workflowsStore).workflowExecutionData = { id: '123', diff --git a/packages/editor-ui/src/composables/useCanvasMapping.ts b/packages/editor-ui/src/composables/useCanvasMapping.ts index fe33c1854c..587984fa35 100644 --- a/packages/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/editor-ui/src/composables/useCanvasMapping.ts @@ -40,6 +40,7 @@ import { NodeConnectionType, NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-wo import type { INodeUi } from '@/Interface'; import { CUSTOM_API_CALL_KEY, + FORM_NODE_TYPE, STICKY_NODE_TYPE, WAIT_NODE_TYPE, WAIT_TIME_UNLIMITED, @@ -353,6 +354,11 @@ export function useCanvasMapping({ return acc; } + if (node?.type === FORM_NODE_TYPE) { + acc[node.id] = i18n.baseText('node.theNodeIsWaitingFormCall'); + return acc; + } + const waitDate = new Date(workflowExecution.waitTill); if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { diff --git a/packages/editor-ui/src/composables/useCanvasOperations.ts b/packages/editor-ui/src/composables/useCanvasOperations.ts index af8ef1de88..d1f7259886 100644 --- a/packages/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/editor-ui/src/composables/useCanvasOperations.ts @@ -76,6 +76,7 @@ import type { Connection } from '@vue-flow/core'; import type { IConnection, IConnections, + IDataObject, INode, INodeConnections, INodeCredentials, @@ -1673,6 +1674,12 @@ export function useCanvasOperations({ router }: { router: ReturnType { return (node: INode) => { - return `${rootStore.formTestUrl}/${node.parameters.path}`; + const path = + node.parameters.path || + (node.parameters.options as IDataObject)?.path || + node.webhookId; + return `${rootStore.formTestUrl}/${path as string}`; }; })(); @@ -373,6 +377,11 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { + return node.name === lastNodeExecuted; + }); + if ( execution.finished || ['error', 'canceled', 'crashed', 'success'].includes(execution.status) @@ -390,24 +399,38 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { - return node.name === lastNodeExecuted; - }); - if ( - waitingNode && - waitingNode.type === WAIT_NODE_TYPE && - waitingNode.parameters.resume === 'form' + lastNode && + (lastNode.type === FORM_NODE_TYPE || + (lastNode.type === WAIT_NODE_TYPE && lastNode.parameters.resume === 'form')) ) { - const testUrl = getFormResumeUrl(waitingNode, executionId as string); + let testUrl = getFormResumeUrl(lastNode, executionId as string); if (isFormShown) { localStorage.setItem(FORM_RELOAD, testUrl); } else { - isFormShown = true; - openPopUpWindow(testUrl); + if (options.destinationNode) { + // Check if the form trigger has starting data + // if not do not show next form as trigger would redirect to page + // otherwise there would be duplicate popup + const formTrigger = execution?.workflowData.nodes.find((node) => { + return node.type === FORM_TRIGGER_NODE_TYPE; + }); + const runNodeFilter = execution?.data?.startData?.runNodeFilter || []; + if (formTrigger && !runNodeFilter.includes(formTrigger.name)) { + isFormShown = true; + } + } + if (!isFormShown) { + if (lastNode.type === FORM_NODE_TYPE) { + testUrl = `${rootStore.formWaitingUrl}/${executionId}`; + } else { + testUrl = getFormResumeUrl(lastNode, executionId as string); + } + + isFormShown = true; + if (testUrl) openPopUpWindow(testUrl); + } } } } diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 521c481e9e..e72da4d2d0 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -195,6 +195,7 @@ export const CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE = export const SIMULATE_NODE_TYPE = 'n8n-nodes-base.simulate'; export const SIMULATE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.simulateTrigger'; export const AI_TRANSFORM_NODE_TYPE = 'n8n-nodes-base.aiTransform'; +export const FORM_NODE_TYPE = 'n8n-nodes-base.form'; export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base'; export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 53f0a82a0d..35bc02c799 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1106,7 +1106,9 @@ "nodeCreator.triggerHelperPanel.webhookTriggerDisplayName": "On webhook call", "nodeCreator.triggerHelperPanel.webhookTriggerDescription": "Runs the flow on receiving an HTTP request", "nodeCreator.triggerHelperPanel.formTriggerDisplayName": "On form submission", - "nodeCreator.triggerHelperPanel.formTriggerDescription": "Runs the flow when an n8n generated webform is submitted", + "nodeCreator.triggerHelperPanel.formTriggerDescription": "Generate webforms in n8n and pass their responses to the workflow", + "nodeCreator.triggerHelperPanel.formDisplayName": "Form", + "nodeCreator.triggerHelperPanel.formDescription": "Add next form page", "nodeCreator.triggerHelperPanel.manualTriggerDisplayName": "Trigger manually", "nodeCreator.triggerHelperPanel.manualTriggerDescription": "Runs the flow on clicking a button in n8n. Good for getting started quickly", "nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName": "On chat message", diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 8deb9292ef..3a8778d2cc 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -3,6 +3,7 @@ import { DEFAULT_NEW_WORKFLOW_NAME, DUPLICATE_POSTFFIX, ERROR_TRIGGER_NODE_TYPE, + FORM_NODE_TYPE, MAX_WORKFLOW_NAME_LENGTH, PLACEHOLDER_EMPTY_WORKFLOW_ID, START_NODE_TYPE, @@ -185,7 +186,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { const isWaitingExecution = computed(() => { return allNodes.value.some( (node) => - (node.type === WAIT_NODE_TYPE || node.parameters.operation === SEND_AND_WAIT_OPERATION) && + (node.type === WAIT_NODE_TYPE || + node.type === FORM_NODE_TYPE || + node.parameters.operation === SEND_AND_WAIT_OPERATION) && node.disabled !== true, ); }); diff --git a/packages/editor-ui/src/utils/executionUtils.ts b/packages/editor-ui/src/utils/executionUtils.ts index d0f8916c8f..e076c8c18e 100644 --- a/packages/editor-ui/src/utils/executionUtils.ts +++ b/packages/editor-ui/src/utils/executionUtils.ts @@ -8,7 +8,7 @@ import { } from 'n8n-workflow'; import type { ExecutionFilterType, ExecutionsQueryFilter } from '@/Interface'; import { isEmpty } from '@/utils/typesUtils'; -import { FORM_TRIGGER_NODE_TYPE } from '../constants'; +import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE } from '../constants'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useRootStore } from '@/stores/root.store'; import { i18n } from '@/plugins/i18n'; @@ -168,6 +168,12 @@ export const waitingNodeTooltip = () => { } } + if (lastNode?.type === FORM_NODE_TYPE) { + const message = i18n.baseText('ndv.output.waitNodeWaitingForFormSubmission'); + const resumeUrl = `${useRootStore().formWaitingUrl}/${useWorkflowsStore().activeExecutionId}`; + return `${message}${resumeUrl}`; + } + if (lastNode?.parameters.operation === SEND_AND_WAIT_OPERATION) { return i18n.baseText('ndv.output.sendAndWaitWaitingApproval'); } diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 3686333125..63d48c3bc9 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -1929,6 +1929,12 @@ export default defineComponent({ ).some((n) => n.webhookId === node.webhookId); if (isDuplicate) { node.webhookId = uuid(); + + if (node.parameters.path) { + node.parameters.path = node.webhookId as string; + } else if ((node.parameters.options as IDataObject).path) { + (node.parameters.options as IDataObject).path = node.webhookId as string; + } } } diff --git a/packages/nodes-base/nodes/Form/Form.node.json b/packages/nodes-base/nodes/Form/Form.node.json new file mode 100644 index 0000000000..140e6d7503 --- /dev/null +++ b/packages/nodes-base/nodes/Form/Form.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.form", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Core Nodes"], + "alias": ["_Form", "form", "table", "submit", "post", "page", "step", "stage", "multi"], + "resources": { + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.form/" + } + ], + "generic": [] + }, + "subcategories": { + "Core Nodes": ["Helpers"] + } +} diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts new file mode 100644 index 0000000000..a064ce016a --- /dev/null +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -0,0 +1,422 @@ +import type { + FormFieldsParameter, + IExecuteFunctions, + INodeExecutionData, + INodeTypeDescription, + IWebhookFunctions, + NodeTypeAndVersion, +} from 'n8n-workflow'; +import { + WAIT_TIME_UNLIMITED, + Node, + updateDisplayOptions, + NodeOperationError, + FORM_NODE_TYPE, + FORM_TRIGGER_NODE_TYPE, + tryToParseJsonToFormFields, + NodeConnectionType, + WAIT_NODE_TYPE, +} from 'n8n-workflow'; + +import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; +import { prepareFormReturnItem, renderForm, resolveRawData } from '../Form/utils'; + +const pageProperties = updateDisplayOptions( + { + show: { + operation: ['page'], + }, + }, + [ + { + displayName: 'Define Form', + name: 'defineForm', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Using Fields Below', + value: 'fields', + }, + { + name: 'Using JSON', + value: 'json', + }, + ], + default: 'fields', + }, + { + displayName: 'Form Fields', + name: 'jsonOutput', + type: 'json', + typeOptions: { + rows: 5, + }, + default: + '[\n {\n "fieldLabel":"Name",\n "placeholder":"enter you name",\n "requiredField":true\n },\n {\n "fieldLabel":"Age",\n "fieldType":"number",\n "placeholder":"enter your age"\n },\n {\n "fieldLabel":"Email",\n "fieldType":"email",\n "requiredField":true\n }\n]', + validateType: 'form-fields', + ignoreValidationDuringExecution: true, + hint: 'See docs for field syntax', + displayOptions: { + show: { + defineForm: ['json'], + }, + }, + }, + { ...formFields, displayOptions: { show: { defineForm: ['fields'] } } }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add option', + default: {}, + options: [ + { ...formTitle, required: false }, + formDescription, + { + displayName: 'Button Label', + name: 'buttonLabel', + type: 'string', + default: 'Submit', + }, + ], + }, + ], +); + +const completionProperties = updateDisplayOptions( + { + show: { + operation: ['completion'], + }, + }, + [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + displayName: 'On n8n Form Submission', + name: 'respondWith', + type: 'options', + default: 'text', + options: [ + { + name: 'Show Completion Screen', + value: 'text', + description: 'Show a response text to the user', + }, + { + name: 'Redirect to URL', + value: 'redirect', + description: 'Redirect the user to a URL', + }, + ], + }, + { + displayName: 'URL', + name: 'redirectUrl', + validateType: 'url', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + respondWith: ['redirect'], + }, + }, + }, + { + displayName: 'Completion Title', + name: 'completionTitle', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + respondWith: ['text'], + }, + }, + }, + { + displayName: 'Completion Message', + name: 'completionMessage', + type: 'string', + default: '', + typeOptions: { + rows: 2, + }, + displayOptions: { + show: { + respondWith: ['text'], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add option', + default: {}, + options: [{ ...formTitle, required: false, displayName: 'Completion Page Title' }], + displayOptions: { + show: { + respondWith: ['text'], + }, + }, + }, + ], +); + +export class Form extends Node { + nodeInputData: INodeExecutionData[] = []; + + description: INodeTypeDescription = { + displayName: 'n8n Form', + name: 'form', + icon: 'file:form.svg', + group: ['input'], + version: 1, + description: 'Generate webforms in n8n and pass their responses to the workflow', + defaults: { + name: 'Form', + }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + webhooks: [ + { + name: 'default', + httpMethod: 'GET', + responseMode: 'onReceived', + path: '', + restartWebhook: true, + isFullPath: true, + isForm: true, + }, + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: '', + restartWebhook: true, + isFullPath: true, + isForm: true, + }, + ], + properties: [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + displayName: 'An n8n Form Trigger node must be set up before this node', + name: 'triggerNotice', + type: 'notice', + default: '', + }, + { + displayName: 'Page Type', + name: 'operation', + type: 'options', + default: 'page', + noDataExpression: true, + options: [ + { + name: 'Next Form Page', + value: 'page', + }, + { + name: 'Form Ending', + value: 'completion', + }, + ], + }, + ...pageProperties, + ...completionProperties, + ], + }; + + async webhook(context: IWebhookFunctions) { + const res = context.getResponseObject(); + + const operation = context.getNodeParameter('operation', '') as string; + + const parentNodes = context.getParentNodes(context.getNode().name); + const trigger = parentNodes.find( + (node) => node.type === 'n8n-nodes-base.formTrigger', + ) as NodeTypeAndVersion; + + const mode = context.evaluateExpression(`{{ $('${trigger?.name}').first().json.formMode }}`) as + | 'test' + | 'production'; + + const defineForm = context.getNodeParameter('defineForm', false) as string; + + let fields: FormFieldsParameter = []; + if (defineForm === 'json') { + try { + const jsonOutput = context.getNodeParameter('jsonOutput', '', { + rawExpressions: true, + }) as string; + + fields = tryToParseJsonToFormFields(resolveRawData(context, jsonOutput)); + } catch (error) { + throw new NodeOperationError(context.getNode(), error.message, { + description: error.message, + type: mode === 'test' ? 'manual-form-test' : undefined, + }); + } + } else { + fields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter; + } + + const method = context.getRequestObject().method; + + if (operation === 'completion') { + const respondWith = context.getNodeParameter('respondWith', '') as string; + + if (respondWith === 'redirect') { + const redirectUrl = context.getNodeParameter('redirectUrl', '') as string; + res.redirect(redirectUrl); + return { + noWebhookResponse: true, + }; + } + + const completionTitle = context.getNodeParameter('completionTitle', '') as string; + const completionMessage = context.getNodeParameter('completionMessage', '') as string; + const options = context.getNodeParameter('options', {}) as { + formTitle: string; + }; + let title = options.formTitle; + if (!title) { + title = context.evaluateExpression( + `{{ $('${trigger?.name}').params.formTitle }}`, + ) as string; + } + const appendAttribution = context.evaluateExpression( + `{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`, + ) as boolean; + + res.render('form-trigger-completion', { + title: completionTitle, + message: completionMessage, + formTitle: title, + appendAttribution, + }); + + return { + noWebhookResponse: true, + }; + } + + if (method === 'GET') { + const options = context.getNodeParameter('options', {}) as { + formTitle: string; + formDescription: string; + buttonLabel: string; + }; + + let title = options.formTitle; + if (!title) { + title = context.evaluateExpression( + `{{ $('${trigger?.name}').params.formTitle }}`, + ) as string; + } + + let description = options.formDescription; + if (!description) { + description = context.evaluateExpression( + `{{ $('${trigger?.name}').params.formDescription }}`, + ) as string; + } + + let buttonLabel = options.buttonLabel; + if (!buttonLabel) { + buttonLabel = + (context.evaluateExpression( + `{{ $('${trigger?.name}').params.options?.buttonLabel }}`, + ) as string) || 'Submit'; + } + + const responseMode = 'onReceived'; + + let redirectUrl; + + const connectedNodes = context.getChildNodes(context.getNode().name); + + const hasNextPage = connectedNodes.some( + (node) => node.type === FORM_NODE_TYPE || node.type === WAIT_NODE_TYPE, + ); + + if (hasNextPage) { + redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string; + } + + const appendAttribution = context.evaluateExpression( + `{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`, + ) as boolean; + + renderForm({ + context, + res, + formTitle: title, + formDescription: description, + formFields: fields, + responseMode, + mode, + redirectUrl, + appendAttribution, + buttonLabel, + }); + + return { + noWebhookResponse: true, + }; + } + + let useWorkflowTimezone = context.evaluateExpression( + `{{ $('${trigger?.name}').params.options?.useWorkflowTimezone }}`, + ) as boolean; + + if (useWorkflowTimezone === undefined && trigger?.typeVersion > 2) { + useWorkflowTimezone = true; + } + + const returnItem = await prepareFormReturnItem(context, fields, mode, useWorkflowTimezone); + + return { + webhookResponse: { status: 200 }, + workflowData: [[returnItem]], + }; + } + + async execute(context: IExecuteFunctions): Promise { + const operation = context.getNodeParameter('operation', 0); + + if (operation === 'completion') { + this.nodeInputData = context.getInputData(); + } + + const parentNodes = context.getParentNodes(context.getNode().name); + const hasFormTrigger = parentNodes.some((node) => node.type === FORM_TRIGGER_NODE_TYPE); + + if (!hasFormTrigger) { + throw new NodeOperationError( + context.getNode(), + 'Form Trigger node must be set before this node', + ); + } + + const childNodes = context.getChildNodes(context.getNode().name); + const hasNextPage = childNodes.some((node) => node.type === FORM_NODE_TYPE); + + if (operation === 'completion' && hasNextPage) { + throw new NodeOperationError( + context.getNode(), + 'Completion has to be the last Form node in the workflow', + ); + } + + if (operation !== 'completion') { + const waitTill = new Date(WAIT_TIME_UNLIMITED); + await context.putExecutionToWait(waitTill); + } + + return [context.getInputData()]; + } +} diff --git a/packages/nodes-base/nodes/Form/FormTrigger.node.ts b/packages/nodes-base/nodes/Form/FormTrigger.node.ts index ca2c85dcc1..a486d1b727 100644 --- a/packages/nodes-base/nodes/Form/FormTrigger.node.ts +++ b/packages/nodes-base/nodes/Form/FormTrigger.node.ts @@ -10,14 +10,15 @@ export class FormTrigger extends VersionedNodeType { name: 'formTrigger', icon: 'file:form.svg', group: ['trigger'], - description: 'Runs the flow when an n8n generated webform is submitted', - defaultVersion: 2.1, + description: 'Generate webforms in n8n and pass their responses to the workflow', + defaultVersion: 2.2, }; const nodeVersions: IVersionedNodeType['nodeVersions'] = { 1: new FormTriggerV1(baseDescription), 2: new FormTriggerV2(baseDescription), 2.1: new FormTriggerV2(baseDescription), + 2.2: new FormTriggerV2(baseDescription), }; super(nodeVersions, baseDescription); diff --git a/packages/nodes-base/nodes/Form/interfaces.ts b/packages/nodes-base/nodes/Form/interfaces.ts index 330a7584dc..26b5cf567a 100644 --- a/packages/nodes-base/nodes/Form/interfaces.ts +++ b/packages/nodes-base/nodes/Form/interfaces.ts @@ -1,15 +1,3 @@ -export type FormField = { - fieldLabel: string; - fieldType: string; - requiredField: boolean; - fieldOptions?: { values: Array<{ option: string }> }; - multiselect?: boolean; - multipleFiles?: boolean; - acceptFileTypes?: string; - formatDate?: string; - placeholder?: string; -}; - export type FormTriggerInput = { isSelect?: boolean; isMultiSelect?: boolean; @@ -40,6 +28,7 @@ export type FormTriggerData = { formFields: FormTriggerInput[]; useResponseData?: boolean; appendAttribution?: boolean; + buttonLabel?: string; }; export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication'; diff --git a/packages/nodes-base/nodes/Form/test/Form.node.test.ts b/packages/nodes-base/nodes/Form/test/Form.node.test.ts new file mode 100644 index 0000000000..616e3cf8db --- /dev/null +++ b/packages/nodes-base/nodes/Form/test/Form.node.test.ts @@ -0,0 +1,190 @@ +import type { MockProxy } from 'jest-mock-extended'; +import { mock } from 'jest-mock-extended'; +import type { + IExecuteFunctions, + INode, + INodeExecutionData, + IWebhookFunctions, + NodeTypeAndVersion, +} from 'n8n-workflow'; +import type { Response, Request } from 'express'; +import { Form } from '../Form.node'; + +describe('Form Node', () => { + let form: Form; + let mockExecuteFunctions: MockProxy; + let mockWebhookFunctions: MockProxy; + + beforeEach(() => { + form = new Form(); + mockExecuteFunctions = mock(); + mockWebhookFunctions = mock(); + }); + + describe('execute method', () => { + it('should throw an error if Form Trigger node is not set', async () => { + mockExecuteFunctions.getNodeParameter.mockReturnValue('page'); + mockExecuteFunctions.getParentNodes.mockReturnValue([]); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + + await expect(form.execute(mockExecuteFunctions)).rejects.toThrow( + 'Form Trigger node must be set before this node', + ); + }); + + it('should put execution to wait if operation is not completion', async () => { + mockExecuteFunctions.getNodeParameter.mockReturnValue('page'); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + mock({ type: 'n8n-nodes-base.formTrigger' }), + ]); + mockExecuteFunctions.getChildNodes.mockReturnValue([]); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + + await form.execute(mockExecuteFunctions); + + expect(mockExecuteFunctions.putExecutionToWait).toHaveBeenCalled(); + }); + + it('should throw an error if completion is not the last Form node', async () => { + mockExecuteFunctions.getNodeParameter.mockReturnValue('completion'); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + mock({ type: 'n8n-nodes-base.formTrigger' }), + ]); + mockExecuteFunctions.getChildNodes.mockReturnValue([ + mock({ type: 'n8n-nodes-base.form' }), + ]); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + + await expect(form.execute(mockExecuteFunctions)).rejects.toThrow( + 'Completion has to be the last Form node in the workflow', + ); + }); + + it('should return input data for completion operation', async () => { + const inputData: INodeExecutionData[] = [{ json: { test: 'data' } }]; + mockExecuteFunctions.getNodeParameter.mockReturnValue('completion'); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + mock({ type: 'n8n-nodes-base.formTrigger' }), + ]); + mockExecuteFunctions.getChildNodes.mockReturnValue([]); + mockExecuteFunctions.getInputData.mockReturnValue(inputData); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + + const result = await form.execute(mockExecuteFunctions); + + expect(result).toEqual([inputData]); + }); + }); + + describe('webhook method', () => { + it('should render form for GET request', async () => { + const mockResponseObject = { + render: jest.fn(), + }; + mockWebhookFunctions.getResponseObject.mockReturnValue( + mockResponseObject as unknown as Response, + ); + mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request); + mockWebhookFunctions.getParentNodes.mockReturnValue([ + { type: 'n8n-nodes-base.formTrigger', name: 'Form Trigger', typeVersion: 2.1 }, + ]); + mockWebhookFunctions.evaluateExpression.mockReturnValue('test'); + mockWebhookFunctions.getNode.mockReturnValue(mock()); + mockWebhookFunctions.getNodeParameter.mockImplementation((paramName: string) => { + if (paramName === 'operation') return 'page'; + if (paramName === 'useJson') return false; + if (paramName === 'formFields.values') return [{ fieldLabel: 'test' }]; + if (paramName === 'options') { + return { + formTitle: 'Form Title', + formDescription: 'Form Description', + buttonLabel: 'Form Button', + }; + } + return undefined; + }); + + mockWebhookFunctions.getChildNodes.mockReturnValue([]); + + await form.webhook(mockWebhookFunctions); + + expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger', expect.any(Object)); + }); + + it('should return form data for POST request', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as Request); + mockWebhookFunctions.getParentNodes.mockReturnValue([ + { type: 'n8n-nodes-base.formTrigger', name: 'Form Trigger', typeVersion: 2.1 }, + ]); + mockWebhookFunctions.evaluateExpression.mockReturnValue('test'); + mockWebhookFunctions.getNode.mockReturnValue(mock()); + mockWebhookFunctions.getNodeParameter.mockImplementation((paramName: string) => { + if (paramName === 'operation') return 'page'; + if (paramName === 'useJson') return false; + if (paramName === 'formFields.values') return [{ fieldLabel: 'test' }]; + if (paramName === 'options') { + return { + formTitle: 'Form Title', + formDescription: 'Form Description', + buttonLabel: 'Form Button', + }; + } + return undefined; + }); + + mockWebhookFunctions.getBodyData.mockReturnValue({ + data: { 'field-0': 'test value' }, + files: {}, + }); + + const result = await form.webhook(mockWebhookFunctions); + + expect(result).toHaveProperty('webhookResponse'); + expect(result).toHaveProperty('workflowData'); + expect(result.workflowData).toEqual([ + [ + { + json: expect.objectContaining({ + formMode: 'test', + submittedAt: expect.any(String), + test: 'test value', + }), + }, + ], + ]); + }); + + it('should handle completion operation', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request); + mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => { + if (paramName === 'operation') return 'completion'; + if (paramName === 'useJson') return false; + if (paramName === 'jsonOutput') return '[]'; + if (paramName === 'respondWith') return 'text'; + if (paramName === 'completionTitle') return 'Test Title'; + if (paramName === 'completionMessage') return 'Test Message'; + return {}; + }); + mockWebhookFunctions.getParentNodes.mockReturnValue([ + { type: 'n8n-nodes-base.formTrigger', name: 'Form Trigger', typeVersion: 2.1 }, + ]); + mockWebhookFunctions.evaluateExpression.mockReturnValue('test'); + + const mockResponseObject = { + render: jest.fn(), + }; + mockWebhookFunctions.getResponseObject.mockReturnValue( + mockResponseObject as unknown as Response, + ); + mockWebhookFunctions.getNode.mockReturnValue(mock()); + + const result = await form.webhook(mockWebhookFunctions); + + expect(result).toEqual({ noWebhookResponse: true }); + expect(mockResponseObject.render).toHaveBeenCalledWith( + 'form-trigger-completion', + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts b/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts index fba596c87e..d3c96783c5 100644 --- a/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts +++ b/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts @@ -4,7 +4,6 @@ import { NodeOperationError, type INode } from 'n8n-workflow'; import { testVersionedWebhookTriggerNode } from '@test/nodes/TriggerHelpers'; import { FormTrigger } from '../FormTrigger.node'; -import type { FormField } from '../interfaces'; describe('FormTrigger', () => { beforeEach(() => { @@ -12,7 +11,7 @@ describe('FormTrigger', () => { }); it('should render a form template with correct fields', async () => { - const formFields: FormField[] = [ + const formFields = [ { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, { fieldLabel: 'Notes', fieldType: 'textarea', requiredField: false }, @@ -49,6 +48,7 @@ describe('FormTrigger', () => { expect(response.render).toHaveBeenCalledWith('form-trigger', { appendAttribution: false, + buttonLabel: 'Submit', formDescription: 'Test Description', formFields: [ { @@ -115,7 +115,7 @@ describe('FormTrigger', () => { }); it('should return workflowData on POST request', async () => { - const formFields: FormField[] = [ + const formFields = [ { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, { fieldLabel: 'Date', fieldType: 'date', formatDate: 'dd MMM', requiredField: false }, @@ -205,13 +205,13 @@ describe('FormTrigger', () => { ], }), ).rejects.toEqual( - new NodeOperationError(mock(), 'n8n Form Trigger node not correctly configured'), + new NodeOperationError(mock(), 'On form submission node not correctly configured'), ); }); }); it('should throw on invalid webhook authentication', async () => { - const formFields: FormField[] = [ + const formFields = [ { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, ]; @@ -239,7 +239,7 @@ describe('FormTrigger', () => { }); it('should handle files', async () => { - const formFields: FormField[] = [ + const formFields = [ { fieldLabel: 'Resume', fieldType: 'file', diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index 59d9c6c118..3cd71b9a92 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -1,9 +1,163 @@ -import type { FormField } from '../interfaces'; -import { prepareFormData } from '../utils'; +import { mock } from 'jest-mock-extended'; +import type { + FormFieldsParameter, + INode, + IWebhookFunctions, + MultiPartFormData, +} from 'n8n-workflow'; +import { DateTime } from 'luxon'; +import { formWebhook, prepareFormData, prepareFormReturnItem, resolveRawData } from '../utils'; + +describe('FormTrigger, formWebhook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call response render', async () => { + const executeFunctions = mock(); + const mockRender = jest.fn(); + + const formFields: FormFieldsParameter = [ + { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, + { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, + { + fieldLabel: 'Gender', + fieldType: 'select', + requiredField: true, + fieldOptions: { values: [{ option: 'Male' }, { option: 'Female' }] }, + }, + { + fieldLabel: 'Resume', + fieldType: 'file', + requiredField: true, + acceptFileTypes: '.pdf,.doc', + multipleFiles: false, + }, + ]; + + executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any); + executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({}); + executeFunctions.getNodeParameter.calledWith('formTitle').mockReturnValue('Test Form'); + executeFunctions.getNodeParameter + .calledWith('formDescription') + .mockReturnValue('Test Description'); + executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived'); + executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields); + executeFunctions.getResponseObject.mockReturnValue({ render: mockRender } as any); + executeFunctions.getRequestObject.mockReturnValue({ method: 'GET', query: {} } as any); + executeFunctions.getMode.mockReturnValue('manual'); + executeFunctions.getInstanceId.mockReturnValue('instanceId'); + executeFunctions.getBodyData.mockReturnValue({ data: {}, files: {} }); + executeFunctions.getChildNodes.mockReturnValue([]); + + await formWebhook(executeFunctions); + + expect(mockRender).toHaveBeenCalledWith('form-trigger', { + appendAttribution: true, + buttonLabel: 'Submit', + formDescription: 'Test Description', + formFields: [ + { + defaultValue: '', + errorId: 'error-field-0', + id: 'field-0', + inputRequired: 'form-required', + isInput: true, + label: 'Name', + placeholder: undefined, + type: 'text', + }, + { + defaultValue: '', + errorId: 'error-field-1', + id: 'field-1', + inputRequired: '', + isInput: true, + label: 'Age', + placeholder: undefined, + type: 'number', + }, + { + defaultValue: '', + errorId: 'error-field-2', + id: 'field-2', + inputRequired: 'form-required', + isInput: true, + label: 'Gender', + placeholder: undefined, + type: 'select', + }, + { + acceptFileTypes: '.pdf,.doc', + defaultValue: '', + errorId: 'error-field-3', + id: 'field-3', + inputRequired: 'form-required', + isFileInput: true, + label: 'Resume', + multipleFiles: '', + placeholder: undefined, + }, + ], + formSubmittedText: 'Your response has been recorded', + formTitle: 'Test Form', + n8nWebsiteLink: + 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=instanceId', + testRun: true, + useResponseData: false, + validForm: true, + }); + }); + + it('should return workflowData on POST request', async () => { + const executeFunctions = mock(); + const mockStatus = jest.fn(); + const mockEnd = jest.fn(); + + const formFields: FormFieldsParameter = [ + { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, + { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, + ]; + + const bodyData = { + 'field-0': 'John Doe', + 'field-1': '30', + }; + + executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any); + executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({}); + executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived'); + executeFunctions.getChildNodes.mockReturnValue([]); + executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields); + executeFunctions.getResponseObject.mockReturnValue({ status: mockStatus, end: mockEnd } as any); + executeFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as any); + executeFunctions.getMode.mockReturnValue('manual'); + executeFunctions.getInstanceId.mockReturnValue('instanceId'); + executeFunctions.getBodyData.mockReturnValue({ data: bodyData, files: {} }); + + const result = await formWebhook(executeFunctions); + + expect(result).toEqual({ + webhookResponse: { status: 200 }, + workflowData: [ + [ + { + json: { + Name: 'John Doe', + Age: 30, + submittedAt: expect.any(String), + formMode: 'test', + }, + }, + ], + ], + }); + }); +}); describe('FormTrigger, prepareFormData', () => { it('should return valid form data with given parameters', () => { - const formFields: FormField[] = [ + const formFields: FormFieldsParameter = [ { fieldLabel: 'Name', fieldType: 'text', @@ -43,6 +197,7 @@ describe('FormTrigger, prepareFormData', () => { query, instanceId: 'test-instance', useResponseData: true, + buttonLabel: 'Submit', }); expect(result).toEqual({ @@ -98,12 +253,13 @@ describe('FormTrigger, prepareFormData', () => { ], useResponseData: true, appendAttribution: true, + buttonLabel: 'Submit', redirectUrl: 'https://example.com/thank-you', }); }); it('should handle missing optional fields gracefully', () => { - const formFields: FormField[] = [ + const formFields: FormFieldsParameter = [ { fieldLabel: 'Name', fieldType: 'text', @@ -120,6 +276,7 @@ describe('FormTrigger, prepareFormData', () => { formFields, testRun: true, query: {}, + buttonLabel: 'Submit', }); expect(result).toEqual({ @@ -143,11 +300,12 @@ describe('FormTrigger, prepareFormData', () => { ], useResponseData: undefined, appendAttribution: true, + buttonLabel: 'Submit', }); }); it('should set redirectUrl with http if protocol is missing', () => { - const formFields: FormField[] = [ + const formFields: FormFieldsParameter = [ { fieldLabel: 'Name', fieldType: 'text', @@ -187,7 +345,7 @@ describe('FormTrigger, prepareFormData', () => { }); it('should correctly handle multiselect fields', () => { - const formFields: FormField[] = [ + const formFields: FormFieldsParameter = [ { fieldLabel: 'Favorite Colors', fieldType: 'text', @@ -217,7 +375,7 @@ describe('FormTrigger, prepareFormData', () => { ]); }); it('should correctly handle multiselect fields with unique ids', () => { - const formFields: FormField[] = [ + const formFields = [ { fieldLabel: 'Favorite Colors', fieldType: 'text', @@ -259,3 +417,306 @@ describe('FormTrigger, prepareFormData', () => { ]); }); }); + +jest.mock('luxon', () => ({ + DateTime: { + fromFormat: jest.fn().mockReturnValue({ + toFormat: jest.fn().mockReturnValue('formatted-date'), + }), + now: jest.fn().mockReturnValue({ + setZone: jest.fn().mockReturnValue({ + toISO: jest.fn().mockReturnValue('2023-04-01T12:00:00.000Z'), + }), + }), + }, +})); + +describe('prepareFormReturnItem', () => { + const mockContext = mock({ + nodeHelpers: mock({ + copyBinaryFile: jest.fn().mockResolvedValue({}), + }), + }); + const formNode = mock({ type: 'n8n-nodes-base.formTrigger' }); + + beforeEach(() => { + jest.clearAllMocks(); + mockContext.getBodyData.mockReturnValue({ data: {}, files: {} }); + mockContext.getTimezone.mockReturnValue('UTC'); + mockContext.getNode.mockReturnValue(formNode); + mockContext.getWorkflowStaticData.mockReturnValue({}); + }); + + it('should handle empty form submission', async () => { + const result = await prepareFormReturnItem(mockContext, [], 'test'); + + expect(result).toEqual({ + json: { + submittedAt: '2023-04-01T12:00:00.000Z', + formMode: 'test', + }, + }); + }); + + it('should process text fields correctly', async () => { + mockContext.getBodyData.mockReturnValue({ + data: { 'field-0': ' test value ' }, + files: {}, + }); + + const formFields = [{ fieldLabel: 'Text Field', fieldType: 'text' }]; + const result = await prepareFormReturnItem(mockContext, formFields, 'production'); + + expect(result.json['Text Field']).toBe('test value'); + expect(result.json.formMode).toBe('production'); + }); + + it('should process number fields correctly', async () => { + mockContext.getBodyData.mockReturnValue({ + data: { 'field-0': '42' }, + files: {}, + }); + + const formFields = [{ fieldLabel: 'Number Field', fieldType: 'number' }]; + const result = await prepareFormReturnItem(mockContext, formFields, 'test'); + + expect(result.json['Number Field']).toBe(42); + }); + + it('should handle file uploads', async () => { + const mockFile: Partial = { + filepath: '/tmp/uploaded-file', + originalFilename: 'test.txt', + mimetype: 'text/plain', + size: 1024, + }; + + mockContext.getBodyData.mockReturnValue({ + data: {}, + files: { 'field-0': mockFile }, + }); + + const formFields = [{ fieldLabel: 'File Upload', fieldType: 'file' }]; + const result = await prepareFormReturnItem(mockContext, formFields, 'test'); + + expect(result.json['File Upload']).toEqual({ + filename: 'test.txt', + mimetype: 'text/plain', + size: 1024, + }); + expect(result.binary).toBeDefined(); + expect(result.binary!.File_Upload).toEqual({}); + }); + + it('should handle multiple file uploads', async () => { + const mockFiles: Array> = [ + { filepath: '/tmp/file1', originalFilename: 'file1.txt', mimetype: 'text/plain', size: 1024 }, + { filepath: '/tmp/file2', originalFilename: 'file2.txt', mimetype: 'text/plain', size: 2048 }, + ]; + + mockContext.getBodyData.mockReturnValue({ + data: {}, + files: { 'field-0': mockFiles }, + }); + + const formFields = [{ fieldLabel: 'Multiple Files', fieldType: 'file', multipleFiles: true }]; + const result = await prepareFormReturnItem(mockContext, formFields, 'test'); + + expect(result.json['Multiple Files']).toEqual([ + { filename: 'file1.txt', mimetype: 'text/plain', size: 1024 }, + { filename: 'file2.txt', mimetype: 'text/plain', size: 2048 }, + ]); + expect(result.binary).toBeDefined(); + expect(result.binary!.Multiple_Files_0).toEqual({}); + expect(result.binary!.Multiple_Files_1).toEqual({}); + }); + + it('should format date fields', async () => { + mockContext.getBodyData.mockReturnValue({ + data: { 'field-0': '2023-04-01' }, + files: {}, + }); + + const formFields = [{ fieldLabel: 'Date Field', fieldType: 'date', formatDate: 'dd/MM/yyyy' }]; + const result = await prepareFormReturnItem(mockContext, formFields, 'test'); + + expect(result.json['Date Field']).toBe('formatted-date'); + expect(DateTime.fromFormat).toHaveBeenCalledWith('2023-04-01', 'yyyy-mm-dd'); + }); + + it('should handle multiselect fields', async () => { + mockContext.getBodyData.mockReturnValue({ + data: { 'field-0': '["option1", "option2"]' }, + files: {}, + }); + + const formFields = [{ fieldLabel: 'Multiselect', fieldType: 'multiSelect', multiselect: true }]; + const result = await prepareFormReturnItem(mockContext, formFields, 'test'); + + expect(result.json.Multiselect).toEqual(['option1', 'option2']); + }); + + it('should use workflow timezone when specified', async () => { + mockContext.getTimezone.mockReturnValue('America/New_York'); + + await prepareFormReturnItem(mockContext, [], 'test', true); + + expect(mockContext.getTimezone).toHaveBeenCalled(); + expect(DateTime.now().setZone).toHaveBeenCalledWith('America/New_York'); + }); + + it('should include workflow static data for form trigger node', async () => { + const staticData = { queryParam: 'value' }; + mockContext.getWorkflowStaticData.mockReturnValue(staticData); + + const result = await prepareFormReturnItem(mockContext, [], 'test'); + + expect(result.json.formQueryParameters).toEqual(staticData); + }); +}); + +describe('resolveRawData', () => { + const mockContext = mock(); + + const dummyData = { + name: 'Hanna', + age: 30, + city: 'New York', + isStudent: false, + hasJob: true, + grades: { + math: 95, + science: 88, + history: 92, + }, + hobbies: ['reading', 'painting', 'coding'], + address: { + street: '123 Main St', + zipCode: '10001', + country: 'USA', + }, + languages: ['English', 'Spanish'], + projects: [ + { name: 'Project A', status: 'completed' }, + { name: 'Project B', status: 'in-progress' }, + ], + emptyArray: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockContext.evaluateExpression.mockImplementation((expression: string) => { + const key = expression.replace(/[{}]/g, '').trim(); + return key.split('.').reduce((obj, prop) => obj?.[prop], dummyData as any); + }); + }); + + it('should return the input string if it does not start with "="', () => { + const input = 'Hello, world!'; + expect(resolveRawData(mockContext, input)).toBe(input); + }); + + it('should remove leading "=" characters', () => { + const input = '=Hello, world!'; + expect(resolveRawData(mockContext, input)).toBe('Hello, world!'); + }); + + it('should resolve a single expression', () => { + const input = '=Hello, {{name}}!'; + expect(resolveRawData(mockContext, input)).toBe('Hello, Hanna!'); + }); + + it('should resolve multiple expressions', () => { + const input = '={{name}} is {{age}} years old and lives in {{city}}.'; + expect(resolveRawData(mockContext, input)).toBe('Hanna is 30 years old and lives in New York.'); + }); + + it('should handle object resolutions', () => { + const input = '=Grades: {{grades}}'; + expect(resolveRawData(mockContext, input)).toBe( + 'Grades: {"math":95,"science":88,"history":92}', + ); + }); + + it('should handle nested object properties', () => { + const input = "={{name}}'s math grade is {{grades.math}}."; + expect(resolveRawData(mockContext, input)).toBe("Hanna's math grade is 95."); + }); + + it('should handle boolean values', () => { + const input = '=Is {{name}} a student? {{isStudent}}'; + expect(resolveRawData(mockContext, input)).toBe('Is Hanna a student? false'); + }); + + it('should handle expressions with whitespace', () => { + const input = '={{ name }} is {{ age }} years old.'; + expect(resolveRawData(mockContext, input)).toBe('Hanna is 30 years old.'); + }); + + it('should return the original string if no resolvables are found', () => { + const input = '=Hello, world!'; + expect(resolveRawData(mockContext, input)).toBe('Hello, world!'); + }); + + it('should handle non-existent properties gracefully', () => { + const input = "={{name}}'s favorite color is {{favoriteColor}}."; + expect(resolveRawData(mockContext, input)).toBe("Hanna's favorite color is undefined."); + }); + + it('should handle mixed resolvable and non-resolvable content', () => { + const input = '={{name}} lives in {{city}} and enjoys programming.'; + expect(resolveRawData(mockContext, input)).toBe( + 'Hanna lives in New York and enjoys programming.', + ); + }); + + it('should handle boolean values correctly', () => { + const input = '={{name}} is a student: {{isStudent}}. {{name}} has a job: {{hasJob}}.'; + expect(resolveRawData(mockContext, input)).toBe( + 'Hanna is a student: false. Hanna has a job: true.', + ); + }); + + it('should handle arrays correctly', () => { + const input = "={{name}}'s hobbies are {{hobbies}}."; + expect(resolveRawData(mockContext, input)).toBe( + 'Hanna\'s hobbies are ["reading","painting","coding"].', + ); + }); + + it('should handle nested objects correctly', () => { + const input = '={{name}} lives at {{address.street}}, {{address.zipCode}}.'; + expect(resolveRawData(mockContext, input)).toBe('Hanna lives at 123 Main St, 10001.'); + }); + + it('should handle arrays of objects correctly', () => { + const input = '=Project statuses: {{projects.0.status}}, {{projects.1.status}}.'; + expect(resolveRawData(mockContext, input)).toBe('Project statuses: completed, in-progress.'); + }); + + it('should handle empty arrays correctly', () => { + const input = '=Empty array: {{emptyArray}}.'; + expect(resolveRawData(mockContext, input)).toBe('Empty array: [].'); + }); + + it('should handle a mix of different data types', () => { + const input = + '={{name}} ({{age}}) knows {{languages.length}} languages. First project: {{projects.0.name}}.'; + expect(resolveRawData(mockContext, input)).toBe( + 'Hanna (30) knows 2 languages. First project: Project A.', + ); + }); + + it('should handle nested array access', () => { + const input = '=First hobby: {{hobbies.0}}, Last hobby: {{hobbies.2}}.'; + expect(resolveRawData(mockContext, input)).toBe('First hobby: reading, Last hobby: coding.'); + }); + + it('should handle object-to-string conversion', () => { + const input = '=Address object: {{address}}.'; + expect(resolveRawData(mockContext, input)).toBe( + 'Address object: {"street":"123 Main St","zipCode":"10001","country":"USA"}.', + ); + }); +}); diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index b433f3c22a..2809024e2b 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -3,15 +3,27 @@ import type { MultiPartFormData, IDataObject, IWebhookFunctions, + FormFieldsParameter, + NodeTypeAndVersion, } from 'n8n-workflow'; -import { NodeOperationError, jsonParse } from 'n8n-workflow'; +import { + FORM_NODE_TYPE, + FORM_TRIGGER_NODE_TYPE, + NodeOperationError, + WAIT_NODE_TYPE, + jsonParse, +} from 'n8n-workflow'; + +import type { FormTriggerData, FormTriggerInput } from './interfaces'; +import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces'; + +import { WebhookAuthorizationError } from '../Webhook/error'; +import { validateWebhookAuthentication } from '../Webhook/utils'; import { DateTime } from 'luxon'; import isbot from 'isbot'; -import { WebhookAuthorizationError } from '../Webhook/error'; -import { validateWebhookAuthentication } from '../Webhook/utils'; -import type { FormField, FormTriggerData, FormTriggerInput } from './interfaces'; -import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces'; +import type { Response } from 'express'; +import { getResolvables } from '../../utils/utilities'; export function prepareFormData({ formTitle, @@ -24,17 +36,19 @@ export function prepareFormData({ instanceId, useResponseData, appendAttribution = true, + buttonLabel, }: { formTitle: string; formDescription: string; formSubmittedText: string | undefined; redirectUrl: string | undefined; - formFields: FormField[]; + formFields: FormFieldsParameter; testRun: boolean; query: IDataObject; instanceId?: string; useResponseData?: boolean; appendAttribution?: boolean; + buttonLabel?: string; }) { const validForm = formFields.length > 0; const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : ''; @@ -54,6 +68,7 @@ export function prepareFormData({ formFields: [], useResponseData, appendAttribution, + buttonLabel, }; if (redirectUrl) { @@ -138,101 +153,12 @@ const checkResponseModeConfiguration = (context: IWebhookFunctions) => { } }; -export async function formWebhook( +export async function prepareFormReturnItem( context: IWebhookFunctions, - authProperty = FORM_TRIGGER_AUTHENTICATION_PROPERTY, + formFields: FormFieldsParameter, + mode: 'test' | 'production', + useWorkflowTimezone: boolean = false, ) { - const node = context.getNode(); - const options = context.getNodeParameter('options', {}) as { - ignoreBots?: boolean; - respondWithOptions?: { - values: { - respondWith: 'text' | 'redirect'; - formSubmittedText: string; - redirectUrl: string; - }; - }; - formSubmittedText?: string; - useWorkflowTimezone?: boolean; - appendAttribution?: boolean; - }; - const res = context.getResponseObject(); - const req = context.getRequestObject(); - - try { - if (options.ignoreBots && isbot(req.headers['user-agent'])) { - throw new WebhookAuthorizationError(403); - } - if (node.typeVersion > 1) { - await validateWebhookAuthentication(context, authProperty); - } - } catch (error) { - if (error instanceof WebhookAuthorizationError) { - res.setHeader('WWW-Authenticate', 'Basic realm="Enter credentials"'); - res.status(401).send(); - return { noWebhookResponse: true }; - } - throw error; - } - - const mode = context.getMode() === 'manual' ? 'test' : 'production'; - const formFields = context.getNodeParameter('formFields.values', []) as FormField[]; - const method = context.getRequestObject().method; - - checkResponseModeConfiguration(context); - - //Show the form on GET request - if (method === 'GET') { - const formTitle = context.getNodeParameter('formTitle', '') as string; - const formDescription = (context.getNodeParameter('formDescription', '') as string) - .replace(/\\n/g, '\n') - .replace(/
/g, '\n'); - const instanceId = context.getInstanceId(); - const responseMode = context.getNodeParameter('responseMode', '') as string; - - let formSubmittedText; - let redirectUrl; - let appendAttribution = true; - - if (options.respondWithOptions) { - const values = (options.respondWithOptions as IDataObject).values as IDataObject; - if (values.respondWith === 'text') { - formSubmittedText = values.formSubmittedText as string; - } - if (values.respondWith === 'redirect') { - redirectUrl = values.redirectUrl as string; - } - } else { - formSubmittedText = options.formSubmittedText as string; - } - - if (options.appendAttribution === false) { - appendAttribution = false; - } - - const useResponseData = responseMode === 'responseNode'; - - const query = context.getRequestObject().query as IDataObject; - - const data = prepareFormData({ - formTitle, - formDescription, - formSubmittedText, - redirectUrl, - formFields, - testRun: mode === 'test', - query, - instanceId, - useResponseData, - appendAttribution, - }); - - res.render('form-trigger', data); - return { - noWebhookResponse: true, - }; - } - const bodyData = (context.getBodyData().data as IDataObject) ?? {}; const files = (context.getBodyData().files as IDataObject) ?? {}; @@ -312,21 +238,233 @@ export async function formWebhook( returnItem.json[field.fieldLabel] = value; } + const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC'; + returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO(); + + returnItem.json.formMode = mode; + + const workflowStaticData = context.getWorkflowStaticData('node'); + if ( + Object.keys(workflowStaticData || {}).length && + context.getNode().type === FORM_TRIGGER_NODE_TYPE + ) { + returnItem.json.formQueryParameters = workflowStaticData; + } + + return returnItem; +} + +export function renderForm({ + context, + res, + formTitle, + formDescription, + formFields, + responseMode, + mode, + formSubmittedText, + redirectUrl, + appendAttribution, + buttonLabel, +}: { + context: IWebhookFunctions; + res: Response; + formTitle: string; + formDescription: string; + formFields: FormFieldsParameter; + responseMode: string; + mode: 'test' | 'production'; + formSubmittedText?: string; + redirectUrl?: string; + appendAttribution?: boolean; + buttonLabel?: string; +}) { + formDescription = (formDescription || '').replace(/\\n/g, '\n').replace(/
/g, '\n'); + const instanceId = context.getInstanceId(); + + const useResponseData = responseMode === 'responseNode'; + + let query: IDataObject = {}; + + if (context.getNode().type === FORM_TRIGGER_NODE_TYPE) { + query = context.getRequestObject().query as IDataObject; + const workflowStaticData = context.getWorkflowStaticData('node'); + for (const key of Object.keys(query)) { + workflowStaticData[key] = query[key]; + } + } else if (context.getNode().type === FORM_NODE_TYPE) { + const parentNodes = context.getParentNodes(context.getNode().name); + const trigger = parentNodes.find( + (node) => node.type === FORM_TRIGGER_NODE_TYPE, + ) as NodeTypeAndVersion; + try { + const triggerQueryParameters = context.evaluateExpression( + `{{ $('${trigger?.name}').first().json.formQueryParameters }}`, + ) as IDataObject; + + if (triggerQueryParameters) { + query = triggerQueryParameters; + } + } catch (error) {} + } + + const data = prepareFormData({ + formTitle, + formDescription, + formSubmittedText, + redirectUrl, + formFields, + testRun: mode === 'test', + query, + instanceId, + useResponseData, + appendAttribution, + buttonLabel, + }); + + res.render('form-trigger', data); +} + +export async function formWebhook( + context: IWebhookFunctions, + authProperty = FORM_TRIGGER_AUTHENTICATION_PROPERTY, +) { + const node = context.getNode(); + const options = context.getNodeParameter('options', {}) as { + ignoreBots?: boolean; + respondWithOptions?: { + values: { + respondWith: 'text' | 'redirect'; + formSubmittedText: string; + redirectUrl: string; + }; + }; + formSubmittedText?: string; + useWorkflowTimezone?: boolean; + appendAttribution?: boolean; + buttonLabel?: string; + }; + const res = context.getResponseObject(); + const req = context.getRequestObject(); + + try { + if (options.ignoreBots && isbot(req.headers['user-agent'])) { + throw new WebhookAuthorizationError(403); + } + if (node.typeVersion > 1) { + await validateWebhookAuthentication(context, authProperty); + } + } catch (error) { + if (error instanceof WebhookAuthorizationError) { + res.setHeader('WWW-Authenticate', 'Basic realm="Enter credentials"'); + res.status(401).send(); + return { noWebhookResponse: true }; + } + throw error; + } + + const mode = context.getMode() === 'manual' ? 'test' : 'production'; + const formFields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter; + const method = context.getRequestObject().method; + + checkResponseModeConfiguration(context); + + //Show the form on GET request + if (method === 'GET') { + const formTitle = context.getNodeParameter('formTitle', '') as string; + const formDescription = context.getNodeParameter('formDescription', '') as string; + const responseMode = context.getNodeParameter('responseMode', '') as string; + + let formSubmittedText; + let redirectUrl; + let appendAttribution = true; + + if (options.respondWithOptions) { + const values = (options.respondWithOptions as IDataObject).values as IDataObject; + if (values.respondWith === 'text') { + formSubmittedText = values.formSubmittedText as string; + } + if (values.respondWith === 'redirect') { + redirectUrl = values.redirectUrl as string; + } + } else { + formSubmittedText = options.formSubmittedText as string; + } + + if (options.appendAttribution === false) { + appendAttribution = false; + } + + let buttonLabel = 'Submit'; + + if (options.buttonLabel) { + buttonLabel = options.buttonLabel; + } + + if (!redirectUrl && node.type !== FORM_TRIGGER_NODE_TYPE) { + const connectedNodes = context.getChildNodes(context.getNode().name); + const hasNextPage = connectedNodes.some( + (n) => n.type === FORM_NODE_TYPE || n.type === WAIT_NODE_TYPE, + ); + + if (hasNextPage) { + redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string; + } + } + + renderForm({ + context, + res, + formTitle, + formDescription, + formFields, + responseMode, + mode, + formSubmittedText, + redirectUrl, + appendAttribution, + buttonLabel, + }); + + return { + noWebhookResponse: true, + }; + } + let { useWorkflowTimezone } = options; if (useWorkflowTimezone === undefined && node.typeVersion > 2) { useWorkflowTimezone = true; } - const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC'; - returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO(); - - returnItem.json.formMode = mode; - - const webhookResponse: IDataObject = { status: 200 }; + const returnItem = await prepareFormReturnItem(context, formFields, mode, useWorkflowTimezone); return { - webhookResponse, + webhookResponse: { status: 200 }, workflowData: [[returnItem]], }; } + +export function resolveRawData(context: IWebhookFunctions, rawData: string) { + const resolvables = getResolvables(rawData); + let returnData: string = rawData; + + if (returnData.startsWith('=')) { + returnData = returnData.replace(/^=+/, ''); + } else { + return returnData; + } + + if (resolvables.length) { + for (const resolvable of resolvables) { + const resolvedValue = context.evaluateExpression(`${resolvable}`); + + if (typeof resolvedValue === 'object' && resolvedValue !== null) { + returnData = returnData.replace(resolvable, JSON.stringify(resolvedValue)); + } else { + returnData = returnData.replace(resolvable, resolvedValue as string); + } + } + } + return returnData; +} diff --git a/packages/nodes-base/nodes/Form/v1/FormTriggerV1.node.ts b/packages/nodes-base/nodes/Form/v1/FormTriggerV1.node.ts index f68e0ac86d..d5755ed2ae 100644 --- a/packages/nodes-base/nodes/Form/v1/FormTriggerV1.node.ts +++ b/packages/nodes-base/nodes/Form/v1/FormTriggerV1.node.ts @@ -23,7 +23,7 @@ const descriptionV1: INodeTypeDescription = { icon: 'file:form.svg', group: ['trigger'], version: 1, - description: 'Runs the flow when an n8n generated webform is submitted', + description: 'Generate webforms in n8n and pass their responses to the workflow', defaults: { name: 'n8n Form Trigger', }, diff --git a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts index e6ba8c619d..7e09170d6a 100644 --- a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts +++ b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts @@ -1,4 +1,6 @@ import { + ADD_FORM_NOTICE, + type INodePropertyOptions, NodeConnectionType, type INodeProperties, type INodeType, @@ -33,10 +35,10 @@ const descriptionV2: INodeTypeDescription = { name: 'formTrigger', icon: 'file:form.svg', group: ['trigger'], - version: [2, 2.1], - description: 'Runs the flow when an n8n generated webform is submitted', + version: [2, 2.1, 2.2], + description: 'Generate webforms in n8n and pass their responses to the workflow', defaults: { - name: 'n8n Form Trigger', + name: 'On form submission', }, inputs: [], @@ -47,7 +49,7 @@ const descriptionV2: INodeTypeDescription = { httpMethod: 'GET', responseMode: 'onReceived', isFullPath: true, - path: '={{$parameter["path"]}}', + path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}', ndvHideUrl: true, isForm: true, }, @@ -57,7 +59,7 @@ const descriptionV2: INodeTypeDescription = { responseMode: '={{$parameter["responseMode"]}}', responseData: '={{$parameter["responseMode"] === "lastNode" ? "noData" : undefined}}', isFullPath: true, - path: '={{$parameter["path"]}}', + path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}', ndvHideMethod: true, isForm: true, }, @@ -94,11 +96,18 @@ const descriptionV2: INodeTypeDescription = { ], default: 'none', }, - webhookPath, + { ...webhookPath, displayOptions: { show: { '@version': [{ _cnd: { lte: 2.1 } }] } } }, formTitle, formDescription, formFields, - formRespondMode, + { ...formRespondMode, displayOptions: { show: { '@version': [{ _cnd: { lte: 2.1 } }] } } }, + { + ...formRespondMode, + options: (formRespondMode.options as INodePropertyOptions[])?.filter( + (option) => option.value !== 'responseNode', + ), + displayOptions: { show: { '@version': [{ _cnd: { gte: 2.2 } }] } }, + }, { displayName: "In the 'Respond to Webhook' node, select 'Respond With JSON' and set the formSubmittedText key to display a custom response in the form, or the redirectURL key to redirect users to a URL", @@ -109,6 +118,13 @@ const descriptionV2: INodeTypeDescription = { }, default: '', }, + // notice would be shown if no Form node was connected to trigger + { + displayName: 'Build multi-step forms by adding a form page later in your workflow', + name: ADD_FORM_NOTICE, + type: 'notice', + default: '', + }, { displayName: 'Options', name: 'options', @@ -117,6 +133,18 @@ const descriptionV2: INodeTypeDescription = { default: {}, options: [ appendAttributionToForm, + { + displayName: 'Button Label', + description: 'The label of the submit button in the form', + name: 'buttonLabel', + type: 'string', + default: 'Submit', + }, + { + ...webhookPath, + required: false, + displayOptions: { show: { '@version': [{ _cnd: { gte: 2.2 } }] } }, + }, { ...respondWithOptions, displayOptions: { @@ -135,6 +163,7 @@ const descriptionV2: INodeTypeDescription = { { ...useWorkflowTimezone, default: false, + description: "Whether to use the workflow timezone in 'submittedAt' field or UTC", displayOptions: { show: { '@version': [2], @@ -144,6 +173,7 @@ const descriptionV2: INodeTypeDescription = { { ...useWorkflowTimezone, default: true, + description: "Whether to use the workflow timezone in 'submittedAt' field or UTC", displayOptions: { show: { '@version': [{ _cnd: { gt: 2 } }], diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a40f7f2cfa..dabd9da434 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -503,6 +503,7 @@ "dist/nodes/Filter/Filter.node.js", "dist/nodes/Flow/Flow.node.js", "dist/nodes/Flow/FlowTrigger.node.js", + "dist/nodes/Form/Form.node.js", "dist/nodes/Form/FormTrigger.node.js", "dist/nodes/FormIo/FormIoTrigger.node.js", "dist/nodes/Formstack/FormstackTrigger.node.js", diff --git a/packages/workflow/src/Constants.ts b/packages/workflow/src/Constants.ts index b9b435be4f..f8d028ae91 100644 --- a/packages/workflow/src/Constants.ts +++ b/packages/workflow/src/Constants.ts @@ -38,6 +38,7 @@ export const FUNCTION_NODE_TYPE = 'n8n-nodes-base.function'; export const FUNCTION_ITEM_NODE_TYPE = 'n8n-nodes-base.functionItem'; export const MERGE_NODE_TYPE = 'n8n-nodes-base.merge'; export const AI_TRANSFORM_NODE_TYPE = 'n8n-nodes-base.aiTransform'; +export const FORM_NODE_TYPE = 'n8n-nodes-base.form'; export const FORM_TRIGGER_NODE_TYPE = 'n8n-nodes-base.formTrigger'; export const CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTrigger'; export const WAIT_NODE_TYPE = 'n8n-nodes-base.wait'; @@ -56,6 +57,8 @@ export const SCRIPTING_NODE_TYPES = [ AI_TRANSFORM_NODE_TYPE, ]; +export const ADD_FORM_NOTICE = 'addFormPage'; + /** * Nodes whose parameter values may refer to other nodes without expressions. * Their content may need to be updated when the referenced node is renamed. diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 3d7c126e90..d0da574649 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1111,6 +1111,7 @@ export interface IWebhookFunctions extends FunctionsBaseWithRequiredKeys<'getMod options?: IGetNodeParameterOptions, ): NodeParameterValueType | object; getNodeWebhookUrl: (name: string) => string | undefined; + evaluateExpression(expression: string, itemIndex?: number): NodeParameterValueType; getParamsData(): object; getQueryData(): object; getRequestObject(): express.Request; @@ -2026,7 +2027,7 @@ export interface IWebhookResponseData { } export type WebhookResponseData = 'allEntries' | 'firstEntryJson' | 'firstEntryBinary' | 'noData'; -export type WebhookResponseMode = 'onReceived' | 'lastNode' | 'responseNode'; +export type WebhookResponseMode = 'onReceived' | 'lastNode' | 'responseNode' | 'formPage'; export interface INodeTypes { getByName(nodeType: string): INodeType | IVersionedNodeType; @@ -2584,6 +2585,18 @@ export interface ResourceMapperField { readOnly?: boolean; } +export type FormFieldsParameter = Array<{ + fieldLabel: string; + fieldType?: string; + requiredField?: boolean; + fieldOptions?: { values: Array<{ option: string }> }; + multiselect?: boolean; + multipleFiles?: boolean; + acceptFileTypes?: string; + formatDate?: string; + placeholder?: string; +}>; + export type FieldTypeMap = { // eslint-disable-next-line id-denylist boolean: boolean; @@ -2599,6 +2612,7 @@ export type FieldTypeMap = { options: any; url: string; jwt: string; + 'form-fields': FormFieldsParameter; }; export type FieldType = keyof FieldTypeMap; diff --git a/packages/workflow/src/TypeValidation.ts b/packages/workflow/src/TypeValidation.ts index d15a0a07be..6b1557af83 100644 --- a/packages/workflow/src/TypeValidation.ts +++ b/packages/workflow/src/TypeValidation.ts @@ -2,7 +2,12 @@ import isObject from 'lodash/isObject'; import { DateTime } from 'luxon'; import { ApplicationError } from './errors'; -import type { FieldType, INodePropertyOptions, ValidationResult } from './Interfaces'; +import type { + FieldType, + FormFieldsParameter, + INodePropertyOptions, + ValidationResult, +} from './Interfaces'; import { jsonParse } from './utils'; export const tryToParseNumber = (value: unknown): number => { @@ -148,6 +153,96 @@ export const tryToParseObject = (value: unknown): object => { } }; +const ALLOWED_FORM_FIELDS_KEYS = [ + 'fieldLabel', + 'fieldType', + 'placeholder', + 'fieldOptions', + 'multiselect', + 'multipleFiles', + 'acceptFileTypes', + 'formatDate', + 'requiredField', +]; + +const ALLOWED_FIELD_TYPES = [ + 'date', + 'dropdown', + 'email', + 'file', + 'number', + 'password', + 'text', + 'textarea', +]; + +export const tryToParseJsonToFormFields = (value: unknown): FormFieldsParameter => { + const fields: FormFieldsParameter = []; + + try { + const rawFields = jsonParse>(value as string, { + acceptJSObject: true, + }); + + for (const [index, field] of rawFields.entries()) { + for (const key of Object.keys(field)) { + if (!ALLOWED_FORM_FIELDS_KEYS.includes(key)) { + throw new ApplicationError(`Key '${key}' in field ${index} is not valid for form fields`); + } + if ( + key !== 'fieldOptions' && + !['string', 'number', 'boolean'].includes(typeof field[key]) + ) { + field[key] = String(field[key]); + } else if (typeof field[key] === 'string') { + field[key] = field[key].replace(//g, '>'); + } + + if (key === 'fieldType' && !ALLOWED_FIELD_TYPES.includes(field[key] as string)) { + throw new ApplicationError( + `Field type '${field[key] as string}' in field ${index} is not valid for form fields`, + ); + } + + if (key === 'fieldOptions') { + if (Array.isArray(field[key])) { + field[key] = { values: field[key] }; + } + + if ( + typeof field[key] !== 'object' || + !(field[key] as { [key: string]: unknown }).values + ) { + throw new ApplicationError( + `Field dropdown in field ${index} does has no 'values' property that contain an array of options`, + ); + } + + for (const [optionIndex, option] of ( + (field[key] as { [key: string]: unknown }).values as Array<{ + [key: string]: { option: string }; + }> + ).entries()) { + if (Object.keys(option).length !== 1 || typeof option.option !== 'string') { + throw new ApplicationError( + `Field dropdown in field ${index} has an invalid option ${optionIndex}`, + ); + } + } + } + } + + fields.push(field as FormFieldsParameter[number]); + } + } catch (error) { + if (error instanceof ApplicationError) throw error; + + throw new ApplicationError('Value is not valid JSON'); + } + + return fields; +}; + export const getValueDescription = (value: T): string => { if (typeof value === 'object') { if (value === null) return "'null'"; @@ -325,6 +420,16 @@ export function validateFieldType( }; } } + case 'form-fields': { + try { + return { valid: true, newValue: tryToParseJsonToFormFields(value) }; + } catch (e) { + return { + valid: false, + errorMessage: (e as Error).message, + }; + } + } default: { return { valid: true, newValue: value }; } diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index c596076845..f140edc98f 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -1365,6 +1365,7 @@ export class WorkflowDataProxy { $thisRunIndex: this.runIndex, $nodeVersion: that.workflow.getNode(that.activeNodeName)?.typeVersion, $nodeId: that.workflow.getNode(that.activeNodeName)?.id, + $webhookId: that.workflow.getNode(that.activeNodeName)?.webhookId, }; return new Proxy(base, { diff --git a/packages/workflow/src/utils.ts b/packages/workflow/src/utils.ts index 4fba8de101..4637b4b47c 100644 --- a/packages/workflow/src/utils.ts +++ b/packages/workflow/src/utils.ts @@ -121,7 +121,7 @@ type JSONParseOptions = { acceptJSObject?: boolean } & MutuallyExclusive< * * @param {string} jsonString - The JSON string to parse. * @param {Object} [options] - Optional settings for parsing the JSON string. Either `fallbackValue` or `errorMessage` can be set, but not both. - * @param {boolean} [options.parseJSObject=false] - If true, attempts to recover from common JSON format errors by parsing the JSON string as a JavaScript Object. + * @param {boolean} [options.acceptJSObject=false] - If true, attempts to recover from common JSON format errors by parsing the JSON string as a JavaScript Object. * @param {string} [options.errorMessage] - A custom error message to throw if the JSON string cannot be parsed. * @param {*} [options.fallbackValue] - A fallback value to return if the JSON string cannot be parsed. * @returns {Object} - The parsed object, or the fallback value if parsing fails and `fallbackValue` is set. From 44f95160fb1b169c513c77eb408d39a03665e0cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 17 Oct 2024 18:38:06 +0200 Subject: [PATCH 20/57] refactor(core): Report potentially unused job processor branch (#11298) --- packages/cli/src/scaling/job-processor.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts index 1322beac27..7c189baf0d 100644 --- a/packages/cli/src/scaling/job-processor.ts +++ b/packages/cli/src/scaling/job-processor.ts @@ -1,7 +1,12 @@ import type { RunningJobSummary } from '@n8n/api-types'; import { InstanceSettings, WorkflowExecute } from 'n8n-core'; -import { BINARY_ENCODING, ApplicationError, Workflow } from 'n8n-workflow'; import type { ExecutionStatus, IExecuteResponsePromiseData, IRun } from 'n8n-workflow'; +import { + BINARY_ENCODING, + ApplicationError, + Workflow, + ErrorReporterProxy as ErrorReporter, +} from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; import { Service } from 'typedi'; @@ -143,6 +148,7 @@ export class JobProcessor { workflowExecute = new WorkflowExecute(additionalData, execution.mode, execution.data); workflowRun = workflowExecute.processRunExecutionData(workflow); } else { + ErrorReporter.info(`Worker found execution ${executionId} without data`); // Execute all nodes // Can execute without webhook so go on workflowExecute = new WorkflowExecute(additionalData, execution.mode); From 25c1c3218cf1075ca3abd961236f3b2fbd9d6ba9 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Thu, 17 Oct 2024 19:47:10 +0200 Subject: [PATCH 21/57] feat: Run `mfa.beforeSetup` hook before enabling MFA (#11116) --- .../cli/src/controllers/mfa.controller.ts | 14 ++++- .../cli/test/integration/mfa/mfa.api.test.ts | 54 ++++++++++++++++++- packages/editor-ui/src/api/cloudPlans.ts | 2 +- packages/editor-ui/src/api/mfa.ts | 4 ++ .../components/__tests__/BannersStack.test.ts | 10 ++-- .../banners/EmailConfirmationBanner.vue | 2 +- .../src/plugins/i18n/locales/en.json | 1 + packages/editor-ui/src/stores/users.store.ts | 11 ++-- .../src/views/SettingsPersonalView.vue | 19 ++++++- 9 files changed, 103 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/controllers/mfa.controller.ts b/packages/cli/src/controllers/mfa.controller.ts index f0af103265..694765761c 100644 --- a/packages/cli/src/controllers/mfa.controller.ts +++ b/packages/cli/src/controllers/mfa.controller.ts @@ -1,11 +1,21 @@ import { Get, Post, RestController } from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ExternalHooks } from '@/external-hooks'; import { MfaService } from '@/mfa/mfa.service'; import { AuthenticatedRequest, MFA } from '@/requests'; @RestController('/mfa') export class MFAController { - constructor(private mfaService: MfaService) {} + constructor( + private mfaService: MfaService, + private externalHooks: ExternalHooks, + ) {} + + @Post('/can-enable') + async canEnableMFA(req: AuthenticatedRequest) { + await this.externalHooks.run('mfa.beforeSetup', [req.user]); + return; + } @Get('/qr') async getQRCode(req: AuthenticatedRequest) { @@ -52,6 +62,8 @@ export class MFAController { const { token = null } = req.body; const { id, mfaEnabled } = req.user; + await this.externalHooks.run('mfa.beforeSetup', [req.user]); + const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } = await this.mfaService.getSecretAndRecoveryCodes(id); diff --git a/packages/cli/test/integration/mfa/mfa.api.test.ts b/packages/cli/test/integration/mfa/mfa.api.test.ts index 0062f87e89..3f19632506 100644 --- a/packages/cli/test/integration/mfa/mfa.api.test.ts +++ b/packages/cli/test/integration/mfa/mfa.api.test.ts @@ -5,9 +5,12 @@ import { AuthService } from '@/auth/auth.service'; import config from '@/config'; import type { User } from '@/databases/entities/user'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ExternalHooks } from '@/external-hooks'; import { TOTPService } from '@/mfa/totp.service'; +import { mockInstance } from '@test/mocking'; -import { createUser, createUserWithMfaEnabled } from '../shared/db/users'; +import { createOwner, createUser, createUserWithMfaEnabled } from '../shared/db/users'; import { randomValidPassword, uniqueId } from '../shared/random'; import * as testDb from '../shared/test-db'; import * as utils from '../shared/utils'; @@ -16,6 +19,8 @@ jest.mock('@/telemetry'); let owner: User; +const externalHooks = mockInstance(ExternalHooks); + const testServer = utils.setupTestServer({ endpointGroups: ['mfa', 'auth', 'me', 'passwordReset'], }); @@ -23,7 +28,9 @@ const testServer = utils.setupTestServer({ beforeEach(async () => { await testDb.truncate(['User']); - owner = await createUser({ role: 'global:owner' }); + owner = await createOwner(); + + externalHooks.run.mockReset(); config.set('userManagement.disabled', false); }); @@ -131,6 +138,27 @@ describe('Enable MFA setup', () => { expect(user.mfaRecoveryCodes).toBeDefined(); expect(user.mfaSecret).toBeDefined(); }); + + test('POST /enable should not enable MFA if pre check fails', async () => { + // This test is to make sure owners verify their email before enabling MFA in cloud + + const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200); + + const { secret } = response.body.data; + const token = new TOTPService().generateTOTP(secret); + + await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }).expect(200); + + externalHooks.run.mockRejectedValue(new BadRequestError('Error message')); + + await testServer.authAgentFor(owner).post('/mfa/enable').send({ token }).expect(400); + + const user = await Container.get(AuthUserRepository).findOneOrFail({ + where: {}, + }); + + expect(user.mfaEnabled).toBe(false); + }); }); }); @@ -232,6 +260,28 @@ describe('Change password with MFA enabled', () => { }); }); +describe('MFA before enable checks', () => { + test('POST /can-enable should throw error if mfa.beforeSetup returns error', async () => { + externalHooks.run.mockRejectedValue(new BadRequestError('Error message')); + + await testServer.authAgentFor(owner).post('/mfa/can-enable').expect(400); + + expect(externalHooks.run).toHaveBeenCalledWith('mfa.beforeSetup', [ + expect.objectContaining(owner), + ]); + }); + + test('POST /can-enable should not throw error if mfa.beforeSetup does not exist', async () => { + externalHooks.run.mockResolvedValue(undefined); + + await testServer.authAgentFor(owner).post('/mfa/can-enable').expect(200); + + expect(externalHooks.run).toHaveBeenCalledWith('mfa.beforeSetup', [ + expect.objectContaining(owner), + ]); + }); +}); + describe('Login', () => { test('POST /login with email/password should succeed when mfa is disabled', async () => { const password = randomString(8); diff --git a/packages/editor-ui/src/api/cloudPlans.ts b/packages/editor-ui/src/api/cloudPlans.ts index f7daa4b0a1..821c1ce1a3 100644 --- a/packages/editor-ui/src/api/cloudPlans.ts +++ b/packages/editor-ui/src/api/cloudPlans.ts @@ -13,7 +13,7 @@ export async function getCloudUserInfo(context: IRestApiContext): Promise { +export async function sendConfirmationEmail(context: IRestApiContext): Promise { return await post(context.baseUrl, '/cloud/proxy/user/resend-confirmation-email'); } diff --git a/packages/editor-ui/src/api/mfa.ts b/packages/editor-ui/src/api/mfa.ts index 09cfb84df4..0cce31c96d 100644 --- a/packages/editor-ui/src/api/mfa.ts +++ b/packages/editor-ui/src/api/mfa.ts @@ -1,6 +1,10 @@ import type { IRestApiContext } from '@/Interface'; import { makeRestApiRequest } from '@/utils/apiUtils'; +export async function canEnableMFA(context: IRestApiContext) { + return await makeRestApiRequest(context, 'POST', '/mfa/can-enable'); +} + export async function getMfaQR( context: IRestApiContext, ): Promise<{ qrCode: string; secret: string; recoveryCodes: string[] }> { diff --git a/packages/editor-ui/src/components/__tests__/BannersStack.test.ts b/packages/editor-ui/src/components/__tests__/BannersStack.test.ts index 71955e8739..468a20232d 100644 --- a/packages/editor-ui/src/components/__tests__/BannersStack.test.ts +++ b/packages/editor-ui/src/components/__tests__/BannersStack.test.ts @@ -105,7 +105,7 @@ describe('BannerStack', () => { }, }), }); - const confirmEmailSpy = vi.spyOn(useUsersStore(), 'confirmEmail'); + const confirmEmailSpy = vi.spyOn(useUsersStore(), 'sendConfirmationEmail'); getByTestId('confirm-email-button').click(); await waitFor(() => expect(confirmEmailSpy).toHaveBeenCalled()); await waitFor(() => { @@ -125,9 +125,11 @@ describe('BannerStack', () => { }, }), }); - const confirmEmailSpy = vi.spyOn(useUsersStore(), 'confirmEmail').mockImplementation(() => { - throw new Error(ERROR_MESSAGE); - }); + const confirmEmailSpy = vi + .spyOn(useUsersStore(), 'sendConfirmationEmail') + .mockImplementation(() => { + throw new Error(ERROR_MESSAGE); + }); getByTestId('confirm-email-button').click(); await waitFor(() => expect(confirmEmailSpy).toHaveBeenCalled()); await waitFor(() => { diff --git a/packages/editor-ui/src/components/banners/EmailConfirmationBanner.vue b/packages/editor-ui/src/components/banners/EmailConfirmationBanner.vue index 45b3d8c425..72d3f6e923 100644 --- a/packages/editor-ui/src/components/banners/EmailConfirmationBanner.vue +++ b/packages/editor-ui/src/components/banners/EmailConfirmationBanner.vue @@ -14,7 +14,7 @@ const userEmail = computed(() => { async function onConfirmEmailClick() { try { - await useUsersStore().confirmEmail(); + await useUsersStore().sendConfirmationEmail(); toast.showMessage({ type: 'success', title: locale.baseText('banners.confirmEmail.toast.success.heading'), diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 35bc02c799..e975ebb12c 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -2592,6 +2592,7 @@ "settings.personal.mfa.toast.disabledMfa.title": "Two-factor authentication disabled", "settings.personal.mfa.toast.disabledMfa.message": "You will no longer need your authenticator app when signing in", "settings.personal.mfa.toast.disabledMfa.error.message": "Error disabling two-factor authentication", + "settings.personal.mfa.toast.canEnableMfa.title": "MFA pre-requisite failed", "settings.mfa.toast.noRecoveryCodeLeft.title": "No 2FA recovery codes remaining", "settings.mfa.toast.noRecoveryCodeLeft.message": "You have used all of your recovery codes. Disable then re-enable two-factor authentication to generate new codes. Open settings", "sso.login.divider": "or", diff --git a/packages/editor-ui/src/stores/users.store.ts b/packages/editor-ui/src/stores/users.store.ts index 53d538012c..2091048fd6 100644 --- a/packages/editor-ui/src/stores/users.store.ts +++ b/packages/editor-ui/src/stores/users.store.ts @@ -320,6 +320,10 @@ export const useUsersStore = defineStore(STORES.USERS, () => { return await mfaApi.verifyMfaToken(rootStore.restApiContext, data); }; + const canEnableMFA = async () => { + return await mfaApi.canEnableMFA(rootStore.restApiContext); + }; + const enableMfa = async (data: { token: string }) => { await mfaApi.enableMfa(rootStore.restApiContext, data); if (currentUser.value) { @@ -347,8 +351,8 @@ export const useUsersStore = defineStore(STORES.USERS, () => { } }; - const confirmEmail = async () => { - await cloudApi.confirmEmail(rootStore.restApiContext); + const sendConfirmationEmail = async () => { + await cloudApi.sendConfirmationEmail(rootStore.restApiContext); }; const updateGlobalRole = async ({ id, newRoleName }: UpdateGlobalRolePayload) => { @@ -403,8 +407,9 @@ export const useUsersStore = defineStore(STORES.USERS, () => { verifyMfaToken, enableMfa, disableMfa, + canEnableMFA, fetchUserCloudAccount, - confirmEmail, + sendConfirmationEmail, updateGlobalRole, reset, }; diff --git a/packages/editor-ui/src/views/SettingsPersonalView.vue b/packages/editor-ui/src/views/SettingsPersonalView.vue index af1c92519d..64effcf52f 100644 --- a/packages/editor-ui/src/views/SettingsPersonalView.vue +++ b/packages/editor-ui/src/views/SettingsPersonalView.vue @@ -200,8 +200,23 @@ function openPasswordModal() { uiStore.openModal(CHANGE_PASSWORD_MODAL_KEY); } -function onMfaEnableClick() { - uiStore.openModal(MFA_SETUP_MODAL_KEY); +async function onMfaEnableClick() { + if (!settingsStore.isCloudDeployment || !usersStore.isInstanceOwner) { + uiStore.openModal(MFA_SETUP_MODAL_KEY); + return; + } + + try { + await usersStore.canEnableMFA(); + uiStore.openModal(MFA_SETUP_MODAL_KEY); + } catch (e) { + showToast({ + title: i18n.baseText('settings.personal.mfa.toast.canEnableMfa.title'), + message: e.message, + type: 'error', + }); + await usersStore.sendConfirmationEmail(); + } } async function disableMfa(payload: MfaModalEvents['closed']) { From c57cac9e4d447c3a4240a565f9f2de8aa3b7c513 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Thu, 17 Oct 2024 21:51:35 +0200 Subject: [PATCH 22/57] fix(editor): Keep always focus on the first item on the node's search panel (#11193) --- .../src/components/Node/NodeCreator/Panel/NodesListPanel.vue | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue b/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue index 2cceed685a..72e31851f9 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue +++ b/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue @@ -40,10 +40,6 @@ const searchPlaceholder = computed(() => const nodeCreatorView = computed(() => useNodeCreatorStore().selectedView); function getDefaultActiveIndex(search: string = ''): number { - if (activeViewStack.value.activeIndex) { - return activeViewStack.value.activeIndex; - } - if (activeViewStack.value.mode === 'actions') { // For actions, set the active focus to the first action, not category return 1; From a042d5c8e6c39191357410c5c837329c76bbdc0c Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Fri, 18 Oct 2024 08:29:19 +0200 Subject: [PATCH 23/57] refactor: Replace json-schema-to-zod with our own fork (#11229) --- .../InformationExtractor.node.ts | 20 +++--- .../OutputParserStructured.node.ts | 29 ++++----- .../nodes/tools/ToolCode/ToolCode.node.ts | 24 ++++--- .../tools/ToolWorkflow/ToolWorkflow.node.ts | 24 +++---- packages/@n8n/nodes-langchain/package.json | 2 +- .../nodes-langchain/utils/schemaParsing.ts | 65 ++----------------- pnpm-lock.yaml | 12 +--- 7 files changed, 55 insertions(+), 121 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts index 7ccfddc5e4..ab6cd8f201 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts @@ -1,3 +1,8 @@ +import type { BaseLanguageModel } from '@langchain/core/language_models/base'; +import { HumanMessage } from '@langchain/core/messages'; +import { ChatPromptTemplate, SystemMessagePromptTemplate } from '@langchain/core/prompts'; +import type { JSONSchema7 } from 'json-schema'; +import { OutputFixingParser, StructuredOutputParser } from 'langchain/output_parsers'; import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import type { INodeType, @@ -6,21 +11,17 @@ import type { INodeExecutionData, INodePropertyOptions, } from 'n8n-workflow'; -import type { JSONSchema7 } from 'json-schema'; -import type { BaseLanguageModel } from '@langchain/core/language_models/base'; -import { ChatPromptTemplate, SystemMessagePromptTemplate } from '@langchain/core/prompts'; import type { z } from 'zod'; -import { OutputFixingParser, StructuredOutputParser } from 'langchain/output_parsers'; -import { HumanMessage } from '@langchain/core/messages'; -import { generateSchema, getSandboxWithZod } from '../../../utils/schemaParsing'; + +import { makeZodSchemaFromAttributes } from './helpers'; +import type { AttributeDefinition } from './types'; import { inputSchemaField, jsonSchemaExampleField, schemaTypeField, } from '../../../utils/descriptions'; +import { convertJsonSchemaToZod, generateSchema } from '../../../utils/schemaParsing'; import { getTracingConfig } from '../../../utils/tracing'; -import type { AttributeDefinition } from './types'; -import { makeZodSchemaFromAttributes } from './helpers'; const SYSTEM_PROMPT_TEMPLATE = `You are an expert extraction algorithm. Only extract relevant information from the text. @@ -261,8 +262,7 @@ export class InformationExtractor implements INodeType { jsonSchema = jsonParse(inputSchema); } - const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0); - const zodSchema = await zodSchemaSandbox.runCode>(); + const zodSchema = convertJsonSchemaToZod>(jsonSchema); parser = OutputFixingParser.fromLLM(llm, StructuredOutputParser.fromZodSchema(zodSchema)); } diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts index 6ce6bff76b..803a2c99b6 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts @@ -1,4 +1,8 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { OutputParserException } from '@langchain/core/output_parsers'; +import type { JSONSchema7 } from 'json-schema'; +import { StructuredOutputParser } from 'langchain/output_parsers'; +import get from 'lodash/get'; import { jsonParse, type IExecuteFunctions, @@ -9,19 +13,15 @@ import { NodeConnectionType, } from 'n8n-workflow'; import { z } from 'zod'; -import type { JSONSchema7 } from 'json-schema'; -import { StructuredOutputParser } from 'langchain/output_parsers'; -import { OutputParserException } from '@langchain/core/output_parsers'; -import get from 'lodash/get'; -import type { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { logWrapper } from '../../../utils/logWrapper'; -import { generateSchema, getSandboxWithZod } from '../../../utils/schemaParsing'; + import { inputSchemaField, jsonSchemaExampleField, schemaTypeField, } from '../../../utils/descriptions'; +import { logWrapper } from '../../../utils/logWrapper'; +import { convertJsonSchemaToZod, generateSchema } from '../../../utils/schemaParsing'; +import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; const STRUCTURED_OUTPUT_KEY = '__structured__output'; const STRUCTURED_OUTPUT_OBJECT_KEY = '__structured__output__object'; @@ -44,12 +44,10 @@ export class N8nStructuredOutputParser extends Structure } } - static async fromZedJsonSchema( - sandboxedSchema: JavaScriptSandbox, + static async fromZedSchema( + zodSchema: z.ZodSchema, nodeVersion: number, ): Promise>> { - const zodSchema = await sandboxedSchema.runCode>(); - let returnSchema: z.ZodSchema; if (nodeVersion === 1) { returnSchema = z.object({ @@ -204,13 +202,10 @@ export class OutputParserStructured implements INodeType { const jsonSchema = schemaType === 'fromJson' ? generateSchema(jsonExample) : jsonParse(inputSchema); - const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0); + const zodSchema = convertJsonSchemaToZod>(jsonSchema); const nodeVersion = this.getNode().typeVersion; try { - const parser = await N8nStructuredOutputParser.fromZedJsonSchema( - zodSchemaSandbox, - nodeVersion, - ); + const parser = await N8nStructuredOutputParser.fromZedSchema(zodSchema, nodeVersion); return { response: logWrapper(parser, this), }; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts index 7980f5fa9d..2a2a635c90 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts @@ -1,4 +1,10 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; +import type { JSONSchema7 } from 'json-schema'; +import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; +import { PythonSandbox } from 'n8n-nodes-base/dist/nodes/Code/PythonSandbox'; +import type { Sandbox } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; +import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; import type { IExecuteFunctions, INodeType, @@ -7,23 +13,16 @@ import type { ExecutionError, IDataObject, } from 'n8n-workflow'; - import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; -import type { Sandbox } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; -import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; -import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; -import { PythonSandbox } from 'n8n-nodes-base/dist/nodes/Code/PythonSandbox'; -import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import type { DynamicZodObject } from '../../../types/zod.types'; import { inputSchemaField, jsonSchemaExampleField, schemaTypeField, } from '../../../utils/descriptions'; -import { generateSchema, getSandboxWithZod } from '../../../utils/schemaParsing'; -import type { JSONSchema7 } from 'json-schema'; -import type { DynamicZodObject } from '../../../types/zod.types'; +import { convertJsonSchemaToZod, generateSchema } from '../../../utils/schemaParsing'; +import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; export class ToolCode implements INodeType { description: INodeTypeDescription = { @@ -273,10 +272,9 @@ export class ToolCode implements INodeType { ? generateSchema(jsonExample) : jsonParse(inputSchema); - const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0); - const zodSchema = await zodSchemaSandbox.runCode(); + const zodSchema = convertJsonSchemaToZod(jsonSchema); - tool = new DynamicStructuredTool({ + tool = new DynamicStructuredTool({ schema: zodSchema, ...commonToolOptions, }); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index 352a727d11..6cc983eae4 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -1,3 +1,10 @@ +import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; +import type { JSONSchema7 } from 'json-schema'; +import get from 'lodash/get'; +import isObject from 'lodash/isObject'; +import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; +import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode'; import type { IExecuteFunctions, IExecuteWorkflowInfo, @@ -11,22 +18,16 @@ import type { INodeParameterResourceLocator, } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; -import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; -import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode'; -import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; -import get from 'lodash/get'; -import isObject from 'lodash/isObject'; -import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; -import type { JSONSchema7 } from 'json-schema'; -import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; import type { DynamicZodObject } from '../../../types/zod.types'; -import { generateSchema, getSandboxWithZod } from '../../../utils/schemaParsing'; import { jsonSchemaExampleField, schemaTypeField, inputSchemaField, } from '../../../utils/descriptions'; +import { convertJsonSchemaToZod, generateSchema } from '../../../utils/schemaParsing'; +import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; + export class ToolWorkflow implements INodeType { description: INodeTypeDescription = { displayName: 'Call n8n Workflow Tool', @@ -529,10 +530,9 @@ export class ToolWorkflow implements INodeType { ? generateSchema(jsonExample) : jsonParse(inputSchema); - const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0); - const zodSchema = await zodSchemaSandbox.runCode(); + const zodSchema = convertJsonSchemaToZod(jsonSchema); - tool = new DynamicStructuredTool({ + tool = new DynamicStructuredTool({ schema: zodSchema, ...functionBase, }); diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 4ac31893c1..0a3dab15cb 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -168,7 +168,7 @@ "generate-schema": "2.6.0", "html-to-text": "9.0.5", "jsdom": "23.0.1", - "json-schema-to-zod": "2.1.0", + "@n8n/json-schema-to-zod": "workspace:*", "langchain": "0.3.2", "lodash": "catalog:", "mammoth": "1.7.2", diff --git a/packages/@n8n/nodes-langchain/utils/schemaParsing.ts b/packages/@n8n/nodes-langchain/utils/schemaParsing.ts index 0591483e2c..592f1597c2 100644 --- a/packages/@n8n/nodes-langchain/utils/schemaParsing.ts +++ b/packages/@n8n/nodes-langchain/utils/schemaParsing.ts @@ -1,67 +1,10 @@ -import { makeResolverFromLegacyOptions } from '@n8n/vm2'; +import { jsonSchemaToZod } from '@n8n/json-schema-to-zod'; import { json as generateJsonSchema } from 'generate-schema'; import type { SchemaObject } from 'generate-schema'; import type { JSONSchema7 } from 'json-schema'; -import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; -import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; import type { IExecuteFunctions } from 'n8n-workflow'; import { NodeOperationError, jsonParse } from 'n8n-workflow'; - -const vmResolver = makeResolverFromLegacyOptions({ - external: { - modules: ['json-schema-to-zod', 'zod'], - transitive: false, - }, - resolve(moduleName, parentDirname) { - if (moduleName === 'json-schema-to-zod') { - return require.resolve( - '@n8n/n8n-nodes-langchain/node_modules/json-schema-to-zod/dist/cjs/jsonSchemaToZod.js', - { - paths: [parentDirname], - }, - ); - } - if (moduleName === 'zod') { - return require.resolve('@n8n/n8n-nodes-langchain/node_modules/zod.cjs', { - paths: [parentDirname], - }); - } - return; - }, - builtin: [], -}); - -export function getSandboxWithZod(ctx: IExecuteFunctions, schema: JSONSchema7, itemIndex: number) { - const context = getSandboxContext.call(ctx, itemIndex); - let itemSchema: JSONSchema7 = schema; - try { - // If the root type is not defined, we assume it's an object - if (itemSchema.type === undefined) { - itemSchema = { - type: 'object', - properties: itemSchema.properties ?? (itemSchema as { [key: string]: JSONSchema7 }), - }; - } - } catch (error) { - throw new NodeOperationError(ctx.getNode(), 'Error during parsing of JSON Schema.'); - } - - // Make sure to remove the description from root schema - const { description, ...restOfSchema } = itemSchema; - const sandboxedSchema = new JavaScriptSandbox( - context, - ` - const { z } = require('zod'); - const { parseSchema } = require('json-schema-to-zod'); - const zodSchema = parseSchema(${JSON.stringify(restOfSchema)}); - const itemSchema = new Function('z', 'return (' + zodSchema + ')')(z) - return itemSchema - `, - ctx.helpers, - { resolver: vmResolver }, - ); - return sandboxedSchema; -} +import type { z } from 'zod'; export function generateSchema(schemaString: string): JSONSchema7 { const parsedSchema = jsonParse(schemaString); @@ -69,6 +12,10 @@ export function generateSchema(schemaString: string): JSONSchema7 { return generateJsonSchema(parsedSchema) as JSONSchema7; } +export function convertJsonSchemaToZod(schema: JSONSchema7) { + return jsonSchemaToZod(schema); +} + export function throwIfToolSchema(ctx: IExecuteFunctions, error: Error) { if (error?.message?.includes('tool input did not match expected schema')) { throw new NodeOperationError( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e83c855bd7..f174ba6fb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -477,6 +477,9 @@ importers: '@mozilla/readability': specifier: 0.5.0 version: 0.5.0 + '@n8n/json-schema-to-zod': + specifier: workspace:* + version: link:../json-schema-to-zod '@n8n/typeorm': specifier: 0.3.20-12 version: 0.3.20-12(@sentry/node@7.87.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.11.0)(pg@8.12.0)(redis@4.6.12)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2)) @@ -522,9 +525,6 @@ importers: jsdom: specifier: 23.0.1 version: 23.0.1 - json-schema-to-zod: - specifier: 2.1.0 - version: 2.1.0 langchain: specifier: 0.3.2 version: 0.3.2(u4cmnaniapk3e37ytin75vjstm) @@ -8602,10 +8602,6 @@ packages: json-pointer@0.6.2: resolution: {integrity: sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==} - json-schema-to-zod@2.1.0: - resolution: {integrity: sha512-7ishNgYY+AbIKeeHcp5xCOdJbdVwSfDx/4V2ktc16LUusCJJbz2fEKdWUmAxhKIiYzhZ9Fp4E8OsAoM/h9cOLA==} - hasBin: true - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -21340,8 +21336,6 @@ snapshots: dependencies: foreach: 2.0.6 - json-schema-to-zod@2.1.0: {} - json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} From 8e5292cf6c7ce5f0e0285b28e70a6354676907d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 18 Oct 2024 09:22:24 +0200 Subject: [PATCH 24/57] chore: Upgrade nodelinter to 1.16.3 (#11286) --- packages/nodes-base/nodes/Code/JsCodeValidator.ts | 4 ++-- .../nodes-base/nodes/DebugHelper/DebugHelper.node.ts | 1 - .../nodes/EmailReadImap/v2/EmailReadImapV2.node.ts | 1 - .../Google/Sheet/v2/actions/sheet/append.operation.ts | 2 +- .../nodes-base/nodes/InvoiceNinja/InvoiceNinja.node.ts | 4 ++-- packages/nodes-base/nodes/Kafka/Kafka.node.ts | 1 - packages/nodes-base/nodes/MongoDb/MongoDb.node.ts | 1 - .../nodes-base/nodes/Postgres/PostgresTrigger.node.ts | 3 +-- .../RemoveDuplicates/v1/RemoveDuplicatesV1.node.ts | 1 - packages/nodes-base/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 11 files changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/nodes-base/nodes/Code/JsCodeValidator.ts b/packages/nodes-base/nodes/Code/JsCodeValidator.ts index fecb6a6854..f56de67d85 100644 --- a/packages/nodes-base/nodes/Code/JsCodeValidator.ts +++ b/packages/nodes-base/nodes/Code/JsCodeValidator.ts @@ -37,7 +37,7 @@ export function mapItemsNotDefinedErrorIfNeededForRunForAll(code: string, error: // anticipate user expecting `items` to pre-exist as in Function Item node if (error.message === 'items is not defined' && !/(let|const|var) +items +=/.test(code)) { const quoted = error.message.replace('items', '`items`'); - error.message = (quoted as string) + '. Did you mean `$input.all()`?'; + error.message = quoted + '. Did you mean `$input.all()`?'; } } @@ -49,6 +49,6 @@ export function mapItemNotDefinedErrorIfNeededForRunForEach(code: string, error: // anticipate user expecting `items` to pre-exist as in Function Item node if (error.message === 'item is not defined' && !/(let|const|var) +item +=/.test(code)) { const quoted = error.message.replace('item', '`item`'); - error.message = (quoted as string) + '. Did you mean `$input.item.json`?'; + error.message = quoted + '. Did you mean `$input.item.json`?'; } } diff --git a/packages/nodes-base/nodes/DebugHelper/DebugHelper.node.ts b/packages/nodes-base/nodes/DebugHelper/DebugHelper.node.ts index ec30bc2bd2..77ae4d861a 100644 --- a/packages/nodes-base/nodes/DebugHelper/DebugHelper.node.ts +++ b/packages/nodes-base/nodes/DebugHelper/DebugHelper.node.ts @@ -277,7 +277,6 @@ export class DebugHelper implements INodeType { message: throwErrorMessage, }); case 'Error': - // eslint-disable-next-line n8n-nodes-base/node-execute-block-wrong-error-thrown throw new ApplicationError(throwErrorMessage); default: break; diff --git a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts index bfb6d50ed3..74f7540188 100644 --- a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts +++ b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts @@ -677,7 +677,6 @@ export class EmailReadImapV2 implements INodeType { if (connection.closeBox) await connection.closeBox(false); connection.end(); } catch (error) { - // eslint-disable-next-line n8n-nodes-base/node-execute-block-wrong-error-thrown throw new TriggerCloseError(this.getNode(), { cause: error as Error, level: 'warning' }); } }; diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/append.operation.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/append.operation.ts index 70d6c29efa..9ac2189095 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/append.operation.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/append.operation.ts @@ -230,7 +230,7 @@ export async function execute( const sheetNameWithRangeForKeyRow = `${sheetNameForKeyRow}!1:${keyRowIndex}`; const sheetData = await sheet.getData(sheetNameWithRangeForKeyRow, 'FORMATTED_VALUE'); - if (sheetData === undefined || !sheetData.length) { + if (!sheetData?.length) { dataMode = 'autoMapInputData'; } diff --git a/packages/nodes-base/nodes/InvoiceNinja/InvoiceNinja.node.ts b/packages/nodes-base/nodes/InvoiceNinja/InvoiceNinja.node.ts index 3702c1e34d..ef7494d306 100644 --- a/packages/nodes-base/nodes/InvoiceNinja/InvoiceNinja.node.ts +++ b/packages/nodes-base/nodes/InvoiceNinja/InvoiceNinja.node.ts @@ -1056,10 +1056,10 @@ export class InvoiceNinja implements INodeType { const paymentId = this.getNodeParameter('paymentId', i) as string; const body: IBankTransaction = {}; if (bankTransactionId) { - body.id = bankTransactionId as string; + body.id = bankTransactionId; } if (paymentId) { - body.paymentId = paymentId as string; + body.paymentId = paymentId; } responseData = await invoiceNinjaApiRequest.call( this, diff --git a/packages/nodes-base/nodes/Kafka/Kafka.node.ts b/packages/nodes-base/nodes/Kafka/Kafka.node.ts index 11117dd653..87e9f95b0d 100644 --- a/packages/nodes-base/nodes/Kafka/Kafka.node.ts +++ b/packages/nodes-base/nodes/Kafka/Kafka.node.ts @@ -229,7 +229,6 @@ export class Kafka implements INodeType { }; if (credentials.authentication === true) { if (!(credentials.username && credentials.password)) { - // eslint-disable-next-line n8n-nodes-base/node-execute-block-wrong-error-thrown throw new ApplicationError('Username and password are required for authentication', { level: 'warning', }); diff --git a/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts b/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts index 756dd72deb..14d8718b92 100644 --- a/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts +++ b/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts @@ -81,7 +81,6 @@ export class MongoDb implements INodeType { const { databases } = await client.db().admin().listDatabases(); if (!(databases as IDataObject[]).map((db) => db.name).includes(database)) { - // eslint-disable-next-line n8n-nodes-base/node-execute-block-wrong-error-thrown throw new ApplicationError(`Database "${database}" does not exist`, { level: 'warning', }); diff --git a/packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts b/packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts index 92ba58fca3..1bf2334071 100644 --- a/packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts +++ b/packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts @@ -290,7 +290,7 @@ export class PostgresTrigger implements INodeType { await connection.query('SELECT 1'); } catch { // connection already closed. Can't perform cleanup - // eslint-disable-next-line n8n-nodes-base/node-execute-block-wrong-error-thrown + throw new TriggerCloseError(this.getNode(), { level: 'warning' }); } @@ -316,7 +316,6 @@ export class PostgresTrigger implements INodeType { ]); } } catch (error) { - // eslint-disable-next-line n8n-nodes-base/node-execute-block-wrong-error-thrown throw new TriggerCloseError(this.getNode(), { cause: error as Error, level: 'error' }); } } finally { diff --git a/packages/nodes-base/nodes/Transform/RemoveDuplicates/v1/RemoveDuplicatesV1.node.ts b/packages/nodes-base/nodes/Transform/RemoveDuplicates/v1/RemoveDuplicatesV1.node.ts index f4e0289107..c76c86953d 100644 --- a/packages/nodes-base/nodes/Transform/RemoveDuplicates/v1/RemoveDuplicatesV1.node.ts +++ b/packages/nodes-base/nodes/Transform/RemoveDuplicates/v1/RemoveDuplicatesV1.node.ts @@ -1,4 +1,3 @@ -/* eslint-disable n8n-nodes-base/node-filename-against-convention */ import { NodeConnectionType } from 'n8n-workflow'; import type { INodeTypeBaseDescription, diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index dabd9da434..81126ac99e 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -840,7 +840,7 @@ "@types/ssh2-sftp-client": "^5.1.0", "@types/uuid": "catalog:", "@types/xml2js": "catalog:", - "eslint-plugin-n8n-nodes-base": "^1.16.2", + "eslint-plugin-n8n-nodes-base": "^1.16.3", "n8n-core": "workspace:*" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f174ba6fb5..28b8c62be8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1850,8 +1850,8 @@ importers: specifier: 'catalog:' version: 0.4.14 eslint-plugin-n8n-nodes-base: - specifier: ^1.16.2 - version: 1.16.2(eslint@8.57.0)(typescript@5.6.2) + specifier: ^1.16.3 + version: 1.16.3(eslint@8.57.0)(typescript@5.6.2) n8n-core: specifier: workspace:* version: link:../core @@ -7145,8 +7145,8 @@ packages: eslint-plugin-n8n-local-rules@1.0.0: resolution: {integrity: sha512-qe6sVFDP1Vj5eXlqZxYZpIjwYvhuqXlI0P8OfPyhiPOhMkFtr0TpFphD8/6WCzkm7LJCvG1eJEzURCtMIsFTAg==} - eslint-plugin-n8n-nodes-base@1.16.2: - resolution: {integrity: sha512-Qj8S+YgymYkt/5Fr1buwOTjl0jAERJBp3MA5V8M6NR1HYfErKazVjpOPEy5+04c0vAQZO1mPLGAzanxqqNUIng==} + eslint-plugin-n8n-nodes-base@1.16.3: + resolution: {integrity: sha512-edLX42Vg4B+y0kzkitTVDmHZQrG5/wUZO874N5Z9leBuxt5TG1pqMY4zdr35RlpM4p4REr/T9x+6DpsQSL63WA==} engines: {node: '>=20.15', pnpm: '>=9.6'} eslint-plugin-unicorn@51.0.1: @@ -19382,7 +19382,7 @@ snapshots: eslint-plugin-n8n-local-rules@1.0.0: {} - eslint-plugin-n8n-nodes-base@1.16.2(eslint@8.57.0)(typescript@5.6.2): + eslint-plugin-n8n-nodes-base@1.16.3(eslint@8.57.0)(typescript@5.6.2): dependencies: '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.6.2) camel-case: 4.1.2 From a13e142ee2907e0a23d80be6e45cd16a086d899d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Fri, 18 Oct 2024 09:26:13 +0200 Subject: [PATCH 25/57] fix(editor): Add striped background to readonly canvas (no-changelog) (#11297) --- .../src/components/canvas/Canvas.spec.ts | 13 +++++++ .../src/components/canvas/Canvas.vue | 8 ++++- .../CanvasBackgroundStripedPattern.vue | 36 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 packages/editor-ui/src/components/canvas/elements/CanvasBackgroundStripedPattern.vue diff --git a/packages/editor-ui/src/components/canvas/Canvas.spec.ts b/packages/editor-ui/src/components/canvas/Canvas.spec.ts index 5ab4c6e96e..8e53425532 100644 --- a/packages/editor-ui/src/components/canvas/Canvas.spec.ts +++ b/packages/editor-ui/src/components/canvas/Canvas.spec.ts @@ -207,4 +207,17 @@ describe('Canvas', () => { await waitFor(() => expect(getByTestId('canvas-minimap')).not.toBeVisible()); }); }); + + describe('background', () => { + it('should render default background', () => { + const { container } = renderComponent(); + expect(container.querySelector('#pattern-canvas')).toBeInTheDocument(); + }); + + it('should render striped background', () => { + const { container } = renderComponent({ props: { readOnly: true } }); + expect(container.querySelector('#pattern-canvas')).not.toBeInTheDocument(); + expect(container.querySelector('#diagonalHatch')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/editor-ui/src/components/canvas/Canvas.vue b/packages/editor-ui/src/components/canvas/Canvas.vue index 521406ade5..7037b0a580 100644 --- a/packages/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/editor-ui/src/components/canvas/Canvas.vue @@ -32,6 +32,7 @@ import { CanvasKey } from '@/constants'; import { onKeyDown, onKeyUp, useDebounceFn } from '@vueuse/core'; import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue'; import { CanvasNodeRenderType } from '@/types'; +import CanvasBackgroundStripedPattern from './elements/CanvasBackgroundStripedPattern.vue'; const $style = useCssModule(); @@ -110,6 +111,7 @@ const { onPaneReady, findNode, onNodesInitialized, + viewport, } = useVueFlow({ id: props.id, deleteKeyCode: null }); const isPaneReady = ref(false); @@ -562,7 +564,11 @@ provide(CanvasKey, { - + + + +/* eslint-disable vue/no-multiple-template-root */ +/** + * @see https://github.com/bcakmakoglu/vue-flow/blob/master/packages/background/src/Background.vue + */ +import { computed } from 'vue'; +const props = defineProps<{ + x: number; + y: number; + zoom: number; +}>(); + +const scaledGap = computed(() => 20 * props.zoom || 1); +const patternOffset = computed(() => scaledGap.value / 2); + + + + + From 0be04c6348d8c059a96c3d37a6d6cd587bfb97f3 Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 18 Oct 2024 09:35:33 +0200 Subject: [PATCH 26/57] fix(OpenAI Chat Model Node, Ollama Chat Model Node): Change default model to a more up-to-date option (#11293) --- .../nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts | 2 +- .../nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts | 2 +- .../@n8n/nodes-langchain/nodes/llms/LMOllama/description.ts | 2 +- packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts index 046f3e4f56..3c40e03203 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts @@ -79,7 +79,7 @@ export class EmbeddingsOpenAi implements INodeType { }, ], group: ['transform'], - version: 1, + version: [1, 1.1], description: 'Use Embeddings OpenAI', defaults: { name: 'Embeddings OpenAI', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts index dcf483a751..3556bca0cf 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts @@ -128,7 +128,7 @@ export class LmChatOpenAi implements INodeType { property: 'model', }, }, - default: 'gpt-3.5-turbo', + default: 'gpt-4o-mini', }, { displayName: diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/description.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/description.ts index 382de60fdd..f91c9a1148 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/description.ts @@ -17,7 +17,7 @@ export const ollamaModel: INodeProperties = { displayName: 'Model', name: 'model', type: 'options', - default: 'llama2', + default: 'llama3.2', description: 'The model which will generate the completion. To download models, visit Ollama Models Library.', typeOptions: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts b/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts index 7d9049a037..a0f47677f7 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts @@ -26,7 +26,7 @@ type RunDetail = { options: SerializedSecret | SerializedNotImplemented | SerializedFields; }; -const TIKTOKEN_ESTIMATE_MODEL = 'gpt-3.5-turbo'; +const TIKTOKEN_ESTIMATE_MODEL = 'gpt-4o'; export class N8nLlmTracing extends BaseCallbackHandler { name = 'N8nLlmTracing'; From 0aae47c2959d82c5d25d00331aeb60fd603c89aa Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Fri, 18 Oct 2024 11:33:08 +0300 Subject: [PATCH 27/57] test: Fix flaky task-runner integration test (#11302) --- packages/cli/src/runners/task-runner-process.ts | 12 ++++++++---- .../integration/runners/task-runner-process.test.ts | 8 ++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/runners/task-runner-process.ts b/packages/cli/src/runners/task-runner-process.ts index 857d581127..a3bc118387 100644 --- a/packages/cli/src/runners/task-runner-process.ts +++ b/packages/cli/src/runners/task-runner-process.ts @@ -23,10 +23,14 @@ export class TaskRunnerProcess { return this.process?.pid; } + /** Promise that resolves when the process has exited */ + public get runPromise() { + return this._runPromise; + } + private process: ChildProcess | null = null; - /** Promise that resolves after the process has exited */ - private runPromise: Promise | null = null; + private _runPromise: Promise | null = null; private isShuttingDown = false; @@ -97,7 +101,7 @@ export class TaskRunnerProcess { } else { this.killNode(); } - await this.runPromise; + await this._runPromise; this.isShuttingDown = false; } @@ -128,7 +132,7 @@ export class TaskRunnerProcess { } private monitorProcess(taskRunnerProcess: ChildProcess) { - this.runPromise = new Promise((resolve) => { + this._runPromise = new Promise((resolve) => { taskRunnerProcess.on('exit', (code) => { this.onProcessExit(code, resolve); }); diff --git a/packages/cli/test/integration/runners/task-runner-process.test.ts b/packages/cli/test/integration/runners/task-runner-process.test.ts index e623d5f371..4b35e270df 100644 --- a/packages/cli/test/integration/runners/task-runner-process.test.ts +++ b/packages/cli/test/integration/runners/task-runner-process.test.ts @@ -88,15 +88,15 @@ describe('TaskRunnerProcess', () => { // @ts-expect-error private property runnerProcess.process?.kill('SIGKILL'); - // Assert - // Wait until the runner is running again - await retryUntil(() => expect(runnerProcess.isRunning).toBeTruthy()); - expect(runnerProcess.pid).not.toBe(processId); + // Wait until the runner has exited + await runnerProcess.runPromise; + // Assert // Wait until the runner has connected again await retryUntil(() => expect(getNumConnectedRunners()).toBe(1)); expect(getNumConnectedRunners()).toBe(1); expect(getNumRegisteredRunners()).toBe(1); + expect(runnerProcess.pid).not.toBe(processId); }); it('should launch runner directly if not using a launcher', async () => { From 6a722c45ea27c3cb8135565071386eace96223a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Fri, 18 Oct 2024 11:13:47 +0200 Subject: [PATCH 28/57] fix(editor): Fix floating nodes sorting (no-changelog) (#11301) --- packages/editor-ui/src/components/NDVFloatingNodes.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/src/components/NDVFloatingNodes.vue b/packages/editor-ui/src/components/NDVFloatingNodes.vue index 303696934c..399588c912 100644 --- a/packages/editor-ui/src/components/NDVFloatingNodes.vue +++ b/packages/editor-ui/src/components/NDVFloatingNodes.vue @@ -73,7 +73,7 @@ const connectedNodes = computed< ), [FloatingNodePosition.right]: getINodesFromNames( workflow.getChildNodes(rootName, NodeConnectionType.Main, 1), - ), + ).reverse(), [FloatingNodePosition.left]: getINodesFromNames( workflow.getParentNodes(rootName, NodeConnectionType.Main, 1), ), From 679fa4a10a85fc96e12ca66fe12cdb32368bc12b Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Fri, 18 Oct 2024 12:06:44 +0200 Subject: [PATCH 29/57] feat(core): introduce JWT API keys for the public API (#11005) --- .../__tests__/api-keys.controller.test.ts | 66 ++++++-- packages/cli/src/public-api/index.ts | 26 +--- .../__tests__/public-api-key.service.test.ts | 147 ++++++++++++++++++ .../src/services/public-api-key.service.ts | 66 ++++++-- .../cli/test/integration/shared/db/users.ts | 16 +- 5 files changed, 252 insertions(+), 69 deletions(-) create mode 100644 packages/cli/src/services/__tests__/public-api-key.service.test.ts diff --git a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts index 81025fb2ca..3f34fc1d2c 100644 --- a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts @@ -1,66 +1,88 @@ import { mock } from 'jest-mock-extended'; -import { randomString } from 'n8n-workflow'; import { Container } from 'typedi'; import type { ApiKey } from '@/databases/entities/api-key'; import type { User } from '@/databases/entities/user'; -import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; +import { EventService } from '@/events/event.service'; import type { ApiKeysRequest, AuthenticatedRequest } from '@/requests'; -import { API_KEY_PREFIX } from '@/services/public-api-key.service'; +import { PublicApiKeyService } from '@/services/public-api-key.service'; import { mockInstance } from '@test/mocking'; import { ApiKeysController } from '../api-keys.controller'; describe('ApiKeysController', () => { - const apiKeysRepository = mockInstance(ApiKeyRepository); + const publicApiKeyService = mockInstance(PublicApiKeyService); + const eventService = mockInstance(EventService); const controller = Container.get(ApiKeysController); let req: AuthenticatedRequest; beforeAll(() => { - req = mock({ user: mock({ id: '123' }) }); + req = { user: { id: '123' } } as AuthenticatedRequest; }); describe('createAPIKey', () => { it('should create and save an API key', async () => { + // Arrange + const apiKeyData = { id: '123', userId: '123', label: 'My API Key', - apiKey: `${API_KEY_PREFIX}${randomString(42)}`, + apiKey: 'apiKey********', createdAt: new Date(), } as ApiKey; - apiKeysRepository.upsert.mockImplementation(); + const req = mock({ user: mock({ id: '123' }) }); - apiKeysRepository.findOneByOrFail.mockResolvedValue(apiKeyData); + publicApiKeyService.createPublicApiKeyForUser.mockResolvedValue(apiKeyData); + + // Act const newApiKey = await controller.createAPIKey(req); - expect(apiKeysRepository.upsert).toHaveBeenCalled(); + // Assert + + expect(publicApiKeyService.createPublicApiKeyForUser).toHaveBeenCalled(); expect(apiKeyData).toEqual(newApiKey); + expect(eventService.emit).toHaveBeenCalledWith( + 'public-api-key-created', + expect.objectContaining({ user: req.user, publicApi: false }), + ); }); }); describe('getAPIKeys', () => { it('should return the users api keys redacted', async () => { + // Arrange + const apiKeyData = { id: '123', userId: '123', label: 'My API Key', - apiKey: `${API_KEY_PREFIX}${randomString(42)}`, + apiKey: 'apiKey***', createdAt: new Date(), + updatedAt: new Date(), } as ApiKey; - apiKeysRepository.findBy.mockResolvedValue([apiKeyData]); + publicApiKeyService.getRedactedApiKeysForUser.mockResolvedValue([apiKeyData]); + + // Act const apiKeys = await controller.getAPIKeys(req); - expect(apiKeys[0].apiKey).not.toEqual(apiKeyData.apiKey); - expect(apiKeysRepository.findBy).toHaveBeenCalledWith({ userId: req.user.id }); + + // Assert + + expect(apiKeys).toEqual([apiKeyData]); + expect(publicApiKeyService.getRedactedApiKeysForUser).toHaveBeenCalledWith( + expect.objectContaining({ id: req.user.id }), + ); }); }); describe('deleteAPIKey', () => { it('should delete the API key', async () => { + // Arrange + const user = mock({ id: '123', password: 'password', @@ -68,12 +90,22 @@ describe('ApiKeysController', () => { role: 'global:member', mfaEnabled: false, }); + const req = mock({ user, params: { id: user.id } }); + + // Act + await controller.deleteAPIKey(req); - expect(apiKeysRepository.delete).toHaveBeenCalledWith({ - userId: req.user.id, - id: req.params.id, - }); + + publicApiKeyService.deleteApiKeyForUser.mockResolvedValue(); + + // Assert + + expect(publicApiKeyService.deleteApiKeyForUser).toHaveBeenCalledWith(user, user.id); + expect(eventService.emit).toHaveBeenCalledWith( + 'public-api-key-deleted', + expect.objectContaining({ user, publicApi: false }), + ); }); }); }); diff --git a/packages/cli/src/public-api/index.ts b/packages/cli/src/public-api/index.ts index 1264f57496..92b3602828 100644 --- a/packages/cli/src/public-api/index.ts +++ b/packages/cli/src/public-api/index.ts @@ -3,16 +3,13 @@ import type { Router } from 'express'; import express from 'express'; import type { HttpError } from 'express-openapi-validator/dist/framework/types'; import fs from 'fs/promises'; -import type { OpenAPIV3 } from 'openapi-types'; import path from 'path'; import type { JsonObject } from 'swagger-ui-express'; import { Container } from 'typedi'; import validator from 'validator'; import YAML from 'yamljs'; -import { EventService } from '@/events/event.service'; import { License } from '@/license'; -import type { AuthenticatedRequest } from '@/requests'; import { PublicApiKeyService } from '@/services/public-api-key.service'; import { UrlService } from '@/services/url.service'; @@ -85,28 +82,7 @@ async function createApiRouter( }, validateSecurity: { handlers: { - ApiKeyAuth: async ( - req: AuthenticatedRequest, - _scopes: unknown, - schema: OpenAPIV3.ApiKeySecurityScheme, - ): Promise => { - const providedApiKey = req.headers[schema.name.toLowerCase()] as string; - - const user = await Container.get(PublicApiKeyService).getUserForApiKey(providedApiKey); - - if (!user) return false; - - Container.get(EventService).emit('public-api-invoked', { - userId: user.id, - path: req.path, - method: req.method, - apiVersion: version, - }); - - req.user = user; - - return true; - }, + ApiKeyAuth: Container.get(PublicApiKeyService).getAuthMiddleware(version), }, }, }), diff --git a/packages/cli/src/services/__tests__/public-api-key.service.test.ts b/packages/cli/src/services/__tests__/public-api-key.service.test.ts new file mode 100644 index 0000000000..7c60b62983 --- /dev/null +++ b/packages/cli/src/services/__tests__/public-api-key.service.test.ts @@ -0,0 +1,147 @@ +import { mock } from 'jest-mock-extended'; +import type { InstanceSettings } from 'n8n-core'; +import type { OpenAPIV3 } from 'openapi-types'; + +import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; +import { UserRepository } from '@/databases/repositories/user.repository'; +import { getConnection } from '@/db'; +import type { EventService } from '@/events/event.service'; +import type { AuthenticatedRequest } from '@/requests'; +import { createOwnerWithApiKey } from '@test-integration/db/users'; +import * as testDb from '@test-integration/test-db'; + +import { JwtService } from '../jwt.service'; +import { PublicApiKeyService } from '../public-api-key.service'; + +const mockReqWith = (apiKey: string, path: string, method: string) => { + return mock({ + path, + method, + headers: { + 'x-n8n-api-key': apiKey, + }, + }); +}; + +const instanceSettings = mock({ encryptionKey: 'test-key' }); + +const eventService = mock(); + +const securitySchema = mock({ + name: 'X-N8N-API-KEY', +}); + +const jwtService = new JwtService(instanceSettings); + +let userRepository: UserRepository; +let apiKeyRepository: ApiKeyRepository; + +describe('PublicApiKeyService', () => { + beforeEach(async () => { + await testDb.truncate(['User']); + jest.clearAllMocks(); + }); + + beforeAll(async () => { + await testDb.init(); + userRepository = new UserRepository(getConnection()); + apiKeyRepository = new ApiKeyRepository(getConnection()); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('getAuthMiddleware', () => { + it('should return false if api key is invalid', async () => { + //Arrange + + const apiKey = 'invalid'; + const path = '/test'; + const method = 'GET'; + const apiVersion = 'v1'; + + const publicApiKeyService = new PublicApiKeyService( + apiKeyRepository, + userRepository, + jwtService, + eventService, + ); + + const middleware = publicApiKeyService.getAuthMiddleware(apiVersion); + + //Act + + const response = await middleware(mockReqWith(apiKey, path, method), {}, securitySchema); + + //Assert + + expect(response).toBe(false); + }); + + it('should return false if valid api key is not in database', async () => { + //Arrange + + const apiKey = jwtService.sign({ sub: '123' }); + const path = '/test'; + const method = 'GET'; + const apiVersion = 'v1'; + + const publicApiKeyService = new PublicApiKeyService( + apiKeyRepository, + userRepository, + jwtService, + eventService, + ); + + const middleware = publicApiKeyService.getAuthMiddleware(apiVersion); + + //Act + + const response = await middleware(mockReqWith(apiKey, path, method), {}, securitySchema); + + //Assert + + expect(response).toBe(false); + }); + + it('should return true if valid api key exist in the database', async () => { + //Arrange + + const path = '/test'; + const method = 'GET'; + const apiVersion = 'v1'; + + const publicApiKeyService = new PublicApiKeyService( + apiKeyRepository, + userRepository, + jwtService, + eventService, + ); + + const owner = await createOwnerWithApiKey(); + + const [{ apiKey }] = owner.apiKeys; + + const middleware = publicApiKeyService.getAuthMiddleware(apiVersion); + + //Act + + const response = await middleware(mockReqWith(apiKey, path, method), {}, securitySchema); + + //Assert + + expect(response).toBe(true); + expect(eventService.emit).toHaveBeenCalledTimes(1); + expect(eventService.emit).toHaveBeenCalledWith( + 'public-api-invoked', + expect.objectContaining({ + userId: owner.id, + path, + method, + apiVersion: 'v1', + }), + ); + }); + }); +}); diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts index e689f3c019..bca3cd0d62 100644 --- a/packages/cli/src/services/public-api-key.service.ts +++ b/packages/cli/src/services/public-api-key.service.ts @@ -1,16 +1,28 @@ -import { randomBytes } from 'node:crypto'; -import Container, { Service } from 'typedi'; +import type { OpenAPIV3 } from 'openapi-types'; +import { Service } from 'typedi'; import { ApiKey } from '@/databases/entities/api-key'; import type { User } from '@/databases/entities/user'; import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; +import { EventService } from '@/events/event.service'; +import type { AuthenticatedRequest } from '@/requests'; -export const API_KEY_PREFIX = 'n8n_api_'; +import { JwtService } from './jwt.service'; + +const API_KEY_AUDIENCE = 'public-api'; +const API_KEY_ISSUER = 'n8n'; +const REDACT_API_KEY_REVEAL_COUNT = 15; +const REDACT_API_KEY_MAX_LENGTH = 80; @Service() export class PublicApiKeyService { - constructor(private readonly apiKeyRepository: ApiKeyRepository) {} + constructor( + private readonly apiKeyRepository: ApiKeyRepository, + private readonly userRepository: UserRepository, + private readonly jwtService: JwtService, + private readonly eventService: EventService, + ) {} /** * Creates a new public API key for the specified user. @@ -18,7 +30,7 @@ export class PublicApiKeyService { * @returns A promise that resolves to the newly created API key. */ async createPublicApiKeyForUser(user: User) { - const apiKey = this.createApiKeyString(); + const apiKey = this.generateApiKey(user); await this.apiKeyRepository.upsert( this.apiKeyRepository.create({ userId: user.id, @@ -48,8 +60,8 @@ export class PublicApiKeyService { await this.apiKeyRepository.delete({ userId: user.id, id: apiKeyId }); } - async getUserForApiKey(apiKey: string) { - return await Container.get(UserRepository) + private async getUserForApiKey(apiKey: string) { + return await this.userRepository .createQueryBuilder('user') .innerJoin(ApiKey, 'apiKey', 'apiKey.userId = user.id') .where('apiKey.apiKey = :apiKey', { apiKey }) @@ -68,13 +80,39 @@ export class PublicApiKeyService { * ``` */ redactApiKey(apiKey: string) { - const keepLength = 5; - return ( - API_KEY_PREFIX + - apiKey.slice(API_KEY_PREFIX.length, API_KEY_PREFIX.length + keepLength) + - '*'.repeat(apiKey.length - API_KEY_PREFIX.length - keepLength) - ); + const visiblePart = apiKey.slice(0, REDACT_API_KEY_REVEAL_COUNT); + const redactedPart = '*'.repeat(apiKey.length - REDACT_API_KEY_REVEAL_COUNT); + + const completeRedactedApiKey = visiblePart + redactedPart; + + return completeRedactedApiKey.slice(0, REDACT_API_KEY_MAX_LENGTH); } - createApiKeyString = () => `${API_KEY_PREFIX}${randomBytes(40).toString('hex')}`; + getAuthMiddleware(version: string) { + return async ( + req: AuthenticatedRequest, + _scopes: unknown, + schema: OpenAPIV3.ApiKeySecurityScheme, + ): Promise => { + const providedApiKey = req.headers[schema.name.toLowerCase()] as string; + + const user = await this.getUserForApiKey(providedApiKey); + + if (!user) return false; + + this.eventService.emit('public-api-invoked', { + userId: user.id, + path: req.path, + method: req.method, + apiVersion: version, + }); + + req.user = user; + + return true; + }; + } + + private generateApiKey = (user: User) => + this.jwtService.sign({ sub: user.id, iss: API_KEY_ISSUER, aud: API_KEY_AUDIENCE }); } diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index 62f9f39a05..64c4d8ad85 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -1,17 +1,16 @@ import { hash } from 'bcryptjs'; -import { randomString } from 'n8n-workflow'; import Container from 'typedi'; import { AuthIdentity } from '@/databases/entities/auth-identity'; import { type GlobalRole, type User } from '@/databases/entities/user'; -import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; import { MfaService } from '@/mfa/mfa.service'; import { TOTPService } from '@/mfa/totp.service'; +import { PublicApiKeyService } from '@/services/public-api-key.service'; -import { randomApiKey, randomEmail, randomName, randomValidPassword } from '../random'; +import { randomEmail, randomName, randomValidPassword } from '../random'; // pre-computed bcrypt hash for the string 'password', using `await hash('password', 10)` const passwordHash = '$2a$10$njedH7S6V5898mj6p0Jr..IGY9Ms.qNwR7RbSzzX9yubJocKfvGGK'; @@ -81,17 +80,8 @@ export async function createUserWithMfaEnabled( }; } -const createApiKeyEntity = (user: User) => { - const apiKey = randomApiKey(); - return Container.get(ApiKeyRepository).create({ - userId: user.id, - label: randomString(10), - apiKey, - }); -}; - export const addApiKey = async (user: User) => { - return await Container.get(ApiKeyRepository).save(createApiKeyEntity(user)); + return await Container.get(PublicApiKeyService).createPublicApiKeyForUser(user); }; export async function createOwnerWithApiKey() { From 64bddf86536ddd688638a643d24f80c947a12f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 18 Oct 2024 12:24:32 +0200 Subject: [PATCH 30/57] fix(core): Do not debounce webhooks, triggers and pollers activation (#11306) --- packages/cli/src/scaling/constants.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/scaling/constants.ts b/packages/cli/src/scaling/constants.ts index 348f156896..e56596e4a0 100644 --- a/packages/cli/src/scaling/constants.ts +++ b/packages/cli/src/scaling/constants.ts @@ -20,4 +20,7 @@ export const SELF_SEND_COMMANDS = new Set([ * Commands that should not be debounced when received, e.g. during webhook handling in * multi-main setup. */ -export const IMMEDIATE_COMMANDS = new Set(['relay-execution-lifecycle-event']); +export const IMMEDIATE_COMMANDS = new Set([ + 'add-webhooks-triggers-and-pollers', + 'relay-execution-lifecycle-event', +]); From 09954f6b32ae617120c687a2b3c745274b129a5f Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Fri, 18 Oct 2024 13:36:53 +0300 Subject: [PATCH 31/57] fix(benchmark): Fix single webhook script path in manifest (#11308) --- .../scenarios/single-webhook/single-webhook.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@n8n/benchmark/scenarios/single-webhook/single-webhook.manifest.json b/packages/@n8n/benchmark/scenarios/single-webhook/single-webhook.manifest.json index 9c68908eef..2113c73ec9 100644 --- a/packages/@n8n/benchmark/scenarios/single-webhook/single-webhook.manifest.json +++ b/packages/@n8n/benchmark/scenarios/single-webhook/single-webhook.manifest.json @@ -3,5 +3,5 @@ "name": "SingleWebhook", "description": "A single webhook trigger that responds with a 200 status code", "scenarioData": { "workflowFiles": ["single-webhook.json"] }, - "scriptPath": "single-webhook.script.ts" + "scriptPath": "single-webhook.script.js" } From b4b543d41daa07753eca24ab93bf7445f672361d Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 18 Oct 2024 15:06:15 +0200 Subject: [PATCH 32/57] fix(core): Add missing primary key to execution annotation tags table (#11168) --- ...3530828-CreateExecutionAnnotationTables.ts | 5 +- ...MissingPrimaryKeyOnAnnotationTagMapping.ts | 23 ++++++ .../src/databases/migrations/mysqldb/index.ts | 2 + .../databases/migrations/postgresdb/index.ts | 2 + ...MissingPrimaryKeyOnAnnotationTagMapping.ts | 72 +++++++++++++++++++ .../src/databases/migrations/sqlite/index.ts | 2 + .../execution.service.integration.test.ts | 12 ++-- 7 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/databases/migrations/common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping.ts create mode 100644 packages/cli/src/databases/migrations/sqlite/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping.ts diff --git a/packages/cli/src/databases/migrations/common/1724753530828-CreateExecutionAnnotationTables.ts b/packages/cli/src/databases/migrations/common/1724753530828-CreateExecutionAnnotationTables.ts index 94c5e5ff68..58cd3cc3f1 100644 --- a/packages/cli/src/databases/migrations/common/1724753530828-CreateExecutionAnnotationTables.ts +++ b/packages/cli/src/databases/migrations/common/1724753530828-CreateExecutionAnnotationTables.ts @@ -25,7 +25,10 @@ export class CreateAnnotationTables1724753530828 implements ReversibleMigration .withIndexOn('name', true).withTimestamps; await createTable(annotationTagMappingsTableName) - .withColumns(column('annotationId').int.notNull, column('tagId').varchar(24).notNull) + .withColumns( + column('annotationId').int.notNull.primary, + column('tagId').varchar(24).notNull.primary, + ) .withForeignKey('annotationId', { tableName: annotationsTableName, columnName: 'id', diff --git a/packages/cli/src/databases/migrations/common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping.ts b/packages/cli/src/databases/migrations/common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping.ts new file mode 100644 index 0000000000..51735d660a --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert'; + +import type { IrreversibleMigration, MigrationContext } from '@/databases/types'; + +export class AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 + implements IrreversibleMigration +{ + async up({ queryRunner, tablePrefix }: MigrationContext) { + // Check if the primary key already exists + const table = await queryRunner.getTable(`${tablePrefix}execution_annotation_tags`); + + assert(table, 'execution_annotation_tags table not found'); + + const hasPrimaryKey = table.primaryColumns.length > 0; + + if (!hasPrimaryKey) { + await queryRunner.createPrimaryKey(`${tablePrefix}execution_annotation_tags`, [ + 'annotationId', + 'tagId', + ]); + } + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 1dcca1e592..d117a6c472 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -66,6 +66,7 @@ import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-Cre import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable'; import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-CreateProcessedDataTable'; import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart'; +import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -134,4 +135,5 @@ export const mysqlMigrations: Migration[] = [ AddApiKeysTable1724951148974, SeparateExecutionCreationFromStart1727427440136, CreateProcessedDataTable1726606152711, + AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index eb0e2bd946..b55ce32750 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -66,6 +66,7 @@ import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-Cre import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable'; import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-CreateProcessedDataTable'; import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart'; +import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -134,4 +135,5 @@ export const postgresMigrations: Migration[] = [ AddApiKeysTable1724951148974, SeparateExecutionCreationFromStart1727427440136, CreateProcessedDataTable1726606152711, + AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping.ts b/packages/cli/src/databases/migrations/sqlite/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping.ts new file mode 100644 index 0000000000..1a2900b7a6 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert'; + +import type { IrreversibleMigration, MigrationContext } from '@/databases/types'; + +const annotationsTableName = 'execution_annotations'; +const annotationTagsTableName = 'annotation_tag_entity'; +const annotationTagMappingsTableName = 'execution_annotation_tags'; + +export class AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 + implements IrreversibleMigration +{ + async up({ + queryRunner, + tablePrefix, + schemaBuilder: { createTable, column, dropIndex }, + }: MigrationContext) { + // Check if the primary key already exists + const table = await queryRunner.getTable(`${tablePrefix}execution_annotation_tags`); + + assert(table, 'execution_annotation_tags table not found'); + + const hasPrimaryKey = table.primaryColumns.length > 0; + + // Do nothing if the primary key already exists + if (hasPrimaryKey) { + return; + } + + // SQLite doesn't support adding a primary key to an existing table + // So we have to do the following steps: + + // 1. Rename the existing table + await queryRunner.query( + `ALTER TABLE ${tablePrefix}${annotationTagMappingsTableName} RENAME TO ${tablePrefix}${annotationTagMappingsTableName}_tmp;`, + ); + + // 1.1 Drop the existing indices + await dropIndex(`${annotationTagMappingsTableName}_tmp`, ['tagId'], { + customIndexName: 'IDX_a3697779b366e131b2bbdae297', + }); + await dropIndex(`${annotationTagMappingsTableName}_tmp`, ['annotationId'], { + customIndexName: 'IDX_c1519757391996eb06064f0e7c', + }); + + // 2. Create a new table with the desired structure + await createTable(annotationTagMappingsTableName) + .withColumns( + column('annotationId').int.notNull.primary, + column('tagId').varchar(24).notNull.primary, + ) + .withForeignKey('annotationId', { + tableName: annotationsTableName, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withIndexOn('tagId') + .withIndexOn('annotationId') + .withForeignKey('tagId', { + tableName: annotationTagsTableName, + columnName: 'id', + onDelete: 'CASCADE', + }); + + // 3. Copy data from the old table to the new one + await queryRunner.query( + `INSERT INTO ${tablePrefix}${annotationTagMappingsTableName} SELECT * FROM ${tablePrefix}${annotationTagMappingsTableName}_tmp;`, + ); + + // 4. Drop the old table + await queryRunner.dropTable(`${tablePrefix}${annotationTagMappingsTableName}_tmp`, true); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 797b26752c..c2c75cbb84 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -38,6 +38,7 @@ import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftD import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting'; import { AddApiKeysTable1724951148974 } from './1724951148974-AddApiKeysTable'; +import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from './1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials'; import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds'; @@ -128,6 +129,7 @@ const sqliteMigrations: Migration[] = [ AddApiKeysTable1724951148974, SeparateExecutionCreationFromStart1727427440136, CreateProcessedDataTable1726606152711, + AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644, ]; export { sqliteMigrations }; diff --git a/packages/cli/test/integration/execution.service.integration.test.ts b/packages/cli/test/integration/execution.service.integration.test.ts index 22d0d65754..4d7144cd4d 100644 --- a/packages/cli/test/integration/execution.service.integration.test.ts +++ b/packages/cli/test/integration/execution.service.integration.test.ts @@ -563,10 +563,10 @@ describe('ExecutionService', () => { { ...summaryShape, annotation: { - tags: [ + tags: expect.arrayContaining([ expect.objectContaining({ name: 'tag1' }), expect.objectContaining({ name: 'tag2' }), - ], + ]), vote: 'up', }, }, @@ -646,10 +646,10 @@ describe('ExecutionService', () => { { ...summaryShape, annotation: { - tags: [ + tags: expect.arrayContaining([ expect.objectContaining({ name: 'tag1' }), expect.objectContaining({ name: 'tag2' }), - ], + ]), vote: 'up', }, }, @@ -691,10 +691,10 @@ describe('ExecutionService', () => { { ...summaryShape, annotation: { - tags: [ + tags: expect.arrayContaining([ expect.objectContaining({ name: 'tag1' }), expect.objectContaining({ name: 'tag2' }), - ], + ]), vote: 'up', }, }, From 321d6deef18806d88d97afef2f2c6f29e739ccb4 Mon Sep 17 00:00:00 2001 From: Danny Martini Date: Fri, 18 Oct 2024 17:30:26 +0200 Subject: [PATCH 33/57] feat(core): Handle cycles in workflows when partially executing them (#11187) --- .../PartialExecutionUtils/DirectedGraph.ts | 143 ++++++++++++++++++ .../__tests__/DirectedGraph.test.ts | 110 ++++++++++++++ .../__tests__/cleanRunData.test.ts | 6 +- .../__tests__/findStartNodes.test.ts | 48 +++--- .../__tests__/handleCycles.test.ts | 116 ++++++++++++++ .../recreateNodeExecutionStack.test.ts | 12 +- .../src/PartialExecutionUtils/cleanRunData.ts | 2 +- .../src/PartialExecutionUtils/findCycles.ts | 6 - .../PartialExecutionUtils/findStartNodes.ts | 4 +- .../src/PartialExecutionUtils/handleCycles.ts | 56 +++++++ .../core/src/PartialExecutionUtils/index.ts | 3 +- .../recreateNodeExecutionStack.ts | 2 +- packages/core/src/WorkflowExecute.ts | 14 +- 13 files changed, 469 insertions(+), 53 deletions(-) create mode 100644 packages/core/src/PartialExecutionUtils/__tests__/handleCycles.test.ts delete mode 100644 packages/core/src/PartialExecutionUtils/findCycles.ts create mode 100644 packages/core/src/PartialExecutionUtils/handleCycles.ts diff --git a/packages/core/src/PartialExecutionUtils/DirectedGraph.ts b/packages/core/src/PartialExecutionUtils/DirectedGraph.ts index 606f624d02..6f8b43a660 100644 --- a/packages/core/src/PartialExecutionUtils/DirectedGraph.ts +++ b/packages/core/src/PartialExecutionUtils/DirectedGraph.ts @@ -286,6 +286,149 @@ export class DirectedGraph { ); } + /** + * Returns all strongly connected components. + * + * Strongly connected components are a set of nodes where it's possible to + * reach every node from every node. + * + * Strongly connected components are mutually exclusive in directed graphs, + * e.g. they cannot overlap. + * + * The smallest strongly connected component is a single node, since it can + * reach itself from itself by not following any edges. + * + * The algorithm implement here is Tarjan's algorithm. + * + * Example: + * ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ + * │node1├────►node2◄────┤node3├────►node5│ + * └─────┘ └──┬──┘ └──▲──┘ └▲───┬┘ + * │ │ │ │ + * ┌──▼──┐ │ ┌┴───▼┐ + * │node4├───────┘ │node6│ + * └─────┘ └─────┘ + * + * The strongly connected components are + * 1. node1 + * 2. node2, node4, node3 + * 3. node5, node6 + * + * Further reading: + * https://en.wikipedia.org/wiki/Strongly_connected_component + * https://www.youtube.com/watch?v=wUgWX0nc4NY + */ + getStronglyConnectedComponents(): Array> { + let id = 0; + const visited = new Set(); + const ids = new Map(); + const lowLinkValues = new Map(); + const stack: INode[] = []; + const stronglyConnectedComponents: Array> = []; + + const followNode = (node: INode) => { + if (visited.has(node)) { + return; + } + + visited.add(node); + lowLinkValues.set(node, id); + ids.set(node, id); + id++; + stack.push(node); + + const directChildren = this.getDirectChildConnections(node).map((c) => c.to); + for (const child of directChildren) { + followNode(child); + + // if node is on stack min the low id + if (stack.includes(child)) { + const childLowLinkValue = lowLinkValues.get(child); + const ownLowLinkValue = lowLinkValues.get(node); + a.ok(childLowLinkValue !== undefined); + a.ok(ownLowLinkValue !== undefined); + const lowestLowLinkValue = Math.min(childLowLinkValue, ownLowLinkValue); + + lowLinkValues.set(node, lowestLowLinkValue); + } + } + + // after we visited all children, check if the low id is the same as the + // nodes id, which means we found a strongly connected component + const ownId = ids.get(node); + const ownLowLinkValue = lowLinkValues.get(node); + a.ok(ownId !== undefined); + a.ok(ownLowLinkValue !== undefined); + + if (ownId === ownLowLinkValue) { + // pop from the stack until the stack is empty or we find a node that + // has a different low id + const scc: Set = new Set(); + let next = stack.at(-1); + + while (next && lowLinkValues.get(next) === ownId) { + stack.pop(); + scc.add(next); + next = stack.at(-1); + } + + if (scc.size > 0) { + stronglyConnectedComponents.push(scc); + } + } + }; + + for (const node of this.nodes.values()) { + followNode(node); + } + + return stronglyConnectedComponents; + } + + private depthFirstSearchRecursive( + from: INode, + fn: (node: INode) => boolean, + seen: Set, + ): INode | undefined { + if (seen.has(from)) { + return undefined; + } + seen.add(from); + + if (fn(from)) { + return from; + } + + for (const childConnection of this.getDirectChildConnections(from)) { + const found = this.depthFirstSearchRecursive(childConnection.to, fn, seen); + + if (found) { + return found; + } + } + + return undefined; + } + + /** + * Like `Array.prototype.find` but for directed graphs. + * + * Starting from, and including, the `from` node this calls the provided + * predicate function with every child node until the predicate function + * returns true. + * + * The search is depth first, meaning every branch is exhausted before the + * next branch is tried. + * + * The first node for which the predicate function returns true is returned. + * + * If the graph is exhausted and the predicate function never returned true, + * undefined is returned instead. + */ + depthFirstSearch({ from, fn }: { from: INode; fn: (node: INode) => boolean }): INode | undefined { + return this.depthFirstSearchRecursive(from, fn, new Set()); + } + toWorkflow(parameters: Omit): Workflow { return new Workflow({ ...parameters, diff --git a/packages/core/src/PartialExecutionUtils/__tests__/DirectedGraph.test.ts b/packages/core/src/PartialExecutionUtils/__tests__/DirectedGraph.test.ts index d6eedf416d..9530ed2217 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/DirectedGraph.test.ts +++ b/packages/core/src/PartialExecutionUtils/__tests__/DirectedGraph.test.ts @@ -9,6 +9,7 @@ // XX denotes that the node is disabled // PD denotes that the node has pinned data +import type { INode } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow'; import { createNodeData, defaultWorkflowParameter } from './helpers'; @@ -89,6 +90,115 @@ describe('DirectedGraph', () => { }); }); + describe('getStronglyConnectedComponents', () => { + // ┌─────┐ ┌─────┐ ┌─────┐ + // │node1├───►│node2├───►│node4│ + // └─────┘ └──┬──┘ └─────┘ + // ▲ │ + // │ │ + // ┌──┴──┐ │ + // │node3│◄──────┘ + // └─────┘ + test('find strongly connected components', () => { + // ARRANGE + const node1 = createNodeData({ name: 'Node1' }); + const node2 = createNodeData({ name: 'Node2' }); + const node3 = createNodeData({ name: 'Node3' }); + const node4 = createNodeData({ name: 'Node4' }); + const graph = new DirectedGraph() + .addNodes(node1, node2, node3, node4) + .addConnections( + { from: node1, to: node2 }, + { from: node2, to: node3 }, + { from: node3, to: node1 }, + { from: node2, to: node4 }, + ); + + // ACT + const stronglyConnectedComponents = graph.getStronglyConnectedComponents(); + + // ASSERT + expect(stronglyConnectedComponents).toHaveLength(2); + expect(stronglyConnectedComponents).toContainEqual(new Set([node4])); + expect(stronglyConnectedComponents).toContainEqual(new Set([node3, node2, node1])); + }); + + // ┌────┐ + // ┌───────┐ │ ├─ + // │trigger├──┬──►loop│ + // └───────┘ │ │ ├────┐ + // │ └────┘ │ + // └─────────┐ │ + // ┌────┐ │ │ + // ┌───►node├─┘ │ + // │ └────┘ │ + // │ │ + // └─────────────┘ + test('find strongly connected components even if they use different output indexes', () => { + // ARRANGE + const trigger = createNodeData({ name: 'trigger' }); + const loop = createNodeData({ name: 'loop' }); + const node = createNodeData({ name: 'node' }); + const graph = new DirectedGraph() + .addNodes(trigger, loop, node) + .addConnections( + { from: trigger, to: loop }, + { from: loop, outputIndex: 1, to: node }, + { from: node, to: loop }, + ); + + // ACT + const stronglyConnectedComponents = graph.getStronglyConnectedComponents(); + + // ASSERT + expect(stronglyConnectedComponents).toHaveLength(2); + expect(stronglyConnectedComponents).toContainEqual(new Set([trigger])); + expect(stronglyConnectedComponents).toContainEqual(new Set([node, loop])); + }); + }); + + describe('depthFirstSearch', () => { + // ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ + // │node0├───►│node1├───►│node2├───►│node4│───►│node5│ + // └─────┘ └─────┘ └──┬──┘ └─────┘ └─────┘ + // ▲ │ + // │ │ + // ┌──┴──┐ │ + // │node3│◄──────┘ + // └─────┘ + test('calls nodes in the correct order and stops when it found the node', () => { + // ARRANGE + const node0 = createNodeData({ name: 'Node0' }); + const node1 = createNodeData({ name: 'Node1' }); + const node2 = createNodeData({ name: 'Node2' }); + const node3 = createNodeData({ name: 'Node3' }); + const node4 = createNodeData({ name: 'Node4' }); + const node5 = createNodeData({ name: 'Node5' }); + const graph = new DirectedGraph() + .addNodes(node0, node1, node2, node3, node4, node5) + .addConnections( + { from: node0, to: node1 }, + { from: node1, to: node2 }, + { from: node2, to: node3 }, + { from: node3, to: node1 }, + { from: node2, to: node4 }, + { from: node4, to: node5 }, + ); + const fn = jest.fn().mockImplementation((node: INode) => node === node4); + + // ACT + const foundNode = graph.depthFirstSearch({ + from: node0, + fn, + }); + + // ASSERT + expect(foundNode).toBe(node4); + expect(fn).toHaveBeenCalledTimes(5); + expect(fn.mock.calls).toEqual([[node0], [node1], [node2], [node3], [node4]]); + }); + }); + describe('getParentConnections', () => { // ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ // │node1├──►│node2├──►│node3│──►│node4│ diff --git a/packages/core/src/PartialExecutionUtils/__tests__/cleanRunData.test.ts b/packages/core/src/PartialExecutionUtils/__tests__/cleanRunData.test.ts index bf37ec7636..5daea46ef6 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/cleanRunData.test.ts +++ b/packages/core/src/PartialExecutionUtils/__tests__/cleanRunData.test.ts @@ -23,7 +23,7 @@ describe('cleanRunData', () => { }; // ACT - const newRunData = cleanRunData(runData, graph, [node1]); + const newRunData = cleanRunData(runData, graph, new Set([node1])); // ASSERT expect(newRunData).toEqual({}); @@ -47,7 +47,7 @@ describe('cleanRunData', () => { }; // ACT - const newRunData = cleanRunData(runData, graph, [node2]); + const newRunData = cleanRunData(runData, graph, new Set([node2])); // ASSERT expect(newRunData).toEqual({ [node1.name]: runData[node1.name] }); @@ -78,7 +78,7 @@ describe('cleanRunData', () => { }; // ACT - const newRunData = cleanRunData(runData, graph, [node2]); + const newRunData = cleanRunData(runData, graph, new Set([node2])); // ASSERT // TODO: Find out if this is a desirable result in milestone 2 diff --git a/packages/core/src/PartialExecutionUtils/__tests__/findStartNodes.test.ts b/packages/core/src/PartialExecutionUtils/__tests__/findStartNodes.test.ts index 57022d862c..ab33ccf8ed 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/findStartNodes.test.ts +++ b/packages/core/src/PartialExecutionUtils/__tests__/findStartNodes.test.ts @@ -48,8 +48,8 @@ describe('findStartNodes', () => { const startNodes = findStartNodes({ graph, trigger: node, destination: node }); - expect(startNodes).toHaveLength(1); - expect(startNodes[0]).toEqual(node); + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(node); }); // ►► @@ -67,8 +67,8 @@ describe('findStartNodes', () => { { const startNodes = findStartNodes({ graph, trigger, destination }); - expect(startNodes).toHaveLength(1); - expect(startNodes[0]).toEqual(trigger); + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(trigger); } // if the trigger has run data @@ -79,8 +79,8 @@ describe('findStartNodes', () => { const startNodes = findStartNodes({ graph, trigger, destination, runData }); - expect(startNodes).toHaveLength(1); - expect(startNodes[0]).toEqual(destination); + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(destination); } }); @@ -115,8 +115,8 @@ describe('findStartNodes', () => { const startNodes = findStartNodes({ graph, trigger, destination: node, runData }); // ASSERT - expect(startNodes).toHaveLength(1); - expect(startNodes[0]).toEqual(node); + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(node); }); // ┌─────┐ ┌─────┐ ►► @@ -156,9 +156,9 @@ describe('findStartNodes', () => { const startNodes = findStartNodes({ graph, trigger, destination: node4 }); // ASSERT - expect(startNodes).toHaveLength(1); + expect(startNodes.size).toBe(1); // no run data means the trigger is the start node - expect(startNodes[0]).toEqual(trigger); + expect(startNodes).toContainEqual(trigger); } { @@ -175,8 +175,8 @@ describe('findStartNodes', () => { const startNodes = findStartNodes({ graph, trigger, destination: node4, runData }); // ASSERT - expect(startNodes).toHaveLength(1); - expect(startNodes[0]).toEqual(node4); + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(node4); } }); @@ -211,8 +211,8 @@ describe('findStartNodes', () => { }); // ASSERT - expect(startNodes).toHaveLength(1); - expect(startNodes[0]).toEqual(node); + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(node); }); // ►► @@ -246,8 +246,8 @@ describe('findStartNodes', () => { }); // ASSERT - expect(startNodes).toHaveLength(1); - expect(startNodes[0]).toEqual(node); + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(node); }); // ►► @@ -286,8 +286,8 @@ describe('findStartNodes', () => { }); // ASSERT - expect(startNodes).toHaveLength(1); - expect(startNodes[0]).toEqual(node); + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(node); }); // ►► @@ -324,8 +324,8 @@ describe('findStartNodes', () => { }); // ASSERT - expect(startNodes).toHaveLength(1); - expect(startNodes[0]).toEqual(node3); + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(node3); }); // ►► @@ -360,8 +360,8 @@ describe('findStartNodes', () => { }); // ASSERT - expect(startNodes).toHaveLength(1); - expect(startNodes[0]).toEqual(node2); + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(node2); }); // ►► @@ -392,7 +392,7 @@ describe('findStartNodes', () => { const startNodes = findStartNodes({ graph, trigger, destination: node2, runData, pinData }); // ASSERT - expect(startNodes).toHaveLength(1); - expect(startNodes[0]).toEqual(node2); + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(node2); }); }); diff --git a/packages/core/src/PartialExecutionUtils/__tests__/handleCycles.test.ts b/packages/core/src/PartialExecutionUtils/__tests__/handleCycles.test.ts new file mode 100644 index 0000000000..def9fed0ff --- /dev/null +++ b/packages/core/src/PartialExecutionUtils/__tests__/handleCycles.test.ts @@ -0,0 +1,116 @@ +// NOTE: Diagrams in this file have been created with https://asciiflow.com/#/ +// If you update the tests, please update the diagrams as well. +// If you add a test, please create a new diagram. +// +// Map +// 0 means the output has no run data +// 1 means the output has run data +// ►► denotes the node that the user wants to execute to +// XX denotes that the node is disabled +// PD denotes that the node has pinned data + +import { createNodeData } from './helpers'; +import { DirectedGraph } from '../DirectedGraph'; +import { handleCycles } from '../handleCycles'; + +describe('handleCycles', () => { + // ┌────┐ ┌─────────┐ + //┌───────┐ │ ├──────────►afterLoop│ + //│trigger├────┬───►loop│ └─────────┘ + //└───────┘ │ │ ├─┐ ►► + // │ └────┘ │ ┌──────┐ + // │ └───►inLoop├────┐ + // │ └──────┘ │ + // │ │ + // └──────────────────────────┘ + test('if the start node is within a cycle it returns the start of the cycle as the new start node', () => { + // ARRANGE + const trigger = createNodeData({ name: 'trigger' }); + const loop = createNodeData({ name: 'loop' }); + const inLoop = createNodeData({ name: 'inLoop' }); + const afterLoop = createNodeData({ name: 'afterLoop' }); + const graph = new DirectedGraph() + .addNodes(trigger, loop, inLoop, afterLoop) + .addConnections( + { from: trigger, to: loop }, + { from: loop, outputIndex: 0, to: afterLoop }, + { from: loop, outputIndex: 1, to: inLoop }, + { from: inLoop, to: loop }, + ); + const startNodes = new Set([inLoop]); + + // ACT + const newStartNodes = handleCycles(graph, startNodes, trigger); + + // ASSERT + expect(newStartNodes.size).toBe(1); + expect(newStartNodes).toContainEqual(loop); + }); + + // ┌────┐ ┌─────────┐ + //┌───────┐ │ ├──────────►afterLoop│ + //│trigger├────┬───►loop│ └─────────┘ + //└───────┘ │ │ ├─┐ ►► + // │ └────┘ │ ┌──────┐ + // │ └───►inLoop├────┐ + // │ └──────┘ │ + // │ │ + // └──────────────────────────┘ + test('does not mutate `startNodes`', () => { + // ARRANGE + const trigger = createNodeData({ name: 'trigger' }); + const loop = createNodeData({ name: 'loop' }); + const inLoop = createNodeData({ name: 'inLoop' }); + const afterLoop = createNodeData({ name: 'afterLoop' }); + const graph = new DirectedGraph() + .addNodes(trigger, loop, inLoop, afterLoop) + .addConnections( + { from: trigger, to: loop }, + { from: loop, outputIndex: 0, to: afterLoop }, + { from: loop, outputIndex: 1, to: inLoop }, + { from: inLoop, to: loop }, + ); + const startNodes = new Set([inLoop]); + + // ACT + handleCycles(graph, startNodes, trigger); + + // ASSERT + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(inLoop); + }); + + // ►► + // ┌────┐ ┌─────────┐ + //┌───────┐ │ ├──────────►afterLoop│ + //│trigger├────┬───►loop│ └─────────┘ + //└───────┘ │ │ ├─┐ + // │ └────┘ │ ┌──────┐ + // │ └───►inLoop├────┐ + // │ └──────┘ │ + // │ │ + // └──────────────────────────┘ + test('if the start node is not within a cycle it returns the same node as the new start node', () => { + // ARRANGE + const trigger = createNodeData({ name: 'trigger' }); + const loop = createNodeData({ name: 'loop' }); + const inLoop = createNodeData({ name: 'inLoop' }); + const afterLoop = createNodeData({ name: 'afterLoop' }); + const graph = new DirectedGraph() + .addNodes(trigger, loop, inLoop, afterLoop) + .addConnections( + { from: trigger, to: loop }, + { from: loop, outputIndex: 0, to: afterLoop }, + { from: loop, outputIndex: 1, to: inLoop }, + { from: inLoop, to: loop }, + ); + const startNodes = new Set([afterLoop]); + + // ACT + const newStartNodes = handleCycles(graph, startNodes, trigger); + + // ASSERT + expect(newStartNodes.size).toBe(1); + expect(newStartNodes).toContainEqual(afterLoop); + }); +}); diff --git a/packages/core/src/PartialExecutionUtils/__tests__/recreateNodeExecutionStack.test.ts b/packages/core/src/PartialExecutionUtils/__tests__/recreateNodeExecutionStack.test.ts index a4bcac23a5..8bae766912 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/recreateNodeExecutionStack.test.ts +++ b/packages/core/src/PartialExecutionUtils/__tests__/recreateNodeExecutionStack.test.ts @@ -33,7 +33,7 @@ describe('recreateNodeExecutionStack', () => { .addConnections({ from: trigger, to: node }); const workflow = findSubgraph({ graph, destination: node, trigger }); - const startNodes = [node]; + const startNodes = new Set([node]); const runData: IRunData = { [trigger.name]: [toITaskData([{ data: { value: 1 } }])], }; @@ -87,7 +87,7 @@ describe('recreateNodeExecutionStack', () => { const workflow = new DirectedGraph() .addNodes(trigger, node) .addConnections({ from: trigger, to: node }); - const startNodes = [trigger]; + const startNodes = new Set([trigger]); const runData: IRunData = {}; const pinData: IPinData = {}; @@ -121,7 +121,7 @@ describe('recreateNodeExecutionStack', () => { const workflow = new DirectedGraph() .addNodes(trigger, node) .addConnections({ from: trigger, to: node }); - const startNodes = [node]; + const startNodes = new Set([node]); const runData: IRunData = {}; const pinData: IPinData = { [trigger.name]: [{ json: { value: 1 } }], @@ -169,7 +169,7 @@ describe('recreateNodeExecutionStack', () => { .addNodes(trigger, node1, node2) .addConnections({ from: trigger, to: node1 }, { from: node1, to: node2 }); - const startNodes = [node2]; + const startNodes = new Set([node2]); const runData: IRunData = { [trigger.name]: [toITaskData([{ data: { value: 1 } }])], }; @@ -204,7 +204,7 @@ describe('recreateNodeExecutionStack', () => { { from: node2, to: node3 }, ); - const startNodes = [node3]; + const startNodes = new Set([node3]); const runData: IRunData = { [trigger.name]: [toITaskData([{ data: { value: 1 } }])], [node1.name]: [toITaskData([{ data: { value: 1 } }])], @@ -287,7 +287,7 @@ describe('recreateNodeExecutionStack', () => { { from: node1, to: node3, inputIndex: 0 }, { from: node2, to: node3, inputIndex: 1 }, ); - const startNodes = [node3]; + const startNodes = new Set([node3]); const runData: IRunData = { [trigger.name]: [toITaskData([{ data: { value: 1 } }])], [node1.name]: [toITaskData([{ data: { value: 1 } }])], diff --git a/packages/core/src/PartialExecutionUtils/cleanRunData.ts b/packages/core/src/PartialExecutionUtils/cleanRunData.ts index 5d74a3575a..bcd60c423b 100644 --- a/packages/core/src/PartialExecutionUtils/cleanRunData.ts +++ b/packages/core/src/PartialExecutionUtils/cleanRunData.ts @@ -10,7 +10,7 @@ import type { DirectedGraph } from './DirectedGraph'; export function cleanRunData( runData: IRunData, graph: DirectedGraph, - startNodes: INode[], + startNodes: Set, ): IRunData { const newRunData: IRunData = { ...runData }; diff --git a/packages/core/src/PartialExecutionUtils/findCycles.ts b/packages/core/src/PartialExecutionUtils/findCycles.ts deleted file mode 100644 index 388518ae52..0000000000 --- a/packages/core/src/PartialExecutionUtils/findCycles.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Workflow } from 'n8n-workflow'; - -export function findCycles(_workflow: Workflow) { - // TODO: implement depth first search or Tarjan's Algorithm - return []; -} diff --git a/packages/core/src/PartialExecutionUtils/findStartNodes.ts b/packages/core/src/PartialExecutionUtils/findStartNodes.ts index a6165f6564..5eb036bd88 100644 --- a/packages/core/src/PartialExecutionUtils/findStartNodes.ts +++ b/packages/core/src/PartialExecutionUtils/findStartNodes.ts @@ -137,7 +137,7 @@ export function findStartNodes(options: { destination: INode; runData?: IRunData; pinData?: IPinData; -}): INode[] { +}): Set { const graph = options.graph; const trigger = options.trigger; const destination = options.destination; @@ -156,5 +156,5 @@ export function findStartNodes(options: { new Set(), ); - return [...startNodes]; + return startNodes; } diff --git a/packages/core/src/PartialExecutionUtils/handleCycles.ts b/packages/core/src/PartialExecutionUtils/handleCycles.ts new file mode 100644 index 0000000000..94a8ae8cbc --- /dev/null +++ b/packages/core/src/PartialExecutionUtils/handleCycles.ts @@ -0,0 +1,56 @@ +import type { INode } from 'n8n-workflow'; +import * as a from 'node:assert/strict'; + +import type { DirectedGraph } from './DirectedGraph'; + +/** + * Returns a new set of start nodes. + * + * For every start node this checks if it is part of a cycle and if it is it + * replaces the start node with the start of the cycle. + * + * This is useful because it prevents executing cycles partially, e.g. figuring + * our which run of the cycle has to be repeated etc. + */ +export function handleCycles( + graph: DirectedGraph, + startNodes: Set, + trigger: INode, +): Set { + // Strongly connected components can also be nodes that are not part of a + // cycle. They form a strongly connected component of one. E.g the trigger is + // always a strongly connected component by itself because it does not have + // any inputs and thus cannot build a cycle. + // + // We're not interested in them so we filter them out. + const cycles = graph.getStronglyConnectedComponents().filter((cycle) => cycle.size >= 1); + const newStartNodes: Set = new Set(startNodes); + + // For each start node, check if the node is part of a cycle and if it is + // replace the start node with the start of the cycle. + if (cycles.length === 0) { + return newStartNodes; + } + + for (const startNode of startNodes) { + for (const cycle of cycles) { + const isPartOfCycle = cycle.has(startNode); + if (isPartOfCycle) { + const firstNode = graph.depthFirstSearch({ + from: trigger, + fn: (node) => cycle.has(node), + }); + + a.ok( + firstNode, + "the trigger must be connected to the cycle, otherwise the cycle wouldn't be part of the subgraph", + ); + + newStartNodes.delete(startNode); + newStartNodes.add(firstNode); + } + } + } + + return newStartNodes; +} diff --git a/packages/core/src/PartialExecutionUtils/index.ts b/packages/core/src/PartialExecutionUtils/index.ts index 6a6f1a233a..cea8ded9b9 100644 --- a/packages/core/src/PartialExecutionUtils/index.ts +++ b/packages/core/src/PartialExecutionUtils/index.ts @@ -2,5 +2,6 @@ export { DirectedGraph } from './DirectedGraph'; export { findTriggerForPartialExecution } from './findTriggerForPartialExecution'; export { findStartNodes } from './findStartNodes'; export { findSubgraph } from './findSubgraph'; -export { findCycles } from './findCycles'; export { recreateNodeExecutionStack } from './recreateNodeExecutionStack'; +export { cleanRunData } from './cleanRunData'; +export { handleCycles } from './handleCycles'; diff --git a/packages/core/src/PartialExecutionUtils/recreateNodeExecutionStack.ts b/packages/core/src/PartialExecutionUtils/recreateNodeExecutionStack.ts index 4926becb79..534969f960 100644 --- a/packages/core/src/PartialExecutionUtils/recreateNodeExecutionStack.ts +++ b/packages/core/src/PartialExecutionUtils/recreateNodeExecutionStack.ts @@ -32,7 +32,7 @@ import { getSourceDataGroups } from './getSourceDataGroups'; */ export function recreateNodeExecutionStack( graph: DirectedGraph, - startNodes: INode[], + startNodes: Set, destinationNode: INode, runData: IRunData, pinData: IPinData, diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index ec5963a54b..1d9aee76c6 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -51,13 +51,13 @@ import PCancelable from 'p-cancelable'; import * as NodeExecuteFunctions from './NodeExecuteFunctions'; import { DirectedGraph, - findCycles, findStartNodes, findSubgraph, findTriggerForPartialExecution, + cleanRunData, + recreateNodeExecutionStack, + handleCycles, } from './PartialExecutionUtils'; -import { cleanRunData } from './PartialExecutionUtils/cleanRunData'; -import { recreateNodeExecutionStack } from './PartialExecutionUtils/recreateNodeExecutionStack'; export class WorkflowExecute { private status: ExecutionStatus = 'new'; @@ -352,15 +352,11 @@ export class WorkflowExecute { const filteredNodes = subgraph.getNodes(); // 3. Find the Start Nodes - const startNodes = findStartNodes({ graph: subgraph, trigger, destination, runData }); + let startNodes = findStartNodes({ graph: subgraph, trigger, destination, runData }); // 4. Detect Cycles - const cycles = findCycles(workflow); - // 5. Handle Cycles - if (cycles.length) { - // TODO: handle - } + startNodes = handleCycles(graph, startNodes, trigger); // 6. Clean Run Data const newRunData: IRunData = cleanRunData(runData, graph, startNodes); From cd15e959c7af82a7d8c682e94add2b2640624a70 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Mon, 21 Oct 2024 09:35:23 +0200 Subject: [PATCH 34/57] feat(editor): Separate node output execution tooltip from status icon (#11196) --- cypress/e2e/40-manual-partial-execution.cy.ts | 1 + cypress/e2e/5-ndv.cy.ts | 8 ++- cypress/pages/ndv.ts | 5 +- .../src/components/N8nInfoTip/InfoTip.vue | 58 +++++++------------ .../__snapshots__/InfoTip.spec.ts.snap | 11 +++- packages/editor-ui/src/components/RunInfo.vue | 57 ++++++++++-------- 6 files changed, 75 insertions(+), 65 deletions(-) diff --git a/cypress/e2e/40-manual-partial-execution.cy.ts b/cypress/e2e/40-manual-partial-execution.cy.ts index 5fe31b56ad..2eb129475f 100644 --- a/cypress/e2e/40-manual-partial-execution.cy.ts +++ b/cypress/e2e/40-manual-partial-execution.cy.ts @@ -23,6 +23,7 @@ describe('Manual partial execution', () => { canvas.actions.openNode('Webhook1'); ndv.getters.nodeRunSuccessIndicator().should('exist'); + ndv.getters.nodeRunTooltipIndicator().should('exist'); ndv.getters.outputRunSelector().should('not.exist'); // single run }); }); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index a591d62895..f2ccccb6ab 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -133,9 +133,10 @@ describe('NDV', () => { "An expression here won't work because it uses .item and n8n can't figure out the matching item.", ); ndv.getters.nodeRunErrorIndicator().should('be.visible'); + ndv.getters.nodeRunTooltipIndicator().should('be.visible'); // The error details should be hidden behind a tooltip - ndv.getters.nodeRunErrorIndicator().should('not.contain', 'Start Time'); - ndv.getters.nodeRunErrorIndicator().should('not.contain', 'Execution Time'); + ndv.getters.nodeRunTooltipIndicator().should('not.contain', 'Start Time'); + ndv.getters.nodeRunTooltipIndicator().should('not.contain', 'Execution Time'); }); it('should save workflow using keyboard shortcut from NDV', () => { @@ -617,8 +618,10 @@ describe('NDV', () => { // Should not show run info before execution ndv.getters.nodeRunSuccessIndicator().should('not.exist'); ndv.getters.nodeRunErrorIndicator().should('not.exist'); + ndv.getters.nodeRunTooltipIndicator().should('not.exist'); ndv.getters.nodeExecuteButton().click(); ndv.getters.nodeRunSuccessIndicator().should('exist'); + ndv.getters.nodeRunTooltipIndicator().should('exist'); }); it('should properly show node execution indicator for multiple nodes', () => { @@ -630,6 +633,7 @@ describe('NDV', () => { // Manual tigger node should show success indicator workflowPage.actions.openNode('When clicking ‘Test workflow’'); ndv.getters.nodeRunSuccessIndicator().should('exist'); + ndv.getters.nodeRunTooltipIndicator().should('exist'); // Code node should show error ndv.getters.backToCanvas().click(); workflowPage.actions.openNode('Code'); diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index cae1fb47b0..f32b1ff1a3 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -130,8 +130,9 @@ export class NDV extends BasePage { codeEditorFullscreenButton: () => cy.getByTestId('code-editor-fullscreen-button'), codeEditorDialog: () => cy.getByTestId('code-editor-fullscreen'), codeEditorFullscreen: () => this.getters.codeEditorDialog().find('.cm-content'), - nodeRunSuccessIndicator: () => cy.getByTestId('node-run-info-success'), - nodeRunErrorIndicator: () => cy.getByTestId('node-run-info-danger'), + nodeRunTooltipIndicator: () => cy.getByTestId('node-run-info'), + nodeRunSuccessIndicator: () => cy.getByTestId('node-run-status-success'), + nodeRunErrorIndicator: () => cy.getByTestId('node-run-status-danger'), nodeRunErrorMessage: () => cy.getByTestId('node-error-message'), nodeRunErrorDescription: () => cy.getByTestId('node-error-description'), fixedCollectionParameter: (paramName: string) => diff --git a/packages/design-system/src/components/N8nInfoTip/InfoTip.vue b/packages/design-system/src/components/N8nInfoTip/InfoTip.vue index cde570d716..04ba27ae5f 100644 --- a/packages/design-system/src/components/N8nInfoTip/InfoTip.vue +++ b/packages/design-system/src/components/N8nInfoTip/InfoTip.vue @@ -2,12 +2,24 @@ import type { Placement } from 'element-plus'; import { computed } from 'vue'; +import type { IconColor } from 'n8n-design-system/types/icon'; + import N8nIcon from '../N8nIcon'; import N8nTooltip from '../N8nTooltip'; const THEME = ['info', 'info-light', 'warning', 'danger', 'success'] as const; const TYPE = ['note', 'tooltip'] as const; +const ICON_MAP = { + info: 'info-circle', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'info-light': 'info-circle', + warning: 'exclamation-triangle', + danger: 'exclamation-triangle', + success: 'check-circle', +} as const; +type IconMap = typeof ICON_MAP; + interface InfoTipProps { theme?: (typeof THEME)[number]; type?: (typeof TYPE)[number]; @@ -23,39 +35,11 @@ const props = withDefaults(defineProps(), { tooltipPlacement: 'top', }); -const iconData = computed((): { icon: string; color: string } => { - switch (props.theme) { - case 'info': - return { - icon: 'info-circle', - color: '--color-text-light)', - }; - case 'info-light': - return { - icon: 'info-circle', - color: 'var(--color-foreground-dark)', - }; - case 'warning': - return { - icon: 'exclamation-triangle', - color: 'var(--color-warning)', - }; - case 'danger': - return { - icon: 'exclamation-triangle', - color: 'var(--color-danger)', - }; - case 'success': - return { - icon: 'check-circle', - color: 'var(--color-success)', - }; - default: - return { - icon: 'info-circle', - color: '--color-text-light)', - }; - } +const iconData = computed<{ icon: IconMap[keyof IconMap]; color: IconColor }>(() => { + return { + icon: ICON_MAP[props.theme], + color: props.theme === 'info' || props.theme === 'info-light' ? 'text-base' : props.theme, + } as const; }); @@ -69,14 +53,16 @@ const iconData = computed((): { icon: string; color: string } => { [$style.bold]: bold, }" > + - - + + - + diff --git a/packages/design-system/src/components/N8nInfoTip/__tests__/__snapshots__/InfoTip.spec.ts.snap b/packages/design-system/src/components/N8nInfoTip/__tests__/__snapshots__/InfoTip.spec.ts.snap index 4bbaaa8b5d..ef7db8dfe8 100644 --- a/packages/design-system/src/components/N8nInfoTip/__tests__/__snapshots__/InfoTip.spec.ts.snap +++ b/packages/design-system/src/components/N8nInfoTip/__tests__/__snapshots__/InfoTip.spec.ts.snap @@ -1,9 +1,16 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`N8nInfoTip > should render correctly as note 1`] = `"
Need help doing something?Open docs
"`; +exports[`N8nInfoTip > should render correctly as note 1`] = ` +"
+ Need help doing something?Open docs +
" +`; exports[`N8nInfoTip > should render correctly as tooltip 1`] = ` -"
+"
+
" diff --git a/packages/editor-ui/src/components/RunInfo.vue b/packages/editor-ui/src/components/RunInfo.vue index 31689dbc1e..f4e68657e5 100644 --- a/packages/editor-ui/src/components/RunInfo.vue +++ b/packages/editor-ui/src/components/RunInfo.vue @@ -50,27 +50,38 @@ const runMetadata = computed(() => { " > - -
- {{ - runTaskData?.error - ? i18n.baseText('runData.executionStatus.failed') - : i18n.baseText('runData.executionStatus.success') - }}
- {{ i18n.baseText('runData.startTime') + ':' }} - {{ runMetadata.startTime }}
- {{ - i18n.baseText('runData.executionTime') + ':' - }} - {{ runMetadata.executionTime }} {{ i18n.baseText('runData.ms') }} -
-
+
+ + +
+ {{ + runTaskData?.error + ? i18n.baseText('runData.executionStatus.failed') + : i18n.baseText('runData.executionStatus.success') + }}
+ {{ + i18n.baseText('runData.startTime') + ':' + }} + {{ runMetadata.startTime }}
+ {{ + i18n.baseText('runData.executionTime') + ':' + }} + {{ runMetadata.executionTime }} {{ i18n.baseText('runData.ms') }} +
+
+
+ + From 76724c3be6e001792433045c2b2aac0ef16d4b8a Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Mon, 21 Oct 2024 10:02:18 +0200 Subject: [PATCH 35/57] fix(editor): Open Community+ enrollment modal only for the instance owner (#11292) --- packages/@n8n/permissions/src/constants.ts | 1 + packages/@n8n/permissions/src/types.ts | 2 + packages/cli/src/permissions/global-roles.ts | 1 + .../src/components/PersonalizationModal.vue | 40 ++++++++++++------- packages/editor-ui/src/permissions.spec.ts | 2 + packages/editor-ui/src/stores/rbac.store.ts | 1 + .../src/views/SettingsUsageAndPlan.test.ts | 7 ++++ .../src/views/SettingsUsageAndPlan.vue | 9 ++++- packages/editor-ui/src/views/SetupView.vue | 2 +- 9 files changed, 48 insertions(+), 17 deletions(-) diff --git a/packages/@n8n/permissions/src/constants.ts b/packages/@n8n/permissions/src/constants.ts index c43677e843..7a0ebf2cb1 100644 --- a/packages/@n8n/permissions/src/constants.ts +++ b/packages/@n8n/permissions/src/constants.ts @@ -3,6 +3,7 @@ export const RESOURCES = { annotationTag: [...DEFAULT_OPERATIONS] as const, auditLogs: ['manage'] as const, banner: ['dismiss'] as const, + community: ['register'] as const, communityPackage: ['install', 'uninstall', 'update', 'list', 'manage'] as const, credential: ['share', 'move', ...DEFAULT_OPERATIONS] as const, externalSecretsProvider: ['sync', ...DEFAULT_OPERATIONS] as const, diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 1a78f79f15..07ed750f91 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -13,6 +13,7 @@ export type WildcardScope = `${Resource}:*` | '*'; export type AnnotationTagScope = ResourceScope<'annotationTag'>; export type AuditLogsScope = ResourceScope<'auditLogs', 'manage'>; export type BannerScope = ResourceScope<'banner', 'dismiss'>; +export type CommunityScope = ResourceScope<'community', 'register'>; export type CommunityPackageScope = ResourceScope< 'communityPackage', 'install' | 'uninstall' | 'update' | 'list' | 'manage' @@ -48,6 +49,7 @@ export type Scope = | AnnotationTagScope | AuditLogsScope | BannerScope + | CommunityScope | CommunityPackageScope | CredentialScope | ExternalSecretProviderScope diff --git a/packages/cli/src/permissions/global-roles.ts b/packages/cli/src/permissions/global-roles.ts index 6315c3c617..7ea1b575da 100644 --- a/packages/cli/src/permissions/global-roles.ts +++ b/packages/cli/src/permissions/global-roles.ts @@ -15,6 +15,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'credential:list', 'credential:share', 'credential:move', + 'community:register', 'communityPackage:install', 'communityPackage:uninstall', 'communityPackage:update', diff --git a/packages/editor-ui/src/components/PersonalizationModal.vue b/packages/editor-ui/src/components/PersonalizationModal.vue index ee7e1ff4d3..53e40f1cfb 100644 --- a/packages/editor-ui/src/components/PersonalizationModal.vue +++ b/packages/editor-ui/src/components/PersonalizationModal.vue @@ -93,6 +93,7 @@ import { useExternalHooks } from '@/composables/useExternalHooks'; import { useI18n } from '@/composables/useI18n'; import { useRoute, useRouter } from 'vue-router'; import { useUIStore } from '@/stores/ui.store'; +import { getResourcePermissions } from '@/permissions'; const SURVEY_VERSION = 'v4'; @@ -110,7 +111,9 @@ const uiStore = useUIStore(); const formValues = ref>({}); const isSaving = ref(false); - +const userPermissions = computed(() => + getResourcePermissions(usersStore.currentUser?.globalScopes), +); const survey = computed(() => [ { name: COMPANY_TYPE_KEY, @@ -548,23 +551,30 @@ const onSave = () => { formBus.emit('submit'); }; +const closeCallback = () => { + const isPartOfOnboardingExperiment = + posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) === + MORE_ONBOARDING_OPTIONS_EXPERIMENT.control; + // In case the redirect to homepage for new users didn't happen + // we try again after closing the modal + if (route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) { + void router.replace({ name: VIEWS.HOMEPAGE }); + } +}; + const closeDialog = () => { modalBus.emit('close'); - uiStore.openModalWithData({ - name: COMMUNITY_PLUS_ENROLLMENT_MODAL, - data: { - closeCallback: () => { - const isPartOfOnboardingExperiment = - posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) === - MORE_ONBOARDING_OPTIONS_EXPERIMENT.control; - // In case the redirect to homepage for new users didn't happen - // we try again after closing the modal - if (route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) { - void router.replace({ name: VIEWS.HOMEPAGE }); - } + + if (userPermissions.value.community.register) { + uiStore.openModalWithData({ + name: COMMUNITY_PLUS_ENROLLMENT_MODAL, + data: { + closeCallback, }, - }, - }); + }); + } else { + closeCallback(); + } }; const onSubmit = async (values: IPersonalizationLatestVersion) => { diff --git a/packages/editor-ui/src/permissions.spec.ts b/packages/editor-ui/src/permissions.spec.ts index e7946ce421..ab3952fbeb 100644 --- a/packages/editor-ui/src/permissions.spec.ts +++ b/packages/editor-ui/src/permissions.spec.ts @@ -8,6 +8,7 @@ describe('permissions', () => { annotationTag: {}, auditLogs: {}, banner: {}, + community: {}, communityPackage: {}, credential: {}, externalSecretsProvider: {}, @@ -62,6 +63,7 @@ describe('permissions', () => { annotationTag: {}, auditLogs: {}, banner: {}, + community: {}, communityPackage: {}, credential: { create: true, diff --git a/packages/editor-ui/src/stores/rbac.store.ts b/packages/editor-ui/src/stores/rbac.store.ts index d51bbc8538..a38b0f674b 100644 --- a/packages/editor-ui/src/stores/rbac.store.ts +++ b/packages/editor-ui/src/stores/rbac.store.ts @@ -27,6 +27,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { eventBusDestination: {}, auditLogs: {}, banner: {}, + community: {}, communityPackage: {}, ldap: {}, license: {}, diff --git a/packages/editor-ui/src/views/SettingsUsageAndPlan.test.ts b/packages/editor-ui/src/views/SettingsUsageAndPlan.test.ts index bd88c02228..e33d2a080e 100644 --- a/packages/editor-ui/src/views/SettingsUsageAndPlan.test.ts +++ b/packages/editor-ui/src/views/SettingsUsageAndPlan.test.ts @@ -6,6 +6,8 @@ import { useUsageStore } from '@/stores/usage.store'; import SettingsUsageAndPlan from '@/views/SettingsUsageAndPlan.vue'; import { useUIStore } from '@/stores/ui.store'; import { COMMUNITY_PLUS_ENROLLMENT_MODAL } from '@/constants'; +import { useUsersStore } from '@/stores/users.store'; +import type { IUser } from '@/Interface'; vi.mock('vue-router', () => { return { @@ -23,6 +25,7 @@ vi.mock('vue-router', () => { let usageStore: ReturnType>; let uiStore: ReturnType>; +let usersStore: ReturnType>; const renderComponent = createComponentRenderer(SettingsUsageAndPlan); @@ -31,6 +34,7 @@ describe('SettingsUsageAndPlan', () => { createTestingPinia(); usageStore = mockedStore(useUsageStore); uiStore = mockedStore(useUIStore); + usersStore = mockedStore(useUsersStore); usageStore.viewPlansUrl = 'https://subscription.n8n.io'; usageStore.managePlanUrl = 'https://subscription.n8n.io'; @@ -49,6 +53,9 @@ describe('SettingsUsageAndPlan', () => { it('should not show badge but unlock notice', async () => { usageStore.isLoading = false; usageStore.planName = 'Community'; + usersStore.currentUser = { + globalScopes: ['community:register'], + } as IUser; const { getByRole, container } = renderComponent(); expect(getByRole('heading', { level: 3 })).toHaveTextContent('Community'); expect(container.querySelector('.n8n-badge')).toBeNull(); diff --git a/packages/editor-ui/src/views/SettingsUsageAndPlan.vue b/packages/editor-ui/src/views/SettingsUsageAndPlan.vue index b1df9d2fa9..82a6da8b39 100644 --- a/packages/editor-ui/src/views/SettingsUsageAndPlan.vue +++ b/packages/editor-ui/src/views/SettingsUsageAndPlan.vue @@ -11,11 +11,14 @@ import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { hasPermission } from '@/utils/rbac/permissions'; import N8nInfoTip from 'n8n-design-system/components/N8nInfoTip'; import { COMMUNITY_PLUS_ENROLLMENT_MODAL } from '@/constants'; +import { useUsersStore } from '@/stores/users.store'; +import { getResourcePermissions } from '@/permissions'; const usageStore = useUsageStore(); const route = useRoute(); const router = useRouter(); const uiStore = useUIStore(); +const usersStore = useUsersStore(); const toast = useToast(); const documentTitle = useDocumentTitle(); @@ -48,6 +51,10 @@ const isCommunityEditionRegistered = computed( () => usageStore.planName.toLowerCase() === 'registered community', ); +const canUserRegisterCommunityPlus = computed( + () => getResourcePermissions(usersStore.currentUser?.globalScopes).community.register, +); + const showActivationSuccess = () => { toast.showMessage({ type: 'success', @@ -173,7 +180,7 @@ const openCommunityRegisterModal = () => { - +