@hasna/contacts 0.6.22 → 0.6.23
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 +24 -6
- package/dist/cli/commands/core.d.ts.map +1 -1
- package/dist/cli/index.js +1350 -269
- package/dist/cli/storage.d.ts.map +1 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/remote-storage.d.ts +14 -0
- package/dist/db/remote-storage.d.ts.map +1 -0
- package/dist/db/remote-sync.d.ts +49 -0
- package/dist/db/remote-sync.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1002 -7
- package/dist/mcp/index.js +1073 -60
- package/dist/mcp/storage-tools.d.ts.map +1 -1
- package/dist/server/index.js +1073 -60
- package/package.json +8 -4
- package/dist/cli/storage-contract.test.d.ts +0 -2
- package/dist/cli/storage-contract.test.d.ts.map +0 -1
- package/dist/db/activity.test.d.ts +0 -2
- package/dist/db/activity.test.d.ts.map +0 -1
- package/dist/db/applications.test.d.ts +0 -2
- package/dist/db/applications.test.d.ts.map +0 -1
- package/dist/db/companies.test.d.ts +0 -2
- package/dist/db/companies.test.d.ts.map +0 -1
- package/dist/db/contact-tasks.test.d.ts +0 -2
- package/dist/db/contact-tasks.test.d.ts.map +0 -1
- package/dist/db/contacts.test.d.ts +0 -2
- package/dist/db/contacts.test.d.ts.map +0 -1
- package/dist/db/coordination.test.d.ts +0 -2
- package/dist/db/coordination.test.d.ts.map +0 -1
- package/dist/db/database.test.d.ts +0 -2
- package/dist/db/database.test.d.ts.map +0 -1
- package/dist/db/deals.test.d.ts +0 -2
- package/dist/db/deals.test.d.ts.map +0 -1
- package/dist/db/documents.test.d.ts +0 -2
- package/dist/db/documents.test.d.ts.map +0 -1
- package/dist/db/events.test.d.ts +0 -2
- package/dist/db/events.test.d.ts.map +0 -1
- package/dist/db/field-history.test.d.ts +0 -2
- package/dist/db/field-history.test.d.ts.map +0 -1
- package/dist/db/freshness.test.d.ts +0 -2
- package/dist/db/freshness.test.d.ts.map +0 -1
- package/dist/db/graph.test.d.ts +0 -2
- package/dist/db/graph.test.d.ts.map +0 -1
- package/dist/db/groups.test.d.ts +0 -2
- package/dist/db/groups.test.d.ts.map +0 -1
- package/dist/db/health.test.d.ts +0 -2
- package/dist/db/health.test.d.ts.map +0 -1
- package/dist/db/identity.test.d.ts +0 -2
- package/dist/db/identity.test.d.ts.map +0 -1
- package/dist/db/job-history.test.d.ts +0 -2
- package/dist/db/job-history.test.d.ts.map +0 -1
- package/dist/db/learnings.test.d.ts +0 -2
- package/dist/db/learnings.test.d.ts.map +0 -1
- package/dist/db/notes.test.d.ts +0 -2
- package/dist/db/notes.test.d.ts.map +0 -1
- package/dist/db/org-chart.test.d.ts +0 -2
- package/dist/db/org-chart.test.d.ts.map +0 -1
- package/dist/db/org-members.test.d.ts +0 -2
- package/dist/db/org-members.test.d.ts.map +0 -1
- package/dist/db/relationships.test.d.ts +0 -2
- package/dist/db/relationships.test.d.ts.map +0 -1
- package/dist/db/signals.test.d.ts +0 -2
- package/dist/db/signals.test.d.ts.map +0 -1
- package/dist/db/sqlite-adapter.test.d.ts +0 -2
- package/dist/db/sqlite-adapter.test.d.ts.map +0 -1
- package/dist/db/storage.test.d.ts +0 -2
- package/dist/db/storage.test.d.ts.map +0 -1
- package/dist/db/tags.test.d.ts +0 -2
- package/dist/db/tags.test.d.ts.map +0 -1
- package/dist/db/vendor-comms.test.d.ts +0 -2
- package/dist/db/vendor-comms.test.d.ts.map +0 -1
- package/dist/lib/audit.test.d.ts +0 -2
- package/dist/lib/audit.test.d.ts.map +0 -1
- package/dist/lib/brief.test.d.ts +0 -2
- package/dist/lib/brief.test.d.ts.map +0 -1
- package/dist/lib/config.test.d.ts +0 -2
- package/dist/lib/config.test.d.ts.map +0 -1
- package/dist/lib/dedup.test.d.ts +0 -2
- package/dist/lib/dedup.test.d.ts.map +0 -1
- package/dist/lib/export.test.d.ts +0 -2
- package/dist/lib/export.test.d.ts.map +0 -1
- package/dist/lib/gmail-import.test.d.ts +0 -2
- package/dist/lib/gmail-import.test.d.ts.map +0 -1
- package/dist/lib/google-contacts.test.d.ts +0 -2
- package/dist/lib/google-contacts.test.d.ts.map +0 -1
- package/dist/lib/import.test.d.ts +0 -2
- package/dist/lib/import.test.d.ts.map +0 -1
- package/dist/lib/signature-parser.test.d.ts +0 -2
- package/dist/lib/signature-parser.test.d.ts.map +0 -1
- package/dist/lib/stats.test.d.ts +0 -2
- package/dist/lib/stats.test.d.ts.map +0 -1
- package/dist/lib/timeline.test.d.ts +0 -2
- package/dist/lib/timeline.test.d.ts.map +0 -1
- package/dist/lib/upcoming.test.d.ts +0 -2
- package/dist/lib/upcoming.test.d.ts.map +0 -1
- package/dist/mcp/http.test.d.ts +0 -2
- package/dist/mcp/http.test.d.ts.map +0 -1
- package/dist/mcp/mcp.test.d.ts +0 -2
- package/dist/mcp/mcp.test.d.ts.map +0 -1
- package/dist/mcp/storage-contract.test.d.ts +0 -2
- package/dist/mcp/storage-contract.test.d.ts.map +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -2178,22 +2178,44 @@ __export(exports_database, {
|
|
|
2178
2178
|
getDatabase: () => getDatabase,
|
|
2179
2179
|
getDataDir: () => getDataDir
|
|
2180
2180
|
});
|
|
2181
|
-
import { copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, statSync } from "fs";
|
|
2181
|
+
import { chmodSync, copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, statSync } from "fs";
|
|
2182
2182
|
import { dirname, join as join2, resolve } from "path";
|
|
2183
|
+
function ensurePrivateDir(dir) {
|
|
2184
|
+
if (!existsSync2(dir))
|
|
2185
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
2186
|
+
chmodSync(dir, 448);
|
|
2187
|
+
}
|
|
2188
|
+
function ensureCreatedPrivateDir(dir) {
|
|
2189
|
+
if (!existsSync2(dir))
|
|
2190
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
2191
|
+
}
|
|
2192
|
+
function protectDatabaseArtifacts(dbPath) {
|
|
2193
|
+
if (dbPath === ":memory:")
|
|
2194
|
+
return;
|
|
2195
|
+
for (const artifact of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
2196
|
+
if (existsSync2(artifact))
|
|
2197
|
+
chmodSync(artifact, 384);
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2183
2200
|
function getDataDir() {
|
|
2184
2201
|
const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
|
|
2202
|
+
const hasnaDir = join2(home, ".hasna");
|
|
2185
2203
|
const newDir = join2(home, ".hasna", "contacts");
|
|
2186
2204
|
const oldDir = join2(home, ".contacts");
|
|
2187
2205
|
if (existsSync2(oldDir) && !existsSync2(newDir)) {
|
|
2188
|
-
|
|
2206
|
+
ensurePrivateDir(hasnaDir);
|
|
2207
|
+
ensurePrivateDir(newDir);
|
|
2189
2208
|
for (const file of readdirSync(oldDir)) {
|
|
2190
2209
|
const oldPath = join2(oldDir, file);
|
|
2191
2210
|
if (statSync(oldPath).isFile()) {
|
|
2192
|
-
|
|
2211
|
+
const newPath = join2(newDir, file);
|
|
2212
|
+
copyFileSync(oldPath, newPath);
|
|
2213
|
+
chmodSync(newPath, 384);
|
|
2193
2214
|
}
|
|
2194
2215
|
}
|
|
2195
2216
|
}
|
|
2196
|
-
|
|
2217
|
+
ensurePrivateDir(hasnaDir);
|
|
2218
|
+
ensurePrivateDir(newDir);
|
|
2197
2219
|
return newDir;
|
|
2198
2220
|
}
|
|
2199
2221
|
function getDbPath() {
|
|
@@ -2207,8 +2229,7 @@ function ensureDir(filePath) {
|
|
|
2207
2229
|
if (filePath === ":memory:")
|
|
2208
2230
|
return;
|
|
2209
2231
|
const dir = dirname(resolve(filePath));
|
|
2210
|
-
|
|
2211
|
-
mkdirSync(dir, { recursive: true });
|
|
2232
|
+
ensureCreatedPrivateDir(dir);
|
|
2212
2233
|
}
|
|
2213
2234
|
function getDatabase(path) {
|
|
2214
2235
|
if (_db)
|
|
@@ -2219,10 +2240,12 @@ function getDatabase(path) {
|
|
|
2219
2240
|
db.exec("PRAGMA journal_mode=WAL");
|
|
2220
2241
|
db.exec("PRAGMA foreign_keys=ON");
|
|
2221
2242
|
runMigrations(db);
|
|
2243
|
+
protectDatabaseArtifacts(dbPath);
|
|
2222
2244
|
_db = db;
|
|
2223
2245
|
return db;
|
|
2224
2246
|
}
|
|
2225
2247
|
function resetDatabase() {
|
|
2248
|
+
_db?.close();
|
|
2226
2249
|
_db = null;
|
|
2227
2250
|
}
|
|
2228
2251
|
function uuid() {
|
|
@@ -14653,241 +14676,1241 @@ var init_storage = __esm(() => {
|
|
|
14653
14676
|
];
|
|
14654
14677
|
});
|
|
14655
14678
|
|
|
14656
|
-
// src/
|
|
14657
|
-
|
|
14658
|
-
|
|
14659
|
-
|
|
14660
|
-
|
|
14661
|
-
|
|
14662
|
-
|
|
14663
|
-
|
|
14664
|
-
|
|
14665
|
-
|
|
14666
|
-
|
|
14667
|
-
|
|
14668
|
-
|
|
14669
|
-
|
|
14670
|
-
|
|
14671
|
-
|
|
14672
|
-
|
|
14673
|
-
|
|
14674
|
-
|
|
14675
|
-
};
|
|
14676
|
-
}
|
|
14677
|
-
function unsupportedCloudSync(operation) {
|
|
14678
|
-
return {
|
|
14679
|
-
ok: false,
|
|
14680
|
-
operation,
|
|
14681
|
-
service: "contacts",
|
|
14682
|
-
mode: "local",
|
|
14683
|
-
error: "Repo-native contacts remote sync is not configured in this package yet.",
|
|
14684
|
-
next: "Use contacts_storage_status to inspect local data."
|
|
14685
|
-
};
|
|
14686
|
-
}
|
|
14687
|
-
function registerContactsStorageTools(server) {
|
|
14688
|
-
server.tool("contacts_storage_status", "Show contacts-owned local database storage status", {}, async () => {
|
|
14689
|
-
try {
|
|
14690
|
-
return ok(getStorageStatus());
|
|
14691
|
-
} catch (error) {
|
|
14692
|
-
return err(error);
|
|
14693
|
-
}
|
|
14694
|
-
});
|
|
14695
|
-
server.tool("contacts_cloud_status", "Compatibility status for contacts-owned storage and remote sync availability", {}, async () => {
|
|
14696
|
-
try {
|
|
14697
|
-
return ok(cloudStatus());
|
|
14698
|
-
} catch (error) {
|
|
14699
|
-
return err(error);
|
|
14700
|
-
}
|
|
14701
|
-
});
|
|
14702
|
-
server.tool("contacts_cloud_push", "Report contacts remote push availability", { tables: exports_external.string().optional().describe("Accepted for compatibility; remote sync is not configured") }, async () => ({
|
|
14703
|
-
content: [{ type: "text", text: JSON.stringify(unsupportedCloudSync("push"), null, 2) }],
|
|
14704
|
-
isError: true
|
|
14705
|
-
}));
|
|
14706
|
-
server.tool("contacts_cloud_pull", "Report contacts remote pull availability", { tables: exports_external.string().optional().describe("Accepted for compatibility; remote sync is not configured") }, async () => ({
|
|
14707
|
-
content: [{ type: "text", text: JSON.stringify(unsupportedCloudSync("pull"), null, 2) }],
|
|
14708
|
-
isError: true
|
|
14709
|
-
}));
|
|
14710
|
-
server.tool("contacts_cloud_feedback", "Save contacts feedback locally", {
|
|
14711
|
-
message: exports_external.string().describe("Feedback message"),
|
|
14712
|
-
email: exports_external.string().optional().describe("Contact email")
|
|
14713
|
-
}, async ({ message, email }) => {
|
|
14714
|
-
try {
|
|
14715
|
-
return ok(saveLocalFeedback({ message, email, category: "general" }));
|
|
14716
|
-
} catch (error) {
|
|
14717
|
-
return err(error);
|
|
14718
|
-
}
|
|
14719
|
-
});
|
|
14720
|
-
}
|
|
14721
|
-
var init_storage_tools = __esm(() => {
|
|
14722
|
-
init_zod();
|
|
14723
|
-
init_feedback();
|
|
14724
|
-
init_storage();
|
|
14725
|
-
});
|
|
14679
|
+
// src/db/pg-migrations.ts
|
|
14680
|
+
var PG_MIGRATIONS;
|
|
14681
|
+
var init_pg_migrations = __esm(() => {
|
|
14682
|
+
PG_MIGRATIONS = [
|
|
14683
|
+
`
|
|
14684
|
+
CREATE TABLE IF NOT EXISTS companies (
|
|
14685
|
+
id TEXT PRIMARY KEY,
|
|
14686
|
+
name TEXT NOT NULL,
|
|
14687
|
+
domain TEXT,
|
|
14688
|
+
logo_url TEXT,
|
|
14689
|
+
description TEXT,
|
|
14690
|
+
industry TEXT,
|
|
14691
|
+
size TEXT,
|
|
14692
|
+
founded_year INTEGER,
|
|
14693
|
+
notes TEXT,
|
|
14694
|
+
custom_fields TEXT NOT NULL DEFAULT '{}',
|
|
14695
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
14696
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14697
|
+
);
|
|
14726
14698
|
|
|
14727
|
-
|
|
14728
|
-
|
|
14729
|
-
|
|
14730
|
-
|
|
14731
|
-
|
|
14732
|
-
|
|
14733
|
-
|
|
14734
|
-
|
|
14735
|
-
|
|
14736
|
-
|
|
14737
|
-
|
|
14738
|
-
|
|
14739
|
-
|
|
14740
|
-
|
|
14741
|
-
|
|
14742
|
-
|
|
14743
|
-
registerContactsTools(server);
|
|
14744
|
-
registerContactsStorageTools(server);
|
|
14745
|
-
return server;
|
|
14746
|
-
}
|
|
14747
|
-
async function main() {
|
|
14748
|
-
const args = process.argv.slice(2);
|
|
14749
|
-
if (isHttpMode(args)) {
|
|
14750
|
-
startMcpHttpServer({
|
|
14751
|
-
name: "contacts",
|
|
14752
|
-
port: resolveMcpHttpPort(args),
|
|
14753
|
-
buildServer
|
|
14754
|
-
});
|
|
14755
|
-
return;
|
|
14756
|
-
}
|
|
14757
|
-
const transport = new StdioServerTransport;
|
|
14758
|
-
await buildServer().connect(transport);
|
|
14759
|
-
console.error("Contacts MCP server running on stdio");
|
|
14760
|
-
}
|
|
14761
|
-
var isDirectRun;
|
|
14762
|
-
var init_mcp = __esm(() => {
|
|
14763
|
-
init_register_tools();
|
|
14764
|
-
init_storage_tools();
|
|
14765
|
-
init_http();
|
|
14766
|
-
isDirectRun = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("mcp/index.ts") || process.argv[1]?.endsWith("mcp/index.js");
|
|
14767
|
-
if (isDirectRun) {
|
|
14768
|
-
main().catch((err2) => {
|
|
14769
|
-
console.error("Fatal error:", err2);
|
|
14770
|
-
process.exit(1);
|
|
14771
|
-
});
|
|
14772
|
-
}
|
|
14773
|
-
});
|
|
14699
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
14700
|
+
id TEXT PRIMARY KEY,
|
|
14701
|
+
first_name TEXT NOT NULL DEFAULT '',
|
|
14702
|
+
last_name TEXT NOT NULL DEFAULT '',
|
|
14703
|
+
display_name TEXT NOT NULL,
|
|
14704
|
+
nickname TEXT,
|
|
14705
|
+
avatar_url TEXT,
|
|
14706
|
+
notes TEXT,
|
|
14707
|
+
birthday TEXT,
|
|
14708
|
+
company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
|
|
14709
|
+
job_title TEXT,
|
|
14710
|
+
source TEXT NOT NULL DEFAULT 'manual',
|
|
14711
|
+
custom_fields TEXT NOT NULL DEFAULT '{}',
|
|
14712
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
14713
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14714
|
+
);
|
|
14774
14715
|
|
|
14775
|
-
|
|
14776
|
-
|
|
14777
|
-
|
|
14778
|
-
|
|
14779
|
-
|
|
14780
|
-
|
|
14781
|
-
|
|
14782
|
-
|
|
14783
|
-
|
|
14784
|
-
|
|
14785
|
-
|
|
14786
|
-
|
|
14787
|
-
|
|
14788
|
-
|
|
14789
|
-
|
|
14790
|
-
|
|
14791
|
-
|
|
14792
|
-
|
|
14793
|
-
|
|
14794
|
-
|
|
14795
|
-
|
|
14796
|
-
|
|
14797
|
-
|
|
14798
|
-
|
|
14799
|
-
|
|
14800
|
-
|
|
14801
|
-
|
|
14802
|
-
|
|
14803
|
-
|
|
14804
|
-
|
|
14805
|
-
|
|
14806
|
-
|
|
14807
|
-
|
|
14808
|
-
|
|
14809
|
-
|
|
14810
|
-
|
|
14811
|
-
|
|
14812
|
-
|
|
14813
|
-
|
|
14814
|
-
|
|
14815
|
-
|
|
14816
|
-
|
|
14817
|
-
|
|
14818
|
-
|
|
14819
|
-
|
|
14820
|
-
|
|
14821
|
-
|
|
14822
|
-
|
|
14823
|
-
|
|
14824
|
-
|
|
14825
|
-
|
|
14826
|
-
|
|
14827
|
-
|
|
14828
|
-
|
|
14829
|
-
|
|
14830
|
-
|
|
14831
|
-
|
|
14832
|
-
|
|
14833
|
-
|
|
14834
|
-
|
|
14835
|
-
|
|
14836
|
-
|
|
14837
|
-
|
|
14838
|
-
|
|
14839
|
-
|
|
14840
|
-
|
|
14841
|
-
|
|
14842
|
-
|
|
14843
|
-
|
|
14844
|
-
|
|
14845
|
-
|
|
14846
|
-
|
|
14847
|
-
|
|
14848
|
-
|
|
14849
|
-
|
|
14850
|
-
|
|
14851
|
-
|
|
14852
|
-
|
|
14853
|
-
|
|
14854
|
-
|
|
14855
|
-
|
|
14856
|
-
|
|
14857
|
-
|
|
14858
|
-
|
|
14859
|
-
|
|
14860
|
-
|
|
14861
|
-
|
|
14862
|
-
|
|
14863
|
-
|
|
14864
|
-
|
|
14865
|
-
|
|
14866
|
-
|
|
14867
|
-
|
|
14868
|
-
|
|
14869
|
-
|
|
14870
|
-
|
|
14871
|
-
|
|
14872
|
-
|
|
14873
|
-
|
|
14874
|
-
|
|
14875
|
-
|
|
14876
|
-
|
|
14877
|
-
|
|
14878
|
-
|
|
14879
|
-
|
|
14880
|
-
|
|
14881
|
-
|
|
14882
|
-
|
|
14883
|
-
|
|
14884
|
-
|
|
14885
|
-
|
|
14886
|
-
|
|
14887
|
-
|
|
14888
|
-
|
|
14889
|
-
|
|
14890
|
-
|
|
14716
|
+
CREATE TABLE IF NOT EXISTS tags (
|
|
14717
|
+
id TEXT PRIMARY KEY,
|
|
14718
|
+
name TEXT NOT NULL UNIQUE,
|
|
14719
|
+
color TEXT NOT NULL DEFAULT '#6366f1',
|
|
14720
|
+
description TEXT,
|
|
14721
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14722
|
+
);
|
|
14723
|
+
|
|
14724
|
+
CREATE TABLE IF NOT EXISTS contact_tags (
|
|
14725
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14726
|
+
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
14727
|
+
PRIMARY KEY (contact_id, tag_id)
|
|
14728
|
+
);
|
|
14729
|
+
|
|
14730
|
+
CREATE TABLE IF NOT EXISTS company_tags (
|
|
14731
|
+
company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
14732
|
+
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
14733
|
+
PRIMARY KEY (company_id, tag_id)
|
|
14734
|
+
);
|
|
14735
|
+
|
|
14736
|
+
CREATE TABLE IF NOT EXISTS emails (
|
|
14737
|
+
id TEXT PRIMARY KEY,
|
|
14738
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14739
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
14740
|
+
address TEXT NOT NULL,
|
|
14741
|
+
type TEXT NOT NULL DEFAULT 'work' CHECK(type IN ('work','personal','other')),
|
|
14742
|
+
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
|
14743
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14744
|
+
);
|
|
14745
|
+
|
|
14746
|
+
CREATE TABLE IF NOT EXISTS phones (
|
|
14747
|
+
id TEXT PRIMARY KEY,
|
|
14748
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14749
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
14750
|
+
number TEXT NOT NULL,
|
|
14751
|
+
country_code TEXT,
|
|
14752
|
+
type TEXT NOT NULL DEFAULT 'mobile' CHECK(type IN ('mobile','work','home','fax','whatsapp','other')),
|
|
14753
|
+
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
|
14754
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14755
|
+
);
|
|
14756
|
+
|
|
14757
|
+
CREATE TABLE IF NOT EXISTS addresses (
|
|
14758
|
+
id TEXT PRIMARY KEY,
|
|
14759
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14760
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
14761
|
+
type TEXT NOT NULL DEFAULT 'physical' CHECK(type IN ('physical','mailing','billing','virtual','other')),
|
|
14762
|
+
street TEXT,
|
|
14763
|
+
city TEXT,
|
|
14764
|
+
state TEXT,
|
|
14765
|
+
zip TEXT,
|
|
14766
|
+
country TEXT,
|
|
14767
|
+
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
|
14768
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14769
|
+
);
|
|
14770
|
+
|
|
14771
|
+
CREATE TABLE IF NOT EXISTS social_profiles (
|
|
14772
|
+
id TEXT PRIMARY KEY,
|
|
14773
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14774
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
14775
|
+
platform TEXT NOT NULL CHECK(platform IN ('twitter','linkedin','github','instagram','telegram','discord','youtube','tiktok','bluesky','facebook','whatsapp','snapchat','reddit','other')),
|
|
14776
|
+
handle TEXT,
|
|
14777
|
+
url TEXT,
|
|
14778
|
+
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
|
14779
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14780
|
+
);
|
|
14781
|
+
|
|
14782
|
+
CREATE TABLE IF NOT EXISTS contact_relationships (
|
|
14783
|
+
id TEXT PRIMARY KEY,
|
|
14784
|
+
contact_a_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14785
|
+
contact_b_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14786
|
+
relationship_type TEXT NOT NULL CHECK(relationship_type IN ('colleague','friend','family','reports_to','mentor','investor','partner','client','vendor','other')),
|
|
14787
|
+
notes TEXT,
|
|
14788
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14789
|
+
);
|
|
14790
|
+
|
|
14791
|
+
CREATE TABLE IF NOT EXISTS activity_log (
|
|
14792
|
+
id TEXT PRIMARY KEY,
|
|
14793
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14794
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
14795
|
+
action TEXT NOT NULL,
|
|
14796
|
+
details TEXT,
|
|
14797
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14798
|
+
);
|
|
14799
|
+
|
|
14800
|
+
CREATE TABLE IF NOT EXISTS webhooks (
|
|
14801
|
+
id TEXT PRIMARY KEY,
|
|
14802
|
+
url TEXT NOT NULL,
|
|
14803
|
+
events TEXT NOT NULL DEFAULT '["*"]',
|
|
14804
|
+
secret TEXT,
|
|
14805
|
+
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
14806
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14807
|
+
);
|
|
14808
|
+
|
|
14809
|
+
-- Full-text search using PostgreSQL tsvector instead of FTS5
|
|
14810
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
|
14811
|
+
CREATE INDEX IF NOT EXISTS idx_contacts_search ON contacts USING GIN(search_vector);
|
|
14812
|
+
|
|
14813
|
+
CREATE OR REPLACE FUNCTION contacts_search_vector_update() RETURNS trigger AS $$
|
|
14814
|
+
BEGIN
|
|
14815
|
+
NEW.search_vector :=
|
|
14816
|
+
setweight(to_tsvector('simple', COALESCE(NEW.display_name, '')), 'A') ||
|
|
14817
|
+
setweight(to_tsvector('simple', COALESCE(NEW.first_name, '')), 'A') ||
|
|
14818
|
+
setweight(to_tsvector('simple', COALESCE(NEW.last_name, '')), 'A') ||
|
|
14819
|
+
setweight(to_tsvector('simple', COALESCE(NEW.nickname, '')), 'B') ||
|
|
14820
|
+
setweight(to_tsvector('english', COALESCE(NEW.notes, '')), 'C') ||
|
|
14821
|
+
setweight(to_tsvector('simple', COALESCE(NEW.job_title, '')), 'B');
|
|
14822
|
+
RETURN NEW;
|
|
14823
|
+
END;
|
|
14824
|
+
$$ LANGUAGE plpgsql;
|
|
14825
|
+
|
|
14826
|
+
DROP TRIGGER IF EXISTS contacts_search_vector_trigger ON contacts;
|
|
14827
|
+
CREATE TRIGGER contacts_search_vector_trigger
|
|
14828
|
+
BEFORE INSERT OR UPDATE OF display_name, first_name, last_name, nickname, notes, job_title ON contacts
|
|
14829
|
+
FOR EACH ROW EXECUTE FUNCTION contacts_search_vector_update();
|
|
14830
|
+
|
|
14831
|
+
-- Backfill
|
|
14832
|
+
UPDATE contacts SET search_vector =
|
|
14833
|
+
setweight(to_tsvector('simple', COALESCE(display_name, '')), 'A') ||
|
|
14834
|
+
setweight(to_tsvector('simple', COALESCE(first_name, '')), 'A') ||
|
|
14835
|
+
setweight(to_tsvector('simple', COALESCE(last_name, '')), 'A') ||
|
|
14836
|
+
setweight(to_tsvector('simple', COALESCE(nickname, '')), 'B') ||
|
|
14837
|
+
setweight(to_tsvector('english', COALESCE(notes, '')), 'C') ||
|
|
14838
|
+
setweight(to_tsvector('simple', COALESCE(job_title, '')), 'B')
|
|
14839
|
+
WHERE search_vector IS NULL;
|
|
14840
|
+
|
|
14841
|
+
CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY);
|
|
14842
|
+
INSERT INTO _migrations (version) VALUES (0) ON CONFLICT DO NOTHING;
|
|
14843
|
+
`,
|
|
14844
|
+
`
|
|
14845
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS last_contacted_at TEXT;
|
|
14846
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS website TEXT;
|
|
14847
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS preferred_contact_method TEXT;
|
|
14848
|
+
|
|
14849
|
+
CREATE TABLE IF NOT EXISTS groups (
|
|
14850
|
+
id TEXT PRIMARY KEY,
|
|
14851
|
+
name TEXT NOT NULL UNIQUE,
|
|
14852
|
+
description TEXT,
|
|
14853
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
14854
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14855
|
+
);
|
|
14856
|
+
|
|
14857
|
+
CREATE TABLE IF NOT EXISTS contact_groups (
|
|
14858
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14859
|
+
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
14860
|
+
PRIMARY KEY (contact_id, group_id)
|
|
14861
|
+
);
|
|
14862
|
+
|
|
14863
|
+
INSERT INTO _migrations (version) VALUES (1) ON CONFLICT DO NOTHING;
|
|
14864
|
+
`,
|
|
14865
|
+
`
|
|
14866
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active';
|
|
14867
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS follow_up_at TEXT;
|
|
14868
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE;
|
|
14869
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS project_id TEXT;
|
|
14870
|
+
ALTER TABLE companies ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE;
|
|
14871
|
+
ALTER TABLE companies ADD COLUMN IF NOT EXISTS project_id TEXT;
|
|
14872
|
+
|
|
14873
|
+
CREATE TABLE IF NOT EXISTS company_groups (
|
|
14874
|
+
company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
14875
|
+
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
14876
|
+
PRIMARY KEY (company_id, group_id)
|
|
14877
|
+
);
|
|
14878
|
+
|
|
14879
|
+
INSERT INTO _migrations (version) VALUES (2) ON CONFLICT DO NOTHING;
|
|
14880
|
+
`,
|
|
14881
|
+
`
|
|
14882
|
+
CREATE TABLE IF NOT EXISTS company_relationships (
|
|
14883
|
+
id TEXT PRIMARY KEY,
|
|
14884
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14885
|
+
company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
14886
|
+
relationship_type TEXT NOT NULL CHECK(relationship_type IN ('client','vendor','partner','employee','contractor','investor','advisor','other')),
|
|
14887
|
+
notes TEXT,
|
|
14888
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14889
|
+
);
|
|
14890
|
+
|
|
14891
|
+
CREATE INDEX IF NOT EXISTS idx_company_relationships_contact ON company_relationships(contact_id);
|
|
14892
|
+
CREATE INDEX IF NOT EXISTS idx_company_relationships_company ON company_relationships(company_id);
|
|
14893
|
+
|
|
14894
|
+
INSERT INTO _migrations (version) VALUES (3) ON CONFLICT DO NOTHING;
|
|
14895
|
+
`,
|
|
14896
|
+
`
|
|
14897
|
+
ALTER TABLE groups ADD COLUMN IF NOT EXISTS project_id TEXT;
|
|
14898
|
+
|
|
14899
|
+
CREATE TABLE IF NOT EXISTS contact_projects (
|
|
14900
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14901
|
+
project_id TEXT NOT NULL,
|
|
14902
|
+
PRIMARY KEY (contact_id, project_id)
|
|
14903
|
+
);
|
|
14904
|
+
|
|
14905
|
+
CREATE INDEX IF NOT EXISTS idx_contact_projects_project ON contact_projects(project_id);
|
|
14906
|
+
CREATE INDEX IF NOT EXISTS idx_contact_projects_contact ON contact_projects(contact_id);
|
|
14907
|
+
|
|
14908
|
+
INSERT INTO contact_projects (contact_id, project_id)
|
|
14909
|
+
SELECT id, project_id FROM contacts WHERE project_id IS NOT NULL
|
|
14910
|
+
ON CONFLICT DO NOTHING;
|
|
14911
|
+
|
|
14912
|
+
INSERT INTO _migrations (version) VALUES (4) ON CONFLICT DO NOTHING;
|
|
14913
|
+
`,
|
|
14914
|
+
`
|
|
14915
|
+
CREATE TABLE IF NOT EXISTS contact_notes (
|
|
14916
|
+
id TEXT PRIMARY KEY,
|
|
14917
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14918
|
+
body TEXT NOT NULL,
|
|
14919
|
+
created_by TEXT,
|
|
14920
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14921
|
+
);
|
|
14922
|
+
|
|
14923
|
+
CREATE INDEX IF NOT EXISTS idx_contact_notes_contact ON contact_notes(contact_id);
|
|
14924
|
+
|
|
14925
|
+
INSERT INTO _migrations (version) VALUES (5) ON CONFLICT DO NOTHING;
|
|
14926
|
+
`,
|
|
14927
|
+
`
|
|
14928
|
+
ALTER TABLE companies ADD COLUMN IF NOT EXISTS is_owned_entity BOOLEAN NOT NULL DEFAULT FALSE;
|
|
14929
|
+
ALTER TABLE companies ADD COLUMN IF NOT EXISTS entity_type TEXT CHECK(entity_type IN ('operating','holding','dissolved','nonprofit','trust','branch','other'));
|
|
14930
|
+
|
|
14931
|
+
ALTER TABLE company_relationships ADD COLUMN IF NOT EXISTS start_date TEXT;
|
|
14932
|
+
ALTER TABLE company_relationships ADD COLUMN IF NOT EXISTS end_date TEXT;
|
|
14933
|
+
ALTER TABLE company_relationships ADD COLUMN IF NOT EXISTS is_primary BOOLEAN NOT NULL DEFAULT FALSE;
|
|
14934
|
+
ALTER TABLE company_relationships ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','inactive','ended'));
|
|
14935
|
+
|
|
14936
|
+
CREATE TABLE IF NOT EXISTS org_members (
|
|
14937
|
+
id TEXT PRIMARY KEY,
|
|
14938
|
+
company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
14939
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14940
|
+
title TEXT,
|
|
14941
|
+
specialization TEXT,
|
|
14942
|
+
office_phone TEXT,
|
|
14943
|
+
response_sla_hours INTEGER,
|
|
14944
|
+
notes TEXT,
|
|
14945
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
14946
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
14947
|
+
UNIQUE(company_id, contact_id)
|
|
14948
|
+
);
|
|
14949
|
+
|
|
14950
|
+
CREATE TABLE IF NOT EXISTS vendor_communications (
|
|
14951
|
+
id TEXT PRIMARY KEY,
|
|
14952
|
+
company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
14953
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
|
|
14954
|
+
comm_date TEXT NOT NULL,
|
|
14955
|
+
type TEXT NOT NULL DEFAULT 'email' CHECK(type IN ('email','call','meeting','invoice_request','invoice_received','payment','dispute','other')),
|
|
14956
|
+
direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound','outbound')),
|
|
14957
|
+
subject TEXT,
|
|
14958
|
+
body TEXT,
|
|
14959
|
+
status TEXT NOT NULL DEFAULT 'sent' CHECK(status IN ('sent','awaiting_response','responded','no_response','resolved')),
|
|
14960
|
+
invoice_amount DOUBLE PRECISION,
|
|
14961
|
+
invoice_currency TEXT,
|
|
14962
|
+
invoice_ref TEXT,
|
|
14963
|
+
follow_up_date TEXT,
|
|
14964
|
+
follow_up_done BOOLEAN NOT NULL DEFAULT FALSE,
|
|
14965
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14966
|
+
);
|
|
14967
|
+
|
|
14968
|
+
CREATE TABLE IF NOT EXISTS contact_tasks (
|
|
14969
|
+
id TEXT PRIMARY KEY,
|
|
14970
|
+
title TEXT NOT NULL,
|
|
14971
|
+
description TEXT,
|
|
14972
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
14973
|
+
assigned_by TEXT,
|
|
14974
|
+
deadline TEXT,
|
|
14975
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','awaiting_response','in_progress','completed','cancelled','escalated')),
|
|
14976
|
+
priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('low','medium','high','critical')),
|
|
14977
|
+
entity_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
|
|
14978
|
+
linked_todos_task_id TEXT,
|
|
14979
|
+
escalation_rules TEXT NOT NULL DEFAULT '[]',
|
|
14980
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
14981
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
14982
|
+
);
|
|
14983
|
+
|
|
14984
|
+
CREATE TABLE IF NOT EXISTS applications (
|
|
14985
|
+
id TEXT PRIMARY KEY,
|
|
14986
|
+
program_name TEXT NOT NULL,
|
|
14987
|
+
provider_company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
|
|
14988
|
+
type TEXT NOT NULL DEFAULT 'other' CHECK(type IN ('ai_credits','grant','startup_program','visa','trademark','tax_filing','loan','other')),
|
|
14989
|
+
value_usd DOUBLE PRECISION,
|
|
14990
|
+
applicant_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
|
|
14991
|
+
primary_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
|
|
14992
|
+
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','submitted','pending','approved','rejected','follow_up_needed','expired','cancelled')),
|
|
14993
|
+
submitted_date TEXT,
|
|
14994
|
+
decision_date TEXT,
|
|
14995
|
+
follow_up_date TEXT,
|
|
14996
|
+
notes TEXT,
|
|
14997
|
+
method TEXT CHECK(method IN ('email','form','typeform','hubspot','manual','browser','feathery','other')),
|
|
14998
|
+
form_url TEXT,
|
|
14999
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
15000
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15001
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
15002
|
+
);
|
|
15003
|
+
|
|
15004
|
+
ALTER TABLE contact_notes ADD COLUMN IF NOT EXISTS company_id TEXT REFERENCES companies(id) ON DELETE SET NULL;
|
|
15005
|
+
|
|
15006
|
+
CREATE TABLE IF NOT EXISTS contact_embeddings (
|
|
15007
|
+
contact_id TEXT PRIMARY KEY REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15008
|
+
embedding TEXT NOT NULL,
|
|
15009
|
+
model TEXT NOT NULL DEFAULT 'tfidf',
|
|
15010
|
+
embedded_text TEXT,
|
|
15011
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15012
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
15013
|
+
);
|
|
15014
|
+
|
|
15015
|
+
INSERT INTO _migrations (version) VALUES (6) ON CONFLICT DO NOTHING;
|
|
15016
|
+
`,
|
|
15017
|
+
`
|
|
15018
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS do_not_contact BOOLEAN NOT NULL DEFAULT FALSE;
|
|
15019
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 3 CHECK(priority BETWEEN 1 AND 5);
|
|
15020
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS timezone TEXT;
|
|
15021
|
+
|
|
15022
|
+
CREATE TABLE IF NOT EXISTS deals (
|
|
15023
|
+
id TEXT PRIMARY KEY,
|
|
15024
|
+
title TEXT NOT NULL,
|
|
15025
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
|
|
15026
|
+
company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
|
|
15027
|
+
stage TEXT NOT NULL DEFAULT 'lead' CHECK(stage IN ('lead','qualified','proposal','negotiation','won','lost','cancelled')),
|
|
15028
|
+
value_usd DOUBLE PRECISION,
|
|
15029
|
+
currency TEXT NOT NULL DEFAULT 'USD',
|
|
15030
|
+
close_date TEXT,
|
|
15031
|
+
notes TEXT,
|
|
15032
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15033
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
15034
|
+
);
|
|
15035
|
+
|
|
15036
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
15037
|
+
id TEXT PRIMARY KEY,
|
|
15038
|
+
title TEXT NOT NULL,
|
|
15039
|
+
type TEXT NOT NULL DEFAULT 'meeting' CHECK(type IN ('meeting','call','lunch','email','demo','conference','intro','other')),
|
|
15040
|
+
event_date TEXT NOT NULL,
|
|
15041
|
+
duration_min INTEGER,
|
|
15042
|
+
contact_ids TEXT NOT NULL DEFAULT '[]',
|
|
15043
|
+
company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
|
|
15044
|
+
notes TEXT,
|
|
15045
|
+
outcome TEXT,
|
|
15046
|
+
deal_id TEXT REFERENCES deals(id) ON DELETE SET NULL,
|
|
15047
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
15048
|
+
);
|
|
15049
|
+
|
|
15050
|
+
INSERT INTO _migrations (version) VALUES (7) ON CONFLICT DO NOTHING;
|
|
15051
|
+
`,
|
|
15052
|
+
`
|
|
15053
|
+
CREATE TABLE IF NOT EXISTS contact_field_history (
|
|
15054
|
+
id TEXT PRIMARY KEY,
|
|
15055
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15056
|
+
field_name TEXT NOT NULL,
|
|
15057
|
+
old_value TEXT,
|
|
15058
|
+
new_value TEXT,
|
|
15059
|
+
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15060
|
+
source TEXT,
|
|
15061
|
+
confidence TEXT NOT NULL DEFAULT 'imported' CHECK(confidence IN ('verified','inferred','imported','stale')),
|
|
15062
|
+
created_by TEXT,
|
|
15063
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
15064
|
+
);
|
|
15065
|
+
|
|
15066
|
+
CREATE TABLE IF NOT EXISTS job_history (
|
|
15067
|
+
id TEXT PRIMARY KEY,
|
|
15068
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15069
|
+
company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
|
|
15070
|
+
company_name TEXT NOT NULL,
|
|
15071
|
+
title TEXT,
|
|
15072
|
+
start_date TEXT,
|
|
15073
|
+
end_date TEXT,
|
|
15074
|
+
is_current BOOLEAN NOT NULL DEFAULT FALSE,
|
|
15075
|
+
inferred BOOLEAN NOT NULL DEFAULT FALSE,
|
|
15076
|
+
source TEXT,
|
|
15077
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
15078
|
+
);
|
|
15079
|
+
|
|
15080
|
+
CREATE TABLE IF NOT EXISTS contact_learnings (
|
|
15081
|
+
id TEXT PRIMARY KEY,
|
|
15082
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15083
|
+
content TEXT NOT NULL,
|
|
15084
|
+
type TEXT NOT NULL DEFAULT 'fact' CHECK(type IN ('preference','fact','inference','warning','signal')),
|
|
15085
|
+
confidence INTEGER NOT NULL DEFAULT 70 CHECK(confidence BETWEEN 0 AND 100),
|
|
15086
|
+
importance INTEGER NOT NULL DEFAULT 5 CHECK(importance BETWEEN 1 AND 10),
|
|
15087
|
+
learned_by TEXT,
|
|
15088
|
+
session_id TEXT,
|
|
15089
|
+
visibility TEXT NOT NULL DEFAULT 'shared' CHECK(visibility IN ('private','shared','human')),
|
|
15090
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
15091
|
+
confirmed_count INTEGER NOT NULL DEFAULT 0,
|
|
15092
|
+
contradicts_id TEXT REFERENCES contact_learnings(id) ON DELETE SET NULL,
|
|
15093
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15094
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
15095
|
+
);
|
|
15096
|
+
|
|
15097
|
+
CREATE TABLE IF NOT EXISTS contact_locks (
|
|
15098
|
+
id TEXT PRIMARY KEY,
|
|
15099
|
+
contact_id TEXT NOT NULL UNIQUE REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15100
|
+
agent_name TEXT NOT NULL,
|
|
15101
|
+
reason TEXT,
|
|
15102
|
+
acquired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15103
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
15104
|
+
session_id TEXT
|
|
15105
|
+
);
|
|
15106
|
+
|
|
15107
|
+
CREATE TABLE IF NOT EXISTS contact_agent_activity (
|
|
15108
|
+
id TEXT PRIMARY KEY,
|
|
15109
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15110
|
+
agent_name TEXT NOT NULL,
|
|
15111
|
+
action TEXT NOT NULL,
|
|
15112
|
+
details TEXT,
|
|
15113
|
+
session_id TEXT,
|
|
15114
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
15115
|
+
);
|
|
15116
|
+
|
|
15117
|
+
ALTER TABLE contact_relationships ADD COLUMN IF NOT EXISTS strength_score INTEGER NOT NULL DEFAULT 50;
|
|
15118
|
+
ALTER TABLE contact_relationships ADD COLUMN IF NOT EXISTS interaction_count INTEGER NOT NULL DEFAULT 0;
|
|
15119
|
+
ALTER TABLE contact_relationships ADD COLUMN IF NOT EXISTS last_interaction TEXT;
|
|
15120
|
+
ALTER TABLE contact_relationships ADD COLUMN IF NOT EXISTS relationship_status TEXT NOT NULL DEFAULT 'stable' CHECK(relationship_status IN ('warming','stable','cooling','ghost'));
|
|
15121
|
+
|
|
15122
|
+
CREATE TABLE IF NOT EXISTS contact_identities (
|
|
15123
|
+
id TEXT PRIMARY KEY,
|
|
15124
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15125
|
+
system TEXT NOT NULL,
|
|
15126
|
+
external_id TEXT NOT NULL,
|
|
15127
|
+
external_url TEXT,
|
|
15128
|
+
confidence TEXT NOT NULL DEFAULT 'inferred' CHECK(confidence IN ('verified','inferred')),
|
|
15129
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15130
|
+
UNIQUE(system, external_id)
|
|
15131
|
+
);
|
|
15132
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS canonical_id TEXT;
|
|
15133
|
+
|
|
15134
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS relationship_health INTEGER NOT NULL DEFAULT 50;
|
|
15135
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS avg_response_hours DOUBLE PRECISION;
|
|
15136
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS preferred_channel TEXT;
|
|
15137
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS engagement_status TEXT NOT NULL DEFAULT 'new' CHECK(engagement_status IN ('warming','stable','cooling','ghost','new'));
|
|
15138
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS interaction_count_30d INTEGER NOT NULL DEFAULT 0;
|
|
15139
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS interaction_count_90d INTEGER NOT NULL DEFAULT 0;
|
|
15140
|
+
|
|
15141
|
+
CREATE TABLE IF NOT EXISTS contact_field_confidence (
|
|
15142
|
+
id TEXT PRIMARY KEY,
|
|
15143
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15144
|
+
field_name TEXT NOT NULL,
|
|
15145
|
+
confidence TEXT NOT NULL DEFAULT 'imported' CHECK(confidence IN ('verified','inferred','imported','stale')),
|
|
15146
|
+
source TEXT,
|
|
15147
|
+
last_verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15148
|
+
UNIQUE(contact_id, field_name)
|
|
15149
|
+
);
|
|
15150
|
+
|
|
15151
|
+
CREATE TABLE IF NOT EXISTS org_chart_edges (
|
|
15152
|
+
id TEXT PRIMARY KEY,
|
|
15153
|
+
company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
15154
|
+
contact_a_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15155
|
+
contact_b_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15156
|
+
edge_type TEXT NOT NULL CHECK(edge_type IN ('reports_to','manages','collaborates_with','peer')),
|
|
15157
|
+
inferred BOOLEAN NOT NULL DEFAULT FALSE,
|
|
15158
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15159
|
+
UNIQUE(company_id, contact_a_id, contact_b_id, edge_type)
|
|
15160
|
+
);
|
|
15161
|
+
|
|
15162
|
+
CREATE TABLE IF NOT EXISTS deal_contact_roles (
|
|
15163
|
+
id TEXT PRIMARY KEY,
|
|
15164
|
+
deal_id TEXT NOT NULL REFERENCES deals(id) ON DELETE CASCADE,
|
|
15165
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15166
|
+
account_role TEXT NOT NULL CHECK(account_role IN ('economic_buyer','technical_evaluator','champion','blocker','influencer','user','sponsor','other')),
|
|
15167
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15168
|
+
UNIQUE(deal_id, contact_id)
|
|
15169
|
+
);
|
|
15170
|
+
|
|
15171
|
+
INSERT INTO _migrations (version) VALUES (8) ON CONFLICT DO NOTHING;
|
|
15172
|
+
`,
|
|
15173
|
+
`
|
|
15174
|
+
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS sensitivity TEXT NOT NULL DEFAULT 'normal' CHECK(sensitivity IN ('normal','confidential','restricted'));
|
|
15175
|
+
|
|
15176
|
+
CREATE TABLE IF NOT EXISTS contact_documents (
|
|
15177
|
+
id TEXT PRIMARY KEY,
|
|
15178
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15179
|
+
doc_type TEXT NOT NULL,
|
|
15180
|
+
label TEXT,
|
|
15181
|
+
encrypted_value TEXT NOT NULL,
|
|
15182
|
+
iv TEXT NOT NULL,
|
|
15183
|
+
encrypted_file_path TEXT,
|
|
15184
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
15185
|
+
expires_at TEXT,
|
|
15186
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15187
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
15188
|
+
);
|
|
15189
|
+
|
|
15190
|
+
CREATE TABLE IF NOT EXISTS contact_health (
|
|
15191
|
+
id TEXT PRIMARY KEY,
|
|
15192
|
+
contact_id TEXT NOT NULL UNIQUE REFERENCES contacts(id) ON DELETE CASCADE,
|
|
15193
|
+
blood_type TEXT,
|
|
15194
|
+
allergies TEXT NOT NULL DEFAULT '[]',
|
|
15195
|
+
medical_conditions TEXT NOT NULL DEFAULT '[]',
|
|
15196
|
+
medications TEXT NOT NULL DEFAULT '[]',
|
|
15197
|
+
emergency_contacts TEXT NOT NULL DEFAULT '[]',
|
|
15198
|
+
health_insurance_provider TEXT,
|
|
15199
|
+
health_insurance_id TEXT,
|
|
15200
|
+
primary_physician TEXT,
|
|
15201
|
+
primary_physician_phone TEXT,
|
|
15202
|
+
organ_donor BOOLEAN NOT NULL DEFAULT FALSE,
|
|
15203
|
+
notes TEXT,
|
|
15204
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15205
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
15206
|
+
);
|
|
15207
|
+
|
|
15208
|
+
INSERT INTO _migrations (version) VALUES (9) ON CONFLICT DO NOTHING;
|
|
15209
|
+
`,
|
|
15210
|
+
`
|
|
15211
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
15212
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
15213
|
+
message TEXT NOT NULL,
|
|
15214
|
+
email TEXT,
|
|
15215
|
+
category TEXT DEFAULT 'general',
|
|
15216
|
+
version TEXT,
|
|
15217
|
+
machine_id TEXT,
|
|
15218
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
15219
|
+
);
|
|
15220
|
+
|
|
15221
|
+
INSERT INTO _migrations (version) VALUES (10) ON CONFLICT DO NOTHING;
|
|
15222
|
+
`
|
|
15223
|
+
];
|
|
15224
|
+
});
|
|
15225
|
+
|
|
15226
|
+
// src/db/remote-storage.ts
|
|
15227
|
+
import pg from "pg";
|
|
15228
|
+
function parsePgUrl(connectionString) {
|
|
15229
|
+
try {
|
|
15230
|
+
return new URL(connectionString);
|
|
15231
|
+
} catch {
|
|
15232
|
+
return;
|
|
15233
|
+
}
|
|
15234
|
+
}
|
|
15235
|
+
function isLocalPgHost(hostname2) {
|
|
15236
|
+
return LOCAL_PG_HOSTS.has(hostname2.replace(/^\[|\]$/g, ""));
|
|
15237
|
+
}
|
|
15238
|
+
function normalizedParams(url, name) {
|
|
15239
|
+
return url.searchParams.getAll(name).map((value) => value.trim().toLowerCase());
|
|
15240
|
+
}
|
|
15241
|
+
function isFalseySslValue(value) {
|
|
15242
|
+
return value === "0" || value === "false" || value === "no" || value === "disable" || value === "disabled";
|
|
15243
|
+
}
|
|
15244
|
+
function isTruthyValue(value) {
|
|
15245
|
+
return value === "1" || value === "true" || value === "yes";
|
|
15246
|
+
}
|
|
15247
|
+
function sanitizedConnectionString(url) {
|
|
15248
|
+
for (const param of ["ssl", "sslmode", "uselibpqcompat"]) {
|
|
15249
|
+
url.searchParams.delete(param);
|
|
15250
|
+
}
|
|
15251
|
+
return url.toString();
|
|
15252
|
+
}
|
|
15253
|
+
function buildPgPoolConfig(connectionString) {
|
|
15254
|
+
const url = parsePgUrl(connectionString);
|
|
15255
|
+
if (!url) {
|
|
15256
|
+
return { connectionString, ssl: { rejectUnauthorized: true } };
|
|
15257
|
+
}
|
|
15258
|
+
const localHost = isLocalPgHost(url.hostname);
|
|
15259
|
+
const sslModes = normalizedParams(url, "sslmode");
|
|
15260
|
+
const sslParams = normalizedParams(url, "ssl");
|
|
15261
|
+
const libpqCompatParams = normalizedParams(url, "uselibpqcompat");
|
|
15262
|
+
const explicitTlsOff = sslModes.includes("disable") || sslParams.some(isFalseySslValue);
|
|
15263
|
+
const weakTlsMode = sslModes.some((mode) => mode === "allow" || mode === "prefer" || mode === "no-verify");
|
|
15264
|
+
const libpqCompatibility = libpqCompatParams.some(isTruthyValue);
|
|
15265
|
+
const sanitized = sanitizedConnectionString(url);
|
|
15266
|
+
if ((explicitTlsOff || weakTlsMode || libpqCompatibility) && !localHost) {
|
|
15267
|
+
throw new Error("Remote contacts PostgreSQL storage must use verified TLS.");
|
|
15268
|
+
}
|
|
15269
|
+
if (localHost && (explicitTlsOff || sslModes.length === 0 && sslParams.length === 0)) {
|
|
15270
|
+
return { connectionString: sanitized };
|
|
15271
|
+
}
|
|
15272
|
+
return { connectionString: sanitized, ssl: { rejectUnauthorized: true } };
|
|
15273
|
+
}
|
|
15274
|
+
function translatePlaceholders(sql) {
|
|
15275
|
+
let index = 0;
|
|
15276
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
15277
|
+
}
|
|
15278
|
+
function normalizeParams2(params) {
|
|
15279
|
+
const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
15280
|
+
return flat.map((value) => value === undefined ? null : value);
|
|
15281
|
+
}
|
|
15282
|
+
|
|
15283
|
+
class PgAdapterAsync {
|
|
15284
|
+
pool;
|
|
15285
|
+
constructor(connectionString) {
|
|
15286
|
+
this.pool = new pg.Pool(buildPgPoolConfig(connectionString));
|
|
15287
|
+
}
|
|
15288
|
+
async run(sql, ...params) {
|
|
15289
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams2(params));
|
|
15290
|
+
return { changes: result.rowCount ?? 0 };
|
|
15291
|
+
}
|
|
15292
|
+
async get(sql, ...params) {
|
|
15293
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams2(params));
|
|
15294
|
+
return result.rows[0] ?? null;
|
|
15295
|
+
}
|
|
15296
|
+
async all(sql, ...params) {
|
|
15297
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams2(params));
|
|
15298
|
+
return result.rows;
|
|
15299
|
+
}
|
|
15300
|
+
async exec(sql) {
|
|
15301
|
+
await this.pool.query(sql);
|
|
15302
|
+
}
|
|
15303
|
+
async close() {
|
|
15304
|
+
await this.pool.end();
|
|
15305
|
+
}
|
|
15306
|
+
}
|
|
15307
|
+
var LOCAL_PG_HOSTS;
|
|
15308
|
+
var init_remote_storage = __esm(() => {
|
|
15309
|
+
LOCAL_PG_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
15310
|
+
});
|
|
15311
|
+
|
|
15312
|
+
// src/db/remote-sync.ts
|
|
15313
|
+
function getRemoteDatabaseUrl() {
|
|
15314
|
+
for (const name of CONTACTS_REMOTE_ENV) {
|
|
15315
|
+
const value = process.env[name];
|
|
15316
|
+
if (value)
|
|
15317
|
+
return value;
|
|
15318
|
+
}
|
|
15319
|
+
return null;
|
|
15320
|
+
}
|
|
15321
|
+
async function getRemotePg() {
|
|
15322
|
+
const url = getRemoteDatabaseUrl();
|
|
15323
|
+
if (!url)
|
|
15324
|
+
throw new Error(`Missing contacts remote database URL. Set one of: ${CONTACTS_REMOTE_ENV.join(", ")}`);
|
|
15325
|
+
return new PgAdapterAsync(url);
|
|
15326
|
+
}
|
|
15327
|
+
async function runRemoteMigrations(remote) {
|
|
15328
|
+
await remote.run("CREATE EXTENSION IF NOT EXISTS pgcrypto");
|
|
15329
|
+
for (const sql of PG_MIGRATIONS)
|
|
15330
|
+
await remote.run(sql);
|
|
15331
|
+
}
|
|
15332
|
+
async function pushRemote(options) {
|
|
15333
|
+
const remote = await getRemotePg();
|
|
15334
|
+
const db = getDatabase();
|
|
15335
|
+
try {
|
|
15336
|
+
await runRemoteMigrations(remote);
|
|
15337
|
+
const results = [];
|
|
15338
|
+
for (const table of resolveRemoteTables(options?.tables))
|
|
15339
|
+
results.push(await pushTable(db, remote, table));
|
|
15340
|
+
recordSyncMeta(db, "push", results);
|
|
15341
|
+
throwIfSyncErrors("push", results);
|
|
15342
|
+
return results;
|
|
15343
|
+
} finally {
|
|
15344
|
+
await remote.close();
|
|
15345
|
+
}
|
|
15346
|
+
}
|
|
15347
|
+
async function pullRemote(options) {
|
|
15348
|
+
const remote = await getRemotePg();
|
|
15349
|
+
const db = getDatabase();
|
|
15350
|
+
try {
|
|
15351
|
+
await runRemoteMigrations(remote);
|
|
15352
|
+
const results = [];
|
|
15353
|
+
for (const table of resolveRemoteTables(options?.tables))
|
|
15354
|
+
results.push(await pullTable(remote, db, table));
|
|
15355
|
+
recordSyncMeta(db, "pull", results);
|
|
15356
|
+
throwIfSyncErrors("pull", results);
|
|
15357
|
+
return results;
|
|
15358
|
+
} finally {
|
|
15359
|
+
await remote.close();
|
|
15360
|
+
}
|
|
15361
|
+
}
|
|
15362
|
+
async function syncRemote(options) {
|
|
15363
|
+
const pull = await pullRemote(options);
|
|
15364
|
+
const push = await pushRemote(options);
|
|
15365
|
+
return { pull, push };
|
|
15366
|
+
}
|
|
15367
|
+
function getSyncMetaAll() {
|
|
15368
|
+
const db = getDatabase();
|
|
15369
|
+
ensureSyncMetaTable(db);
|
|
15370
|
+
return db.query("SELECT table_name, last_synced_at, direction FROM _contacts_sync_meta ORDER BY table_name, direction").all();
|
|
15371
|
+
}
|
|
15372
|
+
function getRemoteStatus() {
|
|
15373
|
+
return {
|
|
15374
|
+
configured: Boolean(getRemoteDatabaseUrl()),
|
|
15375
|
+
env: CONTACTS_REMOTE_ENV,
|
|
15376
|
+
default_tables: CONTACTS_REMOTE_DEFAULT_TABLES,
|
|
15377
|
+
sensitive_tables: CONTACTS_REMOTE_SENSITIVE_TABLES,
|
|
15378
|
+
tables: CONTACTS_REMOTE_TABLES,
|
|
15379
|
+
sync: getSyncMetaAll()
|
|
15380
|
+
};
|
|
15381
|
+
}
|
|
15382
|
+
function resolveRemoteTables(tables) {
|
|
15383
|
+
if (!tables || tables.length === 0)
|
|
15384
|
+
return [...CONTACTS_REMOTE_DEFAULT_TABLES];
|
|
15385
|
+
const allowed = new Set(CONTACTS_REMOTE_TABLES);
|
|
15386
|
+
const requested = tables.map((table) => table.trim()).filter(Boolean);
|
|
15387
|
+
const invalid = requested.filter((table) => !allowed.has(table));
|
|
15388
|
+
if (invalid.length > 0)
|
|
15389
|
+
throw new Error(`Unknown contacts sync table(s): ${invalid.join(", ")}`);
|
|
15390
|
+
return requested;
|
|
15391
|
+
}
|
|
15392
|
+
async function pushTable(db, remote, table) {
|
|
15393
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
15394
|
+
try {
|
|
15395
|
+
const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
|
|
15396
|
+
result.rowsRead = rows.length;
|
|
15397
|
+
if (rows.length === 0)
|
|
15398
|
+
return result;
|
|
15399
|
+
const remoteColumns = await getRemoteColumns(remote, table);
|
|
15400
|
+
const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
|
|
15401
|
+
result.rowsWritten = await upsertPg(remote, table, columns, rows, remoteColumns);
|
|
15402
|
+
} catch (error) {
|
|
15403
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
15404
|
+
}
|
|
15405
|
+
return result;
|
|
15406
|
+
}
|
|
15407
|
+
async function pullTable(remote, db, table) {
|
|
15408
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
15409
|
+
try {
|
|
15410
|
+
const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
|
|
15411
|
+
result.rowsRead = rows.length;
|
|
15412
|
+
if (rows.length === 0)
|
|
15413
|
+
return result;
|
|
15414
|
+
const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
|
|
15415
|
+
result.rowsWritten = upsertSqlite(db, table, columns, rows);
|
|
15416
|
+
} catch (error) {
|
|
15417
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
15418
|
+
}
|
|
15419
|
+
return result;
|
|
15420
|
+
}
|
|
15421
|
+
async function getRemoteColumns(remote, table) {
|
|
15422
|
+
const rows = await remote.all("SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
|
|
15423
|
+
return new Map(rows.map((row) => [row.column_name, row.data_type]));
|
|
15424
|
+
}
|
|
15425
|
+
function filterRemoteColumns(remoteColumns, columns) {
|
|
15426
|
+
if (remoteColumns.size === 0)
|
|
15427
|
+
return columns;
|
|
15428
|
+
return columns.filter((column) => remoteColumns.has(column));
|
|
15429
|
+
}
|
|
15430
|
+
function filterLocalColumns(db, table, columns) {
|
|
15431
|
+
const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
|
|
15432
|
+
const allowed = new Set(rows.map((row) => row.name));
|
|
15433
|
+
return columns.filter((column) => allowed.has(column));
|
|
15434
|
+
}
|
|
15435
|
+
async function upsertPg(remote, table, columns, rows, remoteColumns) {
|
|
15436
|
+
if (columns.length === 0)
|
|
15437
|
+
return 0;
|
|
15438
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
15439
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
15440
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
15441
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
15442
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
15443
|
+
const fallbackKey = primaryKeys[0];
|
|
15444
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
|
|
15445
|
+
const whereClause = conflictWhereClause(table, columns, "EXCLUDED");
|
|
15446
|
+
for (const row of rows) {
|
|
15447
|
+
await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}${whereClause}`, columns.map((column) => toPostgresValue(row[column], remoteColumns.get(column))));
|
|
15448
|
+
}
|
|
15449
|
+
return rows.length;
|
|
15450
|
+
}
|
|
15451
|
+
function upsertSqlite(db, table, columns, rows) {
|
|
15452
|
+
if (columns.length === 0)
|
|
15453
|
+
return 0;
|
|
15454
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
15455
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
15456
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
15457
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
15458
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
15459
|
+
const fallbackKey = primaryKeys[0];
|
|
15460
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
|
|
15461
|
+
const whereClause = conflictWhereClause(table, columns, "excluded");
|
|
15462
|
+
const statement = db.prepare(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}${whereClause}`);
|
|
15463
|
+
for (const row of rows) {
|
|
15464
|
+
const values = columns.map((column) => normalizeSqliteSyncValue(row[column]));
|
|
15465
|
+
statement.run(...values);
|
|
15466
|
+
}
|
|
15467
|
+
return rows.length;
|
|
15468
|
+
}
|
|
15469
|
+
function conflictWhereClause(table, columns, incomingAlias) {
|
|
15470
|
+
const timestampColumn = columns.includes("updated_at") ? "updated_at" : columns.includes("created_at") ? "created_at" : null;
|
|
15471
|
+
if (!timestampColumn)
|
|
15472
|
+
return "";
|
|
15473
|
+
const existing = `${quoteIdent(table)}.${quoteIdent(timestampColumn)}`;
|
|
15474
|
+
const incoming = `${incomingAlias}.${quoteIdent(timestampColumn)}`;
|
|
15475
|
+
return ` WHERE ${existing} IS NULL OR ${incoming} IS NULL OR ${incoming} >= ${existing}`;
|
|
15476
|
+
}
|
|
15477
|
+
function toPostgresValue(value, dataType) {
|
|
15478
|
+
if (value === undefined)
|
|
15479
|
+
return null;
|
|
15480
|
+
if (dataType === "boolean") {
|
|
15481
|
+
if (value === null)
|
|
15482
|
+
return null;
|
|
15483
|
+
if (typeof value === "number")
|
|
15484
|
+
return value !== 0;
|
|
15485
|
+
if (typeof value === "string")
|
|
15486
|
+
return !["0", "false", "no", ""].includes(value.toLowerCase());
|
|
15487
|
+
return Boolean(value);
|
|
15488
|
+
}
|
|
15489
|
+
return value;
|
|
15490
|
+
}
|
|
15491
|
+
function normalizeSqliteSyncValue(value) {
|
|
15492
|
+
if (value === undefined || value === null)
|
|
15493
|
+
return null;
|
|
15494
|
+
if (value instanceof Date)
|
|
15495
|
+
return value.toISOString();
|
|
15496
|
+
if (typeof value === "boolean")
|
|
15497
|
+
return value ? 1 : 0;
|
|
15498
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer)
|
|
15499
|
+
return value;
|
|
15500
|
+
if (typeof value === "object")
|
|
15501
|
+
return JSON.stringify(value);
|
|
15502
|
+
return value;
|
|
15503
|
+
}
|
|
15504
|
+
function ensureSyncMetaTable(db) {
|
|
15505
|
+
db.exec(`
|
|
15506
|
+
CREATE TABLE IF NOT EXISTS _contacts_sync_meta (
|
|
15507
|
+
table_name TEXT NOT NULL,
|
|
15508
|
+
direction TEXT NOT NULL CHECK(direction IN ('push','pull')),
|
|
15509
|
+
last_synced_at TEXT NOT NULL,
|
|
15510
|
+
PRIMARY KEY (table_name, direction)
|
|
15511
|
+
)
|
|
15512
|
+
`);
|
|
15513
|
+
}
|
|
15514
|
+
function recordSyncMeta(db, direction, results) {
|
|
15515
|
+
ensureSyncMetaTable(db);
|
|
15516
|
+
const now4 = new Date().toISOString();
|
|
15517
|
+
const statement = db.prepare(`
|
|
15518
|
+
INSERT INTO _contacts_sync_meta (table_name, direction, last_synced_at)
|
|
15519
|
+
VALUES (?, ?, ?)
|
|
15520
|
+
ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
|
|
15521
|
+
`);
|
|
15522
|
+
for (const result of results) {
|
|
15523
|
+
if (result.errors.length === 0)
|
|
15524
|
+
statement.run(result.table, direction, now4);
|
|
15525
|
+
}
|
|
15526
|
+
}
|
|
15527
|
+
function throwIfSyncErrors(operation, results) {
|
|
15528
|
+
const failures = results.filter((result) => result.errors.length > 0);
|
|
15529
|
+
if (failures.length === 0)
|
|
15530
|
+
return;
|
|
15531
|
+
const details = failures.map((result) => `${result.table}: ${result.errors.join("; ")}`).join("; ");
|
|
15532
|
+
throw new ContactsRemoteSyncError(`Contacts remote ${operation} failed for ${failures.length} table(s): ${details}`, results);
|
|
15533
|
+
}
|
|
15534
|
+
function quoteIdent(identifier) {
|
|
15535
|
+
return `"${identifier.replaceAll('"', '""')}"`;
|
|
15536
|
+
}
|
|
15537
|
+
var CONTACTS_REMOTE_TABLES, CONTACTS_REMOTE_SENSITIVE_TABLES, CONTACTS_REMOTE_DEFAULT_TABLES, PRIMARY_KEYS, CONTACTS_REMOTE_ENV, ContactsRemoteSyncError;
|
|
15538
|
+
var init_remote_sync = __esm(() => {
|
|
15539
|
+
init_database();
|
|
15540
|
+
init_pg_migrations();
|
|
15541
|
+
init_remote_storage();
|
|
15542
|
+
CONTACTS_REMOTE_TABLES = [
|
|
15543
|
+
"companies",
|
|
15544
|
+
"contacts",
|
|
15545
|
+
"tags",
|
|
15546
|
+
"contact_tags",
|
|
15547
|
+
"company_tags",
|
|
15548
|
+
"emails",
|
|
15549
|
+
"phones",
|
|
15550
|
+
"addresses",
|
|
15551
|
+
"social_profiles",
|
|
15552
|
+
"contact_relationships",
|
|
15553
|
+
"activity_log",
|
|
15554
|
+
"webhooks",
|
|
15555
|
+
"groups",
|
|
15556
|
+
"contact_groups",
|
|
15557
|
+
"company_groups",
|
|
15558
|
+
"company_relationships",
|
|
15559
|
+
"contact_projects",
|
|
15560
|
+
"contact_notes",
|
|
15561
|
+
"org_members",
|
|
15562
|
+
"vendor_communications",
|
|
15563
|
+
"contact_tasks",
|
|
15564
|
+
"applications",
|
|
15565
|
+
"deals",
|
|
15566
|
+
"events",
|
|
15567
|
+
"contact_field_history",
|
|
15568
|
+
"job_history",
|
|
15569
|
+
"contact_learnings",
|
|
15570
|
+
"contact_locks",
|
|
15571
|
+
"contact_agent_activity",
|
|
15572
|
+
"contact_identities",
|
|
15573
|
+
"contact_field_confidence",
|
|
15574
|
+
"org_chart_edges",
|
|
15575
|
+
"deal_contact_roles",
|
|
15576
|
+
"contact_embeddings",
|
|
15577
|
+
"contact_documents",
|
|
15578
|
+
"contact_health",
|
|
15579
|
+
"feedback"
|
|
15580
|
+
];
|
|
15581
|
+
CONTACTS_REMOTE_SENSITIVE_TABLES = [
|
|
15582
|
+
"webhooks",
|
|
15583
|
+
"contact_documents",
|
|
15584
|
+
"contact_health"
|
|
15585
|
+
];
|
|
15586
|
+
CONTACTS_REMOTE_DEFAULT_TABLES = CONTACTS_REMOTE_TABLES.filter((table) => !CONTACTS_REMOTE_SENSITIVE_TABLES.includes(table));
|
|
15587
|
+
PRIMARY_KEYS = {
|
|
15588
|
+
companies: ["id"],
|
|
15589
|
+
contacts: ["id"],
|
|
15590
|
+
tags: ["id"],
|
|
15591
|
+
contact_tags: ["contact_id", "tag_id"],
|
|
15592
|
+
company_tags: ["company_id", "tag_id"],
|
|
15593
|
+
emails: ["id"],
|
|
15594
|
+
phones: ["id"],
|
|
15595
|
+
addresses: ["id"],
|
|
15596
|
+
social_profiles: ["id"],
|
|
15597
|
+
contact_relationships: ["id"],
|
|
15598
|
+
activity_log: ["id"],
|
|
15599
|
+
webhooks: ["id"],
|
|
15600
|
+
groups: ["id"],
|
|
15601
|
+
contact_groups: ["contact_id", "group_id"],
|
|
15602
|
+
company_groups: ["company_id", "group_id"],
|
|
15603
|
+
company_relationships: ["id"],
|
|
15604
|
+
contact_projects: ["contact_id", "project_id"],
|
|
15605
|
+
contact_notes: ["id"],
|
|
15606
|
+
org_members: ["id"],
|
|
15607
|
+
vendor_communications: ["id"],
|
|
15608
|
+
contact_tasks: ["id"],
|
|
15609
|
+
applications: ["id"],
|
|
15610
|
+
deals: ["id"],
|
|
15611
|
+
events: ["id"],
|
|
15612
|
+
contact_field_history: ["id"],
|
|
15613
|
+
job_history: ["id"],
|
|
15614
|
+
contact_learnings: ["id"],
|
|
15615
|
+
contact_locks: ["id"],
|
|
15616
|
+
contact_agent_activity: ["id"],
|
|
15617
|
+
contact_identities: ["id"],
|
|
15618
|
+
contact_field_confidence: ["id"],
|
|
15619
|
+
org_chart_edges: ["id"],
|
|
15620
|
+
deal_contact_roles: ["id"],
|
|
15621
|
+
contact_embeddings: ["contact_id"],
|
|
15622
|
+
contact_documents: ["id"],
|
|
15623
|
+
contact_health: ["id"],
|
|
15624
|
+
feedback: ["id"]
|
|
15625
|
+
};
|
|
15626
|
+
CONTACTS_REMOTE_ENV = [
|
|
15627
|
+
"HASNA_CONTACTS_POSTGRES_URL",
|
|
15628
|
+
"OPEN_CONTACTS_POSTGRES_URL",
|
|
15629
|
+
"CONTACTS_POSTGRES_URL",
|
|
15630
|
+
"HASNA_CONTACTS_DATABASE_URL",
|
|
15631
|
+
"CONTACTS_DATABASE_URL"
|
|
15632
|
+
];
|
|
15633
|
+
ContactsRemoteSyncError = class ContactsRemoteSyncError extends Error {
|
|
15634
|
+
results;
|
|
15635
|
+
constructor(message, results) {
|
|
15636
|
+
super(message);
|
|
15637
|
+
this.results = results;
|
|
15638
|
+
this.name = "ContactsRemoteSyncError";
|
|
15639
|
+
}
|
|
15640
|
+
};
|
|
15641
|
+
});
|
|
15642
|
+
|
|
15643
|
+
// src/mcp/storage-tools.ts
|
|
15644
|
+
function ok(data) {
|
|
15645
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
15646
|
+
}
|
|
15647
|
+
function err(error) {
|
|
15648
|
+
return {
|
|
15649
|
+
content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
|
|
15650
|
+
isError: true
|
|
15651
|
+
};
|
|
15652
|
+
}
|
|
15653
|
+
function cloudStatus() {
|
|
15654
|
+
const remote = getRemoteStatus();
|
|
15655
|
+
return {
|
|
15656
|
+
service: "contacts",
|
|
15657
|
+
mode: "local-first",
|
|
15658
|
+
remote_sync: {
|
|
15659
|
+
configured: Boolean(getRemoteDatabaseUrl()),
|
|
15660
|
+
env: CONTACTS_REMOTE_ENV,
|
|
15661
|
+
default_tables: remote.default_tables,
|
|
15662
|
+
sensitive_tables: remote.sensitive_tables,
|
|
15663
|
+
tables: CONTACTS_REMOTE_TABLES,
|
|
15664
|
+
sync: remote.sync,
|
|
15665
|
+
reason: getRemoteDatabaseUrl() ? "Contacts uses repo-owned PostgreSQL sync; the deprecated shared cloud runtime is not used." : "Set a contacts-owned PostgreSQL URL to enable sync; the deprecated shared cloud runtime is not used."
|
|
15666
|
+
},
|
|
15667
|
+
storage: getStorageStatus()
|
|
15668
|
+
};
|
|
15669
|
+
}
|
|
15670
|
+
function parseTables(tables) {
|
|
15671
|
+
if (!tables)
|
|
15672
|
+
return;
|
|
15673
|
+
return tables.split(",").map((table) => table.trim()).filter(Boolean);
|
|
15674
|
+
}
|
|
15675
|
+
function registerContactsStorageTools(server) {
|
|
15676
|
+
server.tool("contacts_storage_status", "Show contacts-owned local database storage status", {}, async () => {
|
|
15677
|
+
try {
|
|
15678
|
+
return ok({ mode: "local-first", local: getStorageStatus(), remote: getRemoteStatus() });
|
|
15679
|
+
} catch (error) {
|
|
15680
|
+
return err(error);
|
|
15681
|
+
}
|
|
15682
|
+
});
|
|
15683
|
+
server.tool("contacts_storage_push", "Push local contacts data to contacts-owned PostgreSQL storage", { tables: exports_external.string().optional().describe("Comma-separated table names") }, async ({ tables }) => {
|
|
15684
|
+
try {
|
|
15685
|
+
return ok(await pushRemote({ tables: parseTables(tables) }));
|
|
15686
|
+
} catch (error) {
|
|
15687
|
+
return err(error);
|
|
15688
|
+
}
|
|
15689
|
+
});
|
|
15690
|
+
server.tool("contacts_storage_pull", "Pull contacts data from contacts-owned PostgreSQL storage", { tables: exports_external.string().optional().describe("Comma-separated table names") }, async ({ tables }) => {
|
|
15691
|
+
try {
|
|
15692
|
+
return ok(await pullRemote({ tables: parseTables(tables) }));
|
|
15693
|
+
} catch (error) {
|
|
15694
|
+
return err(error);
|
|
15695
|
+
}
|
|
15696
|
+
});
|
|
15697
|
+
server.tool("contacts_storage_sync", "Bidirectional contacts sync: pull then push", { tables: exports_external.string().optional().describe("Comma-separated table names") }, async ({ tables }) => {
|
|
15698
|
+
try {
|
|
15699
|
+
return ok(await syncRemote({ tables: parseTables(tables) }));
|
|
15700
|
+
} catch (error) {
|
|
15701
|
+
return err(error);
|
|
15702
|
+
}
|
|
15703
|
+
});
|
|
15704
|
+
server.tool("contacts_cloud_status", "Compatibility status for contacts-owned storage and remote sync availability", {}, async () => {
|
|
15705
|
+
try {
|
|
15706
|
+
return ok(cloudStatus());
|
|
15707
|
+
} catch (error) {
|
|
15708
|
+
return err(error);
|
|
15709
|
+
}
|
|
15710
|
+
});
|
|
15711
|
+
server.tool("contacts_cloud_push", "Compatibility alias for contacts_storage_push", { tables: exports_external.string().optional().describe("Comma-separated table names") }, async ({ tables }) => {
|
|
15712
|
+
try {
|
|
15713
|
+
return ok(await pushRemote({ tables: parseTables(tables) }));
|
|
15714
|
+
} catch (error) {
|
|
15715
|
+
return err(error);
|
|
15716
|
+
}
|
|
15717
|
+
});
|
|
15718
|
+
server.tool("contacts_cloud_pull", "Compatibility alias for contacts_storage_pull", { tables: exports_external.string().optional().describe("Comma-separated table names") }, async ({ tables }) => {
|
|
15719
|
+
try {
|
|
15720
|
+
return ok(await pullRemote({ tables: parseTables(tables) }));
|
|
15721
|
+
} catch (error) {
|
|
15722
|
+
return err(error);
|
|
15723
|
+
}
|
|
15724
|
+
});
|
|
15725
|
+
server.tool("contacts_cloud_sync", "Compatibility alias for contacts_storage_sync", { tables: exports_external.string().optional().describe("Comma-separated table names") }, async ({ tables }) => {
|
|
15726
|
+
try {
|
|
15727
|
+
return ok(await syncRemote({ tables: parseTables(tables) }));
|
|
15728
|
+
} catch (error) {
|
|
15729
|
+
return err(error);
|
|
15730
|
+
}
|
|
15731
|
+
});
|
|
15732
|
+
server.tool("contacts_cloud_feedback", "Save contacts feedback locally", {
|
|
15733
|
+
message: exports_external.string().describe("Feedback message"),
|
|
15734
|
+
email: exports_external.string().optional().describe("Contact email")
|
|
15735
|
+
}, async ({ message, email }) => {
|
|
15736
|
+
try {
|
|
15737
|
+
return ok(saveLocalFeedback({ message, email, category: "general" }));
|
|
15738
|
+
} catch (error) {
|
|
15739
|
+
return err(error);
|
|
15740
|
+
}
|
|
15741
|
+
});
|
|
15742
|
+
}
|
|
15743
|
+
var init_storage_tools = __esm(() => {
|
|
15744
|
+
init_zod();
|
|
15745
|
+
init_feedback();
|
|
15746
|
+
init_storage();
|
|
15747
|
+
init_remote_sync();
|
|
15748
|
+
});
|
|
15749
|
+
|
|
15750
|
+
// src/mcp/index.ts
|
|
15751
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
15752
|
+
import { join as join7 } from "path";
|
|
15753
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15754
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15755
|
+
function getServerVersion() {
|
|
15756
|
+
try {
|
|
15757
|
+
const packageJsonPath = join7(import.meta.dir, "..", "..", "package.json");
|
|
15758
|
+
const pkg = JSON.parse(readFileSync6(packageJsonPath, "utf8"));
|
|
15759
|
+
return pkg.version ?? "0.0.0";
|
|
15760
|
+
} catch {
|
|
15761
|
+
return "0.0.0";
|
|
15762
|
+
}
|
|
15763
|
+
}
|
|
15764
|
+
function buildServer() {
|
|
15765
|
+
const server = new McpServer({ name: "contacts", version: getServerVersion() });
|
|
15766
|
+
registerContactsTools(server);
|
|
15767
|
+
registerContactsStorageTools(server);
|
|
15768
|
+
return server;
|
|
15769
|
+
}
|
|
15770
|
+
async function main() {
|
|
15771
|
+
const args = process.argv.slice(2);
|
|
15772
|
+
if (isHttpMode(args)) {
|
|
15773
|
+
startMcpHttpServer({
|
|
15774
|
+
name: "contacts",
|
|
15775
|
+
port: resolveMcpHttpPort(args),
|
|
15776
|
+
buildServer
|
|
15777
|
+
});
|
|
15778
|
+
return;
|
|
15779
|
+
}
|
|
15780
|
+
const transport = new StdioServerTransport;
|
|
15781
|
+
await buildServer().connect(transport);
|
|
15782
|
+
console.error("Contacts MCP server running on stdio");
|
|
15783
|
+
}
|
|
15784
|
+
var isDirectRun;
|
|
15785
|
+
var init_mcp = __esm(() => {
|
|
15786
|
+
init_register_tools();
|
|
15787
|
+
init_storage_tools();
|
|
15788
|
+
init_http();
|
|
15789
|
+
isDirectRun = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("mcp/index.ts") || process.argv[1]?.endsWith("mcp/index.js");
|
|
15790
|
+
if (isDirectRun) {
|
|
15791
|
+
main().catch((err2) => {
|
|
15792
|
+
console.error("Fatal error:", err2);
|
|
15793
|
+
process.exit(1);
|
|
15794
|
+
});
|
|
15795
|
+
}
|
|
15796
|
+
});
|
|
15797
|
+
|
|
15798
|
+
// src/server/serve.ts
|
|
15799
|
+
var exports_serve = {};
|
|
15800
|
+
__export(exports_serve, {
|
|
15801
|
+
startServer: () => startServer
|
|
15802
|
+
});
|
|
15803
|
+
import { existsSync as existsSync9 } from "fs";
|
|
15804
|
+
import { join as join8 } from "path";
|
|
15805
|
+
function json3(data, status = 200) {
|
|
15806
|
+
return new Response(JSON.stringify(data), {
|
|
15807
|
+
status,
|
|
15808
|
+
headers: { "Content-Type": "application/json" }
|
|
15809
|
+
});
|
|
15810
|
+
}
|
|
15811
|
+
function apiError(message, status = 400) {
|
|
15812
|
+
return json3({ error: message }, status);
|
|
15813
|
+
}
|
|
15814
|
+
async function parseJson(req) {
|
|
15815
|
+
try {
|
|
15816
|
+
return await req.json();
|
|
15817
|
+
} catch {
|
|
15818
|
+
return null;
|
|
15819
|
+
}
|
|
15820
|
+
}
|
|
15821
|
+
function getSegments(url) {
|
|
15822
|
+
return url.pathname.split("/").filter(Boolean);
|
|
15823
|
+
}
|
|
15824
|
+
async function handleContacts(req, url, segments) {
|
|
15825
|
+
const method = req.method;
|
|
15826
|
+
const id = segments[2];
|
|
15827
|
+
if (method === "GET" && !id) {
|
|
15828
|
+
const q = url.searchParams.get("q");
|
|
15829
|
+
if (q) {
|
|
15830
|
+
const contacts = searchContacts(q);
|
|
15831
|
+
return json3(contacts);
|
|
15832
|
+
}
|
|
15833
|
+
const result = listContacts({
|
|
15834
|
+
tag_id: url.searchParams.get("tag_id") ?? url.searchParams.get("tag") ?? undefined,
|
|
15835
|
+
company_id: url.searchParams.get("company_id") ?? undefined,
|
|
15836
|
+
limit: parseInt(url.searchParams.get("limit") ?? "50", 10),
|
|
15837
|
+
offset: parseInt(url.searchParams.get("offset") ?? "0", 10)
|
|
15838
|
+
});
|
|
15839
|
+
return json3(result);
|
|
15840
|
+
}
|
|
15841
|
+
if (method === "POST" && !id) {
|
|
15842
|
+
const body = await parseJson(req);
|
|
15843
|
+
if (!body || typeof body !== "object")
|
|
15844
|
+
return apiError("Invalid body");
|
|
15845
|
+
try {
|
|
15846
|
+
const contact = createContact(body);
|
|
15847
|
+
return json3(contact, 201);
|
|
15848
|
+
} catch (err2) {
|
|
15849
|
+
return apiError(err2 instanceof Error ? err2.message : "Failed to create contact");
|
|
15850
|
+
}
|
|
15851
|
+
}
|
|
15852
|
+
if (method === "GET" && id) {
|
|
15853
|
+
try {
|
|
15854
|
+
const contact = getContact(id);
|
|
15855
|
+
return json3(contact);
|
|
15856
|
+
} catch {
|
|
15857
|
+
return apiError("Contact not found", 404);
|
|
15858
|
+
}
|
|
15859
|
+
}
|
|
15860
|
+
if (method === "PATCH" && id) {
|
|
15861
|
+
const body = await parseJson(req);
|
|
15862
|
+
if (!body || typeof body !== "object")
|
|
15863
|
+
return apiError("Invalid body");
|
|
15864
|
+
try {
|
|
15865
|
+
const contact = updateContact(id, body);
|
|
15866
|
+
return json3(contact);
|
|
15867
|
+
} catch {
|
|
15868
|
+
return apiError("Contact not found", 404);
|
|
15869
|
+
}
|
|
15870
|
+
}
|
|
15871
|
+
if (method === "DELETE" && id) {
|
|
15872
|
+
try {
|
|
15873
|
+
deleteContact(id);
|
|
15874
|
+
return json3({ ok: true });
|
|
15875
|
+
} catch {
|
|
15876
|
+
return apiError("Contact not found", 404);
|
|
15877
|
+
}
|
|
15878
|
+
}
|
|
15879
|
+
return apiError("Method not allowed", 405);
|
|
15880
|
+
}
|
|
15881
|
+
async function handleCompanies(req, url, segments) {
|
|
15882
|
+
const method = req.method;
|
|
15883
|
+
const id = segments[2];
|
|
15884
|
+
if (method === "GET" && !id) {
|
|
15885
|
+
const result = listCompanies({
|
|
15886
|
+
tag_id: url.searchParams.get("tag_id") ?? undefined,
|
|
15887
|
+
industry: url.searchParams.get("industry") ?? undefined,
|
|
15888
|
+
limit: parseInt(url.searchParams.get("limit") ?? "50", 10),
|
|
15889
|
+
offset: parseInt(url.searchParams.get("offset") ?? "0", 10)
|
|
15890
|
+
});
|
|
15891
|
+
return json3(result);
|
|
15892
|
+
}
|
|
15893
|
+
if (method === "POST" && !id) {
|
|
15894
|
+
const body = await parseJson(req);
|
|
15895
|
+
if (!body || typeof body !== "object")
|
|
15896
|
+
return apiError("Invalid body");
|
|
15897
|
+
try {
|
|
15898
|
+
const company = createCompany(body);
|
|
15899
|
+
return json3(company, 201);
|
|
15900
|
+
} catch (err2) {
|
|
15901
|
+
return apiError(err2 instanceof Error ? err2.message : "Failed to create company");
|
|
15902
|
+
}
|
|
15903
|
+
}
|
|
15904
|
+
if (method === "GET" && id) {
|
|
15905
|
+
const company = getCompany(id);
|
|
15906
|
+
if (!company)
|
|
15907
|
+
return apiError("Company not found", 404);
|
|
15908
|
+
return json3(company);
|
|
15909
|
+
}
|
|
15910
|
+
if (method === "PATCH" && id) {
|
|
15911
|
+
const body = await parseJson(req);
|
|
15912
|
+
if (!body || typeof body !== "object")
|
|
15913
|
+
return apiError("Invalid body");
|
|
14891
15914
|
try {
|
|
14892
15915
|
const company = updateCompany(id, body);
|
|
14893
15916
|
return json3(company);
|
|
@@ -15860,7 +16883,7 @@ function readConfig() {
|
|
|
15860
16883
|
}
|
|
15861
16884
|
|
|
15862
16885
|
// src/cli/commands/core.tsx
|
|
15863
|
-
import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, existsSync as existsSync10, copyFileSync as copyFileSync3, statSync as statSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync3 } from "fs";
|
|
16886
|
+
import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, existsSync as existsSync10, copyFileSync as copyFileSync3, statSync as statSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync3, chmodSync as chmodSync3 } from "fs";
|
|
15864
16887
|
import { extname as extname3, join as join9 } from "path";
|
|
15865
16888
|
|
|
15866
16889
|
// src/cli/utils.tsx
|
|
@@ -16634,8 +17657,7 @@ Group not found: ${groupId}
|
|
|
16634
17657
|
console.log();
|
|
16635
17658
|
});
|
|
16636
17659
|
program2.command("backup").description("Backup the contacts database").option("--output <path>", "Output path").option("--list", "List existing backups").action((opts) => {
|
|
16637
|
-
const
|
|
16638
|
-
const backupDir = join9(getDataDir2(), "backups");
|
|
17660
|
+
const backupDir = join9(getDataDir(), "backups");
|
|
16639
17661
|
if (opts.list) {
|
|
16640
17662
|
if (!existsSync10(backupDir)) {
|
|
16641
17663
|
console.log(chalk2.gray(`
|
|
@@ -16669,10 +17691,15 @@ Database not found: ${src}
|
|
|
16669
17691
|
`));
|
|
16670
17692
|
process.exit(1);
|
|
16671
17693
|
}
|
|
16672
|
-
mkdirSync5(backupDir, { recursive: true });
|
|
17694
|
+
mkdirSync5(backupDir, { recursive: true, mode: 448 });
|
|
17695
|
+
chmodSync3(backupDir, 448);
|
|
17696
|
+
const db = getDatabase();
|
|
17697
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
17698
|
+
resetDatabase();
|
|
16673
17699
|
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
16674
17700
|
const dest = opts.output || join9(backupDir, `contacts-${ts}.db`);
|
|
16675
17701
|
copyFileSync3(src, dest);
|
|
17702
|
+
chmodSync3(dest, 384);
|
|
16676
17703
|
const size = statSync2(dest).size;
|
|
16677
17704
|
console.log(chalk2.green(`
|
|
16678
17705
|
\u2713 Backed up to ${dest} (${(size / 1024).toFixed(1)} KB)
|
|
@@ -18347,49 +19374,99 @@ Health data cleared for ${contact.display_name}
|
|
|
18347
19374
|
// src/cli/storage.ts
|
|
18348
19375
|
init_storage();
|
|
18349
19376
|
init_feedback();
|
|
19377
|
+
init_remote_sync();
|
|
18350
19378
|
import chalk5 from "chalk";
|
|
18351
19379
|
function printJson(value) {
|
|
18352
19380
|
console.log(JSON.stringify(value, null, 2));
|
|
18353
19381
|
}
|
|
18354
19382
|
function printStorageStatus(json4) {
|
|
18355
|
-
const status =
|
|
19383
|
+
const status = {
|
|
19384
|
+
mode: "local-first",
|
|
19385
|
+
local: getStorageStatus(),
|
|
19386
|
+
remote: getRemoteStatus()
|
|
19387
|
+
};
|
|
18356
19388
|
if (json4) {
|
|
18357
19389
|
printJson(status);
|
|
18358
19390
|
return;
|
|
18359
19391
|
}
|
|
18360
19392
|
console.log(`Mode: ${status.mode}`);
|
|
18361
|
-
console.log(`Scope: ${chalk5.gray("
|
|
18362
|
-
console.log(`Database: ${status.db_path}`);
|
|
18363
|
-
|
|
19393
|
+
console.log(`Scope: ${chalk5.gray("contacts-owned local SQLite with optional PostgreSQL sync")}`);
|
|
19394
|
+
console.log(`Database: ${status.local.db_path}`);
|
|
19395
|
+
console.log(`Remote sync: ${status.remote.configured ? chalk5.green("configured") : chalk5.yellow("not configured")}`);
|
|
19396
|
+
for (const table of status.local.tables) {
|
|
18364
19397
|
const value = table.ok ? String(table.rows) : chalk5.red(table.error ?? "unavailable");
|
|
18365
19398
|
console.log(` ${table.table}: ${value}`);
|
|
18366
19399
|
}
|
|
18367
19400
|
}
|
|
18368
19401
|
function cloudStatus2() {
|
|
19402
|
+
const remote = getRemoteStatus();
|
|
18369
19403
|
return {
|
|
18370
19404
|
service: "contacts",
|
|
18371
|
-
mode: "local",
|
|
19405
|
+
mode: "local-first",
|
|
18372
19406
|
remote_sync: {
|
|
18373
|
-
configured:
|
|
18374
|
-
|
|
19407
|
+
configured: Boolean(getRemoteDatabaseUrl()),
|
|
19408
|
+
env: CONTACTS_REMOTE_ENV,
|
|
19409
|
+
default_tables: remote.default_tables,
|
|
19410
|
+
sensitive_tables: remote.sensitive_tables,
|
|
19411
|
+
tables: CONTACTS_REMOTE_TABLES,
|
|
19412
|
+
sync: remote.sync,
|
|
19413
|
+
reason: getRemoteDatabaseUrl() ? "Contacts uses repo-owned PostgreSQL sync; the deprecated shared cloud runtime is not used." : "Set a contacts-owned PostgreSQL URL to enable sync; the deprecated shared cloud runtime is not used."
|
|
18375
19414
|
},
|
|
18376
19415
|
storage: getStorageStatus()
|
|
18377
19416
|
};
|
|
18378
19417
|
}
|
|
18379
|
-
function
|
|
18380
|
-
|
|
18381
|
-
|
|
18382
|
-
|
|
18383
|
-
|
|
18384
|
-
|
|
18385
|
-
|
|
18386
|
-
|
|
18387
|
-
|
|
18388
|
-
|
|
18389
|
-
|
|
18390
|
-
|
|
18391
|
-
|
|
18392
|
-
|
|
19418
|
+
function parseTables2(value) {
|
|
19419
|
+
if (!value)
|
|
19420
|
+
return;
|
|
19421
|
+
return value.split(",").map((table) => table.trim()).filter(Boolean);
|
|
19422
|
+
}
|
|
19423
|
+
function printSyncResults(results, label) {
|
|
19424
|
+
const total = results.reduce((sum, result) => sum + result.rowsWritten, 0);
|
|
19425
|
+
for (const result of results) {
|
|
19426
|
+
const errors2 = result.errors.length > 0 ? ` (${result.errors.join("; ")})` : "";
|
|
19427
|
+
console.log(` ${result.table}: ${result.rowsWritten}/${result.rowsRead} rows ${label}${errors2}`);
|
|
19428
|
+
}
|
|
19429
|
+
console.log(`Done. ${total} rows ${label}.`);
|
|
19430
|
+
}
|
|
19431
|
+
async function runSyncAction(action, json4, label) {
|
|
19432
|
+
try {
|
|
19433
|
+
const result = await action();
|
|
19434
|
+
if (json4) {
|
|
19435
|
+
printJson(result);
|
|
19436
|
+
return;
|
|
19437
|
+
}
|
|
19438
|
+
if ("pull" in result) {
|
|
19439
|
+
printSyncResults(result.pull, "pulled");
|
|
19440
|
+
printSyncResults(result.push, "pushed");
|
|
19441
|
+
} else {
|
|
19442
|
+
printSyncResults(result, label);
|
|
19443
|
+
}
|
|
19444
|
+
} catch (error) {
|
|
19445
|
+
const payload = {
|
|
19446
|
+
ok: false,
|
|
19447
|
+
service: "contacts",
|
|
19448
|
+
mode: "local-first",
|
|
19449
|
+
error: error instanceof Error ? error.message : String(error),
|
|
19450
|
+
env: CONTACTS_REMOTE_ENV,
|
|
19451
|
+
results: error instanceof ContactsRemoteSyncError ? error.results : undefined
|
|
19452
|
+
};
|
|
19453
|
+
if (json4)
|
|
19454
|
+
printJson(payload);
|
|
19455
|
+
else
|
|
19456
|
+
console.error(payload.error);
|
|
19457
|
+
process.exitCode = 1;
|
|
19458
|
+
}
|
|
19459
|
+
}
|
|
19460
|
+
function addSyncCommands(command) {
|
|
19461
|
+
command.command("push").description("Push local contacts data to contacts-owned PostgreSQL storage").option("--tables <tables>", "Comma-separated table names").option("--json", "Output as JSON").action(async (opts) => {
|
|
19462
|
+
await runSyncAction(() => pushRemote({ tables: parseTables2(opts.tables) }), opts.json, "pushed");
|
|
19463
|
+
});
|
|
19464
|
+
command.command("pull").description("Pull contacts data from contacts-owned PostgreSQL storage").option("--tables <tables>", "Comma-separated table names").option("--json", "Output as JSON").action(async (opts) => {
|
|
19465
|
+
await runSyncAction(() => pullRemote({ tables: parseTables2(opts.tables) }), opts.json, "pulled");
|
|
19466
|
+
});
|
|
19467
|
+
command.command("sync").description("Bidirectional contacts sync: pull then push").option("--tables <tables>", "Comma-separated table names").option("--json", "Output as JSON").action(async (opts) => {
|
|
19468
|
+
await runSyncAction(() => syncRemote({ tables: parseTables2(opts.tables) }), opts.json, "synced");
|
|
19469
|
+
});
|
|
18393
19470
|
}
|
|
18394
19471
|
function registerCloudCompatibilityCommands(program2) {
|
|
18395
19472
|
const cloud = program2.command("cloud").description("Compatibility commands for contacts-owned storage");
|
|
@@ -18401,15 +19478,18 @@ function registerCloudCompatibilityCommands(program2) {
|
|
|
18401
19478
|
}
|
|
18402
19479
|
console.log(`Mode: ${status.mode}`);
|
|
18403
19480
|
console.log(`Service: ${status.service}`);
|
|
18404
|
-
console.log(`Remote sync: ${chalk5.yellow("not configured")}`);
|
|
19481
|
+
console.log(`Remote sync: ${status.remote_sync.configured ? chalk5.green("configured") : chalk5.yellow("not configured")}`);
|
|
18405
19482
|
console.log(`Reason: ${status.remote_sync.reason}`);
|
|
18406
19483
|
console.log(`Database: ${status.storage.db_path}`);
|
|
18407
19484
|
});
|
|
18408
|
-
cloud.command("push").description("
|
|
18409
|
-
|
|
19485
|
+
cloud.command("push").description("Compatibility alias for contacts storage push").option("--tables <tables>", "Comma-separated table names").option("--json", "Output as JSON").action(async (opts) => {
|
|
19486
|
+
await runSyncAction(() => pushRemote({ tables: parseTables2(opts.tables) }), opts.json, "pushed");
|
|
19487
|
+
});
|
|
19488
|
+
cloud.command("pull").description("Compatibility alias for contacts storage pull").option("--tables <tables>", "Comma-separated table names").option("--json", "Output as JSON").action(async (opts) => {
|
|
19489
|
+
await runSyncAction(() => pullRemote({ tables: parseTables2(opts.tables) }), opts.json, "pulled");
|
|
18410
19490
|
});
|
|
18411
|
-
cloud.command("
|
|
18412
|
-
|
|
19491
|
+
cloud.command("sync").description("Compatibility alias for contacts storage sync").option("--tables <tables>", "Comma-separated table names").option("--json", "Output as JSON").action(async (opts) => {
|
|
19492
|
+
await runSyncAction(() => syncRemote({ tables: parseTables2(opts.tables) }), opts.json, "synced");
|
|
18413
19493
|
});
|
|
18414
19494
|
cloud.command("feedback").description("Save feedback locally").requiredOption("--message <msg>", "Feedback message").option("--email <email>", "Contact email").option("--json", "Output as JSON").action((opts) => {
|
|
18415
19495
|
const result = saveLocalFeedback({ message: opts.message, email: opts.email, category: "general" });
|
|
@@ -18425,6 +19505,7 @@ function registerStorageCommands(program2) {
|
|
|
18425
19505
|
storage.command("status").description("Show local contacts database storage status").option("--json", "Output as JSON").action((opts) => {
|
|
18426
19506
|
printStorageStatus(opts.json);
|
|
18427
19507
|
});
|
|
19508
|
+
addSyncCommands(storage);
|
|
18428
19509
|
registerCloudCompatibilityCommands(program2);
|
|
18429
19510
|
}
|
|
18430
19511
|
|