@lead-routing/cli 0.6.8 → 0.7.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 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,39 @@ async function collectConfig(opts = {}) {
171
173
  const sessionSecret = generateSecret(32);
172
174
  const engineWebhookSecret = generateSecret(32);
173
175
  const internalApiKey = generateSecret(32);
176
+ let hubspotAppId;
177
+ let hubspotClientId;
178
+ let hubspotClientSecret;
179
+ if (crmType === "hubspot") {
180
+ note2(
181
+ "Create a HubSpot app at https://developers.hubspot.com/\nYou will need the App ID, Client ID, and Client Secret.",
182
+ "HubSpot Credentials"
183
+ );
184
+ const rawAppId = await text2({
185
+ message: "HubSpot App ID",
186
+ placeholder: "123456",
187
+ validate: (v) => !v?.trim() ? "Required" : void 0
188
+ });
189
+ if (isCancel2(rawAppId)) bail2(rawAppId);
190
+ hubspotAppId = rawAppId.trim();
191
+ const rawClientId = await text2({
192
+ message: "HubSpot Client ID",
193
+ placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
194
+ validate: (v) => !v?.trim() ? "Required" : void 0
195
+ });
196
+ if (isCancel2(rawClientId)) bail2(rawClientId);
197
+ hubspotClientId = rawClientId.trim();
198
+ const rawClientSecret = await password2({
199
+ message: "HubSpot Client Secret",
200
+ validate: (v) => !v?.trim() ? "Required" : void 0
201
+ });
202
+ if (isCancel2(rawClientSecret)) bail2(rawClientSecret);
203
+ hubspotClientSecret = rawClientSecret.trim();
204
+ }
174
205
  return {
175
206
  appUrl: appUrl.trim().replace(/\/+$/, ""),
176
207
  engineUrl: engineUrl.trim().replace(/\/+$/, ""),
208
+ crmType,
177
209
  managedDb,
178
210
  databaseUrl,
179
211
  dbPassword: managedDb ? dbPassword : "",
@@ -186,7 +218,10 @@ async function collectConfig(opts = {}) {
186
218
  feedbackToEmail: "",
187
219
  sessionSecret,
188
220
  engineWebhookSecret,
189
- internalApiKey
221
+ internalApiKey,
222
+ hubspotAppId,
223
+ hubspotClientId,
224
+ hubspotClientSecret
190
225
  };
191
226
  }
192
227
 
@@ -347,7 +382,18 @@ function renderEnvWeb(c) {
347
382
  ``,
348
383
  `# Email (optional)`,
349
384
  `RESEND_API_KEY=${c.resendApiKey ?? ""}`,
350
- `FEEDBACK_TO_EMAIL=${c.feedbackToEmail ?? ""}`
385
+ `FEEDBACK_TO_EMAIL=${c.feedbackToEmail ?? ""}`,
386
+ ``,
387
+ `# CRM`,
388
+ `CRM_TYPE=${c.crmType ?? "salesforce"}`,
389
+ ...c.crmType === "hubspot" ? [
390
+ ``,
391
+ `# HubSpot`,
392
+ `HUBSPOT_CLIENT_ID=${c.hubspotClientId ?? ""}`,
393
+ `HUBSPOT_CLIENT_SECRET=${c.hubspotClientSecret ?? ""}`,
394
+ `HUBSPOT_APP_ID=${c.hubspotAppId ?? ""}`,
395
+ `HUBSPOT_REDIRECT_URI=${c.appUrl}/api/auth/hubspot/callback`
396
+ ] : []
351
397
  ].join("\n");
352
398
  }
353
399
 
@@ -377,7 +423,12 @@ function renderEnvEngine(c) {
377
423
  `# License`,
378
424
  `LICENSE_KEY=${c.licenseKey ?? ""}`,
379
425
  `LICENSE_TIER=${c.licenseTier}`,
380
- `LICENSE_API_URL=https://lead-routing-license.artyagi2011.workers.dev`
426
+ `LICENSE_API_URL=https://lead-routing-license.artyagi2011.workers.dev`,
427
+ ...c.crmType === "hubspot" ? [
428
+ ``,
429
+ `# HubSpot`,
430
+ `HUBSPOT_CLIENT_SECRET=${c.hubspotClientSecret ?? ""}`
431
+ ] : []
381
432
  ].join("\n");
382
433
  }
383
434
 
@@ -538,7 +589,11 @@ function generateFiles(cfg, sshCfg, license = { licenseTier: "free" }) {
538
589
  resendApiKey: cfg.resendApiKey || void 0,
539
590
  feedbackToEmail: cfg.feedbackToEmail || void 0,
540
591
  licenseKey: license.licenseKey,
541
- licenseTier: license.licenseTier
592
+ licenseTier: license.licenseTier,
593
+ crmType: cfg.crmType,
594
+ hubspotClientId: cfg.hubspotClientId,
595
+ hubspotClientSecret: cfg.hubspotClientSecret,
596
+ hubspotAppId: cfg.hubspotAppId
542
597
  });
543
598
  const envWeb = join2(dir, ".env.web");
544
599
  writeFileSync2(envWeb, envWebContent, "utf8");
@@ -549,7 +604,9 @@ function generateFiles(cfg, sshCfg, license = { licenseTier: "free" }) {
549
604
  engineWebhookSecret: cfg.engineWebhookSecret,
550
605
  internalApiKey: cfg.internalApiKey,
551
606
  licenseKey: license.licenseKey,
552
- licenseTier: license.licenseTier
607
+ licenseTier: license.licenseTier,
608
+ crmType: cfg.crmType,
609
+ hubspotClientSecret: cfg.hubspotClientSecret
553
610
  });
554
611
  const envEngine = join2(dir, ".env.engine");
555
612
  writeFileSync2(envEngine, envEngineContent, "utf8");
@@ -557,6 +614,7 @@ function generateFiles(cfg, sshCfg, license = { licenseTier: "free" }) {
557
614
  writeConfig(dir, {
558
615
  appUrl: cfg.appUrl,
559
616
  engineUrl: cfg.engineUrl,
617
+ crmType: cfg.crmType,
560
618
  installDir: dir,
561
619
  remoteDir: sshCfg.remoteDir,
562
620
  ssh: {
@@ -1315,53 +1373,84 @@ After verifying, press Enter to continue.`,
1315
1373
  try {
1316
1374
  log7.step("Step 1/9 License validation");
1317
1375
  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
1376
+ const crmChoice = await select({
1377
+ message: "Which CRM will you connect?",
1378
+ options: [
1379
+ { value: "salesforce", label: "Salesforce" },
1380
+ { value: "hubspot", label: "HubSpot" }
1381
+ ]
1332
1382
  });
1333
- if (isCancel3(installed)) {
1383
+ if (isCancel3(crmChoice)) {
1334
1384
  cancel3("Setup cancelled.");
1335
1385
  process.exit(0);
1336
1386
  }
1337
- if (!installed) {
1338
- log7.warn("You can install the package later from Integrations \u2192 Salesforce in the web app.");
1387
+ const crmType = crmChoice;
1388
+ if (crmType === "salesforce") {
1389
+ log7.step("Step 2/9 Install Salesforce Package");
1390
+ note3(
1391
+ `The Lead Router managed package installs the required Connected App,
1392
+ triggers, and custom objects in your Salesforce org.
1393
+
1394
+ Install URL: ${chalk2.cyan(MANAGED_PACKAGE_INSTALL_URL)}`,
1395
+ "Salesforce Package"
1396
+ );
1397
+ log7.info("Opening install URL in your browser...");
1398
+ openBrowser(MANAGED_PACKAGE_INSTALL_URL);
1399
+ log7.info(`${chalk2.dim("If the browser didn't open, visit the URL above manually.")}`);
1400
+ const installed = await confirm({
1401
+ message: 'Have you installed the package? (Click "Install for All Users" in Salesforce)',
1402
+ initialValue: false
1403
+ });
1404
+ if (isCancel3(installed)) {
1405
+ cancel3("Setup cancelled.");
1406
+ process.exit(0);
1407
+ }
1408
+ if (!installed) {
1409
+ log7.warn("You can install the package later from Integrations \u2192 Salesforce in the web app.");
1410
+ } else {
1411
+ log7.success("Salesforce package installed");
1412
+ }
1339
1413
  } else {
1340
- log7.success("Salesforce package installed");
1414
+ log7.step("Step 2/9 HubSpot credentials (collected in step 5)");
1415
+ log7.info("HubSpot credentials will be collected during configuration.");
1341
1416
  }
1342
1417
  log7.step("Step 3/9 Checking local prerequisites");
1343
1418
  await checkPrerequisites();
1344
1419
  log7.step("Step 4/9 SSH connection");
1345
- const sshCfg = await collectSshConfig({
1420
+ let sshCfg = await collectSshConfig({
1346
1421
  sshPort: options.sshPort,
1347
1422
  sshUser: options.sshUser,
1348
1423
  sshKey: options.sshKey,
1349
1424
  remoteDir: options.remoteDir
1350
1425
  });
1351
1426
  if (!dryRun) {
1352
- try {
1353
- await ssh.connect(sshCfg);
1354
- log7.success(`Connected to ${sshCfg.host}`);
1355
- } catch (err) {
1356
- log7.error(`SSH connection failed: ${String(err)}`);
1357
- log7.info("Check your password and re-run `lead-routing init`.");
1358
- process.exit(1);
1427
+ let sshConnected = false;
1428
+ while (!sshConnected) {
1429
+ try {
1430
+ await ssh.connect(sshCfg);
1431
+ log7.success(`Connected to ${sshCfg.host}`);
1432
+ sshConnected = true;
1433
+ } catch (err) {
1434
+ log7.error(`SSH connection failed: ${String(err)}`);
1435
+ const retry = await confirm({ message: "Re-enter SSH details and try again?" });
1436
+ if (isCancel3(retry) || !retry) {
1437
+ cancel3("Setup cancelled.");
1438
+ process.exit(0);
1439
+ }
1440
+ sshCfg = await collectSshConfig({
1441
+ sshPort: options.sshPort,
1442
+ sshUser: options.sshUser,
1443
+ sshKey: options.sshKey,
1444
+ remoteDir: options.remoteDir
1445
+ });
1446
+ }
1359
1447
  }
1360
1448
  }
1361
1449
  log7.step("Step 5/9 Configuration");
1362
- const cfg = await collectConfig({
1450
+ let cfg = await collectConfig({
1363
1451
  externalDb: options.externalDb,
1364
- externalRedis: options.externalRedis
1452
+ externalRedis: options.externalRedis,
1453
+ crmType
1365
1454
  });
1366
1455
  await checkDnsResolvable(cfg.appUrl, cfg.engineUrl);
1367
1456
  log7.step("Step 6/9 Generating config files");
@@ -1391,7 +1480,66 @@ Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routin
1391
1480
  log7.step("Step 8/9 Starting services");
1392
1481
  await startServices(ssh, remoteDir);
1393
1482
  log7.step("Step 9/9 Verifying health");
1394
- await verifyHealth(cfg.appUrl, cfg.engineUrl, ssh, remoteDir);
1483
+ let healthy = false;
1484
+ while (!healthy) {
1485
+ try {
1486
+ await verifyHealth(cfg.appUrl, cfg.engineUrl, ssh, remoteDir);
1487
+ healthy = true;
1488
+ } catch (err) {
1489
+ const message = err instanceof Error ? err.message : String(err);
1490
+ log7.error(`Health check failed: ${message}`);
1491
+ const retry = await confirm({ message: "Re-enter URLs and retry?" });
1492
+ if (isCancel3(retry) || !retry) {
1493
+ log7.info(`Run ${chalk2.cyan("lead-routing init --resume")} to retry health checks later.`);
1494
+ process.exit(1);
1495
+ }
1496
+ const newAppUrl = await text3({
1497
+ message: "App URL",
1498
+ initialValue: cfg.appUrl,
1499
+ validate: (v) => {
1500
+ if (!v) return "Required";
1501
+ try {
1502
+ const u = new URL(v);
1503
+ if (u.protocol !== "https:") return "Must be HTTPS";
1504
+ } catch {
1505
+ return "Invalid URL";
1506
+ }
1507
+ }
1508
+ });
1509
+ if (isCancel3(newAppUrl)) {
1510
+ cancel3("Setup cancelled.");
1511
+ process.exit(0);
1512
+ }
1513
+ const newEngineUrl = await text3({
1514
+ message: "Engine URL",
1515
+ initialValue: cfg.engineUrl,
1516
+ validate: (v) => {
1517
+ if (!v) return "Required";
1518
+ try {
1519
+ const u = new URL(v);
1520
+ if (u.protocol !== "https:") return "Must be HTTPS";
1521
+ } catch {
1522
+ return "Invalid URL";
1523
+ }
1524
+ }
1525
+ });
1526
+ if (isCancel3(newEngineUrl)) {
1527
+ cancel3("Setup cancelled.");
1528
+ process.exit(0);
1529
+ }
1530
+ cfg.appUrl = newAppUrl.trim().replace(/\/+$/, "");
1531
+ cfg.engineUrl = newEngineUrl.trim().replace(/\/+$/, "");
1532
+ log7.step("Regenerating config files with new URLs...");
1533
+ const { dir: newDir } = generateFiles(cfg, sshCfg, {
1534
+ licenseKey: licenseResult.key,
1535
+ licenseTier: licenseResult.tier
1536
+ });
1537
+ await uploadFiles(ssh, newDir, remoteDir);
1538
+ log7.step("Restarting services with new config...");
1539
+ await startServices(ssh, remoteDir);
1540
+ log7.step("Retrying health check...");
1541
+ }
1542
+ }
1395
1543
  try {
1396
1544
  const envWebPath = join5(dir, ".env.web");
1397
1545
  const envContent = readFileSync4(envWebPath, "utf-8");
@@ -1400,11 +1548,19 @@ Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routin
1400
1548
  log7.success("Removed ADMIN_PASSWORD from .env.web (no longer needed after seed)");
1401
1549
  } catch {
1402
1550
  }
1403
- note3(
1404
- `Open ${cfg.appUrl} \u2192 Integrations \u2192 Salesforce to connect your org.
1551
+ if (crmType === "salesforce") {
1552
+ note3(
1553
+ `Open ${cfg.appUrl} \u2192 Integrations \u2192 Salesforce to connect your org.
1405
1554
  The managed package is already installed \u2014 just click "Connect Salesforce" to authorize.`,
1406
- "Next: Connect Salesforce"
1407
- );
1555
+ "Next: Connect Salesforce"
1556
+ );
1557
+ } else {
1558
+ note3(
1559
+ `Open ${cfg.appUrl} \u2192 Integrations \u2192 HubSpot to connect your portal.
1560
+ Click "Connect HubSpot" to authorize the integration.`,
1561
+ "Next: Connect HubSpot"
1562
+ );
1563
+ }
1408
1564
  try {
1409
1565
  let webhookSecret = "";
1410
1566
  const envEngineContent = readFileSync4(join5(dir, ".env.engine"), "utf-8");
@@ -1457,6 +1613,11 @@ The managed package is already installed \u2014 just click "Connect Salesforce"
1457
1613
  }
1458
1614
  } catch {
1459
1615
  }
1616
+ const crmSteps = crmType === "salesforce" ? ` ${chalk2.cyan("2.")} Go to Integrations \u2192 Salesforce \u2192 Connect
1617
+ ${chalk2.cyan("3.")} Complete the onboarding wizard in Salesforce
1618
+ ` : ` ${chalk2.cyan("2.")} Go to Integrations \u2192 HubSpot \u2192 Connect
1619
+ ${chalk2.cyan("3.")} Authorize the HubSpot integration
1620
+ `;
1460
1621
  outro(
1461
1622
  chalk2.green("\u2714 You're live!") + `
1462
1623
 
@@ -1466,9 +1627,7 @@ The managed package is already installed \u2014 just click "Connect Salesforce"
1466
1627
  Admin email: ${chalk2.white(cfg.adminEmail)}
1467
1628
 
1468
1629
  ` + chalk2.bold(" Next steps:\n") + ` ${chalk2.cyan("1.")} Open ${chalk2.cyan(cfg.appUrl)} and log in
1469
- ${chalk2.cyan("2.")} Go to Integrations \u2192 Salesforce \u2192 Connect
1470
- ${chalk2.cyan("3.")} Complete the onboarding wizard in Salesforce
1471
- ${chalk2.cyan("4.")} Create your first routing rule
1630
+ ` + crmSteps + ` ${chalk2.cyan("4.")} Create your first routing rule
1472
1631
 
1473
1632
  Run ${chalk2.cyan("lead-routing doctor")} to check service health at any time.
1474
1633
  Run ${chalk2.cyan("lead-routing deploy")} to update to a new version.`
@@ -2250,6 +2409,10 @@ async function runSfdcDeploy() {
2250
2409
  let engineUrl;
2251
2410
  const dir = findInstallDir();
2252
2411
  const config2 = dir ? readConfig(dir) : null;
2412
+ if (config2?.crmType === "hubspot") {
2413
+ log14.error("This installation is configured for HubSpot. The sfdc deploy command is only available for Salesforce.");
2414
+ process.exit(1);
2415
+ }
2253
2416
  if (config2?.appUrl && config2?.engineUrl) {
2254
2417
  appUrl = config2.appUrl;
2255
2418
  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.8",
3
+ "version": "0.7.0",
4
4
  "description": "Self-hosted deployment CLI for Lead Routing",
5
5
  "homepage": "https://github.com/lead-routing/lead-routing",
6
6
  "keywords": [