@hasna/contacts 0.2.1 → 0.2.3
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/dist/cli/index.js +146 -18
- package/dist/db/companies.d.ts +2 -0
- package/dist/db/companies.d.ts.map +1 -1
- package/dist/db/contacts.d.ts +7 -1
- package/dist/db/contacts.d.ts.map +1 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/groups.d.ts +11 -1
- package/dist/db/groups.d.ts.map +1 -1
- package/dist/db/tags.d.ts.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +275 -19
- package/dist/mcp/index.js +666 -34
- package/dist/server/index.js +116 -13
- package/dist/types/index.d.ts +41 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +21 -30
package/dist/mcp/index.js
CHANGED
|
@@ -160,9 +160,7 @@ var MIGRATIONS = [
|
|
|
160
160
|
last_name,
|
|
161
161
|
nickname,
|
|
162
162
|
notes,
|
|
163
|
-
job_title
|
|
164
|
-
content='contacts',
|
|
165
|
-
content_rowid='rowid'
|
|
163
|
+
job_title
|
|
166
164
|
);
|
|
167
165
|
|
|
168
166
|
CREATE TRIGGER IF NOT EXISTS contacts_fts_insert AFTER INSERT ON contacts BEGIN
|
|
@@ -200,6 +198,20 @@ var MIGRATIONS = [
|
|
|
200
198
|
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
201
199
|
PRIMARY KEY (contact_id, group_id)
|
|
202
200
|
);
|
|
201
|
+
`,
|
|
202
|
+
`
|
|
203
|
+
ALTER TABLE contacts ADD COLUMN status TEXT DEFAULT 'active';
|
|
204
|
+
ALTER TABLE contacts ADD COLUMN follow_up_at TEXT;
|
|
205
|
+
ALTER TABLE contacts ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
|
|
206
|
+
ALTER TABLE contacts ADD COLUMN project_id TEXT;
|
|
207
|
+
ALTER TABLE companies ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
|
|
208
|
+
ALTER TABLE companies ADD COLUMN project_id TEXT;
|
|
209
|
+
|
|
210
|
+
CREATE TABLE IF NOT EXISTS company_groups (
|
|
211
|
+
company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
212
|
+
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
213
|
+
PRIMARY KEY (company_id, group_id)
|
|
214
|
+
);
|
|
203
215
|
`
|
|
204
216
|
];
|
|
205
217
|
var _db = null;
|
|
@@ -271,11 +283,32 @@ class DuplicateTagNameError extends Error {
|
|
|
271
283
|
}
|
|
272
284
|
|
|
273
285
|
// src/db/activity.ts
|
|
286
|
+
function rowToActivity(row) {
|
|
287
|
+
return { ...row };
|
|
288
|
+
}
|
|
274
289
|
function logActivity(db, input) {
|
|
275
290
|
const id = uuid();
|
|
276
291
|
db.run(`INSERT INTO activity_log (id, contact_id, company_id, action, details) VALUES (?, ?, ?, ?, ?)`, [id, input.contact_id ?? null, input.company_id ?? null, input.action, input.details ?? null]);
|
|
277
292
|
return db.query(`SELECT * FROM activity_log WHERE id = ?`).get(id);
|
|
278
293
|
}
|
|
294
|
+
function listActivity(opts = {}, db) {
|
|
295
|
+
const d = db || getDatabase();
|
|
296
|
+
const { limit = 50, offset = 0, contact_id, company_id } = opts;
|
|
297
|
+
const conditions = [];
|
|
298
|
+
const params = [];
|
|
299
|
+
if (contact_id) {
|
|
300
|
+
conditions.push("contact_id = ?");
|
|
301
|
+
params.push(contact_id);
|
|
302
|
+
}
|
|
303
|
+
if (company_id) {
|
|
304
|
+
conditions.push("company_id = ?");
|
|
305
|
+
params.push(company_id);
|
|
306
|
+
}
|
|
307
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
308
|
+
const totalRow = d.query(`SELECT COUNT(*) as total FROM activity_log ${where}`).get(...params);
|
|
309
|
+
const rows = d.query(`SELECT * FROM activity_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
310
|
+
return { entries: rows.map(rowToActivity), total: totalRow.total };
|
|
311
|
+
}
|
|
279
312
|
|
|
280
313
|
// src/db/contacts.ts
|
|
281
314
|
function rowToContact(row) {
|
|
@@ -283,7 +316,11 @@ function rowToContact(row) {
|
|
|
283
316
|
...row,
|
|
284
317
|
source: row.source,
|
|
285
318
|
custom_fields: JSON.parse(row.custom_fields || "{}"),
|
|
286
|
-
preferred_contact_method: row.preferred_contact_method ?? null
|
|
319
|
+
preferred_contact_method: row.preferred_contact_method ?? null,
|
|
320
|
+
status: row.status ?? "active",
|
|
321
|
+
follow_up_at: row.follow_up_at ?? null,
|
|
322
|
+
archived: !!row.archived,
|
|
323
|
+
project_id: row.project_id ?? null
|
|
287
324
|
};
|
|
288
325
|
}
|
|
289
326
|
function rowToEmail(row) {
|
|
@@ -320,7 +357,9 @@ function rowToTag(row) {
|
|
|
320
357
|
function rowToCompany(row) {
|
|
321
358
|
return {
|
|
322
359
|
...row,
|
|
323
|
-
custom_fields: JSON.parse(row.custom_fields || "{}")
|
|
360
|
+
custom_fields: JSON.parse(row.custom_fields || "{}"),
|
|
361
|
+
archived: !!row.archived,
|
|
362
|
+
project_id: row.project_id ?? null
|
|
324
363
|
};
|
|
325
364
|
}
|
|
326
365
|
function insertEmails(db, contactId, companyId, emails) {
|
|
@@ -364,8 +403,8 @@ function createContact(input, db) {
|
|
|
364
403
|
const firstName = input.first_name ?? "";
|
|
365
404
|
const lastName = input.last_name ?? "";
|
|
366
405
|
const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
|
|
367
|
-
d.run(`INSERT INTO contacts (id, first_name, last_name, display_name, nickname, avatar_url, notes, birthday, company_id, job_title, source, custom_fields, last_contacted_at, website, preferred_contact_method, created_at, updated_at)
|
|
368
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
406
|
+
d.run(`INSERT INTO contacts (id, first_name, last_name, display_name, nickname, avatar_url, notes, birthday, company_id, job_title, source, custom_fields, last_contacted_at, website, preferred_contact_method, status, follow_up_at, project_id, created_at, updated_at)
|
|
407
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
369
408
|
id,
|
|
370
409
|
firstName,
|
|
371
410
|
lastName,
|
|
@@ -381,6 +420,9 @@ function createContact(input, db) {
|
|
|
381
420
|
input.last_contacted_at ?? null,
|
|
382
421
|
input.website ?? null,
|
|
383
422
|
input.preferred_contact_method ?? null,
|
|
423
|
+
input.status ?? "active",
|
|
424
|
+
input.follow_up_at ?? null,
|
|
425
|
+
input.project_id ?? null,
|
|
384
426
|
timestamp,
|
|
385
427
|
timestamp
|
|
386
428
|
]);
|
|
@@ -415,12 +457,21 @@ function listContacts(opts = {}, db) {
|
|
|
415
457
|
offset = 0,
|
|
416
458
|
company_id,
|
|
417
459
|
tag_id,
|
|
460
|
+
tag_ids,
|
|
418
461
|
source,
|
|
462
|
+
status,
|
|
463
|
+
project_id,
|
|
464
|
+
archived = false,
|
|
465
|
+
follow_up_due,
|
|
466
|
+
last_contacted_after,
|
|
467
|
+
last_contacted_before,
|
|
419
468
|
order_by = "display_name",
|
|
420
469
|
order_dir = "asc"
|
|
421
470
|
} = opts;
|
|
422
471
|
const conditions = [];
|
|
423
472
|
const params = [];
|
|
473
|
+
conditions.push("c.archived = ?");
|
|
474
|
+
params.push(archived ? 1 : 0);
|
|
424
475
|
if (company_id) {
|
|
425
476
|
conditions.push("c.company_id = ?");
|
|
426
477
|
params.push(company_id);
|
|
@@ -429,12 +480,37 @@ function listContacts(opts = {}, db) {
|
|
|
429
480
|
conditions.push("c.source = ?");
|
|
430
481
|
params.push(source);
|
|
431
482
|
}
|
|
483
|
+
if (status) {
|
|
484
|
+
conditions.push("c.status = ?");
|
|
485
|
+
params.push(status);
|
|
486
|
+
}
|
|
487
|
+
if (project_id) {
|
|
488
|
+
conditions.push("c.project_id = ?");
|
|
489
|
+
params.push(project_id);
|
|
490
|
+
}
|
|
432
491
|
if (tag_id) {
|
|
433
492
|
conditions.push("EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = c.id AND ct.tag_id = ?)");
|
|
434
493
|
params.push(tag_id);
|
|
435
494
|
}
|
|
495
|
+
if (tag_ids?.length) {
|
|
496
|
+
for (const tid of tag_ids) {
|
|
497
|
+
conditions.push("EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = c.id AND ct.tag_id = ?)");
|
|
498
|
+
params.push(tid);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (follow_up_due) {
|
|
502
|
+
conditions.push("c.follow_up_at IS NOT NULL AND c.follow_up_at <= datetime('now')");
|
|
503
|
+
}
|
|
504
|
+
if (last_contacted_after) {
|
|
505
|
+
conditions.push("c.last_contacted_at >= ?");
|
|
506
|
+
params.push(last_contacted_after);
|
|
507
|
+
}
|
|
508
|
+
if (last_contacted_before) {
|
|
509
|
+
conditions.push("c.last_contacted_at <= ?");
|
|
510
|
+
params.push(last_contacted_before);
|
|
511
|
+
}
|
|
436
512
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
437
|
-
const validOrderBy = ["display_name", "created_at", "updated_at"].includes(order_by) ? order_by : "display_name";
|
|
513
|
+
const validOrderBy = ["display_name", "created_at", "updated_at", "last_contacted_at", "follow_up_at"].includes(order_by) ? order_by : "display_name";
|
|
438
514
|
const validOrderDir = order_dir === "desc" ? "DESC" : "ASC";
|
|
439
515
|
const totalRow = d.query(`SELECT COUNT(*) as total FROM contacts c ${where}`).get(...params);
|
|
440
516
|
const rows = d.query(`SELECT c.* FROM contacts c ${where} ORDER BY c.${validOrderBy} ${validOrderDir} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
@@ -504,8 +580,36 @@ function updateContact(id, input, db) {
|
|
|
504
580
|
setClauses.push("preferred_contact_method = ?");
|
|
505
581
|
params.push(input.preferred_contact_method);
|
|
506
582
|
}
|
|
583
|
+
if (input.status !== undefined) {
|
|
584
|
+
setClauses.push("status = ?");
|
|
585
|
+
params.push(input.status);
|
|
586
|
+
}
|
|
587
|
+
if (input.follow_up_at !== undefined) {
|
|
588
|
+
setClauses.push("follow_up_at = ?");
|
|
589
|
+
params.push(input.follow_up_at);
|
|
590
|
+
}
|
|
591
|
+
if (input.project_id !== undefined) {
|
|
592
|
+
setClauses.push("project_id = ?");
|
|
593
|
+
params.push(input.project_id);
|
|
594
|
+
}
|
|
507
595
|
params.push(id);
|
|
508
596
|
d.run(`UPDATE contacts SET ${setClauses.join(", ")} WHERE id = ?`, params);
|
|
597
|
+
if (input.emails_add?.length) {
|
|
598
|
+
for (const e of input.emails_add) {
|
|
599
|
+
const exists = d.query(`SELECT id FROM emails WHERE contact_id = ? AND LOWER(address) = LOWER(?)`).get(id, e.address);
|
|
600
|
+
if (!exists) {
|
|
601
|
+
d.run(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?)`, [uuid(), id, e.address, e.type ?? "work", e.is_primary ? 1 : 0]);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (input.phones_add?.length) {
|
|
606
|
+
for (const p of input.phones_add) {
|
|
607
|
+
const exists = d.query(`SELECT id FROM phones WHERE contact_id = ? AND number = ?`).get(id, p.number);
|
|
608
|
+
if (!exists) {
|
|
609
|
+
d.run(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?, ?)`, [uuid(), id, p.number, p.country_code ?? null, p.type ?? "mobile", p.is_primary ? 1 : 0]);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
509
613
|
logActivity(d, { contact_id: id, action: "contact.updated", details: `Updated contact: ${existing.display_name}` });
|
|
510
614
|
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
511
615
|
return loadContactDetails(d, rowToContact(row));
|
|
@@ -523,25 +627,31 @@ function searchContacts(query, db) {
|
|
|
523
627
|
const ftsRows = d.query(`
|
|
524
628
|
SELECT c.* FROM contacts c
|
|
525
629
|
JOIN contacts_fts fts ON fts.id = c.id
|
|
526
|
-
WHERE contacts_fts MATCH ?
|
|
630
|
+
WHERE contacts_fts MATCH ? AND c.archived = 0
|
|
527
631
|
ORDER BY rank
|
|
528
632
|
LIMIT 50
|
|
529
633
|
`).all(`"${query.replace(/"/g, '""')}"*`);
|
|
530
634
|
const emailRows = d.query(`
|
|
531
635
|
SELECT DISTINCT c.* FROM contacts c
|
|
532
636
|
JOIN emails e ON e.contact_id = c.id
|
|
533
|
-
WHERE e.address LIKE ?
|
|
637
|
+
WHERE e.address LIKE ? AND c.archived = 0
|
|
534
638
|
LIMIT 20
|
|
535
639
|
`).all(`%${query}%`);
|
|
536
640
|
const phoneRows = d.query(`
|
|
537
641
|
SELECT DISTINCT c.* FROM contacts c
|
|
538
642
|
JOIN phones p ON p.contact_id = c.id
|
|
539
|
-
WHERE p.number LIKE ?
|
|
643
|
+
WHERE p.number LIKE ? AND c.archived = 0
|
|
644
|
+
LIMIT 20
|
|
645
|
+
`).all(`%${query}%`);
|
|
646
|
+
const companyRows = d.query(`
|
|
647
|
+
SELECT DISTINCT c.* FROM contacts c
|
|
648
|
+
JOIN companies co ON co.id = c.company_id
|
|
649
|
+
WHERE co.name LIKE ? AND c.archived = 0
|
|
540
650
|
LIMIT 20
|
|
541
651
|
`).all(`%${query}%`);
|
|
542
652
|
const seen = new Set;
|
|
543
653
|
const allRows = [];
|
|
544
|
-
for (const row of [...ftsRows, ...emailRows, ...phoneRows]) {
|
|
654
|
+
for (const row of [...ftsRows, ...emailRows, ...phoneRows, ...companyRows]) {
|
|
545
655
|
if (!seen.has(row.id)) {
|
|
546
656
|
seen.add(row.id);
|
|
547
657
|
allRows.push(row);
|
|
@@ -557,8 +667,24 @@ function mergeContacts(keepId, mergeId, db) {
|
|
|
557
667
|
const mergeRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(mergeId);
|
|
558
668
|
if (!mergeRow)
|
|
559
669
|
throw new ContactNotFoundError(mergeId);
|
|
560
|
-
d.
|
|
561
|
-
|
|
670
|
+
const mergeEmailRows = d.query(`SELECT * FROM emails WHERE contact_id = ?`).all(mergeId);
|
|
671
|
+
for (const e of mergeEmailRows) {
|
|
672
|
+
const exists = d.query(`SELECT id FROM emails WHERE contact_id = ? AND LOWER(address) = LOWER(?)`).get(keepId, e.address);
|
|
673
|
+
if (exists) {
|
|
674
|
+
d.run(`DELETE FROM emails WHERE id = ?`, [e.id]);
|
|
675
|
+
} else {
|
|
676
|
+
d.run(`UPDATE emails SET contact_id = ? WHERE id = ?`, [keepId, e.id]);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const mergePhoneRows = d.query(`SELECT * FROM phones WHERE contact_id = ?`).all(mergeId);
|
|
680
|
+
for (const p of mergePhoneRows) {
|
|
681
|
+
const exists = d.query(`SELECT id FROM phones WHERE contact_id = ? AND number = ?`).get(keepId, p.number);
|
|
682
|
+
if (exists) {
|
|
683
|
+
d.run(`DELETE FROM phones WHERE id = ?`, [p.id]);
|
|
684
|
+
} else {
|
|
685
|
+
d.run(`UPDATE phones SET contact_id = ? WHERE id = ?`, [keepId, p.id]);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
562
688
|
d.run(`UPDATE addresses SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
|
|
563
689
|
d.run(`UPDATE social_profiles SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
|
|
564
690
|
const mergeTags = d.query(`SELECT tag_id FROM contact_tags WHERE contact_id = ?`).all(mergeId);
|
|
@@ -605,6 +731,79 @@ function mergeContacts(keepId, mergeId, db) {
|
|
|
605
731
|
const finalRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(keepId);
|
|
606
732
|
return loadContactDetails(d, rowToContact(finalRow));
|
|
607
733
|
}
|
|
734
|
+
function getContactByEmail(email, db) {
|
|
735
|
+
const d = db || getDatabase();
|
|
736
|
+
const emailRow = d.query(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(email);
|
|
737
|
+
if (!emailRow)
|
|
738
|
+
return null;
|
|
739
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(emailRow.contact_id);
|
|
740
|
+
if (!row)
|
|
741
|
+
return null;
|
|
742
|
+
return loadContactDetails(d, rowToContact(row));
|
|
743
|
+
}
|
|
744
|
+
function addEmailToContact(contactId, email, db) {
|
|
745
|
+
const d = db || getDatabase();
|
|
746
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
|
|
747
|
+
if (!row)
|
|
748
|
+
throw new ContactNotFoundError(contactId);
|
|
749
|
+
const exists = d.query(`SELECT id FROM emails WHERE contact_id = ? AND LOWER(address) = LOWER(?)`).get(contactId, email.address);
|
|
750
|
+
if (!exists) {
|
|
751
|
+
d.run(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?)`, [uuid(), contactId, email.address, email.type ?? "work", email.is_primary ? 1 : 0]);
|
|
752
|
+
}
|
|
753
|
+
const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
|
|
754
|
+
return loadContactDetails(d, rowToContact(updated));
|
|
755
|
+
}
|
|
756
|
+
function addPhoneToContact(contactId, phone, db) {
|
|
757
|
+
const d = db || getDatabase();
|
|
758
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
|
|
759
|
+
if (!row)
|
|
760
|
+
throw new ContactNotFoundError(contactId);
|
|
761
|
+
const exists = d.query(`SELECT id FROM phones WHERE contact_id = ? AND number = ?`).get(contactId, phone.number);
|
|
762
|
+
if (!exists) {
|
|
763
|
+
d.run(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?, ?)`, [uuid(), contactId, phone.number, phone.country_code ?? null, phone.type ?? "mobile", phone.is_primary ? 1 : 0]);
|
|
764
|
+
}
|
|
765
|
+
const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
|
|
766
|
+
return loadContactDetails(d, rowToContact(updated));
|
|
767
|
+
}
|
|
768
|
+
function archiveContact(id, db) {
|
|
769
|
+
const d = db || getDatabase();
|
|
770
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
771
|
+
if (!row)
|
|
772
|
+
throw new ContactNotFoundError(id);
|
|
773
|
+
d.run(`UPDATE contacts SET archived = 1, updated_at = ? WHERE id = ?`, [now(), id]);
|
|
774
|
+
logActivity(d, { contact_id: id, action: "contact.archived", details: `Archived contact: ${row.display_name}` });
|
|
775
|
+
const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
776
|
+
return loadContactDetails(d, rowToContact(updated));
|
|
777
|
+
}
|
|
778
|
+
function unarchiveContact(id, db) {
|
|
779
|
+
const d = db || getDatabase();
|
|
780
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
781
|
+
if (!row)
|
|
782
|
+
throw new ContactNotFoundError(id);
|
|
783
|
+
d.run(`UPDATE contacts SET archived = 0, updated_at = ? WHERE id = ?`, [now(), id]);
|
|
784
|
+
logActivity(d, { contact_id: id, action: "contact.unarchived", details: `Unarchived contact: ${row.display_name}` });
|
|
785
|
+
const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
786
|
+
return loadContactDetails(d, rowToContact(updated));
|
|
787
|
+
}
|
|
788
|
+
function autoLinkContactToCompany(contactId, db) {
|
|
789
|
+
const d = db || getDatabase();
|
|
790
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
|
|
791
|
+
if (!row || row.company_id)
|
|
792
|
+
return null;
|
|
793
|
+
const emailRow = d.query(`SELECT address FROM emails WHERE contact_id = ? AND contact_id IS NOT NULL LIMIT 1`).get(contactId);
|
|
794
|
+
if (!emailRow)
|
|
795
|
+
return null;
|
|
796
|
+
const domain = emailRow.address.split("@")[1];
|
|
797
|
+
if (!domain)
|
|
798
|
+
return null;
|
|
799
|
+
const companyRow = d.query(`SELECT id FROM companies WHERE domain = ? LIMIT 1`).get(domain);
|
|
800
|
+
if (!companyRow)
|
|
801
|
+
return null;
|
|
802
|
+
d.run(`UPDATE contacts SET company_id = ?, updated_at = ? WHERE id = ?`, [companyRow.id, now(), contactId]);
|
|
803
|
+
logActivity(d, { contact_id: contactId, action: "contact.auto_linked", details: `Auto-linked to company via email domain: ${domain}` });
|
|
804
|
+
const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
|
|
805
|
+
return loadContactDetails(d, rowToContact(updated));
|
|
806
|
+
}
|
|
608
807
|
|
|
609
808
|
// src/db/groups.ts
|
|
610
809
|
function createGroup(db, input) {
|
|
@@ -616,7 +815,10 @@ function getGroup(db, id) {
|
|
|
616
815
|
return db.query(`SELECT * FROM groups WHERE id = ?`).get(id);
|
|
617
816
|
}
|
|
618
817
|
function listGroups(db) {
|
|
619
|
-
return db.query(`SELECT g.*,
|
|
818
|
+
return db.query(`SELECT g.*,
|
|
819
|
+
(SELECT COUNT(*) FROM contact_groups cg WHERE cg.group_id = g.id) as member_count,
|
|
820
|
+
(SELECT COUNT(*) FROM company_groups cog WHERE cog.group_id = g.id) as company_count
|
|
821
|
+
FROM groups g ORDER BY g.name`).all();
|
|
620
822
|
}
|
|
621
823
|
function updateGroup(db, id, input) {
|
|
622
824
|
const fields = [];
|
|
@@ -639,7 +841,11 @@ function deleteGroup(db, id) {
|
|
|
639
841
|
db.query(`DELETE FROM groups WHERE id = ?`).run(id);
|
|
640
842
|
}
|
|
641
843
|
function addContactToGroup(db, contactId, groupId) {
|
|
642
|
-
db.query(`
|
|
844
|
+
const existing = db.query(`SELECT 1 FROM contact_groups WHERE contact_id = ? AND group_id = ?`).get(contactId, groupId);
|
|
845
|
+
if (existing)
|
|
846
|
+
return { added: false, already_member: true };
|
|
847
|
+
db.query(`INSERT INTO contact_groups(contact_id, group_id) VALUES(?,?)`).run(contactId, groupId);
|
|
848
|
+
return { added: true, already_member: false };
|
|
643
849
|
}
|
|
644
850
|
function removeContactFromGroup(db, contactId, groupId) {
|
|
645
851
|
db.query(`DELETE FROM contact_groups WHERE contact_id = ? AND group_id = ?`).run(contactId, groupId);
|
|
@@ -651,6 +857,23 @@ function listContactsInGroup(db, groupId) {
|
|
|
651
857
|
function listGroupsForContact(db, contactId) {
|
|
652
858
|
return db.query(`SELECT g.* FROM groups g JOIN contact_groups cg ON g.id = cg.group_id WHERE cg.contact_id = ? ORDER BY g.name`).all(contactId);
|
|
653
859
|
}
|
|
860
|
+
function addCompanyToGroup(db, companyId, groupId) {
|
|
861
|
+
const existing = db.query(`SELECT 1 FROM company_groups WHERE company_id = ? AND group_id = ?`).get(companyId, groupId);
|
|
862
|
+
if (existing)
|
|
863
|
+
return { added: false, already_member: true };
|
|
864
|
+
db.query(`INSERT INTO company_groups(company_id, group_id) VALUES(?,?)`).run(companyId, groupId);
|
|
865
|
+
return { added: true, already_member: false };
|
|
866
|
+
}
|
|
867
|
+
function removeCompanyFromGroup(db, companyId, groupId) {
|
|
868
|
+
db.query(`DELETE FROM company_groups WHERE company_id = ? AND group_id = ?`).run(companyId, groupId);
|
|
869
|
+
}
|
|
870
|
+
function listCompaniesInGroup(db, groupId) {
|
|
871
|
+
const rows = db.query(`SELECT company_id FROM company_groups WHERE group_id = ?`).all(groupId);
|
|
872
|
+
return rows.map((r) => r.company_id);
|
|
873
|
+
}
|
|
874
|
+
function listGroupsForCompany(db, companyId) {
|
|
875
|
+
return db.query(`SELECT g.* FROM groups g JOIN company_groups cog ON g.id = cog.group_id WHERE cog.company_id = ? ORDER BY g.name`).all(companyId);
|
|
876
|
+
}
|
|
654
877
|
|
|
655
878
|
// src/db/tags.ts
|
|
656
879
|
function rowToTag2(row) {
|
|
@@ -695,12 +918,28 @@ function removeTagFromContact(contactId, tagId, db) {
|
|
|
695
918
|
const d = db || getDatabase();
|
|
696
919
|
d.run(`DELETE FROM contact_tags WHERE contact_id = ? AND tag_id = ?`, [contactId, tagId]);
|
|
697
920
|
}
|
|
921
|
+
function addTagToCompany(companyId, tagId, db) {
|
|
922
|
+
const d = db || getDatabase();
|
|
923
|
+
const company = d.query(`SELECT id FROM companies WHERE id = ?`).get(companyId);
|
|
924
|
+
if (!company)
|
|
925
|
+
throw new CompanyNotFoundError(companyId);
|
|
926
|
+
const tag = d.query(`SELECT id FROM tags WHERE id = ?`).get(tagId);
|
|
927
|
+
if (!tag)
|
|
928
|
+
throw new TagNotFoundError(tagId);
|
|
929
|
+
d.run(`INSERT OR IGNORE INTO company_tags (company_id, tag_id) VALUES (?, ?)`, [companyId, tagId]);
|
|
930
|
+
}
|
|
931
|
+
function removeTagFromCompany(companyId, tagId, db) {
|
|
932
|
+
const d = db || getDatabase();
|
|
933
|
+
d.run(`DELETE FROM company_tags WHERE company_id = ? AND tag_id = ?`, [companyId, tagId]);
|
|
934
|
+
}
|
|
698
935
|
|
|
699
936
|
// src/db/companies.ts
|
|
700
937
|
function rowToCompany2(row) {
|
|
701
938
|
return {
|
|
702
939
|
...row,
|
|
703
|
-
custom_fields: JSON.parse(row.custom_fields || "{}")
|
|
940
|
+
custom_fields: JSON.parse(row.custom_fields || "{}"),
|
|
941
|
+
archived: !!row.archived,
|
|
942
|
+
project_id: row.project_id ?? null
|
|
704
943
|
};
|
|
705
944
|
}
|
|
706
945
|
function insertEmails2(db, companyId, emails) {
|
|
@@ -810,15 +1049,23 @@ function listCompanies(opts = {}, db) {
|
|
|
810
1049
|
offset = 0,
|
|
811
1050
|
industry,
|
|
812
1051
|
tag_id,
|
|
1052
|
+
project_id,
|
|
1053
|
+
archived = false,
|
|
813
1054
|
order_by = "name",
|
|
814
1055
|
order_dir = "asc"
|
|
815
1056
|
} = opts;
|
|
816
1057
|
const conditions = [];
|
|
817
1058
|
const params = [];
|
|
1059
|
+
conditions.push("co.archived = ?");
|
|
1060
|
+
params.push(archived ? 1 : 0);
|
|
818
1061
|
if (industry) {
|
|
819
1062
|
conditions.push("co.industry = ?");
|
|
820
1063
|
params.push(industry);
|
|
821
1064
|
}
|
|
1065
|
+
if (project_id) {
|
|
1066
|
+
conditions.push("co.project_id = ?");
|
|
1067
|
+
params.push(project_id);
|
|
1068
|
+
}
|
|
822
1069
|
if (tag_id) {
|
|
823
1070
|
conditions.push("EXISTS (SELECT 1 FROM company_tags ct WHERE ct.company_id = co.id AND ct.tag_id = ?)");
|
|
824
1071
|
params.push(tag_id);
|
|
@@ -874,6 +1121,10 @@ function updateCompany(id, input, db) {
|
|
|
874
1121
|
setClauses.push("custom_fields = ?");
|
|
875
1122
|
params.push(JSON.stringify(input.custom_fields));
|
|
876
1123
|
}
|
|
1124
|
+
if ("project_id" in input && input.project_id !== undefined) {
|
|
1125
|
+
setClauses.push("project_id = ?");
|
|
1126
|
+
params.push(input.project_id);
|
|
1127
|
+
}
|
|
877
1128
|
params.push(id);
|
|
878
1129
|
d.run(`UPDATE companies SET ${setClauses.join(", ")} WHERE id = ?`, params);
|
|
879
1130
|
logActivity(d, { company_id: id, action: "company.updated", details: `Updated company: ${existing.name}` });
|
|
@@ -897,6 +1148,26 @@ function searchCompanies(query, db) {
|
|
|
897
1148
|
`).all(`%${query}%`, `%${query}%`, `%${query}%`, `%${query}%`);
|
|
898
1149
|
return rows.map((row) => loadCompanyDetails(d, rowToCompany2(row)));
|
|
899
1150
|
}
|
|
1151
|
+
function archiveCompany(id, db) {
|
|
1152
|
+
const d = db || getDatabase();
|
|
1153
|
+
const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
1154
|
+
if (!row)
|
|
1155
|
+
throw new CompanyNotFoundError(id);
|
|
1156
|
+
d.run(`UPDATE companies SET archived = 1, updated_at = ? WHERE id = ?`, [now(), id]);
|
|
1157
|
+
logActivity(d, { company_id: id, action: "company.archived", details: `Archived company: ${row.name}` });
|
|
1158
|
+
const updated = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
1159
|
+
return loadCompanyDetails(d, rowToCompany2(updated));
|
|
1160
|
+
}
|
|
1161
|
+
function unarchiveCompany(id, db) {
|
|
1162
|
+
const d = db || getDatabase();
|
|
1163
|
+
const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
1164
|
+
if (!row)
|
|
1165
|
+
throw new CompanyNotFoundError(id);
|
|
1166
|
+
d.run(`UPDATE companies SET archived = 0, updated_at = ? WHERE id = ?`, [now(), id]);
|
|
1167
|
+
logActivity(d, { company_id: id, action: "company.unarchived", details: `Unarchived company: ${row.name}` });
|
|
1168
|
+
const updated = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
1169
|
+
return loadCompanyDetails(d, rowToCompany2(updated));
|
|
1170
|
+
}
|
|
900
1171
|
|
|
901
1172
|
// src/db/relationships.ts
|
|
902
1173
|
function rowToRelationship(row) {
|
|
@@ -939,6 +1210,39 @@ function deleteRelationship(id, db) {
|
|
|
939
1210
|
d.run(`DELETE FROM contact_relationships WHERE id = ?`, [id]);
|
|
940
1211
|
}
|
|
941
1212
|
|
|
1213
|
+
// src/lib/dedup.ts
|
|
1214
|
+
function findEmailDuplicates(db) {
|
|
1215
|
+
const rows = db.query(`
|
|
1216
|
+
SELECT e.address as email, GROUP_CONCAT(e.contact_id) as ids
|
|
1217
|
+
FROM emails e
|
|
1218
|
+
WHERE e.contact_id IS NOT NULL
|
|
1219
|
+
GROUP BY LOWER(e.address)
|
|
1220
|
+
HAVING COUNT(*) > 1
|
|
1221
|
+
`).all();
|
|
1222
|
+
return rows.map((r) => ({ email: r.email, contact_ids: r.ids.split(",") }));
|
|
1223
|
+
}
|
|
1224
|
+
function levenshtein(a, b) {
|
|
1225
|
+
const m = a.length, n = b.length;
|
|
1226
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0));
|
|
1227
|
+
for (let i = 1;i <= m; i++)
|
|
1228
|
+
for (let j = 1;j <= n; j++)
|
|
1229
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
1230
|
+
return dp[m][n];
|
|
1231
|
+
}
|
|
1232
|
+
function findNameDuplicates(db) {
|
|
1233
|
+
const contacts = db.query(`SELECT id, display_name FROM contacts`).all();
|
|
1234
|
+
const pairs = [];
|
|
1235
|
+
for (let i = 0;i < contacts.length; i++) {
|
|
1236
|
+
for (let j = i + 1;j < contacts.length; j++) {
|
|
1237
|
+
const dist = levenshtein(contacts[i].display_name.toLowerCase(), contacts[j].display_name.toLowerCase());
|
|
1238
|
+
if (dist <= 2 && dist > 0) {
|
|
1239
|
+
pairs.push({ contact_ids: [contacts[i].id, contacts[j].id], similarity: dist });
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return pairs;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
942
1246
|
// src/lib/import.ts
|
|
943
1247
|
function parseCsv(text) {
|
|
944
1248
|
const lines = text.split(/\r?\n/);
|
|
@@ -1405,6 +1709,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1405
1709
|
website: { type: "string", description: "Personal or professional website URL" },
|
|
1406
1710
|
last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
|
|
1407
1711
|
preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
|
|
1712
|
+
status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"], description: "Contact lifecycle status (default: active)" },
|
|
1713
|
+
follow_up_at: { type: "string", description: "ISO 8601 datetime to follow up with this contact" },
|
|
1714
|
+
project_id: { type: "string", description: "Associate contact with a project ID" },
|
|
1408
1715
|
emails: {
|
|
1409
1716
|
type: "array",
|
|
1410
1717
|
items: {
|
|
@@ -1473,7 +1780,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1473
1780
|
},
|
|
1474
1781
|
{
|
|
1475
1782
|
name: "update_contact",
|
|
1476
|
-
description: "Update an existing contact's fields. Only provided fields are changed
|
|
1783
|
+
description: "Update an existing contact's fields. Only provided fields are changed. Supports all contact fields including status, follow_up_at, project_id. Use emails_add/phones_add to append new contact methods without replacing existing ones.",
|
|
1477
1784
|
inputSchema: {
|
|
1478
1785
|
type: "object",
|
|
1479
1786
|
properties: {
|
|
@@ -1489,7 +1796,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1489
1796
|
website: { type: "string" },
|
|
1490
1797
|
last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
|
|
1491
1798
|
preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
|
|
1492
|
-
|
|
1799
|
+
status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"] },
|
|
1800
|
+
follow_up_at: { type: "string", description: "ISO 8601 datetime for follow-up reminder (null to clear)" },
|
|
1801
|
+
project_id: { type: "string", description: "Project ID to associate this contact with (null to clear)" },
|
|
1802
|
+
source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
|
|
1803
|
+
emails_add: { type: "array", items: { type: "object", properties: { address: { type: "string" }, type: { type: "string" }, is_primary: { type: "boolean" } }, required: ["address"] }, description: "New email addresses to append (duplicates are skipped)" },
|
|
1804
|
+
phones_add: { type: "array", items: { type: "object", properties: { number: { type: "string" }, type: { type: "string" }, country_code: { type: "string" }, is_primary: { type: "boolean" } }, required: ["number"] }, description: "New phone numbers to append (duplicates are skipped)" }
|
|
1493
1805
|
},
|
|
1494
1806
|
required: ["id"]
|
|
1495
1807
|
}
|
|
@@ -1505,15 +1817,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1505
1817
|
},
|
|
1506
1818
|
{
|
|
1507
1819
|
name: "list_contacts",
|
|
1508
|
-
description: "List contacts with optional filters. Supports filtering by company, tag,
|
|
1820
|
+
description: "List contacts with optional filters. Supports filtering by company, tag(s), source, status, project, follow-up due, last_contacted date range, and archived state. Returns paginated results with total count.",
|
|
1509
1821
|
inputSchema: {
|
|
1510
1822
|
type: "object",
|
|
1511
1823
|
properties: {
|
|
1512
1824
|
company_id: { type: "string" },
|
|
1513
|
-
tag_id: { type: "string", description: "Filter by tag ID" },
|
|
1825
|
+
tag_id: { type: "string", description: "Filter by a single tag ID" },
|
|
1826
|
+
tag_ids: { type: "array", items: { type: "string" }, description: "Filter by multiple tag IDs (AND logic \u2014 contact must have all tags)" },
|
|
1827
|
+
source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
|
|
1828
|
+
status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"] },
|
|
1829
|
+
project_id: { type: "string", description: "Filter by project ID" },
|
|
1830
|
+
archived: { type: "boolean", description: "Include archived contacts (default false)" },
|
|
1831
|
+
follow_up_due: { type: "boolean", description: "Only return contacts whose follow_up_at is in the past" },
|
|
1832
|
+
last_contacted_after: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted after this date" },
|
|
1833
|
+
last_contacted_before: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted before this date" },
|
|
1514
1834
|
limit: { type: "number", description: "Max results (default 50)" },
|
|
1515
1835
|
offset: { type: "number" },
|
|
1516
|
-
order_by: { type: "string", enum: ["display_name", "created_at", "updated_at"] },
|
|
1836
|
+
order_by: { type: "string", enum: ["display_name", "created_at", "updated_at", "last_contacted_at", "follow_up_at"] },
|
|
1517
1837
|
order_dir: { type: "string", enum: ["asc", "desc"] }
|
|
1518
1838
|
}
|
|
1519
1839
|
}
|
|
@@ -1587,12 +1907,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1587
1907
|
},
|
|
1588
1908
|
{
|
|
1589
1909
|
name: "list_companies",
|
|
1590
|
-
description: "List companies with optional filters by industry or
|
|
1910
|
+
description: "List companies with optional filters by industry, tag, project, or archived state. Returns paginated results with total count.",
|
|
1591
1911
|
inputSchema: {
|
|
1592
1912
|
type: "object",
|
|
1593
1913
|
properties: {
|
|
1594
1914
|
tag_id: { type: "string" },
|
|
1595
1915
|
industry: { type: "string" },
|
|
1916
|
+
project_id: { type: "string", description: "Filter by project ID" },
|
|
1917
|
+
archived: { type: "boolean", description: "Include archived companies (default false)" },
|
|
1596
1918
|
limit: { type: "number" },
|
|
1597
1919
|
offset: { type: "number" }
|
|
1598
1920
|
}
|
|
@@ -1927,6 +2249,188 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1927
2249
|
properties: { contact_id: { type: "string" } },
|
|
1928
2250
|
required: ["contact_id"]
|
|
1929
2251
|
}
|
|
2252
|
+
},
|
|
2253
|
+
{
|
|
2254
|
+
name: "get_contact_by_email",
|
|
2255
|
+
description: "Fast lookup of a contact by exact email address. Returns null if not found. Unlike search_contacts, this is a precise read-only lookup with no side effects.",
|
|
2256
|
+
inputSchema: {
|
|
2257
|
+
type: "object",
|
|
2258
|
+
properties: { email: { type: "string", description: "Exact email address to look up" } },
|
|
2259
|
+
required: ["email"]
|
|
2260
|
+
}
|
|
2261
|
+
},
|
|
2262
|
+
{
|
|
2263
|
+
name: "add_email_to_contact",
|
|
2264
|
+
description: "Append a new email address to a contact. Idempotent \u2014 silently skips if the email already exists on this contact.",
|
|
2265
|
+
inputSchema: {
|
|
2266
|
+
type: "object",
|
|
2267
|
+
properties: {
|
|
2268
|
+
contact_id: { type: "string" },
|
|
2269
|
+
address: { type: "string" },
|
|
2270
|
+
type: { type: "string", enum: ["work", "personal", "other"] },
|
|
2271
|
+
is_primary: { type: "boolean" }
|
|
2272
|
+
},
|
|
2273
|
+
required: ["contact_id", "address"]
|
|
2274
|
+
}
|
|
2275
|
+
},
|
|
2276
|
+
{
|
|
2277
|
+
name: "add_phone_to_contact",
|
|
2278
|
+
description: "Append a new phone number to a contact. Idempotent \u2014 silently skips if the number already exists on this contact.",
|
|
2279
|
+
inputSchema: {
|
|
2280
|
+
type: "object",
|
|
2281
|
+
properties: {
|
|
2282
|
+
contact_id: { type: "string" },
|
|
2283
|
+
number: { type: "string" },
|
|
2284
|
+
type: { type: "string", enum: ["mobile", "work", "home", "fax", "whatsapp", "other"] },
|
|
2285
|
+
country_code: { type: "string" },
|
|
2286
|
+
is_primary: { type: "boolean" }
|
|
2287
|
+
},
|
|
2288
|
+
required: ["contact_id", "number"]
|
|
2289
|
+
}
|
|
2290
|
+
},
|
|
2291
|
+
{
|
|
2292
|
+
name: "archive_contact",
|
|
2293
|
+
description: "Soft-delete a contact by setting archived=true. Archived contacts are excluded from list_contacts and search_contacts by default. Use unarchive_contact to restore.",
|
|
2294
|
+
inputSchema: {
|
|
2295
|
+
type: "object",
|
|
2296
|
+
properties: { id: { type: "string" } },
|
|
2297
|
+
required: ["id"]
|
|
2298
|
+
}
|
|
2299
|
+
},
|
|
2300
|
+
{
|
|
2301
|
+
name: "unarchive_contact",
|
|
2302
|
+
description: "Restore an archived contact. Sets archived=false so the contact reappears in lists and searches.",
|
|
2303
|
+
inputSchema: {
|
|
2304
|
+
type: "object",
|
|
2305
|
+
properties: { id: { type: "string" } },
|
|
2306
|
+
required: ["id"]
|
|
2307
|
+
}
|
|
2308
|
+
},
|
|
2309
|
+
{
|
|
2310
|
+
name: "archive_company",
|
|
2311
|
+
description: "Soft-delete a company by setting archived=true. Archived companies are excluded from list_companies by default.",
|
|
2312
|
+
inputSchema: {
|
|
2313
|
+
type: "object",
|
|
2314
|
+
properties: { id: { type: "string" } },
|
|
2315
|
+
required: ["id"]
|
|
2316
|
+
}
|
|
2317
|
+
},
|
|
2318
|
+
{
|
|
2319
|
+
name: "unarchive_company",
|
|
2320
|
+
description: "Restore an archived company. Sets archived=false.",
|
|
2321
|
+
inputSchema: {
|
|
2322
|
+
type: "object",
|
|
2323
|
+
properties: { id: { type: "string" } },
|
|
2324
|
+
required: ["id"]
|
|
2325
|
+
}
|
|
2326
|
+
},
|
|
2327
|
+
{
|
|
2328
|
+
name: "find_duplicates",
|
|
2329
|
+
description: "Scan contacts for potential duplicates \u2014 by shared email address (exact) or by similar display name (Levenshtein distance \u2264 2). Returns groups of contact IDs that may be the same person.",
|
|
2330
|
+
inputSchema: { type: "object", properties: {} }
|
|
2331
|
+
},
|
|
2332
|
+
{
|
|
2333
|
+
name: "list_interactions",
|
|
2334
|
+
description: "List activity log entries for a contact or company. Shows all logged interactions, creates, updates, and merges in reverse chronological order.",
|
|
2335
|
+
inputSchema: {
|
|
2336
|
+
type: "object",
|
|
2337
|
+
properties: {
|
|
2338
|
+
contact_id: { type: "string" },
|
|
2339
|
+
company_id: { type: "string" },
|
|
2340
|
+
limit: { type: "number", description: "Max results (default 50)" },
|
|
2341
|
+
offset: { type: "number" }
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
},
|
|
2345
|
+
{
|
|
2346
|
+
name: "add_tag_to_company",
|
|
2347
|
+
description: "Apply an existing tag to a company. Use list_tags to find tag IDs.",
|
|
2348
|
+
inputSchema: {
|
|
2349
|
+
type: "object",
|
|
2350
|
+
properties: {
|
|
2351
|
+
company_id: { type: "string" },
|
|
2352
|
+
tag_id: { type: "string" }
|
|
2353
|
+
},
|
|
2354
|
+
required: ["company_id", "tag_id"]
|
|
2355
|
+
}
|
|
2356
|
+
},
|
|
2357
|
+
{
|
|
2358
|
+
name: "remove_tag_from_company",
|
|
2359
|
+
description: "Remove a tag from a company. The tag itself is not deleted.",
|
|
2360
|
+
inputSchema: {
|
|
2361
|
+
type: "object",
|
|
2362
|
+
properties: {
|
|
2363
|
+
company_id: { type: "string" },
|
|
2364
|
+
tag_id: { type: "string" }
|
|
2365
|
+
},
|
|
2366
|
+
required: ["company_id", "tag_id"]
|
|
2367
|
+
}
|
|
2368
|
+
},
|
|
2369
|
+
{
|
|
2370
|
+
name: "add_company_to_group",
|
|
2371
|
+
description: "Add a company to a group. Returns { added, already_member } \u2014 idempotent.",
|
|
2372
|
+
inputSchema: {
|
|
2373
|
+
type: "object",
|
|
2374
|
+
properties: {
|
|
2375
|
+
company_id: { type: "string" },
|
|
2376
|
+
group_id: { type: "string" }
|
|
2377
|
+
},
|
|
2378
|
+
required: ["company_id", "group_id"]
|
|
2379
|
+
}
|
|
2380
|
+
},
|
|
2381
|
+
{
|
|
2382
|
+
name: "remove_company_from_group",
|
|
2383
|
+
description: "Remove a company from a group.",
|
|
2384
|
+
inputSchema: {
|
|
2385
|
+
type: "object",
|
|
2386
|
+
properties: {
|
|
2387
|
+
company_id: { type: "string" },
|
|
2388
|
+
group_id: { type: "string" }
|
|
2389
|
+
},
|
|
2390
|
+
required: ["company_id", "group_id"]
|
|
2391
|
+
}
|
|
2392
|
+
},
|
|
2393
|
+
{
|
|
2394
|
+
name: "list_companies_in_group",
|
|
2395
|
+
description: "List all company IDs in a group.",
|
|
2396
|
+
inputSchema: {
|
|
2397
|
+
type: "object",
|
|
2398
|
+
properties: { group_id: { type: "string" } },
|
|
2399
|
+
required: ["group_id"]
|
|
2400
|
+
}
|
|
2401
|
+
},
|
|
2402
|
+
{
|
|
2403
|
+
name: "list_groups_for_company",
|
|
2404
|
+
description: "List all groups that a company belongs to.",
|
|
2405
|
+
inputSchema: {
|
|
2406
|
+
type: "object",
|
|
2407
|
+
properties: { company_id: { type: "string" } },
|
|
2408
|
+
required: ["company_id"]
|
|
2409
|
+
}
|
|
2410
|
+
},
|
|
2411
|
+
{
|
|
2412
|
+
name: "bulk_create_contacts",
|
|
2413
|
+
description: "Create multiple contacts in one call. Each item in the contacts array follows the same schema as create_contact. Returns { created, errors }.",
|
|
2414
|
+
inputSchema: {
|
|
2415
|
+
type: "object",
|
|
2416
|
+
properties: {
|
|
2417
|
+
contacts: {
|
|
2418
|
+
type: "array",
|
|
2419
|
+
items: { type: "object" },
|
|
2420
|
+
description: "Array of contact input objects (same schema as create_contact)"
|
|
2421
|
+
}
|
|
2422
|
+
},
|
|
2423
|
+
required: ["contacts"]
|
|
2424
|
+
}
|
|
2425
|
+
},
|
|
2426
|
+
{
|
|
2427
|
+
name: "auto_link_to_company",
|
|
2428
|
+
description: "Auto-link a contact to a company by matching the contact's email domain against known company domains. Only sets company_id if the contact has no company yet and a matching company exists. Returns the updated contact, or null if no match.",
|
|
2429
|
+
inputSchema: {
|
|
2430
|
+
type: "object",
|
|
2431
|
+
properties: { contact_id: { type: "string" } },
|
|
2432
|
+
required: ["contact_id"]
|
|
2433
|
+
}
|
|
1930
2434
|
}
|
|
1931
2435
|
]
|
|
1932
2436
|
}));
|
|
@@ -1948,6 +2452,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1948
2452
|
website: a.website,
|
|
1949
2453
|
last_contacted_at: a.last_contacted_at,
|
|
1950
2454
|
preferred_contact_method: a.preferred_contact_method,
|
|
2455
|
+
status: a.status,
|
|
2456
|
+
follow_up_at: a.follow_up_at,
|
|
2457
|
+
project_id: a.project_id,
|
|
1951
2458
|
emails: a.emails,
|
|
1952
2459
|
phones: a.phones,
|
|
1953
2460
|
addresses: a.addresses,
|
|
@@ -1976,7 +2483,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1976
2483
|
website: rest.website,
|
|
1977
2484
|
last_contacted_at: rest.last_contacted_at,
|
|
1978
2485
|
preferred_contact_method: rest.preferred_contact_method,
|
|
1979
|
-
|
|
2486
|
+
status: rest.status,
|
|
2487
|
+
follow_up_at: rest.follow_up_at,
|
|
2488
|
+
project_id: rest.project_id,
|
|
2489
|
+
source: rest.source,
|
|
2490
|
+
emails_add: rest.emails_add,
|
|
2491
|
+
phones_add: rest.phones_add
|
|
1980
2492
|
};
|
|
1981
2493
|
const contact = updateContact(id, input);
|
|
1982
2494
|
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
@@ -1989,6 +2501,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1989
2501
|
const result = listContacts({
|
|
1990
2502
|
company_id: a.company_id,
|
|
1991
2503
|
tag_id: a.tag_id,
|
|
2504
|
+
tag_ids: a.tag_ids,
|
|
2505
|
+
source: a.source,
|
|
2506
|
+
status: a.status,
|
|
2507
|
+
project_id: a.project_id,
|
|
2508
|
+
archived: a.archived,
|
|
2509
|
+
follow_up_due: a.follow_up_due,
|
|
2510
|
+
last_contacted_after: a.last_contacted_after,
|
|
2511
|
+
last_contacted_before: a.last_contacted_before,
|
|
1992
2512
|
limit: a.limit,
|
|
1993
2513
|
offset: a.offset,
|
|
1994
2514
|
order_by: a.order_by,
|
|
@@ -2001,6 +2521,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2001
2521
|
return { content: [{ type: "text", text: JSON.stringify(contacts, null, 2) }] };
|
|
2002
2522
|
}
|
|
2003
2523
|
case "create_company": {
|
|
2524
|
+
const rawTagIds = a.tag_ids;
|
|
2525
|
+
const tagIds = typeof rawTagIds === "string" ? JSON.parse(rawTagIds) : rawTagIds;
|
|
2004
2526
|
const input = {
|
|
2005
2527
|
name: a.name,
|
|
2006
2528
|
domain: a.domain,
|
|
@@ -2013,7 +2535,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2013
2535
|
phones: a.phones,
|
|
2014
2536
|
addresses: a.addresses,
|
|
2015
2537
|
social_profiles: a.social_profiles,
|
|
2016
|
-
tag_ids:
|
|
2538
|
+
tag_ids: tagIds
|
|
2017
2539
|
};
|
|
2018
2540
|
const company = createCompany(input);
|
|
2019
2541
|
return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
|
|
@@ -2047,6 +2569,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2047
2569
|
const result = listCompanies({
|
|
2048
2570
|
tag_id: a.tag_id,
|
|
2049
2571
|
industry: a.industry,
|
|
2572
|
+
project_id: a.project_id,
|
|
2573
|
+
archived: a.archived,
|
|
2050
2574
|
limit: a.limit,
|
|
2051
2575
|
offset: a.offset
|
|
2052
2576
|
});
|
|
@@ -2170,21 +2694,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2170
2694
|
const emailAddresses = a.emails?.map((e) => e.address) ?? [];
|
|
2171
2695
|
let found = null;
|
|
2172
2696
|
for (const addr of emailAddresses) {
|
|
2173
|
-
const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE address = ? AND contact_id IS NOT NULL LIMIT 1`).get(addr);
|
|
2697
|
+
const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(addr);
|
|
2174
2698
|
if (emailRow) {
|
|
2175
2699
|
found = getContact(emailRow.contact_id);
|
|
2176
2700
|
break;
|
|
2177
2701
|
}
|
|
2178
2702
|
}
|
|
2179
|
-
if (!found
|
|
2180
|
-
const
|
|
2181
|
-
if (
|
|
2182
|
-
|
|
2703
|
+
if (!found) {
|
|
2704
|
+
const nameQuery = a.display_name ?? (a.first_name || a.last_name ? `${a.first_name ?? ""} ${a.last_name ?? ""}`.trim() : null);
|
|
2705
|
+
if (nameQuery) {
|
|
2706
|
+
const results = searchContacts(nameQuery);
|
|
2707
|
+
if (results.length > 0)
|
|
2708
|
+
found = results[0];
|
|
2709
|
+
}
|
|
2183
2710
|
}
|
|
2184
2711
|
if (found) {
|
|
2185
2712
|
return { content: [{ type: "text", text: JSON.stringify({ contact: found, found: true, created: false }, null, 2) }] };
|
|
2186
2713
|
}
|
|
2187
|
-
const
|
|
2714
|
+
const focInput = {
|
|
2188
2715
|
first_name: a.first_name,
|
|
2189
2716
|
last_name: a.last_name,
|
|
2190
2717
|
display_name: a.display_name,
|
|
@@ -2196,6 +2723,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2196
2723
|
website: a.website,
|
|
2197
2724
|
last_contacted_at: a.last_contacted_at,
|
|
2198
2725
|
preferred_contact_method: a.preferred_contact_method,
|
|
2726
|
+
status: a.status,
|
|
2727
|
+
follow_up_at: a.follow_up_at,
|
|
2728
|
+
project_id: a.project_id,
|
|
2199
2729
|
emails: a.emails,
|
|
2200
2730
|
phones: a.phones,
|
|
2201
2731
|
addresses: a.addresses,
|
|
@@ -2203,7 +2733,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2203
2733
|
tag_ids: a.tag_ids,
|
|
2204
2734
|
source: a.source
|
|
2205
2735
|
};
|
|
2206
|
-
const contact = createContact(
|
|
2736
|
+
const contact = createContact(focInput);
|
|
2207
2737
|
return { content: [{ type: "text", text: JSON.stringify({ contact, found: false, created: true }, null, 2) }] };
|
|
2208
2738
|
}
|
|
2209
2739
|
case "upsert_contact": {
|
|
@@ -2327,8 +2857,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2327
2857
|
}
|
|
2328
2858
|
case "add_contact_to_group": {
|
|
2329
2859
|
const db = getDatabase();
|
|
2330
|
-
addContactToGroup(db, a.contact_id, a.group_id);
|
|
2331
|
-
return { content: [{ type: "text", text:
|
|
2860
|
+
const result = addContactToGroup(db, a.contact_id, a.group_id);
|
|
2861
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2332
2862
|
}
|
|
2333
2863
|
case "remove_contact_from_group": {
|
|
2334
2864
|
const db = getDatabase();
|
|
@@ -2345,6 +2875,108 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2345
2875
|
const groups = listGroupsForContact(db, a.contact_id);
|
|
2346
2876
|
return { content: [{ type: "text", text: JSON.stringify(groups, null, 2) }] };
|
|
2347
2877
|
}
|
|
2878
|
+
case "get_contact_by_email": {
|
|
2879
|
+
const contact = getContactByEmail(a.email);
|
|
2880
|
+
if (!contact)
|
|
2881
|
+
return { content: [{ type: "text", text: "null" }] };
|
|
2882
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
2883
|
+
}
|
|
2884
|
+
case "add_email_to_contact": {
|
|
2885
|
+
const contact = addEmailToContact(a.contact_id, {
|
|
2886
|
+
address: a.address,
|
|
2887
|
+
type: a.type,
|
|
2888
|
+
is_primary: a.is_primary
|
|
2889
|
+
});
|
|
2890
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
2891
|
+
}
|
|
2892
|
+
case "add_phone_to_contact": {
|
|
2893
|
+
const contact = addPhoneToContact(a.contact_id, {
|
|
2894
|
+
number: a.number,
|
|
2895
|
+
type: a.type,
|
|
2896
|
+
country_code: a.country_code,
|
|
2897
|
+
is_primary: a.is_primary
|
|
2898
|
+
});
|
|
2899
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
2900
|
+
}
|
|
2901
|
+
case "archive_contact": {
|
|
2902
|
+
const contact = archiveContact(a.id);
|
|
2903
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
2904
|
+
}
|
|
2905
|
+
case "unarchive_contact": {
|
|
2906
|
+
const contact = unarchiveContact(a.id);
|
|
2907
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
2908
|
+
}
|
|
2909
|
+
case "archive_company": {
|
|
2910
|
+
const company = archiveCompany(a.id);
|
|
2911
|
+
return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
|
|
2912
|
+
}
|
|
2913
|
+
case "unarchive_company": {
|
|
2914
|
+
const company = unarchiveCompany(a.id);
|
|
2915
|
+
return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
|
|
2916
|
+
}
|
|
2917
|
+
case "find_duplicates": {
|
|
2918
|
+
const db = getDatabase();
|
|
2919
|
+
const byEmail = findEmailDuplicates(db);
|
|
2920
|
+
const byName = findNameDuplicates(db);
|
|
2921
|
+
return { content: [{ type: "text", text: JSON.stringify({ by_email: byEmail, by_name: byName }, null, 2) }] };
|
|
2922
|
+
}
|
|
2923
|
+
case "list_interactions": {
|
|
2924
|
+
const result = listActivity({
|
|
2925
|
+
contact_id: a.contact_id,
|
|
2926
|
+
company_id: a.company_id,
|
|
2927
|
+
limit: a.limit,
|
|
2928
|
+
offset: a.offset
|
|
2929
|
+
});
|
|
2930
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2931
|
+
}
|
|
2932
|
+
case "add_tag_to_company": {
|
|
2933
|
+
addTagToCompany(a.company_id, a.tag_id);
|
|
2934
|
+
return { content: [{ type: "text", text: `Tag ${a.tag_id} added to company ${a.company_id}` }] };
|
|
2935
|
+
}
|
|
2936
|
+
case "remove_tag_from_company": {
|
|
2937
|
+
removeTagFromCompany(a.company_id, a.tag_id);
|
|
2938
|
+
return { content: [{ type: "text", text: `Tag ${a.tag_id} removed from company ${a.company_id}` }] };
|
|
2939
|
+
}
|
|
2940
|
+
case "add_company_to_group": {
|
|
2941
|
+
const db = getDatabase();
|
|
2942
|
+
const result = addCompanyToGroup(db, a.company_id, a.group_id);
|
|
2943
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2944
|
+
}
|
|
2945
|
+
case "remove_company_from_group": {
|
|
2946
|
+
const db = getDatabase();
|
|
2947
|
+
removeCompanyFromGroup(db, a.company_id, a.group_id);
|
|
2948
|
+
return { content: [{ type: "text", text: `Company ${a.company_id} removed from group ${a.group_id}` }] };
|
|
2949
|
+
}
|
|
2950
|
+
case "list_companies_in_group": {
|
|
2951
|
+
const db = getDatabase();
|
|
2952
|
+
const companyIds = listCompaniesInGroup(db, a.group_id);
|
|
2953
|
+
return { content: [{ type: "text", text: JSON.stringify(companyIds, null, 2) }] };
|
|
2954
|
+
}
|
|
2955
|
+
case "list_groups_for_company": {
|
|
2956
|
+
const db = getDatabase();
|
|
2957
|
+
const groups = listGroupsForCompany(db, a.company_id);
|
|
2958
|
+
return { content: [{ type: "text", text: JSON.stringify(groups, null, 2) }] };
|
|
2959
|
+
}
|
|
2960
|
+
case "bulk_create_contacts": {
|
|
2961
|
+
const contacts = a.contacts;
|
|
2962
|
+
let created = 0;
|
|
2963
|
+
const errors = [];
|
|
2964
|
+
for (const item of contacts) {
|
|
2965
|
+
try {
|
|
2966
|
+
createContact(item);
|
|
2967
|
+
created++;
|
|
2968
|
+
} catch (err) {
|
|
2969
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
return { content: [{ type: "text", text: JSON.stringify({ created, errors: errors.length, error_details: errors }, null, 2) }] };
|
|
2973
|
+
}
|
|
2974
|
+
case "auto_link_to_company": {
|
|
2975
|
+
const contact = autoLinkContactToCompany(a.contact_id);
|
|
2976
|
+
if (!contact)
|
|
2977
|
+
return { content: [{ type: "text", text: "null" }] };
|
|
2978
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
2979
|
+
}
|
|
2348
2980
|
default:
|
|
2349
2981
|
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
2350
2982
|
}
|