@wirecat/cli-core 0.584.0 → 0.586.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 +20 -0
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/domain/deals.d.ts +20 -0
- package/dist/domain/deals.js +15 -0
- package/dist/domain/ref-summaries.js +1 -0
- package/dist/domain/references.d.ts +1 -1
- package/dist/domain/references.js +1 -0
- package/dist/errors/reasons.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/meetings/mcp/index.d.ts +15 -15
- package/dist/services/proposals/kinds/deals.d.ts +4 -0
- package/dist/services/proposals/kinds/deals.js +32 -0
- package/dist/services/proposals/registry.js +2 -0
- package/dist/store/index.d.ts +1 -0
- package/dist/store/sqlite/deals.d.ts +104 -0
- package/dist/store/sqlite/deals.js +264 -0
- package/dist/store/sqlite/entity-types.d.ts +1 -0
- package/dist/store/sqlite/entity-types.js +1 -0
- package/dist/store/sqlite/finance.d.ts +3 -3
- package/dist/store/sqlite/knowledge.js +2 -1
- package/dist/store/sqlite/manifest.js +2 -0
- package/dist/store/sqlite/migrations.generated.js +21 -0
- package/dist/store/sqlite/ref-summaries.js +10 -0
- package/dist/store/sqlite/schema.d.ts +554 -0
- package/dist/store/sqlite/schema.js +52 -0
- package/dist/store/sqlite/things.js +1 -0
- package/dist/store/store.d.ts +3 -0
- package/dist/store/store.js +5 -0
- package/package.json +3 -3
|
@@ -0,0 +1,264 @@
|
|
|
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";
|
|
4
|
+
import { currencyOf, formatDecimal, minorUnits, scaleOf } from "../../domain/money.js";
|
|
5
|
+
import { assertMayDecide, deciderAuthor } from "../decisions.js";
|
|
6
|
+
import { fold } from "../normalize.js";
|
|
7
|
+
import { proposedBySql, proposerOf } from "./actors.js";
|
|
8
|
+
import { isoDate, oneOf, refuse, reviewedRowsOver, text } from "./finance.js";
|
|
9
|
+
import { linkKind } from "./link-kinds.js";
|
|
10
|
+
import { authorActor, projectId } from "./memories.js";
|
|
11
|
+
import { requiredThing } from "./notes.js";
|
|
12
|
+
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;
|
|
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");
|
|
21
|
+
};
|
|
22
|
+
export const dealOperationsOver = (context) => {
|
|
23
|
+
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")} ` +
|
|
38
|
+
"AND to_type = 'person' AND to_id IS NOT NULL ORDER BY id")
|
|
39
|
+
.all(id)
|
|
40
|
+
.map((row) => ({ ...partyOf("person", row.to_id), role: row.role }));
|
|
41
|
+
const dealOf = (row) => {
|
|
42
|
+
const currency = row.currency == null ? null : String(row.currency);
|
|
43
|
+
const amount = row.amount == null || currency === null ? null : Number(row.amount);
|
|
44
|
+
const fields = {
|
|
45
|
+
id: Number(row.id),
|
|
46
|
+
title: String(row.title),
|
|
47
|
+
stage: String(row.stage_name),
|
|
48
|
+
amount,
|
|
49
|
+
currency,
|
|
50
|
+
status: row.status,
|
|
51
|
+
review: row.review,
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
id: String(row.id),
|
|
55
|
+
ref: `deal:${row.id}`,
|
|
56
|
+
title: fields.title,
|
|
57
|
+
summary: dealLine(fields),
|
|
58
|
+
pipeline: String(row.pipeline_name),
|
|
59
|
+
stage: fields.stage,
|
|
60
|
+
status: fields.status,
|
|
61
|
+
organization: partyOf(row.organization_id == null ? null : "organization", row.organization_id),
|
|
62
|
+
currency,
|
|
63
|
+
amount: amount === null || currency === null ? null : formatDecimal(amount, scaleOf(currency)),
|
|
64
|
+
closeDate: row.close_date == null ? null : String(row.close_date),
|
|
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"),
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
const rowOf = (reference) => {
|
|
72
|
+
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);
|
|
76
|
+
};
|
|
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);
|
|
82
|
+
};
|
|
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);
|
|
89
|
+
if (rows.length === 0)
|
|
90
|
+
return refuse(`no stage named ${stage}${within === null ? "" : " in that pipeline"}`, "deal.stage");
|
|
91
|
+
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);
|
|
94
|
+
};
|
|
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),
|
|
106
|
+
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(),
|
|
110
|
+
}));
|
|
111
|
+
};
|
|
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");
|
|
115
|
+
};
|
|
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);
|
|
135
|
+
});
|
|
136
|
+
return dealOf(rowOf(ref));
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
addPipeline: ({ name, stages, by }) => {
|
|
140
|
+
ownerOnly(by, "makes a pipeline");
|
|
141
|
+
const named = text(name, "a pipeline's name", 80) ?? refuse("a pipeline needs a name", "pipeline.field");
|
|
142
|
+
if (!Array.isArray(stages) || stages.length === 0 || stages.length > MAX_STAGES)
|
|
143
|
+
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)
|
|
146
|
+
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");
|
|
150
|
+
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);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
return pipelinesOf().find((pipeline) => fold(pipeline.name) === fold(named));
|
|
161
|
+
},
|
|
162
|
+
pipelines: () => pipelinesOf(),
|
|
163
|
+
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");
|
|
166
|
+
if ((input.amount === undefined) !== (input.currency === undefined))
|
|
167
|
+
refuse("a deal's amount and currency come together", "deal.field");
|
|
168
|
+
const currency = input.currency === undefined ? null : currencyOf(input.currency);
|
|
169
|
+
const amount = currency === null ? null : minorUnits(input.amount, currency, "a deal's amount");
|
|
170
|
+
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");
|
|
173
|
+
const people = input.people ?? [];
|
|
174
|
+
if (people.length > MAX_PEOPLE)
|
|
175
|
+
refuse(`a deal names at most ${MAX_PEOPLE} people`, "deal.field");
|
|
176
|
+
const id = write(database, () => {
|
|
177
|
+
const stage = stageNamed(stageText, input.pipeline);
|
|
178
|
+
const organization = partyThing(input.organization, "the organization");
|
|
179
|
+
if (organization !== undefined && organization.type !== "organization")
|
|
180
|
+
refuse(`the organization is an organization: reference, not ${input.organization}`, "deal.field");
|
|
181
|
+
const persons = people.map(({ person, role }) => {
|
|
182
|
+
const thing = requiredThing(database, person, "the person");
|
|
183
|
+
if (thing.type !== "person")
|
|
184
|
+
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") };
|
|
186
|
+
});
|
|
187
|
+
const by = written(input);
|
|
188
|
+
const at = now();
|
|
189
|
+
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);
|
|
194
|
+
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, ?, ?, ?)`);
|
|
196
|
+
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);
|
|
199
|
+
return made;
|
|
200
|
+
});
|
|
201
|
+
return dealOf(rowOf(`deal:${id}`));
|
|
202
|
+
},
|
|
203
|
+
get: (reference) => dealOf(rowOf(reference)),
|
|
204
|
+
list: (filter = {}) => {
|
|
205
|
+
const project = filter.project === undefined ? null : projectId(database, filter.project);
|
|
206
|
+
const organization = partyThing(filter.organization, "the organization");
|
|
207
|
+
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");
|
|
210
|
+
const stage = filter.stage === undefined ? null : fold(filter.stage.trim());
|
|
211
|
+
const pipeline = filter.pipeline === undefined ? null : fold(filter.pipeline.trim());
|
|
212
|
+
const limit = Math.min(Math.max(filter.limit ?? 100, 1), 500);
|
|
213
|
+
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 = ?) " +
|
|
216
|
+
`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);
|
|
219
|
+
},
|
|
220
|
+
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}`));
|
|
231
|
+
},
|
|
232
|
+
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");
|
|
239
|
+
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));
|
|
244
|
+
});
|
|
245
|
+
return dealOf(rowOf(`deal:${row.id}`));
|
|
246
|
+
},
|
|
247
|
+
confirm: (reference, by, reason) => decide(reference, by, reason, true),
|
|
248
|
+
reject: (reference, by, reason) => decide(reference, by, reason, false),
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
export const dealsStoreOver = (context) => {
|
|
252
|
+
const operations = dealOperationsOver(context);
|
|
253
|
+
return {
|
|
254
|
+
addPipeline: async (input) => operations.addPipeline(input),
|
|
255
|
+
pipelines: async () => operations.pipelines(),
|
|
256
|
+
add: async (input) => operations.add(input),
|
|
257
|
+
get: async (reference) => operations.get(reference),
|
|
258
|
+
list: async (filter) => operations.list(filter),
|
|
259
|
+
move: async (reference, input) => operations.move(reference, input),
|
|
260
|
+
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
|
+
};
|
|
264
|
+
};
|
|
@@ -10,6 +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
14
|
readonly document: "documents";
|
|
14
15
|
readonly document_picture: "documents";
|
|
15
16
|
readonly email: "emails";
|
|
@@ -216,7 +216,7 @@ export declare const iso: (value: unknown) => string;
|
|
|
216
216
|
export declare const reviewedRowsOver: (context: StoreContext) => {
|
|
217
217
|
partyOf: (type: unknown, id: unknown) => Party | null;
|
|
218
218
|
partyThing: (reference: string | undefined, what: string) => import("./things.js").Thing | undefined;
|
|
219
|
-
reviewedOf: (row: Row, type: "invoice" | "payment" | "order") => Reviewed;
|
|
219
|
+
reviewedOf: (row: Row, type: "invoice" | "payment" | "order" | "deal") => Reviewed;
|
|
220
220
|
written: (input: Written) => {
|
|
221
221
|
owner: boolean;
|
|
222
222
|
actor: Actor;
|
|
@@ -229,7 +229,7 @@ export declare const reviewedRowsOver: (context: StoreContext) => {
|
|
|
229
229
|
project: number | null;
|
|
230
230
|
evidence: string[];
|
|
231
231
|
};
|
|
232
|
-
keepEvidence: (type: "invoice" | "payment" | "order", id: number, by: ReturnType<(input: Written) => {
|
|
232
|
+
keepEvidence: (type: "invoice" | "payment" | "order" | "deal", id: number, by: ReturnType<(input: Written) => {
|
|
233
233
|
owner: boolean;
|
|
234
234
|
actor: Actor;
|
|
235
235
|
model: string | null;
|
|
@@ -242,7 +242,7 @@ export declare const reviewedRowsOver: (context: StoreContext) => {
|
|
|
242
242
|
evidence: string[];
|
|
243
243
|
}>) => void;
|
|
244
244
|
ownerOnly: (by: Author, what: string) => void;
|
|
245
|
-
keepDecision: (table: "invoices" | "payments" | "orders", id: number, by: Decider, reason?: string) => void;
|
|
245
|
+
keepDecision: (table: "invoices" | "payments" | "orders" | "opportunities", id: number, by: Decider, reason?: string) => void;
|
|
246
246
|
};
|
|
247
247
|
type FinanceOperations = {
|
|
248
248
|
[K in keyof FinanceStore]: FinanceStore[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => R : never;
|
|
@@ -152,7 +152,8 @@ export const knowledgeStoreOver = (context) => {
|
|
|
152
152
|
parsed.type === "meeting" ||
|
|
153
153
|
parsed.type === "invoice" ||
|
|
154
154
|
parsed.type === "payment" ||
|
|
155
|
-
parsed.type === "order"
|
|
155
|
+
parsed.type === "order" ||
|
|
156
|
+
parsed.type === "deal")
|
|
156
157
|
return undefined;
|
|
157
158
|
return { type: parsed.type, id: parsed.id };
|
|
158
159
|
};
|
|
@@ -68,6 +68,8 @@ export const MANIFEST = [
|
|
|
68
68
|
{ name: "20261105125314_version-50-orders", version: 50, minCompatible: 34 },
|
|
69
69
|
{ name: "20261105125315_version-50-orders-rules", version: 50, minCompatible: 34 },
|
|
70
70
|
{ name: "20261105144549_version-51-task-links", version: 51, minCompatible: 34 },
|
|
71
|
+
{ name: "20261106010504_version-52-deals", version: 52, minCompatible: 34 },
|
|
72
|
+
{ name: "20261106010507_version-52-deals-rules", version: 52, minCompatible: 34 },
|
|
71
73
|
];
|
|
72
74
|
export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
|
|
73
75
|
const migrations = [];
|
|
@@ -984,5 +984,26 @@ export const GENERATED = [
|
|
|
984
984
|
"ALTER TABLE `tasks` ADD `source_url` text;",
|
|
985
985
|
"CREATE INDEX `tasks_by_provider_external_id` ON `tasks` (`provider`,`external_id`) WHERE external_id IS NOT NULL;"
|
|
986
986
|
]
|
|
987
|
+
},
|
|
988
|
+
{
|
|
989
|
+
"name": "20261106010504_version-52-deals",
|
|
990
|
+
"statements": [
|
|
991
|
+
"CREATE TABLE `opportunities` (\n\t`id` integer PRIMARY KEY,\n\t`title` text NOT NULL,\n\t`organization_id` integer,\n\t`project_id` integer,\n\t`stage_id` integer NOT NULL,\n\t`status` text NOT NULL,\n\t`currency` text,\n\t`amount` integer,\n\t`close_date` text,\n\t`closed_date` text,\n\t`review` text NOT NULL,\n\t`author_type` text NOT NULL,\n\t`author_id` integer NOT NULL,\n\t`model` text,\n\t`confirmed_by_type` text,\n\t`confirmed_by_id` integer,\n\t`source` text NOT NULL,\n\t`metadata` text,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\t`deleted_at` integer,\n\tCONSTRAINT `fk_opportunities_organization_id_organizations_id_fk` FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`),\n\tCONSTRAINT `fk_opportunities_project_id_projects_id_fk` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`),\n\tCONSTRAINT `fk_opportunities_stage_id_pipeline_stages_id_fk` FOREIGN KEY (`stage_id`) REFERENCES `pipeline_stages`(`id`)\n);",
|
|
992
|
+
"CREATE TABLE `pipeline_stages` (\n\t`id` integer PRIMARY KEY,\n\t`pipeline_id` integer NOT NULL,\n\t`name` text NOT NULL,\n\t`name_folded` text NOT NULL,\n\t`position` integer NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tCONSTRAINT `fk_pipeline_stages_pipeline_id_pipelines_id_fk` FOREIGN KEY (`pipeline_id`) REFERENCES `pipelines`(`id`),\n\tCONSTRAINT `pipeline_stages_pipeline_id_name_folded_unique` UNIQUE(`pipeline_id`,`name_folded`),\n\tCONSTRAINT `pipeline_stages_pipeline_id_position_unique` UNIQUE(`pipeline_id`,`position`)\n);",
|
|
993
|
+
"CREATE TABLE `pipelines` (\n\t`id` integer PRIMARY KEY,\n\t`name` text NOT NULL,\n\t`name_folded` text NOT NULL UNIQUE,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL\n);",
|
|
994
|
+
"CREATE INDEX `opportunities_by_organization_id` ON `opportunities` (`organization_id`);",
|
|
995
|
+
"CREATE INDEX `opportunities_by_project_id` ON `opportunities` (`project_id`);",
|
|
996
|
+
"CREATE INDEX `opportunities_by_stage_id` ON `opportunities` (`stage_id`);",
|
|
997
|
+
"CREATE INDEX `opportunities_by_author_type_author_id` ON `opportunities` (`author_type`,`author_id`);",
|
|
998
|
+
"CREATE INDEX `opportunities_by_confirmed_by_type_confirmed_by_id` ON `opportunities` (`confirmed_by_type`,`confirmed_by_id`);"
|
|
999
|
+
]
|
|
1000
|
+
},
|
|
1001
|
+
{
|
|
1002
|
+
"name": "20261106010507_version-52-deals-rules",
|
|
1003
|
+
"statements": [
|
|
1004
|
+
"-- The store's code checks these first and says why; the triggers keep a stray write from another build or a hand\n-- edit from storing a deal with an unknown status, or an amount without its currency.\nCREATE TRIGGER opportunities_valid_bi BEFORE INSERT ON opportunities\n WHEN new.status NOT IN ('open', 'won', 'lost')\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected')\n OR (new.amount IS NULL) <> (new.currency IS NULL) OR new.amount < 0 BEGIN\n SELECT RAISE(ABORT, 'opportunities: a known status and review, an amount with its currency');\nEND;",
|
|
1005
|
+
"CREATE TRIGGER opportunities_valid_bu BEFORE UPDATE ON opportunities\n WHEN new.status NOT IN ('open', 'won', 'lost')\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected')\n OR (new.amount IS NULL) <> (new.currency IS NULL) OR new.amount < 0 BEGIN\n SELECT RAISE(ABORT, 'opportunities: a known status and review, an amount with its currency');\nEND;",
|
|
1006
|
+
"-- A deal moves between the stages of its own pipeline only.\nCREATE TRIGGER opportunities_same_pipeline_bu BEFORE UPDATE OF stage_id ON opportunities\n WHEN (SELECT pipeline_id FROM pipeline_stages WHERE id = new.stage_id)\n <> (SELECT pipeline_id FROM pipeline_stages WHERE id = old.stage_id) BEGIN\n SELECT RAISE(ABORT, 'opportunities: a deal moves within its own pipeline');\nEND;"
|
|
1007
|
+
]
|
|
987
1008
|
}
|
|
988
1009
|
];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dealLine } from "../../domain/deals.js";
|
|
1
2
|
import { invoiceLine, paymentLine } from "../../domain/finance.js";
|
|
2
3
|
import { orderLine } from "../../domain/orders.js";
|
|
3
4
|
import { filesAccountSql, providerLabel, providerNamed } from "../../domain/providers.js";
|
|
@@ -207,6 +208,13 @@ const READERS = {
|
|
|
207
208
|
at: "x.created_at",
|
|
208
209
|
deleted: "x.deleted_at",
|
|
209
210
|
})),
|
|
211
|
+
deal: byId("deal", "opportunities", () => ({
|
|
212
|
+
title: "json_object('id', x.id, 'title', x.title, 'stage', (SELECT name FROM pipeline_stages WHERE id = x.stage_id), " +
|
|
213
|
+
"'amount', x.amount, 'currency', x.currency, 'status', x.status, 'review', x.review)",
|
|
214
|
+
holder: "(SELECT name FROM projects WHERE id = x.project_id)",
|
|
215
|
+
at: "x.created_at",
|
|
216
|
+
deleted: "x.deleted_at",
|
|
217
|
+
})),
|
|
210
218
|
};
|
|
211
219
|
/** Mail saved before the mail tables existed is still messages, so a miss there is read again as a message. */
|
|
212
220
|
const FALLBACK = { email: "message", email_thread: "chat" };
|
|
@@ -314,6 +322,8 @@ const titleOf = (kind, reference, found) => {
|
|
|
314
322
|
return fieldsOf(found.title, paymentLine);
|
|
315
323
|
case "order":
|
|
316
324
|
return fieldsOf(found.title, orderLine);
|
|
325
|
+
case "deal":
|
|
326
|
+
return fieldsOf(found.title, dealLine);
|
|
317
327
|
case "folder":
|
|
318
328
|
return reference.type === "folder" && reference.path !== null && found.title !== null
|
|
319
329
|
? `${found.title}/${reference.path}`
|