@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
|
@@ -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. */
|
package/dist/mail/marks.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { mailOwnText } from "../conversations/mail-text.js";
|
|
3
3
|
import { isBusy } from "../store/sqlite/transaction.js";
|
|
4
|
-
import { BULK_TRIAGE_DEFAULTS, newsletterSorter } from "./newsletter.js";
|
|
4
|
+
import { BULK_TRIAGE_DEFAULTS, newsletterSorter, transactionalCue, } from "./newsletter.js";
|
|
5
5
|
/** A holder's name: what `--holder` takes and the mark's key ends with. */
|
|
6
6
|
export const HOLDER = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
7
7
|
/** At most this many emails are read past the mark in one go; the rest come with the next read. */
|
|
@@ -65,13 +65,15 @@ export const marksOf = async (store, mailbox) => {
|
|
|
65
65
|
.flatMap(({ key, value, at }) => parsed(key.slice(PREFIX.length), value, at) ?? [])
|
|
66
66
|
.sort((a, b) => a.holder.localeCompare(b.holder));
|
|
67
67
|
};
|
|
68
|
+
/** Sent to many and not an order, an invoice or a shipment: those carry List-Unsubscribe too, and are for the holder. */
|
|
69
|
+
const isBulk = (email) => email.bulk && (email.listed || transactionalCue({ subject: email.subject, from: email.from }) === null);
|
|
68
70
|
/** Why an email is not for the holder, or `held` while it is partial and young, or `read`. */
|
|
69
71
|
const verdict = (email, now) => {
|
|
70
72
|
if (email.draft)
|
|
71
73
|
return "draft";
|
|
72
74
|
if (email.envelope)
|
|
73
75
|
return "envelope";
|
|
74
|
-
if (email
|
|
76
|
+
if (isBulk(email))
|
|
75
77
|
return "bulk";
|
|
76
78
|
if (email.demoted)
|
|
77
79
|
return "suspicious";
|
|
@@ -102,14 +104,14 @@ export const readPastMark = async (store, mailbox, holder, options = {}) => {
|
|
|
102
104
|
const { wanted, sorted, oldestStoredAt: _, ...read } = await examine(store, mailbox, holder, options);
|
|
103
105
|
const excerpts = wanted.flatMap((id) => {
|
|
104
106
|
const one = sorted.get(id);
|
|
105
|
-
return one ? [{ email: one.email, excerpt: one.excerpt, cues: one.cues }] : [];
|
|
107
|
+
return one ? [{ email: one.email, excerpt: one.excerpt, cues: one.cues, partial: one.partial }] : [];
|
|
106
108
|
});
|
|
107
109
|
const emails = await store.mail.emailHits(wanted.filter((id) => !sorted.has(id)));
|
|
108
110
|
return { emails, excerpts, ...read };
|
|
109
111
|
};
|
|
110
112
|
/**
|
|
111
113
|
* The batch's bulk emails a holder might read, sorted by `newsletterSorter`, and where the batch ends so it holds at
|
|
112
|
-
* most `perRead` excerpts. Drafts, envelopes
|
|
114
|
+
* most `perRead` excerpts. Drafts, envelopes and suspicious senders keep their own verdicts; partial bulk mail is sorted too, and flagged.
|
|
113
115
|
*/
|
|
114
116
|
const sortBulk = async (store, batch, triage) => {
|
|
115
117
|
const sorted = new Map();
|
|
@@ -118,7 +120,7 @@ const sortBulk = async (store, batch, triage) => {
|
|
|
118
120
|
const sort = newsletterSorter(triage.cues);
|
|
119
121
|
let excerpts = 0;
|
|
120
122
|
for (const [index, marked] of batch.entries()) {
|
|
121
|
-
if (!marked
|
|
123
|
+
if (!isBulk(marked) || marked.draft || marked.envelope || marked.demoted)
|
|
122
124
|
continue;
|
|
123
125
|
const [email] = await store.mail.emailHits([marked.id]);
|
|
124
126
|
if (!email)
|
|
@@ -130,7 +132,7 @@ const sortBulk = async (store, batch, triage) => {
|
|
|
130
132
|
return { sorted, end: index };
|
|
131
133
|
if (one.verdict === "excerpt")
|
|
132
134
|
excerpts += 1;
|
|
133
|
-
sorted.set(marked.id, { ...one, email });
|
|
135
|
+
sorted.set(marked.id, { ...one, email, partial: marked.partial });
|
|
134
136
|
}
|
|
135
137
|
return { sorted, end: batch.length };
|
|
136
138
|
};
|
|
@@ -27,6 +27,16 @@ export interface BulkSorted {
|
|
|
27
27
|
cues: string[];
|
|
28
28
|
excerpt: string;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* What makes a bulk email transactional (an order, an invoice, a receipt, a shipment), or `null`. List-Unsubscribe
|
|
32
|
+
* sits on those too, so it proves nothing alone. Subject and sender only, so the store can ask it of every email.
|
|
33
|
+
* An order's number settles it; an amount with its currency or a word of the lists below does not, when the subject
|
|
34
|
+
* also advertises or the sender is a newsletter's ("Order now, save 20%").
|
|
35
|
+
*/
|
|
36
|
+
export declare const transactionalCue: ({ subject, from }: {
|
|
37
|
+
subject: string | null;
|
|
38
|
+
from: string | null;
|
|
39
|
+
}) => string | null;
|
|
30
40
|
/** The first lines of an email's own text, at most `chars`, cut at a word. */
|
|
31
41
|
export declare const excerptOf: (text: string, chars: number) => string;
|
|
32
42
|
/**
|
package/dist/mail/newsletter.js
CHANGED
|
@@ -130,6 +130,76 @@ const matcherOf = (words) => {
|
|
|
130
130
|
const pattern = new RegExp(`(?<![\\p{L}\\p{N}])(?:${list.map(escaped).join("|")})`, "giu");
|
|
131
131
|
return (text) => [...new Set([...text.toLowerCase().matchAll(pattern)].map(([found]) => found))];
|
|
132
132
|
};
|
|
133
|
+
/** Words that, in a subject, say "a record of something of yours": a purchase, a payment, a shipment. */
|
|
134
|
+
const TRANSACTIONAL_WORDS = [
|
|
135
|
+
"заказ",
|
|
136
|
+
"счёт",
|
|
137
|
+
"счет",
|
|
138
|
+
"чек",
|
|
139
|
+
"квитанци",
|
|
140
|
+
"оплат",
|
|
141
|
+
"доставк",
|
|
142
|
+
"посылк",
|
|
143
|
+
"отправлен",
|
|
144
|
+
"order",
|
|
145
|
+
"invoice",
|
|
146
|
+
"receipt",
|
|
147
|
+
"payment",
|
|
148
|
+
"shipped",
|
|
149
|
+
"shipping",
|
|
150
|
+
"delivered",
|
|
151
|
+
"delivery",
|
|
152
|
+
"tracking",
|
|
153
|
+
"refund",
|
|
154
|
+
"pedido",
|
|
155
|
+
"factura",
|
|
156
|
+
"recibo",
|
|
157
|
+
"pago",
|
|
158
|
+
"envío",
|
|
159
|
+
"reembolso",
|
|
160
|
+
];
|
|
161
|
+
/** An amount with its currency, either side: `1 250 ₽`, `49,90 EUR`, `$12.50`. */
|
|
162
|
+
const AMOUNT = /\d[\d\s.,]*\s?(?:₽|€|\$|£|руб|р\.|rub|eur|usd|gbp)(?![\p{L}])|(?:₽|€|\$|£|rub|eur|usd|gbp)\s?\d/iu;
|
|
163
|
+
/** An order number as shops print it, such as Amazon's `406-3547475-4115561`. */
|
|
164
|
+
const ORDER_ID = /(?<!\d)\d{3}-\d{7}-\d{7}(?!\d)/;
|
|
165
|
+
/** Parts of a sender's address that say "orders, billing or shipping". */
|
|
166
|
+
const TRANSACTIONAL_SENDERS = new Set([
|
|
167
|
+
"order",
|
|
168
|
+
"orders",
|
|
169
|
+
"billing",
|
|
170
|
+
"invoice",
|
|
171
|
+
"invoices",
|
|
172
|
+
"receipt",
|
|
173
|
+
"receipts",
|
|
174
|
+
"payment",
|
|
175
|
+
"payments",
|
|
176
|
+
"shipping",
|
|
177
|
+
"tracking",
|
|
178
|
+
]);
|
|
179
|
+
const transactionalWords = matcherOf(TRANSACTIONAL_WORDS);
|
|
180
|
+
const advertisingWords = matcherOf(NEWSLETTER_WORDS);
|
|
181
|
+
const NEWSLETTER_SENDER_PARTS = new Set(NEWSLETTER_SENDERS.filter((one) => !one.includes(".")));
|
|
182
|
+
/**
|
|
183
|
+
* What makes a bulk email transactional (an order, an invoice, a receipt, a shipment), or `null`. List-Unsubscribe
|
|
184
|
+
* sits on those too, so it proves nothing alone. Subject and sender only, so the store can ask it of every email.
|
|
185
|
+
* An order's number settles it; an amount with its currency or a word of the lists below does not, when the subject
|
|
186
|
+
* also advertises or the sender is a newsletter's ("Order now, save 20%").
|
|
187
|
+
*/
|
|
188
|
+
export const transactionalCue = ({ subject, from }) => {
|
|
189
|
+
const title = subject ?? "";
|
|
190
|
+
const number = REFERENCE_NUMBER.exec(title) ?? ORDER_ID.exec(title);
|
|
191
|
+
if (number)
|
|
192
|
+
return number[0];
|
|
193
|
+
const parts = (from ?? "").toLowerCase().split(/[.@_+-]/);
|
|
194
|
+
if (advertisingWords(title).length > 0 || parts.some((part) => NEWSLETTER_SENDER_PARTS.has(part)))
|
|
195
|
+
return null;
|
|
196
|
+
const [word] = transactionalWords(title);
|
|
197
|
+
if (word)
|
|
198
|
+
return word;
|
|
199
|
+
if (AMOUNT.test(title))
|
|
200
|
+
return "an amount";
|
|
201
|
+
return parts.find((part) => TRANSACTIONAL_SENDERS.has(part)) ?? null;
|
|
202
|
+
};
|
|
133
203
|
/** The first lines of an email's own text, at most `chars`, cut at a word. */
|
|
134
204
|
export const excerptOf = (text, chars) => {
|
|
135
205
|
const flat = text
|
package/dist/mail/review.d.ts
CHANGED