conduyt 1.70.0 → 1.72.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 +4 -3
- package/dist/index.js +76 -8
- package/dist/pipeline-settings.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,8 +32,9 @@ The saved config lives at `~/.conduyt/config.json` and is written owner-only (`c
|
|
|
32
32
|
```bash
|
|
33
33
|
conduyt whoami # show the authenticated user
|
|
34
34
|
conduyt contacts list --limit 25 # list contacts (supports --cursor, --query)
|
|
35
|
-
conduyt contacts get <id> # fetch one contact (carries `revision` and `
|
|
35
|
+
conduyt contacts get <id> # fetch one contact (carries `revision`, `hasOutboundSms` and `hasOutboundEmail`: a proven send only)
|
|
36
36
|
conduyt contacts update <id> --first Ann --expected-revision <n> # PATCH; a contact changed since revision n is refused: 409 contact_changed, exit 1, code and current revision printed
|
|
37
|
+
conduyt contacts merge <primaryId> <secondaryId...> [--confirm [--yes]] # DESTRUCTIVE: a dry run by default (prints the impact); --confirm shows it, asks y/N, then merges (the losers are deleted); --yes skips the question; moved counts 409 and 503 merge_contacts_busy / merge_budget_exhausted + Retry-After exit 1, never retried
|
|
37
38
|
conduyt contacts collaborators add <contactId> <userId> [--expected-revision <n>] # POST /contacts/{id}/collaborators; prints { added, revision }
|
|
38
39
|
conduyt contacts collaborators remove <contactId> <userId> [--expected-revision <n>] # DELETE with a body; prints { removed, revision }; 404 when not a collaborator
|
|
39
40
|
conduyt chat channels # GET /chat/channels: your channels; also CREATES your auto-channel memberships the first time
|
|
@@ -78,7 +79,7 @@ conduyt whatsapp connect <senderSid> # connect a sender (verified with Twilio
|
|
|
78
79
|
conduyt whatsapp templates # approved / pending / rejected templates with their {{n}} placeholders
|
|
79
80
|
conduyt dialer voice-drop --contact <id> [--recording "Missed you"] [--if-human play] # a call that plays a Voicemail Drop recording to a machine
|
|
80
81
|
conduyt messages send-sms --contact <id> --transport whatsapp --whatsapp-template HX… --whatsapp-variables '{"1":"Alex"}' --body "…"
|
|
81
|
-
conduyt messages check --channel sms --body "…" [--context first|conversation] [--provider twilio] [--clean] [--body-file draft.txt] # grade a DRAFT before sending (score, grade, flagged words with safer alternatives, Unicode code-point offsets); --provider twilio (texts only) words the notices in Twilio's wording, --clean adds the cleaner's proposal; educates, never blocks, nothing stored or sent; 20 per minute; 400 / 403 / 413 / 429 exit 1 with the code
|
|
82
|
+
conduyt messages check --channel sms --body "…" [--context first|conversation] [--provider twilio] [--clean] [--body-file draft.txt] # grade a DRAFT before sending (score, grade, flagged words with safer alternatives, Unicode code-point offsets); --provider twilio (texts only) words the notices in Twilio's wording, --clean adds the cleaner's proposal; prints `englishDictionary` (`loaded` / `not_loaded`: the final grade is only meaningful when `loaded`) and `engine.version` (2.2.1); educates, never blocks, nothing stored or sent; 20 per minute; 400 / 403 / 413 / 429 exit 1 with the code
|
|
82
83
|
conduyt messages minimum-grade # the admin's Message check minimum grade to send (settings.messageCheck.minimumGrade): enabled, sms / email minimum (A-F, 0-100 or null), adminOverride; groundwork, stored but not enforced yet; owner / admin only (anyone else sees visible: false)
|
|
83
84
|
conduyt messages set-minimum-grade [--sms A|B|C|D|F|0-100|none] [--email ...] [--admin-override on|off] [--enabled on|off] # sends the whole block with the settings revision it read; --enabled on is refused 400 minimum_grade_not_available_yet; 403 forbidden for a non-owner / non-admin; all exit 1 with the code
|
|
84
85
|
conduyt sms providers options # the workspace's active SMS providers, redacted: [{ id, name, color }] (needs email_templates.view or settings.view)
|
|
@@ -131,7 +132,7 @@ out and named in `_omitted`. Every HTTP refusal takes this path (JSON calls, tex
|
|
|
131
132
|
Error: Conduyt API 503: Contact merge in progress [Retry-After: 5s] {"code":"merge_in_progress"}
|
|
132
133
|
```
|
|
133
134
|
|
|
134
|
-
The contact merge route (`POST /contacts/merge`,
|
|
135
|
+
The contact merge route (`POST /contacts/merge`, command `contacts merge`) answers a retryable 503 with `Retry-After` and the code `merge_contacts_busy` (a contact stayed busy for the whole lock budget) or `merge_budget_exhausted` (the request's time budget ran out), nothing changed. A command that reaches it prints the refusal in this grammar, exits 1 and does not retry; run it again after the stated seconds.
|
|
135
136
|
|
|
136
137
|
## License
|
|
137
138
|
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { Command, CommanderError } from "commander";
|
|
|
7
7
|
import { ConduytClient, buildQuery } from "./client.js";
|
|
8
8
|
import { minimumGradeOf, minimumGradePatch, parseGradeFlag } from "./message-check-minimum.js";
|
|
9
9
|
import { splitList, stageRulePatch, validateCardFields, validateRuleChange } from "./board-settings.js";
|
|
10
|
-
import { ARCHIVE_PIPELINE_TIMEOUT_MS, MAILCHIMP_ADMIN_TIMEOUT_MS, STATUS_METADATA_IMPORT_TIMEOUT_MS, PIPELINE_COLORS, boolFlag, intFlag, isoDate, lifecycleStage as lifecycleStageArg, MAX_PIPELINE_ROT_DAYS, MIN_PIPELINE_ROT_DAYS, pipelineColor, settingsRevision, slug as slugArg, uuid as uuidOf, uuidList, validateImportItems, validateStatusMetadataBody } from "./pipeline-settings.js";
|
|
10
|
+
import { ARCHIVE_PIPELINE_TIMEOUT_MS, MERGE_TIMEOUT_MS, MAILCHIMP_ADMIN_TIMEOUT_MS, STATUS_METADATA_IMPORT_TIMEOUT_MS, PIPELINE_COLORS, boolFlag, intFlag, isoDate, lifecycleStage as lifecycleStageArg, MAX_PIPELINE_ROT_DAYS, MIN_PIPELINE_ROT_DAYS, pipelineColor, settingsRevision, slug as slugArg, uuid as uuidOf, uuidList, validateImportItems, validateStatusMetadataBody } from "./pipeline-settings.js";
|
|
11
11
|
import { saveConfig, resolveConfig, configPath } from "./config.js";
|
|
12
12
|
import { print, fail, unwrap } from "./output.js";
|
|
13
13
|
import { callsPage, historyPage } from "./dialer-pages.js";
|
|
@@ -74,6 +74,7 @@ program
|
|
|
74
74
|
.description("Show the authenticated user")
|
|
75
75
|
.action(run(async (client) => client.get("/api/v1/users/me")));
|
|
76
76
|
// ---- contacts ----
|
|
77
|
+
const LOSER_ORDER_NOTE = "losers are merged in the order given: for each field the survivor lacks, the first loser in this order with a value wins; reorder to change precedence; the dry run's filledFields shows which fields will be filled";
|
|
77
78
|
const contacts = program.command("contacts").description("Manage contacts");
|
|
78
79
|
contacts
|
|
79
80
|
.command("list")
|
|
@@ -86,7 +87,7 @@ contacts
|
|
|
86
87
|
.action(run(async (client, opts) => client.get(`/api/v1/contacts${buildQuery({ limit: opts.limit, cursor: opts.cursor, query: opts.query, master_status: opts.masterStatus, exclude_master_status: opts.excludeMasterStatus })}`)));
|
|
87
88
|
contacts
|
|
88
89
|
.command("get <id>")
|
|
89
|
-
.description("Get a single contact by id (includes `revision`: pass it as --expected-revision on `contacts update` / `contacts collaborators`; `hasOutboundSms` is true when the workspace has ever PROVABLY texted the contact: a proven send only, not a draft, a queued row or a pre-send failure; use it to pick `messages check --context`: false = first, true = conversation)")
|
|
90
|
+
.description("Get a single contact by id (includes `revision`: pass it as --expected-revision on `contacts update` / `contacts collaborators`; `hasOutboundSms` is true when the workspace has ever PROVABLY texted the contact: a proven send only, not a draft, a queued row or a pre-send failure; use it to pick `messages check --context`: false = first, true = conversation; `hasOutboundEmail` is the same for email: true when at least one outbound email to the contact was proven sent, so a genuine \"Re:\" reply is a conversation and not flagged)")
|
|
90
91
|
.action(run(async (client, id) => client.get(`/api/v1/contacts/${encodeURIComponent(id)}`)));
|
|
91
92
|
contacts
|
|
92
93
|
.command("create")
|
|
@@ -213,6 +214,66 @@ contacts
|
|
|
213
214
|
assertUuid(id, "contact id");
|
|
214
215
|
return client.del(`/api/v1/contacts/${encodeURIComponent(id)}`);
|
|
215
216
|
}));
|
|
217
|
+
contacts
|
|
218
|
+
.command("merge <primaryId> <secondaryIds...>")
|
|
219
|
+
.description("DESTRUCTIVE merge of duplicate contacts (POST /contacts/merge; owner or admin): <primaryId> survives and receives the records of every <secondaryIds> contact, which are then DELETED (no undelete). By default a DRY RUN: nothing changes, it prints the impact { primary, secondaries, counts, totalRecords, guardCounts, filledFields, identifierOverflow, blockers }. --confirm repeats the dry run, shows the impact (the records that move, and separately the guard counts: chat messages, quick notes, identifiers, outbox rows, which are not in the records total), asks y/N and then merges, sending the values that dry run returned (expectedCounts, expectedGuardCounts, expectedIdentifiers). LOSER ORDER: losers are merged in the order given: for each field the survivor lacks, the first loser in this order with a value wins; reorder to change precedence; the dry run's filledFields shows which fields will be filled. WHAT IS PINNED: the server refuses (409) if the record counts, guard counts or identifier plan moved; other field edits made after this preview (a loser's name, company, source, address) are not detected; --yes skips the question (scripts; needs --confirm). A refusal exits 1 with the status, the API's message and its code and is NEVER retried: 409 \"Merge impact changed\" (preview again), 409 queued work, 400 / 404, and 503 merge_contacts_busy / merge_budget_exhausted with `[Retry-After: <N>s]` (nothing changed: wait that long, then run again).")
|
|
220
|
+
.option("--confirm", "perform the merge (after the dry run's impact and a y/N question) instead of only previewing")
|
|
221
|
+
.option("--yes", "skip the y/N question (non-interactive; needs --confirm)")
|
|
222
|
+
.action(run(async (client, primaryId, secondaryIds, opts) => {
|
|
223
|
+
assertUuid(primaryId, "primaryId");
|
|
224
|
+
for (const id of secondaryIds)
|
|
225
|
+
assertUuid(id, "secondaryIds");
|
|
226
|
+
if (opts.yes && !opts.confirm)
|
|
227
|
+
throw new Error("--yes only skips the question of --confirm: add --confirm to merge, or drop --yes to preview.");
|
|
228
|
+
const preview = unwrap(await client.post("/api/v1/contacts/merge", { primaryId, secondaryIds, dryRun: true }, { timeoutMs: MERGE_TIMEOUT_MS }));
|
|
229
|
+
if (!opts.confirm) {
|
|
230
|
+
process.stderr.write("Dry run only: nothing was merged. Re-run with --confirm to merge.\n");
|
|
231
|
+
return preview;
|
|
232
|
+
}
|
|
233
|
+
const name = (c) => {
|
|
234
|
+
const r = (c ?? {});
|
|
235
|
+
return `${[r.firstName, r.lastName].filter(Boolean).join(" ") || "(no name)"} <${r.email ?? r.phone ?? r.id}> ${r.id}`;
|
|
236
|
+
};
|
|
237
|
+
const guard = (preview.guardCounts ?? {});
|
|
238
|
+
const guardTotal = Object.values(guard).reduce((n, v) => n + (typeof v === "number" ? v : 0), 0);
|
|
239
|
+
const lines = [
|
|
240
|
+
`Merge into: ${name(preview.primary)}`,
|
|
241
|
+
...secondaryIds.map((id, i) => {
|
|
242
|
+
const row = (preview.secondaries ?? []).find((c) => String(c.id).toLowerCase() === id.toLowerCase());
|
|
243
|
+
return ` DELETE (fill priority ${i + 1}): ${row ? name(row) : id}`;
|
|
244
|
+
}),
|
|
245
|
+
`Order: ${LOSER_ORDER_NOTE}`,
|
|
246
|
+
`Records moving to the survivor: ${preview.totalRecords ?? "?"} ${JSON.stringify(preview.counts ?? {})}`,
|
|
247
|
+
`Guard counts (separate from the records above, not in that total): ${guardTotal} ${JSON.stringify(guard)}`,
|
|
248
|
+
`Fields the survivor gains: ${JSON.stringify(preview.filledFields ?? [])}`,
|
|
249
|
+
`Identifiers: ${JSON.stringify(preview.identifierOverflow ?? {})}`,
|
|
250
|
+
];
|
|
251
|
+
const blockers = preview.blockers?.outbox ?? [];
|
|
252
|
+
if (blockers.length > 0)
|
|
253
|
+
lines.push(`Queued work that will block the merge: ${JSON.stringify(blockers)}`);
|
|
254
|
+
lines.push("The server refuses if the record counts, guard counts or identifier plan moved; other field edits made after this preview are not detected.");
|
|
255
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
256
|
+
if (!opts.yes) {
|
|
257
|
+
const { createInterface } = await import("node:readline/promises");
|
|
258
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
259
|
+
// EOF with no answer (a closed stdin) counts as "no"
|
|
260
|
+
const answer = await new Promise((resolve) => {
|
|
261
|
+
rl.once("close", () => resolve(""));
|
|
262
|
+
rl.question("Merge these contacts? The losers are deleted. [y/N] ").then(resolve, () => resolve(""));
|
|
263
|
+
});
|
|
264
|
+
rl.close();
|
|
265
|
+
if (!/^y(es)?$/i.test(answer.trim()))
|
|
266
|
+
throw new Error("Cancelled: nothing was merged.");
|
|
267
|
+
}
|
|
268
|
+
return client.post("/api/v1/contacts/merge", {
|
|
269
|
+
primaryId,
|
|
270
|
+
secondaryIds,
|
|
271
|
+
confirmImpact: true,
|
|
272
|
+
expectedCounts: preview.counts,
|
|
273
|
+
expectedGuardCounts: preview.guardCounts,
|
|
274
|
+
...(preview.identifierOverflow ? { expectedIdentifiers: preview.identifierOverflow } : {}),
|
|
275
|
+
}, { timeoutMs: MERGE_TIMEOUT_MS });
|
|
276
|
+
}));
|
|
216
277
|
// ---- deals ----
|
|
217
278
|
contacts
|
|
218
279
|
.command("delete-matching")
|
|
@@ -1852,7 +1913,7 @@ messages
|
|
|
1852
1913
|
}));
|
|
1853
1914
|
messages
|
|
1854
1915
|
.command("check")
|
|
1855
|
-
.description("Grade a DRAFT text or email before sending (POST /messages/check): the same check the contact composers run as the rep types. Prints the route's data (the standard envelope is unwrapped): { score 0-100, grade A-F, verdict, vclass, hits, subjectHits, checks, sms (a text's chars / segments / unicode / perSeg; null for an email), context, notices (SMS; informational), breakdown (the score's bands, offsets in code points), unicodeCulprits (SMS; the characters that force Unicode), cleaned (only with --clean), engine { version, updatedAt } } (engine 2.1
|
|
1916
|
+
.description("Grade a DRAFT text or email before sending (POST /messages/check): the same check the contact composers run as the rep types. Prints the route's data (the standard envelope is unwrapped): { score 0-100, grade A-F, verdict, vclass, hits, subjectHits, checks, sms (a text's chars / segments / unicode / perSeg; null for an email), context, notices (SMS; informational), breakdown (the score's bands, offsets in code points), unicodeCulprits (SMS; the characters that force Unicode), cleaned (only with --clean), englishDictionary ('loaded' | 'not_loaded', engine 2.2.1+), engine { version, updatedAt } } (engine 2.2.1 today; any further field is additive). The final grade is only meaningful when englishDictionary is 'loaded' (the server embeds the dictionary, so it is): a result with any other value, or without the field, is provisional (the 'no recognizable English words' check did not run): treat it as unavailable, never as a grade. Each hit: { start, end, word, level (r likely blocked, a risky, y caution, n heads up), label, cat, why, alts (safer wording; some are advice rather than a swap), restricted (prohibited in US business texting: no rewording makes it deliverable), rule }. start / end are Unicode CODE-POINT offsets into the submitted text (the body for hits, the subject for subjectHits), end exclusive; an emoji counts as one, so [...text].slice(start, end) in JavaScript or text[start:end] in Python is the word (not a UTF-16 index). The check EDUCATES and never blocks: nothing is stored and nothing is sent; merge tokens such as {{contact.firstName}} are never flagged. --context template (default) grades a reusable message, first the first text to a contact, conversation a reply in an existing thread (engine 2.2.1: 'conversation' skips the SMS opt-out line and who-is-texting checks and the email fake \"Re:\" / \"Fwd:\" subject check, because a reply in an existing thread legitimately lacks them; 'template' and 'first' run all of them); `contacts get <id>` carries hasOutboundSms (hasOutboundEmail for an email) to choose. --provider twilio (a text only) words the notices in Twilio's own wording, as the composers show them; an email with a provider, or any other value, is a 400 invalid_body (`sms providers options` lists the workspace's providers; only twilio is a check wording today). --clean adds `cleaned` { text, subject (email), changes, skipped } or null: the cleaner's PROPOSAL, never stored or applied. Limits: body 10,000 characters, subject 300, request 48 KB; 20 checks per minute per account. Every refusal exits 1 with the status, the API's message and its code, sent once and never retried: 400 invalid_body, 403 forbidden (the key's member may view neither contacts nor email templates, or the key's scopes do not reach the route: needs the contacts, emails, sms or messages scope, a read-tier key too), 413 message_too_long / body_too_large, 429 rate_limited (Retry-After shown in the error text)")
|
|
1856
1917
|
.option("--channel <channel>", "sms | email (required)")
|
|
1857
1918
|
.option("--subject <text>", "email only (300 characters at most); refused on a text")
|
|
1858
1919
|
.option("--body <text>", "the draft text (10,000 characters at most)")
|
|
@@ -2060,6 +2121,10 @@ messages
|
|
|
2060
2121
|
})}`);
|
|
2061
2122
|
}));
|
|
2062
2123
|
// ---- appointments ----
|
|
2124
|
+
/** (app 10-13) a booking can name the deal (card) it came from */
|
|
2125
|
+
const APPOINTMENT_DEAL_NOTE = " contactId is looked up as a contact of your account first; when no contact has that id it is looked up as a deal (card), so a deal id may be passed there. dealId names the deal explicitly. A named deal must be open, in your account, and visible AND editable to the key (deals scope; seeing it is not enough): the appointment takes the deal's contact, is linked to the deal, and appointment.booked carries dealId; no other card is created. Errors: 400 deal_id_conflict / deal_contact_mismatch / invalid_deal_id / contact_not_found (contactId is neither a contact nor a deal you can use), 404 deal_not_found / deal_deleted, 403 deal_not_editable (you can see the deal but not change it), 409 deal_closed, 409 deal_contact_gone (the deal's contact was merged or deleted), 409 deal_changed (the deal moved to another contact while booking: retry the request).";
|
|
2126
|
+
/** (app 10-13) the catalog does not carry automation action config: find_opportunity v2, link_appointment_to_deal, update_deal_field systemField source */
|
|
2127
|
+
const DEAL_NODES_NOTE = " Deal nodes: find_opportunity config {version: 2, pipelineIds (ordered; the first pipeline holding a match wins; empty = every pipeline), status (open default | won | lost | abandoned | any), excludeSources (lead sources never matched, case-insensitive), excludeCreatedByAutomationIds (automations whose create_deal cards are skipped; cards created from 2026-10-13 on), matchField {fieldKey, value} (searches the WHOLE WORKSPACE, any contact, for the card whose deal custom field equals the value, merge fields allowed e.g. {{trigger.oes}}; a card is found when its field holds the searched value (trimmed) as the same text exactly (no case folding); a value written with separators such as dashes or spaces also matches its digits as text; it matches a stored number only when the value (or those digits) is a plain number with no leading zero and at most 15 significant digits that a number holds exactly (007 is text and does not match the number 7; a 16-digit value, 1e-400 or 3e-324 is text only); booleans and differently formatted stored values never match; status, pipelines and exclusions still apply; exactly one match is found, several are ambiguous and take not found; a found card becomes the run's contact when the run has none; the field must exist; an empty rendered value finds nothing), resolution}: searches the run's contact's cards; FOUND makes the card the run's deal and follows nextIfTrue (else next), NOT FOUND is a normal outcome that follows nextIfFalse (else next) and leaves the run's existing deal unchanged; without version 2 the step keeps the original single-pipeline lookup (a miss fails the step). Prerequisite: a version 2 find needs step cursors on this workspace; publishing refuses (find_needs_step_cursors) with \"Find opportunity needs step cursors on this workspace; ask support\", so a workflow can validate and import yet fail at publish; every version 2 find must hold its own live step cursor or it hard-stops the run without selecting or routing. A version 2 find cannot be retried on its own: retrying a failed one alone answers 409, so re-trigger the automation for the contact. An automation definition that touches deals needs the `deals` scope on an API key (403 without it). link_appointment_to_deal config {stageId, pipelineId (needs stageId), lostReason}: in an appointment-triggered run, links the trigger's appointment to the run's deal (its appointment fields follow it; a newer booking is never displaced; the appointment must be the deal's contact's), then optionally moves the card; it needs an appointment trigger and the run's deal (find_opportunity first, or a booking made with a deal id). update_deal_field config {systemField: \"source\", value, overwriteExisting}: sets the deal's own lead source instead of a custom field (a value only, trimmed, at most 500 characters; overwriteExisting false writes only while the source is the Unknown label).";
|
|
2063
2128
|
const appointments = program.command("appointments").description("Manage appointments");
|
|
2064
2129
|
appointments
|
|
2065
2130
|
.command("list")
|
|
@@ -2093,13 +2158,14 @@ appointments
|
|
|
2093
2158
|
}));
|
|
2094
2159
|
appointments
|
|
2095
2160
|
.command("create")
|
|
2096
|
-
.description("Create an appointment. --start/--end must be ISO-8601 WITH a timezone offset (e.g. 2026-07-10T15:00:00-07:00)")
|
|
2161
|
+
.description("Create an appointment. --start/--end must be ISO-8601 WITH a timezone offset (e.g. 2026-07-10T15:00:00-07:00)." + APPOINTMENT_DEAL_NOTE)
|
|
2097
2162
|
.option("--title <title>", "title (required)")
|
|
2098
2163
|
.option("--start <iso>", "start time, ISO-8601 with tz offset (required)")
|
|
2099
2164
|
.option("--end <iso>", "end time, ISO-8601 with tz offset (required)")
|
|
2100
2165
|
.option("--description <text>", "description")
|
|
2101
2166
|
.option("--calendar <id>", "calendar UUID")
|
|
2102
|
-
.option("--contact <id>", "contact UUID")
|
|
2167
|
+
.option("--contact <id>", "contact UUID, or a deal UUID (looked up as a deal when no contact has it)")
|
|
2168
|
+
.option("--deal <id>", "deal UUID: book for this deal (card; open, visible and editable to you); the appointment takes its contact and is linked to it")
|
|
2103
2169
|
.option("--assigned <id>", "assignee user UUID")
|
|
2104
2170
|
.option("--location <location>", "location")
|
|
2105
2171
|
.option("--status <status>", "status (default confirmed)")
|
|
@@ -2118,6 +2184,8 @@ appointments
|
|
|
2118
2184
|
body.calendarId = opts.calendar;
|
|
2119
2185
|
if (opts.contact !== undefined)
|
|
2120
2186
|
body.contactId = opts.contact;
|
|
2187
|
+
if (opts.deal !== undefined)
|
|
2188
|
+
body.dealId = opts.deal;
|
|
2121
2189
|
if (opts.assigned !== undefined)
|
|
2122
2190
|
body.assignedTo = opts.assigned;
|
|
2123
2191
|
if (opts.location !== undefined)
|
|
@@ -2270,13 +2338,13 @@ automations
|
|
|
2270
2338
|
}));
|
|
2271
2339
|
automations
|
|
2272
2340
|
.command("validate")
|
|
2273
|
-
.description("Validate a workflow graph before saving. Provide the body via --json or --file (needs triggerEvent + graph). A delay node waits for a fixed duration by default; waitType \"until_field_date\" (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately). Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, target|applyTo: contact|deal|both, overwriteExisting, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. \"GSM\", max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an Assign-to-contact step. create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first." + AUTOMATION_KEY_REFUSALS_NOTE)
|
|
2341
|
+
.description("Validate a workflow graph before saving. Provide the body via --json or --file (needs triggerEvent + graph). A delay node waits for a fixed duration by default; waitType \"until_field_date\" (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately). Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, target|applyTo: contact|deal|both, overwriteExisting, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. \"GSM\", max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an Assign-to-contact step. create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first." + DEAL_NODES_NOTE + AUTOMATION_KEY_REFUSALS_NOTE)
|
|
2274
2342
|
.option("--json <json>", "full request body as JSON")
|
|
2275
2343
|
.option("--file <path>", "read the request body from a JSON file")
|
|
2276
2344
|
.action(run(async (client, opts) => client.post("/api/v1/automations/validate", bodyFromJsonOrFile(opts))));
|
|
2277
2345
|
automations
|
|
2278
2346
|
.command("import")
|
|
2279
|
-
.description("Create a workflow (with optional name resolution). Provide the body via --json or --file (needs name + triggerEvent + graph). A sequence step is the action enroll_in_sequence with config {sequenceId, enrollAction: add (default) | remove}; add needs the sequence to allow automation enrollment. A delay node waits for a fixed duration by default; waitType \"until_field_date\" (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately). Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, target|applyTo: contact|deal|both, overwriteExisting, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. \"GSM\", max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an Assign-to-contact step. create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first." + AUTOMATION_KEY_REFUSALS_NOTE)
|
|
2347
|
+
.description("Create a workflow (with optional name resolution). Provide the body via --json or --file (needs name + triggerEvent + graph). A sequence step is the action enroll_in_sequence with config {sequenceId, enrollAction: add (default) | remove}; add needs the sequence to allow automation enrollment. A delay node waits for a fixed duration by default; waitType \"until_field_date\" (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately). Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, target|applyTo: contact|deal|both, overwriteExisting, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. \"GSM\", max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an Assign-to-contact step. create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first." + DEAL_NODES_NOTE + AUTOMATION_KEY_REFUSALS_NOTE)
|
|
2280
2348
|
.option("--json <json>", "full request body as JSON")
|
|
2281
2349
|
.option("--file <path>", "read the request body from a JSON file")
|
|
2282
2350
|
.option("--resolve-names", "set resolveNames=true so name fields in node configs are converted to ids")
|
|
@@ -2304,7 +2372,7 @@ automations
|
|
|
2304
2372
|
}));
|
|
2305
2373
|
automations
|
|
2306
2374
|
.command("publish <id>")
|
|
2307
|
-
.description("Publish a draft workflow to live. The API binds a publish to the draft you reviewed and answers 409 without its configRevision, so this reads GET /automations/<id> and POSTs its configRevision as expectedConfigRevision (--expected-config-revision N uses the revision you reviewed and skips the read; a draft changed since then is refused 409). A draft holding an action config key the action does not read, or an unregistered action type, is refused 422 with code unknown_action_config_key / unknown_action_type listed in data.errors (with a \"Did you mean\" suggestion); the error text prints the whole refusal")
|
|
2375
|
+
.description("Publish a draft workflow to live. The API binds a publish to the draft you reviewed and answers 409 without its configRevision, so this reads GET /automations/<id> and POSTs its configRevision as expectedConfigRevision (--expected-config-revision N uses the revision you reviewed and skips the read; a draft changed since then is refused 409). A draft holding an action config key the action does not read, or an unregistered action type, is refused 422 with code unknown_action_config_key / unknown_action_type listed in data.errors (with a \"Did you mean\" suggestion); the error text prints the whole refusal. A workflow with a find_opportunity version 2 step is refused 422 find_needs_step_cursors unless the workspace has step cursors (\"Find opportunity needs step cursors on this workspace; ask support\"); validate and import do not check this")
|
|
2308
2376
|
.option("--expected-config-revision <n>", "the configRevision of the draft you reviewed (from `automations` read / GET /automations/<id>); omit to read the current one")
|
|
2309
2377
|
.action(run(async (client, id, opts) => {
|
|
2310
2378
|
assertUuid(id, "automation id");
|
|
@@ -8,6 +8,8 @@ export const LIFECYCLE_STAGES = ["pre_sale", "sale", "underwriting", "active", "
|
|
|
8
8
|
export const MAX_STATUS_METADATA_IMPORT = 1000;
|
|
9
9
|
/** per-call deadlines above the server budgets (archive tx 60 s; Mailchimp admin 60 s; import tx 20 s plus locks) */
|
|
10
10
|
export const ARCHIVE_PIPELINE_TIMEOUT_MS = 75_000;
|
|
11
|
+
/** the contact merge route's own budget is 45 s: both merge calls (dry run and confirm) get 75 s so the client never gives up while the server commits */
|
|
12
|
+
export const MERGE_TIMEOUT_MS = 75_000;
|
|
11
13
|
export const MAILCHIMP_ADMIN_TIMEOUT_MS = 60_000;
|
|
12
14
|
export const STATUS_METADATA_IMPORT_TIMEOUT_MS = 60_000;
|
|
13
15
|
/** the exact fields an import item takes; anything else (a misspelled isTerminal, say) would be dropped by the API and stored as defaults */
|
package/package.json
CHANGED