@wirecat/cli-core 0.592.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 +64 -11
- 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/services/index.d.ts +1 -0
- package/dist/services/index.js +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/emails.js +3 -21
- package/dist/store/sqlite/mail-nearest.d.ts +12 -0
- package/dist/store/sqlite/mail-nearest.js +73 -0
- 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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { CrmItem } from "../domain/crm.js";
|
|
2
|
+
import type { DealKind, DealRole } from "../domain/deals.js";
|
|
3
|
+
import type { Level, PermissionKey } from "../sends/permissions.js";
|
|
4
|
+
export declare const CRM_VERBS: readonly ["create", "update", "move", "close", "remove"];
|
|
5
|
+
export type CrmVerb = (typeof CRM_VERBS)[number];
|
|
6
|
+
export interface CrmWriteCapabilities {
|
|
7
|
+
/** The kinds the CRM takes writes for. */
|
|
8
|
+
kinds: readonly DealKind[];
|
|
9
|
+
verbs: readonly CrmVerb[];
|
|
10
|
+
/**
|
|
11
|
+
* The CRM answers a repeat of an `operationId` from its record instead of doing the write again (Bitrix24 REST
|
|
12
|
+
* 3.0's `Idempotency-Key`). Only then may the owner repeat a write whose answer was lost with the same id.
|
|
13
|
+
*/
|
|
14
|
+
idempotent: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A deal's or lead's fields in `CrmItem`'s names, each the CRM's own id; `null` clears one. `custom` holds the CRM's
|
|
18
|
+
* own fields by their code. A custom code that is one of the named fields under another name (a stage, a pipeline, an
|
|
19
|
+
* owner or a status) is refused by the adapter: through it a write would get round the keys those fields check.
|
|
20
|
+
*/
|
|
21
|
+
export interface CrmFields {
|
|
22
|
+
title?: string;
|
|
23
|
+
pipeline?: string;
|
|
24
|
+
stage?: string;
|
|
25
|
+
/** A decimal string in `currency`; both or neither. */
|
|
26
|
+
amount?: string | null;
|
|
27
|
+
currency?: string | null;
|
|
28
|
+
/** `YYYY-MM-DD`. */
|
|
29
|
+
closeDate?: string | null;
|
|
30
|
+
organization?: string | null;
|
|
31
|
+
/** Replaces the whole list. */
|
|
32
|
+
people?: {
|
|
33
|
+
externalId: string;
|
|
34
|
+
role: DealRole;
|
|
35
|
+
}[];
|
|
36
|
+
/** The CRM user it is assigned to: a user's id, never a contact's. */
|
|
37
|
+
owner?: string | null;
|
|
38
|
+
custom?: Record<string, string | null>;
|
|
39
|
+
}
|
|
40
|
+
export type CrmDraft = CrmFields & {
|
|
41
|
+
title: string;
|
|
42
|
+
};
|
|
43
|
+
export interface CrmWriteOptions {
|
|
44
|
+
/** Journaled before the call; an idempotent CRM gets it as its idempotency key. */
|
|
45
|
+
operationId: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Writes to one CRM account. Each answers the whole item as the CRM holds it after the write, every field and not
|
|
49
|
+
* only the changed ones: the store replaces its copy with it. None of them retries: a write whose answer was lost
|
|
50
|
+
* throws `outcome_unknown`.
|
|
51
|
+
*/
|
|
52
|
+
export interface CrmWriter {
|
|
53
|
+
readonly provider: string;
|
|
54
|
+
readonly capabilities: CrmWriteCapabilities;
|
|
55
|
+
create(kind: DealKind, draft: CrmDraft, options: CrmWriteOptions): Promise<CrmItem>;
|
|
56
|
+
update(kind: DealKind, externalId: string, patch: CrmFields, options: CrmWriteOptions): Promise<CrmItem>;
|
|
57
|
+
/** To `stage`, a stage of the item's own pipeline. */
|
|
58
|
+
move(kind: DealKind, externalId: string, stage: string, options: CrmWriteOptions): Promise<CrmItem>;
|
|
59
|
+
/**
|
|
60
|
+
* Won or lost, the CRM's own way: Pipedrive sets a status, Kommo and Bitrix24 move it to the pipeline's closing
|
|
61
|
+
* stage, HubSpot to a closed stage.
|
|
62
|
+
*/
|
|
63
|
+
close(kind: DealKind, externalId: string, outcome: "won" | "lost", options: CrmWriteOptions & {
|
|
64
|
+
reason?: string;
|
|
65
|
+
}): Promise<CrmItem>;
|
|
66
|
+
remove(kind: DealKind, externalId: string, options: CrmWriteOptions): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
/** Giving a record to a CRM user other than the one the sign-in acts as. */
|
|
69
|
+
export declare const CRM_ASSIGN_OTHERS_KEY: PermissionKey;
|
|
70
|
+
/** The keys the CRM writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
|
|
71
|
+
export declare const CRM_WRITE_KEYS: readonly PermissionKey[];
|
|
72
|
+
/**
|
|
73
|
+
* Built in, and only ever tightening (WC-PERM-7): an ordinary change goes ahead, a deletion asks, and a record is not
|
|
74
|
+
* given to somebody else until the owner allows it.
|
|
75
|
+
*/
|
|
76
|
+
export declare const CRM_WRITE_DEFAULTS: Readonly<Record<PermissionKey, Level>>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const CRM_VERBS = ["create", "update", "move", "close", "remove"];
|
|
2
|
+
/** Giving a record to a CRM user other than the one the sign-in acts as. */
|
|
3
|
+
export const CRM_ASSIGN_OTHERS_KEY = "crm.assign-others";
|
|
4
|
+
/** The keys the CRM writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
|
|
5
|
+
export const CRM_WRITE_KEYS = [CRM_ASSIGN_OTHERS_KEY];
|
|
6
|
+
/**
|
|
7
|
+
* Built in, and only ever tightening (WC-PERM-7): an ordinary change goes ahead, a deletion asks, and a record is not
|
|
8
|
+
* given to somebody else until the owner allows it.
|
|
9
|
+
*/
|
|
10
|
+
export const CRM_WRITE_DEFAULTS = {
|
|
11
|
+
"deals.delete": "ask",
|
|
12
|
+
"leads.delete": "ask",
|
|
13
|
+
[CRM_ASSIGN_OTHERS_KEY]: "deny",
|
|
14
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { DealKind } from "../domain/deals.js";
|
|
2
|
+
import type { AppIdentity } from "../ports/app.js";
|
|
3
|
+
import type { Warn } from "../runs/events.js";
|
|
4
|
+
import { type Asker, type GuardSettings } from "../sends/guard.js";
|
|
5
|
+
import type { Deal } from "../store/sqlite/deals.js";
|
|
6
|
+
import type { MessageStore } from "../store/store.js";
|
|
7
|
+
import type { AccountKey } from "../store/types.js";
|
|
8
|
+
import type { CrmSource } from "./source.js";
|
|
9
|
+
import { type CrmWriter } from "./writer.js";
|
|
10
|
+
/** What a CLI hands the CRM writes for one profile. */
|
|
11
|
+
export interface CrmWrites {
|
|
12
|
+
writer: CrmWriter;
|
|
13
|
+
app: AppIdentity;
|
|
14
|
+
/** The profile's levels, read on every write so a config change applies at once. */
|
|
15
|
+
settings: () => GuardSettings;
|
|
16
|
+
/** The CRM user the sign-in acts as. An owner other than it checks `crm.assign-others`; left out, every owner does. */
|
|
17
|
+
self?: string;
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
19
|
+
warn?: Warn;
|
|
20
|
+
}
|
|
21
|
+
export interface CrmWriteContext {
|
|
22
|
+
store: MessageStore;
|
|
23
|
+
account: AccountKey;
|
|
24
|
+
writes: CrmWrites;
|
|
25
|
+
/** A terminal's question, or an agent's go-ahead. */
|
|
26
|
+
ask: Asker;
|
|
27
|
+
/** Reads the kind's pipelines again when the CRM answers an item in a stage the store does not hold yet. */
|
|
28
|
+
source?: () => CrmSource;
|
|
29
|
+
/** How the caller spells the operation id in a hint: `--operation-id`, or `operation_id` over MCP. */
|
|
30
|
+
names?: {
|
|
31
|
+
id: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** The fields as typed: a pipeline and a stage by reference or name, money as a decimal. */
|
|
35
|
+
export interface CrmFieldsInput {
|
|
36
|
+
title?: string;
|
|
37
|
+
pipeline?: string;
|
|
38
|
+
stage?: string;
|
|
39
|
+
amount?: string;
|
|
40
|
+
currency?: string;
|
|
41
|
+
closeDate?: string;
|
|
42
|
+
owner?: string;
|
|
43
|
+
custom?: Record<string, string | null>;
|
|
44
|
+
}
|
|
45
|
+
export interface CrmWritten {
|
|
46
|
+
operationId: string;
|
|
47
|
+
item: Deal;
|
|
48
|
+
}
|
|
49
|
+
export declare const createCrmItem: (context: CrmWriteContext, kind: DealKind, input: CrmFieldsInput, options?: {
|
|
50
|
+
operationId?: string;
|
|
51
|
+
}) => Promise<CrmWritten>;
|
|
52
|
+
export declare const updateCrmItem: (context: CrmWriteContext, kind: DealKind, reference: string, input: CrmFieldsInput, options?: {
|
|
53
|
+
operationId?: string;
|
|
54
|
+
}) => Promise<CrmWritten>;
|
|
55
|
+
export declare const moveCrmItem: (context: CrmWriteContext, kind: DealKind, reference: string, stageName: string, options?: {
|
|
56
|
+
operationId?: string;
|
|
57
|
+
}) => Promise<CrmWritten>;
|
|
58
|
+
export declare const closeCrmItem: (context: CrmWriteContext, kind: DealKind, reference: string, outcome: "won" | "lost", options?: {
|
|
59
|
+
reason?: string;
|
|
60
|
+
operationId?: string;
|
|
61
|
+
}) => Promise<CrmWritten>;
|
|
62
|
+
export declare const removeCrmItem: (context: CrmWriteContext, kind: DealKind, reference: string, options?: {
|
|
63
|
+
operationId?: string;
|
|
64
|
+
}) => Promise<{
|
|
65
|
+
operationId: string;
|
|
66
|
+
ref: string;
|
|
67
|
+
removed: number;
|
|
68
|
+
}>;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { CliError, isCliError } from "@wirecat/cli-base";
|
|
2
|
+
import { guardFor } from "../sends/guard.js";
|
|
3
|
+
import { guardedWrite } from "../sends/guarded.js";
|
|
4
|
+
import { keyedGuard } from "../sends/keyed-guard.js";
|
|
5
|
+
import { DEFAULT_PERMISSIONS, levelFor } from "../sends/permissions.js";
|
|
6
|
+
import { newOperationId } from "../sends/send-id.js";
|
|
7
|
+
import { CRM_ASSIGN_OTHERS_KEY, CRM_WRITE_DEFAULTS, } from "./writer.js";
|
|
8
|
+
const PLURAL = { deal: "deals", lead: "leads" };
|
|
9
|
+
const COMMAND = {
|
|
10
|
+
create: "create",
|
|
11
|
+
update: "update",
|
|
12
|
+
move: "move",
|
|
13
|
+
close: "close",
|
|
14
|
+
remove: "delete",
|
|
15
|
+
};
|
|
16
|
+
const keyOf = (kind, verb) => `${PLURAL[kind]}.${COMMAND[verb]}`;
|
|
17
|
+
/** Bitrix24 REST 3.0's `Idempotency-Key` takes 1 to 255 printable ASCII characters; so does every id here. */
|
|
18
|
+
const OPERATION_ID = /^[\x21-\x7e]{1,255}$/u;
|
|
19
|
+
const DECIMAL = /^\d{1,16}(\.\d{1,6})?$/u;
|
|
20
|
+
const CURRENCY = /^[A-Z]{3}$/u;
|
|
21
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/u;
|
|
22
|
+
const FIELD_CODE = /^[A-Za-z][\w.-]{0,99}$/u;
|
|
23
|
+
const invalid = (message, param) => new CliError("validation_error", message, { reason: "deal.field", ...(param ? { param } : {}) });
|
|
24
|
+
const supported = (writer, kind, verb) => {
|
|
25
|
+
const { kinds, verbs } = writer.capabilities;
|
|
26
|
+
if (!kinds.includes(kind) || !verbs.includes(verb))
|
|
27
|
+
throw new CliError("validation_error", `this CRM does not take a ${COMMAND[verb]} of ${PLURAL[kind]}`, {
|
|
28
|
+
reason: "crm.write-unsupported",
|
|
29
|
+
sent: false,
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
const operationOf = (context, given) => {
|
|
33
|
+
if (given === undefined)
|
|
34
|
+
return newOperationId();
|
|
35
|
+
const name = context.names?.id ?? "--operation-id";
|
|
36
|
+
if (!context.writes.writer.capabilities.idempotent)
|
|
37
|
+
throw new CliError("validation_error", `this CRM does not recognise a repeated write, so ${name} would do it a second time; sync and look before ` +
|
|
38
|
+
"writing again", { reason: "crm.write-unsupported", param: name, sent: false });
|
|
39
|
+
if (!OPERATION_ID.test(given))
|
|
40
|
+
throw new CliError("validation_error", `${name} takes 1 to 255 printable characters with no spaces`, {
|
|
41
|
+
param: name,
|
|
42
|
+
});
|
|
43
|
+
return given;
|
|
44
|
+
};
|
|
45
|
+
/** A copy of this profile's account: another account's, or the owner's own, is not written here. */
|
|
46
|
+
const copyOf = async (context, kind, reference) => {
|
|
47
|
+
if (!reference.startsWith(`${kind}:`))
|
|
48
|
+
throw new CliError("validation_error", `name the ${kind} as ${kind}:<id>, as a list shows it`, {
|
|
49
|
+
reason: "ref.malformed",
|
|
50
|
+
});
|
|
51
|
+
const found = await context.store.deals.get(reference);
|
|
52
|
+
const { provider, account } = context.account;
|
|
53
|
+
if (found.account?.provider !== provider || found.account.account !== account || found.externalId === null)
|
|
54
|
+
throw new CliError("not_found", `${reference} is not a copy from this profile's CRM account`);
|
|
55
|
+
return found;
|
|
56
|
+
};
|
|
57
|
+
const pipelineOf = async (context, kind, reference) => {
|
|
58
|
+
if (reference !== undefined) {
|
|
59
|
+
const found = await context.store.deals.pipeline(reference, { kind, account: context.account });
|
|
60
|
+
if (found.externalId === null)
|
|
61
|
+
throw new CliError("not_found", `${reference} is not a pipeline of this CRM account`);
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
64
|
+
const all = await context.store.deals.pipelines({ kind, account: context.account });
|
|
65
|
+
if (all.length === 1)
|
|
66
|
+
return all[0];
|
|
67
|
+
throw new CliError("validation_error", all.length === 0
|
|
68
|
+
? `no ${kind} pipeline of this CRM is stored; run sync first`
|
|
69
|
+
: `this CRM has ${all.length} ${kind} pipelines; name one with --pipeline: ${all.map(({ name }) => name).join(", ")}`, { reason: "crm.pipeline-required", param: "--pipeline" });
|
|
70
|
+
};
|
|
71
|
+
/** A stage of `pipeline`, by the CRM's id or by a name only one of its stages has, in any letter case. */
|
|
72
|
+
const stageOf = (pipeline, given) => {
|
|
73
|
+
const byId = pipeline.stages.find((stage) => stage.externalId === given);
|
|
74
|
+
if (byId)
|
|
75
|
+
return byId;
|
|
76
|
+
const folded = given.trim().toLocaleLowerCase();
|
|
77
|
+
const named = pipeline.stages.filter((stage) => stage.name.toLocaleLowerCase() === folded);
|
|
78
|
+
if (named.length === 1)
|
|
79
|
+
return named[0];
|
|
80
|
+
throw new CliError("validation_error", `${pipeline.name} has ${named.length ? "more than one" : "no"} stage ${given}: ` +
|
|
81
|
+
pipeline.stages.map(({ name }) => name).join(", "), { reason: "deal.stage", param: "--stage" });
|
|
82
|
+
};
|
|
83
|
+
const validDay = (text) => {
|
|
84
|
+
const date = new Date(`${text}T00:00:00Z`);
|
|
85
|
+
return DAY.test(text) && !Number.isNaN(date.getTime()) && date.toISOString().startsWith(text);
|
|
86
|
+
};
|
|
87
|
+
/** Typed fields into the port's, resolving the stage in `pipeline`; `currency` is the copy's when a patch keeps it. */
|
|
88
|
+
const fieldsOf = (input, pipeline, currency) => {
|
|
89
|
+
const fields = {};
|
|
90
|
+
if (input.title !== undefined) {
|
|
91
|
+
if (!input.title.trim())
|
|
92
|
+
throw invalid("a title is never empty", "--title");
|
|
93
|
+
fields.title = input.title.trim();
|
|
94
|
+
}
|
|
95
|
+
if (pipeline && input.pipeline !== undefined)
|
|
96
|
+
fields.pipeline = pipeline.externalId;
|
|
97
|
+
const stage = pipeline && input.stage !== undefined ? stageOf(pipeline, input.stage) : undefined;
|
|
98
|
+
if (stage)
|
|
99
|
+
fields.stage = stage.externalId;
|
|
100
|
+
if (input.amount !== undefined || input.currency !== undefined) {
|
|
101
|
+
const code = (input.currency ?? currency ?? "").toUpperCase();
|
|
102
|
+
if (input.amount === undefined)
|
|
103
|
+
throw invalid("--currency goes with --amount", "--amount");
|
|
104
|
+
if (!DECIMAL.test(input.amount))
|
|
105
|
+
throw invalid("--amount is a decimal such as 1500.50", "--amount");
|
|
106
|
+
if (!CURRENCY.test(code))
|
|
107
|
+
throw invalid("--amount needs --currency, an ISO 4217 code such as EUR", "--currency");
|
|
108
|
+
fields.amount = input.amount;
|
|
109
|
+
fields.currency = code;
|
|
110
|
+
}
|
|
111
|
+
if (input.closeDate !== undefined) {
|
|
112
|
+
if (!validDay(input.closeDate))
|
|
113
|
+
throw invalid("--close-date is a day, YYYY-MM-DD", "--close-date");
|
|
114
|
+
fields.closeDate = input.closeDate;
|
|
115
|
+
}
|
|
116
|
+
if (input.owner !== undefined) {
|
|
117
|
+
if (!input.owner.trim())
|
|
118
|
+
throw invalid("--owner is the CRM user's id", "--owner");
|
|
119
|
+
fields.owner = input.owner.trim();
|
|
120
|
+
}
|
|
121
|
+
if (input.custom !== undefined && Object.keys(input.custom).length) {
|
|
122
|
+
for (const code of Object.keys(input.custom))
|
|
123
|
+
if (!FIELD_CODE.test(code))
|
|
124
|
+
throw invalid(`${code} is not a field code`, "--field");
|
|
125
|
+
fields.custom = input.custom;
|
|
126
|
+
}
|
|
127
|
+
return { fields, stage };
|
|
128
|
+
};
|
|
129
|
+
/** Every key the write checks: its own, and each action its fields also take (WC-PERM-22). */
|
|
130
|
+
const keysOf = (context, kind, verb, { stage, owner }) => {
|
|
131
|
+
const extra = [];
|
|
132
|
+
if (stage && verb !== "move")
|
|
133
|
+
extra.push(keyOf(kind, "move"));
|
|
134
|
+
if (stage && stage.outcome !== "open")
|
|
135
|
+
extra.push(keyOf(kind, "close"));
|
|
136
|
+
if (owner != null && owner !== context.writes.self)
|
|
137
|
+
extra.push(CRM_ASSIGN_OTHERS_KEY);
|
|
138
|
+
return { key: keyOf(kind, verb), extra };
|
|
139
|
+
};
|
|
140
|
+
/** The profile's guard over the write's own key and the keys its fields imply (`keyedGuard`). */
|
|
141
|
+
const guardOf = (context, extra) => {
|
|
142
|
+
const settings = context.writes.settings();
|
|
143
|
+
const defaults = { ...DEFAULT_PERMISSIONS, ...CRM_WRITE_DEFAULTS, ...settings.permissionDefaults };
|
|
144
|
+
return keyedGuard((ask) => guardFor(context.writes.app, { ...settings, permissionDefaults: defaults }, context.writes.warn ?? (() => { }), context.writes.env, ask), context.ask, (key) => levelFor(settings.permissions, key, defaults).level, extra);
|
|
145
|
+
};
|
|
146
|
+
/** Sends the write once, never again: a lost answer is `outcome_unknown`, with what the owner may do about it. */
|
|
147
|
+
const written = async (context, { key, extra }, operationId, act) => {
|
|
148
|
+
try {
|
|
149
|
+
return await guardedWrite(guardOf(context, extra), { chatId: null, kind: "edit", key, operationId }, act);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (!isCliError(error) || error.code !== "outcome_unknown")
|
|
153
|
+
throw error;
|
|
154
|
+
const name = context.names?.id ?? "--operation-id";
|
|
155
|
+
throw new CliError("outcome_unknown", context.writes.writer.capabilities.idempotent
|
|
156
|
+
? `${error.message} — repeat it with ${name} ${operationId}: the CRM answers a repeat from its record ` +
|
|
157
|
+
"instead of doing it again"
|
|
158
|
+
: `${error.message} — sync and look at the CRM before writing again: this CRM would do it a second time`, { ...error.details, operation_id: operationId });
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* The CRM's answer into the store, as a sync would save it. A failure here names the item: the CRM has made the
|
|
163
|
+
* change, and a repeat would make it again.
|
|
164
|
+
*/
|
|
165
|
+
const kept = async (context, kind, item, operationId) => {
|
|
166
|
+
const fail = (why, details = {}) => new CliError("invalid_response", `the CRM wrote ${kind} ${item.externalId}, but the local store did not keep it (${why}); sync to store it — ` +
|
|
167
|
+
"do not write it again", { ...details, operation_id: operationId, external_id: item.externalId, sent: true });
|
|
168
|
+
try {
|
|
169
|
+
let saved = await context.store.crm.saveItems(context.account, kind, [item]);
|
|
170
|
+
if (saved.skipped[0]?.reason === "crm.stage-unknown" && context.source) {
|
|
171
|
+
await context.store.crm.savePipelines(context.account, kind, await context.source().pipelines(kind));
|
|
172
|
+
saved = await context.store.crm.saveItems(context.account, kind, [item]);
|
|
173
|
+
}
|
|
174
|
+
const [skipped] = saved.skipped;
|
|
175
|
+
if (skipped)
|
|
176
|
+
throw fail(skipped.message, { reason: skipped.reason });
|
|
177
|
+
const reference = await context.store.crm.refOf(context.account, kind, item.externalId);
|
|
178
|
+
if (reference === null)
|
|
179
|
+
throw fail("it is not found after the save");
|
|
180
|
+
return await context.store.deals.get(reference);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (isCliError(error) && error.code === "invalid_response" && error.details.sent === true)
|
|
184
|
+
throw error;
|
|
185
|
+
throw fail(error instanceof Error ? error.message : String(error), isCliError(error) ? error.details : {});
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
export const createCrmItem = async (context, kind, input, options = {}) => {
|
|
189
|
+
const { writer } = context.writes;
|
|
190
|
+
supported(writer, kind, "create");
|
|
191
|
+
if (input.title === undefined)
|
|
192
|
+
throw invalid(`a new ${kind} needs --title`, "--title");
|
|
193
|
+
const pipeline = await pipelineOf(context, kind, input.pipeline);
|
|
194
|
+
const { fields, stage } = fieldsOf({ ...input, pipeline: pipeline.ref }, pipeline, null);
|
|
195
|
+
const operationId = operationOf(context, options.operationId);
|
|
196
|
+
const item = await written(context, keysOf(context, kind, "create", { stage, owner: fields.owner }), operationId, () => writer.create(kind, fields, { operationId }));
|
|
197
|
+
return { operationId, item: await kept(context, kind, item, operationId) };
|
|
198
|
+
};
|
|
199
|
+
export const updateCrmItem = async (context, kind, reference, input, options = {}) => {
|
|
200
|
+
const { writer } = context.writes;
|
|
201
|
+
supported(writer, kind, "update");
|
|
202
|
+
const copy = await copyOf(context, kind, reference);
|
|
203
|
+
if (input.pipeline !== undefined && input.stage === undefined)
|
|
204
|
+
throw invalid("a new --pipeline needs the --stage it goes to there", "--stage");
|
|
205
|
+
const pipeline = input.stage === undefined
|
|
206
|
+
? undefined
|
|
207
|
+
: await pipelineOf(context, kind, input.pipeline === undefined ? copy.pipeline : input.pipeline);
|
|
208
|
+
const { fields, stage } = fieldsOf(input, pipeline, copy.currency);
|
|
209
|
+
if (Object.keys(fields).length === 0)
|
|
210
|
+
throw invalid("nothing to change: name a field");
|
|
211
|
+
const operationId = operationOf(context, options.operationId);
|
|
212
|
+
const item = await written(context, keysOf(context, kind, "update", { stage, owner: fields.owner }), operationId, () => writer.update(kind, copy.externalId, fields, { operationId }));
|
|
213
|
+
return { operationId, item: await kept(context, kind, item, operationId) };
|
|
214
|
+
};
|
|
215
|
+
export const moveCrmItem = async (context, kind, reference, stageName, options = {}) => {
|
|
216
|
+
const { writer } = context.writes;
|
|
217
|
+
supported(writer, kind, "move");
|
|
218
|
+
const copy = await copyOf(context, kind, reference);
|
|
219
|
+
const stage = stageOf(await pipelineOf(context, kind, copy.pipeline), stageName);
|
|
220
|
+
const operationId = operationOf(context, options.operationId);
|
|
221
|
+
const item = await written(context, keysOf(context, kind, "move", { stage }), operationId, () => writer.move(kind, copy.externalId, stage.externalId, { operationId }));
|
|
222
|
+
return { operationId, item: await kept(context, kind, item, operationId) };
|
|
223
|
+
};
|
|
224
|
+
export const closeCrmItem = async (context, kind, reference, outcome, options = {}) => {
|
|
225
|
+
const { writer } = context.writes;
|
|
226
|
+
supported(writer, kind, "close");
|
|
227
|
+
if (outcome !== "won" && outcome !== "lost")
|
|
228
|
+
throw invalid("a close is won or lost");
|
|
229
|
+
const copy = await copyOf(context, kind, reference);
|
|
230
|
+
const reason = options.reason?.trim();
|
|
231
|
+
if (reason !== undefined && (reason === "" || reason.length > 2000))
|
|
232
|
+
throw invalid("--reason is 1 to 2000 characters", "--reason");
|
|
233
|
+
const operationId = operationOf(context, options.operationId);
|
|
234
|
+
const item = await written(context, keysOf(context, kind, "close", {}), operationId, () => writer.close(kind, copy.externalId, outcome, { operationId, ...(reason ? { reason } : {}) }));
|
|
235
|
+
return { operationId, item: await kept(context, kind, item, operationId) };
|
|
236
|
+
};
|
|
237
|
+
export const removeCrmItem = async (context, kind, reference, options = {}) => {
|
|
238
|
+
const { writer } = context.writes;
|
|
239
|
+
supported(writer, kind, "remove");
|
|
240
|
+
const copy = await copyOf(context, kind, reference);
|
|
241
|
+
const operationId = operationOf(context, options.operationId);
|
|
242
|
+
await written(context, keysOf(context, kind, "remove", {}), operationId, () => writer.remove(kind, copy.externalId, { operationId }));
|
|
243
|
+
const removed = await context.store.crm.remove(context.account, kind, [copy.externalId]);
|
|
244
|
+
return { operationId, ref: copy.ref, removed };
|
|
245
|
+
};
|
package/dist/domain/crm.d.ts
CHANGED
|
@@ -12,10 +12,7 @@ export interface CrmStage {
|
|
|
12
12
|
outcome: StageOutcome;
|
|
13
13
|
}
|
|
14
14
|
export interface CrmPipeline {
|
|
15
|
-
/**
|
|
16
|
-
* Unique among the account's pipelines of its kind. Until the store's key is widened, one with the id of a stored
|
|
17
|
-
* pipeline of the other kind is skipped and named.
|
|
18
|
-
*/
|
|
15
|
+
/** Unique among the account's pipelines of its kind; a deal pipeline and a lead pipeline may share one. */
|
|
19
16
|
externalId: string;
|
|
20
17
|
name: string;
|
|
21
18
|
stages: CrmStage[];
|
|
@@ -39,6 +36,8 @@ export interface CrmItem {
|
|
|
39
36
|
closeDate?: string | null;
|
|
40
37
|
/** `YYYY-MM-DD`: when it was won or lost. */
|
|
41
38
|
closedDate?: string | null;
|
|
39
|
+
/** Why it was won or lost, as the CRM keeps it. */
|
|
40
|
+
closedReason?: string | null;
|
|
42
41
|
/** A company's id. */
|
|
43
42
|
organization?: string | null;
|
|
44
43
|
/** Contacts on it, by their id, and the part each plays. */
|
package/dist/errors/reasons.js
CHANGED
|
@@ -213,8 +213,9 @@ const OTHERS = [
|
|
|
213
213
|
validation("pipeline.field", "a pipeline needs a name and at least one stage, each named once"),
|
|
214
214
|
["pipeline.not-found", "not_found", "no stored pipeline has that name or reference"],
|
|
215
215
|
validation("crm.stage-unknown", "a CRM item stands in a stage the store does not hold; it was not kept"),
|
|
216
|
-
validation("crm.pipeline-clash", "a CRM pipeline has the id of a stored pipeline of the other kind; it was not kept"),
|
|
217
216
|
validation("crm.item-invalid", "a CRM item has a field the store refuses, such as a currency it does not keep"),
|
|
217
|
+
validation("crm.pipeline-required", "the CRM has more than one pipeline of the kind; the write names one"),
|
|
218
|
+
validation("crm.write-unsupported", "the CRM does not take this write, or does not recognise a repeated one"),
|
|
218
219
|
validation("crm.unlisted-held", "a list of every id would mark most of a kind deleted; nothing was, until --rescan"),
|
|
219
220
|
validation("order.duplicate", "the shop's order with this number is already stored; change its status instead"),
|
|
220
221
|
validation("order.proposed", "the shop's order with this number already waits for the owner in a findings batch"),
|
package/dist/mail/cli/review.js
CHANGED
|
@@ -87,10 +87,11 @@ const waitingHours = (value) => value === true ? AGENDA_DEFAULTS.waiting / 3_600
|
|
|
87
87
|
const quoted = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
88
88
|
/** A file name for the mailbox's points: they describe the mailbox, whichever profile reads it. */
|
|
89
89
|
const pointsName = (mailbox) => encodeURIComponent(mailbox.account);
|
|
90
|
-
const markText = (marks, excerptOnly = false) => {
|
|
90
|
+
const markText = (marks, excerptOnly = false, partial = false) => {
|
|
91
91
|
const said = [
|
|
92
92
|
marks?.bulk || excerptOnly ? "bulk" : "",
|
|
93
93
|
excerptOnly ? "excerpt only" : "",
|
|
94
|
+
partial ? "partial: not fully downloaded" : "",
|
|
94
95
|
marks?.listId ? `list ${marks.listId}` : "",
|
|
95
96
|
marks?.ruleId ? `rule ${marks.ruleId}` : "",
|
|
96
97
|
];
|
|
@@ -144,6 +145,7 @@ const reviewAnswer = (found, mailbox, fresh, step) => {
|
|
|
144
145
|
...messageItem(message, mailbox).email,
|
|
145
146
|
bulk: (message.marks?.bulk ?? false) || message.excerptOnly === true,
|
|
146
147
|
...(message.excerptOnly ? { excerpt_only: true } : {}),
|
|
148
|
+
...(message.partialExcerpt ? { partial: true } : {}),
|
|
147
149
|
list_id: message.marks?.listId ?? null,
|
|
148
150
|
rule_id: message.marks?.ruleId ?? null,
|
|
149
151
|
...(message.marks
|
|
@@ -178,9 +180,14 @@ const holderReview = async (command, mail, holder, query) => {
|
|
|
178
180
|
// An agent reads this: earlier mail and signatures stay out of its text; the fingerprint is over the stored body.
|
|
179
181
|
const emails = [
|
|
180
182
|
...read.emails.map((email) => ({ ...email, text: mailOwnText(email.text) })),
|
|
181
|
-
...read.excerpts.map(({ email, excerpt }) => {
|
|
183
|
+
...read.excerpts.map(({ email, excerpt, partial }) => {
|
|
182
184
|
const subject = email.email?.subject;
|
|
183
|
-
return {
|
|
185
|
+
return {
|
|
186
|
+
...email,
|
|
187
|
+
text: subject ? `${subject}\n\n${excerpt}` : excerpt,
|
|
188
|
+
excerptOnly: true,
|
|
189
|
+
...(partial ? { partialExcerpt: true } : {}),
|
|
190
|
+
};
|
|
184
191
|
}),
|
|
185
192
|
];
|
|
186
193
|
const chats = new Map();
|
|
@@ -237,7 +244,7 @@ const holderReview = async (command, mail, holder, query) => {
|
|
|
237
244
|
? `${holder}: past ${read.mark.throughId}${emails.length ? `; findings key ${read.findingsKey}` : ""}; acknowledge with --cursor ${read.cursor}${read.more ? "; more after these" : ""}`
|
|
238
245
|
: `${holder} has no mark yet: it starts from now — acknowledge with --cursor ${read.cursor}`);
|
|
239
246
|
return emails
|
|
240
|
-
.map((hit) => `${hitText(hit, undefined, local)}${markText(marks.get(hit.id), hit.excerptOnly)}\n`)
|
|
247
|
+
.map((hit) => `${hitText(hit, undefined, local)}${markText(marks.get(hit.id), hit.excerptOnly, hit.partialExcerpt === true)}\n`)
|
|
241
248
|
.join("");
|
|
242
249
|
});
|
|
243
250
|
};
|
package/dist/mail/marks.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface BulkExcerpt {
|
|
|
45
45
|
excerpt: string;
|
|
46
46
|
/** What kept it, or the newsletter signs too few to leave it out. */
|
|
47
47
|
cues: string[];
|
|
48
|
+
/** Stored from its first bytes, not whole: the excerpt may be all of it that there is. */
|
|
49
|
+
partial: boolean;
|
|
48
50
|
}
|
|
49
51
|
export interface MarkRead {
|
|
50
52
|
/** The emails for the holder, oldest first; bulk, envelopes, drafts, suspicious senders and partial mail left out. */
|