@dzhng/crm.cli 0.2.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/README.md +1312 -0
- package/dist/cli.js +4291 -0
- package/package.json +52 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,4291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
// src/cli.ts
|
|
20
|
+
import { Command } from "commander";
|
|
21
|
+
|
|
22
|
+
// src/db.ts
|
|
23
|
+
import { mkdirSync as mkdirSync2 } from "node:fs";
|
|
24
|
+
import { dirname as dirname2 } from "node:path";
|
|
25
|
+
import { createClient } from "@libsql/client";
|
|
26
|
+
import { sql as sql2 } from "drizzle-orm";
|
|
27
|
+
import { drizzle } from "drizzle-orm/libsql";
|
|
28
|
+
|
|
29
|
+
// src/drizzle-schema.ts
|
|
30
|
+
var exports_drizzle_schema = {};
|
|
31
|
+
__export(exports_drizzle_schema, {
|
|
32
|
+
deals: () => deals,
|
|
33
|
+
contacts: () => contacts,
|
|
34
|
+
companies: () => companies,
|
|
35
|
+
activities: () => activities
|
|
36
|
+
});
|
|
37
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
38
|
+
var contacts = sqliteTable("contacts", {
|
|
39
|
+
id: text("id").primaryKey(),
|
|
40
|
+
name: text("name").notNull(),
|
|
41
|
+
emails: text("emails").notNull().default("[]"),
|
|
42
|
+
phones: text("phones").notNull().default("[]"),
|
|
43
|
+
companies: text("companies").notNull().default("[]"),
|
|
44
|
+
linkedin: text("linkedin"),
|
|
45
|
+
x: text("x"),
|
|
46
|
+
bluesky: text("bluesky"),
|
|
47
|
+
telegram: text("telegram"),
|
|
48
|
+
tags: text("tags").notNull().default("[]"),
|
|
49
|
+
custom_fields: text("custom_fields").notNull().default("{}"),
|
|
50
|
+
created_at: text("created_at").notNull(),
|
|
51
|
+
updated_at: text("updated_at").notNull()
|
|
52
|
+
});
|
|
53
|
+
var companies = sqliteTable("companies", {
|
|
54
|
+
id: text("id").primaryKey(),
|
|
55
|
+
name: text("name").notNull(),
|
|
56
|
+
websites: text("websites").notNull().default("[]"),
|
|
57
|
+
phones: text("phones").notNull().default("[]"),
|
|
58
|
+
tags: text("tags").notNull().default("[]"),
|
|
59
|
+
custom_fields: text("custom_fields").notNull().default("{}"),
|
|
60
|
+
created_at: text("created_at").notNull(),
|
|
61
|
+
updated_at: text("updated_at").notNull()
|
|
62
|
+
});
|
|
63
|
+
var deals = sqliteTable("deals", {
|
|
64
|
+
id: text("id").primaryKey(),
|
|
65
|
+
title: text("title").notNull(),
|
|
66
|
+
value: integer("value"),
|
|
67
|
+
stage: text("stage").notNull(),
|
|
68
|
+
contacts: text("contacts").notNull().default("[]"),
|
|
69
|
+
company: text("company"),
|
|
70
|
+
expected_close: text("expected_close"),
|
|
71
|
+
probability: integer("probability"),
|
|
72
|
+
tags: text("tags").notNull().default("[]"),
|
|
73
|
+
custom_fields: text("custom_fields").notNull().default("{}"),
|
|
74
|
+
created_at: text("created_at").notNull(),
|
|
75
|
+
updated_at: text("updated_at").notNull()
|
|
76
|
+
});
|
|
77
|
+
var activities = sqliteTable("activities", {
|
|
78
|
+
id: text("id").primaryKey(),
|
|
79
|
+
type: text("type").notNull(),
|
|
80
|
+
body: text("body").notNull().default(""),
|
|
81
|
+
contacts: text("contacts").notNull().default("[]"),
|
|
82
|
+
company: text("company"),
|
|
83
|
+
deal: text("deal"),
|
|
84
|
+
custom_fields: text("custom_fields").notNull().default("{}"),
|
|
85
|
+
created_at: text("created_at").notNull()
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// src/lib/helpers.ts
|
|
89
|
+
import { eq as eq2, sql } from "drizzle-orm";
|
|
90
|
+
import { ulid } from "ulid";
|
|
91
|
+
|
|
92
|
+
// src/config.ts
|
|
93
|
+
import { execSync } from "node:child_process";
|
|
94
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
95
|
+
import { homedir } from "node:os";
|
|
96
|
+
import { dirname, join, resolve } from "node:path";
|
|
97
|
+
import { parse as parseTOML } from "toml";
|
|
98
|
+
var DEFAULT_STAGES = [
|
|
99
|
+
"lead",
|
|
100
|
+
"qualified",
|
|
101
|
+
"proposal",
|
|
102
|
+
"negotiation",
|
|
103
|
+
"closed-won",
|
|
104
|
+
"closed-lost"
|
|
105
|
+
];
|
|
106
|
+
function defaultConfig() {
|
|
107
|
+
return {
|
|
108
|
+
database: { path: join(homedir(), ".crm", "crm.db") },
|
|
109
|
+
pipeline: {
|
|
110
|
+
stages: [...DEFAULT_STAGES],
|
|
111
|
+
won_stage: "closed-won",
|
|
112
|
+
lost_stage: "closed-lost"
|
|
113
|
+
},
|
|
114
|
+
defaults: { format: "table" },
|
|
115
|
+
phone: { display: "international" },
|
|
116
|
+
hooks: {},
|
|
117
|
+
mount: {
|
|
118
|
+
default_path: join(homedir(), "crm"),
|
|
119
|
+
readonly: false,
|
|
120
|
+
max_recent_activity: 10,
|
|
121
|
+
search_limit: 20
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function findConfigFile(startDir) {
|
|
126
|
+
let dir = resolve(startDir);
|
|
127
|
+
while (true) {
|
|
128
|
+
const candidate = join(dir, "crm.toml");
|
|
129
|
+
if (existsSync(candidate)) {
|
|
130
|
+
return candidate;
|
|
131
|
+
}
|
|
132
|
+
const parent = dirname(dir);
|
|
133
|
+
if (parent === dir) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
dir = parent;
|
|
137
|
+
}
|
|
138
|
+
const global = join(homedir(), ".crm", "config.toml");
|
|
139
|
+
if (existsSync(global)) {
|
|
140
|
+
return global;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
function mergeConfig(base, override) {
|
|
145
|
+
const result = { ...base };
|
|
146
|
+
if (override.database?.path) {
|
|
147
|
+
result.database = { ...result.database, path: override.database.path };
|
|
148
|
+
}
|
|
149
|
+
if (override.pipeline) {
|
|
150
|
+
result.pipeline = { ...result.pipeline };
|
|
151
|
+
if (override.pipeline.stages) {
|
|
152
|
+
result.pipeline.stages = override.pipeline.stages;
|
|
153
|
+
}
|
|
154
|
+
if (override.pipeline.won_stage) {
|
|
155
|
+
result.pipeline.won_stage = override.pipeline.won_stage;
|
|
156
|
+
}
|
|
157
|
+
if (override.pipeline.lost_stage) {
|
|
158
|
+
result.pipeline.lost_stage = override.pipeline.lost_stage;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (override.defaults?.format) {
|
|
162
|
+
result.defaults = { ...result.defaults, format: override.defaults.format };
|
|
163
|
+
}
|
|
164
|
+
if (override.phone) {
|
|
165
|
+
result.phone = { ...result.phone };
|
|
166
|
+
if (override.phone.default_country) {
|
|
167
|
+
result.phone.default_country = override.phone.default_country;
|
|
168
|
+
}
|
|
169
|
+
if (override.phone.display) {
|
|
170
|
+
result.phone.display = override.phone.display;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (override.hooks) {
|
|
174
|
+
result.hooks = { ...result.hooks, ...override.hooks };
|
|
175
|
+
}
|
|
176
|
+
if (override.mount) {
|
|
177
|
+
result.mount = { ...result.mount, ...override.mount };
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
function detectCountry() {
|
|
182
|
+
try {
|
|
183
|
+
if (process.platform === "darwin") {
|
|
184
|
+
const locale = execSync("defaults read NSGlobalDomain AppleLocale", {
|
|
185
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
186
|
+
}).toString().trim();
|
|
187
|
+
const match2 = locale.match(/_([A-Z]{2})/);
|
|
188
|
+
if (match2) {
|
|
189
|
+
return match2[1];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const lang = process.env.LC_ALL || process.env.LANG || "";
|
|
193
|
+
const match = lang.match(/_([A-Z]{2})/);
|
|
194
|
+
if (match) {
|
|
195
|
+
return match[1];
|
|
196
|
+
}
|
|
197
|
+
} catch {}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
function createDefaultConfig(configPath) {
|
|
201
|
+
const country = detectCountry() || "US";
|
|
202
|
+
const content = `# CRM CLI configuration
|
|
203
|
+
# Docs: https://github.com/dzhng/crm.cli#configuration
|
|
204
|
+
|
|
205
|
+
[phone]
|
|
206
|
+
default_country = "${country}"
|
|
207
|
+
display = "national"
|
|
208
|
+
|
|
209
|
+
[pipeline]
|
|
210
|
+
stages = ["lead", "qualified", "proposal", "negotiation", "closed-won", "closed-lost"]
|
|
211
|
+
won_stage = "closed-won"
|
|
212
|
+
lost_stage = "closed-lost"
|
|
213
|
+
`;
|
|
214
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
215
|
+
writeFileSync(configPath, content);
|
|
216
|
+
console.log(`Created default config at ${configPath}`);
|
|
217
|
+
console.log(` phone.default_country = "${country}" (detected from system locale)`);
|
|
218
|
+
console.log(` Edit this file to customize.
|
|
219
|
+
`);
|
|
220
|
+
}
|
|
221
|
+
function loadConfig(opts) {
|
|
222
|
+
let config = defaultConfig();
|
|
223
|
+
const configPath = opts.configPath || process.env.CRM_CONFIG || findConfigFile(process.cwd()) || (() => {
|
|
224
|
+
const p = join(homedir(), ".crm", "config.toml");
|
|
225
|
+
createDefaultConfig(p);
|
|
226
|
+
return p;
|
|
227
|
+
})();
|
|
228
|
+
try {
|
|
229
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
230
|
+
const parsed = parseTOML(raw);
|
|
231
|
+
config = mergeConfig(config, parsed);
|
|
232
|
+
} catch (_e) {
|
|
233
|
+
console.error(`Warning: could not parse config file ${configPath}`);
|
|
234
|
+
}
|
|
235
|
+
if (process.env.CRM_PHONE_DEFAULT_COUNTRY) {
|
|
236
|
+
config.phone.default_country = process.env.CRM_PHONE_DEFAULT_COUNTRY;
|
|
237
|
+
}
|
|
238
|
+
if (process.env.CRM_PHONE_DISPLAY) {
|
|
239
|
+
config.phone.display = process.env.CRM_PHONE_DISPLAY;
|
|
240
|
+
}
|
|
241
|
+
if (opts.dbPath) {
|
|
242
|
+
config.database.path = opts.dbPath;
|
|
243
|
+
} else if (process.env.CRM_DB) {
|
|
244
|
+
config.database.path = process.env.CRM_DB;
|
|
245
|
+
}
|
|
246
|
+
if (opts.format) {
|
|
247
|
+
config.defaults.format = opts.format;
|
|
248
|
+
} else if (process.env.CRM_FORMAT) {
|
|
249
|
+
config.defaults.format = process.env.CRM_FORMAT;
|
|
250
|
+
}
|
|
251
|
+
return config;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/normalize.ts
|
|
255
|
+
import { parsePhoneNumberFromString } from "libphonenumber-js";
|
|
256
|
+
import normalizeUrl from "normalize-url";
|
|
257
|
+
function normalizePhone(input, defaultCountry) {
|
|
258
|
+
const cleaned = input.trim();
|
|
259
|
+
const country = defaultCountry;
|
|
260
|
+
let phone = parsePhoneNumberFromString(cleaned, country);
|
|
261
|
+
if (!(phone || cleaned.startsWith("+") || country)) {
|
|
262
|
+
phone = parsePhoneNumberFromString(cleaned, "US");
|
|
263
|
+
if (phone?.isValid()) {
|
|
264
|
+
const hasFormatting = /[()\-\s]/.test(cleaned);
|
|
265
|
+
if (!hasFormatting) {
|
|
266
|
+
throw new Error(`Invalid phone number: "${input}". No country code provided — set phone.default_country in config or prefix with +`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!phone) {
|
|
271
|
+
if (!(defaultCountry || cleaned.startsWith("+"))) {
|
|
272
|
+
throw new Error(`Invalid phone number: "${input}". No country code provided — set phone.default_country in config or prefix with +`);
|
|
273
|
+
}
|
|
274
|
+
throw new Error(`Invalid phone number: "${input}"`);
|
|
275
|
+
}
|
|
276
|
+
if (!phone.isValid()) {
|
|
277
|
+
throw new Error(`Invalid phone number: "${input}"`);
|
|
278
|
+
}
|
|
279
|
+
return phone.format("E.164");
|
|
280
|
+
}
|
|
281
|
+
function formatPhone(e164, display, defaultCountry) {
|
|
282
|
+
const phone = parsePhoneNumberFromString(e164);
|
|
283
|
+
if (!phone) {
|
|
284
|
+
return e164;
|
|
285
|
+
}
|
|
286
|
+
switch (display) {
|
|
287
|
+
case "e164":
|
|
288
|
+
return phone.format("E.164");
|
|
289
|
+
case "national":
|
|
290
|
+
if (defaultCountry && phone.country !== defaultCountry.toUpperCase()) {
|
|
291
|
+
return phone.formatInternational();
|
|
292
|
+
}
|
|
293
|
+
return phone.formatNational();
|
|
294
|
+
default:
|
|
295
|
+
return phone.formatInternational();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function tryNormalizePhone(input, defaultCountry) {
|
|
299
|
+
try {
|
|
300
|
+
return normalizePhone(input, defaultCountry);
|
|
301
|
+
} catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function extractPhoneDigits(input) {
|
|
306
|
+
return input.replace(/[^\d]/g, "");
|
|
307
|
+
}
|
|
308
|
+
function phoneMatchesByDigits(e164, digits) {
|
|
309
|
+
const stored = extractPhoneDigits(e164);
|
|
310
|
+
return stored.endsWith(digits) || digits.endsWith(stored);
|
|
311
|
+
}
|
|
312
|
+
var NORMALIZE_URL_OPTS = {
|
|
313
|
+
stripProtocol: true,
|
|
314
|
+
stripHash: true,
|
|
315
|
+
removeQueryParameters: true,
|
|
316
|
+
stripWWW: true,
|
|
317
|
+
removeSingleSlash: true,
|
|
318
|
+
sortQueryParameters: false
|
|
319
|
+
};
|
|
320
|
+
function normalizeWebsite(input) {
|
|
321
|
+
return normalizeUrl(input.trim(), NORMALIZE_URL_OPTS);
|
|
322
|
+
}
|
|
323
|
+
function tryNormalizeWebsite(input) {
|
|
324
|
+
try {
|
|
325
|
+
return normalizeUrl(input.trim(), NORMALIZE_URL_OPTS);
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
var SOCIAL_PATTERNS = {
|
|
331
|
+
linkedin: [/(?:https?:\/\/)?(?:www\.)?linkedin\.com\/in\/([^/?#]+)\/?/i],
|
|
332
|
+
x: [/(?:https?:\/\/)?(?:www\.)?(?:x\.com|twitter\.com)\/([^/?#]+)\/?/i],
|
|
333
|
+
bluesky: [/(?:https?:\/\/)?(?:www\.)?bsky\.app\/profile\/([^/?#]+)\/?/i],
|
|
334
|
+
telegram: [/(?:https?:\/\/)?(?:www\.)?t\.me\/([^/?#]+)\/?/i]
|
|
335
|
+
};
|
|
336
|
+
function normalizeSocialHandle(platform, input) {
|
|
337
|
+
const trimmed = input.trim();
|
|
338
|
+
const patterns = SOCIAL_PATTERNS[platform];
|
|
339
|
+
if (patterns) {
|
|
340
|
+
for (const pattern of patterns) {
|
|
341
|
+
const match = trimmed.match(pattern);
|
|
342
|
+
if (match) {
|
|
343
|
+
return match[1];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (trimmed.startsWith("@")) {
|
|
348
|
+
return trimmed.slice(1);
|
|
349
|
+
}
|
|
350
|
+
return trimmed;
|
|
351
|
+
}
|
|
352
|
+
function tryExtractSocialHandle(input) {
|
|
353
|
+
for (const [platform, patterns] of Object.entries(SOCIAL_PATTERNS)) {
|
|
354
|
+
for (const pattern of patterns) {
|
|
355
|
+
const match = input.match(pattern);
|
|
356
|
+
if (match) {
|
|
357
|
+
return { platform, handle: match[1] };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/format.ts
|
|
365
|
+
function formatOutput(data, format, config) {
|
|
366
|
+
switch (format) {
|
|
367
|
+
case "json":
|
|
368
|
+
return JSON.stringify(data, null, 2);
|
|
369
|
+
case "csv":
|
|
370
|
+
return formatCSV(data);
|
|
371
|
+
case "tsv":
|
|
372
|
+
return formatTSV(data);
|
|
373
|
+
case "ids":
|
|
374
|
+
return formatIDs(data);
|
|
375
|
+
default:
|
|
376
|
+
return formatTable(data, config);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function formatIDs(data) {
|
|
380
|
+
if (!Array.isArray(data)) {
|
|
381
|
+
return "";
|
|
382
|
+
}
|
|
383
|
+
return data.map((r) => r.id).join(`
|
|
384
|
+
`);
|
|
385
|
+
}
|
|
386
|
+
function formatCSV(data) {
|
|
387
|
+
if (!Array.isArray(data) || data.length === 0) {
|
|
388
|
+
return "";
|
|
389
|
+
}
|
|
390
|
+
const keys = Object.keys(data[0]);
|
|
391
|
+
const header = keys.map(csvEscape).join(",");
|
|
392
|
+
const rows = data.map((row) => keys.map((k) => {
|
|
393
|
+
const v = row[k];
|
|
394
|
+
if (Array.isArray(v)) {
|
|
395
|
+
return csvEscape(v.join(", "));
|
|
396
|
+
}
|
|
397
|
+
if (v && typeof v === "object") {
|
|
398
|
+
return csvEscape(JSON.stringify(v));
|
|
399
|
+
}
|
|
400
|
+
return csvEscape(String(v ?? ""));
|
|
401
|
+
}).join(","));
|
|
402
|
+
return [header, ...rows].join(`
|
|
403
|
+
`);
|
|
404
|
+
}
|
|
405
|
+
function csvEscape(s) {
|
|
406
|
+
if (s.includes(",") || s.includes('"') || s.includes(`
|
|
407
|
+
`)) {
|
|
408
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
409
|
+
}
|
|
410
|
+
return s;
|
|
411
|
+
}
|
|
412
|
+
function formatTSV(data) {
|
|
413
|
+
if (!Array.isArray(data) || data.length === 0) {
|
|
414
|
+
return "";
|
|
415
|
+
}
|
|
416
|
+
const keys = Object.keys(data[0]);
|
|
417
|
+
const header = keys.join("\t");
|
|
418
|
+
const rows = data.map((row) => keys.map((k) => {
|
|
419
|
+
const v = row[k];
|
|
420
|
+
if (Array.isArray(v)) {
|
|
421
|
+
return v.join(", ");
|
|
422
|
+
}
|
|
423
|
+
if (v && typeof v === "object") {
|
|
424
|
+
return JSON.stringify(v);
|
|
425
|
+
}
|
|
426
|
+
return String(v ?? "");
|
|
427
|
+
}).join("\t"));
|
|
428
|
+
return [header, ...rows].join(`
|
|
429
|
+
`);
|
|
430
|
+
}
|
|
431
|
+
function formatTable(data, config) {
|
|
432
|
+
if (!data || Array.isArray(data) && data.length === 0) {
|
|
433
|
+
return "";
|
|
434
|
+
}
|
|
435
|
+
if (!Array.isArray(data)) {
|
|
436
|
+
return formatEntityDetail(data);
|
|
437
|
+
}
|
|
438
|
+
const keys = Object.keys(data[0]).filter((k) => data.some((row) => {
|
|
439
|
+
const v = row[k];
|
|
440
|
+
if (v === null || v === undefined || v === "") {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
if (Array.isArray(v) && v.length === 0) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
if (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0) {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
return true;
|
|
450
|
+
}));
|
|
451
|
+
if (keys.length === 0) {
|
|
452
|
+
return "";
|
|
453
|
+
}
|
|
454
|
+
const widths = {};
|
|
455
|
+
for (const k of keys) {
|
|
456
|
+
widths[k] = k.length;
|
|
457
|
+
}
|
|
458
|
+
for (const row of data) {
|
|
459
|
+
for (const k of keys) {
|
|
460
|
+
const v = displayValue(row[k], config);
|
|
461
|
+
widths[k] = Math.max(widths[k], v.length);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const header = keys.map((k) => k.padEnd(widths[k])).join(" ");
|
|
465
|
+
const separator = keys.map((k) => "─".repeat(widths[k])).join("──");
|
|
466
|
+
const rows = data.map((row) => keys.map((k) => displayValue(row[k], config).padEnd(widths[k])).join(" "));
|
|
467
|
+
return [header, separator, ...rows].join(`
|
|
468
|
+
`);
|
|
469
|
+
}
|
|
470
|
+
function displayValue(v, config) {
|
|
471
|
+
if (v === null || v === undefined) {
|
|
472
|
+
return "";
|
|
473
|
+
}
|
|
474
|
+
if (Array.isArray(v)) {
|
|
475
|
+
return v.map((item) => displayValue(item, config)).join(", ");
|
|
476
|
+
}
|
|
477
|
+
if (typeof v === "object") {
|
|
478
|
+
return JSON.stringify(v);
|
|
479
|
+
}
|
|
480
|
+
const s = String(v);
|
|
481
|
+
if (/^\+\d{7,15}$/.test(s)) {
|
|
482
|
+
return formatPhone(s, config?.phone?.display || "international", config?.phone?.default_country);
|
|
483
|
+
}
|
|
484
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) {
|
|
485
|
+
const d = new Date(s);
|
|
486
|
+
if (!Number.isNaN(d.getTime())) {
|
|
487
|
+
return d.toLocaleString(undefined, {
|
|
488
|
+
year: "numeric",
|
|
489
|
+
month: "short",
|
|
490
|
+
day: "numeric",
|
|
491
|
+
hour: "numeric",
|
|
492
|
+
minute: "2-digit"
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return s;
|
|
497
|
+
}
|
|
498
|
+
function formatEntityDetail(entity) {
|
|
499
|
+
const lines = [];
|
|
500
|
+
for (const [key, value] of Object.entries(entity)) {
|
|
501
|
+
if (value === null || value === undefined) {
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (Array.isArray(value)) {
|
|
505
|
+
if (value.length === 0) {
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (typeof value[0] === "object") {
|
|
509
|
+
lines.push(`${key}:`);
|
|
510
|
+
for (const item of value) {
|
|
511
|
+
lines.push(` ${JSON.stringify(item)}`);
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
lines.push(`${key}: ${value.join(", ")}`);
|
|
515
|
+
}
|
|
516
|
+
} else if (typeof value === "object") {
|
|
517
|
+
lines.push(`${key}:`);
|
|
518
|
+
for (const [k, v] of Object.entries(value)) {
|
|
519
|
+
lines.push(` ${k}: ${v}`);
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
lines.push(`${key}: ${value}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return lines.join(`
|
|
526
|
+
`);
|
|
527
|
+
}
|
|
528
|
+
function contactToRow(c) {
|
|
529
|
+
const emails = safeJSON(c.emails);
|
|
530
|
+
const phones = safeJSON(c.phones);
|
|
531
|
+
const companies2 = safeJSON(c.companies);
|
|
532
|
+
const tags = safeJSON(c.tags);
|
|
533
|
+
const custom = safeJSON(c.custom_fields);
|
|
534
|
+
return {
|
|
535
|
+
id: c.id,
|
|
536
|
+
name: c.name,
|
|
537
|
+
emails,
|
|
538
|
+
phones,
|
|
539
|
+
companies: companies2,
|
|
540
|
+
linkedin: c.linkedin || null,
|
|
541
|
+
x: c.x || null,
|
|
542
|
+
bluesky: c.bluesky || null,
|
|
543
|
+
telegram: c.telegram || null,
|
|
544
|
+
tags,
|
|
545
|
+
custom_fields: custom,
|
|
546
|
+
created_at: c.created_at,
|
|
547
|
+
updated_at: c.updated_at
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function companyToRow(c) {
|
|
551
|
+
return {
|
|
552
|
+
id: c.id,
|
|
553
|
+
name: c.name,
|
|
554
|
+
websites: safeJSON(c.websites),
|
|
555
|
+
phones: safeJSON(c.phones),
|
|
556
|
+
tags: safeJSON(c.tags),
|
|
557
|
+
custom_fields: safeJSON(c.custom_fields),
|
|
558
|
+
created_at: c.created_at,
|
|
559
|
+
updated_at: c.updated_at
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function dealToRow(d) {
|
|
563
|
+
return {
|
|
564
|
+
id: d.id,
|
|
565
|
+
title: d.title,
|
|
566
|
+
value: d.value ?? null,
|
|
567
|
+
stage: d.stage,
|
|
568
|
+
contacts: safeJSON(d.contacts),
|
|
569
|
+
company: d.company || null,
|
|
570
|
+
expected_close: d.expected_close || null,
|
|
571
|
+
probability: d.probability ?? null,
|
|
572
|
+
tags: safeJSON(d.tags),
|
|
573
|
+
custom_fields: safeJSON(d.custom_fields),
|
|
574
|
+
created_at: d.created_at,
|
|
575
|
+
updated_at: d.updated_at
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function activityToRow(a) {
|
|
579
|
+
return {
|
|
580
|
+
id: a.id,
|
|
581
|
+
type: a.type,
|
|
582
|
+
body: a.body,
|
|
583
|
+
contacts: safeJSON(a.contacts),
|
|
584
|
+
company: a.company || null,
|
|
585
|
+
deal: a.deal || null,
|
|
586
|
+
custom_fields: safeJSON(a.custom_fields),
|
|
587
|
+
created_at: a.created_at
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
function safeJSON(val) {
|
|
591
|
+
if (val === null || val === undefined) {
|
|
592
|
+
if (typeof val === "string") {
|
|
593
|
+
return val;
|
|
594
|
+
}
|
|
595
|
+
return Array.isArray(val) ? [] : {};
|
|
596
|
+
}
|
|
597
|
+
if (typeof val === "string") {
|
|
598
|
+
try {
|
|
599
|
+
return JSON.parse(val);
|
|
600
|
+
} catch {
|
|
601
|
+
return val;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return val;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/resolve.ts
|
|
608
|
+
import { eq } from "drizzle-orm";
|
|
609
|
+
async function resolveContact(db, rawRef, config) {
|
|
610
|
+
const ref = rawRef.trim();
|
|
611
|
+
if (ref.startsWith("ct_")) {
|
|
612
|
+
const results = await db.select().from(contacts).where(eq(contacts.id, ref));
|
|
613
|
+
return results[0] || null;
|
|
614
|
+
}
|
|
615
|
+
if (ref.includes("@") && !ref.includes("/")) {
|
|
616
|
+
const handle = ref.startsWith("@") ? ref.slice(1) : ref;
|
|
617
|
+
const all = await db.select().from(contacts);
|
|
618
|
+
for (const c of all) {
|
|
619
|
+
const emails = safeJSON(c.emails);
|
|
620
|
+
if (emails.some((e) => e.toLowerCase() === ref.toLowerCase())) {
|
|
621
|
+
return c;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
for (const c of all) {
|
|
625
|
+
if (c.linkedin === handle || c.x === handle || c.bluesky === handle || c.telegram === handle) {
|
|
626
|
+
return c;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
const extracted = tryExtractSocialHandle(ref);
|
|
632
|
+
if (extracted) {
|
|
633
|
+
const col = extracted.platform;
|
|
634
|
+
const results = await db.select().from(contacts).where(eq(contacts[col], extracted.handle));
|
|
635
|
+
if (results[0]) {
|
|
636
|
+
return results[0];
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const phoneNorm = tryNormalizePhone(ref, config?.phone?.default_country);
|
|
640
|
+
if (phoneNorm) {
|
|
641
|
+
const all = await db.select().from(contacts);
|
|
642
|
+
for (const c of all) {
|
|
643
|
+
const phones = safeJSON(c.phones);
|
|
644
|
+
if (phones.includes(phoneNorm)) {
|
|
645
|
+
return c;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const digits = extractPhoneDigits(ref);
|
|
650
|
+
if (digits.length >= 7) {
|
|
651
|
+
const all = await db.select().from(contacts);
|
|
652
|
+
for (const c of all) {
|
|
653
|
+
const phones = safeJSON(c.phones);
|
|
654
|
+
for (const p of phones) {
|
|
655
|
+
if (phoneMatchesByDigits(p, digits)) {
|
|
656
|
+
return c;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
{
|
|
662
|
+
const handle = ref.startsWith("@") ? ref.slice(1) : ref;
|
|
663
|
+
const all = await db.select().from(contacts);
|
|
664
|
+
for (const c of all) {
|
|
665
|
+
if (c.linkedin === handle || c.x === handle || c.bluesky === handle || c.telegram === handle) {
|
|
666
|
+
return c;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
async function resolveCompany(db, rawRef, config) {
|
|
673
|
+
const ref = rawRef.trim();
|
|
674
|
+
if (ref.startsWith("co_")) {
|
|
675
|
+
const results = await db.select().from(companies).where(eq(companies.id, ref));
|
|
676
|
+
return results[0] || null;
|
|
677
|
+
}
|
|
678
|
+
const all = await db.select().from(companies);
|
|
679
|
+
const normalizedWeb = tryNormalizeWebsite(ref);
|
|
680
|
+
if (normalizedWeb) {
|
|
681
|
+
for (const co of all) {
|
|
682
|
+
const websites = safeJSON(co.websites);
|
|
683
|
+
if (websites.some((w) => w === normalizedWeb)) {
|
|
684
|
+
return co;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
const phoneNorm = tryNormalizePhone(ref, config?.phone?.default_country);
|
|
689
|
+
if (phoneNorm) {
|
|
690
|
+
for (const co of all) {
|
|
691
|
+
const phones = safeJSON(co.phones);
|
|
692
|
+
if (phones.includes(phoneNorm)) {
|
|
693
|
+
return co;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const digits = extractPhoneDigits(ref);
|
|
698
|
+
if (digits.length >= 7) {
|
|
699
|
+
for (const co of all) {
|
|
700
|
+
const phones = safeJSON(co.phones);
|
|
701
|
+
for (const p of phones) {
|
|
702
|
+
if (phoneMatchesByDigits(p, digits)) {
|
|
703
|
+
return co;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
for (const co of all) {
|
|
709
|
+
if (co.name === ref) {
|
|
710
|
+
return co;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
async function resolveDeal(db, rawRef) {
|
|
716
|
+
const ref = rawRef.trim();
|
|
717
|
+
if (ref.startsWith("dl_")) {
|
|
718
|
+
const results = await db.select().from(deals).where(eq(deals.id, ref));
|
|
719
|
+
return results[0] || null;
|
|
720
|
+
}
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
async function resolveEntity(db, rawRef, config) {
|
|
724
|
+
const ref = rawRef.trim();
|
|
725
|
+
const contact = await resolveContact(db, ref, config);
|
|
726
|
+
if (contact) {
|
|
727
|
+
return { type: "contact", entity: contact };
|
|
728
|
+
}
|
|
729
|
+
const company = await resolveCompany(db, ref, config);
|
|
730
|
+
if (company) {
|
|
731
|
+
return { type: "company", entity: company };
|
|
732
|
+
}
|
|
733
|
+
const deal = await resolveDeal(db, ref);
|
|
734
|
+
if (deal) {
|
|
735
|
+
return { type: "deal", entity: deal };
|
|
736
|
+
}
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
async function resolveCompanyForLink(db, rawRef) {
|
|
740
|
+
const ref = rawRef.trim();
|
|
741
|
+
if (ref.startsWith("co_")) {
|
|
742
|
+
const results = await db.select().from(companies).where(eq(companies.id, ref));
|
|
743
|
+
return results[0] || null;
|
|
744
|
+
}
|
|
745
|
+
const all = await db.select().from(companies);
|
|
746
|
+
const normalizedWeb = tryNormalizeWebsite(ref);
|
|
747
|
+
if (normalizedWeb) {
|
|
748
|
+
for (const co of all) {
|
|
749
|
+
const websites = safeJSON(co.websites);
|
|
750
|
+
if (websites.some((w) => w === normalizedWeb)) {
|
|
751
|
+
return co;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
for (const co of all) {
|
|
756
|
+
if (co.name === ref) {
|
|
757
|
+
return co;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/lib/helpers.ts
|
|
764
|
+
var rawArgv = process.argv.slice(2);
|
|
765
|
+
var gDb;
|
|
766
|
+
var gConfig;
|
|
767
|
+
var gFmt;
|
|
768
|
+
var cleanArgv = [];
|
|
769
|
+
var _argIdx = 0;
|
|
770
|
+
while (_argIdx < rawArgv.length) {
|
|
771
|
+
const arg = rawArgv[_argIdx];
|
|
772
|
+
if (arg === "--db") {
|
|
773
|
+
_argIdx++;
|
|
774
|
+
gDb = rawArgv[_argIdx];
|
|
775
|
+
} else if (arg === "--config") {
|
|
776
|
+
_argIdx++;
|
|
777
|
+
gConfig = rawArgv[_argIdx];
|
|
778
|
+
} else if (arg === "--format") {
|
|
779
|
+
_argIdx++;
|
|
780
|
+
gFmt = rawArgv[_argIdx];
|
|
781
|
+
} else if (arg !== "--no-color") {
|
|
782
|
+
cleanArgv.push(arg);
|
|
783
|
+
}
|
|
784
|
+
_argIdx++;
|
|
785
|
+
}
|
|
786
|
+
async function getCtx() {
|
|
787
|
+
const config = loadConfig({ configPath: gConfig, dbPath: gDb, format: gFmt });
|
|
788
|
+
const db = await openDB(config.database.path);
|
|
789
|
+
return { config, db, fmt: config.defaults.format };
|
|
790
|
+
}
|
|
791
|
+
function makeId(prefix) {
|
|
792
|
+
return `${prefix}_${ulid()}`;
|
|
793
|
+
}
|
|
794
|
+
function now() {
|
|
795
|
+
return new Date().toISOString();
|
|
796
|
+
}
|
|
797
|
+
function die(msg) {
|
|
798
|
+
console.error(msg);
|
|
799
|
+
process.exit(1);
|
|
800
|
+
}
|
|
801
|
+
function collect(v, prev) {
|
|
802
|
+
prev.push(v);
|
|
803
|
+
return prev;
|
|
804
|
+
}
|
|
805
|
+
function confirmOrForce(force, label) {
|
|
806
|
+
if (force) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (!process.stdin.isTTY) {
|
|
810
|
+
die(`Error: refusing to delete ${label} without --force (non-interactive)`);
|
|
811
|
+
}
|
|
812
|
+
const fs = __require("node:fs");
|
|
813
|
+
process.stdout.write(`Delete ${label}? [y/N] `);
|
|
814
|
+
const buf = Buffer.alloc(64);
|
|
815
|
+
const fd = fs.openSync("/dev/tty", "r");
|
|
816
|
+
try {
|
|
817
|
+
const n = fs.readSync(fd, buf, 0, 64, null);
|
|
818
|
+
const answer = buf.slice(0, n).toString().trim().toLowerCase();
|
|
819
|
+
if (answer !== "y" && answer !== "yes") {
|
|
820
|
+
die("Aborted");
|
|
821
|
+
}
|
|
822
|
+
} finally {
|
|
823
|
+
fs.closeSync(fd);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function parseKV(arr) {
|
|
827
|
+
const r = {};
|
|
828
|
+
for (const s of arr || []) {
|
|
829
|
+
const i = s.indexOf("=");
|
|
830
|
+
if (i > 0) {
|
|
831
|
+
const key = s.slice(0, i).trim();
|
|
832
|
+
const val = s.slice(i + 1).trim();
|
|
833
|
+
if (key.startsWith("json:")) {
|
|
834
|
+
try {
|
|
835
|
+
r[key.slice(5)] = JSON.parse(val);
|
|
836
|
+
} catch {
|
|
837
|
+
die(`Error: invalid JSON for custom field "${key.slice(5)}"`);
|
|
838
|
+
}
|
|
839
|
+
} else {
|
|
840
|
+
r[key] = val;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return r;
|
|
845
|
+
}
|
|
846
|
+
async function getOrCreateCompanyId(db, rawRef) {
|
|
847
|
+
const ref = rawRef.trim();
|
|
848
|
+
const co = await resolveCompanyForLink(db, ref);
|
|
849
|
+
if (co) {
|
|
850
|
+
return co.id;
|
|
851
|
+
}
|
|
852
|
+
const cid = makeId("co");
|
|
853
|
+
const n = now();
|
|
854
|
+
await db.insert(companies).values({
|
|
855
|
+
id: cid,
|
|
856
|
+
name: ref,
|
|
857
|
+
websites: "[]",
|
|
858
|
+
phones: "[]",
|
|
859
|
+
tags: "[]",
|
|
860
|
+
custom_fields: "{}",
|
|
861
|
+
created_at: n,
|
|
862
|
+
updated_at: n
|
|
863
|
+
});
|
|
864
|
+
await upsertSearchIndex(db, "company", cid, ref);
|
|
865
|
+
return cid;
|
|
866
|
+
}
|
|
867
|
+
async function getOrCreateContactId(db, rawRef, config) {
|
|
868
|
+
const ref = rawRef.trim();
|
|
869
|
+
const ct = await resolveContact(db, ref, config);
|
|
870
|
+
if (ct) {
|
|
871
|
+
return ct.id;
|
|
872
|
+
}
|
|
873
|
+
const cid = makeId("ct");
|
|
874
|
+
const n = now();
|
|
875
|
+
const isEmail = ref.includes("@") && !ref.includes("/");
|
|
876
|
+
const normalizedPhone = isEmail ? null : tryNormalizePhone(ref, config.phone?.default_country);
|
|
877
|
+
let name = ref;
|
|
878
|
+
if (isEmail) {
|
|
879
|
+
name = ref.split("@")[0];
|
|
880
|
+
}
|
|
881
|
+
await db.insert(contacts).values({
|
|
882
|
+
id: cid,
|
|
883
|
+
name,
|
|
884
|
+
emails: isEmail ? JSON.stringify([ref]) : "[]",
|
|
885
|
+
phones: normalizedPhone ? JSON.stringify([normalizedPhone]) : "[]",
|
|
886
|
+
companies: "[]",
|
|
887
|
+
tags: "[]",
|
|
888
|
+
custom_fields: "{}",
|
|
889
|
+
created_at: n,
|
|
890
|
+
updated_at: n
|
|
891
|
+
});
|
|
892
|
+
await upsertSearchIndex(db, "contact", cid, ref);
|
|
893
|
+
return cid;
|
|
894
|
+
}
|
|
895
|
+
function validateEmail(email) {
|
|
896
|
+
if (!email.includes("@") || email.startsWith("@") || email.endsWith("@")) {
|
|
897
|
+
die(`Error: invalid email "${email}" — must contain @`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
async function checkDupeEmail(db, email, excludeId) {
|
|
901
|
+
const all = await db.select().from(contacts);
|
|
902
|
+
for (const c of all) {
|
|
903
|
+
if (excludeId && c.id === excludeId) {
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const emails = safeJSON(c.emails);
|
|
907
|
+
if (emails.some((e) => e.toLowerCase() === email.toLowerCase())) {
|
|
908
|
+
die(`Error: duplicate email "${email}" — already belongs to ${c.name} (${c.id})`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
async function checkDupePhone(db, phone, table, excludeId) {
|
|
913
|
+
const all = table === "contacts" ? await db.select().from(contacts) : await db.select().from(companies);
|
|
914
|
+
for (const c of all) {
|
|
915
|
+
if (excludeId && c.id === excludeId) {
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
const phones = safeJSON(c.phones);
|
|
919
|
+
if (phones.includes(phone)) {
|
|
920
|
+
die(`Error: duplicate phone "${phone}" — already belongs to ${c.name} (${c.id})`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
async function checkDupeWebsite(db, website, excludeId) {
|
|
925
|
+
const all = await db.select().from(companies);
|
|
926
|
+
for (const co of all) {
|
|
927
|
+
if (excludeId && co.id === excludeId) {
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
const websites = safeJSON(co.websites);
|
|
931
|
+
if (websites.includes(website)) {
|
|
932
|
+
die(`Error: duplicate website "${website}" — already belongs to ${co.name} (${co.id})`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
async function checkDupeSocial(db, platform, handle, excludeId) {
|
|
937
|
+
const col = platform;
|
|
938
|
+
const existing = await db.select().from(contacts).where(eq2(contacts[col], handle));
|
|
939
|
+
const match = existing[0];
|
|
940
|
+
if (match && match.id !== excludeId) {
|
|
941
|
+
die(`Error: duplicate ${platform} handle "${handle}" — already belongs to ${match.name} (${match.id})`);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
async function buildContactSearch(db, c) {
|
|
945
|
+
const companyIds = safeJSON(c.companies);
|
|
946
|
+
const allCompanies = await db.select().from(companies);
|
|
947
|
+
const companyNames = companyIds.map((id) => allCompanies.find((co) => co.id === id)?.name).filter(Boolean);
|
|
948
|
+
return [
|
|
949
|
+
c.name,
|
|
950
|
+
c.emails,
|
|
951
|
+
c.phones,
|
|
952
|
+
companyNames.join(" "),
|
|
953
|
+
c.linkedin,
|
|
954
|
+
c.x,
|
|
955
|
+
c.bluesky,
|
|
956
|
+
c.telegram,
|
|
957
|
+
JSON.stringify(safeJSON(c.custom_fields)),
|
|
958
|
+
c.tags
|
|
959
|
+
].filter(Boolean).join(" ");
|
|
960
|
+
}
|
|
961
|
+
function buildCompanySearch(co) {
|
|
962
|
+
return [
|
|
963
|
+
co.name,
|
|
964
|
+
co.websites,
|
|
965
|
+
co.phones,
|
|
966
|
+
JSON.stringify(safeJSON(co.custom_fields)),
|
|
967
|
+
co.tags
|
|
968
|
+
].filter(Boolean).join(" ");
|
|
969
|
+
}
|
|
970
|
+
function buildDealSearch(d) {
|
|
971
|
+
return [d.title, d.stage, JSON.stringify(safeJSON(d.custom_fields)), d.tags].filter(Boolean).join(" ");
|
|
972
|
+
}
|
|
973
|
+
async function contactDetail(db, c, config) {
|
|
974
|
+
const row = contactToRow(c);
|
|
975
|
+
const phones = safeJSON(c.phones);
|
|
976
|
+
row._display_phones = phones.map((p) => formatPhone(p, config.phone.display, config.phone.default_country));
|
|
977
|
+
const companyIds = safeJSON(c.companies);
|
|
978
|
+
const allCompanies = await db.select().from(companies);
|
|
979
|
+
row.companies = companyIds.map((id) => {
|
|
980
|
+
const co = allCompanies.find((x) => x.id === id);
|
|
981
|
+
return co ? co.name : id;
|
|
982
|
+
});
|
|
983
|
+
const allDeals = await db.select().from(deals);
|
|
984
|
+
row.deals = allDeals.filter((d) => {
|
|
985
|
+
const contacts2 = safeJSON(d.contacts);
|
|
986
|
+
return contacts2.includes(c.id);
|
|
987
|
+
}).map((d) => ({ id: d.id, title: d.title, stage: d.stage, value: d.value }));
|
|
988
|
+
return row;
|
|
989
|
+
}
|
|
990
|
+
async function companyDetail(db, co, config) {
|
|
991
|
+
const row = companyToRow(co);
|
|
992
|
+
const phones = safeJSON(co.phones);
|
|
993
|
+
row._display_phones = phones.map((p) => formatPhone(p, config.phone.display, config.phone.default_country));
|
|
994
|
+
const linkedContacts = await db.select().from(contacts);
|
|
995
|
+
row.contacts = linkedContacts.filter((ct) => {
|
|
996
|
+
const companies2 = safeJSON(ct.companies);
|
|
997
|
+
return companies2.includes(co.id);
|
|
998
|
+
}).map((ct) => ({ id: ct.id, name: ct.name, emails: safeJSON(ct.emails) }));
|
|
999
|
+
const allDeals = await db.select().from(deals).where(eq2(deals.company, co.id));
|
|
1000
|
+
row.deals = allDeals.map((d) => ({
|
|
1001
|
+
id: d.id,
|
|
1002
|
+
title: d.title,
|
|
1003
|
+
stage: d.stage,
|
|
1004
|
+
value: d.value
|
|
1005
|
+
}));
|
|
1006
|
+
return row;
|
|
1007
|
+
}
|
|
1008
|
+
async function dealDetail(db, d) {
|
|
1009
|
+
const row = dealToRow(d);
|
|
1010
|
+
const contactIds = safeJSON(d.contacts);
|
|
1011
|
+
const contactPromises = contactIds.map(async (cid) => {
|
|
1012
|
+
const results = await db.select().from(contacts).where(eq2(contacts.id, cid));
|
|
1013
|
+
const ct = results[0];
|
|
1014
|
+
return ct ? { id: ct.id, name: ct.name, emails: safeJSON(ct.emails) } : null;
|
|
1015
|
+
});
|
|
1016
|
+
row.contacts = (await Promise.all(contactPromises)).filter(Boolean);
|
|
1017
|
+
if (d.company) {
|
|
1018
|
+
const results = await db.select().from(companies).where(eq2(companies.id, d.company));
|
|
1019
|
+
const co = results[0];
|
|
1020
|
+
row.company = co ? { id: co.id, name: co.name } : null;
|
|
1021
|
+
}
|
|
1022
|
+
const stageChanges = await db.select().from(activities).where(sql`${activities.deal} = ${d.id} AND ${activities.type} = 'stage-change'`).orderBy(activities.created_at);
|
|
1023
|
+
const history = [];
|
|
1024
|
+
if (stageChanges.length > 0) {
|
|
1025
|
+
const m = stageChanges[0].body.match(/from (\S+) to/);
|
|
1026
|
+
history.push({ stage: m ? m[1] : d.stage, at: d.created_at });
|
|
1027
|
+
for (const sc of stageChanges) {
|
|
1028
|
+
const tm = sc.body.match(/to (\S+)/);
|
|
1029
|
+
history.push({ stage: tm ? tm[1] : "", at: sc.created_at });
|
|
1030
|
+
}
|
|
1031
|
+
} else {
|
|
1032
|
+
history.push({ stage: d.stage, at: d.created_at });
|
|
1033
|
+
}
|
|
1034
|
+
row.stage_history = history;
|
|
1035
|
+
row.notes = stageChanges.filter((a) => a.body.includes("|")).map((a) => a.body);
|
|
1036
|
+
return row;
|
|
1037
|
+
}
|
|
1038
|
+
function showEntity(detail, fmt) {
|
|
1039
|
+
if (fmt === "json") {
|
|
1040
|
+
console.log(JSON.stringify(detail, null, 2));
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
const lines = [];
|
|
1044
|
+
for (const [k, v] of Object.entries(detail)) {
|
|
1045
|
+
if (k === "_display_phones") {
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
if (v === null || v === undefined) {
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
if (Array.isArray(v)) {
|
|
1052
|
+
if (v.length === 0) {
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
if (typeof v[0] === "object") {
|
|
1056
|
+
lines.push(`${k}:`);
|
|
1057
|
+
for (const item of v) {
|
|
1058
|
+
lines.push(` ${typeof item === "object" ? JSON.stringify(item) : item}`);
|
|
1059
|
+
}
|
|
1060
|
+
} else {
|
|
1061
|
+
lines.push(`${k}: ${v.join(", ")}`);
|
|
1062
|
+
}
|
|
1063
|
+
} else if (typeof v === "object") {
|
|
1064
|
+
lines.push(`${k}:`);
|
|
1065
|
+
for (const [sk, sv] of Object.entries(v)) {
|
|
1066
|
+
lines.push(` ${sk}: ${sv}`);
|
|
1067
|
+
}
|
|
1068
|
+
} else {
|
|
1069
|
+
lines.push(`${k}: ${v}`);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
const displayPhones = detail._display_phones;
|
|
1073
|
+
if (displayPhones?.length) {
|
|
1074
|
+
const idx = lines.findIndex((l) => l.startsWith("phones:"));
|
|
1075
|
+
if (idx >= 0) {
|
|
1076
|
+
lines[idx] = `phones: ${displayPhones.join(", ")}`;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
console.log(lines.join(`
|
|
1080
|
+
`));
|
|
1081
|
+
}
|
|
1082
|
+
function parseCSV(text2) {
|
|
1083
|
+
const lines = text2.split(`
|
|
1084
|
+
`).filter((l) => l.trim());
|
|
1085
|
+
if (lines.length < 1) {
|
|
1086
|
+
return [];
|
|
1087
|
+
}
|
|
1088
|
+
const headers = parseCSVLine(lines[0]);
|
|
1089
|
+
const rows = [];
|
|
1090
|
+
for (let i = 1;i < lines.length; i++) {
|
|
1091
|
+
const vals = parseCSVLine(lines[i]);
|
|
1092
|
+
const row = {};
|
|
1093
|
+
for (let j = 0;j < headers.length; j++) {
|
|
1094
|
+
row[headers[j]] = vals[j] || "";
|
|
1095
|
+
}
|
|
1096
|
+
rows.push(row);
|
|
1097
|
+
}
|
|
1098
|
+
return rows;
|
|
1099
|
+
}
|
|
1100
|
+
function parseCSVLine(line) {
|
|
1101
|
+
const result = [];
|
|
1102
|
+
let current = "";
|
|
1103
|
+
let inQuotes = false;
|
|
1104
|
+
for (let i = 0;i < line.length; i++) {
|
|
1105
|
+
const ch = line[i];
|
|
1106
|
+
if (inQuotes) {
|
|
1107
|
+
if (ch === '"' && line[i + 1] === '"') {
|
|
1108
|
+
current += '"';
|
|
1109
|
+
i++;
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
if (ch === '"') {
|
|
1113
|
+
inQuotes = false;
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
current += ch;
|
|
1117
|
+
} else {
|
|
1118
|
+
if (ch === '"') {
|
|
1119
|
+
inQuotes = true;
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
if (ch === ",") {
|
|
1123
|
+
result.push(current);
|
|
1124
|
+
current = "";
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
current += ch;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
result.push(current);
|
|
1131
|
+
return result;
|
|
1132
|
+
}
|
|
1133
|
+
function levenshtein(a, b) {
|
|
1134
|
+
const la = a.length, lb = b.length;
|
|
1135
|
+
const dp = Array.from({ length: la + 1 }, () => new Array(lb + 1).fill(0));
|
|
1136
|
+
for (let i = 0;i <= la; i++) {
|
|
1137
|
+
dp[i][0] = i;
|
|
1138
|
+
}
|
|
1139
|
+
for (let j = 0;j <= lb; j++) {
|
|
1140
|
+
dp[0][j] = j;
|
|
1141
|
+
}
|
|
1142
|
+
for (let i = 1;i <= la; i++) {
|
|
1143
|
+
for (let j = 1;j <= lb; j++) {
|
|
1144
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return dp[la][lb];
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// src/db.ts
|
|
1151
|
+
var SCHEMA_SQL = `
|
|
1152
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
1153
|
+
id TEXT PRIMARY KEY,
|
|
1154
|
+
name TEXT NOT NULL,
|
|
1155
|
+
emails TEXT NOT NULL DEFAULT '[]',
|
|
1156
|
+
phones TEXT NOT NULL DEFAULT '[]',
|
|
1157
|
+
companies TEXT NOT NULL DEFAULT '[]',
|
|
1158
|
+
linkedin TEXT,
|
|
1159
|
+
x TEXT,
|
|
1160
|
+
bluesky TEXT,
|
|
1161
|
+
telegram TEXT,
|
|
1162
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
1163
|
+
custom_fields TEXT NOT NULL DEFAULT '{}',
|
|
1164
|
+
created_at TEXT NOT NULL,
|
|
1165
|
+
updated_at TEXT NOT NULL
|
|
1166
|
+
);
|
|
1167
|
+
|
|
1168
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_linkedin ON contacts(linkedin) WHERE linkedin IS NOT NULL;
|
|
1169
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_x ON contacts(x) WHERE x IS NOT NULL;
|
|
1170
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_bluesky ON contacts(bluesky) WHERE bluesky IS NOT NULL;
|
|
1171
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_telegram ON contacts(telegram) WHERE telegram IS NOT NULL;
|
|
1172
|
+
|
|
1173
|
+
CREATE TABLE IF NOT EXISTS companies (
|
|
1174
|
+
id TEXT PRIMARY KEY,
|
|
1175
|
+
name TEXT NOT NULL,
|
|
1176
|
+
websites TEXT NOT NULL DEFAULT '[]',
|
|
1177
|
+
phones TEXT NOT NULL DEFAULT '[]',
|
|
1178
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
1179
|
+
custom_fields TEXT NOT NULL DEFAULT '{}',
|
|
1180
|
+
created_at TEXT NOT NULL,
|
|
1181
|
+
updated_at TEXT NOT NULL
|
|
1182
|
+
);
|
|
1183
|
+
|
|
1184
|
+
CREATE TABLE IF NOT EXISTS deals (
|
|
1185
|
+
id TEXT PRIMARY KEY,
|
|
1186
|
+
title TEXT NOT NULL,
|
|
1187
|
+
value INTEGER,
|
|
1188
|
+
stage TEXT NOT NULL,
|
|
1189
|
+
contacts TEXT NOT NULL DEFAULT '[]',
|
|
1190
|
+
company TEXT REFERENCES companies(id) ON DELETE SET NULL,
|
|
1191
|
+
expected_close TEXT,
|
|
1192
|
+
probability INTEGER,
|
|
1193
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
1194
|
+
custom_fields TEXT NOT NULL DEFAULT '{}',
|
|
1195
|
+
created_at TEXT NOT NULL,
|
|
1196
|
+
updated_at TEXT NOT NULL
|
|
1197
|
+
);
|
|
1198
|
+
|
|
1199
|
+
CREATE TABLE IF NOT EXISTS activities (
|
|
1200
|
+
id TEXT PRIMARY KEY,
|
|
1201
|
+
type TEXT NOT NULL,
|
|
1202
|
+
body TEXT NOT NULL DEFAULT '',
|
|
1203
|
+
contacts TEXT NOT NULL DEFAULT '[]',
|
|
1204
|
+
company TEXT,
|
|
1205
|
+
deal TEXT,
|
|
1206
|
+
custom_fields TEXT NOT NULL DEFAULT '{}',
|
|
1207
|
+
created_at TEXT NOT NULL
|
|
1208
|
+
);
|
|
1209
|
+
|
|
1210
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
|
1211
|
+
entity_type, entity_id, content
|
|
1212
|
+
);
|
|
1213
|
+
`;
|
|
1214
|
+
async function openDB(dbPath) {
|
|
1215
|
+
mkdirSync2(dirname2(dbPath), { recursive: true });
|
|
1216
|
+
const client = createClient({ url: `file:${dbPath}` });
|
|
1217
|
+
const db = drizzle(client, { schema: exports_drizzle_schema });
|
|
1218
|
+
const statements = SCHEMA_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1219
|
+
for (const stmt of statements) {
|
|
1220
|
+
await client.execute(stmt);
|
|
1221
|
+
}
|
|
1222
|
+
await client.execute("PRAGMA journal_mode=WAL");
|
|
1223
|
+
await client.execute("PRAGMA foreign_keys=ON");
|
|
1224
|
+
return db;
|
|
1225
|
+
}
|
|
1226
|
+
async function upsertSearchIndex(db, entityType, entityId, content) {
|
|
1227
|
+
await db.run(sql2`DELETE FROM search_index WHERE entity_id = ${entityId}`);
|
|
1228
|
+
await db.run(sql2`INSERT INTO search_index (entity_type, entity_id, content) VALUES (${entityType}, ${entityId}, ${content})`);
|
|
1229
|
+
}
|
|
1230
|
+
async function removeSearchIndex(db, entityId) {
|
|
1231
|
+
await db.run(sql2`DELETE FROM search_index WHERE entity_id = ${entityId}`);
|
|
1232
|
+
}
|
|
1233
|
+
async function rebuildSearchIndex(db) {
|
|
1234
|
+
await db.run(sql2`DELETE FROM search_index`);
|
|
1235
|
+
const allContacts = await db.select().from(contacts);
|
|
1236
|
+
for (const c of allContacts) {
|
|
1237
|
+
const content = await buildContactSearch(db, c);
|
|
1238
|
+
await db.run(sql2`INSERT INTO search_index (entity_type, entity_id, content) VALUES (${"contact"}, ${c.id}, ${content})`);
|
|
1239
|
+
}
|
|
1240
|
+
const allCompanies = await db.select().from(companies);
|
|
1241
|
+
for (const co of allCompanies) {
|
|
1242
|
+
const content = buildCompanySearch(co);
|
|
1243
|
+
await db.run(sql2`INSERT INTO search_index (entity_type, entity_id, content) VALUES (${"company"}, ${co.id}, ${content})`);
|
|
1244
|
+
}
|
|
1245
|
+
const allDeals = await db.select().from(deals);
|
|
1246
|
+
for (const d of allDeals) {
|
|
1247
|
+
const content = buildDealSearch(d);
|
|
1248
|
+
await db.run(sql2`INSERT INTO search_index (entity_type, entity_id, content) VALUES (${"deal"}, ${d.id}, ${content})`);
|
|
1249
|
+
}
|
|
1250
|
+
const allActivities = await db.select().from(activities);
|
|
1251
|
+
for (const a of allActivities) {
|
|
1252
|
+
const content = [a.type, a.body, a.custom_fields].filter(Boolean).join(" ");
|
|
1253
|
+
await db.run(sql2`INSERT INTO search_index (entity_type, entity_id, content) VALUES (${"activity"}, ${a.id}, ${content})`);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// src/hooks.ts
|
|
1258
|
+
import { spawnSync } from "node:child_process";
|
|
1259
|
+
function runHook(config, hookName, data) {
|
|
1260
|
+
const hookCmd = config.hooks[hookName];
|
|
1261
|
+
if (!hookCmd) {
|
|
1262
|
+
return true;
|
|
1263
|
+
}
|
|
1264
|
+
const jsonData = JSON.stringify(data);
|
|
1265
|
+
const result = spawnSync(hookCmd, {
|
|
1266
|
+
shell: true,
|
|
1267
|
+
input: jsonData,
|
|
1268
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1269
|
+
timeout: 30000
|
|
1270
|
+
});
|
|
1271
|
+
return result.status === 0;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// src/commands/activity.ts
|
|
1275
|
+
var VALID_TYPES = ["note", "call", "meeting", "email"];
|
|
1276
|
+
function registerLogCommand(program) {
|
|
1277
|
+
program.command("log").description("Log an activity").argument("<type>", "Activity type (note, call, meeting, email)").argument("<body>", "Activity body").option("--contact <ref>", "Link to contact (repeatable)", collect, []).option("--company <ref>", "Link to company (auto-creates if needed)").option("--deal <ref>", "Link to deal").option("--at <date>", "Custom timestamp").option("--set <kv>", "Custom field", collect, []).action(async (rawType, rawBody, opts) => {
|
|
1278
|
+
const { db, config } = await getCtx();
|
|
1279
|
+
const type = rawType.trim();
|
|
1280
|
+
const body = rawBody.trim();
|
|
1281
|
+
opts.contact = opts.contact.map((c) => c.trim());
|
|
1282
|
+
if (opts.company) {
|
|
1283
|
+
opts.company = opts.company.trim();
|
|
1284
|
+
}
|
|
1285
|
+
if (opts.deal) {
|
|
1286
|
+
opts.deal = opts.deal.trim();
|
|
1287
|
+
}
|
|
1288
|
+
if (!VALID_TYPES.includes(type)) {
|
|
1289
|
+
die(`Error: invalid activity type "${type}". Must be one of: ${VALID_TYPES.join(", ")}`);
|
|
1290
|
+
}
|
|
1291
|
+
const contacts2 = [];
|
|
1292
|
+
let company = null;
|
|
1293
|
+
let deal = null;
|
|
1294
|
+
for (const cRef of opts.contact) {
|
|
1295
|
+
const ctId = await getOrCreateContactId(db, cRef, config);
|
|
1296
|
+
if (!contacts2.includes(ctId)) {
|
|
1297
|
+
contacts2.push(ctId);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (opts.company) {
|
|
1301
|
+
company = await getOrCreateCompanyId(db, opts.company);
|
|
1302
|
+
}
|
|
1303
|
+
if (opts.deal) {
|
|
1304
|
+
const d = await resolveDeal(db, opts.deal);
|
|
1305
|
+
if (!d) {
|
|
1306
|
+
die(`Error: deal not found: ${opts.deal}`);
|
|
1307
|
+
}
|
|
1308
|
+
deal = d.id;
|
|
1309
|
+
}
|
|
1310
|
+
if (opts.at) {
|
|
1311
|
+
opts.at = opts.at.trim();
|
|
1312
|
+
const d = new Date(opts.at);
|
|
1313
|
+
if (Number.isNaN(d.getTime())) {
|
|
1314
|
+
die("Error: invalid --at date");
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
const id = makeId("ac");
|
|
1318
|
+
const ts = opts.at || now();
|
|
1319
|
+
const custom = parseKV(opts.set);
|
|
1320
|
+
if (!runHook(config, "pre-activity-add", {
|
|
1321
|
+
type,
|
|
1322
|
+
body,
|
|
1323
|
+
contacts: contacts2,
|
|
1324
|
+
company,
|
|
1325
|
+
deal,
|
|
1326
|
+
custom_fields: custom
|
|
1327
|
+
})) {
|
|
1328
|
+
die("Error: pre-activity-add hook rejected creation");
|
|
1329
|
+
}
|
|
1330
|
+
await db.insert(activities).values({
|
|
1331
|
+
id,
|
|
1332
|
+
type,
|
|
1333
|
+
body,
|
|
1334
|
+
contacts: JSON.stringify(contacts2),
|
|
1335
|
+
company,
|
|
1336
|
+
deal,
|
|
1337
|
+
custom_fields: JSON.stringify(custom),
|
|
1338
|
+
created_at: ts
|
|
1339
|
+
});
|
|
1340
|
+
await upsertSearchIndex(db, "activity", id, `${type} ${body}`);
|
|
1341
|
+
runHook(config, "post-activity-add", {
|
|
1342
|
+
id,
|
|
1343
|
+
type,
|
|
1344
|
+
body,
|
|
1345
|
+
contacts: contacts2,
|
|
1346
|
+
company,
|
|
1347
|
+
deal,
|
|
1348
|
+
custom_fields: custom
|
|
1349
|
+
});
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
function registerActivityCommands(program) {
|
|
1353
|
+
const cmd = program.command("activity").description("Activity management");
|
|
1354
|
+
cmd.command("list").option("--contact <ref>").option("--company <ref>").option("--deal <id>").option("--type <type>").option("--since <date>").option("--sort <field>").option("--reverse", "Reverse sort order").option("--limit <n>").option("--offset <n>").action(async (opts) => {
|
|
1355
|
+
const { db, config, fmt } = await getCtx();
|
|
1356
|
+
let rows = (await db.select().from(activities)).map((a) => activityToRow(a));
|
|
1357
|
+
if (opts.contact) {
|
|
1358
|
+
const ct = await resolveContact(db, opts.contact, config);
|
|
1359
|
+
if (ct) {
|
|
1360
|
+
rows = rows.filter((a) => a.contacts.includes(ct.id));
|
|
1361
|
+
} else {
|
|
1362
|
+
rows = [];
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
if (opts.company) {
|
|
1366
|
+
const co = await resolveCompany(db, opts.company, config);
|
|
1367
|
+
if (co) {
|
|
1368
|
+
rows = rows.filter((a) => a.company === co.id);
|
|
1369
|
+
} else {
|
|
1370
|
+
rows = [];
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
if (opts.deal) {
|
|
1374
|
+
rows = rows.filter((a) => a.deal === opts.deal);
|
|
1375
|
+
}
|
|
1376
|
+
if (opts.type) {
|
|
1377
|
+
rows = rows.filter((a) => a.type === opts.type);
|
|
1378
|
+
}
|
|
1379
|
+
if (opts.since) {
|
|
1380
|
+
rows = rows.filter((a) => a.created_at >= opts.since);
|
|
1381
|
+
}
|
|
1382
|
+
if (opts.sort) {
|
|
1383
|
+
rows.sort((a, b) => String(a[opts.sort] ?? "").localeCompare(String(b[opts.sort] ?? "")));
|
|
1384
|
+
}
|
|
1385
|
+
if (opts.reverse) {
|
|
1386
|
+
rows.reverse();
|
|
1387
|
+
}
|
|
1388
|
+
if (opts.offset) {
|
|
1389
|
+
rows = rows.slice(Number(opts.offset));
|
|
1390
|
+
}
|
|
1391
|
+
if (opts.limit) {
|
|
1392
|
+
rows = rows.slice(0, Number(opts.limit));
|
|
1393
|
+
}
|
|
1394
|
+
console.log(formatOutput(rows, fmt, config));
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// src/commands/company.ts
|
|
1399
|
+
import { eq as eq3 } from "drizzle-orm";
|
|
1400
|
+
|
|
1401
|
+
// src/filter.ts
|
|
1402
|
+
function parseFilter(expr) {
|
|
1403
|
+
const orParts = expr.split(/\s+OR\s+/);
|
|
1404
|
+
if (orParts.length > 1) {
|
|
1405
|
+
const conditions2 = [];
|
|
1406
|
+
for (const part of orParts) {
|
|
1407
|
+
conditions2.push(parseSingleCondition(part.trim()));
|
|
1408
|
+
}
|
|
1409
|
+
return { conditions: conditions2, logic: "OR" };
|
|
1410
|
+
}
|
|
1411
|
+
const andParts = expr.split(/\s+AND\s+/);
|
|
1412
|
+
const conditions = andParts.map((p) => parseSingleCondition(p.trim()));
|
|
1413
|
+
return { conditions, logic: "AND" };
|
|
1414
|
+
}
|
|
1415
|
+
function parseSingleCondition(expr) {
|
|
1416
|
+
const match = expr.match(/^([^!~<>=]+)(~=|!=|>=|<=|>|<|=)(.*)$/);
|
|
1417
|
+
if (match) {
|
|
1418
|
+
return {
|
|
1419
|
+
field: match[1].trim(),
|
|
1420
|
+
op: match[2],
|
|
1421
|
+
value: match[3].trim()
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
throw new Error(`Invalid filter expression: ${expr}`);
|
|
1425
|
+
}
|
|
1426
|
+
function applyFilter(row, filter) {
|
|
1427
|
+
if (filter.logic === "AND") {
|
|
1428
|
+
return filter.conditions.every((c) => matchCondition(row, c));
|
|
1429
|
+
}
|
|
1430
|
+
return filter.conditions.some((c) => matchCondition(row, c));
|
|
1431
|
+
}
|
|
1432
|
+
function matchCondition(row, condition) {
|
|
1433
|
+
const { field, op, value } = condition;
|
|
1434
|
+
let fieldValue = row[field];
|
|
1435
|
+
if (fieldValue === undefined || fieldValue === null) {
|
|
1436
|
+
const custom = typeof row.custom_fields === "string" ? safeJSON(row.custom_fields) : row.custom_fields || {};
|
|
1437
|
+
fieldValue = custom[field];
|
|
1438
|
+
}
|
|
1439
|
+
if (fieldValue === undefined || fieldValue === null) {
|
|
1440
|
+
if (op === "!=") {
|
|
1441
|
+
return true;
|
|
1442
|
+
}
|
|
1443
|
+
return false;
|
|
1444
|
+
}
|
|
1445
|
+
const strVal = String(fieldValue);
|
|
1446
|
+
switch (op) {
|
|
1447
|
+
case "=":
|
|
1448
|
+
return strVal === value;
|
|
1449
|
+
case "!=":
|
|
1450
|
+
return strVal !== value;
|
|
1451
|
+
case "~=":
|
|
1452
|
+
return strVal.toLowerCase().includes(value.toLowerCase());
|
|
1453
|
+
case ">":
|
|
1454
|
+
return Number(strVal) > Number(value);
|
|
1455
|
+
case "<":
|
|
1456
|
+
return Number(strVal) < Number(value);
|
|
1457
|
+
default:
|
|
1458
|
+
return false;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// src/commands/company.ts
|
|
1463
|
+
function registerCompanyCommands(program) {
|
|
1464
|
+
const cmd = program.command("company").description("Manage companies");
|
|
1465
|
+
cmd.command("add").requiredOption("--name <name>", "Company name").option("--website <url>", "Website", collect, []).option("--phone <phone>", "Phone", collect, []).option("--tag <tag>", "Tag", collect, []).option("--set <kv>", "Custom field", collect, []).action(async (opts) => {
|
|
1466
|
+
const { db, config } = await getCtx();
|
|
1467
|
+
opts.name = opts.name.trim();
|
|
1468
|
+
opts.website = opts.website.map((w) => w.trim());
|
|
1469
|
+
opts.phone = opts.phone.map((p) => p.trim());
|
|
1470
|
+
opts.tag = opts.tag.map((t) => t.trim());
|
|
1471
|
+
const cid = makeId("co");
|
|
1472
|
+
const n = now();
|
|
1473
|
+
const websites = [];
|
|
1474
|
+
for (const w of opts.website) {
|
|
1475
|
+
const norm = normalizeWebsite(w);
|
|
1476
|
+
await checkDupeWebsite(db, norm);
|
|
1477
|
+
websites.push(norm);
|
|
1478
|
+
}
|
|
1479
|
+
const phones = [];
|
|
1480
|
+
for (const p of opts.phone) {
|
|
1481
|
+
try {
|
|
1482
|
+
const norm = normalizePhone(p, config.phone.default_country);
|
|
1483
|
+
await checkDupePhone(db, norm, "companies");
|
|
1484
|
+
phones.push(norm);
|
|
1485
|
+
} catch (e) {
|
|
1486
|
+
die(`Error: invalid phone — ${e.message}`);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
const custom = parseKV(opts.set);
|
|
1490
|
+
if (!runHook(config, "pre-company-add", {
|
|
1491
|
+
name: opts.name,
|
|
1492
|
+
websites,
|
|
1493
|
+
phones,
|
|
1494
|
+
tags: opts.tag,
|
|
1495
|
+
custom_fields: custom
|
|
1496
|
+
})) {
|
|
1497
|
+
die("Error: pre-company-add hook rejected creation");
|
|
1498
|
+
}
|
|
1499
|
+
await db.insert(companies).values({
|
|
1500
|
+
id: cid,
|
|
1501
|
+
name: opts.name,
|
|
1502
|
+
websites: JSON.stringify(websites),
|
|
1503
|
+
phones: JSON.stringify(phones),
|
|
1504
|
+
tags: JSON.stringify(opts.tag),
|
|
1505
|
+
custom_fields: JSON.stringify(custom),
|
|
1506
|
+
created_at: n,
|
|
1507
|
+
updated_at: n
|
|
1508
|
+
});
|
|
1509
|
+
const results = await db.select().from(companies).where(eq3(companies.id, cid));
|
|
1510
|
+
const row = results[0];
|
|
1511
|
+
await upsertSearchIndex(db, "company", cid, buildCompanySearch(row));
|
|
1512
|
+
runHook(config, "post-company-add", {
|
|
1513
|
+
id: cid,
|
|
1514
|
+
name: opts.name,
|
|
1515
|
+
websites,
|
|
1516
|
+
phones,
|
|
1517
|
+
tags: opts.tag,
|
|
1518
|
+
custom_fields: custom
|
|
1519
|
+
});
|
|
1520
|
+
console.log(cid);
|
|
1521
|
+
});
|
|
1522
|
+
cmd.command("list").option("--tag <tag>").option("--sort <field>").option("--reverse", "Reverse sort order").option("--limit <n>").option("--offset <n>").option("--filter <expr>").action(async (opts) => {
|
|
1523
|
+
const { db, config, fmt } = await getCtx();
|
|
1524
|
+
let rows = (await db.select().from(companies)).map((c) => companyToRow(c));
|
|
1525
|
+
if (opts.filter) {
|
|
1526
|
+
const f = parseFilter(opts.filter);
|
|
1527
|
+
rows = rows.filter((c) => applyFilter(c, f));
|
|
1528
|
+
}
|
|
1529
|
+
if (opts.tag) {
|
|
1530
|
+
rows = rows.filter((c) => c.tags?.includes(opts.tag));
|
|
1531
|
+
}
|
|
1532
|
+
if (opts.sort) {
|
|
1533
|
+
rows.sort((a, b) => String(a[opts.sort] ?? "").localeCompare(String(b[opts.sort] ?? "")));
|
|
1534
|
+
}
|
|
1535
|
+
if (opts.reverse) {
|
|
1536
|
+
rows.reverse();
|
|
1537
|
+
}
|
|
1538
|
+
if (opts.offset) {
|
|
1539
|
+
rows = rows.slice(Number(opts.offset));
|
|
1540
|
+
}
|
|
1541
|
+
if (opts.limit) {
|
|
1542
|
+
rows = rows.slice(0, Number(opts.limit));
|
|
1543
|
+
}
|
|
1544
|
+
console.log(formatOutput(rows, fmt, config));
|
|
1545
|
+
});
|
|
1546
|
+
cmd.command("show").argument("<ref>").action(async (ref) => {
|
|
1547
|
+
const { db, config, fmt } = await getCtx();
|
|
1548
|
+
const co = await resolveCompany(db, ref, config);
|
|
1549
|
+
if (!co) {
|
|
1550
|
+
die(`Error: company not found: ${ref}`);
|
|
1551
|
+
}
|
|
1552
|
+
showEntity(await companyDetail(db, co, config), fmt);
|
|
1553
|
+
});
|
|
1554
|
+
cmd.command("edit").argument("<ref>").option("--name <name>").option("--add-website <url>", "", collect, []).option("--rm-website <url>", "", collect, []).option("--add-phone <p>", "", collect, []).option("--rm-phone <p>", "", collect, []).option("--add-tag <t>", "", collect, []).option("--rm-tag <t>", "", collect, []).option("--set <kv>", "", collect, []).option("--unset <key>", "", collect, []).action(async (ref, opts) => {
|
|
1555
|
+
const { db, config } = await getCtx();
|
|
1556
|
+
if (opts.name) {
|
|
1557
|
+
opts.name = opts.name.trim();
|
|
1558
|
+
}
|
|
1559
|
+
opts.addWebsite = opts.addWebsite.map((w) => w.trim());
|
|
1560
|
+
opts.rmWebsite = opts.rmWebsite.map((w) => w.trim());
|
|
1561
|
+
opts.addPhone = opts.addPhone.map((p) => p.trim());
|
|
1562
|
+
opts.rmPhone = opts.rmPhone.map((p) => p.trim());
|
|
1563
|
+
opts.addTag = opts.addTag.map((t) => t.trim());
|
|
1564
|
+
opts.rmTag = opts.rmTag.map((t) => t.trim());
|
|
1565
|
+
const co = await resolveCompany(db, ref.trim(), config);
|
|
1566
|
+
if (!co) {
|
|
1567
|
+
die(`Error: company not found: ${ref}`);
|
|
1568
|
+
}
|
|
1569
|
+
let websites = safeJSON(co.websites);
|
|
1570
|
+
let phones = safeJSON(co.phones);
|
|
1571
|
+
let tags = safeJSON(co.tags);
|
|
1572
|
+
const custom = safeJSON(co.custom_fields);
|
|
1573
|
+
let name = co.name;
|
|
1574
|
+
if (opts.name) {
|
|
1575
|
+
name = opts.name;
|
|
1576
|
+
}
|
|
1577
|
+
for (const w of opts.addWebsite) {
|
|
1578
|
+
const norm = normalizeWebsite(w);
|
|
1579
|
+
if (!websites.includes(norm)) {
|
|
1580
|
+
await checkDupeWebsite(db, norm, co.id);
|
|
1581
|
+
websites.push(norm);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
for (const w of opts.rmWebsite) {
|
|
1585
|
+
const norm = normalizeWebsite(w);
|
|
1586
|
+
websites = websites.filter((v) => v !== norm);
|
|
1587
|
+
}
|
|
1588
|
+
for (const p of opts.addPhone) {
|
|
1589
|
+
const norm = normalizePhone(p, config.phone.default_country);
|
|
1590
|
+
if (!phones.includes(norm)) {
|
|
1591
|
+
await checkDupePhone(db, norm, "companies", co.id);
|
|
1592
|
+
phones.push(norm);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
for (const p of opts.rmPhone) {
|
|
1596
|
+
const norm = tryNormalizePhone(p, config.phone.default_country);
|
|
1597
|
+
phones = norm ? phones.filter((v) => v !== norm) : phones.filter((v) => v !== p);
|
|
1598
|
+
}
|
|
1599
|
+
for (const t of opts.addTag) {
|
|
1600
|
+
if (!tags.includes(t)) {
|
|
1601
|
+
tags.push(t);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
for (const t of opts.rmTag) {
|
|
1605
|
+
tags = tags.filter((v) => v !== t);
|
|
1606
|
+
}
|
|
1607
|
+
const kvs = parseKV(opts.set);
|
|
1608
|
+
for (const [k, v] of Object.entries(kvs)) {
|
|
1609
|
+
custom[k] = v;
|
|
1610
|
+
}
|
|
1611
|
+
for (const k of opts.unset) {
|
|
1612
|
+
delete custom[k];
|
|
1613
|
+
}
|
|
1614
|
+
if (!runHook(config, "pre-company-edit", {
|
|
1615
|
+
id: co.id,
|
|
1616
|
+
name,
|
|
1617
|
+
websites,
|
|
1618
|
+
phones,
|
|
1619
|
+
tags,
|
|
1620
|
+
custom_fields: custom
|
|
1621
|
+
})) {
|
|
1622
|
+
die("Error: pre-company-edit hook rejected edit");
|
|
1623
|
+
}
|
|
1624
|
+
await db.update(companies).set({
|
|
1625
|
+
name,
|
|
1626
|
+
websites: JSON.stringify(websites),
|
|
1627
|
+
phones: JSON.stringify(phones),
|
|
1628
|
+
tags: JSON.stringify(tags),
|
|
1629
|
+
custom_fields: JSON.stringify(custom),
|
|
1630
|
+
updated_at: now()
|
|
1631
|
+
}).where(eq3(companies.id, co.id));
|
|
1632
|
+
const results = await db.select().from(companies).where(eq3(companies.id, co.id));
|
|
1633
|
+
const row = results[0];
|
|
1634
|
+
await upsertSearchIndex(db, "company", co.id, buildCompanySearch(row));
|
|
1635
|
+
console.log(co.id);
|
|
1636
|
+
runHook(config, "post-company-edit", {
|
|
1637
|
+
id: co.id,
|
|
1638
|
+
name,
|
|
1639
|
+
websites,
|
|
1640
|
+
phones,
|
|
1641
|
+
tags,
|
|
1642
|
+
custom_fields: custom
|
|
1643
|
+
});
|
|
1644
|
+
});
|
|
1645
|
+
cmd.command("rm").argument("<ref>").option("--force", "Skip confirmation").action(async (ref, opts) => {
|
|
1646
|
+
const { db, config } = await getCtx();
|
|
1647
|
+
const co = await resolveCompany(db, ref, config);
|
|
1648
|
+
if (!co) {
|
|
1649
|
+
die(`Error: company not found: ${ref}`);
|
|
1650
|
+
}
|
|
1651
|
+
confirmOrForce(opts.force, `company "${co.name}" (${co.id})`);
|
|
1652
|
+
if (!runHook(config, "pre-company-rm", { id: co.id, name: co.name })) {
|
|
1653
|
+
die("Error: pre-company-rm hook rejected deletion");
|
|
1654
|
+
}
|
|
1655
|
+
const allContacts = await db.select().from(contacts);
|
|
1656
|
+
for (const ct of allContacts) {
|
|
1657
|
+
const companies2 = safeJSON(ct.companies);
|
|
1658
|
+
if (companies2.includes(co.id)) {
|
|
1659
|
+
await db.update(contacts).set({
|
|
1660
|
+
companies: JSON.stringify(companies2.filter((n) => n !== co.id))
|
|
1661
|
+
}).where(eq3(contacts.id, ct.id));
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
await db.update(deals).set({ company: null }).where(eq3(deals.company, co.id));
|
|
1665
|
+
await db.delete(companies).where(eq3(companies.id, co.id));
|
|
1666
|
+
await removeSearchIndex(db, co.id);
|
|
1667
|
+
runHook(config, "post-company-rm", { id: co.id, name: co.name });
|
|
1668
|
+
});
|
|
1669
|
+
cmd.command("merge").argument("<id1>").argument("<id2>").action(async (id1, id2) => {
|
|
1670
|
+
const { db, config } = await getCtx();
|
|
1671
|
+
const c1 = await resolveCompany(db, id1, config), c2 = await resolveCompany(db, id2, config);
|
|
1672
|
+
if (!(c1 && c2)) {
|
|
1673
|
+
die("Error: one or both companies not found");
|
|
1674
|
+
}
|
|
1675
|
+
const mergedWebsites = [
|
|
1676
|
+
...new Set([...safeJSON(c1.websites), ...safeJSON(c2.websites)])
|
|
1677
|
+
];
|
|
1678
|
+
const mergedPhones = [
|
|
1679
|
+
...new Set([...safeJSON(c1.phones), ...safeJSON(c2.phones)])
|
|
1680
|
+
];
|
|
1681
|
+
const mergedTags = [
|
|
1682
|
+
...new Set([...safeJSON(c1.tags), ...safeJSON(c2.tags)])
|
|
1683
|
+
];
|
|
1684
|
+
const mergedCustom = {
|
|
1685
|
+
...safeJSON(c2.custom_fields),
|
|
1686
|
+
...safeJSON(c1.custom_fields)
|
|
1687
|
+
};
|
|
1688
|
+
await db.update(companies).set({
|
|
1689
|
+
websites: JSON.stringify(mergedWebsites),
|
|
1690
|
+
phones: JSON.stringify(mergedPhones),
|
|
1691
|
+
tags: JSON.stringify(mergedTags),
|
|
1692
|
+
custom_fields: JSON.stringify(mergedCustom),
|
|
1693
|
+
updated_at: now()
|
|
1694
|
+
}).where(eq3(companies.id, c1.id));
|
|
1695
|
+
const allContacts = await db.select().from(contacts);
|
|
1696
|
+
for (const ct of allContacts) {
|
|
1697
|
+
const companies2 = safeJSON(ct.companies);
|
|
1698
|
+
if (companies2.includes(c2.id)) {
|
|
1699
|
+
const updated = [
|
|
1700
|
+
...new Set(companies2.map((n) => n === c2.id ? c1.id : n))
|
|
1701
|
+
];
|
|
1702
|
+
await db.update(contacts).set({ companies: JSON.stringify(updated) }).where(eq3(contacts.id, ct.id));
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
await db.update(deals).set({ company: c1.id }).where(eq3(deals.company, c2.id));
|
|
1706
|
+
await db.update(activities).set({ company: c1.id }).where(eq3(activities.company, c2.id));
|
|
1707
|
+
await db.delete(companies).where(eq3(companies.id, c2.id));
|
|
1708
|
+
await removeSearchIndex(db, c2.id);
|
|
1709
|
+
const results = await db.select().from(companies).where(eq3(companies.id, c1.id));
|
|
1710
|
+
const row = results[0];
|
|
1711
|
+
await upsertSearchIndex(db, "company", c1.id, buildCompanySearch(row));
|
|
1712
|
+
console.log(c1.id);
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// src/commands/contact.ts
|
|
1717
|
+
import { eq as eq4 } from "drizzle-orm";
|
|
1718
|
+
function registerContactCommands(program) {
|
|
1719
|
+
const cmd = program.command("contact").description("Manage contacts");
|
|
1720
|
+
cmd.command("add").requiredOption("--name <name>", "Contact name").option("--email <email>", "Email", collect, []).option("--phone <phone>", "Phone", collect, []).option("--company <company>", "Company", collect, []).option("--tag <tag>", "Tag", collect, []).option("--linkedin <h>", "LinkedIn").option("--x <h>", "X/Twitter").option("--bluesky <h>", "Bluesky").option("--telegram <h>", "Telegram").option("--set <kv>", "Custom field", collect, []).action(async (opts) => {
|
|
1721
|
+
const { db, config } = await getCtx();
|
|
1722
|
+
opts.name = opts.name.trim();
|
|
1723
|
+
opts.email = opts.email.map((e) => e.trim());
|
|
1724
|
+
opts.phone = opts.phone.map((p) => p.trim());
|
|
1725
|
+
opts.company = opts.company.map((c) => c.trim());
|
|
1726
|
+
opts.tag = opts.tag.map((t) => t.trim());
|
|
1727
|
+
const cid = makeId("ct");
|
|
1728
|
+
const n = now();
|
|
1729
|
+
for (const e of opts.email) {
|
|
1730
|
+
validateEmail(e);
|
|
1731
|
+
await checkDupeEmail(db, e);
|
|
1732
|
+
}
|
|
1733
|
+
const phones = [];
|
|
1734
|
+
for (const p of opts.phone) {
|
|
1735
|
+
try {
|
|
1736
|
+
const norm = normalizePhone(p, config.phone.default_country);
|
|
1737
|
+
await checkDupePhone(db, norm, "contacts");
|
|
1738
|
+
phones.push(norm);
|
|
1739
|
+
} catch (e) {
|
|
1740
|
+
die(`Error: invalid phone — ${e.message}`);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
const linkedin = opts.linkedin ? normalizeSocialHandle("linkedin", opts.linkedin.trim()) : null;
|
|
1744
|
+
const x = opts.x ? normalizeSocialHandle("x", opts.x.trim()) : null;
|
|
1745
|
+
const bluesky = opts.bluesky ? normalizeSocialHandle("bluesky", opts.bluesky.trim()) : null;
|
|
1746
|
+
const telegram = opts.telegram ? normalizeSocialHandle("telegram", opts.telegram.trim()) : null;
|
|
1747
|
+
if (linkedin) {
|
|
1748
|
+
await checkDupeSocial(db, "linkedin", linkedin);
|
|
1749
|
+
}
|
|
1750
|
+
if (x) {
|
|
1751
|
+
await checkDupeSocial(db, "x", x);
|
|
1752
|
+
}
|
|
1753
|
+
if (bluesky) {
|
|
1754
|
+
await checkDupeSocial(db, "bluesky", bluesky);
|
|
1755
|
+
}
|
|
1756
|
+
if (telegram) {
|
|
1757
|
+
await checkDupeSocial(db, "telegram", telegram);
|
|
1758
|
+
}
|
|
1759
|
+
const companies2 = [];
|
|
1760
|
+
for (const c of opts.company) {
|
|
1761
|
+
companies2.push(await getOrCreateCompanyId(db, c));
|
|
1762
|
+
}
|
|
1763
|
+
const custom = parseKV(opts.set);
|
|
1764
|
+
if (!runHook(config, "pre-contact-add", {
|
|
1765
|
+
name: opts.name,
|
|
1766
|
+
emails: opts.email,
|
|
1767
|
+
phones,
|
|
1768
|
+
companies: companies2,
|
|
1769
|
+
linkedin,
|
|
1770
|
+
x,
|
|
1771
|
+
bluesky,
|
|
1772
|
+
telegram,
|
|
1773
|
+
tags: opts.tag,
|
|
1774
|
+
custom_fields: custom
|
|
1775
|
+
})) {
|
|
1776
|
+
die("Error: pre-contact-add hook rejected creation");
|
|
1777
|
+
}
|
|
1778
|
+
await db.insert(contacts).values({
|
|
1779
|
+
id: cid,
|
|
1780
|
+
name: opts.name,
|
|
1781
|
+
emails: JSON.stringify(opts.email),
|
|
1782
|
+
phones: JSON.stringify(phones),
|
|
1783
|
+
companies: JSON.stringify(companies2),
|
|
1784
|
+
linkedin,
|
|
1785
|
+
x,
|
|
1786
|
+
bluesky,
|
|
1787
|
+
telegram,
|
|
1788
|
+
tags: JSON.stringify(opts.tag),
|
|
1789
|
+
custom_fields: JSON.stringify(custom),
|
|
1790
|
+
created_at: n,
|
|
1791
|
+
updated_at: n
|
|
1792
|
+
});
|
|
1793
|
+
const results = await db.select().from(contacts).where(eq4(contacts.id, cid));
|
|
1794
|
+
const row = results[0];
|
|
1795
|
+
await upsertSearchIndex(db, "contact", cid, await buildContactSearch(db, row));
|
|
1796
|
+
runHook(config, "post-contact-add", {
|
|
1797
|
+
id: cid,
|
|
1798
|
+
name: opts.name,
|
|
1799
|
+
emails: opts.email,
|
|
1800
|
+
phones,
|
|
1801
|
+
companies: companies2,
|
|
1802
|
+
linkedin,
|
|
1803
|
+
x,
|
|
1804
|
+
bluesky,
|
|
1805
|
+
telegram,
|
|
1806
|
+
tags: opts.tag,
|
|
1807
|
+
custom_fields: custom
|
|
1808
|
+
});
|
|
1809
|
+
console.log(cid);
|
|
1810
|
+
});
|
|
1811
|
+
cmd.command("list").option("--tag <tag>").option("--company <company>").option("--sort <field>").option("--reverse", "Reverse sort order").option("--limit <n>").option("--offset <n>").option("--filter <expr>").action(async (opts) => {
|
|
1812
|
+
const { db, config, fmt } = await getCtx();
|
|
1813
|
+
let rows = (await db.select().from(contacts)).map((c) => contactToRow(c));
|
|
1814
|
+
if (opts.tag) {
|
|
1815
|
+
rows = rows.filter((c) => c.tags?.includes(opts.tag));
|
|
1816
|
+
}
|
|
1817
|
+
if (opts.company) {
|
|
1818
|
+
const co = await resolveCompany(db, opts.company, config);
|
|
1819
|
+
if (co) {
|
|
1820
|
+
rows = rows.filter((c) => c.companies?.includes(co.id));
|
|
1821
|
+
} else {
|
|
1822
|
+
rows = [];
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
if (opts.filter) {
|
|
1826
|
+
const f = parseFilter(opts.filter);
|
|
1827
|
+
rows = rows.filter((c) => applyFilter(c, f));
|
|
1828
|
+
}
|
|
1829
|
+
if (opts.sort) {
|
|
1830
|
+
rows.sort((a, b) => String(a[opts.sort] ?? "").localeCompare(String(b[opts.sort] ?? "")));
|
|
1831
|
+
}
|
|
1832
|
+
if (opts.reverse) {
|
|
1833
|
+
rows.reverse();
|
|
1834
|
+
}
|
|
1835
|
+
if (opts.offset) {
|
|
1836
|
+
rows = rows.slice(Number(opts.offset));
|
|
1837
|
+
}
|
|
1838
|
+
if (opts.limit) {
|
|
1839
|
+
rows = rows.slice(0, Number(opts.limit));
|
|
1840
|
+
}
|
|
1841
|
+
console.log(formatOutput(rows, fmt, config));
|
|
1842
|
+
});
|
|
1843
|
+
cmd.command("show").argument("<ref>").action(async (ref) => {
|
|
1844
|
+
const { db, config, fmt } = await getCtx();
|
|
1845
|
+
const c = await resolveContact(db, ref, config);
|
|
1846
|
+
if (!c) {
|
|
1847
|
+
die(`Error: contact not found: ${ref}`);
|
|
1848
|
+
}
|
|
1849
|
+
showEntity(await contactDetail(db, c, config), fmt);
|
|
1850
|
+
});
|
|
1851
|
+
cmd.command("edit").argument("<ref>").option("--name <name>").option("--add-email <e>", "", collect, []).option("--rm-email <e>", "", collect, []).option("--add-phone <p>", "", collect, []).option("--rm-phone <p>", "", collect, []).option("--add-company <c>", "", collect, []).option("--rm-company <c>", "", collect, []).option("--add-tag <t>", "", collect, []).option("--rm-tag <t>", "", collect, []).option("--linkedin <h>").option("--x <h>").option("--bluesky <h>").option("--telegram <h>").option("--set <kv>", "", collect, []).option("--unset <key>", "", collect, []).action(async (ref, opts) => {
|
|
1852
|
+
const { db, config } = await getCtx();
|
|
1853
|
+
if (opts.name) {
|
|
1854
|
+
opts.name = opts.name.trim();
|
|
1855
|
+
}
|
|
1856
|
+
opts.addEmail = opts.addEmail.map((e) => e.trim());
|
|
1857
|
+
opts.rmEmail = opts.rmEmail.map((e) => e.trim());
|
|
1858
|
+
opts.addPhone = opts.addPhone.map((p) => p.trim());
|
|
1859
|
+
opts.rmPhone = opts.rmPhone.map((p) => p.trim());
|
|
1860
|
+
opts.addCompany = opts.addCompany.map((c2) => c2.trim());
|
|
1861
|
+
opts.rmCompany = opts.rmCompany.map((c2) => c2.trim());
|
|
1862
|
+
opts.addTag = opts.addTag.map((t) => t.trim());
|
|
1863
|
+
opts.rmTag = opts.rmTag.map((t) => t.trim());
|
|
1864
|
+
const c = await resolveContact(db, ref.trim(), config);
|
|
1865
|
+
if (!c) {
|
|
1866
|
+
die(`Error: contact not found: ${ref}`);
|
|
1867
|
+
}
|
|
1868
|
+
let emails = safeJSON(c.emails);
|
|
1869
|
+
let phones = safeJSON(c.phones);
|
|
1870
|
+
let companies2 = safeJSON(c.companies);
|
|
1871
|
+
let tags = safeJSON(c.tags);
|
|
1872
|
+
const custom = safeJSON(c.custom_fields);
|
|
1873
|
+
let { name, linkedin, x, bluesky, telegram } = c;
|
|
1874
|
+
if (opts.name) {
|
|
1875
|
+
name = opts.name;
|
|
1876
|
+
}
|
|
1877
|
+
for (const e of opts.addEmail) {
|
|
1878
|
+
validateEmail(e);
|
|
1879
|
+
await checkDupeEmail(db, e, c.id);
|
|
1880
|
+
if (!emails.includes(e)) {
|
|
1881
|
+
emails.push(e);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
for (const e of opts.rmEmail) {
|
|
1885
|
+
emails = emails.filter((v) => v !== e);
|
|
1886
|
+
}
|
|
1887
|
+
for (const p of opts.addPhone) {
|
|
1888
|
+
const norm = normalizePhone(p, config.phone.default_country);
|
|
1889
|
+
if (!phones.includes(norm)) {
|
|
1890
|
+
await checkDupePhone(db, norm, "contacts", c.id);
|
|
1891
|
+
phones.push(norm);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
for (const p of opts.rmPhone) {
|
|
1895
|
+
const norm = tryNormalizePhone(p, config.phone.default_country);
|
|
1896
|
+
phones = norm ? phones.filter((v) => v !== norm) : phones.filter((v) => v !== p);
|
|
1897
|
+
}
|
|
1898
|
+
for (const co of opts.addCompany) {
|
|
1899
|
+
const coId = await getOrCreateCompanyId(db, co);
|
|
1900
|
+
if (!companies2.includes(coId)) {
|
|
1901
|
+
companies2.push(coId);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
for (const co of opts.rmCompany) {
|
|
1905
|
+
const resolved = await resolveCompany(db, co, config);
|
|
1906
|
+
if (resolved) {
|
|
1907
|
+
companies2 = companies2.filter((v) => v !== resolved.id);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
for (const t of opts.addTag) {
|
|
1911
|
+
if (!tags.includes(t)) {
|
|
1912
|
+
tags.push(t);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
for (const t of opts.rmTag) {
|
|
1916
|
+
tags = tags.filter((v) => v !== t);
|
|
1917
|
+
}
|
|
1918
|
+
if (opts.linkedin) {
|
|
1919
|
+
linkedin = normalizeSocialHandle("linkedin", opts.linkedin.trim());
|
|
1920
|
+
await checkDupeSocial(db, "linkedin", linkedin, c.id);
|
|
1921
|
+
}
|
|
1922
|
+
if (opts.x) {
|
|
1923
|
+
x = normalizeSocialHandle("x", opts.x.trim());
|
|
1924
|
+
await checkDupeSocial(db, "x", x, c.id);
|
|
1925
|
+
}
|
|
1926
|
+
if (opts.bluesky) {
|
|
1927
|
+
bluesky = normalizeSocialHandle("bluesky", opts.bluesky.trim());
|
|
1928
|
+
await checkDupeSocial(db, "bluesky", bluesky, c.id);
|
|
1929
|
+
}
|
|
1930
|
+
if (opts.telegram) {
|
|
1931
|
+
telegram = normalizeSocialHandle("telegram", opts.telegram.trim());
|
|
1932
|
+
await checkDupeSocial(db, "telegram", telegram, c.id);
|
|
1933
|
+
}
|
|
1934
|
+
const kvs = parseKV(opts.set);
|
|
1935
|
+
for (const [k, v] of Object.entries(kvs)) {
|
|
1936
|
+
custom[k] = v;
|
|
1937
|
+
}
|
|
1938
|
+
for (const k of opts.unset) {
|
|
1939
|
+
delete custom[k];
|
|
1940
|
+
if (k === "linkedin") {
|
|
1941
|
+
linkedin = null;
|
|
1942
|
+
}
|
|
1943
|
+
if (k === "x") {
|
|
1944
|
+
x = null;
|
|
1945
|
+
}
|
|
1946
|
+
if (k === "bluesky") {
|
|
1947
|
+
bluesky = null;
|
|
1948
|
+
}
|
|
1949
|
+
if (k === "telegram") {
|
|
1950
|
+
telegram = null;
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
if (!runHook(config, "pre-contact-edit", {
|
|
1954
|
+
id: c.id,
|
|
1955
|
+
name,
|
|
1956
|
+
emails,
|
|
1957
|
+
phones,
|
|
1958
|
+
companies: companies2,
|
|
1959
|
+
linkedin,
|
|
1960
|
+
x,
|
|
1961
|
+
bluesky,
|
|
1962
|
+
telegram,
|
|
1963
|
+
tags,
|
|
1964
|
+
custom_fields: custom
|
|
1965
|
+
})) {
|
|
1966
|
+
die("Error: pre-contact-edit hook rejected edit");
|
|
1967
|
+
}
|
|
1968
|
+
await db.update(contacts).set({
|
|
1969
|
+
name,
|
|
1970
|
+
emails: JSON.stringify(emails),
|
|
1971
|
+
phones: JSON.stringify(phones),
|
|
1972
|
+
companies: JSON.stringify(companies2),
|
|
1973
|
+
linkedin,
|
|
1974
|
+
x,
|
|
1975
|
+
bluesky,
|
|
1976
|
+
telegram,
|
|
1977
|
+
tags: JSON.stringify(tags),
|
|
1978
|
+
custom_fields: JSON.stringify(custom),
|
|
1979
|
+
updated_at: now()
|
|
1980
|
+
}).where(eq4(contacts.id, c.id));
|
|
1981
|
+
const results = await db.select().from(contacts).where(eq4(contacts.id, c.id));
|
|
1982
|
+
const row = results[0];
|
|
1983
|
+
await upsertSearchIndex(db, "contact", c.id, await buildContactSearch(db, row));
|
|
1984
|
+
console.log(c.id);
|
|
1985
|
+
runHook(config, "post-contact-edit", {
|
|
1986
|
+
id: c.id,
|
|
1987
|
+
name,
|
|
1988
|
+
emails,
|
|
1989
|
+
phones,
|
|
1990
|
+
companies: companies2,
|
|
1991
|
+
linkedin,
|
|
1992
|
+
x,
|
|
1993
|
+
bluesky,
|
|
1994
|
+
telegram,
|
|
1995
|
+
tags,
|
|
1996
|
+
custom_fields: custom
|
|
1997
|
+
});
|
|
1998
|
+
});
|
|
1999
|
+
cmd.command("rm").argument("<ref>").option("--force", "Skip confirmation").action(async (ref, opts) => {
|
|
2000
|
+
const { db, config } = await getCtx();
|
|
2001
|
+
const c = await resolveContact(db, ref, config);
|
|
2002
|
+
if (!c) {
|
|
2003
|
+
die(`Error: contact not found: ${ref}`);
|
|
2004
|
+
}
|
|
2005
|
+
confirmOrForce(opts.force, `contact "${c.name}" (${c.id})`);
|
|
2006
|
+
if (!runHook(config, "pre-contact-rm", { id: c.id, name: c.name })) {
|
|
2007
|
+
die("Error: pre-contact-rm hook rejected deletion");
|
|
2008
|
+
}
|
|
2009
|
+
const allDeals = await db.select().from(deals);
|
|
2010
|
+
for (const d of allDeals) {
|
|
2011
|
+
const contacts2 = safeJSON(d.contacts);
|
|
2012
|
+
if (contacts2.includes(c.id)) {
|
|
2013
|
+
await db.update(deals).set({
|
|
2014
|
+
contacts: JSON.stringify(contacts2.filter((id) => id !== c.id))
|
|
2015
|
+
}).where(eq4(deals.id, d.id));
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
await db.delete(contacts).where(eq4(contacts.id, c.id));
|
|
2019
|
+
await removeSearchIndex(db, c.id);
|
|
2020
|
+
runHook(config, "post-contact-rm", { id: c.id, name: c.name });
|
|
2021
|
+
});
|
|
2022
|
+
cmd.command("merge").argument("<id1>").argument("<id2>").action(async (id1, id2) => {
|
|
2023
|
+
const { db, config } = await getCtx();
|
|
2024
|
+
const c1 = await resolveContact(db, id1, config), c2 = await resolveContact(db, id2, config);
|
|
2025
|
+
if (!(c1 && c2)) {
|
|
2026
|
+
die("Error: one or both contacts not found");
|
|
2027
|
+
}
|
|
2028
|
+
const mergedEmails = [
|
|
2029
|
+
...new Set([...safeJSON(c1.emails), ...safeJSON(c2.emails)])
|
|
2030
|
+
];
|
|
2031
|
+
const mergedPhones = [
|
|
2032
|
+
...new Set([...safeJSON(c1.phones), ...safeJSON(c2.phones)])
|
|
2033
|
+
];
|
|
2034
|
+
const mergedCompanies = [
|
|
2035
|
+
...new Set([...safeJSON(c1.companies), ...safeJSON(c2.companies)])
|
|
2036
|
+
];
|
|
2037
|
+
const mergedTags = [
|
|
2038
|
+
...new Set([...safeJSON(c1.tags), ...safeJSON(c2.tags)])
|
|
2039
|
+
];
|
|
2040
|
+
const mergedCustom = {
|
|
2041
|
+
...safeJSON(c2.custom_fields),
|
|
2042
|
+
...safeJSON(c1.custom_fields)
|
|
2043
|
+
};
|
|
2044
|
+
const linkedin = c1.linkedin || c2.linkedin;
|
|
2045
|
+
const x = c1.x || c2.x;
|
|
2046
|
+
const bluesky = c1.bluesky || c2.bluesky;
|
|
2047
|
+
const telegram = c1.telegram || c2.telegram;
|
|
2048
|
+
await db.update(contacts).set({ linkedin: null, x: null, bluesky: null, telegram: null }).where(eq4(contacts.id, c2.id));
|
|
2049
|
+
await db.update(contacts).set({
|
|
2050
|
+
emails: JSON.stringify(mergedEmails),
|
|
2051
|
+
phones: JSON.stringify(mergedPhones),
|
|
2052
|
+
companies: JSON.stringify(mergedCompanies),
|
|
2053
|
+
tags: JSON.stringify(mergedTags),
|
|
2054
|
+
custom_fields: JSON.stringify(mergedCustom),
|
|
2055
|
+
linkedin,
|
|
2056
|
+
x,
|
|
2057
|
+
bluesky,
|
|
2058
|
+
telegram,
|
|
2059
|
+
updated_at: now()
|
|
2060
|
+
}).where(eq4(contacts.id, c1.id));
|
|
2061
|
+
const allDeals = await db.select().from(deals);
|
|
2062
|
+
for (const d of allDeals) {
|
|
2063
|
+
const contacts2 = safeJSON(d.contacts);
|
|
2064
|
+
if (contacts2.includes(c2.id)) {
|
|
2065
|
+
const updated = [
|
|
2066
|
+
...new Set(contacts2.map((id) => id === c2.id ? c1.id : id))
|
|
2067
|
+
];
|
|
2068
|
+
await db.update(deals).set({ contacts: JSON.stringify(updated) }).where(eq4(deals.id, d.id));
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
const allActivities = await db.select().from(activities);
|
|
2072
|
+
for (const a of allActivities) {
|
|
2073
|
+
const contacts2 = safeJSON(a.contacts);
|
|
2074
|
+
if (contacts2.includes(c2.id)) {
|
|
2075
|
+
const updated = [
|
|
2076
|
+
...new Set(contacts2.map((id) => id === c2.id ? c1.id : id))
|
|
2077
|
+
];
|
|
2078
|
+
await db.update(activities).set({ contacts: JSON.stringify(updated) }).where(eq4(activities.id, a.id));
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
await db.delete(contacts).where(eq4(contacts.id, c2.id));
|
|
2082
|
+
await removeSearchIndex(db, c2.id);
|
|
2083
|
+
const results = await db.select().from(contacts).where(eq4(contacts.id, c1.id));
|
|
2084
|
+
const row = results[0];
|
|
2085
|
+
await upsertSearchIndex(db, "contact", c1.id, await buildContactSearch(db, row));
|
|
2086
|
+
console.log(c1.id);
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// src/commands/deal.ts
|
|
2091
|
+
import { eq as eq5 } from "drizzle-orm";
|
|
2092
|
+
function registerDealCommands(program) {
|
|
2093
|
+
const cmd = program.command("deal").description("Manage deals");
|
|
2094
|
+
cmd.command("add").requiredOption("--title <title>", "Deal title").option("--value <n>", "Deal value").option("--stage <stage>", "Pipeline stage").option("--contact <ref>", "Contact", collect, []).option("--company <ref>", "Company").option("--expected-close <date>", "Expected close date").option("--probability <n>", "Win probability 0-100").option("--tag <tag>", "Tag", collect, []).option("--set <kv>", "Custom field", collect, []).action(async (opts) => {
|
|
2095
|
+
const { db, config } = await getCtx();
|
|
2096
|
+
opts.title = opts.title.trim();
|
|
2097
|
+
opts.contact = opts.contact.map((c) => c.trim());
|
|
2098
|
+
opts.tag = opts.tag.map((t) => t.trim());
|
|
2099
|
+
if (opts.company) {
|
|
2100
|
+
opts.company = opts.company.trim();
|
|
2101
|
+
}
|
|
2102
|
+
if (opts.stage) {
|
|
2103
|
+
opts.stage = opts.stage.trim();
|
|
2104
|
+
}
|
|
2105
|
+
const id = makeId("dl");
|
|
2106
|
+
const n = now();
|
|
2107
|
+
if (opts.value !== undefined && Number(opts.value) < 0) {
|
|
2108
|
+
die("Error: value must be non-negative");
|
|
2109
|
+
}
|
|
2110
|
+
if (opts.probability !== undefined) {
|
|
2111
|
+
const prob = Number(opts.probability);
|
|
2112
|
+
if (prob < 0 || prob > 100) {
|
|
2113
|
+
die("Error: probability must be between 0 and 100");
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
if (opts.expectedClose) {
|
|
2117
|
+
opts.expectedClose = opts.expectedClose.trim();
|
|
2118
|
+
const d = new Date(opts.expectedClose);
|
|
2119
|
+
if (Number.isNaN(d.getTime())) {
|
|
2120
|
+
die("Error: invalid expected-close date");
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
const stage = opts.stage || config.pipeline.stages[0];
|
|
2124
|
+
if (!config.pipeline.stages.includes(stage)) {
|
|
2125
|
+
die(`Error: invalid stage "${stage}"`);
|
|
2126
|
+
}
|
|
2127
|
+
const contactIds = [];
|
|
2128
|
+
for (const ref of opts.contact) {
|
|
2129
|
+
const ctId = await getOrCreateContactId(db, ref, config);
|
|
2130
|
+
if (!contactIds.includes(ctId)) {
|
|
2131
|
+
contactIds.push(ctId);
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
let companyId = null;
|
|
2135
|
+
if (opts.company) {
|
|
2136
|
+
const co = await resolveCompanyForLink(db, opts.company);
|
|
2137
|
+
if (co) {
|
|
2138
|
+
companyId = co.id;
|
|
2139
|
+
} else {
|
|
2140
|
+
if (opts.company.includes(".")) {
|
|
2141
|
+
die(`Error: company not found: ${opts.company}`);
|
|
2142
|
+
}
|
|
2143
|
+
companyId = await getOrCreateCompanyId(db, opts.company);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
const custom = parseKV(opts.set);
|
|
2147
|
+
const value = opts.value === undefined ? null : Number(opts.value);
|
|
2148
|
+
const probability = opts.probability === undefined ? null : Number(opts.probability);
|
|
2149
|
+
if (!runHook(config, "pre-deal-add", {
|
|
2150
|
+
title: opts.title,
|
|
2151
|
+
value,
|
|
2152
|
+
stage,
|
|
2153
|
+
contacts: contactIds,
|
|
2154
|
+
company: companyId,
|
|
2155
|
+
expected_close: opts.expectedClose || null,
|
|
2156
|
+
probability,
|
|
2157
|
+
tags: opts.tag,
|
|
2158
|
+
custom_fields: custom
|
|
2159
|
+
})) {
|
|
2160
|
+
die("Error: pre-deal-add hook rejected creation");
|
|
2161
|
+
}
|
|
2162
|
+
await db.insert(deals).values({
|
|
2163
|
+
id,
|
|
2164
|
+
title: opts.title,
|
|
2165
|
+
value,
|
|
2166
|
+
stage,
|
|
2167
|
+
contacts: JSON.stringify(contactIds),
|
|
2168
|
+
company: companyId,
|
|
2169
|
+
expected_close: opts.expectedClose || null,
|
|
2170
|
+
probability,
|
|
2171
|
+
tags: JSON.stringify(opts.tag),
|
|
2172
|
+
custom_fields: JSON.stringify(custom),
|
|
2173
|
+
created_at: n,
|
|
2174
|
+
updated_at: n
|
|
2175
|
+
});
|
|
2176
|
+
const results = await db.select().from(deals).where(eq5(deals.id, id));
|
|
2177
|
+
const row = results[0];
|
|
2178
|
+
await upsertSearchIndex(db, "deal", id, buildDealSearch(row));
|
|
2179
|
+
runHook(config, "post-deal-add", {
|
|
2180
|
+
id,
|
|
2181
|
+
title: opts.title,
|
|
2182
|
+
value,
|
|
2183
|
+
stage,
|
|
2184
|
+
contacts: contactIds,
|
|
2185
|
+
company: companyId,
|
|
2186
|
+
expected_close: opts.expectedClose || null,
|
|
2187
|
+
probability,
|
|
2188
|
+
tags: opts.tag,
|
|
2189
|
+
custom_fields: custom
|
|
2190
|
+
});
|
|
2191
|
+
console.log(id);
|
|
2192
|
+
});
|
|
2193
|
+
cmd.command("list").option("--stage <stage>").option("--min-value <n>").option("--max-value <n>").option("--contact <ref>").option("--company <ref>").option("--tag <tag>").option("--filter <expr>").option("--sort <field>").option("--reverse", "Reverse sort order").option("--limit <n>").option("--offset <n>").action(async (opts) => {
|
|
2194
|
+
const { db, config, fmt } = await getCtx();
|
|
2195
|
+
let rows = (await db.select().from(deals)).map((d) => dealToRow(d));
|
|
2196
|
+
if (opts.stage) {
|
|
2197
|
+
rows = rows.filter((d) => d.stage === opts.stage);
|
|
2198
|
+
}
|
|
2199
|
+
if (opts.minValue) {
|
|
2200
|
+
rows = rows.filter((d) => (d.value ?? 0) >= Number(opts.minValue));
|
|
2201
|
+
}
|
|
2202
|
+
if (opts.maxValue) {
|
|
2203
|
+
rows = rows.filter((d) => (d.value ?? 0) <= Number(opts.maxValue));
|
|
2204
|
+
}
|
|
2205
|
+
if (opts.contact) {
|
|
2206
|
+
const ct = await resolveContact(db, opts.contact, config);
|
|
2207
|
+
if (ct) {
|
|
2208
|
+
rows = rows.filter((d) => d.contacts?.includes(ct.id));
|
|
2209
|
+
} else {
|
|
2210
|
+
rows = [];
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
if (opts.company) {
|
|
2214
|
+
const co = await resolveCompany(db, opts.company, config);
|
|
2215
|
+
if (co) {
|
|
2216
|
+
rows = rows.filter((d) => d.company === co.id);
|
|
2217
|
+
} else {
|
|
2218
|
+
rows = [];
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
if (opts.tag) {
|
|
2222
|
+
rows = rows.filter((d) => d.tags?.includes(opts.tag));
|
|
2223
|
+
}
|
|
2224
|
+
if (opts.filter) {
|
|
2225
|
+
const f = parseFilter(opts.filter);
|
|
2226
|
+
rows = rows.filter((d) => applyFilter(d, f));
|
|
2227
|
+
}
|
|
2228
|
+
if (opts.sort) {
|
|
2229
|
+
rows.sort((a, b) => {
|
|
2230
|
+
const av = a[opts.sort], bv = b[opts.sort];
|
|
2231
|
+
if (typeof av === "number" && typeof bv === "number") {
|
|
2232
|
+
return av - bv;
|
|
2233
|
+
}
|
|
2234
|
+
return String(av ?? "").localeCompare(String(bv ?? ""));
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
if (opts.reverse) {
|
|
2238
|
+
rows.reverse();
|
|
2239
|
+
}
|
|
2240
|
+
if (opts.offset) {
|
|
2241
|
+
rows = rows.slice(Number(opts.offset));
|
|
2242
|
+
}
|
|
2243
|
+
if (opts.limit) {
|
|
2244
|
+
rows = rows.slice(0, Number(opts.limit));
|
|
2245
|
+
}
|
|
2246
|
+
console.log(formatOutput(rows, fmt, config));
|
|
2247
|
+
});
|
|
2248
|
+
cmd.command("show").argument("<ref>").action(async (ref) => {
|
|
2249
|
+
const { db, fmt } = await getCtx();
|
|
2250
|
+
const d = await resolveDeal(db, ref);
|
|
2251
|
+
if (!d) {
|
|
2252
|
+
die(`Error: deal not found: ${ref}`);
|
|
2253
|
+
}
|
|
2254
|
+
showEntity(await dealDetail(db, d), fmt);
|
|
2255
|
+
});
|
|
2256
|
+
cmd.command("edit").argument("<ref>").option("--title <title>").option("--value <n>").option("--company <ref>", "Change linked company").option("--expected-close <date>", "Expected close date (YYYY-MM-DD)").option("--probability <n>", "Win probability 0-100").option("--add-contact <ref>", "", collect, []).option("--rm-contact <ref>", "", collect, []).option("--add-tag <t>", "", collect, []).option("--rm-tag <t>", "", collect, []).option("--set <kv>", "", collect, []).option("--unset <key>", "", collect, []).action(async (ref, opts) => {
|
|
2257
|
+
const { db, config } = await getCtx();
|
|
2258
|
+
if (opts.title) {
|
|
2259
|
+
opts.title = opts.title.trim();
|
|
2260
|
+
}
|
|
2261
|
+
if (opts.company) {
|
|
2262
|
+
opts.company = opts.company.trim();
|
|
2263
|
+
}
|
|
2264
|
+
opts.addContact = opts.addContact.map((c) => c.trim());
|
|
2265
|
+
opts.rmContact = opts.rmContact.map((c) => c.trim());
|
|
2266
|
+
opts.addTag = opts.addTag.map((t) => t.trim());
|
|
2267
|
+
opts.rmTag = opts.rmTag.map((t) => t.trim());
|
|
2268
|
+
const d = await resolveDeal(db, ref.trim());
|
|
2269
|
+
if (!d) {
|
|
2270
|
+
die(`Error: deal not found: ${ref}`);
|
|
2271
|
+
}
|
|
2272
|
+
const title = opts.title ?? d.title;
|
|
2273
|
+
const value = opts.value === undefined ? d.value : Number(opts.value);
|
|
2274
|
+
const expectedClose = opts.expectedClose ? opts.expectedClose.trim() : d.expected_close;
|
|
2275
|
+
const probability = opts.probability === undefined ? d.probability : Number(opts.probability);
|
|
2276
|
+
let companyId = d.company;
|
|
2277
|
+
if (opts.company) {
|
|
2278
|
+
companyId = await getOrCreateCompanyId(db, opts.company);
|
|
2279
|
+
}
|
|
2280
|
+
let contacts2 = safeJSON(d.contacts);
|
|
2281
|
+
let tags = safeJSON(d.tags);
|
|
2282
|
+
const custom = safeJSON(d.custom_fields);
|
|
2283
|
+
for (const r of opts.addContact) {
|
|
2284
|
+
const ctId = await getOrCreateContactId(db, r, config);
|
|
2285
|
+
if (!contacts2.includes(ctId)) {
|
|
2286
|
+
contacts2.push(ctId);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
for (const r of opts.rmContact) {
|
|
2290
|
+
const ct = await resolveContact(db, r, config);
|
|
2291
|
+
if (ct) {
|
|
2292
|
+
contacts2 = contacts2.filter((id) => id !== ct.id);
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
for (const t of opts.addTag) {
|
|
2296
|
+
if (!tags.includes(t)) {
|
|
2297
|
+
tags.push(t);
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
for (const t of opts.rmTag) {
|
|
2301
|
+
tags = tags.filter((v) => v !== t);
|
|
2302
|
+
}
|
|
2303
|
+
const kvs = parseKV(opts.set);
|
|
2304
|
+
for (const [k, v] of Object.entries(kvs)) {
|
|
2305
|
+
custom[k] = v;
|
|
2306
|
+
}
|
|
2307
|
+
for (const k of opts.unset) {
|
|
2308
|
+
delete custom[k];
|
|
2309
|
+
}
|
|
2310
|
+
if (!runHook(config, "pre-deal-edit", {
|
|
2311
|
+
id: d.id,
|
|
2312
|
+
title,
|
|
2313
|
+
value,
|
|
2314
|
+
contacts: contacts2,
|
|
2315
|
+
tags,
|
|
2316
|
+
custom_fields: custom
|
|
2317
|
+
})) {
|
|
2318
|
+
die("Error: pre-deal-edit hook rejected edit");
|
|
2319
|
+
}
|
|
2320
|
+
await db.update(deals).set({
|
|
2321
|
+
title,
|
|
2322
|
+
value,
|
|
2323
|
+
company: companyId,
|
|
2324
|
+
expected_close: expectedClose,
|
|
2325
|
+
probability,
|
|
2326
|
+
contacts: JSON.stringify(contacts2),
|
|
2327
|
+
tags: JSON.stringify(tags),
|
|
2328
|
+
custom_fields: JSON.stringify(custom),
|
|
2329
|
+
updated_at: now()
|
|
2330
|
+
}).where(eq5(deals.id, d.id));
|
|
2331
|
+
const results = await db.select().from(deals).where(eq5(deals.id, d.id));
|
|
2332
|
+
const row = results[0];
|
|
2333
|
+
await upsertSearchIndex(db, "deal", d.id, buildDealSearch(row));
|
|
2334
|
+
console.log(d.id);
|
|
2335
|
+
runHook(config, "post-deal-edit", {
|
|
2336
|
+
id: d.id,
|
|
2337
|
+
title,
|
|
2338
|
+
value,
|
|
2339
|
+
contacts: contacts2,
|
|
2340
|
+
tags,
|
|
2341
|
+
custom_fields: custom
|
|
2342
|
+
});
|
|
2343
|
+
});
|
|
2344
|
+
cmd.command("move").argument("<ref>").requiredOption("--stage <stage>", "Target stage").option("--note <text>", "Note").action(async (ref, opts) => {
|
|
2345
|
+
const { db, config } = await getCtx();
|
|
2346
|
+
opts.stage = opts.stage.trim();
|
|
2347
|
+
if (opts.note) {
|
|
2348
|
+
opts.note = opts.note.trim();
|
|
2349
|
+
}
|
|
2350
|
+
const d = await resolveDeal(db, ref);
|
|
2351
|
+
if (!d) {
|
|
2352
|
+
die(`Error: deal not found: ${ref}`);
|
|
2353
|
+
}
|
|
2354
|
+
if (!config.pipeline.stages.includes(opts.stage)) {
|
|
2355
|
+
die(`Error: invalid stage "${opts.stage}"`);
|
|
2356
|
+
}
|
|
2357
|
+
if (d.stage === opts.stage) {
|
|
2358
|
+
die(`Error: deal is already in stage "${opts.stage}"`);
|
|
2359
|
+
}
|
|
2360
|
+
const oldStage = d.stage;
|
|
2361
|
+
if (!runHook(config, "pre-deal-stage-change", {
|
|
2362
|
+
deal: d.id,
|
|
2363
|
+
from: oldStage,
|
|
2364
|
+
to: opts.stage,
|
|
2365
|
+
note: opts.note
|
|
2366
|
+
})) {
|
|
2367
|
+
die("Error: pre-deal-stage-change hook rejected stage move");
|
|
2368
|
+
}
|
|
2369
|
+
const n = now();
|
|
2370
|
+
await db.update(deals).set({ stage: opts.stage, updated_at: n }).where(eq5(deals.id, d.id));
|
|
2371
|
+
let body = `from ${oldStage} to ${opts.stage}`;
|
|
2372
|
+
if (opts.note) {
|
|
2373
|
+
body += ` | ${opts.note}`;
|
|
2374
|
+
}
|
|
2375
|
+
const aid = makeId("ac");
|
|
2376
|
+
await db.insert(activities).values({
|
|
2377
|
+
id: aid,
|
|
2378
|
+
type: "stage-change",
|
|
2379
|
+
body,
|
|
2380
|
+
deal: d.id,
|
|
2381
|
+
created_at: n
|
|
2382
|
+
});
|
|
2383
|
+
await upsertSearchIndex(db, "activity", aid, `stage-change ${body}`);
|
|
2384
|
+
console.log(d.id);
|
|
2385
|
+
runHook(config, "post-deal-stage-change", {
|
|
2386
|
+
deal: d.id,
|
|
2387
|
+
from: oldStage,
|
|
2388
|
+
to: opts.stage,
|
|
2389
|
+
note: opts.note
|
|
2390
|
+
});
|
|
2391
|
+
});
|
|
2392
|
+
cmd.command("rm").argument("<ref>").option("--force", "Skip confirmation").action(async (ref, opts) => {
|
|
2393
|
+
const { db, config } = await getCtx();
|
|
2394
|
+
const d = await resolveDeal(db, ref);
|
|
2395
|
+
if (!d) {
|
|
2396
|
+
die(`Error: deal not found: ${ref}`);
|
|
2397
|
+
}
|
|
2398
|
+
confirmOrForce(opts.force, `deal "${d.title}" (${d.id})`);
|
|
2399
|
+
if (!runHook(config, "pre-deal-rm", {
|
|
2400
|
+
id: d.id,
|
|
2401
|
+
title: d.title
|
|
2402
|
+
})) {
|
|
2403
|
+
die("Error: pre-deal-rm hook rejected deletion");
|
|
2404
|
+
}
|
|
2405
|
+
await db.delete(activities).where(eq5(activities.deal, d.id));
|
|
2406
|
+
await db.delete(deals).where(eq5(deals.id, d.id));
|
|
2407
|
+
await removeSearchIndex(db, d.id);
|
|
2408
|
+
runHook(config, "post-deal-rm", {
|
|
2409
|
+
id: d.id,
|
|
2410
|
+
title: d.title
|
|
2411
|
+
});
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
function registerPipelineCommand(program) {
|
|
2415
|
+
program.command("pipeline").description("Pipeline summary").action(async () => {
|
|
2416
|
+
const { db, config, fmt } = await getCtx();
|
|
2417
|
+
const deals2 = await db.select().from(deals);
|
|
2418
|
+
const summary = config.pipeline.stages.map((stage) => ({
|
|
2419
|
+
stage,
|
|
2420
|
+
count: deals2.filter((d) => d.stage === stage).length,
|
|
2421
|
+
value: deals2.filter((d) => d.stage === stage).reduce((s, d) => s + (d.value || 0), 0)
|
|
2422
|
+
}));
|
|
2423
|
+
const total = {
|
|
2424
|
+
stage: "Total",
|
|
2425
|
+
count: deals2.length,
|
|
2426
|
+
value: deals2.reduce((s, d) => s + (d.value || 0), 0)
|
|
2427
|
+
};
|
|
2428
|
+
const data = [...summary, total];
|
|
2429
|
+
if (fmt === "json") {
|
|
2430
|
+
console.log(JSON.stringify(data, null, 2));
|
|
2431
|
+
} else {
|
|
2432
|
+
console.log(formatOutput(data, fmt, config));
|
|
2433
|
+
}
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
// src/commands/dupes.ts
|
|
2438
|
+
function registerDupesCommand(program) {
|
|
2439
|
+
program.command("dupes").description("Find likely duplicates").option("--type <type>", "Entity type (contact or company)").option("--threshold <n>", "Similarity threshold 0-1", "0.3").option("--limit <n>", "Max results").action(async (opts) => {
|
|
2440
|
+
const { db, fmt } = await getCtx();
|
|
2441
|
+
const threshold = Number(opts.threshold);
|
|
2442
|
+
let results = [];
|
|
2443
|
+
if (!opts.type || opts.type === "contact") {
|
|
2444
|
+
const contacts2 = await db.select().from(contacts);
|
|
2445
|
+
for (let i = 0;i < contacts2.length; i++) {
|
|
2446
|
+
for (let j = i + 1;j < contacts2.length; j++) {
|
|
2447
|
+
const reasons = contactDupeReasons(contacts2[i], contacts2[j]);
|
|
2448
|
+
const score = dupeScore(reasons);
|
|
2449
|
+
if (score >= threshold) {
|
|
2450
|
+
results.push({
|
|
2451
|
+
left: contactToRow(contacts2[i]),
|
|
2452
|
+
right: contactToRow(contacts2[j]),
|
|
2453
|
+
reasons,
|
|
2454
|
+
score
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
if (!opts.type || opts.type === "company") {
|
|
2461
|
+
const companies2 = await db.select().from(companies);
|
|
2462
|
+
for (let i = 0;i < companies2.length; i++) {
|
|
2463
|
+
for (let j = i + 1;j < companies2.length; j++) {
|
|
2464
|
+
const reasons = companyDupeReasons(companies2[i], companies2[j]);
|
|
2465
|
+
const score = dupeScore(reasons);
|
|
2466
|
+
if (score >= threshold) {
|
|
2467
|
+
results.push({
|
|
2468
|
+
left: companyToRow(companies2[i]),
|
|
2469
|
+
right: companyToRow(companies2[j]),
|
|
2470
|
+
reasons,
|
|
2471
|
+
score
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
results.sort((a, b) => b.score - a.score);
|
|
2478
|
+
if (opts.limit) {
|
|
2479
|
+
results = results.slice(0, Number(opts.limit));
|
|
2480
|
+
}
|
|
2481
|
+
if (fmt === "json") {
|
|
2482
|
+
console.log(JSON.stringify(results.map((r) => ({
|
|
2483
|
+
left: r.left,
|
|
2484
|
+
right: r.right,
|
|
2485
|
+
reasons: r.reasons
|
|
2486
|
+
})), null, 2));
|
|
2487
|
+
} else {
|
|
2488
|
+
if (results.length === 0) {
|
|
2489
|
+
console.log("");
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
const lines = results.map((r) => {
|
|
2493
|
+
const lName = r.left.name || r.left.title || r.left.id;
|
|
2494
|
+
const rName = r.right.name || r.right.title || r.right.id;
|
|
2495
|
+
return `${lName} <-> ${rName}: ${r.reasons.join(", ")}`;
|
|
2496
|
+
});
|
|
2497
|
+
console.log(lines.join(`
|
|
2498
|
+
`));
|
|
2499
|
+
}
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
function contactDupeReasons(a, b) {
|
|
2503
|
+
const reasons = [];
|
|
2504
|
+
const aName = (a.name || "").toLowerCase();
|
|
2505
|
+
const bName = (b.name || "").toLowerCase();
|
|
2506
|
+
const nameDistance = levenshtein(aName, bName);
|
|
2507
|
+
const maxLen = Math.max(aName.length, bName.length);
|
|
2508
|
+
const nameSimilarity = maxLen > 0 ? 1 - nameDistance / maxLen : 0;
|
|
2509
|
+
if (nameSimilarity >= 0.5) {
|
|
2510
|
+
reasons.push("similar name");
|
|
2511
|
+
}
|
|
2512
|
+
const aEmails = safeJSON(a.emails);
|
|
2513
|
+
const bEmails = safeJSON(b.emails);
|
|
2514
|
+
for (const ae of aEmails) {
|
|
2515
|
+
for (const be of bEmails) {
|
|
2516
|
+
if (ae.toLowerCase() === be.toLowerCase()) {
|
|
2517
|
+
reasons.push("same email");
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
const aPhones = safeJSON(a.phones);
|
|
2522
|
+
const bPhones = safeJSON(b.phones);
|
|
2523
|
+
for (const ap of aPhones) {
|
|
2524
|
+
for (const bp of bPhones) {
|
|
2525
|
+
if (ap === bp) {
|
|
2526
|
+
reasons.push("same phone");
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
const aCompanies = safeJSON(a.companies);
|
|
2531
|
+
const bCompanies = safeJSON(b.companies);
|
|
2532
|
+
for (const ac of aCompanies) {
|
|
2533
|
+
for (const bc of bCompanies) {
|
|
2534
|
+
if (ac.toLowerCase() === bc.toLowerCase()) {
|
|
2535
|
+
reasons.push("same company");
|
|
2536
|
+
break;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
const socialFields = ["linkedin", "x", "bluesky", "telegram"];
|
|
2541
|
+
for (const field of socialFields) {
|
|
2542
|
+
if (a[field] && b[field]) {
|
|
2543
|
+
const aVal = a[field];
|
|
2544
|
+
const bVal = b[field];
|
|
2545
|
+
const dist = levenshtein(aVal.toLowerCase(), bVal.toLowerCase());
|
|
2546
|
+
const ml = Math.max(aVal.length, bVal.length);
|
|
2547
|
+
if (ml > 0 && 1 - dist / ml >= 0.6) {
|
|
2548
|
+
reasons.push(`similar ${field}`);
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
if (nameSimilarity >= 0.3) {
|
|
2553
|
+
let found = false;
|
|
2554
|
+
for (const ae of aEmails) {
|
|
2555
|
+
if (found) {
|
|
2556
|
+
break;
|
|
2557
|
+
}
|
|
2558
|
+
for (const be of bEmails) {
|
|
2559
|
+
const aDomain = ae.split("@")[1]?.toLowerCase();
|
|
2560
|
+
const bDomain = be.split("@")[1]?.toLowerCase();
|
|
2561
|
+
if (aDomain && bDomain && aDomain === bDomain && !["gmail.com", "yahoo.com", "hotmail.com", "outlook.com"].includes(aDomain)) {
|
|
2562
|
+
reasons.push("shared email domain");
|
|
2563
|
+
found = true;
|
|
2564
|
+
break;
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
return reasons;
|
|
2570
|
+
}
|
|
2571
|
+
function companyDupeReasons(a, b) {
|
|
2572
|
+
const reasons = [];
|
|
2573
|
+
const aName = (a.name || "").toLowerCase();
|
|
2574
|
+
const bName = (b.name || "").toLowerCase();
|
|
2575
|
+
const nameDistance = levenshtein(aName, bName);
|
|
2576
|
+
const maxLen = Math.max(aName.length, bName.length);
|
|
2577
|
+
const nameSimilarity = maxLen > 0 ? 1 - nameDistance / maxLen : 0;
|
|
2578
|
+
if (nameSimilarity >= 0.5) {
|
|
2579
|
+
reasons.push("similar name");
|
|
2580
|
+
}
|
|
2581
|
+
const aWebsites = safeJSON(a.websites);
|
|
2582
|
+
const bWebsites = safeJSON(b.websites);
|
|
2583
|
+
for (const aw of aWebsites) {
|
|
2584
|
+
for (const bw of bWebsites) {
|
|
2585
|
+
const aDomain = aw.split("/")[0];
|
|
2586
|
+
const bDomain = bw.split("/")[0];
|
|
2587
|
+
if (aDomain === bDomain) {
|
|
2588
|
+
reasons.push("same domain");
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
return reasons;
|
|
2593
|
+
}
|
|
2594
|
+
function dupeScore(reasons) {
|
|
2595
|
+
if (reasons.length === 0) {
|
|
2596
|
+
return 0;
|
|
2597
|
+
}
|
|
2598
|
+
let score = 0;
|
|
2599
|
+
for (const r of reasons) {
|
|
2600
|
+
if (r === "same email" || r === "same phone") {
|
|
2601
|
+
score += 0.5;
|
|
2602
|
+
} else if (r === "similar name") {
|
|
2603
|
+
score += 0.4;
|
|
2604
|
+
} else if (r === "same company") {
|
|
2605
|
+
score += 0.15;
|
|
2606
|
+
} else if (r.startsWith("similar ")) {
|
|
2607
|
+
score += 0.2;
|
|
2608
|
+
} else if (r === "same domain") {
|
|
2609
|
+
score += 0.2;
|
|
2610
|
+
} else if (r === "shared email domain") {
|
|
2611
|
+
score += 0.15;
|
|
2612
|
+
} else {
|
|
2613
|
+
score += 0.1;
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
return Math.min(score, 1);
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
// src/commands/fuse.ts
|
|
2620
|
+
import { spawn, spawnSync as spawnSync2 } from "node:child_process";
|
|
2621
|
+
import {
|
|
2622
|
+
existsSync as existsSync3,
|
|
2623
|
+
mkdirSync as mkdirSync4,
|
|
2624
|
+
readFileSync as readFileSync2,
|
|
2625
|
+
unlinkSync,
|
|
2626
|
+
writeFileSync as writeFileSync3
|
|
2627
|
+
} from "node:fs";
|
|
2628
|
+
import { homedir as homedir2, tmpdir } from "node:os";
|
|
2629
|
+
import { join as join3 } from "node:path";
|
|
2630
|
+
|
|
2631
|
+
// src/export-fs.ts
|
|
2632
|
+
import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2633
|
+
import { join as join2 } from "node:path";
|
|
2634
|
+
import { eq as eq8 } from "drizzle-orm";
|
|
2635
|
+
|
|
2636
|
+
// src/fuse-json.ts
|
|
2637
|
+
import { eq as eq6 } from "drizzle-orm";
|
|
2638
|
+
var LLM_TXT = `# CRM Filesystem
|
|
2639
|
+
|
|
2640
|
+
This directory is a live view of a CRM database, powered by crm.cli.
|
|
2641
|
+
All data is JSON. Changes made here are written back to the database.
|
|
2642
|
+
|
|
2643
|
+
## Structure
|
|
2644
|
+
|
|
2645
|
+
contacts/ → One JSON file per contact
|
|
2646
|
+
_by-email/ → Lookup by email address
|
|
2647
|
+
_by-phone/ → Lookup by E.164 phone (+12125551234.json)
|
|
2648
|
+
_by-linkedin/ → Lookup by LinkedIn handle
|
|
2649
|
+
_by-x/ → Lookup by X/Twitter handle
|
|
2650
|
+
_by-bluesky/ → Lookup by Bluesky handle
|
|
2651
|
+
_by-telegram/ → Lookup by Telegram handle
|
|
2652
|
+
_by-company/ → Contacts grouped by company name
|
|
2653
|
+
_by-tag/ → Contacts grouped by tag
|
|
2654
|
+
|
|
2655
|
+
companies/ → One JSON file per company
|
|
2656
|
+
_by-website/ → Lookup by website domain
|
|
2657
|
+
_by-phone/ → Lookup by E.164 phone
|
|
2658
|
+
_by-tag/ → Companies grouped by tag
|
|
2659
|
+
|
|
2660
|
+
deals/ → One JSON file per deal
|
|
2661
|
+
_by-stage/ → Deals grouped by pipeline stage
|
|
2662
|
+
_by-company/ → Deals grouped by company name
|
|
2663
|
+
_by-tag/ → Deals grouped by tag
|
|
2664
|
+
|
|
2665
|
+
activities/ → One JSON file per activity (note, email, call, meeting)
|
|
2666
|
+
_by-contact/ → Activities grouped by contact
|
|
2667
|
+
_by-company/ → Activities grouped by company
|
|
2668
|
+
_by-deal/ → Activities grouped by deal
|
|
2669
|
+
_by-type/ → Activities grouped by type
|
|
2670
|
+
|
|
2671
|
+
reports/ → Pre-computed analytics (pipeline, forecast, velocity, stale, won, lost, conversion)
|
|
2672
|
+
search/ → Read search/<query>.json to search (filename is the query)
|
|
2673
|
+
pipeline.json → Pipeline stage counts and values
|
|
2674
|
+
tags.json → All tags with usage counts
|
|
2675
|
+
|
|
2676
|
+
## Reading data
|
|
2677
|
+
|
|
2678
|
+
Each entity file (e.g. contacts/ct_01J8Z...jane-doe.json) is self-contained JSON
|
|
2679
|
+
with all fields, linked entities, and recent activity. The _by-* directories contain
|
|
2680
|
+
copies of the same files, organized for lookup. Use ls + cat to explore.
|
|
2681
|
+
|
|
2682
|
+
## Writing data
|
|
2683
|
+
|
|
2684
|
+
Write a JSON file to a top-level directory to create or update an entity:
|
|
2685
|
+
|
|
2686
|
+
echo '{"name":"Jane Doe","emails":["jane@acme.com"]}' > contacts/new.json
|
|
2687
|
+
|
|
2688
|
+
The filename doesn't matter for writes — the CRM assigns an ID and renames the file.
|
|
2689
|
+
To update, write to the existing filename. Fields you omit are left unchanged.
|
|
2690
|
+
|
|
2691
|
+
## Search
|
|
2692
|
+
|
|
2693
|
+
Read a file in search/ where the filename is your query:
|
|
2694
|
+
|
|
2695
|
+
cat search/jane.json → contacts/companies/deals matching "jane"
|
|
2696
|
+
cat search/fintech-cto.json → results matching "fintech-cto"
|
|
2697
|
+
|
|
2698
|
+
## Phones
|
|
2699
|
+
|
|
2700
|
+
Phones are stored in E.164 format (+12125551234). The _by-phone directories use E.164
|
|
2701
|
+
filenames. When writing, any common format is accepted and normalized automatically.
|
|
2702
|
+
|
|
2703
|
+
## Tips for agents
|
|
2704
|
+
|
|
2705
|
+
- Start with \`ls\` at the root to see what's available
|
|
2706
|
+
- Use _by-email, _by-phone, _by-linkedin for fast lookups instead of scanning all files
|
|
2707
|
+
- Read pipeline.json for a quick overview of deal flow
|
|
2708
|
+
- Read reports/ for pre-computed analytics — no need to calculate from raw data
|
|
2709
|
+
- All JSON files are self-contained — no need to join across files
|
|
2710
|
+
- The search/ directory accepts natural language queries
|
|
2711
|
+
`;
|
|
2712
|
+
function slugify(name) {
|
|
2713
|
+
return (name || "unknown").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
2714
|
+
}
|
|
2715
|
+
function contactFilename(c) {
|
|
2716
|
+
return `${c.id}...${slugify(c.name || "")}.json`;
|
|
2717
|
+
}
|
|
2718
|
+
function companyFilename(co) {
|
|
2719
|
+
return `${co.id}...${slugify(co.name || "")}.json`;
|
|
2720
|
+
}
|
|
2721
|
+
function dealFilename(d) {
|
|
2722
|
+
return `${d.id}...${slugify(d.title || "")}.json`;
|
|
2723
|
+
}
|
|
2724
|
+
function activityFilename(a) {
|
|
2725
|
+
const dateStr = (a.created_at || "").slice(0, 10);
|
|
2726
|
+
return `${a.id}...${a.type || "unknown"}-${dateStr}.json`;
|
|
2727
|
+
}
|
|
2728
|
+
async function buildContactJSON(db, c, config) {
|
|
2729
|
+
const emails = safeJSON(c.emails);
|
|
2730
|
+
const phones = safeJSON(c.phones);
|
|
2731
|
+
const companyIds = safeJSON(c.companies);
|
|
2732
|
+
const tags = safeJSON(c.tags);
|
|
2733
|
+
const customFields = safeJSON(c.custom_fields) || {};
|
|
2734
|
+
const allCompanies = await db.select().from(companies);
|
|
2735
|
+
const linkedCompanies = companyIds.map((id) => {
|
|
2736
|
+
const co = allCompanies.find((x) => x.id === id);
|
|
2737
|
+
return co ? { id: co.id, name: co.name } : { id };
|
|
2738
|
+
}).filter(Boolean);
|
|
2739
|
+
const allDeals = await db.select().from(deals);
|
|
2740
|
+
const linkedDeals = allDeals.filter((d) => {
|
|
2741
|
+
const contacts2 = safeJSON(d.contacts);
|
|
2742
|
+
return contacts2.includes(c.id);
|
|
2743
|
+
}).map((d) => ({ id: d.id, title: d.title, stage: d.stage, value: d.value }));
|
|
2744
|
+
const activities2 = await db.select().from(activities);
|
|
2745
|
+
const contactActivities = activities2.filter((a) => {
|
|
2746
|
+
const contacts2 = safeJSON(a.contacts);
|
|
2747
|
+
return contacts2.includes(c.id);
|
|
2748
|
+
}).sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "")).slice(0, config.mount.max_recent_activity).map((a) => ({
|
|
2749
|
+
id: a.id,
|
|
2750
|
+
type: a.type,
|
|
2751
|
+
note: a.body,
|
|
2752
|
+
created_at: a.created_at
|
|
2753
|
+
}));
|
|
2754
|
+
return {
|
|
2755
|
+
id: c.id,
|
|
2756
|
+
name: c.name,
|
|
2757
|
+
emails,
|
|
2758
|
+
phones,
|
|
2759
|
+
companies: linkedCompanies,
|
|
2760
|
+
linkedin: c.linkedin,
|
|
2761
|
+
x: c.x,
|
|
2762
|
+
bluesky: c.bluesky,
|
|
2763
|
+
telegram: c.telegram,
|
|
2764
|
+
tags,
|
|
2765
|
+
custom_fields: Object.keys(customFields).length > 0 ? customFields : undefined,
|
|
2766
|
+
deals: linkedDeals,
|
|
2767
|
+
recent_activity: contactActivities,
|
|
2768
|
+
created_at: c.created_at,
|
|
2769
|
+
updated_at: c.updated_at
|
|
2770
|
+
};
|
|
2771
|
+
}
|
|
2772
|
+
async function buildCompanyJSON(db, co) {
|
|
2773
|
+
const websites = safeJSON(co.websites);
|
|
2774
|
+
const phones = safeJSON(co.phones);
|
|
2775
|
+
const tags = safeJSON(co.tags);
|
|
2776
|
+
const customFields = safeJSON(co.custom_fields) || {};
|
|
2777
|
+
const allContacts = await db.select().from(contacts);
|
|
2778
|
+
const linkedContacts = allContacts.filter((c) => {
|
|
2779
|
+
const companies2 = safeJSON(c.companies);
|
|
2780
|
+
return companies2.includes(co.id);
|
|
2781
|
+
}).map((c) => ({ id: c.id, name: c.name }));
|
|
2782
|
+
const allDeals = await db.select().from(deals);
|
|
2783
|
+
const linkedDeals = allDeals.filter((d) => d.company === co.id).map((d) => ({ id: d.id, title: d.title, stage: d.stage, value: d.value }));
|
|
2784
|
+
return {
|
|
2785
|
+
id: co.id,
|
|
2786
|
+
name: co.name,
|
|
2787
|
+
websites,
|
|
2788
|
+
phones,
|
|
2789
|
+
tags,
|
|
2790
|
+
custom_fields: Object.keys(customFields).length > 0 ? customFields : undefined,
|
|
2791
|
+
contacts: linkedContacts,
|
|
2792
|
+
deals: linkedDeals,
|
|
2793
|
+
created_at: co.created_at,
|
|
2794
|
+
updated_at: co.updated_at
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
async function buildDealJSON(db, d) {
|
|
2798
|
+
const contactIds = safeJSON(d.contacts);
|
|
2799
|
+
const tags = safeJSON(d.tags);
|
|
2800
|
+
const customFields = safeJSON(d.custom_fields) || {};
|
|
2801
|
+
const allContacts = await db.select().from(contacts);
|
|
2802
|
+
const linkedContacts = contactIds.map((id) => {
|
|
2803
|
+
const c = allContacts.find((x) => x.id === id);
|
|
2804
|
+
return c ? { id: c.id, name: c.name } : { id };
|
|
2805
|
+
}).filter(Boolean);
|
|
2806
|
+
let companyObj;
|
|
2807
|
+
if (d.company) {
|
|
2808
|
+
const results = await db.select().from(companies).where(eq6(companies.id, d.company));
|
|
2809
|
+
if (results[0]) {
|
|
2810
|
+
companyObj = { id: results[0].id, name: results[0].name };
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
const activities2 = await db.select().from(activities);
|
|
2814
|
+
const stageChanges = activities2.filter((a) => a.deal === d.id && a.type === "stage-change").sort((a, b) => (a.created_at || "").localeCompare(b.created_at || "")).map((a) => ({ body: a.body, created_at: a.created_at }));
|
|
2815
|
+
const firstChange = stageChanges[0];
|
|
2816
|
+
let initialStage = d.stage;
|
|
2817
|
+
if (firstChange?.body) {
|
|
2818
|
+
const match = firstChange.body.match(/from (\S+) to/);
|
|
2819
|
+
if (match) {
|
|
2820
|
+
initialStage = match[1];
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
const stageHistory = [
|
|
2824
|
+
{ stage: initialStage, at: d.created_at },
|
|
2825
|
+
...stageChanges.map((sc) => {
|
|
2826
|
+
const match = sc.body?.match(/to (\S+)/);
|
|
2827
|
+
return { stage: match ? match[1] : "", at: sc.created_at };
|
|
2828
|
+
})
|
|
2829
|
+
];
|
|
2830
|
+
return {
|
|
2831
|
+
id: d.id,
|
|
2832
|
+
title: d.title,
|
|
2833
|
+
value: d.value,
|
|
2834
|
+
stage: d.stage,
|
|
2835
|
+
contacts: linkedContacts,
|
|
2836
|
+
company: companyObj,
|
|
2837
|
+
expected_close: d.expected_close,
|
|
2838
|
+
probability: d.probability,
|
|
2839
|
+
tags,
|
|
2840
|
+
custom_fields: Object.keys(customFields).length > 0 ? customFields : undefined,
|
|
2841
|
+
stage_history: stageHistory,
|
|
2842
|
+
created_at: d.created_at,
|
|
2843
|
+
updated_at: d.updated_at
|
|
2844
|
+
};
|
|
2845
|
+
}
|
|
2846
|
+
function buildActivityJSON(a) {
|
|
2847
|
+
const customFields = safeJSON(a.custom_fields) || {};
|
|
2848
|
+
return {
|
|
2849
|
+
id: a.id,
|
|
2850
|
+
type: a.type,
|
|
2851
|
+
body: a.body,
|
|
2852
|
+
contacts: safeJSON(a.contacts),
|
|
2853
|
+
company: a.company,
|
|
2854
|
+
deal: a.deal,
|
|
2855
|
+
custom_fields: Object.keys(customFields).length > 0 ? customFields : undefined,
|
|
2856
|
+
created_at: a.created_at
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// src/reports.ts
|
|
2861
|
+
import { eq as eq7, sql as sql3 } from "drizzle-orm";
|
|
2862
|
+
function computePipeline(deals2, stages) {
|
|
2863
|
+
return stages.map((stage) => ({
|
|
2864
|
+
stage,
|
|
2865
|
+
count: deals2.filter((d) => d.stage === stage).length,
|
|
2866
|
+
value: deals2.filter((d) => d.stage === stage).reduce((s, d) => s + (d.value || 0), 0)
|
|
2867
|
+
}));
|
|
2868
|
+
}
|
|
2869
|
+
async function computeStale(db, config, days = 30) {
|
|
2870
|
+
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
2871
|
+
const terminal = new Set([
|
|
2872
|
+
config.pipeline.won_stage,
|
|
2873
|
+
config.pipeline.lost_stage
|
|
2874
|
+
]);
|
|
2875
|
+
const results = [];
|
|
2876
|
+
const contacts2 = await db.select().from(contacts);
|
|
2877
|
+
const allActivities = await db.select().from(activities);
|
|
2878
|
+
for (const c of contacts2) {
|
|
2879
|
+
const contactActivities = allActivities.filter((a) => {
|
|
2880
|
+
const linked = JSON.parse(a.contacts || "[]");
|
|
2881
|
+
return linked.includes(c.id);
|
|
2882
|
+
});
|
|
2883
|
+
const last = contactActivities.reduce((max, a) => max && max > a.created_at ? max : a.created_at, null);
|
|
2884
|
+
if (!last || last < cutoff) {
|
|
2885
|
+
results.push({
|
|
2886
|
+
type: "contact",
|
|
2887
|
+
id: c.id,
|
|
2888
|
+
name: c.name,
|
|
2889
|
+
last_activity: last
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
const deals2 = await db.select().from(deals);
|
|
2894
|
+
for (const d of deals2) {
|
|
2895
|
+
if (terminal.has(d.stage)) {
|
|
2896
|
+
continue;
|
|
2897
|
+
}
|
|
2898
|
+
const dealActivities = allActivities.filter((a) => a.deal === d.id);
|
|
2899
|
+
const last = dealActivities.reduce((max, a) => max && max > a.created_at ? max : a.created_at, null);
|
|
2900
|
+
const lastTouch = last || d.created_at;
|
|
2901
|
+
if (lastTouch < cutoff) {
|
|
2902
|
+
results.push({
|
|
2903
|
+
type: "deal",
|
|
2904
|
+
id: d.id,
|
|
2905
|
+
title: d.title,
|
|
2906
|
+
last_activity: last
|
|
2907
|
+
});
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
return results;
|
|
2911
|
+
}
|
|
2912
|
+
async function computeConversion(db, stages, since) {
|
|
2913
|
+
let activities2 = await db.select().from(activities).where(eq7(activities.type, "stage-change"));
|
|
2914
|
+
if (since) {
|
|
2915
|
+
activities2 = activities2.filter((a) => a.created_at >= since);
|
|
2916
|
+
}
|
|
2917
|
+
const stageEntries = {};
|
|
2918
|
+
const stageExits = {};
|
|
2919
|
+
for (const s of stages) {
|
|
2920
|
+
stageEntries[s] = new Set;
|
|
2921
|
+
stageExits[s] = new Set;
|
|
2922
|
+
}
|
|
2923
|
+
const deals2 = await db.select().from(deals);
|
|
2924
|
+
for (const d of deals2) {
|
|
2925
|
+
const dealActivities = activities2.filter((a) => a.deal === d.id);
|
|
2926
|
+
if (dealActivities.length === 0) {
|
|
2927
|
+
stageEntries[d.stage]?.add(d.id);
|
|
2928
|
+
} else {
|
|
2929
|
+
const first = dealActivities[0];
|
|
2930
|
+
const m = first.body.match(/from (\S+) to/);
|
|
2931
|
+
const initialStage = m ? m[1] : d.stage;
|
|
2932
|
+
if (stageEntries[initialStage]) {
|
|
2933
|
+
stageEntries[initialStage].add(d.id);
|
|
2934
|
+
}
|
|
2935
|
+
for (const a of dealActivities) {
|
|
2936
|
+
const from = a.body.match(/from (\S+) to/)?.[1];
|
|
2937
|
+
const to = a.body.match(/to (\S+)/)?.[1];
|
|
2938
|
+
if (from && stageExits[from]) {
|
|
2939
|
+
stageExits[from].add(d.id);
|
|
2940
|
+
}
|
|
2941
|
+
if (to && stageEntries[to]) {
|
|
2942
|
+
stageEntries[to].add(d.id);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
return stages.map((stage) => ({
|
|
2948
|
+
stage,
|
|
2949
|
+
entered: stageEntries[stage].size,
|
|
2950
|
+
advanced: stageExits[stage].size,
|
|
2951
|
+
rate: stageEntries[stage].size > 0 ? `${Math.round(stageExits[stage].size / stageEntries[stage].size * 100)}%` : "0%"
|
|
2952
|
+
}));
|
|
2953
|
+
}
|
|
2954
|
+
function formatDuration(ms) {
|
|
2955
|
+
if (ms === 0) {
|
|
2956
|
+
return "0s";
|
|
2957
|
+
}
|
|
2958
|
+
const secs = Math.floor(ms / 1000);
|
|
2959
|
+
const mins = Math.floor(secs / 60);
|
|
2960
|
+
const hours = Math.floor(mins / 60);
|
|
2961
|
+
const days = Math.floor(hours / 24);
|
|
2962
|
+
if (days > 0) {
|
|
2963
|
+
return `${days}d ${hours % 24}h`;
|
|
2964
|
+
}
|
|
2965
|
+
if (hours > 0) {
|
|
2966
|
+
return `${hours}h ${mins % 60}m`;
|
|
2967
|
+
}
|
|
2968
|
+
if (mins > 0) {
|
|
2969
|
+
return `${mins}m ${secs % 60}s`;
|
|
2970
|
+
}
|
|
2971
|
+
return `${secs}s`;
|
|
2972
|
+
}
|
|
2973
|
+
async function computeVelocity(db, stages, wonStage) {
|
|
2974
|
+
const activities2 = await db.select().from(activities).where(eq7(activities.type, "stage-change")).orderBy(activities.created_at);
|
|
2975
|
+
const stageTimes = {};
|
|
2976
|
+
for (const s of stages) {
|
|
2977
|
+
stageTimes[s] = [];
|
|
2978
|
+
}
|
|
2979
|
+
const dealIds = [...new Set(activities2.map((a) => a.deal).filter(Boolean))];
|
|
2980
|
+
for (const did of dealIds) {
|
|
2981
|
+
if (!did) {
|
|
2982
|
+
continue;
|
|
2983
|
+
}
|
|
2984
|
+
const dealActs = activities2.filter((a) => a.deal === did);
|
|
2985
|
+
const dealResults = await db.select().from(deals).where(eq7(deals.id, did));
|
|
2986
|
+
const deal = dealResults[0];
|
|
2987
|
+
if (!deal) {
|
|
2988
|
+
continue;
|
|
2989
|
+
}
|
|
2990
|
+
if (wonStage && deal.stage !== wonStage) {
|
|
2991
|
+
continue;
|
|
2992
|
+
}
|
|
2993
|
+
let prevTime = new Date(deal.created_at).getTime();
|
|
2994
|
+
const first = dealActs[0];
|
|
2995
|
+
const initialStage = first.body.match(/from (\S+) to/)?.[1];
|
|
2996
|
+
if (initialStage && stageTimes[initialStage]) {
|
|
2997
|
+
const duration = new Date(first.created_at).getTime() - prevTime;
|
|
2998
|
+
stageTimes[initialStage].push(duration);
|
|
2999
|
+
prevTime = new Date(first.created_at).getTime();
|
|
3000
|
+
}
|
|
3001
|
+
for (let i = 1;i < dealActs.length; i++) {
|
|
3002
|
+
const from = dealActs[i].body.match(/from (\S+) to/)?.[1];
|
|
3003
|
+
if (from && stageTimes[from]) {
|
|
3004
|
+
const duration = new Date(dealActs[i].created_at).getTime() - new Date(dealActs[i - 1].created_at).getTime();
|
|
3005
|
+
stageTimes[from].push(duration);
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
return stages.map((stage) => {
|
|
3010
|
+
const times = stageTimes[stage];
|
|
3011
|
+
const avg = times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0;
|
|
3012
|
+
return {
|
|
3013
|
+
stage,
|
|
3014
|
+
avg_ms: Math.round(avg),
|
|
3015
|
+
deals: times.length,
|
|
3016
|
+
avg_display: formatDuration(avg)
|
|
3017
|
+
};
|
|
3018
|
+
});
|
|
3019
|
+
}
|
|
3020
|
+
async function computeForecast(db, config) {
|
|
3021
|
+
const terminal = new Set([
|
|
3022
|
+
config.pipeline.won_stage,
|
|
3023
|
+
config.pipeline.lost_stage
|
|
3024
|
+
]);
|
|
3025
|
+
const deals2 = await db.select().from(deals);
|
|
3026
|
+
const open = deals2.filter((d) => !terminal.has(d.stage));
|
|
3027
|
+
return open.map((d) => ({
|
|
3028
|
+
id: d.id,
|
|
3029
|
+
title: d.title,
|
|
3030
|
+
value: d.value || 0,
|
|
3031
|
+
probability: d.probability ?? 100,
|
|
3032
|
+
weighted: Math.round((d.value || 0) * (d.probability ?? 100) / 100),
|
|
3033
|
+
expected_close: d.expected_close,
|
|
3034
|
+
stage: d.stage
|
|
3035
|
+
}));
|
|
3036
|
+
}
|
|
3037
|
+
async function computeWon(db, config) {
|
|
3038
|
+
const wonStage = config.pipeline.won_stage;
|
|
3039
|
+
const deals2 = await db.select().from(deals).where(eq7(deals.stage, wonStage));
|
|
3040
|
+
return Promise.all(deals2.map(async (d) => {
|
|
3041
|
+
const row = dealToRow(d);
|
|
3042
|
+
row.notes = await extractStageNotes(db, d.id, wonStage);
|
|
3043
|
+
return row;
|
|
3044
|
+
}));
|
|
3045
|
+
}
|
|
3046
|
+
async function computeLost(db, config) {
|
|
3047
|
+
const lostStage = config.pipeline.lost_stage;
|
|
3048
|
+
const deals2 = await db.select().from(deals).where(eq7(deals.stage, lostStage));
|
|
3049
|
+
return Promise.all(deals2.map(async (d) => {
|
|
3050
|
+
const row = dealToRow(d);
|
|
3051
|
+
row.notes = await extractStageNotes(db, d.id, lostStage);
|
|
3052
|
+
return row;
|
|
3053
|
+
}));
|
|
3054
|
+
}
|
|
3055
|
+
async function extractStageNotes(db, dealId, stage) {
|
|
3056
|
+
const actResults = await db.all(sql3`SELECT body FROM activities WHERE deal = ${dealId} AND type = 'stage-change' AND body LIKE ${`%${stage}%`}`);
|
|
3057
|
+
const act = actResults[0];
|
|
3058
|
+
return act?.body?.split("|").slice(1).map((s) => s.trim()).join(", ") || "";
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
// src/export-fs.ts
|
|
3062
|
+
function writeJSON(filePath, data) {
|
|
3063
|
+
writeFileSync2(filePath, JSON.stringify(data, null, 2));
|
|
3064
|
+
}
|
|
3065
|
+
function ensureDir(dir) {
|
|
3066
|
+
if (!existsSync2(dir)) {
|
|
3067
|
+
mkdirSync3(dir, { recursive: true });
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
async function generateFS(db, config, outDir) {
|
|
3071
|
+
ensureDir(outDir);
|
|
3072
|
+
ensureDir(join2(outDir, "contacts"));
|
|
3073
|
+
ensureDir(join2(outDir, "contacts", "_by-email"));
|
|
3074
|
+
ensureDir(join2(outDir, "contacts", "_by-phone"));
|
|
3075
|
+
ensureDir(join2(outDir, "contacts", "_by-linkedin"));
|
|
3076
|
+
ensureDir(join2(outDir, "contacts", "_by-x"));
|
|
3077
|
+
ensureDir(join2(outDir, "contacts", "_by-bluesky"));
|
|
3078
|
+
ensureDir(join2(outDir, "contacts", "_by-telegram"));
|
|
3079
|
+
ensureDir(join2(outDir, "contacts", "_by-company"));
|
|
3080
|
+
ensureDir(join2(outDir, "contacts", "_by-tag"));
|
|
3081
|
+
ensureDir(join2(outDir, "companies"));
|
|
3082
|
+
ensureDir(join2(outDir, "companies", "_by-website"));
|
|
3083
|
+
ensureDir(join2(outDir, "companies", "_by-phone"));
|
|
3084
|
+
ensureDir(join2(outDir, "companies", "_by-tag"));
|
|
3085
|
+
ensureDir(join2(outDir, "deals"));
|
|
3086
|
+
ensureDir(join2(outDir, "deals", "_by-stage"));
|
|
3087
|
+
for (const stage of config.pipeline.stages) {
|
|
3088
|
+
ensureDir(join2(outDir, "deals", "_by-stage", stage));
|
|
3089
|
+
}
|
|
3090
|
+
ensureDir(join2(outDir, "deals", "_by-company"));
|
|
3091
|
+
ensureDir(join2(outDir, "deals", "_by-tag"));
|
|
3092
|
+
ensureDir(join2(outDir, "activities"));
|
|
3093
|
+
ensureDir(join2(outDir, "activities", "_by-contact"));
|
|
3094
|
+
ensureDir(join2(outDir, "activities", "_by-company"));
|
|
3095
|
+
ensureDir(join2(outDir, "activities", "_by-deal"));
|
|
3096
|
+
ensureDir(join2(outDir, "activities", "_by-type"));
|
|
3097
|
+
ensureDir(join2(outDir, "reports"));
|
|
3098
|
+
ensureDir(join2(outDir, "search"));
|
|
3099
|
+
writeFileSync2(join2(outDir, "llm.txt"), LLM_TXT);
|
|
3100
|
+
const companies2 = await db.select().from(companies);
|
|
3101
|
+
const contacts2 = await db.select().from(contacts);
|
|
3102
|
+
for (const c of contacts2) {
|
|
3103
|
+
const data = await buildContactJSON(db, c, config);
|
|
3104
|
+
const filename = contactFilename(c);
|
|
3105
|
+
const filePath = join2(outDir, "contacts", filename);
|
|
3106
|
+
writeJSON(filePath, data);
|
|
3107
|
+
const emails = safeJSON(c.emails);
|
|
3108
|
+
for (const email of emails) {
|
|
3109
|
+
copyFileSync(filePath, join2(outDir, "contacts", "_by-email", `${email}.json`));
|
|
3110
|
+
}
|
|
3111
|
+
const phones = safeJSON(c.phones);
|
|
3112
|
+
for (const phone of phones) {
|
|
3113
|
+
copyFileSync(filePath, join2(outDir, "contacts", "_by-phone", `${phone}.json`));
|
|
3114
|
+
}
|
|
3115
|
+
if (c.linkedin) {
|
|
3116
|
+
copyFileSync(filePath, join2(outDir, "contacts", "_by-linkedin", `${c.linkedin}.json`));
|
|
3117
|
+
}
|
|
3118
|
+
if (c.x) {
|
|
3119
|
+
copyFileSync(filePath, join2(outDir, "contacts", "_by-x", `${c.x}.json`));
|
|
3120
|
+
}
|
|
3121
|
+
if (c.bluesky) {
|
|
3122
|
+
copyFileSync(filePath, join2(outDir, "contacts", "_by-bluesky", `${c.bluesky}.json`));
|
|
3123
|
+
}
|
|
3124
|
+
if (c.telegram) {
|
|
3125
|
+
copyFileSync(filePath, join2(outDir, "contacts", "_by-telegram", `${c.telegram}.json`));
|
|
3126
|
+
}
|
|
3127
|
+
const companyIds = safeJSON(c.companies);
|
|
3128
|
+
for (const compId of companyIds) {
|
|
3129
|
+
const compRecord = companies2.find((co) => co.id === compId);
|
|
3130
|
+
const compSlug = slugify(compRecord?.name || compId);
|
|
3131
|
+
ensureDir(join2(outDir, "contacts", "_by-company", compSlug));
|
|
3132
|
+
copyFileSync(filePath, join2(outDir, "contacts", "_by-company", compSlug, filename));
|
|
3133
|
+
}
|
|
3134
|
+
const tags = safeJSON(c.tags);
|
|
3135
|
+
for (const tag of tags) {
|
|
3136
|
+
ensureDir(join2(outDir, "contacts", "_by-tag", tag));
|
|
3137
|
+
copyFileSync(filePath, join2(outDir, "contacts", "_by-tag", tag, filename));
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
for (const co of companies2) {
|
|
3141
|
+
const data = await buildCompanyJSON(db, co);
|
|
3142
|
+
const filename = companyFilename(co);
|
|
3143
|
+
const filePath = join2(outDir, "companies", filename);
|
|
3144
|
+
writeJSON(filePath, data);
|
|
3145
|
+
const websites = safeJSON(co.websites);
|
|
3146
|
+
for (const website of websites) {
|
|
3147
|
+
copyFileSync(filePath, join2(outDir, "companies", "_by-website", `${website}.json`));
|
|
3148
|
+
}
|
|
3149
|
+
const phones = safeJSON(co.phones);
|
|
3150
|
+
for (const phone of phones) {
|
|
3151
|
+
copyFileSync(filePath, join2(outDir, "companies", "_by-phone", `${phone}.json`));
|
|
3152
|
+
}
|
|
3153
|
+
const tags = safeJSON(co.tags);
|
|
3154
|
+
for (const tag of tags) {
|
|
3155
|
+
ensureDir(join2(outDir, "companies", "_by-tag", tag));
|
|
3156
|
+
copyFileSync(filePath, join2(outDir, "companies", "_by-tag", tag, filename));
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
const deals2 = await db.select().from(deals);
|
|
3160
|
+
for (const d of deals2) {
|
|
3161
|
+
const data = await buildDealJSON(db, d);
|
|
3162
|
+
const filename = dealFilename(d);
|
|
3163
|
+
const filePath = join2(outDir, "deals", filename);
|
|
3164
|
+
writeJSON(filePath, data);
|
|
3165
|
+
if (d.stage) {
|
|
3166
|
+
const stageDir = join2(outDir, "deals", "_by-stage", d.stage);
|
|
3167
|
+
ensureDir(stageDir);
|
|
3168
|
+
copyFileSync(filePath, join2(stageDir, filename));
|
|
3169
|
+
}
|
|
3170
|
+
if (d.company) {
|
|
3171
|
+
const companyResults = await db.select().from(companies).where(eq8(companies.id, d.company));
|
|
3172
|
+
if (companyResults[0]) {
|
|
3173
|
+
const compSlug = slugify(companyResults[0].name || "");
|
|
3174
|
+
ensureDir(join2(outDir, "deals", "_by-company", compSlug));
|
|
3175
|
+
copyFileSync(filePath, join2(outDir, "deals", "_by-company", compSlug, filename));
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
const tags = safeJSON(d.tags);
|
|
3179
|
+
for (const tag of tags) {
|
|
3180
|
+
ensureDir(join2(outDir, "deals", "_by-tag", tag));
|
|
3181
|
+
copyFileSync(filePath, join2(outDir, "deals", "_by-tag", tag, filename));
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
const activities2 = await db.select().from(activities);
|
|
3185
|
+
for (const a of activities2) {
|
|
3186
|
+
const data = buildActivityJSON(a);
|
|
3187
|
+
const filename = activityFilename(a);
|
|
3188
|
+
const filePath = join2(outDir, "activities", filename);
|
|
3189
|
+
writeJSON(filePath, data);
|
|
3190
|
+
const actContacts = safeJSON(a.contacts);
|
|
3191
|
+
for (const contactId of actContacts) {
|
|
3192
|
+
const contactResults = await db.select().from(contacts).where(eq8(contacts.id, contactId));
|
|
3193
|
+
if (contactResults[0]) {
|
|
3194
|
+
const contactSlug = `${contactId}...${slugify(contactResults[0].name || "")}`;
|
|
3195
|
+
ensureDir(join2(outDir, "activities", "_by-contact", contactSlug));
|
|
3196
|
+
copyFileSync(filePath, join2(outDir, "activities", "_by-contact", contactSlug, filename));
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
if (a.company) {
|
|
3200
|
+
const companyResults = await db.select().from(companies).where(eq8(companies.id, a.company));
|
|
3201
|
+
if (companyResults[0]) {
|
|
3202
|
+
const compSlug = slugify(companyResults[0].name || "");
|
|
3203
|
+
ensureDir(join2(outDir, "activities", "_by-company", compSlug));
|
|
3204
|
+
copyFileSync(filePath, join2(outDir, "activities", "_by-company", compSlug, filename));
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
if (a.deal) {
|
|
3208
|
+
ensureDir(join2(outDir, "activities", "_by-deal", a.deal));
|
|
3209
|
+
copyFileSync(filePath, join2(outDir, "activities", "_by-deal", a.deal, filename));
|
|
3210
|
+
}
|
|
3211
|
+
if (a.type) {
|
|
3212
|
+
ensureDir(join2(outDir, "activities", "_by-type", a.type));
|
|
3213
|
+
copyFileSync(filePath, join2(outDir, "activities", "_by-type", a.type, filename));
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
const pipelineData = config.pipeline.stages.map((stage) => ({
|
|
3217
|
+
stage,
|
|
3218
|
+
count: deals2.filter((d) => d.stage === stage).length,
|
|
3219
|
+
value: deals2.filter((d) => d.stage === stage).reduce((s, d) => s + (d.value || 0), 0)
|
|
3220
|
+
}));
|
|
3221
|
+
writeJSON(join2(outDir, "pipeline.json"), pipelineData);
|
|
3222
|
+
const tagCounts = {};
|
|
3223
|
+
for (const c of contacts2) {
|
|
3224
|
+
for (const t of safeJSON(c.tags)) {
|
|
3225
|
+
tagCounts[t] = (tagCounts[t] || 0) + 1;
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
for (const co of companies2) {
|
|
3229
|
+
for (const t of safeJSON(co.tags)) {
|
|
3230
|
+
tagCounts[t] = (tagCounts[t] || 0) + 1;
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
for (const d of deals2) {
|
|
3234
|
+
for (const t of safeJSON(d.tags)) {
|
|
3235
|
+
tagCounts[t] = (tagCounts[t] || 0) + 1;
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
const tagsData = Object.entries(tagCounts).map(([tag, count]) => ({
|
|
3239
|
+
tag,
|
|
3240
|
+
count
|
|
3241
|
+
}));
|
|
3242
|
+
writeJSON(join2(outDir, "tags.json"), tagsData);
|
|
3243
|
+
writeJSON(join2(outDir, "reports", "pipeline.json"), pipelineData);
|
|
3244
|
+
writeJSON(join2(outDir, "reports", "stale.json"), await computeStale(db, config));
|
|
3245
|
+
writeJSON(join2(outDir, "reports", "forecast.json"), await computeForecast(db, config));
|
|
3246
|
+
writeJSON(join2(outDir, "reports", "conversion.json"), await computeConversion(db, config.pipeline.stages));
|
|
3247
|
+
writeJSON(join2(outDir, "reports", "velocity.json"), await computeVelocity(db, config.pipeline.stages));
|
|
3248
|
+
writeJSON(join2(outDir, "reports", "won.json"), await computeWon(db, config));
|
|
3249
|
+
writeJSON(join2(outDir, "reports", "lost.json"), await computeLost(db, config));
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
// src/commands/fuse.ts
|
|
3253
|
+
function ensureDir2(dir) {
|
|
3254
|
+
if (!existsSync3(dir)) {
|
|
3255
|
+
mkdirSync4(dir, { recursive: true });
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
async function mountDarwin(mp, config, _opts) {
|
|
3259
|
+
const nfsHelperPath = join3(homedir2(), ".crm", "bin", "crm-nfs");
|
|
3260
|
+
if (!existsSync3(nfsHelperPath)) {
|
|
3261
|
+
const nfsSrcDir = join3(import.meta.dir, "..", "nfs-server");
|
|
3262
|
+
if (!existsSync3(join3(nfsSrcDir, "Cargo.toml"))) {
|
|
3263
|
+
die(`Error: NFS server source not found at ${nfsSrcDir}`);
|
|
3264
|
+
}
|
|
3265
|
+
ensureDir2(join3(homedir2(), ".crm", "bin"));
|
|
3266
|
+
const cargoPath = spawnSync2("which", ["cargo"], { stdio: ["pipe", "pipe", "pipe"] }).stdout?.toString().trim() || join3(homedir2(), ".cargo", "bin", "cargo");
|
|
3267
|
+
if (!existsSync3(cargoPath)) {
|
|
3268
|
+
die("Error: Rust not found. Install with: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh");
|
|
3269
|
+
}
|
|
3270
|
+
console.log("Compiling NFS server (first time only)...");
|
|
3271
|
+
const compile = spawnSync2(cargoPath, ["build", "--release", "--manifest-path", join3(nfsSrcDir, "Cargo.toml")], { stdio: ["pipe", "pipe", "pipe"] });
|
|
3272
|
+
if (compile.status !== 0) {
|
|
3273
|
+
die(`Error: Failed to compile NFS server.
|
|
3274
|
+
${compile.stderr?.toString() || ""}`);
|
|
3275
|
+
}
|
|
3276
|
+
const built = join3(nfsSrcDir, "target", "release", "crm-nfs");
|
|
3277
|
+
spawnSync2("sh", [
|
|
3278
|
+
"-c",
|
|
3279
|
+
`cat "${built}" > "${nfsHelperPath}" && chmod +x "${nfsHelperPath}"`
|
|
3280
|
+
]);
|
|
3281
|
+
}
|
|
3282
|
+
const socketPath = join3(tmpdir(), `crm-fuse-${slugify(mp)}.sock`);
|
|
3283
|
+
const daemonPath = join3(import.meta.dir, "..", "fuse-daemon.ts");
|
|
3284
|
+
const daemonProc = spawn("bun", [
|
|
3285
|
+
"run",
|
|
3286
|
+
daemonPath,
|
|
3287
|
+
socketPath,
|
|
3288
|
+
config.database.path,
|
|
3289
|
+
...config.pipeline.stages
|
|
3290
|
+
], { stdio: "ignore", detached: true });
|
|
3291
|
+
daemonProc.unref();
|
|
3292
|
+
const deadline = Date.now() + 5000;
|
|
3293
|
+
while (Date.now() < deadline) {
|
|
3294
|
+
if (existsSync3(socketPath)) {
|
|
3295
|
+
break;
|
|
3296
|
+
}
|
|
3297
|
+
await new Promise((resolve2) => setTimeout(resolve2, 50));
|
|
3298
|
+
}
|
|
3299
|
+
if (!existsSync3(socketPath)) {
|
|
3300
|
+
daemonProc.kill();
|
|
3301
|
+
die("Error: FUSE daemon failed to start.");
|
|
3302
|
+
}
|
|
3303
|
+
const nfsProc = spawn(nfsHelperPath, [socketPath, "0"], {
|
|
3304
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3305
|
+
detached: true
|
|
3306
|
+
});
|
|
3307
|
+
const port = await new Promise((resolve2, reject) => {
|
|
3308
|
+
let buf = "";
|
|
3309
|
+
const timeout = setTimeout(() => reject(new Error("NFS server did not report port")), 5000);
|
|
3310
|
+
nfsProc.stdout?.on("data", (chunk) => {
|
|
3311
|
+
buf += chunk.toString();
|
|
3312
|
+
const nl = buf.indexOf(`
|
|
3313
|
+
`);
|
|
3314
|
+
if (nl !== -1) {
|
|
3315
|
+
clearTimeout(timeout);
|
|
3316
|
+
resolve2(Number(buf.slice(0, nl).trim()));
|
|
3317
|
+
}
|
|
3318
|
+
});
|
|
3319
|
+
nfsProc.on("exit", (code) => {
|
|
3320
|
+
clearTimeout(timeout);
|
|
3321
|
+
reject(new Error(`NFS server exited with code ${code}`));
|
|
3322
|
+
});
|
|
3323
|
+
}).catch((err) => {
|
|
3324
|
+
daemonProc.kill();
|
|
3325
|
+
try {
|
|
3326
|
+
nfsProc.kill();
|
|
3327
|
+
} catch {}
|
|
3328
|
+
die(`Error: ${err.message}`);
|
|
3329
|
+
return 0;
|
|
3330
|
+
});
|
|
3331
|
+
nfsProc.stdout?.destroy();
|
|
3332
|
+
nfsProc.stderr?.destroy();
|
|
3333
|
+
nfsProc.stdin?.destroy();
|
|
3334
|
+
nfsProc.unref();
|
|
3335
|
+
const connDeadline = Date.now() + 3000;
|
|
3336
|
+
while (Date.now() < connDeadline) {
|
|
3337
|
+
try {
|
|
3338
|
+
const net = await import("node:net");
|
|
3339
|
+
const ok = await new Promise((resolve2) => {
|
|
3340
|
+
const sock = net.createConnection({ host: "127.0.0.1", port }, () => {
|
|
3341
|
+
sock.destroy();
|
|
3342
|
+
resolve2(true);
|
|
3343
|
+
});
|
|
3344
|
+
sock.on("error", () => resolve2(false));
|
|
3345
|
+
});
|
|
3346
|
+
if (ok) {
|
|
3347
|
+
break;
|
|
3348
|
+
}
|
|
3349
|
+
} catch {}
|
|
3350
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
3351
|
+
}
|
|
3352
|
+
const mountResult = spawnSync2("/sbin/mount_nfs", [
|
|
3353
|
+
"-o",
|
|
3354
|
+
`locallocks,vers=3,tcp,port=${port},mountport=${port},soft,intr,timeo=10,retrans=3,noac`,
|
|
3355
|
+
"127.0.0.1:/",
|
|
3356
|
+
mp
|
|
3357
|
+
], { stdio: ["pipe", "pipe", "pipe"] });
|
|
3358
|
+
if (mountResult.status !== 0) {
|
|
3359
|
+
daemonProc.kill();
|
|
3360
|
+
try {
|
|
3361
|
+
nfsProc.kill();
|
|
3362
|
+
} catch {}
|
|
3363
|
+
die(`Error: NFS mount failed: ${mountResult.stderr?.toString() || "unknown error"}`);
|
|
3364
|
+
}
|
|
3365
|
+
const pidFile = join3(tmpdir(), `crm-mount-${slugify(mp)}.pid`);
|
|
3366
|
+
writeFileSync3(pidFile, `${nfsProc.pid}
|
|
3367
|
+
${daemonProc.pid}`);
|
|
3368
|
+
console.log(`Mounted at ${mp} (NFS port ${port}, PID ${nfsProc.pid})`);
|
|
3369
|
+
}
|
|
3370
|
+
async function mountLinux(mp, config, opts) {
|
|
3371
|
+
const helperPath = join3(homedir2(), ".crm", "bin", "crm-fuse");
|
|
3372
|
+
if (!existsSync3(helperPath)) {
|
|
3373
|
+
const srcPath = join3(import.meta.dir, "..", "fuse-helper.c");
|
|
3374
|
+
if (!existsSync3(srcPath)) {
|
|
3375
|
+
die("Error: FUSE helper not found. Install FUSE dependencies and rebuild, or use `crm export-fs` instead.");
|
|
3376
|
+
}
|
|
3377
|
+
ensureDir2(join3(homedir2(), ".crm", "bin"));
|
|
3378
|
+
const fuseFlags = (() => {
|
|
3379
|
+
const pc3 = spawnSync2("pkg-config", ["--cflags", "--libs", "fuse3"]);
|
|
3380
|
+
if (pc3.status === 0 && pc3.stdout) {
|
|
3381
|
+
return pc3.stdout.toString().trim().split(/\s+/);
|
|
3382
|
+
}
|
|
3383
|
+
return ["-lfuse3", "-lpthread"];
|
|
3384
|
+
})();
|
|
3385
|
+
const compile = spawnSync2("gcc", ["-o", helperPath, srcPath, ...fuseFlags], { stdio: ["pipe", "pipe", "pipe"] });
|
|
3386
|
+
if (compile.status !== 0) {
|
|
3387
|
+
die(`Error: Failed to compile FUSE helper. Install libfuse3-dev (apt) or fuse3-devel (yum).
|
|
3388
|
+
${compile.stderr?.toString() || ""}`);
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
const socketPath = join3(tmpdir(), `crm-fuse-${slugify(mp)}.sock`);
|
|
3392
|
+
const daemonPath = join3(import.meta.dir, "..", "fuse-daemon.ts");
|
|
3393
|
+
const daemonProc = spawn("bun", [
|
|
3394
|
+
"run",
|
|
3395
|
+
daemonPath,
|
|
3396
|
+
socketPath,
|
|
3397
|
+
config.database.path,
|
|
3398
|
+
...config.pipeline.stages
|
|
3399
|
+
], { stdio: "ignore", detached: true });
|
|
3400
|
+
daemonProc.unref();
|
|
3401
|
+
const deadline = Date.now() + 5000;
|
|
3402
|
+
while (Date.now() < deadline) {
|
|
3403
|
+
if (existsSync3(socketPath)) {
|
|
3404
|
+
break;
|
|
3405
|
+
}
|
|
3406
|
+
await new Promise((resolve2) => setTimeout(resolve2, 50));
|
|
3407
|
+
}
|
|
3408
|
+
if (!existsSync3(socketPath)) {
|
|
3409
|
+
daemonProc.kill();
|
|
3410
|
+
die("Error: FUSE daemon failed to start.");
|
|
3411
|
+
}
|
|
3412
|
+
const fuseArgs = ["-f", mp, "--", socketPath];
|
|
3413
|
+
if (opts.readonly || config.mount.readonly) {
|
|
3414
|
+
fuseArgs.unshift("-o", "ro");
|
|
3415
|
+
}
|
|
3416
|
+
const fuseProc = spawn(helperPath, fuseArgs, {
|
|
3417
|
+
stdio: "ignore",
|
|
3418
|
+
detached: true
|
|
3419
|
+
});
|
|
3420
|
+
await new Promise((resolve2) => setTimeout(resolve2, 500));
|
|
3421
|
+
if (fuseProc.exitCode !== null) {
|
|
3422
|
+
daemonProc.kill();
|
|
3423
|
+
die("Error: FUSE mount failed. Is FUSE available?");
|
|
3424
|
+
}
|
|
3425
|
+
fuseProc.unref();
|
|
3426
|
+
const pidFile = join3(tmpdir(), `crm-mount-${slugify(mp)}.pid`);
|
|
3427
|
+
writeFileSync3(pidFile, `${fuseProc.pid}
|
|
3428
|
+
${daemonProc.pid}`);
|
|
3429
|
+
console.log(`Mounted at ${mp} (PID ${fuseProc.pid})`);
|
|
3430
|
+
}
|
|
3431
|
+
async function unmountDarwin(mp) {
|
|
3432
|
+
const pidFile = join3(tmpdir(), `crm-mount-${slugify(mp)}.pid`);
|
|
3433
|
+
if (existsSync3(pidFile)) {
|
|
3434
|
+
const lines = readFileSync2(pidFile, "utf-8").trim().split(`
|
|
3435
|
+
`);
|
|
3436
|
+
const serverPid = Number(lines[0]);
|
|
3437
|
+
const daemonPid = lines[1] ? Number(lines[1]) : null;
|
|
3438
|
+
try {
|
|
3439
|
+
process.kill(serverPid, "SIGTERM");
|
|
3440
|
+
} catch {}
|
|
3441
|
+
const deadline = Date.now() + 2000;
|
|
3442
|
+
while (Date.now() < deadline) {
|
|
3443
|
+
try {
|
|
3444
|
+
process.kill(serverPid, 0);
|
|
3445
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
3446
|
+
} catch {
|
|
3447
|
+
break;
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
try {
|
|
3451
|
+
process.kill(serverPid, "SIGKILL");
|
|
3452
|
+
} catch {}
|
|
3453
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
3454
|
+
if (daemonPid) {
|
|
3455
|
+
try {
|
|
3456
|
+
process.kill(daemonPid);
|
|
3457
|
+
} catch {}
|
|
3458
|
+
}
|
|
3459
|
+
unlinkSync(pidFile);
|
|
3460
|
+
}
|
|
3461
|
+
spawnSync2("umount", [mp], { stdio: ["pipe", "pipe", "pipe"] });
|
|
3462
|
+
}
|
|
3463
|
+
function unmountLinux(mp) {
|
|
3464
|
+
const result = spawnSync2("fusermount", ["-u", mp], {
|
|
3465
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3466
|
+
});
|
|
3467
|
+
if (result.status !== 0) {
|
|
3468
|
+
spawnSync2("umount", [mp], { stdio: ["pipe", "pipe", "pipe"] });
|
|
3469
|
+
}
|
|
3470
|
+
const pidFile = join3(tmpdir(), `crm-mount-${slugify(mp)}.pid`);
|
|
3471
|
+
if (existsSync3(pidFile)) {
|
|
3472
|
+
const pids = readFileSync2(pidFile, "utf-8").trim().split(`
|
|
3473
|
+
`);
|
|
3474
|
+
for (const pid of pids) {
|
|
3475
|
+
try {
|
|
3476
|
+
process.kill(Number(pid));
|
|
3477
|
+
} catch {}
|
|
3478
|
+
}
|
|
3479
|
+
unlinkSync(pidFile);
|
|
3480
|
+
}
|
|
3481
|
+
const socketPath = join3(tmpdir(), `crm-fuse-${slugify(mp)}.sock`);
|
|
3482
|
+
try {
|
|
3483
|
+
unlinkSync(socketPath);
|
|
3484
|
+
} catch {}
|
|
3485
|
+
}
|
|
3486
|
+
function registerFuseCommands(program) {
|
|
3487
|
+
program.command("mount").description("Mount CRM as virtual filesystem").argument("[mountpoint]", "Mount point directory").option("--readonly", "Mount read-only").action(async (mountpoint, opts) => {
|
|
3488
|
+
const { config } = await getCtx();
|
|
3489
|
+
const mp = mountpoint || config.mount.default_path;
|
|
3490
|
+
if (!existsSync3(mp)) {
|
|
3491
|
+
mkdirSync4(mp, { recursive: true });
|
|
3492
|
+
}
|
|
3493
|
+
if (process.platform === "darwin") {
|
|
3494
|
+
await mountDarwin(mp, config, opts);
|
|
3495
|
+
} else {
|
|
3496
|
+
await mountLinux(mp, config, opts);
|
|
3497
|
+
}
|
|
3498
|
+
});
|
|
3499
|
+
program.command("unmount").description("Unmount CRM filesystem").argument("[mountpoint]", "Mount point").action(async (mountpoint) => {
|
|
3500
|
+
const { config } = await getCtx();
|
|
3501
|
+
const mp = mountpoint || config.mount.default_path;
|
|
3502
|
+
if (process.platform === "darwin") {
|
|
3503
|
+
await unmountDarwin(mp);
|
|
3504
|
+
} else {
|
|
3505
|
+
await unmountLinux(mp);
|
|
3506
|
+
}
|
|
3507
|
+
console.log(`Unmounted ${mp}`);
|
|
3508
|
+
});
|
|
3509
|
+
program.command("export-fs").description("Export CRM data as static filesystem tree").argument("<dir>", "Output directory").action(async (dir) => {
|
|
3510
|
+
const { db, config } = await getCtx();
|
|
3511
|
+
await generateFS(db, config, dir);
|
|
3512
|
+
console.log(`Exported to ${dir}`);
|
|
3513
|
+
});
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
// src/commands/importexport.ts
|
|
3517
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
3518
|
+
import { eq as eq9 } from "drizzle-orm";
|
|
3519
|
+
var CONTACT_FIELDS = new Set([
|
|
3520
|
+
"name",
|
|
3521
|
+
"email",
|
|
3522
|
+
"emails",
|
|
3523
|
+
"phone",
|
|
3524
|
+
"phones",
|
|
3525
|
+
"company",
|
|
3526
|
+
"companies",
|
|
3527
|
+
"tags",
|
|
3528
|
+
"linkedin",
|
|
3529
|
+
"x",
|
|
3530
|
+
"bluesky",
|
|
3531
|
+
"telegram"
|
|
3532
|
+
]);
|
|
3533
|
+
var COMPANY_FIELDS = new Set([
|
|
3534
|
+
"name",
|
|
3535
|
+
"website",
|
|
3536
|
+
"websites",
|
|
3537
|
+
"phone",
|
|
3538
|
+
"phones",
|
|
3539
|
+
"tags"
|
|
3540
|
+
]);
|
|
3541
|
+
var DEAL_FIELDS = new Set([
|
|
3542
|
+
"title",
|
|
3543
|
+
"value",
|
|
3544
|
+
"stage",
|
|
3545
|
+
"contacts",
|
|
3546
|
+
"company",
|
|
3547
|
+
"expected_close",
|
|
3548
|
+
"probability",
|
|
3549
|
+
"tags"
|
|
3550
|
+
]);
|
|
3551
|
+
function registerImportExportCommands(program) {
|
|
3552
|
+
const imp = program.command("import").description("Import data");
|
|
3553
|
+
imp.command("contacts").argument("<file>").option("--dry-run").option("--skip-errors").option("--update").action(async (file, opts) => {
|
|
3554
|
+
const { db, config } = await getCtx();
|
|
3555
|
+
const records = readRecords(file);
|
|
3556
|
+
let imported = 0, skipped = 0, errors = 0;
|
|
3557
|
+
for (const rec of records) {
|
|
3558
|
+
try {
|
|
3559
|
+
if (!rec.name) {
|
|
3560
|
+
if (opts.skipErrors) {
|
|
3561
|
+
errors++;
|
|
3562
|
+
continue;
|
|
3563
|
+
}
|
|
3564
|
+
die("Error: row missing name");
|
|
3565
|
+
}
|
|
3566
|
+
const name = (rec.name || "").trim();
|
|
3567
|
+
const emails = splitField(rec.email || rec.emails).map((e) => e.trim()).filter((e) => e.includes("@") && !e.startsWith("@") && !e.endsWith("@"));
|
|
3568
|
+
const phones = splitField(rec.phone || rec.phones).map((p) => {
|
|
3569
|
+
const n2 = tryNormalizePhone(p, config.phone.default_country);
|
|
3570
|
+
return n2 || p;
|
|
3571
|
+
}).filter((p) => /^\+\d+$/.test(p));
|
|
3572
|
+
const companies2 = splitField(rec.company || rec.companies).map((c) => c.trim());
|
|
3573
|
+
const tags = splitField(rec.tags).map((t) => t.trim());
|
|
3574
|
+
let existing = null;
|
|
3575
|
+
for (const e of emails) {
|
|
3576
|
+
existing = await findContactByEmail(db, e);
|
|
3577
|
+
if (existing) {
|
|
3578
|
+
break;
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
if (existing && !opts.update) {
|
|
3582
|
+
skipped++;
|
|
3583
|
+
continue;
|
|
3584
|
+
}
|
|
3585
|
+
if (existing && opts.update) {
|
|
3586
|
+
const custom2 = safeJSON(existing.custom_fields);
|
|
3587
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
3588
|
+
if (!CONTACT_FIELDS.has(k) && v) {
|
|
3589
|
+
custom2[k] = v;
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
await db.update(contacts).set({
|
|
3593
|
+
name: name || existing.name,
|
|
3594
|
+
custom_fields: JSON.stringify(custom2),
|
|
3595
|
+
updated_at: now()
|
|
3596
|
+
}).where(eq9(contacts.id, existing.id));
|
|
3597
|
+
const results2 = await db.select().from(contacts).where(eq9(contacts.id, existing.id));
|
|
3598
|
+
const row2 = results2[0];
|
|
3599
|
+
await upsertSearchIndex(db, "contact", existing.id, await buildContactSearch(db, row2));
|
|
3600
|
+
imported++;
|
|
3601
|
+
continue;
|
|
3602
|
+
}
|
|
3603
|
+
if (opts.dryRun) {
|
|
3604
|
+
console.log(`[dry-run] ${name} (${emails.join(", ")})`);
|
|
3605
|
+
imported++;
|
|
3606
|
+
continue;
|
|
3607
|
+
}
|
|
3608
|
+
const id = makeId("ct");
|
|
3609
|
+
const n = now();
|
|
3610
|
+
const custom = {};
|
|
3611
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
3612
|
+
if (!CONTACT_FIELDS.has(k) && v) {
|
|
3613
|
+
custom[k] = v;
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
const social = {
|
|
3617
|
+
linkedin: rec.linkedin?.trim() || null,
|
|
3618
|
+
x: rec.x?.trim() || null,
|
|
3619
|
+
bluesky: rec.bluesky?.trim() || null,
|
|
3620
|
+
telegram: rec.telegram?.trim() || null
|
|
3621
|
+
};
|
|
3622
|
+
await db.insert(contacts).values({
|
|
3623
|
+
id,
|
|
3624
|
+
name,
|
|
3625
|
+
emails: JSON.stringify(emails),
|
|
3626
|
+
phones: JSON.stringify(phones),
|
|
3627
|
+
companies: JSON.stringify(companies2),
|
|
3628
|
+
linkedin: social.linkedin,
|
|
3629
|
+
x: social.x,
|
|
3630
|
+
bluesky: social.bluesky,
|
|
3631
|
+
telegram: social.telegram,
|
|
3632
|
+
tags: JSON.stringify(tags),
|
|
3633
|
+
custom_fields: JSON.stringify(custom),
|
|
3634
|
+
created_at: n,
|
|
3635
|
+
updated_at: n
|
|
3636
|
+
});
|
|
3637
|
+
const results = await db.select().from(contacts).where(eq9(contacts.id, id));
|
|
3638
|
+
const row = results[0];
|
|
3639
|
+
await upsertSearchIndex(db, "contact", id, await buildContactSearch(db, row));
|
|
3640
|
+
imported++;
|
|
3641
|
+
} catch (e) {
|
|
3642
|
+
if (opts.skipErrors) {
|
|
3643
|
+
errors++;
|
|
3644
|
+
continue;
|
|
3645
|
+
}
|
|
3646
|
+
die(`Error importing row: ${e.message}`);
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
console.log(`Imported: ${imported}, skipped: ${skipped}, errors: ${errors}`);
|
|
3650
|
+
});
|
|
3651
|
+
imp.command("companies").argument("<file>").option("--dry-run").option("--skip-errors").action(async (file, opts) => {
|
|
3652
|
+
const { db, config } = await getCtx();
|
|
3653
|
+
const records = readRecords(file);
|
|
3654
|
+
let imported = 0;
|
|
3655
|
+
for (const rec of records) {
|
|
3656
|
+
try {
|
|
3657
|
+
if (!rec.name) {
|
|
3658
|
+
if (opts.skipErrors) {
|
|
3659
|
+
continue;
|
|
3660
|
+
}
|
|
3661
|
+
die("Error: company missing name");
|
|
3662
|
+
}
|
|
3663
|
+
rec.name = rec.name.trim();
|
|
3664
|
+
const websites = splitField(rec.website || rec.websites).map((w) => {
|
|
3665
|
+
try {
|
|
3666
|
+
return normalizeWebsite(w);
|
|
3667
|
+
} catch {
|
|
3668
|
+
return w;
|
|
3669
|
+
}
|
|
3670
|
+
});
|
|
3671
|
+
const phones = splitField(rec.phone || rec.phones).map((p) => {
|
|
3672
|
+
const n2 = tryNormalizePhone(p, config.phone.default_country);
|
|
3673
|
+
return n2 || p;
|
|
3674
|
+
});
|
|
3675
|
+
const tags = splitField(rec.tags);
|
|
3676
|
+
const custom = {};
|
|
3677
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
3678
|
+
if (!COMPANY_FIELDS.has(k) && v) {
|
|
3679
|
+
custom[k] = v;
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
if (opts.dryRun) {
|
|
3683
|
+
console.log(`[dry-run] ${rec.name}`);
|
|
3684
|
+
imported++;
|
|
3685
|
+
continue;
|
|
3686
|
+
}
|
|
3687
|
+
const id = makeId("co");
|
|
3688
|
+
const n = now();
|
|
3689
|
+
await db.insert(companies).values({
|
|
3690
|
+
id,
|
|
3691
|
+
name: rec.name,
|
|
3692
|
+
websites: JSON.stringify(websites),
|
|
3693
|
+
phones: JSON.stringify(phones),
|
|
3694
|
+
tags: JSON.stringify(tags),
|
|
3695
|
+
custom_fields: JSON.stringify(custom),
|
|
3696
|
+
created_at: n,
|
|
3697
|
+
updated_at: n
|
|
3698
|
+
});
|
|
3699
|
+
const results = await db.select().from(companies).where(eq9(companies.id, id));
|
|
3700
|
+
const row = results[0];
|
|
3701
|
+
await upsertSearchIndex(db, "company", id, buildCompanySearch(row));
|
|
3702
|
+
imported++;
|
|
3703
|
+
} catch (e) {
|
|
3704
|
+
if (opts.skipErrors) {
|
|
3705
|
+
continue;
|
|
3706
|
+
}
|
|
3707
|
+
die(`Error: ${e.message}`);
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
console.log(`Imported: ${imported}`);
|
|
3711
|
+
});
|
|
3712
|
+
imp.command("deals").argument("<file>").option("--dry-run").option("--skip-errors").action(async (file, opts) => {
|
|
3713
|
+
const { db, config } = await getCtx();
|
|
3714
|
+
const records = readRecords(file);
|
|
3715
|
+
let imported = 0;
|
|
3716
|
+
for (const rec of records) {
|
|
3717
|
+
try {
|
|
3718
|
+
if (!rec.title) {
|
|
3719
|
+
if (opts.skipErrors) {
|
|
3720
|
+
continue;
|
|
3721
|
+
}
|
|
3722
|
+
die("Error: deal missing title");
|
|
3723
|
+
}
|
|
3724
|
+
rec.title = rec.title.trim();
|
|
3725
|
+
const stage = (rec.stage || config.pipeline.stages[0]).trim();
|
|
3726
|
+
const value = rec.value ? Number(rec.value) : null;
|
|
3727
|
+
const tags = splitField(rec.tags);
|
|
3728
|
+
const custom = {};
|
|
3729
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
3730
|
+
if (!DEAL_FIELDS.has(k) && v) {
|
|
3731
|
+
custom[k] = v;
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
if (opts.dryRun) {
|
|
3735
|
+
console.log(`[dry-run] ${rec.title}`);
|
|
3736
|
+
imported++;
|
|
3737
|
+
continue;
|
|
3738
|
+
}
|
|
3739
|
+
const id = makeId("dl");
|
|
3740
|
+
const n = now();
|
|
3741
|
+
await db.insert(deals).values({
|
|
3742
|
+
id,
|
|
3743
|
+
title: rec.title,
|
|
3744
|
+
value,
|
|
3745
|
+
stage,
|
|
3746
|
+
contacts: "[]",
|
|
3747
|
+
company: null,
|
|
3748
|
+
expected_close: rec.expected_close || null,
|
|
3749
|
+
probability: rec.probability ? Number(rec.probability) : null,
|
|
3750
|
+
tags: JSON.stringify(tags),
|
|
3751
|
+
custom_fields: JSON.stringify(custom),
|
|
3752
|
+
created_at: n,
|
|
3753
|
+
updated_at: n
|
|
3754
|
+
});
|
|
3755
|
+
const results = await db.select().from(deals).where(eq9(deals.id, id));
|
|
3756
|
+
const row = results[0];
|
|
3757
|
+
await upsertSearchIndex(db, "deal", id, buildDealSearch(row));
|
|
3758
|
+
imported++;
|
|
3759
|
+
} catch (e) {
|
|
3760
|
+
if (opts.skipErrors) {
|
|
3761
|
+
continue;
|
|
3762
|
+
}
|
|
3763
|
+
die(`Error: ${e.message}`);
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
console.log(`Imported: ${imported}`);
|
|
3767
|
+
});
|
|
3768
|
+
const exp = program.command("export").description("Export data");
|
|
3769
|
+
exp.command("contacts").action(async () => {
|
|
3770
|
+
const { db, config, fmt } = await getCtx();
|
|
3771
|
+
const rows = (await db.select().from(contacts)).map((c) => contactToRow(c));
|
|
3772
|
+
console.log(formatOutput(rows, fmt, config));
|
|
3773
|
+
});
|
|
3774
|
+
exp.command("companies").action(async () => {
|
|
3775
|
+
const { db, config, fmt } = await getCtx();
|
|
3776
|
+
const rows = (await db.select().from(companies)).map((c) => companyToRow(c));
|
|
3777
|
+
console.log(formatOutput(rows, fmt, config));
|
|
3778
|
+
});
|
|
3779
|
+
exp.command("deals").action(async () => {
|
|
3780
|
+
const { db, config, fmt } = await getCtx();
|
|
3781
|
+
const rows = (await db.select().from(deals)).map((d) => dealToRow(d));
|
|
3782
|
+
console.log(formatOutput(rows, fmt, config));
|
|
3783
|
+
});
|
|
3784
|
+
exp.command("all").action(async () => {
|
|
3785
|
+
const { db, config, fmt } = await getCtx();
|
|
3786
|
+
const data = {
|
|
3787
|
+
contacts: (await db.select().from(contacts)).map((c) => contactToRow(c)),
|
|
3788
|
+
companies: (await db.select().from(companies)).map((c) => companyToRow(c)),
|
|
3789
|
+
deals: (await db.select().from(deals)).map((d) => dealToRow(d)),
|
|
3790
|
+
activities: (await db.select().from(activities)).map((a) => activityToRow(a))
|
|
3791
|
+
};
|
|
3792
|
+
if (fmt === "json") {
|
|
3793
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3794
|
+
} else {
|
|
3795
|
+
console.log(formatOutput(Object.entries(data).map(([k, v]) => ({ type: k, count: v.length })), fmt, config));
|
|
3796
|
+
}
|
|
3797
|
+
});
|
|
3798
|
+
}
|
|
3799
|
+
function readRecords(file) {
|
|
3800
|
+
let raw;
|
|
3801
|
+
if (file === "-") {
|
|
3802
|
+
const chunks = [];
|
|
3803
|
+
const fd = __require("node:fs").openSync("/dev/stdin", "r");
|
|
3804
|
+
const buf = Buffer.alloc(65536);
|
|
3805
|
+
let n = __require("node:fs").readSync(fd, buf);
|
|
3806
|
+
while (n > 0) {
|
|
3807
|
+
chunks.push(buf.subarray(0, n));
|
|
3808
|
+
n = __require("node:fs").readSync(fd, buf);
|
|
3809
|
+
}
|
|
3810
|
+
__require("node:fs").closeSync(fd);
|
|
3811
|
+
raw = Buffer.concat(chunks).toString("utf-8");
|
|
3812
|
+
} else {
|
|
3813
|
+
raw = readFileSync3(file, "utf-8");
|
|
3814
|
+
}
|
|
3815
|
+
raw = raw.trim();
|
|
3816
|
+
if (!raw) {
|
|
3817
|
+
return [];
|
|
3818
|
+
}
|
|
3819
|
+
if (raw.startsWith("[") || raw.startsWith("{")) {
|
|
3820
|
+
return JSON.parse(raw);
|
|
3821
|
+
}
|
|
3822
|
+
return parseCSV(raw);
|
|
3823
|
+
}
|
|
3824
|
+
function splitField(val) {
|
|
3825
|
+
if (!val) {
|
|
3826
|
+
return [];
|
|
3827
|
+
}
|
|
3828
|
+
if (Array.isArray(val)) {
|
|
3829
|
+
return val;
|
|
3830
|
+
}
|
|
3831
|
+
return val.split(",").map((s) => s.trim()).filter(Boolean);
|
|
3832
|
+
}
|
|
3833
|
+
async function findContactByEmail(db, email) {
|
|
3834
|
+
const all = await db.select().from(contacts);
|
|
3835
|
+
for (const c of all) {
|
|
3836
|
+
const emails = safeJSON(c.emails);
|
|
3837
|
+
if (emails.some((e) => e.toLowerCase() === email.toLowerCase())) {
|
|
3838
|
+
return c;
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
return null;
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
// src/commands/report.ts
|
|
3845
|
+
import { eq as eq10 } from "drizzle-orm";
|
|
3846
|
+
function registerReportCommands(program) {
|
|
3847
|
+
const cmd = program.command("report").description("Reports");
|
|
3848
|
+
cmd.command("pipeline").action(async () => {
|
|
3849
|
+
const { db, config, fmt } = await getCtx();
|
|
3850
|
+
const deals2 = await db.select().from(deals);
|
|
3851
|
+
const summary = computePipeline(deals2, config.pipeline.stages);
|
|
3852
|
+
const total = {
|
|
3853
|
+
stage: "Total",
|
|
3854
|
+
count: deals2.length,
|
|
3855
|
+
value: deals2.reduce((s, d) => s + (d.value || 0), 0)
|
|
3856
|
+
};
|
|
3857
|
+
const data = [...summary, total];
|
|
3858
|
+
if (fmt === "json") {
|
|
3859
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3860
|
+
} else {
|
|
3861
|
+
console.log(formatOutput(data, fmt, config));
|
|
3862
|
+
}
|
|
3863
|
+
});
|
|
3864
|
+
cmd.command("activity").option("--by <field>", "Group by (type or contact)").option("--period <period>", "Time period (e.g. 7d, 30d)").action(async (opts) => {
|
|
3865
|
+
const { db, config, fmt } = await getCtx();
|
|
3866
|
+
let activities2 = await db.select().from(activities);
|
|
3867
|
+
if (opts.period) {
|
|
3868
|
+
const cutoff = periodToDate(opts.period);
|
|
3869
|
+
if (cutoff) {
|
|
3870
|
+
activities2 = activities2.filter((a) => a.created_at >= cutoff);
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
const groupBy = opts.by || "type";
|
|
3874
|
+
const groups = {};
|
|
3875
|
+
for (const a of activities2) {
|
|
3876
|
+
if (groupBy === "contact") {
|
|
3877
|
+
const contacts2 = safeJSON(a.contacts);
|
|
3878
|
+
if (contacts2.length === 0) {
|
|
3879
|
+
groups.none = (groups.none || 0) + 1;
|
|
3880
|
+
} else {
|
|
3881
|
+
for (const cid of contacts2) {
|
|
3882
|
+
groups[cid] = (groups[cid] || 0) + 1;
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
} else {
|
|
3886
|
+
groups[a.type] = (groups[a.type] || 0) + 1;
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
let data;
|
|
3890
|
+
if (groupBy === "contact") {
|
|
3891
|
+
const dataPromises = Object.entries(groups).map(async ([contact, count]) => {
|
|
3892
|
+
if (contact === "none") {
|
|
3893
|
+
return { contact: "(none)", count };
|
|
3894
|
+
}
|
|
3895
|
+
const results = await db.select({ name: contacts.name }).from(contacts).where(eq10(contacts.id, contact));
|
|
3896
|
+
const ct = results[0];
|
|
3897
|
+
return { contact: ct?.name || contact, count };
|
|
3898
|
+
});
|
|
3899
|
+
data = await Promise.all(dataPromises);
|
|
3900
|
+
} else {
|
|
3901
|
+
data = Object.entries(groups).map(([type, count]) => ({ type, count }));
|
|
3902
|
+
}
|
|
3903
|
+
if (fmt === "json") {
|
|
3904
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3905
|
+
} else {
|
|
3906
|
+
console.log(formatOutput(data, fmt, config));
|
|
3907
|
+
}
|
|
3908
|
+
});
|
|
3909
|
+
cmd.command("stale").option("--days <n>", "Days threshold", "30").option("--type <type>", "Entity type (contact or deal)").action(async (opts) => {
|
|
3910
|
+
const { db, config, fmt } = await getCtx();
|
|
3911
|
+
const days = Number(opts.days);
|
|
3912
|
+
let results = await computeStale(db, config, days);
|
|
3913
|
+
if (opts.type) {
|
|
3914
|
+
results = results.filter((r) => r.type === opts.type);
|
|
3915
|
+
}
|
|
3916
|
+
if (fmt === "json") {
|
|
3917
|
+
console.log(JSON.stringify(results, null, 2));
|
|
3918
|
+
} else {
|
|
3919
|
+
if (results.length === 0) {
|
|
3920
|
+
console.log("No stale entities found.");
|
|
3921
|
+
return;
|
|
3922
|
+
}
|
|
3923
|
+
const lines = results.map((r) => `[${r.type}] ${r.name || r.title} (${r.id}) — last: ${r.last_activity || "never"}`);
|
|
3924
|
+
console.log(lines.join(`
|
|
3925
|
+
`));
|
|
3926
|
+
}
|
|
3927
|
+
});
|
|
3928
|
+
cmd.command("conversion").option("--since <date>", "Only count transitions after date (YYYY-MM-DD)").action(async (opts) => {
|
|
3929
|
+
const { db, config, fmt } = await getCtx();
|
|
3930
|
+
const data = await computeConversion(db, config.pipeline.stages, opts.since);
|
|
3931
|
+
if (fmt === "json") {
|
|
3932
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3933
|
+
} else {
|
|
3934
|
+
console.log(formatOutput(data, fmt, config));
|
|
3935
|
+
}
|
|
3936
|
+
});
|
|
3937
|
+
cmd.command("velocity").option("--won-only", "Only count deals that were won").action(async (opts) => {
|
|
3938
|
+
const { db, config, fmt } = await getCtx();
|
|
3939
|
+
const wonStage = opts.wonOnly ? config.pipeline.won_stage : undefined;
|
|
3940
|
+
const data = await computeVelocity(db, config.pipeline.stages, wonStage);
|
|
3941
|
+
if (fmt === "json") {
|
|
3942
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3943
|
+
} else {
|
|
3944
|
+
console.log(formatOutput(data.map((d) => ({
|
|
3945
|
+
stage: d.stage,
|
|
3946
|
+
avg_time: d.avg_display,
|
|
3947
|
+
deals: d.deals
|
|
3948
|
+
})), fmt, config));
|
|
3949
|
+
}
|
|
3950
|
+
});
|
|
3951
|
+
cmd.command("forecast").option("--period <period>", "Filter by expected close month (YYYY-MM) or days (30d)").action(async (opts) => {
|
|
3952
|
+
const { db, config, fmt } = await getCtx();
|
|
3953
|
+
let data = await computeForecast(db, config);
|
|
3954
|
+
if (opts.period) {
|
|
3955
|
+
if (opts.period.match(/^\d{4}-\d{2}$/)) {
|
|
3956
|
+
data = data.filter((d) => d.expected_close?.startsWith(opts.period));
|
|
3957
|
+
} else {
|
|
3958
|
+
const cutoff = periodToDate(opts.period);
|
|
3959
|
+
if (cutoff) {
|
|
3960
|
+
data = data.filter((d) => d.expected_close && d.expected_close >= cutoff);
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3964
|
+
if (fmt === "json") {
|
|
3965
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3966
|
+
} else {
|
|
3967
|
+
console.log(formatOutput(data, fmt, config));
|
|
3968
|
+
}
|
|
3969
|
+
});
|
|
3970
|
+
cmd.command("won").option("--period <period>", "Time period (e.g. 30d)").action(async (opts) => {
|
|
3971
|
+
const { db, config, fmt } = await getCtx();
|
|
3972
|
+
let data = await computeWon(db, config);
|
|
3973
|
+
if (opts.period) {
|
|
3974
|
+
const cutoff = periodToDate(opts.period);
|
|
3975
|
+
if (cutoff) {
|
|
3976
|
+
data = data.filter((d) => d.updated_at >= cutoff);
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
if (fmt === "json") {
|
|
3980
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3981
|
+
} else {
|
|
3982
|
+
console.log(formatOutput(data, fmt, config));
|
|
3983
|
+
}
|
|
3984
|
+
});
|
|
3985
|
+
cmd.command("lost").option("--period <period>", "Time period").action(async (opts) => {
|
|
3986
|
+
const { db, config, fmt } = await getCtx();
|
|
3987
|
+
let data = await computeLost(db, config);
|
|
3988
|
+
if (opts.period) {
|
|
3989
|
+
const cutoff = periodToDate(opts.period);
|
|
3990
|
+
if (cutoff) {
|
|
3991
|
+
data = data.filter((d) => d.updated_at >= cutoff);
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
if (fmt === "json") {
|
|
3995
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3996
|
+
} else {
|
|
3997
|
+
console.log(formatOutput(data, fmt, config));
|
|
3998
|
+
}
|
|
3999
|
+
});
|
|
4000
|
+
}
|
|
4001
|
+
function periodToDate(period) {
|
|
4002
|
+
const m = period.match(/^(\d+)d$/);
|
|
4003
|
+
if (m) {
|
|
4004
|
+
return new Date(Date.now() - Number(m[1]) * 86400000).toISOString();
|
|
4005
|
+
}
|
|
4006
|
+
return null;
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
// src/commands/search.ts
|
|
4010
|
+
import { eq as eq11, sql as sql4 } from "drizzle-orm";
|
|
4011
|
+
function registerSearchCommands(program) {
|
|
4012
|
+
program.command("search").description("Full-text search").argument("<query>").option("--type <type>").action(async (rawQuery, opts) => {
|
|
4013
|
+
const query = rawQuery.trim();
|
|
4014
|
+
const { db, config, fmt } = await getCtx();
|
|
4015
|
+
const results = [];
|
|
4016
|
+
try {
|
|
4017
|
+
const ftsRows = await db.all(sql4`SELECT * FROM search_index WHERE content MATCH ${query}`);
|
|
4018
|
+
for (const fr of ftsRows) {
|
|
4019
|
+
if (opts.type && fr.entity_type !== opts.type) {
|
|
4020
|
+
continue;
|
|
4021
|
+
}
|
|
4022
|
+
const entity = await lookupEntity(db, fr.entity_type, fr.entity_id);
|
|
4023
|
+
if (entity) {
|
|
4024
|
+
results.push(entity);
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
} catch {
|
|
4028
|
+
const likeRows = await db.all(sql4`SELECT * FROM search_index WHERE content LIKE ${`%${query}%`}`);
|
|
4029
|
+
for (const fr of likeRows) {
|
|
4030
|
+
if (opts.type && fr.entity_type !== opts.type) {
|
|
4031
|
+
continue;
|
|
4032
|
+
}
|
|
4033
|
+
const entity = await lookupEntity(db, fr.entity_type, fr.entity_id);
|
|
4034
|
+
if (entity) {
|
|
4035
|
+
results.push(entity);
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
const capped = results.slice(0, config.mount.search_limit);
|
|
4040
|
+
if (fmt === "json") {
|
|
4041
|
+
console.log(JSON.stringify(capped, null, 2));
|
|
4042
|
+
} else {
|
|
4043
|
+
if (capped.length === 0) {
|
|
4044
|
+
console.log("");
|
|
4045
|
+
return;
|
|
4046
|
+
}
|
|
4047
|
+
const lines = capped.map((r) => {
|
|
4048
|
+
if (r.type === "contact") {
|
|
4049
|
+
return `[contact] ${r.name} (${r.id})`;
|
|
4050
|
+
}
|
|
4051
|
+
if (r.type === "company") {
|
|
4052
|
+
return `[company] ${r.name} (${r.id})`;
|
|
4053
|
+
}
|
|
4054
|
+
if (r.type === "deal") {
|
|
4055
|
+
return `[deal] ${r.title} (${r.id})`;
|
|
4056
|
+
}
|
|
4057
|
+
if (r.entity_type === "activity") {
|
|
4058
|
+
return `[activity] ${r.body} (${r.id})`;
|
|
4059
|
+
}
|
|
4060
|
+
return `[${r.type}] ${r.id}`;
|
|
4061
|
+
});
|
|
4062
|
+
console.log(lines.join(`
|
|
4063
|
+
`));
|
|
4064
|
+
}
|
|
4065
|
+
});
|
|
4066
|
+
program.command("find").description("Semantic search").argument("<query>").option("--type <type>").option("--limit <n>").option("--threshold <n>", "Minimum similarity score 0.0-1.0").action(async (rawQuery, opts) => {
|
|
4067
|
+
const query = rawQuery.trim();
|
|
4068
|
+
const { db, config, fmt } = await getCtx();
|
|
4069
|
+
const queryWords = query.toLowerCase().split(/\s+/);
|
|
4070
|
+
const allEntities = [];
|
|
4071
|
+
const indexRows = await db.all(sql4`SELECT * FROM search_index`);
|
|
4072
|
+
for (const row of indexRows) {
|
|
4073
|
+
if (opts.type && row.entity_type !== opts.type) {
|
|
4074
|
+
continue;
|
|
4075
|
+
}
|
|
4076
|
+
if (row.entity_type === "activity") {
|
|
4077
|
+
continue;
|
|
4078
|
+
}
|
|
4079
|
+
const content = (row.content || "").toLowerCase();
|
|
4080
|
+
let score = 0;
|
|
4081
|
+
for (const w of queryWords) {
|
|
4082
|
+
if (content.includes(w)) {
|
|
4083
|
+
score += 1;
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
if (score > 0) {
|
|
4087
|
+
const normalized = queryWords.length > 0 ? score / queryWords.length : 0;
|
|
4088
|
+
allEntities.push({ ...row, score: normalized });
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
allEntities.sort((a, b) => b.score - a.score);
|
|
4092
|
+
let limited = allEntities;
|
|
4093
|
+
if (opts.threshold) {
|
|
4094
|
+
const t = Number(opts.threshold);
|
|
4095
|
+
limited = limited.filter((e) => e.score >= t);
|
|
4096
|
+
}
|
|
4097
|
+
const maxResults = opts.limit ? Number(opts.limit) : config.mount.search_limit;
|
|
4098
|
+
limited = limited.slice(0, maxResults);
|
|
4099
|
+
const resultPromises = limited.map((r) => lookupEntity(db, r.entity_type, r.entity_id));
|
|
4100
|
+
const results = (await Promise.all(resultPromises)).filter(Boolean);
|
|
4101
|
+
if (fmt === "json") {
|
|
4102
|
+
console.log(JSON.stringify(results, null, 2));
|
|
4103
|
+
} else {
|
|
4104
|
+
if (results.length === 0) {
|
|
4105
|
+
console.log("");
|
|
4106
|
+
return;
|
|
4107
|
+
}
|
|
4108
|
+
const lines = results.map((r) => `[${r.type}] ${r.name || r.title} (${r.id})`);
|
|
4109
|
+
console.log(lines.join(`
|
|
4110
|
+
`));
|
|
4111
|
+
}
|
|
4112
|
+
});
|
|
4113
|
+
const idx = program.command("index").description("Search index management");
|
|
4114
|
+
idx.command("status").action(async () => {
|
|
4115
|
+
const { db } = await getCtx();
|
|
4116
|
+
const countRows = await db.all(sql4`SELECT entity_type, COUNT(*) as cnt FROM search_index GROUP BY entity_type`);
|
|
4117
|
+
const counts = {};
|
|
4118
|
+
for (const r of countRows) {
|
|
4119
|
+
counts[r.entity_type] = r.cnt;
|
|
4120
|
+
}
|
|
4121
|
+
const contactCount = (await db.select({ cnt: sql4`COUNT(*)` }).from(contacts))[0];
|
|
4122
|
+
const companyCount = (await db.select({ cnt: sql4`COUNT(*)` }).from(companies))[0];
|
|
4123
|
+
const dealCount = (await db.select({ cnt: sql4`COUNT(*)` }).from(deals))[0];
|
|
4124
|
+
console.log(`contacts: ${contactCount.cnt} (indexed: ${counts.contact || 0})`);
|
|
4125
|
+
console.log(`companies: ${companyCount.cnt} (indexed: ${counts.company || 0})`);
|
|
4126
|
+
console.log(`deals: ${dealCount.cnt} (indexed: ${counts.deal || 0})`);
|
|
4127
|
+
});
|
|
4128
|
+
idx.command("rebuild").action(async () => {
|
|
4129
|
+
const { db } = await getCtx();
|
|
4130
|
+
await rebuildSearchIndex(db);
|
|
4131
|
+
console.log("Index rebuilt");
|
|
4132
|
+
});
|
|
4133
|
+
}
|
|
4134
|
+
async function lookupEntity(db, entityType, id) {
|
|
4135
|
+
if (entityType === "contact") {
|
|
4136
|
+
const results = await db.select().from(contacts).where(eq11(contacts.id, id));
|
|
4137
|
+
const c = results[0];
|
|
4138
|
+
if (!c) {
|
|
4139
|
+
return null;
|
|
4140
|
+
}
|
|
4141
|
+
return { type: "contact", ...contactToRow(c) };
|
|
4142
|
+
}
|
|
4143
|
+
if (entityType === "company") {
|
|
4144
|
+
const results = await db.select().from(companies).where(eq11(companies.id, id));
|
|
4145
|
+
const c = results[0];
|
|
4146
|
+
if (!c) {
|
|
4147
|
+
return null;
|
|
4148
|
+
}
|
|
4149
|
+
return { type: "company", ...companyToRow(c) };
|
|
4150
|
+
}
|
|
4151
|
+
if (entityType === "deal") {
|
|
4152
|
+
const results = await db.select().from(deals).where(eq11(deals.id, id));
|
|
4153
|
+
const d = results[0];
|
|
4154
|
+
if (!d) {
|
|
4155
|
+
return null;
|
|
4156
|
+
}
|
|
4157
|
+
return { type: "deal", ...dealToRow(d) };
|
|
4158
|
+
}
|
|
4159
|
+
if (entityType === "activity") {
|
|
4160
|
+
const results = await db.select().from(activities).where(eq11(activities.id, id));
|
|
4161
|
+
const a = results[0];
|
|
4162
|
+
if (!a) {
|
|
4163
|
+
return null;
|
|
4164
|
+
}
|
|
4165
|
+
const row = activityToRow(a);
|
|
4166
|
+
return { entity_type: "activity", ...row };
|
|
4167
|
+
}
|
|
4168
|
+
return null;
|
|
4169
|
+
}
|
|
4170
|
+
|
|
4171
|
+
// src/commands/tag.ts
|
|
4172
|
+
import { eq as eq12 } from "drizzle-orm";
|
|
4173
|
+
function registerTagCommands(program) {
|
|
4174
|
+
program.command("tag").description("Tag an entity or list tags").argument("[args...]").option("--type <type>").action(async (args, opts) => {
|
|
4175
|
+
if (args[0] === "list") {
|
|
4176
|
+
return tagList(opts);
|
|
4177
|
+
}
|
|
4178
|
+
if (args.length < 2) {
|
|
4179
|
+
die("Error: usage: tag <ref> <tags...>");
|
|
4180
|
+
}
|
|
4181
|
+
const ref = args[0].trim();
|
|
4182
|
+
const tags = args.slice(1).map((t) => t.trim());
|
|
4183
|
+
const { db, config } = await getCtx();
|
|
4184
|
+
const resolved = await resolveEntity(db, ref, config);
|
|
4185
|
+
if (!resolved) {
|
|
4186
|
+
die(`Error: entity not found: ${ref}`);
|
|
4187
|
+
}
|
|
4188
|
+
const { type, entity } = resolved;
|
|
4189
|
+
const existing = safeJSON(entity.tags);
|
|
4190
|
+
for (const t of tags) {
|
|
4191
|
+
if (!existing.includes(t)) {
|
|
4192
|
+
existing.push(t);
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
if (type === "contact") {
|
|
4196
|
+
await db.update(contacts).set({ tags: JSON.stringify(existing), updated_at: now() }).where(eq12(contacts.id, entity.id));
|
|
4197
|
+
} else if (type === "company") {
|
|
4198
|
+
await db.update(companies).set({ tags: JSON.stringify(existing), updated_at: now() }).where(eq12(companies.id, entity.id));
|
|
4199
|
+
} else {
|
|
4200
|
+
await db.update(deals).set({ tags: JSON.stringify(existing), updated_at: now() }).where(eq12(deals.id, entity.id));
|
|
4201
|
+
}
|
|
4202
|
+
});
|
|
4203
|
+
program.command("untag").description("Remove tags from an entity").argument("<ref>").argument("<tags...>").action(async (rawRef, rawTags) => {
|
|
4204
|
+
const ref = rawRef.trim();
|
|
4205
|
+
const tags = rawTags.map((t) => t.trim());
|
|
4206
|
+
const { db, config } = await getCtx();
|
|
4207
|
+
const resolved = await resolveEntity(db, ref, config);
|
|
4208
|
+
if (!resolved) {
|
|
4209
|
+
die(`Error: entity not found: ${ref}`);
|
|
4210
|
+
}
|
|
4211
|
+
const { type, entity } = resolved;
|
|
4212
|
+
let existing = safeJSON(entity.tags);
|
|
4213
|
+
for (const t of tags) {
|
|
4214
|
+
existing = existing.filter((v) => v !== t);
|
|
4215
|
+
}
|
|
4216
|
+
if (type === "contact") {
|
|
4217
|
+
await db.update(contacts).set({ tags: JSON.stringify(existing), updated_at: now() }).where(eq12(contacts.id, entity.id));
|
|
4218
|
+
} else if (type === "company") {
|
|
4219
|
+
await db.update(companies).set({ tags: JSON.stringify(existing), updated_at: now() }).where(eq12(companies.id, entity.id));
|
|
4220
|
+
} else {
|
|
4221
|
+
await db.update(deals).set({ tags: JSON.stringify(existing), updated_at: now() }).where(eq12(deals.id, entity.id));
|
|
4222
|
+
}
|
|
4223
|
+
});
|
|
4224
|
+
}
|
|
4225
|
+
async function tagList(opts) {
|
|
4226
|
+
const { db, config, fmt } = await getCtx();
|
|
4227
|
+
const tagMap = {};
|
|
4228
|
+
if (!opts.type || opts.type === "contact") {
|
|
4229
|
+
const rows = await db.select({ tags: contacts.tags }).from(contacts);
|
|
4230
|
+
for (const r of rows) {
|
|
4231
|
+
const tags = safeJSON(r.tags);
|
|
4232
|
+
for (const tag of tags) {
|
|
4233
|
+
tagMap[tag] = (tagMap[tag] || 0) + 1;
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
if (!opts.type || opts.type === "company") {
|
|
4238
|
+
const rows = await db.select({ tags: companies.tags }).from(companies);
|
|
4239
|
+
for (const r of rows) {
|
|
4240
|
+
const tags = safeJSON(r.tags);
|
|
4241
|
+
for (const tag of tags) {
|
|
4242
|
+
tagMap[tag] = (tagMap[tag] || 0) + 1;
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
4246
|
+
if (!opts.type || opts.type === "deal") {
|
|
4247
|
+
const rows = await db.select({ tags: deals.tags }).from(deals);
|
|
4248
|
+
for (const r of rows) {
|
|
4249
|
+
const tags = safeJSON(r.tags);
|
|
4250
|
+
for (const tag of tags) {
|
|
4251
|
+
tagMap[tag] = (tagMap[tag] || 0) + 1;
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
const data = Object.entries(tagMap).map(([tag, count]) => ({ tag, count }));
|
|
4256
|
+
if (fmt === "json") {
|
|
4257
|
+
console.log(JSON.stringify(data, null, 2));
|
|
4258
|
+
} else {
|
|
4259
|
+
console.log(formatOutput(data, fmt, config));
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4263
|
+
// src/cli.ts
|
|
4264
|
+
var program = new Command;
|
|
4265
|
+
program.name("crm").description("Headless CLI-first CRM").version("0.1.0");
|
|
4266
|
+
program.exitOverride();
|
|
4267
|
+
registerContactCommands(program);
|
|
4268
|
+
registerCompanyCommands(program);
|
|
4269
|
+
registerDealCommands(program);
|
|
4270
|
+
registerPipelineCommand(program);
|
|
4271
|
+
registerLogCommand(program);
|
|
4272
|
+
registerActivityCommands(program);
|
|
4273
|
+
registerTagCommands(program);
|
|
4274
|
+
registerSearchCommands(program);
|
|
4275
|
+
registerReportCommands(program);
|
|
4276
|
+
registerImportExportCommands(program);
|
|
4277
|
+
registerDupesCommand(program);
|
|
4278
|
+
registerFuseCommands(program);
|
|
4279
|
+
try {
|
|
4280
|
+
program.parse(["node", "crm", ...cleanArgv]);
|
|
4281
|
+
} catch (e) {
|
|
4282
|
+
const err = e;
|
|
4283
|
+
if (err.exitCode !== undefined && err.exitCode === 0) {
|
|
4284
|
+
process.exit(0);
|
|
4285
|
+
}
|
|
4286
|
+
if (err.exitCode !== undefined) {
|
|
4287
|
+
process.exit(err.exitCode);
|
|
4288
|
+
}
|
|
4289
|
+
console.error(err.message || e);
|
|
4290
|
+
process.exit(1);
|
|
4291
|
+
}
|