@wirecat/cli-core 0.592.0 → 0.594.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -11
- package/dist/crm/cli/index.d.ts +6 -0
- package/dist/crm/cli/index.js +118 -2
- package/dist/crm/index.d.ts +2 -0
- package/dist/crm/index.js +2 -0
- package/dist/crm/testing/index.d.ts +2 -1
- package/dist/crm/testing/index.js +2 -1
- package/dist/crm/testing/source-contract.js +2 -2
- package/dist/crm/testing/source.d.ts +22 -2
- package/dist/crm/testing/source.js +117 -1
- package/dist/crm/testing/sync-contract.js +3 -2
- package/dist/crm/testing/write-contract.d.ts +21 -0
- package/dist/crm/testing/write-contract.js +127 -0
- package/dist/crm/writer.d.ts +76 -0
- package/dist/crm/writer.js +14 -0
- package/dist/crm/writes.d.ts +68 -0
- package/dist/crm/writes.js +245 -0
- package/dist/domain/crm.d.ts +3 -4
- package/dist/errors/reasons.js +2 -1
- package/dist/mail/cli/review.js +11 -4
- package/dist/mail/marks.d.ts +2 -0
- package/dist/mail/marks.js +8 -6
- package/dist/mail/newsletter.d.ts +10 -0
- package/dist/mail/newsletter.js +70 -0
- package/dist/mail/review.d.ts +1 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/store/sqlite/crm.d.ts +3 -1
- package/dist/store/sqlite/crm.js +37 -20
- package/dist/store/sqlite/deals.js +6 -4
- package/dist/store/sqlite/email-activity.d.ts +4 -0
- package/dist/store/sqlite/email-activity.js +5 -1
- package/dist/store/sqlite/emails.js +3 -21
- package/dist/store/sqlite/mail-nearest.d.ts +12 -0
- package/dist/store/sqlite/mail-nearest.js +73 -0
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +9 -0
- package/dist/store/sqlite/schema.d.ts +32 -0
- package/dist/store/sqlite/schema.js +4 -2
- package/package.json +2 -2
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
package/dist/services/index.d.ts
CHANGED
|
@@ -154,6 +154,7 @@ export declare const servicesFor: (deps: ServiceDeps) => Services;
|
|
|
154
154
|
export { entriesOfList, type ListContext, type ListedEntry } from "../search/account-list.js";
|
|
155
155
|
export { FIELD_VERSION, QUERY_FIELDS, QUERY_OPERATORS, validateAst } from "../search/lucene/registry.js";
|
|
156
156
|
export { QUERY_LIMITS, QUERY_VERSION } from "../search/lucene/types.js";
|
|
157
|
+
export { questionLookup } from "../search/question-plan.js";
|
|
157
158
|
export { type SearchTotal, TOTAL_CAP } from "../search/total.js";
|
|
158
159
|
export { TASK_KINDS, TASK_ORIGINS, type TaskKind, type TaskOrigin } from "../tasks/index.js";
|
|
159
160
|
export type { AdminFound, AdminQuery, AdminSelection, AdminStatisticsService } from "./admin-statistics.js";
|
package/dist/services/index.js
CHANGED
|
@@ -119,6 +119,7 @@ export const servicesFor = (deps) => {
|
|
|
119
119
|
export { entriesOfList } from "../search/account-list.js";
|
|
120
120
|
export { FIELD_VERSION, QUERY_FIELDS, QUERY_OPERATORS, validateAst } from "../search/lucene/registry.js";
|
|
121
121
|
export { QUERY_LIMITS, QUERY_VERSION } from "../search/lucene/types.js";
|
|
122
|
+
export { questionLookup } from "../search/question-plan.js";
|
|
122
123
|
export { TOTAL_CAP } from "../search/total.js";
|
|
123
124
|
export { TASK_KINDS, TASK_ORIGINS } from "../tasks/index.js";
|
|
124
125
|
export { adminStatisticsService } from "./admin-statistics.js";
|
|
@@ -6,7 +6,7 @@ import type { StoreContext } from "./open.js";
|
|
|
6
6
|
/** What the store could not keep, and why: an item whose stage is not stored or whose field it refuses, or a pipeline. */
|
|
7
7
|
export interface CrmSkipped {
|
|
8
8
|
externalId: string;
|
|
9
|
-
reason: "crm.stage-unknown" | "crm.item-invalid"
|
|
9
|
+
reason: "crm.stage-unknown" | "crm.item-invalid";
|
|
10
10
|
message: string;
|
|
11
11
|
}
|
|
12
12
|
export interface CrmSaved {
|
|
@@ -39,6 +39,8 @@ export interface CrmCopies {
|
|
|
39
39
|
}): Promise<number>;
|
|
40
40
|
/** How many of the kind the account holds, not deleted; every contact it ever saved. */
|
|
41
41
|
count(key: AccountKey, kind: CrmKind): Promise<number>;
|
|
42
|
+
/** The stored copy's `deal:<id>` or `lead:<id>`, deleted or not; `null` when the store holds none. */
|
|
43
|
+
refOf(key: AccountKey, kind: DealKind, externalId: string): Promise<string | null>;
|
|
42
44
|
}
|
|
43
45
|
/**
|
|
44
46
|
* A contact's identity id: identities are unique per provider, and two portals of one CRM both have a contact 5.
|
package/dist/store/sqlite/crm.js
CHANGED
|
@@ -36,24 +36,14 @@ export const crmCopiesOver = (context, people) => {
|
|
|
36
36
|
const pipelineOf = (accountId, kind, externalId) => database
|
|
37
37
|
.prepare("SELECT * FROM pipelines WHERE account_id = ? AND kind = ? AND external_id = ?")
|
|
38
38
|
.get(accountId, kind, externalId);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.get(accountId, kind, externalId) !== undefined;
|
|
39
|
+
/**
|
|
40
|
+
* One kind's whole list: a pipeline it lacks is marked deleted, one listed again comes back. An empty list marks
|
|
41
|
+
* nothing: a sign-in that lost its CRM rights lists no pipeline at all.
|
|
42
|
+
*/
|
|
44
43
|
const savePipelines = (key, kind, pipelines) => write(database, () => {
|
|
45
44
|
const accountId = account(key);
|
|
46
45
|
const at = now();
|
|
47
|
-
const skipped = [];
|
|
48
46
|
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
47
|
const name = textOf(pipeline.name, 80) ?? pipeline.externalId;
|
|
58
48
|
const held = pipelineOf(accountId, kind, pipeline.externalId);
|
|
59
49
|
const id = held
|
|
@@ -62,13 +52,22 @@ export const crmCopiesOver = (context, people) => {
|
|
|
62
52
|
.prepare("INSERT INTO pipelines (account_id, external_id, kind, name, name_folded, created_at, updated_at) " +
|
|
63
53
|
"VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id")
|
|
64
54
|
.get(accountId, pipeline.externalId, kind, name, fold(name), at, at)?.id);
|
|
65
|
-
if (held && held.name !== name)
|
|
55
|
+
if (held && (held.name !== name || held.deleted_at != null))
|
|
66
56
|
database
|
|
67
|
-
.prepare("UPDATE pipelines SET name = ?, name_folded = ?, updated_at =
|
|
57
|
+
.prepare("UPDATE pipelines SET name = ?, name_folded = ?, updated_at = ?, deleted_at = NULL WHERE id = ?")
|
|
68
58
|
.run(name, fold(name), at, id);
|
|
69
59
|
saveStages(id, pipeline, at);
|
|
70
60
|
}
|
|
71
|
-
|
|
61
|
+
if (pipelines.length === 0)
|
|
62
|
+
return { saved: 0, skipped: [] };
|
|
63
|
+
const listed = new Set(pipelines.map(({ externalId }) => externalId));
|
|
64
|
+
const mark = database.prepare("UPDATE pipelines SET deleted_at = ?, updated_at = ? WHERE id = ?");
|
|
65
|
+
for (const row of database
|
|
66
|
+
.prepare("SELECT id, external_id FROM pipelines WHERE account_id = ? AND kind = ? AND deleted_at IS NULL")
|
|
67
|
+
.all(accountId, kind))
|
|
68
|
+
if (!listed.has(String(row.external_id)))
|
|
69
|
+
mark.run(at, at, Number(row.id));
|
|
70
|
+
return { saved: pipelines.length, skipped: [] };
|
|
72
71
|
});
|
|
73
72
|
/**
|
|
74
73
|
* Stage names are unique in a pipeline, but a CRM may swap two, or give two stages one name: listed stages first
|
|
@@ -90,18 +89,23 @@ export const crmCopiesOver = (context, people) => {
|
|
|
90
89
|
const listed = new Set(wanted.map(({ externalId }) => externalId));
|
|
91
90
|
const taken = new Set(wanted.map(({ folded }) => folded));
|
|
92
91
|
const rename = database.prepare("UPDATE pipeline_stages SET name_folded = ? WHERE id = ?");
|
|
92
|
+
const gone = database.prepare("UPDATE pipeline_stages SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL");
|
|
93
93
|
for (const row of held) {
|
|
94
|
-
|
|
94
|
+
const kept = row.external_id != null && listed.has(String(row.external_id));
|
|
95
|
+
if (kept)
|
|
95
96
|
rename.run(`\u0000${row.id}`, Number(row.id));
|
|
96
97
|
else if (taken.has(String(row.name_folded)))
|
|
97
98
|
rename.run(`${row.name_folded} (${row.external_id ?? row.id})`, Number(row.id));
|
|
99
|
+
if (!kept)
|
|
100
|
+
gone.run(at, Number(row.id));
|
|
98
101
|
}
|
|
99
102
|
const byId = new Map(held.map((row) => [String(row.external_id), row]));
|
|
100
103
|
for (const stage of wanted) {
|
|
101
104
|
const row = byId.get(stage.externalId);
|
|
102
105
|
if (row)
|
|
103
106
|
database
|
|
104
|
-
.prepare("UPDATE pipeline_stages SET name = ?, name_folded = ?, position = ?, outcome = ?, updated_at =
|
|
107
|
+
.prepare("UPDATE pipeline_stages SET name = ?, name_folded = ?, position = ?, outcome = ?, updated_at = ?, " +
|
|
108
|
+
"deleted_at = NULL WHERE id = ?")
|
|
105
109
|
.run(stage.name, stage.folded, stage.position, stage.outcome, at, Number(row.id));
|
|
106
110
|
else
|
|
107
111
|
database
|
|
@@ -151,7 +155,11 @@ export const crmCopiesOver = (context, people) => {
|
|
|
151
155
|
const deal = kind === "lead" && item.deal
|
|
152
156
|
? database.prepare("SELECT id FROM deals WHERE account_id = ? AND external_id = ?").get(accountId, item.deal)
|
|
153
157
|
: undefined;
|
|
154
|
-
const metadata = {
|
|
158
|
+
const metadata = {
|
|
159
|
+
...(item.metadata ?? {}),
|
|
160
|
+
...(item.url ? { url: item.url } : {}),
|
|
161
|
+
...(item.closedReason && status !== "open" ? { closed_reason: textOf(item.closedReason, 2000) } : {}),
|
|
162
|
+
};
|
|
155
163
|
const values = {
|
|
156
164
|
title,
|
|
157
165
|
organization: item.organization ? organizationIdOf(accountId, item.organization) : null,
|
|
@@ -314,5 +322,14 @@ export const crmCopiesOver = (context, people) => {
|
|
|
314
322
|
.prepare("SELECT count(*) AS n FROM identities WHERE provider = ? AND substr(external_id, 1, ?) = ?")
|
|
315
323
|
.get(key.provider, key.account.length + 1, `${key.account}/`)?.n)
|
|
316
324
|
: stored(key, kind).length,
|
|
325
|
+
refOf: async (key, kind, externalId) => {
|
|
326
|
+
const accountId = findAccountPk(context, key);
|
|
327
|
+
const row = accountId === undefined
|
|
328
|
+
? undefined
|
|
329
|
+
: database
|
|
330
|
+
.prepare(`SELECT id FROM ${TABLES[kind]} WHERE account_id = ? AND external_id = ?`)
|
|
331
|
+
.get(accountId, externalId);
|
|
332
|
+
return row ? `${kind}:${row.id}` : null;
|
|
333
|
+
},
|
|
317
334
|
};
|
|
318
335
|
};
|
|
@@ -94,9 +94,11 @@ export const dealOperationsOver = (context) => {
|
|
|
94
94
|
? row.account_id == null
|
|
95
95
|
: row.account_provider === scope.provider && row.account_external_id === scope.account);
|
|
96
96
|
const kindOf = (kind) => (kind === undefined ? "deal" : oneOf(kind, DEAL_KINDS, "a kind"));
|
|
97
|
-
|
|
97
|
+
/** A pipeline the CRM deleted is left out, unless named by its reference: its deals still stand in it. */
|
|
98
|
+
const pipelineRows = (kind, scope, { deleted = false } = {}) => database
|
|
98
99
|
.prepare("SELECT p.*, a.provider AS account_provider, a.external_id AS account_external_id FROM pipelines p " +
|
|
99
|
-
"LEFT JOIN accounts a ON a.id = p.account_id WHERE (? IS NULL OR p.kind = ?)
|
|
100
|
+
"LEFT JOIN accounts a ON a.id = p.account_id WHERE (? IS NULL OR p.kind = ?) " +
|
|
101
|
+
`${deleted ? "" : "AND p.deleted_at IS NULL "}ORDER BY p.name_folded, p.id`)
|
|
100
102
|
.all(kind ?? null, kind ?? null)
|
|
101
103
|
.filter((row) => inScope(scope, row));
|
|
102
104
|
const label = (row) => `pipeline:${row.id} (${row.name}${row.account_provider == null ? "" : `, ${row.account_provider}:${row.account_external_id}`})`;
|
|
@@ -104,7 +106,7 @@ export const dealOperationsOver = (context) => {
|
|
|
104
106
|
const pipelineRow = (reference, kind, scope) => {
|
|
105
107
|
const named = reference.trim();
|
|
106
108
|
if (/^pipeline:\d+$/.test(named)) {
|
|
107
|
-
const row = pipelineRows(kind, scope).find((one) => `pipeline:${one.id}` === named);
|
|
109
|
+
const row = pipelineRows(kind, scope, { deleted: true }).find((one) => `pipeline:${one.id}` === named);
|
|
108
110
|
if (!row)
|
|
109
111
|
throw new CliError("not_found", `no ${kind ?? ""} pipeline ${named}`, { reason: "pipeline.not-found" });
|
|
110
112
|
return row;
|
|
@@ -267,7 +269,7 @@ export const dealOperationsOver = (context) => {
|
|
|
267
269
|
});
|
|
268
270
|
return made;
|
|
269
271
|
});
|
|
270
|
-
return pipelinesOf(pipelineRows(pipelineKind).filter((row) => Number(row.id) === id))[0];
|
|
272
|
+
return pipelinesOf(pipelineRows(pipelineKind, undefined, { deleted: true }).filter((row) => Number(row.id) === id))[0];
|
|
271
273
|
},
|
|
272
274
|
pipelines: (filter = {}) => pipelinesOf(pipelineRows(filter.kind === undefined ? undefined : kindOf(filter.kind), filter.account)),
|
|
273
275
|
pipeline: (reference, options = {}) => {
|
|
@@ -54,6 +54,10 @@ export interface MarkedEmail {
|
|
|
54
54
|
changedAt: number;
|
|
55
55
|
/** Sent to many: the bulk mailbox or a List-Id. */
|
|
56
56
|
bulk: boolean;
|
|
57
|
+
/** Its own List-Id: a real mailing list, as opposed to List-Unsubscribe or Precedence alone. */
|
|
58
|
+
listed: boolean;
|
|
59
|
+
subject: string | null;
|
|
60
|
+
from: string | null;
|
|
57
61
|
envelope: boolean;
|
|
58
62
|
/** Stored from its first bytes, not whole yet. */
|
|
59
63
|
partial: boolean;
|
|
@@ -100,7 +100,8 @@ export const writtenTo = (database, addresses) => {
|
|
|
100
100
|
};
|
|
101
101
|
const MARKED = `
|
|
102
102
|
SELECT e.id, e.external_id, t.external_id AS thread, coalesce(e.received_at, e.sent_at) AS at, e.created_at,
|
|
103
|
-
e.updated_at,
|
|
103
|
+
e.updated_at, e.subject, e.from_address, coalesce(e.draft, 0) AS draft,
|
|
104
|
+
coalesce(json_extract(e.headers, '$."list-id"'), json_extract(e.metadata, '$.listId')) IS NOT NULL AS listed, json_extract(e.metadata, '$.partial') IS NOT NULL AS partial,
|
|
104
105
|
coalesce(json_extract(e.headers, '$."list-id"'), json_extract(e.metadata, '$.listId')) IS NOT NULL
|
|
105
106
|
OR EXISTS (SELECT 1 FROM email_mailboxes m JOIN mailboxes b ON b.id = m.mailbox_id
|
|
106
107
|
WHERE m.email_id = e.id AND b.kind = 'bulk') AS bulk,
|
|
@@ -117,6 +118,9 @@ const markedOf = (row) => ({
|
|
|
117
118
|
storedAt: Number(row.created_at),
|
|
118
119
|
changedAt: Number(row.updated_at),
|
|
119
120
|
bulk: Boolean(row.bulk),
|
|
121
|
+
listed: Boolean(row.listed),
|
|
122
|
+
subject: row.subject === null || row.subject === undefined ? null : String(row.subject),
|
|
123
|
+
from: row.from_address === null || row.from_address === undefined ? null : String(row.from_address),
|
|
120
124
|
envelope: Boolean(row.envelope),
|
|
121
125
|
partial: Boolean(row.partial),
|
|
122
126
|
draft: Boolean(row.draft),
|
|
@@ -8,6 +8,7 @@ import { markEmailsDeleted, markEmailsRestored, recountEmailThread as recount, s
|
|
|
8
8
|
import { bool, flag, fromJson, int, page, str, toJson } from "./events.js";
|
|
9
9
|
import { folded } from "./folded.js";
|
|
10
10
|
import { ensurePerson } from "./identities.js";
|
|
11
|
+
import { nearestMailPieces } from "./mail-nearest.js";
|
|
11
12
|
import { mailRulesOver } from "./mail-rules.js";
|
|
12
13
|
import { attachmentOf, saveAttachment, wordsQuery } from "./meetings.js";
|
|
13
14
|
import { stemmerCache } from "./stems.js";
|
|
@@ -15,7 +16,6 @@ import { writeState } from "./sync.js";
|
|
|
15
16
|
import { threadRows } from "./thread-columns.js";
|
|
16
17
|
import { write } from "./transaction.js";
|
|
17
18
|
import { dropUnusedVectors } from "./vector-index.js";
|
|
18
|
-
import { dot } from "./vectors.js";
|
|
19
19
|
/** A thread as a conversation: the summary `search conversations` and `related` answer for an email. */
|
|
20
20
|
export const threadConversation = (place, builtAt) => ({
|
|
21
21
|
id: `email:${place.emailId}`,
|
|
@@ -590,28 +590,10 @@ export const mailStoreOver = ({ database, orm, now }, stemmerFor = stemmerCache(
|
|
|
590
590
|
},
|
|
591
591
|
async nearest(model, query, { accountId, limit, threadExternalId, since }) {
|
|
592
592
|
drainEmailIndex(database, stemmerFor);
|
|
593
|
-
const
|
|
594
|
-
JOIN embeddings v ON v.model = ? AND v.content_hash = p.content_hash
|
|
595
|
-
WHERE p.id > ? AND p.account_id = ?
|
|
596
|
-
AND (? IS NULL OR p.thread_id IN (SELECT id FROM email_threads WHERE account_id = p.account_id AND external_id = ?))
|
|
597
|
-
AND (? IS NULL OR p.occurred_at >= ?)
|
|
598
|
-
ORDER BY p.id LIMIT 500`);
|
|
599
|
-
const best = new Map();
|
|
600
|
-
for (let after = 0;;) {
|
|
601
|
-
const rows = page.all(model, after, accountId, threadExternalId ?? null, threadExternalId ?? null, since ?? null, since ?? null);
|
|
602
|
-
for (const row of rows) {
|
|
603
|
-
const score = dot(query, row.vector);
|
|
604
|
-
const key = row.type === "email_thread" ? `${row.owner}:${row.position}` : `email:${row.email}`;
|
|
605
|
-
if (score > (best.get(key) ?? Number.NEGATIVE_INFINITY))
|
|
606
|
-
best.set(key, score);
|
|
607
|
-
}
|
|
608
|
-
if (rows.length < 500)
|
|
609
|
-
break;
|
|
610
|
-
after = Number(rows.at(-1)?.id);
|
|
611
|
-
}
|
|
593
|
+
const best = nearestMailPieces(database, model, query, { accountId, threadExternalId, since }, limit * 2);
|
|
612
594
|
const threads = new Map();
|
|
613
595
|
const emails = new Map();
|
|
614
|
-
for (const [key, score] of
|
|
596
|
+
for (const [key, score] of best) {
|
|
615
597
|
if (emails.size >= limit)
|
|
616
598
|
break;
|
|
617
599
|
const [owner, position] = key.split(":");
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CacheDatabase } from "../driver.js";
|
|
2
|
+
interface MailPieceFilter {
|
|
3
|
+
accountId: number;
|
|
4
|
+
threadExternalId?: string | undefined;
|
|
5
|
+
since?: number | undefined;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Each mail piece of the account (a thread's piece or an email's attachment) by its best score against `query`, best
|
|
9
|
+
* first. Without a thread the index gives the best `depth` of each kind; a thread, or an index not ready, scans.
|
|
10
|
+
*/
|
|
11
|
+
export declare const nearestMailPieces: (database: CacheDatabase, model: string, query: Float32Array, filter: MailPieceFilter, depth: number) => (readonly [string, number])[];
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { MAIL_PIECES } from "./email-index.js";
|
|
2
|
+
import { searchIndex } from "./vector-index.js";
|
|
3
|
+
import { dot } from "./vectors.js";
|
|
4
|
+
const MAIL_PIECE_ROWS = `SELECT p.id, p.content_hash AS hash, p.type, p.owner, p.position, p.email_id AS email, v.vector
|
|
5
|
+
FROM ${MAIL_PIECES} JOIN embeddings v ON v.model = ? AND v.content_hash = p.content_hash
|
|
6
|
+
WHERE p.account_id = ?
|
|
7
|
+
AND (? IS NULL OR p.thread_id IN (SELECT id FROM email_threads WHERE account_id = p.account_id AND external_id = ?))
|
|
8
|
+
AND (? IS NULL OR p.occurred_at >= ?)`;
|
|
9
|
+
const pieceKey = (row) => row.type === "email_thread" ? `${row.owner}:${row.position}` : `email:${row.email}`;
|
|
10
|
+
const filterParams = (model, { accountId, threadExternalId, since }) => [
|
|
11
|
+
model,
|
|
12
|
+
accountId,
|
|
13
|
+
threadExternalId ?? null,
|
|
14
|
+
threadExternalId ?? null,
|
|
15
|
+
since ?? null,
|
|
16
|
+
since ?? null,
|
|
17
|
+
];
|
|
18
|
+
const byScore = (best) => [...best.entries()].map(([key, { score }]) => [key, score]).sort(([, a], [, b]) => b - a);
|
|
19
|
+
/** Every mail piece of the account scored against `query`: each thread piece or attachment keeps its best score. */
|
|
20
|
+
const scannedMailPieces = (database, model, query, filter) => {
|
|
21
|
+
const page = database.prepare(`${MAIL_PIECE_ROWS} AND p.id > ? ORDER BY p.id LIMIT 500`);
|
|
22
|
+
const best = new Map();
|
|
23
|
+
for (let after = 0;;) {
|
|
24
|
+
const rows = page.all(...filterParams(model, filter), after);
|
|
25
|
+
for (const row of rows) {
|
|
26
|
+
const score = dot(query, row.vector);
|
|
27
|
+
const key = pieceKey(row);
|
|
28
|
+
if (score > (best.get(key)?.score ?? Number.NEGATIVE_INFINITY))
|
|
29
|
+
best.set(key, { score });
|
|
30
|
+
}
|
|
31
|
+
if (rows.length < 500)
|
|
32
|
+
break;
|
|
33
|
+
after = Number(rows.at(-1)?.id);
|
|
34
|
+
}
|
|
35
|
+
return byScore(best);
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The best `depth` pieces of each kind by the model's vector index, or `undefined` when the index cannot answer and
|
|
39
|
+
* the caller scans. Reading every mail vector of a large mailbox took most of a `search all`.
|
|
40
|
+
*/
|
|
41
|
+
const indexedMailPieces = (database, model, query, filter, depth) => {
|
|
42
|
+
const candidates = database.prepare(`${MAIL_PIECE_ROWS} AND p.id IN (SELECT value FROM json_each(?)) ORDER BY p.id`);
|
|
43
|
+
const merged = new Map();
|
|
44
|
+
for (const kind of ["email_thread", "attachment"]) {
|
|
45
|
+
const found = searchIndex(database, model, { kind, account: filter.accountId, ...(filter.since === undefined ? {} : { since: filter.since }) }, query, depth, (near) => {
|
|
46
|
+
const hashes = new Map(near.map(({ id, hash }) => [id, hash]));
|
|
47
|
+
const best = new Map();
|
|
48
|
+
for (const row of candidates.all(...filterParams(model, filter), JSON.stringify([...hashes.keys()]))) {
|
|
49
|
+
if (hashes.get(Number(row.id)) !== row.hash)
|
|
50
|
+
continue;
|
|
51
|
+
const score = dot(query, row.vector);
|
|
52
|
+
const key = pieceKey(row);
|
|
53
|
+
if (score > (best.get(key)?.score ?? Number.NEGATIVE_INFINITY))
|
|
54
|
+
best.set(key, { score, chunk: Number(row.id) });
|
|
55
|
+
}
|
|
56
|
+
const results = byScore(best)
|
|
57
|
+
.slice(0, depth)
|
|
58
|
+
.map(([key]) => [key, best.get(key)]);
|
|
59
|
+
return { results, best: results.map(([, { chunk }]) => chunk) };
|
|
60
|
+
});
|
|
61
|
+
if (!found)
|
|
62
|
+
return undefined;
|
|
63
|
+
for (const [key, held] of found)
|
|
64
|
+
merged.set(key, held);
|
|
65
|
+
}
|
|
66
|
+
return byScore(merged);
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Each mail piece of the account (a thread's piece or an email's attachment) by its best score against `query`, best
|
|
70
|
+
* first. Without a thread the index gives the best `depth` of each kind; a thread, or an index not ready, scans.
|
|
71
|
+
*/
|
|
72
|
+
export const nearestMailPieces = (database, model, query, filter, depth) => (filter.threadExternalId === undefined ? indexedMailPieces(database, model, query, filter, depth) : undefined) ??
|
|
73
|
+
scannedMailPieces(database, model, query, filter);
|
|
@@ -70,6 +70,7 @@ export const MANIFEST = [
|
|
|
70
70
|
{ name: "20261105144549_version-51-task-links", version: 51, minCompatible: 34 },
|
|
71
71
|
{ name: "20261106010504_version-52-deals", version: 52, minCompatible: 34 },
|
|
72
72
|
{ name: "20261106010507_version-52-deals-rules", version: 52, minCompatible: 34 },
|
|
73
|
+
{ name: "20261106165009_version-53-crm-pipelines", version: 53, minCompatible: 34 },
|
|
73
74
|
];
|
|
74
75
|
export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
|
|
75
76
|
const migrations = [];
|
|
@@ -1033,5 +1033,14 @@ export const GENERATED = [
|
|
|
1033
1033
|
"-- The default lead pipeline, so a lead can be recorded before the owner or an agent makes one.\nINSERT INTO pipelines (kind, name, name_folded, created_at, updated_at)\n VALUES ('lead', 'Leads', 'leads', CAST(strftime('%s', 'now') AS INTEGER) * 1000, CAST(strftime('%s', 'now') AS INTEGER) * 1000);",
|
|
1034
1034
|
"INSERT INTO pipeline_stages (pipeline_id, name, name_folded, position, outcome, created_at, updated_at)\n SELECT p.id, v.name, v.name, v.position, v.outcome, p.created_at, p.created_at FROM pipelines p,\n (SELECT 'new' AS name, 1 AS position, 'open' AS outcome UNION ALL SELECT 'contacted', 2, 'open'\n UNION ALL SELECT 'qualified', 3, 'open' UNION ALL SELECT 'won', 4, 'won' UNION ALL SELECT 'lost', 5, 'lost') v\n WHERE p.kind = 'lead' AND p.name_folded = 'leads' AND p.account_id IS NULL;"
|
|
1035
1035
|
]
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
"name": "20261106165009_version-53-crm-pipelines",
|
|
1039
|
+
"statements": [
|
|
1040
|
+
"ALTER TABLE `pipeline_stages` ADD `deleted_at` integer;",
|
|
1041
|
+
"ALTER TABLE `pipelines` ADD `deleted_at` integer;",
|
|
1042
|
+
"DROP INDEX IF EXISTS `pipelines_by_account_id_external_id`;",
|
|
1043
|
+
"CREATE UNIQUE INDEX `pipelines_by_account_id_kind_external_id` ON `pipelines` (`account_id`,`kind`,`external_id`) WHERE account_id IS NOT NULL AND external_id IS NOT NULL;"
|
|
1044
|
+
]
|
|
1036
1045
|
}
|
|
1037
1046
|
];
|
|
@@ -15527,6 +15527,22 @@ export declare const pipelines: import("drizzle-orm/sqlite-core").SQLiteTableWit
|
|
|
15527
15527
|
identity: undefined;
|
|
15528
15528
|
generated: undefined;
|
|
15529
15529
|
}, {}>;
|
|
15530
|
+
deletedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
15531
|
+
name: string;
|
|
15532
|
+
tableName: "pipelines";
|
|
15533
|
+
dataType: "number int53";
|
|
15534
|
+
data: number;
|
|
15535
|
+
driverParam: number;
|
|
15536
|
+
notNull: false;
|
|
15537
|
+
hasDefault: false;
|
|
15538
|
+
isPrimaryKey: false;
|
|
15539
|
+
isAutoincrement: false;
|
|
15540
|
+
hasRuntimeDefault: false;
|
|
15541
|
+
enumValues: undefined;
|
|
15542
|
+
baseColumn: never;
|
|
15543
|
+
identity: undefined;
|
|
15544
|
+
generated: undefined;
|
|
15545
|
+
}, {}>;
|
|
15530
15546
|
};
|
|
15531
15547
|
dialect: 'sqlite';
|
|
15532
15548
|
isAlias: false;
|
|
@@ -15679,6 +15695,22 @@ export declare const pipelineStages: import("drizzle-orm/sqlite-core").SQLiteTab
|
|
|
15679
15695
|
identity: undefined;
|
|
15680
15696
|
generated: undefined;
|
|
15681
15697
|
}, {}>;
|
|
15698
|
+
deletedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
15699
|
+
name: string;
|
|
15700
|
+
tableName: "pipeline_stages";
|
|
15701
|
+
dataType: "number int53";
|
|
15702
|
+
data: number;
|
|
15703
|
+
driverParam: number;
|
|
15704
|
+
notNull: false;
|
|
15705
|
+
hasDefault: false;
|
|
15706
|
+
isPrimaryKey: false;
|
|
15707
|
+
isAutoincrement: false;
|
|
15708
|
+
hasRuntimeDefault: false;
|
|
15709
|
+
enumValues: undefined;
|
|
15710
|
+
baseColumn: never;
|
|
15711
|
+
identity: undefined;
|
|
15712
|
+
generated: undefined;
|
|
15713
|
+
}, {}>;
|
|
15682
15714
|
};
|
|
15683
15715
|
dialect: 'sqlite';
|
|
15684
15716
|
isAlias: false;
|
|
@@ -1545,9 +1545,10 @@ export const pipelines = sqliteTable("pipelines", {
|
|
|
1545
1545
|
nameFolded: text("name_folded").notNull(),
|
|
1546
1546
|
createdAt: integer("created_at").notNull(),
|
|
1547
1547
|
updatedAt: integer("updated_at").notNull(),
|
|
1548
|
+
deletedAt: integer("deleted_at"),
|
|
1548
1549
|
}, (table) => [
|
|
1549
|
-
uniqueIndex("
|
|
1550
|
-
.on(table.accountId, table.externalId)
|
|
1550
|
+
uniqueIndex("pipelines_by_account_id_kind_external_id")
|
|
1551
|
+
.on(table.accountId, table.kind, table.externalId)
|
|
1551
1552
|
.where(sql `account_id IS NOT NULL AND external_id IS NOT NULL`),
|
|
1552
1553
|
uniqueIndex("pipelines_by_name_folded_kind").on(table.nameFolded, table.kind).where(sql `account_id IS NULL`),
|
|
1553
1554
|
]);
|
|
@@ -1563,6 +1564,7 @@ export const pipelineStages = sqliteTable("pipeline_stages", {
|
|
|
1563
1564
|
externalId: text("external_id"),
|
|
1564
1565
|
createdAt: integer("created_at").notNull(),
|
|
1565
1566
|
updatedAt: integer("updated_at").notNull(),
|
|
1567
|
+
deletedAt: integer("deleted_at"),
|
|
1566
1568
|
}, (table) => [
|
|
1567
1569
|
unique().on(table.pipelineId, table.nameFolded),
|
|
1568
1570
|
uniqueIndex("pipeline_stages_by_pipeline_id_external_id")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.594.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",
|
|
@@ -270,7 +270,7 @@
|
|
|
270
270
|
"@types/node": "^22.20.5",
|
|
271
271
|
"@types/pngjs": "6.0.5",
|
|
272
272
|
"@vitest/coverage-v8": "^5.0.5",
|
|
273
|
-
"@wirecat/cli-base": "0.80.
|
|
273
|
+
"@wirecat/cli-base": "0.80.2",
|
|
274
274
|
"@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
|
|
275
275
|
"commander": "15.0.0",
|
|
276
276
|
"cspell": "10.4.0",
|