@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/background/schedules.d.ts +15 -1
  3. package/dist/background/schedules.js +16 -5
  4. package/dist/cli/messenger/flooded.d.ts +1 -1
  5. package/dist/cli/messenger/flooded.js +3 -26
  6. package/dist/cli/messenger/search-command.d.ts +3 -2
  7. package/dist/cli/messenger/search-command.js +7 -5
  8. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  9. package/dist/cli/schedule/schedule-command.js +23 -12
  10. package/dist/crm/cli/index.d.ts +57 -0
  11. package/dist/crm/cli/index.js +300 -0
  12. package/dist/crm/cli/search-all.d.ts +12 -0
  13. package/dist/crm/cli/search-all.js +63 -0
  14. package/dist/crm/index.d.ts +2 -0
  15. package/dist/crm/index.js +1 -0
  16. package/dist/crm/source.d.ts +43 -0
  17. package/dist/crm/source.js +1 -0
  18. package/dist/crm/sync.d.ts +121 -0
  19. package/dist/crm/sync.js +291 -0
  20. package/dist/crm/testing/index.d.ts +3 -0
  21. package/dist/crm/testing/index.js +3 -0
  22. package/dist/crm/testing/source-contract.d.ts +17 -0
  23. package/dist/crm/testing/source-contract.js +137 -0
  24. package/dist/crm/testing/source.d.ts +33 -0
  25. package/dist/crm/testing/source.js +126 -0
  26. package/dist/crm/testing/sync-contract.d.ts +13 -0
  27. package/dist/crm/testing/sync-contract.js +178 -0
  28. package/dist/domain/crm.d.ts +82 -0
  29. package/dist/domain/crm.js +3 -0
  30. package/dist/entities/deal.d.ts +159 -0
  31. package/dist/entities/deal.js +86 -0
  32. package/dist/entities/index.d.ts +143 -0
  33. package/dist/entities/index.js +5 -0
  34. package/dist/entities/list-results.d.ts +4 -0
  35. package/dist/entities/list-results.js +8 -1
  36. package/dist/errors/reasons.js +5 -0
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.js +1 -0
  39. package/dist/sends/index.d.ts +1 -1
  40. package/dist/sends/index.js +1 -1
  41. package/dist/sends/pace.d.ts +18 -0
  42. package/dist/sends/pace.js +40 -1
  43. package/dist/services/index.d.ts +1 -0
  44. package/dist/services/index.js +1 -0
  45. package/dist/store/index.d.ts +2 -1
  46. package/dist/store/index.js +1 -0
  47. package/dist/store/sqlite/crm.d.ts +48 -0
  48. package/dist/store/sqlite/crm.js +318 -0
  49. package/dist/store/sqlite/deals.d.ts +10 -0
  50. package/dist/store/sqlite/deals.js +33 -15
  51. package/dist/store/store.d.ts +3 -0
  52. package/dist/store/store.js +5 -0
  53. package/package.json +13 -1
  54. 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
- /** A stage by name in one pipeline, or in the only pipeline of the kind that has one of that name. */
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 = ?) ORDER BY p.id")
148
- .all(fold(stage.trim()), kind, pipeline ?? null, pipeline ?? null);
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
- `AND (? IS NULL OR d.organization_id = ?) AND ${by.sql} ORDER BY d.created_at DESC, d.id DESC LIMIT ?`)
334
- .all(status, status, project, project, stage, stage, pipeline, pipeline, organization?.id ?? null, organization?.id ?? null, ...by.params, limit)
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 }) => {
@@ -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. */
@@ -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.590.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
  },