This commit is contained in:
2026-08-21 16:38:06 +00:00
commit 43d764f9bc
31 changed files with 1911 additions and 0 deletions
@@ -0,0 +1,374 @@
public inherited sharing class CreateCaseAutoResponseRule {
private static final String SENDER_NAME = 'Columbus McKinnon Support';
private static final String SENDER_EMAIL = 'cmcoautoreply@columbusmckinnon.com';
private static final Integer MAX_POLLS = 20;
public class Request {
@InvocableVariable(label='Auto Response Rule Id')
public Id autoResponseRuleId;
@InvocableVariable(label='Auto Response Rule Name')
public String autoResponseRuleName;
@InvocableVariable(label='Sort Order')
public Integer sortOrder;
@InvocableVariable(required=true label='Case Origin')
public String caseOrigin;
@InvocableVariable(required=true label='Email Template Id')
public Id emailTemplateId;
}
public class Result {
@InvocableVariable(label='Async Apex Job Id')
public Id asyncJobId;
@InvocableVariable(label='Status')
public String status;
@InvocableVariable(label='Metadata Full Name')
public String metadataFullName;
}
public class Configuration {
public Id autoResponseRuleId;
public String autoResponseRuleName;
public Integer sortOrder;
public String caseOrigin;
public String templateFullName;
}
@InvocableMethod(
label='Create Case Auto-Response Rule Entry'
description='Queues a file-based Metadata API deployment that appends an entry to a Case auto-response rule.'
category='Queue Configuration'
callout=true
)
public static List<Result> createRuleEntry(List<Request> requests) {
MetadataXml.require(
requests != null && requests.size() == 1,
'Create Case Auto-Response Rule Entry accepts exactly one request per invocation.'
);
Request input = requests[0];
input.autoResponseRuleName = input.autoResponseRuleName == null
? null
: input.autoResponseRuleName.trim();
input.caseOrigin = input.caseOrigin == null
? null
: input.caseOrigin.trim();
MetadataXml.require(
input.autoResponseRuleId != null ||
!String.isBlank(input.autoResponseRuleName),
'Auto Response Rule Id or Auto Response Rule Name is required.'
);
MetadataXml.require(
!String.isBlank(input.caseOrigin),
'Case Origin is required.'
);
MetadataXml.require(
input.emailTemplateId != null,
'Email Template Id is required.'
);
MetadataXml.require(
input.sortOrder == null || input.sortOrder > 0,
'Sort Order must be greater than zero.'
);
List<EmailTemplate> templates = [
SELECT Id, DeveloperName, FolderId
FROM EmailTemplate
WHERE Id = :input.emailTemplateId
WITH USER_MODE
LIMIT 1
];
MetadataXml.require(
!templates.isEmpty(),
'Email Template Id was not found.'
);
List<Folder> folders = [
SELECT Id, DeveloperName
FROM Folder
WHERE Id = :templates[0].FolderId AND Type = 'Email'
WITH USER_MODE
LIMIT 1
];
MetadataXml.require(
!folders.isEmpty(),
'The email template must be stored in a shared Classic Email Template folder.'
);
Configuration configuration = new Configuration();
configuration.autoResponseRuleId = input.autoResponseRuleId;
configuration.autoResponseRuleName = String.isBlank(
input.autoResponseRuleName
)
? MetadataApiClient.toolingAutoResponseRuleName(input.autoResponseRuleId)
: input.autoResponseRuleName;
configuration.sortOrder = input.sortOrder;
configuration.caseOrigin = input.caseOrigin;
configuration.templateFullName =
folders[0].DeveloperName +
'/' +
templates[0].DeveloperName;
Id jobId = System.enqueueJob(new StartRetrieveJob(configuration));
Result result = new Result();
result.asyncJobId = jobId;
result.status = 'Queued';
result.metadataFullName = 'Case.' + configuration.autoResponseRuleName;
return new List<Result>{ result };
}
private class StartRetrieveJob implements Queueable, Database.AllowsCallouts {
private Configuration configuration;
private StartRetrieveJob(Configuration configuration) {
this.configuration = configuration;
}
public void execute(QueueableContext context) {
String retrieveId = MetadataApiClient.startRetrieve(
'Case.' + configuration.autoResponseRuleName,
'AutoResponseRule'
);
System.enqueueJob(new RetrievePollJob(configuration, retrieveId, 0), 1);
}
}
private class RetrievePollJob implements Queueable, Database.AllowsCallouts {
private Configuration configuration;
private String retrieveId;
private Integer pollCount;
private RetrievePollJob(
Configuration configuration,
String retrieveId,
Integer pollCount
) {
this.configuration = configuration;
this.retrieveId = retrieveId;
this.pollCount = pollCount;
}
public void execute(QueueableContext context) {
MetadataApiClient.RetrieveResult retrieveResult = MetadataApiClient.checkRetrieve(
retrieveId
);
if (!retrieveResult.done) {
if (pollCount >= MAX_POLLS) {
throw new MetadataApiClient.MetadataApiException(
'Timed out waiting for the auto-response rule metadata retrieve.'
);
}
System.enqueueJob(
new RetrievePollJob(configuration, retrieveId, pollCount + 1),
1
);
return;
}
if (
retrieveResult.status != 'Succeeded' ||
retrieveResult.zipFile == null
) {
throw new MetadataApiClient.MetadataApiException(
'Auto-response rule retrieve failed: ' +
(String.isBlank(retrieveResult.errorMessage)
? retrieveResult.status
: retrieveResult.errorMessage)
);
}
Blob deploymentZip = buildDeploymentZip(
retrieveResult.zipFile,
configuration
);
String deployId = MetadataApiClient.startDeploy(deploymentZip);
System.enqueueJob(new DeployPollJob(deployId, 0), 1);
}
}
private class DeployPollJob implements Queueable, Database.AllowsCallouts {
private String deployId;
private Integer pollCount;
private DeployPollJob(String deployId, Integer pollCount) {
this.deployId = deployId;
this.pollCount = pollCount;
}
public void execute(QueueableContext context) {
MetadataApiClient.DeployResult deployResult = MetadataApiClient.checkDeploy(
deployId
);
if (!deployResult.done) {
if (pollCount >= MAX_POLLS) {
throw new MetadataApiClient.MetadataApiException(
'Timed out waiting for the auto-response rule metadata deployment.'
);
}
System.enqueueJob(new DeployPollJob(deployId, pollCount + 1), 1);
return;
}
if (!deployResult.success) {
throw new MetadataApiClient.MetadataApiException(
'Auto-response rule deployment failed: ' +
(String.isBlank(deployResult.errorMessage)
? deployResult.status
: deployResult.errorMessage)
);
}
}
}
@TestVisible
private static Blob buildDeploymentZip(
Blob retrievedZip,
Configuration configuration
) {
Compression.ZipReader reader = new Compression.ZipReader(retrievedZip);
Compression.ZipEntry ruleEntry;
for (String entryName : reader.getEntriesMap().keySet()) {
if (entryName.endsWith('Case.autoResponseRules')) {
ruleEntry = reader.getEntriesMap().get(entryName);
break;
}
}
MetadataXml.require(
ruleEntry != null,
'The retrieved Case auto-response rule file is missing.'
);
String sourceXml = reader.extractEntry(ruleEntry).toString();
String updatedXml = appendRuleEntry(sourceXml, configuration);
String packageXml =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<Package xmlns="http://soap.sforce.com/2006/04/metadata">' +
'<types><members>Case.' +
MetadataXml.escape(configuration.autoResponseRuleName) +
'</members><name>AutoResponseRule</name></types>' +
'<version>' +
MetadataApiClient.API_VERSION +
'</version></Package>';
Compression.ZipWriter writer = new Compression.ZipWriter();
writer.addEntry('package.xml', Blob.valueOf(packageXml));
writer.addEntry(
'autoResponseRules/Case.autoResponseRules',
Blob.valueOf(updatedXml)
);
return writer.getArchive();
}
@TestVisible
private static String appendRuleEntry(
String sourceXml,
Configuration configuration
) {
Dom.Document document = new Dom.Document();
document.load(sourceXml);
Dom.XmlNode root = document.getRootElement();
Dom.XmlNode targetRule;
for (Dom.XmlNode rule : MetadataXml.children(root, 'autoResponseRule')) {
if (
configuration.autoResponseRuleName == MetadataXml.text(rule, 'fullName')
) {
targetRule = rule;
break;
}
}
MetadataXml.require(
targetRule != null,
'Auto-response rule "' +
configuration.autoResponseRuleName +
'" was not found.'
);
List<Dom.XmlNode> existingEntries = MetadataXml.children(
targetRule,
'ruleEntry'
);
for (Dom.XmlNode existingEntry : existingEntries) {
if (
configuration.caseOrigin ==
criterionValue(existingEntry, 'Case.Origin') &&
configuration.templateFullName ==
MetadataXml.text(existingEntry, 'template')
) {
return sourceXml;
}
}
Integer insertionPosition = configuration.sortOrder == null
? existingEntries.size() + 1
: Math.min(configuration.sortOrder, existingEntries.size() + 1);
String newEntry =
'<ruleEntry><booleanFilter>1 AND 2 AND 3 AND 4</booleanFilter>' +
criterion('Case.Origin', 'equals', configuration.caseOrigin) +
criterion('Case.ContactEmail', 'notEqual', '') +
criterion('Case.Status', 'equals', 'Active') +
criterion('Case.RecordTypeId', 'equals', 'Customer Service') +
'<senderEmail>' +
MetadataXml.escape(SENDER_EMAIL) +
'</senderEmail><senderName>' +
MetadataXml.escape(SENDER_NAME) +
'</senderName><template>' +
MetadataXml.escape(configuration.templateFullName) +
'</template></ruleEntry>';
String targetRuleXml = '<autoResponseRule>';
Integer currentEntryPosition = 0;
Boolean inserted = false;
for (Dom.XmlNode child : targetRule.getChildElements()) {
if (child.getName() == 'ruleEntry') {
currentEntryPosition++;
if (!inserted && currentEntryPosition == insertionPosition) {
targetRuleXml += newEntry;
inserted = true;
}
}
targetRuleXml += MetadataXml.serialize(child);
}
if (!inserted) {
targetRuleXml += newEntry;
}
targetRuleXml += '</autoResponseRule>';
String output =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<AutoResponseRules xmlns="http://soap.sforce.com/2006/04/metadata">';
for (Dom.XmlNode rule : root.getChildElements()) {
output += rule == targetRule
? targetRuleXml
: MetadataXml.serialize(rule);
}
return output + '</AutoResponseRules>';
}
private static String criterion(
String field,
String operation,
String value
) {
return '<criteriaItems><field>' +
MetadataXml.escape(field) +
'</field><operation>' +
MetadataXml.escape(operation) +
'</operation><value>' +
MetadataXml.escape(value) +
'</value></criteriaItems>';
}
private static String criterionValue(Dom.XmlNode ruleEntry, String field) {
for (
Dom.XmlNode criterionNode : MetadataXml.children(
ruleEntry,
'criteriaItems'
)
) {
if (field == MetadataXml.text(criterionNode, 'field')) {
return MetadataXml.text(criterionNode, 'value');
}
}
return null;
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>65.0</apiVersion>
<status>Active</status>
</ApexClass>
@@ -0,0 +1,243 @@
public inherited sharing class CreateEmailToCase {
public class Request {
@InvocableVariable(required=true label='Routing Name')
public String routingName;
@InvocableVariable(required=true label='Email Address')
public String emailAddress;
@InvocableVariable(required=true label='Case Owner Queue Id')
public Id caseOwnerId;
@InvocableVariable(required=true label='Case Origin')
public String caseOrigin;
}
public class Result {
@InvocableVariable(label='Metadata Full Name')
public String metadataFullName;
@InvocableVariable(label='Routing Name')
public String routingName;
@InvocableVariable(label='Email Address')
public String emailAddress;
@InvocableVariable(label='Email-to-Case Address')
public String emailToCaseAddress;
@InvocableVariable(label='Email Routing Address Id')
public Id emailRoutingAddressId;
@InvocableVariable(label='Created')
public Boolean created;
}
@InvocableMethod(
label='Create Email-To-Case'
description='Adds an on-demand Email-to-Case routing address to Case Settings.'
category='Queue Configuration'
callout=true
)
public static List<Result> createRoutingAddress(List<Request> requests) {
MetadataXml.require(
requests != null && requests.size() == 1,
'Create Email-To-Case accepts exactly one request per invocation.'
);
Request input = requests[0];
input.routingName = input.routingName == null
? null
: input.routingName.trim();
input.emailAddress = input.emailAddress == null
? null
: input.emailAddress.trim();
input.caseOrigin = input.caseOrigin == null
? null
: input.caseOrigin.trim();
MetadataXml.require(
!String.isBlank(input.routingName),
'Routing Name is required.'
);
MetadataXml.require(
!String.isBlank(input.emailAddress),
'Email Address is required.'
);
MetadataXml.require(
input.caseOwnerId != null,
'Case Owner Queue Id is required.'
);
MetadataXml.require(
!String.isBlank(input.caseOrigin),
'Case Origin is required.'
);
List<Group> queues = [
SELECT Id, DeveloperName
FROM Group
WHERE Id = :input.caseOwnerId AND Type = 'Queue'
WITH USER_MODE
LIMIT 1
];
MetadataXml.require(
!queues.isEmpty(),
'Case Owner Id must identify a queue.'
);
Dom.XmlNode caseSettings = MetadataApiClient.readMetadata(
'CaseSettings',
'Case'
);
MetadataXml.require(
caseSettings != null,
'Case Settings metadata was not found.'
);
Dom.XmlNode emailToCase = MetadataXml.child(caseSettings, 'emailToCase');
MetadataXml.require(
emailToCase != null,
'Email-to-Case is not configured. Enable Email-to-Case in Setup before creating a routing address.'
);
for (
Dom.XmlNode route : MetadataXml.children(emailToCase, 'routingAddresses')
) {
String existingName = MetadataXml.text(route, 'routingName');
String existingAddress = MetadataXml.text(route, 'emailAddress');
Boolean sameName = input.routingName.equalsIgnoreCase(existingName);
Boolean sameAddress = input.emailAddress.equalsIgnoreCase(
existingAddress
);
if (sameName || sameAddress) {
MetadataXml.require(
sameName && sameAddress,
'An Email-to-Case routing address already uses this routing name or email address with different values.'
);
return new List<Result>{ buildResult(input, false) };
}
}
String routeXml =
'<routingAddresses>' +
'<addressType>EmailToCase</addressType>' +
'<caseOrigin>' +
MetadataXml.escape(input.caseOrigin) +
'</caseOrigin>' +
'<caseOwner>' +
MetadataXml.escape(queues[0].DeveloperName) +
'</caseOwner>' +
'<caseOwnerType>Queue</caseOwnerType>' +
'<casePriority>Medium</casePriority>' +
'<emailAddress>' +
MetadataXml.escape(input.emailAddress) +
'</emailAddress>' +
'<routingName>' +
MetadataXml.escape(input.routingName) +
'</routingName>' +
'<saveEmailHeaders>false</saveEmailHeaders>' +
'</routingAddresses>';
String emailToCaseXml = '<emailToCase>';
Boolean inserted = false;
for (Dom.XmlNode child : emailToCase.getChildElements()) {
if (!inserted && child.getName().compareTo('routingAddresses') > 0) {
emailToCaseXml += routeXml;
inserted = true;
}
emailToCaseXml += MetadataXml.serialize(child);
}
if (!inserted) {
emailToCaseXml += routeXml;
}
emailToCaseXml += '</emailToCase>';
MetadataApiClient.updateMetadata(
'CaseSettings',
MetadataXml.replaceOrInsertChild(
caseSettings,
'emailToCase',
emailToCaseXml
)
);
return new List<Result>{ buildResult(input, true) };
}
private static Result buildResult(Request input, Boolean created) {
Result result = new Result();
result.metadataFullName = 'Case';
result.routingName = input.routingName;
result.emailAddress = input.emailAddress;
result.created = created;
Schema.SObjectType routingType = Schema.getGlobalDescribe()
.get('EmailRoutingAddress');
if (routingType == null) {
return result;
}
Map<String, Schema.SObjectField> fields = routingType.getDescribe()
.fields.getMap();
String addressField = fields.containsKey('EmailAddress')
? 'EmailAddress'
: (fields.containsKey('Address') ? 'Address' : null);
if (addressField == null) {
return result;
}
List<String> selectFields = new List<String>{ 'Id', addressField };
if (fields.containsKey('RoutingAddress')) {
selectFields.add('RoutingAddress');
}
if (fields.containsKey('EmailServicesAddressId')) {
selectFields.add('EmailServicesAddressId');
}
String query =
'SELECT ' +
String.join(selectFields, ',') +
' FROM EmailRoutingAddress WHERE ' +
addressField +
' = :emailAddress LIMIT 1';
List<SObject> matches = Database.queryWithBinds(
query,
new Map<String, Object>{ 'emailAddress' => input.emailAddress },
AccessLevel.USER_MODE
);
if (matches.isEmpty()) {
return result;
}
result.emailRoutingAddressId = (Id) matches[0].get('Id');
if (fields.containsKey('RoutingAddress')) {
result.emailToCaseAddress = (String) matches[0].get('RoutingAddress');
} else if (fields.containsKey('EmailServicesAddressId')) {
result.emailToCaseAddress = emailServicesAddress(
(Id) matches[0].get('EmailServicesAddressId')
);
}
return result;
}
private static String emailServicesAddress(Id serviceAddressId) {
if (serviceAddressId == null) {
return null;
}
Schema.SObjectType serviceType = Schema.getGlobalDescribe()
.get('EmailServicesAddress');
if (serviceType == null) {
return null;
}
Map<String, Schema.SObjectField> fields = serviceType.getDescribe()
.fields.getMap();
if (
!fields.containsKey('LocalPart') || !fields.containsKey('EmailDomainName')
) {
return null;
}
List<SObject> rows = Database.queryWithBinds(
'SELECT LocalPart, EmailDomainName FROM EmailServicesAddress WHERE Id = :serviceAddressId LIMIT 1',
new Map<String, Object>{ 'serviceAddressId' => serviceAddressId },
AccessLevel.USER_MODE
);
return rows.isEmpty()
? null
: String.valueOf(rows[0].get('LocalPart')) +
'@' +
String.valueOf(rows[0].get('EmailDomainName'));
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>65.0</apiVersion>
<status>Active</status>
</ApexClass>
@@ -0,0 +1,120 @@
public inherited sharing class CreateListView {
public class Request {
@InvocableVariable(required=true label='List View Label')
public String listViewLabel;
@InvocableVariable(required=true label='Object API Name')
public String objectApiName;
@InvocableVariable(required=true label='Queue Group Id')
public Id groupId;
@InvocableVariable(required=true label='Display Columns')
public String displayColumns;
}
public class Result {
@InvocableVariable(label='List View API Name')
public String listViewApiName;
@InvocableVariable(label='Metadata Full Name')
public String metadataFullName;
@InvocableVariable(label='Created')
public Boolean created;
}
@InvocableMethod(
label='Create List View'
description='Creates or updates a queue-scoped list view that excludes resolved, cancelled, and closed records.'
category='Queue Configuration'
callout=true
)
public static List<Result> createView(List<Request> requests) {
MetadataXml.require(
requests != null && requests.size() == 1,
'Create List View accepts exactly one request per invocation.'
);
Request input = requests[0];
input.listViewLabel = input.listViewLabel == null
? null
: input.listViewLabel.trim();
input.objectApiName = input.objectApiName == null
? null
: input.objectApiName.trim();
MetadataXml.require(
!String.isBlank(input.listViewLabel),
'List View Label is required.'
);
MetadataXml.require(
!String.isBlank(input.objectApiName),
'Object API Name is required.'
);
MetadataXml.require(input.groupId != null, 'Queue Group Id is required.');
MetadataXml.require(
!String.isBlank(input.displayColumns),
'Display Columns is required.'
);
MetadataXml.require(
Schema.getGlobalDescribe().containsKey(input.objectApiName),
'Object API Name does not identify an object in this org.'
);
List<Group> queues = [
SELECT Id, DeveloperName
FROM Group
WHERE Id = :input.groupId AND Type = 'Queue'
WITH USER_MODE
LIMIT 1
];
MetadataXml.require(
!queues.isEmpty(),
'Queue Group Id must identify a queue.'
);
List<String> columns = new List<String>();
for (String column : input.displayColumns.split(',')) {
if (!String.isBlank(column)) {
columns.add(column.trim());
}
}
MetadataXml.require(
!columns.isEmpty(),
'Display Columns must contain at least one field.'
);
String apiName = MetadataXml.developerName(input.listViewLabel);
String fullName = input.objectApiName + '.' + apiName;
Boolean existed =
MetadataApiClient.readMetadata('ListView', fullName) != null;
String metadataXml =
'<fullName>' +
MetadataXml.escape(fullName) +
'</fullName>';
for (String column : columns) {
metadataXml += '<columns>' + MetadataXml.escape(column) + '</columns>';
}
metadataXml +=
'<filterScope>Queue</filterScope>' +
'<filters><field>' +
MetadataXml.escape(statusField(input.objectApiName)) +
'</field><operation>notEqual</operation>' +
'<value>Resolved,Cancelled,Closed</value></filters>' +
'<label>' +
MetadataXml.escape(input.listViewLabel) +
'</label><queue>' +
MetadataXml.escape(queues[0].DeveloperName) +
'</queue>';
MetadataApiClient.upsertMetadata('ListView', metadataXml);
Result result = new Result();
result.listViewApiName = apiName;
result.metadataFullName = fullName;
result.created = !existed;
return new List<Result>{ result };
}
private static String statusField(String objectApiName) {
return objectApiName.equalsIgnoreCase('Case') ? 'CASE.STATUS' : 'Status';
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>65.0</apiVersion>
<status>Active</status>
</ApexClass>
@@ -0,0 +1,92 @@
public inherited sharing class CreateOriginPicklistItem {
public class Request {
@InvocableVariable(required=true label='Picklist Item Name')
public String picklistItemName;
}
public class Result {
@InvocableVariable(label='Case Origin')
public String caseOrigin;
@InvocableVariable(label='Created')
public Boolean created;
@InvocableVariable(label='Metadata Full Name')
public String metadataFullName;
}
@InvocableMethod(
label='Create Origin Picklist Item'
description='Adds a value to the Case Origin standard value set when it does not already exist.'
category='Queue Configuration'
callout=true
)
public static List<Result> createOrigin(List<Request> requests) {
MetadataXml.require(
requests != null && requests.size() == 1,
'Create Origin Picklist Item accepts exactly one request per invocation.'
);
String label = requests[0].picklistItemName == null
? null
: requests[0].picklistItemName.trim();
MetadataXml.require(
!String.isBlank(label),
'Picklist Item Name is required.'
);
String developerName = MetadataXml.developerName(label);
Dom.XmlNode valueSet = MetadataApiClient.readMetadata(
'StandardValueSet',
'CaseOrigin'
);
MetadataXml.require(
valueSet != null,
'The CaseOrigin standard value set was not found.'
);
for (
Dom.XmlNode valueNode : MetadataXml.children(valueSet, 'standardValue')
) {
String existingName = MetadataXml.text(valueNode, 'fullName');
String existingLabel = MetadataXml.text(valueNode, 'label');
if (
label.equalsIgnoreCase(existingLabel) ||
developerName.equalsIgnoreCase(existingName)
) {
if (
developerName.equalsIgnoreCase(existingName) &&
!label.equalsIgnoreCase(existingLabel)
) {
throw new IllegalArgumentException(
'Case Origin developer name ' +
developerName +
' is already used by the label "' +
existingLabel +
'".'
);
}
return new List<Result>{ result(existingName, false) };
}
}
String newValueXml =
'<standardValue><fullName>' +
MetadataXml.escape(developerName) +
'</fullName><default>false</default><label>' +
MetadataXml.escape(label) +
'</label></standardValue>';
MetadataApiClient.updateMetadata(
'StandardValueSet',
MetadataXml.serializeChildren(valueSet) + newValueXml
);
return new List<Result>{ result(developerName, true) };
}
private static Result result(String caseOrigin, Boolean created) {
Result value = new Result();
value.caseOrigin = caseOrigin;
value.created = created;
value.metadataFullName = 'CaseOrigin';
return value;
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>65.0</apiVersion>
<status>Active</status>
</ApexClass>
@@ -0,0 +1,305 @@
public inherited sharing class MetadataApiClient {
public static final String NAMED_CREDENTIAL = 'Salesforce_Metadata';
public static final String API_VERSION = '65.0';
private static final String METADATA_NS = 'http://soap.sforce.com/2006/04/metadata';
private static final String SOAP_NS = 'http://schemas.xmlsoap.org/soap/envelope/';
private static final String XSI_NS = 'http://www.w3.org/2001/XMLSchema-instance';
public class MetadataApiException extends Exception {
}
public class SaveResult {
public Boolean created;
public String fullName;
}
public class RetrieveResult {
public Boolean done;
public String id;
public String status;
public String errorMessage;
public Blob zipFile;
}
public class DeployResult {
public Boolean done;
public Boolean success;
public String id;
public String status;
public String errorMessage;
}
public static Dom.XmlNode readMetadata(String metadataType, String fullName) {
String operation =
'<met:readMetadata>' +
'<met:type>' +
MetadataXml.escape(metadataType) +
'</met:type>' +
'<met:fullNames>' +
MetadataXml.escape(fullName) +
'</met:fullNames>' +
'</met:readMetadata>';
Dom.XmlNode body = send(operation);
Dom.XmlNode records = MetadataXml.findDescendant(body, 'records');
if (
records == null ||
'true'.equalsIgnoreCase(records.getAttributeValue('nil', XSI_NS))
) {
return null;
}
return records;
}
public static SaveResult updateMetadata(
String metadataType,
String metadataChildrenXml
) {
return save('updateMetadata', metadataType, metadataChildrenXml);
}
public static SaveResult upsertMetadata(
String metadataType,
String metadataChildrenXml
) {
return save('upsertMetadata', metadataType, metadataChildrenXml);
}
public static String startRetrieve(String member, String metadataType) {
Dom.XmlNode body = send(
'<met:retrieve><met:retrieveRequest>' +
'<met:apiVersion>' +
API_VERSION +
'</met:apiVersion><met:singlePackage>true</met:singlePackage>' +
'<met:unpackaged><met:members>' +
MetadataXml.escape(member) +
'</met:members><met:name>' +
MetadataXml.escape(metadataType) +
'</met:name></met:unpackaged>' +
'</met:retrieveRequest></met:retrieve>'
);
String id = MetadataXml.text(
MetadataXml.findDescendant(body, 'result'),
'id'
);
if (String.isBlank(id)) {
throw new MetadataApiException(
'The Metadata API did not return a retrieve job ID.'
);
}
return id;
}
public static RetrieveResult checkRetrieve(String retrieveId) {
Dom.XmlNode body = send(
'<met:checkRetrieveStatus><met:asyncProcessId>' +
MetadataXml.escape(retrieveId) +
'</met:asyncProcessId><met:includeZip>true</met:includeZip>' +
'</met:checkRetrieveStatus>'
);
Dom.XmlNode resultNode = MetadataXml.findDescendant(body, 'result');
RetrieveResult result = new RetrieveResult();
result.done = MetadataXml.booleanValue(resultNode, 'done');
result.id = MetadataXml.text(resultNode, 'id');
result.status = MetadataXml.text(resultNode, 'status');
result.errorMessage = MetadataXml.text(resultNode, 'errorMessage');
String zipFile = MetadataXml.text(resultNode, 'zipFile');
result.zipFile = String.isBlank(zipFile)
? null
: EncodingUtil.base64Decode(zipFile);
return result;
}
public static String startDeploy(Blob zipFile) {
Dom.XmlNode body = send(
'<met:deploy><met:ZipFile>' +
EncodingUtil.base64Encode(zipFile) +
'</met:ZipFile><met:DeployOptions>' +
'<met:allowMissingFiles>false</met:allowMissingFiles>' +
'<met:autoUpdatePackage>false</met:autoUpdatePackage>' +
'<met:checkOnly>false</met:checkOnly>' +
'<met:ignoreWarnings>false</met:ignoreWarnings>' +
'<met:performRetrieve>false</met:performRetrieve>' +
'<met:purgeOnDelete>false</met:purgeOnDelete>' +
'<met:rollbackOnError>true</met:rollbackOnError>' +
'<met:singlePackage>true</met:singlePackage>' +
'<met:testLevel>NoTestRun</met:testLevel>' +
'</met:DeployOptions></met:deploy>'
);
String id = MetadataXml.text(
MetadataXml.findDescendant(body, 'result'),
'id'
);
if (String.isBlank(id)) {
throw new MetadataApiException(
'The Metadata API did not return a deployment job ID.'
);
}
return id;
}
public static DeployResult checkDeploy(String deployId) {
Dom.XmlNode body = send(
'<met:checkDeployStatus><met:asyncProcessId>' +
MetadataXml.escape(deployId) +
'</met:asyncProcessId><met:includeDetails>true</met:includeDetails>' +
'</met:checkDeployStatus>'
);
Dom.XmlNode resultNode = MetadataXml.findDescendant(body, 'result');
DeployResult result = new DeployResult();
result.done = MetadataXml.booleanValue(resultNode, 'done');
result.success = MetadataXml.booleanValue(resultNode, 'success');
result.id = MetadataXml.text(resultNode, 'id');
result.status = MetadataXml.text(resultNode, 'status');
result.errorMessage = MetadataXml.text(resultNode, 'errorMessage');
if (String.isBlank(result.errorMessage)) {
Dom.XmlNode problem = MetadataXml.findDescendant(resultNode, 'problem');
result.errorMessage = problem == null ? null : problem.getText();
}
return result;
}
public static String toolingAutoResponseRuleName(Id ruleId) {
HttpRequest request = new HttpRequest();
request.setEndpoint(
'callout:' +
NAMED_CREDENTIAL +
'/services/data/v' +
API_VERSION +
'/tooling/query/?q=' +
EncodingUtil.urlEncode(
'SELECT Id, Name, EntityDefinitionId FROM AutoResponseRule WHERE Id = \'' +
String.escapeSingleQuotes(String.valueOf(ruleId)) +
'\' LIMIT 1',
'UTF-8'
)
);
request.setMethod('GET');
request.setTimeout(120000);
HttpResponse response = new Http().send(request);
if (response.getStatusCode() < 200 || response.getStatusCode() >= 300) {
throw new MetadataApiException(
'Tooling API returned HTTP ' +
response.getStatusCode() +
': ' +
response.getBody()
);
}
Map<String, Object> payload = (Map<String, Object>) JSON.deserializeUntyped(
response.getBody()
);
List<Object> records = (List<Object>) payload.get('records');
if (records == null || records.isEmpty()) {
throw new MetadataApiException('Auto Response Rule Id was not found.');
}
return (String) ((Map<String, Object>) records[0]).get('Name');
}
private static SaveResult save(
String operationName,
String metadataType,
String metadataChildrenXml
) {
String operation =
'<met:' +
operationName +
'><met:metadata xmlns="' +
METADATA_NS +
'" xsi:type="met:' +
MetadataXml.escape(metadataType) +
'">' +
metadataChildrenXml +
'</met:metadata></met:' +
operationName +
'>';
Dom.XmlNode body = send(operation);
Dom.XmlNode resultNode = MetadataXml.findDescendant(body, 'result');
if (resultNode == null) {
throw new MetadataApiException(
'The Metadata API returned no save result.'
);
}
Boolean success = MetadataXml.booleanValue(resultNode, 'success');
if (!success) {
Dom.XmlNode errors = MetadataXml.child(resultNode, 'errors');
String statusCode = errors == null
? null
: MetadataXml.text(errors, 'statusCode');
String message = errors == null
? null
: MetadataXml.text(errors, 'message');
throw new MetadataApiException(
'Metadata API save failed' +
(String.isBlank(statusCode) ? '' : ' [' + statusCode + ']') +
(String.isBlank(message) ? '.' : ': ' + message)
);
}
SaveResult result = new SaveResult();
result.created = MetadataXml.booleanValue(resultNode, 'created');
result.fullName = MetadataXml.text(resultNode, 'fullName');
return result;
}
private static Dom.XmlNode send(String operationXml) {
HttpRequest request = new HttpRequest();
request.setEndpoint(
'callout:' + NAMED_CREDENTIAL + '/services/Soap/m/' + API_VERSION
);
request.setMethod('POST');
request.setHeader('Content-Type', 'text/xml; charset=UTF-8');
request.setHeader('SOAPAction', '""');
request.setTimeout(120000);
request.setBody(
'<?xml version="1.0" encoding="UTF-8"?>' +
'<soapenv:Envelope xmlns:soapenv="' +
SOAP_NS +
'" xmlns:met="' +
METADATA_NS +
'" xmlns:xsi="' +
XSI_NS +
'"><soapenv:Header/><soapenv:Body>' +
operationXml +
'</soapenv:Body></soapenv:Envelope>'
);
HttpResponse response = new Http().send(request);
Dom.Document document = new Dom.Document();
try {
document.load(response.getBody());
} catch (Exception exceptionValue) {
throw new MetadataApiException(
'Metadata API returned HTTP ' +
response.getStatusCode() +
' with an unreadable response: ' +
response.getBody()
);
}
Dom.XmlNode body = MetadataXml.findDescendant(
document.getRootElement(),
'Body'
);
Dom.XmlNode fault = MetadataXml.findDescendant(body, 'Fault');
if (fault != null) {
String faultMessage = MetadataXml.text(fault, 'faultstring');
throw new MetadataApiException(
String.isBlank(faultMessage)
? 'Unknown Metadata API SOAP fault.'
: faultMessage
);
}
if (response.getStatusCode() < 200 || response.getStatusCode() >= 300) {
throw new MetadataApiException(
'Metadata API returned HTTP ' +
response.getStatusCode() +
': ' +
response.getBody()
);
}
return body;
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>65.0</apiVersion>
<status>Active</status>
</ApexClass>
@@ -0,0 +1,140 @@
public inherited sharing class MetadataXml {
public static String escape(String value) {
if (value == null) {
return '';
}
return value
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace('\'', '&apos;');
}
public static Dom.XmlNode child(Dom.XmlNode parent, String localName) {
if (parent == null) {
return null;
}
for (Dom.XmlNode candidate : parent.getChildElements()) {
if (candidate.getName() == localName) {
return candidate;
}
}
return null;
}
public static List<Dom.XmlNode> children(
Dom.XmlNode parent,
String localName
) {
List<Dom.XmlNode> matches = new List<Dom.XmlNode>();
if (parent == null) {
return matches;
}
for (Dom.XmlNode candidate : parent.getChildElements()) {
if (candidate.getName() == localName) {
matches.add(candidate);
}
}
return matches;
}
public static Dom.XmlNode findDescendant(
Dom.XmlNode parent,
String localName
) {
if (parent == null) {
return null;
}
if (parent.getName() == localName) {
return parent;
}
for (Dom.XmlNode candidate : parent.getChildElements()) {
Dom.XmlNode match = findDescendant(candidate, localName);
if (match != null) {
return match;
}
}
return null;
}
public static String text(Dom.XmlNode parent, String localName) {
Dom.XmlNode valueNode = child(parent, localName);
return valueNode == null ? null : valueNode.getText();
}
public static Boolean booleanValue(Dom.XmlNode parent, String localName) {
return 'true'.equalsIgnoreCase(text(parent, localName));
}
public static String serialize(Dom.XmlNode node) {
String name = node.getName();
List<Dom.XmlNode> childNodes = node.getChildElements();
if (childNodes.isEmpty()) {
return '<' + name + '>' + escape(node.getText()) + '</' + name + '>';
}
String result = '<' + name + '>';
for (Dom.XmlNode childNode : childNodes) {
result += serialize(childNode);
}
return result + '</' + name + '>';
}
public static String serializeChildren(Dom.XmlNode parent) {
String result = '';
for (Dom.XmlNode childNode : parent.getChildElements()) {
result += serialize(childNode);
}
return result;
}
public static String replaceOrInsertChild(
Dom.XmlNode parent,
String childName,
String replacementXml
) {
String result = '';
Boolean replaced = false;
for (Dom.XmlNode childNode : parent.getChildElements()) {
if (childNode.getName() == childName) {
if (!replaced) {
result += replacementXml;
replaced = true;
}
} else if (!replaced && childNode.getName().compareTo(childName) > 0) {
result += replacementXml;
result += serialize(childNode);
replaced = true;
} else {
result += serialize(childNode);
}
}
if (!replaced) {
result += replacementXml;
}
return result;
}
public static String developerName(String label) {
String value = String.isBlank(label)
? ''
: label.trim().replaceAll('[^A-Za-z0-9_]+', '_');
value = value.replaceAll('_+', '_').replaceAll('^_+|_+$', '');
if (String.isBlank(value)) {
throw new IllegalArgumentException(
'A developer name could not be generated from the label.'
);
}
if (Pattern.matches('^[0-9].*', value)) {
value = 'X_' + value;
}
return value.length() > 80 ? value.substring(0, 80) : value;
}
public static void require(Boolean condition, String message) {
if (!condition) {
throw new IllegalArgumentException(message);
}
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>65.0</apiVersion>
<status>Active</status>
</ApexClass>
@@ -0,0 +1,252 @@
@IsTest
private class QueueMetadataActionsTest {
@TestSetup
static void setupQueue() {
insert new Group(
Name = 'Support Queue',
DeveloperName = 'Support_Queue',
Type = 'Queue',
Email = 'support@example.com'
);
}
@IsTest
static void createsOriginWhenMissing() {
MetadataMock mock = new MetadataMock('origin');
Test.setMock(HttpCalloutMock.class, mock);
CreateOriginPicklistItem.Request request = new CreateOriginPicklistItem.Request();
request.picklistItemName = 'Phone Support';
Test.startTest();
CreateOriginPicklistItem.Result result = CreateOriginPicklistItem.createOrigin(
new List<CreateOriginPicklistItem.Request>{ request }
)[0];
Test.stopTest();
Assert.areEqual('Phone_Support', result.caseOrigin);
Assert.isTrue(result.created);
Assert.areEqual(2, mock.requests.size());
Assert.isTrue(
mock.requests[1].contains('<fullName>Phone_Support</fullName>')
);
Assert.isTrue(mock.requests[1].contains('<label>Phone Support</label>'));
}
@IsTest
static void createsEmailToCaseRoute() {
Group queue = [
SELECT Id
FROM Group
WHERE DeveloperName = 'Support_Queue'
LIMIT 1
];
MetadataMock mock = new MetadataMock('emailToCase');
Test.setMock(HttpCalloutMock.class, mock);
CreateEmailToCase.Request request = new CreateEmailToCase.Request();
request.routingName = 'Support Route';
request.emailAddress = 'support@example.com';
request.caseOwnerId = queue.Id;
request.caseOrigin = 'Phone_Support';
Test.startTest();
CreateEmailToCase.Result result = CreateEmailToCase.createRoutingAddress(
new List<CreateEmailToCase.Request>{ request }
)[0];
Test.stopTest();
Assert.isTrue(result.created);
Assert.areEqual('Case', result.metadataFullName);
Assert.areEqual(2, mock.requests.size());
Assert.isTrue(
mock.requests[1].contains('<caseOwner>Support_Queue</caseOwner>')
);
Assert.isTrue(
mock.requests[1].contains('<casePriority>Medium</casePriority>')
);
Assert.isTrue(
mock.requests[1].contains('<addressType>EmailToCase</addressType>')
);
Assert.isTrue(
mock.requests[1]
.contains('<emailAddress>support@example.com</emailAddress>')
);
Assert.isTrue(
mock.requests[1].contains('<caseOrigin>Phone_Support</caseOrigin>')
);
}
@IsTest
static void createsQueueListView() {
Group queue = [
SELECT Id
FROM Group
WHERE DeveloperName = 'Support_Queue'
LIMIT 1
];
MetadataMock mock = new MetadataMock('listView');
Test.setMock(HttpCalloutMock.class, mock);
CreateListView.Request request = new CreateListView.Request();
request.listViewLabel = 'Support Open Cases';
request.objectApiName = 'Case';
request.groupId = queue.Id;
request.displayColumns = 'CASE.CASE_NUMBER, CASE.SUBJECT, CASE.STATUS';
Test.startTest();
CreateListView.Result result = CreateListView.createView(
new List<CreateListView.Request>{ request }
)[0];
Test.stopTest();
Assert.areEqual('Support_Open_Cases', result.listViewApiName);
Assert.areEqual('Case.Support_Open_Cases', result.metadataFullName);
Assert.isTrue(result.created);
Assert.isTrue(
mock.requests[1].contains('<filterScope>Queue</filterScope>')
);
Assert.isTrue(mock.requests[1].contains('<queue>Support_Queue</queue>'));
Assert.isTrue(
mock.requests[1].contains('<value>Resolved,Cancelled,Closed</value>')
);
}
@IsTest
static void appendsAutoResponseEntryAtEnd() {
String sourceXml =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<AutoResponseRules xmlns="http://soap.sforce.com/2006/04/metadata">' +
'<autoResponseRule><fullName>Case Auto-Response</fullName><active>true</active>' +
'<ruleEntry><senderEmail>old@example.com</senderEmail>' +
'<template>unfiled$public/Old</template></ruleEntry>' +
'</autoResponseRule></AutoResponseRules>';
CreateCaseAutoResponseRule.Configuration configuration = new CreateCaseAutoResponseRule.Configuration();
configuration.autoResponseRuleName = 'Case Auto-Response';
configuration.caseOrigin = 'Phone_Support';
configuration.templateFullName = 'unfiled$public/Support_Template';
String result = CreateCaseAutoResponseRule.appendRuleEntry(
sourceXml,
configuration
);
Assert.areNotEqual(
result.indexOf('<ruleEntry>'),
result.lastIndexOf('<ruleEntry>')
);
Assert.isFalse(result.contains('<sortOrder>'));
Assert.isTrue(result.contains('<field>Case.Origin</field>'));
Assert.isTrue(result.contains('<value>Phone_Support</value>'));
Assert.isTrue(result.contains('<field>Case.ContactEmail</field>'));
Assert.isTrue(result.contains('<field>Case.Status</field>'));
Assert.isTrue(result.contains('<value>Active</value>'));
Assert.isTrue(result.contains('<field>Case.RecordTypeId</field>'));
Assert.isTrue(result.contains('<value>Customer Service</value>'));
Assert.isTrue(
result.contains('<template>unfiled$public/Support_Template</template>')
);
}
@IsTest
static void rejectsMultipleInvocableRequests() {
CreateOriginPicklistItem.Request first = new CreateOriginPicklistItem.Request();
first.picklistItemName = 'One';
CreateOriginPicklistItem.Request second = new CreateOriginPicklistItem.Request();
second.picklistItemName = 'Two';
try {
CreateOriginPicklistItem.createOrigin(
new List<CreateOriginPicklistItem.Request>{ first, second }
);
Assert.fail('Expected a validation exception.');
} catch (IllegalArgumentException expected) {
Assert.isTrue(expected.getMessage().contains('exactly one request'));
}
}
@IsTest
static void insertsAutoResponseEntryAtRequestedPosition() {
String sourceXml =
'<AutoResponseRules xmlns="http://soap.sforce.com/2006/04/metadata">' +
'<autoResponseRule><fullName>Case Auto-Response</fullName><active>true</active>' +
'<ruleEntry><template>unfiled$public/First</template></ruleEntry>' +
'</autoResponseRule></AutoResponseRules>';
CreateCaseAutoResponseRule.Configuration configuration = new CreateCaseAutoResponseRule.Configuration();
configuration.autoResponseRuleName = 'Case Auto-Response';
configuration.caseOrigin = 'Phone_Support';
configuration.templateFullName = 'unfiled$public/Inserted';
configuration.sortOrder = 1;
String result = CreateCaseAutoResponseRule.appendRuleEntry(
sourceXml,
configuration
);
Assert.isTrue(
result.indexOf('unfiled$public/Inserted') <
result.indexOf('unfiled$public/First')
);
}
private class MetadataMock implements HttpCalloutMock {
private String scenario;
public List<String> requests = new List<String>();
private MetadataMock(String scenario) {
this.scenario = scenario;
}
public HttpResponse respond(HttpRequest request) {
requests.add(request.getBody());
HttpResponse response = new HttpResponse();
response.setStatusCode(200);
response.setHeader('Content-Type', 'text/xml');
if (request.getBody().contains('<met:readMetadata>')) {
response.setBody(readResponse());
} else {
response.setBody(saveResponse());
}
return response;
}
private String readResponse() {
String recordXml;
if (scenario == 'origin') {
recordXml =
'<records xsi:type="StandardValueSet"><fullName>CaseOrigin</fullName>' +
'<standardValue><fullName>Email</fullName><default>false</default>' +
'<label>Email</label></standardValue></records>';
} else if (scenario == 'emailToCase') {
recordXml =
'<records xsi:type="CaseSettings"><fullName>Case</fullName>' +
'<emailToCase><enableEmailToCase>true</enableEmailToCase>' +
'<unauthorizedSenderAction>Discard</unauthorizedSenderAction>' +
'</emailToCase></records>';
} else {
recordXml = '<records xsi:nil="true"/>';
}
return envelope(
'<readMetadataResponse><result>' +
recordXml +
'</result></readMetadataResponse>'
);
}
private String saveResponse() {
Boolean created = scenario == 'listView';
return envelope(
'<saveResponse><result><created>' +
String.valueOf(created).toLowerCase() +
'</created><fullName>Test</fullName><success>true</success>' +
'</result></saveResponse>'
);
}
private String envelope(String body) {
return '<?xml version="1.0" encoding="UTF-8"?>' +
'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" ' +
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' +
'<soapenv:Body>' +
body +
'</soapenv:Body></soapenv:Envelope>';
}
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>65.0</apiVersion>
<status>Active</status>
</ApexClass>