@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.
- package/dist/index.js +315 -310
- package/dist/prisma/migrations/20260308100000_route_match_config/migration.sql +95 -0
- package/dist/prisma/migrations/20260309200000_add_path_label_to_routing_logs/migration.sql +2 -0
- package/dist/prisma/migrations/20260310000000_add_team_to_routing_log/migration.sql +6 -0
- package/dist/prisma/migrations/20260310100000_add_field_type_to_branch_conditions/migration.sql +2 -0
- package/dist/prisma/migrations/20260310200000_analytics_foundation/migration.sql +84 -0
- package/dist/prisma/schema.prisma +188 -17
- package/dist/sfdc-package/force-app/main/default/classes/AccountTriggerTest.cls +26 -0
- package/dist/sfdc-package/force-app/main/default/classes/LeadTriggerTest.cls +49 -0
- package/dist/sfdc-package/force-app/main/default/classes/RoutingEngineCallout.cls +31 -4
- package/dist/sfdc-package/force-app/main/default/classes/RoutingEngineMock.cls +9 -2
- package/dist/sfdc-package/force-app/main/default/classes/RoutingPayloadBuilder.cls +36 -0
- package/dist/sfdc-package/force-app/main/default/triggers/AccountTrigger.trigger +14 -4
- package/dist/sfdc-package/force-app/main/default/triggers/ContactTrigger.trigger +14 -4
- package/dist/sfdc-package/force-app/main/default/triggers/LeadTrigger.trigger +16 -4
- package/package.json +11 -3
|
@@ -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,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
|
|
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
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 =
|
|
13
|
-
this.responseBody = '{"
|
|
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) {
|