@wirecat/cli-core 0.590.0 → 0.591.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/dist/background/schedules.d.ts +15 -1
- package/dist/background/schedules.js +16 -5
- package/dist/cli/messenger/flooded.d.ts +1 -1
- package/dist/cli/messenger/flooded.js +3 -26
- package/dist/cli/messenger/search-command.d.ts +3 -2
- package/dist/cli/messenger/search-command.js +7 -5
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/schedule/schedule-command.js +23 -12
- package/dist/crm/cli/index.d.ts +57 -0
- package/dist/crm/cli/index.js +300 -0
- package/dist/crm/cli/search-all.d.ts +12 -0
- package/dist/crm/cli/search-all.js +63 -0
- package/dist/crm/index.d.ts +2 -0
- package/dist/crm/index.js +1 -0
- package/dist/crm/source.d.ts +43 -0
- package/dist/crm/source.js +1 -0
- package/dist/crm/sync.d.ts +121 -0
- package/dist/crm/sync.js +291 -0
- package/dist/crm/testing/index.d.ts +3 -0
- package/dist/crm/testing/index.js +3 -0
- package/dist/crm/testing/source-contract.d.ts +17 -0
- package/dist/crm/testing/source-contract.js +137 -0
- package/dist/crm/testing/source.d.ts +33 -0
- package/dist/crm/testing/source.js +126 -0
- package/dist/crm/testing/sync-contract.d.ts +13 -0
- package/dist/crm/testing/sync-contract.js +178 -0
- package/dist/domain/crm.d.ts +82 -0
- package/dist/domain/crm.js +3 -0
- package/dist/entities/deal.d.ts +159 -0
- package/dist/entities/deal.js +86 -0
- package/dist/entities/index.d.ts +143 -0
- package/dist/entities/index.js +5 -0
- package/dist/entities/list-results.d.ts +4 -0
- package/dist/entities/list-results.js +8 -1
- package/dist/errors/reasons.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sends/index.d.ts +1 -1
- package/dist/sends/index.js +1 -1
- package/dist/sends/pace.d.ts +18 -0
- package/dist/sends/pace.js +40 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/store/index.d.ts +2 -1
- package/dist/store/index.js +1 -0
- package/dist/store/sqlite/crm.d.ts +48 -0
- package/dist/store/sqlite/crm.js +318 -0
- package/dist/store/sqlite/deals.d.ts +10 -0
- package/dist/store/sqlite/deals.js +33 -15
- package/dist/store/store.d.ts +3 -0
- package/dist/store/store.js +5 -0
- package/package.json +13 -1
- package/parity.json +16 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { DEAL_ROLES, STAGE_OUTCOMES } from "../../domain/deals.js";
|
|
3
|
+
import { currencyOf, minorUnits } from "../../domain/money.js";
|
|
4
|
+
import { recordFingerprint } from "../../domain/records.js";
|
|
5
|
+
import { fold } from "../normalize.js";
|
|
6
|
+
import { accountPk, findAccountPk } from "./accounts.js";
|
|
7
|
+
import { linkKind } from "./link-kinds.js";
|
|
8
|
+
import { authorActor } from "./memories.js";
|
|
9
|
+
import { write } from "./transaction.js";
|
|
10
|
+
const TABLES = { deal: "deals", lead: "leads" };
|
|
11
|
+
/** Who writes a CRM's copies: the sync, not an agent, so `author.kind` is `rule`. */
|
|
12
|
+
const SOURCE = "crm";
|
|
13
|
+
/**
|
|
14
|
+
* A contact's identity id: identities are unique per provider, and two portals of one CRM both have a contact 5.
|
|
15
|
+
* `contact:` references percent-encode it, so the `/` stays one part.
|
|
16
|
+
*/
|
|
17
|
+
export const crmIdentityId = (key, externalId, type = "contact") => `${key.account}/${type}/${externalId}`;
|
|
18
|
+
const day = (value, what) => {
|
|
19
|
+
if (value === undefined || value === null || value === "")
|
|
20
|
+
return null;
|
|
21
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(value)))
|
|
22
|
+
throw new CliError("validation_error", `${what} is YYYY-MM-DD, not ${JSON.stringify(value)}`);
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
const epoch = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
|
|
26
|
+
const textOf = (value, most) => {
|
|
27
|
+
if (typeof value !== "string")
|
|
28
|
+
return null;
|
|
29
|
+
const kept = value.trim().slice(0, most);
|
|
30
|
+
return kept || null;
|
|
31
|
+
};
|
|
32
|
+
export const crmCopiesOver = (context, people) => {
|
|
33
|
+
const { database, now } = context;
|
|
34
|
+
const author = (key) => authorActor(context, { bot: key.provider, kind: "rule" }).actor;
|
|
35
|
+
const account = (key) => accountPk(context, key);
|
|
36
|
+
const pipelineOf = (accountId, kind, externalId) => database
|
|
37
|
+
.prepare("SELECT * FROM pipelines WHERE account_id = ? AND kind = ? AND external_id = ?")
|
|
38
|
+
.get(accountId, kind, externalId);
|
|
39
|
+
// Store version 52 keeps a pipeline's id unique per account, not per kind: a lead pipeline sharing a deal pipeline's
|
|
40
|
+
// id cannot be stored until a migration widens that key, so it is skipped and named, not written over the other.
|
|
41
|
+
const otherKindHolds = (accountId, kind, externalId) => database
|
|
42
|
+
.prepare("SELECT 1 FROM pipelines WHERE account_id = ? AND kind <> ? AND external_id = ?")
|
|
43
|
+
.get(accountId, kind, externalId) !== undefined;
|
|
44
|
+
const savePipelines = (key, kind, pipelines) => write(database, () => {
|
|
45
|
+
const accountId = account(key);
|
|
46
|
+
const at = now();
|
|
47
|
+
const skipped = [];
|
|
48
|
+
for (const pipeline of pipelines) {
|
|
49
|
+
if (otherKindHolds(accountId, kind, pipeline.externalId)) {
|
|
50
|
+
skipped.push({
|
|
51
|
+
externalId: pipeline.externalId,
|
|
52
|
+
reason: "crm.pipeline-clash",
|
|
53
|
+
message: `${kind} pipeline ${pipeline.externalId} has the id of a stored pipeline of the other kind`,
|
|
54
|
+
});
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const name = textOf(pipeline.name, 80) ?? pipeline.externalId;
|
|
58
|
+
const held = pipelineOf(accountId, kind, pipeline.externalId);
|
|
59
|
+
const id = held
|
|
60
|
+
? Number(held.id)
|
|
61
|
+
: Number(database
|
|
62
|
+
.prepare("INSERT INTO pipelines (account_id, external_id, kind, name, name_folded, created_at, updated_at) " +
|
|
63
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id")
|
|
64
|
+
.get(accountId, pipeline.externalId, kind, name, fold(name), at, at)?.id);
|
|
65
|
+
if (held && held.name !== name)
|
|
66
|
+
database
|
|
67
|
+
.prepare("UPDATE pipelines SET name = ?, name_folded = ?, updated_at = ? WHERE id = ?")
|
|
68
|
+
.run(name, fold(name), at, id);
|
|
69
|
+
saveStages(id, pipeline, at);
|
|
70
|
+
}
|
|
71
|
+
return { saved: pipelines.length - skipped.length, skipped };
|
|
72
|
+
});
|
|
73
|
+
/**
|
|
74
|
+
* Stage names are unique in a pipeline, but a CRM may swap two, or give two stages one name: listed stages first
|
|
75
|
+
* take a name no stage has, then their own, and a name used twice keeps the stage's id beside it.
|
|
76
|
+
*/
|
|
77
|
+
const saveStages = (pipelineId, pipeline, at) => {
|
|
78
|
+
const ordered = [...pipeline.stages].sort((a, b) => a.position - b.position);
|
|
79
|
+
const counts = new Map();
|
|
80
|
+
for (const stage of ordered)
|
|
81
|
+
counts.set(fold(stage.name), (counts.get(fold(stage.name)) ?? 0) + 1);
|
|
82
|
+
const wanted = ordered.map((stage, index) => {
|
|
83
|
+
const name = textOf(stage.name, 80) ?? stage.externalId;
|
|
84
|
+
const folded = (counts.get(fold(stage.name)) ?? 0) > 1 ? `${fold(name)} (${stage.externalId})` : fold(name);
|
|
85
|
+
if (!STAGE_OUTCOMES.includes(stage.outcome))
|
|
86
|
+
throw new CliError("invalid_response", `stage ${stage.externalId} has no known outcome: ${stage.outcome}`);
|
|
87
|
+
return { ...stage, name, folded, position: index + 1 };
|
|
88
|
+
});
|
|
89
|
+
const held = database.prepare("SELECT * FROM pipeline_stages WHERE pipeline_id = ?").all(pipelineId);
|
|
90
|
+
const listed = new Set(wanted.map(({ externalId }) => externalId));
|
|
91
|
+
const taken = new Set(wanted.map(({ folded }) => folded));
|
|
92
|
+
const rename = database.prepare("UPDATE pipeline_stages SET name_folded = ? WHERE id = ?");
|
|
93
|
+
for (const row of held) {
|
|
94
|
+
if (row.external_id != null && listed.has(String(row.external_id)))
|
|
95
|
+
rename.run(`\u0000${row.id}`, Number(row.id));
|
|
96
|
+
else if (taken.has(String(row.name_folded)))
|
|
97
|
+
rename.run(`${row.name_folded} (${row.external_id ?? row.id})`, Number(row.id));
|
|
98
|
+
}
|
|
99
|
+
const byId = new Map(held.map((row) => [String(row.external_id), row]));
|
|
100
|
+
for (const stage of wanted) {
|
|
101
|
+
const row = byId.get(stage.externalId);
|
|
102
|
+
if (row)
|
|
103
|
+
database
|
|
104
|
+
.prepare("UPDATE pipeline_stages SET name = ?, name_folded = ?, position = ?, outcome = ?, updated_at = ? WHERE id = ?")
|
|
105
|
+
.run(stage.name, stage.folded, stage.position, stage.outcome, at, Number(row.id));
|
|
106
|
+
else
|
|
107
|
+
database
|
|
108
|
+
.prepare("INSERT INTO pipeline_stages (pipeline_id, name, name_folded, position, outcome, external_id, created_at, " +
|
|
109
|
+
"updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
110
|
+
.run(pipelineId, stage.name, stage.folded, stage.position, stage.outcome, stage.externalId, at, at);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const personIdOf = (key, externalId) => {
|
|
114
|
+
const row = database
|
|
115
|
+
.prepare("SELECT l.person_id FROM identities i JOIN identity_links l ON l.identity_id = i.id " +
|
|
116
|
+
"WHERE i.provider = ? AND i.external_id = ?")
|
|
117
|
+
.get(key.provider, crmIdentityId(key, externalId));
|
|
118
|
+
return row ? Number(row.person_id) : null;
|
|
119
|
+
};
|
|
120
|
+
const organizationIdOf = (accountId, externalId) => {
|
|
121
|
+
const row = database
|
|
122
|
+
.prepare("SELECT entity_id FROM records WHERE account_id = ? AND type = 'organization' AND external_id = ? " +
|
|
123
|
+
"AND entity_type = 'organization'")
|
|
124
|
+
.get(accountId, externalId);
|
|
125
|
+
return row?.entity_id == null ? null : Number(row.entity_id);
|
|
126
|
+
};
|
|
127
|
+
const saveItem = (key, accountId, kind, item, at) => {
|
|
128
|
+
const pipeline = pipelineOf(accountId, kind, item.pipeline);
|
|
129
|
+
const stage = pipeline &&
|
|
130
|
+
database
|
|
131
|
+
.prepare("SELECT id, outcome FROM pipeline_stages WHERE pipeline_id = ? AND external_id = ?")
|
|
132
|
+
.get(Number(pipeline.id), item.stage);
|
|
133
|
+
if (!pipeline || !stage)
|
|
134
|
+
return {
|
|
135
|
+
externalId: item.externalId,
|
|
136
|
+
reason: "crm.stage-unknown",
|
|
137
|
+
message: `${kind} ${item.externalId} stands in stage ${item.stage} of pipeline ${item.pipeline}, which is not stored`,
|
|
138
|
+
};
|
|
139
|
+
const title = textOf(item.title, 200) ?? `${kind} ${item.externalId}`;
|
|
140
|
+
const given = item.amount != null && item.currency != null;
|
|
141
|
+
const currency = given ? currencyOf(item.currency) : null;
|
|
142
|
+
const amount = currency === null ? null : minorUnits(String(item.amount), currency, `${kind} ${item.externalId}'s amount`);
|
|
143
|
+
if (amount !== null && amount < 0)
|
|
144
|
+
throw new CliError("validation_error", "an amount is 0 or more");
|
|
145
|
+
if (item.status != null && !STAGE_OUTCOMES.includes(item.status))
|
|
146
|
+
throw new CliError("validation_error", `${kind} ${item.externalId}'s status is open, won or lost`);
|
|
147
|
+
const status = item.status ?? String(stage.outcome);
|
|
148
|
+
const closed = status === "open"
|
|
149
|
+
? null
|
|
150
|
+
: (day(item.closedDate, "a closed day") ?? new Date(epoch(item.updatedAt, at)).toISOString().slice(0, 10));
|
|
151
|
+
const deal = kind === "lead" && item.deal
|
|
152
|
+
? database.prepare("SELECT id FROM deals WHERE account_id = ? AND external_id = ?").get(accountId, item.deal)
|
|
153
|
+
: undefined;
|
|
154
|
+
const metadata = { ...(item.metadata ?? {}), ...(item.url ? { url: item.url } : {}) };
|
|
155
|
+
const values = {
|
|
156
|
+
title,
|
|
157
|
+
organization: item.organization ? organizationIdOf(accountId, item.organization) : null,
|
|
158
|
+
stage: Number(stage.id),
|
|
159
|
+
status,
|
|
160
|
+
currency,
|
|
161
|
+
amount,
|
|
162
|
+
close: day(item.closeDate, "an expected close day"),
|
|
163
|
+
closed,
|
|
164
|
+
metadata: Object.keys(metadata).length ? JSON.stringify(metadata) : null,
|
|
165
|
+
created: epoch(item.createdAt, at),
|
|
166
|
+
updated: epoch(item.updatedAt, at),
|
|
167
|
+
};
|
|
168
|
+
const table = TABLES[kind];
|
|
169
|
+
const held = database
|
|
170
|
+
.prepare(`SELECT id FROM ${table} WHERE account_id = ? AND external_id = ?`)
|
|
171
|
+
.get(accountId, item.externalId);
|
|
172
|
+
let id;
|
|
173
|
+
if (held) {
|
|
174
|
+
id = Number(held.id);
|
|
175
|
+
database
|
|
176
|
+
.prepare(`UPDATE ${table} SET title = ?, organization_id = ?, stage_id = ?, status = ?, currency = ?, amount = ?, ` +
|
|
177
|
+
"close_date = ?, closed_date = ?, metadata = ?, updated_at = ?, deleted_at = NULL" +
|
|
178
|
+
(kind === "lead" ? ", deal_id = coalesce(?, deal_id)" : "") +
|
|
179
|
+
" WHERE id = ?")
|
|
180
|
+
.run(values.title, values.organization, values.stage, values.status, values.currency, values.amount, values.close, values.closed, values.metadata, values.updated, ...(kind === "lead" ? [deal ? Number(deal.id) : null] : []), id);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
const actor = author(key);
|
|
184
|
+
id = Number(database
|
|
185
|
+
.prepare(`INSERT INTO ${table} (title, organization_id, stage_id, status, currency, amount, close_date, closed_date, ` +
|
|
186
|
+
`account_id, external_id, author_type, author_id, source, metadata, created_at, updated_at` +
|
|
187
|
+
(kind === "lead" ? ", deal_id" : "") +
|
|
188
|
+
`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?${kind === "lead" ? ", ?" : ""}) RETURNING id`)
|
|
189
|
+
.get(values.title, values.organization, values.stage, values.status, values.currency, values.amount, values.close, values.closed, accountId, item.externalId, actor.type, actor.id, SOURCE, values.metadata, values.created, values.updated, ...(kind === "lead" ? [deal ? Number(deal.id) : null] : []))?.id);
|
|
190
|
+
}
|
|
191
|
+
database
|
|
192
|
+
.prepare(`DELETE FROM links WHERE from_type = ? AND from_id = ? AND to_type = 'person' AND kind = ${linkKind("related-to")} ` +
|
|
193
|
+
"AND source = ?")
|
|
194
|
+
.run(kind, id, SOURCE);
|
|
195
|
+
const link = database.prepare("INSERT INTO links (from_type, from_id, to_type, to_id, kind, role, source, confirmed, created_at, author, " +
|
|
196
|
+
`updated_at) VALUES (?, ?, 'person', ?, ${linkKind("related-to")}, ?, ?, 1, ?, 'rule', ?)`);
|
|
197
|
+
const seen = new Set();
|
|
198
|
+
for (const person of item.people ?? []) {
|
|
199
|
+
const personId = personIdOf(key, person.externalId);
|
|
200
|
+
const role = DEAL_ROLES.includes(person.role) ? person.role : "contact";
|
|
201
|
+
if (personId === null || seen.has(personId))
|
|
202
|
+
continue;
|
|
203
|
+
seen.add(personId);
|
|
204
|
+
link.run(kind, id, personId, role, SOURCE, at, at);
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
};
|
|
208
|
+
const saveItems = (key, kind, items) => write(database, () => {
|
|
209
|
+
const accountId = account(key);
|
|
210
|
+
const at = now();
|
|
211
|
+
const skipped = [];
|
|
212
|
+
for (const item of items)
|
|
213
|
+
try {
|
|
214
|
+
const missing = saveItem(key, accountId, kind, item, at);
|
|
215
|
+
if (missing)
|
|
216
|
+
skipped.push(missing);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (!(error instanceof CliError) || error.code !== "validation_error")
|
|
220
|
+
throw error;
|
|
221
|
+
skipped.push({ externalId: item.externalId, reason: "crm.item-invalid", message: error.message });
|
|
222
|
+
}
|
|
223
|
+
return { saved: items.length - skipped.length, skipped };
|
|
224
|
+
});
|
|
225
|
+
const saveOrganizations = (key, organizations) => write(database, () => {
|
|
226
|
+
const accountId = account(key);
|
|
227
|
+
const at = now();
|
|
228
|
+
for (const organization of organizations) {
|
|
229
|
+
const name = textOf(organization.name, 200) ?? `${key.provider} company ${organization.externalId}`;
|
|
230
|
+
const fields = {
|
|
231
|
+
name,
|
|
232
|
+
...(organization.url ? { url: organization.url } : {}),
|
|
233
|
+
...(organization.metadata ?? {}),
|
|
234
|
+
};
|
|
235
|
+
const fingerprint = recordFingerprint(fields, []);
|
|
236
|
+
const held = database
|
|
237
|
+
.prepare("SELECT * FROM records WHERE account_id = ? AND type = 'organization' AND external_id = ?")
|
|
238
|
+
.get(accountId, organization.externalId);
|
|
239
|
+
const before = held ? JSON.parse(String(held.fields)).name : undefined;
|
|
240
|
+
let entity = held?.entity_type === "organization" && held.entity_id != null ? Number(held.entity_id) : null;
|
|
241
|
+
if (entity === null)
|
|
242
|
+
entity = Number(database
|
|
243
|
+
.prepare("INSERT INTO organizations (kind, name, scope, created_at, updated_at) VALUES ('company', ?, 'work', ?, ?) " +
|
|
244
|
+
"RETURNING id")
|
|
245
|
+
.get(name, at, at)?.id);
|
|
246
|
+
// The owner may have renamed it here; only a name nobody changed follows the CRM's.
|
|
247
|
+
else if (before !== name)
|
|
248
|
+
database
|
|
249
|
+
.prepare("UPDATE organizations SET name = ?, updated_at = ? WHERE id = ? AND name = ?")
|
|
250
|
+
.run(name, at, entity, typeof before === "string" ? before : name);
|
|
251
|
+
if (held)
|
|
252
|
+
database
|
|
253
|
+
.prepare("UPDATE records SET fields = ?, fingerprint = ?, entity_type = 'organization', entity_id = ?, " +
|
|
254
|
+
"projected_fingerprint = ?, batch_id = ?, updated_at = ?, deleted_at = NULL WHERE id = ?")
|
|
255
|
+
.run(JSON.stringify(fields), fingerprint, entity, fingerprint, SOURCE, at, Number(held.id));
|
|
256
|
+
else
|
|
257
|
+
database
|
|
258
|
+
.prepare("INSERT INTO records (account_id, type, external_id, fields, fingerprint, entity_type, entity_id, " +
|
|
259
|
+
"projected_fingerprint, batch_id, created_at, updated_at) " +
|
|
260
|
+
"VALUES (?, 'organization', ?, ?, ?, 'organization', ?, ?, ?, ?, ?)")
|
|
261
|
+
.run(accountId, organization.externalId, JSON.stringify(fields), fingerprint, entity, fingerprint, SOURCE, at, at);
|
|
262
|
+
}
|
|
263
|
+
return { saved: organizations.length, skipped: [] };
|
|
264
|
+
});
|
|
265
|
+
const stored = (key, kind) => {
|
|
266
|
+
const accountId = findAccountPk(context, key);
|
|
267
|
+
if (accountId === undefined || kind === "person")
|
|
268
|
+
return [];
|
|
269
|
+
return kind === "organization"
|
|
270
|
+
? database
|
|
271
|
+
.prepare("SELECT id, external_id, updated_at FROM records WHERE account_id = ? AND type = 'organization' " +
|
|
272
|
+
"AND deleted_at IS NULL")
|
|
273
|
+
.all(accountId)
|
|
274
|
+
: database
|
|
275
|
+
.prepare(`SELECT id, external_id, updated_at FROM ${TABLES[kind]} WHERE account_id = ? AND deleted_at IS NULL`)
|
|
276
|
+
.all(accountId);
|
|
277
|
+
};
|
|
278
|
+
const markDeleted = (kind, ids) => {
|
|
279
|
+
const table = kind === "organization" ? "records" : TABLES[kind];
|
|
280
|
+
const mark = database.prepare(`UPDATE ${table} SET deleted_at = ? WHERE id = ?`);
|
|
281
|
+
const at = now();
|
|
282
|
+
for (const id of ids)
|
|
283
|
+
mark.run(at, id);
|
|
284
|
+
return ids.length;
|
|
285
|
+
};
|
|
286
|
+
return {
|
|
287
|
+
savePipelines: async (key, kind, pipelines) => savePipelines(key, kind, pipelines),
|
|
288
|
+
saveItems: async (key, kind, items) => saveItems(key, kind, items),
|
|
289
|
+
savePeople: async (key, contacts) => {
|
|
290
|
+
await people.savePeople(key, contacts.map((contact) => ({
|
|
291
|
+
id: crmIdentityId(key, contact.externalId),
|
|
292
|
+
name: textOf(contact.name, 200),
|
|
293
|
+
email: textOf(contact.email, 320)?.toLowerCase() ?? null,
|
|
294
|
+
phone: textOf(contact.phone, 64),
|
|
295
|
+
})));
|
|
296
|
+
return { saved: contacts.length, skipped: [] };
|
|
297
|
+
},
|
|
298
|
+
saveOrganizations: async (key, organizations) => saveOrganizations(key, organizations),
|
|
299
|
+
remove: async (key, kind, externalIds) => write(database, () => {
|
|
300
|
+
const gone = new Set(externalIds);
|
|
301
|
+
return markDeleted(kind, stored(key, kind)
|
|
302
|
+
.filter((row) => gone.has(String(row.external_id)))
|
|
303
|
+
.map((row) => Number(row.id)));
|
|
304
|
+
}),
|
|
305
|
+
removeUnlisted: async (key, kind, listed, before, { dryRun } = {}) => write(database, () => {
|
|
306
|
+
const held = new Set(listed);
|
|
307
|
+
const gone = stored(key, kind)
|
|
308
|
+
.filter((row) => !held.has(String(row.external_id)) && Number(row.updated_at) < before)
|
|
309
|
+
.map((row) => Number(row.id));
|
|
310
|
+
return dryRun ? gone.length : markDeleted(kind, gone);
|
|
311
|
+
}),
|
|
312
|
+
count: async (key, kind) => kind === "person"
|
|
313
|
+
? Number(database
|
|
314
|
+
.prepare("SELECT count(*) AS n FROM identities WHERE provider = ? AND substr(external_id, 1, ?) = ?")
|
|
315
|
+
.get(key.provider, key.account.length + 1, `${key.account}/`)?.n)
|
|
316
|
+
: stored(key, kind).length,
|
|
317
|
+
};
|
|
318
|
+
};
|
|
@@ -121,9 +121,13 @@ export interface DealInput {
|
|
|
121
121
|
reason?: string;
|
|
122
122
|
evidence?: string[];
|
|
123
123
|
}
|
|
124
|
+
/** Whose pipelines: the owner's own, one CRM account's copies, or, left out, every one. */
|
|
125
|
+
export type DealScope = "own" | AccountKey;
|
|
124
126
|
export interface DealFilter {
|
|
125
127
|
/** `deal` when left out. */
|
|
126
128
|
kind?: DealKind;
|
|
129
|
+
/** Only the owner's own, or only one CRM account's copies. */
|
|
130
|
+
account?: DealScope;
|
|
127
131
|
status?: DealStatus;
|
|
128
132
|
/** A stage's name, in any letter case. */
|
|
129
133
|
stage?: string;
|
|
@@ -134,6 +138,8 @@ export interface DealFilter {
|
|
|
134
138
|
proposedBy?: ProposedByFilter;
|
|
135
139
|
/** 1–500, 100 by default; newest written first. */
|
|
136
140
|
limit?: number;
|
|
141
|
+
/** Rows to skip before the first one answered, for the next page. */
|
|
142
|
+
offset?: number;
|
|
137
143
|
}
|
|
138
144
|
export interface DealSearch {
|
|
139
145
|
/** Words of the title, each in any letter case; a word may be a phrase. */
|
|
@@ -165,11 +171,13 @@ export interface DealsStore {
|
|
|
165
171
|
addPipeline(input: PipelineInput): Promise<Pipeline>;
|
|
166
172
|
pipelines(filter?: {
|
|
167
173
|
kind?: DealKind;
|
|
174
|
+
account?: DealScope;
|
|
168
175
|
}): Promise<Pipeline[]>;
|
|
169
176
|
/** One pipeline by `pipeline:<id>` or a name only one pipeline has, with its funnel. */
|
|
170
177
|
pipeline(reference: string, options?: {
|
|
171
178
|
kind?: DealKind;
|
|
172
179
|
since?: string;
|
|
180
|
+
account?: DealScope;
|
|
173
181
|
}): Promise<PipelineReport>;
|
|
174
182
|
add(input: DealInput): Promise<Deal>;
|
|
175
183
|
/** `deal:<id>` or `lead:<id>`; a bare id is a deal. */
|
|
@@ -194,10 +202,12 @@ export declare const dealOperationsOver: (context: StoreContext) => {
|
|
|
194
202
|
addPipeline: ({ name, kind, stages, by }: PipelineInput) => Pipeline;
|
|
195
203
|
pipelines: (filter?: {
|
|
196
204
|
kind?: DealKind;
|
|
205
|
+
account?: DealScope;
|
|
197
206
|
}) => Pipeline[];
|
|
198
207
|
pipeline: (reference: string, options?: {
|
|
199
208
|
kind?: DealKind;
|
|
200
209
|
since?: string;
|
|
210
|
+
account?: DealScope;
|
|
201
211
|
}) => PipelineReport;
|
|
202
212
|
add: (input: DealInput) => Deal;
|
|
203
213
|
get: (reference: string) => Deal;
|
|
@@ -89,22 +89,27 @@ export const dealOperationsOver = (context) => {
|
|
|
89
89
|
const { kind, row } = rowOf(reference);
|
|
90
90
|
return itemOf(kind)(row);
|
|
91
91
|
};
|
|
92
|
+
const inScope = (scope, row) => scope === undefined ||
|
|
93
|
+
(scope === "own"
|
|
94
|
+
? row.account_id == null
|
|
95
|
+
: row.account_provider === scope.provider && row.account_external_id === scope.account);
|
|
92
96
|
const kindOf = (kind) => (kind === undefined ? "deal" : oneOf(kind, DEAL_KINDS, "a kind"));
|
|
93
|
-
const pipelineRows = (kind) => database
|
|
97
|
+
const pipelineRows = (kind, scope) => database
|
|
94
98
|
.prepare("SELECT p.*, a.provider AS account_provider, a.external_id AS account_external_id FROM pipelines p " +
|
|
95
99
|
"LEFT JOIN accounts a ON a.id = p.account_id WHERE (? IS NULL OR p.kind = ?) ORDER BY p.name_folded, p.id")
|
|
96
|
-
.all(kind ?? null, kind ?? null)
|
|
100
|
+
.all(kind ?? null, kind ?? null)
|
|
101
|
+
.filter((row) => inScope(scope, row));
|
|
97
102
|
const label = (row) => `pipeline:${row.id} (${row.name}${row.account_provider == null ? "" : `, ${row.account_provider}:${row.account_external_id}`})`;
|
|
98
103
|
/** A pipeline by `pipeline:<id>`, or by a name only one pipeline of the kind has; never a guess. */
|
|
99
|
-
const pipelineRow = (reference, kind) => {
|
|
104
|
+
const pipelineRow = (reference, kind, scope) => {
|
|
100
105
|
const named = reference.trim();
|
|
101
106
|
if (/^pipeline:\d+$/.test(named)) {
|
|
102
|
-
const row = pipelineRows(kind).find((one) => `pipeline:${one.id}` === named);
|
|
107
|
+
const row = pipelineRows(kind, scope).find((one) => `pipeline:${one.id}` === named);
|
|
103
108
|
if (!row)
|
|
104
109
|
throw new CliError("not_found", `no ${kind ?? ""} pipeline ${named}`, { reason: "pipeline.not-found" });
|
|
105
110
|
return row;
|
|
106
111
|
}
|
|
107
|
-
const rows = pipelineRows(kind).filter((one) => one.name_folded === fold(named));
|
|
112
|
+
const rows = pipelineRows(kind, scope).filter((one) => one.name_folded === fold(named));
|
|
108
113
|
if (rows.length === 0)
|
|
109
114
|
throw new CliError("not_found", `no ${kind === undefined ? "" : `${kind} `}pipeline named ${named}`, {
|
|
110
115
|
reason: "pipeline.not-found",
|
|
@@ -140,12 +145,16 @@ export const dealOperationsOver = (context) => {
|
|
|
140
145
|
createdAt: iso(row.created_at),
|
|
141
146
|
}));
|
|
142
147
|
};
|
|
143
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* A stage by name in one pipeline, or in the only one of the owner's pipelines of the kind that has one of that
|
|
150
|
+
* name: a CRM's stages are its own, and a deal written here into one would be undone by the next sync.
|
|
151
|
+
*/
|
|
144
152
|
const stageRow = (stage, kind, pipeline) => {
|
|
145
153
|
const rows = database
|
|
146
154
|
.prepare("SELECT s.*, p.name AS pipeline_name FROM pipeline_stages s JOIN pipelines p ON p.id = s.pipeline_id " +
|
|
147
|
-
"WHERE s.name_folded = ? AND p.kind = ? AND (? IS NULL OR p.id = ?)
|
|
148
|
-
|
|
155
|
+
"WHERE s.name_folded = ? AND p.kind = ? AND (? IS NULL OR p.id = ?) AND (? IS NOT NULL OR p.account_id IS NULL) " +
|
|
156
|
+
"ORDER BY p.id")
|
|
157
|
+
.all(fold(stage.trim()), kind, pipeline ?? null, pipeline ?? null, pipeline ?? null);
|
|
149
158
|
if (rows.length === 0)
|
|
150
159
|
return refuse(`no stage named ${stage} in ${pipeline === undefined ? `a ${kind} pipeline` : "that pipeline"}`, "deal.stage");
|
|
151
160
|
if (rows.length > 1)
|
|
@@ -153,7 +162,7 @@ export const dealOperationsOver = (context) => {
|
|
|
153
162
|
return rows[0];
|
|
154
163
|
};
|
|
155
164
|
const firstStage = (kind, pipeline) => {
|
|
156
|
-
const pipelines = pipeline === undefined ? pipelineRows(kind) : [{ id: pipeline }];
|
|
165
|
+
const pipelines = pipeline === undefined ? pipelineRows(kind, "own") : [{ id: pipeline }];
|
|
157
166
|
if (pipelines.length !== 1)
|
|
158
167
|
return refuse(`name the stage: ${pipelines.length === 0 ? `no ${kind} pipeline is stored` : `more than one ${kind} pipeline is stored`}`, "deal.stage");
|
|
159
168
|
const row = database
|
|
@@ -222,6 +231,9 @@ export const dealOperationsOver = (context) => {
|
|
|
222
231
|
const notDeleted = (row, kind, what) => {
|
|
223
232
|
if (row.deleted_at != null)
|
|
224
233
|
refuse(`${kind}:${row.id} is deleted; it no longer ${what}`, "deal.closed");
|
|
234
|
+
if (row.account_id != null)
|
|
235
|
+
refuse(`${kind}:${row.id} is a copy from ${row.account_provider}:${row.account_external_id}; change it there, and ` +
|
|
236
|
+
"its next sync brings the change here", "deal.copy");
|
|
225
237
|
};
|
|
226
238
|
return {
|
|
227
239
|
addPipeline: ({ name, kind, stages, by }) => {
|
|
@@ -257,11 +269,11 @@ export const dealOperationsOver = (context) => {
|
|
|
257
269
|
});
|
|
258
270
|
return pipelinesOf(pipelineRows(pipelineKind).filter((row) => Number(row.id) === id))[0];
|
|
259
271
|
},
|
|
260
|
-
pipelines: (filter = {}) => pipelinesOf(pipelineRows(filter.kind === undefined ? undefined : kindOf(filter.kind))),
|
|
272
|
+
pipelines: (filter = {}) => pipelinesOf(pipelineRows(filter.kind === undefined ? undefined : kindOf(filter.kind), filter.account)),
|
|
261
273
|
pipeline: (reference, options = {}) => {
|
|
262
274
|
const kind = options.kind === undefined ? undefined : kindOf(options.kind);
|
|
263
275
|
const since = isoDate(options.since, "the first day counted");
|
|
264
|
-
return funnelOf(pipelinesOf([pipelineRow(reference, kind)])[0], since);
|
|
276
|
+
return funnelOf(pipelinesOf([pipelineRow(reference, kind, options.account)])[0], since);
|
|
265
277
|
},
|
|
266
278
|
add: (input) => {
|
|
267
279
|
const kind = kindOf(input.kind);
|
|
@@ -283,7 +295,7 @@ export const dealOperationsOver = (context) => {
|
|
|
283
295
|
if (!Array.isArray(evidence) || evidence.length > 8)
|
|
284
296
|
refuse("evidence is at most 8 references", "deal.field");
|
|
285
297
|
const id = write(database, () => {
|
|
286
|
-
const pipeline = input.pipeline === undefined ? undefined : Number(pipelineRow(input.pipeline, kind).id);
|
|
298
|
+
const pipeline = input.pipeline === undefined ? undefined : Number(pipelineRow(input.pipeline, kind, "own").id);
|
|
287
299
|
const stageText = text(input.stage, "a stage's name", 80);
|
|
288
300
|
const stage = stageText === null
|
|
289
301
|
? kind === "lead"
|
|
@@ -325,13 +337,19 @@ export const dealOperationsOver = (context) => {
|
|
|
325
337
|
const by = proposedBySql("d.author_type", "d.author_id", filter.proposedBy);
|
|
326
338
|
const status = filter.status === undefined ? null : oneOf(filter.status, DEAL_STATUSES, "a status");
|
|
327
339
|
const stage = filter.stage === undefined ? null : fold(filter.stage.trim());
|
|
328
|
-
const pipeline = filter.pipeline === undefined ? null : Number(pipelineRow(filter.pipeline, kind).id);
|
|
340
|
+
const pipeline = filter.pipeline === undefined ? null : Number(pipelineRow(filter.pipeline, kind, filter.account).id);
|
|
341
|
+
const scope = filter.account;
|
|
342
|
+
const own = scope === "own" ? 1 : 0;
|
|
343
|
+
const provider = scope === undefined || scope === "own" ? null : scope.provider;
|
|
344
|
+
const account = scope === undefined || scope === "own" ? null : scope.account;
|
|
329
345
|
const limit = Math.min(Math.max(filter.limit ?? 100, 1), 500);
|
|
330
346
|
return database
|
|
331
347
|
.prepare(`${itemsSql(kind)} WHERE d.deleted_at IS NULL AND (? IS NULL OR d.status = ?) ` +
|
|
332
348
|
"AND (? IS NULL OR d.project_id = ?) AND (? IS NULL OR s.name_folded = ?) AND (? IS NULL OR p.id = ?) " +
|
|
333
|
-
|
|
334
|
-
|
|
349
|
+
"AND (? IS NULL OR d.organization_id = ?) AND (? = 0 OR d.account_id IS NULL) " +
|
|
350
|
+
`AND (? IS NULL OR (a.provider = ? AND a.external_id = ?)) AND ${by.sql} ` +
|
|
351
|
+
"ORDER BY d.created_at DESC, d.id DESC LIMIT ? OFFSET ?")
|
|
352
|
+
.all(status, status, project, project, stage, stage, pipeline, pipeline, organization?.id ?? null, organization?.id ?? null, own, provider, provider, account, ...by.params, limit, Math.max(0, Math.trunc(filter.offset ?? 0)))
|
|
335
353
|
.map(itemOf(kind));
|
|
336
354
|
},
|
|
337
355
|
find: ({ words, anyWord, accounts, limit }) => {
|
package/dist/store/store.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { type SearchesStore } from "./searches.js";
|
|
|
22
22
|
import { type AgentActionsStore } from "./sqlite/agent-actions.js";
|
|
23
23
|
import { type BotUpdateStore } from "./sqlite/bot-updates.js";
|
|
24
24
|
import { type CatalogStore } from "./sqlite/catalog.js";
|
|
25
|
+
import { type CrmCopies } from "./sqlite/crm.js";
|
|
25
26
|
import { type DealsStore } from "./sqlite/deals.js";
|
|
26
27
|
import { type MailStore } from "./sqlite/emails.js";
|
|
27
28
|
import { type EventSearch } from "./sqlite/event-search.js";
|
|
@@ -108,6 +109,8 @@ export interface MessageStore {
|
|
|
108
109
|
readonly orders: OrdersStore;
|
|
109
110
|
/** The owner's sales pipelines and the deals in them; an agent's deal waits for the owner. */
|
|
110
111
|
readonly deals: DealsStore;
|
|
112
|
+
/** A CRM account's pipelines, deals, leads, contacts and companies, written as the CRM has them. */
|
|
113
|
+
readonly crm: CrmCopies;
|
|
111
114
|
/** What each import said, by the source's identity; `ingest` (services) is the way in. */
|
|
112
115
|
readonly records: RecordStore;
|
|
113
116
|
/** What agents concluded: summaries, digests, facts, preferences, each with evidence and a scope. */
|
package/dist/store/store.js
CHANGED
|
@@ -19,6 +19,7 @@ import { botUpdateStoreOver } from "./sqlite/bot-updates.js";
|
|
|
19
19
|
import { eventStoreOver } from "./sqlite/calendar.js";
|
|
20
20
|
import { catalogStoreOver } from "./sqlite/catalog.js";
|
|
21
21
|
import { contextEvidenceOver, contextIndexOver, contextRecordsOver } from "./sqlite/context-evidence.js";
|
|
22
|
+
import { crmCopiesOver } from "./sqlite/crm.js";
|
|
22
23
|
import { dealsStoreOver } from "./sqlite/deals.js";
|
|
23
24
|
import { mailStoreOver } from "./sqlite/emails.js";
|
|
24
25
|
import { eventSearchOver } from "./sqlite/event-search.js";
|
|
@@ -119,6 +120,7 @@ const storeOver = (context) => {
|
|
|
119
120
|
const finance = once(() => financeStoreOver(context));
|
|
120
121
|
const orders = once(() => ordersStoreOver(context));
|
|
121
122
|
const deals = once(() => dealsStoreOver(context));
|
|
123
|
+
const crm = once(() => crmCopiesOver(context, accountPeopleOver(context)));
|
|
122
124
|
const records = once(() => recordStoreOver(context));
|
|
123
125
|
const memories = once(() => memoriesStoreOver(context));
|
|
124
126
|
const proposedActions = once(() => proposedActionsStoreOver(context));
|
|
@@ -232,6 +234,9 @@ const storeOver = (context) => {
|
|
|
232
234
|
get deals() {
|
|
233
235
|
return deals();
|
|
234
236
|
},
|
|
237
|
+
get crm() {
|
|
238
|
+
return crm();
|
|
239
|
+
},
|
|
235
240
|
get records() {
|
|
236
241
|
return records();
|
|
237
242
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.591.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|
|
@@ -128,6 +128,18 @@
|
|
|
128
128
|
"types": "./dist/calendar/testing/index.d.ts",
|
|
129
129
|
"default": "./dist/calendar/testing/index.js"
|
|
130
130
|
},
|
|
131
|
+
"./crm": {
|
|
132
|
+
"types": "./dist/crm/index.d.ts",
|
|
133
|
+
"default": "./dist/crm/index.js"
|
|
134
|
+
},
|
|
135
|
+
"./crm/cli": {
|
|
136
|
+
"types": "./dist/crm/cli/index.d.ts",
|
|
137
|
+
"default": "./dist/crm/cli/index.js"
|
|
138
|
+
},
|
|
139
|
+
"./crm/testing": {
|
|
140
|
+
"types": "./dist/crm/testing/index.d.ts",
|
|
141
|
+
"default": "./dist/crm/testing/index.js"
|
|
142
|
+
},
|
|
131
143
|
"./mail": {
|
|
132
144
|
"types": "./dist/mail/index.d.ts",
|
|
133
145
|
"default": "./dist/mail/index.js"
|
package/parity.json
CHANGED
|
@@ -697,6 +697,10 @@
|
|
|
697
697
|
"--notify": {
|
|
698
698
|
"meaning": "tell the chat's members about the pin"
|
|
699
699
|
},
|
|
700
|
+
"--notify-on": {
|
|
701
|
+
"value": "<when>",
|
|
702
|
+
"meaning": "desktop notice after a schedule's run: always, failures or never (default failures for an agent that repeats with --every, else always)"
|
|
703
|
+
},
|
|
700
704
|
"--ocr": {
|
|
701
705
|
"meaning": "explicitly call models.ocr for bulk image and scanned-PDF text extraction; agents normally transcribe files themselves"
|
|
702
706
|
},
|
|
@@ -3978,6 +3982,12 @@
|
|
|
3978
3982
|
"planned": {
|
|
3979
3983
|
"wa": "wa backlog: mount the shared schedule command"
|
|
3980
3984
|
}
|
|
3985
|
+
},
|
|
3986
|
+
"--notify-on": {
|
|
3987
|
+
"in": ["max", "tg"],
|
|
3988
|
+
"planned": {
|
|
3989
|
+
"wa": "wa backlog: mount the shared schedule command"
|
|
3990
|
+
}
|
|
3981
3991
|
}
|
|
3982
3992
|
}
|
|
3983
3993
|
},
|
|
@@ -4004,6 +4014,12 @@
|
|
|
4004
4014
|
"planned": {
|
|
4005
4015
|
"wa": "wa backlog: mount the shared schedule command"
|
|
4006
4016
|
}
|
|
4017
|
+
},
|
|
4018
|
+
"--notify-on": {
|
|
4019
|
+
"in": ["max", "tg"],
|
|
4020
|
+
"planned": {
|
|
4021
|
+
"wa": "wa backlog: mount the shared schedule command"
|
|
4022
|
+
}
|
|
4007
4023
|
}
|
|
4008
4024
|
}
|
|
4009
4025
|
},
|