@wirecat/cli-core 0.586.0 → 0.588.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 +38 -0
- package/dist/calendar/cli/local-times.js +3 -13
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/local-times.d.ts +16 -0
- package/dist/cli/local-times.js +65 -0
- package/dist/cli/runs/command.js +3 -2
- package/dist/domain/deals.d.ts +8 -6
- package/dist/domain/deals.js +7 -8
- 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/errors/reasons.js +6 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mail/cli/index.js +12 -11
- package/dist/mail/cli/review.js +10 -7
- package/dist/mail/read.d.ts +1 -1
- package/dist/mail/read.js +2 -2
- package/dist/services/proposals/registry.js +0 -2
- package/dist/store/index.d.ts +1 -1
- package/dist/store/sqlite/deals.d.ts +128 -29
- package/dist/store/sqlite/deals.js +270 -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 +3 -3
- 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,81 @@ 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));
|
|
219
336
|
},
|
|
220
337
|
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}`));
|
|
338
|
+
authorActor(context, by);
|
|
339
|
+
const { kind, row } = rowOf(reference);
|
|
340
|
+
notDeleted(row, kind, "moves");
|
|
341
|
+
const next = stageRow(text(stage, "a stage's name", 80) ?? "", kind, Number(row.pipeline_id));
|
|
342
|
+
write(database, () => setStage(kind, Number(row.id), next, next.outcome, today()));
|
|
343
|
+
return read(`${kind}:${row.id}`);
|
|
231
344
|
},
|
|
232
345
|
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
|
-
|
|
346
|
+
authorActor(context, by);
|
|
347
|
+
const outcome = oneOf(status, ["won", "lost"], "a closed status");
|
|
348
|
+
const day = isoDate(date, "the day it closed") ?? today();
|
|
349
|
+
const why = text(reason, "why it closed", 2000);
|
|
350
|
+
const { kind, row } = rowOf(reference);
|
|
351
|
+
notDeleted(row, kind, "closes");
|
|
352
|
+
const stage = database
|
|
353
|
+
.prepare("SELECT * FROM pipeline_stages WHERE pipeline_id = ? AND outcome = ? ORDER BY position, id LIMIT 1")
|
|
354
|
+
.get(Number(row.pipeline_id), outcome) ?? { id: row.stage_id };
|
|
239
355
|
write(database, () => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
356
|
+
setStage(kind, Number(row.id), stage, outcome, day);
|
|
357
|
+
if (why !== null)
|
|
358
|
+
database
|
|
359
|
+
.prepare(`UPDATE ${TABLES[kind]} SET metadata = json_set(coalesce(metadata, '{}'), '$.closed_reason', ?) WHERE id = ?`)
|
|
360
|
+
.run(why, Number(row.id));
|
|
244
361
|
});
|
|
245
|
-
return
|
|
362
|
+
return read(`${kind}:${row.id}`);
|
|
246
363
|
},
|
|
247
|
-
confirm: (reference, by, reason) => decide(reference, by, reason, true),
|
|
248
|
-
reject: (reference, by, reason) => decide(reference, by, reason, false),
|
|
249
364
|
};
|
|
250
365
|
};
|
|
251
366
|
export const dealsStoreOver = (context) => {
|
|
252
367
|
const operations = dealOperationsOver(context);
|
|
253
368
|
return {
|
|
254
369
|
addPipeline: async (input) => operations.addPipeline(input),
|
|
255
|
-
pipelines: async () => operations.pipelines(),
|
|
370
|
+
pipelines: async (filter) => operations.pipelines(filter),
|
|
371
|
+
pipeline: async (reference, options) => operations.pipeline(reference, options),
|
|
256
372
|
add: async (input) => operations.add(input),
|
|
257
373
|
get: async (reference) => operations.get(reference),
|
|
258
374
|
list: async (filter) => operations.list(filter),
|
|
259
375
|
move: async (reference, input) => operations.move(reference, input),
|
|
260
376
|
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
377
|
};
|
|
264
378
|
};
|
|
@@ -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",
|
|
@@ -153,7 +153,9 @@ export const knowledgeStoreOver = (context) => {
|
|
|
153
153
|
parsed.type === "invoice" ||
|
|
154
154
|
parsed.type === "payment" ||
|
|
155
155
|
parsed.type === "order" ||
|
|
156
|
-
parsed.type === "deal"
|
|
156
|
+
parsed.type === "deal" ||
|
|
157
|
+
parsed.type === "lead" ||
|
|
158
|
+
parsed.type === "pipeline")
|
|
157
159
|
return undefined;
|
|
158
160
|
return { type: parsed.type, id: parsed.id };
|
|
159
161
|
};
|