From 19e5c0397ad75b47c6068db194a3f938722095c8 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Mon, 3 Jun 2024 12:09:05 +0300 Subject: [PATCH] feat(HighLevel Node): Api v2 support, new node version (#9554) --- packages/cli/src/constants.ts | 7 + .../oauth/oAuth2Credential.controller.ts | 10 +- .../HighLevelOAuth2Api.credentials.ts | 2 +- .../credentials/OAuth2Api.credentials.ts | 3 + .../nodes/HighLevel/HighLevel.node.ts | 102 +-- .../HighLevel/{ => v1}/GenericFunctions.ts | 0 .../nodes/HighLevel/v1/HighLevelV1.node.ts | 102 +++ .../description/ContactDescription.ts | 0 .../description/OpportunityDescription.ts | 0 .../{ => v1}/description/TaskDescription.ts | 0 .../nodes/HighLevel/v2/GenericFunctions.ts | 355 ++++++++ .../nodes/HighLevel/v2/HighLevelV2.node.ts | 107 +++ .../v2/description/ContactDescription.ts | 845 ++++++++++++++++++ .../v2/description/OpportunityDescription.ts | 669 ++++++++++++++ .../v2/description/TaskDescription.ts | 491 ++++++++++ 15 files changed, 2603 insertions(+), 90 deletions(-) rename packages/nodes-base/nodes/HighLevel/{ => v1}/GenericFunctions.ts (100%) create mode 100644 packages/nodes-base/nodes/HighLevel/v1/HighLevelV1.node.ts rename packages/nodes-base/nodes/HighLevel/{ => v1}/description/ContactDescription.ts (100%) rename packages/nodes-base/nodes/HighLevel/{ => v1}/description/OpportunityDescription.ts (100%) rename packages/nodes-base/nodes/HighLevel/{ => v1}/description/TaskDescription.ts (100%) create mode 100644 packages/nodes-base/nodes/HighLevel/v2/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/HighLevel/v2/HighLevelV2.node.ts create mode 100644 packages/nodes-base/nodes/HighLevel/v2/description/ContactDescription.ts create mode 100644 packages/nodes-base/nodes/HighLevel/v2/description/OpportunityDescription.ts create mode 100644 packages/nodes-base/nodes/HighLevel/v2/description/TaskDescription.ts diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 0dc0ee1fdf..aac5ac9f19 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -149,3 +149,10 @@ export const TEST_WEBHOOK_TIMEOUT = 2 * TIME.MINUTE; export const TEST_WEBHOOK_TIMEOUT_BUFFER = 30 * TIME.SECOND; export const N8N_DOCS_URL = 'https://docs.n8n.io'; + +export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [ + 'oAuth2Api', + 'googleOAuth2Api', + 'microsoftOAuth2Api', + 'highLevelOAuth2Api', +]; diff --git a/packages/cli/src/controllers/oauth/oAuth2Credential.controller.ts b/packages/cli/src/controllers/oauth/oAuth2Credential.controller.ts index ae0d18b230..71a0fe140c 100644 --- a/packages/cli/src/controllers/oauth/oAuth2Credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oAuth2Credential.controller.ts @@ -10,6 +10,7 @@ import { Get, RestController } from '@/decorators'; import { jsonStringify } from 'n8n-workflow'; import { OAuthRequest } from '@/requests'; import { AbstractOAuthController, type CsrfStateParam } from './abstractOAuth.controller'; +import { GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE as GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE } from '../../constants'; @RestController('/oauth2-credential') export class OAuth2CredentialController extends AbstractOAuthController { @@ -25,16 +26,11 @@ export class OAuth2CredentialController extends AbstractOAuthController { // At some point in the past we saved hidden scopes to credentials (but shouldn't) // Delete scope before applying defaults to make sure new scopes are present on reconnect // Generic Oauth2 API is an exception because it needs to save the scope - const genericOAuth2 = [ - 'oAuth2Api', - 'googleOAuth2Api', - 'microsoftOAuth2Api', - 'highLevelOAuth2Api', - ]; + if ( decryptedDataOriginal?.scope && credential.type.includes('OAuth2') && - !genericOAuth2.includes(credential.type) + !GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE.includes(credential.type) ) { delete decryptedDataOriginal.scope; } diff --git a/packages/nodes-base/credentials/HighLevelOAuth2Api.credentials.ts b/packages/nodes-base/credentials/HighLevelOAuth2Api.credentials.ts index 5ebb51b256..3325c5e72b 100644 --- a/packages/nodes-base/credentials/HighLevelOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/HighLevelOAuth2Api.credentials.ts @@ -39,7 +39,7 @@ export class HighLevelOAuth2Api implements ICredentialType { displayName: 'Scope', name: 'scope', type: 'string', - hint: 'Separate scopes by space', + hint: "Separate scopes by space, scopes needed for node: 'locations.readonly contacts.readonly contacts.write opportunities.readonly opportunities.write users.readonly'", default: '', required: true, }, diff --git a/packages/nodes-base/credentials/OAuth2Api.credentials.ts b/packages/nodes-base/credentials/OAuth2Api.credentials.ts index ed61e2d639..ded876fe28 100644 --- a/packages/nodes-base/credentials/OAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/OAuth2Api.credentials.ts @@ -66,6 +66,9 @@ export class OAuth2Api implements ICredentialType { default: '', required: true, }, + // WARNING: if you are extending from this credentials and allow user to set their own scopes + // you HAVE TO add it to GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE in packages/cli/src/constants.ts + // track any updates to this behavior in N8N-7424 { displayName: 'Scope', name: 'scope', diff --git a/packages/nodes-base/nodes/HighLevel/HighLevel.node.ts b/packages/nodes-base/nodes/HighLevel/HighLevel.node.ts index f68805d702..72b8fa20d9 100644 --- a/packages/nodes-base/nodes/HighLevel/HighLevel.node.ts +++ b/packages/nodes-base/nodes/HighLevel/HighLevel.node.ts @@ -1,87 +1,25 @@ -import type { INodeProperties, INodeType, INodeTypeDescription } from 'n8n-workflow'; +import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; -import { contactFields, contactNotes, contactOperations } from './description/ContactDescription'; -import { opportunityFields, opportunityOperations } from './description/OpportunityDescription'; -import { taskFields, taskOperations } from './description/TaskDescription'; -import { - getPipelineStages, - getTimezones, - getUsers, - highLevelApiPagination, -} from './GenericFunctions'; +import { HighLevelV1 } from './v1/HighLevelV1.node'; +import { HighLevelV2 } from './v2/HighLevelV2.node'; -const ressources: INodeProperties[] = [ - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Contact', - value: 'contact', - }, - { - name: 'Opportunity', - value: 'opportunity', - }, - { - name: 'Task', - value: 'task', - }, - ], - default: 'contact', - required: true, - }, -]; +export class HighLevel extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'HighLevel', + name: 'highLevel', + icon: 'file:highLevel.svg', + group: ['transform'], + defaultVersion: 2, + description: 'Consume HighLevel API', + }; -export class HighLevel implements INodeType { - description: INodeTypeDescription = { - displayName: 'HighLevel', - name: 'highLevel', - icon: 'file:highLevel.svg', - group: ['transform'], - version: 1, - description: 'Consume HighLevel API', - subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', - defaults: { - name: 'HighLevel', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - name: 'highLevelApi', - required: true, - }, - ], - requestDefaults: { - baseURL: 'https://rest.gohighlevel.com/v1', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - }, - requestOperations: { - pagination: highLevelApiPagination, - }, - properties: [ - ...ressources, - ...contactOperations, - ...contactNotes, - ...contactFields, - ...opportunityOperations, - ...opportunityFields, - ...taskOperations, - ...taskFields, - ], - }; + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new HighLevelV1(baseDescription), + 2: new HighLevelV2(baseDescription), + }; - methods = { - loadOptions: { - getPipelineStages, - getUsers, - getTimezones, - }, - }; + super(nodeVersions, baseDescription); + } } diff --git a/packages/nodes-base/nodes/HighLevel/GenericFunctions.ts b/packages/nodes-base/nodes/HighLevel/v1/GenericFunctions.ts similarity index 100% rename from packages/nodes-base/nodes/HighLevel/GenericFunctions.ts rename to packages/nodes-base/nodes/HighLevel/v1/GenericFunctions.ts diff --git a/packages/nodes-base/nodes/HighLevel/v1/HighLevelV1.node.ts b/packages/nodes-base/nodes/HighLevel/v1/HighLevelV1.node.ts new file mode 100644 index 0000000000..2fb5bc2520 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v1/HighLevelV1.node.ts @@ -0,0 +1,102 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import type { + INodeProperties, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { contactFields, contactNotes, contactOperations } from './description/ContactDescription'; +import { opportunityFields, opportunityOperations } from './description/OpportunityDescription'; +import { taskFields, taskOperations } from './description/TaskDescription'; +import { + getPipelineStages, + getTimezones, + getUsers, + highLevelApiPagination, +} from './GenericFunctions'; + +const resources: INodeProperties[] = [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Contact', + value: 'contact', + }, + { + name: 'Opportunity', + value: 'opportunity', + }, + { + name: 'Task', + value: 'task', + }, + ], + default: 'contact', + required: true, + }, +]; + +const versionDescription: INodeTypeDescription = { + displayName: 'HighLevel', + name: 'highLevel', + icon: 'file:highLevel.svg', + group: ['transform'], + version: 1, + description: 'Consume HighLevel API v1', + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + defaults: { + name: 'HighLevel', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'highLevelApi', + required: true, + }, + ], + requestDefaults: { + baseURL: 'https://rest.gohighlevel.com/v1', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }, + requestOperations: { + pagination: highLevelApiPagination, + }, + properties: [ + ...resources, + ...contactOperations, + ...contactNotes, + ...contactFields, + ...opportunityOperations, + ...opportunityFields, + ...taskOperations, + ...taskFields, + ], +}; + +export class HighLevelV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { + loadOptions: { + getPipelineStages, + getUsers, + getTimezones, + }, + }; +} diff --git a/packages/nodes-base/nodes/HighLevel/description/ContactDescription.ts b/packages/nodes-base/nodes/HighLevel/v1/description/ContactDescription.ts similarity index 100% rename from packages/nodes-base/nodes/HighLevel/description/ContactDescription.ts rename to packages/nodes-base/nodes/HighLevel/v1/description/ContactDescription.ts diff --git a/packages/nodes-base/nodes/HighLevel/description/OpportunityDescription.ts b/packages/nodes-base/nodes/HighLevel/v1/description/OpportunityDescription.ts similarity index 100% rename from packages/nodes-base/nodes/HighLevel/description/OpportunityDescription.ts rename to packages/nodes-base/nodes/HighLevel/v1/description/OpportunityDescription.ts diff --git a/packages/nodes-base/nodes/HighLevel/description/TaskDescription.ts b/packages/nodes-base/nodes/HighLevel/v1/description/TaskDescription.ts similarity index 100% rename from packages/nodes-base/nodes/HighLevel/description/TaskDescription.ts rename to packages/nodes-base/nodes/HighLevel/v1/description/TaskDescription.ts diff --git a/packages/nodes-base/nodes/HighLevel/v2/GenericFunctions.ts b/packages/nodes-base/nodes/HighLevel/v2/GenericFunctions.ts new file mode 100644 index 0000000000..04a92c512c --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/GenericFunctions.ts @@ -0,0 +1,355 @@ +import type { + DeclarativeRestApiSettings, + IDataObject, + IExecuteFunctions, + IExecutePaginationFunctions, + IExecuteSingleFunctions, + IHookFunctions, + IHttpRequestMethods, + IHttpRequestOptions, + ILoadOptionsFunctions, + IN8nHttpFullResponse, + INodeExecutionData, + INodePropertyOptions, + IPollFunctions, + IWebhookFunctions, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import type { ToISOTimeOptions } from 'luxon'; +import { DateTime } from 'luxon'; + +const VALID_EMAIL_REGEX = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; +const VALID_PHONE_REGEX = + /((?:\+|00)[17](?: |\-)?|(?:\+|00)[1-9]\d{0,2}(?: |\-)?|(?:\+|00)1\-\d{3}(?: |\-)?)?(0\d|\([0-9]{3}\)|[1-9]{0,3})(?:((?: |\-)[0-9]{2}){4}|((?:[0-9]{2}){4})|((?: |\-)[0-9]{3}(?: |\-)[0-9]{4})|([0-9]{7}))/; + +export function isEmailValid(email: string): boolean { + return VALID_EMAIL_REGEX.test(String(email).toLowerCase()); +} + +export function isPhoneValid(phone: string): boolean { + return VALID_PHONE_REGEX.test(String(phone)); +} + +function dateToIsoSupressMillis(dateTime: string) { + const options: ToISOTimeOptions = { suppressMilliseconds: true }; + return DateTime.fromISO(dateTime).toISO(options); +} + +export async function taskPostReceiceAction( + this: IExecuteSingleFunctions, + items: INodeExecutionData[], + _response: IN8nHttpFullResponse, +): Promise { + const contactId = this.getNodeParameter('contactId'); + items.forEach((item) => (item.json.contactId = contactId)); + return items; +} + +export async function dueDatePreSendAction( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + let dueDateParam = this.getNodeParameter('dueDate', null) as string; + if (!dueDateParam) { + const fields = this.getNodeParameter('updateFields') as { dueDate: string }; + dueDateParam = fields.dueDate; + } + if (!dueDateParam) { + throw new NodeApiError( + this.getNode(), + {}, + { message: 'dueDate is required', description: 'dueDate is required' }, + ); + } + const dueDate = dateToIsoSupressMillis(dueDateParam); + requestOptions.body = (requestOptions.body || {}) as object; + Object.assign(requestOptions.body, { dueDate }); + return requestOptions; +} + +export async function contactIdentifierPreSendAction( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + requestOptions.body = (requestOptions.body || {}) as object; + let identifier = this.getNodeParameter('contactIdentifier', null) as string; + if (!identifier) { + const fields = this.getNodeParameter('updateFields') as { contactIdentifier: string }; + identifier = fields.contactIdentifier; + } + if (isEmailValid(identifier)) { + Object.assign(requestOptions.body, { email: identifier }); + } else if (isPhoneValid(identifier)) { + Object.assign(requestOptions.body, { phone: identifier }); + } else { + Object.assign(requestOptions.body, { contactId: identifier }); + } + return requestOptions; +} + +export async function validEmailAndPhonePreSendAction( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const body = (requestOptions.body || {}) as { email?: string; phone?: string }; + + if (body.email && !isEmailValid(body.email)) { + const message = `email "${body.email}" has invalid format`; + throw new NodeApiError(this.getNode(), {}, { message, description: message }); + } + + if (body.phone && !isPhoneValid(body.phone)) { + const message = `phone "${body.phone}" has invalid format`; + throw new NodeApiError(this.getNode(), {}, { message, description: message }); + } + + return requestOptions; +} + +export async function dateTimeToEpochPreSendAction( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const qs = (requestOptions.qs || {}) as { + startDate?: string | number; + endDate?: string | number; + }; + const toEpoch = (dt: string) => new Date(dt).getTime(); + if (qs.startDate) qs.startDate = toEpoch(qs.startDate as string); + if (qs.endDate) qs.endDate = toEpoch(qs.endDate as string); + return requestOptions; +} + +export async function addLocationIdPreSendAction( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const { locationId } = + ((await this.getCredentials('highLevelOAuth2Api'))?.oauthTokenData as IDataObject) ?? {}; + + const resource = this.getNodeParameter('resource') as string; + const operation = this.getNodeParameter('operation') as string; + + if (resource === 'contact') { + if (operation === 'getAll') { + requestOptions.qs = requestOptions.qs || {}; + Object.assign(requestOptions.qs, { locationId }); + } + if (operation === 'create') { + requestOptions.body = requestOptions.body || {}; + Object.assign(requestOptions.body, { locationId }); + } + } + + if (resource === 'opportunity') { + if (operation === 'create') { + requestOptions.body = requestOptions.body || {}; + Object.assign(requestOptions.body, { locationId }); + } + if (operation === 'getAll') { + requestOptions.qs = requestOptions.qs || {}; + Object.assign(requestOptions.qs, { location_id: locationId }); + } + } + + return requestOptions; +} + +export async function highLevelApiRequest( + this: + | IExecuteFunctions + | IExecuteSingleFunctions + | IWebhookFunctions + | IPollFunctions + | IHookFunctions + | ILoadOptionsFunctions, + method: IHttpRequestMethods, + resource: string, + body: IDataObject = {}, + qs: IDataObject = {}, + url?: string, + option: IDataObject = {}, +) { + let options: IHttpRequestOptions = { + headers: { + 'Content-Type': 'application/json', + Version: '2021-07-28', + }, + method, + body, + qs, + url: url || `https://services.leadconnectorhq.com${resource}`, + json: true, + }; + if (!Object.keys(body).length) { + delete options.body; + } + if (!Object.keys(qs).length) { + delete options.qs; + } + options = Object.assign({}, options, option); + return await this.helpers.httpRequestWithAuthentication.call(this, 'highLevelOAuth2Api', options); +} + +export async function taskUpdatePreSendAction( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const body = (requestOptions.body || {}) as { title?: string; dueDate?: string }; + if (!body.title || !body.dueDate) { + const contactId = this.getNodeParameter('contactId'); + const taskId = this.getNodeParameter('taskId'); + const resource = `/contacts/${contactId}/tasks/${taskId}`; + const responseData = await highLevelApiRequest.call(this, 'GET', resource); + body.title = body.title || responseData.title; + // the api response dueDate has to be formatted or it will error on update + body.dueDate = body.dueDate || dateToIsoSupressMillis(responseData.dueDate as string); + requestOptions.body = body; + } + return requestOptions; +} + +export async function splitTagsPreSendAction( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const body = (requestOptions.body || {}) as IDataObject; + if (body.tags) { + if (Array.isArray(body.tags)) return requestOptions; + body.tags = (body.tags as string).split(',').map((tag) => tag.trim()); + } + return requestOptions; +} + +export async function highLevelApiPagination( + this: IExecutePaginationFunctions, + requestData: DeclarativeRestApiSettings.ResultOptions, +): Promise { + const responseData: INodeExecutionData[] = []; + const resource = this.getNodeParameter('resource') as string; + const returnAll = this.getNodeParameter('returnAll', false) as boolean; + + const resourceMapping: { [key: string]: string } = { + contact: 'contacts', + opportunity: 'opportunities', + }; + const rootProperty = resourceMapping[resource]; + + requestData.options.qs = requestData.options.qs || {}; + if (returnAll) requestData.options.qs.limit = 100; + + let responseTotal = 0; + + do { + const pageResponseData: INodeExecutionData[] = await this.makeRoutingRequest(requestData); + const items = pageResponseData[0].json[rootProperty] as []; + items.forEach((item) => responseData.push({ json: item })); + + const meta = pageResponseData[0].json.meta as IDataObject; + const startAfterId = meta.startAfterId as string; + const startAfter = meta.startAfter as number; + requestData.options.qs = { startAfterId, startAfter }; + responseTotal = (meta.total as number) || 0; + } while (returnAll && responseTotal > responseData.length); + + return responseData; +} + +export async function getPipelineStages( + this: ILoadOptionsFunctions, +): Promise { + const operation = this.getNodeParameter('operation') as string; + + let pipelineId = ''; + if (operation === 'create') { + pipelineId = this.getCurrentNodeParameter('pipelineId') as string; + } + if (operation === 'update') { + pipelineId = this.getNodeParameter('updateFields.pipelineId') as string; + } + if (operation === 'getAll') { + pipelineId = this.getNodeParameter('filters.pipelineId') as string; + } + + const { locationId } = + ((await this.getCredentials('highLevelOAuth2Api'))?.oauthTokenData as IDataObject) ?? {}; + + const pipelines = ( + await highLevelApiRequest.call(this, 'GET', '/opportunities/pipelines', undefined, { + locationId, + }) + ).pipelines as IDataObject[]; + + const pipeline = pipelines.find((p) => p.id === pipelineId); + if (pipeline) { + const options: INodePropertyOptions[] = (pipeline.stages as IDataObject[]).map((stage) => { + const name = stage.name as string; + const value = stage.id as string; + return { name, value }; + }); + return options; + } + return []; +} +export async function getPipelines(this: ILoadOptionsFunctions): Promise { + const { locationId } = + ((await this.getCredentials('highLevelOAuth2Api'))?.oauthTokenData as IDataObject) ?? {}; + const responseData = await highLevelApiRequest.call( + this, + 'GET', + '/opportunities/pipelines', + undefined, + { locationId }, + ); + + const pipelines = responseData.pipelines as [{ id: string; name: string; email: string }]; + const options: INodePropertyOptions[] = pipelines.map((pipeline) => { + const name = pipeline.name; + const value = pipeline.id; + return { name, value }; + }); + return options; +} + +export async function getContacts(this: ILoadOptionsFunctions): Promise { + const { locationId } = + ((await this.getCredentials('highLevelOAuth2Api'))?.oauthTokenData as IDataObject) ?? {}; + const responseData = await highLevelApiRequest.call(this, 'GET', '/contacts/', undefined, { + locationId, + }); + + const contacts = responseData.contacts as [{ id: string; name: string; email: string }]; + const options: INodePropertyOptions[] = contacts.map((contact) => { + const name = contact.email; + const value = contact.id; + return { name, value }; + }); + return options; +} + +export async function getUsers(this: ILoadOptionsFunctions): Promise { + const { locationId } = + ((await this.getCredentials('highLevelOAuth2Api'))?.oauthTokenData as IDataObject) ?? {}; + const responseData = await highLevelApiRequest.call(this, 'GET', '/users/', undefined, { + locationId, + }); + + const users = responseData.users as [{ id: string; name: string; email: string }]; + const options: INodePropertyOptions[] = users.map((user) => { + const name = user.name; + const value = user.id; + return { name, value }; + }); + return options; +} + +export async function getTimezones(this: ILoadOptionsFunctions): Promise { + const responseData = await highLevelApiRequest.call(this, 'GET', '/timezones'); + const timezones = responseData.timezones as string[]; + return timezones.map((zone) => ({ + name: zone, + value: zone, + })) as INodePropertyOptions[]; +} diff --git a/packages/nodes-base/nodes/HighLevel/v2/HighLevelV2.node.ts b/packages/nodes-base/nodes/HighLevel/v2/HighLevelV2.node.ts new file mode 100644 index 0000000000..8b22237076 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/HighLevelV2.node.ts @@ -0,0 +1,107 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import type { + INodeProperties, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { contactFields, contactNotes, contactOperations } from './description/ContactDescription'; +import { opportunityFields, opportunityOperations } from './description/OpportunityDescription'; +import { taskFields, taskOperations } from './description/TaskDescription'; +import { + getContacts, + getPipelines, + getPipelineStages, + getTimezones, + getUsers, + highLevelApiPagination, +} from './GenericFunctions'; + +const resources: INodeProperties[] = [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Contact', + value: 'contact', + }, + { + name: 'Opportunity', + value: 'opportunity', + }, + { + name: 'Task', + value: 'task', + }, + ], + default: 'contact', + required: true, + }, +]; + +const versionDescription: INodeTypeDescription = { + displayName: 'HighLevel', + name: 'highLevel', + icon: 'file:highLevel.svg', + group: ['transform'], + version: 2, + description: 'Consume HighLevel API v2', + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + defaults: { + name: 'HighLevel', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'highLevelOAuth2Api', + required: true, + }, + ], + requestDefaults: { + baseURL: 'https://services.leadconnectorhq.com', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Version: '2021-07-28', + }, + }, + requestOperations: { + pagination: highLevelApiPagination, + }, + properties: [ + ...resources, + ...contactOperations, + ...contactNotes, + ...contactFields, + ...opportunityOperations, + ...opportunityFields, + ...taskOperations, + ...taskFields, + ], +}; + +export class HighLevelV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { + loadOptions: { + getPipelines, + getContacts, + getPipelineStages, + getUsers, + getTimezones, + }, + }; +} diff --git a/packages/nodes-base/nodes/HighLevel/v2/description/ContactDescription.ts b/packages/nodes-base/nodes/HighLevel/v2/description/ContactDescription.ts new file mode 100644 index 0000000000..60122e192b --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/description/ContactDescription.ts @@ -0,0 +1,845 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { + addLocationIdPreSendAction, + splitTagsPreSendAction, + validEmailAndPhonePreSendAction, +} from '../GenericFunctions'; + +export const contactOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['contact'], + }, + }, + options: [ + { + name: 'Create or Update', + value: 'create', + routing: { + request: { + method: 'POST', + url: '/contacts/upsert/', + }, + send: { + preSend: [ + validEmailAndPhonePreSendAction, + splitTagsPreSendAction, + addLocationIdPreSendAction, + ], + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'contact', + }, + }, + ], + }, + }, + action: 'Create or update a contact', + }, + { + name: 'Delete', + value: 'delete', + routing: { + request: { + method: 'DELETE', + url: '=/contacts/{{$parameter.contactId}}/', + }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', + }, + }, + ], + }, + }, + action: 'Delete a contact', + }, + { + name: 'Get', + value: 'get', + routing: { + request: { + method: 'GET', + url: '=/contacts/{{$parameter.contactId}}/', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'contact', + }, + }, + ], + }, + }, + action: 'Get a contact', + }, + { + name: 'Get Many', + value: 'getAll', + routing: { + request: { + method: 'GET', + url: '=/contacts/', + }, + send: { + preSend: [addLocationIdPreSendAction], + paginate: true, + }, + }, + action: 'Get many contacts', + }, + { + name: 'Update', + value: 'update', + routing: { + request: { + method: 'PUT', + url: '=/contacts/{{$parameter.contactId}}/', + }, + send: { + preSend: [validEmailAndPhonePreSendAction, splitTagsPreSendAction], + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'contact', + }, + }, + ], + }, + }, + action: 'Update a contact', + }, + ], + default: 'create', + }, +]; + +export const contactNotes: INodeProperties[] = [ + { + displayName: + 'Create a new contact or update an existing one if email or phone matches (upsert)', + name: 'contactCreateNotice', + type: 'notice', + displayOptions: { + show: { + resource: ['contact'], + operation: ['create'], + }, + }, + default: '', + }, +]; + +const customFields: INodeProperties = { + displayName: 'Custom Fields', + name: 'customFields', + placeholder: 'Add Field', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'values', + displayName: 'Value', + values: [ + { + displayName: 'Field Name or ID', + name: 'fieldId', + type: 'options', + required: true, + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptions: { + routing: { + request: { + url: '/custom-fields', + method: 'GET', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'customFields', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.name}}', + value: '={{$responseItem.id}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + }, + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + routing: { + send: { + value: '={{$value}}', + property: '=customField.{{$parent.fieldId}}', + type: 'body', + }, + }, + }, + ], + }, + ], +}; + +const createProperties: INodeProperties[] = [ + { + displayName: 'Email', + name: 'email', + type: 'string', + placeholder: 'name@email.com', + description: 'Email or Phone are required to create contact', + displayOptions: { + show: { + resource: ['contact'], + operation: ['create'], + }, + }, + default: '', + routing: { + send: { + type: 'body', + property: 'email', + }, + }, + }, + { + displayName: 'Phone', + name: 'phone', + type: 'string', + description: + 'Phone or Email are required to create contact. Phone number has to start with a valid country code leading with + sign.', + placeholder: '+491234567890', + displayOptions: { + show: { + resource: ['contact'], + operation: ['create'], + }, + }, + default: '', + routing: { + send: { + type: 'body', + property: 'phone', + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['contact'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Address', + name: 'address1', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'address1', + }, + }, + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'city', + }, + }, + }, + customFields, + { + displayName: 'Do Not Disturb', + name: 'dnd', + description: + 'Whether automated/manual outbound messages are permitted to go out or not. True means NO outbound messages are permitted.', + type: 'boolean', + default: false, + routing: { + send: { + type: 'body', + property: 'dnd', + }, + }, + }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'firstName', + }, + }, + }, + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'lastName', + }, + }, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: 'e.g. John Deo', + description: + "The full name of the contact, will be overwritten by 'First Name' and 'Last Name' if set", + routing: { + send: { + type: 'body', + property: 'name', + }, + }, + }, + { + displayName: 'Postal Code', + name: 'postalCode', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'postalCode', + }, + }, + }, + { + displayName: 'Source', + name: 'source', + type: 'string', + default: '', + placeholder: 'e.g. Public API', + routing: { + send: { + type: 'body', + property: 'source', + }, + }, + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'state', + }, + }, + }, + { + displayName: 'Tags', + name: 'tags', + type: 'string', + hint: 'Comma separated list of tags, array of strings can be set in expression', + default: '', + routing: { + send: { + type: 'body', + property: 'tags', + }, + }, + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Timezone', + name: 'timezone', + type: 'options', + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + routing: { + send: { + type: 'body', + property: 'timezone', + }, + }, + }, + { + displayName: 'Website', + name: 'website', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'website', + }, + }, + }, + ], + }, +]; + +const updateProperties: INodeProperties[] = [ + { + displayName: 'Contact ID', + name: 'contactId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: ['contact'], + operation: ['update'], + }, + }, + default: '', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['contact'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Address', + name: 'address1', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'address1', + }, + }, + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'city', + }, + }, + }, + customFields, + { + displayName: 'Do Not Disturb', + name: 'dnd', + description: + 'Whether automated/manual outbound messages are permitted to go out or not. True means NO outbound messages are permitted.', + type: 'boolean', + default: false, + routing: { + send: { + type: 'body', + property: 'dnd', + }, + }, + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + placeholder: 'name@email.com', + default: '', + routing: { + send: { + type: 'body', + property: 'email', + }, + }, + }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'firstName', + }, + }, + }, + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'lastName', + }, + }, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + description: + "The full name of the contact, will be overwritten by 'First Name' and 'Last Name' if set", + default: 'e.g. John Deo', + routing: { + send: { + type: 'body', + property: 'name', + }, + }, + }, + { + displayName: 'Phone', + name: 'phone', + type: 'string', + default: '', + description: + 'Phone number has to start with a valid country code leading with + sign', + placeholder: '+491234567890', + routing: { + send: { + type: 'body', + property: 'phone', + }, + }, + }, + { + displayName: 'Postal Code', + name: 'postalCode', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'postalCode', + }, + }, + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'state', + }, + }, + }, + { + displayName: 'Tags', + name: 'tags', + type: 'string', + hint: 'Comma separated list of tags, array of strings can be set in expression', + default: '', + routing: { + send: { + type: 'body', + property: 'tags', + }, + }, + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Timezone', + name: 'timezone', + type: 'options', + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + routing: { + send: { + type: 'body', + property: 'timezone', + }, + }, + }, + { + displayName: 'Website', + name: 'website', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'website', + }, + }, + }, + ], + }, +]; + +const deleteProperties: INodeProperties[] = [ + { + displayName: 'Contact ID', + name: 'contactId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: ['contact'], + operation: ['delete'], + }, + }, + default: '', + }, +]; + +const getProperties: INodeProperties[] = [ + { + displayName: 'Contact ID', + name: 'contactId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: ['contact'], + operation: ['get'], + }, + }, + default: '', + }, +]; + +const getAllProperties: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: ['contact'], + operation: ['getAll'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['contact'], + operation: ['getAll'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 20, + routing: { + send: { + type: 'query', + property: 'limit', + }, + output: { + maxResults: '={{$value}}', // Set maxResults to the value of current parameter + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: ['contact'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + description: + 'Query will search on these fields: Name, Phone, Email, Tags, and Company Name', + routing: { + send: { + type: 'query', + property: 'query', + }, + }, + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: ['contact'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Order', + name: 'order', + type: 'options', + options: [ + { + name: 'Descending', + value: 'desc', + }, + { + name: 'Ascending', + value: 'asc', + }, + ], + default: 'desc', + routing: { + send: { + type: 'query', + property: 'order', + }, + }, + }, + { + displayName: 'Sort By', + name: 'sortBy', + type: 'options', + options: [ + { + name: 'Date Added', + value: 'date_added', + }, + { + name: 'Date Updated', + value: 'date_updated', + }, + ], + default: 'date_added', + routing: { + send: { + type: 'query', + property: 'sortBy', + }, + }, + }, + ], + }, +]; + +const lookupProperties: INodeProperties[] = [ + { + displayName: 'Email', + name: 'email', + type: 'string', + placeholder: 'name@email.com', + description: + 'Lookup Contact by Email. If Email is not found it will try to find a contact by phone.', + displayOptions: { + show: { + resource: ['contact'], + operation: ['lookup'], + }, + }, + default: '', + }, + { + displayName: 'Phone', + name: 'phone', + type: 'string', + description: + 'Lookup Contact by Phone. It will first try to find a contact by Email and than by Phone.', + displayOptions: { + show: { + resource: ['contact'], + operation: ['lookup'], + }, + }, + default: '', + }, +]; + +export const contactFields: INodeProperties[] = [ + ...createProperties, + ...updateProperties, + ...deleteProperties, + ...getProperties, + ...getAllProperties, + ...lookupProperties, +]; diff --git a/packages/nodes-base/nodes/HighLevel/v2/description/OpportunityDescription.ts b/packages/nodes-base/nodes/HighLevel/v2/description/OpportunityDescription.ts new file mode 100644 index 0000000000..debac10216 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/description/OpportunityDescription.ts @@ -0,0 +1,669 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { + addLocationIdPreSendAction, + dateTimeToEpochPreSendAction, + splitTagsPreSendAction, +} from '../GenericFunctions'; + +export const opportunityOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['opportunity'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + routing: { + send: { + preSend: [splitTagsPreSendAction, addLocationIdPreSendAction], + }, + request: { + method: 'POST', + url: '/opportunities/', + }, + }, + action: 'Create an opportunity', + }, + { + name: 'Delete', + value: 'delete', + routing: { + request: { + method: 'DELETE', + url: '=/opportunities/{{$parameter.opportunityId}}', + }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', + }, + }, + ], + }, + }, + action: 'Delete an opportunity', + }, + { + name: 'Get', + value: 'get', + routing: { + request: { + method: 'GET', + url: '=/opportunities/{{$parameter.opportunityId}}', + }, + }, + action: 'Get an opportunity', + }, + { + name: 'Get Many', + value: 'getAll', + routing: { + request: { + method: 'GET', + url: '/opportunities/search', + }, + send: { + preSend: [addLocationIdPreSendAction], + paginate: true, + }, + }, + action: 'Get many opportunities', + }, + { + name: 'Update', + value: 'update', + routing: { + request: { + method: 'PUT', + url: '=/opportunities/{{$parameter.opportunityId}}', + }, + }, + action: 'Update an opportunity', + }, + ], + default: 'create', + }, +]; + +const pipelineId: INodeProperties = { + displayName: 'Pipeline Name or ID', + name: 'pipelineId', + type: 'options', + displayOptions: { + show: { + resource: ['opportunity'], + operation: ['create'], + }, + }, + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: 'getPipelines', + }, + routing: { + send: { + type: 'body', + property: 'pipelineId', + }, + }, + default: '', +}; + +const createProperties: INodeProperties[] = [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Contact Email or ID', + name: 'contactId', + required: true, + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + hint: 'There can only be one opportunity for each contact', + displayOptions: { + show: { + resource: ['opportunity'], + operation: ['create'], + }, + }, + typeOptions: { + loadOptionsMethod: 'getContacts', + }, + default: '', + routing: { + send: { + type: 'body', + property: 'contactId', + }, + }, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + required: true, + displayOptions: { + show: { + resource: ['opportunity'], + operation: ['create'], + }, + }, + default: '', + routing: { + send: { + type: 'body', + property: 'name', + }, + }, + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + required: true, + displayOptions: { + show: { + resource: ['opportunity'], + operation: ['create'], + }, + }, + options: [ + { + name: 'Open', + value: 'open', + }, + { + name: 'Won', + value: 'won', + }, + { + name: 'Lost', + value: 'lost', + }, + { + name: 'Abandoned', + value: 'abandoned', + }, + ], + default: 'open', + routing: { + send: { + type: 'body', + property: 'status', + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['opportunity'], + operation: ['create'], + }, + }, + options: [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Assigned To', + name: 'assignedTo', + type: 'options', + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + routing: { + send: { + type: 'body', + property: 'assignedTo', + }, + }, + }, + { + displayName: 'Company Name', + name: 'companyName', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'companyName', + }, + }, + }, + { + displayName: 'Monetary Value', + name: 'monetaryValue', + type: 'number', + default: '', + description: 'Monetary value of lead opportunity', + routing: { + send: { + type: 'body', + property: 'monetaryValue', + }, + }, + }, + { + displayName: 'Tags', + name: 'tags', + type: 'string', + hint: 'Comma separated list of tags, array of strings can be set in expression', + default: '', + routing: { + send: { + type: 'body', + property: 'tags', + }, + }, + }, + { + displayName: 'Stage Name or ID', + name: 'stageId', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + + default: '', + typeOptions: { + loadOptionsDependsOn: ['/pipelineId'], + loadOptionsMethod: 'getPipelineStages', + }, + routing: { + send: { + type: 'body', + property: 'pipelineStageId', + }, + }, + }, + ], + }, +]; + +const deleteProperties: INodeProperties[] = [ + { + displayName: 'Opportunity ID', + name: 'opportunityId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: ['opportunity'], + operation: ['delete'], + }, + }, + default: '', + }, +]; + +const getProperties: INodeProperties[] = [ + { + displayName: 'Opportunity ID', + name: 'opportunityId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: ['opportunity'], + operation: ['get'], + }, + }, + default: '', + }, +]; + +const getAllProperties: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: ['opportunity'], + operation: ['getAll'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['opportunity'], + operation: ['getAll'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 20, + routing: { + send: { + type: 'query', + property: 'limit', + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: ['opportunity'], + operation: ['getAll'], + }, + }, + options: [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Assigned To', + name: 'assignedTo', + type: 'options', + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + routing: { + send: { + type: 'query', + property: 'assigned_to', + }, + }, + }, + { + displayName: 'Campaign ID', + name: 'campaignId', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'campaignId', + }, + }, + }, + { + displayName: 'End Date', + name: 'endDate', + type: 'dateTime', + default: '', + routing: { + send: { + type: 'query', + property: 'endDate', + preSend: [dateTimeToEpochPreSendAction], + }, + }, + }, + { + displayName: 'Pipeline Name or ID', + name: 'pipelineId', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: 'getPipelines', + }, + routing: { + send: { + type: 'query', + property: 'pipeline_id', + }, + }, + default: '', + }, + { + displayName: 'Stage Name or ID', + name: 'stageId', + type: 'options', + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + hint: "Select 'Pipeline Name or ID' first to see stages", + typeOptions: { + loadOptionsDependsOn: ['pipelineId'], + loadOptionsMethod: 'getPipelineStages', + }, + routing: { + send: { + type: 'query', + property: 'pipeline_stage_id', + }, + }, + }, + { + displayName: 'Start Date', + name: 'startDate', + type: 'dateTime', + default: '', + routing: { + send: { + type: 'query', + property: 'date', + preSend: [dateTimeToEpochPreSendAction], + }, + }, + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + options: [ + { + name: 'Open', + value: 'open', + }, + { + name: 'Won', + value: 'won', + }, + { + name: 'Lost', + value: 'lost', + }, + { + name: 'Abandoned', + value: 'abandoned', + }, + ], + default: 'open', + routing: { + send: { + type: 'query', + property: 'status', + }, + }, + }, + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + description: + 'Query will search on these fields: Name, Phone, Email, Tags, and Company Name', + routing: { + send: { + type: 'query', + property: 'q', + }, + }, + }, + ], + }, +]; + +const updateProperties: INodeProperties[] = [ + { + displayName: 'Opportunity ID', + name: 'opportunityId', + type: 'string', + required: true, + hint: "You cannot update an opportunity's pipeline ID.", + displayOptions: { + show: { + resource: ['opportunity'], + operation: ['update'], + }, + }, + default: '', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['opportunity'], + operation: ['update'], + }, + }, + options: [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Assigned To', + name: 'assignedTo', + type: 'options', + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + routing: { + send: { + type: 'body', + property: 'assignedTo', + }, + }, + }, + { + displayName: 'Monetary Value', + name: 'monetaryValue', + type: 'number', + default: '', + description: 'Monetary value of lead opportunity', + routing: { + send: { + type: 'body', + property: 'monetaryValue', + }, + }, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. John Deo', + routing: { + send: { + type: 'body', + property: 'name', + }, + }, + }, + { + displayName: 'Pipeline Name or ID', + name: 'pipelineId', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: 'getPipelines', + }, + routing: { + send: { + type: 'body', + property: 'pipelineId', + }, + }, + default: '', + }, + { + displayName: 'Stage Name or ID', + name: 'stageId', + type: 'options', + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + hint: "Select 'Pipeline Name or ID' first to see stages", + typeOptions: { + loadOptionsDependsOn: ['pipelineId'], + loadOptionsMethod: 'getPipelineStages', + }, + routing: { + send: { + type: 'body', + property: 'pipelineStageId', + }, + }, + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + options: [ + { + name: 'Open', + value: 'open', + }, + { + name: 'Won', + value: 'won', + }, + { + name: 'Lost', + value: 'lost', + }, + { + name: 'Abandoned', + value: 'abandoned', + }, + ], + default: 'open', + routing: { + send: { + type: 'body', + property: 'status', + }, + }, + }, + ], + }, +]; + +export const opportunityFields: INodeProperties[] = [ + pipelineId, + ...createProperties, + ...updateProperties, + ...deleteProperties, + ...getProperties, + ...getAllProperties, +]; diff --git a/packages/nodes-base/nodes/HighLevel/v2/description/TaskDescription.ts b/packages/nodes-base/nodes/HighLevel/v2/description/TaskDescription.ts new file mode 100644 index 0000000000..599c583a7c --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/description/TaskDescription.ts @@ -0,0 +1,491 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { + dueDatePreSendAction, + taskPostReceiceAction, + taskUpdatePreSendAction, +} from '../GenericFunctions'; + +export const taskOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['task'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + routing: { + request: { + method: 'POST', + url: '=/contacts/{{$parameter.contactId}}/tasks/', + }, + output: { + postReceive: [taskPostReceiceAction], + }, + }, + action: 'Create a task', + }, + { + name: 'Delete', + value: 'delete', + routing: { + request: { + method: 'DELETE', + url: '=/contacts/{{$parameter.contactId}}/tasks/{{$parameter.taskId}}/', + }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', + }, + }, + ], + }, + }, + action: 'Delete a task', + }, + { + name: 'Get', + value: 'get', + routing: { + request: { + method: 'GET', + url: '=/contacts/{{$parameter.contactId}}/tasks/{{$parameter.taskId}}/', + }, + output: { + postReceive: [taskPostReceiceAction], + }, + }, + action: 'Get a task', + }, + { + name: 'Get Many', + value: 'getAll', + routing: { + request: { + method: 'GET', + url: '=/contacts/{{$parameter.contactId}}/tasks/', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'tasks', + }, + }, + taskPostReceiceAction, + ], + }, + }, + action: 'Get many tasks', + }, + { + name: 'Update', + value: 'update', + routing: { + request: { + method: 'PUT', + url: '=/contacts/{{$parameter.contactId}}/tasks/{{$parameter.taskId}}/', + }, + send: { + preSend: [taskUpdatePreSendAction], + }, + output: { + postReceive: [taskPostReceiceAction], + }, + }, + action: 'Update a task', + }, + ], + default: 'create', + }, +]; + +const createProperties: INodeProperties[] = [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Contact Email or ID', + name: 'contactId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getContacts', + }, + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + default: '', + required: true, + description: + 'Contact the task belongs to. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + routing: { + send: { + type: 'body', + property: 'title', + }, + }, + }, + { + displayName: 'Due Date', + name: 'dueDate', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + routing: { + send: { + type: 'body', + property: 'dueDate', + preSend: [dueDatePreSendAction], + }, + }, + }, + { + displayName: 'Completed', + name: 'completed', + type: 'boolean', + required: true, + default: false, + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + routing: { + send: { + type: 'body', + property: 'completed', + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + options: [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Assigned To', + name: 'assignedTo', + type: 'options', + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + routing: { + send: { + type: 'body', + property: 'assignedTo', + }, + }, + }, + { + displayName: 'Body', + name: 'body', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'body', + }, + }, + }, + ], + }, +]; + +const deleteProperties: INodeProperties[] = [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Contact Email or ID', + name: 'contactId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getContacts', + }, + displayOptions: { + show: { + resource: ['task'], + operation: ['delete'], + }, + }, + default: '', + required: true, + description: + 'Contact the task belongs to. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Task ID', + name: 'taskId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: ['task'], + operation: ['delete'], + }, + }, + default: '', + }, +]; + +const getProperties: INodeProperties[] = [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Contact Email or ID', + name: 'contactId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getContacts', + }, + displayOptions: { + show: { + resource: ['task'], + operation: ['get'], + }, + }, + default: '', + required: true, + description: + 'Contact the task belongs to. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Task ID', + name: 'taskId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: ['task'], + operation: ['get'], + }, + }, + default: '', + }, +]; + +const getAllProperties: INodeProperties[] = [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Contact Email or ID', + name: 'contactId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getContacts', + }, + displayOptions: { + show: { + resource: ['task'], + operation: ['getAll'], + }, + }, + default: '', + required: true, + description: + 'Contact the task belongs to. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: ['task'], + operation: ['getAll'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['task'], + operation: ['getAll'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 20, + routing: { + send: { + type: 'query', + property: 'limit', + }, + }, + description: 'Max number of results to return', + }, +]; + +const updateProperties: INodeProperties[] = [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Contact Email or ID', + name: 'contactId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getContacts', + }, + displayOptions: { + show: { + resource: ['task'], + operation: ['update'], + }, + }, + default: '', + required: true, + description: + 'Contact the task belongs to. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Task ID', + name: 'taskId', + type: 'string', + displayOptions: { + show: { + resource: ['task'], + operation: ['update'], + }, + }, + default: '', + required: true, + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['task'], + operation: ['update'], + }, + }, + options: [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Assigned To', + name: 'assignedTo', + type: 'options', + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + routing: { + send: { + type: 'body', + property: 'assignedTo', + }, + }, + }, + { + displayName: 'Completed', + name: 'completed', + type: 'boolean', + default: false, + routing: { + send: { + type: 'body', + property: 'completed', + }, + }, + }, + { + displayName: 'Body', + name: 'body', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'body', + }, + }, + }, + { + displayName: 'Due Date', + name: 'dueDate', + type: 'dateTime', + default: '', + routing: { + send: { + type: 'body', + property: 'dueDate', + preSend: [dueDatePreSendAction], + }, + }, + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'title', + }, + }, + }, + ], + }, +]; + +export const taskFields: INodeProperties[] = [ + ...createProperties, + ...updateProperties, + ...deleteProperties, + ...getProperties, + ...getAllProperties, +];