@wirecat/cli-core 0.587.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.
@@ -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, proposerOf } from "./actors.js";
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 DEALS = "SELECT d.*, s.name AS stage_name, s.pipeline_id, p.name AS pipeline_name, b.name AS author_name, " +
14
- "b.kind AS author_kind FROM opportunities d JOIN pipeline_stages s ON s.id = d.stage_id " +
15
- "JOIN pipelines p ON p.id = s.pipeline_id LEFT JOIN bots b ON d.author_type = 'bot' AND b.id = d.author_id";
16
- const MAX_STAGES = 20;
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 ownerOnly = (by, what) => {
19
- if (by !== "owner")
20
- refuse(`only the owner ${what}`, "deal.owner-only", "permission_error");
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, reviewedOf, written, keepEvidence, keepDecision } = reviewedRowsOver(context);
25
- const closedReasonOf = (metadata) => {
26
- if (metadata == null)
27
- return null;
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 dealOf = (row) => {
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: `deal:${row.id}`,
53
+ ref: `${kind}:${row.id}`,
54
+ kind,
56
55
  title: fields.title,
57
56
  summary: dealLine(fields),
58
- pipeline: String(row.pipeline_name),
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: closedReasonOf(row.metadata),
67
- people: peopleOf(fields.id),
68
- ...reviewedOf(row, "deal"),
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(`${DEALS} WHERE d.id = ?`).get(thing.id);
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 pipelineNamed = (name) => {
78
- const found = database.prepare("SELECT id FROM pipelines WHERE name_folded = ?").get(fold(name.trim()));
79
- if (!found)
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
- /** A stage by name, in one pipeline when it is named, else in the only pipeline that has one of that name. */
84
- const stageNamed = (stage, pipeline) => {
85
- const within = typeof pipeline === "string" ? pipelineNamed(pipeline) : (pipeline ?? null);
86
- const rows = database
87
- .prepare("SELECT id FROM pipeline_stages WHERE name_folded = ? AND (? IS NULL OR pipeline_id = ?)")
88
- .all(fold(stage.trim()), within, within);
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
- return refuse(`no stage named ${stage}${within === null ? "" : " in that pipeline"}`, "deal.stage");
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 has a stage named ${stage}; name the pipeline`, "deal.stage");
93
- return Number(rows[0]?.id);
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 pipelinesOf = () => {
96
- const stages = database
97
- .prepare("SELECT s.pipeline_id, s.name, s.position, (SELECT count(*) FROM opportunities d WHERE d.stage_id = s.id " +
98
- "AND d.status = 'open' AND d.review <> 'rejected' AND d.deleted_at IS NULL) AS open " +
99
- "FROM pipeline_stages s ORDER BY s.pipeline_id, s.position")
100
- .all();
101
- return database
102
- .prepare("SELECT id, name, created_at FROM pipelines ORDER BY name_folded")
103
- .all()
104
- .map((pipeline) => ({
105
- name: String(pipeline.name),
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 === pipeline.id)
108
- .map((stage) => ({ name: String(stage.name), position: Number(stage.position), open: Number(stage.open) })),
109
- createdAt: new Date(Number(pipeline.created_at)).toISOString(),
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
- const stillOpen = (row, what) => {
113
- if (row.status !== "open" || row.review === "rejected" || row.deleted_at != null)
114
- refuse(`deal:${row.id} is ${row.deleted_at != null ? "deleted" : row.review === "rejected" ? "rejected" : String(row.status)}; it no longer ${what}`, "deal.closed");
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 decide = (reference, by, reason, confirm) => {
117
- const row = rowOf(reference);
118
- const ref = `deal:${row.id}`;
119
- assertMayDecide({ id: ref, proposedBy: proposerOf(database, row.author_type, row.author_id), ownerOnly: true }, by, context.decisions);
120
- if (row.review !== "proposed")
121
- refuse(`${ref} is already ${String(row.review)}`, "proposal.wrong-state");
122
- write(database, () => {
123
- if (confirm) {
124
- const { actor } = authorActor(context, deciderAuthor(by));
125
- database
126
- .prepare("UPDATE opportunities SET review = 'confirmed', confirmed_by_type = ?, confirmed_by_id = ?, updated_at = ? " +
127
- "WHERE id = ?")
128
- .run(actor.type, actor.id, now(), Number(row.id));
129
- }
130
- else
131
- database
132
- .prepare("UPDATE opportunities SET review = 'rejected', updated_at = ? WHERE id = ?")
133
- .run(now(), Number(row.id));
134
- keepDecision("opportunities", Number(row.id), by, reason);
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
- return dealOf(rowOf(ref));
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
- ownerOnly(by, "makes a pipeline");
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) => text(stage, "a stage's name", 80));
145
- if (new Set(kept.map(fold)).size !== kept.length)
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
- write(database, () => {
148
- if (database.prepare("SELECT 1 FROM pipelines WHERE name_folded = ?").get(fold(named)))
149
- refuse(`a pipeline named ${named} is already stored`, "pipeline.duplicate");
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 id = database
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, at_) => {
157
- insert.run(Number(id), stage, fold(stage), at_ + 1, at, at);
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().find((pipeline) => fold(pipeline.name) === fold(named));
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 title = text(input.title, "a deal's title", 200) ?? refuse("a deal needs a title", "deal.field");
165
- const stageText = text(input.stage, "a stage's name", 80) ?? refuse("a deal starts in a stage", "deal.field");
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("a deal's amount and currency come together", "deal.field");
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, "a deal's amount");
272
+ const amount = currency === null ? null : minorUnits(input.amount, currency, "an amount");
170
273
  if (amount !== null && amount < 0)
171
- refuse("a deal's amount is 0 or more", "money.amount");
172
- const closeDate = isoDate(input.closeDate, "a deal's close date");
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(`a deal names at most ${MAX_PEOPLE} people`, "deal.field");
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 stage = stageNamed(stageText, input.pipeline);
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 on a deal") };
301
+ return { id: thing.id, role: oneOf(role, DEAL_ROLES, "a person's role") };
186
302
  });
187
- const by = written(input);
303
+ const { actor, model } = authorActor(context, input.by);
188
304
  const at = now();
189
305
  const made = Number(database
190
- .prepare("INSERT INTO opportunities (title, organization_id, project_id, stage_id, status, currency, amount, " +
191
- "close_date, review, author_type, author_id, model, confirmed_by_type, confirmed_by_id, source, metadata, " +
192
- "created_at, updated_at) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
193
- .get(title, organization?.id ?? null, by.project, stage, currency, amount, closeDate, by.review, by.actor.type, by.actor.id, by.model, by.confirmedType, by.confirmedId, by.source, by.metadata, at, at)?.id);
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 ('deal', ?, 'person', ?, ${linkKind("related-to")}, ?, ?, 1, ?, ?, ?)`);
311
+ `updated_at) VALUES (?, ?, 'person', ?, ${linkKind("related-to")}, ?, ?, 1, ?, ?, ?)`);
196
312
  for (const person of persons)
197
- link.run(made, person.id, person.role, by.owner ? "owner" : "suggested", at, by.owner ? "owner" : "agent", at);
198
- keepEvidence("deal", made, by);
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 dealOf(rowOf(`deal:${id}`));
318
+ return read(`${kind}:${id}`);
202
319
  },
203
- get: (reference) => dealOf(rowOf(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 deal's status");
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 : fold(filter.pipeline.trim());
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(`${DEALS} WHERE d.deleted_at IS NULL AND (? IS NULL OR d.status = ?) AND (? IS NULL OR d.review = ?) ` +
215
- "AND (? IS NULL OR d.project_id = ?) AND (? IS NULL OR s.name_folded = ?) AND (? IS NULL OR p.name_folded = ?) " +
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, review, review, project, project, stage, stage, pipeline, pipeline, organization?.id ?? null, organization?.id ?? null, ...by.params, limit)
218
- .map(dealOf);
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
- ownerOnly(by, "moves a deal");
222
- const row = rowOf(reference);
223
- stillOpen(row, "moves");
224
- const next = stageNamed(text(stage, "a stage's name", 80) ?? "", Number(row.pipeline_id));
225
- write(database, () => {
226
- database
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
- ownerOnly(by, "closes a deal");
234
- const outcome = oneOf(status, ["won", "lost"], "a closed deal's status");
235
- const day = isoDate(date, "the day a deal closed") ?? new Date(now()).toISOString().slice(0, 10);
236
- const why = text(reason, "why a deal closed", 2000);
237
- const row = rowOf(reference);
238
- stillOpen(row, "closes");
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
- database
241
- .prepare("UPDATE opportunities SET status = ?, closed_date = ?, updated_at = ?, metadata = CASE WHEN ? IS NULL " +
242
- "THEN metadata ELSE json_set(coalesce(metadata, '{}'), '$.closed_reason', ?) END WHERE id = ?")
243
- .run(outcome, day, now(), why, why, Number(row.id));
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 dealOf(rowOf(`deal:${row.id}`));
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: "opportunities";
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: "opportunities",
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
  };