diff --git a/packages/nodes-base/credentials/HubspotDeveloperApi.credentials.ts b/packages/nodes-base/credentials/HubspotDeveloperApi.credentials.ts index d217d18e578..0402acb6802 100644 --- a/packages/nodes-base/credentials/HubspotDeveloperApi.credentials.ts +++ b/packages/nodes-base/credentials/HubspotDeveloperApi.credentials.ts @@ -7,6 +7,7 @@ const scopes = [ 'crm.schemas.companies.read', 'crm.objects.deals.read', 'crm.schemas.deals.read', + 'conversations.read', 'tickets', ]; diff --git a/packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts b/packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts index adfb55803d0..e84b0eea454 100644 --- a/packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts +++ b/packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts @@ -13,6 +13,32 @@ import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { hubspotApiRequest, propertyEvents } from './V1/GenericFunctions'; +export async function getEntityProperties( + this: ILoadOptionsFunctions, + endpoint: string, +): Promise { + const returnData: INodePropertyOptions[] = []; + const properties = await hubspotApiRequest.call(this, 'GET', endpoint, {}); + + if (!Array.isArray(properties)) { + throw new NodeOperationError( + this.getNode(), + `HubSpot returned an unexpected response while loading properties from "${endpoint}". Expected an array of properties.`, + ); + } + + for (const property of properties) { + if (typeof property?.label === 'string' && typeof property?.name === 'string') { + returnData.push({ + name: property.label, + value: property.name, + }); + } + } + + return returnData; +} + export class HubspotTrigger implements INodeType { description: INodeTypeDescription = { displayName: 'HubSpot Trigger', @@ -224,6 +250,52 @@ export class HubspotTrigger implements INodeType { default: '', required: true, }, + { + displayName: 'Property Name or ID', + name: 'property', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsDependsOn: ['ticket.propertyChange'], + loadOptionsMethod: 'getTicketProperties', + }, + displayOptions: { + show: { + name: ['ticket.propertyChange'], + }, + }, + default: '', + required: true, + }, + { + displayName: 'Property Name or ID', + name: 'property', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + options: [ + { + name: 'Assigned To', + value: 'assignedTo', + }, + { + name: 'Is Archived', + value: 'isArchived', + }, + { + name: 'Status', + value: 'status', + }, + ], + displayOptions: { + show: { + name: ['conversation.propertyChange'], + }, + }, + default: '', + required: true, + }, ], }, ], @@ -251,53 +323,17 @@ export class HubspotTrigger implements INodeType { methods = { loadOptions: { - // Get all the available contacts to display them to user so that they can - // select them easily async getContactProperties(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const endpoint = '/properties/v2/contacts/properties'; - const properties = await hubspotApiRequest.call(this, 'GET', endpoint, {}); - for (const property of properties) { - const propertyName = property.label; - const propertyId = property.name; - returnData.push({ - name: propertyName, - value: propertyId, - }); - } - return returnData; + return await getEntityProperties.call(this, '/properties/v2/contacts/properties'); }, - // Get all the available companies to display them to user so that they can - // select them easily async getCompanyProperties(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const endpoint = '/properties/v2/companies/properties'; - const properties = await hubspotApiRequest.call(this, 'GET', endpoint, {}); - for (const property of properties) { - const propertyName = property.label; - const propertyId = property.name; - returnData.push({ - name: propertyName, - value: propertyId, - }); - } - return returnData; + return await getEntityProperties.call(this, '/properties/v2/companies/properties'); }, - // Get all the available deals to display them to user so that they can - // select them easily async getDealProperties(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const endpoint = '/properties/v2/deals/properties'; - const properties = await hubspotApiRequest.call(this, 'GET', endpoint, {}); - for (const property of properties) { - const propertyName = property.label; - const propertyId = property.name; - returnData.push({ - name: propertyName, - value: propertyId, - }); - } - return returnData; + return await getEntityProperties.call(this, '/properties/v2/deals/properties'); + }, + async getTicketProperties(this: ILoadOptionsFunctions): Promise { + return await getEntityProperties.call(this, '/properties/v2/tickets/properties'); }, }, }; @@ -448,6 +484,9 @@ export class HubspotTrigger implements INodeType { if (subscriptionType.includes('ticket')) { bodyData[i].ticketId = bodyData[i].objectId; } + if (subscriptionType.includes('conversation')) { + bodyData[i].conversationId = bodyData[i].objectId; + } delete bodyData[i].objectId; } return { diff --git a/packages/nodes-base/nodes/Hubspot/V1/GenericFunctions.ts b/packages/nodes-base/nodes/Hubspot/V1/GenericFunctions.ts index dbca30617a1..a1ad331e204 100644 --- a/packages/nodes-base/nodes/Hubspot/V1/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Hubspot/V1/GenericFunctions.ts @@ -135,6 +135,8 @@ export const propertyEvents = [ 'contact.propertyChange', 'company.propertyChange', 'deal.propertyChange', + 'ticket.propertyChange', + 'conversation.propertyChange', ]; export const contactFields = [ diff --git a/packages/nodes-base/nodes/Hubspot/__test__/HubspotTrigger.node.test.ts b/packages/nodes-base/nodes/Hubspot/__test__/HubspotTrigger.node.test.ts new file mode 100644 index 00000000000..378062586cb --- /dev/null +++ b/packages/nodes-base/nodes/Hubspot/__test__/HubspotTrigger.node.test.ts @@ -0,0 +1,66 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { getEntityProperties } from '../HubspotTrigger.node'; +import { hubspotApiRequest } from '../V1/GenericFunctions'; + +jest.mock('../V1/GenericFunctions', () => ({ + hubspotApiRequest: jest.fn(), + propertyEvents: [], +})); + +const mockedHubspotApiRequest = jest.mocked(hubspotApiRequest); + +describe('HubspotTrigger getEntityProperties', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('maps HubSpot properties into options', async () => { + mockedHubspotApiRequest.mockResolvedValueOnce([ + { label: 'Email', name: 'email' }, + { label: 'First Name', name: 'firstname' }, + ]); + + const context = {} as ILoadOptionsFunctions; + const result = await getEntityProperties.call(context, '/properties/v2/contacts/properties'); + + expect(result).toEqual([ + { name: 'Email', value: 'email' }, + { name: 'First Name', value: 'firstname' }, + ]); + expect(mockedHubspotApiRequest).toHaveBeenCalledWith( + 'GET', + '/properties/v2/contacts/properties', + {}, + ); + expect(mockedHubspotApiRequest.mock.contexts[0]).toBe(context); + }); + + it('throws for non-array responses', async () => { + mockedHubspotApiRequest.mockResolvedValueOnce({ results: [] }); + const endpoint = '/properties/v2/contacts/properties'; + const context = { + getNode: jest.fn().mockReturnValue({}), + } as unknown as ILoadOptionsFunctions; + + await expect(getEntityProperties.call(context, endpoint)).rejects.toThrow( + `HubSpot returned an unexpected response while loading properties from "${endpoint}". Expected an array of properties.`, + ); + }); + + it('filters invalid property entries', async () => { + mockedHubspotApiRequest.mockResolvedValueOnce([ + { label: 'Valid', name: 'valid_name' }, + { label: 'Missing Name' }, + { name: 'missing_label' }, + { label: 123, name: 'bad_label_type' }, + ]); + + const result = await getEntityProperties.call( + {} as ILoadOptionsFunctions, + '/properties/v2/contacts/properties', + ); + + expect(result).toEqual([{ name: 'Valid', value: 'valid_name' }]); + }); +});