mcdev
Version:
Accenture Salesforce Marketing Cloud DevTools
616 lines (586 loc) • 23.4 kB
JavaScript
;
import MetadataType from './MetadataType.js';
import { Util } from '../util/util.js';
import cache from '../util/cache.js';
import asset from './Asset.js';
import folder from './Folder.js';
import list from './List.js';
import sendClassification from './SendClassification.js';
import senderProfile from './SenderProfile.js';
import ReplaceCbReference from '../util/replaceContentBlockReference.js';
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
*/
/**
* MessageSendActivity MetadataType
*
* @augments MetadataType
*/
class TriggeredSend extends MetadataType {
/**
* Retrieves SOAP based metadata of metadata type into local filesystem. executes callback with retrieved metadata
*
* @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 retrieve(retrieveDir, _, __, key) {
/** @type {SoapRequestParams} */
let requestParams = {
filter: {
leftOperand: 'TriggeredSendStatus',
operator: 'IN',
rightOperand: ['New', 'Active', 'Inactive', 'Moved', 'Canceled'], // New, Active=Running, Inactive=Paused, (Deleted)
},
};
if (key) {
// move original filter down one level into rightOperand and add key filter into leftOperand
requestParams = {
filter: {
leftOperand: {
leftOperand: 'CustomerKey',
operator: 'equals',
rightOperand: key,
},
operator: 'AND',
rightOperand: requestParams.filter,
},
};
}
return super.retrieveSOAP(retrieveDir, requestParams, key);
}
/**
* Create a single TSD.
*
* @param {MetadataTypeItem} metadata single metadata entry
* @returns {Promise} Promise
*/
static create(metadata) {
return super.createSOAP(metadata);
}
/**
* Updates a single TSD.
*
* @param {MetadataTypeItem} metadata single metadata entry
* @returns {Promise} Promise
*/
static update(metadata) {
// * in case of update and active definition, we need to pause first.
// * this should be done manually to not accidentally pause production queues without restarting them
return super.updateSOAP(metadata);
}
/**
* Delete a metadata item from the specified business unit
*
* @param {string} key Identifier of data extension
* @returns {Promise.<boolean>} deletion success status
*/
static deleteByKey(key) {
return super.deleteByKeySOAP(key, undefined, 17015);
}
/**
* parses retrieved Metadata before saving
*
* @param {MetadataTypeItem} metadata a single item
* @returns {MetadataTypeItem | void} Array with one metadata object and one sql string
*/
static postRetrieveTasks(metadata) {
// remove IsPlatformObject, always has to be 'false'
delete metadata.IsPlatformObject;
// folder
this.setFolderPath(metadata);
if (!metadata.r__folder_Path) {
Util.logger.verbose(
` ☇ skipping ${this.definition.typeName} '${metadata.Name}'/'${metadata.CustomerKey}': Could not find folder.`
);
// do not save this TSD because it would not be visible in the user interface
return;
}
// email
try {
// content builder
const contentBuilderEmailName = cache.searchForField(
'asset',
metadata.Email.ID,
'legacyData.legacyId',
'name'
);
metadata.r__asset_name_readOnly = contentBuilderEmailName;
const contentBuilderEmailKey = cache.searchForField(
'asset',
metadata.Email.ID,
'legacyData.legacyId',
'customerKey'
);
metadata.r__asset_key = contentBuilderEmailKey;
delete metadata.Email;
} catch {
try {
// classic
const classicEmail = cache.searchForField('email', metadata.Email.ID, 'ID', 'Name');
metadata.r__email_name = classicEmail;
delete metadata.Email;
} catch {
Util.logger.verbose(
` - ${this.definition.typeName} '${metadata.Name}'/'${metadata.CustomerKey}': Could not find email with ID ${metadata.Email.ID} in Classic nor in Content Builder. This TSD cannot be republished but potentially restarted with its cached version of the email.`
);
// save this TSD because it could be fixed by the user or potentially restarted without a fix; also, it might be used by a journey
}
}
// message priority
if (metadata.Priority) {
metadata.c__priority = Util.inverseGet(
this.definition.priorityMapping,
metadata.Priority
);
delete metadata.Priority;
}
// List (optional)
if (metadata.List) {
try {
metadata.r__list_PathName = cache.getListPathName(metadata.List.ID, 'ID');
delete metadata.List;
} catch (ex) {
Util.logger.verbose(
` - ${this.definition.typeName} '${metadata.Name}'/'${metadata.CustomerKey}': ${ex.message}`
);
// save this TSD because it could be fixed by the user
}
}
// sender profile
if (metadata.SenderProfile?.ObjectID) {
try {
const spKey = cache.searchForField(
'senderProfile',
metadata.SenderProfile.ObjectID,
'ObjectID',
'CustomerKey'
);
metadata.r__senderProfile_key = spKey;
delete metadata.SenderProfile;
} catch (ex) {
Util.logger.warn(
` - ${this.definition.type} ${metadata.CustomerKey}: ${ex.message} (senderProfile key ${metadata.SenderProfile.CustomerKey})`
);
}
}
// send classification
if (metadata.SendClassification?.ObjectID) {
try {
const scKey = cache.searchForField(
'sendClassification',
metadata.SendClassification.ObjectID,
'ObjectID',
'CustomerKey'
);
metadata.r__sendClassification_key = scKey;
delete metadata.SendClassification;
} catch (ex) {
Util.logger.warn(
` - ${this.definition.type} ${metadata.CustomerKey}: ${ex.message} (sendClassification key ${metadata.SendClassification.CustomerKey})`
);
}
}
return metadata;
}
/**
* prepares a TSD for deployment
*
* @param {MetadataTypeItem} metadata of a single TSD
* @returns {Promise.<MetadataTypeItem>} metadata object
*/
static async preDeployTasks(metadata) {
const cachedVersion = cache.getByKey(this.definition.type, metadata.CustomerKey);
if (
cachedVersion?.TriggeredSendStatus === 'Active' &&
cachedVersion?.TriggeredSendStatus === metadata.TriggeredSendStatus
) {
throw new Error(
`Please pause the Triggered Send '${metadata.Name}' before updating it. You may do so via GUI; or via Accenture SFMC DevTools by setting TriggeredSendStatus to 'Inactive'.`
);
}
// re-add IsPlatformObject, required for visibility
metadata.IsPlatformObject = false;
// folder
super.setFolderId(metadata);
// email
if (metadata.r__email_name) {
// classic
metadata.Email = {
ID: cache.searchForField('email', metadata.r__email_name, 'Name', 'ID'),
};
delete metadata.r__email_name;
} else if (metadata.r__asset_key) {
// content builder
// * this ignores r__asset_name_readOnly on purpose as that is only unique per parent folder but useful during PR reviews
metadata.Email = {
ID: cache.searchForField(
'asset',
metadata.r__asset_key,
'customerKey',
'legacyData.legacyId'
),
};
delete metadata.r__asset_key;
delete metadata.r__asset_name_readOnly;
} else if (metadata?.Email?.ID) {
throw new Error(
`r__asset_key / r__email_name not defined but instead found Email.ID. Please try re-retrieving this TSD from your BU.`
);
}
// message priority
if (metadata.c__priority) {
metadata.Priority = this.definition.priorityMapping[metadata.c__priority];
delete metadata.c__priority;
}
// get List (optional)
if (metadata.r__list_PathName) {
metadata.List = {
ID: cache.getListObjectId(metadata.r__list_PathName, 'ID'),
};
delete metadata.r__list_PathName;
} else if (metadata?.List?.ID) {
throw new Error(
`r__list_PathName not defined but instead found List.ID. Please try re-retrieving this TSD from your BU.`
);
}
// sender profile
if (metadata.r__senderProfile_key) {
const spId = cache.searchForField(
'senderProfile',
metadata.r__senderProfile_key,
'CustomerKey',
'ObjectID'
);
metadata.SenderProfile = {
ObjectID: spId,
CustomerKey: metadata.r__senderProfile_key,
};
delete metadata.r__senderProfile_key;
}
// send classification
if (metadata.r__sendClassification_key) {
const scId = cache.searchForField(
'sendClassification',
metadata.r__sendClassification_key,
'CustomerKey',
'ObjectID'
);
metadata.SendClassification = {
ObjectID: scId,
CustomerKey: metadata.r__sendClassification_key,
};
delete metadata.r__sendClassification_key;
}
return metadata;
}
/**
* TSD-specific refresh method that finds active TSDs and refreshes them
*
* @param {string[]} [keyArr] metadata keys
* @param {boolean} [checkKey] whether to check if the key is valid
* @returns {Promise.<string[]>} Returns list of keys that were refreshed
*/
static async refresh(keyArr, checkKey = true) {
if (!keyArr) {
keyArr = await this.getKeysForValidTSDs((await this.findRefreshableItems()).metadata);
checkKey = false;
}
// then executes pause, publish, start on them.
Util.logger.info(`Refreshing ${keyArr.length} ${this.definition.typeName}...`);
Util.logger.debug(`Refreshing keys: ${keyArr.join(', ')}`);
const refreshedKeyArr = [];
const rateLimit = pLimit(10);
await Promise.all(
keyArr.map((key) =>
rateLimit(async () => {
const result = await this._refreshItem(key, checkKey);
if (result) {
refreshedKeyArr.push(key);
}
})
)
);
Util.logger.info(
`Refreshed ${refreshedKeyArr.length} of ${keyArr.length} ${this.definition.type}`
);
return refreshedKeyArr;
}
/**
* helper for {@link TriggeredSend.refresh} that extracts the keys from the TSD item map and eli
*
* @param {MetadataTypeMap} metadata TSD item map
* @returns {Promise.<string[]>} keyArr
*/
static async getKeysForValidTSDs(metadata) {
const keyArr = Object.keys(metadata).filter((key) => {
const test = this.postRetrieveTasks(metadata[key]);
return test?.CustomerKey || false;
});
Util.logger.info(`Found ${keyArr.length} refreshable items.`);
return keyArr;
}
/**
* helper for {@link TriggeredSend.refresh} that finds active TSDs on the server and filters it by the same rules that {@link TriggeredSend.retrieve} is using to avoid refreshing TSDs with broken dependencies
*
* @param {boolean} [assetLoaded] if run after Asset.deploy via --refresh option this will skip caching assets
* @returns {Promise.<MetadataTypeMapObj>} Promise of TSD item map
*/
static async findRefreshableItems(assetLoaded = false) {
Util.logger.info('Finding refreshable items...');
// cache dependencies to test for broken links
const requiredCache = {
folder: [
'hidden',
'list',
'mysubs',
'suppression_list',
'publication',
'contextual_suppression_list',
'triggered_send',
'triggered_send_journeybuilder',
],
};
for (const dep of this.definition.dependencies) {
if (dep === 'email') {
// skip deprecated classic emails here, assuming they cannot be updated and hence are not relevant for {@link refresh}
continue;
}
const [type, subtype] = dep.split('-');
if (requiredCache[type]) {
requiredCache[type].push(subtype);
} else {
requiredCache[type] = subtype ? [subtype] : null;
}
}
for (const [type, subTypeArr] of Object.entries(requiredCache)) {
if (Array.isArray(subTypeArr)) {
// make sure entries are unique
requiredCache[type] = [...new Set(subTypeArr)];
}
}
const cacheTypes = {
asset,
folder,
list,
sendClassification,
senderProfile,
};
for (const [type, subTypeArr] of Object.entries(requiredCache)) {
if (type === 'asset' && assetLoaded) {
continue;
}
Util.logger.info(` - Caching dependent Metadata: ${type}`);
Util.logSubtypes(subTypeArr);
if (!cacheTypes[type]) {
throw new Error(`Cache type ${type} not implemented.`);
}
cacheTypes[type].client = this.client;
cacheTypes[type].buObject = this.buObject;
cacheTypes[type].properties = this.properties;
const result = await cacheTypes[type].retrieveForCache(null, subTypeArr);
if (cache.getCache()?.[type]) {
// re-run caching to merge with existing cache, assuming we might have missed subtypes
cache.mergeMetadata(type, result.metadata);
} else {
cache.setMetadata(type, result.metadata);
}
}
// cache ACTIVE triggeredSends from the server
/** @type {SoapRequestParams} */
const requestParams = {
filter: {
leftOperand: 'TriggeredSendStatus',
operator: 'IN',
rightOperand: ['dummy', 'Active'], // using equals does not work for this field for an unknown reason and IN requires at least 2 values, hence the 'dummy' entry
},
};
return super.retrieveSOAP(null, requestParams);
}
/**
* helper for {@link TriggeredSend.refresh} that pauses, publishes and starts a triggered send
*
* @param {string} key external key of triggered send item
* @param {boolean} checkKey whether to check if key exists on the server
* @returns {Promise.<boolean>} true if refresh was successful
*/
static async _refreshItem(key, checkKey) {
const item = {};
let test;
item[this.definition.keyField] = key;
// check triggeredSend-key exists on the server AND its status==ACTIVE
if (checkKey) {
/** @type {SoapRequestParams} */
const requestParams = {
filter: {
leftOperand: 'CustomerKey',
operator: 'equals',
rightOperand: key,
},
};
try {
test = (
await super.retrieveSOAP(null, requestParams, key, [
'CustomerKey',
'TriggeredSendStatus',
])
)?.metadata;
} catch (ex) {
const errorMsg = super.getSOAPErrorMsg(ex);
Util.logger.error(` ☇ skipping ${this.definition.typeName}: ${key} - ${errorMsg}}`);
return false;
}
if (!test[key]) {
Util.logger.error(
` ☇ skipping ${this.definition.typeName}: ${key} - not found on server`
);
return false;
}
if (test[key].TriggeredSendStatus !== 'Active') {
Util.logger.error(
` ☇ skipping ${this.definition.typeName}: ${key} - refresh only needed for running entries (TriggeredSendStatus=Active)`
);
return false;
}
}
// pause
try {
item.TriggeredSendStatus = 'Inactive';
test = await super.updateSOAP(item, true);
if (test.OverallStatus !== 'OK') {
throw new Error(test.Results[0].StatusMessage);
}
delete item.TriggeredSendStatus;
Util.logger.info(` - 🛑 paused ${this.definition.typeName}: ${key}`);
} catch (ex) {
const errorMsg = super.getSOAPErrorMsg(ex);
Util.logger.error(
` - failed to pause ${this.definition.typeName}: ${key} - ${errorMsg}`
);
return false;
}
// publish
try {
item.RefreshContent = 'true';
test = await super.updateSOAP(item, true);
if (test.OverallStatus !== 'OK') {
throw new Error(test.Results[0].StatusMessage);
}
delete item.RefreshContent;
Util.logger.info(` - 🔃 published ${this.definition.typeName}: ${key}`);
} catch (ex) {
const errorMsg = super.getSOAPErrorMsg(ex);
Util.logger.error(
` - failed to publish ${this.definition.typeName}: ${key} - ${errorMsg}`
);
return false;
}
// start
try {
item.TriggeredSendStatus = 'Active';
test = await super.updateSOAP(item, true);
if (test.OverallStatus !== 'OK') {
throw new Error(test.Results[0].StatusMessage);
}
delete item.RefreshContent;
Util.logger.info(` - ✅ started ${this.definition.typeName}: ${key}`);
} catch (ex) {
const errorMsg = super.getSOAPErrorMsg(ex);
Util.logger.error(
` - failed to publish ${this.definition.typeName}: ${key} - ${errorMsg}`
);
return false;
}
return true;
}
/**
*
* @param {MetadataTypeItem} item single metadata item
* @param {string} [_] parameter not used
* @param {Set.<string>} [findAssetKeys] list of keys that were found referenced via ContentBlockByX; if set, method only gets keys and runs no updates
* @returns {Promise.<MetadataTypeItem>} key of the item that was updated
*/
static async replaceCbReference(item, _, findAssetKeys) {
const parentName = `${this.definition.type} ${item[this.definition.keyField]}`;
let changes = false;
let error;
// *** type specific logic ***
try {
item.FromName = ReplaceCbReference.replaceReference(
item.FromName,
parentName,
findAssetKeys
);
changes = true;
} catch (ex) {
if (ex.code !== 200) {
error = ex;
}
}
try {
item.FromAddress = ReplaceCbReference.replaceReference(
item.FromAddress,
parentName,
findAssetKeys
);
changes = true;
} catch (ex) {
if (ex.code !== 200) {
error = ex;
}
}
try {
item.EmailSubject = ReplaceCbReference.replaceReference(
item.EmailSubject,
parentName,
findAssetKeys
);
changes = true;
} catch (ex) {
if (ex.code !== 200) {
error = ex;
}
}
try {
item.DynamicEmailSubject = ReplaceCbReference.replaceReference(
item.DynamicEmailSubject,
parentName,
findAssetKeys
);
changes = true;
} catch (ex) {
if (ex.code !== 200) {
error = ex;
}
}
if (error) {
throw error;
}
if (!changes) {
const ex = new Error('No changes made to the code.');
// @ts-expect-error custom error object
ex.code = 200;
throw ex;
}
// *** finish ***
// replaceReference will throw an error if nothing was updated which will end execution here
// no error means we have a new item to deploy and need to update the item in our retrieve folder
return item;
}
}
// Assign definition to static attributes
import MetadataTypeDefinitions from '../MetadataTypeDefinitions.js';
TriggeredSend.definition = MetadataTypeDefinitions.triggeredSend;
export default TriggeredSend;