@lead-routing/cli 0.6.9 → 0.7.1

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 CHANGED
@@ -112,6 +112,7 @@ function bail2(value) {
112
112
  throw new Error("Unexpected cancel");
113
113
  }
114
114
  async function collectConfig(opts = {}) {
115
+ const crmType = opts.crmType ?? "salesforce";
115
116
  note2(
116
117
  "You will need:\n \u2022 Public HTTPS URLs for the web app and routing engine",
117
118
  "Before you begin"
@@ -130,14 +131,15 @@ async function collectConfig(opts = {}) {
130
131
  }
131
132
  });
132
133
  if (isCancel2(appUrl)) bail2(appUrl);
134
+ const crmLabel = crmType === "hubspot" ? "HubSpot" : "Salesforce";
133
135
  const engineUrl = await text2({
134
- message: "Engine URL (public URL Salesforce will use to route leads)",
136
+ message: `Engine URL (public URL ${crmLabel} will use to route leads)`,
135
137
  placeholder: "https://engine.acme.com or https://acme.com:3001",
136
138
  validate: (v) => {
137
139
  if (!v) return "Required";
138
140
  try {
139
141
  const u = new URL(v);
140
- if (u.protocol !== "https:") return "Must be an HTTPS URL (Salesforce requires HTTPS)";
142
+ if (u.protocol !== "https:") return `Must be an HTTPS URL (${crmLabel} requires HTTPS)`;
141
143
  } catch {
142
144
  return "Must be a valid URL (e.g. https://engine.acme.com)";
143
145
  }
@@ -171,9 +173,13 @@ async function collectConfig(opts = {}) {
171
173
  const sessionSecret = generateSecret(32);
172
174
  const engineWebhookSecret = generateSecret(32);
173
175
  const internalApiKey = generateSecret(32);
176
+ const hubspotAppId = void 0;
177
+ const hubspotClientId = void 0;
178
+ const hubspotClientSecret = void 0;
174
179
  return {
175
180
  appUrl: appUrl.trim().replace(/\/+$/, ""),
176
181
  engineUrl: engineUrl.trim().replace(/\/+$/, ""),
182
+ crmType,
177
183
  managedDb,
178
184
  databaseUrl,
179
185
  dbPassword: managedDb ? dbPassword : "",
@@ -186,7 +192,10 @@ async function collectConfig(opts = {}) {
186
192
  feedbackToEmail: "",
187
193
  sessionSecret,
188
194
  engineWebhookSecret,
189
- internalApiKey
195
+ internalApiKey,
196
+ hubspotAppId,
197
+ hubspotClientId,
198
+ hubspotClientSecret
190
199
  };
191
200
  }
192
201
 
@@ -347,7 +356,18 @@ function renderEnvWeb(c) {
347
356
  ``,
348
357
  `# Email (optional)`,
349
358
  `RESEND_API_KEY=${c.resendApiKey ?? ""}`,
350
- `FEEDBACK_TO_EMAIL=${c.feedbackToEmail ?? ""}`
359
+ `FEEDBACK_TO_EMAIL=${c.feedbackToEmail ?? ""}`,
360
+ ``,
361
+ `# CRM`,
362
+ `CRM_TYPE=${c.crmType ?? "salesforce"}`,
363
+ ...c.crmType === "hubspot" ? [
364
+ ``,
365
+ `# HubSpot`,
366
+ `HUBSPOT_CLIENT_ID=${c.hubspotClientId ?? ""}`,
367
+ `HUBSPOT_CLIENT_SECRET=${c.hubspotClientSecret ?? ""}`,
368
+ `HUBSPOT_APP_ID=${c.hubspotAppId ?? ""}`,
369
+ `HUBSPOT_REDIRECT_URI=${c.appUrl}/api/auth/hubspot/callback`
370
+ ] : []
351
371
  ].join("\n");
352
372
  }
353
373
 
@@ -377,7 +397,12 @@ function renderEnvEngine(c) {
377
397
  `# License`,
378
398
  `LICENSE_KEY=${c.licenseKey ?? ""}`,
379
399
  `LICENSE_TIER=${c.licenseTier}`,
380
- `LICENSE_API_URL=https://lead-routing-license.artyagi2011.workers.dev`
400
+ `LICENSE_API_URL=https://lead-routing-license.artyagi2011.workers.dev`,
401
+ ...c.crmType === "hubspot" ? [
402
+ ``,
403
+ `# HubSpot`,
404
+ `HUBSPOT_CLIENT_SECRET=${c.hubspotClientSecret ?? ""}`
405
+ ] : []
381
406
  ].join("\n");
382
407
  }
383
408
 
@@ -538,7 +563,11 @@ function generateFiles(cfg, sshCfg, license = { licenseTier: "free" }) {
538
563
  resendApiKey: cfg.resendApiKey || void 0,
539
564
  feedbackToEmail: cfg.feedbackToEmail || void 0,
540
565
  licenseKey: license.licenseKey,
541
- licenseTier: license.licenseTier
566
+ licenseTier: license.licenseTier,
567
+ crmType: cfg.crmType,
568
+ hubspotClientId: cfg.hubspotClientId,
569
+ hubspotClientSecret: cfg.hubspotClientSecret,
570
+ hubspotAppId: cfg.hubspotAppId
542
571
  });
543
572
  const envWeb = join2(dir, ".env.web");
544
573
  writeFileSync2(envWeb, envWebContent, "utf8");
@@ -549,7 +578,9 @@ function generateFiles(cfg, sshCfg, license = { licenseTier: "free" }) {
549
578
  engineWebhookSecret: cfg.engineWebhookSecret,
550
579
  internalApiKey: cfg.internalApiKey,
551
580
  licenseKey: license.licenseKey,
552
- licenseTier: license.licenseTier
581
+ licenseTier: license.licenseTier,
582
+ crmType: cfg.crmType,
583
+ hubspotClientSecret: cfg.hubspotClientSecret
553
584
  });
554
585
  const envEngine = join2(dir, ".env.engine");
555
586
  writeFileSync2(envEngine, envEngineContent, "utf8");
@@ -557,6 +588,7 @@ function generateFiles(cfg, sshCfg, license = { licenseTier: "free" }) {
557
588
  writeConfig(dir, {
558
589
  appUrl: cfg.appUrl,
559
590
  engineUrl: cfg.engineUrl,
591
+ crmType: cfg.crmType,
560
592
  installDir: dir,
561
593
  remoteDir: sshCfg.remoteDir,
562
594
  ssh: {
@@ -1315,29 +1347,46 @@ After verifying, press Enter to continue.`,
1315
1347
  try {
1316
1348
  log7.step("Step 1/9 License validation");
1317
1349
  const licenseResult = { tier: auth.customer.tier, key: void 0 };
1318
- log7.step("Step 2/9 Install Salesforce Package");
1319
- note3(
1320
- `The Lead Router managed package installs the required Connected App,
1321
- triggers, and custom objects in your Salesforce org.
1322
-
1323
- Install URL: ${chalk2.cyan(MANAGED_PACKAGE_INSTALL_URL)}`,
1324
- "Salesforce Package"
1325
- );
1326
- log7.info("Opening install URL in your browser...");
1327
- openBrowser(MANAGED_PACKAGE_INSTALL_URL);
1328
- log7.info(`${chalk2.dim("If the browser didn't open, visit the URL above manually.")}`);
1329
- const installed = await confirm({
1330
- message: 'Have you installed the package? (Click "Install for All Users" in Salesforce)',
1331
- initialValue: false
1350
+ const crmChoice = await select({
1351
+ message: "Which CRM will you connect?",
1352
+ options: [
1353
+ { value: "salesforce", label: "Salesforce" },
1354
+ { value: "hubspot", label: "HubSpot" }
1355
+ ]
1332
1356
  });
1333
- if (isCancel3(installed)) {
1357
+ if (isCancel3(crmChoice)) {
1334
1358
  cancel3("Setup cancelled.");
1335
1359
  process.exit(0);
1336
1360
  }
1337
- if (!installed) {
1338
- log7.warn("You can install the package later from Integrations \u2192 Salesforce in the web app.");
1361
+ const crmType = crmChoice;
1362
+ if (crmType === "salesforce") {
1363
+ log7.step("Step 2/9 Install Salesforce Package");
1364
+ note3(
1365
+ `The Lead Router managed package installs the required Connected App,
1366
+ triggers, and custom objects in your Salesforce org.
1367
+
1368
+ Install URL: ${chalk2.cyan(MANAGED_PACKAGE_INSTALL_URL)}`,
1369
+ "Salesforce Package"
1370
+ );
1371
+ log7.info("Opening install URL in your browser...");
1372
+ openBrowser(MANAGED_PACKAGE_INSTALL_URL);
1373
+ log7.info(`${chalk2.dim("If the browser didn't open, visit the URL above manually.")}`);
1374
+ const installed = await confirm({
1375
+ message: 'Have you installed the package? (Click "Install for All Users" in Salesforce)',
1376
+ initialValue: false
1377
+ });
1378
+ if (isCancel3(installed)) {
1379
+ cancel3("Setup cancelled.");
1380
+ process.exit(0);
1381
+ }
1382
+ if (!installed) {
1383
+ log7.warn("You can install the package later from Integrations \u2192 Salesforce in the web app.");
1384
+ } else {
1385
+ log7.success("Salesforce package installed");
1386
+ }
1339
1387
  } else {
1340
- log7.success("Salesforce package installed");
1388
+ log7.step("Step 2/9 HubSpot credentials (collected in step 5)");
1389
+ log7.info("HubSpot credentials will be collected during configuration.");
1341
1390
  }
1342
1391
  log7.step("Step 3/9 Checking local prerequisites");
1343
1392
  await checkPrerequisites();
@@ -1374,7 +1423,8 @@ Install URL: ${chalk2.cyan(MANAGED_PACKAGE_INSTALL_URL)}`,
1374
1423
  log7.step("Step 5/9 Configuration");
1375
1424
  let cfg = await collectConfig({
1376
1425
  externalDb: options.externalDb,
1377
- externalRedis: options.externalRedis
1426
+ externalRedis: options.externalRedis,
1427
+ crmType
1378
1428
  });
1379
1429
  await checkDnsResolvable(cfg.appUrl, cfg.engineUrl);
1380
1430
  log7.step("Step 6/9 Generating config files");
@@ -1472,11 +1522,19 @@ Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routin
1472
1522
  log7.success("Removed ADMIN_PASSWORD from .env.web (no longer needed after seed)");
1473
1523
  } catch {
1474
1524
  }
1475
- note3(
1476
- `Open ${cfg.appUrl} \u2192 Integrations \u2192 Salesforce to connect your org.
1525
+ if (crmType === "salesforce") {
1526
+ note3(
1527
+ `Open ${cfg.appUrl} \u2192 Integrations \u2192 Salesforce to connect your org.
1477
1528
  The managed package is already installed \u2014 just click "Connect Salesforce" to authorize.`,
1478
- "Next: Connect Salesforce"
1479
- );
1529
+ "Next: Connect Salesforce"
1530
+ );
1531
+ } else {
1532
+ note3(
1533
+ `Open ${cfg.appUrl} \u2192 Integrations \u2192 HubSpot to connect your portal.
1534
+ Click "Connect HubSpot" to authorize the integration.`,
1535
+ "Next: Connect HubSpot"
1536
+ );
1537
+ }
1480
1538
  try {
1481
1539
  let webhookSecret = "";
1482
1540
  const envEngineContent = readFileSync4(join5(dir, ".env.engine"), "utf-8");
@@ -1529,6 +1587,11 @@ The managed package is already installed \u2014 just click "Connect Salesforce"
1529
1587
  }
1530
1588
  } catch {
1531
1589
  }
1590
+ const crmSteps = crmType === "salesforce" ? ` ${chalk2.cyan("2.")} Go to Integrations \u2192 Salesforce \u2192 Connect
1591
+ ${chalk2.cyan("3.")} Complete the onboarding wizard in Salesforce
1592
+ ` : ` ${chalk2.cyan("2.")} Go to Integrations \u2192 HubSpot \u2192 Connect
1593
+ ${chalk2.cyan("3.")} Authorize the HubSpot integration
1594
+ `;
1532
1595
  outro(
1533
1596
  chalk2.green("\u2714 You're live!") + `
1534
1597
 
@@ -1538,9 +1601,7 @@ The managed package is already installed \u2014 just click "Connect Salesforce"
1538
1601
  Admin email: ${chalk2.white(cfg.adminEmail)}
1539
1602
 
1540
1603
  ` + chalk2.bold(" Next steps:\n") + ` ${chalk2.cyan("1.")} Open ${chalk2.cyan(cfg.appUrl)} and log in
1541
- ${chalk2.cyan("2.")} Go to Integrations \u2192 Salesforce \u2192 Connect
1542
- ${chalk2.cyan("3.")} Complete the onboarding wizard in Salesforce
1543
- ${chalk2.cyan("4.")} Create your first routing rule
1604
+ ` + crmSteps + ` ${chalk2.cyan("4.")} Create your first routing rule
1544
1605
 
1545
1606
  Run ${chalk2.cyan("lead-routing doctor")} to check service health at any time.
1546
1607
  Run ${chalk2.cyan("lead-routing deploy")} to update to a new version.`
@@ -2322,6 +2383,10 @@ async function runSfdcDeploy() {
2322
2383
  let engineUrl;
2323
2384
  const dir = findInstallDir();
2324
2385
  const config2 = dir ? readConfig(dir) : null;
2386
+ if (config2?.crmType === "hubspot") {
2387
+ log14.error("This installation is configured for HubSpot. The sfdc deploy command is only available for Salesforce.");
2388
+ process.exit(1);
2389
+ }
2325
2390
  if (config2?.appUrl && config2?.engineUrl) {
2326
2391
  appUrl = config2.appUrl;
2327
2392
  engineUrl = config2.engineUrl;
@@ -0,0 +1,233 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "CrmType" AS ENUM ('SALESFORCE', 'HUBSPOT');
3
+
4
+ -- CreateEnum
5
+ CREATE TYPE "CrmObjectType" AS ENUM ('LEAD', 'CONTACT', 'ACCOUNT', 'COMPANY', 'DEAL', 'USER');
6
+
7
+ -- CreateEnum
8
+ CREATE TYPE "SlaMetCriteria" AS ENUM ('ANY_ACTIVITY', 'STATUS_CHANGE', 'SPECIFIC_STATUS', 'MEETING_BOOKED');
9
+
10
+ -- CreateEnum
11
+ CREATE TYPE "SlaInstanceStatus" AS ENUM ('ACTIVE', 'MET', 'BREACHED', 'ESCALATED', 'CANCELLED');
12
+
13
+ -- AlterTable organizations - Add new CRM fields
14
+ ALTER TABLE "organizations" ADD COLUMN "crmType" "CrmType";
15
+ ALTER TABLE "organizations" ADD COLUMN "hubspotPortalId" TEXT;
16
+ ALTER TABLE "organizations" ADD COLUMN "hubspotAppId" TEXT;
17
+ ALTER TABLE "organizations" ADD COLUMN "hubspotSubscriptionId" TEXT;
18
+
19
+ -- Add unique constraint on hubspotPortalId
20
+ CREATE UNIQUE INDEX "organizations_hubspotPortalId_key" ON "organizations"("hubspotPortalId");
21
+
22
+ -- AlterTable users - Rename sfdcUserId to crmUserId
23
+ ALTER TABLE "users" RENAME COLUMN "sfdcUserId" TO "crmUserId";
24
+
25
+ -- Update unique constraint on users
26
+ DROP INDEX IF EXISTS "users_orgId_sfdcUserId_key";
27
+ CREATE UNIQUE INDEX "users_orgId_crmUserId_key" ON "users"("orgId", "crmUserId");
28
+
29
+ -- AlterTable routing_logs - Rename sfdcRecordId to crmRecordId and update objectType
30
+ ALTER TABLE "routing_logs" RENAME COLUMN "sfdcRecordId" TO "crmRecordId";
31
+ ALTER TABLE "routing_logs" ADD COLUMN "objectType_new" "CrmObjectType";
32
+
33
+ -- Migrate existing SfdcObjectType values to CrmObjectType
34
+ UPDATE "routing_logs" SET "objectType_new" = 'LEAD' WHERE "objectType" = 'LEAD';
35
+ UPDATE "routing_logs" SET "objectType_new" = 'CONTACT' WHERE "objectType" = 'CONTACT';
36
+ UPDATE "routing_logs" SET "objectType_new" = 'ACCOUNT' WHERE "objectType" = 'ACCOUNT';
37
+
38
+ -- Drop old column and rename new one
39
+ ALTER TABLE "routing_logs" DROP COLUMN "objectType";
40
+ ALTER TABLE "routing_logs" RENAME COLUMN "objectType_new" TO "objectType";
41
+ ALTER TABLE "routing_logs" ALTER COLUMN "objectType" SET NOT NULL;
42
+
43
+ -- Update routing_logs index
44
+ DROP INDEX IF EXISTS "routing_logs_orgId_sfdcRecordId_createdAt_idx";
45
+ CREATE INDEX "routing_logs_orgId_crmRecordId_createdAt_idx" ON "routing_logs"("orgId", "crmRecordId", "createdAt");
46
+
47
+ -- AlterTable conversion_tracking - Rename sfdcLeadId to crmRecordId
48
+ ALTER TABLE "conversion_tracking" RENAME COLUMN "sfdcLeadId" TO "crmRecordId";
49
+
50
+ -- Update conversion_tracking index
51
+ DROP INDEX IF EXISTS "conversion_tracking_orgId_sfdcLeadId_idx";
52
+ CREATE INDEX "conversion_tracking_orgId_crmRecordId_idx" ON "conversion_tracking"("orgId", "crmRecordId");
53
+
54
+ -- AlterTable routing_rules - Migrate objectType to CrmObjectType
55
+ ALTER TABLE "routing_rules" ADD COLUMN "objectType_new" "CrmObjectType";
56
+ UPDATE "routing_rules" SET "objectType_new" = 'LEAD' WHERE "objectType" = 'LEAD';
57
+ UPDATE "routing_rules" SET "objectType_new" = 'CONTACT' WHERE "objectType" = 'CONTACT';
58
+ UPDATE "routing_rules" SET "objectType_new" = 'ACCOUNT' WHERE "objectType" = 'ACCOUNT';
59
+ ALTER TABLE "routing_rules" DROP COLUMN "objectType";
60
+ ALTER TABLE "routing_rules" RENAME COLUMN "objectType_new" TO "objectType";
61
+ ALTER TABLE "routing_rules" ALTER COLUMN "objectType" SET NOT NULL;
62
+
63
+ -- AlterTable field_schemas - Migrate objectType to CrmObjectType
64
+ ALTER TABLE "field_schemas" ADD COLUMN "objectType_new" "CrmObjectType";
65
+ UPDATE "field_schemas" SET "objectType_new" = 'LEAD' WHERE "objectType" = 'LEAD';
66
+ UPDATE "field_schemas" SET "objectType_new" = 'CONTACT' WHERE "objectType" = 'CONTACT';
67
+ UPDATE "field_schemas" SET "objectType_new" = 'ACCOUNT' WHERE "objectType" = 'ACCOUNT';
68
+ ALTER TABLE "field_schemas" DROP COLUMN "objectType";
69
+ ALTER TABLE "field_schemas" RENAME COLUMN "objectType_new" TO "objectType";
70
+ ALTER TABLE "field_schemas" ALTER COLUMN "objectType" SET NOT NULL;
71
+
72
+ -- Update field_schemas unique constraint
73
+ DROP INDEX IF EXISTS "field_schemas_orgId_objectType_fieldApiName_key";
74
+ CREATE UNIQUE INDEX "field_schemas_orgId_objectType_fieldApiName_key" ON "field_schemas"("orgId", "objectType", "fieldApiName");
75
+
76
+ -- AlterTable routing_daily_aggregates - Migrate objectType to CrmObjectType
77
+ ALTER TABLE "routing_daily_aggregates" ADD COLUMN "objectType_new" "CrmObjectType";
78
+ UPDATE "routing_daily_aggregates" SET "objectType_new" = 'LEAD' WHERE "objectType" = 'LEAD';
79
+ UPDATE "routing_daily_aggregates" SET "objectType_new" = 'CONTACT' WHERE "objectType" = 'CONTACT';
80
+ UPDATE "routing_daily_aggregates" SET "objectType_new" = 'ACCOUNT' WHERE "objectType" = 'ACCOUNT';
81
+ ALTER TABLE "routing_daily_aggregates" DROP COLUMN "objectType";
82
+ ALTER TABLE "routing_daily_aggregates" RENAME COLUMN "objectType_new" TO "objectType";
83
+
84
+ -- Update routing_daily_aggregates unique constraint
85
+ DROP INDEX IF EXISTS "routing_daily_aggregates_orgId_date_ruleId_pathLabel_branchId_key";
86
+ CREATE UNIQUE INDEX "routing_daily_aggregates_orgId_date_ruleId_pathLabel_branchId_key" ON "routing_daily_aggregates"("orgId", "date", "ruleId", "pathLabel", "branchId", "teamId", "assigneeId", "objectType");
87
+
88
+ -- AlterTable routing_flows - Migrate objectType to CrmObjectType
89
+ ALTER TABLE "routing_flows" ADD COLUMN "objectType_new" "CrmObjectType";
90
+ UPDATE "routing_flows" SET "objectType_new" = 'LEAD' WHERE "objectType" = 'LEAD';
91
+ UPDATE "routing_flows" SET "objectType_new" = 'CONTACT' WHERE "objectType" = 'CONTACT';
92
+ UPDATE "routing_flows" SET "objectType_new" = 'ACCOUNT' WHERE "objectType" = 'ACCOUNT';
93
+ ALTER TABLE "routing_flows" DROP COLUMN "objectType";
94
+ ALTER TABLE "routing_flows" RENAME COLUMN "objectType_new" TO "objectType";
95
+ ALTER TABLE "routing_flows" ALTER COLUMN "objectType" SET NOT NULL;
96
+
97
+ -- Update routing_flows unique constraint
98
+ DROP INDEX IF EXISTS "routing_flows_orgId_objectType_key";
99
+ CREATE UNIQUE INDEX "routing_flows_orgId_objectType_key" ON "routing_flows"("orgId", "objectType");
100
+
101
+ -- Drop the function that depends on SfdcObjectType BEFORE dropping the type
102
+ DROP FUNCTION IF EXISTS immutable_object_type_text("SfdcObjectType");
103
+ DROP INDEX IF EXISTS routing_daily_aggregates_unique_dimensions;
104
+
105
+ -- Now we can safely drop the old SfdcObjectType enum
106
+ DROP TYPE "SfdcObjectType";
107
+
108
+ -- CreateTable sla_policies
109
+ CREATE TABLE "sla_policies" (
110
+ "id" TEXT NOT NULL,
111
+ "orgId" TEXT NOT NULL,
112
+ "name" TEXT NOT NULL,
113
+ "deadlineMinutes" INTEGER NOT NULL,
114
+ "metCriteria" "SlaMetCriteria" NOT NULL DEFAULT 'ANY_ACTIVITY',
115
+ "metTargetValue" TEXT,
116
+ "breachAction" TEXT NOT NULL DEFAULT 'REROUTE',
117
+ "maxReroutes" INTEGER NOT NULL DEFAULT 3,
118
+ "enforceBusinessHours" BOOLEAN NOT NULL DEFAULT false,
119
+ "businessHoursStart" TEXT,
120
+ "businessHoursEnd" TEXT,
121
+ "businessTimezone" TEXT,
122
+ "businessDays" TEXT,
123
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
124
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
125
+ "updatedAt" TIMESTAMP(3) NOT NULL,
126
+
127
+ CONSTRAINT "sla_policies_pkey" PRIMARY KEY ("id")
128
+ );
129
+
130
+ -- CreateTable sla_rule_assignments
131
+ CREATE TABLE "sla_rule_assignments" (
132
+ "id" TEXT NOT NULL,
133
+ "slaPolicyId" TEXT NOT NULL,
134
+ "ruleId" TEXT,
135
+ "teamId" TEXT,
136
+ "branchId" TEXT,
137
+
138
+ CONSTRAINT "sla_rule_assignments_pkey" PRIMARY KEY ("id")
139
+ );
140
+
141
+ -- CreateTable sla_instances
142
+ CREATE TABLE "sla_instances" (
143
+ "id" TEXT NOT NULL,
144
+ "orgId" TEXT NOT NULL,
145
+ "slaPolicyId" TEXT NOT NULL,
146
+ "routingLogId" TEXT NOT NULL,
147
+ "crmRecordId" TEXT NOT NULL,
148
+ "objectType" "CrmObjectType" NOT NULL,
149
+ "assigneeId" TEXT NOT NULL,
150
+ "ruleId" TEXT,
151
+ "status" "SlaInstanceStatus" NOT NULL DEFAULT 'ACTIVE',
152
+ "startedAt" TIMESTAMP(3) NOT NULL,
153
+ "deadline" TIMESTAMP(3) NOT NULL,
154
+ "metAt" TIMESTAMP(3),
155
+ "breachedAt" TIMESTAMP(3),
156
+ "responseTimeMs" INTEGER,
157
+ "breachAction" TEXT,
158
+ "reassignedTo" TEXT,
159
+ "rerouteCount" INTEGER NOT NULL DEFAULT 0,
160
+ "lastAttemptAt" TIMESTAMP(3),
161
+ "attemptCount" INTEGER NOT NULL DEFAULT 0,
162
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
163
+ "updatedAt" TIMESTAMP(3) NOT NULL,
164
+
165
+ CONSTRAINT "sla_instances_pkey" PRIMARY KEY ("id")
166
+ );
167
+
168
+ -- CreateIndex
169
+ CREATE UNIQUE INDEX "sla_policies_orgId_name_key" ON "sla_policies"("orgId", "name");
170
+
171
+ -- CreateIndex
172
+ CREATE UNIQUE INDEX "sla_rule_assignments_slaPolicyId_ruleId_teamId_branchId_key" ON "sla_rule_assignments"("slaPolicyId", "ruleId", "teamId", "branchId");
173
+
174
+ -- CreateIndex
175
+ CREATE UNIQUE INDEX "sla_instances_routingLogId_key" ON "sla_instances"("routingLogId");
176
+
177
+ -- CreateIndex
178
+ CREATE INDEX "sla_instances_status_deadline_idx" ON "sla_instances"("status", "deadline");
179
+
180
+ -- CreateIndex
181
+ CREATE INDEX "sla_instances_orgId_status_idx" ON "sla_instances"("orgId", "status");
182
+
183
+ -- CreateIndex
184
+ CREATE INDEX "sla_instances_orgId_crmRecordId_status_idx" ON "sla_instances"("orgId", "crmRecordId", "status");
185
+
186
+ -- CreateIndex
187
+ CREATE INDEX "sla_instances_orgId_slaPolicyId_status_idx" ON "sla_instances"("orgId", "slaPolicyId", "status");
188
+
189
+ -- AddForeignKey
190
+ ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
191
+
192
+ -- AddForeignKey
193
+ ALTER TABLE "sla_rule_assignments" ADD CONSTRAINT "sla_rule_assignments_slaPolicyId_fkey" FOREIGN KEY ("slaPolicyId") REFERENCES "sla_policies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
194
+
195
+ -- AddForeignKey
196
+ ALTER TABLE "sla_rule_assignments" ADD CONSTRAINT "sla_rule_assignments_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "routing_rules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
197
+
198
+ -- AddForeignKey
199
+ ALTER TABLE "sla_rule_assignments" ADD CONSTRAINT "sla_rule_assignments_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "round_robin_teams"("id") ON DELETE CASCADE ON UPDATE CASCADE;
200
+
201
+ -- AddForeignKey
202
+ ALTER TABLE "sla_rule_assignments" ADD CONSTRAINT "sla_rule_assignments_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "routing_branches"("id") ON DELETE CASCADE ON UPDATE CASCADE;
203
+
204
+ -- AddForeignKey
205
+ ALTER TABLE "sla_instances" ADD CONSTRAINT "sla_instances_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
206
+
207
+ -- AddForeignKey
208
+ ALTER TABLE "sla_instances" ADD CONSTRAINT "sla_instances_slaPolicyId_fkey" FOREIGN KEY ("slaPolicyId") REFERENCES "sla_policies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
209
+
210
+ -- AddForeignKey
211
+ ALTER TABLE "sla_instances" ADD CONSTRAINT "sla_instances_routingLogId_fkey" FOREIGN KEY ("routingLogId") REFERENCES "routing_logs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
212
+
213
+ -- Update immutable_object_type_text function to use CrmObjectType
214
+ CREATE OR REPLACE FUNCTION immutable_object_type_text(val "CrmObjectType")
215
+ RETURNS text
216
+ LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS
217
+ $$SELECT val::text$$;
218
+
219
+ -- Recreate functional unique index on routing_daily_aggregates
220
+ DROP INDEX IF EXISTS routing_daily_aggregates_unique_dimensions;
221
+ CREATE UNIQUE INDEX routing_daily_aggregates_unique_dimensions
222
+ ON routing_daily_aggregates (
223
+ "orgId", date,
224
+ COALESCE("ruleId", ''),
225
+ COALESCE("pathLabel", ''),
226
+ COALESCE("branchId", ''),
227
+ COALESCE("teamId", ''),
228
+ COALESCE("assigneeId", ''),
229
+ COALESCE(immutable_object_type_text("objectType"), '')
230
+ );
231
+
232
+ -- Backfill: Set crmType to SALESFORCE for existing orgs with sfdcOrgId
233
+ UPDATE "organizations" SET "crmType" = 'SALESFORCE' WHERE "sfdcOrgId" IS NOT NULL;
@@ -10,13 +10,35 @@ datasource db {
10
10
 
11
11
  // ─── Enums ────────────────────────────────────────────────────────────────────
12
12
 
13
- enum SfdcObjectType {
13
+ enum CrmType {
14
+ SALESFORCE
15
+ HUBSPOT
16
+ }
17
+
18
+ enum CrmObjectType {
14
19
  LEAD
15
20
  CONTACT
16
21
  ACCOUNT
22
+ COMPANY
23
+ DEAL
17
24
  USER
18
25
  }
19
26
 
27
+ enum SlaMetCriteria {
28
+ ANY_ACTIVITY // notes_last_contacted changed
29
+ STATUS_CHANGE // hs_lead_status changed from initial value
30
+ SPECIFIC_STATUS // hs_lead_status reaches a target value
31
+ MEETING_BOOKED // engagements_last_meeting_booked changed
32
+ }
33
+
34
+ enum SlaInstanceStatus {
35
+ ACTIVE // Timer running
36
+ MET // Rep took action in time
37
+ BREACHED // Timer expired, breach actions executed
38
+ ESCALATED // Max re-routes exceeded, needs manual attention
39
+ CANCELLED // Record re-routed manually or deleted
40
+ }
41
+
20
42
  enum TriggerEvent {
21
43
  INSERT
22
44
  UPDATE
@@ -100,11 +122,16 @@ enum FlowNodeType {
100
122
 
101
123
  model Organization {
102
124
  id String @id @default(cuid())
125
+ crmType CrmType? // null until CRM connected
103
126
  sfdcOrgId String? @unique // null until CRM is connected in onboarding
104
127
  sfdcInstanceUrl String? // null until CRM is connected
105
128
  oauthAccessToken String? // null until CRM is connected
106
129
  oauthRefreshToken String? // null until CRM is connected
107
130
  webhookSecret String // HMAC secret for verifying Apex trigger payloads
131
+ // HubSpot-specific fields
132
+ hubspotPortalId String? @unique // null until HubSpot connected
133
+ hubspotAppId String? // HubSpot developer app ID
134
+ hubspotSubscriptionId String? // HubSpot webhook subscription ID
108
135
  // Integration / package deploy tracking
109
136
  packageDeployedAt DateTime?
110
137
  packageDeployId String?
@@ -154,6 +181,8 @@ model Organization {
154
181
  routingMode Json?
155
182
  routingFlows RoutingFlow[]
156
183
  apiTokens ApiToken[]
184
+ slaPolicies SlaPolicy[]
185
+ slaInstances SlaInstance[]
157
186
 
158
187
  @@map("organizations")
159
188
  }
@@ -192,7 +221,7 @@ model Invite {
192
221
  model User {
193
222
  id String @id @default(cuid())
194
223
  orgId String
195
- sfdcUserId String
224
+ crmUserId String // stores SFDC user ID or HubSpot owner ID
196
225
  name String
197
226
  email String
198
227
  role String?
@@ -200,7 +229,7 @@ model User {
200
229
  department String?
201
230
  isLicensed Boolean @default(false)
202
231
  licensedVia String? // "individual" | "role" | "profile" | "custom_field" — how user was licensed
203
- isActive Boolean @default(true) // false if no longer in SFDC
232
+ isActive Boolean @default(true) // false if no longer in CRM
204
233
  lastRoutedAt DateTime?
205
234
  syncedAt DateTime @default(now())
206
235
  createdAt DateTime @default(now())
@@ -211,7 +240,7 @@ model User {
211
240
  branchAssignments RoutingBranch[] @relation("BranchAssigneeUser")
212
241
  defaultOwnerRules RoutingRule[] @relation("DefaultOwnerUser")
213
242
 
214
- @@unique([orgId, sfdcUserId])
243
+ @@unique([orgId, crmUserId])
215
244
  @@map("users")
216
245
  }
217
246
 
@@ -230,6 +259,7 @@ model RoundRobinTeam {
230
259
  routingRules RoutingRule[] @relation("TeamRules")
231
260
  branchAssignments RoutingBranch[] @relation("BranchAssigneeTeam")
232
261
  defaultOwnerRules RoutingRule[] @relation("DefaultOwnerTeam")
262
+ slaRuleAssignments SlaRuleAssignment[]
233
263
 
234
264
  @@map("round_robin_teams")
235
265
  }
@@ -254,7 +284,7 @@ model TeamMember {
254
284
  model RoutingRule {
255
285
  id String @id @default(cuid())
256
286
  orgId String
257
- objectType SfdcObjectType
287
+ objectType CrmObjectType
258
288
  triggerEvent TriggerEvent
259
289
  name String
260
290
  priority Int
@@ -303,6 +333,7 @@ model RoutingRule {
303
333
  matchConfig RouteMatchConfig?
304
334
  triggerConditions TriggerCondition[]
305
335
  bulkSearchRuns BulkSearchRun[]
336
+ slaRuleAssignments SlaRuleAssignment[]
306
337
 
307
338
  @@map("routing_rules")
308
339
  }
@@ -326,6 +357,7 @@ model RoutingBranch {
326
357
 
327
358
  conditions BranchCondition[]
328
359
  routingLogs RoutingLog[]
360
+ slaRuleAssignments SlaRuleAssignment[]
329
361
 
330
362
  @@map("routing_branches")
331
363
  }
@@ -419,8 +451,8 @@ model RuleCondition {
419
451
  model RoutingLog {
420
452
  id String @id @default(cuid())
421
453
  orgId String
422
- sfdcRecordId String
423
- objectType SfdcObjectType
454
+ crmRecordId String
455
+ objectType CrmObjectType
424
456
  eventType TriggerEvent
425
457
  ruleId String?
426
458
  ruleName String?
@@ -446,12 +478,13 @@ model RoutingLog {
446
478
  org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
447
479
  branch RoutingBranch? @relation(fields: [branchId], references: [id])
448
480
  conversionTracking ConversionTracking?
481
+ slaInstance SlaInstance?
449
482
 
450
483
  @@index([orgId, createdAt])
451
484
  @@index([orgId, status])
452
485
  @@index([orgId, teamId])
453
486
  @@index([orgId, ruleId])
454
- @@index([orgId, sfdcRecordId, createdAt])
487
+ @@index([orgId, crmRecordId, createdAt])
455
488
  @@map("routing_logs")
456
489
  }
457
490
 
@@ -476,7 +509,7 @@ model AuditLog {
476
509
  model RoutingFlow {
477
510
  id String @id @default(cuid())
478
511
  orgId String
479
- objectType SfdcObjectType
512
+ objectType CrmObjectType
480
513
  name String @default("Untitled Flow")
481
514
  status FlowStatus @default(DRAFT)
482
515
  triggerEvent TriggerEvent @default(BOTH)
@@ -551,7 +584,7 @@ model SfdcQueue {
551
584
  model FieldSchema {
552
585
  id String @id @default(cuid())
553
586
  orgId String
554
- objectType SfdcObjectType
587
+ objectType CrmObjectType
555
588
  fieldApiName String
556
589
  fieldLabel String
557
590
  fieldType String // TEXT | NUMBER | DATE | DATETIME | BOOLEAN | PICKLIST | MULTI_PICKLIST | LOOKUP
@@ -604,7 +637,7 @@ model RoutingDailyAggregate {
604
637
  branchId String?
605
638
  teamId String?
606
639
  assigneeId String?
607
- objectType SfdcObjectType?
640
+ objectType CrmObjectType?
608
641
  successCount Int @default(0)
609
642
  failedCount Int @default(0)
610
643
  unmatchedCount Int @default(0)
@@ -629,7 +662,7 @@ model ConversionTracking {
629
662
  id String @id @default(cuid())
630
663
  orgId String
631
664
  routingLogId String @unique
632
- sfdcLeadId String
665
+ crmRecordId String
633
666
  isConverted Boolean @default(false)
634
667
  convertedAt DateTime?
635
668
  opportunityId String?
@@ -649,7 +682,7 @@ model ConversionTracking {
649
682
  routingLog RoutingLog @relation(fields: [routingLogId], references: [id], onDelete: Cascade)
650
683
 
651
684
  @@index([orgId, isConverted])
652
- @@index([orgId, sfdcLeadId])
685
+ @@index([orgId, crmRecordId])
653
686
  @@map("conversion_tracking")
654
687
  }
655
688
 
@@ -772,3 +805,80 @@ model AiChatFeedback {
772
805
  @@index([orgId, rating])
773
806
  @@map("ai_chat_feedback")
774
807
  }
808
+
809
+ model SlaPolicy {
810
+ id String @id @default(cuid())
811
+ orgId String
812
+ name String
813
+ deadlineMinutes Int // e.g., 15
814
+ metCriteria SlaMetCriteria @default(ANY_ACTIVITY)
815
+ metTargetValue String? // For SPECIFIC_STATUS: target value
816
+ breachAction String @default("REROUTE") // REROUTE | NOTIFY_ONLY
817
+ maxReroutes Int @default(3) // Circuit breaker
818
+ // Business hours (V1 — org-level, prevents midnight loops)
819
+ enforceBusinessHours Boolean @default(false)
820
+ businessHoursStart String? // "09:00"
821
+ businessHoursEnd String? // "18:00"
822
+ businessTimezone String? // "America/New_York"
823
+ businessDays String? // "1,2,3,4,5" (Mon-Fri)
824
+ isActive Boolean @default(true)
825
+ createdAt DateTime @default(now())
826
+ updatedAt DateTime @updatedAt
827
+
828
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
829
+ ruleAssignments SlaRuleAssignment[]
830
+ instances SlaInstance[]
831
+
832
+ @@unique([orgId, name])
833
+ @@map("sla_policies")
834
+ }
835
+
836
+ model SlaRuleAssignment {
837
+ id String @id @default(cuid())
838
+ slaPolicyId String
839
+ ruleId String? // Apply to entire rule
840
+ teamId String? // Apply to specific team
841
+ branchId String? // Apply to specific branch/path
842
+
843
+ slaPolicy SlaPolicy @relation(fields: [slaPolicyId], references: [id], onDelete: Cascade)
844
+ rule RoutingRule? @relation(fields: [ruleId], references: [id], onDelete: Cascade)
845
+ team RoundRobinTeam? @relation(fields: [teamId], references: [id], onDelete: Cascade)
846
+ branch RoutingBranch? @relation(fields: [branchId], references: [id], onDelete: Cascade)
847
+
848
+ @@unique([slaPolicyId, ruleId, teamId, branchId])
849
+ @@map("sla_rule_assignments")
850
+ }
851
+
852
+ model SlaInstance {
853
+ id String @id @default(cuid())
854
+ orgId String
855
+ slaPolicyId String
856
+ routingLogId String @unique
857
+ crmRecordId String
858
+ objectType CrmObjectType
859
+ assigneeId String // CRM user/owner ID
860
+ ruleId String? // Which rule triggered this
861
+ status SlaInstanceStatus @default(ACTIVE)
862
+ startedAt DateTime
863
+ deadline DateTime
864
+ metAt DateTime?
865
+ breachedAt DateTime?
866
+ responseTimeMs Int? // metAt - startedAt
867
+ breachAction String? // What was done on breach
868
+ reassignedTo String? // New owner after re-route
869
+ rerouteCount Int @default(0) // For circuit breaker
870
+ lastAttemptAt DateTime? // When sweeper last attempted breach processing (v2.1)
871
+ attemptCount Int @default(0) // Failed re-route attempt count (v2.1)
872
+ createdAt DateTime @default(now())
873
+ updatedAt DateTime @updatedAt
874
+
875
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
876
+ slaPolicy SlaPolicy @relation(fields: [slaPolicyId], references: [id], onDelete: Cascade)
877
+ routingLog RoutingLog @relation(fields: [routingLogId], references: [id], onDelete: Cascade)
878
+
879
+ @@index([status, deadline]) // Sweeper query
880
+ @@index([orgId, status]) // Per-org active count
881
+ @@index([orgId, crmRecordId, status]) // Webhook lookup + circuit breaker count
882
+ @@index([orgId, slaPolicyId, status]) // Compliance reporting
883
+ @@map("sla_instances")
884
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lead-routing/cli",
3
- "version": "0.6.9",
3
+ "version": "0.7.1",
4
4
  "description": "Self-hosted deployment CLI for Lead Routing",
5
5
  "homepage": "https://github.com/lead-routing/lead-routing",
6
6
  "keywords": [