diff --git a/packages/nodes-base/credentials/ConvertKitApi.credentials.ts b/packages/nodes-base/credentials/ConvertKitApi.credentials.ts new file mode 100644 index 0000000000..d7e8697555 --- /dev/null +++ b/packages/nodes-base/credentials/ConvertKitApi.credentials.ts @@ -0,0 +1,21 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class ConvertKitApi implements ICredentialType { + name = 'convertKitApi'; + displayName = 'ConvertKit API'; + properties = [ + { + displayName: 'API Secret', + name: 'apiSecret', + type: 'string' as NodePropertyTypes, + default: '', + typeOptions: { + password: true, + }, + }, + ]; +} diff --git a/packages/nodes-base/nodes/ConvertKit/ConvertKit.node.ts b/packages/nodes-base/nodes/ConvertKit/ConvertKit.node.ts new file mode 100644 index 0000000000..8c9870639e --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/ConvertKit.node.ts @@ -0,0 +1,486 @@ +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeTypeDescription, + INodeType, + INodePropertyOptions, +} from 'n8n-workflow'; + +import { + convertKitApiRequest, +} from './GenericFunctions'; + +import { + customFieldOperations, + customFieldFields, +} from './CustomFieldDescription'; + +import { + formOperations, + formFields, +} from './FormDescription'; + +import { + sequenceOperations, + sequenceFields, +} from './SequenceDescription'; + +import { + tagOperations, + tagFields, +} from './TagDescription'; + +import { + tagSubscriberOperations, + tagSubscriberFields, +} from './TagSubscriberDescription'; + +export class ConvertKit implements INodeType { + description: INodeTypeDescription = { + displayName: 'ConvertKit', + name: 'convertKit', + icon: 'file:convertKit.png', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume ConvertKit API.', + defaults: { + name: 'ConvertKit', + color: '#fb6970', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'convertKitApi', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Custom Field', + value: 'customField', + }, + { + name: 'Form', + value: 'form', + }, + { + name: 'Sequence', + value: 'sequence', + }, + { + name: 'Tag', + value: 'tag', + }, + { + name: 'Tag Subscriber', + value: 'tagSubscriber', + }, + ], + default: 'customField', + description: 'The resource to operate on.' + }, + //-------------------- + // Field Description + //-------------------- + ...customFieldOperations, + ...customFieldFields, + //-------------------- + // FormDescription + //-------------------- + ...formOperations, + ...formFields, + //-------------------- + // Sequence Description + //-------------------- + ...sequenceOperations, + ...sequenceFields, + //-------------------- + // Tag Description + //-------------------- + ...tagOperations, + ...tagFields, + //-------------------- + // Tag Subscriber Description + //-------------------- + ...tagSubscriberOperations, + ...tagSubscriberFields, + ], + }; + + methods = { + loadOptions: { + // Get all the tags to display them to user so that he can + // select them easily + async getTags(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { tags } = await convertKitApiRequest.call(this, 'GET', '/tags'); + for (const tag of tags) { + const tagName = tag.name; + const tagId = tag.id; + returnData.push({ + name: tagName, + value: tagId, + }); + } + + return returnData; + }, + // Get all the forms to display them to user so that he can + // select them easily + async getForms(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { forms } = await convertKitApiRequest.call(this, 'GET', '/forms'); + for (const form of forms) { + const formName = form.name; + const formId = form.id; + returnData.push({ + name: formName, + value: formId, + }); + } + + return returnData; + }, + + // Get all the sequences to display them to user so that he can + // select them easily + async getSequences(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { courses } = await convertKitApiRequest.call(this, 'GET', '/sequences'); + for (const course of courses) { + const courseName = course.name; + const courseId = course.id; + returnData.push({ + name: courseName, + value: courseId, + }); + } + + return returnData; + }, + } + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + + const qs: IDataObject = {}; + let responseData; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < items.length; i++) { + + if (resource === 'customField') { + if (operation === 'create') { + + const label = this.getNodeParameter('label', i) as string; + + responseData = await convertKitApiRequest.call(this, 'POST', '/custom_fields', { label }, qs); + } + if (operation === 'delete') { + + const id = this.getNodeParameter('id', i) as string; + + responseData = await convertKitApiRequest.call(this, 'DELETE', `/custom_fields/${id}`); + } + if (operation === 'get') { + + const id = this.getNodeParameter('id', i) as string; + + responseData = await convertKitApiRequest.call(this, 'GET', `/custom_fields/${id}`); + } + if (operation === 'getAll') { + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + responseData = await convertKitApiRequest.call(this, 'GET', `/custom_fields`); + + responseData = responseData.custom_fields; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + if (operation === 'update') { + + const id = this.getNodeParameter('id', i) as string; + + const label = this.getNodeParameter('label', i) as string; + + responseData = await convertKitApiRequest.call(this, 'PUT', `/custom_fields/${id}`, { label }); + + responseData = { success: true }; + } + } + + if (resource === 'form') { + if (operation === 'addSubscriber') { + + const email = this.getNodeParameter('email', i) as string; + + const formId = this.getNodeParameter('id', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const body: IDataObject = { + email, + }; + + if (additionalFields.firstName) { + body.first_name = additionalFields.firstName as string; + } + + if (additionalFields.tags) { + body.tags = additionalFields.tags as string[]; + } + + if (additionalFields.fieldsUi) { + const fieldValues = (additionalFields.fieldsUi as IDataObject).fieldsValues as IDataObject[]; + if (fieldValues) { + body.fields = {}; + for (const fieldValue of fieldValues) { + //@ts-ignore + body.fields[fieldValue.key] = fieldValue.value; + } + } + } + + const { subscription } = await convertKitApiRequest.call(this, 'POST', `/forms/${formId}/subscribe`, body); + + responseData = subscription; + } + if (operation === 'getAll') { + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + responseData = await convertKitApiRequest.call(this, 'GET', `/forms`); + + responseData = responseData.forms; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + if (operation === 'getSubscriptions') { + + const formId = this.getNodeParameter('id', i) as string; + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.subscriberState) { + qs.subscriber_state = additionalFields.subscriberState as string; + } + + responseData = await convertKitApiRequest.call(this, 'GET', `/forms/${formId}/subscriptions`, {}, qs); + + responseData = responseData.subscriptions; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + } + + if (resource === 'sequence') { + if (operation === 'addSubscriber') { + + const email = this.getNodeParameter('email', i) as string; + + const sequenceId = this.getNodeParameter('id', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const body: IDataObject = { + email, + }; + + if (additionalFields.firstName) { + body.first_name = additionalFields.firstName as string; + } + + if (additionalFields.tags) { + body.tags = additionalFields.tags as string[]; + } + + if (additionalFields.fieldsUi) { + const fieldValues = (additionalFields.fieldsUi as IDataObject).fieldsValues as IDataObject[]; + if (fieldValues) { + body.fields = {}; + for (const fieldValue of fieldValues) { + //@ts-ignore + body.fields[fieldValue.key] = fieldValue.value; + } + } + } + + const { subscription } = await convertKitApiRequest.call(this, 'POST', `/sequences/${sequenceId}/subscribe`, body); + + responseData = subscription; + } + if (operation === 'getAll') { + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + responseData = await convertKitApiRequest.call(this, 'GET', `/sequences`); + + responseData = responseData.courses; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + if (operation === 'getSubscriptions') { + + const sequenceId = this.getNodeParameter('id', i) as string; + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.subscriberState) { + qs.subscriber_state = additionalFields.subscriberState as string; + } + + responseData = await convertKitApiRequest.call(this, 'GET', `/sequences/${sequenceId}/subscriptions`, {}, qs); + + responseData = responseData.subscriptions; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + } + + if (resource === 'tag') { + if (operation === 'create') { + + const names = ((this.getNodeParameter('name', i) as string).split(',') as string[]).map((e) => ({ name: e })); + + const body: IDataObject = { + tag: names + }; + + responseData = await convertKitApiRequest.call(this, 'POST', '/tags', body); + } + + if (operation === 'getAll') { + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + responseData = await convertKitApiRequest.call(this, 'GET', `/tags`); + + responseData = responseData.tags; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + } + + if (resource === 'tagSubscriber') { + + if (operation === 'add') { + + const tagId = this.getNodeParameter('tagId', i) as string; + + const email = this.getNodeParameter('email', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const body: IDataObject = { + email, + }; + + if (additionalFields.firstName) { + body.first_name = additionalFields.firstName as string; + } + + if (additionalFields.fieldsUi) { + const fieldValues = (additionalFields.fieldsUi as IDataObject).fieldsValues as IDataObject[]; + if (fieldValues) { + body.fields = {}; + for (const fieldValue of fieldValues) { + //@ts-ignore + body.fields[fieldValue.key] = fieldValue.value; + } + } + } + + const { subscription } = await convertKitApiRequest.call(this, 'POST', `/tags/${tagId}/subscribe`, body); + + responseData = subscription; + } + + if (operation === 'getAll') { + + const tagId = this.getNodeParameter('tagId', i) as string; + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + responseData = await convertKitApiRequest.call(this, 'GET', `/tags/${tagId}/subscriptions`); + + responseData = responseData.subscriptions; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + + if (operation === 'delete') { + + const tagId = this.getNodeParameter('tagId', i) as string; + + const email = this.getNodeParameter('email', i) as string; + + responseData= await convertKitApiRequest.call(this, 'POST', `/tags/${tagId}>/unsubscribe`, { email }); + } + } + + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else if (responseData !== undefined) { + returnData.push(responseData as IDataObject); + } + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts b/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts new file mode 100644 index 0000000000..580bab7d56 --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts @@ -0,0 +1,373 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeTypeDescription, + INodeType, + IWebhookResponseData, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; + +import { + convertKitApiRequest, +} from './GenericFunctions'; + +import { + snakeCase, +} from 'change-case'; + +export class ConvertKitTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'ConvertKit Trigger', + name: 'convertKitTrigger', + icon: 'file:convertKit.png', + subtitle: '={{$parameter["event"]}}', + group: ['trigger'], + version: 1, + description: 'Handle ConvertKit events via webhooks', + defaults: { + name: 'ConvertKit Trigger', + color: '#fb6970', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'convertKitApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Event', + name: 'event', + type: 'options', + required: true, + default: '', + description: 'The events that can trigger the webhook and whether they are enabled.', + options: [ + { + name: 'Form Subscribe', + value: 'formSubscribe', + }, + { + name: 'Link Click', + value: 'linkClick', + }, + { + name: 'Product Purchase', + value: 'productPurchase', + }, + { + name: 'Purchase Created', + value: 'purchaseCreate', + }, + { + name: 'Sequence Complete', + value: 'courseComplete', + }, + { + name: 'Sequence Subscribe', + value: 'courseSubscribe', + }, + { + name: 'Subscriber Activated', + value: 'subscriberActivate', + }, + { + name: 'Subscriber Unsubscribe', + value: 'subscriberUnsubscribe', + }, + { + name: 'Tag Add', + value: 'tagAdd', + }, + { + name: 'Tag Remove', + value: 'tagRemove', + }, + ], + }, + { + displayName: 'Form ID', + name: 'formId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getForms', + }, + required: true, + default: '', + displayOptions: { + show: { + event: [ + 'formSubscribe', + ], + }, + }, + }, + { + displayName: 'Sequence ID', + name: 'courseId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSequences', + }, + required: true, + default: '', + displayOptions: { + show: { + event: [ + 'courseSubscribe', + 'courseComplete', + ], + }, + }, + }, + { + displayName: 'Initiating Link', + name: 'link', + type: 'string', + required: true, + default: '', + description: 'The URL of the initiating link', + displayOptions: { + show: { + event: [ + 'linkClick', + ], + }, + }, + }, + { + displayName: 'Product ID', + name: 'productId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + event: [ + 'productPurchase', + ], + }, + }, + }, + { + displayName: 'Tag ID', + name: 'tagId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTags', + }, + required: true, + default: '', + displayOptions: { + show: { + event: [ + 'tagAdd', + 'tagRemove', + ], + }, + }, + }, + ], + }; + + methods = { + loadOptions: { + // Get all the tags to display them to user so that he can + // select them easily + async getTags(this: ILoadOptionsFunctions): Promise { + + const returnData: INodePropertyOptions[] = []; + + const { tags } = await convertKitApiRequest.call(this, 'GET', '/tags'); + + for (const tag of tags) { + + const tagName = tag.name; + + const tagId = tag.id; + + returnData.push({ + name: tagName, + value: tagId, + }); + } + + return returnData; + }, + // Get all the forms to display them to user so that he can + // select them easily + async getForms(this: ILoadOptionsFunctions): Promise { + + const returnData: INodePropertyOptions[] = []; + + const { forms } = await convertKitApiRequest.call(this, 'GET', '/forms'); + + for (const form of forms) { + + const formName = form.name; + + const formId = form.id; + + returnData.push({ + name: formName, + value: formId, + }); + } + + return returnData; + }, + + // Get all the sequences to display them to user so that he can + // select them easily + async getSequences(this: ILoadOptionsFunctions): Promise { + + const returnData: INodePropertyOptions[] = []; + + const { courses } = await convertKitApiRequest.call(this, 'GET', '/sequences'); + + for (const course of courses) { + + const courseName = course.name; + + const courseId = course.id; + + returnData.push({ + name: courseName, + value: courseId, + }); + } + + return returnData; + }, + } + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + + const webhookData = this.getWorkflowStaticData('node'); + + // THe API does not have an endpoint to list all webhooks + + if(webhookData.webhookId) { + return true; + } + + return false; + }, + + async create(this: IHookFunctions): Promise { + + const webhookUrl = this.getNodeWebhookUrl('default'); + + let event = this.getNodeParameter('event', 0) as string; + + const endpoint = '/automations/hooks'; + + if (event === 'purchaseCreate') { + + event = `purchase.${snakeCase(event)}`; + + } else { + + event = `subscriber.${snakeCase(event)}`; + } + + const body: IDataObject = { + target_url: webhookUrl as string, + event: { + name: event + }, + }; + + if (event === 'subscriber.form_subscribe') { + //@ts-ignore + body.event['form_id'] = this.getNodeParameter('formId', 0); + } + + if (event === 'subscriber.course_subscribe' || event === 'subscriber.course_complete') { + //@ts-ignore + body.event['sequence_id'] = this.getNodeParameter('courseId', 0); + } + + if (event === 'subscriber.link_click') { + //@ts-ignore + body.event['initiator_value'] = this.getNodeParameter('link', 0); + } + + if (event === 'subscriber.product_purchase') { + //@ts-ignore + body.event['product_id'] = this.getNodeParameter('productId', 0); + } + + if (event === 'subscriber.tag_add' || event === 'subscriber.tag_remove') { + //@ts-ignore + body.event['tag_id'] = this.getNodeParameter('tagId', 0); + } + + const webhook = await convertKitApiRequest.call(this, 'POST', endpoint, body); + + if (webhook.rule.id === undefined) { + return false; + } + + const webhookData = this.getWorkflowStaticData('node'); + + webhookData.webhookId = webhook.rule.id as string; + + return true; + }, + + async delete(this: IHookFunctions): Promise { + + const webhookData = this.getWorkflowStaticData('node'); + + if (webhookData.webhookId !== undefined) { + + const endpoint = `/automations/hooks/${webhookData.webhookId}`; + + try { + + await convertKitApiRequest.call(this, 'DELETE', endpoint); + + } catch (error) { + + return false; + } + + delete webhookData.webhookId; + } + + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const returnData: IDataObject[] = []; + returnData.push(this.getBodyData()); + + return { + workflowData: [ + this.helpers.returnJsonArray(returnData), + ], + }; + } +} diff --git a/packages/nodes-base/nodes/ConvertKit/CustomFieldDescription.ts b/packages/nodes-base/nodes/ConvertKit/CustomFieldDescription.ts new file mode 100644 index 0000000000..a8541c6493 --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/CustomFieldDescription.ts @@ -0,0 +1,124 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const customFieldOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'customField', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a field', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a field', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all fields', + }, + { + name: 'Update', + value: 'update', + description: 'Update a field', + }, + ], + default: 'update', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const customFieldFields = [ + { + displayName: 'Field ID', + name: 'id', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'customField', + ], + operation: [ + 'update', + 'delete', + ], + }, + }, + default: '', + description: 'The ID of your custom field.', + }, + { + displayName: 'Label', + name: 'label', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'customField', + ], + operation: [ + 'update', + 'create', + ], + }, + }, + default: '', + description: 'The label of the custom field.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'customField', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'customField', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ConvertKit/FormDescription.ts b/packages/nodes-base/nodes/ConvertKit/FormDescription.ts new file mode 100644 index 0000000000..a8496805bc --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/FormDescription.ts @@ -0,0 +1,220 @@ +import { + INodeProperties +} from 'n8n-workflow'; + +export const formOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'form', + ], + }, + }, + options: [ + { + name: 'Add Subscriber', + value: 'addSubscriber', + description: 'Add a subscriber', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all forms', + }, + { + name: 'Get Subscriptions', + value: 'getSubscriptions', + description: 'List subscriptions to a form including subscriber data', + }, + ], + default: 'addSubscriber', + description: 'The operations to perform.', + }, +] as INodeProperties[]; + +export const formFields = [ + { + displayName: 'Form ID', + name: 'id', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getForms', + }, + required: true, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'addSubscriber', + 'getSubscriptions', + ], + }, + }, + default: '', + description: 'Form ID.', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'addSubscriber', + ], + }, + }, + default: '', + description: `The subscriber's email address.`, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'addSubscriber', + ], + }, + }, + options: [ + { + displayName: 'Custom Fields', + name: 'fieldsUi', + placeholder: 'Add Custom Field', + description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'fieldsValues', + displayName: 'Custom Field', + values: [ + { + displayName: 'Field Key', + name: 'key', + type: 'string', + default: '', + placeholder: 'last_name', + description: `The field's key.`, + }, + { + displayName: 'Field Value', + name: 'value', + type: 'string', + default: '', + placeholder: 'Doe', + description: 'Value of the field.', + }, + ], + }, + ], + }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: `The subscriber's first name.`, + }, + ], + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + 'getSubscriptions', + ], + resource: [ + 'form', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + 'getSubscriptions', + ], + resource: [ + 'form', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'getSubscriptions', + ], + }, + }, + options: [ + { + displayName: 'Subscriber State', + name: 'subscriberState', + type: 'options', + options: [ + { + name: 'Active', + value: 'active', + }, + { + name: 'Cancelled', + value: 'cancelled', + }, + ], + default: 'active', + }, + ], + description: 'Receive only active subscribers or cancelled subscribers.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts b/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts new file mode 100644 index 0000000000..81c8e43b32 --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts @@ -0,0 +1,67 @@ +import { + OptionsWithUri +} from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, + IExecuteSingleFunctions, +} from 'n8n-core'; + +import { + IDataObject, + IHookFunctions +} from 'n8n-workflow'; + +export async function convertKitApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IHookFunctions, + method: string, endpoint: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const credentials = this.getCredentials('convertKitApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + let options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + }, + method, + qs, + body, + uri: uri ||`https://api.convertkit.com/v3${endpoint}`, + json: true, + }; + + options = Object.assign({}, options, option); + + if (Object.keys(options.body).length === 0) { + delete options.body; + } + + // it's a webhook so include the api secret on the body + if ((options.uri as string).includes('/automations/hooks')) { + options.body['api_secret'] = credentials.apiSecret; + } else { + qs.api_secret = credentials.apiSecret; + } + + if (Object.keys(options.qs).length === 0) { + delete options.qs; + } + + try { + + return await this.helpers.request!(options); + + } catch (error) { + + let errorMessage = error; + + if (error.response && error.response.body && error.response.body.message) { + errorMessage = error.response.body.message; + } + + throw new Error(`ConvertKit error response: ${errorMessage}`); + } +} diff --git a/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts b/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts new file mode 100644 index 0000000000..42437cc155 --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts @@ -0,0 +1,230 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const sequenceOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'sequence', + ], + }, + }, + options: [ + { + name: 'Add Subscriber', + value: 'addSubscriber', + description: 'Add a subscriber', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all sequences', + }, + { + name: 'Get Subscriptions', + value: 'getSubscriptions', + description: 'Get all subscriptions to a sequence including subscriber data', + }, + ], + default: 'addSubscriber', + description: 'The operations to perform.', + }, +] as INodeProperties[]; + +export const sequenceFields = [ + { + displayName: 'Sequence ID', + name: 'id', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSequences', + }, + required: true, + displayOptions: { + show: { + resource: [ + 'sequence', + ], + operation: [ + 'addSubscriber', + 'getSubscriptions', + ], + }, + }, + default: '', + description: 'Sequence ID.', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'sequence', + ], + operation: [ + 'addSubscriber', + ], + }, + }, + default: '', + description: `The subscriber's email address.`, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + 'getSubscriptions', + ], + resource: [ + 'sequence', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + 'getSubscriptions', + ], + resource: [ + 'sequence', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'sequence', + ], + operation: [ + 'addSubscriber', + ], + }, + }, + options: [ + { + displayName: 'Custom Fields', + name: 'fieldsUi', + placeholder: 'Add Custom Field', + description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'fieldsValues', + displayName: 'Custom Field', + values: [ + { + displayName: 'Field Key', + name: 'key', + type: 'string', + default: '', + placeholder: 'last_name', + description: `The field's key.`, + }, + { + displayName: 'Field Value', + name: 'value', + type: 'string', + default: '', + placeholder: 'Doe', + description: 'Value of the field.', + }, + ], + }, + ], + }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: `The subscriber's first name.`, + }, + { + displayName: 'Tag IDs', + name: 'tags', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getTags', + }, + default: [], + description: 'Tags', + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'sequence', + ], + operation: [ + 'getSubscriptions', + ], + }, + }, + options: [ + { + displayName: 'Subscriber State', + name: 'subscriberState', + type: 'options', + options: [ + { + name: 'Active', + value: 'active', + }, + { + name: 'Cancelled', + value: 'cancelled', + }, + ], + default: 'active', + }, + ], + description: 'Receive only active subscribers or cancelled subscribers.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ConvertKit/TagDescription.ts b/packages/nodes-base/nodes/ConvertKit/TagDescription.ts new file mode 100644 index 0000000000..b0d18adcfc --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/TagDescription.ts @@ -0,0 +1,94 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const tagOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'tag', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a tag', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all tags', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const tagFields = [ + { + displayName: 'Name', + name: 'name', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'tag', + ], + operation: [ + 'create', + ], + }, + }, + default: '', + description: 'Tag name, multiple can be added separated by comma', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'tag', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'tag', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ConvertKit/TagSubscriberDescription.ts b/packages/nodes-base/nodes/ConvertKit/TagSubscriberDescription.ts new file mode 100644 index 0000000000..f625dfed83 --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/TagSubscriberDescription.ts @@ -0,0 +1,219 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const tagSubscriberOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'tagSubscriber', + ], + }, + }, + options: [ + { + name: 'Add', + value: 'add', + description: 'Add a tag to a subscriber', + }, + { + name: 'Get All', + value: 'getAll', + description: 'List subscriptions to a tag including subscriber data', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a tag from a subscriber', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const tagSubscriberFields = [ + { + displayName: 'Tag ID', + name: 'tagId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTags', + }, + required: true, + displayOptions: { + show: { + resource: [ + 'tagSubscriber', + ], + operation: [ + 'add', + 'getAll', + 'delete', + ], + }, + }, + default: '', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'tagSubscriber', + ], + operation: [ + 'add', + 'delete', + ], + }, + }, + default: '', + description: 'Subscriber email address.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'tagSubscriber', + ], + operation: [ + 'add', + ], + }, + }, + options: [ + { + displayName: 'Custom Fields', + name: 'fields', + placeholder: 'Add Custom Field', + description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'field', + displayName: 'Custom Field', + values: [ + { + displayName: 'Field Key', + name: 'key', + type: 'string', + default: '', + placeholder: 'last_name', + description: `The field's key.`, + }, + { + displayName: 'Field Value', + name: 'value', + type: 'string', + default: '', + placeholder: 'Doe', + description: 'Value of the field.', + }, + ], + }, + ], + }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: 'Subscriber first name.', + }, + ], + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'tagSubscriber', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'tagSubscriber', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'tagSubscriber', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Subscriber State', + name: 'subscriberState', + type: 'options', + options: [ + { + name: 'Active', + value: 'active', + }, + { + name: 'Cancelled', + value: 'cancelled', + }, + ], + default: 'active', + }, + ], + description: 'Receive only active subscribers or cancelled subscribers.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ConvertKit/convertKit.png b/packages/nodes-base/nodes/ConvertKit/convertKit.png new file mode 100644 index 0000000000..2dc9cf3ee5 Binary files /dev/null and b/packages/nodes-base/nodes/ConvertKit/convertKit.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index d7186000ca..acbba35985 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -48,6 +48,7 @@ "dist/credentials/CockpitApi.credentials.js", "dist/credentials/CodaApi.credentials.js", "dist/credentials/ContentfulApi.credentials.js", + "dist/credentials/ConvertKitApi.credentials.js", "dist/credentials/CopperApi.credentials.js", "dist/credentials/CalendlyApi.credentials.js", "dist/credentials/CustomerIoApi.credentials.js", @@ -207,6 +208,8 @@ "dist/nodes/Cockpit/Cockpit.node.js", "dist/nodes/Coda/Coda.node.js", "dist/nodes/Contentful/Contentful.node.js", + "dist/nodes/ConvertKit/ConvertKit.node.js", + "dist/nodes/ConvertKit/ConvertKitTrigger.node.js", "dist/nodes/Copper/CopperTrigger.node.js", "dist/nodes/CrateDb/CrateDb.node.js", "dist/nodes/Cron.node.js",