diff --git a/packages/nodes-base/credentials/GongApi.credentials.ts b/packages/nodes-base/credentials/GongApi.credentials.ts new file mode 100644 index 0000000000000..19c56a65defb1 --- /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 0000000000000..bea935c4f1f76 --- /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 0000000000000..8c9069959b488 --- /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 0000000000000..be07bbb3307a5 --- /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 0000000000000..81b678f283d9a --- /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 0000000000000..ab6df9626dcdc --- /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 0000000000000..38fb847b9072b --- /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 0000000000000..ff9bc4319bf93 --- /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 0000000000000..044aba4e03371 --- /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 0000000000000..d4e3e307fd7a5 --- /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 0000000000000..621e6c0d72add --- /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 4f37056d1786e..44fd57a2ffdb4 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", @@ -516,6 +518,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 fbf6d00b71a8a..eaccd22fdcabb 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 14d9494e039ed..98e8ebf9182c9 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 15802fe0b0ea5..34a5f157df2b2 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2305,7 +2305,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 fe7fac6790a3a..4b3ed7f5970a0 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, ),