@lead-routing/cli 0.1.13 → 0.2.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.
@@ -0,0 +1,95 @@
1
+ -- Migration: Route Match Config, Routing Branches, Default Owner
2
+ -- Adds: RouteMatchConfig, RoutingBranch, BranchCondition models
3
+ -- defaultOwner* fields on routing_rules
4
+ -- LeadMatchAction, ContactMatchAction, AccountMatchAction enums
5
+ -- MERGED value to RoutingStatus enum
6
+ -- Makes assignmentType nullable on routing_rules
7
+
8
+ -- 1. New enums
9
+ CREATE TYPE "LeadMatchAction" AS ENUM ('SFDC_MERGE', 'ASSIGN_TO_OWNER', 'ASSIGN_CUSTOM');
10
+ CREATE TYPE "ContactMatchAction" AS ENUM ('ASSIGN_TO_OWNER', 'ASSIGN_CUSTOM', 'SKIP');
11
+ CREATE TYPE "AccountMatchAction" AS ENUM ('ASSIGN_TO_OWNER', 'ASSIGN_CUSTOM', 'SKIP');
12
+
13
+ -- 2. Add MERGED to RoutingStatus enum
14
+ ALTER TYPE "RoutingStatus" ADD VALUE 'MERGED';
15
+
16
+ -- 3. Make assignmentType nullable on routing_rules
17
+ ALTER TABLE "routing_rules" ALTER COLUMN "assignmentType" DROP NOT NULL;
18
+
19
+ -- 4. Add defaultOwner* fields to routing_rules
20
+ ALTER TABLE "routing_rules"
21
+ ADD COLUMN "defaultOwnerType" "AssignmentType",
22
+ ADD COLUMN "defaultOwnerUserId" TEXT,
23
+ ADD COLUMN "defaultOwnerTeamId" TEXT,
24
+ ADD COLUMN "defaultOwnerQueueId" TEXT;
25
+
26
+ -- 5. Create routing_branches table
27
+ CREATE TABLE "routing_branches" (
28
+ "id" TEXT NOT NULL,
29
+ "ruleId" TEXT NOT NULL,
30
+ "label" TEXT,
31
+ "priority" INTEGER NOT NULL DEFAULT 0,
32
+ "assignmentType" "AssignmentType" NOT NULL,
33
+ "assigneeUserId" TEXT,
34
+ "assigneeTeamId" TEXT,
35
+ "assigneeQueueId" TEXT,
36
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
37
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
38
+ CONSTRAINT "routing_branches_pkey" PRIMARY KEY ("id")
39
+ );
40
+
41
+ ALTER TABLE "routing_branches"
42
+ ADD CONSTRAINT "routing_branches_ruleId_fkey"
43
+ FOREIGN KEY ("ruleId") REFERENCES "routing_rules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
44
+
45
+ -- 6. Create branch_conditions table
46
+ CREATE TABLE "branch_conditions" (
47
+ "id" TEXT NOT NULL,
48
+ "branchId" TEXT NOT NULL,
49
+ "groupId" TEXT NOT NULL,
50
+ "fieldName" TEXT NOT NULL,
51
+ "operator" TEXT NOT NULL,
52
+ "value" TEXT,
53
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
54
+ CONSTRAINT "branch_conditions_pkey" PRIMARY KEY ("id")
55
+ );
56
+
57
+ ALTER TABLE "branch_conditions"
58
+ ADD CONSTRAINT "branch_conditions_branchId_fkey"
59
+ FOREIGN KEY ("branchId") REFERENCES "routing_branches"("id") ON DELETE CASCADE ON UPDATE CASCADE;
60
+
61
+ -- 7. Create route_match_configs table
62
+ CREATE TABLE "route_match_configs" (
63
+ "id" TEXT NOT NULL,
64
+ "ruleId" TEXT NOT NULL,
65
+ "checkLeads" BOOLEAN NOT NULL DEFAULT true,
66
+ "checkContacts" BOOLEAN NOT NULL DEFAULT true,
67
+ "checkAccounts" BOOLEAN NOT NULL DEFAULT false,
68
+ "matchEmail" BOOLEAN NOT NULL DEFAULT true,
69
+ "matchPhone" BOOLEAN NOT NULL DEFAULT false,
70
+ "matchDomain" BOOLEAN NOT NULL DEFAULT false,
71
+ "onLeadMatch" "LeadMatchAction" NOT NULL DEFAULT 'SFDC_MERGE',
72
+ "leadAssignmentType" "AssignmentType",
73
+ "leadAssigneeUserId" TEXT,
74
+ "leadAssigneeTeamId" TEXT,
75
+ "leadAssigneeQueueId" TEXT,
76
+ "onContactMatch" "ContactMatchAction" NOT NULL DEFAULT 'ASSIGN_TO_OWNER',
77
+ "contactAssignmentType" "AssignmentType",
78
+ "contactAssigneeUserId" TEXT,
79
+ "contactAssigneeTeamId" TEXT,
80
+ "contactAssigneeQueueId" TEXT,
81
+ "onAccountMatch" "AccountMatchAction" NOT NULL DEFAULT 'ASSIGN_TO_OWNER',
82
+ "accountAssignmentType" "AssignmentType",
83
+ "accountAssigneeUserId" TEXT,
84
+ "accountAssigneeTeamId" TEXT,
85
+ "accountAssigneeQueueId" TEXT,
86
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
87
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
88
+ CONSTRAINT "route_match_configs_pkey" PRIMARY KEY ("id")
89
+ );
90
+
91
+ CREATE UNIQUE INDEX "route_match_configs_ruleId_key" ON "route_match_configs"("ruleId");
92
+
93
+ ALTER TABLE "route_match_configs"
94
+ ADD CONSTRAINT "route_match_configs_ruleId_fkey"
95
+ FOREIGN KEY ("ruleId") REFERENCES "routing_rules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
1
+ -- AlterTable
2
+ ALTER TABLE "routing_logs" ADD COLUMN IF NOT EXISTS "pathLabel" TEXT;
@@ -0,0 +1,6 @@
1
+ -- AlterTable
2
+ ALTER TABLE "routing_logs" ADD COLUMN "teamId" TEXT;
3
+ ALTER TABLE "routing_logs" ADD COLUMN "teamName" TEXT;
4
+
5
+ -- CreateIndex
6
+ CREATE INDEX "routing_logs_orgId_teamId_idx" ON "routing_logs"("orgId", "teamId");
@@ -0,0 +1,2 @@
1
+ -- AlterTable
2
+ ALTER TABLE "branch_conditions" ADD COLUMN "fieldType" TEXT NOT NULL DEFAULT 'TEXT';
@@ -0,0 +1,84 @@
1
+ -- Add analytics columns to routing_logs
2
+ ALTER TABLE "routing_logs" ADD COLUMN "routingDurationMs" INTEGER;
3
+ ALTER TABLE "routing_logs" ADD COLUMN "branchId" TEXT;
4
+
5
+ -- AddForeignKey
6
+ ALTER TABLE "routing_logs" ADD CONSTRAINT "routing_logs_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "routing_branches"("id") ON DELETE SET NULL ON UPDATE CASCADE;
7
+
8
+ -- CreateIndex
9
+ CREATE INDEX "routing_logs_orgId_ruleId_idx" ON "routing_logs"("orgId", "ruleId");
10
+
11
+ -- CreateTable: routing_daily_aggregates
12
+ CREATE TABLE "routing_daily_aggregates" (
13
+ "id" TEXT NOT NULL,
14
+ "orgId" TEXT NOT NULL,
15
+ "date" DATE NOT NULL,
16
+ "ruleId" TEXT,
17
+ "pathLabel" TEXT,
18
+ "branchId" TEXT,
19
+ "teamId" TEXT,
20
+ "assigneeId" TEXT,
21
+ "objectType" "SfdcObjectType",
22
+ "successCount" INTEGER NOT NULL DEFAULT 0,
23
+ "failedCount" INTEGER NOT NULL DEFAULT 0,
24
+ "unmatchedCount" INTEGER NOT NULL DEFAULT 0,
25
+ "mergedCount" INTEGER NOT NULL DEFAULT 0,
26
+ "totalCount" INTEGER NOT NULL DEFAULT 0,
27
+ "avgDurationMs" DOUBLE PRECISION,
28
+ "minDurationMs" INTEGER,
29
+ "maxDurationMs" INTEGER,
30
+ "p50DurationMs" INTEGER,
31
+ "p95DurationMs" INTEGER,
32
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
33
+ "updatedAt" TIMESTAMP(3) NOT NULL,
34
+
35
+ CONSTRAINT "routing_daily_aggregates_pkey" PRIMARY KEY ("id")
36
+ );
37
+
38
+ -- CreateIndex
39
+ CREATE INDEX "routing_daily_aggregates_orgId_date_idx" ON "routing_daily_aggregates"("orgId", "date");
40
+
41
+ -- CreateIndex (unique composite)
42
+ CREATE UNIQUE INDEX "routing_daily_aggregates_orgId_date_ruleId_pathLabel_branch_key" ON "routing_daily_aggregates"("orgId", "date", "ruleId", "pathLabel", "branchId", "teamId", "assigneeId", "objectType");
43
+
44
+ -- AddForeignKey
45
+ ALTER TABLE "routing_daily_aggregates" ADD CONSTRAINT "routing_daily_aggregates_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
46
+
47
+ -- CreateTable: conversion_tracking
48
+ CREATE TABLE "conversion_tracking" (
49
+ "id" TEXT NOT NULL,
50
+ "orgId" TEXT NOT NULL,
51
+ "routingLogId" TEXT NOT NULL,
52
+ "sfdcLeadId" TEXT NOT NULL,
53
+ "isConverted" BOOLEAN NOT NULL DEFAULT false,
54
+ "convertedAt" TIMESTAMP(3),
55
+ "opportunityId" TEXT,
56
+ "opportunityAmount" DOUBLE PRECISION,
57
+ "opportunityStageName" TEXT,
58
+ "ruleId" TEXT,
59
+ "ruleName" TEXT,
60
+ "pathLabel" TEXT,
61
+ "teamId" TEXT,
62
+ "assigneeId" TEXT,
63
+ "assigneeName" TEXT,
64
+ "lastCheckedAt" TIMESTAMP(3),
65
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
66
+ "updatedAt" TIMESTAMP(3) NOT NULL,
67
+
68
+ CONSTRAINT "conversion_tracking_pkey" PRIMARY KEY ("id")
69
+ );
70
+
71
+ -- CreateIndex
72
+ CREATE UNIQUE INDEX "conversion_tracking_routingLogId_key" ON "conversion_tracking"("routingLogId");
73
+
74
+ -- CreateIndex
75
+ CREATE INDEX "conversion_tracking_orgId_isConverted_idx" ON "conversion_tracking"("orgId", "isConverted");
76
+
77
+ -- CreateIndex
78
+ CREATE INDEX "conversion_tracking_orgId_sfdcLeadId_idx" ON "conversion_tracking"("orgId", "sfdcLeadId");
79
+
80
+ -- AddForeignKey
81
+ ALTER TABLE "conversion_tracking" ADD CONSTRAINT "conversion_tracking_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
82
+
83
+ -- AddForeignKey
84
+ ALTER TABLE "conversion_tracking" ADD CONSTRAINT "conversion_tracking_routingLogId_fkey" FOREIGN KEY ("routingLogId") REFERENCES "routing_logs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -43,6 +43,25 @@ enum RoutingStatus {
43
43
  FAILED
44
44
  UNMATCHED
45
45
  RETRY
46
+ MERGED
47
+ }
48
+
49
+ enum LeadMatchAction {
50
+ SFDC_MERGE
51
+ ASSIGN_TO_OWNER
52
+ ASSIGN_CUSTOM
53
+ }
54
+
55
+ enum ContactMatchAction {
56
+ ASSIGN_TO_OWNER
57
+ ASSIGN_CUSTOM
58
+ SKIP
59
+ }
60
+
61
+ enum AccountMatchAction {
62
+ ASSIGN_TO_OWNER
63
+ ASSIGN_CUSTOM
64
+ SKIP
46
65
  }
47
66
 
48
67
  enum Plan {
@@ -79,7 +98,9 @@ model Organization {
79
98
  auditLogs AuditLog[]
80
99
  sfdcQueues SfdcQueue[]
81
100
  fieldSchemas FieldSchema[]
82
- billingInfo BillingInfo?
101
+ billingInfo BillingInfo?
102
+ routingDailyAggregates RoutingDailyAggregate[]
103
+ conversionTracking ConversionTracking[]
83
104
 
84
105
  @@map("organizations")
85
106
  }
@@ -172,29 +193,112 @@ model TeamMember {
172
193
  }
173
194
 
174
195
  model RoutingRule {
175
- id String @id @default(cuid())
196
+ id String @id @default(cuid())
176
197
  orgId String
177
198
  objectType SfdcObjectType
178
199
  triggerEvent TriggerEvent
179
200
  name String
180
201
  priority Int
181
- status RuleStatus @default(ACTIVE)
182
- assignmentType AssignmentType
183
- assigneeUserId String? // references User.id (individual user)
184
- assigneeTeamId String? // references RoundRobinTeam.id
185
- assigneeQueueId String? // references SfdcQueue.id
186
- isDryRun Boolean @default(false)
187
- createdAt DateTime @default(now())
188
- updatedAt DateTime @updatedAt
189
-
190
- org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
191
- team RoundRobinTeam? @relation("TeamRules", fields: [assigneeTeamId], references: [id])
192
- queue SfdcQueue? @relation(fields: [assigneeQueueId], references: [id])
193
- conditions RuleCondition[]
202
+ status RuleStatus @default(ACTIVE)
203
+ // Legacy single-assignee fields (used by old rules; null for new Route Builder rules)
204
+ assignmentType AssignmentType?
205
+ assigneeUserId String? // references User.id (individual user)
206
+ assigneeTeamId String? // references RoundRobinTeam.id
207
+ assigneeQueueId String? // references SfdcQueue.id
208
+ isDryRun Boolean @default(false)
209
+ // Default owner: absolute catch-all for new Route Builder routes
210
+ defaultOwnerType AssignmentType?
211
+ defaultOwnerUserId String?
212
+ defaultOwnerTeamId String?
213
+ defaultOwnerQueueId String?
214
+ createdAt DateTime @default(now())
215
+ updatedAt DateTime @updatedAt
216
+
217
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
218
+ team RoundRobinTeam? @relation("TeamRules", fields: [assigneeTeamId], references: [id])
219
+ queue SfdcQueue? @relation(fields: [assigneeQueueId], references: [id])
220
+ conditions RuleCondition[] // legacy conditions (old-style rules)
221
+ branches RoutingBranch[] // new Route Builder paths
222
+ matchConfig RouteMatchConfig?
194
223
 
195
224
  @@map("routing_rules")
196
225
  }
197
226
 
227
+ model RoutingBranch {
228
+ id String @id @default(cuid())
229
+ ruleId String
230
+ rule RoutingRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
231
+ label String?
232
+ priority Int @default(0)
233
+ assignmentType AssignmentType
234
+ assigneeUserId String?
235
+ assigneeTeamId String?
236
+ assigneeQueueId String?
237
+ createdAt DateTime @default(now())
238
+ updatedAt DateTime @updatedAt
239
+
240
+ conditions BranchCondition[]
241
+ routingLogs RoutingLog[]
242
+
243
+ @@map("routing_branches")
244
+ }
245
+
246
+ model BranchCondition {
247
+ id String @id @default(cuid())
248
+ branchId String
249
+ branch RoutingBranch @relation(fields: [branchId], references: [id], onDelete: Cascade)
250
+ groupId String
251
+ fieldName String
252
+ fieldType String @default("TEXT")
253
+ operator String
254
+ value String?
255
+ sortOrder Int @default(0)
256
+
257
+ @@map("branch_conditions")
258
+ }
259
+
260
+ model RouteMatchConfig {
261
+ id String @id @default(cuid())
262
+ ruleId String @unique
263
+ rule RoutingRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
264
+
265
+ // Which objects to check
266
+ checkLeads Boolean @default(true)
267
+ checkContacts Boolean @default(true)
268
+ checkAccounts Boolean @default(false)
269
+
270
+ // Which fields to match on
271
+ matchEmail Boolean @default(true)
272
+ matchPhone Boolean @default(false)
273
+ matchDomain Boolean @default(false)
274
+
275
+ // Lead match outcome
276
+ onLeadMatch LeadMatchAction @default(SFDC_MERGE)
277
+ leadAssignmentType AssignmentType?
278
+ leadAssigneeUserId String?
279
+ leadAssigneeTeamId String?
280
+ leadAssigneeQueueId String?
281
+
282
+ // Contact match outcome
283
+ onContactMatch ContactMatchAction @default(ASSIGN_TO_OWNER)
284
+ contactAssignmentType AssignmentType?
285
+ contactAssigneeUserId String?
286
+ contactAssigneeTeamId String?
287
+ contactAssigneeQueueId String?
288
+
289
+ // Account match outcome
290
+ onAccountMatch AccountMatchAction @default(ASSIGN_TO_OWNER)
291
+ accountAssignmentType AssignmentType?
292
+ accountAssigneeUserId String?
293
+ accountAssigneeTeamId String?
294
+ accountAssigneeQueueId String?
295
+
296
+ createdAt DateTime @default(now())
297
+ updatedAt DateTime @updatedAt
298
+
299
+ @@map("route_match_configs")
300
+ }
301
+
198
302
  model RuleCondition {
199
303
  id String @id @default(cuid())
200
304
  ruleId String
@@ -217,6 +321,7 @@ model RoutingLog {
217
321
  eventType TriggerEvent
218
322
  ruleId String?
219
323
  ruleName String?
324
+ pathLabel String? // which branch/path triggered: e.g. "Path A" or "Default Owner"
220
325
  assigneeId String? // SFDC user/queue ID
221
326
  assigneeName String?
222
327
  assignmentType AssignmentType?
@@ -226,12 +331,20 @@ model RoutingLog {
226
331
  retryCount Int @default(0)
227
332
  dismissed Boolean @default(false)
228
333
  recordSnapshot Json? // full field payload from SFDC at time of routing
229
- createdAt DateTime @default(now())
334
+ teamId String?
335
+ teamName String?
336
+ routingDurationMs Int? // ms from webhook receipt → SFDC assignment
337
+ branchId String? // FK → RoutingBranch for path-level analytics
338
+ createdAt DateTime @default(now())
230
339
 
231
- org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
340
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
341
+ branch RoutingBranch? @relation(fields: [branchId], references: [id])
342
+ conversionTracking ConversionTracking?
232
343
 
233
344
  @@index([orgId, createdAt])
234
345
  @@index([orgId, status])
346
+ @@index([orgId, teamId])
347
+ @@index([orgId, ruleId])
235
348
  @@map("routing_logs")
236
349
  }
237
350
 
@@ -313,3 +426,61 @@ model Session {
313
426
  @@index([orgId])
314
427
  @@map("sessions")
315
428
  }
429
+
430
+ model RoutingDailyAggregate {
431
+ id String @id @default(cuid())
432
+ orgId String
433
+ date DateTime @db.Date
434
+ ruleId String?
435
+ pathLabel String?
436
+ branchId String?
437
+ teamId String?
438
+ assigneeId String?
439
+ objectType SfdcObjectType?
440
+ successCount Int @default(0)
441
+ failedCount Int @default(0)
442
+ unmatchedCount Int @default(0)
443
+ mergedCount Int @default(0)
444
+ totalCount Int @default(0)
445
+ avgDurationMs Float?
446
+ minDurationMs Int?
447
+ maxDurationMs Int?
448
+ p50DurationMs Int?
449
+ p95DurationMs Int?
450
+ createdAt DateTime @default(now())
451
+ updatedAt DateTime @updatedAt
452
+
453
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
454
+
455
+ @@unique([orgId, date, ruleId, pathLabel, branchId, teamId, assigneeId, objectType])
456
+ @@index([orgId, date])
457
+ @@map("routing_daily_aggregates")
458
+ }
459
+
460
+ model ConversionTracking {
461
+ id String @id @default(cuid())
462
+ orgId String
463
+ routingLogId String @unique
464
+ sfdcLeadId String
465
+ isConverted Boolean @default(false)
466
+ convertedAt DateTime?
467
+ opportunityId String?
468
+ opportunityAmount Float?
469
+ opportunityStageName String?
470
+ ruleId String?
471
+ ruleName String?
472
+ pathLabel String?
473
+ teamId String?
474
+ assigneeId String?
475
+ assigneeName String?
476
+ lastCheckedAt DateTime?
477
+ createdAt DateTime @default(now())
478
+ updatedAt DateTime @updatedAt
479
+
480
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
481
+ routingLog RoutingLog @relation(fields: [routingLogId], references: [id], onDelete: Cascade)
482
+
483
+ @@index([orgId, isConverted])
484
+ @@index([orgId, sfdcLeadId])
485
+ @@map("conversion_tracking")
486
+ }
@@ -55,4 +55,30 @@ class AccountTriggerTest {
55
55
  System.assertEquals(String.valueOf(a.Id), parsed.get('recordId'));
56
56
  System.assertNotEquals(null, parsed.get('fields'));
57
57
  }
58
+
59
+ @isTest
60
+ static void testPayloadBuilderBuildBatch() {
61
+ // Unit-test the batch payload builder
62
+ List<Account> accts = new List<Account>();
63
+ accts.add(new Account(Name = 'Batch A'));
64
+ accts.add(new Account(Name = 'Batch B'));
65
+ insert accts;
66
+ accts = [SELECT Id, Name FROM Account WHERE Id IN :accts];
67
+
68
+ String payload = RoutingPayloadBuilder.buildBatch('Account', 'INSERT', accts);
69
+ Map<String, Object> parsed = (Map<String, Object>) JSON.deserializeUntyped(payload);
70
+
71
+ System.assertEquals('ACCOUNT', parsed.get('objectType'));
72
+ System.assertEquals('INSERT', parsed.get('eventType'));
73
+ System.assertNotEquals(null, parsed.get('sfdcOrgId'));
74
+
75
+ List<Object> records = (List<Object>) parsed.get('records');
76
+ System.assertEquals(2, records.size(), 'Expected 2 records in batch');
77
+
78
+ for (Object r : records) {
79
+ Map<String, Object> rec = (Map<String, Object>) r;
80
+ System.assertNotEquals(null, rec.get('recordId'));
81
+ System.assertNotEquals(null, rec.get('fields'));
82
+ }
83
+ }
58
84
  }
@@ -92,4 +92,53 @@ class LeadTriggerTest {
92
92
  Routing_Error_Log__c errLog = [SELECT Status_Code__c FROM Routing_Error_Log__c LIMIT 1];
93
93
  System.assertEquals(500, (Integer) errLog.Status_Code__c);
94
94
  }
95
+
96
+ @isTest
97
+ static void testBatchPayloadFormat() {
98
+ // Verify RoutingPayloadBuilder.buildBatch produces correct schema
99
+ Lead l = new Lead(FirstName = 'Batch', LastName = 'Test', Company = 'BatchCo');
100
+ insert l;
101
+ l = [SELECT Id, FirstName, LastName, Company FROM Lead WHERE Id = :l.Id];
102
+
103
+ String payload = RoutingPayloadBuilder.buildBatch('Lead', 'INSERT', new List<Lead>{ l });
104
+ Map<String, Object> parsed = (Map<String, Object>) JSON.deserializeUntyped(payload);
105
+
106
+ System.assertEquals('LEAD', parsed.get('objectType'));
107
+ System.assertEquals('INSERT', parsed.get('eventType'));
108
+ System.assertNotEquals(null, parsed.get('sfdcOrgId'));
109
+ System.assertNotEquals(null, parsed.get('timestamp'));
110
+
111
+ List<Object> records = (List<Object>) parsed.get('records');
112
+ System.assertEquals(1, records.size(), 'Expected 1 record in batch');
113
+
114
+ Map<String, Object> rec = (Map<String, Object>) records[0];
115
+ System.assertEquals(String.valueOf(l.Id), rec.get('recordId'));
116
+ System.assertNotEquals(null, rec.get('fields'));
117
+ }
118
+
119
+ @isTest
120
+ static void testBulkInsertUsesBatchEndpoint() {
121
+ // Verify that inserting multiple leads sends to /route/batch
122
+ Routing_Settings__c settings = Routing_Settings__c.getOrgDefaults();
123
+ settings.Engine_Endpoint__c = 'https://engine.example.com';
124
+ upsert settings;
125
+
126
+ RoutingEngineMock mock = new RoutingEngineMock();
127
+ Test.setMock(HttpCalloutMock.class, mock);
128
+
129
+ Test.startTest();
130
+ List<Lead> leads = new List<Lead>();
131
+ for (Integer i = 0; i < 5; i++) {
132
+ leads.add(new Lead(
133
+ FirstName = 'Bulk' + i,
134
+ LastName = 'Test' + i,
135
+ Company = 'BulkCo'
136
+ ));
137
+ }
138
+ insert leads;
139
+ Test.stopTest();
140
+
141
+ System.assertEquals(0, [SELECT COUNT() FROM Routing_Error_Log__c],
142
+ 'Expected no error logs for batch callout');
143
+ }
95
144
  }
@@ -23,10 +23,9 @@ public with sharing class RoutingEngineCallout {
23
23
 
24
24
  List<SObject> records = queryRecords(objectType, recordIds);
25
25
 
26
- for (SObject record : records) {
27
- String payload = RoutingPayloadBuilder.build(objectType, eventType, record);
28
- sendPayload(payload, secret, engineEndpoint);
29
- }
26
+ // Send all records in a single batch HTTP call to /route/batch
27
+ String batchPayload = RoutingPayloadBuilder.buildBatch(objectType, eventType, records);
28
+ sendBatchPayload(batchPayload, secret, engineEndpoint);
30
29
  }
31
30
 
32
31
  // ─── Private helpers ───────────────────────────────────────────────────────
@@ -44,6 +43,34 @@ public with sharing class RoutingEngineCallout {
44
43
  return Database.query(soql);
45
44
  }
46
45
 
46
+ private static void sendBatchPayload(String payload, String secret, String engineEndpoint) {
47
+ String signature = hmacSha256(payload, secret);
48
+
49
+ HttpRequest req = new HttpRequest();
50
+ req.setEndpoint(engineEndpoint.removeEnd('/') + '/route/batch');
51
+ req.setMethod('POST');
52
+ req.setHeader('Content-Type', 'application/json');
53
+ req.setHeader('X-Sfdc-Org-Id', UserInfo.getOrganizationId());
54
+ req.setHeader('X-Signature-256', 'sha256=' + signature);
55
+ req.setBody(payload);
56
+ req.setTimeout(30000); // 30s for batch
57
+
58
+ Http http = new Http();
59
+ HttpResponse res;
60
+
61
+ try {
62
+ res = http.send(req);
63
+ } catch (Exception e) {
64
+ logError(payload, 0, 'Batch callout exception: ' + e.getMessage());
65
+ return;
66
+ }
67
+
68
+ if (res.getStatusCode() != 200 && res.getStatusCode() != 202) {
69
+ logError(payload, res.getStatusCode(), res.getBody());
70
+ }
71
+ }
72
+
73
+ // Kept for backward compatibility (single-record POST to /route)
47
74
  private static void sendPayload(String payload, String secret, String engineEndpoint) {
48
75
  String signature = hmacSha256(payload, secret);
49
76
 
@@ -8,9 +8,13 @@ global class RoutingEngineMock implements HttpCalloutMock {
8
8
  private Integer statusCode;
9
9
  private String responseBody;
10
10
 
11
+ // Captured from last request for test assertions
12
+ global String lastEndpoint { get; private set; }
13
+ global String lastBody { get; private set; }
14
+
11
15
  global RoutingEngineMock() {
12
- this.statusCode = 200;
13
- this.responseBody = '{"ok":true}';
16
+ this.statusCode = 202;
17
+ this.responseBody = '{"accepted":1,"duplicates":0}';
14
18
  }
15
19
 
16
20
  global RoutingEngineMock(Integer statusCode, String responseBody) {
@@ -19,6 +23,9 @@ global class RoutingEngineMock implements HttpCalloutMock {
19
23
  }
20
24
 
21
25
  global HTTPResponse respond(HTTPRequest req) {
26
+ lastEndpoint = req.getEndpoint();
27
+ lastBody = req.getBody();
28
+
22
29
  HttpResponse res = new HttpResponse();
23
30
  res.setStatusCode(this.statusCode);
24
31
  res.setHeader('Content-Type', 'application/json');
@@ -27,6 +27,42 @@ public with sharing class RoutingPayloadBuilder {
27
27
  return JSON.serialize(payload);
28
28
  }
29
29
 
30
+ /**
31
+ * Serialises a batch of SObject records into the JSON payload format
32
+ * expected by the engine's POST /route/batch endpoint.
33
+ *
34
+ * Schema:
35
+ * {
36
+ * "objectType": "LEAD",
37
+ * "eventType": "INSERT",
38
+ * "sfdcOrgId": "00D000000000001EAA",
39
+ * "timestamp": "2026-02-23T10:32:00Z",
40
+ * "records": [
41
+ * { "recordId": "00Q...", "fields": { ... } },
42
+ * ...
43
+ * ]
44
+ * }
45
+ */
46
+ public static String buildBatch(String objectType, String eventType, List<SObject> records) {
47
+ List<Map<String, Object>> recordList = new List<Map<String, Object>>();
48
+ for (SObject record : records) {
49
+ recordList.add(new Map<String, Object>{
50
+ 'recordId' => String.valueOf(record.get('Id')),
51
+ 'fields' => extractFields(record)
52
+ });
53
+ }
54
+
55
+ Map<String, Object> payload = new Map<String, Object>{
56
+ 'objectType' => objectType.toUpperCase(),
57
+ 'eventType' => eventType.toUpperCase(),
58
+ 'sfdcOrgId' => UserInfo.getOrganizationId(),
59
+ 'timestamp' => Datetime.now().formatGmt('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''),
60
+ 'records' => recordList
61
+ };
62
+
63
+ return JSON.serialize(payload);
64
+ }
65
+
30
66
  // ─── Private ───────────────────────────────────────────────────────────────
31
67
 
32
68
  private static Map<String, Object> extractFields(SObject record) {