@wirecat/cli-core 0.593.0 → 0.594.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 +37 -0
- package/dist/crm/cli/index.d.ts +6 -0
- package/dist/crm/cli/index.js +118 -2
- package/dist/crm/index.d.ts +2 -0
- package/dist/crm/index.js +2 -0
- package/dist/crm/testing/index.d.ts +2 -1
- package/dist/crm/testing/index.js +2 -1
- package/dist/crm/testing/source-contract.js +2 -2
- package/dist/crm/testing/source.d.ts +22 -2
- package/dist/crm/testing/source.js +117 -1
- package/dist/crm/testing/sync-contract.js +3 -2
- package/dist/crm/testing/write-contract.d.ts +21 -0
- package/dist/crm/testing/write-contract.js +127 -0
- package/dist/crm/writer.d.ts +76 -0
- package/dist/crm/writer.js +14 -0
- package/dist/crm/writes.d.ts +68 -0
- package/dist/crm/writes.js +245 -0
- package/dist/domain/crm.d.ts +3 -4
- package/dist/errors/reasons.js +2 -1
- package/dist/mail/cli/review.js +11 -4
- package/dist/mail/marks.d.ts +2 -0
- package/dist/mail/marks.js +8 -6
- package/dist/mail/newsletter.d.ts +10 -0
- package/dist/mail/newsletter.js +70 -0
- package/dist/mail/review.d.ts +1 -0
- package/dist/store/sqlite/crm.d.ts +3 -1
- package/dist/store/sqlite/crm.js +37 -20
- package/dist/store/sqlite/deals.js +6 -4
- package/dist/store/sqlite/email-activity.d.ts +4 -0
- package/dist/store/sqlite/email-activity.js +5 -1
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +9 -0
- package/dist/store/sqlite/schema.d.ts +32 -0
- package/dist/store/sqlite/schema.js +4 -2
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,43 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.594.0 — 06.11.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **A CRM write port, `CrmWriter`** (`@wirecat/cli-core/crm`), beside `CrmSource`: `create`, `update`, `move`,
|
|
15
|
+
`close` and `remove` of deals and leads, each sent once with its `operationId` and answering the whole item as the
|
|
16
|
+
CRM holds it. `capabilities` names the kinds and verbs it takes and whether it recognises a repeated operation id
|
|
17
|
+
(Bitrix24's `Idempotency-Key`). Fields use `CrmItem`'s names, plus `owner` (a CRM user's id) and `custom` (the CRM's
|
|
18
|
+
own fields by code). An adapter refuses a custom code that is a neutral field under another name, such as a stage
|
|
19
|
+
or an owner. `CRM_WRITE_DEFAULTS` and `CRM_WRITE_KEYS` go into a CLI's `commandPermissions`.
|
|
20
|
+
- **`deals` and `leads` `create`, `update`, `move`, `close` and `delete`** in `addCrmCommands`, mounted by its new
|
|
21
|
+
`writes` option (the writer's capabilities) and run through `CrmRun.writes`. Each goes through the write guard and
|
|
22
|
+
the send journal, honours `--dry-run`, and is exposed over MCP as a write. A write is never sent twice: a lost
|
|
23
|
+
answer is `outcome_unknown`, and `--operation-id` is offered only by a CRM that recognises a repeat. A stage in
|
|
24
|
+
`create` or `update` also checks the `move` key, and a won or lost stage the `close` key. An owner other than the
|
|
25
|
+
sign-in's own user (`CrmWrites.self`) checks `crm.assign-others`, denied by default. `delete` asks by default. The
|
|
26
|
+
item the CRM answers is stored as a sync would store it, and the answer carries it with its `operation_id`.
|
|
27
|
+
`--field <key=value>` sets a custom field; an empty value clears it.
|
|
28
|
+
- **`crmWriteContract` and a write side on `fakeCrmSource`** (`@wirecat/cli-core/crm/testing`). The contract checks
|
|
29
|
+
that an update of one field answers the whole item, that a custom code naming a neutral field is refused before
|
|
30
|
+
anything is sent, and that a lost answer ends after one request.
|
|
31
|
+
- **`CrmItem.closedReason`** is stored as the deal's or lead's `closed_reason`, and `store.crm.refOf` finds the stored
|
|
32
|
+
copy of a CRM id.
|
|
33
|
+
- **A CRM's deal and lead pipelines may share an id (store version 53).** The unique key of a CRM account's pipelines
|
|
34
|
+
is now its account, kind and id, so `savePipelines` stores a lead pipeline whose id a deal pipeline already has,
|
|
35
|
+
and `crmSourceContract` checks pipeline ids are unique within each kind only.
|
|
36
|
+
- **Pipelines and stages the CRM no longer lists are marked deleted (store version 53),** never removed: deals may still
|
|
37
|
+
stand in them. `savePipelines` takes a kind's whole list, marks what it lacks and brings back what it lists again; an
|
|
38
|
+
empty list marks nothing. A deleted pipeline is left out of `pipelines()` and of a lookup by name, and still found by
|
|
39
|
+
its `pipeline:<id>`. A deleted stage is only marked. The migration adds columns and swaps one index; `minCompatible`
|
|
40
|
+
stays 34.
|
|
41
|
+
|
|
42
|
+
### Removed
|
|
43
|
+
|
|
44
|
+
- **The `crm.pipeline-clash` skip reason.** `savePipelines` no longer skips a pipeline for sharing the other kind's id,
|
|
45
|
+
so `CrmSkipped.reason` is `crm.stage-unknown` or `crm.item-invalid`.
|
|
46
|
+
|
|
10
47
|
## 0.593.0 — 06.11.2026
|
|
11
48
|
|
|
12
49
|
### Added
|
package/dist/crm/cli/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { AppIdentity } from "../../ports/app.js";
|
|
|
4
4
|
import type { MessageStore } from "../../store/store.js";
|
|
5
5
|
import type { AccountKey } from "../../store/types.js";
|
|
6
6
|
import type { CrmSource } from "../source.js";
|
|
7
|
+
import type { CrmWriteCapabilities } from "../writer.js";
|
|
8
|
+
import { type CrmWrites } from "../writes.js";
|
|
7
9
|
/**
|
|
8
10
|
* The selected profile's CRM account, opened by the app for one command and closed once its answer is built, before
|
|
9
11
|
* anything is printed (WC-LIFE-4).
|
|
@@ -18,6 +20,8 @@ export interface CrmRun {
|
|
|
18
20
|
source: () => CrmSource;
|
|
19
21
|
/** A profile lock pins the process (WC-ACCT-5): `search all` then reads only this account's deals. */
|
|
20
22
|
locked?: boolean;
|
|
23
|
+
/** The CRM's writer and the profile's levels, built on first use; needed by every write command. */
|
|
24
|
+
writes?: () => CrmWrites;
|
|
21
25
|
env?: NodeJS.ProcessEnv;
|
|
22
26
|
close?: () => Promise<void>;
|
|
23
27
|
}
|
|
@@ -30,6 +34,8 @@ export interface CrmCommandOptions {
|
|
|
30
34
|
}) => Promise<CrmRun>;
|
|
31
35
|
/** The CRM has leads: mounts `leads list|show`. True when absent. */
|
|
32
36
|
leads?: boolean;
|
|
37
|
+
/** The writer's capabilities: mounts `create`, `update`, `move`, `close` and `delete` of each kind it writes. */
|
|
38
|
+
writes?: CrmWriteCapabilities;
|
|
33
39
|
now?: () => number;
|
|
34
40
|
}
|
|
35
41
|
/** What `deals list` and `leads list` narrow by, the same in memo and every CRM CLI (WC-OUT-11). */
|
package/dist/crm/cli/index.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { annotate, metaOf, sharedCommand } from "@wirecat/cli-base/commands";
|
|
3
|
-
import { InvalidArgumentError } from "commander";
|
|
3
|
+
import { Argument, InvalidArgumentError } from "commander";
|
|
4
4
|
import * as v from "valibot";
|
|
5
5
|
import { answerEntity } from "../../cli/answer-entity.js";
|
|
6
6
|
import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
|
|
7
7
|
import { honoursDryRun } from "../../cli/dry-run-guard.js";
|
|
8
|
-
import { FETCH, LOCAL_READ, NO_EFFECTS } from "../../cli/effects.js";
|
|
8
|
+
import { FETCH, LOCAL_READ, NO_EFFECTS, REMOTE_WRITE } from "../../cli/effects.js";
|
|
9
9
|
import { itemOf, listOf, renderAnswer, snakeKeys } from "../../cli/envelope.js";
|
|
10
10
|
import { queryTerms, termField, withQuery } from "../../cli/list-query.js";
|
|
11
|
+
import { terminalAsker } from "../../cli/messenger/ask.js";
|
|
11
12
|
import { lineOf } from "../../cli/steps.js";
|
|
12
13
|
import { CRM_KINDS } from "../../domain/crm.js";
|
|
13
14
|
import { DEAL_KINDS, DEAL_STATUSES } from "../../domain/deals.js";
|
|
15
|
+
import { deal as dealEntity } from "../../entities/deal.js";
|
|
14
16
|
import { count, text } from "../../entities/fields.js";
|
|
15
17
|
import { crmStatus, syncCrm } from "../sync.js";
|
|
18
|
+
import { closeCrmItem, createCrmItem, moveCrmItem, removeCrmItem, updateCrmItem, } from "../writes.js";
|
|
16
19
|
import { addCrmSearchAll } from "./search-all.js";
|
|
17
20
|
const MOST = 500;
|
|
18
21
|
const DEAL_FIELDS = [
|
|
@@ -83,6 +86,24 @@ const crmSyncProgress = v.strictObject({
|
|
|
83
86
|
cursor_age_ms: v.nullable(count),
|
|
84
87
|
line: text,
|
|
85
88
|
});
|
|
89
|
+
const crmWrite = v.strictObject({
|
|
90
|
+
operation_id: text,
|
|
91
|
+
deal: v.optional(dealEntity),
|
|
92
|
+
lead: v.optional(dealEntity),
|
|
93
|
+
untrusted: v.array(text),
|
|
94
|
+
});
|
|
95
|
+
const crmRemoval = v.strictObject({ operation_id: text, ref: text, removed: count });
|
|
96
|
+
const collect = (value, previous = []) => {
|
|
97
|
+
if (value.indexOf("=") < 1)
|
|
98
|
+
throw new InvalidArgumentError("expected code=value");
|
|
99
|
+
return [...previous, value];
|
|
100
|
+
};
|
|
101
|
+
/** `--field code=value` pairs; an empty value clears the field. */
|
|
102
|
+
const customOf = (pairs) => Object.fromEntries(pairs.map((pair) => {
|
|
103
|
+
const at = pair.indexOf("=");
|
|
104
|
+
const given = pair.slice(at + 1);
|
|
105
|
+
return [pair.slice(0, at), given === "" ? null : given];
|
|
106
|
+
}));
|
|
86
107
|
const integer = (most) => (value) => {
|
|
87
108
|
if (!/^[1-9]\d*$/.test(value) || Number(value) > most)
|
|
88
109
|
throw new InvalidArgumentError(`expected 1 to ${most}`);
|
|
@@ -219,6 +240,7 @@ const addCrmCommandsDefinition = (program, options) => {
|
|
|
219
240
|
return itemOf({ ...snakeKeys(found), untrusted: DEAL_UNTRUSTED });
|
|
220
241
|
});
|
|
221
242
|
});
|
|
243
|
+
addWrites(group, kind);
|
|
222
244
|
};
|
|
223
245
|
/** A row of this profile's account: another account's copy, or the owner's own, is not found here. */
|
|
224
246
|
const ownCopy = async (run, kind, reference) => {
|
|
@@ -231,6 +253,100 @@ const addCrmCommandsDefinition = (program, options) => {
|
|
|
231
253
|
throw new CliError("not_found", `${reference} is not a copy from this profile's CRM account`);
|
|
232
254
|
return found;
|
|
233
255
|
};
|
|
256
|
+
const writes = options.writes;
|
|
257
|
+
const addWrites = (group, kind) => {
|
|
258
|
+
if (!writes?.kinds.includes(kind))
|
|
259
|
+
return;
|
|
260
|
+
const plural = `${kind}s`;
|
|
261
|
+
const contextOf = (run, command) => {
|
|
262
|
+
if (!run.writes)
|
|
263
|
+
throw new CliError("configuration_error", "this profile cannot write to its CRM");
|
|
264
|
+
return {
|
|
265
|
+
store: run.store,
|
|
266
|
+
account: run.account,
|
|
267
|
+
writes: run.writes(),
|
|
268
|
+
ask: terminalAsker(command),
|
|
269
|
+
source: run.source,
|
|
270
|
+
names: { id: "--operation-id" },
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
const answered = (operationId, item) => itemOf({
|
|
274
|
+
operation_id: operationId,
|
|
275
|
+
[kind]: { ...snakeKeys(item), untrusted: DEAL_UNTRUSTED },
|
|
276
|
+
untrusted: DEAL_UNTRUSTED.map((field) => `${kind}.${field}`),
|
|
277
|
+
});
|
|
278
|
+
const write = (name, verb, description) => {
|
|
279
|
+
if (!writes.verbs.includes(verb))
|
|
280
|
+
return undefined;
|
|
281
|
+
const command = answerEntity(annotate(group.command(name), { effects: REMOTE_WRITE, offline: false }), verb === "remove" ? "crm_removal" : "crm_write", verb === "remove" ? crmRemoval : crmWrite).description(description);
|
|
282
|
+
if (writes.idempotent)
|
|
283
|
+
command.option("--operation-id <id>", "the operation_id a write that ended in outcome_unknown named: the CRM answers the repeat from its record");
|
|
284
|
+
return command;
|
|
285
|
+
};
|
|
286
|
+
const reference = (command) => command.argument("<ref>", `${kind}:<id>, as a list shows it`);
|
|
287
|
+
const fields = (command) => command
|
|
288
|
+
.option("--title <title>", `the ${kind}'s title`)
|
|
289
|
+
.option("--pipeline <pipeline>", "pipeline:<id>, or a name only one of the CRM's pipelines has")
|
|
290
|
+
.option("--stage <stage>", "a stage of its pipeline, by the CRM's id or name; checks the move key too, and the close key for a won or lost stage")
|
|
291
|
+
.option("--amount <decimal>", "the expected sum, such as 1500.50, with --currency")
|
|
292
|
+
.option("--currency <code>", "an ISO 4217 code, such as EUR")
|
|
293
|
+
.option("--close-date <date>", "the day it is expected to close, YYYY-MM-DD")
|
|
294
|
+
.option("--owner <user>", "the CRM user it is assigned to; anyone but the sign-in's own user checks crm.assign-others")
|
|
295
|
+
.option("--field <key=value>", "one of the CRM's own fields, by its code; repeat for more, empty clears it", collect,
|
|
296
|
+
// The empty default marks the flag as repeatable, so an agent's tool takes a list.
|
|
297
|
+
[]);
|
|
298
|
+
const inputOf = ({ field, operationId: _id, ...rest }) => ({
|
|
299
|
+
...Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== undefined)),
|
|
300
|
+
...(field?.length ? { custom: customOf(field) } : {}),
|
|
301
|
+
});
|
|
302
|
+
const idOf = (opts) => opts.operationId === undefined ? {} : { operationId: opts.operationId };
|
|
303
|
+
const run = (command, work) => within(command, (opened) => work(contextOf(opened, command)));
|
|
304
|
+
const create = write("create", "create", `make a ${kind} in the CRM, then store the CRM's copy of it`);
|
|
305
|
+
if (create)
|
|
306
|
+
example(fields(create), `${plural} create --title "Example kitchen" --amount 15000 --currency EUR`, `${plural} create --title "Example office" --pipeline Sales --stage "Quote sent" --field UF_CRM_SOURCE=web`).action(async (opts, command) => {
|
|
307
|
+
await run(command, async (context) => {
|
|
308
|
+
const done = await createCrmItem(context, kind, inputOf(opts), idOf(opts));
|
|
309
|
+
return answered(done.operationId, done.item);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
const update = write("update", "update", `change a ${kind} in the CRM; what is left out keeps its value; the CRM's copy is stored again`);
|
|
313
|
+
if (update)
|
|
314
|
+
example(fields(reference(update)), `${plural} update ${kind}:12 --amount 18000`, `${plural} update ${kind}:12 --field UF_CRM_SOURCE=`).action(async (ref, opts, command) => {
|
|
315
|
+
await run(command, async (context) => {
|
|
316
|
+
const done = await updateCrmItem(context, kind, ref, inputOf(opts), idOf(opts));
|
|
317
|
+
return answered(done.operationId, done.item);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
const move = write("move", "move", `move a ${kind} to another stage of its pipeline; a won or lost stage checks the close key too`);
|
|
321
|
+
if (move)
|
|
322
|
+
example(reference(move).argument("<stage>", "the stage, by the CRM's id or a name only one stage of its pipeline has"), `${plural} move ${kind}:12 "Quote sent"`).action(async (ref, stage, opts, command) => {
|
|
323
|
+
await run(command, async (context) => {
|
|
324
|
+
const done = await moveCrmItem(context, kind, ref, stage, idOf(opts));
|
|
325
|
+
return answered(done.operationId, done.item);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
const close = write("close", "close", `mark a ${kind} won or lost, the CRM's own way`);
|
|
329
|
+
if (close)
|
|
330
|
+
example(reference(close)
|
|
331
|
+
.addArgument(new Argument("<outcome>", "how it ended").choices(["won", "lost"]))
|
|
332
|
+
.option("--reason <text>", "why, in a few words; kept with it"), `${plural} close ${kind}:12 won`, `${plural} close ${kind}:12 lost --reason "Example: chose another supplier"`).action(async (ref, outcome, opts, command) => {
|
|
333
|
+
await run(command, async (context) => {
|
|
334
|
+
const done = await closeCrmItem(context, kind, ref, outcome, {
|
|
335
|
+
...idOf(opts),
|
|
336
|
+
...(opts.reason === undefined ? {} : { reason: opts.reason }),
|
|
337
|
+
});
|
|
338
|
+
return answered(done.operationId, done.item);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
const remove = write("delete", "remove", `delete a ${kind} in the CRM; asks first unless allowed`);
|
|
342
|
+
if (remove)
|
|
343
|
+
example(reference(remove), `${plural} delete ${kind}:12`).action(async (ref, opts, command) => {
|
|
344
|
+
await run(command, async (context) => {
|
|
345
|
+
const done = await removeCrmItem(context, kind, ref, idOf(opts));
|
|
346
|
+
return itemOf({ operation_id: done.operationId, ref: done.ref, removed: done.removed });
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
};
|
|
234
350
|
items("deal");
|
|
235
351
|
if (options.leads !== false)
|
|
236
352
|
items("lead");
|
package/dist/crm/index.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export type { CrmChanges, CrmIdPage, CrmPage, CrmSource, CrmSourceTraits } from "./source.js";
|
|
2
2
|
export { type CrmCursor, type CrmSyncIssue, type CrmSyncOptions, type CrmSyncReport, type CrmSyncResult, crmStatus, cursorName, parseCrmCursor, syncCrm, } from "./sync.js";
|
|
3
|
+
export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, type CrmDraft, type CrmFields, type CrmVerb, type CrmWriteCapabilities, type CrmWriteOptions, type CrmWriter, } from "./writer.js";
|
|
4
|
+
export { type CrmFieldsInput, type CrmWriteContext, type CrmWrites, type CrmWritten, closeCrmItem, createCrmItem, moveCrmItem, removeCrmItem, updateCrmItem, } from "./writes.js";
|
package/dist/crm/index.js
CHANGED
|
@@ -1 +1,3 @@
|
|
|
1
1
|
export { crmStatus, cursorName, parseCrmCursor, syncCrm, } from "./sync.js";
|
|
2
|
+
export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, } from "./writer.js";
|
|
3
|
+
export { closeCrmItem, createCrmItem, moveCrmItem, removeCrmItem, updateCrmItem, } from "./writes.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { exampleDeal, exampleLead, exampleOrganization, examplePerson, type FakeCrmSource, type FakeCrmSourceOptions, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
|
|
1
|
+
export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, type FakeCrmSource, type FakeCrmSourceOptions, type FakeCrmWrite, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
|
|
2
2
|
export { type CrmSourceFixture, crmSourceContract } from "./source-contract.js";
|
|
3
3
|
export { CRM_TRAIT_COMBINATIONS, type CrmSyncFixture, crmSyncContract } from "./sync-contract.js";
|
|
4
|
+
export { type CrmWriteFixture, crmWriteContract } from "./write-contract.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { exampleDeal, exampleLead, exampleOrganization, examplePerson, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
|
|
1
|
+
export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
|
|
2
2
|
export { crmSourceContract } from "./source-contract.js";
|
|
3
3
|
export { CRM_TRAIT_COMBINATIONS, crmSyncContract } from "./sync-contract.js";
|
|
4
|
+
export { crmWriteContract } from "./write-contract.js";
|
|
@@ -29,11 +29,11 @@ const listAll = async (source, kind, cursor) => {
|
|
|
29
29
|
export const crmSourceContract = (make) => {
|
|
30
30
|
const cases = [
|
|
31
31
|
{
|
|
32
|
-
name: "pipelines: unique ids
|
|
32
|
+
name: "pipelines: unique ids in each kind, stages with a known outcome and unique ids",
|
|
33
33
|
async run() {
|
|
34
34
|
const { source } = await make();
|
|
35
|
-
const seen = new Set();
|
|
36
35
|
for (const kind of DEAL_KINDS.filter((one) => source.traits.kinds.includes(one))) {
|
|
36
|
+
const seen = new Set();
|
|
37
37
|
const pipelines = await source.pipelines(kind);
|
|
38
38
|
assert.ok(pipelines.length > 0, `${kind}: at least one pipeline`);
|
|
39
39
|
for (const pipeline of pipelines) {
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import type { CrmItem, CrmKind, CrmOrganization, CrmPerson, CrmPipeline, CrmRecords } from "../../domain/crm.js";
|
|
2
2
|
import type { DealKind } from "../../domain/deals.js";
|
|
3
3
|
import type { CrmSource, CrmSourceTraits } from "../source.js";
|
|
4
|
+
import { type CrmVerb, type CrmWriteCapabilities, type CrmWriter } from "../writer.js";
|
|
4
5
|
export interface FakeCrmSourceOptions {
|
|
5
6
|
traits?: Partial<CrmSourceTraits>;
|
|
6
7
|
/** Rows per page of `changes()` and `ids()`; 2 when absent, so every list has several pages. */
|
|
7
8
|
pageSize?: number;
|
|
9
|
+
/** What the write side takes: every verb on both kinds, not idempotent, when absent. */
|
|
10
|
+
capabilities?: Partial<CrmWriteCapabilities>;
|
|
8
11
|
}
|
|
9
|
-
/**
|
|
10
|
-
export interface
|
|
12
|
+
/** One write the fake CRM was sent, in order. */
|
|
13
|
+
export interface FakeCrmWrite {
|
|
14
|
+
verb: CrmVerb;
|
|
15
|
+
kind: DealKind;
|
|
16
|
+
externalId: string | null;
|
|
17
|
+
operationId: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A CRM held in memory, with a change feed and a write side: what the contracts sync and write against, and what a
|
|
21
|
+
* CLI's tests may use. Custom field codes `stage`, `pipeline`, `owner` and `status` are refused, as an adapter refuses
|
|
22
|
+
* a custom code that names one of the neutral fields.
|
|
23
|
+
*/
|
|
24
|
+
export interface FakeCrmSource extends CrmSource, CrmWriter {
|
|
11
25
|
/** Adds or changes a row; it is in the next `changes()`. */
|
|
12
26
|
put<K extends CrmKind>(kind: K, record: CrmRecords[K]): void;
|
|
13
27
|
/** Deletes a row: named in `removed` when `removedInChanges`, otherwise only missing from `ids()`. */
|
|
@@ -17,13 +31,19 @@ export interface FakeCrmSource extends CrmSource {
|
|
|
17
31
|
expireCursors(kind: CrmKind): void;
|
|
18
32
|
/** Lists no id at all, as a CRM does for a sign-in that lost its rights. */
|
|
19
33
|
hideAll(hidden: boolean): void;
|
|
34
|
+
/** The next write is carried out and its answer lost: it throws `outcome_unknown`. */
|
|
35
|
+
loseNextAnswer(): void;
|
|
20
36
|
/** Requests made, by method. */
|
|
21
37
|
readonly calls: {
|
|
22
38
|
pipelines: number;
|
|
23
39
|
changes: number;
|
|
24
40
|
ids: number;
|
|
25
41
|
};
|
|
42
|
+
/** Writes sent, refused ones included. */
|
|
43
|
+
readonly writes: FakeCrmWrite[];
|
|
26
44
|
}
|
|
45
|
+
/** Custom codes the fake refuses: each is a neutral field under another name. */
|
|
46
|
+
export declare const FAKE_CRM_ALIASES: string[];
|
|
27
47
|
export declare const SALES_PIPELINE: CrmPipeline;
|
|
28
48
|
export declare const LEAD_PIPELINE: CrmPipeline;
|
|
29
49
|
export declare const fakeCrmSource: (options?: FakeCrmSourceOptions) => FakeCrmSource;
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { CRM_VERBS } from "../writer.js";
|
|
3
|
+
/** Custom codes the fake refuses: each is a neutral field under another name. */
|
|
4
|
+
export const FAKE_CRM_ALIASES = ["stage", "pipeline", "owner", "status"];
|
|
1
5
|
/** When the fake CRM's changes happened: well before any `now` a contract syncs at. */
|
|
2
6
|
const STAMPED_FROM = Date.UTC(2026, 0, 1);
|
|
3
7
|
export const SALES_PIPELINE = {
|
|
@@ -32,6 +36,16 @@ export const fakeCrmSource = (options = {}) => {
|
|
|
32
36
|
const expired = new Map();
|
|
33
37
|
let hidden = false;
|
|
34
38
|
const calls = { pipelines: 0, changes: 0, ids: 0 };
|
|
39
|
+
const capabilities = {
|
|
40
|
+
kinds: ["deal", "lead"],
|
|
41
|
+
verbs: CRM_VERBS,
|
|
42
|
+
idempotent: false,
|
|
43
|
+
...options.capabilities,
|
|
44
|
+
};
|
|
45
|
+
const writes = [];
|
|
46
|
+
const answered = new Map();
|
|
47
|
+
let made = 0;
|
|
48
|
+
let lose = false;
|
|
35
49
|
const of = (kind) => {
|
|
36
50
|
const held = rows.get(kind) ?? new Map();
|
|
37
51
|
rows.set(kind, held);
|
|
@@ -42,10 +56,111 @@ export const fakeCrmSource = (options = {}) => {
|
|
|
42
56
|
const next = at + size;
|
|
43
57
|
return { slice: list.slice(at, next), nextPage: next < list.length ? String(next) : undefined };
|
|
44
58
|
};
|
|
45
|
-
|
|
59
|
+
const held = (kind, externalId) => {
|
|
60
|
+
const found = of(kind).get(externalId);
|
|
61
|
+
if (!found || found.deleted)
|
|
62
|
+
throw new CliError("not_found", `the CRM has no ${kind} ${externalId}`);
|
|
63
|
+
return found.record;
|
|
64
|
+
};
|
|
65
|
+
const placed = (kind, pipelineId, stageId) => {
|
|
66
|
+
const pipeline = pipelineId === undefined ? pipelines[kind][0] : pipelines[kind].find((one) => one.externalId === pipelineId);
|
|
67
|
+
const stage = stageId === undefined ? pipeline?.stages[0] : pipeline?.stages.find((one) => one.externalId === stageId);
|
|
68
|
+
if (!pipeline || !stage)
|
|
69
|
+
throw new CliError("validation_error", `the CRM has no stage ${stageId} there`);
|
|
70
|
+
return { pipeline: pipeline.externalId, stage: stage.externalId, outcome: stage.outcome };
|
|
71
|
+
};
|
|
72
|
+
const applied = (item, fields) => {
|
|
73
|
+
const { custom, owner, pipeline, stage, ...named } = fields;
|
|
74
|
+
const metadata = {
|
|
75
|
+
...item.metadata,
|
|
76
|
+
...(owner === undefined ? {} : { owner }),
|
|
77
|
+
...(custom ? { custom: { ...item.metadata?.custom, ...custom } } : {}),
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
...item,
|
|
81
|
+
...Object.fromEntries(Object.entries(named).filter(([, value]) => value !== undefined)),
|
|
82
|
+
...(pipeline === undefined ? {} : { pipeline }),
|
|
83
|
+
...(stage === undefined ? {} : { stage }),
|
|
84
|
+
metadata,
|
|
85
|
+
updatedAt: null,
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
const refuseAliases = (fields) => {
|
|
89
|
+
const refused = Object.keys(fields.custom ?? {}).find((code) => FAKE_CRM_ALIASES.includes(code));
|
|
90
|
+
if (refused)
|
|
91
|
+
throw new CliError("validation_error", `${refused} is not a custom field`);
|
|
92
|
+
};
|
|
93
|
+
/** Each write once: a repeated idempotent id answers its first outcome; a lost answer still made the change. */
|
|
94
|
+
const write = (verb, kind, externalId, operationId, act) => {
|
|
95
|
+
writes.push({ verb, kind, externalId, operationId });
|
|
96
|
+
if (capabilities.idempotent && answered.has(operationId))
|
|
97
|
+
return answered.get(operationId);
|
|
98
|
+
const done = act();
|
|
99
|
+
if (capabilities.idempotent)
|
|
100
|
+
answered.set(operationId, done);
|
|
101
|
+
if (lose) {
|
|
102
|
+
lose = false;
|
|
103
|
+
throw new CliError("outcome_unknown", "the CRM did not answer");
|
|
104
|
+
}
|
|
105
|
+
return done;
|
|
106
|
+
};
|
|
107
|
+
const saved = (kind, item) => {
|
|
108
|
+
fake.put(kind, item);
|
|
109
|
+
return held(kind, item.externalId);
|
|
110
|
+
};
|
|
111
|
+
const fake = {
|
|
46
112
|
provider: "example-crm",
|
|
47
113
|
traits,
|
|
48
114
|
calls,
|
|
115
|
+
capabilities,
|
|
116
|
+
writes,
|
|
117
|
+
loseNextAnswer() {
|
|
118
|
+
lose = true;
|
|
119
|
+
},
|
|
120
|
+
async create(kind, draft, { operationId }) {
|
|
121
|
+
refuseAliases(draft);
|
|
122
|
+
return write("create", kind, null, operationId, () => {
|
|
123
|
+
const { pipeline, stage } = placed(kind, draft.pipeline, draft.stage);
|
|
124
|
+
made += 1;
|
|
125
|
+
const blank = { externalId: `${kind}-new-${made}`, title: draft.title, pipeline, stage };
|
|
126
|
+
return saved(kind, applied(blank, { ...draft, pipeline, stage }));
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
async update(kind, externalId, patch, { operationId }) {
|
|
130
|
+
refuseAliases(patch);
|
|
131
|
+
return write("update", kind, externalId, operationId, () => {
|
|
132
|
+
const item = held(kind, externalId);
|
|
133
|
+
const at = patch.stage === undefined ? {} : placed(kind, patch.pipeline ?? item.pipeline, patch.stage);
|
|
134
|
+
return saved(kind, applied(item, { ...patch, ...at }));
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
async move(kind, externalId, stage, { operationId }) {
|
|
138
|
+
return write("move", kind, externalId, operationId, () => {
|
|
139
|
+
const item = held(kind, externalId);
|
|
140
|
+
return saved(kind, { ...item, stage: placed(kind, item.pipeline, stage).stage, status: null, updatedAt: null });
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
async close(kind, externalId, outcome, { operationId, reason }) {
|
|
144
|
+
return write("close", kind, externalId, operationId, () => {
|
|
145
|
+
const item = held(kind, externalId);
|
|
146
|
+
const pipeline = pipelines[kind].find((one) => one.externalId === item.pipeline);
|
|
147
|
+
const closing = pipeline?.stages.find((one) => one.outcome === outcome);
|
|
148
|
+
return saved(kind, {
|
|
149
|
+
...item,
|
|
150
|
+
...(closing ? { stage: closing.externalId } : {}),
|
|
151
|
+
status: outcome,
|
|
152
|
+
closedReason: reason ?? null,
|
|
153
|
+
updatedAt: null,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
async remove(kind, externalId, { operationId }) {
|
|
158
|
+
write("remove", kind, externalId, operationId, () => {
|
|
159
|
+
held(kind, externalId);
|
|
160
|
+
fake.delete(kind, externalId);
|
|
161
|
+
return undefined;
|
|
162
|
+
});
|
|
163
|
+
},
|
|
49
164
|
put(kind, record) {
|
|
50
165
|
version += 1;
|
|
51
166
|
// A CRM stamps each change; without one here the store would take its own clock, which an id pass skips.
|
|
@@ -99,6 +214,7 @@ export const fakeCrmSource = (options = {}) => {
|
|
|
99
214
|
return { ids: slice, ...(nextPage === undefined ? {} : { nextPage }) };
|
|
100
215
|
},
|
|
101
216
|
};
|
|
217
|
+
return fake;
|
|
102
218
|
};
|
|
103
219
|
export const exampleDeal = (externalId, extra = {}) => ({
|
|
104
220
|
externalId,
|
|
@@ -138,12 +138,13 @@ export const crmSyncContract = (make) => CRM_TRAIT_COMBINATIONS.flatMap((traits)
|
|
|
138
138
|
assert.deepEqual(await deals(), ["d2"]);
|
|
139
139
|
assert.deepEqual(run.issues.map(({ kind, reason, externalId }) => [kind, reason, externalId]), [["deal", "crm.stage-unknown", "d1"]]);
|
|
140
140
|
}),
|
|
141
|
-
test("a pipeline
|
|
141
|
+
test("a lead pipeline may share a deal pipeline's id", async ({ source, sync, deals }) => {
|
|
142
142
|
source.setPipelines("lead", [{ ...LEAD_PIPELINE, externalId: SALES_PIPELINE.externalId }]);
|
|
143
143
|
source.put("deal", exampleDeal("d1"));
|
|
144
|
+
source.put("lead", exampleLead("l1", { pipeline: SALES_PIPELINE.externalId }));
|
|
144
145
|
const run = await sync();
|
|
145
146
|
assert.deepEqual(await deals(), ["d1"]);
|
|
146
|
-
assert.
|
|
147
|
+
assert.deepEqual(run.issues, []);
|
|
147
148
|
}),
|
|
148
149
|
test("a contact joins a person by exact email, and a company never joins an organization by name", async ({ source, sync, store, account, }) => {
|
|
149
150
|
const own = await store.knowledge.addOrganization({ kind: "company", name: "Example company c1" });
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CrmPipeline } from "../../domain/crm.js";
|
|
2
|
+
import { type DealKind } from "../../domain/deals.js";
|
|
3
|
+
import type { ContractCase } from "../../meetings/index.js";
|
|
4
|
+
import { type CrmWriter } from "../writer.js";
|
|
5
|
+
/**
|
|
6
|
+
* One adapter over recorded or faked responses, fresh for each case, that takes writes of `kind`. `pipeline` is one
|
|
7
|
+
* of the kind's pipelines with at least two open stages and a won one.
|
|
8
|
+
*/
|
|
9
|
+
export interface CrmWriteFixture {
|
|
10
|
+
writer: CrmWriter;
|
|
11
|
+
kind?: DealKind;
|
|
12
|
+
pipeline: CrmPipeline;
|
|
13
|
+
/** Custom field codes that are a neutral field under the CRM's own name (Bitrix24's `stageId`, `ASSIGNED_BY_ID`). */
|
|
14
|
+
aliases: readonly string[];
|
|
15
|
+
/** Makes the next write's answer lost after the CRM got it, as a dropped connection does. */
|
|
16
|
+
loseNextAnswer: () => void;
|
|
17
|
+
/** How many write requests reached the CRM so far. */
|
|
18
|
+
requests: () => number;
|
|
19
|
+
}
|
|
20
|
+
/** What every CRM adapter's write side does, whatever the CRM: run each case in the adapter's tests. */
|
|
21
|
+
export declare const crmWriteContract: (make: () => CrmWriteFixture | Promise<CrmWriteFixture>) => ContractCase[];
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { isCliError } from "@wirecat/cli-base";
|
|
3
|
+
import { DEAL_KINDS } from "../../domain/deals.js";
|
|
4
|
+
import { CRM_VERBS } from "../writer.js";
|
|
5
|
+
let operations = 0;
|
|
6
|
+
const operation = () => {
|
|
7
|
+
operations += 1;
|
|
8
|
+
return { operationId: `contract-${Date.now()}-${operations}` };
|
|
9
|
+
};
|
|
10
|
+
const refusedWith = async (code, act) => {
|
|
11
|
+
try {
|
|
12
|
+
await act();
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
assert.ok(isCliError(error), `a CliError, not ${String(error)}`);
|
|
16
|
+
assert.equal(error.code, code);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
assert.fail(`expected ${code}`);
|
|
20
|
+
};
|
|
21
|
+
const outcomeOf = (pipeline, item) => item.status ?? pipeline.stages.find((stage) => stage.externalId === item.stage)?.outcome;
|
|
22
|
+
/** What every CRM adapter's write side does, whatever the CRM: run each case in the adapter's tests. */
|
|
23
|
+
export const crmWriteContract = (make) => {
|
|
24
|
+
const openStages = async () => {
|
|
25
|
+
const fixture = await make();
|
|
26
|
+
const open = fixture.pipeline.stages.filter((stage) => stage.outcome === "open");
|
|
27
|
+
assert.ok(open.length >= 2, "the fixture's pipeline has two open stages");
|
|
28
|
+
const kind = fixture.kind ?? "deal";
|
|
29
|
+
const made = await fixture.writer.create(kind, {
|
|
30
|
+
title: "Example contract deal",
|
|
31
|
+
pipeline: fixture.pipeline.externalId,
|
|
32
|
+
stage: open[0]?.externalId,
|
|
33
|
+
amount: "1500.50",
|
|
34
|
+
currency: "EUR",
|
|
35
|
+
}, operation());
|
|
36
|
+
return { ...fixture, kind, open, made };
|
|
37
|
+
};
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
name: "capabilities: known verbs and kinds, and a yes or no for repeated writes",
|
|
41
|
+
async run() {
|
|
42
|
+
const { writer } = await make();
|
|
43
|
+
const { kinds, verbs, idempotent } = writer.capabilities;
|
|
44
|
+
assert.ok(kinds.length > 0 && kinds.every((kind) => DEAL_KINDS.includes(kind)));
|
|
45
|
+
assert.ok(verbs.length > 0 && verbs.every((verb) => CRM_VERBS.includes(verb)));
|
|
46
|
+
assert.equal(typeof idempotent, "boolean");
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "create answers the whole item in the stage it was given, with its new id",
|
|
51
|
+
async run() {
|
|
52
|
+
const { made, pipeline, open } = await openStages();
|
|
53
|
+
assert.ok(made.externalId, "an id");
|
|
54
|
+
assert.equal(made.title, "Example contract deal");
|
|
55
|
+
assert.equal(made.pipeline, pipeline.externalId);
|
|
56
|
+
assert.equal(made.stage, open[0]?.externalId);
|
|
57
|
+
assert.equal(made.currency, "EUR");
|
|
58
|
+
assert.equal(Number(made.amount), 1500.5);
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "update of one field answers the whole item, every other field as it was",
|
|
63
|
+
async run() {
|
|
64
|
+
const { writer, kind, made } = await openStages();
|
|
65
|
+
const changed = await writer.update(kind, made.externalId, { title: "Example renamed" }, operation());
|
|
66
|
+
assert.equal(changed.externalId, made.externalId);
|
|
67
|
+
assert.equal(changed.title, "Example renamed");
|
|
68
|
+
for (const field of ["pipeline", "stage", "amount", "currency", "organization"])
|
|
69
|
+
assert.deepEqual(changed[field] ?? null, made[field] ?? null, `${field} kept`);
|
|
70
|
+
assert.deepEqual(changed.people ?? [], made.people ?? [], "people kept");
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "move goes to the stage given; close marks it won",
|
|
75
|
+
async run() {
|
|
76
|
+
const { writer, kind, made, open, pipeline } = await openStages();
|
|
77
|
+
const moved = await writer.move(kind, made.externalId, open[1]?.externalId, operation());
|
|
78
|
+
assert.equal(moved.stage, open[1]?.externalId);
|
|
79
|
+
assert.equal(outcomeOf(pipeline, moved), "open");
|
|
80
|
+
const closed = await writer.close(kind, made.externalId, "won", operation());
|
|
81
|
+
assert.equal(outcomeOf(pipeline, closed), "won");
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "remove answers once the CRM deleted it",
|
|
86
|
+
async run() {
|
|
87
|
+
const { writer, kind, made } = await openStages();
|
|
88
|
+
await writer.remove(kind, made.externalId, operation());
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "a custom code that is a neutral field under another name is refused, and nothing is sent",
|
|
93
|
+
async run() {
|
|
94
|
+
const { writer, kind, made, aliases, requests, open } = await openStages();
|
|
95
|
+
assert.ok(aliases.length > 0, "the fixture names the CRM's own names for stage and owner");
|
|
96
|
+
for (const code of aliases) {
|
|
97
|
+
const before = requests();
|
|
98
|
+
await refusedWith("validation_error", () => writer.update(kind, made.externalId, { custom: { [code]: open[1]?.externalId } }, operation()));
|
|
99
|
+
assert.equal(requests(), before, `${code}: nothing sent`);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "a lost answer is outcome_unknown after one request, never retried",
|
|
105
|
+
async run() {
|
|
106
|
+
const fixture = await openStages();
|
|
107
|
+
const before = fixture.requests();
|
|
108
|
+
fixture.loseNextAnswer();
|
|
109
|
+
await refusedWith("outcome_unknown", () => fixture.writer.update(fixture.kind, fixture.made.externalId, { title: "Example lost" }, operation()));
|
|
110
|
+
assert.equal(fixture.requests(), before + 1, "one request");
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "an idempotent CRM answers a repeated operation id from its record",
|
|
115
|
+
async run() {
|
|
116
|
+
const { writer, kind, pipeline } = await make();
|
|
117
|
+
if (!writer.capabilities.idempotent)
|
|
118
|
+
return;
|
|
119
|
+
const once = operation();
|
|
120
|
+
const draft = { title: "Example repeated", pipeline: pipeline.externalId };
|
|
121
|
+
const first = await writer.create(kind ?? "deal", draft, once);
|
|
122
|
+
const again = await writer.create(kind ?? "deal", draft, once);
|
|
123
|
+
assert.equal(again.externalId, first.externalId);
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
};
|