@wirecat/cli-core 0.590.0 → 0.591.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/CHANGELOG.md +58 -0
- package/dist/background/schedules.d.ts +15 -1
- package/dist/background/schedules.js +16 -5
- package/dist/cli/messenger/flooded.d.ts +1 -1
- package/dist/cli/messenger/flooded.js +3 -26
- package/dist/cli/messenger/search-command.d.ts +3 -2
- package/dist/cli/messenger/search-command.js +7 -5
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/schedule/schedule-command.js +23 -12
- package/dist/crm/cli/index.d.ts +57 -0
- package/dist/crm/cli/index.js +300 -0
- package/dist/crm/cli/search-all.d.ts +12 -0
- package/dist/crm/cli/search-all.js +63 -0
- package/dist/crm/index.d.ts +2 -0
- package/dist/crm/index.js +1 -0
- package/dist/crm/source.d.ts +43 -0
- package/dist/crm/source.js +1 -0
- package/dist/crm/sync.d.ts +121 -0
- package/dist/crm/sync.js +291 -0
- package/dist/crm/testing/index.d.ts +3 -0
- package/dist/crm/testing/index.js +3 -0
- package/dist/crm/testing/source-contract.d.ts +17 -0
- package/dist/crm/testing/source-contract.js +137 -0
- package/dist/crm/testing/source.d.ts +33 -0
- package/dist/crm/testing/source.js +126 -0
- package/dist/crm/testing/sync-contract.d.ts +13 -0
- package/dist/crm/testing/sync-contract.js +178 -0
- package/dist/domain/crm.d.ts +82 -0
- package/dist/domain/crm.js +3 -0
- package/dist/entities/deal.d.ts +159 -0
- package/dist/entities/deal.js +86 -0
- package/dist/entities/index.d.ts +143 -0
- package/dist/entities/index.js +5 -0
- package/dist/entities/list-results.d.ts +4 -0
- package/dist/entities/list-results.js +8 -1
- package/dist/errors/reasons.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sends/index.d.ts +1 -1
- package/dist/sends/index.js +1 -1
- package/dist/sends/pace.d.ts +18 -0
- package/dist/sends/pace.js +40 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/store/index.d.ts +2 -1
- package/dist/store/index.js +1 -0
- package/dist/store/sqlite/crm.d.ts +48 -0
- package/dist/store/sqlite/crm.js +318 -0
- package/dist/store/sqlite/deals.d.ts +10 -0
- package/dist/store/sqlite/deals.js +33 -15
- package/dist/store/store.d.ts +3 -0
- package/dist/store/store.js +5 -0
- package/package.json +13 -1
- package/parity.json +16 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { annotate, metaOf, sharedCommand } from "@wirecat/cli-base/commands";
|
|
3
|
+
import { InvalidArgumentError } from "commander";
|
|
4
|
+
import * as v from "valibot";
|
|
5
|
+
import { answerEntity } from "../../cli/answer-entity.js";
|
|
6
|
+
import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
|
|
7
|
+
import { honoursDryRun } from "../../cli/dry-run-guard.js";
|
|
8
|
+
import { FETCH, LOCAL_READ, NO_EFFECTS } from "../../cli/effects.js";
|
|
9
|
+
import { itemOf, listOf, renderAnswer, snakeKeys } from "../../cli/envelope.js";
|
|
10
|
+
import { queryTerms, termField, withQuery } from "../../cli/list-query.js";
|
|
11
|
+
import { lineOf } from "../../cli/steps.js";
|
|
12
|
+
import { CRM_KINDS } from "../../domain/crm.js";
|
|
13
|
+
import { DEAL_KINDS, DEAL_STATUSES } from "../../domain/deals.js";
|
|
14
|
+
import { count, text } from "../../entities/fields.js";
|
|
15
|
+
import { crmStatus, syncCrm } from "../sync.js";
|
|
16
|
+
import { addCrmSearchAll } from "./search-all.js";
|
|
17
|
+
const MOST = 500;
|
|
18
|
+
const DEAL_FIELDS = [
|
|
19
|
+
termField("status", "enum", "status:open", { values: DEAL_STATUSES }),
|
|
20
|
+
termField("stage", "kw", 'stage:"Quote sent"', { relation: "a stage's name, in any letter case" }),
|
|
21
|
+
termField("pipeline", "kw", "pipeline:Sales", { relation: "pipeline:<id>, or a name only one pipeline has" }),
|
|
22
|
+
termField("organization", "ref", 'organization:"organization:3"'),
|
|
23
|
+
];
|
|
24
|
+
/** What `deals list` and `leads list` narrow by, the same in memo and every CRM CLI (WC-OUT-11). */
|
|
25
|
+
export const DEALS_QUERY = { entity: "deal", backend: "native", fields: DEAL_FIELDS };
|
|
26
|
+
export const LEADS_QUERY = { entity: "lead", backend: "native", fields: DEAL_FIELDS };
|
|
27
|
+
export const PIPELINES_QUERY = {
|
|
28
|
+
entity: "pipeline",
|
|
29
|
+
backend: "native",
|
|
30
|
+
fields: [termField("kind", "enum", "kind:lead", { values: DEAL_KINDS })],
|
|
31
|
+
};
|
|
32
|
+
export const STAGES_QUERY = {
|
|
33
|
+
entity: "stage",
|
|
34
|
+
backend: "native",
|
|
35
|
+
fields: [
|
|
36
|
+
termField("kind", "enum", "kind:deal", { values: DEAL_KINDS }),
|
|
37
|
+
termField("pipeline", "kw", "pipeline:Sales", { relation: "pipeline:<id>, or a name only one pipeline has" }),
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
/** A CRM's users typed these; a list item names its own, as `events list` does. */
|
|
41
|
+
const DEAL_UNTRUSTED = ["title", "summary", "stage", "pipeline_name", "organization.name", "people.name"];
|
|
42
|
+
const PIPELINE_UNTRUSTED = ["name", "stages.name"];
|
|
43
|
+
const STAGE_UNTRUSTED = ["name", "pipeline_name"];
|
|
44
|
+
const crmSync = v.strictObject({
|
|
45
|
+
provider: text,
|
|
46
|
+
account: text,
|
|
47
|
+
pipelines: count,
|
|
48
|
+
synced: v.array(v.strictObject({
|
|
49
|
+
kind: v.picklist(CRM_KINDS),
|
|
50
|
+
round: v.picklist(["full", "changes"]),
|
|
51
|
+
reset: v.boolean(),
|
|
52
|
+
state: v.picklist(["partial", "complete"]),
|
|
53
|
+
saved: count,
|
|
54
|
+
removed: count,
|
|
55
|
+
unlisted: count,
|
|
56
|
+
pass: v.optional(v.picklist(["complete", "partial", "held"])),
|
|
57
|
+
error: v.optional(text),
|
|
58
|
+
})),
|
|
59
|
+
issues: v.array(v.strictObject({
|
|
60
|
+
kind: v.picklist(CRM_KINDS),
|
|
61
|
+
reason: text,
|
|
62
|
+
message: text,
|
|
63
|
+
external_id: v.optional(text),
|
|
64
|
+
})),
|
|
65
|
+
complete: v.boolean(),
|
|
66
|
+
state: v.picklist(["partial", "complete"]),
|
|
67
|
+
stopped: v.optional(v.picklist(["requests", "deadline", "cancelled"])),
|
|
68
|
+
progress: v.strictObject({ requests: count, saved: count, steps: count }),
|
|
69
|
+
hint: v.optional(text),
|
|
70
|
+
});
|
|
71
|
+
const crmSyncProgress = v.strictObject({
|
|
72
|
+
kind: v.picklist(CRM_KINDS),
|
|
73
|
+
round: v.nullable(v.picklist(["full", "changes"])),
|
|
74
|
+
id_pass: v.nullable(text),
|
|
75
|
+
stored: count,
|
|
76
|
+
job: text,
|
|
77
|
+
profile: text,
|
|
78
|
+
done: count,
|
|
79
|
+
total: v.nullable(count),
|
|
80
|
+
remaining: v.nullable(count),
|
|
81
|
+
state: v.picklist(["partial", "complete"]),
|
|
82
|
+
updated_at: v.nullable(text),
|
|
83
|
+
cursor_age_ms: v.nullable(count),
|
|
84
|
+
line: text,
|
|
85
|
+
});
|
|
86
|
+
const integer = (most) => (value) => {
|
|
87
|
+
if (!/^[1-9]\d*$/.test(value) || Number(value) > most)
|
|
88
|
+
throw new InvalidArgumentError(`expected 1 to ${most}`);
|
|
89
|
+
return Number(value);
|
|
90
|
+
};
|
|
91
|
+
/** The words below the program, as `page.next` and `sync`'s next step run it again. */
|
|
92
|
+
const pathOf = (command) => command.parent?.parent ? [...pathOf(command.parent), command.name()] : [command.name()];
|
|
93
|
+
const marked = (items, untrusted) => items.map((item) => ({ ...snakeKeys(item), untrusted }));
|
|
94
|
+
const addCrmCommandsDefinition = (program, options) => {
|
|
95
|
+
const now = () => options.now?.() ?? Date.now();
|
|
96
|
+
const within = async (command, work) => {
|
|
97
|
+
const run = await options.open(command, { readOnly: metaOf(command).effects?.local !== "write" });
|
|
98
|
+
let body;
|
|
99
|
+
try {
|
|
100
|
+
body = await work(run);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
await run.close?.();
|
|
104
|
+
}
|
|
105
|
+
renderAnswer(command, outputFor(command), body);
|
|
106
|
+
};
|
|
107
|
+
const example = (command, ...lines) => annotate(command, { examples: lines.map((line) => `${options.app.command} work ${line}`) });
|
|
108
|
+
example(answerEntity(annotate(program.command("sync"), { effects: FETCH, offline: false, cooperative: true, partial: true }), "crm_sync", crmSync).description("bring the CRM's copy in the local store up to date: pipelines, companies, contacts, deals and leads that " +
|
|
109
|
+
"changed since the last sync, or everything with --rescan; a run stops before its deadline or --max-pages " +
|
|
110
|
+
"and the next one goes on from there"), "sync", "sync --rescan", "sync --max-pages 20")
|
|
111
|
+
.option("--rescan", "list everything again, every id too, dropping a round in progress")
|
|
112
|
+
.option("--max-pages <pages>", "at most this many requests to the CRM in this run", integer(100_000))
|
|
113
|
+
.action(async (opts, command) => {
|
|
114
|
+
const { signal } = environmentOf(command);
|
|
115
|
+
await within(command, async (run) => {
|
|
116
|
+
const report = await syncCrm(run.source(), run.store, run.account, {
|
|
117
|
+
now: now(),
|
|
118
|
+
budget: runBudgetOf(command, opts.maxPages),
|
|
119
|
+
...(signal ? { signal } : {}),
|
|
120
|
+
...(opts.rescan ? { rescan: true } : {}),
|
|
121
|
+
});
|
|
122
|
+
const failed = report.synced.filter((one) => one.error !== undefined);
|
|
123
|
+
const timeout = command.optsWithGlobals().timeout;
|
|
124
|
+
// The same command again, never --rescan: that would drop the round this run leaves in progress.
|
|
125
|
+
const stuck = report.state === "partial" && report.progress.steps === 0 && failed.length === 0;
|
|
126
|
+
const next = failed.length || (report.state === "partial" && !stuck)
|
|
127
|
+
? [
|
|
128
|
+
{
|
|
129
|
+
kind: "command",
|
|
130
|
+
step: "crm.sync",
|
|
131
|
+
command: "sync",
|
|
132
|
+
args: Object.fromEntries(Object.entries({ max_pages: opts.maxPages, timeout }).filter(([, value]) => value !== undefined)),
|
|
133
|
+
why: failed.length
|
|
134
|
+
? "a kind failed to sync; running sync again retries it"
|
|
135
|
+
: "the sync stopped before its end to answer in time; running it again goes on from there",
|
|
136
|
+
safe_to_auto_execute: true,
|
|
137
|
+
},
|
|
138
|
+
]
|
|
139
|
+
: undefined;
|
|
140
|
+
const progressAt = lineOf(command, "store status");
|
|
141
|
+
const hint = failed.length
|
|
142
|
+
? `${failed.map((one) => `${one.kind}: ${one.error}`).join("; ")} — run sync again to retry`
|
|
143
|
+
: stuck
|
|
144
|
+
? `this run moved nothing on: raise --timeout or --max-pages, then run sync again; see progress: ${progressAt}`
|
|
145
|
+
: report.state === "partial"
|
|
146
|
+
? `run sync again to continue; see progress: ${progressAt}`
|
|
147
|
+
: undefined;
|
|
148
|
+
const { hint: _hint, ...rest } = report;
|
|
149
|
+
// What the healthy kinds brought is saved and answered; each failure and each item not kept is an issue.
|
|
150
|
+
const issues = [
|
|
151
|
+
...failed.map((one) => ({
|
|
152
|
+
code: "provider_error",
|
|
153
|
+
reason: "sync.partial",
|
|
154
|
+
source: `crm:${one.kind}`,
|
|
155
|
+
})),
|
|
156
|
+
...report.issues.map((one) => ({
|
|
157
|
+
code: "validation_error",
|
|
158
|
+
reason: one.reason,
|
|
159
|
+
source: one.externalId === undefined ? `crm:${one.kind}` : `crm:${one.kind}/${one.externalId}`,
|
|
160
|
+
})),
|
|
161
|
+
];
|
|
162
|
+
const partial = report.state === "partial" || issues.length > 0;
|
|
163
|
+
return itemOf(snakeKeys({ ...rest, ...(hint === undefined ? {} : { hint }) }), {
|
|
164
|
+
...(partial ? { meta: { complete: false, ...(issues.length ? { issues } : {}) } } : {}),
|
|
165
|
+
...(next ? { next } : {}),
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
const store = annotate(program.command("store").description("the local copy of the CRM"), { effects: NO_EFFECTS });
|
|
170
|
+
example(answerEntity(annotate(store.command("status"), { effects: LOCAL_READ }), "crm_sync_progress", crmSyncProgress).description("how far each kind's sync is: rows saved, partial or complete, when it last moved, how many are stored"), "store status --json").action(async (_opts, command) => {
|
|
171
|
+
await within(command, async (run) => {
|
|
172
|
+
const status = await crmStatus(run.store, run.account, { profile: run.profile, now: now() });
|
|
173
|
+
return listOf(pathOf(command).join(" "), {
|
|
174
|
+
items: status.items,
|
|
175
|
+
limit: status.limit,
|
|
176
|
+
has_more: false,
|
|
177
|
+
page: 1,
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
const items = (kind) => {
|
|
182
|
+
const plural = `${kind}s`;
|
|
183
|
+
const query = kind === "deal" ? DEALS_QUERY : LEADS_QUERY;
|
|
184
|
+
const group = annotate(program.command(plural), { effects: NO_EFFECTS }).description(kind === "deal"
|
|
185
|
+
? "the CRM's deals as last synced: stage, status, amount and the people on them"
|
|
186
|
+
: "the CRM's leads as last synced");
|
|
187
|
+
example(withQuery(annotate(group.command("list"), { effects: LOCAL_READ, output: { entity: kind } }).description(`${plural} copied from the CRM, newest first; run sync to bring them up to date`), query)
|
|
188
|
+
.option("--limit <n>", `${plural} per page, 1–${MOST}`, integer(MOST), 50)
|
|
189
|
+
.option("--page <n>", "which page, starting at 1", integer(1_000_000), 1), `${plural} list status:open --json`, `${plural} list stage:"Quote sent"`).action(async (words, opts, command) => {
|
|
190
|
+
const terms = queryTerms(words, query, `${plural} list`);
|
|
191
|
+
await within(command, async (run) => {
|
|
192
|
+
const filter = {
|
|
193
|
+
kind,
|
|
194
|
+
account: run.account,
|
|
195
|
+
...(terms.has("status") ? { status: terms.get("status") } : {}),
|
|
196
|
+
...(terms.has("stage") ? { stage: terms.get("stage") } : {}),
|
|
197
|
+
...(terms.has("pipeline") ? { pipeline: terms.get("pipeline") } : {}),
|
|
198
|
+
...(terms.has("organization") ? { organization: terms.get("organization") } : {}),
|
|
199
|
+
};
|
|
200
|
+
const found = await run.store.deals.list({
|
|
201
|
+
...filter,
|
|
202
|
+
limit: Math.min(opts.limit + 1, MOST),
|
|
203
|
+
offset: (opts.page - 1) * opts.limit,
|
|
204
|
+
});
|
|
205
|
+
return listOf(pathOf(command).join(" "), {
|
|
206
|
+
items: marked(found.slice(0, opts.limit), DEAL_UNTRUSTED),
|
|
207
|
+
limit: opts.limit,
|
|
208
|
+
page: opts.page,
|
|
209
|
+
has_more: found.length > opts.limit,
|
|
210
|
+
...(words.length ? { args: { query: words } } : {}),
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
example(annotate(group.command("show"), { effects: LOCAL_READ, output: { entity: kind } })
|
|
215
|
+
.description(`one ${kind} with its pipeline, stage, status, amount and the people on it`)
|
|
216
|
+
.argument("<ref>", `${kind}:<id>, as a list shows it`), `${plural} show ${kind}:12`).action(async (reference, _opts, command) => {
|
|
217
|
+
await within(command, async (run) => {
|
|
218
|
+
const found = await ownCopy(run, kind, reference);
|
|
219
|
+
return itemOf({ ...snakeKeys(found), untrusted: DEAL_UNTRUSTED });
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
/** A row of this profile's account: another account's copy, or the owner's own, is not found here. */
|
|
224
|
+
const ownCopy = async (run, kind, reference) => {
|
|
225
|
+
if (!reference.startsWith(`${kind}:`))
|
|
226
|
+
throw new CliError("validation_error", `${kind}s show takes a ${kind}:<id> reference, as a list shows it`, {
|
|
227
|
+
reason: "ref.malformed",
|
|
228
|
+
});
|
|
229
|
+
const found = await run.store.deals.get(reference);
|
|
230
|
+
if (found.account?.provider !== run.account.provider || found.account.account !== run.account.account)
|
|
231
|
+
throw new CliError("not_found", `${reference} is not a copy from this profile's CRM account`);
|
|
232
|
+
return found;
|
|
233
|
+
};
|
|
234
|
+
items("deal");
|
|
235
|
+
if (options.leads !== false)
|
|
236
|
+
items("lead");
|
|
237
|
+
const pipelines = annotate(program.command("pipelines"), { effects: NO_EFFECTS }).description("the CRM's pipelines and their stages, as last synced");
|
|
238
|
+
example(withQuery(annotate(pipelines.command("list"), { effects: LOCAL_READ, output: { entity: "pipeline" } }).description("pipelines copied from the CRM, with their stages in order and how many are open in each"), PIPELINES_QUERY), "pipelines list --json", "pipelines list kind:lead").action(async (words, _opts, command) => {
|
|
239
|
+
const terms = queryTerms(words, PIPELINES_QUERY, "pipelines list");
|
|
240
|
+
await within(command, async (run) => {
|
|
241
|
+
const found = await run.store.deals.pipelines({
|
|
242
|
+
account: run.account,
|
|
243
|
+
...(terms.has("kind") ? { kind: terms.get("kind") } : {}),
|
|
244
|
+
});
|
|
245
|
+
return listOf(pathOf(command).join(" "), {
|
|
246
|
+
items: marked(found, PIPELINE_UNTRUSTED),
|
|
247
|
+
limit: Math.max(1, found.length),
|
|
248
|
+
page: 1,
|
|
249
|
+
has_more: false,
|
|
250
|
+
...(words.length ? { args: { query: words } } : {}),
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
example(annotate(pipelines.command("show"), { effects: LOCAL_READ, output: { entity: "pipeline" } })
|
|
255
|
+
.description("one pipeline with its funnel: how many reached each stage, how many went on, and the win rate")
|
|
256
|
+
.argument("<pipeline>", "pipeline:<id>, or a name only one of the CRM's pipelines has"), "pipelines show pipeline:3").action(async (reference, _opts, command) => {
|
|
257
|
+
await within(command, async (run) => {
|
|
258
|
+
const report = await run.store.deals.pipeline(reference, { account: run.account });
|
|
259
|
+
return itemOf({ ...snakeKeys(report), untrusted: PIPELINE_UNTRUSTED });
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
const stages = annotate(program.command("stages"), { effects: NO_EFFECTS }).description("the stages of the CRM's pipelines, as last synced");
|
|
263
|
+
example(withQuery(annotate(stages.command("list"), { effects: LOCAL_READ, output: { entity: "stage" } }).description("every stage of the CRM's pipelines in order, with its outcome and how many are open in it"), STAGES_QUERY), "stages list --json", "stages list kind:lead").action(async (words, _opts, command) => {
|
|
264
|
+
const terms = queryTerms(words, STAGES_QUERY, "stages list");
|
|
265
|
+
await within(command, async (run) => {
|
|
266
|
+
const kind = terms.has("kind") ? terms.get("kind") : undefined;
|
|
267
|
+
const all = await run.store.deals.pipelines({ account: run.account, ...(kind ? { kind } : {}) });
|
|
268
|
+
const chosen = terms.has("pipeline")
|
|
269
|
+
? [
|
|
270
|
+
await run.store.deals.pipeline(terms.get("pipeline"), {
|
|
271
|
+
account: run.account,
|
|
272
|
+
...(kind ? { kind } : {}),
|
|
273
|
+
}),
|
|
274
|
+
]
|
|
275
|
+
: all;
|
|
276
|
+
const found = chosen.flatMap((pipeline) => pipeline.stages.map(({ name, position, outcome, externalId, open }) => ({
|
|
277
|
+
name,
|
|
278
|
+
position,
|
|
279
|
+
outcome,
|
|
280
|
+
externalId,
|
|
281
|
+
open,
|
|
282
|
+
pipeline: pipeline.ref,
|
|
283
|
+
pipelineName: pipeline.name,
|
|
284
|
+
kind: pipeline.kind,
|
|
285
|
+
})));
|
|
286
|
+
return listOf(pathOf(command).join(" "), {
|
|
287
|
+
items: marked(found, STAGE_UNTRUSTED),
|
|
288
|
+
limit: Math.max(1, found.length),
|
|
289
|
+
page: 1,
|
|
290
|
+
has_more: false,
|
|
291
|
+
...(words.length ? { args: { query: words } } : {}),
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
const search = program.commands.find((one) => one.name() === "search") ??
|
|
296
|
+
annotate(program.command("search"), { effects: NO_EFFECTS }).description("search what the local store holds");
|
|
297
|
+
addCrmSearchAll(search, { within, app: options.app, example });
|
|
298
|
+
};
|
|
299
|
+
// honoursDryRun: a host program other than `run` would otherwise let a write through under --dry-run.
|
|
300
|
+
export const addCrmCommands = honoursDryRun(sharedCommand(addCrmCommandsDefinition));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import type { AppIdentity } from "../../ports/app.js";
|
|
3
|
+
import type { CrmWithin } from "./index.js";
|
|
4
|
+
/**
|
|
5
|
+
* `search all` in a CRM CLI: the shared search over the store, its own deals and leads beside the messages, notes and
|
|
6
|
+
* events other WireCat tools stored. A locked profile reads only its own account's deals.
|
|
7
|
+
*/
|
|
8
|
+
export declare const addCrmSearchAll: (searches: Command, { within, app, example, }: {
|
|
9
|
+
within: CrmWithin;
|
|
10
|
+
app: AppIdentity;
|
|
11
|
+
example: (command: Command, ...lines: string[]) => Command;
|
|
12
|
+
}) => void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { annotate } from "@wirecat/cli-base/commands";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { outputFor } from "../../cli/context.js";
|
|
5
|
+
import { LOCAL_READ } from "../../cli/effects.js";
|
|
6
|
+
import { historySkipsOf } from "../../cli/messenger/history-gaps.js";
|
|
7
|
+
import { allAnswer, offsetOf, SOURCE_SEARCHED } from "../../cli/messenger/search-answers.js";
|
|
8
|
+
import { searchAllOptions } from "../../cli/messenger/search-command.js";
|
|
9
|
+
import { SAVE_HELP } from "../../meetings/index.js";
|
|
10
|
+
import { meaningLane } from "../../services/messages-meaning.js";
|
|
11
|
+
import { searchAllWithMeetings } from "../../services/search-all-meetings.js";
|
|
12
|
+
/** What a CRM CLI's `search all` reads: its deals and leads first, then what the other tools stored. */
|
|
13
|
+
const SEARCHED = ["crm", ...SOURCE_SEARCHED];
|
|
14
|
+
const DEFAULT_LIMIT = 20;
|
|
15
|
+
/**
|
|
16
|
+
* `search all` in a CRM CLI: the shared search over the store, its own deals and leads beside the messages, notes and
|
|
17
|
+
* events other WireCat tools stored. A locked profile reads only its own account's deals.
|
|
18
|
+
*/
|
|
19
|
+
export const addCrmSearchAll = (searches, { within, app, example, }) => {
|
|
20
|
+
const all = annotate(new Command("all"), { effects: LOCAL_READ, partial: true, output: { entity: "hit" } })
|
|
21
|
+
.summary("search stored deals and leads, messages, notes and calendar events")
|
|
22
|
+
.description("search the CRM's deals and leads by the words of their title, and messages, notes and calendar events other " +
|
|
23
|
+
"WireCat tools stored — best match first. Use it when you do not know where something was written")
|
|
24
|
+
.argument("<query...>", 'words and "phrases"; deals and leads match the words of their title')
|
|
25
|
+
.addHelpText("after", SAVE_HELP);
|
|
26
|
+
searches.addCommand(searchAllOptions(all, SEARCHED));
|
|
27
|
+
example(all, "search all Example LLC", "search all website --only crm --json");
|
|
28
|
+
all.action(async (words, _opts, leaf) => {
|
|
29
|
+
const opts = leaf.opts();
|
|
30
|
+
await within(leaf, async (run) => {
|
|
31
|
+
if (run.locked && opts.source !== undefined)
|
|
32
|
+
throw new CliError("permission_error", "this process is locked to one profile; search all reads its own CRM account only, without --source", { reason: "profile.locked" });
|
|
33
|
+
const limit = opts.limit ?? DEFAULT_LIMIT;
|
|
34
|
+
const offset = offsetOf(opts.page, limit);
|
|
35
|
+
const env = run.env ?? process.env;
|
|
36
|
+
const found = await searchAllWithMeetings(run.store, run.account, {
|
|
37
|
+
text: words.join(" "),
|
|
38
|
+
limit,
|
|
39
|
+
...(offset === undefined ? {} : { offset }),
|
|
40
|
+
...(opts.exact ? { exact: true } : {}),
|
|
41
|
+
...(opts.timezone === undefined ? {} : { timezone: opts.timezone }),
|
|
42
|
+
env,
|
|
43
|
+
only: opts.only ?? SEARCHED,
|
|
44
|
+
...(opts.source === undefined ? {} : { source: opts.source }),
|
|
45
|
+
...(opts.maxMeetings === undefined ? {} : { maxMeetings: opts.maxMeetings }),
|
|
46
|
+
...(run.locked ? { scope: { crm: [run.account] } } : {}),
|
|
47
|
+
}, { app }, undefined, meaningLane({ env, messenger: { app }, store: async () => run.store }));
|
|
48
|
+
const note = (text) => outputFor(leaf).renderer.note(text);
|
|
49
|
+
for (const { resource, source, reason } of found.skipped)
|
|
50
|
+
note(`${resource ?? source} not searched: ${reason}`);
|
|
51
|
+
if (!found.items.length)
|
|
52
|
+
note("nothing found in what the local store holds");
|
|
53
|
+
const skips = await historySkipsOf(run.store, found.accounts).catch(() => []);
|
|
54
|
+
const again = {
|
|
55
|
+
query: words.join(" "),
|
|
56
|
+
...(opts.only === undefined ? {} : { only: opts.only.join(",") }),
|
|
57
|
+
...(opts.source === undefined ? {} : { source: opts.source }),
|
|
58
|
+
...(opts.exact ? { exact: true } : {}),
|
|
59
|
+
};
|
|
60
|
+
return allAnswer("search all", found, { limit, page: opts.page ?? 1, again }, opts.exact === true, skips);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { crmStatus, cursorName, parseCrmCursor, syncCrm, } from "./sync.js";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { CrmKind, CrmPipeline, CrmRecords } from "../domain/crm.js";
|
|
2
|
+
import type { DealKind } from "../domain/deals.js";
|
|
3
|
+
export interface CrmSourceTraits {
|
|
4
|
+
/** What this CRM has: one without leads leaves `lead` out. Contacts and companies come before the items on them. */
|
|
5
|
+
kinds: readonly CrmKind[];
|
|
6
|
+
/**
|
|
7
|
+
* `changes()` names what was deleted in `removed`, so the engine never lists every id to find them. A CRM that
|
|
8
|
+
* only lists what exists leaves this false and gives `ids()`.
|
|
9
|
+
*/
|
|
10
|
+
removedInChanges: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface CrmPage<T> {
|
|
13
|
+
items: T[];
|
|
14
|
+
/** Ids the CRM says it deleted since the cursor; never one that only left a filter. */
|
|
15
|
+
removed?: string[];
|
|
16
|
+
/** More pages in this round. */
|
|
17
|
+
nextPage?: string;
|
|
18
|
+
/** On the last page: the cursor the next run reads changes after; `null` when the CRM keeps none. */
|
|
19
|
+
cursor?: string | null;
|
|
20
|
+
}
|
|
21
|
+
/** `reset`: the cursor is dead, and the CRM must be listed in full again. */
|
|
22
|
+
export type CrmChanges<T> = CrmPage<T> | {
|
|
23
|
+
reset: true;
|
|
24
|
+
};
|
|
25
|
+
export interface CrmIdPage {
|
|
26
|
+
ids: string[];
|
|
27
|
+
nextPage?: string;
|
|
28
|
+
}
|
|
29
|
+
/** One CRM account, as an adapter reads it. The engine never sees a provider word. */
|
|
30
|
+
export interface CrmSource {
|
|
31
|
+
/** The registry name: `bitrix24`, `pipedrive`, `hubspot`, `amocrm`. */
|
|
32
|
+
readonly provider: string;
|
|
33
|
+
readonly traits: CrmSourceTraits;
|
|
34
|
+
/** One kind's pipelines with their stages; a CRM whose leads have no pipelines answers one of its own making. */
|
|
35
|
+
pipelines(kind: DealKind): Promise<CrmPipeline[]>;
|
|
36
|
+
/**
|
|
37
|
+
* What changed since `cursor`, a page at a time; `cursor: null` lists everything. A CRM without a change feed
|
|
38
|
+
* reads by its own modified time, with an overlap, and gives that time back as the cursor.
|
|
39
|
+
*/
|
|
40
|
+
changes<K extends CrmKind>(kind: K, cursor: string | null, page?: string): Promise<CrmChanges<CrmRecords[K]>>;
|
|
41
|
+
/** Every id the CRM holds of the kind, a page at a time: how deletions are found when `removedInChanges` is false. */
|
|
42
|
+
ids?(kind: CrmKind, page?: string): Promise<CrmIdPage>;
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type BudgetStop, type RunBudget } from "../background/budget.js";
|
|
2
|
+
import { type CrmKind } from "../domain/crm.js";
|
|
3
|
+
import type { CrmSkipped } from "../store/sqlite/crm.js";
|
|
4
|
+
import type { MessageStore } from "../store/store.js";
|
|
5
|
+
import type { AccountKey } from "../store/types.js";
|
|
6
|
+
import type { CrmSource } from "./source.js";
|
|
7
|
+
/** A round of changes that stopped before its last page: the next run goes on from its page with the same cursor. */
|
|
8
|
+
interface PendingRound {
|
|
9
|
+
from: string | null;
|
|
10
|
+
page: string | null;
|
|
11
|
+
reset: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** A list of every id that stopped before its end: the ids it has seen, and its next page. */
|
|
14
|
+
interface PendingPass {
|
|
15
|
+
page: string | null;
|
|
16
|
+
startedAt: number;
|
|
17
|
+
seen: string[];
|
|
18
|
+
}
|
|
19
|
+
/** What the engine keeps per kind in `store.sync` under `crm:<kind>`. */
|
|
20
|
+
export interface CrmCursor {
|
|
21
|
+
v: 1;
|
|
22
|
+
/** The CRM's cursor after the last finished round; `null` before the first. */
|
|
23
|
+
token: string | null;
|
|
24
|
+
round: PendingRound | null;
|
|
25
|
+
/** Rows saved by the round in progress, or by the last one. */
|
|
26
|
+
done: number;
|
|
27
|
+
updatedAt: number | null;
|
|
28
|
+
pass: PendingPass | null;
|
|
29
|
+
/** When the last complete id pass started; 0 before the first. */
|
|
30
|
+
passAt: number;
|
|
31
|
+
}
|
|
32
|
+
export declare const parseCrmCursor: (value: string | null | undefined) => CrmCursor | null;
|
|
33
|
+
export declare const cursorName: (kind: CrmKind) => string;
|
|
34
|
+
export interface CrmSyncOptions {
|
|
35
|
+
now?: number;
|
|
36
|
+
/** List everything again and every id, dropping a round in progress: `sync --rescan`. */
|
|
37
|
+
rescan?: boolean;
|
|
38
|
+
/** Asked before every request to the CRM; without one the run goes until every kind is done. */
|
|
39
|
+
budget?: RunBudget;
|
|
40
|
+
/** Checked after every request: a cancelled run stops and keeps what it saved. */
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
/** How long a complete id pass holds before the next one; a day when absent. */
|
|
43
|
+
passEvery?: number;
|
|
44
|
+
}
|
|
45
|
+
/** Something a run could not do that the owner should know: an item not kept, or a deletion held back. */
|
|
46
|
+
export interface CrmSyncIssue {
|
|
47
|
+
kind: CrmKind;
|
|
48
|
+
reason: CrmSkipped["reason"] | "crm.unlisted-held";
|
|
49
|
+
message: string;
|
|
50
|
+
externalId?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface CrmSyncResult {
|
|
53
|
+
kind: CrmKind;
|
|
54
|
+
/** `full`: everything was listed; `changes`: only what changed since the cursor. */
|
|
55
|
+
round: "full" | "changes";
|
|
56
|
+
/** The cursor was dead, so the round became a full one. */
|
|
57
|
+
reset: boolean;
|
|
58
|
+
state: "partial" | "complete";
|
|
59
|
+
saved: number;
|
|
60
|
+
/** Rows the CRM said it deleted, now marked deleted. */
|
|
61
|
+
removed: number;
|
|
62
|
+
/** Rows a complete id pass did not list, now marked deleted. */
|
|
63
|
+
unlisted: number;
|
|
64
|
+
/** `complete`: a full list of ids arrived; `partial`: it stopped and goes on next run; `held`: it would mark too many. */
|
|
65
|
+
pass?: "complete" | "partial" | "held";
|
|
66
|
+
/** Set when the kind failed; its cursor stays at its last saved page. */
|
|
67
|
+
error?: string;
|
|
68
|
+
}
|
|
69
|
+
export interface CrmSyncReport {
|
|
70
|
+
provider: string;
|
|
71
|
+
account: string;
|
|
72
|
+
/** Pipelines read this run, both kinds. */
|
|
73
|
+
pipelines: number;
|
|
74
|
+
synced: CrmSyncResult[];
|
|
75
|
+
issues: CrmSyncIssue[];
|
|
76
|
+
/** Every kind finished its round and none failed. */
|
|
77
|
+
complete: boolean;
|
|
78
|
+
state: "partial" | "complete";
|
|
79
|
+
stopped?: BudgetStop;
|
|
80
|
+
progress: {
|
|
81
|
+
/** Requests made to the CRM. */
|
|
82
|
+
requests: number;
|
|
83
|
+
saved: number;
|
|
84
|
+
/** Pages saved: 0 means this run moved nothing on. */
|
|
85
|
+
steps: number;
|
|
86
|
+
};
|
|
87
|
+
hint?: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Brings one CRM account's copy up to date: both kinds' pipelines, then contacts, companies, deals and leads, each
|
|
91
|
+
* from its cursor or listed in full, a page at a time, each page saved with the round's place in the cursor, so a
|
|
92
|
+
* run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or from a list of
|
|
93
|
+
* every id that marks only after its last page arrived, and never more than half of a kind without `--rescan`.
|
|
94
|
+
*/
|
|
95
|
+
export declare const syncCrm: (source: CrmSource, store: MessageStore, account: AccountKey, options?: CrmSyncOptions) => Promise<CrmSyncReport>;
|
|
96
|
+
/** Each kind's sync in the WC-LIFE-14 shape: `cursor_age_ms` growing on a `partial` one means nothing moves it. */
|
|
97
|
+
export declare const crmStatus: (store: MessageStore, account: AccountKey, { profile, now, kinds }: {
|
|
98
|
+
profile: string;
|
|
99
|
+
now: number;
|
|
100
|
+
kinds?: readonly CrmKind[];
|
|
101
|
+
}) => Promise<{
|
|
102
|
+
items: {
|
|
103
|
+
job: string;
|
|
104
|
+
profile: string;
|
|
105
|
+
done: number;
|
|
106
|
+
total: number | null;
|
|
107
|
+
remaining: number | null;
|
|
108
|
+
state: "partial" | "complete";
|
|
109
|
+
updated_at: string | null;
|
|
110
|
+
cursor_age_ms: number | null;
|
|
111
|
+
kind: "deal" | "lead" | "organization" | "person";
|
|
112
|
+
round: string | null;
|
|
113
|
+
id_pass: string | null;
|
|
114
|
+
stored: number;
|
|
115
|
+
line: string;
|
|
116
|
+
}[];
|
|
117
|
+
page: number;
|
|
118
|
+
limit: number;
|
|
119
|
+
hasMore: boolean;
|
|
120
|
+
}>;
|
|
121
|
+
export {};
|