@hasna/contacts 0.5.2 → 0.6.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/dist/cli/index.js +1042 -28
- package/dist/db/companies.d.ts.map +1 -1
- package/dist/db/contacts.d.ts.map +1 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/documents.d.ts +37 -0
- package/dist/db/documents.d.ts.map +1 -0
- package/dist/db/health.d.ts +40 -0
- package/dist/db/health.d.ts.map +1 -0
- package/dist/db/tags.d.ts.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +524 -6
- package/dist/lib/document-scanner.d.ts +8 -0
- package/dist/lib/document-scanner.d.ts.map +1 -0
- package/dist/lib/images.d.ts +30 -0
- package/dist/lib/images.d.ts.map +1 -0
- package/dist/lib/vault.d.ts +15 -0
- package/dist/lib/vault.d.ts.map +1 -0
- package/dist/mcp/index.js +636 -9
- package/dist/server/index.js +190 -12
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -543,6 +543,41 @@ var init_database = __esm(() => {
|
|
|
543
543
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
544
544
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
545
545
|
);
|
|
546
|
+
`,
|
|
547
|
+
`
|
|
548
|
+
ALTER TABLE contacts ADD COLUMN sensitivity TEXT NOT NULL DEFAULT 'normal' CHECK(sensitivity IN ('normal','confidential','restricted'));
|
|
549
|
+
|
|
550
|
+
CREATE TABLE IF NOT EXISTS contact_documents (
|
|
551
|
+
id TEXT PRIMARY KEY,
|
|
552
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
553
|
+
doc_type TEXT NOT NULL,
|
|
554
|
+
label TEXT,
|
|
555
|
+
encrypted_value TEXT NOT NULL,
|
|
556
|
+
iv TEXT NOT NULL,
|
|
557
|
+
encrypted_file_path TEXT,
|
|
558
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
559
|
+
expires_at TEXT,
|
|
560
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
561
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
CREATE TABLE IF NOT EXISTS contact_health (
|
|
565
|
+
id TEXT PRIMARY KEY,
|
|
566
|
+
contact_id TEXT NOT NULL UNIQUE REFERENCES contacts(id) ON DELETE CASCADE,
|
|
567
|
+
blood_type TEXT,
|
|
568
|
+
allergies TEXT NOT NULL DEFAULT '[]',
|
|
569
|
+
medical_conditions TEXT NOT NULL DEFAULT '[]',
|
|
570
|
+
medications TEXT NOT NULL DEFAULT '[]',
|
|
571
|
+
emergency_contacts TEXT NOT NULL DEFAULT '[]',
|
|
572
|
+
health_insurance_provider TEXT,
|
|
573
|
+
health_insurance_id TEXT,
|
|
574
|
+
primary_physician TEXT,
|
|
575
|
+
primary_physician_phone TEXT,
|
|
576
|
+
organ_donor INTEGER NOT NULL DEFAULT 0,
|
|
577
|
+
notes TEXT,
|
|
578
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
579
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
580
|
+
);
|
|
546
581
|
`
|
|
547
582
|
];
|
|
548
583
|
});
|
|
@@ -647,6 +682,7 @@ function rowToContact(row) {
|
|
|
647
682
|
follow_up_at: row.follow_up_at ?? null,
|
|
648
683
|
archived: !!row.archived,
|
|
649
684
|
project_id: row.project_id ?? null,
|
|
685
|
+
sensitivity: row.sensitivity ?? "normal",
|
|
650
686
|
do_not_contact: !!row.do_not_contact,
|
|
651
687
|
priority: row.priority ?? 3,
|
|
652
688
|
timezone: row.timezone ?? null
|
|
@@ -734,8 +770,8 @@ function createContact(input, db) {
|
|
|
734
770
|
const firstName = input.first_name ?? "";
|
|
735
771
|
const lastName = input.last_name ?? "";
|
|
736
772
|
const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
|
|
737
|
-
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, do_not_contact, priority, timezone, created_at, updated_at)
|
|
738
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
773
|
+
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, sensitivity, do_not_contact, priority, timezone, created_at, updated_at)
|
|
774
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
739
775
|
id,
|
|
740
776
|
firstName,
|
|
741
777
|
lastName,
|
|
@@ -754,6 +790,7 @@ function createContact(input, db) {
|
|
|
754
790
|
input.status ?? "active",
|
|
755
791
|
input.follow_up_at ?? null,
|
|
756
792
|
input.project_id ?? null,
|
|
793
|
+
input.sensitivity ?? "normal",
|
|
757
794
|
input.do_not_contact ? 1 : 0,
|
|
758
795
|
input.priority ?? 3,
|
|
759
796
|
input.timezone ?? null,
|
|
@@ -802,6 +839,7 @@ function listContacts(opts = {}, db) {
|
|
|
802
839
|
order_by = "display_name",
|
|
803
840
|
order_dir = "asc",
|
|
804
841
|
include_dnc = false,
|
|
842
|
+
include_restricted = false,
|
|
805
843
|
priority_min,
|
|
806
844
|
updated_since
|
|
807
845
|
} = opts;
|
|
@@ -812,6 +850,9 @@ function listContacts(opts = {}, db) {
|
|
|
812
850
|
if (!include_dnc) {
|
|
813
851
|
conditions.push("c.do_not_contact = 0");
|
|
814
852
|
}
|
|
853
|
+
if (!include_restricted) {
|
|
854
|
+
conditions.push("c.sensitivity != 'restricted'");
|
|
855
|
+
}
|
|
815
856
|
if (company_id) {
|
|
816
857
|
conditions.push("c.company_id = ?");
|
|
817
858
|
params.push(company_id);
|
|
@@ -940,6 +981,10 @@ function updateContact(id, input, db) {
|
|
|
940
981
|
setClauses.push("project_id = ?");
|
|
941
982
|
params.push(input.project_id);
|
|
942
983
|
}
|
|
984
|
+
if (input.sensitivity !== undefined) {
|
|
985
|
+
setClauses.push("sensitivity = ?");
|
|
986
|
+
params.push(input.sensitivity);
|
|
987
|
+
}
|
|
943
988
|
if (input.do_not_contact !== undefined) {
|
|
944
989
|
setClauses.push("do_not_contact = ?");
|
|
945
990
|
params.push(input.do_not_contact ? 1 : 0);
|
|
@@ -987,26 +1032,26 @@ function searchContacts(query, db) {
|
|
|
987
1032
|
const ftsRows = d.query(`
|
|
988
1033
|
SELECT c.* FROM contacts c
|
|
989
1034
|
JOIN contacts_fts fts ON fts.id = c.id
|
|
990
|
-
WHERE contacts_fts MATCH ? AND c.archived = 0
|
|
1035
|
+
WHERE contacts_fts MATCH ? AND c.archived = 0 AND c.sensitivity != 'restricted'
|
|
991
1036
|
ORDER BY rank
|
|
992
1037
|
LIMIT 50
|
|
993
1038
|
`).all(`"${query.replace(/"/g, '""')}"*`);
|
|
994
1039
|
const emailRows = d.query(`
|
|
995
1040
|
SELECT DISTINCT c.* FROM contacts c
|
|
996
1041
|
JOIN emails e ON e.contact_id = c.id
|
|
997
|
-
WHERE e.address LIKE ? AND c.archived = 0
|
|
1042
|
+
WHERE e.address LIKE ? AND c.archived = 0 AND c.sensitivity != 'restricted'
|
|
998
1043
|
LIMIT 20
|
|
999
1044
|
`).all(`%${query}%`);
|
|
1000
1045
|
const phoneRows = d.query(`
|
|
1001
1046
|
SELECT DISTINCT c.* FROM contacts c
|
|
1002
1047
|
JOIN phones p ON p.contact_id = c.id
|
|
1003
|
-
WHERE p.number LIKE ? AND c.archived = 0
|
|
1048
|
+
WHERE p.number LIKE ? AND c.archived = 0 AND c.sensitivity != 'restricted'
|
|
1004
1049
|
LIMIT 20
|
|
1005
1050
|
`).all(`%${query}%`);
|
|
1006
1051
|
const companyRows = d.query(`
|
|
1007
1052
|
SELECT DISTINCT c.* FROM contacts c
|
|
1008
1053
|
JOIN companies co ON co.id = c.company_id
|
|
1009
|
-
WHERE co.name LIKE ? AND c.archived = 0
|
|
1054
|
+
WHERE co.name LIKE ? AND c.archived = 0 AND c.sensitivity != 'restricted'
|
|
1010
1055
|
LIMIT 20
|
|
1011
1056
|
`).all(`%${query}%`);
|
|
1012
1057
|
const seen = new Set;
|
|
@@ -1572,6 +1617,7 @@ function listCompanyEmployees(companyId, db) {
|
|
|
1572
1617
|
follow_up_at: r.follow_up_at ?? null,
|
|
1573
1618
|
archived: !!r.archived,
|
|
1574
1619
|
project_id: r.project_id ?? null,
|
|
1620
|
+
sensitivity: r.sensitivity ?? "normal",
|
|
1575
1621
|
do_not_contact: !!r.do_not_contact,
|
|
1576
1622
|
priority: r.priority ?? 3,
|
|
1577
1623
|
timezone: r.timezone ?? null
|
|
@@ -1702,6 +1748,7 @@ function listContactsByTag(tagId, db) {
|
|
|
1702
1748
|
follow_up_at: r.follow_up_at ?? null,
|
|
1703
1749
|
archived: !!r.archived,
|
|
1704
1750
|
project_id: r.project_id ?? null,
|
|
1751
|
+
sensitivity: r.sensitivity ?? "normal",
|
|
1705
1752
|
do_not_contact: !!r.do_not_contact,
|
|
1706
1753
|
priority: r.priority ?? 3,
|
|
1707
1754
|
timezone: r.timezone ?? null
|
|
@@ -4376,6 +4423,451 @@ async function ingestMeetingParticipants(event, db) {
|
|
|
4376
4423
|
|
|
4377
4424
|
// src/index.ts
|
|
4378
4425
|
init_contacts();
|
|
4426
|
+
|
|
4427
|
+
// src/lib/images.ts
|
|
4428
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, copyFileSync, unlinkSync, readdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
4429
|
+
import { join as join3, extname, basename } from "path";
|
|
4430
|
+
var IMAGES_DIR = join3(process.env["HOME"] || "~", ".contacts", "images");
|
|
4431
|
+
function ensureImagesDir() {
|
|
4432
|
+
if (!existsSync3(IMAGES_DIR))
|
|
4433
|
+
mkdirSync2(IMAGES_DIR, { recursive: true });
|
|
4434
|
+
}
|
|
4435
|
+
function getImagesDir() {
|
|
4436
|
+
ensureImagesDir();
|
|
4437
|
+
return IMAGES_DIR;
|
|
4438
|
+
}
|
|
4439
|
+
function saveImage(entityId, source, options) {
|
|
4440
|
+
ensureImagesDir();
|
|
4441
|
+
deleteImage(entityId);
|
|
4442
|
+
const base64Match = source.match(/^data:image\/(\w+);base64,(.+)$/s);
|
|
4443
|
+
if (base64Match) {
|
|
4444
|
+
const ext2 = base64Match[1] === "jpeg" ? "jpg" : base64Match[1];
|
|
4445
|
+
const data = Buffer.from(base64Match[2], "base64");
|
|
4446
|
+
const filename2 = `${entityId}.${ext2}`;
|
|
4447
|
+
writeFileSync(join3(IMAGES_DIR, filename2), data);
|
|
4448
|
+
return filename2;
|
|
4449
|
+
}
|
|
4450
|
+
if (!existsSync3(source) && /^[A-Za-z0-9+/=\n\r]+$/.test(source.trim()) && source.length > 100) {
|
|
4451
|
+
const ext2 = options?.format || "jpg";
|
|
4452
|
+
const data = Buffer.from(source.trim(), "base64");
|
|
4453
|
+
const filename2 = `${entityId}.${ext2}`;
|
|
4454
|
+
writeFileSync(join3(IMAGES_DIR, filename2), data);
|
|
4455
|
+
return filename2;
|
|
4456
|
+
}
|
|
4457
|
+
if (!existsSync3(source)) {
|
|
4458
|
+
throw new Error(`Image file not found: ${source}`);
|
|
4459
|
+
}
|
|
4460
|
+
const ext = extname(source).slice(1).toLowerCase() || "jpg";
|
|
4461
|
+
const validExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "ico"];
|
|
4462
|
+
if (!validExts.includes(ext)) {
|
|
4463
|
+
throw new Error(`Unsupported image format: ${ext}. Supported: ${validExts.join(", ")}`);
|
|
4464
|
+
}
|
|
4465
|
+
const filename = `${entityId}.${ext === "jpeg" ? "jpg" : ext}`;
|
|
4466
|
+
copyFileSync(source, join3(IMAGES_DIR, filename));
|
|
4467
|
+
return filename;
|
|
4468
|
+
}
|
|
4469
|
+
function getImagePath(entityId) {
|
|
4470
|
+
ensureImagesDir();
|
|
4471
|
+
const files = readdirSync(IMAGES_DIR);
|
|
4472
|
+
const match = files.find((f) => f.startsWith(`${entityId}.`));
|
|
4473
|
+
return match ? join3(IMAGES_DIR, match) : null;
|
|
4474
|
+
}
|
|
4475
|
+
function getImageAsBase64(entityId) {
|
|
4476
|
+
const path = getImagePath(entityId);
|
|
4477
|
+
if (!path)
|
|
4478
|
+
return null;
|
|
4479
|
+
const ext = extname(path).slice(1);
|
|
4480
|
+
const mime = ext === "jpg" ? "image/jpeg" : ext === "svg" ? "image/svg+xml" : `image/${ext}`;
|
|
4481
|
+
const data = readFileSync2(path);
|
|
4482
|
+
return `data:${mime};base64,${data.toString("base64")}`;
|
|
4483
|
+
}
|
|
4484
|
+
function deleteImage(entityId) {
|
|
4485
|
+
ensureImagesDir();
|
|
4486
|
+
const files = readdirSync(IMAGES_DIR);
|
|
4487
|
+
let deleted = false;
|
|
4488
|
+
for (const f of files) {
|
|
4489
|
+
if (f.startsWith(`${entityId}.`)) {
|
|
4490
|
+
unlinkSync(join3(IMAGES_DIR, f));
|
|
4491
|
+
deleted = true;
|
|
4492
|
+
}
|
|
4493
|
+
}
|
|
4494
|
+
return deleted;
|
|
4495
|
+
}
|
|
4496
|
+
function listImages() {
|
|
4497
|
+
ensureImagesDir();
|
|
4498
|
+
const files = readdirSync(IMAGES_DIR).filter((f) => !f.startsWith("."));
|
|
4499
|
+
return files.map((f) => ({
|
|
4500
|
+
entity_id: basename(f, extname(f)),
|
|
4501
|
+
filename: f,
|
|
4502
|
+
path: join3(IMAGES_DIR, f)
|
|
4503
|
+
}));
|
|
4504
|
+
}
|
|
4505
|
+
// src/lib/vault.ts
|
|
4506
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
|
|
4507
|
+
import { join as join4 } from "path";
|
|
4508
|
+
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, createHash } from "crypto";
|
|
4509
|
+
var VAULT_DIR = join4(process.env["HOME"] || "~", ".contacts");
|
|
4510
|
+
var VAULT_CONFIG = join4(VAULT_DIR, "vault.json");
|
|
4511
|
+
var DOCUMENTS_DIR = join4(VAULT_DIR, "documents");
|
|
4512
|
+
var _derivedKey = null;
|
|
4513
|
+
function deriveKey(passphrase, salt) {
|
|
4514
|
+
return pbkdf2Sync(passphrase, salt, 1e5, 32, "sha512");
|
|
4515
|
+
}
|
|
4516
|
+
function initVault(passphrase) {
|
|
4517
|
+
if (!existsSync4(VAULT_DIR))
|
|
4518
|
+
mkdirSync3(VAULT_DIR, { recursive: true });
|
|
4519
|
+
if (!existsSync4(DOCUMENTS_DIR))
|
|
4520
|
+
mkdirSync3(DOCUMENTS_DIR, { recursive: true });
|
|
4521
|
+
const salt = randomBytes(32);
|
|
4522
|
+
const key = deriveKey(passphrase, salt);
|
|
4523
|
+
const keyHash = createHash("sha256").update(key).digest("hex");
|
|
4524
|
+
const config = { salt: salt.toString("hex"), key_hash: keyHash, created_at: new Date().toISOString() };
|
|
4525
|
+
writeFileSync2(VAULT_CONFIG, JSON.stringify(config, null, 2));
|
|
4526
|
+
_derivedKey = key;
|
|
4527
|
+
}
|
|
4528
|
+
function isVaultInitialized() {
|
|
4529
|
+
return existsSync4(VAULT_CONFIG);
|
|
4530
|
+
}
|
|
4531
|
+
function unlockVault(passphrase) {
|
|
4532
|
+
if (!existsSync4(VAULT_CONFIG))
|
|
4533
|
+
throw new Error("Vault not initialized. Run 'contacts vault init' first.");
|
|
4534
|
+
const config = JSON.parse(readFileSync3(VAULT_CONFIG, "utf-8"));
|
|
4535
|
+
const salt = Buffer.from(config.salt, "hex");
|
|
4536
|
+
const key = deriveKey(passphrase, salt);
|
|
4537
|
+
const keyHash = createHash("sha256").update(key).digest("hex");
|
|
4538
|
+
if (keyHash !== config.key_hash)
|
|
4539
|
+
return false;
|
|
4540
|
+
_derivedKey = key;
|
|
4541
|
+
return true;
|
|
4542
|
+
}
|
|
4543
|
+
function lockVault() {
|
|
4544
|
+
_derivedKey = null;
|
|
4545
|
+
}
|
|
4546
|
+
function isVaultUnlocked() {
|
|
4547
|
+
return _derivedKey !== null;
|
|
4548
|
+
}
|
|
4549
|
+
function requireVault() {
|
|
4550
|
+
if (!_derivedKey)
|
|
4551
|
+
throw new Error("Vault is locked. Unlock with 'contacts vault unlock' or vault_unlock MCP tool first.");
|
|
4552
|
+
return _derivedKey;
|
|
4553
|
+
}
|
|
4554
|
+
function encrypt(plaintext) {
|
|
4555
|
+
const key = requireVault();
|
|
4556
|
+
const iv = randomBytes(16);
|
|
4557
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
4558
|
+
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
|
4559
|
+
encrypted += cipher.final("hex");
|
|
4560
|
+
const authTag = cipher.getAuthTag().toString("hex");
|
|
4561
|
+
return { ciphertext: encrypted + ":" + authTag, iv: iv.toString("hex") };
|
|
4562
|
+
}
|
|
4563
|
+
function decrypt(ciphertext, iv) {
|
|
4564
|
+
const key = requireVault();
|
|
4565
|
+
const [encData, authTag] = ciphertext.split(":");
|
|
4566
|
+
if (!encData || !authTag)
|
|
4567
|
+
throw new Error("Invalid ciphertext format");
|
|
4568
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "hex"));
|
|
4569
|
+
decipher.setAuthTag(Buffer.from(authTag, "hex"));
|
|
4570
|
+
let decrypted = decipher.update(encData, "hex", "utf8");
|
|
4571
|
+
decrypted += decipher.final("utf8");
|
|
4572
|
+
return decrypted;
|
|
4573
|
+
}
|
|
4574
|
+
function encryptFile(sourcePath, entityId) {
|
|
4575
|
+
const key = requireVault();
|
|
4576
|
+
if (!existsSync4(DOCUMENTS_DIR))
|
|
4577
|
+
mkdirSync3(DOCUMENTS_DIR, { recursive: true });
|
|
4578
|
+
const data = readFileSync3(sourcePath);
|
|
4579
|
+
const iv = randomBytes(16);
|
|
4580
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
4581
|
+
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
4582
|
+
const authTag = cipher.getAuthTag();
|
|
4583
|
+
const destPath = join4(DOCUMENTS_DIR, `${entityId}.enc`);
|
|
4584
|
+
const output = Buffer.concat([iv, authTag, encrypted]);
|
|
4585
|
+
writeFileSync2(destPath, output);
|
|
4586
|
+
return destPath;
|
|
4587
|
+
}
|
|
4588
|
+
function decryptFile(encPath) {
|
|
4589
|
+
const key = requireVault();
|
|
4590
|
+
const data = readFileSync3(encPath);
|
|
4591
|
+
const iv = data.subarray(0, 16);
|
|
4592
|
+
const authTag = data.subarray(16, 32);
|
|
4593
|
+
const encrypted = data.subarray(32);
|
|
4594
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
4595
|
+
decipher.setAuthTag(authTag);
|
|
4596
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
4597
|
+
}
|
|
4598
|
+
function getDocumentsDir() {
|
|
4599
|
+
if (!existsSync4(DOCUMENTS_DIR))
|
|
4600
|
+
mkdirSync3(DOCUMENTS_DIR, { recursive: true });
|
|
4601
|
+
return DOCUMENTS_DIR;
|
|
4602
|
+
}
|
|
4603
|
+
// src/db/documents.ts
|
|
4604
|
+
init_database();
|
|
4605
|
+
import { existsSync as existsSync5, unlinkSync as unlinkSync2 } from "fs";
|
|
4606
|
+
var DOCUMENT_TYPES = [
|
|
4607
|
+
"passport",
|
|
4608
|
+
"national_id",
|
|
4609
|
+
"tax_id",
|
|
4610
|
+
"ssn",
|
|
4611
|
+
"drivers_license",
|
|
4612
|
+
"bank_account",
|
|
4613
|
+
"visa",
|
|
4614
|
+
"insurance",
|
|
4615
|
+
"contract",
|
|
4616
|
+
"certificate",
|
|
4617
|
+
"medical_record",
|
|
4618
|
+
"prescription",
|
|
4619
|
+
"allergy_list",
|
|
4620
|
+
"vaccination",
|
|
4621
|
+
"blood_type",
|
|
4622
|
+
"health_insurance",
|
|
4623
|
+
"medical_condition",
|
|
4624
|
+
"emergency_contact_medical",
|
|
4625
|
+
"other"
|
|
4626
|
+
];
|
|
4627
|
+
function addDocument(input, db) {
|
|
4628
|
+
requireVault();
|
|
4629
|
+
const _db2 = db || getDatabase();
|
|
4630
|
+
const id = uuid();
|
|
4631
|
+
const { ciphertext, iv } = encrypt(input.value);
|
|
4632
|
+
let encFilePath = null;
|
|
4633
|
+
if (input.file_path) {
|
|
4634
|
+
encFilePath = encryptFile(input.file_path, id);
|
|
4635
|
+
}
|
|
4636
|
+
_db2.query(`INSERT INTO contact_documents (id, contact_id, doc_type, label, encrypted_value, iv, encrypted_file_path, metadata, expires_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`).run(id, input.contact_id, input.doc_type, input.label ?? null, ciphertext, iv, encFilePath, JSON.stringify(input.metadata || {}), input.expires_at ?? null, now(), now());
|
|
4637
|
+
return getDocument(id, _db2);
|
|
4638
|
+
}
|
|
4639
|
+
function getDocument(id, db) {
|
|
4640
|
+
requireVault();
|
|
4641
|
+
const _db2 = db || getDatabase();
|
|
4642
|
+
const row = _db2.query(`SELECT * FROM contact_documents WHERE id = ?`).get(id);
|
|
4643
|
+
if (!row)
|
|
4644
|
+
throw new Error(`Document not found: ${id}`);
|
|
4645
|
+
return rowToDoc(row);
|
|
4646
|
+
}
|
|
4647
|
+
function listDocuments(contactId, db) {
|
|
4648
|
+
const _db2 = db || getDatabase();
|
|
4649
|
+
const rows = _db2.query(`SELECT id, doc_type, label, encrypted_file_path, expires_at, created_at FROM contact_documents WHERE contact_id = ? ORDER BY created_at DESC`).all(contactId);
|
|
4650
|
+
return rows.map((r) => ({
|
|
4651
|
+
id: r.id,
|
|
4652
|
+
doc_type: r.doc_type,
|
|
4653
|
+
label: r.label,
|
|
4654
|
+
has_file: !!r.encrypted_file_path,
|
|
4655
|
+
expires_at: r.expires_at,
|
|
4656
|
+
created_at: r.created_at
|
|
4657
|
+
}));
|
|
4658
|
+
}
|
|
4659
|
+
function deleteDocument(id, db) {
|
|
4660
|
+
const _db2 = db || getDatabase();
|
|
4661
|
+
const row = _db2.query(`SELECT encrypted_file_path FROM contact_documents WHERE id = ?`).get(id);
|
|
4662
|
+
if (row?.encrypted_file_path && existsSync5(row.encrypted_file_path)) {
|
|
4663
|
+
try {
|
|
4664
|
+
unlinkSync2(row.encrypted_file_path);
|
|
4665
|
+
} catch {}
|
|
4666
|
+
}
|
|
4667
|
+
_db2.query(`DELETE FROM contact_documents WHERE id = ?`).run(id);
|
|
4668
|
+
}
|
|
4669
|
+
function rowToDoc(row) {
|
|
4670
|
+
return {
|
|
4671
|
+
id: row.id,
|
|
4672
|
+
contact_id: row.contact_id,
|
|
4673
|
+
doc_type: row.doc_type,
|
|
4674
|
+
label: row.label,
|
|
4675
|
+
value: decrypt(row.encrypted_value, row.iv),
|
|
4676
|
+
has_file: !!row.encrypted_file_path,
|
|
4677
|
+
metadata: JSON.parse(row.metadata || "{}"),
|
|
4678
|
+
expires_at: row.expires_at,
|
|
4679
|
+
created_at: row.created_at,
|
|
4680
|
+
updated_at: row.updated_at
|
|
4681
|
+
};
|
|
4682
|
+
}
|
|
4683
|
+
// src/db/health.ts
|
|
4684
|
+
init_database();
|
|
4685
|
+
function setHealthData(contactId, input, db) {
|
|
4686
|
+
requireVault();
|
|
4687
|
+
const _db2 = db || getDatabase();
|
|
4688
|
+
const existing = _db2.query(`SELECT id FROM contact_health WHERE contact_id = ?`).get(contactId);
|
|
4689
|
+
if (existing) {
|
|
4690
|
+
const sets = [];
|
|
4691
|
+
const params = [];
|
|
4692
|
+
if (input.blood_type !== undefined) {
|
|
4693
|
+
sets.push("blood_type = ?");
|
|
4694
|
+
params.push(input.blood_type);
|
|
4695
|
+
}
|
|
4696
|
+
if (input.allergies !== undefined) {
|
|
4697
|
+
sets.push("allergies = ?");
|
|
4698
|
+
params.push(JSON.stringify(input.allergies));
|
|
4699
|
+
}
|
|
4700
|
+
if (input.medical_conditions !== undefined) {
|
|
4701
|
+
sets.push("medical_conditions = ?");
|
|
4702
|
+
params.push(JSON.stringify(input.medical_conditions));
|
|
4703
|
+
}
|
|
4704
|
+
if (input.medications !== undefined) {
|
|
4705
|
+
sets.push("medications = ?");
|
|
4706
|
+
params.push(JSON.stringify(input.medications));
|
|
4707
|
+
}
|
|
4708
|
+
if (input.emergency_contacts !== undefined) {
|
|
4709
|
+
sets.push("emergency_contacts = ?");
|
|
4710
|
+
params.push(JSON.stringify(input.emergency_contacts));
|
|
4711
|
+
}
|
|
4712
|
+
if (input.health_insurance_provider !== undefined) {
|
|
4713
|
+
sets.push("health_insurance_provider = ?");
|
|
4714
|
+
params.push(input.health_insurance_provider);
|
|
4715
|
+
}
|
|
4716
|
+
if (input.health_insurance_id !== undefined) {
|
|
4717
|
+
sets.push("health_insurance_id = ?");
|
|
4718
|
+
params.push(input.health_insurance_id);
|
|
4719
|
+
}
|
|
4720
|
+
if (input.primary_physician !== undefined) {
|
|
4721
|
+
sets.push("primary_physician = ?");
|
|
4722
|
+
params.push(input.primary_physician);
|
|
4723
|
+
}
|
|
4724
|
+
if (input.primary_physician_phone !== undefined) {
|
|
4725
|
+
sets.push("primary_physician_phone = ?");
|
|
4726
|
+
params.push(input.primary_physician_phone);
|
|
4727
|
+
}
|
|
4728
|
+
if (input.organ_donor !== undefined) {
|
|
4729
|
+
sets.push("organ_donor = ?");
|
|
4730
|
+
params.push(input.organ_donor ? 1 : 0);
|
|
4731
|
+
}
|
|
4732
|
+
if (input.notes !== undefined) {
|
|
4733
|
+
sets.push("notes = ?");
|
|
4734
|
+
params.push(input.notes);
|
|
4735
|
+
}
|
|
4736
|
+
if (sets.length) {
|
|
4737
|
+
sets.push("updated_at = ?");
|
|
4738
|
+
params.push(now());
|
|
4739
|
+
params.push(contactId);
|
|
4740
|
+
_db2.query(`UPDATE contact_health SET ${sets.join(", ")} WHERE contact_id = ?`).run(...params);
|
|
4741
|
+
}
|
|
4742
|
+
} else {
|
|
4743
|
+
const id = uuid();
|
|
4744
|
+
_db2.query(`INSERT INTO contact_health (id, contact_id, blood_type, allergies, medical_conditions, medications, emergency_contacts, health_insurance_provider, health_insurance_id, primary_physician, primary_physician_phone, organ_donor, notes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(id, contactId, input.blood_type ?? null, JSON.stringify(input.allergies || []), JSON.stringify(input.medical_conditions || []), JSON.stringify(input.medications || []), JSON.stringify(input.emergency_contacts || []), input.health_insurance_provider ?? null, input.health_insurance_id ?? null, input.primary_physician ?? null, input.primary_physician_phone ?? null, input.organ_donor ? 1 : 0, input.notes ?? null, now(), now());
|
|
4745
|
+
}
|
|
4746
|
+
return getHealthData(contactId, _db2);
|
|
4747
|
+
}
|
|
4748
|
+
function getHealthData(contactId, db) {
|
|
4749
|
+
requireVault();
|
|
4750
|
+
const _db2 = db || getDatabase();
|
|
4751
|
+
const row = _db2.query(`SELECT * FROM contact_health WHERE contact_id = ?`).get(contactId);
|
|
4752
|
+
if (!row)
|
|
4753
|
+
return null;
|
|
4754
|
+
return {
|
|
4755
|
+
id: row.id,
|
|
4756
|
+
contact_id: row.contact_id,
|
|
4757
|
+
blood_type: row.blood_type,
|
|
4758
|
+
allergies: JSON.parse(row.allergies || "[]"),
|
|
4759
|
+
medical_conditions: JSON.parse(row.medical_conditions || "[]"),
|
|
4760
|
+
medications: JSON.parse(row.medications || "[]"),
|
|
4761
|
+
emergency_contacts: JSON.parse(row.emergency_contacts || "[]"),
|
|
4762
|
+
health_insurance_provider: row.health_insurance_provider,
|
|
4763
|
+
health_insurance_id: row.health_insurance_id,
|
|
4764
|
+
primary_physician: row.primary_physician,
|
|
4765
|
+
primary_physician_phone: row.primary_physician_phone,
|
|
4766
|
+
organ_donor: !!row.organ_donor,
|
|
4767
|
+
notes: row.notes,
|
|
4768
|
+
created_at: row.created_at,
|
|
4769
|
+
updated_at: row.updated_at
|
|
4770
|
+
};
|
|
4771
|
+
}
|
|
4772
|
+
function deleteHealthData(contactId, db) {
|
|
4773
|
+
const _db2 = db || getDatabase();
|
|
4774
|
+
_db2.query(`DELETE FROM contact_health WHERE contact_id = ?`).run(contactId);
|
|
4775
|
+
}
|
|
4776
|
+
// src/lib/document-scanner.ts
|
|
4777
|
+
import { readFileSync as readFileSync4, existsSync as existsSync6 } from "fs";
|
|
4778
|
+
import { extname as extname2 } from "path";
|
|
4779
|
+
async function scanDocument(imageSource, docType) {
|
|
4780
|
+
const apiKey = process.env["OPENAI_API_KEY"];
|
|
4781
|
+
if (!apiKey) {
|
|
4782
|
+
throw new Error("OPENAI_API_KEY not set. Set it in ~/.secrets or environment to use document scanning.");
|
|
4783
|
+
}
|
|
4784
|
+
let imageData;
|
|
4785
|
+
if (imageSource.startsWith("data:image/")) {
|
|
4786
|
+
imageData = imageSource;
|
|
4787
|
+
} else if (existsSync6(imageSource)) {
|
|
4788
|
+
const buffer = readFileSync4(imageSource);
|
|
4789
|
+
const ext = extname2(imageSource).slice(1).toLowerCase();
|
|
4790
|
+
const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : ext === "gif" ? "image/gif" : "image/jpeg";
|
|
4791
|
+
imageData = `data:${mime};base64,${buffer.toString("base64")}`;
|
|
4792
|
+
} else if (/^[A-Za-z0-9+/=\n\r]+$/.test(imageSource.trim()) && imageSource.length > 100) {
|
|
4793
|
+
imageData = `data:image/jpeg;base64,${imageSource.trim()}`;
|
|
4794
|
+
} else {
|
|
4795
|
+
throw new Error(`Image source not found or invalid: ${imageSource.slice(0, 50)}...`);
|
|
4796
|
+
}
|
|
4797
|
+
const typeHint = docType ? ` This is a ${docType} document.` : "";
|
|
4798
|
+
const prompt = `Extract all text and structured data from this document image.${typeHint} Return a JSON object with these fields:
|
|
4799
|
+
- document_type: detected type (passport, national_id, drivers_license, tax_document, medical_record, prescription, insurance_card, bank_statement, visa, certificate, contract, other)
|
|
4800
|
+
- full_name: full name as shown
|
|
4801
|
+
- date_of_birth: in YYYY-MM-DD format if visible
|
|
4802
|
+
- document_number: main ID/document number
|
|
4803
|
+
- issuing_country: country code or name
|
|
4804
|
+
- issue_date: in YYYY-MM-DD format if visible
|
|
4805
|
+
- expiry_date: in YYYY-MM-DD format if visible
|
|
4806
|
+
- address: full address if visible
|
|
4807
|
+
- nationality: if visible
|
|
4808
|
+
- gender: if visible
|
|
4809
|
+
- mrz_code: Machine Readable Zone text if this is a passport/ID with MRZ
|
|
4810
|
+
- phone: any phone numbers visible
|
|
4811
|
+
- email: any email addresses visible
|
|
4812
|
+
- additional_fields: object with any other visible structured data
|
|
4813
|
+
- raw_text: all visible text transcribed
|
|
4814
|
+
|
|
4815
|
+
Only include fields that are actually visible in the document. Return valid JSON only.`;
|
|
4816
|
+
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
4817
|
+
method: "POST",
|
|
4818
|
+
headers: {
|
|
4819
|
+
Authorization: `Bearer ${apiKey}`,
|
|
4820
|
+
"Content-Type": "application/json"
|
|
4821
|
+
},
|
|
4822
|
+
body: JSON.stringify({
|
|
4823
|
+
model: "gpt-4o",
|
|
4824
|
+
messages: [
|
|
4825
|
+
{
|
|
4826
|
+
role: "user",
|
|
4827
|
+
content: [
|
|
4828
|
+
{ type: "text", text: prompt },
|
|
4829
|
+
{ type: "image_url", image_url: { url: imageData, detail: "high" } }
|
|
4830
|
+
]
|
|
4831
|
+
}
|
|
4832
|
+
],
|
|
4833
|
+
max_tokens: 2000,
|
|
4834
|
+
temperature: 0
|
|
4835
|
+
})
|
|
4836
|
+
});
|
|
4837
|
+
if (!response.ok) {
|
|
4838
|
+
const err = await response.text();
|
|
4839
|
+
throw new Error(`OpenAI API error: ${response.status} \u2014 ${err}`);
|
|
4840
|
+
}
|
|
4841
|
+
const data = await response.json();
|
|
4842
|
+
const content = data.choices?.[0]?.message?.content || "";
|
|
4843
|
+
const jsonMatch = content.match(/```json\s*([\s\S]*?)```/) || content.match(/\{[\s\S]*\}/);
|
|
4844
|
+
if (!jsonMatch) {
|
|
4845
|
+
return { fields: {}, raw_text: content, document_type: docType || "unknown", confidence: 0.3 };
|
|
4846
|
+
}
|
|
4847
|
+
try {
|
|
4848
|
+
const parsed = JSON.parse(jsonMatch[1] || jsonMatch[0]);
|
|
4849
|
+
const { document_type, raw_text, additional_fields, ...mainFields } = parsed;
|
|
4850
|
+
const fields = {};
|
|
4851
|
+
for (const [k, v] of Object.entries(mainFields)) {
|
|
4852
|
+
if (v && typeof v === "string")
|
|
4853
|
+
fields[k] = v;
|
|
4854
|
+
}
|
|
4855
|
+
if (additional_fields && typeof additional_fields === "object") {
|
|
4856
|
+
for (const [k, v] of Object.entries(additional_fields)) {
|
|
4857
|
+
if (v && typeof v === "string")
|
|
4858
|
+
fields[k] = v;
|
|
4859
|
+
}
|
|
4860
|
+
}
|
|
4861
|
+
return {
|
|
4862
|
+
fields,
|
|
4863
|
+
raw_text: raw_text || content,
|
|
4864
|
+
document_type: document_type || docType || "unknown",
|
|
4865
|
+
confidence: Object.keys(fields).length > 3 ? 0.9 : Object.keys(fields).length > 0 ? 0.7 : 0.3
|
|
4866
|
+
};
|
|
4867
|
+
} catch {
|
|
4868
|
+
return { fields: {}, raw_text: content, document_type: docType || "unknown", confidence: 0.3 };
|
|
4869
|
+
}
|
|
4870
|
+
}
|
|
4379
4871
|
export {
|
|
4380
4872
|
updateVendorCommunication,
|
|
4381
4873
|
updateTag,
|
|
@@ -4386,19 +4878,24 @@ export {
|
|
|
4386
4878
|
updateContact,
|
|
4387
4879
|
updateCompany,
|
|
4388
4880
|
updateApplication,
|
|
4881
|
+
unlockVault,
|
|
4389
4882
|
unarchiveContact,
|
|
4390
4883
|
unarchiveCompany,
|
|
4884
|
+
setHealthData,
|
|
4391
4885
|
setDealContactRole,
|
|
4392
4886
|
semanticSearch,
|
|
4393
4887
|
searchLearnings,
|
|
4394
4888
|
searchGoogleContacts,
|
|
4395
4889
|
searchContacts,
|
|
4396
4890
|
searchCompanies,
|
|
4891
|
+
scanDocument,
|
|
4397
4892
|
saveLearning,
|
|
4893
|
+
saveImage,
|
|
4398
4894
|
runConnector,
|
|
4399
4895
|
resolveIdentity,
|
|
4400
4896
|
resolveByPartial,
|
|
4401
4897
|
resetDatabase,
|
|
4898
|
+
requireVault,
|
|
4402
4899
|
removeTagFromContact,
|
|
4403
4900
|
removeTagFromCompany,
|
|
4404
4901
|
removeOrgMember,
|
|
@@ -4421,6 +4918,7 @@ export {
|
|
|
4421
4918
|
logEvent,
|
|
4422
4919
|
logAgentActivity,
|
|
4423
4920
|
logActivity,
|
|
4921
|
+
lockVault,
|
|
4424
4922
|
listVendorCommunications,
|
|
4425
4923
|
listTags,
|
|
4426
4924
|
listRelationships,
|
|
@@ -4435,12 +4933,14 @@ export {
|
|
|
4435
4933
|
listNotesForContactAtCompany,
|
|
4436
4934
|
listNotes,
|
|
4437
4935
|
listMissingInvoices,
|
|
4936
|
+
listImages,
|
|
4438
4937
|
listGroupsForContact,
|
|
4439
4938
|
listGroupsForCompany,
|
|
4440
4939
|
listGroups,
|
|
4441
4940
|
listGoogleContacts,
|
|
4442
4941
|
listFollowUpDue,
|
|
4443
4942
|
listEvents,
|
|
4943
|
+
listDocuments,
|
|
4444
4944
|
listDeals,
|
|
4445
4945
|
listContactsInGroup,
|
|
4446
4946
|
listContactsByTag,
|
|
@@ -4454,6 +4954,9 @@ export {
|
|
|
4454
4954
|
listColdContacts,
|
|
4455
4955
|
listApplications,
|
|
4456
4956
|
listActivity,
|
|
4957
|
+
isVaultUnlocked,
|
|
4958
|
+
isVaultInitialized,
|
|
4959
|
+
initVault,
|
|
4457
4960
|
ingestMeetingParticipants,
|
|
4458
4961
|
importToApple,
|
|
4459
4962
|
importFromCsv,
|
|
@@ -4472,13 +4975,19 @@ export {
|
|
|
4472
4975
|
getNetworkStats,
|
|
4473
4976
|
getLearnings,
|
|
4474
4977
|
getJobHistory,
|
|
4978
|
+
getImagesDir,
|
|
4979
|
+
getImagePath,
|
|
4980
|
+
getImageAsBase64,
|
|
4475
4981
|
getIdentities,
|
|
4982
|
+
getHealthData,
|
|
4476
4983
|
getGroup,
|
|
4477
4984
|
getGhostContacts,
|
|
4478
4985
|
getFreshnessScore,
|
|
4479
4986
|
getFieldHistory,
|
|
4480
4987
|
getEvent,
|
|
4481
4988
|
getEntityTeam,
|
|
4989
|
+
getDocumentsDir,
|
|
4990
|
+
getDocument,
|
|
4482
4991
|
getDealsByStage,
|
|
4483
4992
|
getDealTeam,
|
|
4484
4993
|
getDeal,
|
|
@@ -4505,6 +5014,8 @@ export {
|
|
|
4505
5014
|
extractContactsFromEmailThread,
|
|
4506
5015
|
exportFromApple,
|
|
4507
5016
|
exportContacts,
|
|
5017
|
+
encryptFile,
|
|
5018
|
+
encrypt,
|
|
4508
5019
|
embedContact,
|
|
4509
5020
|
embedAllContacts,
|
|
4510
5021
|
domainToCompany,
|
|
@@ -4514,14 +5025,19 @@ export {
|
|
|
4514
5025
|
deleteRelationship,
|
|
4515
5026
|
deleteNote,
|
|
4516
5027
|
deleteLearning,
|
|
5028
|
+
deleteImage,
|
|
5029
|
+
deleteHealthData,
|
|
4517
5030
|
deleteGroup,
|
|
4518
5031
|
deleteEvent,
|
|
5032
|
+
deleteDocument,
|
|
4519
5033
|
deleteDeal,
|
|
4520
5034
|
deleteContactTask,
|
|
4521
5035
|
deleteContact,
|
|
4522
5036
|
deleteCompanyRelationship,
|
|
4523
5037
|
deleteCompany,
|
|
4524
5038
|
deleteApplication,
|
|
5039
|
+
decryptFile,
|
|
5040
|
+
decrypt,
|
|
4525
5041
|
decayLearnings,
|
|
4526
5042
|
createTag,
|
|
4527
5043
|
createRelationship,
|
|
@@ -4553,11 +5069,13 @@ export {
|
|
|
4553
5069
|
addJobEntry,
|
|
4554
5070
|
addIdentity,
|
|
4555
5071
|
addEmailToContact,
|
|
5072
|
+
addDocument,
|
|
4556
5073
|
addContactToGroup,
|
|
4557
5074
|
addCompanyToGroup,
|
|
4558
5075
|
acquireLock,
|
|
4559
5076
|
TagNotFoundError,
|
|
4560
5077
|
DuplicateTagNameError,
|
|
5078
|
+
DOCUMENT_TYPES,
|
|
4561
5079
|
ContactNotFoundError,
|
|
4562
5080
|
ConnectorNotInstalledError,
|
|
4563
5081
|
ConnectorAuthError,
|