Clone CampaignMembers with a Flow using Invocable Apex

In our last post, we showed how to create a simple Apex Action (invocable class) that can be called from a Flow to do things that are handled most efficiently in Apex. This post will focus on building a more complex Apex Action that will let us clone all the CampaignMembers (up to 10k) from one campaign into another. This functionality isn't built into Salesforce, and that absence has been an ongoing source of pain for nearly a decade. Let's build a solution that looks like this:


This is a screen flow that can be placed in a tab on a Campaign record page. In this example, it's on a Campaign record called January, so that Campaign name is automatically filled into the Target Campaign variable, which we'll need to pass along to the Apex Action.

The user will have a pulldown menu to select whether to clone all members, members with SENT status, or members with RESPONDED status. That's a second parameter to pass to Apex.

The user will use a lookup field to select the Source Campaign, from which to clone members. That's a third parameter.

Finally the user will select how to set the Status of the new Campaign Members. Choices are SENT, RESPONDED, or an option to copy the status from the source campaign. 

Here's what the Flow looks like.
That Apex Action element has fields to provide those parameters we listed:



That's four parameters in all to pass from the Flow into the Apex Action, but the documentation says "there can be at most one input parameter...". Since invocable apex must be bulkified, that input parameter needs to be a list of something, and luckily, we can define that something by creating a request class. A request class is an Apex class that has no methods, but it does have invocable variables (explicitly declared) that will be visible to the Flow, so you need to specify a label. 


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class CampaignMemberClonerRequest {
    @InvocableVariable(label='Members to Include' required=true)
    public String statusFilter;

    @InvocableVariable(label='Set New Member Status' required=true)
    public String memberStatus;

    @InvocableVariable(label='Source Campaign' required=true)
    public Campaign sourceCampaign;

    @InvocableVariable(label='Target Campaign' required=true)
    public Campaign targetCampaign;
}

With the request class built, we'll be passing in a List<CampaignMemberClonerRequest> as the single parameter for our invocable method. Here's that class, with explanation following.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public class CampaignMemberCloner {
    
    @InvocableMethod(label='Clone Campaign Members')
    public static list<String> cloneCampaignMembersBatch(List<CampaignMemberClonerRequest> requests){
        List<String> statusMessages = new List<String>();
        
        //STEP THROUGH THE LIST OF REQUESTS, CALLING cloneCampaignMembers(request)
        //RETURNING STATUS MESSAGE
        for(CampaignMemberClonerRequest request : requests){
            statusMessages.add(cloneCampaignMembers(request));
        }
        return statusMessages;
    }
    
    private static String cloneCampaignMembers(CampaignMemberClonerRequest request){
        String statusMessage = '';
        //BUILD THE DYNAMIC QUERY
        String countQuery = 'SELECT Count() FROM CampaignMember WHERE CampaignId = '';
        countQuery += request.sourceCampaign.Id + '\' ';
        String memberQuery = 'SELECT Id, CampaignId, ContactId, LeadId, Status ';
        memberQuery += 'FROM CampaignMember WHERE CampaignId = '';
        memberQuery += request.sourceCampaign.Id + '\' ';
        if(request.statusFilter != 'All'){
            countQuery += 'AND Status = '' + request.statusFilter + '\' '; 
            memberQuery += 'AND Status = '' + request.statusFilter + '\' '; 
        }
        
        //CHECK TO MAKE SURE THERE ARE FEWER THAN 10K CAMPAIGNMEMBERS IN SOURCE CAMPAIGN
        //AND THE SOURCE CAMPAIGN != THE TARGET CAMPAIGN
        Integer sourceCmCount = Database.countQuery(countQuery);
        String errorMessage = '';
        errorMessage = 'The source campaign ' + request.sourceCampaign.Name;
        errorMessage += ' has ' + sourceCmCount + ' campaign members, ';
        errorMessage += 'which exceeds the limit of 10,000 for this clone operation.';
        if(sourceCmCount > 10000){
            return errorMessage;
        }else if(request.sourceCampaign.Id == request.targetCampaign.Id){
            statusMessage = 'Sorry, you cannot clone members from the target campaign. ';
            statusMessage += 'Select a different Source Campaign.';
            return statusMessage;
        }else{
            List<CampaignMember> targetCmList = new List<CampaignMember>();
            
            //GET THE MEMBERS FROM THE SOURCE CAMPAIGN (USING THE DYNAMIC SOQL QUERY)
            for(CampaignMember sourceCm : Database.query(memberQuery)){
                
                //BUILD A LIST OF CAMPAIGNMEMBERS FOR OUR TARGET CAMPAIGN
                CampaignMember newCm = new CampaignMember();
                newCm.CampaignId = request.targetCampaign.Id;
                newCm.ContactId = (sourceCm.ContactId != null) ? sourceCm.ContactId : null;
                newCm.LeadId = (sourceCm.LeadId != null) ? sourceCm.LeadId : null;
                if(request.memberStatus == 'Copy Source'){
                    newCm.Status = sourceCm.Status;
                }else{
                    newCm.Status = request.memberStatus;
                }
                targetCmList.add(newCm);
            }
            //INSERT THE LIST OF CAMPAIGNMEMBERS FOR OUR TARGET CAMPAIGN
            try{
                insert targetCmList;
            }   catch(dmlException e){
                return 'Error saving CampaignMembers: ' + e.getDmlMessage(0);
            }
            
            //RETURN A STATUS MESSAGE
            statusMessage = 'Created ' + targetCmList.size() + ' CampaignMembers on ';
            statusMessage += request.targetCampaign.Name + ' cloned from '; 
            statusMessage += request.sourceCampaign.Name + '.';
            return statusMessage; 
        }
    }
}


In this class, our invocable method is cloneCampaignMembersBatch() and it loops through the list of CampaignMemberClonerRequests, passes each request to the logic in the cloneCampaignMembers() method, and returns a status message to the flow in the form of a List<String>. (This is another example of how the bulkification is explicit in the Apex, but the flow just treats it as a single value.)

In the cloneCampaignMembers() method, lines 16-26 use the parameters in the CampaignMemberClonerRequest (these are the values that the user selected in the Flow) to build a dynamic SOQL query, as well as a countQuery that we'll use to check how many campaign members there are.

Line 30 runs that countQuery, and lines 31-41 do some error checking to make sure that we have fewer than 10K campaign members (so that we don't hit org limits), and to check that our Source Campaign is not the same as our Target Campaign.  If we make it past these checks, we can move forward into cloning the CampaignMember records.

Lines 45-58 run the query to find the CampaignMembers from our SourceCampaign and create new CampaignMember clones for the TargetCampaign. Note that line 45 uses a for-loop with the query as the collection variable. Structuring the query this way automatically lets Apex access and process these records in batches of 200, so we don't hit a heap size limit for returning too many records. (More info about SOQL For Loops)

Lines 60-64, we use a try-catch statement to insert our new CampaignMembers, then lines 67-70 construct and return the status message to the Flow.

DOWNLOAD

All of the code (along with the test class) and the Flow for the CampaignMemberCloner is available for installation as an unmanaged packed. 

Note: this post is derived from a Dreamforce '19 presentation by Pat McClellan and Donald Bohrisch.




Comments