@wirecat/cli-core 0.584.0 → 0.585.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.
@@ -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";
@@ -9,6 +9,7 @@ export const ENTITY_TABLES = {
9
9
  calendar_entry: "calendar_entries",
10
10
  chat: "chats",
11
11
  conversation: "conversations",
12
+ deal: "opportunities",
12
13
  document: "documents",
13
14
  document_picture: "documents",
14
15
  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}`