Class Name: SubscriptionTriggerHandler¶
Last Updated: 2025-10-22 Source Code: https://github.com/AANP-IT/I2C.Salesforce.Metadata/blob/STAGING/force-app/main/default/classes/SubscriptionTriggerHandler.cls
API Name: SubscriptionTriggerHandler Type: Trigger Handler Test Coverage: To be determined
Business Purpose¶
The SubscriptionTriggerHandler class manages subscription-related trigger logic, specifically synchronizing Higher Logic community access based on active subscriptions. This supports:
- Automatic community product access when subscriptions are created/updated
- Syncing Account.Higher_Logic_Communities__c field with community product subscriptions
- Queueable async processing to avoid trigger governor limits
- Recursion control via static flag
This ensures that customers automatically gain access to community products they've subscribed to.
Class Overview¶
- Author: Not specified
- Created: Unknown
- Test Class: SubscriptionTriggerHandlerTest (likely)
- Scope/Sharing:
without sharing- Bypasses record-level security - Key Responsibilities:
- Queue async community sync jobs
- Update Account.Higher_Logic_Communities__c with community product names
- Set HL__c checkbox flag when communities exist
- Prevent recursion with static flag
Security Warning¶
🚨 CRITICAL CONCERN: Uses without sharing which bypasses all record-level security.
Implications:
- Can read/write ANY subscription records regardless of user access
- Can update ANY account records
- Violates principle of least privilege
- Should use with sharing unless specific business requirement
Justification Needed:
- Document why without sharing is required
- Consider if system context is truly necessary
- If needed for system automation, add comments explaining why
Static Variables¶
Purpose: Prevents recursion when queueing jobs (line 3).
Pattern: Common recursion control pattern in triggers.
Issues/Concerns:
- ✅ Recursion Prevention: Prevents infinite loops
- ⚠️ Name: alreadyRan is vague - syncCommunitiesExecuted would be clearer
- ⚠️ Scope: public allows external code to manipulate flag
- Should be private or @TestVisible private
Public Methods¶
syncCommunities¶
Purpose: Queues asynchronous job to sync communities for subscription changes.
Parameters:
- subscriptionNewMap (Map
Business Logic (lines 5-12):
if(!alreadyRan){
alreadyRan = true;
I2C_QueueableCommunitiesSync q = new I2C_QueueableCommunitiesSync(subscriptionNewMap);
System.enqueueJob(q);
}
Flow: 1. Check if already executed in this transaction 2. Set flag to true 3. Create queueable job with subscription map 4. Enqueue for async execution
Caller: Likely called from Subscription__c trigger (SubscriptionTrigger).
Issues/Concerns:
- ✅ Recursion Control: Prevents multiple queue jobs in same transaction
- ✅ Async Pattern: Uses queueable to avoid trigger governor limits
- ⚠️ No Error Handling: enqueueJob could fail if queue limit reached
- Should wrap in try-catch
- ⚠️ No Null Check: Assumes subscriptionNewMap is not null
- ⚠️ Flag Never Resets: Once true, stays true for entire transaction
- This is correct for trigger context but could cause issues if called multiple times
updateAccountCommunities¶
public static void updateAccountCommunities(Map<Id, List<Id>> accIdToListProdIdMap, Set<Id> productIdSet)
Purpose: Updates Account.Higher_Logic_Communities__c with community product names from subscriptions.
Parameters:
- accIdToListProdIdMap (MapproductIdSet (Set
Business Logic:
- Query Products and Accounts (lines 15-16):
- Filters products by Family = 'Communities'
-
Queries accounts with existing Higher_Logic_Communities__c values
-
Build Community String (lines 20-38):
for(Id accId: accIdToListProdIdMap.keySet()){ Account acc = accountQuery.get(accId); Boolean updateCommunities = false; acc.Higher_Logic_Communities__c = ''; // ⚠️ Clears existing value! for(Id productId: accIdToListProdIdMap.get(accId)){ Boolean notACommunityProduct = !productQuery.containsKey(productId); if(notACommunityProduct){continue;} String productName = productQuery.get(productId).Name; Boolean isCommunitiesEmpty = String.isBlank(acc.Higher_Logic_Communities__c); Boolean productNotFoundInString = isCommunitiesEmpty || !acc.Higher_Logic_Communities__c?.contains(productName); if(productNotFoundInString){ if(isCommunitiesEmpty){ acc.Higher_Logic_Communities__c = productName; } else { acc.Higher_Logic_Communities__c += ';' + productName; } } } acc.HL__c = !String.isBlank(acc.Higher_Logic_Communities__c); accToUpdate.add(acc); }
Key Operations:
- Line 23: Clears existing Higher_Logic_Communities__c
- Loops through each account's product subscriptions
- Skips products not in 'Communities' family
- Builds semicolon-separated list of product names
- Uses .contains() to check for duplicates
- Sets HL__c checkbox flag if communities exist
- Update Accounts (lines 43-45):
Issues/Concerns:
- 🚨 CRITICAL - Line 23:
acc.Higher_Logic_Communities__c = '';CLEARS EXISTING VALUE - This removes all previous communities before rebuilding
- If
accIdToListProdIdMaponly contains partial data, existing communities are lost - Impact: User could lose access to communities they should have
-
Fix: Only clear if rebuilding complete community list
-
⚠️ Substring Matching Issue (line 30):
- If productName = "Women" and field contains "Women's Health", contains() returns true
- Could cause false positive for duplicate detection
-
Fix: Split field by ';' and check for exact matches in list
-
⚠️ Unused Variable (line 22):
Boolean updateCommunities = false;- Never used -
⚠️ No Error Handling: Update could fail silently in WITHOUT SHARING context
-
⚠️ No Transaction Safety: All-or-nothing update, no partial success
-
⚠️ Field Name: Higher_Logic_Communities__c is multi-select picklist or text field?
- If picklist: Semicolon-separated is correct
-
If text: Should validate against picklist values
-
✅ Null Safety: Uses safe navigation operator
?.(line 30) -
✅ Bulk Pattern: Queries and updates in bulk
Caller: I2C_QueueableCommunitiesSync¶
Based on line 8, this handler is called by I2C_QueueableCommunitiesSync:
I2C_QueueableCommunitiesSync q = new I2C_QueueableCommunitiesSync(subscriptionNewMap);
System.enqueueJob(q);
Expected Flow:
1. Subscription trigger fires (insert/update)
2. Trigger calls SubscriptionTriggerHandler.syncCommunities(Trigger.newMap)
3. Handler queues I2C_QueueableCommunitiesSync job
4. Queueable executes async:
- Queries active subscriptions per account
- Builds account → product list map
- Calls updateAccountCommunities() to sync
Dependencies¶
Salesforce Objects¶
- Subscription__c (Custom Object)
- Access: Read (from queueable)
-
Used to determine active subscriptions
-
Account (Standard Object)
- Fields:
Id,Higher_Logic_Communities__c,HL__c -
Access: Read, Update (WITHOUT SHARING)
-
Product2 (Standard Object)
- Fields:
Id,Name,Family - Access: Read
- Filter: Family = 'Communities'
Other Classes¶
- I2C_QueueableCommunitiesSync: Queueable job for async processing
- Constructor: Takes Map
- Calls: updateAccountCommunities()
Custom Fields¶
- Account.Higher_Logic_Communities__c - Semicolon-separated list of community product names
- Account.HL__c - Checkbox indicating if account has communities
Design Patterns¶
- Static Trigger Handler: Static methods called from trigger
- Queueable Pattern: Async processing to avoid trigger limits
- Recursion Control: Static boolean flag
- Bulk Processing: Handles multiple accounts/products at once
- Map-Based Queries: Efficient lookups via Map
Governor Limits Considerations¶
syncCommunities Method¶
- Queueable Jobs: 1 per invocation (limit: 50 per transaction)
- SOQL Queries: 0 (queries happen in queueable)
updateAccountCommunities Method¶
- SOQL Queries: 2 (Product2, Account)
- DML Statements: 1 (Account update)
- DML Rows: Number of accounts in map
Scalability¶
- ✅ Bulk Pattern: Handles multiple accounts at once
- ✅ Queueable: Avoids trigger timeout
- ⚠️ String Concatenation: Higher_Logic_Communities__c could exceed field length
- ⚠️ No Chunking: Large account sets could hit heap size limits
Test Class Requirements¶
@IsTest
public class SubscriptionTriggerHandlerTest {
@TestSetup
static void setup() {
// Create account
Account acc = TestDataFactory.getAccountRecord(true);
// Create community products
Product2 communityProd1 = TestDataFactory.getProduct(true);
communityProd1.Name = 'Pediatrics Community';
communityProd1.Family = 'Communities';
update communityProd1;
Product2 communityProd2 = TestDataFactory.getProduct(true);
communityProd2.Name = 'Women\'s Health Community';
communityProd2.Family = 'Communities';
update communityProd2;
// Create subscriptions
Subscription__c sub1 = TestDataFactory.createSubscription(acc.Id, communityProd1.Id);
sub1.Status__c = 'Active';
insert sub1;
Subscription__c sub2 = TestDataFactory.createSubscription(acc.Id, communityProd2.Id);
sub2.Status__c = 'Active';
insert sub2;
}
@IsTest
static void testSyncCommunities() {
Subscription__c sub = [SELECT Id FROM Subscription__c LIMIT 1];
Map<Id, Subscription__c> subMap = new Map<Id, Subscription__c>{sub.Id => sub};
// Reset flag for test
SubscriptionTriggerHandler.alreadyRan = false;
Test.startTest();
SubscriptionTriggerHandler.syncCommunities(subMap);
Test.stopTest();
// Verify job was queued
Assert.isTrue(SubscriptionTriggerHandler.alreadyRan, 'Flag should be set');
// Check AsyncApexJob
List<AsyncApexJob> jobs = [
SELECT Id, Status, ApexClass.Name
FROM AsyncApexJob
WHERE ApexClass.Name = 'I2C_QueueableCommunitiesSync'
];
Assert.areEqual(1, jobs.size(), 'Should queue one job');
}
@IsTest
static void testUpdateAccountCommunities() {
Account acc = [SELECT Id, Higher_Logic_Communities__c, HL__c FROM Account LIMIT 1];
List<Product2> products = [SELECT Id FROM Product2 WHERE Family = 'Communities'];
Set<Id> productIdSet = new Set<Id>();
List<Id> productIdList = new List<Id>();
for (Product2 p : products) {
productIdSet.add(p.Id);
productIdList.add(p.Id);
}
Map<Id, List<Id>> accToProductMap = new Map<Id, List<Id>>{
acc.Id => productIdList
};
Test.startTest();
SubscriptionTriggerHandler.updateAccountCommunities(accToProductMap, productIdSet);
Test.stopTest();
Account updatedAcc = [SELECT Id, Higher_Logic_Communities__c, HL__c FROM Account WHERE Id = :acc.Id];
Assert.isTrue(updatedAcc.HL__c, 'HL checkbox should be checked');
Assert.isTrue(updatedAcc.Higher_Logic_Communities__c.contains('Pediatrics Community'), 'Should contain first community');
Assert.isTrue(updatedAcc.Higher_Logic_Communities__c.contains('Women\'s Health Community'), 'Should contain second community');
// Verify semicolon separator
List<String> communities = updatedAcc.Higher_Logic_Communities__c.split(';');
Assert.areEqual(2, communities.size(), 'Should have 2 communities');
}
@IsTest
static void testRecursionPrevention() {
Subscription__c sub = [SELECT Id FROM Subscription__c LIMIT 1];
Map<Id, Subscription__c> subMap = new Map<Id, Subscription__c>{sub.Id => sub};
// Reset flag
SubscriptionTriggerHandler.alreadyRan = false;
Test.startTest();
SubscriptionTriggerHandler.syncCommunities(subMap);
SubscriptionTriggerHandler.syncCommunities(subMap); // Second call should do nothing
Test.stopTest();
// Only one job should be queued
List<AsyncApexJob> jobs = [
SELECT Id, Status, ApexClass.Name
FROM AsyncApexJob
WHERE ApexClass.Name = 'I2C_QueueableCommunitiesSync'
];
Assert.areEqual(1, jobs.size(), 'Should only queue one job despite two calls');
}
}
Pre-Go-Live Concerns¶
🚨 CRITICAL¶
- WITHOUT SHARING (line 1): Bypasses all record-level security
- Document business justification
- Consider if
with sharingis possible -
If required, add extensive comments explaining why
-
Clears Existing Communities (line 23):
acc.Higher_Logic_Communities__c = '' - Loses existing communities if map is partial
- Fix: Only clear if rebuilding complete list, or append/remove specific communities
- Risk: Data loss, user access revoked incorrectly
HIGH¶
- Substring Matching Bug (line 30):
.contains()for duplicate check - False positives possible (e.g., "Women" matches "Women's Health")
-
Fix: Split by ';' and check for exact matches in list
-
No Error Handling: Updates could fail silently
- Add try-catch and logging
- Consider partial success with Database.update(records, false)
MEDIUM¶
- Static Flag Scope (line 3):
public static Boolean alreadyRan - Should be
privateor@TestVisible private -
Prevents external manipulation
-
Unused Variable (line 22):
updateCommunitiesnever used -
Remove dead code
-
No Null Checks: Assumes parameters are valid
- Validate
subscriptionNewMap,accIdToListProdIdMap,productIdSet
LOW¶
- Variable Naming:
alreadyRanis vague - Rename to
syncCommunitiesExecutedorisQueuedForCommunitySync
Recommended Fixes¶
Fix 1: Additive Community Sync (Don't Clear Existing)¶
public static void updateAccountCommunities(Map<Id, List<Id>> accIdToListProdIdMap, Set<Id> productIdSet){
Map<Id, Product2> productQuery = new Map<Id, Product2>([SELECT Id, Name FROM Product2 WHERE Id IN: productIdSet AND Family = 'Communities']);
Map<Id, Account> accountQuery = new Map<Id, Account>([SELECT Id, Higher_Logic_Communities__c FROM Account WHERE Id IN: accIdToListProdIdMap.keySet()]);
List<Account> accToUpdate = new List<Account>();
for(Id accId: accIdToListProdIdMap.keySet()){
Account acc = accountQuery.get(accId);
// Parse existing communities
Set<String> existingCommunities = new Set<String>();
if (String.isNotBlank(acc.Higher_Logic_Communities__c)) {
existingCommunities.addAll(acc.Higher_Logic_Communities__c.split(';'));
}
// Add new communities
for(Id productId: accIdToListProdIdMap.get(accId)){
if(!productQuery.containsKey(productId)){continue;}
String productName = productQuery.get(productId).Name;
existingCommunities.add(productName); // Set automatically handles duplicates
}
// Rebuild field
acc.Higher_Logic_Communities__c = String.join(new List<String>(existingCommunities), ';');
acc.HL__c = !existingCommunities.isEmpty();
accToUpdate.add(acc);
}
if(!accToUpdate.isEmpty()){
try {
update accToUpdate;
} catch (Exception e) {
System.debug(LoggingLevel.ERROR, 'Failed to update account communities: ' + e.getMessage());
// Consider logging to custom object
}
}
}
Fix 2: Use WITH SHARING¶
public with sharing class SubscriptionTriggerHandler {
@TestVisible
private static Boolean syncCommunitiesExecuted = false;
public static void syncCommunities(Map<Id, Subscription__c> subscriptionNewMap){
if(subscriptionNewMap == null || subscriptionNewMap.isEmpty()) {
return;
}
if(!syncCommunitiesExecuted){
syncCommunitiesExecuted = true;
try {
I2C_QueueableCommunitiesSync q = new I2C_QueueableCommunitiesSync(subscriptionNewMap);
System.enqueueJob(q);
} catch (Exception e) {
System.debug(LoggingLevel.ERROR, 'Failed to enqueue community sync: ' + e.getMessage());
}
}
}
}
Changes & History¶
| Date | Author | Description |
|---|---|---|
| Unknown | Original Developer | Initial implementation for Higher Logic community sync |
Documentation Status: ✅ Complete Code Review Status: 🚨 CRITICAL - Fix data loss bug (line 23), justify WITHOUT SHARING Test Coverage: Test class needed Trigger Integration: Called from Subscription__c trigger Queueable Dependency: I2C_QueueableCommunitiesSync Security Risk: WITHOUT SHARING bypasses all record security