Custom Approval Process: A Different Perspective – Part 2

Anindya Halder – Senior Salesforce Developer and Pavel Hrbáček – Delivery Consultant and Senior Salesforce Developer at Bluewave recently presented at CzechDreamin, offering a new perspective on the custom approval process. Find out more about how they utilised the power of Apex code to develop an adaptable solution that can keep up with their customer’s frequently changing requirements.

In our previous blog, we discussed about the idea of eval in apex and how it can be used to dynamically evaluate code. In today’s blog, we’ll dive deeper into the topic and explore the idea of using it in our custom approval process..

Framework Class Structure

Utilising the eval function, the class structure of our approval framework becomes organised into distinct layers:

  1. Eval: At the heart of the framework.

  2. Core Methods:  Workflow methods that call eval.

  3. Public Methods:  Methods like submit, approve, reject, etc., that utilise the core methods.

 

Here is a simple example of using the framework’s eval for a submission of accounts for approval where the entry criteria are to check if the industry of the account is finance.
We can configure the entry criteria directly in the associated Process Definition record. Let us say it has a field EntryCriteria__c which is a text area. Its value will be a string like this:

result = record.Industry == 'Finance' ? '':'Criteria doesn't match';​

The public method submit would call a core method WF_Helper.checkEntryCriteria, and if the entry criteria match, it would proceed and start the Process Instance, compute the first step ID and then activate the Process Instance Node with that step ID, and finally create approval requests (Process Instance Work Items). If the criteria do not match, it should receive the error message as a string and surface that in the UI.

//Public methods ​
submit(processDefinition,contextRecord) {​
 String errorMessage = WF_helper.checkEntryCriteria(processDefinition, contextRecord);​
 If(String.isBlank(errorMessage)) { 
  //start process instance, compute first stepid, activate node with that stepid and create workitems/approval requests
  }​
}

The core method checkEntryCriteria would prepare the string of code to be passed to eval. It would first write a string of query to access the Industry of the context account record. Then it would fetch the config saved in the EntryCriteria__c field of process definition. Then it will concatenate all these strings ensuring all input parameters and variables are declared and included in the string. It will also concatenate a System.Debug statement in the final string.

//Workflow Core methods​
String contextRecordString = 'Account record = [SELECT Id, Industry FROM Account WHERE Id =\' '+ contextRecord.Id + '\'];';​
String configScript =  processDefinition.EntryCriteria__c;​
​
String toEval = contextRecordString;​
toEval += 'String result = \'\';';​
toEval += configScript;​
toEval += 'System.debug(LoggingLevelError, result);';​
String finalResult = WF_ExecuteAnonymousApex.eval(toEval);​
return finalResult;

So the final string would contain the below code – for better visibility here is the code but not in string format:

Account record = [SELECT Id, Industry FROM Account WHERE Id ='<accountId>'];​ 
String result = record.Industry == 'Finance' ? '' : 'Criteria doesn\'t match';​ 
System.debug(LoggingLevel.Error, result);​

And the eval call can be elaborated as follows:

  1. HTTP Request to make the ExecuteAnonymous API Call:
    //ExecuteAnonymous API call​
    String endpoint = URL.getSalesforceBaseUrl().toExternalForm() + '/services/Soap/s/56.0';​
    HttpRequest req = new HttpRequest();​
    req.setEndpoint(endpoint_x);​
    req.setMethod('POST');​
    req.setHeader('Content-Type', 'text/xml; charset=UTF-8');​
    req.setHeader('SOAPAction', 'blank');​
    req.setBodyDocument(doc);​
    Http http = new Http();​
    HTTPResponse res = http.send(req);​
      ​
    return extractDebugLog(res.getBodyDocument());
  2. Request Body

    <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
        xmlns:apex="http://soap.sforce.com/2006/08/apex">
        <Soapenv:Header>
            <apex:DebuggingHeader>
                <apex:categories>
                    <apex:category>Apex_code</apex:category>
                    <apex:level>ERROR</apex:level>
                </apex:categories>
                <apex:debugLevel>NONE</apex:debugLevel>
            </apex:DebuggingHeader>
            <apex:SessionHeader>
                <apex:sessionId>Session Id</apex:sessionId>
            </apex:SessionHeader>
            </soapeny:Header>
            <Soapenv:Body>
                <apex:executeAnonymous>
                    <apex:String>Final string of code*</apex:String>
                </apex:executeAnonymous>
            </soapenv:Body>
    </soapenv:Envelope>
    // *Final String of code
    Account record = [SELECT Id, Industry FROM Account WHERE Id = '<accountId>'];
    String result = record.Industry == 'Finance' ? '' : 'Criteria doesn\'t match';
    System.debug(LoggingLevel.Error, result);
  3. Response:

    <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/e...">
        <soapenv:Header>
            <DebuggingInfo>
                <debugLog>31.0 APEX_ CODE, ERROR
                    Execute Anonymous:Final string of code
                    13:24:24.027 (27573409) CODE LUNT STARTED EXTERNAL execute_anonymous._ape
                    13:24:24.028 (28065096) |USER_DEBUG| [1] |ERROR| Actual Output ("" or Criteria doesn\'t match)
                    13:24:24.028 28098385) |CODE_UNIT_FINISHED| execute_anonymous_apex
                    13:24:24.029 29024086) |EXECUTION_FINISHED</debugLog>
                </Debugging.Info>
        </soapenv:Header>
        <soapenv:Body>
            <executeAnonymous Response>
                <result>
                    <column>-1</column>
                    <compileProblem xsi:nil="true" />
                    <compiled>true</compiled>
                    <exceptionMessage xsi:nil="true" />
                    <exceptionstackTrace xsi:nil="true" />
                    <line>-1</line>
                    <success>true</success>
                </result>
                </executeAnonymousResponse>
        </soapenv:Body>
    </soapenv:Envelope>

Imagine the above scenario. The client wants to change the entry criteria and wants to add the industry ‘Banking’ too. All we need to do is open the right process definition record and change the text in its EntryCriteria__c field from

result = record.Industry == 'Finance' ? '' : 'Criteria doesn\'t match';​

to

result = record.Industry == 'Finance' || record.Industry == 'Banking' ? '' : 'Criteria doesn\'t match';​

Next, we will explore a sample scenario and show how the framework can be used to configure things quite flexibly.

.

Sample Approval Process Scenario

Consider the following scenario – “Developers who are employees of an IT company have been contracted to work on a project for a client. When they want to go on annual leave, they need to get approval from both their own direct managers and the client’s project manager.”

Let’s summarise the rules:

  1. 50% Approval from Direct Managers: The leave request requires approval from 50% of the direct managers (which could be 2 or 3 managers).

  2. Project Manager’s Approval: After receiving approval from the direct managers, the client’s project manager must approve the request, provided the developer has an active project assigned.

.

Execution Flow of the Submission Process

When a developer submits a leave request, the following steps occur:
  1. Submission

    • The developer submits their leave request.

    • Our public framework method submit() is called, which starts executing.

    • It evaluates the entry criteria and, if all conditions are met, calls a method to create the Process Instance record and sets some defaults.

  2. Initial Process Step

    • The computeStepId method is executed, which returns the first step ID based on the initial Process Step ID.

    • The activateNode method uses the step configuration and creates a new ProcessInstanceNode record, setting its defaults.

    • The Process Instance status is updated to “Started”.

  3. Creating Approval Requests

    • The first set of approval requests is created for the direct managers using the After Submission Action (see the below section) configuration.

    • The Process Instance status is set to “In Progress,” and the Leave Request record is flagged as part of an ongoing approval process.

.

After Submission Configuration

Now, let’s have a look at a sample After Submission configuration, which illustrates the process we just described.

Before we dive in, it’s important to note that our Process Definition object includes several text and text area fields used for the Approvals configuration. The content of these fields is evaluated and executed at various stages of the approval process.

.

One of these fields is the ‘After Submission Action’. Here’s a simplified example of its configuration:

  1. Fetching User Names of Direct Managers

    We first retrieve the usernames of the direct managers.

  2. Creating Request Parameters for Each Manager

    Instances of Request Parameters are created for each manager. These objects store a few details about the request, such as a prompt or an assignee.

  3. Creating Approval Request Records

    Finally, we call a method that creates the Approval Request records.

Here is the code that would be stored inside a text field on the process configuration record:

Map<String, List<User>> roleToUsersMap = WF_Actors.getRoleUsers(contextRecordId, ​
  new List<String> {'Line Manager', 'Delivery Manager', 'Division Manager'});​
​
List<WF_App_Actions.RequestParams> requestParamsList = new List<WF_App_Actions.RequestParams>();​
​
for (String role : roleToUsersMap.keySet()) {​
  for (User assignee : roleToUsersMap.get(role)) {​
      requestParamsList.add(new WF_App_Actions.RequestParams(​
        'Approve - ' + role, ​
        'Select \'Approve\' if you are happy to approve the leave.', ​
        assignee, role, null));​
    }    ​
  }​
}​
WF_App_Actions.createApproverRequest(processInstanceId, processInstanceNodeId, requestParamsList);​

This code snippet accomplishes the following:

  • It uses the WF_Actors.getRoleUsers method to fetch the usernames of the managers based on their roles.

  • It creates a list of WF_App_Actions.RequestParams objects, each representing a request parameter for a manager.

  • Finally, it calls the WF_App_Actions.createApproverRequest method to create the approval request records in the system.

By configuring the After Submission Action in this way, we ensure that the appropriate approval requests are generated and assigned to the relevant managers as soon as a leave request is submitted. This setup demonstrates the flexibility and power of our custom approval framework in handling complex approval scenarios.

.

After Approval Configuration

Now, let’s examine what happens after one of the managers approves the request.

When one of the managers approves the request, the framework needs to check if the 50% approval threshold is reached. This is done by computing the percentage of received approvals relative to the total number of requests generated for this step (node).

Here is how it works:

  1. Check Approval Percentage
    • We calculate the percentage of approvals received so far.

    • If the 50% threshold is met, we can move to the next step.

      // Is Final Approval - Framework code
      public static Boolean checkIfFinalApproval(Id precessInstanceNodeld, Integer percentageRequired) {
         ...
         completedApprovals = Integer.valueof(processInstanceNode.NumberQfAppravals__c);
         
         Integer allApprovals = [SELECT Count () 
             FROM WF_RrocessInstanceWorkItem__c
             WHERE processInstanceNode =: processInstanceNodeId];
      
         return (completedApprovals/allApproxals*100) >= percentageRequired;
      }
      // Is Final Approval - Framework configuration
      result = WF_App_Actions.checkIfFinalApproval(processInstanceNodeId, 50); // When at least 50% percentage is required
  2. Compute Next Step Id
    • Once the approval threshold is achieved, we compute the Step Id of the next node where the process will move.
      // Approved Next Step Id - Framework configuration
      result = ourHR.hasActiveProject(contextRecord.RequestedBy__c ? '002' : null);
  3. Complete Current Step and Start Next Step
    • The framework marks the current step as complete and initiates the next step.

    • The process then uses the After Final Approval Action, another configurable endpoint/hook, to generate the new approval request for the Project Manager.

  4. Handle Absence of Project Manager
    • If the developer has no active project or project manager, the approval steps are considered complete.

    • The process reverts to the Process Definition and executes the AFTER APPROVAL ACTION, which can be configured to send an email notification to the developer or requester, informing them that their leave has been approved.

      Here is a simplified example of how the After Final Approval Action configuration might look:

      // After Final Approval Action - Framework code
      Map<String, List<User>> roleToUsersMap = LeaveApproval.getRoleUsers(contextRecordId, new List<String> {'Project Manager'});
      
      If (roleToUsersMap.size() == 0) return; // Finish the step when user has no Project Manager
      
      List<WF_App_Actions.RequestParams> requestParamsList = new List<WF_App_Actions.RequestParams>();
      
      for (String role : roleToUsersMap.keySet()) {
          for (User assignee : roleToUsersMap.get(role)) {
      
              requestParamsList.add(new WF_App_Actions.RequestParams(
                  'Approve a leave request', 
                  'Select \'Approve\' if you are happy to approve', 
                  assignee, role, null, null, false
              ))
          }
      }
      
      WF_App_Actions.createApRceverRequest(processInstanceId, nextProcessInstanceNodeId, requestParamsList);

By configuring the After Approval Action in this way, we ensure that the process correctly handles multiple approval steps and dynamically adapts based on the developer’s current project status. This showcases the flexibility and robustness of our custom approval framework in managing complex approval workflows.

.

Implementation Highlights

Implementing our custom approval framework came with some challenges. Here are some key issues we faced and how we tackled them.

Topic Solution Approach
Executing anonymous apex script runs as the running user, not in system mode, and if the script needs to refer to any Apex classes to invoke methods, we will need to grant the users permissions that we wouldn’t want to, such as Author Apex or access to Setup​. Using Named Credential to authenticate and login as an admin user.​​

With Named Credentials, we can provide the callout with admin credentials so the dynamic code can be executed with as much flexibility as our written code. The Named Credential takes in the URL of the callout we want to make and the necessary authentication credentials for an admin user. When the callout is being made, those credentials are used to authenticate and perform the callout.​

Executing anonymous Apex script relies on making a callout, and we need to avoid the error of ‘uncommitted changes in transaction…’ whenever trying to make a callout after any DMLs within the same transaction​ We execute the Apex script in a future call. ​
Configuration details – All configs are saved in custom object records like ProcessDef, Process Node etc. ​

We need a way to migrate these records across orgs and as well make them available for unit tests​

Fetch the records and save them as CSVs and upload them as static resources. The static resources can be migrated from one org to another using source control system.​

We can then have a command line data loader or an Apex class to read static resources and load records.​

.

If  you have any further questions about our Custom Approval Process, feel free to contact us – here for Anindya and here for Pavel. For more information about Bluewave and the bespoke Salesforce-powered digital transformations we are known for, feel free to get in touch with a member of our dedicated team today.