diff --git a/packages/nodes-base/credentials/ERPNextApi.credentials.ts b/packages/nodes-base/credentials/ERPNextApi.credentials.ts new file mode 100644 index 0000000000..d94210600e --- /dev/null +++ b/packages/nodes-base/credentials/ERPNextApi.credentials.ts @@ -0,0 +1,32 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class ERPNextApi implements ICredentialType { + name = 'erpNextApi'; + displayName = 'ERPNext API'; + documentationUrl = 'erpnext'; + properties = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'API Secret', + name: 'apiSecret', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Subdomain', + name: 'subdomain', + type: 'string' as NodePropertyTypes, + default: '', + placeholder: 'n8n', + description: 'ERPNext subdomain. For instance, entering n8n will make the url look like: https://n8n.erpnext.com/.', + }, + ]; +} diff --git a/packages/nodes-base/nodes/ERPNext/DocumentDescription.ts b/packages/nodes-base/nodes/ERPNext/DocumentDescription.ts new file mode 100644 index 0000000000..775c658cc9 --- /dev/null +++ b/packages/nodes-base/nodes/ERPNext/DocumentDescription.ts @@ -0,0 +1,463 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const documentOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'document', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a document.', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a document.', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a document.', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all documents.', + }, + { + name: 'Update', + value: 'update', + description: 'Update a document.', + }, + ], + default: 'create', + description: 'Operation to perform.', + }, +] as INodeProperties[]; + +export const documentFields = [ + // ---------------------------------- + // document: getAll + // ---------------------------------- + { + displayName: 'DocType', + name: 'docType', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDocTypes', + }, + default: '', + description: 'DocType whose documents to retrieve.', + placeholder: 'Customer', + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Return all items.', + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 10, + description: 'The number of results to return.', + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Fields', + name: 'fields', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getDocFilters', + loadOptionsDependsOn: [ + 'docType', + ], + }, + default: '', + description: 'Comma-separated list of fields to return.', + placeholder: 'name,country', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'fixedCollection', + placeholder: 'Add Filter', + description: 'Custom Properties', + typeOptions: { + multipleValues: true, + }, + options: [ + { + displayName: 'Property', + name: 'customProperty', + values: [ + { + displayName: 'Field', + name: 'field', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDocFields', + loadOptionsDependsOn: [ + 'docType', + ], + }, + default: '', + }, + { + displayName: 'Operator', + name: 'operator', + type: 'options', + default: 'is', + options: [ + { + name: 'IS', + value: 'is', + }, + { + name: 'IS NOT', + value: 'isNot', + }, + { + name: 'IS GREATER', + value: 'greater', + }, + { + name: 'IS LESS', + value: 'less', + }, + { + name: 'EQUALS, or GREATER', + value: 'equalsGreater', + }, + { + name: 'EQUALS, or LESS', + value: 'equalsLess', + }, + ], + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value of the operator condition.', + }, + ], + }, + ], + }, + ], + }, + + // ---------------------------------- + // document: create + // ---------------------------------- + { + displayName: 'DocType', + name: 'docType', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDocTypes', + }, + required: true, + description: 'DocType you would like to create.', + placeholder: 'Customer', + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Properties', + name: 'properties', + type: 'fixedCollection', + placeholder: 'Add Property', + required: true, + default: {}, + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Property', + name: 'customProperty', + placeholder: 'Add Property', + values: [ + { + displayName: 'Field', + name: 'field', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDocFields', + loadOptionsDependsOn: [ + 'docType', + ], + }, + default: [], + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + }, + + // ---------------------------------- + // document: get + // ---------------------------------- + { + displayName: 'DocType', + name: 'docType', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDocTypes', + }, + default: '', + description: 'The type of document you would like to get.', + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + }, + { + displayName: 'Document Name', + name: 'documentName', + type: 'string', + default: '', + description: 'The name (ID) of document you would like to get.', + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + }, + + // ---------------------------------- + // document: delete + // ---------------------------------- + { + displayName: 'DocType', + name: 'docType', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDocTypes', + }, + default: '', + description: 'The type of document you would like to delete.', + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'delete', + ], + }, + }, + required: true, + }, + { + displayName: 'Document Name', + name: 'documentName', + type: 'string', + default: '', + description: 'The name (ID) of document you would like to get.', + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'delete', + ], + }, + }, + required: true, + }, + + // ---------------------------------- + // document: update + // ---------------------------------- + { + displayName: 'DocType', + name: 'docType', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDocTypes', + }, + default: '', + description: 'The type of document you would like to update', + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'update', + ], + }, + }, + required: true, + }, + { + displayName: 'Document Name', + name: 'documentName', + type: 'string', + default: '', + description: 'The name (ID) of document you would like to get.', + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'update', + ], + }, + }, + required: true, + }, + { + displayName: 'Properties', + name: 'properties', + type: 'fixedCollection', + placeholder: 'Add Property', + description: 'Properties of request body.', + default: {}, + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Property', + name: 'customProperty', + values: [ + { + displayName: 'Field', + name: 'field', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDocFields', + loadOptionsDependsOn: [ + 'docType', + ], + }, + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ERPNext/ERPNext.node.ts b/packages/nodes-base/nodes/ERPNext/ERPNext.node.ts new file mode 100644 index 0000000000..b5fc28a8bb --- /dev/null +++ b/packages/nodes-base/nodes/ERPNext/ERPNext.node.ts @@ -0,0 +1,268 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + documentFields, + documentOperations, +} from './DocumentDescription'; + +import { + erpNextApiRequest, + erpNextApiRequestAllItems +} from './GenericFunctions'; + +import { + DocumentProperties, + processNames, + toSQL, +} from './utils'; + +export class ERPNext implements INodeType { + description: INodeTypeDescription = { + displayName: 'ERPNext', + name: 'erpNext', + icon: 'file:erpnext.svg', + group: ['output'], + version: 1, + subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}', + description: 'Consume ERPNext API', + defaults: { + name: 'ERPNext', + color: '#7574ff', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'erpNextApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Document', + value: 'document', + }, + ], + default: 'document', + description: 'Resource to consume.', + }, + ...documentOperations, + ...documentFields, + ], + }; + + methods = { + loadOptions: { + async getDocTypes(this: ILoadOptionsFunctions): Promise { + const data = await erpNextApiRequestAllItems.call(this, 'data', 'GET', '/api/resource/DocType', {}); + const docTypes = data.map(({ name }: { name: string }) => { + return { name, value: encodeURI(name) }; + }); + + return processNames(docTypes); + }, + async getDocFilters(this: ILoadOptionsFunctions): Promise { + const docType = this.getCurrentNodeParameter('docType') as string; + const { data } = await erpNextApiRequest.call(this, 'GET', `/api/resource/DocType/${docType}`, {}); + + const docFields = data.fields.map(({ label, fieldname }: { label: string, fieldname: string }) => { + return ({ name: label, value: fieldname }); + }); + + docFields.unshift({ name: '*', value: '*' }); + + return processNames(docFields); + }, + async getDocFields(this: ILoadOptionsFunctions): Promise { + const docType = this.getCurrentNodeParameter('docType') as string; + const { data } = await erpNextApiRequest.call(this, 'GET', `/api/resource/DocType/${docType}`, {}); + + const docFields = data.fields.map(({ label, fieldname }: { label: string, fieldname: string }) => { + return ({ name: label, value: fieldname }); + }); + + return processNames(docFields); + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const returnData: IDataObject[] = []; + let responseData; + + const body: IDataObject = {}; + const qs: IDataObject = {}; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < items.length; i++) { + + // https://app.swaggerhub.com/apis-docs/alyf.de/ERPNext/11#/Resources/post_api_resource_Webhook + // https://frappeframework.com/docs/user/en/guides/integration/rest_api/manipulating_documents + + if (resource === 'document') { + + // ********************************************************************* + // document + // ********************************************************************* + + if (operation === 'get') { + + // ---------------------------------- + // document: get + // ---------------------------------- + + // https://app.swaggerhub.com/apis-docs/alyf.de/ERPNext/11#/General/get_api_resource__DocType___DocumentName_ + + const docType = this.getNodeParameter('docType', i) as string; + const documentName = this.getNodeParameter('documentName', i) as string; + + responseData = await erpNextApiRequest.call(this, 'GET', `/api/resource/${docType}/${documentName}`); + responseData = responseData.data; + } + + if (operation === 'getAll') { + + // ---------------------------------- + // document: getAll + // ---------------------------------- + + // https://app.swaggerhub.com/apis-docs/alyf.de/ERPNext/11#/General/get_api_resource__DocType_ + + const docType = this.getNodeParameter('docType', i) as string; + const endpoint = `/api/resource/${docType}`; + + const { + fields, + filters, + } = this.getNodeParameter('options', i) as { + fields: string[], + filters: { + customProperty: Array<{ field: string, operator: string, value: string }>, + }, + }; + + // fields=["test", "example", "hi"] + if (fields) { + if (fields.includes('*')) { + qs.fields = JSON.stringify(['*']); + } else { + qs.fields = JSON.stringify(fields); + } + } + // filters=[["Person","first_name","=","Jane"]] + // TODO: filters not working + if (filters) { + qs.filters = JSON.stringify(filters.customProperty.map((filter) => { + return [ + docType, + filter.field, + toSQL(filter.operator), + filter.value, + ]; + })); + } + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (!returnAll) { + const limit = this.getNodeParameter('limit', i) as number; + qs.limit_page_length = limit; + qs.limit_start = 0; + responseData = await erpNextApiRequest.call(this, 'GET', endpoint, {}, qs); + responseData = responseData.data; + + } else { + responseData = await erpNextApiRequestAllItems.call(this, 'data', 'GET', endpoint, {}, qs); + } + + } else if (operation === 'create') { + + // ---------------------------------- + // document: create + // ---------------------------------- + + // https://app.swaggerhub.com/apis-docs/alyf.de/ERPNext/11#/General/post_api_resource__DocType_ + + const properties = this.getNodeParameter('properties', i) as DocumentProperties; + + if (!properties.customProperty.length) { + throw new Error('Please enter at least one property for the document to create.'); + } + + properties.customProperty.forEach(property => { + body[property.field] = property.value; + }); + + const docType = this.getNodeParameter('docType', i) as string; + + responseData = await erpNextApiRequest.call(this, 'POST', `/api/resource/${docType}`, body); + responseData = responseData.data; + + } else if (operation === 'delete') { + + // ---------------------------------- + // document: delete + // ---------------------------------- + + // https://app.swaggerhub.com/apis-docs/alyf.de/ERPNext/11#/General/delete_api_resource__DocType___DocumentName_ + + const docType = this.getNodeParameter('docType', i) as string; + const documentName = this.getNodeParameter('documentName', i) as string; + + responseData = await erpNextApiRequest.call(this, 'DELETE', `/api/resource/${docType}/${documentName}`); + + } else if (operation === 'update') { + + // ---------------------------------- + // document: update + // ---------------------------------- + + // https://app.swaggerhub.com/apis-docs/alyf.de/ERPNext/11#/General/put_api_resource__DocType___DocumentName_ + + const properties = this.getNodeParameter('properties', i) as DocumentProperties; + + if (!properties.customProperty.length) { + throw new Error('Please enter at least one property for the document to update.'); + } + + properties.customProperty.forEach(property => { + body[property.field] = property.value; + }); + + const docType = this.getNodeParameter('docType', i) as string; + const documentName = this.getNodeParameter('documentName', i) as string; + + responseData = await erpNextApiRequest.call(this, 'PUT', `/api/resource/${docType}/${documentName}`, body); + responseData = responseData.data; + + } + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData); + + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/ERPNext/GenericFunctions.ts b/packages/nodes-base/nodes/ERPNext/GenericFunctions.ts new file mode 100644 index 0000000000..b8da3d99d6 --- /dev/null +++ b/packages/nodes-base/nodes/ERPNext/GenericFunctions.ts @@ -0,0 +1,107 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + IHookFunctions, + IWebhookFunctions +} from 'n8n-workflow'; + +export async function erpNextApiRequest( + this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, + method: string, + resource: string, + body: IDataObject = {}, + query: IDataObject = {}, + uri?: string, + option: IDataObject = {}, +) { + + const credentials = this.getCredentials('erpNextApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + let options: OptionsWithUri = { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + Authorization: `token ${credentials.apiKey}:${credentials.apiSecret}`, + }, + method, + body, + qs: query, + uri: uri || `https://${credentials.subdomain}.erpnext.com${resource}`, + json: true, + }; + + options = Object.assign({}, options, option); + + if (!Object.keys(options.body).length) { + delete options.body; + } + + if (!Object.keys(options.qs).length) { + delete options.qs; + } + try { + return await this.helpers.request!(options); + } catch (error) { + + if (error.statusCode === 403) { + throw new Error( + `ERPNext error response [${error.statusCode}]: DocType unavailable.`, + ); + } + + if (error.statusCode === 307) { + throw new Error( + `ERPNext error response [${error.statusCode}]: Please ensure the subdomain is correct.`, + ); + } + + let errorMessages; + if (error?.response?.body?._server_messages) { + const errors = JSON.parse(error.response.body._server_messages); + errorMessages = errors.map((e: string) => JSON.parse(e).message); + throw new Error( + `ARPNext error response [${error.statusCode}]: ${errorMessages.join('|')}`, + ); + } + + throw error; + } +} + +export async function erpNextApiRequestAllItems( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, + propertyName: string, + method: string, + resource: string, + body: IDataObject, + query: IDataObject = {}, +) { + // tslint:disable-next-line: no-any + const returnData: any[] = []; + + let responseData; + query!.limit_start = 0; + query!.limit_page_length = 1000; + + do { + responseData = await erpNextApiRequest.call(this, method, resource, body, query); + returnData.push.apply(returnData, responseData[propertyName]); + query!.limit_start += query!.limit_page_length - 1; + } while ( + responseData.data.length > 0 + ); + + return returnData; +} diff --git a/packages/nodes-base/nodes/ERPNext/erpnext.svg b/packages/nodes-base/nodes/ERPNext/erpnext.svg new file mode 100644 index 0000000000..3bf4e10dbd --- /dev/null +++ b/packages/nodes-base/nodes/ERPNext/erpnext.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/nodes-base/nodes/ERPNext/utils.ts b/packages/nodes-base/nodes/ERPNext/utils.ts new file mode 100644 index 0000000000..3d9852d8eb --- /dev/null +++ b/packages/nodes-base/nodes/ERPNext/utils.ts @@ -0,0 +1,30 @@ +import { + flow, + sortBy, + uniqBy, +} from 'lodash'; + +export type DocumentProperties = { + customProperty: Array<{ field: string; value: string; }>; +}; + +type DocFields = Array<{ name: string, value: string }>; + +const ensureName = (docFields: DocFields) => docFields.filter(o => o.name); +const sortByName = (docFields: DocFields) => sortBy(docFields, ['name']); +const uniqueByName = (docFields: DocFields) => uniqBy(docFields, o => o.name); + +export const processNames = flow(ensureName, sortByName, uniqueByName); + +export const toSQL = (operator: string) => { + const operators: { [key: string]: string } = { + 'is': '=', + 'isNot': '!=', + 'greater': '>', + 'less': '<', + 'equalsGreater': '>=', + 'equalsLess': '<=', + }; + + return operators[operator]; +}; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index b545998de8..55cc068a38 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -75,6 +75,7 @@ "dist/credentials/DropboxOAuth2Api.credentials.js", "dist/credentials/EgoiApi.credentials.js", "dist/credentials/EmeliaApi.credentials.js", + "dist/credentials/ERPNextApi.credentials.js", "dist/credentials/EventbriteApi.credentials.js", "dist/credentials/EventbriteOAuth2Api.credentials.js", "dist/credentials/FacebookGraphApi.credentials.js", @@ -339,6 +340,7 @@ "dist/nodes/Emelia/Emelia.node.js", "dist/nodes/Emelia/EmeliaTrigger.node.js", "dist/nodes/ErrorTrigger.node.js", + "dist/nodes/ERPNext/ERPNext.node.js", "dist/nodes/Eventbrite/EventbriteTrigger.node.js", "dist/nodes/ExecuteCommand.node.js", "dist/nodes/ExecuteWorkflow.node.js",