diff --git a/flows.yaml b/flows.yaml index 5a1524d9..091f9a58 100644 --- a/flows.yaml +++ b/flows.yaml @@ -3764,13 +3764,14 @@ integrations: actions: fetch-fields: description: > - Fetch available task fields. If the input is not specified then it - defaults back to "Task" + Fetch available task fields, child relationships and validation rules. + If the input is not specified then it defaults back to "Task" Data Validation: Parses all incoming data with Zod. Does not fail on parsing error will instead log parse error and return result. scopes: offline_access,api input: SalesforceEntity + version: 1.0.0 output: SalesforceFieldSchema endpoint: GET /salesforce/fetch-fields syncs: @@ -3867,6 +3868,7 @@ integrations: SalesforceFieldSchema: fields: Field[] childRelationships: ChildField[] + validationRules: ValidationRule[] NestedFieldSchema: fields: Field[] Field: @@ -3887,6 +3889,11 @@ integrations: method: string url: string code: string + ValidationRule: + id: string + name: string + errorConditionFormula: string + errorMessage: string sharepoint-online: actions: list-sites: diff --git a/integrations/salesforce/actions/fetch-fields.ts b/integrations/salesforce/actions/fetch-fields.ts index bfd8bc94..0420fbc9 100644 --- a/integrations/salesforce/actions/fetch-fields.ts +++ b/integrations/salesforce/actions/fetch-fields.ts @@ -1,33 +1,61 @@ -import type { NangoAction, SalesforceFieldSchema, ProxyConfiguration, ActionResponseError, ChildField, Field, SalesforceEntity } from '../../models'; -import { fieldSchema, childFieldSchema } from '../schema.zod.js'; +import type { + NangoAction, + SalesforceFieldSchema, + ProxyConfiguration, + ActionResponseError, + ChildField, + Field, + SalesforceEntity, + ValidationRule +} from '../../models'; +import { fieldSchema, childFieldSchema, validationRuleSchema } from '../schema.zod.js'; +import type { DescribeSObjectResult, SalesForceField, ChildRelationship, ValidationRecord, ValidationRuleResponse } from '../types'; /** - * This action fetches the available objects for a given organization in Salesforce. + * This action retrieves the available properties of a custom object, including fields, child relationships, and validation rules, for a given organization in Salesforce. * https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_describe.htm - * @param nango - The NangoAction object - * @returns SalesforceFieldSchema - The fields for the object + * https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_validationrule.htm + * + * @param nango - The NangoAction instance used for making API requests. + * @param input - SalesforceEntity defining the object to describe + * @returns A promise that resolves to a SalesforceFieldSchema object containing: fields, child relationships, and validation rules for the object */ export default async function runAction(nango: NangoAction, input: SalesforceEntity): Promise { try { const entity = input?.name || 'Task'; - const proxyConfig: ProxyConfiguration = { + + const proxyConfigFields: ProxyConfiguration = { endpoint: `/services/data/v60.0/sobjects/${entity}/describe`, - retries: 5 + retries: 10 }; - const response = await nango.get(proxyConfig); + const proxyConfigValidationIds: ProxyConfiguration = { + endpoint: `/services/data/v60.0/tooling/query`, + retries: 10, + params: { + q: `SELECT Id, ValidationName FROM ValidationRule WHERE EntityDefinition.QualifiedApiName='${entity}'` + } + }; - const { data } = response; - const { fields, childRelationships } = data; + // Parallelize both requests as we won't get rate limited in here. + const [fieldsResponse, validationResponse] = await Promise.all([ + nango.get(proxyConfigFields), + nango.get(proxyConfigValidationIds) + ]); - const fieldResults = mapFields({ fields }); - const childRelationshipsResults = mapChildRelationships({ - relationships: childRelationships - }); + const { fields, childRelationships } = fieldsResponse.data; + const validationRulesIds = validationResponse.data.records; + + const validationRulesData: ValidationRecord[] = await fetchValidationRuleMetadata(nango, validationRulesIds); + + const fieldResults = mapFields(fields); + const childRelationshipsResults = mapChildRelationships(childRelationships); + const validationRulesResults = mapValidationRules(validationRulesData); return { fields: fieldResults, - childRelationships: childRelationshipsResults + childRelationships: childRelationshipsResults, + validationRules: validationRulesResults }; } catch (error: unknown) { const errorResponse = error as Record; @@ -45,44 +73,86 @@ export default async function runAction(nango: NangoAction, input: SalesforceEnt } /** - * Maps the fields from the Salesforce API response to the Field schema - * @param fields - The unverified data returned from the Salesforce API - * @param nango - The NangoAction object - * @returns The mapped fields and a boolean indicating if the mapping was successful + * Fetches metadata for multiple validation rules from the Salesforce Tooling API. + * Note: The maximum number of active validation rules per object is limited to 500, + * and this limit can vary based on the Salesforce edition. + * For more details, refer to the Salesforce documentation: + * https://help.salesforce.com/s/articleView?id=000383591&type=1 + * + * @param nango - The NangoAction instance used for making API requests. + * @param validationRulesIds - An array of objects containing the IDs and names of the validation rules. + * @returns A promise that resolves to an array of ValidationRecord objects with metadata for each validation rule. */ -function mapFields({ fields }: { fields: Field[] }): Field[] { - const validatedFields: Field[] = []; - for (const field of fields) { - const resultData = field; - const parsedField = fieldSchema.parse({ - name: resultData['name'], - label: resultData['label'], - type: resultData['type'], - referenceTo: resultData['referenceTo'], - relationshipName: resultData['relationshipName'] - }); +async function fetchValidationRuleMetadata(nango: NangoAction, validationRulesIds: { Id: string; ValidationName: string }[]): Promise { + const metadataFetchPromises: Promise[] = validationRulesIds.map((rule) => + nango + .get({ + endpoint: `/services/data/v60.0/tooling/query`, + retries: 10, + params: { + q: `SELECT Id, ValidationName, Metadata FROM ValidationRule WHERE Id='${rule.Id}'` + } + }) + .then((response: { data: ValidationRuleResponse }) => { + const record = response.data.records[0]; + if (record) { + return { ...record, ValidationName: rule.ValidationName }; + } + throw new nango.ActionError({ + message: `Validation rule with ID ${rule.Id} not found.` + }); + }) + ); - validatedFields.push(parsedField); - } + const settledResults = await Promise.allSettled(metadataFetchPromises); - return validatedFields; + return settledResults.filter((result) => result.status === 'fulfilled').map((result) => (result as PromiseFulfilledResult).value); } /** - * Maps the child relationships from the Salesforce API response to the ChildField schema - * @param relationships - The unverified data returned from the Salesforce API - * @returns The mapped child relationships and a boolean indicating if the mapping was successful + * Maps the fields from the Salesforce API response to the Field schema. + * @param fields - Array of fields from Salesforce + * @returns An array of mapped validation rules conforming to the Field schema. */ -function mapChildRelationships({ relationships }: { relationships: Record[] }): ChildField[] { - const validatedRelationships: ChildField[] = []; - for (const relationship of relationships) { - const resultData = relationship; - const parsedChildField = childFieldSchema.parse({ - object: resultData['childSObject'], - field: resultData['field'], - relationshipName: resultData['relationshipName'] - }); - validatedRelationships.push(parsedChildField); - } - return validatedRelationships; +function mapFields(fields: SalesForceField[]): Field[] { + return fields.map((field) => + fieldSchema.parse({ + name: field.name, + label: field.label, + type: field.type, + referenceTo: field.referenceTo, + relationshipName: field.relationshipName + }) + ); +} + +/** + * Maps child relationships from Salesforce API to the ChildField schema. + * @param relationships - Array of child relationships from Salesforce + * @returns An array of mapped child relationships conforming to the ChildField schema. + */ +function mapChildRelationships(relationships: ChildRelationship[]): ChildField[] { + return relationships.map((relationship) => + childFieldSchema.parse({ + object: relationship.childSObject, + field: relationship.field, + relationshipName: relationship.relationshipName + }) + ); } + +/** + * Maps validation rules from Salesforce Tooling API response to the ValidationRule schema. + * @param validationRules - Array of validation rules from Salesforce Tooling API + * @returns An array of mapped validation rules conforming to the ValidationRule schema. + */ +function mapValidationRules(validationRules: ValidationRecord[]): ValidationRule[] { + return validationRules.map((rule) => + validationRuleSchema.parse({ + id: rule.Id, + name: rule.ValidationName, + errorConditionFormula: rule.Metadata.errorConditionFormula, + errorMessage: rule.Metadata.errorMessage + }) + ); +} \ No newline at end of file diff --git a/integrations/salesforce/nango.yaml b/integrations/salesforce/nango.yaml index 08086b43..009f8b1f 100644 --- a/integrations/salesforce/nango.yaml +++ b/integrations/salesforce/nango.yaml @@ -3,10 +3,11 @@ integrations: actions: fetch-fields: description: | - Fetch available task fields. If the input is not specified then it defaults back to "Task" + Fetch available task fields, child relationships and validation rules. If the input is not specified then it defaults back to "Task" Data Validation: Parses all incoming data with Zod. Does not fail on parsing error will instead log parse error and return result. scopes: offline_access,api input: SalesforceEntity + version: 1.0.0 output: SalesforceFieldSchema endpoint: GET /salesforce/fetch-fields syncs: @@ -103,6 +104,7 @@ models: SalesforceFieldSchema: __extends: NestedFieldSchema childRelationships: ChildField[] + validationRules: ValidationRule[] NestedFieldSchema: fields: Field[] Field: @@ -123,3 +125,8 @@ models: method: string url: string code: string + ValidationRule: + id: string + name: string + errorConditionFormula: string + errorMessage: string diff --git a/integrations/salesforce/schema.zod.ts b/integrations/salesforce/schema.zod.ts index af1b3544..be9d5eb7 100644 --- a/integrations/salesforce/schema.zod.ts +++ b/integrations/salesforce/schema.zod.ts @@ -102,3 +102,10 @@ export const actionResponseErrorSchema = z.object({ message: z.string(), details: actionResponseErrorDetailsSchema.optional() }); + +export const validationRuleSchema = z.object({ + id: z.string(), + name: z.string(), + errorConditionFormula: z.string(), + errorMessage: z.string() +}); \ No newline at end of file diff --git a/integrations/salesforce/tests/salesforce-accounts.test.ts b/integrations/salesforce/tests/salesforce-accounts.test.ts new file mode 100644 index 00000000..b0f909f4 --- /dev/null +++ b/integrations/salesforce/tests/salesforce-accounts.test.ts @@ -0,0 +1,27 @@ +import { vi, expect, it, describe } from "vitest"; +import type { NangoSync } from "../models.js"; + +import fetchData from "../syncs/accounts.js"; + +describe("salesforce accounts tests", () => { + const nangoMock = new global.vitest.NangoSyncMock({ + dirname: __dirname, + name: "accounts", + Model: "SalesforceAccount" + }); + it("should get, map correctly the data and batchSave the result", async () => { + await fetchData(nangoMock); + + const batchSaveData = await nangoMock.getBatchSaveData(); + expect(nangoMock.batchSave).toHaveBeenCalledWith(batchSaveData, "SalesforceAccount"); + }); + + it('should get, map correctly the data and batchDelete the result', async () => { + await fetchData(nangoMock); + + const batchDeleteData = await nangoMock.getBatchDeleteData(); + if (batchDeleteData && batchDeleteData.length > 0) { + expect(nangoMock.batchDelete).toHaveBeenCalledWith(batchDeleteData, "SalesforceAccount"); + } + }); +}); diff --git a/integrations/salesforce/tests/salesforce-articles.test.ts b/integrations/salesforce/tests/salesforce-articles.test.ts new file mode 100644 index 00000000..85934dc1 --- /dev/null +++ b/integrations/salesforce/tests/salesforce-articles.test.ts @@ -0,0 +1,27 @@ +import { vi, expect, it, describe } from "vitest"; +import type { NangoSync } from "../models.js"; + +import fetchData from "../syncs/articles.js"; + +describe("salesforce articles tests", () => { + const nangoMock = new global.vitest.NangoSyncMock({ + dirname: __dirname, + name: "articles", + Model: "SalesforceArticle" + }); + it("should get, map correctly the data and batchSave the result", async () => { + await fetchData(nangoMock); + + const batchSaveData = await nangoMock.getBatchSaveData(); + expect(nangoMock.batchSave).toHaveBeenCalledWith(batchSaveData, "SalesforceArticle"); + }); + + it('should get, map correctly the data and batchDelete the result', async () => { + await fetchData(nangoMock); + + const batchDeleteData = await nangoMock.getBatchDeleteData(); + if (batchDeleteData && batchDeleteData.length > 0) { + expect(nangoMock.batchDelete).toHaveBeenCalledWith(batchDeleteData, "SalesforceArticle"); + } + }); +}); diff --git a/integrations/salesforce/tests/salesforce-contacts.test.ts b/integrations/salesforce/tests/salesforce-contacts.test.ts new file mode 100644 index 00000000..f27fed79 --- /dev/null +++ b/integrations/salesforce/tests/salesforce-contacts.test.ts @@ -0,0 +1,27 @@ +import { vi, expect, it, describe } from "vitest"; +import type { NangoSync } from "../models.js"; + +import fetchData from "../syncs/contacts.js"; + +describe("salesforce contacts tests", () => { + const nangoMock = new global.vitest.NangoSyncMock({ + dirname: __dirname, + name: "contacts", + Model: "SalesforceContact" + }); + it("should get, map correctly the data and batchSave the result", async () => { + await fetchData(nangoMock); + + const batchSaveData = await nangoMock.getBatchSaveData(); + expect(nangoMock.batchSave).toHaveBeenCalledWith(batchSaveData, "SalesforceContact"); + }); + + it('should get, map correctly the data and batchDelete the result', async () => { + await fetchData(nangoMock); + + const batchDeleteData = await nangoMock.getBatchDeleteData(); + if (batchDeleteData && batchDeleteData.length > 0) { + expect(nangoMock.batchDelete).toHaveBeenCalledWith(batchDeleteData, "SalesforceContact"); + } + }); +}); diff --git a/integrations/salesforce/tests/salesforce-deals.test.ts b/integrations/salesforce/tests/salesforce-deals.test.ts new file mode 100644 index 00000000..80402032 --- /dev/null +++ b/integrations/salesforce/tests/salesforce-deals.test.ts @@ -0,0 +1,27 @@ +import { vi, expect, it, describe } from "vitest"; +import type { NangoSync } from "../models.js"; + +import fetchData from "../syncs/deals.js"; + +describe("salesforce deals tests", () => { + const nangoMock = new global.vitest.NangoSyncMock({ + dirname: __dirname, + name: "deals", + Model: "SalesforceDeal" + }); + it("should get, map correctly the data and batchSave the result", async () => { + await fetchData(nangoMock); + + const batchSaveData = await nangoMock.getBatchSaveData(); + expect(nangoMock.batchSave).toHaveBeenCalledWith(batchSaveData, "SalesforceDeal"); + }); + + it('should get, map correctly the data and batchDelete the result', async () => { + await fetchData(nangoMock); + + const batchDeleteData = await nangoMock.getBatchDeleteData(); + if (batchDeleteData && batchDeleteData.length > 0) { + expect(nangoMock.batchDelete).toHaveBeenCalledWith(batchDeleteData, "SalesforceDeal"); + } + }); +}); diff --git a/integrations/salesforce/tests/salesforce-tickets.test.ts b/integrations/salesforce/tests/salesforce-tickets.test.ts new file mode 100644 index 00000000..484d2a4f --- /dev/null +++ b/integrations/salesforce/tests/salesforce-tickets.test.ts @@ -0,0 +1,27 @@ +import { vi, expect, it, describe } from "vitest"; +import type { NangoSync } from "../models.js"; + +import fetchData from "../syncs/tickets.js"; + +describe("salesforce tickets tests", () => { + const nangoMock = new global.vitest.NangoSyncMock({ + dirname: __dirname, + name: "tickets", + Model: "SalesforceTicket" + }); + it("should get, map correctly the data and batchSave the result", async () => { + await fetchData(nangoMock); + + const batchSaveData = await nangoMock.getBatchSaveData(); + expect(nangoMock.batchSave).toHaveBeenCalledWith(batchSaveData, "SalesforceTicket"); + }); + + it('should get, map correctly the data and batchDelete the result', async () => { + await fetchData(nangoMock); + + const batchDeleteData = await nangoMock.getBatchDeleteData(); + if (batchDeleteData && batchDeleteData.length > 0) { + expect(nangoMock.batchDelete).toHaveBeenCalledWith(batchDeleteData, "SalesforceTicket"); + } + }); +}); diff --git a/integrations/salesforce/types.ts b/integrations/salesforce/types.ts new file mode 100644 index 00000000..0a15ab4a --- /dev/null +++ b/integrations/salesforce/types.ts @@ -0,0 +1,180 @@ +export interface DescribeSObjectResult { + actionOverrides: ActionOverride[]; + activateable: boolean; + associateEntityType: string | null; + associateParentEntity: string | null; + childRelationships: ChildRelationship[]; + compactLayoutable: boolean; + createable: boolean; + custom: boolean; + customSetting: boolean; + dataTranslationEnabled?: boolean; + deepCloneable: boolean; + defaultImplementation: string | null; + deletable: boolean; + deprecatedAndHidden: boolean; + extendedBy: string | null; + extendsInterfaces: string | null; + feedEnabled: boolean; + fields: SalesForceField[]; + hasSubtypes?: boolean; + implementedBy: string; + implementsInterfaces: string | null; + isInterface: boolean; + keyPrefix: string; + label: string; + labelPlural: string; + layoutable: boolean; + mergeable: boolean; + mruEnabled: boolean; + name: string; + namedLayoutInfos: NamedLayoutInfo[]; + networkScopeFieldName: string | null; + queryable: boolean; + recordTypeInfos: RecordTypeInfo[]; + replicateable: boolean; + retrieveable: boolean; + searchable: boolean; + searchLayoutable: boolean; + supportedScopes: ScopeInfo; + triggerable: boolean; + undeletable: boolean; + updateable: boolean; + urlDetail: string; + urlEdit: string; + urlNew: string; +} + +interface ActionOverride { + formFactor: string; + isAvailableInTouch: boolean; + name: string; + pageId: string; + url: string | null; +} + +export interface ChildRelationship { + cascadeDelete: boolean; + childSObject: string; + deprecatedAndHidden: boolean; + field: string; + junctionIdListNames: string[]; + junctionReferenceTo: string[]; + relationshipName: string | null; + restrictedDelete: boolean; +} + +export interface SalesForceField { + aggregatable: boolean; + aiPredictionField?: boolean; + cascadeDelete?: boolean; + autoNumber: boolean; + byteLength: number; + calculated: boolean; + caseSensitive: boolean; + controllerName: string; + createable: boolean; + custom: boolean; + dataTranslationEnabled: boolean; + defaultedOnCreate: boolean; + defaultValueFormula: string | null; + dependentPicklist: boolean; + deprecatedAndHidden: boolean; + digits: number; + displayLocationInDecimal: boolean; + encrypted: boolean; + extraTypeInfo: string | null; + filterable: boolean; + filteredLookupInfo: FilteredLookupInfo | null; + formula?: string; + groupable: boolean; + highScaleNumber: boolean; + htmlFormatted: boolean; + idLookup: boolean; + inlineHelpText: string | null; + label: string; + length: number; + mask: string | null; + maskType: string | null; + name: string; + nameField: boolean; + namePointing: boolean; + nillable: boolean; + permissionable: boolean; + picklistValues: PicklistEntry[]; + polymorphicForeignKey: boolean; + precision: number; + relationshipName: string | null; + relationshipOrder: number | null; + referenceTargetField: string | null; + referenceTo: string[]; + restrictedPicklist: boolean; + scale: number; + searchPrefilterable: boolean; + soapType: string; + sortable: boolean; + type: string; + unique: boolean; + updateable: boolean; + writeRequiresMasterRead: boolean; +} + +interface FilteredLookupInfo { + controllingFields: string[]; + dependent: boolean; + optionalFilter: boolean; +} +interface PicklistEntry { + active: boolean; + validFor: Uint8Array | null; + defaultValue: boolean; + label: string; + value: string; +} + +interface NamedLayoutInfo { + name: string; +} + +interface RecordTypeInfo { + available: boolean; + defaultRecordTypeMapping: boolean; + developerName: string; + master: boolean; + name: string; + recordTypeId: string; +} + +interface ScopeInfo { + label: string; + name: string; +} + +interface Metadata { + description: string; + errorConditionFormula: string; + errorDisplayField: string; + errorMessage: string; + shouldEvaluateOnClient: boolean; + urls: string; + active: boolean; +} + +export interface ValidationRecord { + attributes: { + type: string; + url: string; + }; + Id: string; + ValidationName: string; + Metadata: Metadata; +} + +export interface ValidationRuleResponse { + size: number; + totalSize: number; + done: boolean; + queryLocator: string | null; + entityTypeName: string; + records: ValidationRecord[]; +} \ No newline at end of file