@lead-routing/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/index.js +1916 -0
  2. package/dist/prisma/migrations/20260101000000_init/migration.sql +276 -0
  3. package/dist/prisma/migrations/20260223000000_add_routing_log_dismissed/migration.sql +2 -0
  4. package/dist/prisma/migrations/20260224000000_add_org_notification_webhook/migration.sql +2 -0
  5. package/dist/prisma/migrations/20260227000000_self_hosted_schema_updates/migration.sql +68 -0
  6. package/dist/prisma/schema.prisma +315 -0
  7. package/dist/sfdc-package/force-app/main/default/applications/Lead_Router_Setup.app-meta.xml +12 -0
  8. package/dist/sfdc-package/force-app/main/default/classes/AccountTriggerTest.cls +58 -0
  9. package/dist/sfdc-package/force-app/main/default/classes/AccountTriggerTest.cls-meta.xml +5 -0
  10. package/dist/sfdc-package/force-app/main/default/classes/ContactTriggerTest.cls +62 -0
  11. package/dist/sfdc-package/force-app/main/default/classes/ContactTriggerTest.cls-meta.xml +5 -0
  12. package/dist/sfdc-package/force-app/main/default/classes/LeadTriggerTest.cls +95 -0
  13. package/dist/sfdc-package/force-app/main/default/classes/LeadTriggerTest.cls-meta.xml +5 -0
  14. package/dist/sfdc-package/force-app/main/default/classes/OnboardingController.cls +183 -0
  15. package/dist/sfdc-package/force-app/main/default/classes/OnboardingController.cls-meta.xml +5 -0
  16. package/dist/sfdc-package/force-app/main/default/classes/RoutingEngineCallout.cls +96 -0
  17. package/dist/sfdc-package/force-app/main/default/classes/RoutingEngineCallout.cls-meta.xml +5 -0
  18. package/dist/sfdc-package/force-app/main/default/classes/RoutingEngineMock.cls +28 -0
  19. package/dist/sfdc-package/force-app/main/default/classes/RoutingEngineMock.cls-meta.xml +5 -0
  20. package/dist/sfdc-package/force-app/main/default/classes/RoutingPayloadBuilder.cls +50 -0
  21. package/dist/sfdc-package/force-app/main/default/classes/RoutingPayloadBuilder.cls-meta.xml +5 -0
  22. package/dist/sfdc-package/force-app/main/default/lwc/onboardingWizard/onboardingWizard.html +230 -0
  23. package/dist/sfdc-package/force-app/main/default/lwc/onboardingWizard/onboardingWizard.js +222 -0
  24. package/dist/sfdc-package/force-app/main/default/lwc/onboardingWizard/onboardingWizard.js-meta.xml +11 -0
  25. package/dist/sfdc-package/force-app/main/default/namedCredentials/RoutingEngine.namedCredential-meta.xml +10 -0
  26. package/dist/sfdc-package/force-app/main/default/objects/Routing_Error_Log__c/Routing_Error_Log__c.object-meta.xml +21 -0
  27. package/dist/sfdc-package/force-app/main/default/objects/Routing_Error_Log__c/fields/Created_At__c.field-meta.xml +6 -0
  28. package/dist/sfdc-package/force-app/main/default/objects/Routing_Error_Log__c/fields/Payload__c.field-meta.xml +8 -0
  29. package/dist/sfdc-package/force-app/main/default/objects/Routing_Error_Log__c/fields/Response_Body__c.field-meta.xml +8 -0
  30. package/dist/sfdc-package/force-app/main/default/objects/Routing_Error_Log__c/fields/Status_Code__c.field-meta.xml +9 -0
  31. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/Routing_Settings__c.object-meta.xml +8 -0
  32. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Account_Insert_Enabled__c.field-meta.xml +7 -0
  33. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Account_Routing_Enabled__c.field-meta.xml +7 -0
  34. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Account_Update_Enabled__c.field-meta.xml +7 -0
  35. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/App_Url__c.field-meta.xml +8 -0
  36. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Contact_Insert_Enabled__c.field-meta.xml +7 -0
  37. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Contact_Routing_Enabled__c.field-meta.xml +7 -0
  38. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Contact_Update_Enabled__c.field-meta.xml +7 -0
  39. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Engine_Endpoint__c.field-meta.xml +8 -0
  40. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Lead_Insert_Enabled__c.field-meta.xml +7 -0
  41. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Lead_Routing_Enabled__c.field-meta.xml +7 -0
  42. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Lead_Update_Enabled__c.field-meta.xml +7 -0
  43. package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Webhook_Secret__c.field-meta.xml +8 -0
  44. package/dist/sfdc-package/force-app/main/default/permissionsets/LeadRouterAdmin.permissionset-meta.xml +14 -0
  45. package/dist/sfdc-package/force-app/main/default/remoteSiteSettings/LeadRouterEngine.remoteSite-meta.xml +7 -0
  46. package/dist/sfdc-package/force-app/main/default/tabs/Lead_Router_Setup.tab-meta.xml +7 -0
  47. package/dist/sfdc-package/force-app/main/default/triggers/AccountTrigger.trigger +28 -0
  48. package/dist/sfdc-package/force-app/main/default/triggers/AccountTrigger.trigger-meta.xml +5 -0
  49. package/dist/sfdc-package/force-app/main/default/triggers/ContactTrigger.trigger +28 -0
  50. package/dist/sfdc-package/force-app/main/default/triggers/ContactTrigger.trigger-meta.xml +5 -0
  51. package/dist/sfdc-package/force-app/main/default/triggers/LeadTrigger.trigger +28 -0
  52. package/dist/sfdc-package/force-app/main/default/triggers/LeadTrigger.trigger-meta.xml +5 -0
  53. package/dist/sfdc-package/sfdx-project.json +14 -0
  54. package/package.json +41 -0
@@ -0,0 +1,58 @@
1
+ @isTest
2
+ class AccountTriggerTest {
3
+
4
+ @TestSetup
5
+ static void makeData() {
6
+ Routing_Settings__c settings = new Routing_Settings__c(
7
+ Account_Routing_Enabled__c = true,
8
+ Account_Insert_Enabled__c = true,
9
+ Account_Update_Enabled__c = true,
10
+ Webhook_Secret__c = 'test-secret-key'
11
+ );
12
+ upsert settings;
13
+ }
14
+
15
+ @isTest
16
+ static void testAccountInsertFiresCallout() {
17
+ Test.setMock(HttpCalloutMock.class, new RoutingEngineMock());
18
+
19
+ Test.startTest();
20
+ Account a = new Account(Name = 'Tata Consultancy');
21
+ insert a;
22
+ Test.stopTest();
23
+
24
+ System.assertEquals(0, [SELECT COUNT() FROM Routing_Error_Log__c],
25
+ 'Expected no error logs for successful callout');
26
+ }
27
+
28
+ @isTest
29
+ static void testAccountUpdateFiresCallout() {
30
+ Test.setMock(HttpCalloutMock.class, new RoutingEngineMock());
31
+
32
+ Account a = new Account(Name = 'Infosys Ltd');
33
+ insert a;
34
+
35
+ Test.startTest();
36
+ a.BillingCity = 'Bengaluru';
37
+ update a;
38
+ Test.stopTest();
39
+
40
+ System.assertEquals(0, [SELECT COUNT() FROM Routing_Error_Log__c]);
41
+ }
42
+
43
+ @isTest
44
+ static void testPayloadBuilderBuild() {
45
+ // Unit-test the payload builder directly
46
+ Account a = new Account(Name = 'Test Co');
47
+ insert a;
48
+ a = [SELECT Id, Name, BillingCity FROM Account WHERE Id = :a.Id];
49
+
50
+ String payload = RoutingPayloadBuilder.build('Account', 'INSERT', a);
51
+ Map<String, Object> parsed = (Map<String, Object>) JSON.deserializeUntyped(payload);
52
+
53
+ System.assertEquals('ACCOUNT', parsed.get('objectType'));
54
+ System.assertEquals('INSERT', parsed.get('eventType'));
55
+ System.assertEquals(String.valueOf(a.Id), parsed.get('recordId'));
56
+ System.assertNotEquals(null, parsed.get('fields'));
57
+ }
58
+ }
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3
+ <apiVersion>59.0</apiVersion>
4
+ <status>Active</status>
5
+ </ApexClass>
@@ -0,0 +1,62 @@
1
+ @isTest
2
+ class ContactTriggerTest {
3
+
4
+ @TestSetup
5
+ static void makeData() {
6
+ Routing_Settings__c settings = new Routing_Settings__c(
7
+ Contact_Routing_Enabled__c = true,
8
+ Contact_Insert_Enabled__c = true,
9
+ Contact_Update_Enabled__c = true,
10
+ Webhook_Secret__c = 'test-secret-key'
11
+ );
12
+ upsert settings;
13
+ }
14
+
15
+ @isTest
16
+ static void testContactInsertFiresCallout() {
17
+ Test.setMock(HttpCalloutMock.class, new RoutingEngineMock());
18
+
19
+ Test.startTest();
20
+ Contact c = new Contact(
21
+ FirstName = 'Ananya',
22
+ LastName = 'Singh',
23
+ Email = 'ananya@example.com'
24
+ );
25
+ insert c;
26
+ Test.stopTest();
27
+
28
+ System.assertEquals(0, [SELECT COUNT() FROM Routing_Error_Log__c],
29
+ 'Expected no error logs for successful callout');
30
+ }
31
+
32
+ @isTest
33
+ static void testContactUpdateFiresCallout() {
34
+ Test.setMock(HttpCalloutMock.class, new RoutingEngineMock());
35
+
36
+ Contact c = new Contact(FirstName = 'Dev', LastName = 'Kumar');
37
+ insert c;
38
+
39
+ Test.startTest();
40
+ c.Email = 'dev@example.com';
41
+ update c;
42
+ Test.stopTest();
43
+
44
+ System.assertEquals(0, [SELECT COUNT() FROM Routing_Error_Log__c]);
45
+ }
46
+
47
+ @isTest
48
+ static void testContactRoutingDisabled() {
49
+ Routing_Settings__c settings = Routing_Settings__c.getOrgDefaults();
50
+ settings.Contact_Routing_Enabled__c = false;
51
+ upsert settings;
52
+
53
+ Test.setMock(HttpCalloutMock.class, new RoutingEngineMock(500, 'should not be called'));
54
+
55
+ Test.startTest();
56
+ Contact c = new Contact(FirstName = 'No', LastName = 'Route');
57
+ insert c;
58
+ Test.stopTest();
59
+
60
+ System.assertEquals(0, [SELECT COUNT() FROM Routing_Error_Log__c]);
61
+ }
62
+ }
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3
+ <apiVersion>59.0</apiVersion>
4
+ <status>Active</status>
5
+ </ApexClass>
@@ -0,0 +1,95 @@
1
+ @isTest
2
+ class LeadTriggerTest {
3
+
4
+ @TestSetup
5
+ static void makeData() {
6
+ Routing_Settings__c settings = new Routing_Settings__c(
7
+ Lead_Routing_Enabled__c = true,
8
+ Lead_Insert_Enabled__c = true,
9
+ Lead_Update_Enabled__c = true,
10
+ Webhook_Secret__c = 'test-secret-key'
11
+ );
12
+ upsert settings;
13
+ }
14
+
15
+ @isTest
16
+ static void testLeadInsertFiresCallout() {
17
+ Test.setMock(HttpCalloutMock.class, new RoutingEngineMock());
18
+
19
+ Test.startTest();
20
+ Lead l = new Lead(
21
+ FirstName = 'Priya',
22
+ LastName = 'Sharma',
23
+ Company = 'Acme Corp',
24
+ LeadSource = 'Web'
25
+ );
26
+ insert l;
27
+ Test.stopTest();
28
+
29
+ // No error logs expected for a 200 response
30
+ System.assertEquals(0, [SELECT COUNT() FROM Routing_Error_Log__c],
31
+ 'Expected no error logs for successful callout');
32
+ }
33
+
34
+ @isTest
35
+ static void testLeadUpdateFiresCallout() {
36
+ Test.setMock(HttpCalloutMock.class, new RoutingEngineMock());
37
+
38
+ Lead l = new Lead(
39
+ FirstName = 'Rahul',
40
+ LastName = 'Mehta',
41
+ Company = 'Beta LLC',
42
+ LeadSource = 'Phone'
43
+ );
44
+ insert l;
45
+
46
+ Test.startTest();
47
+ l.LeadSource = 'Web';
48
+ update l;
49
+ Test.stopTest();
50
+
51
+ System.assertEquals(0, [SELECT COUNT() FROM Routing_Error_Log__c],
52
+ 'Expected no error logs for successful update callout');
53
+ }
54
+
55
+ @isTest
56
+ static void testLeadInsertDisabledNoCallout() {
57
+ Routing_Settings__c settings = Routing_Settings__c.getOrgDefaults();
58
+ settings.Lead_Routing_Enabled__c = false;
59
+ upsert settings;
60
+
61
+ Test.setMock(HttpCalloutMock.class, new RoutingEngineMock(500, 'should not be called'));
62
+
63
+ Test.startTest();
64
+ Lead l = new Lead(
65
+ FirstName = 'Test',
66
+ LastName = 'Disabled',
67
+ Company = 'NoRoute Inc'
68
+ );
69
+ insert l;
70
+ Test.stopTest();
71
+
72
+ // No error logs because routing is disabled (no callout made)
73
+ System.assertEquals(0, [SELECT COUNT() FROM Routing_Error_Log__c]);
74
+ }
75
+
76
+ @isTest
77
+ static void testCalloutFailureLogsError() {
78
+ Test.setMock(HttpCalloutMock.class, new RoutingEngineMock(500, 'Internal Server Error'));
79
+
80
+ Test.startTest();
81
+ Lead l = new Lead(
82
+ FirstName = 'Error',
83
+ LastName = 'Test',
84
+ Company = 'Fail Corp',
85
+ LeadSource = 'Web'
86
+ );
87
+ insert l;
88
+ Test.stopTest();
89
+
90
+ System.assertEquals(1, [SELECT COUNT() FROM Routing_Error_Log__c],
91
+ 'Expected one error log for 500 response');
92
+ Routing_Error_Log__c errLog = [SELECT Status_Code__c FROM Routing_Error_Log__c LIMIT 1];
93
+ System.assertEquals(500, (Integer) errLog.Status_Code__c);
94
+ }
95
+ }
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3
+ <apiVersion>59.0</apiVersion>
4
+ <status>Active</status>
5
+ </ApexClass>
@@ -0,0 +1,183 @@
1
+ public with sharing class OnboardingController {
2
+
3
+ // ─── Helpers ───────────────────────────────────────────────────────────────
4
+
5
+ /**
6
+ * Returns the web app base URL from Routing_Settings__c.App_Url__c.
7
+ * Throws a user-friendly error if the setting hasn't been configured yet
8
+ * (i.e. lead-routing sfdc deploy hasn't been run).
9
+ */
10
+ private static String getAppBaseUrl() {
11
+ Routing_Settings__c s = Routing_Settings__c.getOrgDefaults();
12
+ if (String.isBlank(s.App_Url__c)) {
13
+ throw new AuraHandledException(
14
+ 'Lead Router App URL not configured. Run: lead-routing sfdc deploy'
15
+ );
16
+ }
17
+ return s.App_Url__c.removeEnd('/') + '/api';
18
+ }
19
+
20
+ // ─── Init methods (called by LWC connectedCallback) ───────────────────────
21
+
22
+ /**
23
+ * Returns the web app URL for the LWC to use in OAuth popup and links.
24
+ * Returns null if not yet configured.
25
+ */
26
+ @AuraEnabled
27
+ public static String getAppUrl() {
28
+ return Routing_Settings__c.getOrgDefaults().App_Url__c;
29
+ }
30
+
31
+ /**
32
+ * Returns the Salesforce org ID for use in API calls.
33
+ * Replaces the fragile window.location.hostname.split('.')[0] approach.
34
+ */
35
+ @AuraEnabled
36
+ public static String getOrgId() {
37
+ return UserInfo.getOrganizationId();
38
+ }
39
+
40
+ // ─── Step 1 ────────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Called by LWC polling: returns true once the web app has stored OAuth tokens
44
+ * for this org (i.e. GET /api/setup/status?sfdcOrgId=xxx returns connected=true).
45
+ */
46
+ @AuraEnabled
47
+ public static Boolean checkConnectionStatus(String sfdcOrgId) {
48
+ HttpRequest req = new HttpRequest();
49
+ req.setEndpoint(getAppBaseUrl() + '/setup/status?sfdcOrgId=' + EncodingUtil.urlEncode(sfdcOrgId, 'UTF-8'));
50
+ req.setMethod('GET');
51
+ req.setTimeout(8000);
52
+
53
+ try {
54
+ HttpResponse res = new Http().send(req);
55
+ if (res.getStatusCode() == 200) {
56
+ Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
57
+ return body.get('connected') == true;
58
+ }
59
+ } catch (Exception e) {
60
+ // Swallow — LWC will keep polling
61
+ }
62
+ return false;
63
+ }
64
+
65
+ // ─── Step 2 ────────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Persists the admin's object/event selections to Routing_Settings__c.
69
+ */
70
+ @AuraEnabled
71
+ public static void saveRoutingSettings(
72
+ Boolean leadEnabled, Boolean leadInsert, Boolean leadUpdate,
73
+ Boolean contactEnabled, Boolean contactInsert, Boolean contactUpdate,
74
+ Boolean accountEnabled, Boolean accountInsert, Boolean accountUpdate
75
+ ) {
76
+ Routing_Settings__c s = Routing_Settings__c.getOrgDefaults();
77
+ s.Lead_Routing_Enabled__c = leadEnabled;
78
+ s.Lead_Insert_Enabled__c = leadInsert;
79
+ s.Lead_Update_Enabled__c = leadUpdate;
80
+ s.Contact_Routing_Enabled__c = contactEnabled;
81
+ s.Contact_Insert_Enabled__c = contactInsert;
82
+ s.Contact_Update_Enabled__c = contactUpdate;
83
+ s.Account_Routing_Enabled__c = accountEnabled;
84
+ s.Account_Insert_Enabled__c = accountInsert;
85
+ s.Account_Update_Enabled__c = accountUpdate;
86
+ upsert s;
87
+ }
88
+
89
+ /**
90
+ * Fires a test payload to the routing engine to verify connectivity.
91
+ */
92
+ @AuraEnabled
93
+ public static void sendTestEvent() {
94
+ Routing_Settings__c settings = Routing_Settings__c.getOrgDefaults();
95
+
96
+ Map<String, Object> payload = new Map<String, Object>{
97
+ 'objectType' => 'LEAD',
98
+ 'eventType' => 'INSERT',
99
+ 'recordId' => '00Q000000000001EAA',
100
+ 'sfdcOrgId' => UserInfo.getOrganizationId(),
101
+ 'timestamp' => Datetime.now().formatGmt('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''),
102
+ 'fields' => new Map<String, Object>{
103
+ 'FirstName' => 'Test',
104
+ 'LastName' => 'Event',
105
+ 'LeadSource' => 'Web'
106
+ },
107
+ 'isTest' => true
108
+ };
109
+
110
+ String body = JSON.serialize(payload);
111
+ String secret = settings.Webhook_Secret__c ?? '';
112
+ String signature = 'sha256=' + hmacHex(body, secret);
113
+
114
+ String engineEndpoint = settings.Engine_Endpoint__c;
115
+ if (String.isBlank(engineEndpoint)) {
116
+ throw new AuraHandledException('Engine endpoint not configured. Run: lead-routing sfdc deploy');
117
+ }
118
+
119
+ HttpRequest req = new HttpRequest();
120
+ req.setEndpoint(engineEndpoint.removeEnd('/') + '/route');
121
+ req.setMethod('POST');
122
+ req.setHeader('Content-Type', 'application/json');
123
+ req.setHeader('X-Sfdc-Org-Id', UserInfo.getOrganizationId());
124
+ req.setHeader('X-Signature-256', signature);
125
+ req.setBody(body);
126
+ req.setTimeout(10000);
127
+
128
+ HttpResponse res = new Http().send(req);
129
+ if (res.getStatusCode() != 200 && res.getStatusCode() != 202) {
130
+ throw new AuraHandledException('Engine responded with ' + res.getStatusCode() + ': ' + res.getBody());
131
+ }
132
+ }
133
+
134
+ // ─── Step 3 ────────────────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Triggers a field schema sync for one object type.
138
+ * Calls POST /api/fields/sync?object=LEAD on the web app.
139
+ * Returns the count of fields synced.
140
+ */
141
+ @AuraEnabled
142
+ public static Integer syncFieldSchema(String objectType) {
143
+ HttpRequest req = new HttpRequest();
144
+ req.setEndpoint(getAppBaseUrl() + '/fields/sync?object=' + objectType);
145
+ req.setMethod('POST');
146
+ req.setHeader('Content-Type', 'application/json');
147
+ req.setHeader('X-Sfdc-Org-Id', UserInfo.getOrganizationId());
148
+ req.setTimeout(30000);
149
+
150
+ HttpResponse res = new Http().send(req);
151
+ if (res.getStatusCode() != 200) {
152
+ throw new AuraHandledException('Sync failed for ' + objectType + ': ' + res.getBody());
153
+ }
154
+
155
+ Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
156
+ return (Integer) body.get('synced');
157
+ }
158
+
159
+ // ─── Step 4 ────────────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Marks onboarding as complete in the web app.
163
+ */
164
+ @AuraEnabled
165
+ public static void markOnboardingDone() {
166
+ HttpRequest req = new HttpRequest();
167
+ req.setEndpoint(getAppBaseUrl() + '/setup/onboarding-done');
168
+ req.setMethod('POST');
169
+ req.setHeader('Content-Type', 'application/json');
170
+ req.setHeader('X-Sfdc-Org-Id', UserInfo.getOrganizationId());
171
+ req.setTimeout(8000);
172
+ new Http().send(req);
173
+ // Best-effort — ignore errors
174
+ }
175
+
176
+ // ─── Private ───────────────────────────────────────────────────────────────
177
+
178
+ private static String hmacHex(String payload, String secret) {
179
+ if (String.isBlank(secret)) return '';
180
+ Blob mac = Crypto.generateMac('hmacSHA256', Blob.valueOf(payload), Blob.valueOf(secret));
181
+ return EncodingUtil.convertToHex(mac);
182
+ }
183
+ }
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3
+ <apiVersion>59.0</apiVersion>
4
+ <status>Active</status>
5
+ </ApexClass>
@@ -0,0 +1,96 @@
1
+ public with sharing class RoutingEngineCallout {
2
+
3
+ // ─── Public API ────────────────────────────────────────────────────────────
4
+
5
+ /**
6
+ * Called from triggers. @future ensures the callout runs outside the
7
+ * trigger transaction, avoiding mixed-DML and callout-in-trigger errors.
8
+ *
9
+ * @param objectType 'Lead' | 'Contact' | 'Account' (Pascal case)
10
+ * @param recordIds IDs of records to route
11
+ * @param eventType 'INSERT' | 'UPDATE'
12
+ */
13
+ @future(callout=true)
14
+ public static void sendAsync(String objectType, List<Id> recordIds, String eventType) {
15
+ Routing_Settings__c settings = Routing_Settings__c.getOrgDefaults();
16
+ String secret = settings.Webhook_Secret__c;
17
+ String engineEndpoint = settings.Engine_Endpoint__c;
18
+
19
+ if (String.isBlank(engineEndpoint)) {
20
+ System.debug('RoutingEngineCallout: Engine_Endpoint__c not configured. Run: lead-routing sfdc deploy');
21
+ return;
22
+ }
23
+
24
+ List<SObject> records = queryRecords(objectType, recordIds);
25
+
26
+ for (SObject record : records) {
27
+ String payload = RoutingPayloadBuilder.build(objectType, eventType, record);
28
+ sendPayload(payload, secret, engineEndpoint);
29
+ }
30
+ }
31
+
32
+ // ─── Private helpers ───────────────────────────────────────────────────────
33
+
34
+ private static List<SObject> queryRecords(String objectType, List<Id> recordIds) {
35
+ // Dynamically build SELECT with all standard fields for the object
36
+ Schema.SObjectType sObjType = Schema.getGlobalDescribe().get(objectType);
37
+ Map<String, Schema.SObjectField> fieldMap = sObjType.getDescribe().fields.getMap();
38
+ List<String> fieldNames = new List<String>(fieldMap.keySet());
39
+
40
+ String soql = 'SELECT ' + String.join(fieldNames, ', ')
41
+ + ' FROM ' + objectType
42
+ + ' WHERE Id IN :recordIds';
43
+
44
+ return Database.query(soql);
45
+ }
46
+
47
+ private static void sendPayload(String payload, String secret, String engineEndpoint) {
48
+ String signature = hmacSha256(payload, secret);
49
+
50
+ HttpRequest req = new HttpRequest();
51
+ req.setEndpoint(engineEndpoint.removeEnd('/') + '/route');
52
+ req.setMethod('POST');
53
+ req.setHeader('Content-Type', 'application/json');
54
+ req.setHeader('X-Sfdc-Org-Id', UserInfo.getOrganizationId());
55
+ req.setHeader('X-Signature-256', 'sha256=' + signature);
56
+ req.setBody(payload);
57
+ req.setTimeout(10000);
58
+
59
+ Http http = new Http();
60
+ HttpResponse res;
61
+
62
+ try {
63
+ res = http.send(req);
64
+ } catch (Exception e) {
65
+ logError(payload, 0, 'Callout exception: ' + e.getMessage());
66
+ return;
67
+ }
68
+
69
+ if (res.getStatusCode() != 200 && res.getStatusCode() != 202) {
70
+ logError(payload, res.getStatusCode(), res.getBody());
71
+ }
72
+ }
73
+
74
+ private static String hmacSha256(String payload, String secret) {
75
+ if (String.isBlank(secret)) return '';
76
+ Blob hmac = Crypto.generateMac(
77
+ 'hmacSHA256',
78
+ Blob.valueOf(payload),
79
+ Blob.valueOf(secret)
80
+ );
81
+ return EncodingUtil.convertToHex(hmac);
82
+ }
83
+
84
+ private static void logError(String payload, Integer statusCode, String responseBody) {
85
+ try {
86
+ insert new Routing_Error_Log__c(
87
+ Payload__c = payload.length() > 131072 ? payload.substring(0, 131072) : payload,
88
+ Status_Code__c = statusCode,
89
+ Response_Body__c = responseBody,
90
+ Created_At__c = Datetime.now()
91
+ );
92
+ } catch (Exception ex) {
93
+ System.debug('Failed to insert Routing_Error_Log__c: ' + ex.getMessage());
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3
+ <apiVersion>59.0</apiVersion>
4
+ <status>Active</status>
5
+ </ApexClass>
@@ -0,0 +1,28 @@
1
+ /**
2
+ * HTTP mock used in Apex tests to intercept callouts to the routing engine.
3
+ * Returns a 200 OK response so tests do not fail on missing HTTP connections.
4
+ */
5
+ @isTest
6
+ global class RoutingEngineMock implements HttpCalloutMock {
7
+
8
+ private Integer statusCode;
9
+ private String responseBody;
10
+
11
+ global RoutingEngineMock() {
12
+ this.statusCode = 200;
13
+ this.responseBody = '{"ok":true}';
14
+ }
15
+
16
+ global RoutingEngineMock(Integer statusCode, String responseBody) {
17
+ this.statusCode = statusCode;
18
+ this.responseBody = responseBody;
19
+ }
20
+
21
+ global HTTPResponse respond(HTTPRequest req) {
22
+ HttpResponse res = new HttpResponse();
23
+ res.setStatusCode(this.statusCode);
24
+ res.setHeader('Content-Type', 'application/json');
25
+ res.setBody(this.responseBody);
26
+ return res;
27
+ }
28
+ }
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3
+ <apiVersion>59.0</apiVersion>
4
+ <status>Active</status>
5
+ </ApexClass>
@@ -0,0 +1,50 @@
1
+ public with sharing class RoutingPayloadBuilder {
2
+
3
+ /**
4
+ * Serialises a single SObject record into the JSON payload format
5
+ * expected by the routing engine.
6
+ *
7
+ * Schema:
8
+ * {
9
+ * "objectType": "LEAD",
10
+ * "eventType": "INSERT",
11
+ * "recordId": "00Q5g000001XyZEEA0",
12
+ * "sfdcOrgId": "00D000000000001EAA",
13
+ * "timestamp": "2026-02-23T10:32:00Z",
14
+ * "fields": { "FirstName": "Priya", "LeadSource": "Web", ... }
15
+ * }
16
+ */
17
+ public static String build(String objectType, String eventType, SObject record) {
18
+ Map<String, Object> payload = new Map<String, Object>{
19
+ 'objectType' => objectType.toUpperCase(),
20
+ 'eventType' => eventType.toUpperCase(),
21
+ 'recordId' => String.valueOf(record.get('Id')),
22
+ 'sfdcOrgId' => UserInfo.getOrganizationId(),
23
+ 'timestamp' => Datetime.now().formatGmt('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''),
24
+ 'fields' => extractFields(record)
25
+ };
26
+
27
+ return JSON.serialize(payload);
28
+ }
29
+
30
+ // ─── Private ───────────────────────────────────────────────────────────────
31
+
32
+ private static Map<String, Object> extractFields(SObject record) {
33
+ Map<String, Object> fields = new Map<String, Object>();
34
+ Map<String, Schema.SObjectField> fieldMap =
35
+ record.getSObjectType().getDescribe().fields.getMap();
36
+
37
+ for (String fieldName : fieldMap.keySet()) {
38
+ try {
39
+ Object value = record.get(fieldName);
40
+ if (value != null) {
41
+ fields.put(fieldName, value);
42
+ }
43
+ } catch (SObjectException e) {
44
+ // Field not retrieved in query — skip silently
45
+ }
46
+ }
47
+
48
+ return fields;
49
+ }
50
+ }
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3
+ <apiVersion>59.0</apiVersion>
4
+ <status>Active</status>
5
+ </ApexClass>