conduyt 1.5.0 → 1.6.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.
Files changed (2) hide show
  1. package/dist/index.js +166 -0
  2. package/package.json +5 -4
package/dist/index.js CHANGED
@@ -82,6 +82,8 @@ contacts
82
82
  .option("--email <email>", "email")
83
83
  .option("--phone <phone>", "phone")
84
84
  .option("--company <company>", "company name (auto-creates/links a company)")
85
+ .option("--source <source>", "lead source (first-write-wins acquisition truth)")
86
+ .option("--master-status <status>", "lead lifecycle status: open|won|lost|abandoned|disqualified or an account custom (terminal statuses silence all outbound automation)")
85
87
  .option("--json <json>", "full JSON body, merged over the flags above (for any field)")
86
88
  .action(run(async (client, opts) => {
87
89
  const body = {};
@@ -95,8 +97,23 @@ contacts
95
97
  body.phone = opts.phone;
96
98
  if (opts.company !== undefined)
97
99
  body.company = opts.company;
100
+ if (opts.source !== undefined) {
101
+ if (opts.source.trim() === "")
102
+ throw new Error("--source was provided but is blank. Omit it or pass a source.");
103
+ if (opts.source.length > 100)
104
+ throw new Error("--source must be at most 100 characters.");
105
+ body.source = opts.source;
106
+ }
107
+ if (opts.masterStatus !== undefined) {
108
+ if (opts.masterStatus.trim() === "")
109
+ throw new Error("--master-status was provided but is blank. Omit it or pass a status.");
110
+ if (opts.masterStatus.length > 40)
111
+ throw new Error("--master-status must be at most 40 characters.");
112
+ body.masterStatus = opts.masterStatus;
113
+ }
98
114
  if (opts.json !== undefined)
99
115
  Object.assign(body, jsonArg(opts.json, "--json"));
116
+ assertContactSource(body.source, { allowNull: false });
100
117
  return client.post("/api/v1/contacts", body);
101
118
  }));
102
119
  contacts
@@ -107,6 +124,8 @@ contacts
107
124
  .option("--email <email>", "email")
108
125
  .option("--phone <phone>", "phone")
109
126
  .option("--company <company>", "company name (auto-creates/links a company; pass '' to clear)")
127
+ .option("--source <source>", "correct the lead's first-touch source (normally set once at creation — update only to fix bad attribution; pass 'null' to clear)")
128
+ .option("--master-status <status>", "lead lifecycle status: open|won|lost|abandoned|disqualified or an account custom (terminal statuses silence all outbound automation)")
110
129
  .option("--json <json>", "full JSON body, merged over the flags above (for any field)")
111
130
  .action(run(async (client, id, opts) => {
112
131
  assertUuid(id, "contact id");
@@ -121,10 +140,28 @@ contacts
121
140
  body.phone = opts.phone;
122
141
  if (opts.company !== undefined)
123
142
  body.company = opts.company;
143
+ if (opts.source !== undefined) {
144
+ if (opts.source !== "null") {
145
+ if (opts.source.trim() === "") {
146
+ throw new Error("--source was provided but is blank. Omit it, or pass 'null' to clear the source.");
147
+ }
148
+ if (opts.source.length > 100)
149
+ throw new Error("--source must be at most 100 characters.");
150
+ }
151
+ body.source = opts.source === "null" ? null : opts.source;
152
+ }
153
+ if (opts.masterStatus !== undefined) {
154
+ if (opts.masterStatus.trim() === "")
155
+ throw new Error("--master-status was provided but is blank. Omit it or pass a status.");
156
+ if (opts.masterStatus.length > 40)
157
+ throw new Error("--master-status must be at most 40 characters.");
158
+ body.masterStatus = opts.masterStatus;
159
+ }
124
160
  if (opts.json !== undefined)
125
161
  Object.assign(body, jsonArg(opts.json, "--json"));
126
162
  if (Object.keys(body).length === 0)
127
163
  throw new Error("Nothing to update. Pass at least one field flag or --json.");
164
+ assertContactSource(body.source, { allowNull: true });
128
165
  return client.patch(`/api/v1/contacts/${encodeURIComponent(id)}`, body);
129
166
  }));
130
167
  contacts
@@ -271,6 +308,7 @@ deals
271
308
  .option("--value <n>", "deal value (number)")
272
309
  .option("--currency <code>", "ISO currency, e.g. GBP")
273
310
  .option("--contact <id>", "contact UUID to link")
311
+ .option("--source <source>", "attribution source for THIS deal (the trigger that produced it, e.g. sms-reply, booking:demo)")
274
312
  .option("--json <json>", "full JSON body, merged over the flags")
275
313
  .action(run(async (client, opts) => {
276
314
  const body = {};
@@ -295,6 +333,13 @@ deals
295
333
  body.currency = opts.currency;
296
334
  if (opts.contact !== undefined)
297
335
  body.contactId = opts.contact;
336
+ if (opts.source !== undefined) {
337
+ if (opts.source.trim() === "")
338
+ throw new Error("--source was provided but is blank. Omit it or pass a source.");
339
+ if (opts.source.length > 500)
340
+ throw new Error("--source must be at most 500 characters.");
341
+ body.source = opts.source;
342
+ }
298
343
  if (opts.json !== undefined)
299
344
  Object.assign(body, jsonArg(opts.json, "--json"));
300
345
  assertFiniteValue(body.value);
@@ -317,6 +362,7 @@ deals
317
362
  .option("--pipeline <id>", "move to this pipeline UUID")
318
363
  .option("--contact <id>", "primary contact UUID")
319
364
  .option("--status <status>", "explicit status (open|won|lost); overrides the status auto-derived from --stage")
365
+ .option("--source <source>", "attribution source for THIS deal (pass 'null' to clear it)")
320
366
  .option("--json <json>", "full JSON body, merged over the flags")
321
367
  .action(run(async (client, id, opts) => {
322
368
  assertUuid(id, "deal id");
@@ -339,6 +385,16 @@ deals
339
385
  body.contactId = opts.contact;
340
386
  if (opts.status !== undefined)
341
387
  body.status = opts.status;
388
+ if (opts.source !== undefined) {
389
+ // Mirror the API validator (blank rejected, 500-char cap) so the CLI
390
+ // fails locally rather than shipping a guaranteed 422.
391
+ if (opts.source.trim() === "") {
392
+ throw new Error("--source was provided but is blank. Omit it, or pass 'null' to clear the source.");
393
+ }
394
+ if (opts.source.length > 500)
395
+ throw new Error("--source must be at most 500 characters.");
396
+ body.source = opts.source === "null" ? null : opts.source;
397
+ }
342
398
  if (opts.stage !== undefined) {
343
399
  if (opts.stage.trim() === "")
344
400
  throw new Error("--stage was provided but is blank. Omit it or pass a stage name/UUID.");
@@ -1604,6 +1660,99 @@ program
1604
1660
  p = `/api/v1${p}`;
1605
1661
  return client.request(method.toUpperCase(), p, body);
1606
1662
  }));
1663
+ // ---- dialer ----
1664
+ const dialer = program.command("dialer").description("Dialer operations");
1665
+ dialer
1666
+ .command("sync-local-presence")
1667
+ .description("Reconcile the Local Presence pool from Twilio. Only adopts numbers whose Twilio FriendlyName contains a standalone 'LP' (or 'LocalPres'), that are voice-capable, and that are US +1 E.164 — first 50 sorted matches; pool entries that no longer match are REMOVED. Name the number in Twilio first, then sync. Requires settings:edit")
1668
+ .action(run(async (client) => client.post("/api/v1/dialer/local-presence/sync", {})));
1669
+ // ---- lifecycle (master lead status + intake deals) ----
1670
+ const lifecycle = program
1671
+ .command("lifecycle")
1672
+ .description("Lifecycle v2 settings: lead master statuses and automatic intake-deal creation");
1673
+ lifecycle
1674
+ .command("settings")
1675
+ .description("Show the Lifecycle v2 configuration: masterStatuses.effective (the five defaults plus the account's customs) and intakeDeals (null can mean not configured OR not visible to your key — the setting is admin-visible only)")
1676
+ .action(run(async (client) => {
1677
+ const DEFAULTS = ["open", "won", "lost", "abandoned", "disqualified"];
1678
+ const result = (await client.get("/api/v1/settings"));
1679
+ const raw = result?.data ?? {};
1680
+ const customs = Array.isArray(raw.masterStatuses)
1681
+ ? raw.masterStatuses.filter((x) => typeof x === "string")
1682
+ : [];
1683
+ const intakeDeals = raw.intakeDeals && typeof raw.intakeDeals === "object" ? raw.intakeDeals : null;
1684
+ return {
1685
+ data: {
1686
+ masterStatuses: {
1687
+ defaults: DEFAULTS,
1688
+ customs,
1689
+ effective: [...DEFAULTS, ...customs.filter((c) => !DEFAULTS.includes(c.toLowerCase()))],
1690
+ },
1691
+ intakeDeals,
1692
+ ...(intakeDeals === null
1693
+ ? {
1694
+ intakeDealsNote: "null = not configured, OR your key's role cannot read this admin-visible setting",
1695
+ }
1696
+ : {}),
1697
+ },
1698
+ };
1699
+ }));
1700
+ lifecycle
1701
+ .command("set-intake-deals")
1702
+ .description("Configure automatic deal creation on live intake (webhooks, public forms, public bookings). Even under --re-inbound always: bulk imports and staff/calendar/dialer-created appointments never mint, exact replays and same-source events within 10 min are skipped, and terminal-status contacts get no new deals")
1703
+ .option("--enable", "turn intake deals ON")
1704
+ .option("--disable", "turn intake deals OFF")
1705
+ .option("--pipeline <id>", "pipeline UUID the intake deals land in")
1706
+ .option("--stage <id>", "starting stage UUID within that pipeline")
1707
+ .option("--re-inbound <policy>", "policy for existing contacts: always|if_no_open|never")
1708
+ .action(run(async (client, opts) => {
1709
+ if (opts.enable && opts.disable)
1710
+ throw new Error("Pass either --enable or --disable, not both.");
1711
+ if (opts.enable === undefined && opts.disable === undefined) {
1712
+ throw new Error("Pass --enable or --disable.");
1713
+ }
1714
+ if (!opts.pipeline || !opts.stage) {
1715
+ // The API requires the full object, so the CLI must too — a partial
1716
+ // PATCH would 422 with a less obvious message.
1717
+ throw new Error("--pipeline and --stage are required (see `conduyt pipelines list`).");
1718
+ }
1719
+ const body = {
1720
+ enabled: Boolean(opts.enable),
1721
+ pipelineId: opts.pipeline,
1722
+ stageId: opts.stage,
1723
+ };
1724
+ if (opts.reInbound !== undefined) {
1725
+ if (!["always", "if_no_open", "never"].includes(opts.reInbound)) {
1726
+ throw new Error("--re-inbound must be one of: always, if_no_open, never.");
1727
+ }
1728
+ body.reInbound = opts.reInbound;
1729
+ }
1730
+ return client.patch("/api/v1/settings", { intakeDeals: body });
1731
+ }));
1732
+ lifecycle
1733
+ .command("set-master-statuses [statuses...]")
1734
+ .description("Set the account's CUSTOM lead lifecycle statuses (the five defaults always exist). Pass --clear with no names to remove all customs")
1735
+ .option("--clear", "remove ALL custom statuses (restore defaults-only)")
1736
+ .action(run(async (client, statuses, opts) => {
1737
+ statuses = statuses ?? [];
1738
+ if (statuses.length === 0 && !opts.clear) {
1739
+ throw new Error("Pass at least one status name, or --clear to remove all customs.");
1740
+ }
1741
+ if (statuses.length > 0 && opts.clear) {
1742
+ throw new Error("--clear cannot be combined with status names.");
1743
+ }
1744
+ // Mirror of the API validator: max 20 names, 1-40 chars, letters/
1745
+ // numbers/spaces/-/_ only — fail fast locally instead of a late 422.
1746
+ const clean = statuses.map((s) => s.trim());
1747
+ if (clean.some((s) => !s))
1748
+ throw new Error("Status names must not be blank.");
1749
+ if (clean.length > 20)
1750
+ throw new Error("At most 20 custom statuses.");
1751
+ const bad = clean.find((s) => s.length > 40 || !/^[a-z0-9][a-z0-9 _-]*$/i.test(s));
1752
+ if (bad)
1753
+ throw new Error(`Invalid status name '${bad}' — 1-40 chars; letters, numbers, spaces, - and _ only.`);
1754
+ return client.patch("/api/v1/settings", { masterStatuses: clean });
1755
+ }));
1607
1756
  program.parseAsync(process.argv).catch(fail);
1608
1757
  // helpers
1609
1758
  // Parse a --json / --stages style argument, throwing a clear CLI error (caught
@@ -1761,6 +1910,23 @@ function run(handler) {
1761
1910
  }
1762
1911
  };
1763
1912
  }
1913
+ // Contact source is the first-write-wins acquisition truth: a blank value
1914
+ // must never reach storage, whether it arrived via a flag or --json (the
1915
+ // merge runs last, so this validates the FINAL body).
1916
+ function assertContactSource(value, opts) {
1917
+ if (value === undefined)
1918
+ return;
1919
+ if (value === null) {
1920
+ if (opts.allowNull)
1921
+ return;
1922
+ throw new Error("source cannot be null on create — omit it instead.");
1923
+ }
1924
+ if (typeof value !== "string" || value.trim() === "") {
1925
+ throw new Error("source must be a non-blank string (or omitted).");
1926
+ }
1927
+ if (value.length > 100)
1928
+ throw new Error("source must be at most 100 characters.");
1929
+ }
1764
1930
  function mask(key) {
1765
1931
  if (!key)
1766
1932
  return "(not set)";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Command-line interface for Conduyt CRM \u2014 manage contacts, deals, pipelines, and run insight queries from your terminal.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -18,8 +18,9 @@
18
18
  "build": "tsc",
19
19
  "typecheck": "tsc --noEmit",
20
20
  "start": "tsx src/index.ts",
21
- "prepublishOnly": "npm run build",
22
- "test": "node --import tsx --test src/*.test.ts"
21
+ "prepublishOnly": "npm run check:version && npm run build && node --import tsx --experimental-test-module-mocks --test src/privacy-forget.test.ts src/contact-source-json.test.ts",
22
+ "test": "node --import tsx --test src/*.test.ts",
23
+ "check:version": "node -e \"const p=require('./package.json').version,l=require('./package-lock.json');if(l.version!==p||l.packages[''].version!==p){console.error('lockfile version != '+p);process.exit(1)}console.log('Version sync OK: '+p)\""
23
24
  },
24
25
  "keywords": [
25
26
  "conduyt",
@@ -47,4 +48,4 @@
47
48
  "tsx": "^4.19.0",
48
49
  "@types/node": "^22.0.0"
49
50
  }
50
- }
51
+ }