Skip to content
SlashClone
Made by@revopslab

Lead routing rules

@revopslab/lead-routing-rules

Design, test, and apply lead routing rules outside your CRM. Write ordered rules in a JSON file (existing-account match, partner, segment, region, junk filters), prove them with rule tests, then route a leads CSV with weighted round robin, out-of-office skips, and a fallback queue. Works with HubSpot, Salesforce, or any CSV.

SkillRevOpsv1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// lead-routing-rules: apply an ordered rules file to a leads CSV. First matching
  3. 3// rule wins. Supports existing-account matching by email domain, weighted round
  4. 4// robin pools, queues, a fallback, and rule tests that must pass before routing.
  5. 5// Node 22, built-ins only.
  6. 6//
  7. 7// Usage:
  8. 8// node scripts/route.mjs <rules.json> <leads.csv> [--accounts accounts.csv]
  9. 9// [--state rr-state.json] [--out out] [--name routed]
  10. 10// node scripts/route.mjs <rules.json> --check (run the rule tests only)
  11. 12import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
  12. 13import { join } from "node:path";
  13. 15function fail(msg) {
  14. 16 console.error(`lead-routing-rules: ${msg}`);
  15. 17 process.exit(1);
  16. 20const args = process.argv.slice(2);
  17. 21const opts = { out: "out", name: "routed", accounts: null, state: null, check: false };
  18. 22const pos = [];
  19. 23for (let i = 0; i < args.length; i++) {
  20. 24 const a = args[i];
  21. 25 if (a === "--out") opts.out = args[++i];
  22. 26 else if (a === "--name") opts.name = args[++i];
  23. 27 else if (a === "--accounts") opts.accounts = args[++i];
  24. 28 else if (a === "--state") opts.state = args[++i];
  25. 29 else if (a === "--check") opts.check = true;
  26. 30 else if (a === "--help" || a === "-h") {
  27. 31 console.log("Usage: node scripts/route.mjs <rules.json> <leads.csv> [--accounts accounts.csv] [--state rr-state.json] [--out out] [--name routed]\n node scripts/route.mjs <rules.json> --check");
  28. 32 process.exit(0);
  29. 33 } else pos.push(a);
  30. 35if (pos.length < 1 || (!opts.check && pos.length !== 2)) fail("give a rules JSON and a leads CSV (or a rules JSON and --check). Try --help.");
  31. 37const read = (p) => {
  32. 38 try {
  33. 39 return readFileSync(p, "utf8");
  34. 40 } catch {
  35. 41 fail(`cannot read ${p}`);
  36. 42 }
  37. 43};
  38. 44let rules;
  39. 45try {
  40. 46 rules = JSON.parse(read(pos[0]));
  41. 47} catch (e) {
  42. 48 fail(`${pos[0]} is not valid JSON: ${e.message}`);
  43. 50if (!Array.isArray(rules.rules)) fail("rules file needs a rules array");
  44. 51const pools = rules.pools || {};
  45. 52const fallback = rules.fallback || { queue: "unassigned" };
  46. 53const away = new Set((rules.out_of_office || []).map((s) => s.toLowerCase()));
  47. 55// ---------- validate rules ----------
  48. 56const OPS = ["==", "!=", "in", "not_in", ">", ">=", "<", "<=", "contains", "starts_with", "ends_with", "exists", "missing"];
  49. 57const ids = new Set();
  50. 58for (const r of rules.rules) {
  51. 59 if (!r.id) fail("every rule needs an id");
  52. 60 if (ids.has(r.id)) fail(`duplicate rule id "${r.id}"`);
  53. 61 ids.add(r.id);
  54. 62 for (const c of [...(r.when || []), ...(r.when_any || [])]) {
  55. 63 if (!OPS.includes(c.op)) fail(`rule ${r.id}: unknown op "${c.op}" (use ${OPS.join(" ")})`);
  56. 64 if (!c.field) fail(`rule ${r.id}: a condition has no field`);
  57. 65 }
  58. 66 const a = r.assign || {};
  59. 67 if (!a.owner && !a.pool && !a.queue) fail(`rule ${r.id}: assign needs owner, pool, or queue`);
  60. 68 if (a.pool && !pools[a.pool]) fail(`rule ${r.id}: pool "${a.pool}" is not defined in pools`);
  61. 71// ---------- CSV ----------
  62. 72function parseCsv(text) {
  63. 73 if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
  64. 74 const rows = [];
  65. 75 let row = [];
  66. 76 let field = "";
  67. 77 let q = false;
  68. 78 for (let i = 0; i < text.length; i++) {
  69. 79 const c = text[i];
  70. 80 if (q) {
  71. 81 if (c === '"') {
  72. 82 if (text[i + 1] === '"') {
  73. 83 field += '"';
  74. 84 i++;
  75. 85 } else q = false;
  76. 86 } else field += c;
  77. 87 } else if (c === '"') q = true;
  78. 88 else if (c === ",") {
  79. 89 row.push(field);
  80. 90 field = "";
  81. 91 } else if (c === "\n" || c === "\r") {
  82. 92 if (c === "\r" && text[i + 1] === "\n") i++;
  83. 93 row.push(field);
  84. 94 rows.push(row);
  85. 95 row = [];
  86. 96 field = "";
  87. 97 } else field += c;
  88. 98 }
  89. 99 if (field !== "" || row.length) {
  90. 100 row.push(field);
  91. 101 rows.push(row);
  92. 102 }
  93. 103 const data = rows.filter((r) => r.some((v) => v.trim() !== ""));
  94. 104 if (!data.length) return { header: [], records: [] };
  95. 105 const header = data.shift().map((h) => h.trim());
  96. 106 return { header, records: data.map((r) => Object.fromEntries(header.map((h, i) => [h, (r[i] ?? "").trim()]))) };
  97. 108const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
  98. 109const csvCell = (s) => (/[",\n]/.test(String(s)) ? `"${String(s).replace(/"/g, '""')}"` : String(s));
  99. 111const FREE = new Set(
  100. 112 "gmail.com googlemail.com yahoo.com ymail.com hotmail.com outlook.com live.com msn.com icloud.com me.com mac.com aol.com proton.me protonmail.com gmx.com gmx.net mail.com yandex.com zohomail.com fastmail.com hey.com".split(" "),
  101. 113);
  102. 114const domainOf = (s) =>
  103. 115 String(s || "")
  104. 116 .toLowerCase()
  105. 117 .trim()
  106. 118 .replace(/^.*@/, "")
  107. 119 .replace(/^https?:\/\//, "")
  108. 120 .replace(/^www\./, "")
  109. 121 .replace(/[/?#].*$/, "");
  110. 123// lead fields: normalized column names plus derived fields
  111. 124function leadView(raw) {
  112. 125 const v = {};
  113. 126 for (const [k, val] of Object.entries(raw)) v[norm(k)] = val;
  114. 127 const email = v.email || v.email_address || v.work_email || "";
  115. 128 v.email_domain = email.includes("@") ? domainOf(email) : "";
  116. 129 v.is_free_email = v.email_domain ? FREE.has(v.email_domain) : false;
  117. 130 v.company_domain = domainOf(v.company_domain || v.website || v.domain || (v.is_free_email ? "" : v.email_domain));
  118. 131 return v;
  119. 133const asNum = (x) => {
  120. 134 const m = String(x ?? "").replace(/,/g, "").match(/-?\d+(?:\.\d+)?/);
  121. 135 return m ? Number(m[0]) : NaN;
  122. 136};
  123. 137function test(c, lead) {
  124. 138 const raw = lead[norm(c.field)];
  125. 139 const s = raw === undefined || raw === null ? "" : String(raw).toLowerCase();
  126. 140 const vals = [].concat(c.value ?? []).map((x) => String(x).toLowerCase());
  127. 141 switch (c.op) {
  128. 142 case "exists":
  129. 143 return s !== "" && s !== "false";
  130. 144 case "missing":
  131. 145 return s === "" || s === "false";
  132. 146 case "==":
  133. 147 return s === vals[0];
  134. 148 case "!=":
  135. 149 return s !== vals[0];
  136. 150 case "in":
  137. 151 return vals.includes(s);
  138. 152 case "not_in":
  139. 153 return !vals.includes(s);
  140. 154 case "contains":
  141. 155 return vals.some((x) => s.includes(x));
  142. 156 case "starts_with":
  143. 157 return vals.some((x) => s.startsWith(x));
  144. 158 case "ends_with":
  145. 159 return vals.some((x) => s.endsWith(x));
  146. 160 default: {
  147. 161 const n = asNum(raw);
  148. 162 const t = Number(c.value);
  149. 163 if (!Number.isFinite(n)) return false;
  150. 164 return c.op === ">" ? n > t : c.op === ">=" ? n >= t : c.op === "<" ? n < t : n <= t;
  151. 165 }
  152. 166 }
  153. 169// ---------- accounts ----------
  154. 170const accountOwner = new Map();
  155. 171if (opts.accounts) {
  156. 172 const { header, records } = parseCsv(read(opts.accounts));
  157. 173 const h = new Map(header.map((x) => [norm(x), x]));
  158. 174 const dCol = ["domain", "company_domain", "website", "company_domain_name"].map((x) => h.get(x)).find(Boolean);
  159. 175 const oCol = ["owner", "account_owner", "company_owner", "owner_name"].map((x) => h.get(x)).find(Boolean);
  160. 176 if (!dCol || !oCol) fail(`${opts.accounts} needs a domain (or website) column and an owner column`);
  161. 177 for (const r of records) if (r[dCol] && r[oCol]) accountOwner.set(domainOf(r[dCol]), r[oCol]);
  162. 179const accountRule = rules.account_match === false ? null : { id: (rules.account_match && rules.account_match.rule_id) || "existing-account" };
  163. 181// ---------- round robin ----------
  164. 182let rrState = {};
  165. 183if (opts.state && existsSync(opts.state)) {
  166. 184 try {
  167. 185 rrState = JSON.parse(read(opts.state));
  168. 186 } catch {
  169. 187 fail(`${opts.state} is not valid JSON`);
  170. 188 }
  171. 190function nextInPool(name) {
  172. 191 const p = pools[name];
  173. 192 const ring = [];
  174. 193 for (const m of p.members || []) {
  175. 194 if (away.has(m.toLowerCase())) continue;
  176. 195 const w = Math.max(1, Math.round(Number(p.weights?.[m] ?? 1)));
  177. 196 for (let i = 0; i < w; i++) ring.push(m);
  178. 197 }
  179. 198 if (!ring.length) return null;
  180. 199 const i = (rrState[name] ?? 0) % ring.length;
  181. 200 rrState[name] = (i + 1) % ring.length;
  182. 201 return ring[i];
  183. 204function route(raw, { dryRun = false } = {}) {
  184. 205 const lead = leadView(raw);
  185. 206 if (accountRule && !lead.is_free_email && lead.company_domain && accountOwner.has(lead.company_domain)) {
  186. 207 const owner = accountOwner.get(lead.company_domain);
  187. 208 if (!away.has(owner.toLowerCase()))
  188. 209 return { rule_id: accountRule.id, route_type: "owner", routed_to: owner, target: owner, reason: `existing account ${lead.company_domain}` };
  189. 210 }
  190. 211 for (const r of rules.rules) {
  191. 212 const all = (r.when || []).every((c) => test(c, lead));
  192. 213 const any = !r.when_any || r.when_any.some((c) => test(c, lead));
  193. 214 if (!all || !any) continue;
  194. 215 const a = r.assign;
  195. 216 if (a.owner && !away.has(a.owner.toLowerCase())) return { rule_id: r.id, route_type: "owner", routed_to: a.owner, target: a.owner, reason: r.reason || r.id };
  196. 217 if (a.pool) {
  197. 218 const who = dryRun ? a.pool : nextInPool(a.pool);
  198. 219 if (who) return { rule_id: r.id, route_type: "pool", routed_to: who, target: a.pool, reason: r.reason || r.id };
  199. 220 continue; // everyone in the pool is away: fall through to later rules
  200. 221 }
  201. 222 if (a.queue) return { rule_id: r.id, route_type: "queue", routed_to: a.queue, target: a.queue, reason: r.reason || r.id };
  202. 223 }
  203. 224 const f = fallback.queue || fallback.owner;
  204. 225 return { rule_id: "fallback", route_type: fallback.queue ? "queue" : "owner", routed_to: f, target: f, reason: "no rule matched" };
  205. 228// ---------- rule tests ----------
  206. 229const testResults = (rules.tests || []).map((t) => {
  207. 230 const got = route(t.lead || {}, { dryRun: true });
  208. 231 const okRule = !t.expect_rule || got.rule_id === t.expect_rule;
  209. 232 const okTo = !t.expect_to || String(got.target).toLowerCase() === String(t.expect_to).toLowerCase();
  210. 233 return { name: t.name || "(unnamed)", pass: okRule && okTo, expected: { rule: t.expect_rule, to: t.expect_to }, got: { rule: got.rule_id, to: got.target } };
  211. 234});
  212. 235const failing = testResults.filter((t) => !t.pass);
  213. 236for (const t of testResults) if (!t.pass) console.error(`FAIL ${t.name}: expected rule ${t.expected.rule ?? "any"} to ${t.expected.to ?? "any"}, got rule ${t.got.rule} to ${t.got.to}`);
  214. 237if (opts.check) {
  215. 238 console.log(`${testResults.length - failing.length}/${testResults.length} rule tests pass.`);
  216. 239 process.exit(failing.length ? 1 : 0);
  217. 241if (failing.length) fail(`${failing.length} rule test(s) fail. Fix the rules (or the tests) before routing real leads.`);
  218. 243// ---------- route the leads ----------
  219. 244const { header, records } = parseCsv(read(pos[1]));
  220. 245const out = [];
  221. 246const byRule = {};
  222. 247const byAssignee = {};
  223. 248for (const r of records) {
  224. 249 const res = route(r);
  225. 250 out.push({ ...r, routed_to: res.routed_to, route_type: res.route_type, rule_id: res.rule_id, reason: res.reason });
  226. 251 byRule[res.rule_id] = (byRule[res.rule_id] || 0) + 1;
  227. 252 byAssignee[res.routed_to] = (byAssignee[res.routed_to] || 0) + 1;
  228. 254const cols = [...header, "routed_to", "route_type", "rule_id", "reason"];
  229. 255const csv = [cols.map(csvCell).join(",")].concat(out.map((r) => cols.map((c) => csvCell(r[c] ?? "")).join(","))).join("\n") + "\n";
  230. 256const unusedRules = rules.rules.map((r) => r.id).filter((id) => !byRule[id]);
  231. 257const summary = {
  232. 258 leads: records.length,
  233. 259 by_rule: byRule,
  234. 260 by_assignee: byAssignee,
  235. 261 fallback: byRule.fallback || 0,
  236. 262 unused_rules: unusedRules,
  237. 263 tests: { total: testResults.length, passing: testResults.length - failing.length },
  238. 264 round_robin_state: rrState,
  239. 265};
  240. 267mkdirSync(opts.out, { recursive: true });
  241. 268writeFileSync(join(opts.out, `${opts.name}.csv`), csv);
  242. 269writeFileSync(join(opts.out, `${opts.name}-summary.json`), JSON.stringify(summary, null, 2) + "\n");
  243. 270if (opts.state) writeFileSync(opts.state, JSON.stringify(rrState, null, 2) + "\n");
  244. 271console.log(
  245. 272 `Routed ${records.length} lead(s); ${summary.fallback} to fallback; ${testResults.length} rule test(s) pass. Wrote ${join(opts.out, opts.name)}.csv and ${opts.name}-summary.json`,
  246. 273);