causly-server 0.0.0-stage → 1.5.1

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.
Files changed (91) hide show
  1. package/.env.example +120 -0
  2. package/CHANGELOG.md +126 -0
  3. package/LICENSE +21 -0
  4. package/README.md +377 -3
  5. package/bin/causly.js +192 -0
  6. package/index.js +229 -0
  7. package/package.json +59 -6
  8. package/packs/calendar.js +141 -0
  9. package/packs/command.js +22 -0
  10. package/packs/database.js +95 -0
  11. package/packs/directory.js +63 -0
  12. package/packs/discord.js +63 -0
  13. package/packs/docker.js +202 -0
  14. package/packs/files.js +106 -0
  15. package/packs/git.js +211 -0
  16. package/packs/github.js +321 -0
  17. package/packs/gmail.js +98 -0
  18. package/packs/leads.js +46 -0
  19. package/packs/linear.js +104 -0
  20. package/packs/loader.js +247 -0
  21. package/packs/manifest.json +1152 -0
  22. package/packs/meetings.js +141 -0
  23. package/packs/notion.js +185 -0
  24. package/packs/pipeline.js +151 -0
  25. package/packs/project.js +70 -0
  26. package/packs/registry.js +149 -0
  27. package/packs/reminders.js +57 -0
  28. package/packs/research.js +34 -0
  29. package/packs/secrets.js +77 -0
  30. package/packs/sentry.js +97 -0
  31. package/packs/seo.js +70 -0
  32. package/packs/slack.js +95 -0
  33. package/packs/stripe.js +127 -0
  34. package/packs/supabase.js +73 -0
  35. package/packs/telegram.js +70 -0
  36. package/packs/terraform.js +247 -0
  37. package/packs/vercel.js +114 -0
  38. package/packs/workflow.js +84 -0
  39. package/prompts/cofounder.js +66 -0
  40. package/prompts/playbooks.js +75 -0
  41. package/tools/backupOps.js +87 -0
  42. package/tools/boundaryAttest.js +184 -0
  43. package/tools/calendarOps.js +563 -0
  44. package/tools/cliOps.js +233 -0
  45. package/tools/commandOps.js +198 -0
  46. package/tools/dbOps.js +117 -0
  47. package/tools/directoryOps.js +96 -0
  48. package/tools/discordOps.js +133 -0
  49. package/tools/dockerOps.js +259 -0
  50. package/tools/envFile.js +89 -0
  51. package/tools/envLoader.js +85 -0
  52. package/tools/fileOps.js +125 -0
  53. package/tools/gitOps.js +170 -0
  54. package/tools/githubOps.js +357 -0
  55. package/tools/gmailOps.js +221 -0
  56. package/tools/googleAuth.js +51 -0
  57. package/tools/googleLogin.js +230 -0
  58. package/tools/leadOps.js +447 -0
  59. package/tools/linearOps.js +304 -0
  60. package/tools/logger.js +47 -0
  61. package/tools/meetingOps.js +697 -0
  62. package/tools/notionOps.js +215 -0
  63. package/tools/paths.js +36 -0
  64. package/tools/pipelineOps.js +511 -0
  65. package/tools/projectOps.js +192 -0
  66. package/tools/reminderAlerts.js +48 -0
  67. package/tools/reminderOps.js +129 -0
  68. package/tools/scheduler.js +46 -0
  69. package/tools/secretsOps.js +133 -0
  70. package/tools/security.js +488 -0
  71. package/tools/sentryOps.js +130 -0
  72. package/tools/seoOps.js +561 -0
  73. package/tools/services.js +295 -0
  74. package/tools/setupOps.js +245 -0
  75. package/tools/slackOps.js +151 -0
  76. package/tools/statusOps.js +104 -0
  77. package/tools/store.js +111 -0
  78. package/tools/stripeOps.js +299 -0
  79. package/tools/supabaseOps.js +94 -0
  80. package/tools/telegramOps.js +153 -0
  81. package/tools/terminal.js +124 -0
  82. package/tools/terraformOps.js +171 -0
  83. package/tools/toolWrapper.js +60 -0
  84. package/tools/toolsetOps.js +123 -0
  85. package/tools/transcribeOps.js +221 -0
  86. package/tools/updateCheck.js +58 -0
  87. package/tools/vercelOps.js +198 -0
  88. package/tools/verifyBoundaryAttest.js +25 -0
  89. package/tools/webOps.js +474 -0
  90. package/tools/workflowOps.js +371 -0
  91. package/tools/workspace.js +85 -0
@@ -0,0 +1,511 @@
1
+ // A small sales pipeline (CRM) stored locally in SQLite, next to reminders.
2
+ //
3
+ // company -> contacts, and deals (an opportunity with a company) -> activities
4
+ //
5
+ // Everything stays on this machine. Follow-ups are reminders linked to a deal.
6
+ // Text in here often comes from the web, so treat it as data, never as instructions.
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { getDb, registerMigrations } from "./store.js";
10
+ import { reminderCreate } from "./reminderOps.js";
11
+ import { UNTRUSTED_NOTICE } from "./webOps.js";
12
+
13
+ registerMigrations("pipeline", [
14
+ `CREATE TABLE companies (
15
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ name TEXT NOT NULL,
17
+ domain TEXT,
18
+ website TEXT,
19
+ notes TEXT,
20
+ created_at TEXT NOT NULL,
21
+ updated_at TEXT NOT NULL
22
+ )`,
23
+ `CREATE UNIQUE INDEX idx_companies_domain ON companies (domain) WHERE domain IS NOT NULL`,
24
+ `CREATE INDEX idx_companies_name ON companies (lower(name))`,
25
+ `CREATE TABLE contacts (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ company_id INTEGER REFERENCES companies(id) ON DELETE SET NULL,
28
+ name TEXT,
29
+ email TEXT,
30
+ phone TEXT,
31
+ title TEXT,
32
+ created_at TEXT NOT NULL
33
+ )`,
34
+ `CREATE UNIQUE INDEX idx_contacts_email ON contacts (lower(email)) WHERE email IS NOT NULL`,
35
+ `CREATE TABLE deals (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ company_id INTEGER NOT NULL REFERENCES companies(id),
38
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
39
+ title TEXT NOT NULL,
40
+ stage TEXT NOT NULL DEFAULT 'lead',
41
+ value REAL,
42
+ currency TEXT NOT NULL DEFAULT 'USD',
43
+ source TEXT,
44
+ source_url TEXT,
45
+ next_step TEXT,
46
+ lost_reason TEXT,
47
+ created_at TEXT NOT NULL,
48
+ updated_at TEXT NOT NULL,
49
+ last_activity_at TEXT NOT NULL,
50
+ closed_at TEXT
51
+ )`,
52
+ `CREATE INDEX idx_deals_stage ON deals (stage)`,
53
+ `CREATE TABLE activities (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ deal_id INTEGER NOT NULL REFERENCES deals(id) ON DELETE CASCADE,
56
+ type TEXT NOT NULL,
57
+ summary TEXT NOT NULL,
58
+ stage_from TEXT,
59
+ stage_to TEXT,
60
+ occurred_at TEXT NOT NULL
61
+ )`,
62
+ `CREATE INDEX idx_activities_deal ON activities (deal_id, occurred_at)`,
63
+ ]);
64
+
65
+ export const STAGES = ["lead", "contacted", "replied", "meeting", "proposal", "won", "lost"];
66
+ export const ACTIVITY_TYPES = ["note", "email", "call", "meeting", "proposal", "other"];
67
+ const CLOSED = new Set(["won", "lost"]);
68
+ const DAY_MS = 86_400_000;
69
+
70
+ const clamp = (n, min, max) => Math.min(Math.max(n, min), max);
71
+ const iso = (ms = Date.now()) => new Date(ms).toISOString();
72
+ const text = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
73
+ const daysSince = (isoDate) => Math.max(0, Math.floor((Date.now() - Date.parse(isoDate)) / DAY_MS));
74
+ const plain = (row) => (row ? { ...row } : row);
75
+
76
+ function domainOf(website) {
77
+ const raw = text(website);
78
+ if (!raw) return null;
79
+ try {
80
+ const host = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`).hostname.toLowerCase();
81
+ return host.includes(".") ? host.replace(/^www\./, "") : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function tx(db, fn) {
88
+ db.exec("BEGIN");
89
+ try {
90
+ const result = fn();
91
+ db.exec("COMMIT");
92
+ return result;
93
+ } catch (err) {
94
+ db.exec("ROLLBACK");
95
+ throw err;
96
+ }
97
+ }
98
+
99
+ function checkId(id, label = "deal_id") {
100
+ if (!Number.isInteger(id)) throw new Error(`${label} must be a whole number.`);
101
+ return id;
102
+ }
103
+
104
+ function checkFollowUp(days) {
105
+ if (days === undefined) return undefined;
106
+ if (!Number.isFinite(days) || days < 0 || days > 365) throw new Error("follow_up_in_days must be between 0 and 365.");
107
+ return days;
108
+ }
109
+
110
+ function checkMoney(value, currency) {
111
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) throw new Error("value must be a positive number.");
112
+ if (currency !== undefined && !/^[A-Za-z]{3}$/.test(currency)) throw new Error("currency must be a 3-letter code such as USD or INR.");
113
+ }
114
+
115
+ const DEAL_SELECT = `
116
+ SELECT d.*, c.name AS company, c.domain AS domain, c.website AS website,
117
+ ct.name AS contact_name, ct.email AS contact_email, ct.phone AS contact_phone,
118
+ (SELECT MIN(r.due_at) FROM reminders r WHERE r.deal_id = d.id AND r.status = 'pending') AS next_follow_up
119
+ FROM deals d
120
+ JOIN companies c ON c.id = d.company_id
121
+ LEFT JOIN contacts ct ON ct.id = d.contact_id`;
122
+
123
+ const present = (row) => ({ ...row, days_since_activity: daysSince(row.last_activity_at) });
124
+
125
+ function dealView(db, id) {
126
+ const row = db.prepare(`${DEAL_SELECT} WHERE d.id = ?`).get(id);
127
+ return row ? present(row) : undefined;
128
+ }
129
+
130
+ function requireDeal(db, id) {
131
+ checkId(id);
132
+ const deal = db.prepare("SELECT * FROM deals WHERE id = ?").get(id);
133
+ if (!deal) throw new Error(`No deal with id ${id}.`);
134
+ return deal;
135
+ }
136
+
137
+ function logActivityRow(db, dealId, type, summary, ts, stageFrom = null, stageTo = null) {
138
+ db.prepare("INSERT INTO activities (deal_id, type, summary, stage_from, stage_to, occurred_at) VALUES (?, ?, ?, ?, ?, ?)").run(
139
+ dealId,
140
+ type,
141
+ summary,
142
+ stageFrom,
143
+ stageTo,
144
+ ts
145
+ );
146
+ db.prepare("UPDATE deals SET last_activity_at = ?, updated_at = ? WHERE id = ?").run(ts, ts, dealId);
147
+ }
148
+
149
+ async function scheduleFollowUp(deal, days, company) {
150
+ if (days === undefined || CLOSED.has(deal.stage)) return undefined;
151
+ return reminderCreate({ title: `Follow up: ${company} (${deal.title})`, in_hours: days * 24, deal_id: deal.id });
152
+ }
153
+
154
+ // ---------------------------------------------------------------- add lead
155
+
156
+ /** Add a prospect: finds or creates the company and contact, and opens a deal unless one is already open. */
157
+ export async function addLead({ company, website, contact_name, email, phone, title, source, source_url, value, currency, notes, deal_title, follow_up_in_days } = {}) {
158
+ const companyName = text(company);
159
+ if (!companyName) throw new Error("company is required.");
160
+ const domain = website ? domainOf(website) : null;
161
+ if (website && !domain) throw new Error(`website "${website}" is not a valid URL or domain.`);
162
+ const emailNorm = text(email)?.toLowerCase() ?? null;
163
+ if (emailNorm && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(emailNorm)) throw new Error(`email "${email}" does not look valid.`);
164
+ checkMoney(value, currency);
165
+ checkFollowUp(follow_up_in_days);
166
+
167
+ const db = await getDb();
168
+ const out = tx(db, () => {
169
+ const ts = iso();
170
+
171
+ let co = domain ? db.prepare("SELECT * FROM companies WHERE domain = ?").get(domain) : undefined;
172
+ co ??= db.prepare("SELECT * FROM companies WHERE lower(name) = lower(?)").get(companyName);
173
+ let companyCreated = false;
174
+ if (!co) {
175
+ const r = db.prepare("INSERT INTO companies (name, domain, website, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(companyName, domain, text(website), ts, ts);
176
+ co = db.prepare("SELECT * FROM companies WHERE id = ?").get(Number(r.lastInsertRowid));
177
+ companyCreated = true;
178
+ } else if (domain && !co.domain) {
179
+ db.prepare("UPDATE companies SET domain = ?, website = ?, updated_at = ? WHERE id = ?").run(domain, text(website), ts, co.id);
180
+ }
181
+
182
+ let contact = emailNorm ? db.prepare("SELECT * FROM contacts WHERE lower(email) = ?").get(emailNorm) : undefined;
183
+ let contactCreated = false;
184
+ if (!contact && (text(contact_name) || emailNorm || text(phone))) {
185
+ const r = db
186
+ .prepare("INSERT INTO contacts (company_id, name, email, phone, title, created_at) VALUES (?, ?, ?, ?, ?, ?)")
187
+ .run(co.id, text(contact_name), emailNorm, text(phone), text(title), ts);
188
+ contact = db.prepare("SELECT * FROM contacts WHERE id = ?").get(Number(r.lastInsertRowid));
189
+ contactCreated = true;
190
+ }
191
+
192
+ let deal = db.prepare("SELECT * FROM deals WHERE company_id = ? AND stage NOT IN ('won', 'lost') ORDER BY id DESC LIMIT 1").get(co.id);
193
+ let dealCreated = false;
194
+ if (!deal) {
195
+ const r = db
196
+ .prepare(
197
+ `INSERT INTO deals (company_id, contact_id, title, stage, value, currency, source, source_url, created_at, updated_at, last_activity_at)
198
+ VALUES (?, ?, ?, 'lead', ?, ?, ?, ?, ?, ?, ?)`
199
+ )
200
+ .run(co.id, contact?.id ?? null, text(deal_title) ?? companyName, value ?? null, (currency ?? "USD").toUpperCase(), text(source), text(source_url), ts, ts, ts);
201
+ deal = db.prepare("SELECT * FROM deals WHERE id = ?").get(Number(r.lastInsertRowid));
202
+ dealCreated = true;
203
+ logActivityRow(db, deal.id, "note", `Added to pipeline${text(source) ? ` from ${text(source)}` : ""}${text(notes) ? `: ${text(notes)}` : ""}`, ts);
204
+ } else if (text(notes)) {
205
+ logActivityRow(db, deal.id, "note", text(notes), ts);
206
+ }
207
+ if (!deal.contact_id && contact) db.prepare("UPDATE deals SET contact_id = ?, updated_at = ? WHERE id = ?").run(contact.id, ts, deal.id);
208
+
209
+ return { dealId: deal.id, companyCreated, contactCreated, dealCreated };
210
+ });
211
+
212
+ const deal = dealView(db, out.dealId);
213
+ const reminder = await scheduleFollowUp(deal, follow_up_in_days, deal.company);
214
+ return {
215
+ deal: dealView(db, out.dealId),
216
+ company_created: out.companyCreated,
217
+ contact_created: out.contactCreated,
218
+ deal_created: out.dealCreated,
219
+ ...(out.dealCreated ? {} : { note: "This company already has an open deal, so no new deal was created. New contact details and notes were added to it." }),
220
+ ...(reminder ? { follow_up: { reminder_id: reminder.id, due_at: reminder.due_at } } : {}),
221
+ };
222
+ }
223
+
224
+ // ---------------------------------------------------------------- list / get
225
+
226
+ /** List deals, most recently active first. Open deals by default. */
227
+ export async function listDeals({ stage, status, query, source, sort = "recent", limit = 25 } = {}) {
228
+ if (stage !== undefined && !STAGES.includes(stage)) throw new Error(`stage must be one of: ${STAGES.join(", ")}.`);
229
+ const effectiveStatus = status ?? (stage ? "all" : "open");
230
+ if (!["open", "won", "lost", "all"].includes(effectiveStatus)) throw new Error("status must be open, won, lost or all.");
231
+ if (!["recent", "stale", "value"].includes(sort)) throw new Error("sort must be recent, stale or value.");
232
+
233
+ const clauses = [];
234
+ const params = [];
235
+ if (effectiveStatus === "open") clauses.push("d.stage NOT IN ('won', 'lost')");
236
+ else if (effectiveStatus !== "all") {
237
+ clauses.push("d.stage = ?");
238
+ params.push(effectiveStatus);
239
+ }
240
+ if (stage) {
241
+ clauses.push("d.stage = ?");
242
+ params.push(stage);
243
+ }
244
+ if (text(query)) {
245
+ const like = `%${text(query)}%`;
246
+ clauses.push("(c.name LIKE ? OR c.domain LIKE ? OR ct.name LIKE ? OR ct.email LIKE ? OR d.title LIKE ?)");
247
+ params.push(like, like, like, like, like);
248
+ }
249
+ if (text(source)) {
250
+ clauses.push("lower(d.source) = lower(?)");
251
+ params.push(text(source));
252
+ }
253
+ const order = { recent: "d.last_activity_at DESC", stale: "d.last_activity_at ASC", value: "COALESCE(d.value, 0) DESC, d.last_activity_at DESC" }[sort];
254
+
255
+ const db = await getDb();
256
+ const rows = db
257
+ .prepare(`${DEAL_SELECT} ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY ${order} LIMIT ?`)
258
+ .all(...params, clamp(Math.trunc(limit) || 25, 1, 100));
259
+ return { count: rows.length, deals: rows.map(present), notice: UNTRUSTED_NOTICE };
260
+ }
261
+
262
+ /** Everything about one deal: company, contacts, activity timeline and follow-ups. */
263
+ export async function getDeal({ deal_id } = {}) {
264
+ const db = await getDb();
265
+ requireDeal(db, deal_id);
266
+ const deal = dealView(db, deal_id);
267
+ const company = plain(db.prepare("SELECT * FROM companies WHERE id = ?").get(deal.company_id));
268
+ const contacts = db.prepare("SELECT * FROM contacts WHERE company_id = ? ORDER BY id").all(deal.company_id).map(plain);
269
+ const activities = db.prepare("SELECT * FROM activities WHERE deal_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 50").all(deal_id).map(plain);
270
+ const follow_ups = db.prepare("SELECT id, title, due_at, status FROM reminders WHERE deal_id = ? AND status = 'pending' ORDER BY due_at").all(deal_id).map(plain);
271
+ return { deal, company, contacts, activities, follow_ups, notice: UNTRUSTED_NOTICE };
272
+ }
273
+
274
+ // ---------------------------------------------------------------- update / activity / contact
275
+
276
+ /** Move a deal between stages, edit its fields, and optionally schedule a follow-up. */
277
+ export async function updateDeal({ deal_id, stage, value, currency, title, next_step, notes, lost_reason, follow_up_in_days } = {}) {
278
+ if (stage !== undefined && !STAGES.includes(stage)) throw new Error(`stage must be one of: ${STAGES.join(", ")}.`);
279
+ checkMoney(value, currency);
280
+ checkFollowUp(follow_up_in_days);
281
+ if ([stage, value, currency, title, next_step, notes, lost_reason, follow_up_in_days].every((v) => v === undefined)) {
282
+ throw new Error("Nothing to update: pass stage, value, title, next_step, notes, lost_reason or follow_up_in_days.");
283
+ }
284
+
285
+ const db = await getDb();
286
+ tx(db, () => {
287
+ const deal = requireDeal(db, deal_id);
288
+ const ts = iso();
289
+ const sets = [];
290
+ const params = [];
291
+ if (text(title)) (sets.push("title = ?"), params.push(text(title)));
292
+ if (value !== undefined) (sets.push("value = ?"), params.push(value));
293
+ if (currency !== undefined) (sets.push("currency = ?"), params.push(currency.toUpperCase()));
294
+ if (next_step !== undefined) (sets.push("next_step = ?"), params.push(text(next_step)));
295
+ if (lost_reason !== undefined && (stage === "lost" || (stage === undefined && deal.stage === "lost"))) {
296
+ sets.push("lost_reason = ?");
297
+ params.push(text(lost_reason));
298
+ }
299
+ if (sets.length) db.prepare(`UPDATE deals SET ${sets.join(", ")}, updated_at = ? WHERE id = ?`).run(...params, ts, deal.id);
300
+
301
+ if (stage !== undefined && stage !== deal.stage) {
302
+ const closed = CLOSED.has(stage);
303
+ db.prepare("UPDATE deals SET stage = ?, closed_at = ?, lost_reason = ? WHERE id = ?").run(
304
+ stage,
305
+ closed ? ts : null,
306
+ stage === "lost" ? text(lost_reason) ?? deal.lost_reason : null,
307
+ deal.id
308
+ );
309
+ logActivityRow(db, deal.id, "stage_change", `${deal.stage} -> ${stage}`, ts, deal.stage, stage);
310
+ if (closed) db.prepare("UPDATE reminders SET status = 'cancelled', updated_at = ? WHERE deal_id = ? AND status = 'pending'").run(ts, deal.id);
311
+ }
312
+ if (text(notes)) logActivityRow(db, deal.id, "note", text(notes), ts);
313
+ });
314
+
315
+ const deal = dealView(db, deal_id);
316
+ const reminder = await scheduleFollowUp(deal, follow_up_in_days, deal.company);
317
+ return {
318
+ deal: dealView(db, deal_id),
319
+ ...(reminder ? { follow_up: { reminder_id: reminder.id, due_at: reminder.due_at } } : {}),
320
+ ...(follow_up_in_days !== undefined && !reminder ? { note: "No follow-up was scheduled because the deal is closed." } : {}),
321
+ };
322
+ }
323
+
324
+ /** Record something that happened on a deal (email, call, meeting, note) and optionally schedule a follow-up. */
325
+ export async function logActivity({ deal_id, type = "note", summary, follow_up_in_days } = {}) {
326
+ if (!ACTIVITY_TYPES.includes(type)) throw new Error(`type must be one of: ${ACTIVITY_TYPES.join(", ")}.`);
327
+ if (!text(summary)) throw new Error("summary is required.");
328
+ checkFollowUp(follow_up_in_days);
329
+ const db = await getDb();
330
+ tx(db, () => {
331
+ const deal = requireDeal(db, deal_id);
332
+ logActivityRow(db, deal.id, type, text(summary), iso());
333
+ });
334
+ const deal = dealView(db, deal_id);
335
+ const reminder = await scheduleFollowUp(deal, follow_up_in_days, deal.company);
336
+ return { deal, ...(reminder ? { follow_up: { reminder_id: reminder.id, due_at: reminder.due_at } } : {}) };
337
+ }
338
+
339
+ /** Add another person at the deal's company. */
340
+ export async function addContact({ deal_id, name, email, phone, title, primary = false } = {}) {
341
+ const emailNorm = text(email)?.toLowerCase() ?? null;
342
+ if (emailNorm && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(emailNorm)) throw new Error(`email "${email}" does not look valid.`);
343
+ if (!text(name) && !emailNorm && !text(phone)) throw new Error("Pass at least a name, email or phone.");
344
+ const db = await getDb();
345
+ return tx(db, () => {
346
+ const deal = requireDeal(db, deal_id);
347
+ const ts = iso();
348
+ let contact = emailNorm ? db.prepare("SELECT * FROM contacts WHERE lower(email) = ?").get(emailNorm) : undefined;
349
+ const created = !contact;
350
+ if (!contact) {
351
+ const r = db
352
+ .prepare("INSERT INTO contacts (company_id, name, email, phone, title, created_at) VALUES (?, ?, ?, ?, ?, ?)")
353
+ .run(deal.company_id, text(name), emailNorm, text(phone), text(title), ts);
354
+ contact = db.prepare("SELECT * FROM contacts WHERE id = ?").get(Number(r.lastInsertRowid));
355
+ }
356
+ if (primary || !deal.contact_id) db.prepare("UPDATE deals SET contact_id = ?, updated_at = ? WHERE id = ?").run(contact.id, ts, deal.id);
357
+ return { contact: plain(contact), created, primary: primary || !deal.contact_id, deal: dealView(db, deal.id) };
358
+ });
359
+ }
360
+
361
+ /** Delete a deal and its history (the company and contacts stay). */
362
+ export async function deleteDeal({ deal_id } = {}) {
363
+ const db = await getDb();
364
+ return tx(db, () => {
365
+ const deal = requireDeal(db, deal_id);
366
+ const view = dealView(db, deal.id);
367
+ db.prepare("DELETE FROM reminders WHERE deal_id = ?").run(deal.id);
368
+ db.prepare("DELETE FROM deals WHERE id = ?").run(deal.id); // activities go with it
369
+ return { deleted: true, deal: { id: view.id, title: view.title, company: view.company, stage: view.stage } };
370
+ });
371
+ }
372
+
373
+ // ---------------------------------------------------------------- report
374
+
375
+ const sumByCurrency = (rows) => {
376
+ const out = {};
377
+ for (const r of rows) if (r.value > 0) out[r.currency] = (out[r.currency] ?? 0) + r.value;
378
+ return out;
379
+ };
380
+
381
+ /** The pipeline at a glance: what is open, what is stale, what is due, and what is working. */
382
+ export async function pipelineReport({ days = 30, stale_days = 7 } = {}) {
383
+ const span = clamp(Math.trunc(days) || 30, 1, 365);
384
+ const staleAfter = clamp(Math.trunc(stale_days) || 7, 1, 90);
385
+ const nowMs = Date.now();
386
+ const nowIso = iso(nowMs);
387
+ const since = iso(nowMs - span * DAY_MS);
388
+ const staleBefore = iso(nowMs - staleAfter * DAY_MS);
389
+ const weekAhead = iso(nowMs + 7 * DAY_MS);
390
+ const db = await getDb();
391
+
392
+ const openRows = db
393
+ .prepare("SELECT stage, currency, COUNT(*) AS deals, SUM(COALESCE(value, 0)) AS value FROM deals WHERE stage NOT IN ('won', 'lost') GROUP BY stage, currency")
394
+ .all();
395
+ const open = STAGES.filter((s) => !CLOSED.has(s)).map((stage) => {
396
+ const rows = openRows.filter((r) => r.stage === stage);
397
+ return { stage, deals: rows.reduce((n, r) => n + r.deals, 0), value: sumByCurrency(rows) };
398
+ });
399
+
400
+ const stale = db
401
+ .prepare(
402
+ `SELECT d.id AS deal_id, d.title, d.stage, d.last_activity_at, c.name AS company
403
+ FROM deals d JOIN companies c ON c.id = d.company_id
404
+ WHERE d.stage NOT IN ('won', 'lost') AND d.last_activity_at < ?
405
+ ORDER BY d.last_activity_at ASC LIMIT 10`
406
+ )
407
+ .all(staleBefore)
408
+ .map((r) => ({ ...r, days_since_activity: daysSince(r.last_activity_at) }));
409
+
410
+ const followUps = db
411
+ .prepare(
412
+ `SELECT r.id AS reminder_id, r.title, r.due_at, d.id AS deal_id, d.stage, c.name AS company
413
+ FROM reminders r JOIN deals d ON d.id = r.deal_id JOIN companies c ON c.id = d.company_id
414
+ WHERE r.status = 'pending' AND r.due_at <= ?
415
+ ORDER BY r.due_at ASC LIMIT 20`
416
+ )
417
+ .all(weekAhead)
418
+ .map((r) => ({ ...r, overdue: r.due_at <= nowIso }));
419
+
420
+ const closed = db
421
+ .prepare("SELECT stage, currency, COUNT(*) AS deals, SUM(COALESCE(value, 0)) AS value FROM deals WHERE closed_at >= ? GROUP BY stage, currency")
422
+ .all(since);
423
+ const wonRows = closed.filter((r) => r.stage === "won");
424
+ const won = wonRows.reduce((n, r) => n + r.deals, 0);
425
+ const lost = closed.filter((r) => r.stage === "lost").reduce((n, r) => n + r.deals, 0);
426
+ const avgClose = db
427
+ .prepare("SELECT AVG(julianday(closed_at) - julianday(created_at)) AS d FROM deals WHERE stage = 'won' AND closed_at >= ?")
428
+ .get(since).d;
429
+
430
+ const sources = db
431
+ .prepare(
432
+ `SELECT COALESCE(source, 'unknown') AS source, COUNT(*) AS deals,
433
+ SUM(CASE WHEN stage = 'won' THEN 1 ELSE 0 END) AS won, SUM(CASE WHEN stage = 'lost' THEN 1 ELSE 0 END) AS lost
434
+ FROM deals GROUP BY COALESCE(source, 'unknown') ORDER BY deals DESC, source LIMIT 10`
435
+ )
436
+ .all();
437
+ const sourceValue = db
438
+ .prepare("SELECT COALESCE(source, 'unknown') AS source, currency, SUM(COALESCE(value, 0)) AS value FROM deals WHERE stage = 'won' GROUP BY 1, 2")
439
+ .all();
440
+ const by_source = sources.map((s) => ({ ...s, won_value: sumByCurrency(sourceValue.filter((v) => v.source === s.source)) }));
441
+
442
+ const totalDeals = db.prepare("SELECT COUNT(*) AS n FROM deals").get().n;
443
+ const reached = db.prepare(
444
+ "SELECT COUNT(*) AS n FROM (SELECT deal_id AS id FROM activities WHERE type = 'stage_change' AND stage_to = ? UNION SELECT id FROM deals WHERE stage = ?)"
445
+ );
446
+ const funnel = STAGES.filter((s) => s !== "lost").map((stage) => ({ stage, reached: stage === "lead" ? totalDeals : reached.get(stage, stage).n }));
447
+
448
+ return {
449
+ generated_at: nowIso,
450
+ period_days: span,
451
+ total_deals: totalDeals,
452
+ new_in_period: db.prepare("SELECT COUNT(*) AS n FROM deals WHERE created_at >= ?").get(since).n,
453
+ open,
454
+ stale_after_days: staleAfter,
455
+ stale,
456
+ follow_ups: followUps,
457
+ closed_in_period: {
458
+ won,
459
+ lost,
460
+ win_rate_percent: won + lost ? Math.round((won / (won + lost)) * 100) : null,
461
+ won_value: sumByCurrency(wonRows),
462
+ avg_days_to_close: avgClose == null ? null : Math.round(avgClose * 10) / 10,
463
+ },
464
+ by_source,
465
+ funnel,
466
+ note: "Funnel counts deals that reached each stage through logged stage changes. Skipped stages are not counted.",
467
+ };
468
+ }
469
+
470
+ // ---------------------------------------------------------------- export
471
+
472
+ // Cells that start with = + - @ can run as formulas when a CSV is opened in a spreadsheet, and the
473
+ // text here often comes from the web, so neutralise them.
474
+ function csvCell(value) {
475
+ if (value === null || value === undefined) return "";
476
+ let s = String(value);
477
+ // Phone-like values ("+1 415 555 0100") are plain numbers, so leave them alone.
478
+ if (/^[=+\-@\t\r]/.test(s) && !/^[+-]?[\d ().-]+$/.test(s)) s = `'${s}`;
479
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
480
+ }
481
+
482
+ /** Write deals or contacts to a CSV file you can open in a spreadsheet. */
483
+ export async function exportCsv({ path: filePath, what = "deals", overwrite = false } = {}) {
484
+ if (!text(filePath)) throw new Error("path is required.");
485
+ if (!["deals", "contacts"].includes(what)) throw new Error("what must be deals or contacts.");
486
+ const target = path.resolve(filePath);
487
+ if (fs.existsSync(target) && !overwrite) throw new Error(`${target} already exists. Pass overwrite: true to replace it.`);
488
+
489
+ const db = await getDb();
490
+ const rows =
491
+ what === "deals"
492
+ ? db
493
+ .prepare(
494
+ `SELECT d.id AS deal_id, c.name AS company, c.website, ct.name AS contact_name, ct.email AS contact_email, ct.phone AS contact_phone,
495
+ d.title, d.stage, d.value, d.currency, d.source, d.source_url, d.next_step, d.lost_reason,
496
+ d.created_at, d.last_activity_at, d.closed_at
497
+ FROM deals d JOIN companies c ON c.id = d.company_id LEFT JOIN contacts ct ON ct.id = d.contact_id ORDER BY d.id`
498
+ )
499
+ .all()
500
+ : db
501
+ .prepare(
502
+ `SELECT ct.id AS contact_id, ct.name, ct.email, ct.phone, ct.title, c.name AS company, c.website, ct.created_at
503
+ FROM contacts ct LEFT JOIN companies c ON c.id = ct.company_id ORDER BY ct.id`
504
+ )
505
+ .all();
506
+ const headers = rows.length ? Object.keys(rows[0]) : what === "deals" ? ["deal_id", "company", "stage"] : ["contact_id", "name", "email"];
507
+ const csv = [headers.join(","), ...rows.map((r) => headers.map((h) => csvCell(r[h])).join(","))].join("\r\n") + "\r\n";
508
+ fs.mkdirSync(path.dirname(target), { recursive: true });
509
+ fs.writeFileSync(target, csv);
510
+ return { path: target, what, rows: rows.length };
511
+ }