UNPKG

mcdev

Version:

Accenture Salesforce Marketing Cloud DevTools

1,123 lines (1,061 loc) 58.5 kB
'use strict'; import MetadataType from './MetadataType.js'; import { Util } from '../util/util.js'; import File from '../util/file.js'; import cache from '../util/cache.js'; import deepEqual from 'deep-equal'; import pLimit from 'p-limit'; /** * @typedef {import('../../types/mcdev.d.js').BuObject} BuObject * @typedef {import('../../types/mcdev.d.js').CodeExtract} CodeExtract * @typedef {import('../../types/mcdev.d.js').CodeExtractItem} CodeExtractItem * @typedef {import('../../types/mcdev.d.js').MetadataTypeItem} MetadataTypeItem * @typedef {import('../../types/mcdev.d.js').MetadataTypeItemDiff} MetadataTypeItemDiff * @typedef {import('../../types/mcdev.d.js').MetadataTypeItemObj} MetadataTypeItemObj * @typedef {import('../../types/mcdev.d.js').MetadataTypeMap} MetadataTypeMap * @typedef {import('../../types/mcdev.d.js').MetadataTypeMapObj} MetadataTypeMapObj * @typedef {import('../../types/mcdev.d.js').SoapRequestParams} SoapRequestParams * @typedef {import('../../types/mcdev.d.js').TemplateMap} TemplateMap * * @typedef {import('../../types/mcdev.d.js').ReferenceObject} ReferenceObject * @typedef {import('../../types/mcdev.d.js').SfObjectField} SfObjectField * @typedef {import('../../types/mcdev.d.js').configurationArguments} configurationArguments * @typedef {import('../../types/mcdev.d.js').Conditions} Conditions * @typedef {import('../../types/mcdev.d.js').DataExtensionItem} DataExtensionItem */ /** * Event MetadataType * * @augments MetadataType */ class Event extends MetadataType { static reCacheDataExtensions = []; static createdKeys = []; /** * Retrieves Metadata of Event Definition. * Endpoint /interaction/v1/eventDefinitions return all Event Definitions with all details. * Currently it is not needed to loop over Imports with endpoint /interaction/v1/eventDefinitions/{id} * * @param {string} retrieveDir Directory where retrieved metadata directory will be saved * @param {void | string[]} [_] unused parameter * @param {void | string[]} [__] unused parameter * @param {string} [key] customer key of single item to retrieve * @returns {Promise.<MetadataTypeMapObj>} Promise of metadata */ static async retrieve(retrieveDir, _, __, key) { try { return await super.retrieveREST( retrieveDir, `/interaction/v1/eventDefinitions${ key ? '/key:' + encodeURIComponent(key) : '' }?extras=all`, null, key ); } catch (ex) { // if the event does not exist, the API returns the error "Request failed with status code 400 (ERR_BAD_REQUEST)" which would otherwise bring execution to a hold if (key && ex.code === 'ERR_BAD_REQUEST') { Util.logger.info( `Downloaded: ${this.definition.type} (0)${Util.getKeysString(key)}` ); this.postDeleteTasks(key); } else { throw ex; } } return; } /** * Retrieves event definition metadata for caching * * @returns {Promise.<MetadataTypeMapObj>} Promise of metadata */ static retrieveForCache() { return this.retrieve(null); } /** * Retrieve a specific Event Definition by Name * * @deprecated Use `retrieve` followed by `build` instead. `retrieveAsTemplate` will be removed in a future version. * @param {string} templateDir Directory where retrieved metadata directory will be saved * @param {string} name name of the metadata file * @param {TemplateMap} templateVariables variables to be replaced in the metadata * @returns {Promise.<MetadataTypeItemObj>} Promise of metadata */ static async retrieveAsTemplate(templateDir, name, templateVariables) { Util.logDeprecated('retrieveAsTemplate', `'retrieve' followed by 'build'`); const res = await this.client.rest.get( '/interaction/v1/eventDefinitions?name=' + encodeURIComponent(name) ); const event = res.items.filter((item) => item.name === name); try { if (!event || event.length === 0) { throw new Error(`No Event Definitions Found with name "${name}"`); } else if (event.length > 1) { throw new Error( `Multiple Event Definitions with name "${name}"` + `please rename to be unique to avoid issues` ); } else if (event?.length === 1) { const originalKey = event[0][this.definition.keyField]; const metadataItemTemplated = Util.replaceByObject( await this.postRetrieveTasks(event[0]), templateVariables ); if (!metadataItemTemplated.r__dataExtension_key) { throw new Error( `Event.postRetrieveTasks:: No Data Extension found for ${this.definition.type}: ${metadataItemTemplated.name}. This cannot be templated.` ); } // remove all fields listed in Definition for templating this.keepTemplateFields(metadataItemTemplated); await File.writeJSONToFile( [templateDir, this.definition.type].join('/'), originalKey + '.' + this.definition.type + '-meta', metadataItemTemplated ); Util.logger.info(` - templated ${this.definition.type}: ${name}`); return { metadata: metadataItemTemplated, type: this.definition.type }; } else { throw new Error( `Encountered unknown error when retrieveing ${ this.definition.typeName } "${name}": ${JSON.stringify(res.body)}` ); } } catch (ex) { Util.logger.error('Event.retrieveAsTemplate:: ' + ex); return null; } } /** * Delete a metadata item from the specified business unit * * @param {string} key Identifier of item * @returns {Promise.<boolean>} deletion success status */ static async deleteByKey(key) { return await super.deleteByKeyREST( '/interaction/v1/eventDefinitions/key:' + encodeURIComponent(key), key, 30000 ); } /** * Deploys metadata - merely kept here to be able to print {@link Util.logBeta} once per deploy * * @param {MetadataTypeMap} metadata metadata mapped by their keyField * @param {string} deployDir directory where deploy metadata are saved * @param {string} retrieveDir directory where metadata after deploy should be saved * @returns {Promise.<MetadataTypeMap>} Promise of keyField => metadata map */ static async deploy(metadata, deployDir, retrieveDir) { Util.logBeta(this.definition.type); const metadataMap = await super.deploy(metadata, deployDir, retrieveDir); this.createdKeys.length = 0; // reset createdKeys after deploy to ensure it's not used in future retrieves return metadataMap; } /** * Creates a single Event Definition * * @param {MetadataTypeItem} metadata a single Event Definition * @returns {Promise} Promise */ static create(metadata) { this.createdKeys.push(metadata[this.definition.keyField]); return super.createREST(metadata, '/interaction/v1/eventDefinitions/'); } /** * Updates a single Event Definition (using PUT method since PATCH isn't supported) * * @param {MetadataTypeItem} metadataEntry a single Event Definition * @returns {Promise} Promise */ static async update(metadataEntry) { return super.updateREST( metadataEntry, '/interaction/v1/eventDefinitions/key:' + encodeURIComponent(metadataEntry[this.definition.keyField]), 'put' ); } /** * prepares an event definition for deployment * * @param {MetadataTypeItem} metadata a single eventDefinition * @returns {Promise.<MetadataTypeItem>} parsed version */ static async preDeployTasks(metadata) { // Note: lots has to be done in createOrUpdate based on what action is required metadata.arguments ||= {}; metadata.arguments.eventDefinitionKey = metadata.eventDefinitionKey; // standard values metadata.isVisibleInPicker ||= false; if (metadata.isVisibleInPicker && !metadata.sourceApplicationExtensionId) { Util.logger.warn( ` - ${this.definition.type} ${metadata[this.definition.keyField]}: isVisibleInPicker is true but sourceApplicationExtensionId is missing. Setting isVisibleInPicker to false.` ); metadata.isVisibleInPicker = false; } metadata.isPlatformObject ||= false; metadata.mode ||= 'Production'; switch (metadata.type) { case 'APIEvent': { metadata.entrySourceGroupConfigUrl ||= 'jb:///data/entry/api-event/entrysourcegroupconfig.json'; metadata.iconUrl ||= '/images/icon_journeyBuilder-event-api-blue.svg'; break; } case 'SalesforceObjectTriggerV2': { metadata.iconUrl ||= '/events/js/salesforce-event/images/SF-Event-Icon.svg'; break; } case 'AutomationAudience': { metadata.iconUrl ||= '/images/icon-data-extension.svg'; break; } } // filter if (!metadata.filterDefinitionId) { metadata.filterDefinitionId = '00000000-0000-0000-0000-000000000000'; } // automation if (metadata.r__automation_key) { metadata.automationId = cache.searchForField( 'automation', metadata.r__automation_key, 'key', 'id' ); if (metadata.arguments) { metadata.arguments.automationId = metadata.automationId; } } else if (!metadata.automationId) { // if no automation was linked during retrieve we remove the field and hence need to re-set it before deployment metadata.automationId = '00000000-0000-0000-0000-000000000000'; if (metadata.arguments) { metadata.arguments.automationId = metadata.automationId; } } // dataExteension // is resolved in createOrUpdate const warnings = await this.preDeployTasks_SalesforceEntryEvents( metadata.type, metadata.configurationArguments ); if (warnings) { Util.logger.warn( ` - ${this.definition.type} '${metadata[this.definition.nameField]}' (${metadata[this.definition.keyField]}): ${warnings}` ); } await this.compareSalesforceEntryEvents_dataExtension( metadata.type, metadata.configurationArguments?.eventDataSummary, metadata.r__dataExtension_key ); return metadata; } /** * helper for {@link MetadataType.upsert} * * @param {MetadataTypeMap} metadataMap list of metadata * @param {string} metadataKey key of item we are looking at * @param {boolean} hasError error flag from previous code * @param {MetadataTypeItemDiff[]} metadataToUpdate list of items to update * @param {MetadataTypeItem[]} metadataToCreate list of items to create * @returns {Promise.<'create'|'update'|'skip'>} action to take */ static async createOrUpdate( metadataMap, metadataKey, hasError, metadataToUpdate, metadataToCreate ) { const createOrUpdateAction = await super.createOrUpdate( metadataMap, metadataKey, hasError, metadataToUpdate, metadataToCreate ); const metadataItem = metadataMap[metadataKey]; if (createOrUpdateAction === 'update') { if (metadataItem.r__dataExtension_key) { metadataItem.dataExtensionId = cache.searchForField( 'dataExtension', metadataItem.r__dataExtension_key, 'CustomerKey', 'ObjectID' ); metadataItem.dataExtensionName = cache.searchForField( 'dataExtension', metadataItem.r__dataExtension_key, 'CustomerKey', 'Name' ); metadataItem.arguments.dataExtensionId = metadataItem.dataExtensionId; if (metadataItem.schema) { metadataItem.schema.id = metadataItem.dataExtensionId; metadataItem.schema.name = metadataItem.dataExtensionName; } } if (metadataItem.schema?.fields?.length) { const normalizedKey = File.reverseFilterIllegalFilenames( metadataMap[metadataKey][this.definition.keyField] ); const cachedVersion = cache.getByKey(this.definition.type, normalizedKey); if (cachedVersion?.schema?.fields?.length) { const cacheClone = structuredClone(cachedVersion); cacheClone.schema.fields = cacheClone.schema.fields.map((field) => { delete field.isDevicePreference; return field; }); if (!deepEqual(metadataItem?.schema?.fields, cacheClone?.schema?.fields)) { Util.logger.warn( ` - ${this.definition.type} ${metadataItem[this.definition.keyField]}: schema fields differ from server version. Resetting as this will not be reflected on dataExtension.` ); metadataItem.schema.fields = cacheClone.schema.fields; } } } } else if (createOrUpdateAction === 'create') { let deFound; try { if (metadataItem.r__dataExtension_key) { metadataItem.dataExtensionId = cache.searchForField( 'dataExtension', metadataItem.r__dataExtension_key, 'CustomerKey', 'ObjectID' ); metadataItem.dataExtensionName = cache.searchForField( 'dataExtension', metadataItem.r__dataExtension_key, 'CustomerKey', 'Name' ); if (metadataItem.schema) { Util.logger.info( ` - ${this.definition.type} ${metadataItem[this.definition.keyField]}: dataExtension ${metadataItem.r__dataExtension_key} found, ignoring schema-section in ${this.definition.type} json` ); } deFound = true; } else { deFound = false; } } catch { deFound = false; } if (!deFound) { if (metadataItem.r__dataExtension_key) { if (!metadataItem.schema) { // make sure we skip this item this.removeFromDeployment(metadataKey, metadataToUpdate, metadataToCreate); throw new Error( `related dataExtension ${metadataItem.r__dataExtension_key} not found` ); } metadataItem.schema.name = metadataItem.r__dataExtension_key; } if (!metadataItem.schema) { this.removeFromDeployment(metadataKey, metadataToUpdate, metadataToCreate); throw new Error(`no related dataExtension and no schema found`); } Util.logger.warn( `Data Extension ${metadataItem.schema.name || metadataItem[this.definition.keyField]} not found on BU. Creating it automatically based on schema-definition.` ); // we want the event api to create the DE for us based on the schema this.reCacheDataExtensions.push({ eventKey: metadataItem[this.definition.keyField], deKey: metadataItem.schema.name || metadataItem[this.definition.keyField], }); } } return createOrUpdateAction; } /** * helper for {@link createOrUpdate} * * @param {string} metadataKey key of item we are looking at * @param {MetadataTypeItemDiff[]} metadataToUpdate list of items to update * @param {MetadataTypeItem[]} metadataToCreate list of items to create */ static removeFromDeployment(metadataKey, metadataToUpdate, metadataToCreate) { const removeUpdate = metadataToUpdate.findIndex( (el) => el.after[this.definition.keyField] === metadataKey ); if (removeUpdate !== -1) { metadataToUpdate.splice(removeUpdate, 1); } const removeCreate = metadataToCreate.findIndex( (el) => el[this.definition.keyField] === metadataKey ); if (removeCreate !== -1) { metadataToCreate.splice(removeCreate, 1); } } /** * Gets executed after deployment of metadata type * * @param {MetadataTypeMap} upsertResults metadata mapped by their keyField as returned by update/create * @param {MetadataTypeMap} originalMetadata metadata to be updated (contains additioanl fields) * @param {{created: number, updated: number}} createdUpdated counter representing successful creates/updates * @returns {Promise.<void>} - */ static async postDeployTasks(upsertResults, originalMetadata, createdUpdated) { // CREATE ONLY - if dataExtensions were auto-created if (this.reCacheDataExtensions.length && createdUpdated.created > 0) { Util.logger.warn(' - Re-caching dependent Metadata: dataExtension'); const deRetrieve = await DataExtension.retrieveForCache(); cache.setMetadata('dataExtension', deRetrieve.metadata); const reDownloadDeKeys = []; // try to update key & name of the auto-generated dataExtension for (const { eventKey, deKey } of this.reCacheDataExtensions) { if (!upsertResults[eventKey]) { continue; } const eventItem = upsertResults[eventKey]; const newDeKey = cache.searchForField( 'dataExtension', eventItem.dataExtensionId, 'ObjectID', 'CustomerKey' ); // get dataExtension from cache which conveniently already has the ObjectID set const deObj = cache.getByKey('dataExtension', newDeKey); const oldName = deObj[DataExtension.definition.nameField]; // prepare a clone of the DE to update name & key to match the event const clone = structuredClone(deObj); clone[DataExtension.definition.keyField] = deKey; clone[DataExtension.definition.nameField] = deKey; try { // update DE on server await DataExtension.update(clone, true); Util.logger.info( ` - changed dataExtension ${newDeKey} (${oldName}) key/name to ${deKey}` ); // update cache deObj[DataExtension.definition.keyField] = deKey; deObj[DataExtension.definition.nameField] = deKey; reDownloadDeKeys.push(deObj[DataExtension.definition.keyField]); } catch { // fallback, set DE key to value of DE name const clone = structuredClone(deObj); clone[DataExtension.definition.keyField] = oldName; try { // update DE on server await DataExtension.update(clone, true); Util.logger.info( ` - changed dataExtension ${newDeKey} (${oldName}) key to ${oldName}` ); // update cache deObj[DataExtension.definition.keyField] = deObj[DataExtension.definition.nameField]; reDownloadDeKeys.push(deObj[DataExtension.definition.keyField]); } catch { Util.logger.debug( ` - failed to change dataExtension ${newDeKey} (${oldName}) key/name` ); } } } this.reCacheDataExtensions.length = 0; // ensure we have downloaded auto-created DEs if (reDownloadDeKeys.length) { const retriever = new Retriever(this.properties, this.buObject); await retriever.retrieve(['dataExtension'], reDownloadDeKeys); } } // re-retrieve all upserted items to ensure we have all fields (createdDate and modifiedDate are otherwise not present) Util.logger.debug( `Caching all ${this.definition.type} post-deploy to ensure we have all fields` ); const typeCache = await this.retrieveForCache(); // update values in upsertResults with retrieved values before saving to disk for (const key of Object.keys(upsertResults)) { if (typeCache.metadata[key]) { upsertResults[key] = typeCache.metadata[key]; } } } /** * parses retrieved Metadata before saving * * @param {MetadataTypeItem} metadata a single event definition * @returns {Promise.<MetadataTypeItem>} parsed metadata */ static async postRetrieveTasks(metadata) { try { metadata.createdBy = cache.searchForField( 'user', metadata.createdBy, 'AccountUserID', 'Name' ); } catch (ex) { Util.logger.verbose( ` - ${this.definition.type} ${metadata[this.definition.nameField]} (${ metadata[this.definition.keyField] }): ${ex.message}.` ); } try { metadata.modifiedBy = cache.searchForField( 'user', metadata.modifiedBy, 'AccountUserID', 'Name' ); } catch (ex) { Util.logger.verbose( ` - ${this.definition.type} ${metadata[this.definition.nameField]} (${ metadata[this.definition.keyField] }): ${ex.message}.` ); } // filter if ( metadata?.filterDefinitionId && metadata.filterDefinitionId === '00000000-0000-0000-0000-000000000000' ) { // if no filter is linked this placeholder value is set which holds no value delete metadata.filterDefinitionId; } // automation if (metadata?.automationId) { if (metadata?.automationId === '00000000-0000-0000-0000-000000000000') { // if no automation is linked this placeholder value is set which holds no value delete metadata.automationId; } else { try { metadata.r__automation_key = cache.searchForField( 'automation', metadata.automationId, 'id', 'key' ); delete metadata.automationId; delete metadata.arguments?.automationId; } catch (ex) { Util.logger.warn( ` - ${this.definition.type} ${metadata[this.definition.nameField]} (${ metadata[this.definition.keyField] }): ${ex.message}.` ); } } } // dataExtension try { metadata.r__dataExtension_key = cache.searchForField( 'dataExtension', metadata.dataExtensionId, 'ObjectID', 'CustomerKey' ); delete metadata.dataExtensionId; delete metadata.dataExtensionName; delete metadata.arguments.dataExtensionId; if (metadata.schema) { delete metadata.schema.id; } } catch (ex) { Util.logger.verbose( ` - ${this.definition.type} ${metadata[this.definition.nameField]} (${ metadata[this.definition.keyField] }): ${ex.message}.` ); } if (!metadata.isPlatformObject) { delete metadata.isPlatformObject; } if (metadata.mode === 'Production') { delete metadata.mode; } if (!this.createdKeys.includes(metadata[this.definition.keyField])) { if (metadata.interactionCount === 0 && metadata.publishedInteractionCount === 0) { Util.logger.warn( ` - ${this.definition.type} '${metadata[this.definition.nameField]}' (${metadata[this.definition.keyField]}): is not used and could therefore be deleted. Associated Journeys: ${metadata.interactionCount}. Active Journeys: ${metadata.publishedInteractionCount}.` ); } else if (metadata.publishedInteractionCount === 0) { Util.logger.info( Util.getGrayMsg( ` - ${this.definition.type} '${metadata[this.definition.nameField]}' (${metadata[this.definition.keyField]}): is currently inactive. Associated Journeys: ${metadata.interactionCount}. Active Journeys: ${metadata.publishedInteractionCount}.` ) ); } } try { await this.postRetrieveTasks_SalesforceEntryEvents( metadata.type, metadata.configurationArguments, metadata.eventDefinitionKey, metadata.publishedInteractionCount >= 1 ); } catch (ex) { const msg = ` - ${this.definition.type} '${metadata[this.definition.nameField]}' (${metadata[this.definition.keyField]}) with ${metadata.publishedInteractionCount} active journeys: ${ex.message}`; Util.logger.warn(metadata.publishedInteractionCount === 0 ? Util.getGrayMsg(msg) : msg); } try { await this.compareSalesforceEntryEvents_dataExtension( metadata.type, metadata.configurationArguments?.eventDataSummary, metadata.r__dataExtension_key ); } catch (ex) { const msg = ` - ${this.definition.type} '${metadata[this.definition.nameField]}' (${metadata[this.definition.keyField]}) with ${metadata.publishedInteractionCount} active journeys: ${ex.message}`; Util.logger.warn(metadata.publishedInteractionCount === 0 ? Util.getGrayMsg(msg) : msg); } return metadata; } static sfObjects = { /** @type {string[]} */ workflowObjects: null, /** @type {Object.<string, ReferenceObject[]>} object-name > object data */ referencedObjects: {}, /** @type {Object.<string, Object.<string, SfObjectField>>} object-name > field-name > field data */ objectFields: {}, /** @type {Object.<string, Promise.<any>>} */ loadingFields: {}, /** @type {Object.<string, Promise.<any>>} */ loadingRelatedObjects: {}, /** @type {Promise.<any>} */ loadingWorkflowObjects: null, }; /** * helper for {@link checkSalesforceEntryEvents} that retrieves information about SF object fields * * @param {string} objectAPIName salesforce object api name */ static async getSalesforceObjects(objectAPIName) { if (!objectAPIName) { return; } // 1 get all available Salesforce objects // similar response to /jbint/getWorkflowObjects if (!this.sfObjects.workflowObjects) { if (!this.sfObjects.loadingWorkflowObjects) { this.sfObjects.loadingWorkflowObjects = this._getWorkflowObjects(); } await this.sfObjects.loadingWorkflowObjects; } // 2 get objects related to the selected object // same response as /jbint/getRelatedObjects?type=<objectAPIName> if (!this.sfObjects.referencedObjects?.[objectAPIName]) { if (!this.sfObjects.loadingRelatedObjects[objectAPIName]) { this.sfObjects.loadingRelatedObjects[objectAPIName] = this._getRelatedSfObjects(objectAPIName); } await this.sfObjects.loadingRelatedObjects[objectAPIName]; // 3 get fields const rateLimit = pLimit(20); const uniqueSfObjectNames = this.sfObjects.referencedObjects[objectAPIName] ? [ ...new Set( Object.values(this.sfObjects.referencedObjects[objectAPIName]) .map((el) => el.referenceObjectName) .sort() ), ] : []; await Promise.all( uniqueSfObjectNames.map((objectAPIName) => rateLimit(async () => { this.sfObjects.loadingFields[objectAPIName] ||= this._getSalesforceObjectFields(objectAPIName); return this.sfObjects.loadingFields[objectAPIName]; }) ) ); // 4 create Common fields const contactLeadName = 'Contacts and Leads'; if ( this.sfObjects.objectFields['Contact'] && this.sfObjects.objectFields['Lead'] && !this.sfObjects.workflowObjects.includes(contactLeadName) ) { Util.logger.verbose( Util.getGrayMsg(' - Constructing Common / Contacts and Leads object') ); // add fake entry to workflowObjects to allow testing for this easily this.sfObjects.workflowObjects.push(contactLeadName); // construct fields object for it this.sfObjects.objectFields[contactLeadName] = {}; const contactFieldNames = Object.keys(this.sfObjects.objectFields['Contact']); const leadFieldNames = Object.keys(this.sfObjects.objectFields['Lead']); for (const fieldName of contactFieldNames.filter((item) => leadFieldNames.includes(item) )) { // copy the value from contact - while thats not perfectly correct it will hopefully be sufficient for what we need to check this.sfObjects.objectFields[contactLeadName][fieldName] = structuredClone( this.sfObjects.objectFields['Contact'][fieldName] ); this.sfObjects.objectFields[contactLeadName][fieldName].objectname = 'Common'; // do not delete fields from Contact or Lead because it depends on the environment where we have to look for those } // create duplicate to also reference this via "Common" this.sfObjects.objectFields['Common'] = this.sfObjects.objectFields[contactLeadName]; } } return; } /** * helper that allows skipping to run this again in multi-key retrieval */ static async _getWorkflowObjects() { Util.logger.info(Util.getGrayMsg(' - Caching Salesforce Objects')); const workflowObjectsResponse = await this.client.rest.get( `/data/v1/integration/member/salesforce/workflowobjects` ); this.sfObjects.workflowObjects = workflowObjectsResponse ? workflowObjectsResponse.map((o) => o.apiname) : []; } /** * helper that allows skipping to run this again in multi-key retrieval * * @param {string} objectAPIName SF entry object of the current event */ static async _getRelatedSfObjects(objectAPIName) { Util.logger.info( Util.getGrayMsg(' - Caching Related Salesforce Objects for ' + objectAPIName) ); try { const referenceObjectsResponse = await this.client.rest.get( `/data/v1/integration/member/salesforce/object/${objectAPIName}/referenceobjects` ); // add itself first so that we get the fields for objectAPIName as well const selfReference = { referenceObjectName: objectAPIName, relationshipName: objectAPIName, }; this.sfObjects.referencedObjects[objectAPIName] = referenceObjectsResponse ? [selfReference, ...referenceObjectsResponse] : [selfReference]; if ( referenceObjectsResponse.some((el) => el.referenceObjectName === 'Lead') && referenceObjectsResponse.some((el) => el.referenceObjectName === 'Contact') ) { // add fake object "Common" to referenced objects for testing this.sfObjects.referencedObjects[objectAPIName].push({ displayname: 'Common', relationshipIdName: 'Id', relationshipName: 'Common', isPolymorphic: false, referenceObjectName: 'Common', }); } } catch (ex) { if (ex.code === 'ERR_BAD_RESPONSE') { throw new Error( `Could not find Salesforce entry object ${objectAPIName} on connected org.` ); } } } /** * helper that allows skipping to run this again in multi-key retrieval * * @param {string} objectAPIName SF object for which to get the fields */ static async _getSalesforceObjectFields(objectAPIName) { if (this.sfObjects.objectFields[objectAPIName] || objectAPIName === 'Common') { return; } Util.logger.verbose( Util.getGrayMsg(' - Caching Fields for Salesforce Object ' + objectAPIName) ); const referenceObjectsFieldsResponse = await this.client.rest.get( `/legacy/v1/beta/integration/member/salesforce/object/${objectAPIName}` ); if (referenceObjectsFieldsResponse?.sfobjectfields?.length) { Util.logger.debug( `Found ${referenceObjectsFieldsResponse?.sfobjectfields?.length} fields for Salesforce Object ${objectAPIName}` ); this.sfObjects.objectFields[objectAPIName] = {}; // !add default fields that are somehow not always returned by this legacy beta API for (const field of this.defaultSalesforceFields) { // @ts-expect-error hack to work around shortcomings of legacy beta API this.sfObjects.objectFields[objectAPIName][field] = { label: field, name: field, }; } // add fields returned by API for (const field of referenceObjectsFieldsResponse.sfobjectfields) { this.sfObjects.objectFields[objectAPIName][field.name] = field; } } else { Util.logger.warn( `Could not cache fields for Salesforce Object '${objectAPIName}'. This is likely caused by insufficient access of your MC-Connect integration user. Please check assigned permission sets / the profile.` ); } return; } static defaultSalesforceFields = [ 'Id', 'Name', 'FirstName', 'LastName', 'Phone', 'CreatedById', 'CreatedDate', 'IsDeleted', 'LastModifiedById', 'LastModifiedDate', 'SystemModstamp', ]; /** * * @param {configurationArguments} ca trigger[0].configurationArguments * @param {boolean} isPublished if the current item is published it means we do not need to do contact vs common checks * @returns {string} warnings or null */ static checkSalesforceEntryEvents(ca, isPublished) { // 1 check eventDataConfig const edcObjects = ca.eventDataConfig.objects.sort((a, b) => a.dePrefix.localeCompare(b.dePrefix) ); const warnings = []; const errors = []; const dePrefixFields = {}; const dePrefixRelationshipMap = {}; const dePrefixReferenceObjectMap = {}; // SFMC only uses "Common" to aggreagate Contacts and Leads if that was actively selected in the entry event. Also, already published journeys/events continue to work even if fields would later be changed, leading to a shift from or to the "common" fake-object. const checkCommon = ca.whoToInject === 'Contact ID/Lead ID (Contacts and Leads)' && !isPublished; for (const object of edcObjects) { // create secondary object to quickly check eventDataSummary against dePrefixFields[object.dePrefix] = object.fields; // if the current object is the entry object then relationshipName and referenceObject are set to empty strings because it's not "referencing" a "relationship" but just listing its own fields dePrefixRelationshipMap[object.dePrefix] = object.relationshipName === '' ? object.dePrefix.split(':')[0] : object.relationshipName; dePrefixReferenceObjectMap[object.dePrefix] = object.referenceObject === '' ? object.dePrefix.split(':')[0] : object.referenceObject; // 1.1 check if fields in eventDataConfig exist in Salesforce // if it has no value this is the entry-source object itself const referencedObject = object.referenceObject === '' ? ca.objectAPIName : object.referenceObject; // sort list of fields alphabetically object.fields.sort(); // check if object was cached earlier if (!this.sfObjects.workflowObjects.includes(referencedObject)) { errors.push(`Salesforce object ${referencedObject} not found on connected org.`); } else if ( !this.sfObjects.objectFields[referencedObject] || !Object.keys(this.sfObjects.objectFields[referencedObject]).length ) { // check if we found fields for the object const msg = `Fields for Salesforce object ${referencedObject} could not be checked. Fields selected in entry event: ` + object.fields.join(', '); if (Util.OPTIONS.ignoreSfFields) { warnings.push(` (--ignoreSfFields) ` + msg); } else { errors.push( msg + ` (you can use --ignoreSfFields to skip this error in case you are convinced it is a false positive)` ); } } else { // check if the fields selected in the eventDefinition are actually available for (const fieldName of object.fields) { if ( checkCommon && (referencedObject === 'Contact' || referencedObject === 'Lead') && this.sfObjects.objectFields['Common'][fieldName] ) { errors.push( `Salesforce object field ${referencedObject}.${fieldName} needs to be referenced as Common.${fieldName}` ); } else if (!this.sfObjects.objectFields[referencedObject][fieldName]) { // TODO reactivate after switch to new API // errors.push( // `Salesforce object field ${referencedObject}.${fieldName} not available on connected org.` // ); } // 1.2 check if all fields in eventDataConfig are listed in the eventDataSummary if (!ca.eventDataSummary.includes(object.dePrefix + fieldName)) { // we could auto-create eventDataSummary but frankly this is good for code reviews and for searching for fields errors.push( `Field ${object.dePrefix + fieldName} is listed under eventDataConfig${object.referenceObject ? ` for referenceObject ` + object.referenceObject : ''} but not in eventDataSummary` ); } } } } // 2 compare eventDataConfig with eventDataSummary // check if all fields in eventDataSummary are listed in the eventDataConfig for (let fieldName of ca.eventDataSummary) { // we could auto-create eventDataSummary but frankly this is good for code reviews and for searching for fields const fieldPath = fieldName.split(':'); fieldName = fieldPath.pop(); const dePrefix = fieldPath.join(':') + ':'; if (!dePrefixFields[dePrefix]) { errors.push( `Field ${dePrefix + fieldName} is listed under eventDataSummary but object ${dePrefix} was not found in eventDataConfig` ); } else if (!dePrefixFields[dePrefix]?.includes(fieldName)) { errors.push( `Field ${dePrefix + fieldName} is listed under eventDataSummary but not in eventDataConfig` ); } } // 3 check contactKey // check against referencedObjects const referencedContactObj = this.sfObjects.referencedObjects[ca.objectAPIName].find( (el) => el.relationshipName === (ca.contactKey.relationshipName == '' ? ca.contactKey.referenceObjectName : ca.contactKey.relationshipName) ); if (referencedContactObj) { if ( ca.contactKey.isPolymorphic && referencedContactObj.isPolymorphic !== ca.contactKey.isPolymorphic ) { errors.push( `configurationArguments.contactKey states an incorrect isPolimorphic value. Should be ${referencedContactObj.isPolymorphic}` ); } if (referencedContactObj.referenceObjectName !== ca.contactKey.referenceObjectName) { errors.push( `configurationArguments.contactKey states an incorrect referenceObjectName value. Should be ${referencedContactObj.referenceObjectName}` ); } // * if contactKey uses "Common" then there is no fieldName attribute but instead relationshipIdName needs to be checked if ( checkCommon && ca.contactKey.referenceObjectName === 'Contact' && this.sfObjects.objectFields['Common'][ ca.contactKey.fieldName || ca.contactKey.relationshipIdName ] ) { errors.push( `configurationArguments.contactKey should be referencing Common instead of Contact` ); } else if ( !this.sfObjects.objectFields[ca.contactKey.referenceObjectName]?.[ ca.contactKey.fieldName || ca.contactKey.relationshipIdName ] ) { errors.push( `configurationArguments.contactKey states the invalid fieldName '${ca.contactKey.fieldName || ca.contactKey.relationshipIdName}' value that does not exist on ${ca.contactKey.referenceObjectName}` ); } } else { errors.push( `configurationArguments.contactKey references ${ ca.contactKey.relationshipName == '' ? ca.contactKey.referenceObjectName : ca.contactKey.relationshipName } which is not found in related salesforce objects` ); } // 4 check passThroughArgument const dePrefixCommon = ca.objectAPIName + ':Common'; for (const key of Object.keys(ca.passThroughArgument.fields)) { const fieldPath = ca.passThroughArgument.fields[key].split(':'); const fieldName = fieldPath.pop(); const dePrefix = fieldPath.join(':') + ':'; // it seems these fields do NOT need to be in the eventDataConfig const relationshipName = dePrefixRelationshipMap[dePrefix]; const referenceObject = dePrefixReferenceObjectMap[dePrefix]; if (!this.sfObjects.objectFields[referenceObject]?.[fieldName]) { errors.push( `Field ${dePrefix + fieldName} is listed under passThroughArgument.fields.${key} but is not available on connected org.` ); } else if ( checkCommon && (relationshipName === 'Contact' || relationshipName === 'Lead') && this.sfObjects.objectFields['Common']?.[fieldName] ) { errors.push( `Field ${dePrefix + fieldName} is listed under passThroughArgument.fields.${key} but needs to be referenced as ${dePrefixCommon}.${fieldName}` ); } } // 5.a check primaryObjectFilterCriteria this.checkSfFilterFieldsExist( ca.primaryObjectFilterCriteria.conditions, errors, 'primaryObjectFilterCriteria' ); // 5.b check relatedObjectFilterCriteria this.checkSfFilterFieldsExist( ca.relatedObjectFilterCriteria.conditions, errors, 'relatedObjectFilterCriteria' ); // 6.a remove primaryObjectFilterSummary (and auto-generate it again in preDeploy from primaryObjectFilterCriteria) // TODO // 6.b remove relatedObjectFilterSummary (and auto-generate it again in preDeploy from relatedObjectFilterCriteria) // TODO // 7 remove eventDataSummary (and auto-generate it again in preDeploy from eventDataConfig) // TODO // 8 remove evaluationCriteriaSummary (and auto-generate it again in preDeploy from salesforceTriggerCriteria) // TODO // throw error if problems were found if (errors?.length) { // add a line break if (errors.length > 1) { errors.unshift(``); } throw new Error(errors.join('\n · ')); // eslint-disable-line unicorn/error-message } if (warnings?.length) { // add a line break if (warnings.length > 1) { warnings.unshift(``); } return warnings.join('\n · '); } else { return null; } } /** * * @param {object[]} conditions - * @param {string[]} errors list of errors * @param {'primaryObjectFilterCriteria'|'relatedObjectFilterCriteria'} context used to improve error logs */ static checkSfFilterFieldsExist(conditions, errors, context) { for (const condition of conditions) { if ( condition.fieldName & condition.referenceObjectName && !this.sfObjects.objectFields[condition.referenceObjectName]?.[condition.fieldName] ) { errors.push( `Field ${condition.referenceObjectName}.${condition.fieldName} is listed under ${context} but is not available on connected org.` ); } else if (condition.conditions) { this.checkSfFilterFieldsExist(condition.conditions, errors, context); } } } static requiredConfigurationArguments = [ 'applicationExtensionKey', 'contactKey', 'contactPersonType', 'eventDataConfig', 'objectAPIName', 'passThroughArgument', 'primaryObjectFilterCriteria', 'relatedObjectFilterCriteria', 'salesforceTriggerCriteria', 'version', 'whoToInject', ]; /** * * @param {string} triggerType e.g. SalesforceObjectTriggerV2, APIEvent, ... * @param {configurationArguments} ca trigger[0].configurationArguments * @param {string} key of event / journey * @param {boolean} isPublished if the current item is published it means we do not need to do contact vs common checks