From 22816b3f343dc87d262b5acba604c0189c70fbf3 Mon Sep 17 00:00:00 2001 From: RomanDavydchuk Date: Fri, 31 Jul 2026 10:58:46 +0300 Subject: [PATCH] feat(Cal.com Trigger Node): Fully migrate to API v2 (#35055) --- .../credentials/CalApi.credentials.ts | 8 +- .../nodes-base/nodes/Cal/CalTrigger.node.ts | 299 ++---------------- .../nodes-base/nodes/Cal/GenericFunctions.ts | 31 +- .../nodes/Cal/test/CalTrigger.node.test.ts | 5 +- .../nodes/Cal/test/CalTriggerV2.node.test.ts | 206 ++++++++++++ .../nodes/Cal/test/GenericFunctions.test.ts | 60 ++++ .../nodes/Cal/v1/CalTriggerV1.node.ts | 289 +++++++++++++++++ .../nodes/Cal/v2/CalTriggerV2.node.ts | 287 +++++++++++++++++ 8 files changed, 899 insertions(+), 286 deletions(-) create mode 100644 packages/nodes-base/nodes/Cal/test/CalTriggerV2.node.test.ts create mode 100644 packages/nodes-base/nodes/Cal/test/GenericFunctions.test.ts create mode 100644 packages/nodes-base/nodes/Cal/v1/CalTriggerV1.node.ts create mode 100644 packages/nodes-base/nodes/Cal/v2/CalTriggerV2.node.ts diff --git a/packages/nodes-base/credentials/CalApi.credentials.ts b/packages/nodes-base/credentials/CalApi.credentials.ts index bfe5232f66d..c6bba45cfd2 100644 --- a/packages/nodes-base/credentials/CalApi.credentials.ts +++ b/packages/nodes-base/credentials/CalApi.credentials.ts @@ -31,16 +31,16 @@ export class CalApi implements ICredentialType { authenticate: IAuthenticateGeneric = { type: 'generic', properties: { - qs: { - apiKey: '={{$credentials.apiKey}}', + headers: { + Authorization: '=Bearer {{ $credentials.apiKey }}', }, }, }; test: ICredentialTestRequest = { request: { - baseURL: '={{$credentials.host}}', - url: '=/v1/memberships', + baseURL: '={{ $credentials.host }}', + url: '=/v2/me', }, }; } diff --git a/packages/nodes-base/nodes/Cal/CalTrigger.node.ts b/packages/nodes-base/nodes/Cal/CalTrigger.node.ts index 4c626c92d42..764209742b3 100644 --- a/packages/nodes-base/nodes/Cal/CalTrigger.node.ts +++ b/packages/nodes-base/nodes/Cal/CalTrigger.node.ts @@ -1,283 +1,26 @@ -import { randomBytes } from 'crypto'; -import { - type IDataObject, - type IHookFunctions, - type IWebhookFunctions, - type ILoadOptionsFunctions, - type INodePropertyOptions, - type INodeType, - type INodeTypeDescription, - type IWebhookResponseData, - NodeConnectionTypes, -} from 'n8n-workflow'; +import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; -import { verifySignature } from './CalTriggerHelpers'; -import { calApiRequest, sortOptionParameters } from './GenericFunctions'; +import { CalTriggerV1 } from './v1/CalTriggerV1.node'; +import { CalTriggerV2 } from './v2/CalTriggerV2.node'; -export class CalTrigger implements INodeType { - description: INodeTypeDescription = { - displayName: 'Cal.com Trigger', - name: 'calTrigger', - icon: { light: 'file:cal.svg', dark: 'file:cal.dark.svg' }, - group: ['trigger'], - version: [1, 2], - subtitle: '=Events: {{$parameter["events"].join(", ")}}', - description: 'Handle Cal.com events via webhooks', - defaults: { - name: 'Cal.com Trigger', - }, - inputs: [], - outputs: [NodeConnectionTypes.Main], - credentials: [ - { - name: 'calApi', - required: true, - }, - ], - webhooks: [ - { - name: 'default', - httpMethod: 'POST', - responseMode: 'onReceived', - path: 'webhook', - }, - ], - properties: [ - { - displayName: 'Events', - name: 'events', - type: 'multiOptions', - options: [ - { - name: 'Booking Cancelled', - value: 'BOOKING_CANCELLED', - description: 'Receive notifications when a Cal event is canceled', - }, - { - name: 'Booking Created', - value: 'BOOKING_CREATED', - description: 'Receive notifications when a new Cal event is created', - }, - { - name: 'Booking Rescheduled', - value: 'BOOKING_RESCHEDULED', - description: 'Receive notifications when a Cal event is rescheduled', - }, - { - name: 'Meeting Ended', - value: 'MEETING_ENDED', - description: 'Receive notifications when a Cal event or meeting has ended', - }, - ], - default: [], - required: true, - }, - { - displayName: 'API Version', - name: 'version', - type: 'options', - displayOptions: { - show: { - '@version': [1], - }, - }, - isNodeSetting: true, - options: [ - { - name: 'Before v2.0', - value: 1, - }, - { - name: 'v2.0 Onwards', - value: 2, - }, - ], - default: 1, - }, - { - displayName: 'API Version', - name: 'version', - type: 'options', - displayOptions: { - show: { - '@version': [2], - }, - }, - isNodeSetting: true, - options: [ - { - name: 'Before v2.0', - value: 1, - }, - { - name: 'v2.0 Onwards', - value: 2, - }, - ], - default: 2, - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Field', - default: {}, - options: [ - { - displayName: 'App ID', - name: 'appId', - type: 'string', - description: 'The ID of the App to monitor', - default: '', - }, - { - displayName: 'EventType Name or ID', - name: 'eventTypeId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getEventTypes', - }, - description: - 'The EventType to monitor. Choose from the list, or specify an ID using an expression.', - default: '', - }, - { - displayName: 'Payload Template', - name: 'payloadTemplate', - type: 'string', - description: 'Template to customize the webhook payload', - default: '', - typeOptions: { - rows: 4, - }, - }, - ], - }, - ], - }; - - methods = { - loadOptions: { - async getEventTypes(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const data = await calApiRequest.call(this, 'GET', '/event-types', {}); - - for (const item of data.event_types) { - returnData.push({ - name: item.title, - value: item.id, - }); - } - - return sortOptionParameters(returnData); - }, - }, - }; - - webhookMethods = { - default: { - async checkExists(this: IHookFunctions): Promise { - const version = this.getNodeParameter('version', 0) as number; - const webhookUrl = this.getNodeWebhookUrl('default'); - const webhookData = this.getWorkflowStaticData('node'); - const events = this.getNodeParameter('events') as string; - - // Check all the webhooks which exist already if it is identical to the - // one that is supposed to get created. - const data = - version === 2 - ? await calApiRequest.call(this, 'GET', '/webhooks', {}) - : await calApiRequest.call(this, 'GET', '/hooks', {}); - - for (const webhook of data.webhooks) { - if (webhook.subscriberUrl === webhookUrl) { - for (const event of events) { - if (!webhook.eventTriggers.includes(event)) { - return false; - } - } - // Set webhook-id to be sure that it can be deleted - webhookData.webhookId = webhook.id as string; - return true; - } - } - return false; - }, - async create(this: IHookFunctions): Promise { - const version = this.getNodeParameter('version', 0) as number; - const webhookData = this.getWorkflowStaticData('node'); - const subscriberUrl = this.getNodeWebhookUrl('default'); - const eventTriggers = this.getNodeParameter('events') as string; - const options = this.getNodeParameter('options'); - const active = true; - - const webhookSecret = randomBytes(32).toString('hex'); - - const body = { - subscriberUrl, - eventTriggers, - active, - secret: webhookSecret, - ...(options as object), - }; - - const responseData = - version === 2 - ? await calApiRequest.call(this, 'POST', '/webhooks', body) - : await calApiRequest.call(this, 'POST', '/hooks', body); - - if (responseData.webhook.id === undefined) { - // Required data is missing so was not successful - return false; - } - - webhookData.webhookId = responseData.webhook.id as string; - webhookData.webhookSecret = webhookSecret; - return true; - }, - async delete(this: IHookFunctions): Promise { - const version = this.getNodeParameter('version', 0) as number; - const webhookData = this.getWorkflowStaticData('node'); - if (webhookData.webhookId !== undefined) { - const endpoint = - version === 2 - ? `/webhooks/${webhookData.webhookId}` - : `/hooks/${webhookData.webhookId}`; - - try { - await calApiRequest.call(this, 'DELETE', endpoint); - } catch (error) { - return false; - } - - // Remove from the static workflow data so that it is clear - // that no webhooks are registered anymore - delete webhookData.webhookId; - delete webhookData.webhookSecret; - } - return true; - }, - }, - }; - - async webhook(this: IWebhookFunctions): Promise { - if (!verifySignature.call(this)) { - const res = this.getResponseObject(); - res.status(401).send('Unauthorized').end(); - return { - noWebhookResponse: true, - }; - } - - const req = this.getRequestObject(); - return { - workflowData: [ - this.helpers.returnJsonArray({ - triggerEvent: req.body.triggerEvent as string, - createdAt: req.body.createdAt as string, - ...(req.body.payload as IDataObject), - }), - ], +export class CalTrigger extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Cal.com Trigger', + name: 'calTrigger', + icon: { light: 'file:cal.svg', dark: 'file:cal.dark.svg' }, + group: ['trigger'], + description: 'Handle Cal.com events via webhooks', + defaultVersion: 3, }; + + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new CalTriggerV1(baseDescription), + 2: new CalTriggerV1(baseDescription), + 3: new CalTriggerV2(baseDescription), + }; + + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/Cal/GenericFunctions.ts b/packages/nodes-base/nodes/Cal/GenericFunctions.ts index e95eb42cfed..5874053e85e 100644 --- a/packages/nodes-base/nodes/Cal/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Cal/GenericFunctions.ts @@ -15,24 +15,51 @@ export async function calApiRequest( this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method: IHttpRequestMethods, resource: string, - body: any = {}, query: IDataObject = {}, option: IDataObject = {}, ): Promise { const credentials = await this.getCredentials('calApi'); + let options: IHttpRequestOptions = { + baseURL: credentials.host as string, + method, + body, + qs: { + ...query, + apiKey: credentials.apiKey, + }, + url: resource, + }; + options = Object.assign({}, options, option); + try { + return await this.helpers.httpRequest(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error as JsonObject); + } +} + +export async function calApiRequestV2( + this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + resource: string, + body: IDataObject = {}, + query: IDataObject = {}, + option: Partial = {}, +): Promise { + const credentials = await this.getCredentials('calApi'); let options: IHttpRequestOptions = { baseURL: credentials.host as string, method, body, qs: query, - url: resource, + url: `/v2${resource}`, }; if (!Object.keys(query).length) { delete options.qs; } + options = Object.assign({}, options, option); try { return await this.helpers.httpRequestWithAuthentication.call(this, 'calApi', options); diff --git a/packages/nodes-base/nodes/Cal/test/CalTrigger.node.test.ts b/packages/nodes-base/nodes/Cal/test/CalTrigger.node.test.ts index be1e39c091f..52b324ee0fe 100644 --- a/packages/nodes-base/nodes/Cal/test/CalTrigger.node.test.ts +++ b/packages/nodes-base/nodes/Cal/test/CalTrigger.node.test.ts @@ -4,6 +4,7 @@ import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow'; import { CalTrigger } from '../CalTrigger.node'; import { verifySignature } from '../CalTriggerHelpers'; import { calApiRequest } from '../GenericFunctions'; +import type { CalTriggerV1 } from '../v1/CalTriggerV1.node'; import type { Mock, Mocked } from 'vitest'; import type * as _importType0 from 'crypto'; @@ -15,7 +16,7 @@ vi.mock('crypto', async () => ({ })); describe('CalTrigger', () => { - let trigger: CalTrigger; + let trigger: CalTriggerV1; let mockHookFunctions: Pick< Mocked, 'getNodeWebhookUrl' | 'getNodeParameter' | 'getWorkflowStaticData' | 'helpers' @@ -27,7 +28,7 @@ describe('CalTrigger', () => { beforeEach(() => { vi.clearAllMocks(); - trigger = new CalTrigger(); + trigger = new CalTrigger().getNodeType(2) as CalTriggerV1; mockHookFunctions = { getNodeWebhookUrl: vi.fn(), diff --git a/packages/nodes-base/nodes/Cal/test/CalTriggerV2.node.test.ts b/packages/nodes-base/nodes/Cal/test/CalTriggerV2.node.test.ts new file mode 100644 index 00000000000..5147c6851ba --- /dev/null +++ b/packages/nodes-base/nodes/Cal/test/CalTriggerV2.node.test.ts @@ -0,0 +1,206 @@ +import { randomBytes } from 'crypto'; +import type * as crypto from 'crypto'; +import type { + IDataObject, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-workflow'; +import type { Mock } from 'vitest'; +import { mock } from 'vitest-mock-extended'; + +import { CalTrigger } from '../CalTrigger.node'; +import { verifySignature } from '../CalTriggerHelpers'; +import { calApiRequestV2 } from '../GenericFunctions'; +import type { CalTriggerV2 } from '../v2/CalTriggerV2.node'; + +vi.mock('../CalTriggerHelpers'); +vi.mock('../GenericFunctions', async () => ({ + ...(await vi.importActual('../GenericFunctions')), + calApiRequestV2: vi.fn(), +})); +vi.mock('crypto', async () => ({ + ...(await vi.importActual('crypto')), + randomBytes: vi.fn(), +})); + +describe('CalTriggerV2', () => { + const webhookUrl = 'https://example.com/webhook'; + const webhookSecret = 'a'.repeat(64); + const events = ['BOOKING_CREATED']; + + let trigger: CalTriggerV2; + let hookFunctions: ReturnType>; + let loadOptionsFunctions: ReturnType>; + let webhookFunctions: ReturnType>; + let webhookData: IDataObject; + let options: IDataObject; + + beforeEach(() => { + vi.clearAllMocks(); + trigger = new CalTrigger().getNodeType(3) as CalTriggerV2; + hookFunctions = mock(); + loadOptionsFunctions = mock(); + webhookFunctions = mock(); + webhookData = {}; + options = {}; + + hookFunctions.getNodeWebhookUrl.mockReturnValue(webhookUrl); + hookFunctions.getWorkflowStaticData.mockReturnValue(webhookData); + hookFunctions.getNodeParameter.mockImplementation((name: string) => { + if (name === 'events') return events; + if (name === 'options') return options; + return undefined; + }); + webhookFunctions.helpers = { + returnJsonArray: vi.fn((data) => data), + } as never; + + (randomBytes as Mock).mockReturnValue({ + toString: vi.fn().mockReturnValue(webhookSecret), + }); + }); + + it('loads event types using the required API version header', async () => { + (calApiRequestV2 as Mock).mockResolvedValue({ + data: [ + { id: 2, title: 'Team Meeting' }, + { id: 1, title: 'Consultation' }, + ], + }); + + const result = await trigger.methods.loadOptions.getEventTypes.call(loadOptionsFunctions); + + expect(calApiRequestV2).toHaveBeenCalledWith( + 'GET', + '/event-types', + {}, + {}, + { headers: { 'cal-api-version': '2024-06-14' } }, + ); + expect(result).toEqual([ + { name: 'Consultation', value: 1 }, + { name: 'Team Meeting', value: 2 }, + ]); + }); + + it('creates a user webhook with the v2 request shape', async () => { + (calApiRequestV2 as Mock).mockResolvedValue({ data: { id: 123 } }); + + const result = await trigger.webhookMethods.default.create.call(hookFunctions); + + expect(result).toBe(true); + expect(calApiRequestV2).toHaveBeenCalledWith('POST', '/webhooks', { + subscriberUrl: webhookUrl, + triggers: events, + active: true, + secret: webhookSecret, + }); + expect(webhookData).toEqual({ webhookId: 123, webhookSecret }); + }); + + it('creates an event-type webhook and stores its scope', async () => { + options = { eventTypeId: 42, payloadTemplate: '{"event":"{{type}}"}' }; + (calApiRequestV2 as Mock).mockResolvedValue({ data: { id: 456 } }); + + const result = await trigger.webhookMethods.default.create.call(hookFunctions); + + expect(result).toBe(true); + expect(calApiRequestV2).toHaveBeenCalledWith('POST', '/event-types/42/webhooks', { + subscriberUrl: webhookUrl, + triggers: events, + active: true, + secret: webhookSecret, + payloadTemplate: '{"event":"{{type}}"}', + }); + expect(webhookData).toEqual({ + webhookId: 456, + webhookSecret, + webhookEventTypeId: 42, + }); + }); + + it('reuses an exact active webhook match', async () => { + (calApiRequestV2 as Mock).mockResolvedValue({ + data: [ + { + id: 123, + subscriberUrl: webhookUrl, + triggers: events, + active: true, + payloadTemplate: null, + secret: webhookSecret, + }, + ], + }); + + const result = await trigger.webhookMethods.default.checkExists.call(hookFunctions); + + expect(result).toBe(true); + expect(calApiRequestV2).toHaveBeenCalledWith('GET', '/webhooks', {}, { take: 250, skip: 0 }); + expect(webhookData).toEqual({ webhookId: 123, webhookSecret }); + }); + + it('stops checking after ten full pages', async () => { + const fullPage = Array.from({ length: 250 }, (_, id) => ({ + id, + subscriberUrl: 'https://example.com/other-webhook', + triggers: events, + active: true, + payloadTemplate: null, + })); + (calApiRequestV2 as Mock).mockResolvedValue({ data: fullPage }); + + const result = await trigger.webhookMethods.default.checkExists.call(hookFunctions); + + expect(result).toBe(false); + expect(calApiRequestV2).toHaveBeenCalledTimes(10); + expect(calApiRequestV2).toHaveBeenLastCalledWith( + 'GET', + '/webhooks', + {}, + { take: 250, skip: 2250 }, + ); + }); + + it('deletes an event-type webhook using its stored scope', async () => { + webhookData.webhookId = 456; + webhookData.webhookSecret = webhookSecret; + webhookData.webhookEventTypeId = 42; + (calApiRequestV2 as Mock).mockResolvedValue({}); + + const result = await trigger.webhookMethods.default.delete.call(hookFunctions); + + expect(result).toBe(true); + expect(calApiRequestV2).toHaveBeenCalledWith('DELETE', '/event-types/42/webhooks/456'); + expect(webhookData).toEqual({}); + }); + + it.each([ + { + name: 'nested payload', + body: { + triggerEvent: 'BOOKING_CREATED', + createdAt: '2026-07-28T10:00:00Z', + payload: { bookingId: 123 }, + }, + expected: { + triggerEvent: 'BOOKING_CREATED', + createdAt: '2026-07-28T10:00:00Z', + bookingId: 123, + }, + }, + { + name: 'flat payload', + body: { triggerEvent: 'MEETING_ENDED', bookingId: 456 }, + expected: { triggerEvent: 'MEETING_ENDED', bookingId: 456 }, + }, + ])('returns the $name body', async ({ body, expected }) => { + (verifySignature as Mock).mockReturnValue(true); + webhookFunctions.getRequestObject.mockReturnValue({ body } as never); + + const result = await trigger.webhook.call(webhookFunctions); + + expect(result.workflowData).toEqual([expected]); + }); +}); diff --git a/packages/nodes-base/nodes/Cal/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Cal/test/GenericFunctions.test.ts new file mode 100644 index 00000000000..261efa7b347 --- /dev/null +++ b/packages/nodes-base/nodes/Cal/test/GenericFunctions.test.ts @@ -0,0 +1,60 @@ +import type { IHookFunctions } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import { calApiRequestV2 } from '../GenericFunctions'; + +describe('calApiRequestV2', () => { + const httpRequestWithAuthentication = vi.fn(); + const hookFunctions = { + getCredentials: vi.fn().mockResolvedValue({ host: 'https://api.cal.com' }), + getNode: vi.fn().mockReturnValue({}), + helpers: { + httpRequestWithAuthentication, + }, + } as unknown as IHookFunctions; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sends requests to the v2 API using authenticated requests', async () => { + const response = { status: 'success', data: [] }; + httpRequestWithAuthentication.mockResolvedValue(response); + + const result = await calApiRequestV2.call(hookFunctions, 'GET', '/webhooks'); + + expect(result).toEqual(response); + expect(httpRequestWithAuthentication).toHaveBeenCalledWith('calApi', { + baseURL: 'https://api.cal.com', + method: 'GET', + body: {}, + url: '/v2/webhooks', + }); + }); + + it('forwards body, query parameters, and request options', async () => { + httpRequestWithAuthentication.mockResolvedValue({ status: 'success' }); + const body = { triggers: ['BOOKING_CREATED'] }; + const query = { take: 250, skip: 0 }; + const requestOptions = { headers: { 'cal-api-version': '2024-06-14' } }; + + await calApiRequestV2.call(hookFunctions, 'POST', '/event-types', body, query, requestOptions); + + expect(httpRequestWithAuthentication).toHaveBeenCalledWith('calApi', { + baseURL: 'https://api.cal.com', + method: 'POST', + body, + qs: query, + url: '/v2/event-types', + headers: { 'cal-api-version': '2024-06-14' }, + }); + }); + + it('wraps request errors in NodeApiError', async () => { + httpRequestWithAuthentication.mockRejectedValue(new Error('Request failed')); + + await expect(calApiRequestV2.call(hookFunctions, 'GET', '/webhooks')).rejects.toThrow( + NodeApiError, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Cal/v1/CalTriggerV1.node.ts b/packages/nodes-base/nodes/Cal/v1/CalTriggerV1.node.ts new file mode 100644 index 00000000000..b87cb8d29b7 --- /dev/null +++ b/packages/nodes-base/nodes/Cal/v1/CalTriggerV1.node.ts @@ -0,0 +1,289 @@ +import { randomBytes } from 'crypto'; +import { + type IDataObject, + type IHookFunctions, + type IWebhookFunctions, + type ILoadOptionsFunctions, + type INodePropertyOptions, + type INodeType, + type INodeTypeBaseDescription, + type INodeTypeDescription, + type IWebhookResponseData, + NodeConnectionTypes, +} from 'n8n-workflow'; + +import { verifySignature } from '../CalTriggerHelpers'; +import { calApiRequest, sortOptionParameters } from '../GenericFunctions'; + +function getDescription(baseDescription: INodeTypeBaseDescription): INodeTypeDescription { + return { + ...baseDescription, + version: [1, 2], + subtitle: '=Events: {{$parameter["events"].join(", ")}}', + description: 'Handle Cal.com events via webhooks', + defaults: { + name: 'Cal.com Trigger', + }, + inputs: [], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'calApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Events', + name: 'events', + type: 'multiOptions', + options: [ + { + name: 'Booking Cancelled', + value: 'BOOKING_CANCELLED', + description: 'Receive notifications when a Cal event is canceled', + }, + { + name: 'Booking Created', + value: 'BOOKING_CREATED', + description: 'Receive notifications when a new Cal event is created', + }, + { + name: 'Booking Rescheduled', + value: 'BOOKING_RESCHEDULED', + description: 'Receive notifications when a Cal event is rescheduled', + }, + { + name: 'Meeting Ended', + value: 'MEETING_ENDED', + description: 'Receive notifications when a Cal event or meeting has ended', + }, + ], + default: [], + required: true, + }, + { + displayName: 'API Version', + name: 'version', + type: 'options', + displayOptions: { + show: { + '@version': [1], + }, + }, + isNodeSetting: true, + options: [ + { + name: 'Before v2.0', + value: 1, + }, + { + name: 'v2.0 Onwards', + value: 2, + }, + ], + default: 1, + }, + { + displayName: 'API Version', + name: 'version', + type: 'options', + displayOptions: { + show: { + '@version': [2], + }, + }, + isNodeSetting: true, + options: [ + { + name: 'Before v2.0', + value: 1, + }, + { + name: 'v2.0 Onwards', + value: 2, + }, + ], + default: 2, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'App ID', + name: 'appId', + type: 'string', + description: 'The ID of the App to monitor', + default: '', + }, + { + displayName: 'EventType Name or ID', + name: 'eventTypeId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getEventTypes', + }, + description: + 'The EventType to monitor. Choose from the list, or specify an ID using an expression.', + default: '', + }, + { + displayName: 'Payload Template', + name: 'payloadTemplate', + type: 'string', + description: 'Template to customize the webhook payload', + default: '', + typeOptions: { + rows: 4, + }, + }, + ], + }, + ], + }; +} + +export class CalTriggerV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = getDescription(baseDescription); + } + + methods = { + loadOptions: { + async getEventTypes(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const data = await calApiRequest.call(this, 'GET', '/event-types', {}); + + for (const item of data.event_types) { + returnData.push({ + name: item.title, + value: item.id, + }); + } + + return sortOptionParameters(returnData); + }, + }, + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const version = this.getNodeParameter('version', 0) as number; + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); + const events = this.getNodeParameter('events') as string; + + // Check all the webhooks which exist already if it is identical to the + // one that is supposed to get created. + const data = + version === 2 + ? await calApiRequest.call(this, 'GET', '/webhooks', {}) + : await calApiRequest.call(this, 'GET', '/hooks', {}); + + for (const webhook of data.webhooks) { + if (webhook.subscriberUrl === webhookUrl) { + for (const event of events) { + if (!webhook.eventTriggers.includes(event)) { + return false; + } + } + // Set webhook-id to be sure that it can be deleted + webhookData.webhookId = webhook.id as string; + return true; + } + } + return false; + }, + async create(this: IHookFunctions): Promise { + const version = this.getNodeParameter('version', 0) as number; + const webhookData = this.getWorkflowStaticData('node'); + const subscriberUrl = this.getNodeWebhookUrl('default'); + const eventTriggers = this.getNodeParameter('events') as string; + const options = this.getNodeParameter('options'); + const active = true; + + const webhookSecret = randomBytes(32).toString('hex'); + + const body = { + subscriberUrl, + eventTriggers, + active, + secret: webhookSecret, + ...(options as object), + }; + + const responseData = + version === 2 + ? await calApiRequest.call(this, 'POST', '/webhooks', body) + : await calApiRequest.call(this, 'POST', '/hooks', body); + + if (responseData.webhook.id === undefined) { + // Required data is missing so was not successful + return false; + } + + webhookData.webhookId = responseData.webhook.id as string; + webhookData.webhookSecret = webhookSecret; + return true; + }, + async delete(this: IHookFunctions): Promise { + const version = this.getNodeParameter('version', 0) as number; + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId !== undefined) { + const endpoint = + version === 2 + ? `/webhooks/${webhookData.webhookId}` + : `/hooks/${webhookData.webhookId}`; + + try { + await calApiRequest.call(this, 'DELETE', endpoint); + } catch (error) { + return false; + } + + // Remove from the static workflow data so that it is clear + // that no webhooks are registered anymore + delete webhookData.webhookId; + delete webhookData.webhookSecret; + } + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + if (!verifySignature.call(this)) { + const res = this.getResponseObject(); + res.status(401).send('Unauthorized').end(); + return { + noWebhookResponse: true, + }; + } + + const req = this.getRequestObject(); + return { + workflowData: [ + this.helpers.returnJsonArray({ + triggerEvent: req.body.triggerEvent as string, + createdAt: req.body.createdAt as string, + ...(req.body.payload as IDataObject), + }), + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Cal/v2/CalTriggerV2.node.ts b/packages/nodes-base/nodes/Cal/v2/CalTriggerV2.node.ts new file mode 100644 index 00000000000..9e5e67de769 --- /dev/null +++ b/packages/nodes-base/nodes/Cal/v2/CalTriggerV2.node.ts @@ -0,0 +1,287 @@ +import { isRecord } from '@n8n/utils/is-record'; +import { randomBytes } from 'crypto'; +import { + type IDataObject, + type IHookFunctions, + type IWebhookFunctions, + type ILoadOptionsFunctions, + type INodePropertyOptions, + type INodeType, + type INodeTypeBaseDescription, + type INodeTypeDescription, + type IWebhookResponseData, + NodeConnectionTypes, +} from 'n8n-workflow'; + +import { verifySignature } from '../CalTriggerHelpers'; +import { calApiRequestV2, sortOptionParameters } from '../GenericFunctions'; + +const WEBHOOK_PAGE_SIZE = 250; +const MAX_WEBHOOK_PAGES = 10; + +interface CalApiResponse { + data: T; +} + +interface CalEventType { + id: number; + title: string; +} + +interface CalWebhook { + id: number; + subscriberUrl: string; + triggers: string[]; + active: boolean; + payloadTemplate?: string | null; + secret?: string; +} + +function getWebhookEndpoint(eventTypeId: IDataObject[string]): string { + if (typeof eventTypeId === 'number' || (typeof eventTypeId === 'string' && eventTypeId)) { + return `/event-types/${encodeURIComponent(eventTypeId)}/webhooks`; + } + + return '/webhooks'; +} + +export class CalTriggerV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + version: 3, + subtitle: '=Events: {{$parameter["events"].join(", ")}}', + defaults: { + name: 'Cal.com Trigger', + }, + inputs: [], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'calApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Events', + name: 'events', + type: 'multiOptions', + options: [ + { + name: 'Booking Cancelled', + value: 'BOOKING_CANCELLED', + description: 'Receive notifications when a Cal event is canceled', + }, + { + name: 'Booking Created', + value: 'BOOKING_CREATED', + description: 'Receive notifications when a new Cal event is created', + }, + { + name: 'Booking Rescheduled', + value: 'BOOKING_RESCHEDULED', + description: 'Receive notifications when a Cal event is rescheduled', + }, + { + name: 'Meeting Ended', + value: 'MEETING_ENDED', + description: 'Receive notifications when a Cal event or meeting has ended', + }, + ], + default: [], + required: true, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'EventType Name or ID', + name: 'eventTypeId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getEventTypes', + }, + description: + 'The EventType to monitor. Choose from the list, or specify an ID using an expression.', + default: '', + }, + { + displayName: 'Payload Template', + name: 'payloadTemplate', + type: 'string', + description: 'Template to customize the webhook payload', + default: '', + typeOptions: { + rows: 4, + }, + }, + ], + }, + ], + }; + } + + methods = { + loadOptions: { + async getEventTypes(this: ILoadOptionsFunctions): Promise { + const response = await (calApiRequestV2>).call( + this, + 'GET', + '/event-types', + {}, + {}, + { headers: { 'cal-api-version': '2024-06-14' } }, + ); + return sortOptionParameters( + response.data.map(({ id, title }) => ({ name: title, value: id })), + ); + }, + }, + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); + const events = this.getNodeParameter('events') as string[]; + const options = this.getNodeParameter('options') as IDataObject; + const eventTypeId = options.eventTypeId; + const endpoint = getWebhookEndpoint(eventTypeId); + const payloadTemplate = + typeof options.payloadTemplate === 'string' ? options.payloadTemplate : ''; + + for (let page = 0; page < MAX_WEBHOOK_PAGES; page++) { + const skip = page * WEBHOOK_PAGE_SIZE; + const response = await (calApiRequestV2>).call( + this, + 'GET', + endpoint, + {}, + { take: WEBHOOK_PAGE_SIZE, skip }, + ); + + for (const webhook of response.data) { + if ( + webhook.subscriberUrl === webhookUrl && + webhook.active && + webhook.triggers.length === events.length && + events.every((event) => webhook.triggers.includes(event)) && + (webhook.payloadTemplate ?? '') === payloadTemplate + ) { + webhookData.webhookId = webhook.id; + if (webhook.secret) { + webhookData.webhookSecret = webhook.secret; + } else { + delete webhookData.webhookSecret; + } + if (eventTypeId !== undefined && eventTypeId !== '') { + webhookData.webhookEventTypeId = eventTypeId; + } else { + delete webhookData.webhookEventTypeId; + } + + return true; + } + } + + if (response.data.length < WEBHOOK_PAGE_SIZE) return false; + } + + return false; + }, + + async create(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const subscriberUrl = this.getNodeWebhookUrl('default'); + const events = this.getNodeParameter('events') as string[]; + const options = this.getNodeParameter('options') as IDataObject; + const eventTypeId = options.eventTypeId; + const endpoint = getWebhookEndpoint(eventTypeId); + const webhookSecret = randomBytes(32).toString('hex'); + const body: IDataObject = { + subscriberUrl, + triggers: events, + active: true, + secret: webhookSecret, + }; + + if (typeof options.payloadTemplate === 'string' && options.payloadTemplate) { + body.payloadTemplate = options.payloadTemplate; + } + + const response = await (calApiRequestV2>).call( + this, + 'POST', + endpoint, + body, + ); + if (response.data?.id === undefined) return false; + + webhookData.webhookId = response.data.id; + webhookData.webhookSecret = webhookSecret; + if (eventTypeId !== undefined && eventTypeId !== '') { + webhookData.webhookEventTypeId = eventTypeId; + } else { + delete webhookData.webhookEventTypeId; + } + + return true; + }, + + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId === undefined) return true; + + const endpoint = `${getWebhookEndpoint(webhookData.webhookEventTypeId)}/${webhookData.webhookId}`; + try { + await calApiRequestV2.call(this, 'DELETE', endpoint); + } catch { + return false; + } + + delete webhookData.webhookId; + delete webhookData.webhookSecret; + delete webhookData.webhookEventTypeId; + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + if (!verifySignature.call(this)) { + const res = this.getResponseObject(); + res.status(401).send('Unauthorized').end(); + return { noWebhookResponse: true }; + } + + const body = this.getRequestObject().body as IDataObject; + const payload = body.payload; + const output = isRecord(payload) + ? { + triggerEvent: body.triggerEvent, + createdAt: body.createdAt, + ...payload, + } + : body; + + return { + workflowData: [this.helpers.returnJsonArray(output)], + }; + } +}