conduyt 1.80.0 → 1.82.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.
- package/README.md +7 -2
- package/dist/board-settings.js +3 -3
- package/dist/index.js +193 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,8 +42,12 @@ conduyt contacts collaborators add <contactId> <userId> [--expected-revision <n>
|
|
|
42
42
|
conduyt contacts collaborators remove <contactId> <userId> [--expected-revision <n>] # DELETE with a body; prints { removed, revision }; 404 when not a collaborator
|
|
43
43
|
conduyt chat channels # GET /chat/channels: your channels; also CREATES your auto-channel memberships the first time
|
|
44
44
|
conduyt chat unread # GET /chat/unread: per channel { channelId, unread, soundUnread, newestSeq, latestSeq }
|
|
45
|
-
conduyt chat messages <channelId> [--limit 1-100] [--cursor <messageId>] # newest first; prints { data, hasMore, nextCursor }; every message carries `seq`; does not move the read marker; a 403 "Not a member of this channel" is retried once after `chat channels`
|
|
45
|
+
conduyt chat messages <channelId> [--limit 1-100] [--cursor <messageId> | --after-seq <n>] # newest first; --after-seq = only messages committed after that seq (catch-up read, never with --cursor); each message also carries `version`; prints { data, hasMore, nextCursor }; every message carries `seq`; does not move the read marker; a 403 "Not a member of this channel" is retried once after `chat channels`
|
|
46
46
|
conduyt chat mark-read <channelId> <messageId> # readUpTo: marker moves to that message's seq, forward only (older or foreign message = no-op, exit 0); prints the channel's newest message; same one-retry membership bootstrap
|
|
47
|
+
conduyt chat realtime-token [--channel <id>]... # GET /realtime/token: signed Ably token request (up to 10 --channel); { enabled: false, reason } when the account switch is off; prints accountId / userId
|
|
48
|
+
conduyt chat notifications <channelId> # GET .../notifications: { level, isDefault, notify, updatedAt, membershipId, membershipSince, membershipSeq }
|
|
49
|
+
conduyt chat set-notifications <channelId> --level all|mentions|mute|default --expected-updated-at <iso|none> --expected-membership-id <id> --account-id <id> --user-id <id> # compare-and-set, sent once; 409 stale prints the current membership and exits 1
|
|
50
|
+
conduyt chat settings [--realtime on|off] [--presence on|off] [--quiet-hours HH:MM-HH:MM|none] [--digest-minutes 0|15-240] # PATCH /settings teamChat, owner/admin only
|
|
47
51
|
conduyt notifications list [--page n] [--per-page 1-200] # prints { data, meta }: meta.latestByType = { type: { latest, count } } over the last 24 h, next to unreadCount / unreadByType
|
|
48
52
|
conduyt deals list --pipeline <id> # list deals
|
|
49
53
|
conduyt deals list --health-summary --pipeline <id> # Pipeline health strip for the filtered set (totalOpenDeals, atRiskValue, checks[]); add --health-check stale --health-limit 20 for one check's deals, --health-cursor <nextCursor> to page, --health-rows-only for just the page
|
|
@@ -52,8 +56,9 @@ conduyt deals health --pipeline <id> --check needs_action # GET /deals/inspect
|
|
|
52
56
|
conduyt deals health-fix <dealId> --check stale --expected-updated-at <iso> --stage-id <uuid> # Pipeline health quick fix (PATCH /deals/{id} with checkOrigin): stale = --stage-id, unassigned = --assigned, missing_close_date = --close-date, missing_contact = --contact; 409 deal_changed / deal_check_cleared and 400 health_fix_mismatch exit 1 verbatim, never retried
|
|
53
57
|
conduyt deals health-next-action <dealId> --check needs_action --expected-updated-at <iso> --title "Call back" --idempotency-key <key> # "Set next action" (POST /tasks with checkOrigin): one non-done quick-fix task per deal, 409 deal_has_next_action / deal_changed / deal_check_cleared exit 1 verbatim
|
|
54
58
|
conduyt deals card-fields set value,contact,custom:next_contact_at # the kanban card fields (1-8, built-ins or custom:<fieldKey>); `card-fields get` reads them
|
|
55
|
-
conduyt pipelines stage-rules set <pipelineId> <stageId> --required-fields contactId,custom:loan_amount --required-documents 2 [--lost-reason-allow-other true] # what a deal needs to enter the stage; other stages untouched; `stage-rules get [pipelineId] [stageId]` reads
|
|
59
|
+
conduyt pipelines stage-rules set <pipelineId> <stageId> --required-fields contactId,custom:loan_amount --required-documents 2 [--lost-reason-allow-other true] [--entry-by-automation-only true] # what a deal needs to enter the stage (--entry-by-automation-only true: only an automation can move or create a deal into it, every other entry is refused 422 stage_automation_only; `pipelines list` shows it on each stage); other stages untouched; `stage-rules get [pipelineId] [stageId]` reads
|
|
56
60
|
conduyt deals bulk-update --file updates.json # update up to 100 deals at once ([{id, stageId, value, ...}]); per-item results, exit 1 if any failed
|
|
61
|
+
conduyt deals bulk-edit --ids <id>,<id> --stage <stage> # the SAME change on up to 500 deals (POST /bulk/deals/edit { ids, updates }); failed deals are listed with their code (e.g. stage_automation_only), exit 1 if any failed
|
|
57
62
|
conduyt pipelines # list pipelines and stages (each carries color, automationOnly, sortOrder)
|
|
58
63
|
conduyt pipelines list --archived # the archived pipelines (owner / admin key): name, archivedName, openDeals, stageCount
|
|
59
64
|
conduyt pipelines archive <id> --move-open-deals-to <pipelineId|none> [--expected-settings-revision N] # archive + move the open deals in one transaction; refusals print their code (stale_settings_revision, target_not_open, dpp_post_close, archive_too_large, target_archived, intake_target, pipeline_archived) and exit 1
|
package/dist/board-settings.js
CHANGED
|
@@ -11,7 +11,7 @@ export const MAX_STAGE_REQUIRED_FIELDS = 25;
|
|
|
11
11
|
export const MAX_STAGE_REQUIRED_DOCUMENTS = 20;
|
|
12
12
|
export const MAX_LOST_REASON_OPTIONS = 50;
|
|
13
13
|
const CUSTOM_FIELD_RE = /^custom:[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
14
|
-
export const RULE_KEYS = ["requiredFields", "requiredDocuments", "lostReasonLabel", "lostReasonOptions", "lostReasonAllowOther"];
|
|
14
|
+
export const RULE_KEYS = ["requiredFields", "requiredDocuments", "lostReasonLabel", "lostReasonOptions", "lostReasonAllowOther", "entryByAutomationOnly"];
|
|
15
15
|
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
16
16
|
/** "a,b,custom:x" → ["a","b","custom:x"]; blank items are dropped, an all-blank value is an empty list */
|
|
17
17
|
export function splitList(raw) {
|
|
@@ -64,9 +64,9 @@ export function validateRuleChange(raw) {
|
|
|
64
64
|
throw new Error("lostReasonLabel must be 1-60 characters.");
|
|
65
65
|
out[k] = t;
|
|
66
66
|
}
|
|
67
|
-
else if (k === "lostReasonAllowOther") {
|
|
67
|
+
else if (k === "lostReasonAllowOther" || k === "entryByAutomationOnly") {
|
|
68
68
|
if (typeof v !== "boolean")
|
|
69
|
-
throw new Error(
|
|
69
|
+
throw new Error(`${k} must be true or false.`);
|
|
70
70
|
out[k] = v;
|
|
71
71
|
}
|
|
72
72
|
else {
|
package/dist/index.js
CHANGED
|
@@ -38,6 +38,18 @@ function readVersion() {
|
|
|
38
38
|
return "0.0.0";
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
/** (#289 r3) the bulk deal routes read a fixed field list and silently DROP any other key, so a typo (`titel`) would hide behind a
|
|
42
|
+
* partly applied call: refuse it locally. app/api/v1/bulk/deals/edit/route.ts:56 (MAX_BULK_SIZE 500, refused 422 at :139-140) and
|
|
43
|
+
* its ALLOWED_FIELDS; app/api/v1/bulk/deals/update/route.ts:49-60 (MAX_BULK_SIZE 100, ALLOWED_FIELDS plus customFields; each item also has id). */
|
|
44
|
+
const BULK_EDIT_MAX_IDS = 500;
|
|
45
|
+
const BULK_UPDATE_MAX_ITEMS = 100;
|
|
46
|
+
const BULK_EDIT_FIELDS = ["title", "stageId", "value", "currency", "status", "priority", "assignedTo", "expectedCloseDate", "lostReason"];
|
|
47
|
+
const BULK_UPDATE_FIELDS = ["id", ...BULK_EDIT_FIELDS, "customFields"];
|
|
48
|
+
function assertAllowedKeys(keys, allowed, where) {
|
|
49
|
+
const bad = keys.filter((k) => !allowed.includes(k));
|
|
50
|
+
if (bad.length > 0)
|
|
51
|
+
throw new Error(`${where}: unknown field ${bad.map((k) => `"${k}"`).join(", ")}. The API ignores unknown fields silently, so nothing was sent. Allowed: ${allowed.join(", ")}.`);
|
|
52
|
+
}
|
|
41
53
|
const program = new Command();
|
|
42
54
|
program
|
|
43
55
|
.name("conduyt")
|
|
@@ -891,7 +903,7 @@ cardFields
|
|
|
891
903
|
}));
|
|
892
904
|
deals
|
|
893
905
|
.command("create")
|
|
894
|
-
.description("Create a deal (use `pipelines list` to get pipeline/stage ids)")
|
|
906
|
+
.description("Create a deal (use `pipelines list` to get pipeline/stage ids). A stage set to \"Entry by automation only\" refuses every entry: 422 code stage_automation_only, message \"Only an automation can move a deal into <stage>\" (admins and API keys included; moving out is free); the code and message are printed on stderr unchanged.")
|
|
895
907
|
.option("--title <title>", "deal title")
|
|
896
908
|
.option("--pipeline <id>", "pipeline UUID")
|
|
897
909
|
.option("--stage <id>", "stage UUID")
|
|
@@ -958,7 +970,7 @@ function assertDealSource(body) {
|
|
|
958
970
|
}
|
|
959
971
|
deals
|
|
960
972
|
.command("update <id>")
|
|
961
|
-
.description("Update a deal (PATCH — only the fields you pass change). --stage moves the deal (won/lost status auto-derives from the target stage's flags)")
|
|
973
|
+
.description("Update a deal (PATCH — only the fields you pass change). --stage moves the deal (won/lost status auto-derives from the target stage's flags). A stage set to \"Entry by automation only\" refuses every entry: 422 code stage_automation_only, message \"Only an automation can move a deal into <stage>\" (admins and API keys included; moving out is free); the code and message are printed on stderr unchanged.")
|
|
962
974
|
.option("--title <title>", "deal title")
|
|
963
975
|
.option("--value <n>", "deal value (number)")
|
|
964
976
|
.option("--currency <code>", "ISO currency, e.g. GBP")
|
|
@@ -1027,7 +1039,7 @@ deals
|
|
|
1027
1039
|
}));
|
|
1028
1040
|
deals
|
|
1029
1041
|
.command("bulk-update")
|
|
1030
|
-
.description("Update up to 100 deals in one request (POST /bulk/deals/update). Pass a JSON array of { id, ...editable fields (title, stageId, value, currency, status, priority, assignedTo, expectedCloseDate, lostReason, customFields) } via --updates or --file. Each item is applied on its own; prints the summary and one result per item, and exits 1 when any item failed (the full result is still printed). Rate limited to 5 requests a minute")
|
|
1042
|
+
.description("Update up to 100 deals in one request (POST /bulk/deals/update). Pass a JSON array of { id, ...editable fields (title, stageId, value, currency, status, priority, assignedTo, expectedCloseDate, lostReason, customFields) } via --updates or --file. Each item is applied on its own; prints the summary and one result per item, and exits 1 when any item failed (the full result is still printed). Rate limited to 5 requests a minute. A stage change into an \"Entry by automation only\" stage fails that item with code stage_automation_only; the code stays on its result row in the printed output and is repeated on its `Failed <id>` stderr line")
|
|
1031
1043
|
.option("--updates <json>", "the updates as a JSON array (or @file)")
|
|
1032
1044
|
.option("--file <path>", "read the updates JSON array from a file")
|
|
1033
1045
|
.action(async (opts) => {
|
|
@@ -1060,6 +1072,10 @@ deals
|
|
|
1060
1072
|
}
|
|
1061
1073
|
if (!Array.isArray(updates))
|
|
1062
1074
|
throw new Error("The updates must be a JSON array of { id, ...fields } objects.");
|
|
1075
|
+
if (updates.length > BULK_UPDATE_MAX_ITEMS)
|
|
1076
|
+
throw new Error(`The updates take at most ${BULK_UPDATE_MAX_ITEMS} items (got ${updates.length}); the API refuses more with 422.`);
|
|
1077
|
+
updates.forEach((u, i) => { if (u && typeof u === "object" && !Array.isArray(u))
|
|
1078
|
+
assertAllowedKeys(Object.keys(u), BULK_UPDATE_FIELDS, `updates[${i}]`); });
|
|
1063
1079
|
const client = new ConduytClient();
|
|
1064
1080
|
const res = await client.post("/api/v1/bulk/deals/update", { updates });
|
|
1065
1081
|
print(res);
|
|
@@ -1083,7 +1099,7 @@ deals
|
|
|
1083
1099
|
const failedItems = results.filter((r) => r.success === false);
|
|
1084
1100
|
if (failedItems.length > 0 || Number(sm.failed) > 0) {
|
|
1085
1101
|
for (const r of failedItems)
|
|
1086
|
-
process.stderr.write(`Failed ${r.id}: ${r.error ?? "unknown error"}\n`);
|
|
1102
|
+
process.stderr.write(`Failed ${r.id}: ${r.error ?? "unknown error"}${typeof r.code === "string" ? ` [code: ${r.code}]` : ""}\n`);
|
|
1087
1103
|
process.stderr.write(`Error: ${failedItems.length || sm.failed} of ${sm.total} updates failed.\n`);
|
|
1088
1104
|
process.exitCode = 1;
|
|
1089
1105
|
}
|
|
@@ -1092,6 +1108,83 @@ deals
|
|
|
1092
1108
|
fail(err);
|
|
1093
1109
|
}
|
|
1094
1110
|
});
|
|
1111
|
+
deals
|
|
1112
|
+
.command("bulk-edit")
|
|
1113
|
+
.description("Apply the SAME field changes to many deals in one request (POST /bulk/deals/edit, body { ids, updates }; up to 500 ids). --ids is a comma-separated list of deal UUIDs; the update flags (--stage, --title, --value, --currency, --status, --priority, --assigned-to, --expected-close-date, --lost-reason) or --json build the one updates object. Each deal is applied on its own; prints { updated, skippedNoOp, requested, errors } and exits 1 when any deal failed (the full result is still printed). A stage change into an \"Entry by automation only\" stage fails that deal with code stage_automation_only and the message \"Only an automation can move a deal into <stage>\": the code stays on its row in the printed errors and is repeated on its `Failed <id>` stderr line. For different changes per deal use `deals bulk-update`")
|
|
1114
|
+
.requiredOption("--ids <list>", "comma-separated deal UUIDs")
|
|
1115
|
+
.option("--stage <name-or-uuid>", "move every deal to this stage (a stage name is resolved via /automations/resolve)")
|
|
1116
|
+
.option("--title <title>", "deal title")
|
|
1117
|
+
.option("--value <n>", "deal value (number)")
|
|
1118
|
+
.option("--currency <code>", "ISO currency, e.g. GBP")
|
|
1119
|
+
.option("--status <status>", "explicit status (open|won|lost)")
|
|
1120
|
+
.option("--priority <priority>", "priority, e.g. low|medium|high")
|
|
1121
|
+
.option("--assigned-to <id>", "owner user UUID")
|
|
1122
|
+
.option("--expected-close-date <date>", "expected close date (ISO 8601)")
|
|
1123
|
+
.option("--lost-reason <reason>", "reason for a lost/disqualified deal")
|
|
1124
|
+
.option("--json <json>", "the updates object as JSON, merged over the flags")
|
|
1125
|
+
.action(async (opts) => {
|
|
1126
|
+
try {
|
|
1127
|
+
// Preflight: everything the CLI can check is checked BEFORE the stage name is looked up and before any request.
|
|
1128
|
+
const ids = uuidList(opts.ids, "--ids");
|
|
1129
|
+
if (ids.length > BULK_EDIT_MAX_IDS)
|
|
1130
|
+
throw new Error(`--ids takes at most ${BULK_EDIT_MAX_IDS} deal ids (got ${ids.length}); the API refuses more with 422.`);
|
|
1131
|
+
const updates = {};
|
|
1132
|
+
if (opts.title !== undefined)
|
|
1133
|
+
updates.title = opts.title;
|
|
1134
|
+
if (opts.value !== undefined) {
|
|
1135
|
+
const t = opts.value.trim();
|
|
1136
|
+
const n = Number(t);
|
|
1137
|
+
if (t === "" || !Number.isFinite(n))
|
|
1138
|
+
throw new Error("--value must be a finite number.");
|
|
1139
|
+
updates.value = n;
|
|
1140
|
+
}
|
|
1141
|
+
if (opts.currency !== undefined)
|
|
1142
|
+
updates.currency = opts.currency;
|
|
1143
|
+
if (opts.status !== undefined)
|
|
1144
|
+
updates.status = opts.status;
|
|
1145
|
+
if (opts.priority !== undefined)
|
|
1146
|
+
updates.priority = opts.priority;
|
|
1147
|
+
if (opts.assignedTo !== undefined)
|
|
1148
|
+
updates.assignedTo = uuidOf(opts.assignedTo, "--assigned-to");
|
|
1149
|
+
if (opts.expectedCloseDate !== undefined)
|
|
1150
|
+
updates.expectedCloseDate = opts.expectedCloseDate;
|
|
1151
|
+
if (opts.lostReason !== undefined) {
|
|
1152
|
+
if (opts.lostReason.trim() === "")
|
|
1153
|
+
throw new Error("--lost-reason was provided but is blank. Omit it or pass a reason.");
|
|
1154
|
+
updates.lostReason = opts.lostReason;
|
|
1155
|
+
}
|
|
1156
|
+
if (opts.stage !== undefined && opts.stage.trim() === "")
|
|
1157
|
+
throw new Error("--stage was provided but is blank. Omit it or pass a stage name/UUID.");
|
|
1158
|
+
if (opts.json !== undefined)
|
|
1159
|
+
Object.assign(updates, jsonArg(opts.json, "--json"));
|
|
1160
|
+
assertAllowedKeys(Object.keys(updates), BULK_EDIT_FIELDS, "--json updates");
|
|
1161
|
+
assertFiniteValue(updates.value);
|
|
1162
|
+
const stageFromFlag = opts.stage !== undefined && updates.stageId === undefined;
|
|
1163
|
+
if (Object.keys(updates).length === 0 && !stageFromFlag)
|
|
1164
|
+
throw new Error("Nothing to apply. Pass at least one update flag or --json.");
|
|
1165
|
+
const client = new ConduytClient();
|
|
1166
|
+
if (stageFromFlag)
|
|
1167
|
+
updates.stageId = await resolveStageId(client, opts.stage);
|
|
1168
|
+
const res = await client.post("/api/v1/bulk/deals/edit", { ids, updates });
|
|
1169
|
+
print(res);
|
|
1170
|
+
const data = unwrap(res);
|
|
1171
|
+
const errors = data && typeof data === "object" && Array.isArray(data.errors) ? data.errors : null;
|
|
1172
|
+
if (!errors || typeof data.updated !== "number" || typeof data.requested !== "number") {
|
|
1173
|
+
process.stderr.write("Error: Unexpected response shape from the API: expected data.updated, data.requested and data.errors.\n");
|
|
1174
|
+
process.exitCode = 1;
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
if (errors.length > 0) {
|
|
1178
|
+
for (const e of errors)
|
|
1179
|
+
process.stderr.write(`Failed ${String(e?.id ?? "?")}: ${e?.error ?? "unknown error"}${typeof e?.code === "string" ? ` [code: ${e.code}]` : ""}\n`);
|
|
1180
|
+
process.stderr.write(`Error: ${errors.length} of ${data.requested} deals failed.\n`);
|
|
1181
|
+
process.exitCode = 1;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
catch (err) {
|
|
1185
|
+
fail(err);
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1095
1188
|
// ---- pipelines ----
|
|
1096
1189
|
// Default action preserves the original published `conduyt pipelines` (list)
|
|
1097
1190
|
// invocation now that pipelines is a group — without it, commander prints help
|
|
@@ -1103,7 +1196,7 @@ const pipelines = program
|
|
|
1103
1196
|
.action(run(async (client) => client.get("/api/v1/pipelines")));
|
|
1104
1197
|
pipelines
|
|
1105
1198
|
.command("list")
|
|
1106
|
-
.description("List pipelines and their stages (each pipeline carries color, automationOnly, sortOrder). --archived lists the ARCHIVED pipelines instead (owner / admin key): id, name (the pre-archive name), archivedName, color, archivedAt, openDeals, stageCount")
|
|
1199
|
+
.description("List pipelines and their stages (each pipeline carries color, automationOnly, sortOrder; each stage carries entryByAutomationOnly: true = only an automation can move or create a deal INTO it, every other entry is refused 422 stage_automation_only; set it with `pipelines stage-rules set --entry-by-automation-only`). --archived lists the ARCHIVED pipelines instead (owner / admin key): id, name (the pre-archive name), archivedName, color, archivedAt, openDeals, stageCount")
|
|
1107
1200
|
.option("--archived", "list the archived pipelines (GET /pipelines?archived=true) instead of the active ones")
|
|
1108
1201
|
.action(run(async (client, opts) => client.get(opts.archived ? "/api/v1/pipelines?archived=true" : "/api/v1/pipelines")));
|
|
1109
1202
|
pipelines
|
|
@@ -1205,7 +1298,7 @@ const stageRules = pipelines
|
|
|
1205
1298
|
.description("Per-stage rules (settings.pipelineStageRules): the fields and documents a deal needs before it may enter a stage");
|
|
1206
1299
|
stageRules
|
|
1207
1300
|
.command("get [pipelineId] [stageId]")
|
|
1208
|
-
.description("Read the stage rules as { pipelineId: { stageId: { requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions, lostReasonAllowOther } } }, for everything, one pipeline, or one stage")
|
|
1301
|
+
.description("Read the stage rules as { pipelineId: { stageId: { requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions, lostReasonAllowOther, entryByAutomationOnly } } }, for everything, one pipeline, or one stage")
|
|
1209
1302
|
.action(run(async (client, pipelineId, stageId) => {
|
|
1210
1303
|
if (pipelineId !== undefined)
|
|
1211
1304
|
assertUuid(pipelineId, "pipeline id");
|
|
@@ -1229,7 +1322,8 @@ stageRules
|
|
|
1229
1322
|
.option("--lost-reason-label <text>", "label of the stage's lost-reason prompt (1-60 chars)")
|
|
1230
1323
|
.option("--lost-reason-options <list>", "comma-separated dropdown options of the lost-reason prompt (max 50; empty clears)")
|
|
1231
1324
|
.option("--lost-reason-allow-other <bool>", "true|false: the rep may also pick \"Other\" and type the reason (stored as the reason text; takes effect while the stage has lost-reason options)")
|
|
1232
|
-
.option("--
|
|
1325
|
+
.option("--entry-by-automation-only <bool>", "true|false: \"Entry by automation only\": only an automation run can move or create a deal INTO the stage; every other entry (admins and API keys included) is refused 422 stage_automation_only; moving out stays free (to correct by hand: set false, move the deal, set true; the starting stage of automatic intake deals cannot be one)")
|
|
1326
|
+
.option("--json <json>", "the rule fields as a JSON object ({requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions, lostReasonAllowOther, entryByAutomationOnly}), merged over the flags")
|
|
1233
1327
|
.action(run(async (client, pipelineId, stageId, opts) => {
|
|
1234
1328
|
assertUuid(pipelineId, "pipeline id");
|
|
1235
1329
|
assertUuid(stageId, "stage id");
|
|
@@ -1246,18 +1340,20 @@ stageRules
|
|
|
1246
1340
|
raw.lostReasonOptions = splitList(opts.lostReasonOptions);
|
|
1247
1341
|
if (opts.lostReasonAllowOther !== undefined)
|
|
1248
1342
|
raw.lostReasonAllowOther = boolFlag(opts.lostReasonAllowOther, "--lost-reason-allow-other");
|
|
1343
|
+
if (opts.entryByAutomationOnly !== undefined)
|
|
1344
|
+
raw.entryByAutomationOnly = boolFlag(opts.entryByAutomationOnly, "--entry-by-automation-only");
|
|
1249
1345
|
if (opts.json !== undefined)
|
|
1250
1346
|
Object.assign(raw, jsonArg(opts.json, "--json"));
|
|
1251
1347
|
const change = validateRuleChange(raw);
|
|
1252
1348
|
if (Object.keys(change).length === 0)
|
|
1253
|
-
throw new Error("Nothing to set. Pass --required-fields, --required-documents, --lost-reason-label, --lost-reason-options, --lost-reason-allow-other or --json.");
|
|
1349
|
+
throw new Error("Nothing to set. Pass --required-fields, --required-documents, --lost-reason-label, --lost-reason-options, --lost-reason-allow-other, --entry-by-automation-only or --json.");
|
|
1254
1350
|
return patchGeneralSettingsMerged(client, (settings) => stageRulePatch(settings, pipelineId, stageId, change));
|
|
1255
1351
|
}));
|
|
1256
1352
|
// (system stages 2026-09-24) tri-surface parity: the stage flags the API takes on PATCH /pipelines/:id/stages/:stageId,
|
|
1257
1353
|
// isSystem included (a system holding stage automations route deals through; hidden from rep pickers, never won or lost)
|
|
1258
1354
|
pipelines
|
|
1259
1355
|
.command("update-stage <pipelineId> <stageId>")
|
|
1260
|
-
.description("Update one stage (PATCH — only the fields you pass change). --is-system marks a system holding stage: automations route deals through it, reps never see it in a picker, and it can never be won or lost. A soft-deleted stage (`pipelines delete-stage`) answers 404")
|
|
1356
|
+
.description("Update one stage (PATCH — only the fields you pass change). --is-system marks a system holding stage: automations route deals through it, reps never see it in a picker, and it can never be won or lost. A soft-deleted stage (`pipelines delete-stage`) answers 404. The \"Entry by automation only\" flag is not set here (the API reads it from the stage rules): use `pipelines stage-rules set --entry-by-automation-only`")
|
|
1261
1357
|
.option("--name <name>", "stage name")
|
|
1262
1358
|
.option("--color <hex>", "stage colour")
|
|
1263
1359
|
.option("--is-won <bool>", "true|false — entering this stage closes the deal as won")
|
|
@@ -1309,7 +1405,7 @@ pipelines
|
|
|
1309
1405
|
// (pipeline settings 10-08) archive / restore / reorder, stage delete with a move, the stale-in-stage days.
|
|
1310
1406
|
pipelines
|
|
1311
1407
|
.command("archive <id>")
|
|
1312
|
-
.description("Archive a pipeline (owner / admin key): renamed with the archived prefix, never deleted; its closed deals stay as history and `pipelines restore` brings it back. Every OPEN deal moves in the same transaction to --move-open-deals-to's first stage through the normal stage-entry rules; any refusal aborts all of it. Pass --move-open-deals-to none only when there are no open deals. Waits up to 75 s; if it times out the archive may have committed, so run `pipelines list` / `pipelines list --archived` before retrying. The default pipeline cannot be archived. Refusal codes are printed with the error and the command exits 1: stale_settings_revision, target_not_open, dpp_post_close, archive_too_large, target_archived, intake_target, pipeline_archived, open_deals, default_pipeline, stage_requirements, archive_raced, pipeline_automation_only. An already-archived pipeline answers { archived: true, alreadyArchived: true }")
|
|
1408
|
+
.description("Archive a pipeline (owner / admin key): renamed with the archived prefix, never deleted; its closed deals stay as history and `pipelines restore` brings it back. Every OPEN deal moves in the same transaction to --move-open-deals-to's first stage through the normal stage-entry rules; any refusal aborts all of it. Pass --move-open-deals-to none only when there are no open deals. Waits up to 75 s; if it times out the archive may have committed, so run `pipelines list` / `pipelines list --archived` before retrying. The default pipeline cannot be archived. Refusal codes are printed with the error and the command exits 1: stale_settings_revision, target_not_open, dpp_post_close, archive_too_large, target_archived, intake_target, pipeline_archived, open_deals, default_pipeline, stage_requirements, archive_raced, pipeline_automation_only, stage_automation_only (422, the destination is an \"Entry by automation only\" stage; nothing moved). An already-archived pipeline answers { archived: true, alreadyArchived: true }")
|
|
1313
1409
|
.requiredOption("--move-open-deals-to <pipelineId|none>", "the active pipeline the open deals move to (its first stage must be open), or none when the pipeline has no open deals")
|
|
1314
1410
|
.option("--expected-settings-revision <n>", "the settingsRevision from `pipelines settings-revision`; refused 409 stale_settings_revision when settings changed since")
|
|
1315
1411
|
.action(run(async (client, id, opts) => {
|
|
@@ -1350,7 +1446,7 @@ pipelines
|
|
|
1350
1446
|
}));
|
|
1351
1447
|
pipelines
|
|
1352
1448
|
.command("delete-stage <pipelineId> <stageId>")
|
|
1353
|
-
.description("Soft-delete a stage (owner / admin key). An EMPTY stage can always be deleted, even if deals once used it: the row is kept so deal history and reports keep its name, but it is hidden from every pipeline / stage list, GET / PATCH on it answer 404, there is NO restore in v1, and its name may be reused by a new stage; its stage rule, outcome mappings and status metadata go with it. With --move-to the live deals move to another stage of the SAME pipeline (same won / lost setting, not a system stage) and the stage is deleted in one transaction. With --expected-settings-revision the answer is { id, deleted: true, settingsRevision }. A refusal exits 1 with the server's message printed verbatim and its code when it has one (never retried): deals currently in the stage and no --move-to; 'Cannot delete stage: it is used by N active automation(s) (names). Change their stage or pause them first.' (a published automation uses the stage in an action, trigger, condition or wait-until); the DPP status map selects it; stale_settings_revision, pipeline_archived, intake_target. A --move-to that is not a valid target is a 400. Accepted window: a run parked or in flight before the delete is not tracked; at the step that targets the deleted stage it fails or is skipped with a clear reason in the execution log")
|
|
1449
|
+
.description("Soft-delete a stage (owner / admin key). An EMPTY stage can always be deleted, even if deals once used it: the row is kept so deal history and reports keep its name, but it is hidden from every pipeline / stage list, GET / PATCH on it answer 404, there is NO restore in v1, and its name may be reused by a new stage; its stage rule, outcome mappings and status metadata go with it. With --move-to the live deals move to another stage of the SAME pipeline (same won / lost setting, not a system stage) and the stage is deleted in one transaction. With --expected-settings-revision the answer is { id, deleted: true, settingsRevision }. A refusal exits 1 with the server's message printed verbatim and its code when it has one (never retried): deals currently in the stage and no --move-to; 'Cannot delete stage: it is used by N active automation(s) (names). Change their stage or pause them first.' (a published automation uses the stage in an action, trigger, condition or wait-until); the DPP status map selects it; stale_settings_revision, pipeline_archived, intake_target. A --move-to that is not a valid target is a 400, and a --move-to stage that is \"Entry by automation only\" is refused 422 stage_automation_only (code and message unchanged). Accepted window: a run parked or in flight before the delete is not tracked; at the step that targets the deleted stage it fails or is skipped with a clear reason in the execution log")
|
|
1354
1450
|
.option("--move-to <stageId>", "another stage of the same pipeline the live deals move to")
|
|
1355
1451
|
.option("--expected-settings-revision <n>", "the settingsRevision from `pipelines settings-revision`; refused 409 stale_settings_revision when settings changed since")
|
|
1356
1452
|
.action(run(async (client, pipelineId, stageId, opts) => {
|
|
@@ -3937,7 +4033,7 @@ ai
|
|
|
3937
4033
|
return client.post("/api/v1/ai/enrich-contact", body);
|
|
3938
4034
|
}));
|
|
3939
4035
|
// ---- team chat (app 10-09, live sha 3869c79ba) ----
|
|
3940
|
-
const chat = program.command("chat").description("Team chat: unread vector, messages (each carries `seq`), read acknowledgement");
|
|
4036
|
+
const chat = program.command("chat").description("Team chat: unread vector, messages (each carries `seq` and `version`), read acknowledgement, realtime token, per-channel notification level, Team Chat 2.0 settings");
|
|
3941
4037
|
/** The messages route's membership refusal (403 "Not a member of this channel"); any other 403 (a permission) is not this one. */
|
|
3942
4038
|
function isMembershipRefusal(err) {
|
|
3943
4039
|
return err instanceof Error && /\b403\b/.test(err.message) && /Not a member of this channel/.test(err.message);
|
|
@@ -3957,23 +4053,27 @@ async function withChatMembership(client, read) {
|
|
|
3957
4053
|
}
|
|
3958
4054
|
chat
|
|
3959
4055
|
.command("channels")
|
|
3960
|
-
.description("GET /chat/channels: your channels (id, name, kind, members, last message). SIDE EFFECT: also creates your memberships in the account's auto channels (needs-a-hand, new-leads, wins) the first time. `chat messages` and `chat mark-read` do this once for you on a 403 'Not a member of this channel'")
|
|
4056
|
+
.description("GET /chat/channels: your channels (id, name, kind, members, last message). SIDE EFFECT: also creates your memberships in the account's auto channels (needs-a-hand, new-leads, wins) the first time. `chat messages` and `chat mark-read` do this once for you on a 403 'Not a member of this channel'. Team Chat 2.0 rows also carry mentionCount (unread @mentions of you), notify (your stored level: all, mentions, mute, or null = default), updatedAt (when notify last changed, null = never), membershipId, membershipSince and membershipSeq")
|
|
3961
4057
|
.action(run(async (client) => client.get("/api/v1/chat/channels")));
|
|
3962
4058
|
chat
|
|
3963
4059
|
.command("unread")
|
|
3964
|
-
.description("GET /chat/unread: per channel { channelId, unread, soundUnread, newestSeq, latestSeq }; a row can be an AUTO channel you have not opened yet (no membership row: reading it first answers 403, which `chat messages` / `chat mark-read` recover from once). unread is the badge count (auto channels 0 by design); soundUnread counts every channel; newestSeq = seq of the newest message not written by you (null = none), latestSeq the same including panic cards. Read-only")
|
|
4060
|
+
.description("GET /chat/unread: per channel { channelId, unread, soundUnread, newestSeq, latestSeq }; a row can be an AUTO channel you have not opened yet (no membership row: reading it first answers 403, which `chat messages` / `chat mark-read` recover from once). unread is the badge count (auto channels 0 by design); soundUnread counts every channel; newestSeq = seq of the newest message not written by you (null = none), latestSeq the same including panic cards. Team Chat 2.0 adds per row mentions (unread @mentions of you), notify, updatedAt, membershipId (null = no membership row), membershipSince and membershipSeq, and per response teamChatRealtime (the account's realtime switch) and membershipSeqHigh. Read-only")
|
|
3965
4061
|
.action(run(async (client) => client.get("/api/v1/chat/unread")));
|
|
3966
4062
|
chat
|
|
3967
4063
|
.command("messages <channelId>")
|
|
3968
|
-
.description("List a channel's messages, newest first (GET /chat/channels/{id}/messages); every message carries `seq`, its commit-ordered position in the channel. Prints { data, hasMore, nextCursor }. Needs channel membership (else 403). Does NOT move your read marker: use `chat mark-read`. On a 403 'Not a member of this channel' it calls GET /chat/channels once and retries ONCE (a second refusal or any other 403 exits 1 as is)")
|
|
4064
|
+
.description("List a channel's messages, newest first (GET /chat/channels/{id}/messages); every message carries `seq`, its commit-ordered position in the channel. Every message also carries `version`, a server counter bumped on every update (edit, delete, reaction, pin, claim, handoff). Prints { data, hasMore, nextCursor }. Needs channel membership (else 403). Does NOT move your read marker: use `chat mark-read`. On a 403 'Not a member of this channel' it calls GET /chat/channels once and retries ONCE (a second refusal or any other 403 exits 1 as is)")
|
|
3969
4065
|
.option("--limit <n>", "messages per page, 1-100 (default 50)")
|
|
3970
4066
|
.option("--cursor <messageId>", "nextCursor from the previous page (a message UUID)")
|
|
4067
|
+
.option("--after-seq <n>", "only messages committed after this seq (the realtime catch-up read), a whole number 0-999999999; given once, never with --cursor; hasMore then means more follow this page (re-read the latest page)")
|
|
3971
4068
|
.action(run(async (client, channelId, opts) => {
|
|
3972
4069
|
assertUuid(channelId, "channel id");
|
|
3973
4070
|
const limit = opts.limit !== undefined ? intFlag(opts.limit, "--limit", 1, 100) : undefined;
|
|
3974
4071
|
const cursor = opts.cursor !== undefined ? uuidOf(opts.cursor, "--cursor") : undefined;
|
|
4072
|
+
const afterSeq = opts.afterSeq !== undefined ? intFlag(opts.afterSeq, "--after-seq", 0, 999_999_999) : undefined;
|
|
4073
|
+
if (afterSeq !== undefined && cursor !== undefined)
|
|
4074
|
+
throw new Error("--after-seq and --cursor cannot be combined.");
|
|
3975
4075
|
// the live route answers { data: { data, hasMore, nextCursor } }: print() unwraps the outer envelope, leaving { data, hasMore, nextCursor }
|
|
3976
|
-
return withChatMembership(client, () => client.get(`/api/v1/chat/channels/${encodeURIComponent(channelId)}/messages${buildQuery({ limit, cursor })}`));
|
|
4076
|
+
return withChatMembership(client, () => client.get(`/api/v1/chat/channels/${encodeURIComponent(channelId)}/messages${buildQuery({ limit, cursor, afterSeq })}`));
|
|
3977
4077
|
}));
|
|
3978
4078
|
chat
|
|
3979
4079
|
.command("mark-read <channelId> <messageId>")
|
|
@@ -3983,6 +4083,83 @@ chat
|
|
|
3983
4083
|
const readUpTo = uuidOf(messageId, "message id");
|
|
3984
4084
|
return withChatMembership(client, () => client.get(`/api/v1/chat/channels/${encodeURIComponent(channelId)}/messages${buildQuery({ limit: 1, readUpTo })}`));
|
|
3985
4085
|
}));
|
|
4086
|
+
chat
|
|
4087
|
+
.command("realtime-token")
|
|
4088
|
+
.description("GET /realtime/token: a signed Ably token request for the Team Chat 2.0 realtime pipe, scoped to your user channel, the presence channel (only with realtimePresence on) and the chat channels you belong to (the 100 most recently active, plus any --channel). Prints { enabled: true, tokenRequest, accountId, userId, channels, presence, expiresAt }, or { enabled: false, reason } when the account switch (settings.teamChat.realtime) is off or the provider is unconfigured. accountId and userId are what `chat set-notifications` needs. The token is short-lived: do not log it. Errors exit 1 with the status and message: 403 'Not an active member of this account', 400 'channel must be a channel id', 429 'Too many token requests'")
|
|
4089
|
+
.option("--channel <channelId>", "a channel to include, repeatable up to 10 times (each a UUID; sent as repeated channel=)", (v, prev = []) => [...prev, v])
|
|
4090
|
+
.action(run(async (client, opts) => {
|
|
4091
|
+
const ids = (opts.channel ?? []).map((c) => uuidOf(c, "--channel"));
|
|
4092
|
+
if (ids.length > 10)
|
|
4093
|
+
throw new Error("--channel may be given at most 10 times.");
|
|
4094
|
+
const qs = new URLSearchParams();
|
|
4095
|
+
for (const id of ids)
|
|
4096
|
+
qs.append("channel", id);
|
|
4097
|
+
return client.get(`/api/v1/realtime/token${qs.size > 0 ? `?${qs}` : ""}`);
|
|
4098
|
+
}));
|
|
4099
|
+
chat
|
|
4100
|
+
.command("notifications <channelId>")
|
|
4101
|
+
.description("GET /chat/channels/{id}/notifications: your notification level for one channel. Prints { level, isDefault, notify, updatedAt, membershipId, membershipSince, membershipSeq }: level is the effective level (all = every message pushes, mentions = only @mentions of you, mute = nothing but a direct @mention), notify the STORED value (null = the kind's default), updatedAt its version (null = never set). `chat set-notifications` needs updatedAt and membershipId. Members only: a 403 'Not a member of this channel' is retried ONCE after GET /chat/channels, like the other reads")
|
|
4102
|
+
.action(run(async (client, channelId) => {
|
|
4103
|
+
assertUuid(channelId, "channel id");
|
|
4104
|
+
return withChatMembership(client, () => client.get(`/api/v1/chat/channels/${encodeURIComponent(channelId)}/notifications`));
|
|
4105
|
+
}));
|
|
4106
|
+
chat
|
|
4107
|
+
.command("set-notifications <channelId>")
|
|
4108
|
+
.description("PATCH /chat/channels/{id}/notifications: set your notification level, compare-and-set against what you last read (run `chat notifications` first). --level all|mentions|mute|default (default = the kind's default); --expected-updated-at <iso|none> is the updatedAt you read (none = it was null); --expected-membership-id the membershipId you read; --account-id and --user-id are yours (from `chat realtime-token`; another person's are refused 403 identity_mismatch). Sent once, never retried. Every refusal exits 1 with the status, the API's message and its code: 409 stale prints the CURRENT membership ({ code: stale, current: { notify, updatedAt, membershipId, membershipSince, membershipSeq } }; a different membershipId means you were removed and re-added; nothing was changed), 404 not_a_member, 403 identity_mismatch or 'Not a member of this channel', 400 for a bad level, expectedUpdatedAt or expectedMembershipId. Takes effect only while the account's realtime switch is on")
|
|
4109
|
+
.requiredOption("--level <level>", "all, mentions, mute or default")
|
|
4110
|
+
.requiredOption("--expected-updated-at <iso|none>", "the updatedAt you last read (an ISO date), or none when it was null")
|
|
4111
|
+
.requiredOption("--expected-membership-id <id>", "the membershipId you last read (a UUID)")
|
|
4112
|
+
.requiredOption("--account-id <id>", "your account id (a UUID)")
|
|
4113
|
+
.requiredOption("--user-id <id>", "your user id (a UUID)")
|
|
4114
|
+
.action(run(async (client, channelId, opts) => {
|
|
4115
|
+
assertUuid(channelId, "channel id");
|
|
4116
|
+
const level = opts.level.trim().toLowerCase();
|
|
4117
|
+
if (!["all", "mentions", "mute", "default"].includes(level))
|
|
4118
|
+
throw new Error(`--level takes all, mentions, mute or default (got '${opts.level}').`);
|
|
4119
|
+
const raw = opts.expectedUpdatedAt.trim();
|
|
4120
|
+
if (raw.toLowerCase() !== "none" && Number.isNaN(Date.parse(raw)))
|
|
4121
|
+
throw new Error(`--expected-updated-at takes the updatedAt you read (an ISO date) or none (got '${opts.expectedUpdatedAt}').`);
|
|
4122
|
+
return client.patch(`/api/v1/chat/channels/${encodeURIComponent(channelId)}/notifications`, {
|
|
4123
|
+
level,
|
|
4124
|
+
expectedUpdatedAt: raw.toLowerCase() === "none" ? null : raw,
|
|
4125
|
+
expectedMembershipId: uuidOf(opts.expectedMembershipId, "--expected-membership-id"),
|
|
4126
|
+
accountId: uuidOf(opts.accountId, "--account-id"),
|
|
4127
|
+
userId: uuidOf(opts.userId, "--user-id"),
|
|
4128
|
+
});
|
|
4129
|
+
}));
|
|
4130
|
+
chat
|
|
4131
|
+
.command("settings")
|
|
4132
|
+
.description("Change the account's Team Chat 2.0 settings (PATCH /settings, settings.teamChat). OWNER / ADMIN ONLY (403 forbidden 'Only an owner or admin can change Team Chat realtime delivery'). Pass at least one: --realtime on|off (instant delivery over the realtime pipe; off = the chat polls; account default off), --presence on|off (online/away over the pipe; needs realtime), --quiet-hours <HH:MM-HH:MM|none> (account timezone; the end may be earlier than the start to cross midnight; needs realtime), --digest-minutes <0|15-240> (missed-message email digest window, 0 = off, default 30; needs realtime). Sends the settings revision it read; a 409 re-reads once, then exits 1 with the current settings. 422 names an invalid field")
|
|
4133
|
+
.option("--realtime <on|off>", "Team Chat realtime delivery")
|
|
4134
|
+
.option("--presence <on|off>", "realtime presence (needs realtime)")
|
|
4135
|
+
.option("--quiet-hours <window>", "HH:MM-HH:MM in the account timezone, or none")
|
|
4136
|
+
.option("--digest-minutes <n>", "0 (off) or 15-240")
|
|
4137
|
+
.action(run(async (client, opts) => {
|
|
4138
|
+
const teamChat = {};
|
|
4139
|
+
if (opts.realtime !== undefined)
|
|
4140
|
+
teamChat.realtime = onOffArg(opts.realtime, "--realtime");
|
|
4141
|
+
if (opts.presence !== undefined)
|
|
4142
|
+
teamChat.realtimePresence = onOffArg(opts.presence, "--presence");
|
|
4143
|
+
if (opts.quietHours !== undefined) {
|
|
4144
|
+
const w = opts.quietHours.trim();
|
|
4145
|
+
const m = /^(([01]\d|2[0-3]):[0-5]\d)-(([01]\d|2[0-3]):[0-5]\d)$/.exec(w);
|
|
4146
|
+
if (w.toLowerCase() === "none")
|
|
4147
|
+
teamChat.quietHours = null;
|
|
4148
|
+
else if (m)
|
|
4149
|
+
teamChat.quietHours = { start: m[1], end: m[3] };
|
|
4150
|
+
else
|
|
4151
|
+
throw new Error(`--quiet-hours takes HH:MM-HH:MM (24-hour) or none (got '${opts.quietHours}').`);
|
|
4152
|
+
}
|
|
4153
|
+
if (opts.digestMinutes !== undefined) {
|
|
4154
|
+
const n = intFlag(opts.digestMinutes, "--digest-minutes", 0, 240);
|
|
4155
|
+
if (n !== 0 && n < 15)
|
|
4156
|
+
throw new Error("--digest-minutes is 0 (off) or 15-240.");
|
|
4157
|
+
teamChat.digestMinutes = n;
|
|
4158
|
+
}
|
|
4159
|
+
if (Object.keys(teamChat).length === 0)
|
|
4160
|
+
throw new Error("nothing to change: pass at least one of --realtime, --presence, --quiet-hours, --digest-minutes");
|
|
4161
|
+
return patchGeneralSettings(client, { teamChat });
|
|
4162
|
+
}));
|
|
3986
4163
|
// ---- notifications (read side; meta.latestByType, app 10-09) ----
|
|
3987
4164
|
const notifications = program.command("notifications").description("Your in-app notifications");
|
|
3988
4165
|
notifications
|
package/package.json
CHANGED