mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
⚡ Expand Zoho node (#1763)
* ⚡ Initial refactor of Zoho node * ⚡ Refactor out extra credentials parameter * 🔥 Remove unused filters * ⚡ Fix date of birth fields * ⚡ Fix param casing * ⚡ Adjust param types * ⚡ Adjust invoice operations * ⚡ Refactor types in adjusters * ⚡ Add product resource * ⚡ Refactor product details field * ⚡ Adjust purchase order params * ⚡ Adjust quote params * ⚡ Adjust sales orders params * 🔥 Remove old unused files * ⚡ Add vendor resource * ⚡ Fix minor details * ⚡ Implement continueOnFail * 🐛 Fix empty response for getAll * ⚡ Simplify response for single item * 🔥 Remove unused import * 🔨 Restore old node name * ⚡ Prevent request on empty update * ⚡ Apply Dali's suggestions * ⚡ Improvements * ⚡ Add filters for lead:getAll * ⚡ Add upsert to all resources * ⚡ Add filters to all getAll operations * 🔨 Restore continue on fail * 🔨 Refactor upsert addition * 🔨 Refactor getFields for readability * ⚡ Add custom fields to all create-update ops * ⚡ Implement custom fields adjuster * 🔥 Remove logging * 👕 Appease linter * 👕 Refactor type helper for linter * ⚡ Fix refactored type * 🔨 Refactor reduce for simplicity * ⚡ Fix vendor:getAll filter options * ⚡ Fix custom fields for product operations * ⚡ Make sort_by into options param * 🚚 Rename upsert operation * ✏️ Add descriptions to upsert * ⚡ Deduplicate system-defined check fields * 🔨 Re-order address fields * ✏️ Generalize references in getAll fields * 🔥 Remove extra comma * ⚡ Make getFields helper more readable * ✏️ Touch up description for account ID * 🔥 Remove currency from contacts * 🔨 Resort emails and phones for contact * 🐛 Fix sales cycle duration param type * ✏️ Clarify descriptions with percentages * 🔨 Reorder total fields * ✏️ Clarify percentages for discounts * ✏️ Clarify percentages for commissions * 🔨 Convert currency to picklist * ✏️ Add documentation links * ⚡ Add resource loaders for picklists * ⚡ Fix build * 🔨 Refactor product details * ⚡ Add resolve data to all resources * ⚡ Change resolve data toggle default * ⚡ Restore lead:getFields operation * 🔥 Remove upsert descriptions * 🔨 Change casing for upsert operations * ⚡ Add operation descriptions * 🔨 Restore makeResolve default value * 🔨 Return nested details * ⚡ Reposition Resolve Data toggles * ✏️ Document breaking changes * Revert "Reposition Resolve Data toggles" This reverts commit 72ac41780b3ebec9cc6a5bf527e154ffe6ed884a. * ⚡ Improvements Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
This commit is contained in:
@@ -1,59 +1,434 @@
|
||||
import {
|
||||
import {
|
||||
OptionsWithUri,
|
||||
} from 'request';
|
||||
|
||||
import {
|
||||
IExecuteFunctions,
|
||||
IExecuteSingleFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
IHookFunctions,
|
||||
} from 'n8n-core';
|
||||
|
||||
import {
|
||||
IDataObject, NodeApiError
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
NodeApiError,
|
||||
NodeOperationError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export async function zohoApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
|
||||
const { oauthTokenData: { api_domain } } = this.getCredentials('zohoOAuth2Api') as { [key: string]: IDataObject };
|
||||
import {
|
||||
flow,
|
||||
sortBy,
|
||||
} from 'lodash';
|
||||
|
||||
import {
|
||||
AllFields,
|
||||
CamelCaseResource,
|
||||
DateType,
|
||||
GetAllFilterOptions,
|
||||
IdType,
|
||||
LoadedFields,
|
||||
LoadedLayouts,
|
||||
LocationType,
|
||||
NameType,
|
||||
ProductDetails,
|
||||
ResourceItems,
|
||||
SnakeCaseResource,
|
||||
ZohoOAuth2ApiCredentials,
|
||||
} from './types';
|
||||
|
||||
export async function zohoApiRequest(
|
||||
this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||
method: string,
|
||||
endpoint: string,
|
||||
body: IDataObject = {},
|
||||
qs: IDataObject = {},
|
||||
uri?: string,
|
||||
) {
|
||||
const { oauthTokenData } = this.getCredentials('zohoOAuth2Api') as ZohoOAuth2ApiCredentials;
|
||||
|
||||
const options: OptionsWithUri = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method,
|
||||
body: {
|
||||
data: [
|
||||
body,
|
||||
],
|
||||
},
|
||||
method,
|
||||
qs,
|
||||
uri: uri || `${api_domain}/crm/v2${resource}`,
|
||||
uri: uri ?? `${oauthTokenData.api_domain}/crm/v2${endpoint}`,
|
||||
json: true,
|
||||
};
|
||||
|
||||
if (!Object.keys(body).length) {
|
||||
delete options.body;
|
||||
}
|
||||
|
||||
if (!Object.keys(qs).length) {
|
||||
delete options.qs;
|
||||
}
|
||||
|
||||
try {
|
||||
//@ts-ignore
|
||||
return await this.helpers.requestOAuth2.call(this, 'zohoOAuth2Api', options);
|
||||
const responseData = await this.helpers.requestOAuth2?.call(this, 'zohoOAuth2Api', options);
|
||||
|
||||
if (responseData === undefined) return [];
|
||||
|
||||
throwOnErrorStatus.call(this, responseData);
|
||||
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function zohoApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
|
||||
|
||||
/**
|
||||
* Make an authenticated API request to Zoho CRM API and return all items.
|
||||
*/
|
||||
export async function zohoApiRequestAllItems(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: string,
|
||||
endpoint: string,
|
||||
body: IDataObject = {},
|
||||
qs: IDataObject = {},
|
||||
) {
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
let responseData;
|
||||
let uri: string | undefined;
|
||||
query.per_page = 200;
|
||||
query.page = 0;
|
||||
qs.per_page = 200;
|
||||
qs.page = 0;
|
||||
|
||||
do {
|
||||
responseData = await zohoApiRequest.call(this, method, endpoint, body, query, uri);
|
||||
responseData = await zohoApiRequest.call(this, method, endpoint, body, qs, uri);
|
||||
if (Array.isArray(responseData) && !responseData.length) return returnData;
|
||||
returnData.push(...responseData.data);
|
||||
uri = responseData.info.more_records;
|
||||
returnData.push.apply(returnData, responseData[propertyName]);
|
||||
query.page++;
|
||||
qs.page++;
|
||||
} while (
|
||||
responseData.info.more_records !== undefined &&
|
||||
responseData.info.more_records === true
|
||||
);
|
||||
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Zoho CRM API listing by returning all items or up to a limit.
|
||||
*/
|
||||
export async function handleListing(
|
||||
this: IExecuteFunctions,
|
||||
method: string,
|
||||
endpoint: string,
|
||||
body: IDataObject = {},
|
||||
qs: IDataObject = {},
|
||||
) {
|
||||
const returnAll = this.getNodeParameter('returnAll', 0) as boolean;
|
||||
|
||||
if (returnAll) {
|
||||
return await zohoApiRequestAllItems.call(this, method, endpoint, body, qs);
|
||||
}
|
||||
|
||||
const responseData = await zohoApiRequestAllItems.call(this, method, endpoint, body, qs);
|
||||
const limit = this.getNodeParameter('limit', 0) as number;
|
||||
|
||||
return responseData.slice(0, limit);
|
||||
}
|
||||
|
||||
export function throwOnEmptyUpdate(this: IExecuteFunctions, resource: CamelCaseResource) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Please enter at least one field to update for the ${resource}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function throwOnMissingProducts(
|
||||
this: IExecuteFunctions,
|
||||
resource: CamelCaseResource,
|
||||
productDetails: ProductDetails,
|
||||
) {
|
||||
if (!productDetails.length) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Please enter at least one product for the ${resource}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function throwOnErrorStatus(
|
||||
this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||
responseData: { data?: Array<{ status: string, message: string }> },
|
||||
) {
|
||||
if (responseData?.data?.[0].status === 'error') {
|
||||
throw new NodeOperationError(this.getNode(), responseData as Error);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// required field adjusters
|
||||
// ----------------------------------------
|
||||
|
||||
/**
|
||||
* Place a product ID at a nested position in a product details field.
|
||||
*/
|
||||
export const adjustProductDetails = (productDetails: ProductDetails) => {
|
||||
return productDetails.map(p => {
|
||||
return {
|
||||
...omit('product', p),
|
||||
product: { id: p.id },
|
||||
quantity: p.quantity || 1,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// ----------------------------------------
|
||||
// additional field adjusters
|
||||
// ----------------------------------------
|
||||
|
||||
/**
|
||||
* Place a product ID at a nested position in a product details field.
|
||||
*
|
||||
* Only for updating products from Invoice, Purchase Order, Quote, and Sales Order.
|
||||
*/
|
||||
export const adjustProductDetailsOnUpdate = (allFields: AllFields) => {
|
||||
if (!allFields.Product_Details) return allFields;
|
||||
|
||||
return allFields.Product_Details.map(p => {
|
||||
return {
|
||||
...omit('product', p),
|
||||
product: { id: p.id },
|
||||
quantity: p.quantity || 1,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Place a location field's contents at the top level of the payload.
|
||||
*/
|
||||
const adjustLocationFields = (locationType: LocationType) => (allFields: AllFields) => {
|
||||
const locationField = allFields[locationType];
|
||||
|
||||
if (!locationField) return allFields;
|
||||
|
||||
return {
|
||||
...omit(locationType, allFields),
|
||||
...locationField.address_fields,
|
||||
};
|
||||
};
|
||||
|
||||
const adjustAddressFields = adjustLocationFields('Address');
|
||||
const adjustBillingAddressFields = adjustLocationFields('Billing_Address');
|
||||
const adjustMailingAddressFields = adjustLocationFields('Mailing_Address');
|
||||
const adjustShippingAddressFields = adjustLocationFields('Shipping_Address');
|
||||
const adjustOtherAddressFields = adjustLocationFields('Other_Address');
|
||||
|
||||
/**
|
||||
* Remove from a date field the timestamp set by the datepicker.
|
||||
*/
|
||||
const adjustDateField = (dateType: DateType) => (allFields: AllFields) => {
|
||||
const dateField = allFields[dateType];
|
||||
|
||||
if (!dateField) return allFields;
|
||||
|
||||
allFields[dateType] = dateField.split('T')[0];
|
||||
|
||||
return allFields;
|
||||
};
|
||||
|
||||
const adjustDateOfBirthField = adjustDateField('Date_of_Birth');
|
||||
const adjustClosingDateField = adjustDateField('Closing_Date');
|
||||
const adjustInvoiceDateField = adjustDateField('Invoice_Date');
|
||||
const adjustDueDateField = adjustDateField('Due_Date');
|
||||
const adjustPurchaseOrderDateField = adjustDateField('PO_Date');
|
||||
const adjustValidTillField = adjustDateField('Valid_Till');
|
||||
|
||||
/**
|
||||
* Place an ID field's value nested inside the payload.
|
||||
*/
|
||||
const adjustIdField = (idType: IdType, nameProperty: NameType) => (allFields: AllFields) => {
|
||||
const idValue = allFields[idType];
|
||||
|
||||
if (!idValue) return allFields;
|
||||
|
||||
return {
|
||||
...omit(idType, allFields),
|
||||
[nameProperty]: { id: idValue },
|
||||
};
|
||||
};
|
||||
|
||||
const adjustAccountIdField = adjustIdField('accountId', 'Account_Name');
|
||||
const adjustContactIdField = adjustIdField('contactId', 'Full_Name');
|
||||
const adjustDealIdField = adjustIdField('dealId', 'Deal_Name');
|
||||
|
||||
const adjustCustomFields = (allFields: AllFields) => {
|
||||
const { customFields, ...rest } = allFields;
|
||||
|
||||
if (!customFields?.customFields.length) return allFields;
|
||||
|
||||
return customFields.customFields.reduce((acc, cur) => {
|
||||
acc[cur.fieldId] = cur.value;
|
||||
return acc;
|
||||
}, rest);
|
||||
};
|
||||
|
||||
// ----------------------------------------
|
||||
// payload adjusters
|
||||
// ----------------------------------------
|
||||
|
||||
export const adjustAccountPayload = flow(
|
||||
adjustBillingAddressFields,
|
||||
adjustShippingAddressFields,
|
||||
adjustCustomFields,
|
||||
);
|
||||
|
||||
export const adjustContactPayload = flow(
|
||||
adjustMailingAddressFields,
|
||||
adjustOtherAddressFields,
|
||||
adjustDateOfBirthField,
|
||||
adjustCustomFields,
|
||||
);
|
||||
|
||||
export const adjustDealPayload = flow(
|
||||
adjustClosingDateField,
|
||||
adjustCustomFields,
|
||||
);
|
||||
|
||||
export const adjustInvoicePayload = flow(
|
||||
adjustBillingAddressFields,
|
||||
adjustShippingAddressFields,
|
||||
adjustInvoiceDateField,
|
||||
adjustDueDateField,
|
||||
adjustAccountIdField,
|
||||
adjustCustomFields,
|
||||
);
|
||||
|
||||
export const adjustInvoicePayloadOnUpdate = flow(
|
||||
adjustInvoicePayload,
|
||||
adjustProductDetailsOnUpdate,
|
||||
);
|
||||
|
||||
export const adjustLeadPayload = flow(
|
||||
adjustAddressFields,
|
||||
adjustCustomFields,
|
||||
);
|
||||
|
||||
export const adjustPurchaseOrderPayload = flow(
|
||||
adjustBillingAddressFields,
|
||||
adjustShippingAddressFields,
|
||||
adjustDueDateField,
|
||||
adjustPurchaseOrderDateField,
|
||||
adjustCustomFields,
|
||||
);
|
||||
|
||||
export const adjustQuotePayload = flow(
|
||||
adjustBillingAddressFields,
|
||||
adjustShippingAddressFields,
|
||||
adjustValidTillField,
|
||||
adjustCustomFields,
|
||||
);
|
||||
|
||||
export const adjustSalesOrderPayload = flow(
|
||||
adjustBillingAddressFields,
|
||||
adjustShippingAddressFields,
|
||||
adjustDueDateField,
|
||||
adjustAccountIdField,
|
||||
adjustContactIdField,
|
||||
adjustDealIdField,
|
||||
adjustCustomFields,
|
||||
);
|
||||
|
||||
export const adjustVendorPayload = flow(
|
||||
adjustAddressFields,
|
||||
adjustCustomFields,
|
||||
);
|
||||
|
||||
export const adjustProductPayload = adjustCustomFields;
|
||||
|
||||
// ----------------------------------------
|
||||
// helpers
|
||||
// ----------------------------------------
|
||||
|
||||
/**
|
||||
* Create a copy of an object without a specific property.
|
||||
*/
|
||||
const omit = (propertyToOmit: string, { [propertyToOmit]: _, ...remainingObject }) => remainingObject;
|
||||
|
||||
/**
|
||||
* Convert items in a Zoho CRM API response into n8n load options.
|
||||
*/
|
||||
export const toLoadOptions = (items: ResourceItems, nameProperty: NameType) =>
|
||||
items.map((item) => ({ name: item[nameProperty], value: item.id }));
|
||||
|
||||
/**
|
||||
* Retrieve all fields for a resource, sorted alphabetically.
|
||||
*/
|
||||
export async function getFields(
|
||||
this: ILoadOptionsFunctions,
|
||||
resource: SnakeCaseResource,
|
||||
{ onlyCustom } = { onlyCustom: false },
|
||||
) {
|
||||
const qs = { module: getModuleName(resource) };
|
||||
|
||||
let { fields } = await zohoApiRequest.call(this, 'GET', '/settings/fields', {}, qs) as LoadedFields;
|
||||
|
||||
if (onlyCustom) {
|
||||
fields = fields.filter(({ custom_field }) => custom_field);
|
||||
}
|
||||
|
||||
const options = fields.map(({ field_label, api_name }) => ({ name: field_label, value: api_name }));
|
||||
|
||||
return sortBy(options, o => o.name);
|
||||
}
|
||||
|
||||
export function getModuleName(resource: string) {
|
||||
const map: { [key: string]: string } = {
|
||||
account: 'Accounts',
|
||||
contact: 'Contacts',
|
||||
deal: 'Deals',
|
||||
invoice: 'Invoices',
|
||||
lead: 'Leads',
|
||||
product: 'Products',
|
||||
purchaseOrder: 'Purchase_Orders',
|
||||
salesOrder: 'Sales_Orders',
|
||||
vendor: 'Vendors',
|
||||
quote: 'Quotes',
|
||||
};
|
||||
|
||||
return map[resource];
|
||||
}
|
||||
|
||||
export async function getPicklistOptions(
|
||||
this: ILoadOptionsFunctions,
|
||||
resource: string,
|
||||
targetField: string,
|
||||
) {
|
||||
const qs = { module: getModuleName(resource) };
|
||||
const responseData = await zohoApiRequest.call(this, 'GET', '/settings/layouts', {}, qs) as LoadedLayouts;
|
||||
|
||||
const pickListOptions = responseData.layouts[0]
|
||||
.sections.find(section => section.api_name === getSectionApiName(resource))
|
||||
?.fields.find(f => f.api_name === targetField)
|
||||
?.pick_list_values;
|
||||
|
||||
if (!pickListOptions) return [];
|
||||
|
||||
return pickListOptions.map(
|
||||
(option) => ({ name: option.display_value, value: option.actual_value }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function getSectionApiName(resource: string) {
|
||||
if (resource === 'purchaseOrder') return 'Purchase Order Information';
|
||||
if (resource === 'salesOrder') return 'Sales Order Information';
|
||||
|
||||
return `${capitalizeInitial(resource)} Information`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add filter options to a query string object.
|
||||
*/
|
||||
export const addGetAllFilterOptions = (qs: IDataObject, options: GetAllFilterOptions) => {
|
||||
if (Object.keys(options).length) {
|
||||
const { fields, ...rest } = options;
|
||||
Object.assign(qs, fields && { fields: fields.join(',') }, rest);
|
||||
}
|
||||
};
|
||||
|
||||
export const capitalizeInitial = (str: string) => str[0].toUpperCase() + str.slice(1);
|
||||
|
||||
Reference in New Issue
Block a user