init
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
# List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status
|
||||||
|
# More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm
|
||||||
|
#
|
||||||
|
|
||||||
|
package.xml
|
||||||
|
|
||||||
|
# LWC configuration files
|
||||||
|
**/jsconfig.json
|
||||||
|
**/.eslintrc.json
|
||||||
|
|
||||||
|
# LWC Jest
|
||||||
|
**/__tests__/**
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# This file is used for Git repositories to specify intentionally untracked files that Git should ignore.
|
||||||
|
# If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore
|
||||||
|
# For useful gitignore templates see: https://github.com/github/gitignore
|
||||||
|
|
||||||
|
# Salesforce cache
|
||||||
|
.sf/
|
||||||
|
.sfdx/
|
||||||
|
.localdevserver/
|
||||||
|
deploy-options.json
|
||||||
|
|
||||||
|
# LWC VSCode autocomplete
|
||||||
|
**/lwc/jsconfig.json
|
||||||
|
|
||||||
|
# LWC Jest coverage reports
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# ApexPMD cache
|
||||||
|
.pmdCache
|
||||||
|
|
||||||
|
# Eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# MacOS system files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Windows system files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
[Dd]esktop.ini
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Local environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Python Salesforce Functions
|
||||||
|
**/__pycache__/
|
||||||
|
**/.venv/
|
||||||
|
**/venv/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
npm run precommit
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# List files or directories below to ignore them when running prettier
|
||||||
|
# More information: https://prettier.io/docs/en/ignore.html
|
||||||
|
#
|
||||||
|
|
||||||
|
**/staticresources/**
|
||||||
|
.localdevserver
|
||||||
|
.sfdx
|
||||||
|
.sf
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
coverage/
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"trailingComma": "none",
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-apex",
|
||||||
|
"@prettier/plugin-xml"
|
||||||
|
],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "**/lwc/**/*.html",
|
||||||
|
"options": { "parser": "lwc" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": "*.{cmp,page,component}",
|
||||||
|
"options": { "parser": "html" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"salesforce.salesforcedx-vscode",
|
||||||
|
"redhat.vscode-xml",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"financialforce.lana"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Launch Apex Replay Debugger",
|
||||||
|
"type": "apex-replay",
|
||||||
|
"request": "launch",
|
||||||
|
"logFile": "${command:AskForLogFileName}",
|
||||||
|
"stopOnEntry": true,
|
||||||
|
"trace": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"search.exclude": {
|
||||||
|
"**/node_modules": true,
|
||||||
|
"**/bower_components": true,
|
||||||
|
"**/.sf": true,
|
||||||
|
"**/.sfdx": true
|
||||||
|
},
|
||||||
|
"xml.preferences.showSchemaDocumentationType": "none"
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Queue Metadata Flow Actions
|
||||||
|
|
||||||
|
This project contains only the four Apex actions needed by the existing queue-creation Flow:
|
||||||
|
|
||||||
|
- `CreateOriginPicklistItem`
|
||||||
|
- `CreateEmailToCase`
|
||||||
|
- `CreateCaseAutoResponseRule`
|
||||||
|
- `CreateListView`
|
||||||
|
|
||||||
|
`MetadataApiClient` and `MetadataXml` are shared implementation helpers, not additional Flow actions.
|
||||||
|
|
||||||
|
## Required Named Credential
|
||||||
|
|
||||||
|
Create a Named Credential named `Salesforce_Metadata` with:
|
||||||
|
|
||||||
|
- URL: your org's My Domain URL
|
||||||
|
- Authentication: OAuth 2.0 to the same Salesforce org
|
||||||
|
- Generate Authorization Header: enabled
|
||||||
|
- Principal permissions: API Enabled and Customize Application
|
||||||
|
|
||||||
|
The actions call Metadata API v65.0 through this credential.
|
||||||
|
|
||||||
|
## Flow action contracts
|
||||||
|
|
||||||
|
### Create Origin Picklist Item
|
||||||
|
|
||||||
|
Input: `picklistItemName`
|
||||||
|
|
||||||
|
Outputs: `caseOrigin`, `metadataFullName`, `created`
|
||||||
|
|
||||||
|
The action reads `StandardValueSet.CaseOrigin`, returns an existing case-insensitive match, or appends a value. The API name is generated from the label.
|
||||||
|
|
||||||
|
### Create Email-To-Case
|
||||||
|
|
||||||
|
Inputs: `routingName`, `emailAddress`, `caseOwnerId`, `caseOrigin`
|
||||||
|
|
||||||
|
Outputs: `metadataFullName`, `routingName`, `emailAddress`, `emailToCaseAddress`, `emailRoutingAddressId`, `created`
|
||||||
|
|
||||||
|
The owner ID must be a queue `Group`. Priority is always `Medium`. Email-to-Case must already be enabled in Setup. Salesforce sends a verification message for a new routing address; the generated service address can therefore be blank immediately after creation.
|
||||||
|
|
||||||
|
### Create Case Auto-Response Rule Entry
|
||||||
|
|
||||||
|
Inputs: `autoResponseRuleId` or `autoResponseRuleName`, optional `sortOrder`, `caseOrigin`, `emailTemplateId`
|
||||||
|
|
||||||
|
Outputs: `asyncJobId`, `status`, `metadataFullName`
|
||||||
|
|
||||||
|
This action is asynchronous because Salesforce requires file-based retrieve/deploy for auto-response rules. Salesforce stores the sort order as the rule-entry sequence in the metadata file; the supplied position inserts there, and a blank position appends the entry. It adds these criteria:
|
||||||
|
|
||||||
|
- `Case.Origin` equals the supplied origin
|
||||||
|
- `Case.ContactEmail` is not blank
|
||||||
|
- `Case.Status` equals `Active`
|
||||||
|
- `Case.RecordTypeId` equals `Customer Service`
|
||||||
|
|
||||||
|
The sender is `Columbus McKinnon Support <cmcoautoreply@columbusmckinnon.com>`. The email template must be a Classic Email Template in a shared folder. The returned ID identifies the first coordinator job; the retrieve/deploy continuation jobs are visible in Setup's Apex Jobs page.
|
||||||
|
|
||||||
|
### Create List View
|
||||||
|
|
||||||
|
Inputs: `listViewLabel`, `objectApiName`, `groupId`, `displayColumns`
|
||||||
|
|
||||||
|
Outputs: `listViewApiName`, `metadataFullName`, `created`
|
||||||
|
|
||||||
|
`displayColumns` is comma-separated and must use Metadata API list-view column names (for example, `CASE.CASE_NUMBER,CASE.SUBJECT,CASE.STATUS`). The action creates or replaces a queue-scoped view and excludes `Resolved`, `Cancelled`, and `Closed` statuses. The queue Group ID is resolved to its developer name for Metadata API use.
|
||||||
|
|
||||||
|
## Transaction note
|
||||||
|
|
||||||
|
Place each callout action after a transaction boundary if earlier Flow elements create records. In particular, commit the queue and email template before invoking the Email-to-Case or auto-response actions.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf project deploy start --manifest manifest/package.xml --target-org YOUR_ORG
|
||||||
|
sf apex run test --class-names QueueMetadataActionsTest --result-format human --target-org YOUR_ORG
|
||||||
|
```
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"orgName": "zachsaunders company",
|
||||||
|
"edition": "Developer",
|
||||||
|
"features": ["EnableSetPasswordInApi"],
|
||||||
|
"settings": {
|
||||||
|
"lightningExperienceSettings": {
|
||||||
|
"enableS1DesktopEnabled": true
|
||||||
|
},
|
||||||
|
"mobileSettings": {
|
||||||
|
"enableS1EncryptedStoragePref2": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const eslintJs = require('@eslint/js');
|
||||||
|
const jestPlugin = require('eslint-plugin-jest');
|
||||||
|
const auraConfig = require('@salesforce/eslint-plugin-aura');
|
||||||
|
const lwcConfig = require('@salesforce/eslint-config-lwc/recommended');
|
||||||
|
const globals = require('globals');
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
// Aura configuration
|
||||||
|
{
|
||||||
|
files: ['**/aura/**/*.js'],
|
||||||
|
extends: [
|
||||||
|
...auraConfig.configs.recommended,
|
||||||
|
...auraConfig.configs.locker
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// LWC configuration
|
||||||
|
{
|
||||||
|
files: ['**/lwc/**/*.js'],
|
||||||
|
extends: [lwcConfig]
|
||||||
|
},
|
||||||
|
|
||||||
|
// LWC configuration with override for LWC test files
|
||||||
|
{
|
||||||
|
files: ['**/lwc/**/*.test.js'],
|
||||||
|
extends: [lwcConfig],
|
||||||
|
rules: {
|
||||||
|
'@lwc/lwc/no-unexpected-wire-adapter-usages': 'off'
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Jest mocks configuration
|
||||||
|
{
|
||||||
|
files: ['**/jest-mocks/**/*.js'],
|
||||||
|
languageOptions: {
|
||||||
|
sourceType: 'module',
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.es2021,
|
||||||
|
...jestPlugin.environments.globals.globals
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
eslintJs
|
||||||
|
},
|
||||||
|
extends: ['eslintJs/recommended']
|
||||||
|
}
|
||||||
|
]);
|
||||||
@@ -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('&', '&')
|
||||||
|
.replace('<', '<')
|
||||||
|
.replace('>', '>')
|
||||||
|
.replace('"', '"')
|
||||||
|
.replace('\'', ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...jestConfig,
|
||||||
|
modulePathIgnorePatterns: ['<rootDir>/.localdevserver']
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||||
|
<types>
|
||||||
|
<members>*</members>
|
||||||
|
<name>ApexClass</name>
|
||||||
|
</types>
|
||||||
|
<version>65.0</version>
|
||||||
|
</Package>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "salesforce-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Salesforce App",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint **/{aura,lwc}/**/*.js",
|
||||||
|
"test": "npm run test:unit",
|
||||||
|
"test:unit": "sfdx-lwc-jest",
|
||||||
|
"test:unit:watch": "sfdx-lwc-jest --watch",
|
||||||
|
"test:unit:debug": "sfdx-lwc-jest --debug",
|
||||||
|
"test:unit:coverage": "sfdx-lwc-jest --coverage",
|
||||||
|
"prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"",
|
||||||
|
"prettier:verify": "prettier --check \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"",
|
||||||
|
"prepare": "husky || true",
|
||||||
|
"precommit": "lint-staged"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@lwc/eslint-plugin-lwc": "^3.1.0",
|
||||||
|
"@prettier/plugin-xml": "^3.4.1",
|
||||||
|
"@salesforce/eslint-config-lwc": "^4.0.0",
|
||||||
|
"@salesforce/eslint-plugin-aura": "^3.0.0",
|
||||||
|
"@salesforce/eslint-plugin-lightning": "^2.0.0",
|
||||||
|
"@salesforce/sfdx-lwc-jest": "^7.0.2",
|
||||||
|
"eslint": "^9.29.0",
|
||||||
|
"eslint-plugin-import": "^2.31.0",
|
||||||
|
"eslint-plugin-jest": "^28.14.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^16.1.2",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"prettier-plugin-apex": "^2.2.6"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}": [
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"**/{aura,lwc}/**/*.js": [
|
||||||
|
"eslint"
|
||||||
|
],
|
||||||
|
"**/lwc/**": [
|
||||||
|
"sfdx-lwc-jest -- --bail --findRelatedTests --passWithNoTests"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Use .apex files to store anonymous Apex.
|
||||||
|
// You can execute anonymous Apex in VS Code by selecting the
|
||||||
|
// apex text and running the command:
|
||||||
|
// SFDX: Execute Anonymous Apex with Currently Selected Text
|
||||||
|
// You can also execute the entire file by running the command:
|
||||||
|
// SFDX: Execute Anonymous Apex with Editor Contents
|
||||||
|
|
||||||
|
string tempvar = 'Enter_your_name_here';
|
||||||
|
System.debug('Hello World!');
|
||||||
|
System.debug('My name is ' + tempvar);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Use .soql files to store SOQL queries.
|
||||||
|
// You can execute queries in VS Code by selecting the
|
||||||
|
// query text and running the command:
|
||||||
|
// SFDX: Execute SOQL Query with Currently Selected Text
|
||||||
|
|
||||||
|
SELECT Id, Name FROM Account
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"packageDirectories": [
|
||||||
|
{
|
||||||
|
"path": "force-app",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "queue-creation",
|
||||||
|
"namespace": "",
|
||||||
|
"sfdcLoginUrl": "https://login.salesforce.com",
|
||||||
|
"sourceApiVersion": "65.0"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user