From 735170b314ea87810a5f7ec3fcb0cc61d07a89f9 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Wed, 11 Sep 2024 21:17:59 +0200 Subject: [PATCH 01/24] add gong node --- .../credentials/GongApi.credentials.ts | 56 + .../nodes-base/nodes/Gong/GenericFunctions.ts | 120 ++ packages/nodes-base/nodes/Gong/Gong.node.json | 18 + packages/nodes-base/nodes/Gong/Gong.node.ts | 141 +++ .../Gong/descriptions/CallDescription.ts | 1112 +++++++++++++++++ .../Gong/descriptions/UserDescription.ts | 234 ++++ .../nodes/Gong/descriptions/index.ts | 2 + packages/nodes-base/nodes/Gong/gong.svg | 4 + packages/nodes-base/package.json | 2 + 9 files changed, 1689 insertions(+) create mode 100644 packages/nodes-base/credentials/GongApi.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 diff --git a/packages/nodes-base/credentials/GongApi.credentials.ts b/packages/nodes-base/credentials/GongApi.credentials.ts new file mode 100644 index 0000000000000..cbc270f5a5d1f --- /dev/null +++ b/packages/nodes-base/credentials/GongApi.credentials.ts @@ -0,0 +1,56 @@ +import type { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class GongApi implements ICredentialType { + name = 'gongApi'; + + displayName = 'Gong API'; + + documentationUrl = 'https://gong.app.gong.io/settings/api/documentation'; + + properties: INodeProperties[] = [ + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'string', + default: 'https://api.gong.io', + }, + { + displayName: 'Access Key', + name: 'accessKey', + // eslint-disable-next-line n8n-nodes-base/cred-class-field-type-options-password-missing + type: 'string', + 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/nodes/Gong/GenericFunctions.ts b/packages/nodes-base/nodes/Gong/GenericFunctions.ts new file mode 100644 index 0000000000000..1b7f789fe3bfd --- /dev/null +++ b/packages/nodes-base/nodes/Gong/GenericFunctions.ts @@ -0,0 +1,120 @@ +import type { + DeclarativeRestApiSettings, + IDataObject, + IExecuteFunctions, + IExecutePaginationFunctions, + IExecuteSingleFunctions, + IHttpRequestMethods, + IHttpRequestOptions, + ILoadOptionsFunctions, + INodeExecutionData, +} from 'n8n-workflow'; + +export async function gongApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + endpoint: string, + body: IDataObject = {}, + query: IDataObject = {}, +) { + const options: IHttpRequestOptions = { + method, + url: `https://api.gong.io${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, 'gongApi', 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 options: IHttpRequestOptions = { + method, + url: `https://api.gong.io${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']: { + cursor: '={{ $response.body?.records.cursor ?? "" }}', + }, + url: options.url, + }, + }, + 'gongApi', + ); + + 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(); + } +} + +export const getCursorPaginator = (rootProperty: string) => { + 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; + + const extractItems = (page: INodeExecutionData) => { + const items = page.json[rootProperty] as IDataObject[]; + if (items) { + executions = executions.concat(items.map((item) => ({ json: item }))); + } + }; + + 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; + responseData.forEach(extractItems); + } while (returnAll && nextCursor); + + return executions; + }; +}; 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..27841aff484a4 --- /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://gong.app.gong.io/settings/api/documentation" + } + ], + "primaryDocumentation": [ + { + "url": "https://gong.app.gong.io/settings/api/documentation" + } + ] + } +} 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..72af2edf3138f --- /dev/null +++ b/packages/nodes-base/nodes/Gong/Gong.node.ts @@ -0,0 +1,141 @@ +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, + }, + ], + requestDefaults: { + baseURL: '={{ $credentials.baseUrl.replace(new RegExp("/$"), "") }}', + }, + properties: [ + { + 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..b0e108937e630 --- /dev/null +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -0,0 +1,1112 @@ +import type { + IDataObject, + IExecuteSingleFunctions, + IHttpRequestOptions, + IN8nHttpFullResponse, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import FormData from 'form-data'; + +import { getCursorPaginator, gongApiPaginateRequest } from '../GenericFunctions'; + +export const callOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['call'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Add new call', + routing: { + request: { + method: 'POST', + url: '/v2/calls', + }, + }, + action: 'Create call', + }, + { + name: 'Create Media', + value: 'createMedia', + description: 'Adds a call media', + action: 'Add call media', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve data for a specific call', + routing: { + request: { + method: 'POST', + url: '/v2/calls/extensive', + }, + }, + action: 'Get call', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'List calls that took place during a specified date range', + routing: { + request: { + method: 'POST', + url: '/v2/calls/extensive', + body: { + filter: {}, + }, + }, + }, + action: 'Get many calls', + }, + ], + default: 'getAll', + }, +]; + +const createFields: INodeProperties[] = [ + { + displayName: 'Actual Start', + name: 'actualStart', + default: '', + description: + "The actual date and time when the call started in the ISO-8601 format (e.g., '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC);", + displayOptions: { + show: { + resource: ['call'], + operation: ['create'], + }, + }, + required: true, + routing: { + send: { + type: 'body', + property: 'actualStart', + value: '={{ new Date($value).toISOString() }}', + }, + }, + type: 'dateTime', + }, + { + displayName: 'Client Unique ID', + name: 'clientUniqueId', + default: '', + description: + "A call's unique identifier in the PBX or the recording system. Gong uses this identifier to prevent repeated attempts to upload the same recording.", + displayOptions: { + show: { + resource: ['call'], + operation: ['create'], + }, + }, + required: true, + routing: { + send: { + type: 'body', + property: 'clientUniqueId', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Direction', + name: 'direction', + default: 'Inbound', + description: + 'Whether the call is inbound (someone called the company), outbound (a rep dialed someone outside the company), or a conference call', + displayOptions: { + show: { + resource: ['call'], + operation: ['create'], + }, + }, + options: [ + { + name: 'Inbound', + value: 'Inbound', + }, + { + name: 'Outbound', + value: 'Outbound', + }, + { + name: 'Conference', + value: 'Conference', + }, + { + name: 'Unknown', + value: 'Unknown', + }, + ], + required: true, + routing: { + send: { + type: 'body', + property: 'direction', + value: '={{ $value }}', + }, + }, + type: 'options', + }, + { + displayName: 'Parties', + name: 'parties', + default: [], + description: "A list of the call's participants. A party must be provided for the primaryUser.", + displayOptions: { + show: { + resource: ['call'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Party Fields', + name: 'partyFields', + values: [ + // Fields not included: context + { + displayName: 'Phone Number', + name: 'phoneNumber', + default: '', + description: 'The phone number of the party, if available', + routing: { + send: { + type: 'body', + property: '=parties[{{$index}}].phoneNumber', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Email Address', + name: 'emailAddress', + default: '', + description: 'The email address of the party, if available', + routing: { + send: { + type: 'body', + property: '=parties[{{$index}}].emailAddress', + value: '={{ $value || null }}', + }, + }, + type: 'string', + }, + { + displayName: 'Name', + name: 'name', + default: '', + description: 'The name of the party, if available', + routing: { + send: { + type: 'body', + property: '=parties[{{$index}}].name', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Party ID', + name: 'partyId', + default: '', + description: + 'An identifier that is only required when speakersTimeline is provided. The partyId is used to recognize the speakers within the provided speakersTimeline.', + routing: { + send: { + type: 'body', + property: '=parties[{{$index}}].partyId', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Media Channel ID', + name: 'mediaChannelId', + default: 0, + description: + 'The audio channel corresponding to the company team member (rep) used when the uploaded media file is multi-channel (stereo). The channel ID is either 0 or 1 (representing left or right respectively).', + options: [ + { + name: 'Left', + value: 0, + }, + { + name: 'Right', + value: 1, + }, + ], + routing: { + send: { + type: 'body', + property: '=parties[{{$index}}].mediaChannelId', + value: '={{ $value }}', + }, + }, + type: 'options', + }, + { + displayName: 'User ID', + name: 'userId', + default: '', + description: + 'The user ID of the participant within the Gong system, if the participant is a user', + routing: { + send: { + type: 'body', + property: '=parties[{{$index}}].userId', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + ], + }, + ], + required: true, + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + }, + { + displayName: 'Primary User', + name: 'primaryUser', + default: '', + description: 'The Gong internal user ID of the team member who hosted the call', + displayOptions: { + show: { + resource: ['call'], + operation: ['create'], + }, + }, + required: true, + routing: { + send: { + type: 'body', + property: 'primaryUser', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + default: {}, + displayOptions: { + show: { + resource: ['call'], + operation: ['create'], + }, + }, + options: [ + // Fields not included: context, speakersTimeline + { + displayName: 'Call Provider Code', + name: 'callProviderCode', + default: '', + description: + 'The code identifies the provider conferencing or telephony system. For example: zoom, clearslide, gotomeeting, ringcentral, outreach, insidesales, etc. These values are predefined by Gong, please contact help@gong.io to find the proper value for your system.', + routing: { + send: { + type: 'body', + property: 'callProviderCode', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Custom Data', + name: 'customData', + default: '', + description: + 'Optional metadata associated with the call (represented as text). Gong stores this metadata and it can be used for troubleshooting.', + routing: { + send: { + type: 'body', + property: 'customData', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Disposition', + name: 'disposition', + default: '', + description: + 'The disposition of the call. The disposition is free text of up to 255 characters.', + routing: { + send: { + type: 'body', + property: 'disposition', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Download Media Url', + name: 'downloadMediaUrl', + default: '', + description: + "The URL from which Gong can download the media file.The URL must be unique, the audio or video file must be a maximum of 1.5GB.If you provide this URL, you should not perform the 'Add call media' step", + routing: { + send: { + type: 'body', + property: 'downloadMediaUrl', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Duration', + name: 'duration', + default: 0, + description: 'The actual call duration in seconds', + routing: { + send: { + type: 'body', + property: 'duration', + value: '={{ $value }}', + }, + }, + type: 'number', + }, + { + displayName: 'Language Code', + name: 'languageCode', + default: '', + description: + 'The language code the call should be transcribed to.This field is optional as Gong automatically detects the language spoken in the call and transcribes it accordingly. Set this field only if you are sure of the language the call is in.', + routing: { + send: { + type: 'body', + property: 'languageCode', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Meeting URL', + name: 'meetingUrl', + default: '', + description: 'The URL of the conference call by which users join the meeting', + routing: { + send: { + type: 'body', + property: 'meetingUrl', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Purpose', + name: 'purpose', + default: '', + description: + 'The purpose of the call. This optional field is a free text of up to 255 characters.', + routing: { + send: { + type: 'body', + property: 'purpose', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Scheduled End', + name: 'scheduledEnd', + default: '', + description: + "The date and time the call was scheduled to end in the ISO-8601 format (e.g., '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC);", + routing: { + send: { + type: 'body', + property: 'scheduledEnd', + value: '={{ new Date($value).toISOString() }}', + }, + }, + type: 'dateTime', + }, + { + displayName: 'Scheduled Start', + name: 'scheduledStart', + default: '', + description: + "The date and time the call was scheduled to begin in the ISO-8601 format (e.g., '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC);", + routing: { + send: { + type: 'body', + property: 'scheduledStart', + value: '={{ new Date($value).toISOString() }}', + }, + }, + type: 'dateTime', + }, + { + displayName: 'Title', + name: 'title', + default: '', + description: + 'The title of the call. This title is available in the Gong system for indexing and search.', + routing: { + send: { + type: 'body', + property: 'title', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + displayName: 'Workspace ID', + name: 'workspaceId', + default: '', + description: + 'Optional workspace identifier. If specified, the call will be placed into this workspace, otherwise, the default algorithm for workspace placement will be applied.', + routing: { + send: { + type: 'body', + property: 'workspaceId', + value: '={{ $value }}', + }, + }, + type: 'string', + }, + ], + placeholder: 'Add Field', + type: 'collection', + }, +]; + +const createMediaFields: INodeProperties[] = [ + { + displayName: 'Call to Get', + name: 'call', + default: { + mode: 'id', + value: '', + }, + displayOptions: { + show: { + resource: ['call'], + operation: ['createMedia'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getCalls', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]{1,20}', + errorMessage: 'Not a valid Gong Call ID', + }, + }, + ], + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://subdomain.app.gong.io/call?id=123456789', + extractValue: { + type: 'regex', + regex: 'https:\\/\\/[a-zA-Z0-9-]+\\.app\\.gong\\.io\\/call\\?id=([0-9]{1,20})', + }, + 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: { + request: { + method: 'PUT', + url: '=/v2/calls/{{$value}}/media', + }, + }, + type: 'resourceLocator', + }, + { + displayName: 'Input Binary Field', + name: 'binaryPropertyName', + default: '', + description: 'The name of the incoming field containing the binary file data to be processed', + displayOptions: { + show: { + resource: ['call'], + operation: ['createMedia'], + }, + }, + required: true, + routing: { + send: { + preSend: [ + async function (this: IExecuteSingleFunctions, requestOptions: IHttpRequestOptions) { + const binaryPropertyName = this.getNodeParameter('binaryPropertyName') as string; + const binaryData = this.helpers.assertBinaryData(binaryPropertyName); + const binaryDataBuffer = await this.helpers.getBinaryDataBuffer(binaryPropertyName); + + const formData = new FormData(); + formData.append('mediaFile', binaryDataBuffer, { + filename: binaryData.fileName, + contentType: binaryData.mimeType, + }); + requestOptions.body = formData; + + return requestOptions; + }, + ], + }, + }, + type: 'string', + }, +]; + +const getFields: INodeProperties[] = [ + { + displayName: 'Call to Get', + name: 'call', + default: { + mode: 'id', + 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', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]{1,20}', + errorMessage: 'Not a valid Gong Call ID', + }, + }, + ], + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://subdomain.app.gong.io/call?id=123456789', + extractValue: { + type: 'regex', + regex: 'https:\\/\\/[a-zA-Z0-9-]+\\.app\\.gong\\.io\\/call\\?id=([0-9]{1,20})', + }, + 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'], + }, + }, + placeholder: 'Add option', + options: [ + { + displayName: 'Action Items', + name: 'pointsOfInterest', + default: false, + description: 'Whether to add call points of interest', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.content.pointsOfInterest', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + { + displayName: 'Audio and Video URLs', + name: 'media', + default: false, + description: + 'Whether to add audio and video URL of the call. The URLs will be available for 8 hours.', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.media', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + { + displayName: 'Brief', + name: 'brief', + default: false, + description: 'Whether to add the spotlight call brief', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.content.brief', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + { + displayName: 'Comments', + name: 'publicComments', + default: false, + description: 'Whether to add public comments made for this call', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.collaboration.publicComments', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + { + displayName: 'Highlights', + name: 'highlights', + default: false, + description: 'Whether to add the call highlights', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.content.highlights', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + { + displayName: 'Keypoints', + name: 'keyPoints', + default: false, + description: 'Whether to add the key points of the call', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.content.keyPoints', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + { + displayName: 'Outline', + name: 'outline', + default: false, + description: 'Whether to add the call outline', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.content.outline', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + { + displayName: 'Outcome', + name: 'callOutcome', + default: false, + description: 'Whether to add the outcome of the call', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.content.callOutcome', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + { + displayName: 'Participants', + name: 'parties', + default: false, + description: 'Whether to add information about the parties of the call', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.parties', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + { + displayName: 'Structure', + name: 'structure', + default: false, + description: 'Whether to add the call agenda', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.content.structure', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + { + displayName: 'Trackers', + name: 'Trackers', + default: false, + description: + 'Whether to add the smart tracker and keyword tracker information for the call', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.content.trackers', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + { + displayName: 'Transcript', + name: 'transcript', + default: false, + description: 'Whether to add information about the participants', + routing: { + output: { + postReceive: [ + async function ( + this: IExecuteSingleFunctions, + items: INodeExecutionData[], + _responseData: IN8nHttpFullResponse, + ): Promise { + 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[0].transcript; + } + return items; + }, + ], + }, + }, + type: 'boolean', + }, + { + displayName: 'Topics', + name: 'topics', + default: false, + description: 'Whether to add the duration of call topics', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.content.topics', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + ], + 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: getCursorPaginator('calls'), + }, + }, + type: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'Max number of results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: ['call'], + operation: ['getAll'], + returnAll: [false], + }, + }, + routing: { + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'calls', + }, + }, + { + type: 'limit', + properties: { + maxResults: '={{ $value }}', + }, + }, + ], + }, + }, + }, + { + displayName: 'Filters', + name: 'filters', + default: {}, + displayOptions: { + show: { + resource: ['call'], + operation: ['getAll'], + }, + }, + placeholder: 'Add Filter', + options: [ + { + displayName: 'From', + name: 'fromDateTime', + default: '', + description: + "Date and time (in ISO-8601 format: '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC) from which to list recorded calls. 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.", + routing: { + send: { + type: 'body', + property: 'filter.fromDateTime', + propertyInDotNotation: true, + value: '={{ new Date($value).toISOString() }}', + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + return requestOptions; + }, + ], + }, + }, + + type: 'dateTime', + }, + { + displayName: 'To', + name: 'toDateTime', + default: '', + description: + "Date and time (in ISO-8601 format: '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC) until which to list recorded calls. 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.", + routing: { + send: { + type: 'body', + property: 'filter.toDateTime', + propertyInDotNotation: true, + value: '={{ new Date($value).toISOString() }}', + }, + }, + type: 'dateTime', + }, + { + displayName: 'Workspace ID', + name: 'workspaceId', + default: '', + description: 'Return only the calls belonging to this workspace', + routing: { + send: { + type: 'body', + property: 'filter.workspaceId', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'string', + }, + { + 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: { + type: 'body', + property: 'filter.callIds', + propertyInDotNotation: true, + value: + '={{ Array.isArray($value) ? $value.map(x => x.toString()) : $value.split(",").map(x => x.trim()) }}', + }, + }, + type: 'string', + }, + { + displayName: 'User IDs', + name: 'primaryUserIds', + default: '', + description: 'Return only the calls hosted by the specified users', + hint: 'Comma separated list of IDs, array of strings can be set in expression', + routing: { + send: { + type: 'body', + property: 'filter.primaryUserIds', + propertyInDotNotation: true, + value: + '={{ Array.isArray($value) ? $value.map(x => x.toString()) : $value.split(",").map(x => x.trim()) }}', + }, + }, + type: 'string', + }, + ], + type: 'collection', + }, + { + displayName: 'Options', + name: 'options', + default: {}, + displayOptions: { + show: { + resource: ['call'], + operation: ['getAll'], + }, + }, + placeholder: 'Add option', + options: [ + { + displayName: 'Participants', + name: 'parties', + default: false, + description: 'Whether to add information about the parties of the call', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.parties', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, + type: 'boolean', + }, + ], + type: 'collection', + }, +]; + +export const callFields: INodeProperties[] = [ + ...createFields, + ...createMediaFields, + ...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..62938753a3570 --- /dev/null +++ b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts @@ -0,0 +1,234 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { getCursorPaginator } 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 a specific user', + action: 'Get user', + routing: { + request: { + method: 'POST', + url: '/v2/users/extensive', + }, + }, + }, + { + name: 'Get Many', + value: 'getAll', + description: 'List multiple users', + action: 'Get many users', + routing: { + request: { + method: 'POST', + url: '/v2/users/extensive', + body: { + filter: {}, + }, + }, + }, + }, + ], + default: 'get', + }, +]; + +const getOperation: INodeProperties[] = [ + { + displayName: 'User to Get', + name: 'user', + default: { + mode: 'id', + 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', + 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: getCursorPaginator('users'), + }, + }, + type: '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, + }, + }, + { + displayName: 'Filter', + name: 'filter', + default: {}, + displayOptions: { + show: { + resource: ['user'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Created From', + 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. The filed is in the ISO-8601 format (e.g., '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC).", + routing: { + send: { + type: 'body', + property: 'filter.createdFromDateTime', + propertyInDotNotation: true, + value: '={{ new Date($value).toISOString() }}', + }, + }, + type: 'dateTime', + }, + { + displayName: 'Created To', + name: 'createdToDateTime', + default: '', + description: + "An optional user creation time upper limit, if supplied the API will return only the users created before this time. The filed is in the ISO-8601 format (e.g., '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC).", + routing: { + send: { + type: 'body', + property: 'filter.createdToDateTime', + propertyInDotNotation: true, + value: '={{ new Date($value).toISOString() }}', + }, + }, + type: '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: { + type: 'body', + property: 'filter.userIds', + propertyInDotNotation: true, + value: + '={{ Array.isArray($value) ? $value.map(x => x.toString()) : $value.split(",").map(x => x.trim()) }}', + }, + }, + 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/package.json b/packages/nodes-base/package.json index afbc99b9cdea3..0226024dee554 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -124,6 +124,7 @@ "dist/credentials/GitlabOAuth2Api.credentials.js", "dist/credentials/GitPassword.credentials.js", "dist/credentials/GmailOAuth2Api.credentials.js", + "dist/credentials/GongApi.credentials.js", "dist/credentials/GoogleAdsOAuth2Api.credentials.js", "dist/credentials/GoogleAnalyticsOAuth2Api.credentials.js", "dist/credentials/GoogleApi.credentials.js", @@ -515,6 +516,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", From f8f251d40b1d6ceab4b09943835991acf2daca15 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 17 Sep 2024 12:54:57 +0200 Subject: [PATCH 02/24] add gong oAuth2 --- .../credentials/GongOAuth2Api.credentials.ts | 59 +++++++++++++++++++ .../nodes-base/nodes/Gong/GenericFunctions.ts | 25 +++++--- packages/nodes-base/nodes/Gong/Gong.node.ts | 30 ++++++++++ .../Gong/descriptions/CallDescription.ts | 4 +- packages/nodes-base/package.json | 1 + packages/workflow/src/RoutingNode.ts | 39 +++++++----- 6 files changed, 136 insertions(+), 22 deletions(-) create mode 100644 packages/nodes-base/credentials/GongOAuth2Api.credentials.ts diff --git a/packages/nodes-base/credentials/GongOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GongOAuth2Api.credentials.ts new file mode 100644 index 0000000000000..29cd8033a6350 --- /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 = 'https://help.gong.io/docs/create-an-app-for-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 index 1b7f789fe3bfd..dabf793094eb4 100644 --- a/packages/nodes-base/nodes/Gong/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Gong/GenericFunctions.ts @@ -17,9 +17,15 @@ export async function gongApiRequest( 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: `https://api.gong.io${endpoint}`, + url: baseUrl.replace(new RegExp('/$'), '') + endpoint, json: true, headers: { 'Content-Type': 'application/json', @@ -32,7 +38,7 @@ export async function gongApiRequest( delete options.body; } - return await this.helpers.requestWithAuthentication.call(this, 'gongApi', options); + return await this.helpers.requestWithAuthentication.call(this, credentialsType, options); } export async function gongApiPaginateRequest( @@ -44,9 +50,15 @@ export async function gongApiPaginateRequest( 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: `https://api.gong.io${endpoint}`, + url: baseUrl.replace(new RegExp('/$'), '') + endpoint, json: true, headers: { 'Content-Type': 'application/json', @@ -67,13 +79,12 @@ export async function gongApiPaginateRequest( requestInterval: 340, // Rate limit 3 calls per second continue: '={{ $response.body.records.cursor }}', request: { - [method === 'POST' ? 'body' : 'qs']: { - cursor: '={{ $response.body?.records.cursor ?? "" }}', - }, + [method === 'POST' ? 'body' : 'qs']: + '={{ $if($response.body?.records.cursor, { cursor: $response.body.records.cursor }, {}) }}', url: options.url, }, }, - 'gongApi', + credentialsType, ); if (rootProperty) { diff --git a/packages/nodes-base/nodes/Gong/Gong.node.ts b/packages/nodes-base/nodes/Gong/Gong.node.ts index 72af2edf3138f..81b678f283d9a 100644 --- a/packages/nodes-base/nodes/Gong/Gong.node.ts +++ b/packages/nodes-base/nodes/Gong/Gong.node.ts @@ -29,12 +29,42 @@ export class Gong implements INodeType { { 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', diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index b0e108937e630..f5ab0e4c71eb8 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -881,7 +881,9 @@ const getFields: INodeProperties[] = [ item.index ?? 0, 'callTranscripts', ); - item.json.transcript = callTranscripts[0].transcript; + item.json.transcript = callTranscripts?.length + ? callTranscripts[0].transcript + : []; } return items; }, diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 0226024dee554..6a10b8bab953c 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -125,6 +125,7 @@ "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", diff --git a/packages/workflow/src/RoutingNode.ts b/packages/workflow/src/RoutingNode.ts index 8824f9c516c28..6ceb0908ce595 100644 --- a/packages/workflow/src/RoutingNode.ts +++ b/packages/workflow/src/RoutingNode.ts @@ -39,6 +39,7 @@ import type { PostReceiveAction, JsonObject, CloseFunction, + INodeCredentialDescription, } from './Interfaces'; import * as NodeHelpers from './NodeHelpers'; @@ -91,11 +92,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, @@ -111,24 +107,39 @@ 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), + ); + // Todo: throw error if not found? + } + } + 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; } } } @@ -285,7 +296,7 @@ export class RoutingNode { itemContext[itemIndex].thisArgs, itemIndex, runIndex, - credentialType, + credentialDescription?.name, itemContext[itemIndex].requestData.requestOperations, credentialsDecrypted, ), From f2fc28eb1723e2bb76c7d989c81904744e760cea Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 17 Sep 2024 15:46:35 +0200 Subject: [PATCH 03/24] add tests --- .../nodes/Gong/test/Gong.node.test.ts | 962 ++++++++++++++++++ packages/nodes-base/nodes/Gong/test/mocks.ts | 698 +++++++++++++ .../test/nodes/FakeCredentialsMap.ts | 26 + packages/nodes-base/test/nodes/Helpers.ts | 4 +- packages/workflow/src/Interfaces.ts | 2 +- 5 files changed, 1690 insertions(+), 2 deletions(-) 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/nodes/Gong/test/Gong.node.test.ts b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts new file mode 100644 index 0000000000000..27be324e55251 --- /dev/null +++ b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts @@ -0,0 +1,962 @@ +import nock from 'nock'; +import type { + ICredentialDataDecryptedObject, + IDataObject, + IHttpRequestOptions, +} from 'n8n-workflow'; +import { ExpressionEvaluatorProxy, NodeConnectionType } from 'n8n-workflow'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; +import * as Helpers from '@test/nodes/Helpers'; +import { gongApiResponse, gongNodeResponse } from './mocks'; +import { FAKE_CREDENTIALS_DATA } from '../../../test/nodes/FakeCredentialsMap'; + +describe('Gong Node', () => { + const baseUrl = 'https://api.gong.io'; + + beforeAll(() => { + // Test expression '={{ Array.isArray($value) ? $value.map(x => x.toString()) : $value.split(",").map(x => x.trim()) }}', + ExpressionEvaluatorProxy.setEvaluator('tournament'); + }); + + 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: { + pointsOfInterest: true, + media: true, + brief: true, + publicComments: true, + highlights: true, + keyPoints: true, + outline: true, + callOutcome: true, + parties: true, + structure: true, + Trackers: true, + transcript: true, + topics: true, + }, + 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', + 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: '234599484848423', + }, + options: { + parties: true, + }, + 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, + }, + }, + cursor: undefined, + }, + responseBody: { + ...gongApiResponse.postCallsExtensive, + calls: [ + { + metaData: { + ...gongApiResponse.postCallsExtensive.calls[0].metaData, + id: '3662366901393371750', + }, + }, + ], + }, + }, + { + 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, + }, + }, + cursor: + 'eyJhbGciOiJIUzI1NiJ9.eyJjYWxsSWQiM1M30.6qKwpOcvnuweTZmFRzYdtjs_YwJphJU4QIwWFM', + }, + responseBody: { + ...gongApiResponse.postCallsExtensive, + records: {}, + calls: [ + { + metaData: { + ...gongApiResponse.postCallsExtensive.calls[0].metaData, + id: '3662366901393371751', + }, + }, + ], + }, + }, + ], + }, + }, + { + description: 'should create call', + 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: 'create', + actualStart: '={{ "2018-02-17T02:30:00-08:00" }}', + clientUniqueId: '123abc', + parties: { + partyFields: [ + { + phoneNumber: '+1 123-567-8989', + emailAddress: 'test@test.com', + name: 'Test User', + partyId: '1', + userId: '234599484848423', + }, + ], + }, + primaryUser: '234599484848423', + additionalFields: { + callProviderCode: 'clearslide', + customData: 'Optional data', + disposition: 'No Answer', + downloadMediaUrl: 'https://upload-server.com/sample-call.mp3', + duration: 125.8, + languageCode: 'string', + meetingUrl: 'https://www.conference.com/john.smith', + purpose: 'Demo Call', + scheduledEnd: '={{ "2018-02-19T02:30:00-08:00" }}', + scheduledStart: '={{ "2018-02-17T02:30:00-08:00" }}', + title: 'Example call', + workspaceId: '623457276584334', + }, + 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.createCall], + }, + }, + nock: { + baseUrl, + mocks: [ + { + method: 'post', + path: '/v2/calls', + statusCode: 200, + requestBody: { + actualStart: '2018-02-17T10:30:00.000Z', + callProviderCode: 'clearslide', + clientUniqueId: '123abc', + customData: 'Optional data', + direction: 'Inbound', + disposition: 'No Answer', + downloadMediaUrl: 'https://upload-server.com/sample-call.mp3', + duration: 125.8, + languageCode: 'string', + meetingUrl: 'https://www.conference.com/john.smith', + parties: [ + { + emailAddress: 'test@test.com', + mediaChannelId: 0, + name: 'Test User', + partyId: '1', + phoneNumber: '+1 123-567-8989', + userId: '234599484848423', + }, + ], + primaryUser: '234599484848423', + purpose: 'Demo Call', + scheduledEnd: '2018-02-19T10:30:00.000Z', + scheduledStart: '2018-02-17T10:30:00.000Z', + title: 'Example call', + workspaceId: '623457276584334', + }, + responseBody: gongApiResponse.postCalls, + }, + ], + }, + }, + { + description: 'should create call media', + 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: 'createMedia', + call: { + __rl: true, + value: '7782342274025937895', + mode: 'id', + }, + binaryPropertyName: 'data', + requestOptions: {}, + }, + id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', + name: 'Gong', + type: 'n8n-nodes-base.gong', + typeVersion: 1, + position: [1220, 380], + credentials: { + gongApi: { + id: 'fH4Sg82VCJi3JaWm', + name: 'Gong account', + }, + }, + }, + { + parameters: { + mode: 'runOnceForEachItem', + jsCode: + "const myBuffer = Buffer.from('dummy-image', 'base64');\n\n$input.item.binary = {\n data: await this.helpers.prepareBinaryData(myBuffer, 'image.png')\n};\n\nreturn $input.item;", + }, + id: 'c5b2967d-8f11-46f6-b516-e3ac051f542e', + name: 'File', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [1020, 380], + }, + ], + connections: { + "When clicking 'Test workflow'": { + main: [ + [ + { + node: 'File', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + File: { + main: [ + [ + { + node: 'Gong', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start'], + nodeData: { + Gong: [gongNodeResponse.createCallMedia], + }, + }, + nock: { + baseUrl, + mocks: [ + { + method: 'put', + path: '/v2/calls/7782342274025937895/media', + statusCode: 200, + requestBody: (body) => { + const buffer = Buffer.from(body as string, 'hex'); + const decodedString = buffer.toString('utf-8'); + const regex = + /Content-Disposition:\s*form-data;\s*name="mediaFile";\s*filename="([^"]+)"/; + return !!regex.exec(decodedString); + }, + responseBody: gongApiResponse.postCallsMedia, + }, + ], + }, + }, + ]; + + 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('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, + filter: { + 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' }], + }, + }, + ], + }, + }, + ]; + + console.log('temp1'); + const nodeTypes = Helpers.setup(tests); + + test.each(tests)('$description', async (testData) => { + const { result } = await executeWorkflow(testData, nodeTypes); + console.log('temp2'); + 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..10fbcbed940cd --- /dev/null +++ b/packages/nodes-base/nodes/Gong/test/mocks.ts @@ -0,0 +1,698 @@ +/* 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 = { + createCall: [ + { + json: { + requestId: '4al018gzaztcr8nbukw', + callId: '7782342274025937895', + }, + }, + ], + createCallMedia: [ + { + json: { + requestId: '4al018gzaztcr8nbukw', + callId: '7782342274025937895', + url: 'https://app.gong.io/call?id=7782342274025937895', + }, + }, + ], + 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: { + metaData: { + id: '3662366901393371750', + 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', + }, + }, + }, + { + json: { + metaData: { + id: '3662366901393371751', + 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/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 b63a549300f92..7f4e629548d5f 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2295,7 +2295,7 @@ export interface WorkflowTestData { nock?: { baseUrl: string; mocks: Array<{ - method: 'get' | 'post'; + method: 'delete' | 'get' | 'post' | 'put'; path: string; requestBody?: RequestBodyMatcher; statusCode: number; From 9fed6fd469ec46cd7592ecaf8e4b5f1ff512f8f2 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 17 Sep 2024 22:00:57 +0200 Subject: [PATCH 04/24] review improvements --- .../Gong/descriptions/CallDescription.ts | 1053 +++++------------ .../Gong/descriptions/UserDescription.ts | 58 +- .../nodes/Gong/test/Gong.node.test.ts | 259 ++-- 3 files changed, 399 insertions(+), 971 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index f5ab0e4c71eb8..cd8a5fa8fe9e4 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -6,7 +6,6 @@ import type { INodeExecutionData, INodeProperties, } from 'n8n-workflow'; -import FormData from 'form-data'; import { getCursorPaginator, gongApiPaginateRequest } from '../GenericFunctions'; @@ -22,24 +21,6 @@ export const callOperations: INodeProperties[] = [ }, }, options: [ - { - name: 'Create', - value: 'create', - description: 'Add new call', - routing: { - request: { - method: 'POST', - url: '/v2/calls', - }, - }, - action: 'Create call', - }, - { - name: 'Create Media', - value: 'createMedia', - description: 'Adds a call media', - action: 'Add call media', - }, { name: 'Get', value: 'get', @@ -50,553 +31,25 @@ export const callOperations: INodeProperties[] = [ url: '/v2/calls/extensive', }, }, - action: 'Get call', - }, - { - name: 'Get Many', - value: 'getAll', - description: 'List calls that took place during a specified date range', - routing: { - request: { - method: 'POST', - url: '/v2/calls/extensive', - body: { - filter: {}, - }, - }, - }, - action: 'Get many calls', - }, - ], - default: 'getAll', - }, -]; - -const createFields: INodeProperties[] = [ - { - displayName: 'Actual Start', - name: 'actualStart', - default: '', - description: - "The actual date and time when the call started in the ISO-8601 format (e.g., '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC);", - displayOptions: { - show: { - resource: ['call'], - operation: ['create'], - }, - }, - required: true, - routing: { - send: { - type: 'body', - property: 'actualStart', - value: '={{ new Date($value).toISOString() }}', - }, - }, - type: 'dateTime', - }, - { - displayName: 'Client Unique ID', - name: 'clientUniqueId', - default: '', - description: - "A call's unique identifier in the PBX or the recording system. Gong uses this identifier to prevent repeated attempts to upload the same recording.", - displayOptions: { - show: { - resource: ['call'], - operation: ['create'], - }, - }, - required: true, - routing: { - send: { - type: 'body', - property: 'clientUniqueId', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Direction', - name: 'direction', - default: 'Inbound', - description: - 'Whether the call is inbound (someone called the company), outbound (a rep dialed someone outside the company), or a conference call', - displayOptions: { - show: { - resource: ['call'], - operation: ['create'], - }, - }, - options: [ - { - name: 'Inbound', - value: 'Inbound', - }, - { - name: 'Outbound', - value: 'Outbound', - }, - { - name: 'Conference', - value: 'Conference', - }, - { - name: 'Unknown', - value: 'Unknown', - }, - ], - required: true, - routing: { - send: { - type: 'body', - property: 'direction', - value: '={{ $value }}', - }, - }, - type: 'options', - }, - { - displayName: 'Parties', - name: 'parties', - default: [], - description: "A list of the call's participants. A party must be provided for the primaryUser.", - displayOptions: { - show: { - resource: ['call'], - operation: ['create'], - }, - }, - options: [ - { - displayName: 'Party Fields', - name: 'partyFields', - values: [ - // Fields not included: context - { - displayName: 'Phone Number', - name: 'phoneNumber', - default: '', - description: 'The phone number of the party, if available', - routing: { - send: { - type: 'body', - property: '=parties[{{$index}}].phoneNumber', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Email Address', - name: 'emailAddress', - default: '', - description: 'The email address of the party, if available', - routing: { - send: { - type: 'body', - property: '=parties[{{$index}}].emailAddress', - value: '={{ $value || null }}', - }, - }, - type: 'string', - }, - { - displayName: 'Name', - name: 'name', - default: '', - description: 'The name of the party, if available', - routing: { - send: { - type: 'body', - property: '=parties[{{$index}}].name', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Party ID', - name: 'partyId', - default: '', - description: - 'An identifier that is only required when speakersTimeline is provided. The partyId is used to recognize the speakers within the provided speakersTimeline.', - routing: { - send: { - type: 'body', - property: '=parties[{{$index}}].partyId', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Media Channel ID', - name: 'mediaChannelId', - default: 0, - description: - 'The audio channel corresponding to the company team member (rep) used when the uploaded media file is multi-channel (stereo). The channel ID is either 0 or 1 (representing left or right respectively).', - options: [ - { - name: 'Left', - value: 0, - }, - { - name: 'Right', - value: 1, - }, - ], - routing: { - send: { - type: 'body', - property: '=parties[{{$index}}].mediaChannelId', - value: '={{ $value }}', - }, - }, - type: 'options', - }, - { - displayName: 'User ID', - name: 'userId', - default: '', - description: - 'The user ID of the participant within the Gong system, if the participant is a user', - routing: { - send: { - type: 'body', - property: '=parties[{{$index}}].userId', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - ], - }, - ], - required: true, - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - }, - { - displayName: 'Primary User', - name: 'primaryUser', - default: '', - description: 'The Gong internal user ID of the team member who hosted the call', - displayOptions: { - show: { - resource: ['call'], - operation: ['create'], - }, - }, - required: true, - routing: { - send: { - type: 'body', - property: 'primaryUser', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - default: {}, - displayOptions: { - show: { - resource: ['call'], - operation: ['create'], - }, - }, - options: [ - // Fields not included: context, speakersTimeline - { - displayName: 'Call Provider Code', - name: 'callProviderCode', - default: '', - description: - 'The code identifies the provider conferencing or telephony system. For example: zoom, clearslide, gotomeeting, ringcentral, outreach, insidesales, etc. These values are predefined by Gong, please contact help@gong.io to find the proper value for your system.', - routing: { - send: { - type: 'body', - property: 'callProviderCode', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Custom Data', - name: 'customData', - default: '', - description: - 'Optional metadata associated with the call (represented as text). Gong stores this metadata and it can be used for troubleshooting.', - routing: { - send: { - type: 'body', - property: 'customData', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Disposition', - name: 'disposition', - default: '', - description: - 'The disposition of the call. The disposition is free text of up to 255 characters.', - routing: { - send: { - type: 'body', - property: 'disposition', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Download Media Url', - name: 'downloadMediaUrl', - default: '', - description: - "The URL from which Gong can download the media file.The URL must be unique, the audio or video file must be a maximum of 1.5GB.If you provide this URL, you should not perform the 'Add call media' step", - routing: { - send: { - type: 'body', - property: 'downloadMediaUrl', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Duration', - name: 'duration', - default: 0, - description: 'The actual call duration in seconds', - routing: { - send: { - type: 'body', - property: 'duration', - value: '={{ $value }}', - }, - }, - type: 'number', - }, - { - displayName: 'Language Code', - name: 'languageCode', - default: '', - description: - 'The language code the call should be transcribed to.This field is optional as Gong automatically detects the language spoken in the call and transcribes it accordingly. Set this field only if you are sure of the language the call is in.', - routing: { - send: { - type: 'body', - property: 'languageCode', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Meeting URL', - name: 'meetingUrl', - default: '', - description: 'The URL of the conference call by which users join the meeting', - routing: { - send: { - type: 'body', - property: 'meetingUrl', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Purpose', - name: 'purpose', - default: '', - description: - 'The purpose of the call. This optional field is a free text of up to 255 characters.', - routing: { - send: { - type: 'body', - property: 'purpose', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Scheduled End', - name: 'scheduledEnd', - default: '', - description: - "The date and time the call was scheduled to end in the ISO-8601 format (e.g., '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC);", - routing: { - send: { - type: 'body', - property: 'scheduledEnd', - value: '={{ new Date($value).toISOString() }}', - }, - }, - type: 'dateTime', - }, - { - displayName: 'Scheduled Start', - name: 'scheduledStart', - default: '', - description: - "The date and time the call was scheduled to begin in the ISO-8601 format (e.g., '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC);", - routing: { - send: { - type: 'body', - property: 'scheduledStart', - value: '={{ new Date($value).toISOString() }}', - }, - }, - type: 'dateTime', - }, - { - displayName: 'Title', - name: 'title', - default: '', - description: - 'The title of the call. This title is available in the Gong system for indexing and search.', - routing: { - send: { - type: 'body', - property: 'title', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - { - displayName: 'Workspace ID', - name: 'workspaceId', - default: '', - description: - 'Optional workspace identifier. If specified, the call will be placed into this workspace, otherwise, the default algorithm for workspace placement will be applied.', - routing: { - send: { - type: 'body', - property: 'workspaceId', - value: '={{ $value }}', - }, - }, - type: 'string', - }, - ], - placeholder: 'Add Field', - type: 'collection', - }, -]; - -const createMediaFields: INodeProperties[] = [ - { - displayName: 'Call to Get', - name: 'call', - default: { - mode: 'id', - value: '', - }, - displayOptions: { - show: { - resource: ['call'], - operation: ['createMedia'], - }, - }, - modes: [ - { - displayName: 'From List', - name: 'list', - type: 'list', - typeOptions: { - searchListMethod: 'getCalls', - searchable: true, - }, + action: 'Get call', }, { - displayName: 'By ID', - name: 'id', - type: 'string', - validation: [ - { - type: 'regex', - properties: { - regex: '[0-9]{1,20}', - errorMessage: 'Not a valid Gong Call ID', + name: 'Get Many', + value: 'getAll', + description: 'Retrieve a list of calls', + routing: { + request: { + method: 'POST', + url: '/v2/calls/extensive', + body: { + filter: {}, }, }, - ], - }, - { - displayName: 'By URL', - name: 'url', - type: 'string', - placeholder: 'https://subdomain.app.gong.io/call?id=123456789', - extractValue: { - type: 'regex', - regex: 'https:\\/\\/[a-zA-Z0-9-]+\\.app\\.gong\\.io\\/call\\?id=([0-9]{1,20})', }, - 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', - }, - }, - ], + action: 'Get many calls', }, ], - required: true, - routing: { - request: { - method: 'PUT', - url: '=/v2/calls/{{$value}}/media', - }, - }, - type: 'resourceLocator', - }, - { - displayName: 'Input Binary Field', - name: 'binaryPropertyName', - default: '', - description: 'The name of the incoming field containing the binary file data to be processed', - displayOptions: { - show: { - resource: ['call'], - operation: ['createMedia'], - }, - }, - required: true, - routing: { - send: { - preSend: [ - async function (this: IExecuteSingleFunctions, requestOptions: IHttpRequestOptions) { - const binaryPropertyName = this.getNodeParameter('binaryPropertyName') as string; - const binaryData = this.helpers.assertBinaryData(binaryPropertyName); - const binaryDataBuffer = await this.helpers.getBinaryDataBuffer(binaryPropertyName); - - const formData = new FormData(); - formData.append('mediaFile', binaryDataBuffer, { - filename: binaryData.fileName, - contentType: binaryData.mimeType, - }); - requestOptions.body = formData; - - return requestOptions; - }, - ], - }, - }, - type: 'string', + default: 'getAll', }, ]; @@ -605,7 +58,7 @@ const getFields: INodeProperties[] = [ displayName: 'Call to Get', name: 'call', default: { - mode: 'id', + mode: 'list', value: '', }, displayOptions: { @@ -627,6 +80,7 @@ const getFields: INodeProperties[] = [ { displayName: 'By ID', name: 'id', + placeholder: 'e.g. 7782342274025937895', type: 'string', validation: [ { @@ -641,12 +95,12 @@ const getFields: INodeProperties[] = [ { displayName: 'By URL', name: 'url', - type: 'string', - placeholder: 'https://subdomain.app.gong.io/call?id=123456789', 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', @@ -692,178 +146,130 @@ const getFields: INodeProperties[] = [ placeholder: 'Add option', options: [ { - displayName: 'Action Items', - name: 'pointsOfInterest', - default: false, - description: 'Whether to add call points of interest', - routing: { - send: { - type: 'body', - property: 'contentSelector.exposedFields.content.pointsOfInterest', - propertyInDotNotation: true, - value: '={{ $value }}', - }, - }, - type: 'boolean', - }, - { - displayName: 'Audio and Video URLs', - name: 'media', - default: false, + displayName: 'Call Data to Include', + name: 'properties', + type: 'multiOptions', + default: [], description: - 'Whether to add audio and video URL of the call. The URLs will be available for 8 hours.', - routing: { - send: { - type: 'body', - property: 'contentSelector.exposedFields.media', - propertyInDotNotation: true, - value: '={{ $value }}', + 'Whether to include specific Call properties in the returned results. Choose from a list, or specify IDs using an expression. Choose from the list, or specify IDs using an expression.', + options: [ + { + name: 'Action Items', + value: 'pointsOfInterest', + description: 'Whether to add call points of interest', }, - }, - type: 'boolean', - }, - { - displayName: 'Brief', - name: 'brief', - default: false, - description: 'Whether to add the spotlight call brief', - routing: { - send: { - type: 'body', - property: 'contentSelector.exposedFields.content.brief', - propertyInDotNotation: true, - value: '={{ $value }}', + { + name: 'Audio and Video URLs', + value: 'media', + description: + 'Whether to add audio and video URL of the call. The URLs will be available for 8 hours.', }, - }, - type: 'boolean', - }, - { - displayName: 'Comments', - name: 'publicComments', - default: false, - description: 'Whether to add public comments made for this call', - routing: { - send: { - type: 'body', - property: 'contentSelector.exposedFields.collaboration.publicComments', - propertyInDotNotation: true, - value: '={{ $value }}', + { + name: 'Brief', + value: 'brief', + description: 'Whether to add the spotlight call brief', + routing: { + send: { + type: 'body', + property: 'contentSelector.exposedFields.content.brief', + propertyInDotNotation: true, + value: '={{ $value }}', + }, + }, }, - }, - type: 'boolean', - }, - { - displayName: 'Highlights', - name: 'highlights', - default: false, - description: 'Whether to add the call highlights', - routing: { - send: { - type: 'body', - property: 'contentSelector.exposedFields.content.highlights', - propertyInDotNotation: true, - value: '={{ $value }}', + { + name: 'Comments', + value: 'publicComments', + description: 'Whether to add public comments made for this call', }, - }, - type: 'boolean', - }, - { - displayName: 'Keypoints', - name: 'keyPoints', - default: false, - description: 'Whether to add the key points of the call', - routing: { - send: { - type: 'body', - property: 'contentSelector.exposedFields.content.keyPoints', - propertyInDotNotation: true, - value: '={{ $value }}', + { + name: 'Highlights', + value: 'highlights', + description: 'Whether to add the call highlights', }, - }, - type: 'boolean', - }, - { - displayName: 'Outline', - name: 'outline', - default: false, - description: 'Whether to add the call outline', - routing: { - send: { - type: 'body', - property: 'contentSelector.exposedFields.content.outline', - propertyInDotNotation: true, - value: '={{ $value }}', + { + name: 'Keypoints', + value: 'keyPoints', + description: 'Whether to add the key points of the call', }, - }, - type: 'boolean', - }, - { - displayName: 'Outcome', - name: 'callOutcome', - default: false, - description: 'Whether to add the outcome of the call', - routing: { - send: { - type: 'body', - property: 'contentSelector.exposedFields.content.callOutcome', - propertyInDotNotation: true, - value: '={{ $value }}', + { + name: 'Outcome', + value: 'callOutcome', + description: 'Whether to add the outcome of the call', }, - }, - type: 'boolean', - }, - { - displayName: 'Participants', - name: 'parties', - default: false, - description: 'Whether to add information about the parties of the call', - routing: { - send: { - type: 'body', - property: 'contentSelector.exposedFields.parties', - propertyInDotNotation: true, - value: '={{ $value }}', + { + name: 'Outline', + value: 'outline', + description: 'Whether to add the call outline', }, - }, - type: 'boolean', - }, - { - displayName: 'Structure', - name: 'structure', - default: false, - description: 'Whether to add the call agenda', - routing: { - send: { - type: 'body', - property: 'contentSelector.exposedFields.content.structure', - propertyInDotNotation: true, - value: '={{ $value }}', + { + name: 'Participants', + value: 'parties', + description: 'Whether to add information about the participants of the call', }, - }, - type: 'boolean', - }, - { - displayName: 'Trackers', - name: 'Trackers', - default: false, - description: - 'Whether to add the smart tracker and keyword tracker information for the call', + { + name: 'Structure', + value: 'structure', + description: 'Whether to add the call agenda', + }, + { + name: 'Topics', + value: 'topics', + description: 'Whether to add the duration of call topics', + }, + { + name: 'Trackers', + value: 'trackers', + description: + 'Whether to add the smart tracker and keyword tracker information for the call', + }, + { + name: 'Transcript', + value: 'transcript', + description: 'Whether to add information about the participants', + }, + ], routing: { send: { - type: 'body', - property: 'contentSelector.exposedFields.content.trackers', - propertyInDotNotation: true, - value: '={{ $value }}', + 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; + }, + ], }, - }, - type: 'boolean', - }, - { - displayName: 'Transcript', - name: 'transcript', - default: false, - description: 'Whether to add information about the participants', - routing: { output: { postReceive: [ async function ( @@ -871,41 +277,28 @@ const getFields: INodeProperties[] = [ items: INodeExecutionData[], _responseData: IN8nHttpFullResponse, ): Promise { - 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 - : []; + 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; }, ], }, }, - type: 'boolean', - }, - { - displayName: 'Topics', - name: 'topics', - default: false, - description: 'Whether to add the duration of call topics', - routing: { - send: { - type: 'body', - property: 'contentSelector.exposedFields.content.topics', - propertyInDotNotation: true, - value: '={{ $value }}', - }, - }, - type: 'boolean', }, ], type: 'collection', @@ -982,36 +375,29 @@ const getAllFields: INodeProperties[] = [ placeholder: 'Add Filter', options: [ { - displayName: 'From', + displayName: 'After', name: 'fromDateTime', default: '', description: - "Date and time (in ISO-8601 format: '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC) from which to list recorded calls. 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.", + '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() }}', - preSend: [ - async function ( - this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, - ): Promise { - return requestOptions; - }, - ], }, }, - type: 'dateTime', }, { - displayName: 'To', + displayName: 'Before', name: 'toDateTime', default: '', description: - "Date and time (in ISO-8601 format: '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC) until which to list recorded calls. 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.", + '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', @@ -1027,6 +413,7 @@ const getAllFields: INodeProperties[] = [ name: 'workspaceId', default: '', description: 'Return only the calls belonging to this workspace', + placeholder: 'e.g. 623457276584334', routing: { send: { type: 'body', @@ -1040,36 +427,98 @@ const getAllFields: INodeProperties[] = [ { displayName: 'Call IDs', name: 'callIds', - default: '', + default: { + mode: 'list', + value: '', + }, description: 'List of calls IDs to be filtered', - hint: 'Comma separated list of IDs, array of strings can be set in expression', + 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', + }, + }, + ], + }, + ], + required: true, routing: { send: { type: 'body', property: 'filter.callIds', propertyInDotNotation: true, - value: - '={{ Array.isArray($value) ? $value.map(x => x.toString()) : $value.split(",").map(x => x.trim()) }}', + value: '={{ $value.flatMap(x => x.value) }}', }, }, - type: 'string', + type: 'resourceLocator', + typeOptions: { + multipleValues: true, + }, }, { displayName: 'User IDs', name: 'primaryUserIds', - default: '', + default: { + mode: 'list', + value: '', + }, description: 'Return only the calls hosted by the specified users', - hint: 'Comma separated list of IDs, array of strings can be set in expression', + 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', + }, + }, + ], + }, + ], + required: true, routing: { send: { type: 'body', property: 'filter.primaryUserIds', propertyInDotNotation: true, - value: - '={{ Array.isArray($value) ? $value.map(x => x.toString()) : $value.split(",").map(x => x.trim()) }}', + value: '={{ $value.flatMap(x => x.value) }}', }, }, - type: 'string', + type: 'resourceLocator', + typeOptions: { + multipleValues: true, + }, }, ], type: 'collection', @@ -1087,28 +536,56 @@ const getAllFields: INodeProperties[] = [ placeholder: 'Add option', options: [ { - displayName: 'Participants', - name: 'parties', - default: false, - description: 'Whether to add information about the parties of the call', + displayName: 'Call Data to Include', + name: 'properties', + type: 'multiOptions', + default: [], + description: + 'Whether to include specific Call properties in the returned results. Choose from a list, or specify IDs using an expression. Choose from the list, or specify IDs using an expression.', + options: [ + { + name: 'Participants', + value: 'parties', + description: 'Whether to add information about the participants of the call', + }, + { + name: 'Topics', + value: 'topics', + description: 'Whether to add information about the topics of the call', + }, + ], routing: { send: { - type: 'body', - property: 'contentSelector.exposedFields.parties', - propertyInDotNotation: true, - value: '={{ $value }}', + 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; + }, + ], }, }, - type: 'boolean', }, ], type: 'collection', }, ]; -export const callFields: INodeProperties[] = [ - ...createFields, - ...createMediaFields, - ...getFields, - ...getAllFields, -]; +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 index 62938753a3570..128f04f2326ba 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts @@ -17,7 +17,7 @@ export const userOperations: INodeProperties[] = [ { name: 'Get', value: 'get', - description: 'Retrieve a specific user', + description: 'Retrieve data for a specific user', action: 'Get user', routing: { request: { @@ -29,7 +29,7 @@ export const userOperations: INodeProperties[] = [ { name: 'Get Many', value: 'getAll', - description: 'List multiple users', + description: 'Retrieve a list of users', action: 'Get many users', routing: { request: { @@ -51,7 +51,7 @@ const getOperation: INodeProperties[] = [ displayName: 'User to Get', name: 'user', default: { - mode: 'id', + mode: 'list', value: '', }, displayOptions: { @@ -74,6 +74,7 @@ const getOperation: INodeProperties[] = [ { displayName: 'By ID', name: 'id', + placeholder: 'e.g. 7782342274025937895', type: 'string', validation: [ { @@ -177,11 +178,12 @@ const getAllOperation: INodeProperties[] = [ }, options: [ { - displayName: 'Created From', + 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. The filed is in the ISO-8601 format (e.g., '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC).", + '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', @@ -193,11 +195,12 @@ const getAllOperation: INodeProperties[] = [ type: 'dateTime', }, { - displayName: 'Created To', + 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. The filed is in the ISO-8601 format (e.g., '2018-02-18T02:30:00-07:00' or '2018-02-18T08:00:00Z', where Z stands for UTC).", + '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', @@ -211,19 +214,50 @@ const getAllOperation: INodeProperties[] = [ { displayName: 'User IDs', name: 'userIds', - default: '', + default: { + mode: 'list', + value: '', + }, 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', + 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', + }, + }, + ], + }, + ], + required: true, routing: { send: { type: 'body', property: 'filter.userIds', propertyInDotNotation: true, - value: - '={{ Array.isArray($value) ? $value.map(x => x.toString()) : $value.split(",").map(x => x.trim()) }}', + value: '={{ $value.flatMap(x => x.value) }}', }, }, - type: 'string', + type: 'resourceLocator', + typeOptions: { + multipleValues: true, + }, }, ], placeholder: 'Add Filter', diff --git a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts index 27be324e55251..3709c965b12a2 100644 --- a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts +++ b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts @@ -305,19 +305,21 @@ describe('Gong Node', () => { mode: 'id', }, options: { - pointsOfInterest: true, - media: true, - brief: true, - publicComments: true, - highlights: true, - keyPoints: true, - outline: true, - callOutcome: true, - parties: true, - structure: true, - Trackers: true, - transcript: true, - topics: true, + properties: [ + 'pointsOfInterest', + 'transcript', + 'media', + 'brief', + 'publicComments', + 'highlights', + 'trackers', + 'topics', + 'structure', + 'parties', + 'callOutcome', + 'outline', + 'keyPoints', + ], }, requestOptions: {}, }, @@ -426,11 +428,28 @@ describe('Gong Node', () => { fromDateTime: '2024-01-01T00:00:00Z', toDateTime: '2024-12-31T00:00:00Z', workspaceId: '3662366901393371750', - callIds: '3662366901393371750,3662366901393371751', - primaryUserIds: '234599484848423', + callIds: [ + { + __rl: true, + value: '3662366901393371750', + mode: 'id', + }, + { + __rl: true, + value: '3662366901393371751', + mode: 'id', + }, + ], + primaryUserIds: [ + { + __rl: true, + value: '234599484848423', + mode: 'id', + }, + ], }, options: { - parties: true, + properties: ['parties', 'topics'], }, requestOptions: {}, }, @@ -486,6 +505,9 @@ describe('Gong Node', () => { contentSelector: { exposedFields: { parties: true, + content: { + topics: true, + }, }, }, cursor: undefined, @@ -517,6 +539,9 @@ describe('Gong Node', () => { contentSelector: { exposedFields: { parties: true, + content: { + topics: true, + }, }, }, cursor: @@ -539,7 +564,7 @@ describe('Gong Node', () => { }, }, { - description: 'should create call', + description: 'should get all calls with multiple id filters', input: { workflowData: { nodes: [ @@ -553,35 +578,32 @@ describe('Gong Node', () => { }, { parameters: { - operation: 'create', - actualStart: '={{ "2018-02-17T02:30:00-08:00" }}', - clientUniqueId: '123abc', - parties: { - partyFields: [ + filters: { + callIds: [ + { + __rl: true, + value: '=3662366901393371750', + mode: 'id', + }, { - phoneNumber: '+1 123-567-8989', - emailAddress: 'test@test.com', - name: 'Test User', - partyId: '1', - userId: '234599484848423', + __rl: true, + value: "={{ '3662366901393371751' }}", + mode: 'id', + }, + { + __rl: true, + value: "={{ ['3662366901393371752','3662366901393731753'] }}", + mode: 'id', + }, + { + __rl: true, + value: '3662366901393731754', + mode: 'list', + cachedResultName: 'Call name', }, ], }, - primaryUser: '234599484848423', - additionalFields: { - callProviderCode: 'clearslide', - customData: 'Optional data', - disposition: 'No Answer', - downloadMediaUrl: 'https://upload-server.com/sample-call.mp3', - duration: 125.8, - languageCode: 'string', - meetingUrl: 'https://www.conference.com/john.smith', - purpose: 'Demo Call', - scheduledEnd: '={{ "2018-02-19T02:30:00-08:00" }}', - scheduledStart: '={{ "2018-02-17T02:30:00-08:00" }}', - title: 'Example call', - workspaceId: '623457276584334', - }, + options: {}, requestOptions: {}, }, id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', @@ -615,7 +637,7 @@ describe('Gong Node', () => { output: { nodeExecutionOrder: ['Start'], nodeData: { - Gong: [gongNodeResponse.createCall], + Gong: [[{ json: undefined }]], }, }, nock: { @@ -623,137 +645,21 @@ describe('Gong Node', () => { mocks: [ { method: 'post', - path: '/v2/calls', + path: '/v2/calls/extensive', statusCode: 200, requestBody: { - actualStart: '2018-02-17T10:30:00.000Z', - callProviderCode: 'clearslide', - clientUniqueId: '123abc', - customData: 'Optional data', - direction: 'Inbound', - disposition: 'No Answer', - downloadMediaUrl: 'https://upload-server.com/sample-call.mp3', - duration: 125.8, - languageCode: 'string', - meetingUrl: 'https://www.conference.com/john.smith', - parties: [ - { - emailAddress: 'test@test.com', - mediaChannelId: 0, - name: 'Test User', - partyId: '1', - phoneNumber: '+1 123-567-8989', - userId: '234599484848423', - }, - ], - primaryUser: '234599484848423', - purpose: 'Demo Call', - scheduledEnd: '2018-02-19T10:30:00.000Z', - scheduledStart: '2018-02-17T10:30:00.000Z', - title: 'Example call', - workspaceId: '623457276584334', - }, - responseBody: gongApiResponse.postCalls, - }, - ], - }, - }, - { - description: 'should create call media', - 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: 'createMedia', - call: { - __rl: true, - value: '7782342274025937895', - mode: 'id', - }, - binaryPropertyName: 'data', - requestOptions: {}, - }, - id: 'c87d72ec-0683-4e32-9829-5e6ea1d1ee7d', - name: 'Gong', - type: 'n8n-nodes-base.gong', - typeVersion: 1, - position: [1220, 380], - credentials: { - gongApi: { - id: 'fH4Sg82VCJi3JaWm', - name: 'Gong account', - }, - }, - }, - { - parameters: { - mode: 'runOnceForEachItem', - jsCode: - "const myBuffer = Buffer.from('dummy-image', 'base64');\n\n$input.item.binary = {\n data: await this.helpers.prepareBinaryData(myBuffer, 'image.png')\n};\n\nreturn $input.item;", - }, - id: 'c5b2967d-8f11-46f6-b516-e3ac051f542e', - name: 'File', - type: 'n8n-nodes-base.code', - typeVersion: 2, - position: [1020, 380], - }, - ], - connections: { - "When clicking 'Test workflow'": { - main: [ - [ - { - node: 'File', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - File: { - main: [ - [ - { - node: 'Gong', - type: NodeConnectionType.Main, - index: 0, - }, + filter: { + callIds: [ + '3662366901393371750', + '3662366901393371751', + '3662366901393371752', + '3662366901393731753', + '3662366901393731754', ], - ], + }, + cursor: undefined, }, - }, - }, - }, - output: { - nodeExecutionOrder: ['Start'], - nodeData: { - Gong: [gongNodeResponse.createCallMedia], - }, - }, - nock: { - baseUrl, - mocks: [ - { - method: 'put', - path: '/v2/calls/7782342274025937895/media', - statusCode: 200, - requestBody: (body) => { - const buffer = Buffer.from(body as string, 'hex'); - const decodedString = buffer.toString('utf-8'); - const regex = - /Content-Disposition:\s*form-data;\s*name="mediaFile";\s*filename="([^"]+)"/; - return !!regex.exec(decodedString); - }, - responseBody: gongApiResponse.postCallsMedia, + responseBody: {}, }, ], }, @@ -868,7 +774,18 @@ describe('Gong Node', () => { filter: { createdFromDateTime: '2024-01-01T00:00:00Z', createdToDateTime: '2024-12-31T00:00:00Z', - userIds: '234599484848423,234599484848424', + userIds: [ + { + __rl: true, + value: '234599484848423', + mode: 'id', + }, + { + __rl: true, + value: '234599484848424', + mode: 'id', + }, + ], }, requestOptions: {}, }, From 6acfaecd7749f298f9f053006645bcb69f84d793 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Thu, 26 Sep 2024 09:56:09 +0200 Subject: [PATCH 05/24] review improvements --- .../Gong/descriptions/CallDescription.ts | 115 ++++++++---------- .../Gong/descriptions/UserDescription.ts | 42 +------ .../nodes/Gong/test/Gong.node.test.ts | 83 +++---------- 3 files changed, 75 insertions(+), 165 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index cd8a5fa8fe9e4..f52e157af67d5 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -5,7 +5,9 @@ import type { IN8nHttpFullResponse, INodeExecutionData, INodeProperties, + JsonObject, } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; import { getCursorPaginator, gongApiPaginateRequest } from '../GenericFunctions'; @@ -44,6 +46,25 @@ export const callOperations: INodeProperties[] = [ body: { filter: {}, }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [ + async function ( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, + ): Promise { + const userId = this.getNodeParameter( + 'filters.primaryUserIds', + undefined, + ) as IDataObject; + if (userId && response.statusCode === 404) { + return [{ json: { success: true } }]; + } + return data; + }, + ], }, }, action: 'Get many calls', @@ -143,7 +164,6 @@ const getFields: INodeProperties[] = [ operation: ['get'], }, }, - placeholder: 'Add option', options: [ { displayName: 'Call Data to Include', @@ -151,23 +171,22 @@ const getFields: INodeProperties[] = [ type: 'multiOptions', default: [], description: - 'Whether to include specific Call properties in the returned results. Choose from a list, or specify IDs using an expression. Choose from the list, or specify IDs using an expression.', + '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: 'Whether to add call points of interest', + description: 'Call points of interest', }, { name: 'Audio and Video URLs', value: 'media', - description: - 'Whether to add audio and video URL of the call. The URLs will be available for 8 hours.', + description: 'Audio and video URL of the call. The URLs will be available for 8 hours.', }, { name: 'Brief', value: 'brief', - description: 'Whether to add the spotlight call brief', + description: 'Spotlight call brief', routing: { send: { type: 'body', @@ -180,53 +199,52 @@ const getFields: INodeProperties[] = [ { name: 'Comments', value: 'publicComments', - description: 'Whether to add public comments made for this call', + description: 'Public comments made for this call', }, { name: 'Highlights', value: 'highlights', - description: 'Whether to add the call highlights', + description: 'Call highlights', }, { name: 'Keypoints', value: 'keyPoints', - description: 'Whether to add the key points of the call', + description: 'Key points of the call', }, { name: 'Outcome', value: 'callOutcome', - description: 'Whether to add the outcome of the call', + description: 'Outcome of the call', }, { name: 'Outline', value: 'outline', - description: 'Whether to add the call outline', + description: 'Call outline', }, { name: 'Participants', value: 'parties', - description: 'Whether to add information about the participants of the call', + description: 'Information about the participants of the call', }, { name: 'Structure', value: 'structure', - description: 'Whether to add the call agenda', + description: 'Call agenda', }, { name: 'Topics', value: 'topics', - description: 'Whether to add the duration of call topics', + description: 'Duration of call topics', }, { name: 'Trackers', value: 'trackers', - description: - 'Whether to add the smart tracker and keyword tracker information for the call', + description: 'Smart tracker and keyword tracker information for the call', }, { name: 'Transcript', value: 'transcript', - description: 'Whether to add information about the participants', + description: 'Information about the participants', }, ], routing: { @@ -301,6 +319,7 @@ const getFields: INodeProperties[] = [ }, }, ], + placeholder: 'Add Option', type: 'collection', }, ]; @@ -372,7 +391,6 @@ const getAllFields: INodeProperties[] = [ operation: ['getAll'], }, }, - placeholder: 'Add Filter', options: [ { displayName: 'After', @@ -427,59 +445,29 @@ const getAllFields: INodeProperties[] = [ { displayName: 'Call IDs', name: 'callIds', - default: { - mode: 'list', - value: '', - }, + default: '', description: 'List of calls IDs to be filtered', - 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', - }, - }, - ], - }, - ], - required: true, + hint: 'Comma separated list of IDs, array of strings can be set in expression', routing: { send: { type: 'body', property: 'filter.callIds', propertyInDotNotation: true, - value: '={{ $value.flatMap(x => x.value) }}', + value: + '={{ Array.isArray($value) ? $value.map(x => x.toString()) : $value.split(",").map(x => x.trim()) }}', }, }, - type: 'resourceLocator', - typeOptions: { - multipleValues: true, - }, + placeholder: 'e.g. 7782342274025937895', + type: 'string', }, { - displayName: 'User IDs', + displayName: 'Organizer', name: 'primaryUserIds', default: { mode: 'list', value: '', }, - description: 'Return only the calls hosted by the specified users', + description: 'Return only the calls hosted by the specified user', modes: [ { displayName: 'From List', @@ -506,21 +494,18 @@ const getAllFields: INodeProperties[] = [ ], }, ], - required: true, routing: { send: { type: 'body', property: 'filter.primaryUserIds', propertyInDotNotation: true, - value: '={{ $value.flatMap(x => x.value) }}', + value: '={{ [$value] }}', }, }, type: 'resourceLocator', - typeOptions: { - multipleValues: true, - }, }, ], + placeholder: 'Add Filter', type: 'collection', }, { @@ -533,7 +518,6 @@ const getAllFields: INodeProperties[] = [ operation: ['getAll'], }, }, - placeholder: 'Add option', options: [ { displayName: 'Call Data to Include', @@ -541,17 +525,17 @@ const getAllFields: INodeProperties[] = [ type: 'multiOptions', default: [], description: - 'Whether to include specific Call properties in the returned results. Choose from a list, or specify IDs using an expression. Choose from the list, or specify IDs using an expression.', + '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: 'Whether to add information about the participants of the call', + description: 'Information about the participants of the call', }, { name: 'Topics', value: 'topics', - description: 'Whether to add information about the topics of the call', + description: 'Information about the topics of the call', }, ], routing: { @@ -584,6 +568,7 @@ const getAllFields: INodeProperties[] = [ }, }, ], + placeholder: 'Add Option', type: 'collection', }, ]; diff --git a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts index 128f04f2326ba..d43d9331d6355 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts @@ -214,50 +214,20 @@ const getAllOperation: INodeProperties[] = [ { displayName: 'User IDs', name: 'userIds', - default: { - mode: 'list', - value: '', - }, + default: '', description: "Set of Gong's unique numeric identifiers for the users (up to 20 digits)", - 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', - }, - }, - ], - }, - ], - required: true, + hint: 'Comma separated list of IDs, array of strings can be set in expression', routing: { send: { type: 'body', property: 'filter.userIds', propertyInDotNotation: true, - value: '={{ $value.flatMap(x => x.value) }}', + value: + '={{ Array.isArray($value) ? $value.map(x => x.toString()) : $value.split(",").map(x => x.trim()) }}', }, }, - type: 'resourceLocator', - typeOptions: { - multipleValues: true, - }, + placeholder: 'e.g. 7782342274025937895', + type: 'string', }, ], placeholder: 'Add Filter', diff --git a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts index 3709c965b12a2..b4210b8a56cc5 100644 --- a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts +++ b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts @@ -428,25 +428,12 @@ describe('Gong Node', () => { fromDateTime: '2024-01-01T00:00:00Z', toDateTime: '2024-12-31T00:00:00Z', workspaceId: '3662366901393371750', - callIds: [ - { - __rl: true, - value: '3662366901393371750', - mode: 'id', - }, - { - __rl: true, - value: '3662366901393371751', - mode: 'id', - }, - ], - primaryUserIds: [ - { - __rl: true, - value: '234599484848423', - mode: 'id', - }, - ], + callIds: "={{ ['3662366901393371750', '3662366901393371751'] }}", + primaryUserIds: { + __rl: true, + value: '234599484848423', + mode: 'id', + }, }, options: { properties: ['parties', 'topics'], @@ -564,7 +551,7 @@ describe('Gong Node', () => { }, }, { - description: 'should get all calls with multiple id filters', + description: 'should return empty result if no calls found for user', input: { workflowData: { nodes: [ @@ -579,29 +566,11 @@ describe('Gong Node', () => { { parameters: { filters: { - callIds: [ - { - __rl: true, - value: '=3662366901393371750', - mode: 'id', - }, - { - __rl: true, - value: "={{ '3662366901393371751' }}", - mode: 'id', - }, - { - __rl: true, - value: "={{ ['3662366901393371752','3662366901393731753'] }}", - mode: 'id', - }, - { - __rl: true, - value: '3662366901393731754', - mode: 'list', - cachedResultName: 'Call name', - }, - ], + primaryUserIds: { + __rl: true, + value: '234599484848423', + mode: 'id', + }, }, options: {}, requestOptions: {}, @@ -646,20 +615,17 @@ describe('Gong Node', () => { { method: 'post', path: '/v2/calls/extensive', - statusCode: 200, + statusCode: 400, requestBody: { filter: { - callIds: [ - '3662366901393371750', - '3662366901393371751', - '3662366901393371752', - '3662366901393731753', - '3662366901393731754', - ], + primaryUserIds: ['234599484848423'], }, cursor: undefined, }, - responseBody: {}, + responseBody: { + requestId: 'thrhbxbkqiw41ma1cl', + errors: ['No calls found corresponding to the provided filters'], + }, }, ], }, @@ -774,18 +740,7 @@ describe('Gong Node', () => { filter: { createdFromDateTime: '2024-01-01T00:00:00Z', createdToDateTime: '2024-12-31T00:00:00Z', - userIds: [ - { - __rl: true, - value: '234599484848423', - mode: 'id', - }, - { - __rl: true, - value: '234599484848424', - mode: 'id', - }, - ], + userIds: '234599484848423, 234599484848424', }, requestOptions: {}, }, From 9deb1ab3ba80307f5d6d4e89f9225e3d6478e9f2 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Thu, 26 Sep 2024 17:03:55 +0200 Subject: [PATCH 06/24] lint --- .../nodes-base/nodes/Gong/descriptions/CallDescription.ts | 2 -- packages/nodes-base/nodes/Gong/test/Gong.node.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index f52e157af67d5..325818e5177dc 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -5,9 +5,7 @@ import type { IN8nHttpFullResponse, INodeExecutionData, INodeProperties, - JsonObject, } from 'n8n-workflow'; -import { NodeApiError } from 'n8n-workflow'; import { getCursorPaginator, gongApiPaginateRequest } from '../GenericFunctions'; diff --git a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts index b4210b8a56cc5..3014556b8495a 100644 --- a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts +++ b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts @@ -5,11 +5,11 @@ import type { IHttpRequestOptions, } from 'n8n-workflow'; import { ExpressionEvaluatorProxy, NodeConnectionType } from 'n8n-workflow'; +import { FAKE_CREDENTIALS_DATA } from '../../../test/nodes/FakeCredentialsMap'; +import { gongApiResponse, gongNodeResponse } from './mocks'; import type { WorkflowTestData } from '@test/nodes/types'; import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; import * as Helpers from '@test/nodes/Helpers'; -import { gongApiResponse, gongNodeResponse } from './mocks'; -import { FAKE_CREDENTIALS_DATA } from '../../../test/nodes/FakeCredentialsMap'; describe('Gong Node', () => { const baseUrl = 'https://api.gong.io'; From dbe26b61e228ce154dbb3dbcddb53e2fe8c2e305 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Mon, 30 Sep 2024 10:33:08 +0200 Subject: [PATCH 07/24] fix fallback value --- .../Gong/descriptions/CallDescription.ts | 6 +- .../nodes/Gong/test/Gong.node.test.ts | 79 ++++++++++++++++++- packages/nodes-base/nodes/Gong/test/mocks.ts | 6 +- 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index 325818e5177dc..50b23bb3c03c1 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -53,11 +53,11 @@ export const callOperations: INodeProperties[] = [ data: INodeExecutionData[], response: IN8nHttpFullResponse, ): Promise { - const userId = this.getNodeParameter( + const primaryUserId = this.getNodeParameter( 'filters.primaryUserIds', - undefined, + null, ) as IDataObject; - if (userId && response.statusCode === 404) { + if (primaryUserId && response.statusCode === 404) { return [{ json: { success: true } }]; } return data; diff --git a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts index 3014556b8495a..166c73230e9d4 100644 --- a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts +++ b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts @@ -409,7 +409,7 @@ describe('Gong Node', () => { }, }, { - description: 'should get all calls', + description: 'should get all calls with filters', input: { workflowData: { nodes: [ @@ -505,7 +505,6 @@ describe('Gong Node', () => { { metaData: { ...gongApiResponse.postCallsExtensive.calls[0].metaData, - id: '3662366901393371750', }, }, ], @@ -541,7 +540,8 @@ describe('Gong Node', () => { { metaData: { ...gongApiResponse.postCallsExtensive.calls[0].metaData, - id: '3662366901393371751', + id: '7782342274025937896', + url: 'https://app.gong.io/call?id=7782342274025937896', }, }, ], @@ -550,6 +550,79 @@ describe('Gong Node', () => { ], }, }, + { + 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.getAllCall[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: { diff --git a/packages/nodes-base/nodes/Gong/test/mocks.ts b/packages/nodes-base/nodes/Gong/test/mocks.ts index 10fbcbed940cd..91cf1fa2b61a9 100644 --- a/packages/nodes-base/nodes/Gong/test/mocks.ts +++ b/packages/nodes-base/nodes/Gong/test/mocks.ts @@ -539,7 +539,7 @@ export const gongNodeResponse = { { json: { metaData: { - id: '3662366901393371750', + id: '7782342274025937895', url: 'https://app.gong.io/call?id=7782342274025937895', title: 'Example call', scheduled: 1518863400, @@ -565,8 +565,8 @@ export const gongNodeResponse = { { json: { metaData: { - id: '3662366901393371751', - url: 'https://app.gong.io/call?id=7782342274025937895', + id: '7782342274025937896', + url: 'https://app.gong.io/call?id=7782342274025937896', title: 'Example call', scheduled: 1518863400, started: 1518863400, From 69529033ac8b4c9a5058506ec9ecc2ed6a3bdded Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 1 Oct 2024 12:01:44 +0200 Subject: [PATCH 08/24] use root metaData for getAll calls --- .../nodes-base/nodes/Gong/GenericFunctions.ts | 11 ++- .../Gong/descriptions/CallDescription.ts | 8 +- packages/nodes-base/nodes/Gong/test/mocks.ts | 84 +++++++++---------- 3 files changed, 56 insertions(+), 47 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/GenericFunctions.ts b/packages/nodes-base/nodes/Gong/GenericFunctions.ts index dabf793094eb4..a28f7003d394c 100644 --- a/packages/nodes-base/nodes/Gong/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Gong/GenericFunctions.ts @@ -10,6 +10,9 @@ import type { INodeExecutionData, } from 'n8n-workflow'; +import get from 'lodash/get'; +import toPath from 'lodash/toPath'; + export async function gongApiRequest( this: IExecuteFunctions | ILoadOptionsFunctions, method: IHttpRequestMethods, @@ -112,8 +115,12 @@ export const getCursorPaginator = (rootProperty: string) => { const returnAll = this.getNodeParameter('returnAll', true) as boolean; const extractItems = (page: INodeExecutionData) => { - const items = page.json[rootProperty] as IDataObject[]; - if (items) { + const paths = toPath(rootProperty); + let items: IDataObject[] = [page.json]; + for (const path of paths) { + items = items.flatMap((x) => get(x, path)) as IDataObject[]; + } + if (items.length > 0) { executions = executions.concat(items.map((item) => ({ json: item }))); } }; diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index 50b23bb3c03c1..a8e064657b8ee 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -339,7 +339,7 @@ const getAllFields: INodeProperties[] = [ paginate: '={{ $value }}', }, operations: { - pagination: getCursorPaginator('calls'), + pagination: getCursorPaginator('calls.metaData'), }, }, type: 'boolean', @@ -369,6 +369,12 @@ const getAllFields: INodeProperties[] = [ property: 'calls', }, }, + { + type: 'rootProperty', + properties: { + property: 'metaData', + }, + }, { type: 'limit', properties: { diff --git a/packages/nodes-base/nodes/Gong/test/mocks.ts b/packages/nodes-base/nodes/Gong/test/mocks.ts index 91cf1fa2b61a9..bc2edb4a18ca6 100644 --- a/packages/nodes-base/nodes/Gong/test/mocks.ts +++ b/packages/nodes-base/nodes/Gong/test/mocks.ts @@ -538,54 +538,50 @@ export const gongNodeResponse = { getAllCall: [ { 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', - }, + 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', }, }, { json: { - metaData: { - 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', - }, + 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', }, }, ], From 0185fc3eea3a6067352fde1777be3e7613ad7022 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 1 Oct 2024 14:28:03 +0200 Subject: [PATCH 09/24] handle errors --- .../nodes-base/nodes/Gong/GenericFunctions.ts | 16 +- .../Gong/descriptions/CallDescription.ts | 43 ++++- .../Gong/descriptions/UserDescription.ts | 52 +++++- .../nodes/Gong/test/Gong.node.test.ts | 172 +++++++++++++++++- 4 files changed, 269 insertions(+), 14 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/GenericFunctions.ts b/packages/nodes-base/nodes/Gong/GenericFunctions.ts index a28f7003d394c..6ba5e7e008f59 100644 --- a/packages/nodes-base/nodes/Gong/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Gong/GenericFunctions.ts @@ -7,8 +7,11 @@ import type { IHttpRequestMethods, IHttpRequestOptions, ILoadOptionsFunctions, + IN8nHttpFullResponse, INodeExecutionData, + JsonObject, } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; import get from 'lodash/get'; import toPath from 'lodash/toPath'; @@ -129,10 +132,21 @@ export const getCursorPaginator = (rootProperty: string) => { (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; + nextCursor = (lastItem.records as IDataObject)?.cursor as string | undefined; responseData.forEach(extractItems); } while (returnAll && nextCursor); return executions; }; }; + +export async function sendErrorPostReceive( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { + throw new NodeApiError(this.getNode(), response as unknown as JsonObject); + } + return data; +} diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index a8e064657b8ee..7ceb4b660c2dc 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -5,9 +5,15 @@ import type { IN8nHttpFullResponse, INodeExecutionData, INodeProperties, + JsonObject, } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; -import { getCursorPaginator, gongApiPaginateRequest } from '../GenericFunctions'; +import { + getCursorPaginator, + gongApiPaginateRequest, + sendErrorPostReceive, +} from '../GenericFunctions'; export const callOperations: INodeProperties[] = [ { @@ -29,6 +35,25 @@ export const callOperations: INodeProperties[] = [ request: { method: 'POST', url: '/v2/calls/extensive', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [ + async function ( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, + ): Promise { + 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", + }); + } + return await sendErrorPostReceive.call(this, data, response); + }, + ], }, }, action: 'Get call', @@ -53,14 +78,16 @@ export const callOperations: INodeProperties[] = [ data: INodeExecutionData[], response: IN8nHttpFullResponse, ): Promise { - const primaryUserId = this.getNodeParameter( - 'filters.primaryUserIds', - null, - ) as IDataObject; - if (primaryUserId && response.statusCode === 404) { - return [{ json: { success: true } }]; + if (response.statusCode === 404) { + const primaryUserId = this.getNodeParameter( + 'filters.primaryUserIds', + null, + ) as IDataObject; + if (primaryUserId) { + return [{ json: { success: true } }]; + } } - return data; + return await sendErrorPostReceive.call(this, data, response); }, ], }, diff --git a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts index d43d9331d6355..3662f75ad9e78 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts @@ -1,6 +1,13 @@ -import type { INodeProperties } from 'n8n-workflow'; +import type { + IExecuteSingleFunctions, + IN8nHttpFullResponse, + INodeExecutionData, + INodeProperties, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; -import { getCursorPaginator } from '../GenericFunctions'; +import { getCursorPaginator, sendErrorPostReceive } from '../GenericFunctions'; export const userOperations: INodeProperties[] = [ { @@ -23,6 +30,25 @@ export const userOperations: INodeProperties[] = [ request: { method: 'POST', url: '/v2/users/extensive', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [ + async function ( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, + ): Promise { + 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", + }); + } + return await sendErrorPostReceive.call(this, data, response); + }, + ], }, }, }, @@ -38,6 +64,28 @@ export const userOperations: INodeProperties[] = [ body: { filter: {}, }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [ + async function ( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, + ): Promise { + if (response.statusCode === 404) { + const userIds = this.getNodeParameter('filter.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", + }); + } + } + return await sendErrorPostReceive.call(this, data, response); + }, + ], }, }, }, diff --git a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts index 166c73230e9d4..1f26f1838f319 100644 --- a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts +++ b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts @@ -688,7 +688,7 @@ describe('Gong Node', () => { { method: 'post', path: '/v2/calls/extensive', - statusCode: 400, + statusCode: 404, requestBody: { filter: { primaryUserIds: ['234599484848423'], @@ -703,12 +703,96 @@ describe('Gong Node', () => { ], }, }, + { + 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]), @@ -889,14 +973,96 @@ describe('Gong Node', () => { ], }, }, + { + 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', + filter: { + 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'], + }, + }, + ], + }, + }, ]; - console.log('temp1'); const nodeTypes = Helpers.setup(tests); test.each(tests)('$description', async (testData) => { const { result } = await executeWorkflow(testData, nodeTypes); - console.log('temp2'); + + 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]), From 3c7ff9b97605c834c39502f455ec9a83aa9d8b9e Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 8 Oct 2024 11:05:26 +0200 Subject: [PATCH 10/24] add validation --- .../nodes-base/nodes/Gong/GenericFunctions.ts | 21 +++++++ .../Gong/descriptions/CallDescription.ts | 55 +++++++++++++++---- .../Gong/descriptions/UserDescription.ts | 50 ++++++++++++++--- 3 files changed, 107 insertions(+), 19 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/GenericFunctions.ts b/packages/nodes-base/nodes/Gong/GenericFunctions.ts index 6ba5e7e008f59..ac5d11fd111fa 100644 --- a/packages/nodes-base/nodes/Gong/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Gong/GenericFunctions.ts @@ -150,3 +150,24 @@ export async function sendErrorPostReceive( } 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/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index 7ceb4b660c2dc..58f54e67ae680 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -12,6 +12,7 @@ import { NodeApiError } from 'n8n-workflow'; import { getCursorPaginator, gongApiPaginateRequest, + isValidNumberIds, sendErrorPostReceive, } from '../GenericFunctions'; @@ -81,9 +82,9 @@ export const callOperations: INodeProperties[] = [ if (response.statusCode === 404) { const primaryUserId = this.getNodeParameter( 'filters.primaryUserIds', - null, + {}, ) as IDataObject; - if (primaryUserId) { + if (Object.keys(primaryUserId).length === 0) { return [{ json: { success: true } }]; } } @@ -374,12 +375,8 @@ const getAllFields: INodeProperties[] = [ { displayName: 'Limit', name: 'limit', - type: 'number', default: 50, description: 'Max number of results to return', - typeOptions: { - minValue: 1, - }, displayOptions: { show: { resource: ['call'], @@ -411,6 +408,11 @@ const getAllFields: INodeProperties[] = [ ], }, }, + type: 'number', + typeOptions: { + minValue: 1, + }, + validateType: 'number', }, { displayName: 'Filters', @@ -439,6 +441,7 @@ const getAllFields: INodeProperties[] = [ }, }, type: 'dateTime', + validateType: 'dateTime', }, { displayName: 'Before', @@ -456,6 +459,7 @@ const getAllFields: INodeProperties[] = [ }, }, type: 'dateTime', + validateType: 'dateTime', }, { displayName: 'Workspace ID', @@ -472,6 +476,7 @@ const getAllFields: INodeProperties[] = [ }, }, type: 'string', + validateType: 'number', }, { displayName: 'Call IDs', @@ -481,11 +486,39 @@ const getAllFields: INodeProperties[] = [ hint: 'Comma separated list of IDs, array of strings can be set in expression', routing: { send: { - type: 'body', - property: 'filter.callIds', - propertyInDotNotation: true, - value: - '={{ Array.isArray($value) ? $value.map(x => x.toString()) : $value.split(",").map(x => x.trim()) }}', + 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: 'User IDs must be numbers', + description: "Double-check the value in the parameter 'User 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', diff --git a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts index 3662f75ad9e78..d6b5f1050fb4f 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts @@ -1,5 +1,7 @@ import type { + IDataObject, IExecuteSingleFunctions, + IHttpRequestOptions, IN8nHttpFullResponse, INodeExecutionData, INodeProperties, @@ -7,7 +9,7 @@ import type { } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; -import { getCursorPaginator, sendErrorPostReceive } from '../GenericFunctions'; +import { getCursorPaginator, isValidNumberIds, sendErrorPostReceive } from '../GenericFunctions'; export const userOperations: INodeProperties[] = [ { @@ -178,6 +180,7 @@ const getAllOperation: INodeProperties[] = [ }, }, type: 'boolean', + validateType: 'boolean', }, { displayName: 'Limit', @@ -213,10 +216,11 @@ const getAllOperation: INodeProperties[] = [ typeOptions: { minValue: 1, }, + validateType: 'number', }, { - displayName: 'Filter', - name: 'filter', + displayName: 'Filters', + name: 'filters', default: {}, displayOptions: { show: { @@ -241,6 +245,7 @@ const getAllOperation: INodeProperties[] = [ }, }, type: 'dateTime', + validateType: 'dateTime', }, { displayName: 'Created Before', @@ -258,6 +263,7 @@ const getAllOperation: INodeProperties[] = [ }, }, type: 'dateTime', + validateType: 'dateTime', }, { displayName: 'User IDs', @@ -267,11 +273,39 @@ const getAllOperation: INodeProperties[] = [ hint: 'Comma separated list of IDs, array of strings can be set in expression', routing: { send: { - type: 'body', - property: 'filter.userIds', - propertyInDotNotation: true, - value: - '={{ Array.isArray($value) ? $value.map(x => x.toString()) : $value.split(",").map(x => x.trim()) }}', + 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 numbers', + 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', From edfd3609e84f4adf5205a56190bb4a3c825bb83a Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 8 Oct 2024 11:29:32 +0200 Subject: [PATCH 11/24] flatten metaData --- .../nodes-base/nodes/Gong/GenericFunctions.ts | 50 +++++++++++++++++-- .../Gong/descriptions/CallDescription.ts | 22 +++++--- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/GenericFunctions.ts b/packages/nodes-base/nodes/Gong/GenericFunctions.ts index ac5d11fd111fa..04b19722f7890 100644 --- a/packages/nodes-base/nodes/Gong/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Gong/GenericFunctions.ts @@ -107,7 +107,7 @@ export async function gongApiPaginateRequest( } } -export const getCursorPaginator = (rootProperty: string) => { +export const getCursorPaginator = (rootProperty: string | null = null) => { return async function cursorPagination( this: IExecutePaginationFunctions, requestOptions: DeclarativeRestApiSettings.ResultOptions, @@ -118,10 +118,12 @@ export const getCursorPaginator = (rootProperty: string) => { const returnAll = this.getNodeParameter('returnAll', true) as boolean; const extractItems = (page: INodeExecutionData) => { - const paths = toPath(rootProperty); let items: IDataObject[] = [page.json]; - for (const path of paths) { - items = items.flatMap((x) => get(x, path)) as IDataObject[]; + if (rootProperty) { + const paths = toPath(rootProperty); + for (const path of paths) { + items = items.flatMap((x) => get(x, path)) as IDataObject[]; + } } if (items.length > 0) { executions = executions.concat(items.map((item) => ({ json: item }))); @@ -140,6 +142,46 @@ export const getCursorPaginator = (rootProperty: string) => { }; }; +export const getCursorPaginatorCalls = () => { + 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; + + const extractItems = (page: INodeExecutionData) => { + let items: IDataObject[] = [page.json]; + items = items.flatMap((x) => get(x, 'calls')) as IDataObject[]; + if (items.length > 0) { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item?.metaData) { + items[i] = { + ...(item.metaData as IDataObject), + ...item, + }; + delete items[i].metaData; + } + } + executions = executions.concat(items.map((item) => ({ json: item }))); + } + }; + + 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; + responseData.forEach(extractItems); + } while (returnAll && nextCursor); + + return executions; + }; +}; + export async function sendErrorPostReceive( this: IExecuteSingleFunctions, data: INodeExecutionData[], diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index 58f54e67ae680..5326f93eb6559 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -10,7 +10,7 @@ import type { import { NodeApiError } from 'n8n-workflow'; import { - getCursorPaginator, + getCursorPaginatorCalls, gongApiPaginateRequest, isValidNumberIds, sendErrorPostReceive, @@ -367,7 +367,7 @@ const getAllFields: INodeProperties[] = [ paginate: '={{ $value }}', }, operations: { - pagination: getCursorPaginator('calls.metaData'), + pagination: getCursorPaginatorCalls(), }, }, type: 'boolean', @@ -393,11 +393,19 @@ const getAllFields: INodeProperties[] = [ property: 'calls', }, }, - { - type: 'rootProperty', - properties: { - property: 'metaData', - }, + async function ( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + _response: IN8nHttpFullResponse, + ): Promise { + for (const item of data) { + if (item.json?.metaData) { + item.json = { ...(item.json.metaData as IDataObject), ...item.json }; + delete item.json.metaData; + } + } + + return data; }, { type: 'limit', From 660b9f439a5e030b213ebd3b64ff1bd279bd4284 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 8 Oct 2024 11:47:36 +0200 Subject: [PATCH 12/24] fix tests --- .../Gong/descriptions/CallDescription.ts | 2 +- .../Gong/descriptions/UserDescription.ts | 2 +- .../nodes/Gong/test/Gong.node.test.ts | 16 ++- packages/nodes-base/nodes/Gong/test/mocks.ts | 121 +++++++++++++++--- 4 files changed, 119 insertions(+), 22 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index 5326f93eb6559..a05beb9b59e67 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -84,7 +84,7 @@ export const callOperations: INodeProperties[] = [ 'filters.primaryUserIds', {}, ) as IDataObject; - if (Object.keys(primaryUserId).length === 0) { + if (Object.keys(primaryUserId).length !== 0) { return [{ json: { success: true } }]; } } diff --git a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts index d6b5f1050fb4f..de4c5925ea5ea 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts @@ -76,7 +76,7 @@ export const userOperations: INodeProperties[] = [ response: IN8nHttpFullResponse, ): Promise { if (response.statusCode === 404) { - const userIds = this.getNodeParameter('filter.userIds', '') as string; + 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", diff --git a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts index 1f26f1838f319..dd3bf276d40c2 100644 --- a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts +++ b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts @@ -505,6 +505,10 @@ describe('Gong Node', () => { { metaData: { ...gongApiResponse.postCallsExtensive.calls[0].metaData, + parties: [...gongApiResponse.postCallsExtensive.calls[0].parties], + content: { + topics: [...gongApiResponse.postCallsExtensive.calls[0].content.topics], + }, }, }, ], @@ -543,6 +547,10 @@ describe('Gong Node', () => { id: '7782342274025937896', url: 'https://app.gong.io/call?id=7782342274025937896', }, + parties: [...gongApiResponse.postCallsExtensive.calls[0].parties], + content: { + topics: [...gongApiResponse.postCallsExtensive.calls[0].content.topics], + }, }, ], }, @@ -600,7 +608,9 @@ describe('Gong Node', () => { output: { nodeExecutionOrder: ['Start'], nodeData: { - Gong: [Array.from({ length: 50 }, () => ({ ...gongNodeResponse.getAllCall[0] }))], + Gong: [ + Array.from({ length: 50 }, () => ({ ...gongNodeResponse.getAllCallNoOptions[0] })), + ], }, }, nock: { @@ -894,7 +904,7 @@ describe('Gong Node', () => { resource: 'user', operation: 'getAll', returnAll: true, - filter: { + filters: { createdFromDateTime: '2024-01-01T00:00:00Z', createdToDateTime: '2024-12-31T00:00:00Z', userIds: '234599484848423, 234599484848424', @@ -990,7 +1000,7 @@ describe('Gong Node', () => { parameters: { resource: 'user', operation: 'getAll', - filter: { + filters: { userIds: '234599484848423', }, requestOptions: {}, diff --git a/packages/nodes-base/nodes/Gong/test/mocks.ts b/packages/nodes-base/nodes/Gong/test/mocks.ts index bc2edb4a18ca6..621e6c0d72add 100644 --- a/packages/nodes-base/nodes/Gong/test/mocks.ts +++ b/packages/nodes-base/nodes/Gong/test/mocks.ts @@ -299,23 +299,6 @@ export const gongApiResponse = { }; export const gongNodeResponse = { - createCall: [ - { - json: { - requestId: '4al018gzaztcr8nbukw', - callId: '7782342274025937895', - }, - }, - ], - createCallMedia: [ - { - json: { - requestId: '4al018gzaztcr8nbukw', - callId: '7782342274025937895', - url: 'https://app.gong.io/call?id=7782342274025937895', - }, - }, - ], getCall: [ { json: { @@ -558,6 +541,45 @@ export const gongNodeResponse = { 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'], + }, + ], }, }, { @@ -582,6 +604,71 @@ export const gongNodeResponse = { 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', }, }, ], From 65a81978120d40312036ae01b677a019af2df435 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 8 Oct 2024 11:53:12 +0200 Subject: [PATCH 13/24] improve error message --- .../nodes-base/nodes/Gong/descriptions/CallDescription.ts | 4 ++-- .../nodes-base/nodes/Gong/descriptions/UserDescription.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index a05beb9b59e67..673c8de82640d 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -506,8 +506,8 @@ const getAllFields: INodeProperties[] = [ | string[]; if (callIdsParam && !isValidNumberIds(callIdsParam)) { throw new NodeApiError(this.getNode(), { - message: 'User IDs must be numbers', - description: "Double-check the value in the parameter 'User IDs' and try again", + message: 'Call IDs must be numeric', + description: "Double-check the value in the parameter 'Call IDs' and try again", }); } diff --git a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts index de4c5925ea5ea..ea9b98047f731 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts @@ -285,7 +285,7 @@ const getAllOperation: INodeProperties[] = [ | string[]; if (userIdsParam && !isValidNumberIds(userIdsParam)) { throw new NodeApiError(this.getNode(), { - message: 'User IDs must be numbers', + message: 'User IDs must be numeric', description: "Double-check the value in the parameter 'User IDs' and try again", }); } From b6f67ff486a1a68b7f1f8eb65a576dc8e9b47ef6 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 8 Oct 2024 12:18:33 +0200 Subject: [PATCH 14/24] handle error --- .../nodes-base/nodes/Gong/descriptions/CallDescription.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index 673c8de82640d..015e0077183f2 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -87,6 +87,10 @@ export const callOperations: INodeProperties[] = [ if (Object.keys(primaryUserId).length !== 0) { return [{ json: { success: true } }]; } + } 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)', + }); } return await sendErrorPostReceive.call(this, data, response); }, From bcf34b0ab1ac8adb0d44686bc4d85f90a7965dfe Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 15 Oct 2024 10:53:13 +0200 Subject: [PATCH 15/24] use n8n docs --- packages/nodes-base/nodes/Gong/Gong.node.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/Gong.node.json b/packages/nodes-base/nodes/Gong/Gong.node.json index 27841aff484a4..be07bbb3307a5 100644 --- a/packages/nodes-base/nodes/Gong/Gong.node.json +++ b/packages/nodes-base/nodes/Gong/Gong.node.json @@ -6,12 +6,12 @@ "resources": { "credentialDocumentation": [ { - "url": "https://gong.app.gong.io/settings/api/documentation" + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.gong/" } ], "primaryDocumentation": [ { - "url": "https://gong.app.gong.io/settings/api/documentation" + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.gong/" } ] } From a96fb57017faad673df03525b619ad241ceefa8b Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 15 Oct 2024 11:05:30 +0200 Subject: [PATCH 16/24] use n8n docs --- packages/nodes-base/credentials/GongApi.credentials.ts | 2 +- packages/nodes-base/credentials/GongOAuth2Api.credentials.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/credentials/GongApi.credentials.ts b/packages/nodes-base/credentials/GongApi.credentials.ts index cbc270f5a5d1f..2f94b22d95553 100644 --- a/packages/nodes-base/credentials/GongApi.credentials.ts +++ b/packages/nodes-base/credentials/GongApi.credentials.ts @@ -10,7 +10,7 @@ export class GongApi implements ICredentialType { displayName = 'Gong API'; - documentationUrl = 'https://gong.app.gong.io/settings/api/documentation'; + documentationUrl = 'gong'; properties: INodeProperties[] = [ { diff --git a/packages/nodes-base/credentials/GongOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GongOAuth2Api.credentials.ts index 29cd8033a6350..bea935c4f1f76 100644 --- a/packages/nodes-base/credentials/GongOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/GongOAuth2Api.credentials.ts @@ -7,7 +7,7 @@ export class GongOAuth2Api implements ICredentialType { displayName = 'Gong OAuth2 API'; - documentationUrl = 'https://help.gong.io/docs/create-an-app-for-gong'; + documentationUrl = 'gong'; properties: INodeProperties[] = [ { From 5ec52e546a23d5b8a65f07b9bdf91884fb067e28 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 15 Oct 2024 11:06:47 +0200 Subject: [PATCH 17/24] use typeOptions password for accessKey --- packages/nodes-base/credentials/GongApi.credentials.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/credentials/GongApi.credentials.ts b/packages/nodes-base/credentials/GongApi.credentials.ts index 2f94b22d95553..19c56a65defb1 100644 --- a/packages/nodes-base/credentials/GongApi.credentials.ts +++ b/packages/nodes-base/credentials/GongApi.credentials.ts @@ -22,8 +22,10 @@ export class GongApi implements ICredentialType { { displayName: 'Access Key', name: 'accessKey', - // eslint-disable-next-line n8n-nodes-base/cred-class-field-type-options-password-missing type: 'string', + typeOptions: { + password: true, + }, default: '', }, { From 123bbc94f60a7c29354863ac0f87f3e89f2d2840 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 15 Oct 2024 11:10:14 +0200 Subject: [PATCH 18/24] lint reorder import --- packages/nodes-base/nodes/Gong/GenericFunctions.ts | 5 ++--- packages/nodes-base/nodes/Gong/test/Gong.node.test.ts | 11 ++++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/GenericFunctions.ts b/packages/nodes-base/nodes/Gong/GenericFunctions.ts index 04b19722f7890..7d9c231505701 100644 --- a/packages/nodes-base/nodes/Gong/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Gong/GenericFunctions.ts @@ -1,3 +1,5 @@ +import get from 'lodash/get'; +import toPath from 'lodash/toPath'; import type { DeclarativeRestApiSettings, IDataObject, @@ -13,9 +15,6 @@ import type { } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; -import get from 'lodash/get'; -import toPath from 'lodash/toPath'; - export async function gongApiRequest( this: IExecuteFunctions | ILoadOptionsFunctions, method: IHttpRequestMethods, diff --git a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts index dd3bf276d40c2..2ee64f958c848 100644 --- a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts +++ b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts @@ -1,15 +1,16 @@ -import nock from 'nock'; +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 { ExpressionEvaluatorProxy, NodeConnectionType } from 'n8n-workflow'; -import { FAKE_CREDENTIALS_DATA } from '../../../test/nodes/FakeCredentialsMap'; +import nock from 'nock'; + import { gongApiResponse, gongNodeResponse } from './mocks'; -import type { WorkflowTestData } from '@test/nodes/types'; -import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; -import * as Helpers from '@test/nodes/Helpers'; +import { FAKE_CREDENTIALS_DATA } from '../../../test/nodes/FakeCredentialsMap'; describe('Gong Node', () => { const baseUrl = 'https://api.gong.io'; From 4b305016bb54094578ab76b39b616dc905f69691 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 15 Oct 2024 15:07:12 +0200 Subject: [PATCH 19/24] cleanup code --- .../nodes-base/nodes/Gong/test/Gong.node.test.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts index 2ee64f958c848..52a4ad60a002d 100644 --- a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts +++ b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts @@ -6,7 +6,7 @@ import type { IDataObject, IHttpRequestOptions, } from 'n8n-workflow'; -import { ExpressionEvaluatorProxy, NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; import nock from 'nock'; import { gongApiResponse, gongNodeResponse } from './mocks'; @@ -15,11 +15,6 @@ import { FAKE_CREDENTIALS_DATA } from '../../../test/nodes/FakeCredentialsMap'; describe('Gong Node', () => { const baseUrl = 'https://api.gong.io'; - beforeAll(() => { - // Test expression '={{ Array.isArray($value) ? $value.map(x => x.toString()) : $value.split(",").map(x => x.trim()) }}', - ExpressionEvaluatorProxy.setEvaluator('tournament'); - }); - beforeEach(() => { // https://github.com/nock/nock/issues/2057#issuecomment-663665683 if (!nock.isActive()) { @@ -120,9 +115,9 @@ describe('Gong Node', () => { nodeExecutionOrder: ['Start'], nodeData: { 'Gong gongApi': [[{ json: { metaData: gongNodeResponse.getCall[0].json.metaData } }]], - // 'Gong gongOAuth2Api': [ - // [{ json: { metaData: gongNodeResponse.getCall[0].json.metaData } }], - // ], + 'Gong gongOAuth2Api': [ + [{ json: { metaData: gongNodeResponse.getCall[0].json.metaData } }], + ], }, }, }, From efc6954f52735ce807498f7ddfcaa766312ae09a Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 15 Oct 2024 16:14:08 +0200 Subject: [PATCH 20/24] abstract pagination --- .../nodes-base/nodes/Gong/GenericFunctions.ts | 72 ++++++------------- .../Gong/descriptions/UserDescription.ts | 8 ++- 2 files changed, 28 insertions(+), 52 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/GenericFunctions.ts b/packages/nodes-base/nodes/Gong/GenericFunctions.ts index 7d9c231505701..eb4b76ac3ed6e 100644 --- a/packages/nodes-base/nodes/Gong/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Gong/GenericFunctions.ts @@ -106,7 +106,7 @@ export async function gongApiPaginateRequest( } } -export const getCursorPaginator = (rootProperty: string | null = null) => { +const getCursorPaginator = (extractItems: (page: INodeExecutionData) => INodeExecutionData[]) => { return async function cursorPagination( this: IExecutePaginationFunctions, requestOptions: DeclarativeRestApiSettings.ResultOptions, @@ -116,69 +116,41 @@ export const getCursorPaginator = (rootProperty: string | null = null) => { let nextCursor: string | undefined = undefined; const returnAll = this.getNodeParameter('returnAll', true) as boolean; - const extractItems = (page: INodeExecutionData) => { - let items: IDataObject[] = [page.json]; - if (rootProperty) { - const paths = toPath(rootProperty); - for (const path of paths) { - items = items.flatMap((x) => get(x, path)) as IDataObject[]; - } - } - if (items.length > 0) { - executions = executions.concat(items.map((item) => ({ json: item }))); - } - }; - 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; - responseData.forEach(extractItems); + for (const page of responseData) { + executions = executions.concat(extractItems(page)); + } } while (returnAll && nextCursor); return executions; }; }; -export const getCursorPaginatorCalls = () => { - 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; +const extractCalls = (page: INodeExecutionData): INodeExecutionData[] => { + const items: IDataObject[] = get(page.json, 'calls') as IDataObject[]; + return items + .filter((item) => item?.metaData) + .map((item) => { + const { metaData, ...rest } = item; + return { json: { ...(metaData as IDataObject), ...rest } }; + }); +}; - const extractItems = (page: INodeExecutionData) => { - let items: IDataObject[] = [page.json]; - items = items.flatMap((x) => get(x, 'calls')) as IDataObject[]; - if (items.length > 0) { - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (item?.metaData) { - items[i] = { - ...(item.metaData as IDataObject), - ...item, - }; - delete items[i].metaData; - } - } - executions = executions.concat(items.map((item) => ({ json: item }))); - } - }; +const extractUsers = (page: INodeExecutionData): INodeExecutionData[] => { + const items: IDataObject[] = get(page.json, 'users') as IDataObject[]; + return items.map((item) => ({ json: item })); +}; - 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; - responseData.forEach(extractItems); - } while (returnAll && nextCursor); +export const getCursorPaginatorCalls = () => { + return getCursorPaginator(extractCalls); +}; - return executions; - }; +export const getCursorPaginatorUsers = () => { + return getCursorPaginator(extractUsers); }; export async function sendErrorPostReceive( diff --git a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts index ea9b98047f731..8192537518bbd 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts @@ -9,7 +9,11 @@ import type { } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; -import { getCursorPaginator, isValidNumberIds, sendErrorPostReceive } from '../GenericFunctions'; +import { + getCursorPaginatorUsers, + isValidNumberIds, + sendErrorPostReceive, +} from '../GenericFunctions'; export const userOperations: INodeProperties[] = [ { @@ -176,7 +180,7 @@ const getAllOperation: INodeProperties[] = [ paginate: '={{ $value }}', }, operations: { - pagination: getCursorPaginator('users'), + pagination: getCursorPaginatorUsers(), }, }, type: 'boolean', From e322e99b7ab8fa1c2bbfe968a806da93173dd88a Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 15 Oct 2024 16:20:02 +0200 Subject: [PATCH 21/24] abstract error handling --- .../nodes-base/nodes/Gong/GenericFunctions.ts | 46 ++++++++++++++++++- .../Gong/descriptions/CallDescription.ts | 43 ++--------------- .../Gong/descriptions/UserDescription.ts | 39 ++-------------- 3 files changed, 51 insertions(+), 77 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/GenericFunctions.ts b/packages/nodes-base/nodes/Gong/GenericFunctions.ts index eb4b76ac3ed6e..c192d0e86e190 100644 --- a/packages/nodes-base/nodes/Gong/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Gong/GenericFunctions.ts @@ -153,14 +153,58 @@ export const getCursorPaginatorUsers = () => { return getCursorPaginator(extractUsers); }; -export async function sendErrorPostReceive( +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: { success: true } }]; + } + } 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; } diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index 015e0077183f2..67889abb309da 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -5,7 +5,6 @@ import type { IN8nHttpFullResponse, INodeExecutionData, INodeProperties, - JsonObject, } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; @@ -13,7 +12,7 @@ import { getCursorPaginatorCalls, gongApiPaginateRequest, isValidNumberIds, - sendErrorPostReceive, + handleErrorPostReceive, } from '../GenericFunctions'; export const callOperations: INodeProperties[] = [ @@ -39,22 +38,7 @@ export const callOperations: INodeProperties[] = [ ignoreHttpStatusErrors: true, }, output: { - postReceive: [ - async function ( - this: IExecuteSingleFunctions, - data: INodeExecutionData[], - response: IN8nHttpFullResponse, - ): Promise { - 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", - }); - } - return await sendErrorPostReceive.call(this, data, response); - }, - ], + postReceive: [handleErrorPostReceive], }, }, action: 'Get call', @@ -73,28 +57,7 @@ export const callOperations: INodeProperties[] = [ ignoreHttpStatusErrors: true, }, output: { - postReceive: [ - async function ( - this: IExecuteSingleFunctions, - data: INodeExecutionData[], - response: IN8nHttpFullResponse, - ): Promise { - if (response.statusCode === 404) { - const primaryUserId = this.getNodeParameter( - 'filters.primaryUserIds', - {}, - ) as IDataObject; - if (Object.keys(primaryUserId).length !== 0) { - return [{ json: { success: true } }]; - } - } 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)', - }); - } - return await sendErrorPostReceive.call(this, data, response); - }, - ], + postReceive: [handleErrorPostReceive], }, }, action: 'Get many calls', diff --git a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts index 8192537518bbd..96646dfddb385 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts @@ -12,7 +12,7 @@ import { NodeApiError } from 'n8n-workflow'; import { getCursorPaginatorUsers, isValidNumberIds, - sendErrorPostReceive, + handleErrorPostReceive, } from '../GenericFunctions'; export const userOperations: INodeProperties[] = [ @@ -39,22 +39,7 @@ export const userOperations: INodeProperties[] = [ ignoreHttpStatusErrors: true, }, output: { - postReceive: [ - async function ( - this: IExecuteSingleFunctions, - data: INodeExecutionData[], - response: IN8nHttpFullResponse, - ): Promise { - 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", - }); - } - return await sendErrorPostReceive.call(this, data, response); - }, - ], + postReceive: [handleErrorPostReceive], }, }, }, @@ -73,25 +58,7 @@ export const userOperations: INodeProperties[] = [ ignoreHttpStatusErrors: true, }, output: { - postReceive: [ - async function ( - this: IExecuteSingleFunctions, - data: INodeExecutionData[], - response: IN8nHttpFullResponse, - ): Promise { - 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", - }); - } - } - return await sendErrorPostReceive.call(this, data, response); - }, - ], + postReceive: [handleErrorPostReceive], }, }, }, From 30c5d71b61fc995b0e7e7ef3b7333878cdc28fd3 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 15 Oct 2024 16:37:18 +0200 Subject: [PATCH 22/24] throw error if credentials not found --- packages/workflow/src/RoutingNode.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/workflow/src/RoutingNode.ts b/packages/workflow/src/RoutingNode.ts index adc2b7bd0635a..4b3ed7f5970a0 100644 --- a/packages/workflow/src/RoutingNode.ts +++ b/packages/workflow/src/RoutingNode.ts @@ -117,7 +117,13 @@ export class RoutingNode { credentialDescription = nodeType.description.credentials.find((x) => x.displayOptions?.show?.authentication?.includes(authenticationMethod), ); - // Todo: throw error if not found? + if (!credentialDescription) { + throw new NodeOperationError( + this.node, + `Node type "${this.node.type}" does not have any credentials of type "${authenticationMethod}" defined`, + { level: 'warning' }, + ); + } } } From 44cca8611633906d969f91e6dd9cf33369806542 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 15 Oct 2024 16:59:33 +0200 Subject: [PATCH 23/24] lint --- packages/nodes-base/nodes/Gong/GenericFunctions.ts | 1 - packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/GenericFunctions.ts b/packages/nodes-base/nodes/Gong/GenericFunctions.ts index c192d0e86e190..7095842b4c16f 100644 --- a/packages/nodes-base/nodes/Gong/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Gong/GenericFunctions.ts @@ -1,5 +1,4 @@ import get from 'lodash/get'; -import toPath from 'lodash/toPath'; import type { DeclarativeRestApiSettings, IDataObject, diff --git a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts index 96646dfddb385..38fb847b9072b 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/UserDescription.ts @@ -2,10 +2,7 @@ import type { IDataObject, IExecuteSingleFunctions, IHttpRequestOptions, - IN8nHttpFullResponse, - INodeExecutionData, INodeProperties, - JsonObject, } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; From 4c3e1bbff311c1214c629acc5ccc8dd8e1705146 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Tue, 15 Oct 2024 18:40:21 +0200 Subject: [PATCH 24/24] fix response for empty user calls, reuse call extraction --- .../nodes-base/nodes/Gong/GenericFunctions.ts | 30 +++++++++---------- .../Gong/descriptions/CallDescription.ts | 18 ++--------- .../nodes/Gong/test/Gong.node.test.ts | 2 +- 3 files changed, 18 insertions(+), 32 deletions(-) diff --git a/packages/nodes-base/nodes/Gong/GenericFunctions.ts b/packages/nodes-base/nodes/Gong/GenericFunctions.ts index 7095842b4c16f..8c9069959b488 100644 --- a/packages/nodes-base/nodes/Gong/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Gong/GenericFunctions.ts @@ -105,7 +105,9 @@ export async function gongApiPaginateRequest( } } -const getCursorPaginator = (extractItems: (page: INodeExecutionData) => INodeExecutionData[]) => { +const getCursorPaginator = ( + extractItems: (items: INodeExecutionData[]) => INodeExecutionData[], +) => { return async function cursorPagination( this: IExecutePaginationFunctions, requestOptions: DeclarativeRestApiSettings.ResultOptions, @@ -120,28 +122,24 @@ const getCursorPaginator = (extractItems: (page: INodeExecutionData) => INodeExe responseData = await this.makeRoutingRequest(requestOptions); const lastItem = responseData[responseData.length - 1].json; nextCursor = (lastItem.records as IDataObject)?.cursor as string | undefined; - for (const page of responseData) { - executions = executions.concat(extractItems(page)); - } + executions = executions.concat(extractItems(responseData)); } while (returnAll && nextCursor); return executions; }; }; -const extractCalls = (page: INodeExecutionData): INodeExecutionData[] => { - const items: IDataObject[] = get(page.json, 'calls') as IDataObject[]; - return items - .filter((item) => item?.metaData) - .map((item) => { - const { metaData, ...rest } = item; - return { json: { ...(metaData as IDataObject), ...rest } }; - }); +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 } }; + }); }; -const extractUsers = (page: INodeExecutionData): INodeExecutionData[] => { - const items: IDataObject[] = get(page.json, 'users') as IDataObject[]; - return items.map((item) => ({ json: item })); +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 = () => { @@ -172,7 +170,7 @@ export async function handleErrorPostReceive( if (response.statusCode === 404) { const primaryUserId = this.getNodeParameter('filters.primaryUserIds', {}) as IDataObject; if (Object.keys(primaryUserId).length !== 0) { - return [{ json: { success: true } }]; + return [{ json: {} }]; } } else if (response.statusCode === 400 || response.statusCode === 500) { throw new NodeApiError(this.getNode(), response as unknown as JsonObject, { diff --git a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts index 67889abb309da..ab6df9626dcdc 100644 --- a/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts +++ b/packages/nodes-base/nodes/Gong/descriptions/CallDescription.ts @@ -13,6 +13,7 @@ import { gongApiPaginateRequest, isValidNumberIds, handleErrorPostReceive, + extractCalls, } from '../GenericFunctions'; export const callOperations: INodeProperties[] = [ @@ -354,25 +355,12 @@ const getAllFields: INodeProperties[] = [ routing: { output: { postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'calls', - }, - }, async function ( this: IExecuteSingleFunctions, - data: INodeExecutionData[], + items: INodeExecutionData[], _response: IN8nHttpFullResponse, ): Promise { - for (const item of data) { - if (item.json?.metaData) { - item.json = { ...(item.json.metaData as IDataObject), ...item.json }; - delete item.json.metaData; - } - } - - return data; + return extractCalls(items); }, { type: 'limit', diff --git a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts index 52a4ad60a002d..d4e3e307fd7a5 100644 --- a/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts +++ b/packages/nodes-base/nodes/Gong/test/Gong.node.test.ts @@ -685,7 +685,7 @@ describe('Gong Node', () => { output: { nodeExecutionOrder: ['Start'], nodeData: { - Gong: [[{ json: undefined }]], + Gong: [[{ json: {} }]], }, }, nock: {