commit 43d764f9bc822e1d7aa78e9d15077f13a0b221cf Author: Zach Saunders Date: Fri Aug 21 16:38:06 2026 +0000 init diff --git a/.forceignore b/.forceignore new file mode 100644 index 0000000..7b5b5a7 --- /dev/null +++ b/.forceignore @@ -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__/** \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0931e26 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2601a3b --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run precommit \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..8cccc6e --- /dev/null +++ b/.prettierignore @@ -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/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..18039a0 --- /dev/null +++ b/.prettierrc @@ -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" } + } + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..7e6cb10 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "salesforce.salesforcedx-vscode", + "redhat.vscode-xml", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "financialforce.lana" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..e07e391 --- /dev/null +++ b/.vscode/launch.json @@ -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 + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4106373 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "search.exclude": { + "**/node_modules": true, + "**/bower_components": true, + "**/.sf": true, + "**/.sfdx": true + }, + "xml.preferences.showSchemaDocumentationType": "none" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ac66bc --- /dev/null +++ b/README.md @@ -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 `. 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 +``` diff --git a/config/project-scratch-def.json b/config/project-scratch-def.json new file mode 100644 index 0000000..d76112c --- /dev/null +++ b/config/project-scratch-def.json @@ -0,0 +1,13 @@ +{ + "orgName": "zachsaunders company", + "edition": "Developer", + "features": ["EnableSetPasswordInApi"], + "settings": { + "lightningExperienceSettings": { + "enableS1DesktopEnabled": true + }, + "mobileSettings": { + "enableS1EncryptedStoragePref2": false + } + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..a58c917 --- /dev/null +++ b/eslint.config.js @@ -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'] + } +]); \ No newline at end of file diff --git a/force-app/main/default/classes/CreateCaseAutoResponseRule.cls b/force-app/main/default/classes/CreateCaseAutoResponseRule.cls new file mode 100644 index 0000000..fbf1574 --- /dev/null +++ b/force-app/main/default/classes/CreateCaseAutoResponseRule.cls @@ -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 createRuleEntry(List 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 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 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 }; + } + + 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 = + '' + + '' + + 'Case.' + + MetadataXml.escape(configuration.autoResponseRuleName) + + 'AutoResponseRule' + + '' + + MetadataApiClient.API_VERSION + + ''; + 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 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 = + '1 AND 2 AND 3 AND 4' + + criterion('Case.Origin', 'equals', configuration.caseOrigin) + + criterion('Case.ContactEmail', 'notEqual', '') + + criterion('Case.Status', 'equals', 'Active') + + criterion('Case.RecordTypeId', 'equals', 'Customer Service') + + '' + + MetadataXml.escape(SENDER_EMAIL) + + '' + + MetadataXml.escape(SENDER_NAME) + + ''; + + String targetRuleXml = ''; + 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 += ''; + + String output = + '' + + ''; + for (Dom.XmlNode rule : root.getChildElements()) { + output += rule == targetRule + ? targetRuleXml + : MetadataXml.serialize(rule); + } + return output + ''; + } + + private static String criterion( + String field, + String operation, + String value + ) { + return '' + + MetadataXml.escape(field) + + '' + + MetadataXml.escape(operation) + + '' + + MetadataXml.escape(value) + + ''; + } + + 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; + } +} diff --git a/force-app/main/default/classes/CreateCaseAutoResponseRule.cls-meta.xml b/force-app/main/default/classes/CreateCaseAutoResponseRule.cls-meta.xml new file mode 100644 index 0000000..fb735c1 --- /dev/null +++ b/force-app/main/default/classes/CreateCaseAutoResponseRule.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/force-app/main/default/classes/CreateEmailToCase.cls b/force-app/main/default/classes/CreateEmailToCase.cls new file mode 100644 index 0000000..79de672 --- /dev/null +++ b/force-app/main/default/classes/CreateEmailToCase.cls @@ -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 createRoutingAddress(List 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 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{ buildResult(input, false) }; + } + } + + String routeXml = + '' + + 'EmailToCase' + + '' + + MetadataXml.escape(input.caseOrigin) + + '' + + '' + + MetadataXml.escape(queues[0].DeveloperName) + + '' + + 'Queue' + + 'Medium' + + '' + + MetadataXml.escape(input.emailAddress) + + '' + + '' + + MetadataXml.escape(input.routingName) + + '' + + 'false' + + ''; + String emailToCaseXml = ''; + 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 += ''; + + MetadataApiClient.updateMetadata( + 'CaseSettings', + MetadataXml.replaceOrInsertChild( + caseSettings, + 'emailToCase', + emailToCaseXml + ) + ); + return new List{ 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 fields = routingType.getDescribe() + .fields.getMap(); + String addressField = fields.containsKey('EmailAddress') + ? 'EmailAddress' + : (fields.containsKey('Address') ? 'Address' : null); + if (addressField == null) { + return result; + } + + List selectFields = new List{ '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 matches = Database.queryWithBinds( + query, + new Map{ '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 fields = serviceType.getDescribe() + .fields.getMap(); + if ( + !fields.containsKey('LocalPart') || !fields.containsKey('EmailDomainName') + ) { + return null; + } + List rows = Database.queryWithBinds( + 'SELECT LocalPart, EmailDomainName FROM EmailServicesAddress WHERE Id = :serviceAddressId LIMIT 1', + new Map{ 'serviceAddressId' => serviceAddressId }, + AccessLevel.USER_MODE + ); + return rows.isEmpty() + ? null + : String.valueOf(rows[0].get('LocalPart')) + + '@' + + String.valueOf(rows[0].get('EmailDomainName')); + } +} diff --git a/force-app/main/default/classes/CreateEmailToCase.cls-meta.xml b/force-app/main/default/classes/CreateEmailToCase.cls-meta.xml new file mode 100644 index 0000000..fb735c1 --- /dev/null +++ b/force-app/main/default/classes/CreateEmailToCase.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/force-app/main/default/classes/CreateListView.cls b/force-app/main/default/classes/CreateListView.cls new file mode 100644 index 0000000..724661c --- /dev/null +++ b/force-app/main/default/classes/CreateListView.cls @@ -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 createView(List 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 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 columns = new List(); + 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 = + '' + + MetadataXml.escape(fullName) + + ''; + for (String column : columns) { + metadataXml += '' + MetadataXml.escape(column) + ''; + } + metadataXml += + 'Queue' + + '' + + MetadataXml.escape(statusField(input.objectApiName)) + + 'notEqual' + + 'Resolved,Cancelled,Closed' + + '' + + MetadataXml.escape(queues[0].DeveloperName) + + ''; + + MetadataApiClient.upsertMetadata('ListView', metadataXml); + Result result = new Result(); + result.listViewApiName = apiName; + result.metadataFullName = fullName; + result.created = !existed; + return new List{ result }; + } + + private static String statusField(String objectApiName) { + return objectApiName.equalsIgnoreCase('Case') ? 'CASE.STATUS' : 'Status'; + } +} diff --git a/force-app/main/default/classes/CreateListView.cls-meta.xml b/force-app/main/default/classes/CreateListView.cls-meta.xml new file mode 100644 index 0000000..fb735c1 --- /dev/null +++ b/force-app/main/default/classes/CreateListView.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/force-app/main/default/classes/CreateOriginPicklistItem.cls b/force-app/main/default/classes/CreateOriginPicklistItem.cls new file mode 100644 index 0000000..95c5834 --- /dev/null +++ b/force-app/main/default/classes/CreateOriginPicklistItem.cls @@ -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 createOrigin(List 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(existingName, false) }; + } + } + + String newValueXml = + '' + + MetadataXml.escape(developerName) + + 'false'; + MetadataApiClient.updateMetadata( + 'StandardValueSet', + MetadataXml.serializeChildren(valueSet) + newValueXml + ); + return new List{ 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; + } +} diff --git a/force-app/main/default/classes/CreateOriginPicklistItem.cls-meta.xml b/force-app/main/default/classes/CreateOriginPicklistItem.cls-meta.xml new file mode 100644 index 0000000..fb735c1 --- /dev/null +++ b/force-app/main/default/classes/CreateOriginPicklistItem.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/force-app/main/default/classes/MetadataApiClient.cls b/force-app/main/default/classes/MetadataApiClient.cls new file mode 100644 index 0000000..7e93f28 --- /dev/null +++ b/force-app/main/default/classes/MetadataApiClient.cls @@ -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 = + '' + + '' + + MetadataXml.escape(metadataType) + + '' + + '' + + MetadataXml.escape(fullName) + + '' + + ''; + + 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( + '' + + '' + + API_VERSION + + 'true' + + '' + + MetadataXml.escape(member) + + '' + + MetadataXml.escape(metadataType) + + '' + + '' + ); + 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( + '' + + MetadataXml.escape(retrieveId) + + 'true' + + '' + ); + 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( + '' + + EncodingUtil.base64Encode(zipFile) + + '' + + 'false' + + 'false' + + 'false' + + 'false' + + 'false' + + 'false' + + 'true' + + 'true' + + 'NoTestRun' + + '' + ); + 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( + '' + + MetadataXml.escape(deployId) + + 'true' + + '' + ); + 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 payload = (Map) JSON.deserializeUntyped( + response.getBody() + ); + List records = (List) payload.get('records'); + if (records == null || records.isEmpty()) { + throw new MetadataApiException('Auto Response Rule Id was not found.'); + } + return (String) ((Map) records[0]).get('Name'); + } + + private static SaveResult save( + String operationName, + String metadataType, + String metadataChildrenXml + ) { + String operation = + '' + + metadataChildrenXml + + ''; + + 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( + '' + + '' + + operationXml + + '' + ); + + 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; + } +} diff --git a/force-app/main/default/classes/MetadataApiClient.cls-meta.xml b/force-app/main/default/classes/MetadataApiClient.cls-meta.xml new file mode 100644 index 0000000..fb735c1 --- /dev/null +++ b/force-app/main/default/classes/MetadataApiClient.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/force-app/main/default/classes/MetadataXml.cls b/force-app/main/default/classes/MetadataXml.cls new file mode 100644 index 0000000..5063a62 --- /dev/null +++ b/force-app/main/default/classes/MetadataXml.cls @@ -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 children( + Dom.XmlNode parent, + String localName + ) { + List matches = new List(); + 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 childNodes = node.getChildElements(); + if (childNodes.isEmpty()) { + return '<' + name + '>' + escape(node.getText()) + ''; + } + + String result = '<' + name + '>'; + for (Dom.XmlNode childNode : childNodes) { + result += serialize(childNode); + } + return result + ''; + } + + 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); + } + } +} diff --git a/force-app/main/default/classes/MetadataXml.cls-meta.xml b/force-app/main/default/classes/MetadataXml.cls-meta.xml new file mode 100644 index 0000000..fb735c1 --- /dev/null +++ b/force-app/main/default/classes/MetadataXml.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/force-app/main/default/classes/QueueMetadataActionsTest.cls b/force-app/main/default/classes/QueueMetadataActionsTest.cls new file mode 100644 index 0000000..d866e1c --- /dev/null +++ b/force-app/main/default/classes/QueueMetadataActionsTest.cls @@ -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{ 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('Phone_Support') + ); + Assert.isTrue(mock.requests[1].contains('')); + } + + @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{ 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('Support_Queue') + ); + Assert.isTrue( + mock.requests[1].contains('Medium') + ); + Assert.isTrue( + mock.requests[1].contains('EmailToCase') + ); + Assert.isTrue( + mock.requests[1] + .contains('support@example.com') + ); + Assert.isTrue( + mock.requests[1].contains('Phone_Support') + ); + } + + @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{ 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('Queue') + ); + Assert.isTrue(mock.requests[1].contains('Support_Queue')); + Assert.isTrue( + mock.requests[1].contains('Resolved,Cancelled,Closed') + ); + } + + @IsTest + static void appendsAutoResponseEntryAtEnd() { + String sourceXml = + '' + + '' + + 'Case Auto-Responsetrue' + + 'old@example.com' + + '' + + ''; + 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(''), + result.lastIndexOf('') + ); + Assert.isFalse(result.contains('')); + Assert.isTrue(result.contains('Case.Origin')); + Assert.isTrue(result.contains('Phone_Support')); + Assert.isTrue(result.contains('Case.ContactEmail')); + Assert.isTrue(result.contains('Case.Status')); + Assert.isTrue(result.contains('Active')); + Assert.isTrue(result.contains('Case.RecordTypeId')); + Assert.isTrue(result.contains('Customer Service')); + Assert.isTrue( + result.contains('') + ); + } + + @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{ 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 = + '' + + 'Case Auto-Responsetrue' + + '' + + ''; + 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 requests = new List(); + + 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('')) { + response.setBody(readResponse()); + } else { + response.setBody(saveResponse()); + } + return response; + } + + private String readResponse() { + String recordXml; + if (scenario == 'origin') { + recordXml = + 'CaseOrigin' + + 'Emailfalse' + + ''; + } else if (scenario == 'emailToCase') { + recordXml = + 'Case' + + 'true' + + 'Discard' + + ''; + } else { + recordXml = ''; + } + return envelope( + '' + + recordXml + + '' + ); + } + + private String saveResponse() { + Boolean created = scenario == 'listView'; + return envelope( + '' + + String.valueOf(created).toLowerCase() + + 'Testtrue' + + '' + ); + } + + private String envelope(String body) { + return '' + + '' + + '' + + body + + ''; + } + } +} diff --git a/force-app/main/default/classes/QueueMetadataActionsTest.cls-meta.xml b/force-app/main/default/classes/QueueMetadataActionsTest.cls-meta.xml new file mode 100644 index 0000000..fb735c1 --- /dev/null +++ b/force-app/main/default/classes/QueueMetadataActionsTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..f5a9fed --- /dev/null +++ b/jest.config.js @@ -0,0 +1,6 @@ +const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config'); + +module.exports = { + ...jestConfig, + modulePathIgnorePatterns: ['/.localdevserver'] +}; diff --git a/manifest/package.xml b/manifest/package.xml new file mode 100644 index 0000000..19a832b --- /dev/null +++ b/manifest/package.xml @@ -0,0 +1,8 @@ + + + + * + ApexClass + + 65.0 + diff --git a/package.json b/package.json new file mode 100644 index 0000000..0d77cdc --- /dev/null +++ b/package.json @@ -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" + ] + } +} diff --git a/scripts/apex/hello.apex b/scripts/apex/hello.apex new file mode 100644 index 0000000..1fba732 --- /dev/null +++ b/scripts/apex/hello.apex @@ -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); \ No newline at end of file diff --git a/scripts/soql/account.soql b/scripts/soql/account.soql new file mode 100644 index 0000000..10d4b9c --- /dev/null +++ b/scripts/soql/account.soql @@ -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 diff --git a/sfdx-project.json b/sfdx-project.json new file mode 100644 index 0000000..4fffe3e --- /dev/null +++ b/sfdx-project.json @@ -0,0 +1,12 @@ +{ + "packageDirectories": [ + { + "path": "force-app", + "default": true + } + ], + "name": "queue-creation", + "namespace": "", + "sfdcLoginUrl": "https://login.salesforce.com", + "sourceApiVersion": "65.0" +}