conduyt 1.41.0 → 1.42.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 +88 -6
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1877,6 +1877,65 @@ program
1877
1877
  }));
1878
1878
  // ---- dialer ----
1879
1879
  const dialer = program.command("dialer").description("Dialer operations");
1880
+ // ---- curated agent statuses (#71, 2026-09-15) ----
1881
+ const AGENT_STATUS_CODES = ["available", "break", "lunch", "meeting", "training", "offline"];
1882
+ const FIXED_AGENT_STATUS_CODES = ["available", "offline"];
1883
+ const MAX_AGENT_STATUS_LABEL = 24;
1884
+ function agentStatusCodeArg(raw) {
1885
+ const code = raw.trim().toLowerCase();
1886
+ if (!AGENT_STATUS_CODES.includes(code))
1887
+ throw new Error(`'${raw}' is not a dialer status — one of ${AGENT_STATUS_CODES.join(", ")}.`);
1888
+ return code;
1889
+ }
1890
+ async function readAgentStatusesConfig(client) {
1891
+ const current = (await client.get("/api/v1/settings"));
1892
+ const stored = current?.data?.agentStatuses ?? {};
1893
+ const labels = Object.fromEntries(Object.entries(stored.labels ?? {}).filter((e) => typeof e[1] === "string"));
1894
+ const hidden = Array.isArray(stored.hidden) ? stored.hidden.filter((c) => typeof c === "string") : [];
1895
+ return { labels, hidden };
1896
+ }
1897
+ dialer
1898
+ .command("statuses")
1899
+ .description("List the account's curated dialer statuses (GET /dialer/agent-statuses): Available, Break, Lunch, Meeting, Training, Offline. Each carries a FIXED basis the dialer runs on (available = takes inbound calls and Smart Dialing hand-outs; break = signed in, not ringable; offline = signed out), the label reps see, and whether admins hid it from the softphone menu. Rename with `dialer rename-status`, hide/show with `dialer hide-status` / `dialer show-status`; Available and Offline can never be hidden. Reports bucket by basis.")
1900
+ .action(run(async (client) => client.get("/api/v1/dialer/agent-statuses")));
1901
+ dialer
1902
+ .command("rename-status <code> <label>")
1903
+ .description(`Rename a curated dialer status for this account (settings scope): e.g. \`rename-status break "Short break"\`. Pass \`default\` as the label to restore the built-in name. Labels are 1–${MAX_AGENT_STATUS_LABEL} characters; two visible statuses cannot share a label (422). Codes: ${AGENT_STATUS_CODES.join(", ")}. The other labels and the hidden list stay as they are.`)
1904
+ .action(run(async (client, codeRaw, labelRaw) => {
1905
+ const code = agentStatusCodeArg(codeRaw);
1906
+ const label = labelRaw.trim();
1907
+ if (!label)
1908
+ throw new Error("The label must not be blank; pass 'default' to restore the built-in name.");
1909
+ if (label.length > MAX_AGENT_STATUS_LABEL)
1910
+ throw new Error(`Labels are at most ${MAX_AGENT_STATUS_LABEL} characters.`);
1911
+ const next = await readAgentStatusesConfig(client);
1912
+ if (label.toLowerCase() === "default")
1913
+ delete next.labels[code];
1914
+ else
1915
+ next.labels[code] = label;
1916
+ return patchGeneralSettings(client, { agentStatuses: next });
1917
+ }));
1918
+ dialer
1919
+ .command("hide-status <code>")
1920
+ .description(`Hide a curated dialer status from the softphone menu (settings scope). Available and Offline cannot be hidden; a rep already on a hidden status keeps it until they change. Codes: ${AGENT_STATUS_CODES.join(", ")}.`)
1921
+ .action(run(async (client, codeRaw) => {
1922
+ const code = agentStatusCodeArg(codeRaw);
1923
+ if (FIXED_AGENT_STATUS_CODES.includes(code))
1924
+ throw new Error(`'${code}' cannot be hidden — every rep needs it.`);
1925
+ const next = await readAgentStatusesConfig(client);
1926
+ if (!next.hidden.includes(code))
1927
+ next.hidden.push(code);
1928
+ return patchGeneralSettings(client, { agentStatuses: next });
1929
+ }));
1930
+ dialer
1931
+ .command("show-status <code>")
1932
+ .description(`Put a hidden curated dialer status back in the softphone menu (settings scope). Codes: ${AGENT_STATUS_CODES.join(", ")}.`)
1933
+ .action(run(async (client, codeRaw) => {
1934
+ const code = agentStatusCodeArg(codeRaw);
1935
+ const next = await readAgentStatusesConfig(client);
1936
+ next.hidden = next.hidden.filter((c) => c !== code);
1937
+ return patchGeneralSettings(client, { agentStatuses: next });
1938
+ }));
1880
1939
  dialer
1881
1940
  .command("voice-drop")
1882
1941
  .description("Place a voice drop to a contact (POST /calls/voice-drop): an outbound Twilio call with answering-machine detection that plays one of the account's Voicemail Drop recordings (Settings → Twilio → Voicemail Drop; names from `messages transports` voiceDrop.recordings) when a machine answers, and hangs up quietly when a person answers unless --if-human play. Governed like a call: calls-tier consent (a texting STOP does not block it, a DNC or litigator row does), calling hours, one live call per lead. Placed from your own Twilio line (else the account number) unless --from names a number the account owns. Returns 202 with the Call row; the outcome lands on it through the voice webhooks (disposition voicemail_drop = played to a machine, voice_drop_human = a person answered, voice_drop_live = played to a person). Refusals carry a code (dnc, litigator, calling_window, active_call, voice_drop_recording_missing, voice_drop_not_configured); 429 and 502 are safe to retry (dialer scope)")
@@ -1952,6 +2011,26 @@ replyCapture
1952
2011
  return client.del("/api/v1/email-domains/reply-capture");
1953
2012
  }));
1954
2013
  // ---- lifecycle (master lead status + intake deals) ----
2014
+ // ---- general settings writes (09-15) ----
2015
+ // PATCH /api/v1/settings refuses a general-settings change without the settingsRevision the editor loaded
2016
+ // (400 expected_settings_revision_required) and answers 409 when another save landed first. Every general
2017
+ // write below reads the revision first and sends it; a 409 is re-raised with the current settings so the
2018
+ // caller re-derives the change instead of overwriting a newer save.
2019
+ async function patchGeneralSettings(client, body) {
2020
+ const read = async () => (await client.get("/api/v1/settings"));
2021
+ const before = await read();
2022
+ const expectedSettingsRevision = typeof before?.data?.settingsRevision === "string" ? before.data.settingsRevision : null;
2023
+ try {
2024
+ return await client.patch("/api/v1/settings", { ...body, ...(expectedSettingsRevision ? { expectedSettingsRevision } : {}) });
2025
+ }
2026
+ catch (err) {
2027
+ if (err instanceof Error && /^Conduyt API 409/.test(err.message)) {
2028
+ const current = await read();
2029
+ throw new Error(`${err.message} — the account settings changed since revision ${expectedSettingsRevision ?? "?"} was read; re-run the command (current: ${JSON.stringify(current?.data ?? null).slice(0, 1500)})`);
2030
+ }
2031
+ throw err;
2032
+ }
2033
+ }
1955
2034
  const lifecycle = program
1956
2035
  .command("lifecycle")
1957
2036
  .description("Lifecycle settings: lead master statuses, intake-deal creation, how the tenant works leads, and lost propagation");
@@ -2007,7 +2086,7 @@ lifecycle
2007
2086
  .action(run(async (client, mode) => {
2008
2087
  if (mode !== "pipeline" && mode !== "status")
2009
2088
  throw new Error("mode must be 'pipeline' or 'status'.");
2010
- return client.patch("/api/v1/settings", { dealWorkingMode: mode });
2089
+ return patchGeneralSettings(client, { dealWorkingMode: mode });
2011
2090
  }));
2012
2091
  lifecycle
2013
2092
  .command("set-lost-propagation")
@@ -2019,7 +2098,7 @@ lifecycle
2019
2098
  throw new Error("Pass either --enable or --disable, not both.");
2020
2099
  if (opts.enable === undefined && opts.disable === undefined)
2021
2100
  throw new Error("Pass --enable or --disable.");
2022
- return client.patch("/api/v1/settings", { lostPropagation: { enabled: Boolean(opts.enable) } });
2101
+ return patchGeneralSettings(client, { lostPropagation: { enabled: Boolean(opts.enable) } });
2023
2102
  }));
2024
2103
  lifecycle
2025
2104
  .command("set-intake-deals")
@@ -2051,7 +2130,7 @@ lifecycle
2051
2130
  }
2052
2131
  body.reInbound = opts.reInbound;
2053
2132
  }
2054
- return client.patch("/api/v1/settings", { intakeDeals: body });
2133
+ return patchGeneralSettings(client, { intakeDeals: body });
2055
2134
  }));
2056
2135
  lifecycle
2057
2136
  .command("set-master-statuses [statuses...]")
@@ -2075,7 +2154,7 @@ lifecycle
2075
2154
  const bad = clean.find((s) => s.length > 40 || !/^[a-z0-9][a-z0-9 _-]*$/i.test(s));
2076
2155
  if (bad)
2077
2156
  throw new Error(`Invalid status name '${bad}' — 1-40 chars; letters, numbers, spaces, - and _ only.`);
2078
- return client.patch("/api/v1/settings", { masterStatuses: clean });
2157
+ return patchGeneralSettings(client, { masterStatuses: clean });
2079
2158
  }));
2080
2159
  lifecycle
2081
2160
  .command("set-disposition-status <disposition> <status>")
@@ -2096,7 +2175,7 @@ lifecycle
2096
2175
  delete next[d];
2097
2176
  else
2098
2177
  next[d] = st;
2099
- return client.patch("/api/v1/settings", { callDispositions: { statusMap: next } });
2178
+ return patchGeneralSettings(client, { callDispositions: { statusMap: next } });
2100
2179
  }));
2101
2180
  // ---- account default columns for the Contacts list (tri-surface parity, 2026-09-03) ----
2102
2181
  const CONTACT_COLUMN_IDS = ["name", "email", "phone", "company", "source", "tags", "score", "intent", "stage", "createdAt", "lastActivity", "assignedTo", "address", "city", "state", "zip"];
@@ -2124,7 +2203,10 @@ lifecycle
2124
2203
  const bad = clean.find((id) => !CONTACT_COLUMN_IDS.includes(id) && !/^custom:[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/.test(id));
2125
2204
  if (bad)
2126
2205
  throw new Error(`Unknown column id '${bad}' — one of ${CONTACT_COLUMN_IDS.join(", ")} or custom:<fieldKey>.`);
2127
- return client.patch("/api/v1/settings", { contactTableDefaults: { columns: clean } });
2206
+ // the defaults row carries its own revision token ("0" before the first save); the API refuses the write without it
2207
+ const current = (await client.get("/api/v1/settings"));
2208
+ const rev = current?.data?.contactTableDefaults?.revision;
2209
+ return client.patch("/api/v1/settings", { contactTableDefaults: { columns: clean, expectedRevision: typeof rev === "string" && rev ? rev : "0" } });
2128
2210
  }));
2129
2211
  // ---- call flows + ring groups (#25 tri-surface parity, 2026-08-23) ----
2130
2212
  // IVR routing graphs and their companion ring sets. Settings-scoped at the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.41.0",
3
+ "version": "1.42.0",
4
4
  "description": "Command-line interface for Conduyt CRM — manage contacts, deals, pipelines, and run insight queries from your terminal.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",