Skip to content

Commit f6c76e0

Browse files
committed
Merge pull request #1 from jamessimone/v0.0.1
v0.0.1 - Initial package version
2 parents c1d6813 + 9afd240 commit f6c76e0

25 files changed

+1498
-675
lines changed

.github/workflows/deploy.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Unique name for this workflow
2-
name: Rollup Release Status
2+
name: Round Robin Release Status
33

44
on:
55
push:

.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ debug.log
77
DEVHUB_SFDX_URL.txt
88
tests/apex
99
**/main/default/
10-
coverage/
10+
coverage/
11+
.vscode

.prettierignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sfdx-project.json

README.md

+84-1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,87 @@
33
[![Round Robin Release status](https://github.com/jamessimone/salesforce-round-robin/actions/workflows/deploy.yml/badge.svg?branch=main)](https://github.com/jamessimone/salesforce-round-robin/actions/workflows/deploy.yml 'Click to view deployment pipeline history')
44
[![License](https://img.shields.io/npm/l/scanner.svg)](https://github.com/jamessimone/salesforce-round-robin/blob/main/package.json)
55

6-
Easy, configurable round robin assigner!
6+
## Deployment
7+
8+
<a href="https://login.salesforce.com/packaging/installPackage.apexp?p0=04t6g000008SjNvAAK">
9+
<img alt="Deploy to Salesforce"
10+
src="./media/deploy-package-to-prod.png">
11+
</a>
12+
13+
<a href="https://test.salesforce.com/packaging/installPackage.apexp?p0=04t6g000008SjNvAAK">
14+
<img alt="Deploy to Salesforce Sandbox"
15+
src="./media/deploy-package-to-sandbox.png">
16+
</a>
17+
18+
<br/>
19+
<br/>
20+
21+
Between Assignment Rules and OmniChannel, there are plenty of out-of-the-box options for how records get owners assigned to them in Salesforce. As always, there are also many things that require customization. As it has frequently been the case that a "round robin" assignment be part of companies' business rules, Salesforce Round Robin aims to easily add support for round robin-type assignments to both Flow and Apex.
22+
23+
Here are some of the benefits to using this package:
24+
25+
- Ease of setup. All you have to supply (via Flow or Apex) is which records qualify to be part of the round robin (e.g. which records are part of the "ownership pool") and the records you'd like to have owners assigned
26+
- Speed and transactional safety - there are many pitfalls with naive round robin implementations, but the biggest one is unfair assignment (where some owners receive more records than others). This skew is typically caused by the tracking for how owners have been assigned getting out of date with how many records each person has already received. This package takes advantage of Salesforce's Platform Cache to offer truly fair assignment.
27+
28+
## Round Robin Assignment From Flow
29+
30+
Something to be aware of when using Flow as the starting point - until Apex Invocable actions are supported from the "before create/update" part of Flow, one should be aware of the possibility for recursion when making use of the bundled invocable action with Record-Triggered Flows. Put simply - because this action can only be run _after_ create/update presently, any _other_ parts of your Flow(s) may run twice.
31+
32+
Here's a basic look at what a simple Record Triggered Flow would look like using the action:
33+
34+
![Simple record-triggered flow](./media/round-robin-record-triggered-flow.png)
35+
36+
And then the configuration for the action:
37+
38+
![Setting the invocable properties](./media/round-robin-inside-action.png)
39+
40+
### Invocable Properties To Set
41+
42+
- `Object for "Records to round robin" (Input)` - for Record-Triggered Flows, this should correspond to the object the flow is for. For anything else, this should correspond to the SObject name of the record collection you're looking to pass in
43+
- `Alternative to query: API name of class that implements RoundRobinAssigner.IAssignmentRepo` (optional) - for Flow developers working with Apex (or with Apex developers), you may have more complicated business rules for which Users or other records qualify to be used in the assignment than the Query property can provide you with. For advanced users only!
44+
- `Owner Field API Name - defaults to OwnerId` (optional) - which field are you looking to assign to? Not all objects have `OwnerId` as a field (the detail side of M/D relationships, for instance). Use the field's API Name for these cases.
45+
- `Query Id Field - defaults to Id if not supplied` (optional) - used in conjunction with the `Query To Retrieve Possible Assignees` property, below. If you are using the round robin assigner to assign lookup fields _other_ than `OwnerId`, this allows you to override which field is pulled off of the records that the `Query To Retrieve Possible Assignees` returns.
46+
- `Query To Retrieve Possible Assignees` (optional) - either this or `Alternative to query ...` must be provided! This query will pull back records - like Users - and grab their `Id` field (or the field stipulated using the `Query Id Field ...` property) that should be included in the "ownership pool" for the given round robin.
47+
- `Records To Round Robin` (optional, but should always be supplied) - set this equal to a collection variable that is either the output of a `Get Records` call, contains `$Record` in a Record-Triggered flow, etc ...
48+
- `Update records - defaults to false` (optional) - by default, the collection supplied via the `Records To Round Robin` property aren't updated; set this to `{!$GlobalConstant.True}` to have the action update your records with their newly assigned owners
49+
50+
## Round Robin Assignment From Apex
51+
52+
You have quite a few options when it comes to performing round robin assignments from Apex. I would _highly recommend_ performing round robin assignments in the `BEFORE_UPDATE` Apex trigger context so that owners are assigned prior to being committed to the database.
53+
54+
Here are a few ways that you can perform assignments:
55+
56+
- You can re-use the static method `FlowRoundRobinAssigner.assign` by creating synthetic `FlowRoundRobinAssigner.FlowInput` records
57+
- You can call use the bundled `QueryAssigner`:
58+
59+
```java
60+
// in a Trigger/ trigger handler class
61+
RoundRobinAssigner.IAssignmentRepo queryRepo = new QueryAssigner('SELECT Id FROM User WHERE Some_Condition__c = true', 'Id');
62+
RoundRobinAssigner.Details assignmentDetails = new RoundRobinAssigner.Details();
63+
assignmentDetails.assignmentType = 'this is the cache key';
64+
new RoundRobinAssigner(queryRepo, assignmentDetails).assignOwners(someListOfSObjectsToBeAssigned);
65+
```
66+
67+
- Or you can supply an implementation of `RoundRobinAssigner.IAssignmentRepo` that retrieves the records that qualify for the ownership pool via the `getAssignmentIds` method:
68+
69+
```java
70+
// inside RoundRobinAssigner:
71+
public interface IAssignmentRepo {
72+
// note that the provided implementations of IAssignmentRepo
73+
// don't make use of the "assignmentType" argument passed here,
74+
// but this helps to decouple your logic for returning assignment Ids
75+
// in the event that you want to provide a single implementation that
76+
// can respond to multiple assignment types!
77+
List<Id> getAssignmentIds(String assignmentType);
78+
}
79+
```
80+
81+
The `assignmentType` property that comes from the invocable action (`FlowRoundRobinAssigner`) is always the `Object Type.Field Name`; so something like `Lead.OwnerId` for a typical Lead round robin.
82+
83+
Note that the records are _not_ updated by default in `RoundRobinAssigner`; if you are updating a related list of records (where updating them in a `BEFORE_UPDATED` context wouldn't just persist the updated ownership values by default), you should call `update` or `Database.update` on the records after calling the assigner.
84+
85+
## Additional Details & Architectural Notes
86+
87+
Again, this package employs the usage of Platform Cache to ensure fairness across competing transactions. This means that Platform Cache must be _enabled_ in your org in order to successfully install the round robin package. By default, it creates a 1 MB partition within your org cache. That should be plenty for most use-cases; if you have a ton of _different_ round robin assignments you may need to bump that amount up in:
88+
89+
- Setup -> Platform Cache -> Click "Edit" on the `RoundRobinCache` record -> Bump the amount to 2 under the `Provider Free` section

core/classes/AbstractCacheRepo.cls

+13-6
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@ public abstract class AbstractCacheRepo implements Cache.CacheBuilder {
44
}
55

66
public void updateCache(Object cachedItem) {
7-
this.getPartition().put(this.getCacheKey(), cachedItem);
7+
this.getPartition()?.put(this.getCacheKey(), cachedItem);
88
}
99

1010
protected abstract String getCachePartitionName();
1111
protected abstract String getCacheKey();
1212
protected abstract Object populateCache();
1313

1414
protected Object getFromCache() {
15-
Cache.OrgPartition partition = this.getPartition();
16-
return partition.get(this.getCacheBuilder(), this.getCacheKey());
15+
Object cachedItem = this.getPartition()?.get(this.getCacheBuilder(), this.getCacheKey());
16+
if (cachedItem == null) {
17+
// the item is only null when there's an issue with the packaging org not properly
18+
// creating the cache partition; in this case, we "know" what the value will be
19+
// and can manually load it
20+
cachedItem = this.populateCache();
21+
}
22+
return cachedItem;
1723
}
1824

1925
protected virtual Type getCacheBuilder() {
@@ -23,13 +29,14 @@ public abstract class AbstractCacheRepo implements Cache.CacheBuilder {
2329
return Type.forName(className);
2430
}
2531

26-
private Cache.OrgPartition getPartition() {
32+
@SuppressWarnings('PMD.EmptyCatchBlock')
33+
private Cache.OrgPartition getPartition() {
2734
Cache.OrgPartition partition;
2835
try {
29-
Cache.OrgPartition.validatePartitionName(this.getCachePartitionName());
3036
partition = Cache.Org.getPartition(this.getCachePartitionName());
3137
} catch (cache.Org.OrgCacheException orgCacheEx) {
32-
partition = new Cache.OrgPartition(this.getCachePartitionName());
38+
// do nothing - there seem to be some timing dependencies on when
39+
// it's possible to use Platform Cache while packaging.
3340
}
3441
return partition;
3542
}

core/classes/FlowRoundRobinAssigner.cls

+32-22
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
global without sharing class FlowRoundRobinAssigner {
22
@TestVisible
3-
RoundRobinAssigner.IAssignmentRepo stubAssignmentRepo;
3+
private static RoundRobinAssigner.IAssignmentRepo stubAssignmentRepo;
4+
@TestVisible
5+
private static Boolean hasBeenUpdated = false;
46

7+
private static final Set<Id> PROCESSED_RECORD_IDS = new Set<Id>();
58
private static final FlowRoundRobinAssigner SELF = new FlowRoundRobinAssigner();
69

710
global class FlowInput {
@@ -22,18 +25,40 @@ global without sharing class FlowRoundRobinAssigner {
2225
@InvocableMethod(category='Round Robin' label='Round robin records')
2326
global static void assign(List<FlowInput> flowInputs) {
2427
for (FlowInput input : flowInputs) {
25-
if (input.recordsToRoundRobin.isEmpty() == false) {
26-
SELF.validateInput(input);
27-
RoundRobinAssigner.IAssignmentRepo assignmentRepo = SELF.getAssignmentRepo(input);
28-
RoundRobinAssigner.Details assignmentDetails = SELF.getAssignmentDetails(input);
29-
new RoundRobinAssigner(assignmentRepo, assignmentDetails).assignOwners(input.recordsToRoundRobin);
28+
if (input.recordsToRoundRobin?.isEmpty() == false) {
29+
SELF.trackAssignedIds(input);
30+
SELF.roundRobin(input);
3031
}
3132
}
3233
}
3334

35+
private void roundRobin(FlowInput input) {
36+
this.validateInput(input);
37+
RoundRobinAssigner.IAssignmentRepo assignmentRepo = this.getAssignmentRepo(input);
38+
RoundRobinAssigner.Details assignmentDetails = this.getAssignmentDetails(input);
39+
new RoundRobinAssigner(assignmentRepo, assignmentDetails).assignOwners(input.recordsToRoundRobin);
40+
if (input.updateRecords) {
41+
update input.recordsToRoundRobin;
42+
hasBeenUpdated = true;
43+
}
44+
}
45+
3446
private void validateInput(FlowInput input) {
3547
if (String.isBlank(input.queryToRetrieveAssignees) && String.isBlank(input.assignmentRepoClassName)) {
36-
throw new IllegalArgumentException('Query To Retrieve Possible Assignees or API name of class implementing RoundRobinAssigner.IAssignment repo is required!');
48+
throw new IllegalArgumentException(
49+
'Query To Retrieve Possible Assignees or API name of class implementing RoundRobinAssigner.IAssignment repo is required!'
50+
);
51+
}
52+
}
53+
54+
private void trackAssignedIds(FlowInput input) {
55+
for (Integer reverseIndex = input.recordsToRoundRobin.size() - 1; reverseIndex >= 0; reverseIndex--) {
56+
SObject record = input.recordsToRoundRobin[reverseIndex];
57+
if (record.Id != null && PROCESSED_RECORD_IDS.contains(record.Id)) {
58+
input.recordsToRoundRobin.remove(reverseIndex);
59+
} else if (record.Id != null) {
60+
PROCESSED_RECORD_IDS.add(record.Id);
61+
}
3762
}
3863
}
3964

@@ -55,19 +80,4 @@ global without sharing class FlowRoundRobinAssigner {
5580
details.ownerField = input.ownerFieldApiName;
5681
return details;
5782
}
58-
59-
private without sharing class QueryAssigner implements RoundRobinAssigner.IAssignmentRepo {
60-
private final List<Id> validAssignmentIds;
61-
public QueryAssigner(String query, String assignmentFieldName) {
62-
Set<Id> assignmentIds = new Set<Id>();
63-
List<SObject> matchingRecords = Database.query(query);
64-
for (SObject matchingRecord : matchingRecords) {
65-
assignmentIds.add((Id) matchingRecord.get(assignmentFieldName));
66-
}
67-
this.validAssignmentIds = new List<Id>(assignmentIds);
68-
}
69-
public List<Id> getAssignmentIds(String assignmentType) {
70-
return this.validAssignmentIds;
71-
}
72-
}
7383
}

core/classes/FlowRoundRobinAssignerTests.cls

+3-1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ private class FlowRoundRobinAssignerTests {
4949
static void updatesRecordsWhenFlagIsPassed() {
5050
ContactPointAddress cpa = new ContactPointAddress(Name = 'updatesRecordsWhenFlagIsPassed');
5151
insert cpa;
52+
cpa.OwnerId = null;
5253

5354
FlowRoundRobinAssigner.FlowInput input = new FlowRoundRobinAssigner.FlowInput();
5455
input.updateRecords = true;
@@ -57,6 +58,7 @@ private class FlowRoundRobinAssignerTests {
5758

5859
FlowRoundRobinAssigner.assign(new List<FlowRoundRobinAssigner.FlowInput>{ input });
5960

60-
System.assertEquals(UserInfo.getUserId(), [SELECT OwnerId FROM ContactPointAddress WHERE Id = :cpa.Id].OwnerId);
61+
System.assertEquals(true, FlowRoundRobinAssigner.hasBeenUpdated);
62+
System.assertEquals(UserInfo.getUserId(), cpa.OwnerId);
6163
}
6264
}

core/classes/QueryAssigner.cls

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
public without sharing class QueryAssigner implements RoundRobinAssigner.IAssignmentRepo {
2+
private final List<Id> validAssignmentIds;
3+
4+
public QueryAssigner(String query, String assignmentFieldName) {
5+
Set<Id> assignmentIds = new Set<Id>();
6+
List<SObject> matchingRecords = Database.query(query);
7+
for (SObject matchingRecord : matchingRecords) {
8+
assignmentIds.add((Id) matchingRecord.get(assignmentFieldName));
9+
}
10+
this.validAssignmentIds = new List<Id>(assignmentIds);
11+
}
12+
13+
public List<Id> getAssignmentIds(String assignmentType) {
14+
return this.validAssignmentIds;
15+
}
16+
}
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<apiVersion>54.0</apiVersion>
4+
<status>Active</status>
5+
</ApexClass>

core/classes/RoundRobinAssigner.cls

+3
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ public without sharing class RoundRobinAssigner implements IThreadSafeCacheVisit
5454
}
5555

5656
private Integer getNextAssignmentIndex(List<Id> assignmentIds, RoundRobin__c cachedAssignment) {
57+
if (cachedAssignment.Index__c == null && assignmentIds.isEmpty() == false) {
58+
return 0;
59+
}
5760
Integer currentAssignmentIndex = SENTINEL_INDEX;
5861
for (Integer index = 0; index < assignmentIds.size(); index++) {
5962
Id assignmentId = assignmentIds[index];

core/classes/RoundRobinRepository.cls

+29-12
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
public without sharing class RoundRobinRepository extends AbstractCacheRepo {
22
private static Map<String, RoundRobin__c> CACHED_ASSIGNMENTS;
3-
private static final String SENTINEL_USER_INDEX = getSentinelIndex();
3+
private static final String SENTINEL_OWNER_INDEX = getSentinelIndex();
44

55
@SuppressWarnings('PMD.ApexCRUDViolation')
66
public void accept(IThreadSafeCacheVisitor visitor, List<SObject> records) {
@@ -10,8 +10,6 @@ public without sharing class RoundRobinRepository extends AbstractCacheRepo {
1010
this.forceRefreshCache();
1111
this.accept(visitor, records);
1212
}
13-
currentAssignment.LastUpdated__c = System.now();
14-
upsert currentAssignment;
1513
}
1614

1715
/** AbstractCacheRepo overrides */
@@ -48,21 +46,40 @@ public without sharing class RoundRobinRepository extends AbstractCacheRepo {
4846
this.updateCache(CACHED_ASSIGNMENTS);
4947
}
5048

51-
private Boolean commitUpdatedAssignment(RoundRobin__c updatedAssignment) {
49+
@SuppressWarnings('PMD.ApexCRUDViolation')
50+
private Boolean commitUpdatedAssignment(RoundRobin__c assignment) {
5251
Boolean wasCommitSuccessful = true;
5352
Map<String, RoundRobin__c> currentCache = this.getCachedAssignments();
5453
if (
55-
currentCache.containsKey(updatedAssignment.Name) &&
56-
currentCache.get(updatedAssignment.Name).LastUpdated__c > CACHED_ASSIGNMENTS.get(updatedAssignment.Name).LastUpdated__c
54+
currentCache.containsKey(assignment.Name) &&
55+
currentCache.get(assignment.Name).LastUpdated__c > CACHED_ASSIGNMENTS.get(assignment.Name).LastUpdated__c
5756
) {
58-
updatedAssignment = currentCache.get(updatedAssignment.Name);
57+
assignment = currentCache.get(assignment.Name);
5958
wasCommitSuccessful = false;
6059
} else {
61-
updatedAssignment.LastUpdated__c = System.now();
62-
upsert updatedAssignment;
60+
assignment.LastUpdated__c = System.now();
61+
/**
62+
* integration tests with after save Flows have shown something unfortunate:
63+
* though the second (recursive) call to the assigner is spawned in a second transaction
64+
* the RoundRobin__c.getAll() still doesn't contain the Id of the inserted record (for the times where the assignment
65+
* is being run for the first time).
66+
* That means that we can't just call "upsert", and instead have to do this goofy
67+
* song and dance to ensure the Id is appended correctly
68+
*/
69+
if (assignment.Id == null) {
70+
List<RoundRobin__c> existingAssignments = [SELECT Id FROM RoundRobin__c WHERE Name = :assignment.Name];
71+
if (existingAssignments.isEmpty() == false) {
72+
assignment.Id = existingAssignments[0].Id;
73+
}
74+
}
75+
if (assignment.Id != null) {
76+
update assignment;
77+
} else {
78+
insert assignment;
79+
}
6380
}
6481

65-
CACHED_ASSIGNMENTS.put(updatedAssignment.Name, updatedAssignment);
82+
CACHED_ASSIGNMENTS.put(assignment.Name, assignment);
6683
return wasCommitSuccessful;
6784
}
6885

@@ -77,7 +94,7 @@ public without sharing class RoundRobinRepository extends AbstractCacheRepo {
7794
Name = assignmentType,
7895
// some sentinel value
7996
LastUpdated__c = Datetime.newInstanceGmt(1970, 1, 1),
80-
Index__c = SENTINEL_USER_INDEX
97+
Index__c = SENTINEL_OWNER_INDEX
8198
)
8299
);
83100
}
@@ -87,6 +104,6 @@ public without sharing class RoundRobinRepository extends AbstractCacheRepo {
87104
}
88105

89106
private static String getSentinelIndex() {
90-
return User.SObjectType.getDescribe().getKeyPrefix() + '0'.repeat(12);
107+
return null;
91108
}
92109
}

core/classes/RoundRobinRepositoryTests.cls

+17-1
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,29 @@ private class RoundRobinRepositoryTests {
1717
);
1818
}
1919

20+
@IsTest
21+
static void avoidsRecursiveUpdateIssues() {
22+
Datetime someTimeAgo = System.now().addDays(-3);
23+
upsert new RoundRobin__c(LastUpdated__c = someTimeAgo, Name = cacheKey);
24+
RoundRobinRepository repo = new RoundRobinRepository();
25+
26+
Lead firstLead = new Lead();
27+
Lead secondLead = new Lead();
28+
29+
repo.accept(new VisitorMock(), new List<SObject>{ firstLead });
30+
repo.accept(new VisitorMock(), new List<SObject>{ secondLead });
31+
32+
RoundRobin__c updatedAssignment = [SELECT Id, Index__c FROM RoundRobin__c];
33+
System.assertEquals(UserInfo.getUserId(), updatedAssignment.Index__c);
34+
}
35+
2036
private class VisitorMock implements IThreadSafeCacheVisitor {
2137
public String getVisitKey() {
2238
return cacheKey;
2339
}
2440
@SuppressWarnings('PMD.EmptyStatementBlock')
2541
public void visitRecords(List<SObject> records, SObject currentCacheRecord) {
26-
// no-op
42+
currentCacheRecord.put('Index__c', UserInfo.getUserId());
2743
}
2844
}
2945
}

0 commit comments

Comments
 (0)