UNPKG

n8n-nodes-close-crm

Version:

N8N community node for Close CRM integration with comprehensive lead, opportunity, task, note, call management, and enhanced triggers for workflow automation

1,064 lines (1,063 loc) 44.5 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.CloseTrigger = void 0; exports.evaluateCustomActivityWebhook = evaluateCustomActivityWebhook; const n8n_workflow_1 = require("n8n-workflow"); const GenericFunctions_1 = require("./GenericFunctions"); const crypto = __importStar(require("crypto")); function timingSafeEqual(a, b) { if (a.length !== b.length) { return false; } let result = 0; for (let i = 0; i < a.length; i++) { result |= a[i] ^ b[i]; } return result === 0; } function mapAccountSetupAction(action) { const mapping = { custom_field_lead_created: { object_type: 'custom_fields.lead', action: 'created' }, custom_field_lead_updated: { object_type: 'custom_fields.lead', action: 'updated' }, custom_field_lead_deleted: { object_type: 'custom_fields.lead', action: 'deleted' }, custom_field_contact_created: { object_type: 'custom_fields.contact', action: 'created' }, custom_field_contact_updated: { object_type: 'custom_fields.contact', action: 'updated' }, custom_field_contact_deleted: { object_type: 'custom_fields.contact', action: 'deleted' }, custom_field_opportunity_created: { object_type: 'custom_fields.opportunity', action: 'created', }, custom_field_opportunity_updated: { object_type: 'custom_fields.opportunity', action: 'updated', }, custom_field_opportunity_deleted: { object_type: 'custom_fields.opportunity', action: 'deleted', }, custom_field_activity_created: { object_type: 'custom_fields.activity', action: 'created' }, custom_field_activity_updated: { object_type: 'custom_fields.activity', action: 'updated' }, custom_field_activity_deleted: { object_type: 'custom_fields.activity', action: 'deleted' }, custom_activity_type_created: { object_type: 'custom_activity_type', action: 'created' }, custom_activity_type_updated: { object_type: 'custom_activity_type', action: 'updated' }, custom_activity_type_deleted: { object_type: 'custom_activity_type', action: 'deleted' }, status_lead_created: { object_type: 'status.lead', action: 'created' }, status_lead_updated: { object_type: 'status.lead', action: 'updated' }, status_lead_deleted: { object_type: 'status.lead', action: 'deleted' }, status_opportunity_created: { object_type: 'status.opportunity', action: 'created' }, status_opportunity_updated: { object_type: 'status.opportunity', action: 'updated' }, status_opportunity_deleted: { object_type: 'status.opportunity', action: 'deleted' }, membership_activated: { object_type: 'membership', action: 'activated' }, membership_deactivated: { object_type: 'membership', action: 'deactivated' }, group_created: { object_type: 'group', action: 'created' }, group_updated: { object_type: 'group', action: 'updated' }, group_deleted: { object_type: 'group', action: 'deleted' }, saved_search_created: { object_type: 'saved_search', action: 'created' }, saved_search_updated: { object_type: 'saved_search', action: 'updated' }, phone_number_created: { object_type: 'phone_number', action: 'created' }, phone_number_updated: { object_type: 'phone_number', action: 'updated' }, phone_number_deleted: { object_type: 'phone_number', action: 'deleted' }, }; return mapping[action] || null; } function getStatusValue(value) { if (typeof value !== 'string') { return undefined; } return value.trim().toLowerCase(); } function getCustomActivityAction(eventName) { const eventParts = eventName.split('.'); return eventParts[eventParts.length - 1] || ''; } function isCustomActivityEvent(eventName) { return (eventName.startsWith('activity.custom_activity.') || eventName.startsWith('custom_activity.')); } function getPreviousStatusFromPayload(payload) { var _a, _b, _c, _d, _e, _f; return ((_d = (_b = getStatusValue((_a = payload.data) === null || _a === void 0 ? void 0 : _a.old_status)) !== null && _b !== void 0 ? _b : getStatusValue((_c = payload.data) === null || _c === void 0 ? void 0 : _c.previous_status)) !== null && _d !== void 0 ? _d : getStatusValue((_f = (_e = payload.data) === null || _e === void 0 ? void 0 : _e.previous) === null || _f === void 0 ? void 0 : _f.status)); } function getEventData(payload) { if (payload.event && typeof payload.event === 'object') { const eventObject = payload.event; return eventObject.data; } return payload.data; } function getEventPreviousData(payload) { if (payload.event && typeof payload.event === 'object') { const eventObject = payload.event; return eventObject.previous_data; } return undefined; } function getEventObjectId(payload) { if (payload.event && typeof payload.event === 'object') { const eventObject = payload.event; return typeof eventObject.object_id === 'string' ? eventObject.object_id : undefined; } return undefined; } function getEventName(payload) { if (typeof payload.event === 'string') { return payload.event; } if (payload.event && typeof payload.event === 'object') { const eventObject = payload.event; const objectType = typeof eventObject.object_type === 'string' ? eventObject.object_type : ''; const action = typeof eventObject.action === 'string' ? eventObject.action : ''; if (objectType && action) { return `${objectType}.${action}`; } } return ''; } async function waitForCustomActivityPublished(context, activityId) { const maxAttempts = 24; const waitMs = 5000; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const activity = await GenericFunctions_1.closeApiRequest.call(context, 'GET', `/activity/custom/${activityId}/`); const status = getStatusValue(activity === null || activity === void 0 ? void 0 : activity.status); if (status === 'published') { return true; } } catch { // Ignore transient API errors during polling and continue trying. } if (attempt < maxAttempts - 1) { await new Promise((resolve) => setTimeout(resolve, waitMs)); } } return false; } async function fetchCustomActivity(context, activityId) { try { const activity = await GenericFunctions_1.closeApiRequest.call(context, 'GET', `/activity/custom/${activityId}/`); return activity; } catch { return null; } } function evaluateCustomActivityWebhook(payload, cachedStatus) { var _a, _b; const eventName = getEventName(payload); if (!isCustomActivityEvent(eventName)) { return { shouldEmit: true }; } const action = getCustomActivityAction(eventName); if (action === 'deleted') { return { shouldEmit: true }; } const eventData = getEventData(payload); const eventPreviousData = getEventPreviousData(payload); const eventObjectId = getEventObjectId(payload); const currentStatus = getStatusValue(eventData === null || eventData === void 0 ? void 0 : eventData.status); const activityId = typeof (eventData === null || eventData === void 0 ? void 0 : eventData.id) === 'string' ? eventData.id : eventObjectId; if (action === 'created') { return { shouldEmit: currentStatus === 'published', activityId, currentStatus, }; } if (action === 'updated') { const previousStatus = (_b = (_a = getStatusValue(eventPreviousData === null || eventPreviousData === void 0 ? void 0 : eventPreviousData.status)) !== null && _a !== void 0 ? _a : getPreviousStatusFromPayload(payload)) !== null && _b !== void 0 ? _b : getStatusValue(cachedStatus); return { shouldEmit: previousStatus === 'draft' && currentStatus === 'published', activityId, currentStatus, }; } return { shouldEmit: false, activityId, currentStatus }; } function buildEventsArray(triggerOn, actions) { const events = []; switch (triggerOn) { case 'lead': for (const action of actions) { if (action === 'status_change') { events.push({ object_type: 'activity.lead_status_change', action: 'created' }); } else { events.push({ object_type: 'lead', action }); } } break; case 'custom_activity': for (const action of actions) { events.push({ object_type: 'activity.custom_activity', action }); } break; case 'contact': for (const action of actions) { events.push({ object_type: 'contact', action }); } break; case 'opportunity': for (const action of actions) { if (action === 'status_change') { events.push({ object_type: 'activity.opportunity_status_change', action: 'created' }); } else { events.push({ object_type: 'opportunity', action }); } } break; case 'task': for (const action of actions) { if (action === 'completed') { events.push({ object_type: 'activity.task_completed', action: 'created' }); } else { events.push({ object_type: 'task.lead', action }); } } break; case 'email': for (const action of actions) { if (action.startsWith('template_')) { const templateAction = action.replace('template_', ''); events.push({ object_type: 'email_template', action: templateAction }); } else { events.push({ object_type: 'activity.email', action }); } } break; case 'meeting': for (const action of actions) { events.push({ object_type: 'activity.meeting', action }); } break; case 'call': for (const action of actions) { events.push({ object_type: 'activity.call', action }); } break; case 'sms': for (const action of actions) { events.push({ object_type: 'activity.sms', action }); } break; case 'export': for (const action of actions) { events.push({ object_type: 'export.lead', action }); } break; case 'bulk_action': for (const action of actions) { events.push({ object_type: 'bulk_action.delete', action }); events.push({ object_type: 'bulk_action.edit', action }); events.push({ object_type: 'bulk_action.email', action }); events.push({ object_type: 'bulk_action.sequence_subscription', action }); } break; case 'account_setup': for (const action of actions) { const eventMapping = mapAccountSetupAction(action); if (eventMapping) { events.push(eventMapping); } } break; } return events; } class CloseTrigger { constructor() { this.description = { displayName: 'Close CRM Trigger', name: 'closeTrigger', icon: 'file:close.svg', group: ['trigger'], version: 1, subtitle: '={{$parameter["event"]}}', description: 'Starts the workflow when Close CRM events occur', defaults: { name: 'Close CRM Trigger', }, inputs: [], outputs: ['main'], credentials: [ { name: 'closeApi', required: true, }, ], webhooks: [ { name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook', }, ], properties: [ { displayName: 'Event', name: 'event', type: 'options', options: [ { name: 'Lead', value: 'lead', }, { name: 'Contact', value: 'contact', }, { name: 'Opportunity', value: 'opportunity', }, { name: 'Task', value: 'task', }, { name: 'Custom Activity', value: 'custom_activity', }, { name: 'Email', value: 'email', }, { name: 'Meeting', value: 'meeting', }, { name: 'Call', value: 'call', }, { name: 'SMS', value: 'sms', }, { name: 'Export', value: 'export', }, { name: 'Bulk Action', value: 'bulk_action', }, { name: 'Account Setup', value: 'account_setup', }, ], default: 'lead', required: true, description: 'The entity type to trigger on', }, // Lead Actions { displayName: 'Actions', name: 'leadActions', type: 'multiOptions', displayOptions: { show: { event: ['lead'], }, }, options: [ { name: 'Lead Created', value: 'created', }, { name: 'Lead Updated', value: 'updated', }, { name: 'Lead Deleted', value: 'deleted', }, { name: 'Lead in New Status', value: 'status_change', }, ], default: ['created'], required: true, description: 'The actions to trigger on for leads', }, // Contact Actions { displayName: 'Actions', name: 'contactActions', type: 'multiOptions', displayOptions: { show: { event: ['contact'], }, }, options: [ { name: 'Contact Created', value: 'created', }, { name: 'Contact Updated', value: 'updated', }, { name: 'Contact Deleted', value: 'deleted', }, ], default: ['created'], required: true, description: 'The actions to trigger on for contacts', }, // Opportunity Actions { displayName: 'Actions', name: 'opportunityActions', type: 'multiOptions', displayOptions: { show: { event: ['opportunity'], }, }, options: [ { name: 'Opportunity Created', value: 'created', }, { name: 'Opportunity Updated', value: 'updated', }, { name: 'Opportunity Deleted', value: 'deleted', }, { name: 'Opportunity in New Status', value: 'status_change', }, ], default: ['created'], required: true, description: 'The actions to trigger on for opportunities', }, // Task Actions { displayName: 'Actions', name: 'taskActions', type: 'multiOptions', displayOptions: { show: { event: ['task'], }, }, options: [ { name: 'Task Created', value: 'created', }, { name: 'Task Updated', value: 'updated', }, { name: 'Task Deleted', value: 'deleted', }, { name: 'Task Completed', value: 'completed', }, ], default: ['created'], required: true, description: 'The actions to trigger on for tasks', }, // Custom Activity Actions { displayName: 'Actions', name: 'customActivityActions', type: 'multiOptions', displayOptions: { show: { event: ['custom_activity'], }, }, options: [ { name: 'Custom Activity Created', value: 'created', }, { name: 'Custom Activity Updated', value: 'updated', }, { name: 'Custom Activity Deleted', value: 'deleted', }, ], default: ['created'], required: true, description: 'The actions to trigger on for custom activities', }, // Email Actions { displayName: 'Actions', name: 'emailActions', type: 'multiOptions', displayOptions: { show: { event: ['email'], }, }, options: [ { name: 'Email Created', value: 'created', }, { name: 'Email Deleted', value: 'deleted', }, { name: 'Email Sent', value: 'sent', }, { name: 'Email Template Created', value: 'template_created', }, { name: 'Email Template Updated', value: 'template_updated', }, { name: 'Email Template Deleted', value: 'template_deleted', }, ], default: ['created'], required: true, description: 'The actions to trigger on for emails', }, // Meeting Actions { displayName: 'Actions', name: 'meetingActions', type: 'multiOptions', displayOptions: { show: { event: ['meeting'], }, }, options: [ { name: 'Meeting Created', value: 'created', }, { name: 'Meeting Updated', value: 'updated', }, { name: 'Meeting Deleted', value: 'deleted', }, { name: 'Meeting Scheduled', value: 'scheduled', }, { name: 'Meeting Started', value: 'started', }, { name: 'Meeting Completed', value: 'completed', }, { name: 'Meeting Canceled', value: 'canceled', }, ], default: ['created'], required: true, description: 'The actions to trigger on for meetings', }, // Call Actions { displayName: 'Actions', name: 'callActions', type: 'multiOptions', displayOptions: { show: { event: ['call'], }, }, options: [ { name: 'Call Created', value: 'created', }, { name: 'Call Deleted', value: 'deleted', }, { name: 'Call Answered', value: 'answered', }, { name: 'Call Completed', value: 'completed', }, ], default: ['created'], required: true, description: 'The actions to trigger on for calls', }, // SMS Actions { displayName: 'Actions', name: 'smsActions', type: 'multiOptions', displayOptions: { show: { event: ['sms'], }, }, options: [ { name: 'SMS Created', value: 'created', }, { name: 'SMS Updated', value: 'updated', }, { name: 'SMS Deleted', value: 'deleted', }, { name: 'SMS Sent', value: 'sent', }, ], default: ['created'], required: true, description: 'The actions to trigger on for SMS', }, // Export Actions { displayName: 'Actions', name: 'exportActions', type: 'multiOptions', displayOptions: { show: { event: ['export'], }, }, options: [ { name: 'Export Completed', value: 'completed', }, ], default: ['completed'], required: true, description: 'The actions to trigger on for exports', }, // Bulk Action Actions { displayName: 'Actions', name: 'bulkActionActions', type: 'multiOptions', displayOptions: { show: { event: ['bulk_action'], }, }, options: [ { name: 'Bulk Action Created', value: 'created', }, { name: 'Bulk Action Updated', value: 'updated', }, { name: 'Bulk Action Completed', value: 'completed', }, ], default: ['created'], required: true, description: 'The actions to trigger on for bulk actions', }, // Account Setup Actions { displayName: 'Actions', name: 'accountSetupActions', type: 'multiOptions', displayOptions: { show: { event: ['account_setup'], }, }, options: [ { name: 'Custom Field (Lead) Created', value: 'custom_field_lead_created', }, { name: 'Custom Field (Lead) Updated', value: 'custom_field_lead_updated', }, { name: 'Custom Field (Lead) Deleted', value: 'custom_field_lead_deleted', }, { name: 'Custom Field (Contact) Created', value: 'custom_field_contact_created', }, { name: 'Custom Field (Contact) Updated', value: 'custom_field_contact_updated', }, { name: 'Custom Field (Contact) Deleted', value: 'custom_field_contact_deleted', }, { name: 'Custom Field (Opportunity) Created', value: 'custom_field_opportunity_created', }, { name: 'Custom Field (Opportunity) Updated', value: 'custom_field_opportunity_updated', }, { name: 'Custom Field (Opportunity) Deleted', value: 'custom_field_opportunity_deleted', }, { name: 'Activity Custom Field Created', value: 'custom_field_activity_created', }, { name: 'Activity Custom Field Updated', value: 'custom_field_activity_updated', }, { name: 'Activity Custom Field Deleted', value: 'custom_field_activity_deleted', }, { name: 'Custom Activity Type Created', value: 'custom_activity_type_created', }, { name: 'Custom Activity Type Updated', value: 'custom_activity_type_updated', }, { name: 'Custom Activity Type Deleted', value: 'custom_activity_type_deleted', }, { name: 'Status (Lead) Created', value: 'status_lead_created', }, { name: 'Status (Lead) Updated', value: 'status_lead_updated', }, { name: 'Status (Lead) Deleted', value: 'status_lead_deleted', }, { name: 'Status (Opportunity) Created', value: 'status_opportunity_created', }, { name: 'Status (Opportunity) Updated', value: 'status_opportunity_updated', }, { name: 'Status (Opportunity) Deleted', value: 'status_opportunity_deleted', }, { name: 'Membership Activated', value: 'membership_activated', }, { name: 'Membership Deactivated', value: 'membership_deactivated', }, { name: 'Group Created', value: 'group_created', }, { name: 'Group Updated', value: 'group_updated', }, { name: 'Group Deleted', value: 'group_deleted', }, { name: 'Saved Search Created', value: 'saved_search_created', }, { name: 'Saved Search Updated', value: 'saved_search_updated', }, { name: 'Phone Number Created', value: 'phone_number_created', }, { name: 'Phone Number Updated', value: 'phone_number_updated', }, { name: 'Phone Number Deleted', value: 'phone_number_deleted', }, ], default: ['custom_field_lead_created'], required: true, description: 'The actions to trigger on for account setup changes', }, ], }; this.webhookMethods = { default: { async checkExists() { const webhookData = this.getWorkflowStaticData('node'); if (webhookData.webhookId === undefined) { return false; } try { const webhook = await GenericFunctions_1.closeApiRequest.call(this, 'GET', `/webhook/${webhookData.webhookId}`); const currentUrl = this.getNodeWebhookUrl('default'); if (currentUrl && webhook.url !== currentUrl) { try { await GenericFunctions_1.closeApiRequest.call(this, 'DELETE', `/webhook/${webhookData.webhookId}`); } catch { } delete webhookData.webhookId; delete webhookData.signatureKey; return false; } if (webhook.status === 'paused') { await GenericFunctions_1.closeApiRequest.call(this, 'PUT', `/webhook/${webhookData.webhookId}/`, { status: 'active', }); } return true; } catch { return false; } }, async create() { const webhookUrl = this.getNodeWebhookUrl('default'); const webhookData = this.getWorkflowStaticData('node'); const triggerOn = this.getNodeParameter('event'); // Convert event name to camelCase for parameter name const actionsParam = triggerOn .split('_') .map((word, index) => index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1)) .join('') + 'Actions'; let selectedActions = []; try { selectedActions = this.getNodeParameter(actionsParam); } catch { selectedActions = ['created']; } const events = buildEventsArray(triggerOn, selectedActions); const body = { url: webhookUrl, events, }; const postWebhook = async () => GenericFunctions_1.closeApiRequest.call(this, 'POST', '/webhook/', body); try { let responseData; try { responseData = await postWebhook(); } catch (error) { const errorMessage = error instanceof Error ? error.message : ''; const duplicateIds = Array.from(errorMessage.matchAll(/whsub_[A-Za-z0-9]+/g), (m) => m[0]); if (duplicateIds.length === 0) { throw error; } for (const duplicateId of duplicateIds) { try { await GenericFunctions_1.closeApiRequest.call(this, 'DELETE', `/webhook/${duplicateId}`); } catch { } } responseData = await postWebhook(); } if (responseData.id === undefined || responseData.signature_key === undefined) { return false; } webhookData.webhookId = responseData.id; webhookData.signatureKey = responseData.signature_key; return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Failed to create webhook: ${errorMessage}`); } }, async delete() { const webhookData = this.getWorkflowStaticData('node'); if (webhookData.webhookId !== undefined) { try { await GenericFunctions_1.closeApiRequest.call(this, 'DELETE', `/webhook/${webhookData.webhookId}`); } catch { } delete webhookData.webhookId; delete webhookData.signatureKey; } return true; }, }, }; } async webhook() { const req = this.getRequestObject(); const webhookData = this.getWorkflowStaticData('node'); const headerData = this.getHeaderData(); const signatureKey = webhookData.signatureKey; if (signatureKey) { const timestamp = headerData['close-sig-timestamp']; const receivedHash = headerData['close-sig-hash']; if (!timestamp || !receivedHash) { throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Missing signature headers from Close webhook'); } // Get the raw body - Close CRM requires the exact body string for signature verification let bodyString; // Try to get rawBody from request (this is set by n8n's body-parser middleware) if (req.rawBody !== undefined) { // rawBody can be either a Buffer or a string bodyString = typeof req.rawBody === 'string' ? req.rawBody : req.rawBody.toString(); } // Fallback: try body as string else if (typeof req.body === 'string') { bodyString = req.body; } // Last resort: stringify the parsed body // Note: This may fail signature verification if the JSON formatting differs else { bodyString = JSON.stringify(req.body); } const keyBuffer = Buffer.from(signatureKey, 'hex'); const expectedHash = crypto .createHmac('sha256', keyBuffer) .update(timestamp + bodyString) .digest('hex'); if (!timingSafeEqual(Buffer.from(receivedHash), Buffer.from(expectedHash))) { throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Webhook signature verification failed - invalid signature'); } } const payload = req.body; const eventName = getEventName(payload); if (isCustomActivityEvent(eventName)) { const statusByActivityId = webhookData.customActivityStatusById || {}; const eventData = getEventData(payload); const activityId = typeof (eventData === null || eventData === void 0 ? void 0 : eventData.id) === 'string' ? eventData.id : undefined; const cachedStatus = activityId ? statusByActivityId[activityId] : undefined; const evaluation = evaluateCustomActivityWebhook(payload, cachedStatus); if (evaluation.activityId && evaluation.currentStatus) { statusByActivityId[evaluation.activityId] = evaluation.currentStatus; webhookData.customActivityStatusById = statusByActivityId; } if (!evaluation.shouldEmit) { // Close may emit a draft update first and skip a dedicated publish update. // Poll the activity status briefly to emit as soon as it is actually submitted. if (evaluation.activityId) { const isNowPublished = await waitForCustomActivityPublished(this, evaluation.activityId); if (isNowPublished) { const freshActivity = await fetchCustomActivity(this, evaluation.activityId); if (freshActivity) { if (payload.event && typeof payload.event === 'object') { const eventObject = payload.event; payload.event = { ...eventObject, data: freshActivity, }; } else { payload.data = freshActivity; } } return { workflowData: [this.helpers.returnJsonArray(payload)], }; } } return { workflowData: [[]], }; } } return { workflowData: [this.helpers.returnJsonArray(req.body)], }; } } exports.CloseTrigger = CloseTrigger;