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