@lead-routing/cli 0.6.0 → 0.6.2

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
@@ -491,6 +491,8 @@ function writeConfig(dir, config2) {
491
491
  function findInstallDir(startDir = process.cwd()) {
492
492
  const candidate = join(startDir, "lead-routing.json");
493
493
  if (existsSync2(candidate)) return startDir;
494
+ const dev = join(startDir, "lead-routing-dev", "lead-routing.json");
495
+ if (existsSync2(dev)) return join(startDir, "lead-routing-dev");
494
496
  const nested = join(startDir, "lead-routing", "lead-routing.json");
495
497
  if (existsSync2(nested)) return join(startDir, "lead-routing");
496
498
  return null;
@@ -1589,8 +1591,10 @@ function runConfigShow() {
1589
1591
  }
1590
1592
 
1591
1593
  // src/commands/sfdc.ts
1592
- import { intro as intro5, outro as outro5, text as text3, log as log15 } from "@clack/prompts";
1593
- import chalk7 from "chalk";
1594
+ import { intro as intro5, outro as outro5, text as text3, note as note4, confirm as confirm2, log as log14, isCancel as isCancel4, cancel as cancel4 } from "@clack/prompts";
1595
+ import chalk6 from "chalk";
1596
+ import { exec as exec2 } from "child_process";
1597
+ import { platform as platform2 } from "os";
1594
1598
 
1595
1599
  // src/steps/sfdc-deploy-inline.ts
1596
1600
  import { readFileSync as readFileSync7, writeFileSync as writeFileSync7, existsSync as existsSync5, cpSync, rmSync } from "fs";
@@ -1719,9 +1723,9 @@ Content-Type: application/zip\r
1719
1723
  body
1720
1724
  });
1721
1725
  if (!res.ok) {
1722
- const text6 = await res.text();
1726
+ const text7 = await res.text();
1723
1727
  throw new Error(
1724
- `Metadata deploy request failed (${res.status}): ${text6}`
1728
+ `Metadata deploy request failed (${res.status}): ${text7}`
1725
1729
  );
1726
1730
  }
1727
1731
  const result = await res.json();
@@ -1738,9 +1742,9 @@ Content-Type: application/zip\r
1738
1742
  const url = `${this.baseUrl}/metadata/deployRequest/${deployId}?includeDetails=true`;
1739
1743
  const res = await fetch(url, { headers: this.headers() });
1740
1744
  if (!res.ok) {
1741
- const text6 = await res.text();
1745
+ const text7 = await res.text();
1742
1746
  throw new Error(
1743
- `Deploy status check failed (${res.status}): ${text6}`
1747
+ `Deploy status check failed (${res.status}): ${text7}`
1744
1748
  );
1745
1749
  }
1746
1750
  const data = await res.json();
@@ -2090,50 +2094,12 @@ Ensure the app is running and the URL is correct.`
2090
2094
  return { accessToken, instanceUrl };
2091
2095
  }
2092
2096
 
2093
- // src/steps/app-launcher-guide.ts
2094
- import { note as note4, confirm as confirm2, isCancel as isCancel4, log as log14 } from "@clack/prompts";
2095
- import chalk6 from "chalk";
2096
- async function guideAppLauncherSetup(appUrl) {
2097
- note4(
2098
- `Complete the following steps in Salesforce now:
2099
-
2100
- ${chalk6.cyan("1.")} Open ${chalk6.bold("App Launcher")} (grid icon, top-left in Salesforce)
2101
- ${chalk6.cyan("2.")} Search for ${chalk6.white('"Lead Router Setup"')} and click it
2102
- ${chalk6.cyan("3.")} Click ${chalk6.white('"Connect to Lead Router"')}
2103
- \u2192 You will be redirected to ${chalk6.dim(appUrl)} and back
2104
- \u2192 Authorize the OAuth connection when prompted
2105
-
2106
- ${chalk6.cyan("4.")} ${chalk6.bold("Step 1")} \u2014 wait for the ${chalk6.green('"Connected"')} checkmark (~5 sec)
2107
- ${chalk6.cyan("5.")} ${chalk6.bold("Step 2")} \u2014 click ${chalk6.white("Activate")} to enable Lead triggers
2108
- ${chalk6.cyan("6.")} ${chalk6.bold("Step 3")} \u2014 click ${chalk6.white("Sync Fields")} to index your Lead field schema
2109
- ${chalk6.cyan("7.")} ${chalk6.bold("Step 4")} \u2014 click ${chalk6.white("Send Test")} to fire a test routing event
2110
- \u2192 ${chalk6.dim('"Test successful"')} or ${chalk6.dim('"No matching rule"')} are both valid
2111
-
2112
- ` + chalk6.dim("Keep this terminal open while you complete the wizard."),
2113
- "Complete Salesforce setup"
2114
- );
2115
- const done = await confirm2({
2116
- message: "Have you completed the App Launcher wizard?",
2117
- initialValue: false
2118
- });
2119
- if (isCancel4(done)) {
2120
- log14.warn(
2121
- "Wizard skipped. Run `lead-routing sfdc deploy` to retry the Salesforce setup."
2122
- );
2123
- return;
2124
- }
2125
- if (!done) {
2126
- log14.warn(
2127
- `No problem \u2014 complete it at your own pace.
2128
- Open App Launcher \u2192 Lead Router Setup \u2192 Connect to Lead Router
2129
- Dashboard: ${appUrl}`
2130
- );
2131
- } else {
2132
- log14.success("Salesforce setup complete");
2133
- }
2134
- }
2135
-
2136
2097
  // src/commands/sfdc.ts
2098
+ var MANAGED_PACKAGE_INSTALL_URL2 = "https://login.salesforce.com/packaging/installPackage.apexp?p0=04tgL000000CTnp";
2099
+ function openBrowser2(url) {
2100
+ const cmd = platform2() === "darwin" ? "open" : "xdg-open";
2101
+ exec2(`${cmd} ${JSON.stringify(url)}`);
2102
+ }
2137
2103
  async function runSfdcDeploy() {
2138
2104
  intro5("Lead Routing \u2014 Deploy Salesforce Package");
2139
2105
  let appUrl;
@@ -2143,9 +2109,9 @@ async function runSfdcDeploy() {
2143
2109
  if (config2?.appUrl && config2?.engineUrl) {
2144
2110
  appUrl = config2.appUrl;
2145
2111
  engineUrl = config2.engineUrl;
2146
- log15.info(`Using config from ${dir}/lead-routing.json`);
2112
+ log14.info(`Using config from ${dir}/lead-routing.json`);
2147
2113
  } else {
2148
- log15.warn("No lead-routing.json found \u2014 enter the URLs from your installation.");
2114
+ log14.warn("No lead-routing.json found \u2014 enter the URLs from your installation.");
2149
2115
  const rawApp = await text3({
2150
2116
  message: "App URL (e.g. https://leads.acme.com)",
2151
2117
  validate: (v) => !v ? "Required" : void 0
@@ -2159,6 +2125,29 @@ async function runSfdcDeploy() {
2159
2125
  if (typeof rawEngine === "symbol") process.exit(0);
2160
2126
  engineUrl = rawEngine.trim();
2161
2127
  }
2128
+ note4(
2129
+ `The Lead Router managed package installs the required Connected App,
2130
+ triggers, and custom objects in your Salesforce org.
2131
+
2132
+ Install URL: ${chalk6.cyan(MANAGED_PACKAGE_INSTALL_URL2)}`,
2133
+ "Salesforce Package"
2134
+ );
2135
+ log14.info("Opening install URL in your browser...");
2136
+ openBrowser2(MANAGED_PACKAGE_INSTALL_URL2);
2137
+ log14.info(chalk6.dim("If the browser didn't open, visit the URL above manually."));
2138
+ const installed = await confirm2({
2139
+ message: 'Have you installed the package? (Click "Install for All Users" in Salesforce)',
2140
+ initialValue: false
2141
+ });
2142
+ if (isCancel4(installed)) {
2143
+ cancel4("Setup cancelled.");
2144
+ process.exit(0);
2145
+ }
2146
+ if (!installed) {
2147
+ log14.warn("You can install the package later \u2014 routing will not work until it is installed.");
2148
+ } else {
2149
+ log14.success("Salesforce package installed");
2150
+ }
2162
2151
  const alias = await text3({
2163
2152
  message: "Salesforce org alias (used to log in)",
2164
2153
  placeholder: "lead-routing",
@@ -2175,49 +2164,48 @@ async function runSfdcDeploy() {
2175
2164
  webhookSecret: config2?.engineWebhookSecret
2176
2165
  });
2177
2166
  } catch (err) {
2178
- log15.error(err instanceof Error ? err.message : String(err));
2167
+ log14.error(err instanceof Error ? err.message : String(err));
2179
2168
  process.exit(1);
2180
2169
  }
2181
- await guideAppLauncherSetup(appUrl);
2182
2170
  outro5(
2183
- chalk7.green("\u2714 Salesforce package deployed!") + `
2171
+ chalk6.green("\u2714 Salesforce package deployed!") + `
2184
2172
 
2185
- Your Lead Router dashboard: ${chalk7.cyan(appUrl)}`
2173
+ Your Lead Router dashboard: ${chalk6.cyan(appUrl)}`
2186
2174
  );
2187
2175
  }
2188
2176
 
2189
2177
  // src/commands/uninstall.ts
2190
2178
  import { rmSync as rmSync2, existsSync as existsSync6 } from "fs";
2191
- import { intro as intro6, outro as outro6, log as log16, confirm as confirm3, password as promptPassword3, isCancel as isCancel5 } from "@clack/prompts";
2192
- import chalk8 from "chalk";
2179
+ import { intro as intro6, outro as outro6, log as log15, confirm as confirm3, password as promptPassword3, isCancel as isCancel5 } from "@clack/prompts";
2180
+ import chalk7 from "chalk";
2193
2181
  async function runUninstall() {
2194
2182
  console.log();
2195
- intro6(chalk8.bold.red("Lead Routing \u2014 Uninstall"));
2183
+ intro6(chalk7.bold.red("Lead Routing \u2014 Uninstall"));
2196
2184
  const dir = findInstallDir();
2197
2185
  if (!dir) {
2198
- log16.error(
2186
+ log15.error(
2199
2187
  "No lead-routing.json found. Run this command from your install directory."
2200
2188
  );
2201
2189
  process.exit(1);
2202
2190
  }
2203
2191
  const cfg = readConfig(dir);
2204
2192
  if (!cfg.ssh) {
2205
- log16.error(
2193
+ log15.error(
2206
2194
  "This lead-routing.json was created with an older CLI version and has no SSH config. Please manually SSH into your server and run:\n\n cd ~/lead-routing && docker compose down -v && cd ~ && rm -rf ~/lead-routing"
2207
2195
  );
2208
2196
  process.exit(1);
2209
2197
  }
2210
- log16.warn(chalk8.red("This will permanently destroy:"));
2211
- log16.warn(` \u2022 Remote: ${cfg.ssh.username}@${cfg.ssh.host}:${cfg.remoteDir}`);
2212
- log16.warn(" \u2514\u2500 All containers, Postgres data, Redis data, config files");
2213
- log16.warn(` \u2022 Local: ${dir}/`);
2214
- log16.warn(" \u2514\u2500 docker-compose.yml, .env.web, .env.engine, Caddyfile, lead-routing.json");
2198
+ log15.warn(chalk7.red("This will permanently destroy:"));
2199
+ log15.warn(` \u2022 Remote: ${cfg.ssh.username}@${cfg.ssh.host}:${cfg.remoteDir}`);
2200
+ log15.warn(" \u2514\u2500 All containers, Postgres data, Redis data, config files");
2201
+ log15.warn(` \u2022 Local: ${dir}/`);
2202
+ log15.warn(" \u2514\u2500 docker-compose.yml, .env.web, .env.engine, Caddyfile, lead-routing.json");
2215
2203
  const confirmed = await confirm3({
2216
- message: chalk8.bold("Are you sure you want to uninstall? This cannot be undone."),
2204
+ message: chalk7.bold("Are you sure you want to uninstall? This cannot be undone."),
2217
2205
  initialValue: false
2218
2206
  });
2219
2207
  if (isCancel5(confirmed) || !confirmed) {
2220
- log16.info("Uninstall cancelled.");
2208
+ log15.info("Uninstall cancelled.");
2221
2209
  process.exit(0);
2222
2210
  }
2223
2211
  const ssh = new SshConnection();
@@ -2238,47 +2226,47 @@ async function runUninstall() {
2238
2226
  password: sshPassword,
2239
2227
  remoteDir: cfg.remoteDir
2240
2228
  });
2241
- log16.success(`Connected to ${cfg.ssh.host}`);
2229
+ log15.success(`Connected to ${cfg.ssh.host}`);
2242
2230
  } catch (err) {
2243
- log16.error(`SSH connection failed: ${String(err)}`);
2231
+ log15.error(`SSH connection failed: ${String(err)}`);
2244
2232
  process.exit(1);
2245
2233
  }
2246
2234
  try {
2247
2235
  const remoteDir = await ssh.resolveHome(cfg.remoteDir);
2248
- log16.step("Stopping containers and removing volumes");
2236
+ log15.step("Stopping containers and removing volumes");
2249
2237
  const { code } = await ssh.execSilent("docker compose down -v", remoteDir);
2250
2238
  if (code !== 0) {
2251
- log16.warn("docker compose down reported an error \u2014 directory may already be partially removed");
2239
+ log15.warn("docker compose down reported an error \u2014 directory may already be partially removed");
2252
2240
  } else {
2253
- log16.success("Containers and volumes removed");
2241
+ log15.success("Containers and volumes removed");
2254
2242
  }
2255
- log16.step(`Removing remote directory ${remoteDir}`);
2243
+ log15.step(`Removing remote directory ${remoteDir}`);
2256
2244
  await ssh.exec(`rm -rf ${remoteDir}`);
2257
- log16.success("Remote directory removed");
2245
+ log15.success("Remote directory removed");
2258
2246
  } catch (err) {
2259
2247
  const message = err instanceof Error ? err.message : String(err);
2260
- log16.error(`Remote cleanup failed: ${message}`);
2248
+ log15.error(`Remote cleanup failed: ${message}`);
2261
2249
  process.exit(1);
2262
2250
  } finally {
2263
2251
  await ssh.disconnect();
2264
2252
  }
2265
- log16.step("Removing local config directory");
2253
+ log15.step("Removing local config directory");
2266
2254
  if (existsSync6(dir)) {
2267
2255
  rmSync2(dir, { recursive: true, force: true });
2268
- log16.success(`Removed ${dir}`);
2256
+ log15.success(`Removed ${dir}`);
2269
2257
  }
2270
2258
  outro6(
2271
- chalk8.green("\u2714 Uninstall complete.") + `
2259
+ chalk7.green("\u2714 Uninstall complete.") + `
2272
2260
 
2273
- Run ${chalk8.cyan("npx @lead-routing/cli init")} to start fresh.`
2261
+ Run ${chalk7.cyan("npx @lead-routing/cli init")} to start fresh.`
2274
2262
  );
2275
2263
  }
2276
2264
 
2277
2265
  // src/commands/signup.ts
2278
2266
  import * as p from "@clack/prompts";
2279
- import chalk9 from "chalk";
2267
+ import chalk8 from "chalk";
2280
2268
  async function runSignup() {
2281
- p.intro(chalk9.bgBlue.white(" lead-routing signup "));
2269
+ p.intro(chalk8.bgBlue.white(" lead-routing signup "));
2282
2270
  const firstName = await p.text({ message: "First name", placeholder: "John", validate: (v) => v.trim() ? void 0 : "Required" });
2283
2271
  if (p.isCancel(firstName)) process.exit(0);
2284
2272
  const lastName = await p.text({ message: "Last name", placeholder: "Smith", validate: (v) => v.trim() ? void 0 : "Required" });
@@ -2287,10 +2275,10 @@ async function runSignup() {
2287
2275
  if (p.isCancel(email)) process.exit(0);
2288
2276
  const password5 = await p.password({ message: "Password (min 8 characters)", validate: (v) => v.length >= 8 ? void 0 : "Must be at least 8 characters" });
2289
2277
  if (p.isCancel(password5)) process.exit(0);
2290
- const confirm5 = await p.password({ message: "Confirm password", validate: (v) => v === password5 ? void 0 : "Passwords do not match" });
2291
- if (p.isCancel(confirm5)) process.exit(0);
2292
- const spinner8 = p.spinner();
2293
- spinner8.start("Creating account...");
2278
+ const confirm6 = await p.password({ message: "Confirm password", validate: (v) => v === password5 ? void 0 : "Passwords do not match" });
2279
+ if (p.isCancel(confirm6)) process.exit(0);
2280
+ const spinner9 = p.spinner();
2281
+ spinner9.start("Creating account...");
2294
2282
  try {
2295
2283
  const message = await apiSignup({
2296
2284
  firstName: firstName.trim(),
@@ -2298,20 +2286,20 @@ async function runSignup() {
2298
2286
  email: email.trim(),
2299
2287
  password: password5
2300
2288
  });
2301
- spinner8.stop("Account created!");
2289
+ spinner9.stop("Account created!");
2302
2290
  p.note(
2303
2291
  `Check your email (${email.trim()}) for a verification link.
2304
2292
 
2305
2293
  After verifying, run:
2306
2294
 
2307
- ${chalk9.cyan("lead-routing login")}`,
2295
+ ${chalk8.cyan("lead-routing login")}`,
2308
2296
  "Next Steps"
2309
2297
  );
2310
2298
  } catch (err) {
2311
2299
  const msg = err instanceof Error ? err.message : "Signup failed";
2312
- spinner8.stop(msg);
2300
+ spinner9.stop(msg);
2313
2301
  if (msg.includes("already")) {
2314
- p.log.info(`Try ${chalk9.cyan("lead-routing login")} instead.`);
2302
+ p.log.info(`Try ${chalk8.cyan("lead-routing login")} instead.`);
2315
2303
  }
2316
2304
  process.exit(1);
2317
2305
  }
@@ -2320,19 +2308,19 @@ After verifying, run:
2320
2308
 
2321
2309
  // src/commands/login.ts
2322
2310
  import * as p2 from "@clack/prompts";
2323
- import chalk10 from "chalk";
2311
+ import chalk9 from "chalk";
2324
2312
  async function runLogin() {
2325
- p2.intro(chalk10.bgBlue.white(" lead-routing login "));
2313
+ p2.intro(chalk9.bgBlue.white(" lead-routing login "));
2326
2314
  const email = await p2.text({ message: "Email", placeholder: "john@acme.com" });
2327
2315
  if (p2.isCancel(email)) process.exit(0);
2328
2316
  const password5 = await p2.password({ message: "Password" });
2329
2317
  if (p2.isCancel(password5)) process.exit(0);
2330
- const spinner8 = p2.spinner();
2331
- spinner8.start("Authenticating...");
2318
+ const spinner9 = p2.spinner();
2319
+ spinner9.start("Authenticating...");
2332
2320
  try {
2333
2321
  const { token, customer } = await apiLogin(email.trim(), password5);
2334
2322
  if (!customer.emailVerified) {
2335
- spinner8.stop("Email not verified");
2323
+ spinner9.stop("Email not verified");
2336
2324
  p2.log.warn(`Your email (${customer.email}) hasn't been verified yet.`);
2337
2325
  const resend = await p2.confirm({ message: "Resend verification email?" });
2338
2326
  if (resend && !p2.isCancel(resend)) {
@@ -2345,19 +2333,421 @@ async function runLogin() {
2345
2333
  }
2346
2334
  p2.note(`Verify your email, then run:
2347
2335
 
2348
- ${chalk10.cyan("lead-routing login")}`, "Next Steps");
2336
+ ${chalk9.cyan("lead-routing login")}`, "Next Steps");
2349
2337
  process.exit(1);
2350
2338
  }
2351
2339
  saveCredentials({ token, customer, storedAt: (/* @__PURE__ */ new Date()).toISOString() });
2352
- spinner8.stop(`Logged in as ${customer.firstName} ${customer.lastName} \u2014 ${formatTierBadge(customer.tier)}`);
2340
+ spinner9.stop(`Logged in as ${customer.firstName} ${customer.lastName} \u2014 ${formatTierBadge(customer.tier)}`);
2353
2341
  p2.note(`Credentials saved to ~/.lead-routing/credentials.json`, "Saved");
2354
2342
  } catch (err) {
2355
- spinner8.stop(err instanceof Error ? err.message : "Login failed");
2343
+ spinner9.stop(err instanceof Error ? err.message : "Login failed");
2356
2344
  process.exit(1);
2357
2345
  }
2358
2346
  p2.outro("Done!");
2359
2347
  }
2360
2348
 
2349
+ // src/commands/dev.ts
2350
+ import {
2351
+ intro as intro9,
2352
+ outro as outro9,
2353
+ spinner as spinner8,
2354
+ text as text6,
2355
+ password as promptPassword4,
2356
+ note as note7,
2357
+ log as log18,
2358
+ isCancel as isCancel8,
2359
+ cancel as cancel5,
2360
+ confirm as confirm5
2361
+ } from "@clack/prompts";
2362
+ import { execa as execa4 } from "execa";
2363
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync8, readFileSync as readFileSync8, existsSync as existsSync7 } from "fs";
2364
+ import { join as join10 } from "path";
2365
+ function getDevDir() {
2366
+ return join10(process.cwd(), "lead-routing-dev");
2367
+ }
2368
+ function renderDevDockerCompose(dbPassword, redisPassword) {
2369
+ return `# Lead Routing \u2014 Local Dev Docker Compose
2370
+ # Generated by lead-routing dev \u2014 do not edit manually.
2371
+
2372
+ services:
2373
+
2374
+ postgres:
2375
+ image: postgres:16-alpine
2376
+ restart: unless-stopped
2377
+ ports:
2378
+ - "127.0.0.1:5432:5432"
2379
+ environment:
2380
+ POSTGRES_DB: leadrouting
2381
+ POSTGRES_USER: leadrouting
2382
+ POSTGRES_PASSWORD: ${dbPassword}
2383
+ volumes:
2384
+ - postgres_data:/var/lib/postgresql/data
2385
+ healthcheck:
2386
+ test: ["CMD-SHELL", "pg_isready -U leadrouting"]
2387
+ interval: 5s
2388
+ timeout: 5s
2389
+ retries: 10
2390
+
2391
+ redis:
2392
+ image: redis:7-alpine
2393
+ restart: unless-stopped
2394
+ command: redis-server --requirepass ${redisPassword}
2395
+ volumes:
2396
+ - redis_data:/data
2397
+ healthcheck:
2398
+ test: ["CMD-SHELL", "redis-cli -a ${redisPassword} ping | grep PONG"]
2399
+ interval: 5s
2400
+ timeout: 3s
2401
+ retries: 10
2402
+
2403
+ web:
2404
+ image: ghcr.io/atgatzby/lead-routing-web:latest
2405
+ restart: unless-stopped
2406
+ ports:
2407
+ - "127.0.0.1:3000:3000"
2408
+ env_file: .env.web
2409
+ healthcheck:
2410
+ test: ["CMD-SHELL", "wget -qO- http://$(hostname -i):3000/api/health || exit 1"]
2411
+ interval: 10s
2412
+ timeout: 5s
2413
+ retries: 6
2414
+ depends_on:
2415
+ postgres:
2416
+ condition: service_healthy
2417
+ redis:
2418
+ condition: service_healthy
2419
+
2420
+ engine:
2421
+ image: ghcr.io/atgatzby/lead-routing-engine:latest
2422
+ restart: unless-stopped
2423
+ ports:
2424
+ - "127.0.0.1:3001:3001"
2425
+ env_file: .env.engine
2426
+ healthcheck:
2427
+ test: ["CMD-SHELL", "wget -qO- http://$(hostname -i):3001/health || exit 1"]
2428
+ interval: 10s
2429
+ timeout: 5s
2430
+ retries: 6
2431
+ depends_on:
2432
+ postgres:
2433
+ condition: service_healthy
2434
+ redis:
2435
+ condition: service_healthy
2436
+
2437
+ volumes:
2438
+ postgres_data:
2439
+ redis_data:
2440
+ `;
2441
+ }
2442
+ function bail3(value) {
2443
+ if (isCancel8(value)) {
2444
+ cancel5("Setup cancelled.");
2445
+ process.exit(0);
2446
+ }
2447
+ throw new Error("Unexpected cancel");
2448
+ }
2449
+ async function pollHealth2(url, attempts = 30, intervalMs = 3e3) {
2450
+ for (let i = 0; i < attempts; i++) {
2451
+ try {
2452
+ const res = await fetch(url);
2453
+ if (res.ok) return true;
2454
+ } catch {
2455
+ }
2456
+ await new Promise((r) => setTimeout(r, intervalMs));
2457
+ }
2458
+ return false;
2459
+ }
2460
+ async function runDev(opts = {}) {
2461
+ if (opts.sfdc) {
2462
+ const devDir2 = getDevDir();
2463
+ const configFile2 = join10(devDir2, "dev.json");
2464
+ if (!existsSync7(configFile2)) {
2465
+ console.error("No dev environment found. Run `lead-routing dev` first.");
2466
+ process.exit(1);
2467
+ }
2468
+ intro9("Lead Routing \u2014 Update Salesforce Credentials");
2469
+ note7(
2470
+ "You need a Salesforce Connected App with callback URL:\n http://localhost:3000/api/auth/sfdc/callback",
2471
+ "Before you begin"
2472
+ );
2473
+ const clientId = await text6({
2474
+ message: "Consumer Key (Client ID)",
2475
+ validate: (v) => !v ? "Required" : void 0
2476
+ });
2477
+ if (isCancel8(clientId)) {
2478
+ cancel5("Cancelled.");
2479
+ process.exit(0);
2480
+ }
2481
+ const clientSecret = await promptPassword4({ message: "Consumer Secret" });
2482
+ if (isCancel8(clientSecret)) {
2483
+ cancel5("Cancelled.");
2484
+ process.exit(0);
2485
+ }
2486
+ const loginUrl = await text6({
2487
+ message: "Login URL",
2488
+ initialValue: "https://login.salesforce.com",
2489
+ validate: (v) => !v ? "Required" : void 0
2490
+ });
2491
+ if (isCancel8(loginUrl)) {
2492
+ cancel5("Cancelled.");
2493
+ process.exit(0);
2494
+ }
2495
+ const envPath = join10(devDir2, ".env.web");
2496
+ const lines = readFileSync8(envPath, "utf8").split("\n");
2497
+ const updates = {
2498
+ SFDC_CLIENT_ID: clientId.trim(),
2499
+ SFDC_CLIENT_SECRET: clientSecret.trim(),
2500
+ SFDC_LOGIN_URL: loginUrl.trim(),
2501
+ SFDC_REDIRECT_URI: "http://localhost:3000/api/auth/sfdc/callback"
2502
+ };
2503
+ const updated = /* @__PURE__ */ new Set();
2504
+ const result = lines.map((line) => {
2505
+ const eq = line.indexOf("=");
2506
+ if (eq === -1) return line;
2507
+ const key = line.slice(0, eq);
2508
+ if (key in updates) {
2509
+ updated.add(key);
2510
+ return `${key}=${updates[key]}`;
2511
+ }
2512
+ return line;
2513
+ });
2514
+ for (const [k, v] of Object.entries(updates)) {
2515
+ if (!updated.has(k)) result.push(`${k}=${v}`);
2516
+ }
2517
+ writeFileSync8(envPath, result.join("\n"), "utf8");
2518
+ const cfg2 = JSON.parse(readFileSync8(configFile2, "utf8"));
2519
+ cfg2.sfdcClientId = clientId.trim();
2520
+ cfg2.sfdcClientSecret = clientSecret.trim();
2521
+ cfg2.sfdcLoginUrl = loginUrl.trim();
2522
+ writeFileSync8(configFile2, JSON.stringify(cfg2, null, 2), "utf8");
2523
+ log18.success("Credentials saved");
2524
+ const s = spinner8();
2525
+ s.start("Restarting web container...");
2526
+ await execa4("docker", ["compose", "restart", "web"], { cwd: devDir2, stdio: "ignore" });
2527
+ s.stop("Web container restarted");
2528
+ outro9("Done! Connect Salesforce at http://localhost:3000/integrations/salesforce");
2529
+ return;
2530
+ }
2531
+ if (opts.logs !== void 0) {
2532
+ const service = opts.logs || "engine";
2533
+ const devDir2 = getDevDir();
2534
+ if (!existsSync7(join10(devDir2, "docker-compose.yml"))) {
2535
+ console.error("No dev environment found. Run `lead-routing dev` first.");
2536
+ process.exit(1);
2537
+ }
2538
+ const valid = ["web", "engine", "postgres", "redis"];
2539
+ if (!valid.includes(service)) {
2540
+ console.error(`Unknown service "${service}". Valid: ${valid.join(", ")}`);
2541
+ process.exit(1);
2542
+ }
2543
+ await execa4("docker", ["compose", "logs", "-f", "--tail=100", service], {
2544
+ cwd: devDir2,
2545
+ stdio: "inherit"
2546
+ });
2547
+ return;
2548
+ }
2549
+ intro9("Lead Routing \u2014 Local Dev");
2550
+ try {
2551
+ await execa4("docker", ["info"], { stdio: "ignore" });
2552
+ } catch {
2553
+ log18.error("Docker is not running. Please start Docker Desktop and try again.");
2554
+ process.exit(1);
2555
+ }
2556
+ const devDir = getDevDir();
2557
+ const configFile = join10(devDir, "dev.json");
2558
+ const isFirstRun = !existsSync7(configFile);
2559
+ let cfg;
2560
+ let adminPassword = "";
2561
+ if (!isFirstRun && !opts.reset) {
2562
+ cfg = JSON.parse(readFileSync8(configFile, "utf8"));
2563
+ log18.info("Existing dev environment found \u2014 starting services...");
2564
+ } else {
2565
+ if (opts.reset && !isFirstRun) {
2566
+ const ok = await confirm5({
2567
+ message: "This will wipe all local dev data (database, redis). Continue?"
2568
+ });
2569
+ if (isCancel8(ok) || !ok) {
2570
+ cancel5("Reset cancelled.");
2571
+ process.exit(0);
2572
+ }
2573
+ const s = spinner8();
2574
+ s.start("Stopping and removing volumes...");
2575
+ try {
2576
+ await execa4("docker", ["compose", "down", "-v"], { cwd: devDir, stdio: "ignore" });
2577
+ } catch {
2578
+ }
2579
+ s.stop("Volumes removed");
2580
+ }
2581
+ note7(
2582
+ "Sets up a full Lead Routing stack locally using Docker.\nNo SSH, no VPS, no manual steps.",
2583
+ "First-time setup"
2584
+ );
2585
+ const adminEmail = await text6({
2586
+ message: "Admin email",
2587
+ placeholder: "you@company.com",
2588
+ validate: (v) => {
2589
+ if (!v) return "Required";
2590
+ if (!v.includes("@")) return "Must be a valid email";
2591
+ }
2592
+ });
2593
+ if (isCancel8(adminEmail)) bail3(adminEmail);
2594
+ const pw = await promptPassword4({
2595
+ message: "Admin password (min 8 characters)",
2596
+ validate: (v) => {
2597
+ if (!v) return "Required";
2598
+ if (v.length < 8) return "Must be at least 8 characters";
2599
+ }
2600
+ });
2601
+ if (isCancel8(pw)) bail3(pw);
2602
+ adminPassword = pw;
2603
+ note7(
2604
+ "Optional \u2014 needed to connect Salesforce.\nPress Enter to skip; add credentials later via `lead-routing config sfdc`.",
2605
+ "Salesforce Connected App"
2606
+ );
2607
+ const sfdcClientId = await text6({
2608
+ message: "Consumer Key (Client ID)",
2609
+ placeholder: "Leave blank to skip"
2610
+ });
2611
+ if (isCancel8(sfdcClientId)) bail3(sfdcClientId);
2612
+ let sfdcClientSecret = "";
2613
+ if (sfdcClientId?.trim()) {
2614
+ const s = await promptPassword4({ message: "Consumer Secret" });
2615
+ if (isCancel8(s)) bail3(s);
2616
+ sfdcClientSecret = s.trim();
2617
+ }
2618
+ const tunnelUrl = await text6({
2619
+ message: "Engine tunnel URL (for Salesforce webhooks)",
2620
+ placeholder: "Leave blank \u2014 use `ssh -R 80:localhost:3001 localhost.run` to get one"
2621
+ });
2622
+ if (isCancel8(tunnelUrl)) bail3(tunnelUrl);
2623
+ cfg = {
2624
+ adminEmail: adminEmail.trim(),
2625
+ dbPassword: generateSecret(16),
2626
+ redisPassword: generateSecret(16),
2627
+ sessionSecret: generateSecret(32),
2628
+ engineWebhookSecret: generateSecret(32),
2629
+ internalApiKey: generateSecret(32),
2630
+ sfdcClientId: sfdcClientId?.trim() ?? "",
2631
+ sfdcClientSecret,
2632
+ sfdcLoginUrl: "https://login.salesforce.com",
2633
+ engineTunnelUrl: tunnelUrl?.trim() ?? ""
2634
+ };
2635
+ mkdirSync3(devDir, { recursive: true });
2636
+ const dbUrl = `postgresql://leadrouting:${cfg.dbPassword}@postgres:5432/leadrouting`;
2637
+ const redisUrl = `redis://:${cfg.redisPassword}@redis:6379`;
2638
+ const publicEngineUrl = cfg.engineTunnelUrl || "http://localhost:3001";
2639
+ writeFileSync8(
2640
+ join10(devDir, "docker-compose.yml"),
2641
+ renderDevDockerCompose(cfg.dbPassword, cfg.redisPassword),
2642
+ "utf8"
2643
+ );
2644
+ const envWebBase = renderEnvWeb({
2645
+ appUrl: "http://localhost:3000",
2646
+ engineUrl: "http://engine:3001",
2647
+ publicEngineUrl,
2648
+ databaseUrl: dbUrl,
2649
+ redisUrl,
2650
+ sessionSecret: cfg.sessionSecret,
2651
+ engineWebhookSecret: cfg.engineWebhookSecret,
2652
+ adminEmail: cfg.adminEmail,
2653
+ adminPassword,
2654
+ internalApiKey: cfg.internalApiKey,
2655
+ licenseKey: void 0,
2656
+ licenseTier: "pro"
2657
+ });
2658
+ writeFileSync8(
2659
+ join10(devDir, ".env.web"),
2660
+ envWebBase + `
2661
+ # Salesforce Connected App
2662
+ SFDC_CLIENT_ID=${cfg.sfdcClientId}
2663
+ SFDC_CLIENT_SECRET=${cfg.sfdcClientSecret}
2664
+ SFDC_REDIRECT_URI=http://localhost:3000/api/auth/sfdc/callback
2665
+ SFDC_LOGIN_URL=${cfg.sfdcLoginUrl}
2666
+ `,
2667
+ "utf8"
2668
+ );
2669
+ writeFileSync8(
2670
+ join10(devDir, ".env.engine"),
2671
+ renderEnvEngine({
2672
+ databaseUrl: dbUrl,
2673
+ redisUrl,
2674
+ engineWebhookSecret: cfg.engineWebhookSecret,
2675
+ internalApiKey: cfg.internalApiKey,
2676
+ licenseKey: void 0,
2677
+ licenseTier: "pro"
2678
+ }),
2679
+ "utf8"
2680
+ );
2681
+ writeFileSync8(configFile, JSON.stringify(cfg, null, 2), "utf8");
2682
+ writeConfig(devDir, {
2683
+ appUrl: "http://localhost:3000",
2684
+ engineUrl: publicEngineUrl,
2685
+ installDir: devDir,
2686
+ remoteDir: devDir,
2687
+ ssh: { host: "localhost", port: 22, username: "local" },
2688
+ dockerManaged: { db: true, redis: true },
2689
+ engineWebhookSecret: cfg.engineWebhookSecret,
2690
+ licenseTier: "pro",
2691
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2692
+ version: "0.0.0"
2693
+ });
2694
+ log18.success("Generated config files in ./lead-routing-dev/");
2695
+ }
2696
+ const pullS = spinner8();
2697
+ pullS.start("Pulling latest images...");
2698
+ try {
2699
+ await execa4("docker", ["compose", "pull"], { cwd: devDir, stdio: "ignore" });
2700
+ pullS.stop("Images up to date");
2701
+ } catch {
2702
+ pullS.stop("Using cached images");
2703
+ }
2704
+ const startS = spinner8();
2705
+ startS.start("Starting services...");
2706
+ try {
2707
+ await execa4("docker", ["compose", "up", "-d", "--remove-orphans"], {
2708
+ cwd: devDir,
2709
+ stdio: "ignore"
2710
+ });
2711
+ startS.stop("Services started");
2712
+ } catch (err) {
2713
+ startS.stop("Failed to start services");
2714
+ log18.error(err instanceof Error ? err.message : String(err));
2715
+ process.exit(1);
2716
+ }
2717
+ const healthS = spinner8();
2718
+ healthS.start("Waiting for web app...");
2719
+ const webOk = await pollHealth2("http://localhost:3000/api/health");
2720
+ if (!webOk) {
2721
+ healthS.stop("Web app did not start in time");
2722
+ log18.warn("Run `lead-routing dev logs web` to diagnose.");
2723
+ process.exit(1);
2724
+ }
2725
+ healthS.stop("Web app ready");
2726
+ const engineS = spinner8();
2727
+ engineS.start("Waiting for engine...");
2728
+ const engineOk = await pollHealth2("http://localhost:3001/health");
2729
+ engineS.stop(engineOk ? "Engine ready" : "Engine slow to start (check: lead-routing dev logs engine)");
2730
+ note7(
2731
+ ` Web: http://localhost:3000
2732
+ Engine: http://localhost:3001
2733
+
2734
+ Login: ${cfg.adminEmail}`,
2735
+ "\u2713 Lead Routing running locally"
2736
+ );
2737
+ if (cfg.sfdcClientId) {
2738
+ if (cfg.engineTunnelUrl) {
2739
+ log18.info("Next: run `lead-routing sfdc deploy` to deploy the Salesforce package (same as prod)");
2740
+ } else {
2741
+ log18.warn(
2742
+ "No engine tunnel URL set \u2014 Salesforce cannot reach localhost:3001.\nTo get a tunnel: ssh -R 80:localhost:3001 localhost.run\nThen re-run: lead-routing dev --reset (to update the tunnel URL)\nThen deploy: lead-routing sfdc deploy"
2743
+ );
2744
+ }
2745
+ } else {
2746
+ log18.info("No Salesforce creds set. Add via: lead-routing dev --sfdc");
2747
+ }
2748
+ outro9("Run `lead-routing dev logs` to stream logs \u2022 `lead-routing dev --reset` to wipe and restart");
2749
+ }
2750
+
2361
2751
  // src/index.ts
2362
2752
  var program = new Command();
2363
2753
  program.name("lead-routing").description("Self-hosted Lead Routing \u2014 scaffold, deploy, and manage your installation").version("0.1.13");
@@ -2381,6 +2771,7 @@ config.command("sfdc").description("Update Salesforce Connected App credentials
2381
2771
  var sfdc = program.command("sfdc").description("Manage the Salesforce package for this installation");
2382
2772
  sfdc.command("deploy").description("Deploy (or redeploy) the Lead Router Salesforce package to your Salesforce org").action(runSfdcDeploy);
2383
2773
  program.command("uninstall").description("Stop all containers, remove all data, and delete the remote installation").action(runUninstall);
2774
+ program.command("dev").description("Start a local dev environment (Docker-based, no SSH required)").option("--reset", "Wipe all local data and start fresh").option("--logs [service]", "Stream logs from a service (web, engine, postgres, redis)").option("--sfdc", "Update Salesforce Connected App credentials").action((opts) => runDev(opts));
2384
2775
  program.command("signup").description("Create a new Lead Routing account").action(runSignup);
2385
2776
  program.command("login").description("Log in to your Lead Routing account").action(runLogin);
2386
2777
  program.parseAsync(process.argv).catch((err) => {
@@ -0,0 +1,36 @@
1
+ -- AlterTable
2
+ ALTER TABLE "routing_rules" ADD COLUMN "searchMaxRecords" INTEGER,
3
+ ADD COLUMN "searchBatchSize" INTEGER;
4
+
5
+ -- CreateTable
6
+ CREATE TABLE "bulk_search_runs" (
7
+ "id" TEXT NOT NULL,
8
+ "orgId" TEXT NOT NULL,
9
+ "ruleId" TEXT NOT NULL,
10
+ "status" TEXT NOT NULL DEFAULT 'RUNNING',
11
+ "recordsFound" INTEGER NOT NULL DEFAULT 0,
12
+ "recordsProcessed" INTEGER NOT NULL DEFAULT 0,
13
+ "recordsRouted" INTEGER NOT NULL DEFAULT 0,
14
+ "recordsFailed" INTEGER NOT NULL DEFAULT 0,
15
+ "recordsSkipped" INTEGER NOT NULL DEFAULT 0,
16
+ "error" TEXT,
17
+ "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
18
+ "completedAt" TIMESTAMP(3),
19
+ "durationMs" INTEGER,
20
+ "maxRecords" INTEGER,
21
+ "batchSize" INTEGER,
22
+
23
+ CONSTRAINT "bulk_search_runs_pkey" PRIMARY KEY ("id")
24
+ );
25
+
26
+ -- CreateIndex
27
+ CREATE INDEX "bulk_search_runs_orgId_ruleId_idx" ON "bulk_search_runs"("orgId", "ruleId");
28
+
29
+ -- CreateIndex
30
+ CREATE INDEX "bulk_search_runs_ruleId_status_idx" ON "bulk_search_runs"("ruleId", "status");
31
+
32
+ -- AddForeignKey
33
+ ALTER TABLE "bulk_search_runs" ADD CONSTRAINT "bulk_search_runs_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
34
+
35
+ -- AddForeignKey
36
+ ALTER TABLE "bulk_search_runs" ADD CONSTRAINT "bulk_search_runs_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "routing_rules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,97 @@
1
+ -- AlterTable
2
+ ALTER TABLE "route_match_configs" ALTER COLUMN "updatedAt" DROP DEFAULT;
3
+
4
+ -- AlterTable
5
+ ALTER TABLE "routing_branches" ALTER COLUMN "updatedAt" DROP DEFAULT;
6
+
7
+ -- CreateTable
8
+ CREATE TABLE "ai_custom_prompts" (
9
+ "id" TEXT NOT NULL,
10
+ "orgId" TEXT NOT NULL,
11
+ "type" TEXT NOT NULL,
12
+ "name" TEXT NOT NULL,
13
+ "content" TEXT NOT NULL,
14
+ "context" TEXT,
15
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
16
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
17
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
18
+ "updatedAt" TIMESTAMP(3) NOT NULL,
19
+
20
+ CONSTRAINT "ai_custom_prompts_pkey" PRIMARY KEY ("id")
21
+ );
22
+
23
+ -- CreateTable
24
+ CREATE TABLE "ai_agent_logs" (
25
+ "id" TEXT NOT NULL,
26
+ "orgId" TEXT NOT NULL,
27
+ "context" TEXT NOT NULL,
28
+ "toolName" TEXT NOT NULL,
29
+ "action" TEXT NOT NULL,
30
+ "entityType" TEXT NOT NULL,
31
+ "entityId" TEXT,
32
+ "entityName" TEXT,
33
+ "input" JSONB NOT NULL,
34
+ "output" JSONB,
35
+ "status" TEXT NOT NULL,
36
+ "error" TEXT,
37
+ "actorId" TEXT NOT NULL,
38
+ "actorName" TEXT NOT NULL,
39
+ "durationMs" INTEGER,
40
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
41
+
42
+ CONSTRAINT "ai_agent_logs_pkey" PRIMARY KEY ("id")
43
+ );
44
+
45
+ -- CreateTable
46
+ CREATE TABLE "ai_chat_feedback" (
47
+ "id" TEXT NOT NULL,
48
+ "orgId" TEXT NOT NULL,
49
+ "rating" TEXT NOT NULL,
50
+ "userMessage" TEXT NOT NULL,
51
+ "aiResponse" TEXT NOT NULL,
52
+ "toolsUsed" JSONB,
53
+ "context" TEXT,
54
+ "feedback" TEXT,
55
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
56
+
57
+ CONSTRAINT "ai_chat_feedback_pkey" PRIMARY KEY ("id")
58
+ );
59
+
60
+ -- CreateIndex
61
+ CREATE INDEX "ai_custom_prompts_orgId_type_isActive_idx" ON "ai_custom_prompts"("orgId", "type", "isActive");
62
+
63
+ -- CreateIndex
64
+ CREATE INDEX "ai_agent_logs_orgId_createdAt_idx" ON "ai_agent_logs"("orgId", "createdAt");
65
+
66
+ -- CreateIndex
67
+ CREATE INDEX "ai_agent_logs_orgId_context_idx" ON "ai_agent_logs"("orgId", "context");
68
+
69
+ -- CreateIndex
70
+ CREATE INDEX "ai_chat_feedback_orgId_rating_idx" ON "ai_chat_feedback"("orgId", "rating");
71
+
72
+ -- AddForeignKey
73
+ ALTER TABLE "routing_rules" ADD CONSTRAINT "routing_rules_defaultOwnerUserId_fkey" FOREIGN KEY ("defaultOwnerUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
74
+
75
+ -- AddForeignKey
76
+ ALTER TABLE "routing_rules" ADD CONSTRAINT "routing_rules_defaultOwnerTeamId_fkey" FOREIGN KEY ("defaultOwnerTeamId") REFERENCES "round_robin_teams"("id") ON DELETE SET NULL ON UPDATE CASCADE;
77
+
78
+ -- AddForeignKey
79
+ ALTER TABLE "routing_rules" ADD CONSTRAINT "routing_rules_defaultOwnerQueueId_fkey" FOREIGN KEY ("defaultOwnerQueueId") REFERENCES "sfdc_queues"("id") ON DELETE SET NULL ON UPDATE CASCADE;
80
+
81
+ -- AddForeignKey
82
+ ALTER TABLE "routing_branches" ADD CONSTRAINT "routing_branches_assigneeUserId_fkey" FOREIGN KEY ("assigneeUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
83
+
84
+ -- AddForeignKey
85
+ ALTER TABLE "routing_branches" ADD CONSTRAINT "routing_branches_assigneeTeamId_fkey" FOREIGN KEY ("assigneeTeamId") REFERENCES "round_robin_teams"("id") ON DELETE SET NULL ON UPDATE CASCADE;
86
+
87
+ -- AddForeignKey
88
+ ALTER TABLE "routing_branches" ADD CONSTRAINT "routing_branches_assigneeQueueId_fkey" FOREIGN KEY ("assigneeQueueId") REFERENCES "sfdc_queues"("id") ON DELETE SET NULL ON UPDATE CASCADE;
89
+
90
+ -- AddForeignKey
91
+ ALTER TABLE "ai_custom_prompts" ADD CONSTRAINT "ai_custom_prompts_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
92
+
93
+ -- AddForeignKey
94
+ ALTER TABLE "ai_agent_logs" ADD CONSTRAINT "ai_agent_logs_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
95
+
96
+ -- AddForeignKey
97
+ ALTER TABLE "ai_chat_feedback" ADD CONSTRAINT "ai_chat_feedback_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,76 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "FlowStatus" AS ENUM ('DRAFT', 'ACTIVE', 'INACTIVE');
3
+
4
+ -- CreateEnum
5
+ CREATE TYPE "FlowNodeType" AS ENUM ('ENTRY', 'DECISION', 'BRANCH_DECISION', 'MATCH', 'ASSIGNMENT', 'UPDATE_FIELD', 'CREATE_TASK', 'FILTER', 'DEFAULT');
6
+
7
+ -- AlterTable
8
+ ALTER TABLE "organizations" ADD COLUMN "routingMode" JSONB;
9
+
10
+ -- AlterTable
11
+ ALTER TABLE "routing_logs" ADD COLUMN "flowId" TEXT,
12
+ ADD COLUMN "flowNodePath" JSONB;
13
+
14
+ -- CreateTable
15
+ CREATE TABLE "routing_flows" (
16
+ "id" TEXT NOT NULL,
17
+ "orgId" TEXT NOT NULL,
18
+ "objectType" "SfdcObjectType" NOT NULL,
19
+ "name" TEXT NOT NULL DEFAULT 'Untitled Flow',
20
+ "status" "FlowStatus" NOT NULL DEFAULT 'DRAFT',
21
+ "triggerEvent" "TriggerEvent" NOT NULL DEFAULT 'BOTH',
22
+ "isDryRun" BOOLEAN NOT NULL DEFAULT false,
23
+ "version" INTEGER NOT NULL DEFAULT 1,
24
+ "publishedAt" TIMESTAMP(3),
25
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
26
+ "updatedAt" TIMESTAMP(3) NOT NULL,
27
+
28
+ CONSTRAINT "routing_flows_pkey" PRIMARY KEY ("id")
29
+ );
30
+
31
+ -- CreateTable
32
+ CREATE TABLE "flow_nodes" (
33
+ "id" TEXT NOT NULL,
34
+ "flowId" TEXT NOT NULL,
35
+ "type" "FlowNodeType" NOT NULL,
36
+ "label" TEXT,
37
+ "positionX" DOUBLE PRECISION NOT NULL DEFAULT 0,
38
+ "positionY" DOUBLE PRECISION NOT NULL DEFAULT 0,
39
+ "config" JSONB,
40
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
41
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
42
+ "updatedAt" TIMESTAMP(3) NOT NULL,
43
+
44
+ CONSTRAINT "flow_nodes_pkey" PRIMARY KEY ("id")
45
+ );
46
+
47
+ -- CreateTable
48
+ CREATE TABLE "flow_edges" (
49
+ "id" TEXT NOT NULL,
50
+ "flowId" TEXT NOT NULL,
51
+ "fromId" TEXT NOT NULL,
52
+ "toId" TEXT NOT NULL,
53
+ "label" TEXT,
54
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
55
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
56
+
57
+ CONSTRAINT "flow_edges_pkey" PRIMARY KEY ("id")
58
+ );
59
+
60
+ -- CreateIndex
61
+ CREATE UNIQUE INDEX "routing_flows_orgId_objectType_key" ON "routing_flows"("orgId", "objectType");
62
+
63
+ -- AddForeignKey
64
+ ALTER TABLE "routing_flows" ADD CONSTRAINT "routing_flows_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
65
+
66
+ -- AddForeignKey
67
+ ALTER TABLE "flow_nodes" ADD CONSTRAINT "flow_nodes_flowId_fkey" FOREIGN KEY ("flowId") REFERENCES "routing_flows"("id") ON DELETE CASCADE ON UPDATE CASCADE;
68
+
69
+ -- AddForeignKey
70
+ ALTER TABLE "flow_edges" ADD CONSTRAINT "flow_edges_flowId_fkey" FOREIGN KEY ("flowId") REFERENCES "routing_flows"("id") ON DELETE CASCADE ON UPDATE CASCADE;
71
+
72
+ -- AddForeignKey
73
+ ALTER TABLE "flow_edges" ADD CONSTRAINT "flow_edges_fromId_fkey" FOREIGN KEY ("fromId") REFERENCES "flow_nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
74
+
75
+ -- AddForeignKey
76
+ ALTER TABLE "flow_edges" ADD CONSTRAINT "flow_edges_toId_fkey" FOREIGN KEY ("toId") REFERENCES "flow_nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
1
+ -- AlterTable
2
+ ALTER TABLE "flow_edges" ADD COLUMN "sourceHandle" TEXT;
3
+ ALTER TABLE "flow_edges" ADD COLUMN "targetHandle" TEXT;
@@ -0,0 +1,2 @@
1
+ -- AlterTable
2
+ ALTER TABLE "routing_branches" ADD COLUMN "steps" JSONB;
@@ -0,0 +1,24 @@
1
+ -- CreateTable
2
+ CREATE TABLE "api_tokens" (
3
+ "id" TEXT NOT NULL,
4
+ "orgId" TEXT NOT NULL,
5
+ "name" TEXT NOT NULL,
6
+ "tokenHash" TEXT NOT NULL,
7
+ "prefix" TEXT NOT NULL,
8
+ "scopes" TEXT[] DEFAULT ARRAY['read', 'route']::TEXT[],
9
+ "lastUsedAt" TIMESTAMP(3),
10
+ "expiresAt" TIMESTAMP(3),
11
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
12
+ "revokedAt" TIMESTAMP(3),
13
+
14
+ CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id")
15
+ );
16
+
17
+ -- CreateIndex
18
+ CREATE UNIQUE INDEX "api_tokens_tokenHash_key" ON "api_tokens"("tokenHash");
19
+
20
+ -- CreateIndex
21
+ CREATE INDEX "api_tokens_orgId_idx" ON "api_tokens"("orgId");
22
+
23
+ -- AddForeignKey
24
+ ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
1
+ -- AlterEnum
2
+ ALTER TYPE "SfdcObjectType" ADD VALUE 'USER';
@@ -1,3 +1,3 @@
1
1
  # Please do not edit this file manually
2
- # It should be added in your version-control system (i.e. Git)
3
- provider = "postgresql"
2
+ # It should be added in your version-control system (e.g., Git)
3
+ provider = "postgresql"
@@ -14,6 +14,7 @@ enum SfdcObjectType {
14
14
  LEAD
15
15
  CONTACT
16
16
  ACCOUNT
17
+ USER
17
18
  }
18
19
 
19
20
  enum TriggerEvent {
@@ -77,6 +78,24 @@ enum Plan {
77
78
  PAID
78
79
  }
79
80
 
81
+ enum FlowStatus {
82
+ DRAFT
83
+ ACTIVE
84
+ INACTIVE
85
+ }
86
+
87
+ enum FlowNodeType {
88
+ ENTRY
89
+ DECISION
90
+ BRANCH_DECISION
91
+ MATCH
92
+ ASSIGNMENT
93
+ UPDATE_FIELD
94
+ CREATE_TASK
95
+ FILTER
96
+ DEFAULT
97
+ }
98
+
80
99
  // ─── Models ───────────────────────────────────────────────────────────────────
81
100
 
82
101
  model Organization {
@@ -128,6 +147,13 @@ model Organization {
128
147
  routingDailyAggregates RoutingDailyAggregate[]
129
148
  conversionTracking ConversionTracking[]
130
149
  companyAliases CompanyAlias[]
150
+ bulkSearchRuns BulkSearchRun[]
151
+ aiCustomPrompts AiCustomPrompt[]
152
+ aiAgentLogs AiAgentLog[]
153
+ aiChatFeedback AiChatFeedback[]
154
+ routingMode Json?
155
+ routingFlows RoutingFlow[]
156
+ apiTokens ApiToken[]
131
157
 
132
158
  @@map("organizations")
133
159
  }
@@ -255,6 +281,9 @@ model RoutingRule {
255
281
  lastRunDurationMs Int?
256
282
  totalRuns Int @default(0)
257
283
  totalRecordsRouted Int @default(0)
284
+ // Bulk search configuration
285
+ searchMaxRecords Int? // user-configurable max records per run
286
+ searchBatchSize Int? // micro-batch size (default 500)
258
287
  // Default owner: absolute catch-all for new Route Builder routes
259
288
  defaultOwnerType AssignmentType?
260
289
  defaultOwnerUserId String?
@@ -273,6 +302,7 @@ model RoutingRule {
273
302
  branches RoutingBranch[] // new Route Builder paths
274
303
  matchConfig RouteMatchConfig?
275
304
  triggerConditions TriggerCondition[]
305
+ bulkSearchRuns BulkSearchRun[]
276
306
 
277
307
  @@map("routing_rules")
278
308
  }
@@ -290,6 +320,7 @@ model RoutingBranch {
290
320
  assigneeTeam RoundRobinTeam? @relation("BranchAssigneeTeam", fields: [assigneeTeamId], references: [id])
291
321
  assigneeQueueId String?
292
322
  assigneeQueue SfdcQueue? @relation("BranchAssigneeQueue", fields: [assigneeQueueId], references: [id])
323
+ steps Json?
293
324
  createdAt DateTime @default(now())
294
325
  updatedAt DateTime @updatedAt
295
326
 
@@ -410,6 +441,8 @@ model RoutingLog {
410
441
  branchId String? // FK → RoutingBranch for path-level analytics
411
442
  createdAt DateTime @default(now())
412
443
 
444
+ flowId String?
445
+ flowNodePath Json?
413
446
  org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
414
447
  branch RoutingBranch? @relation(fields: [branchId], references: [id])
415
448
  conversionTracking ConversionTracking?
@@ -440,6 +473,64 @@ model AuditLog {
440
473
  @@map("audit_logs")
441
474
  }
442
475
 
476
+ model RoutingFlow {
477
+ id String @id @default(cuid())
478
+ orgId String
479
+ objectType SfdcObjectType
480
+ name String @default("Untitled Flow")
481
+ status FlowStatus @default(DRAFT)
482
+ triggerEvent TriggerEvent @default(BOTH)
483
+ isDryRun Boolean @default(false)
484
+ version Int @default(1)
485
+ publishedAt DateTime?
486
+ createdAt DateTime @default(now())
487
+ updatedAt DateTime @updatedAt
488
+
489
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
490
+ nodes FlowNode[]
491
+ edges FlowEdge[]
492
+
493
+ @@unique([orgId, objectType])
494
+ @@map("routing_flows")
495
+ }
496
+
497
+ model FlowNode {
498
+ id String @id @default(cuid())
499
+ flowId String
500
+ type FlowNodeType
501
+ label String?
502
+ positionX Float @default(0)
503
+ positionY Float @default(0)
504
+ config Json?
505
+ sortOrder Int @default(0)
506
+ createdAt DateTime @default(now())
507
+ updatedAt DateTime @updatedAt
508
+
509
+ flow RoutingFlow @relation(fields: [flowId], references: [id], onDelete: Cascade)
510
+ outEdges FlowEdge[] @relation("FromNode")
511
+ inEdges FlowEdge[] @relation("ToNode")
512
+
513
+ @@map("flow_nodes")
514
+ }
515
+
516
+ model FlowEdge {
517
+ id String @id @default(cuid())
518
+ flowId String
519
+ fromId String
520
+ toId String
521
+ label String?
522
+ sourceHandle String?
523
+ targetHandle String?
524
+ sortOrder Int @default(0)
525
+ createdAt DateTime @default(now())
526
+
527
+ flow RoutingFlow @relation(fields: [flowId], references: [id], onDelete: Cascade)
528
+ from FlowNode @relation("FromNode", fields: [fromId], references: [id], onDelete: Cascade)
529
+ to FlowNode @relation("ToNode", fields: [toId], references: [id], onDelete: Cascade)
530
+
531
+ @@map("flow_edges")
532
+ }
533
+
443
534
  model SfdcQueue {
444
535
  id String @id @default(cuid())
445
536
  orgId String
@@ -581,3 +672,103 @@ model CompanyAlias {
581
672
  @@index([orgId, nameB])
582
673
  @@map("company_aliases")
583
674
  }
675
+
676
+ model BulkSearchRun {
677
+ id String @id @default(cuid())
678
+ orgId String
679
+ ruleId String
680
+ status String @default("RUNNING") // RUNNING | COMPLETE | FAILED | CANCELLED
681
+ recordsFound Int @default(0)
682
+ recordsProcessed Int @default(0)
683
+ recordsRouted Int @default(0)
684
+ recordsFailed Int @default(0)
685
+ recordsSkipped Int @default(0)
686
+ error String?
687
+ startedAt DateTime @default(now())
688
+ completedAt DateTime?
689
+ durationMs Int?
690
+ maxRecords Int?
691
+ batchSize Int?
692
+
693
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
694
+ rule RoutingRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
695
+
696
+ @@index([orgId, ruleId])
697
+ @@index([ruleId, status])
698
+ @@map("bulk_search_runs")
699
+ }
700
+
701
+ model ApiToken {
702
+ id String @id @default(cuid())
703
+ orgId String
704
+ name String
705
+ tokenHash String @unique
706
+ prefix String
707
+ scopes String[] @default(["read", "route"])
708
+ lastUsedAt DateTime?
709
+ expiresAt DateTime?
710
+ createdAt DateTime @default(now())
711
+ revokedAt DateTime?
712
+
713
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
714
+
715
+ @@index([orgId])
716
+ @@map("api_tokens")
717
+ }
718
+
719
+ model AiCustomPrompt {
720
+ id String @id @default(cuid())
721
+ orgId String
722
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
723
+ type String // "instruction" | "template" | "alias" | "memory"
724
+ name String
725
+ content String
726
+ context String? // null = global, "license-users" | "teams" | "routing-rules"
727
+ isActive Boolean @default(true)
728
+ sortOrder Int @default(0)
729
+ createdAt DateTime @default(now())
730
+ updatedAt DateTime @updatedAt
731
+
732
+ @@index([orgId, type, isActive])
733
+ @@map("ai_custom_prompts")
734
+ }
735
+
736
+ model AiAgentLog {
737
+ id String @id @default(cuid())
738
+ orgId String
739
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
740
+ context String
741
+ toolName String
742
+ action String
743
+ entityType String
744
+ entityId String?
745
+ entityName String?
746
+ input Json
747
+ output Json?
748
+ status String
749
+ error String?
750
+ actorId String
751
+ actorName String
752
+ durationMs Int?
753
+ createdAt DateTime @default(now())
754
+
755
+ @@index([orgId, createdAt])
756
+ @@index([orgId, context])
757
+ @@map("ai_agent_logs")
758
+ }
759
+
760
+ model AiChatFeedback {
761
+ id String @id @default(cuid())
762
+ orgId String
763
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
764
+ rating String
765
+ userMessage String
766
+ aiResponse String
767
+ toolsUsed Json?
768
+ context String?
769
+ feedback String?
770
+ createdAt DateTime @default(now())
771
+
772
+ @@index([orgId, rating])
773
+ @@map("ai_chat_feedback")
774
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lead-routing/cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Self-hosted deployment CLI for Lead Routing",
5
5
  "homepage": "https://github.com/lead-routing/lead-routing",
6
6
  "keywords": [