@wirecat/cli-core 0.587.0 → 0.589.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 +42 -0
- package/dist/cli/messenger/search-answers.js +3 -0
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/domain/deals.d.ts +8 -6
- package/dist/domain/deals.js +7 -8
- package/dist/domain/providers.d.ts +7 -1
- package/dist/domain/providers.js +3 -1
- package/dist/domain/ref-summaries.js +2 -0
- package/dist/domain/references.d.ts +1 -1
- package/dist/domain/references.js +2 -0
- package/dist/entities/hit.d.ts +2 -2
- package/dist/entities/hit.js +2 -2
- package/dist/entities/index.d.ts +1 -1
- package/dist/errors/reasons.js +6 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/search/account-list.js +1 -0
- package/dist/services/project-status/index.d.ts +1 -1
- package/dist/services/project-status/index.js +13 -12
- package/dist/services/project-status/signals/amounts.d.ts +1 -0
- package/dist/services/project-status/signals/amounts.js +4 -3
- package/dist/services/project-status/types.d.ts +2 -0
- package/dist/services/proposals/registry.js +0 -2
- package/dist/services/search-all-meetings.d.ts +1 -1
- package/dist/services/search-all-meetings.js +1 -0
- package/dist/services/search-all.d.ts +15 -5
- package/dist/services/search-all.js +46 -3
- package/dist/store/index.d.ts +1 -1
- package/dist/store/sqlite/deals.d.ts +142 -29
- package/dist/store/sqlite/deals.js +295 -156
- package/dist/store/sqlite/entity-types.d.ts +3 -1
- package/dist/store/sqlite/entity-types.js +3 -1
- package/dist/store/sqlite/knowledge.js +3 -1
- package/dist/store/sqlite/migrations.generated.js +39 -11
- package/dist/store/sqlite/ref-summaries.js +11 -7
- package/dist/store/sqlite/schema.d.ts +600 -38
- package/dist/store/sqlite/schema.js +86 -14
- package/dist/store/sqlite/things.js +2 -0
- package/package.json +1 -1
- package/dist/services/proposals/kinds/deals.d.ts +0 -4
- package/dist/services/proposals/kinds/deals.js +0 -32
|
@@ -1,44 +1,43 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
import { DEAL_ROLES, DEAL_STATUSES, dealLine } from "../../domain/deals.js";
|
|
3
|
-
import { FINANCE_REVIEWS } from "../../domain/finance.js";
|
|
2
|
+
import { DEAL_KINDS, DEAL_ROLES, DEAL_STATUSES, dealLine, STAGE_OUTCOMES, } from "../../domain/deals.js";
|
|
4
3
|
import { currencyOf, formatDecimal, minorUnits, scaleOf } from "../../domain/money.js";
|
|
5
|
-
import { assertMayDecide, deciderAuthor } from "../decisions.js";
|
|
6
4
|
import { fold } from "../normalize.js";
|
|
7
|
-
import { proposedBySql
|
|
5
|
+
import { proposedBySql } from "./actors.js";
|
|
8
6
|
import { isoDate, oneOf, refuse, reviewedRowsOver, text } from "./finance.js";
|
|
9
7
|
import { linkKind } from "./link-kinds.js";
|
|
10
|
-
import { authorActor, projectId } from "./memories.js";
|
|
8
|
+
import { authorActor, authorOf, evidenceOf, linkEvidence, projectId, projectOf } from "./memories.js";
|
|
11
9
|
import { requiredThing } from "./notes.js";
|
|
12
10
|
import { write } from "./transaction.js";
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
11
|
+
const TABLES = { deal: "deals", lead: "leads" };
|
|
12
|
+
const itemsSql = (kind) => "SELECT d.*, s.name AS stage_name, s.pipeline_id, p.name AS pipeline_name, b.name AS author_name, " +
|
|
13
|
+
"b.kind AS author_kind, a.provider AS account_provider, a.external_id AS account_external_id " +
|
|
14
|
+
`FROM ${TABLES[kind]} d JOIN pipeline_stages s ON s.id = d.stage_id JOIN pipelines p ON p.id = s.pipeline_id ` +
|
|
15
|
+
"LEFT JOIN bots b ON d.author_type = 'bot' AND b.id = d.author_id LEFT JOIN accounts a ON a.id = d.account_id";
|
|
16
|
+
const MAX_STAGES = 30;
|
|
17
17
|
const MAX_PEOPLE = 20;
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
const iso = (value) => new Date(Number(value)).toISOString();
|
|
19
|
+
const jsonField = (metadata, key) => {
|
|
20
|
+
if (metadata == null)
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const value = JSON.parse(String(metadata))[key];
|
|
24
|
+
return typeof value === "string" ? value : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
21
29
|
};
|
|
30
|
+
const accountOf = (provider, external) => provider == null ? null : { provider: String(provider), account: String(external) };
|
|
22
31
|
export const dealOperationsOver = (context) => {
|
|
23
32
|
const { database, now } = context;
|
|
24
|
-
const { partyOf, partyThing
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
try {
|
|
29
|
-
const { closed_reason } = JSON.parse(String(metadata));
|
|
30
|
-
return typeof closed_reason === "string" ? closed_reason : null;
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
const peopleOf = (id) => database
|
|
37
|
-
.prepare(`SELECT to_id, role FROM links WHERE from_type = 'deal' AND from_id = ? AND kind = ${linkKind("related-to")} ` +
|
|
33
|
+
const { partyOf, partyThing } = reviewedRowsOver(context);
|
|
34
|
+
const today = () => new Date(now()).toISOString().slice(0, 10);
|
|
35
|
+
const peopleOf = (kind, id) => database
|
|
36
|
+
.prepare(`SELECT to_id, role FROM links WHERE from_type = ? AND from_id = ? AND kind = ${linkKind("related-to")} ` +
|
|
38
37
|
"AND to_type = 'person' AND to_id IS NOT NULL ORDER BY id")
|
|
39
|
-
.all(id)
|
|
38
|
+
.all(kind, id)
|
|
40
39
|
.map((row) => ({ ...partyOf("person", row.to_id), role: row.role }));
|
|
41
|
-
const
|
|
40
|
+
const itemOf = (kind) => (row) => {
|
|
42
41
|
const currency = row.currency == null ? null : String(row.currency);
|
|
43
42
|
const amount = row.amount == null || currency === null ? null : Number(row.amount);
|
|
44
43
|
const fields = {
|
|
@@ -48,14 +47,15 @@ export const dealOperationsOver = (context) => {
|
|
|
48
47
|
amount,
|
|
49
48
|
currency,
|
|
50
49
|
status: row.status,
|
|
51
|
-
review: row.review,
|
|
52
50
|
};
|
|
53
51
|
return {
|
|
54
52
|
id: String(row.id),
|
|
55
|
-
ref:
|
|
53
|
+
ref: `${kind}:${row.id}`,
|
|
54
|
+
kind,
|
|
56
55
|
title: fields.title,
|
|
57
56
|
summary: dealLine(fields),
|
|
58
|
-
pipeline:
|
|
57
|
+
pipeline: `pipeline:${row.pipeline_id}`,
|
|
58
|
+
pipelineName: String(row.pipeline_name),
|
|
59
59
|
stage: fields.stage,
|
|
60
60
|
status: fields.status,
|
|
61
61
|
organization: partyOf(row.organization_id == null ? null : "organization", row.organization_id),
|
|
@@ -63,118 +63,234 @@ export const dealOperationsOver = (context) => {
|
|
|
63
63
|
amount: amount === null || currency === null ? null : formatDecimal(amount, scaleOf(currency)),
|
|
64
64
|
closeDate: row.close_date == null ? null : String(row.close_date),
|
|
65
65
|
closedDate: row.closed_date == null ? null : String(row.closed_date),
|
|
66
|
-
closedReason:
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
closedReason: jsonField(row.metadata, "closed_reason"),
|
|
67
|
+
rank: row.rank == null ? null : Number(row.rank),
|
|
68
|
+
deal: row.deal_id == null ? null : `deal:${row.deal_id}`,
|
|
69
|
+
account: accountOf(row.account_provider, row.account_external_id),
|
|
70
|
+
externalId: row.external_id == null ? null : String(row.external_id),
|
|
71
|
+
people: peopleOf(kind, fields.id),
|
|
72
|
+
author: authorOf(row),
|
|
73
|
+
model: row.model == null ? null : String(row.model),
|
|
74
|
+
source: String(row.source),
|
|
75
|
+
reason: jsonField(row.metadata, "reason"),
|
|
76
|
+
evidence: evidenceOf(context, { type: kind, id: fields.id }),
|
|
77
|
+
...projectOf(database, row),
|
|
78
|
+
createdAt: iso(row.created_at),
|
|
79
|
+
updatedAt: iso(row.updated_at),
|
|
69
80
|
};
|
|
70
81
|
};
|
|
71
82
|
const rowOf = (reference) => {
|
|
72
83
|
const thing = requiredThing(database, reference.includes(":") ? reference : `deal:${reference}`, "the deal");
|
|
73
|
-
if (thing.type !== "deal")
|
|
74
|
-
return refuse(`${reference} is not a deal`, "deal.ref");
|
|
75
|
-
return database.prepare(`${
|
|
84
|
+
if (thing.type !== "deal" && thing.type !== "lead")
|
|
85
|
+
return refuse(`${reference} is not a deal or a lead`, "deal.ref");
|
|
86
|
+
return { kind: thing.type, row: database.prepare(`${itemsSql(thing.type)} WHERE d.id = ?`).get(thing.id) };
|
|
76
87
|
};
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
throw new CliError("not_found", `no pipeline named ${name}`, { reason: "pipeline.not-found" });
|
|
81
|
-
return Number(found.id);
|
|
88
|
+
const read = (reference) => {
|
|
89
|
+
const { kind, row } = rowOf(reference);
|
|
90
|
+
return itemOf(kind)(row);
|
|
82
91
|
};
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
const kindOf = (kind) => (kind === undefined ? "deal" : oneOf(kind, DEAL_KINDS, "a kind"));
|
|
93
|
+
const pipelineRows = (kind) => database
|
|
94
|
+
.prepare("SELECT p.*, a.provider AS account_provider, a.external_id AS account_external_id FROM pipelines p " +
|
|
95
|
+
"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);
|
|
97
|
+
const label = (row) => `pipeline:${row.id} (${row.name}${row.account_provider == null ? "" : `, ${row.account_provider}:${row.account_external_id}`})`;
|
|
98
|
+
/** A pipeline by `pipeline:<id>`, or by a name only one pipeline of the kind has; never a guess. */
|
|
99
|
+
const pipelineRow = (reference, kind) => {
|
|
100
|
+
const named = reference.trim();
|
|
101
|
+
if (/^pipeline:\d+$/.test(named)) {
|
|
102
|
+
const row = pipelineRows(kind).find((one) => `pipeline:${one.id}` === named);
|
|
103
|
+
if (!row)
|
|
104
|
+
throw new CliError("not_found", `no ${kind ?? ""} pipeline ${named}`, { reason: "pipeline.not-found" });
|
|
105
|
+
return row;
|
|
106
|
+
}
|
|
107
|
+
const rows = pipelineRows(kind).filter((one) => one.name_folded === fold(named));
|
|
89
108
|
if (rows.length === 0)
|
|
90
|
-
|
|
109
|
+
throw new CliError("not_found", `no ${kind === undefined ? "" : `${kind} `}pipeline named ${named}`, {
|
|
110
|
+
reason: "pipeline.not-found",
|
|
111
|
+
});
|
|
91
112
|
if (rows.length > 1)
|
|
92
|
-
return refuse(`more than one pipeline
|
|
93
|
-
return
|
|
113
|
+
return refuse(`more than one pipeline is named ${named}: ${rows.map(label).join(", ")}; name one by its reference`, "pipeline.ambiguous");
|
|
114
|
+
return rows[0];
|
|
94
115
|
};
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
.
|
|
105
|
-
|
|
116
|
+
const stagesOf = (pipelineIds) => database
|
|
117
|
+
.prepare("SELECT s.*, (SELECT count(*) FROM deals d WHERE d.stage_id = s.id AND d.status = 'open' AND d.deleted_at IS NULL) + " +
|
|
118
|
+
"(SELECT count(*) FROM leads l WHERE l.stage_id = s.id AND l.status = 'open' AND l.deleted_at IS NULL) AS open " +
|
|
119
|
+
`FROM pipeline_stages s WHERE s.pipeline_id IN (${pipelineIds.map(() => "?").join(", ") || "NULL"}) ` +
|
|
120
|
+
"ORDER BY s.pipeline_id, s.position, s.id")
|
|
121
|
+
.all(...pipelineIds);
|
|
122
|
+
const pipelinesOf = (rows) => {
|
|
123
|
+
const stages = stagesOf(rows.map((row) => Number(row.id)));
|
|
124
|
+
return rows.map((row) => ({
|
|
125
|
+
id: String(row.id),
|
|
126
|
+
ref: `pipeline:${row.id}`,
|
|
127
|
+
name: String(row.name),
|
|
128
|
+
kind: row.kind,
|
|
129
|
+
account: accountOf(row.account_provider, row.account_external_id),
|
|
130
|
+
externalId: row.external_id == null ? null : String(row.external_id),
|
|
106
131
|
stages: stages
|
|
107
|
-
.filter((stage) => stage.pipeline_id ===
|
|
108
|
-
.map((stage) => ({
|
|
109
|
-
|
|
132
|
+
.filter((stage) => stage.pipeline_id === row.id)
|
|
133
|
+
.map((stage) => ({
|
|
134
|
+
name: String(stage.name),
|
|
135
|
+
position: Number(stage.position),
|
|
136
|
+
outcome: stage.outcome,
|
|
137
|
+
externalId: stage.external_id == null ? null : String(stage.external_id),
|
|
138
|
+
open: Number(stage.open),
|
|
139
|
+
})),
|
|
140
|
+
createdAt: iso(row.created_at),
|
|
110
141
|
}));
|
|
111
142
|
};
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
143
|
+
/** A stage by name in one pipeline, or in the only pipeline of the kind that has one of that name. */
|
|
144
|
+
const stageRow = (stage, kind, pipeline) => {
|
|
145
|
+
const rows = database
|
|
146
|
+
.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);
|
|
149
|
+
if (rows.length === 0)
|
|
150
|
+
return refuse(`no stage named ${stage} in ${pipeline === undefined ? `a ${kind} pipeline` : "that pipeline"}`, "deal.stage");
|
|
151
|
+
if (rows.length > 1)
|
|
152
|
+
return refuse(`more than one pipeline has a stage named ${stage}: ${rows.map((row) => `pipeline:${row.pipeline_id} (${row.pipeline_name})`).join(", ")}; name the pipeline`, "deal.stage");
|
|
153
|
+
return rows[0];
|
|
115
154
|
};
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
155
|
+
const firstStage = (kind, pipeline) => {
|
|
156
|
+
const pipelines = pipeline === undefined ? pipelineRows(kind) : [{ id: pipeline }];
|
|
157
|
+
if (pipelines.length !== 1)
|
|
158
|
+
return refuse(`name the stage: ${pipelines.length === 0 ? `no ${kind} pipeline is stored` : `more than one ${kind} pipeline is stored`}`, "deal.stage");
|
|
159
|
+
const row = database
|
|
160
|
+
.prepare("SELECT * FROM pipeline_stages WHERE pipeline_id = ? ORDER BY position, id LIMIT 1")
|
|
161
|
+
.get(Number(pipelines[0]?.id));
|
|
162
|
+
return row ?? refuse("the pipeline has no stages", "deal.stage");
|
|
163
|
+
};
|
|
164
|
+
const funnelOf = (pipeline, since) => {
|
|
165
|
+
const kind = pipeline.kind;
|
|
166
|
+
const cutoff = since === null ? null : Date.parse(`${since}T00:00:00Z`);
|
|
167
|
+
const items = database
|
|
168
|
+
.prepare(`SELECT d.id, d.status, d.stage_id FROM ${TABLES[kind]} d JOIN pipeline_stages s ON s.id = d.stage_id ` +
|
|
169
|
+
"WHERE s.pipeline_id = ? AND d.deleted_at IS NULL AND (? IS NULL OR d.created_at >= ?)")
|
|
170
|
+
.all(Number(pipeline.id), cutoff, cutoff);
|
|
171
|
+
const stages = database
|
|
172
|
+
.prepare("SELECT id, position, outcome FROM pipeline_stages WHERE pipeline_id = ? ORDER BY position, id")
|
|
173
|
+
.all(Number(pipeline.id));
|
|
174
|
+
const order = new Map(stages.map((stage, at) => [Number(stage.id), at]));
|
|
175
|
+
const lostStage = new Set(stages.filter((stage) => stage.outcome === "lost").map((stage) => Number(stage.id)));
|
|
176
|
+
const entered = new Map();
|
|
177
|
+
for (const change of database
|
|
178
|
+
.prepare("SELECT item_id, to_stage_id FROM pipeline_stage_changes WHERE item_type = ? AND pipeline_id = ?")
|
|
179
|
+
.all(kind, Number(pipeline.id))) {
|
|
180
|
+
const list = entered.get(Number(change.item_id)) ?? [];
|
|
181
|
+
list.push(Number(change.to_stage_id));
|
|
182
|
+
entered.set(Number(change.item_id), list);
|
|
183
|
+
}
|
|
184
|
+
const furthest = items.map((item) => {
|
|
185
|
+
if (item.status === "won")
|
|
186
|
+
return Number.POSITIVE_INFINITY;
|
|
187
|
+
const reached = [...(entered.get(Number(item.id)) ?? []), Number(item.stage_id)]
|
|
188
|
+
.filter((stage) => !lostStage.has(stage))
|
|
189
|
+
.map((stage) => order.get(stage) ?? -1);
|
|
190
|
+
return Math.max(-1, ...reached);
|
|
135
191
|
});
|
|
136
|
-
|
|
192
|
+
const lost = items.filter((item) => item.status === "lost").length;
|
|
193
|
+
const reachedAt = (at) => lostStage.has(Number(stages[at]?.id)) ? lost : furthest.filter((far) => far >= at).length;
|
|
194
|
+
const funnel = pipeline.stages.map((stage, at) => {
|
|
195
|
+
const reached = reachedAt(at);
|
|
196
|
+
const next = stages.findIndex((one, after) => after > at && !lostStage.has(Number(one.id)));
|
|
197
|
+
const conversion = lostStage.has(Number(stages[at]?.id)) || next === -1 || reached === 0
|
|
198
|
+
? null
|
|
199
|
+
: Math.round((reachedAt(next) / reached) * 1000) / 1000;
|
|
200
|
+
return { ...stage, reached, conversion };
|
|
201
|
+
});
|
|
202
|
+
const won = items.filter((item) => item.status === "won").length;
|
|
203
|
+
return {
|
|
204
|
+
...pipeline,
|
|
205
|
+
stages: funnel,
|
|
206
|
+
totals: {
|
|
207
|
+
items: items.length,
|
|
208
|
+
open: items.length - won - lost,
|
|
209
|
+
won,
|
|
210
|
+
lost,
|
|
211
|
+
winRate: won + lost === 0 ? null : Math.round((won / (won + lost)) * 1000) / 1000,
|
|
212
|
+
},
|
|
213
|
+
since,
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
/** Sets the stage and, from its outcome, the status; a stage that ends the row closes it today. */
|
|
217
|
+
const setStage = (kind, id, stage, status, date) => {
|
|
218
|
+
database
|
|
219
|
+
.prepare(`UPDATE ${TABLES[kind]} SET stage_id = ?, status = ?, closed_date = ?, updated_at = ? WHERE id = ?`)
|
|
220
|
+
.run(Number(stage.id), status, status === "open" ? null : date, now(), id);
|
|
221
|
+
};
|
|
222
|
+
const notDeleted = (row, kind, what) => {
|
|
223
|
+
if (row.deleted_at != null)
|
|
224
|
+
refuse(`${kind}:${row.id} is deleted; it no longer ${what}`, "deal.closed");
|
|
137
225
|
};
|
|
138
226
|
return {
|
|
139
|
-
addPipeline: ({ name, stages, by }) => {
|
|
140
|
-
|
|
227
|
+
addPipeline: ({ name, kind, stages, by }) => {
|
|
228
|
+
const pipelineKind = kindOf(kind);
|
|
141
229
|
const named = text(name, "a pipeline's name", 80) ?? refuse("a pipeline needs a name", "pipeline.field");
|
|
142
230
|
if (!Array.isArray(stages) || stages.length === 0 || stages.length > MAX_STAGES)
|
|
143
231
|
refuse(`a pipeline has 1–${MAX_STAGES} stages`, "pipeline.field");
|
|
144
|
-
const kept = stages.map((stage) =>
|
|
145
|
-
|
|
232
|
+
const kept = stages.map((stage) => {
|
|
233
|
+
const one = typeof stage === "string" ? { name: stage } : stage;
|
|
234
|
+
return {
|
|
235
|
+
name: text(one.name, "a stage's name", 80),
|
|
236
|
+
outcome: one.outcome === undefined ? "open" : oneOf(one.outcome, STAGE_OUTCOMES, "a stage's outcome"),
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
if (new Set(kept.map((stage) => fold(stage.name))).size !== kept.length)
|
|
146
240
|
refuse("each stage is named once", "pipeline.field");
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
241
|
+
authorActor(context, by);
|
|
242
|
+
const id = write(database, () => {
|
|
243
|
+
if (database
|
|
244
|
+
.prepare("SELECT 1 FROM pipelines WHERE name_folded = ? AND kind = ? AND account_id IS NULL")
|
|
245
|
+
.get(fold(named), pipelineKind))
|
|
246
|
+
refuse(`a ${pipelineKind} pipeline named ${named} is already stored`, "pipeline.duplicate");
|
|
150
247
|
const at = now();
|
|
151
|
-
const
|
|
152
|
-
.prepare("INSERT INTO pipelines (name, name_folded, created_at, updated_at) VALUES (?, ?, ?, ?) RETURNING id")
|
|
153
|
-
.get(named, fold(named), at, at)?.id;
|
|
154
|
-
const insert = database.prepare("INSERT INTO pipeline_stages (pipeline_id, name, name_folded, position, created_at, updated_at) " +
|
|
155
|
-
"VALUES (?, ?, ?, ?, ?, ?)");
|
|
156
|
-
kept.forEach((stage,
|
|
157
|
-
insert.run(
|
|
248
|
+
const made = Number(database
|
|
249
|
+
.prepare("INSERT INTO pipelines (kind, name, name_folded, created_at, updated_at) VALUES (?, ?, ?, ?, ?) RETURNING id")
|
|
250
|
+
.get(pipelineKind, named, fold(named), at, at)?.id);
|
|
251
|
+
const insert = database.prepare("INSERT INTO pipeline_stages (pipeline_id, name, name_folded, position, outcome, created_at, updated_at) " +
|
|
252
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)");
|
|
253
|
+
kept.forEach((stage, index) => {
|
|
254
|
+
insert.run(made, stage.name, fold(stage.name), index + 1, stage.outcome, at, at);
|
|
158
255
|
});
|
|
256
|
+
return made;
|
|
159
257
|
});
|
|
160
|
-
return pipelinesOf().
|
|
258
|
+
return pipelinesOf(pipelineRows(pipelineKind).filter((row) => Number(row.id) === id))[0];
|
|
259
|
+
},
|
|
260
|
+
pipelines: (filter = {}) => pipelinesOf(pipelineRows(filter.kind === undefined ? undefined : kindOf(filter.kind))),
|
|
261
|
+
pipeline: (reference, options = {}) => {
|
|
262
|
+
const kind = options.kind === undefined ? undefined : kindOf(options.kind);
|
|
263
|
+
const since = isoDate(options.since, "the first day counted");
|
|
264
|
+
return funnelOf(pipelinesOf([pipelineRow(reference, kind)])[0], since);
|
|
161
265
|
},
|
|
162
|
-
pipelines: () => pipelinesOf(),
|
|
163
266
|
add: (input) => {
|
|
164
|
-
const
|
|
165
|
-
const
|
|
267
|
+
const kind = kindOf(input.kind);
|
|
268
|
+
const title = text(input.title, `a ${kind}'s title`, 200) ?? refuse(`a ${kind} needs a title`, "deal.field");
|
|
166
269
|
if ((input.amount === undefined) !== (input.currency === undefined))
|
|
167
|
-
refuse("
|
|
270
|
+
refuse("an amount and its currency come together", "deal.field");
|
|
168
271
|
const currency = input.currency === undefined ? null : currencyOf(input.currency);
|
|
169
|
-
const amount = currency === null ? null : minorUnits(input.amount, currency, "
|
|
272
|
+
const amount = currency === null ? null : minorUnits(input.amount, currency, "an amount");
|
|
170
273
|
if (amount !== null && amount < 0)
|
|
171
|
-
refuse("
|
|
172
|
-
const closeDate = isoDate(input.closeDate, "
|
|
274
|
+
refuse("an amount is 0 or more", "money.amount");
|
|
275
|
+
const closeDate = isoDate(input.closeDate, "an expected close day");
|
|
173
276
|
const people = input.people ?? [];
|
|
174
277
|
if (people.length > MAX_PEOPLE)
|
|
175
|
-
refuse(`
|
|
278
|
+
refuse(`at most ${MAX_PEOPLE} people`, "deal.field");
|
|
279
|
+
const owner = input.by === "owner";
|
|
280
|
+
const source = owner ? "owner" : (text(input.source, "a source", 80) ?? "agent");
|
|
281
|
+
const reason = text(input.reason, "a reason", 2000);
|
|
282
|
+
const evidence = input.evidence ?? [];
|
|
283
|
+
if (!Array.isArray(evidence) || evidence.length > 8)
|
|
284
|
+
refuse("evidence is at most 8 references", "deal.field");
|
|
176
285
|
const id = write(database, () => {
|
|
177
|
-
const
|
|
286
|
+
const pipeline = input.pipeline === undefined ? undefined : Number(pipelineRow(input.pipeline, kind).id);
|
|
287
|
+
const stageText = text(input.stage, "a stage's name", 80);
|
|
288
|
+
const stage = stageText === null
|
|
289
|
+
? kind === "lead"
|
|
290
|
+
? firstStage(kind, pipeline)
|
|
291
|
+
: refuse("a deal starts in a stage", "deal.field")
|
|
292
|
+
: stageRow(stageText, kind, pipeline);
|
|
293
|
+
const status = stage.outcome ?? "open";
|
|
178
294
|
const organization = partyThing(input.organization, "the organization");
|
|
179
295
|
if (organization !== undefined && organization.type !== "organization")
|
|
180
296
|
refuse(`the organization is an organization: reference, not ${input.organization}`, "deal.field");
|
|
@@ -182,83 +298,106 @@ export const dealOperationsOver = (context) => {
|
|
|
182
298
|
const thing = requiredThing(database, person, "the person");
|
|
183
299
|
if (thing.type !== "person")
|
|
184
300
|
return refuse(`${person} is not a person`, "deal.field");
|
|
185
|
-
return { id: thing.id, role: oneOf(role, DEAL_ROLES, "a person's role
|
|
301
|
+
return { id: thing.id, role: oneOf(role, DEAL_ROLES, "a person's role") };
|
|
186
302
|
});
|
|
187
|
-
const
|
|
303
|
+
const { actor, model } = authorActor(context, input.by);
|
|
188
304
|
const at = now();
|
|
189
305
|
const made = Number(database
|
|
190
|
-
.prepare(
|
|
191
|
-
"close_date,
|
|
192
|
-
"
|
|
193
|
-
.get(title, organization?.id ?? null,
|
|
306
|
+
.prepare(`INSERT INTO ${TABLES[kind]} (title, organization_id, project_id, stage_id, status, currency, amount, ` +
|
|
307
|
+
"close_date, closed_date, author_type, author_id, model, source, metadata, created_at, updated_at) " +
|
|
308
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
|
|
309
|
+
.get(title, organization?.id ?? null, input.project === undefined ? null : projectId(database, input.project), Number(stage.id), status, currency, amount, closeDate, status === "open" ? null : today(), actor.type, actor.id, model, source, reason === null ? null : JSON.stringify({ reason }), at, at)?.id);
|
|
194
310
|
const link = database.prepare("INSERT INTO links (from_type, from_id, to_type, to_id, kind, role, source, confirmed, created_at, author, " +
|
|
195
|
-
`updated_at) VALUES (
|
|
311
|
+
`updated_at) VALUES (?, ?, 'person', ?, ${linkKind("related-to")}, ?, ?, 1, ?, ?, ?)`);
|
|
196
312
|
for (const person of persons)
|
|
197
|
-
link.run(made, person.id, person.role,
|
|
198
|
-
|
|
313
|
+
link.run(kind, made, person.id, person.role, owner ? "owner" : "suggested", at, owner ? "owner" : "agent", at);
|
|
314
|
+
if (evidence.length)
|
|
315
|
+
linkEvidence(context, { type: kind, id: made }, evidence, owner ? "owner" : "agent");
|
|
199
316
|
return made;
|
|
200
317
|
});
|
|
201
|
-
return
|
|
318
|
+
return read(`${kind}:${id}`);
|
|
202
319
|
},
|
|
203
|
-
get: (reference) =>
|
|
320
|
+
get: (reference) => read(reference),
|
|
204
321
|
list: (filter = {}) => {
|
|
322
|
+
const kind = kindOf(filter.kind);
|
|
205
323
|
const project = filter.project === undefined ? null : projectId(database, filter.project);
|
|
206
324
|
const organization = partyThing(filter.organization, "the organization");
|
|
207
325
|
const by = proposedBySql("d.author_type", "d.author_id", filter.proposedBy);
|
|
208
|
-
const status = filter.status === undefined ? null : oneOf(filter.status, DEAL_STATUSES, "a
|
|
209
|
-
const review = filter.review === undefined ? null : oneOf(filter.review, FINANCE_REVIEWS, "a review");
|
|
326
|
+
const status = filter.status === undefined ? null : oneOf(filter.status, DEAL_STATUSES, "a status");
|
|
210
327
|
const stage = filter.stage === undefined ? null : fold(filter.stage.trim());
|
|
211
|
-
const pipeline = filter.pipeline === undefined ? null :
|
|
328
|
+
const pipeline = filter.pipeline === undefined ? null : Number(pipelineRow(filter.pipeline, kind).id);
|
|
212
329
|
const limit = Math.min(Math.max(filter.limit ?? 100, 1), 500);
|
|
213
330
|
return database
|
|
214
|
-
.prepare(`${
|
|
215
|
-
"AND (? IS NULL OR d.project_id = ?) AND (? IS NULL OR s.name_folded = ?) AND (? IS NULL OR p.
|
|
331
|
+
.prepare(`${itemsSql(kind)} WHERE d.deleted_at IS NULL AND (? IS NULL OR d.status = ?) ` +
|
|
332
|
+
"AND (? IS NULL OR d.project_id = ?) AND (? IS NULL OR s.name_folded = ?) AND (? IS NULL OR p.id = ?) " +
|
|
216
333
|
`AND (? IS NULL OR d.organization_id = ?) AND ${by.sql} ORDER BY d.created_at DESC, d.id DESC LIMIT ?`)
|
|
217
|
-
.all(status, status,
|
|
218
|
-
.map(
|
|
334
|
+
.all(status, status, project, project, stage, stage, pipeline, pipeline, organization?.id ?? null, organization?.id ?? null, ...by.params, limit)
|
|
335
|
+
.map(itemOf(kind));
|
|
336
|
+
},
|
|
337
|
+
find: ({ words, anyWord, accounts, limit }) => {
|
|
338
|
+
const wanted = [...new Set(words.map((word) => fold(word).trim()).filter(Boolean))];
|
|
339
|
+
if (!wanted.length)
|
|
340
|
+
return [];
|
|
341
|
+
const most = Math.min(Math.max(limit ?? 100, 1), 500);
|
|
342
|
+
const within = accounts?.map(({ provider, account }) => `${provider}\u0000${account}`);
|
|
343
|
+
// Titles are short and a CRM holds thousands, not millions: folding them here is cheaper than a word index.
|
|
344
|
+
const found = DEAL_KINDS.flatMap((kind) => database
|
|
345
|
+
.prepare(`SELECT d.id, d.title, d.updated_at, a.provider, a.external_id AS account FROM ${TABLES[kind]} d ` +
|
|
346
|
+
"LEFT JOIN accounts a ON a.id = d.account_id WHERE d.deleted_at IS NULL")
|
|
347
|
+
.all()
|
|
348
|
+
.filter((row) => within === undefined || within.includes(`${row.provider}\u0000${row.account}`))
|
|
349
|
+
.map((row) => {
|
|
350
|
+
const title = fold(String(row.title));
|
|
351
|
+
return { kind, row, score: wanted.filter((word) => title.includes(word)).length };
|
|
352
|
+
})
|
|
353
|
+
.filter(({ score }) => (anyWord ? score > 0 : score === wanted.length)));
|
|
354
|
+
return found
|
|
355
|
+
.sort((a, b) => b.score - a.score ||
|
|
356
|
+
Number(b.row.updated_at) - Number(a.row.updated_at) ||
|
|
357
|
+
Number(b.row.id) - Number(a.row.id))
|
|
358
|
+
.slice(0, most)
|
|
359
|
+
.map(({ kind, row }) => read(`${kind}:${row.id}`));
|
|
219
360
|
},
|
|
220
361
|
move: (reference, { stage, by }) => {
|
|
221
|
-
|
|
222
|
-
const row = rowOf(reference);
|
|
223
|
-
|
|
224
|
-
const next =
|
|
225
|
-
write(database, () =>
|
|
226
|
-
|
|
227
|
-
.prepare("UPDATE opportunities SET stage_id = ?, updated_at = ? WHERE id = ?")
|
|
228
|
-
.run(next, now(), Number(row.id));
|
|
229
|
-
});
|
|
230
|
-
return dealOf(rowOf(`deal:${row.id}`));
|
|
362
|
+
authorActor(context, by);
|
|
363
|
+
const { kind, row } = rowOf(reference);
|
|
364
|
+
notDeleted(row, kind, "moves");
|
|
365
|
+
const next = stageRow(text(stage, "a stage's name", 80) ?? "", kind, Number(row.pipeline_id));
|
|
366
|
+
write(database, () => setStage(kind, Number(row.id), next, next.outcome, today()));
|
|
367
|
+
return read(`${kind}:${row.id}`);
|
|
231
368
|
},
|
|
232
369
|
close: (reference, { status, date, reason, by }) => {
|
|
233
|
-
|
|
234
|
-
const outcome = oneOf(status, ["won", "lost"], "a closed
|
|
235
|
-
const day = isoDate(date, "the day
|
|
236
|
-
const why = text(reason, "why
|
|
237
|
-
const row = rowOf(reference);
|
|
238
|
-
|
|
370
|
+
authorActor(context, by);
|
|
371
|
+
const outcome = oneOf(status, ["won", "lost"], "a closed status");
|
|
372
|
+
const day = isoDate(date, "the day it closed") ?? today();
|
|
373
|
+
const why = text(reason, "why it closed", 2000);
|
|
374
|
+
const { kind, row } = rowOf(reference);
|
|
375
|
+
notDeleted(row, kind, "closes");
|
|
376
|
+
const stage = database
|
|
377
|
+
.prepare("SELECT * FROM pipeline_stages WHERE pipeline_id = ? AND outcome = ? ORDER BY position, id LIMIT 1")
|
|
378
|
+
.get(Number(row.pipeline_id), outcome) ?? { id: row.stage_id };
|
|
239
379
|
write(database, () => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
380
|
+
setStage(kind, Number(row.id), stage, outcome, day);
|
|
381
|
+
if (why !== null)
|
|
382
|
+
database
|
|
383
|
+
.prepare(`UPDATE ${TABLES[kind]} SET metadata = json_set(coalesce(metadata, '{}'), '$.closed_reason', ?) WHERE id = ?`)
|
|
384
|
+
.run(why, Number(row.id));
|
|
244
385
|
});
|
|
245
|
-
return
|
|
386
|
+
return read(`${kind}:${row.id}`);
|
|
246
387
|
},
|
|
247
|
-
confirm: (reference, by, reason) => decide(reference, by, reason, true),
|
|
248
|
-
reject: (reference, by, reason) => decide(reference, by, reason, false),
|
|
249
388
|
};
|
|
250
389
|
};
|
|
251
390
|
export const dealsStoreOver = (context) => {
|
|
252
391
|
const operations = dealOperationsOver(context);
|
|
253
392
|
return {
|
|
254
393
|
addPipeline: async (input) => operations.addPipeline(input),
|
|
255
|
-
pipelines: async () => operations.pipelines(),
|
|
394
|
+
pipelines: async (filter) => operations.pipelines(filter),
|
|
395
|
+
pipeline: async (reference, options) => operations.pipeline(reference, options),
|
|
256
396
|
add: async (input) => operations.add(input),
|
|
257
397
|
get: async (reference) => operations.get(reference),
|
|
258
398
|
list: async (filter) => operations.list(filter),
|
|
399
|
+
find: async (search) => operations.find(search),
|
|
259
400
|
move: async (reference, input) => operations.move(reference, input),
|
|
260
401
|
close: async (reference, input) => operations.close(reference, input),
|
|
261
|
-
confirm: async (reference, by, reason) => operations.confirm(reference, by, reason),
|
|
262
|
-
reject: async (reference, by, reason) => operations.reject(reference, by, reason),
|
|
263
402
|
};
|
|
264
403
|
};
|
|
@@ -10,7 +10,7 @@ export declare const ENTITY_TABLES: {
|
|
|
10
10
|
readonly calendar_entry: "calendar_entries";
|
|
11
11
|
readonly chat: "chats";
|
|
12
12
|
readonly conversation: "conversations";
|
|
13
|
-
readonly deal: "
|
|
13
|
+
readonly deal: "deals";
|
|
14
14
|
readonly document: "documents";
|
|
15
15
|
readonly document_picture: "documents";
|
|
16
16
|
readonly email: "emails";
|
|
@@ -22,6 +22,7 @@ export declare const ENTITY_TABLES: {
|
|
|
22
22
|
readonly identity: "identities";
|
|
23
23
|
readonly image: "attachments";
|
|
24
24
|
readonly invoice: "invoices";
|
|
25
|
+
readonly lead: "leads";
|
|
25
26
|
readonly meeting: "meetings";
|
|
26
27
|
readonly meeting_transcript: "meeting_transcripts";
|
|
27
28
|
readonly meeting_transcript_row: "meeting_transcript_rows";
|
|
@@ -32,6 +33,7 @@ export declare const ENTITY_TABLES: {
|
|
|
32
33
|
readonly organization: "organizations";
|
|
33
34
|
readonly payment: "payments";
|
|
34
35
|
readonly person: "persons";
|
|
36
|
+
readonly pipeline: "pipelines";
|
|
35
37
|
readonly place: "places";
|
|
36
38
|
readonly project: "projects";
|
|
37
39
|
readonly tag: "canonical_tags";
|
|
@@ -9,7 +9,7 @@ export const ENTITY_TABLES = {
|
|
|
9
9
|
calendar_entry: "calendar_entries",
|
|
10
10
|
chat: "chats",
|
|
11
11
|
conversation: "conversations",
|
|
12
|
-
deal: "
|
|
12
|
+
deal: "deals",
|
|
13
13
|
document: "documents",
|
|
14
14
|
document_picture: "documents",
|
|
15
15
|
email: "emails",
|
|
@@ -21,6 +21,7 @@ export const ENTITY_TABLES = {
|
|
|
21
21
|
identity: "identities",
|
|
22
22
|
image: "attachments",
|
|
23
23
|
invoice: "invoices",
|
|
24
|
+
lead: "leads",
|
|
24
25
|
meeting: "meetings",
|
|
25
26
|
meeting_transcript: "meeting_transcripts",
|
|
26
27
|
meeting_transcript_row: "meeting_transcript_rows",
|
|
@@ -31,6 +32,7 @@ export const ENTITY_TABLES = {
|
|
|
31
32
|
organization: "organizations",
|
|
32
33
|
payment: "payments",
|
|
33
34
|
person: "persons",
|
|
35
|
+
pipeline: "pipelines",
|
|
34
36
|
place: "places",
|
|
35
37
|
project: "projects",
|
|
36
38
|
tag: "canonical_tags",
|