@hasna/contacts 0.6.29 → 0.6.31
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 +2269 -320
- package/dist/index.js +255 -216
- package/dist/mcp/index.js +255 -216
- package/dist/server/index.js +2268 -319
- package/dist/server/pg-store.d.ts +801 -0
- package/dist/server/pg-store.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/store/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -7456,6 +7456,30 @@ class ApiStore {
|
|
|
7456
7456
|
constructor(client) {
|
|
7457
7457
|
this.client = client;
|
|
7458
7458
|
}
|
|
7459
|
+
g(path, query) {
|
|
7460
|
+
return this.client.transport.get(path, query ? { query } : undefined);
|
|
7461
|
+
}
|
|
7462
|
+
post(path, body) {
|
|
7463
|
+
return this.client.transport.post(path, body);
|
|
7464
|
+
}
|
|
7465
|
+
patch(path, body) {
|
|
7466
|
+
return this.client.transport.patch(path, body);
|
|
7467
|
+
}
|
|
7468
|
+
del(path, query) {
|
|
7469
|
+
return this.client.transport.del(path, undefined, query ? { query } : undefined);
|
|
7470
|
+
}
|
|
7471
|
+
async gMaybe(path) {
|
|
7472
|
+
try {
|
|
7473
|
+
return await this.client.transport.get(path);
|
|
7474
|
+
} catch (e) {
|
|
7475
|
+
if (e && typeof e === "object" && e.status === 404)
|
|
7476
|
+
return null;
|
|
7477
|
+
throw e;
|
|
7478
|
+
}
|
|
7479
|
+
}
|
|
7480
|
+
enc(id) {
|
|
7481
|
+
return encodeURIComponent(String(id));
|
|
7482
|
+
}
|
|
7459
7483
|
async createContact(input) {
|
|
7460
7484
|
const res = await this.client.create("contacts", stripUndefined(input));
|
|
7461
7485
|
return pick(res, "contact") ?? res;
|
|
@@ -7586,74 +7610,74 @@ class ApiStore {
|
|
|
7586
7610
|
async removeTagFromCompany() {
|
|
7587
7611
|
return unavailable("removeTagFromCompany");
|
|
7588
7612
|
}
|
|
7589
|
-
async createGroup() {
|
|
7590
|
-
return
|
|
7613
|
+
async createGroup(input) {
|
|
7614
|
+
return pick(await this.post("/groups", input), "group");
|
|
7591
7615
|
}
|
|
7592
|
-
async getGroup() {
|
|
7593
|
-
return
|
|
7616
|
+
async getGroup(id) {
|
|
7617
|
+
return pick(await this.gMaybe(`/groups/${this.enc(id)}`), "group") ?? null;
|
|
7594
7618
|
}
|
|
7595
|
-
async listGroups() {
|
|
7596
|
-
return
|
|
7619
|
+
async listGroups(projectId) {
|
|
7620
|
+
return pick(await this.g("/groups", projectId ? { project_id: projectId } : undefined), "groups") ?? [];
|
|
7597
7621
|
}
|
|
7598
|
-
async updateGroup() {
|
|
7599
|
-
return
|
|
7622
|
+
async updateGroup(id, input) {
|
|
7623
|
+
return pick(await this.patch(`/groups/${this.enc(id)}`, input), "group");
|
|
7600
7624
|
}
|
|
7601
|
-
async deleteGroup() {
|
|
7602
|
-
|
|
7625
|
+
async deleteGroup(id) {
|
|
7626
|
+
await this.del(`/groups/${this.enc(id)}`);
|
|
7603
7627
|
}
|
|
7604
|
-
async addContactToGroup() {
|
|
7605
|
-
return
|
|
7628
|
+
async addContactToGroup(contactId, groupId) {
|
|
7629
|
+
return this.post(`/groups/${this.enc(groupId)}/contacts`, { contact_id: contactId });
|
|
7606
7630
|
}
|
|
7607
|
-
async removeContactFromGroup() {
|
|
7608
|
-
|
|
7631
|
+
async removeContactFromGroup(contactId, groupId) {
|
|
7632
|
+
await this.del(`/groups/${this.enc(groupId)}/contacts/${this.enc(contactId)}`);
|
|
7609
7633
|
}
|
|
7610
|
-
async listContactsInGroup() {
|
|
7611
|
-
return
|
|
7634
|
+
async listContactsInGroup(groupId) {
|
|
7635
|
+
return pick(await this.g(`/groups/${this.enc(groupId)}/contacts`), "contact_ids") ?? [];
|
|
7612
7636
|
}
|
|
7613
|
-
async listGroupsForContact() {
|
|
7614
|
-
return
|
|
7637
|
+
async listGroupsForContact(contactId) {
|
|
7638
|
+
return pick(await this.g(`/groups/for-contact/${this.enc(contactId)}`), "groups") ?? [];
|
|
7615
7639
|
}
|
|
7616
|
-
async addCompanyToGroup() {
|
|
7617
|
-
return
|
|
7640
|
+
async addCompanyToGroup(companyId, groupId) {
|
|
7641
|
+
return this.post(`/groups/${this.enc(groupId)}/companies`, { company_id: companyId });
|
|
7618
7642
|
}
|
|
7619
|
-
async removeCompanyFromGroup() {
|
|
7620
|
-
|
|
7643
|
+
async removeCompanyFromGroup(companyId, groupId) {
|
|
7644
|
+
await this.del(`/groups/${this.enc(groupId)}/companies/${this.enc(companyId)}`);
|
|
7621
7645
|
}
|
|
7622
|
-
async listCompaniesInGroup() {
|
|
7623
|
-
return
|
|
7646
|
+
async listCompaniesInGroup(groupId) {
|
|
7647
|
+
return pick(await this.g(`/groups/${this.enc(groupId)}/companies`), "company_ids") ?? [];
|
|
7624
7648
|
}
|
|
7625
|
-
async listGroupsForCompany() {
|
|
7626
|
-
return
|
|
7649
|
+
async listGroupsForCompany(companyId) {
|
|
7650
|
+
return pick(await this.g(`/groups/for-company/${this.enc(companyId)}`), "groups") ?? [];
|
|
7627
7651
|
}
|
|
7628
|
-
async createRelationship() {
|
|
7629
|
-
return
|
|
7652
|
+
async createRelationship(input) {
|
|
7653
|
+
return pick(await this.post("/relationships", input), "relationship");
|
|
7630
7654
|
}
|
|
7631
|
-
async listRelationships() {
|
|
7632
|
-
return
|
|
7655
|
+
async listRelationships(opts = {}) {
|
|
7656
|
+
return pick(await this.g("/relationships", stripUndefined(opts)), "relationships") ?? [];
|
|
7633
7657
|
}
|
|
7634
|
-
async deleteRelationship() {
|
|
7635
|
-
|
|
7658
|
+
async deleteRelationship(id) {
|
|
7659
|
+
await this.del(`/relationships/${this.enc(id)}`);
|
|
7636
7660
|
}
|
|
7637
|
-
async createCompanyRelationship() {
|
|
7638
|
-
return
|
|
7661
|
+
async createCompanyRelationship(input) {
|
|
7662
|
+
return pick(await this.post("/company-relationships", input), "relationship");
|
|
7639
7663
|
}
|
|
7640
|
-
async listCompanyRelationships() {
|
|
7641
|
-
return
|
|
7664
|
+
async listCompanyRelationships(opts = {}) {
|
|
7665
|
+
return pick(await this.g("/company-relationships", stripUndefined(opts)), "relationships") ?? [];
|
|
7642
7666
|
}
|
|
7643
|
-
async deleteCompanyRelationship() {
|
|
7644
|
-
|
|
7667
|
+
async deleteCompanyRelationship(id) {
|
|
7668
|
+
await this.del(`/company-relationships/${this.enc(id)}`);
|
|
7645
7669
|
}
|
|
7646
|
-
async addNote() {
|
|
7647
|
-
return
|
|
7670
|
+
async addNote(contactId, body, createdBy, companyId) {
|
|
7671
|
+
return pick(await this.post("/notes", { contact_id: contactId, body, created_by: createdBy, company_id: companyId }), "note");
|
|
7648
7672
|
}
|
|
7649
|
-
async listNotes() {
|
|
7650
|
-
return
|
|
7673
|
+
async listNotes(contactId) {
|
|
7674
|
+
return pick(await this.g("/notes", { contact_id: contactId }), "notes") ?? [];
|
|
7651
7675
|
}
|
|
7652
|
-
async listNotesForContactAtCompany() {
|
|
7653
|
-
return
|
|
7676
|
+
async listNotesForContactAtCompany(contactId, companyId) {
|
|
7677
|
+
return pick(await this.g("/notes", { contact_id: contactId, company_id: companyId }), "notes") ?? [];
|
|
7654
7678
|
}
|
|
7655
|
-
async deleteNote() {
|
|
7656
|
-
|
|
7679
|
+
async deleteNote(noteId) {
|
|
7680
|
+
await this.del(`/notes/${this.enc(noteId)}`);
|
|
7657
7681
|
}
|
|
7658
7682
|
async listActivity() {
|
|
7659
7683
|
return unavailable("listActivity");
|
|
@@ -7668,178 +7692,191 @@ class ApiStore {
|
|
|
7668
7692
|
};
|
|
7669
7693
|
}
|
|
7670
7694
|
async findEmailDuplicates() {
|
|
7671
|
-
return
|
|
7695
|
+
return pick(await this.g("/email-duplicates"), "duplicates") ?? [];
|
|
7672
7696
|
}
|
|
7673
7697
|
async findNameDuplicates() {
|
|
7674
|
-
return
|
|
7675
|
-
}
|
|
7676
|
-
async flushForBackup() {
|
|
7677
|
-
return unavailable("flushForBackup");
|
|
7698
|
+
return pick(await this.g("/name-duplicates"), "duplicates") ?? [];
|
|
7678
7699
|
}
|
|
7679
|
-
async
|
|
7680
|
-
|
|
7700
|
+
async flushForBackup() {}
|
|
7701
|
+
async listColdContacts(days) {
|
|
7702
|
+
return pick(await this.g("/cold-contacts", { days }), "contacts") ?? [];
|
|
7681
7703
|
}
|
|
7682
|
-
async findOrCreateContact() {
|
|
7683
|
-
|
|
7704
|
+
async findOrCreateContact(input) {
|
|
7705
|
+
const emails = (input.emails ?? []).map((e) => e.address).filter(Boolean);
|
|
7706
|
+
for (const addr of emails) {
|
|
7707
|
+
const c = await this.getContactByEmail(addr);
|
|
7708
|
+
if (c)
|
|
7709
|
+
return { contact: c, created: false };
|
|
7710
|
+
}
|
|
7711
|
+
const nameQuery = input.display_name ?? (input.first_name || input.last_name ? `${input.first_name ?? ""} ${input.last_name ?? ""}`.trim() : null);
|
|
7712
|
+
if (nameQuery) {
|
|
7713
|
+
const results = await this.searchContacts(nameQuery);
|
|
7714
|
+
if (results[0])
|
|
7715
|
+
return { contact: results[0], created: false };
|
|
7716
|
+
}
|
|
7717
|
+
return { contact: await this.createContact(input), created: true };
|
|
7684
7718
|
}
|
|
7685
|
-
async findContactsForContext() {
|
|
7686
|
-
return
|
|
7719
|
+
async findContactsForContext(topic, limit) {
|
|
7720
|
+
return pick(await this.g("/contacts-for-context", { topic, limit }), "contacts") ?? [];
|
|
7687
7721
|
}
|
|
7688
|
-
async listContactsNotContactedSince() {
|
|
7689
|
-
return
|
|
7722
|
+
async listContactsNotContactedSince(days, limit) {
|
|
7723
|
+
return pick(await this.g("/not-contacted", { days, limit }), "contacts") ?? [];
|
|
7690
7724
|
}
|
|
7691
|
-
async listFollowupDueContacts() {
|
|
7692
|
-
return
|
|
7725
|
+
async listFollowupDueContacts(onOrBefore) {
|
|
7726
|
+
return pick(await this.g("/followup-due-contacts", { on_or_before: onOrBefore }), "contacts") ?? [];
|
|
7693
7727
|
}
|
|
7694
|
-
async logVendorCommunication() {
|
|
7695
|
-
return
|
|
7728
|
+
async logVendorCommunication(input) {
|
|
7729
|
+
return pick(await this.post("/vendor-comms", input), "communication");
|
|
7696
7730
|
}
|
|
7697
|
-
async listVendorCommunications() {
|
|
7698
|
-
return
|
|
7731
|
+
async listVendorCommunications(companyId, opts = {}) {
|
|
7732
|
+
return pick(await this.g("/vendor-comms", { company_id: companyId, ...stripUndefined(opts) }), "communications") ?? [];
|
|
7699
7733
|
}
|
|
7700
7734
|
async listMissingInvoices() {
|
|
7701
|
-
return
|
|
7735
|
+
return pick(await this.g("/vendor-comms/missing-invoices"), "communications") ?? [];
|
|
7702
7736
|
}
|
|
7703
7737
|
async listPendingFollowUps() {
|
|
7704
|
-
return
|
|
7738
|
+
return pick(await this.g("/vendor-comms/pending-follow-ups"), "communications") ?? [];
|
|
7705
7739
|
}
|
|
7706
|
-
async markFollowUpDone() {
|
|
7707
|
-
return
|
|
7740
|
+
async markFollowUpDone(id) {
|
|
7741
|
+
return pick(await this.post(`/vendor-comms/${this.enc(id)}/mark-done`), "communication");
|
|
7708
7742
|
}
|
|
7709
|
-
async createContactTask() {
|
|
7710
|
-
return
|
|
7743
|
+
async createContactTask(input) {
|
|
7744
|
+
return pick(await this.post("/tasks", input), "task");
|
|
7711
7745
|
}
|
|
7712
|
-
async listContactTasks() {
|
|
7713
|
-
return
|
|
7746
|
+
async listContactTasks(opts = {}) {
|
|
7747
|
+
return pick(await this.g("/tasks", stripUndefined(opts)), "tasks") ?? [];
|
|
7714
7748
|
}
|
|
7715
|
-
async updateContactTask() {
|
|
7716
|
-
return
|
|
7749
|
+
async updateContactTask(id, input) {
|
|
7750
|
+
return pick(await this.patch(`/tasks/${this.enc(id)}`, input), "task");
|
|
7717
7751
|
}
|
|
7718
|
-
async deleteContactTask() {
|
|
7719
|
-
|
|
7752
|
+
async deleteContactTask(id) {
|
|
7753
|
+
await this.del(`/tasks/${this.enc(id)}`);
|
|
7720
7754
|
}
|
|
7721
7755
|
async listOverdueTasks() {
|
|
7722
|
-
return
|
|
7756
|
+
return pick(await this.g("/tasks/overdue"), "tasks") ?? [];
|
|
7723
7757
|
}
|
|
7724
7758
|
async checkEscalations() {
|
|
7725
|
-
return
|
|
7759
|
+
return pick(await this.g("/tasks/escalations"), "escalations") ?? [];
|
|
7726
7760
|
}
|
|
7727
|
-
async createApplication() {
|
|
7728
|
-
return
|
|
7761
|
+
async createApplication(input) {
|
|
7762
|
+
return pick(await this.post("/applications", input), "application");
|
|
7729
7763
|
}
|
|
7730
|
-
async listApplications() {
|
|
7731
|
-
return
|
|
7764
|
+
async listApplications(opts = {}) {
|
|
7765
|
+
return pick(await this.g("/applications", stripUndefined(opts)), "applications") ?? [];
|
|
7732
7766
|
}
|
|
7733
|
-
async updateApplication() {
|
|
7734
|
-
return
|
|
7767
|
+
async updateApplication(id, input) {
|
|
7768
|
+
return pick(await this.patch(`/applications/${this.enc(id)}`, input), "application");
|
|
7735
7769
|
}
|
|
7736
7770
|
async listFollowUpDueApplications() {
|
|
7737
|
-
return
|
|
7771
|
+
return pick(await this.g("/applications/follow-up-due"), "applications") ?? [];
|
|
7738
7772
|
}
|
|
7739
|
-
async addOrgMember() {
|
|
7740
|
-
return
|
|
7773
|
+
async addOrgMember(input) {
|
|
7774
|
+
return pick(await this.post("/org-members", input), "org_member");
|
|
7741
7775
|
}
|
|
7742
|
-
async listOrgMembers() {
|
|
7743
|
-
return
|
|
7776
|
+
async listOrgMembers(companyId) {
|
|
7777
|
+
return pick(await this.g("/org-members", { company_id: companyId }), "org_members") ?? [];
|
|
7744
7778
|
}
|
|
7745
|
-
async updateOrgMember() {
|
|
7746
|
-
return
|
|
7779
|
+
async updateOrgMember(id, input) {
|
|
7780
|
+
return pick(await this.patch(`/org-members/${this.enc(id)}`, input), "org_member");
|
|
7747
7781
|
}
|
|
7748
|
-
async removeOrgMember() {
|
|
7749
|
-
|
|
7782
|
+
async removeOrgMember(id) {
|
|
7783
|
+
await this.del(`/org-members/${this.enc(id)}`);
|
|
7750
7784
|
}
|
|
7751
|
-
async listOrgMembersForContact() {
|
|
7752
|
-
return
|
|
7785
|
+
async listOrgMembersForContact(contactId) {
|
|
7786
|
+
return pick(await this.g("/org-members", { contact_id: contactId }), "org_members") ?? [];
|
|
7753
7787
|
}
|
|
7754
|
-
async createDeal() {
|
|
7755
|
-
return
|
|
7788
|
+
async createDeal(input) {
|
|
7789
|
+
return pick(await this.post("/deals", input), "deal");
|
|
7756
7790
|
}
|
|
7757
|
-
async getDeal() {
|
|
7758
|
-
return
|
|
7791
|
+
async getDeal(id) {
|
|
7792
|
+
return pick(await this.gMaybe(`/deals/${this.enc(id)}`), "deal") ?? null;
|
|
7759
7793
|
}
|
|
7760
|
-
async listDeals() {
|
|
7761
|
-
return
|
|
7794
|
+
async listDeals(opts = {}) {
|
|
7795
|
+
return pick(await this.g("/deals", stripUndefined(opts)), "deals") ?? [];
|
|
7762
7796
|
}
|
|
7763
|
-
async updateDeal() {
|
|
7764
|
-
return
|
|
7797
|
+
async updateDeal(id, input) {
|
|
7798
|
+
return pick(await this.patch(`/deals/${this.enc(id)}`, input), "deal") ?? null;
|
|
7765
7799
|
}
|
|
7766
|
-
async deleteDeal() {
|
|
7767
|
-
|
|
7800
|
+
async deleteDeal(id) {
|
|
7801
|
+
await this.del(`/deals/${this.enc(id)}`);
|
|
7768
7802
|
}
|
|
7769
|
-
async logEvent() {
|
|
7770
|
-
return
|
|
7803
|
+
async logEvent(input) {
|
|
7804
|
+
return pick(await this.post("/events", input), "event");
|
|
7771
7805
|
}
|
|
7772
|
-
async listEvents() {
|
|
7773
|
-
return
|
|
7806
|
+
async listEvents(opts = {}) {
|
|
7807
|
+
return pick(await this.g("/events", stripUndefined(opts)), "events") ?? [];
|
|
7774
7808
|
}
|
|
7775
|
-
async deleteEvent() {
|
|
7776
|
-
|
|
7809
|
+
async deleteEvent(id) {
|
|
7810
|
+
await this.del(`/events/${this.enc(id)}`);
|
|
7777
7811
|
}
|
|
7778
|
-
async getFieldHistory() {
|
|
7779
|
-
return
|
|
7812
|
+
async getFieldHistory(contactId, fieldName) {
|
|
7813
|
+
return pick(await this.g(`/contacts/${this.enc(contactId)}/field-history`, fieldName ? { field_name: fieldName } : undefined), "history") ?? [];
|
|
7780
7814
|
}
|
|
7781
|
-
async getContactAt() {
|
|
7782
|
-
return
|
|
7815
|
+
async getContactAt(contactId, timestamp) {
|
|
7816
|
+
return pick(await this.g(`/contacts/${this.enc(contactId)}/field-at`, { timestamp }), "fields") ?? {};
|
|
7783
7817
|
}
|
|
7784
|
-
async addJobEntry() {
|
|
7785
|
-
return
|
|
7818
|
+
async addJobEntry(contactId, input) {
|
|
7819
|
+
return pick(await this.post(`/contacts/${this.enc(contactId)}/job-history`, input), "job");
|
|
7786
7820
|
}
|
|
7787
|
-
async getJobHistory() {
|
|
7788
|
-
return
|
|
7821
|
+
async getJobHistory(contactId) {
|
|
7822
|
+
return pick(await this.g(`/contacts/${this.enc(contactId)}/job-history`), "job_history") ?? [];
|
|
7789
7823
|
}
|
|
7790
|
-
async saveLearning() {
|
|
7791
|
-
return
|
|
7824
|
+
async saveLearning(contactId, input) {
|
|
7825
|
+
return pick(await this.post(`/contacts/${this.enc(contactId)}/learnings`, input), "learning");
|
|
7792
7826
|
}
|
|
7793
|
-
async getLearnings() {
|
|
7794
|
-
return
|
|
7827
|
+
async getLearnings(contactId, opts = {}) {
|
|
7828
|
+
return pick(await this.g(`/contacts/${this.enc(contactId)}/learnings`, stripUndefined(opts)), "learnings") ?? [];
|
|
7795
7829
|
}
|
|
7796
|
-
async searchLearnings() {
|
|
7797
|
-
return
|
|
7830
|
+
async searchLearnings(query, opts = {}) {
|
|
7831
|
+
return pick(await this.g("/learnings/search", { q: query, ...stripUndefined(opts) }), "learnings") ?? [];
|
|
7798
7832
|
}
|
|
7799
|
-
async confirmLearning() {
|
|
7800
|
-
|
|
7833
|
+
async confirmLearning(learningId) {
|
|
7834
|
+
await this.post(`/learnings/${this.enc(learningId)}/confirm`);
|
|
7801
7835
|
}
|
|
7802
|
-
async getStaleLearnings() {
|
|
7803
|
-
return
|
|
7836
|
+
async getStaleLearnings(daysOld, minConfidence) {
|
|
7837
|
+
return pick(await this.g("/learnings/stale", { days_old: daysOld, min_confidence: minConfidence }), "learnings") ?? [];
|
|
7804
7838
|
}
|
|
7805
7839
|
async runLearningMaintenance() {
|
|
7806
|
-
|
|
7840
|
+
const r = await this.post("/learnings/maintenance");
|
|
7841
|
+
return { decayed_count: Number(r?.decayed_count ?? 0), potential_contradictions: r?.potential_contradictions ?? [] };
|
|
7807
7842
|
}
|
|
7808
|
-
async acquireContactLock() {
|
|
7809
|
-
return
|
|
7843
|
+
async acquireContactLock(contactId, agentName, ttlSeconds, reason, sessionId) {
|
|
7844
|
+
return this.post("/locks", { contact_id: contactId, agent_name: agentName, ttl_seconds: ttlSeconds, reason, session_id: sessionId });
|
|
7810
7845
|
}
|
|
7811
|
-
async releaseContactLock() {
|
|
7812
|
-
|
|
7846
|
+
async releaseContactLock(contactId, agentName) {
|
|
7847
|
+
const r = await this.del(`/locks/${this.enc(contactId)}`, { agent_name: agentName });
|
|
7848
|
+
return Boolean(r?.released);
|
|
7813
7849
|
}
|
|
7814
|
-
async checkContactLock() {
|
|
7815
|
-
return
|
|
7850
|
+
async checkContactLock(contactId) {
|
|
7851
|
+
return pick(await this.g(`/locks/${this.enc(contactId)}`), "lock") ?? null;
|
|
7816
7852
|
}
|
|
7817
|
-
async logAgentActivity() {
|
|
7818
|
-
|
|
7853
|
+
async logAgentActivity(contactId, agentName, action, details, sessionId) {
|
|
7854
|
+
await this.post("/activity", { contact_id: contactId, agent_name: agentName, action, details, session_id: sessionId });
|
|
7819
7855
|
}
|
|
7820
|
-
async getAgentActivity() {
|
|
7821
|
-
return
|
|
7856
|
+
async getAgentActivity(contactId, limit) {
|
|
7857
|
+
return pick(await this.g("/activity", { contact_id: contactId, limit }), "activity") ?? [];
|
|
7822
7858
|
}
|
|
7823
|
-
async computeRelationshipStrength() {
|
|
7824
|
-
|
|
7859
|
+
async computeRelationshipStrength(contactId) {
|
|
7860
|
+
const r = await this.g(`/graph/strength/${this.enc(contactId)}`);
|
|
7861
|
+
return Number(r?.strength ?? 0);
|
|
7825
7862
|
}
|
|
7826
|
-
async findWarmPath() {
|
|
7827
|
-
return
|
|
7863
|
+
async findWarmPath(fromContactId, toContactId) {
|
|
7864
|
+
return pick(await this.g("/graph/warm-path", { from: fromContactId, to: toContactId }), "path") ?? [];
|
|
7828
7865
|
}
|
|
7829
|
-
async findConnectionsAtCompany() {
|
|
7830
|
-
return
|
|
7866
|
+
async findConnectionsAtCompany(companyId) {
|
|
7867
|
+
return pick(await this.g(`/graph/company/${this.enc(companyId)}`), "connections") ?? [];
|
|
7831
7868
|
}
|
|
7832
7869
|
async detectCoolingRelationships() {
|
|
7833
|
-
return
|
|
7870
|
+
return pick(await this.g("/graph/cooling"), "cooling") ?? [];
|
|
7834
7871
|
}
|
|
7835
|
-
async resolveContactIdentity() {
|
|
7836
|
-
return
|
|
7872
|
+
async resolveContactIdentity(partial) {
|
|
7873
|
+
return pick(await this.post("/identity/resolve", partial), "matches") ?? [];
|
|
7837
7874
|
}
|
|
7838
|
-
async addContactIdentity() {
|
|
7839
|
-
return
|
|
7875
|
+
async addContactIdentity(contactId, system, externalId, externalUrl, confidence = "inferred") {
|
|
7876
|
+
return pick(await this.post("/identity", { contact_id: contactId, system, external_id: externalId, external_url: externalUrl, confidence }), "identity");
|
|
7840
7877
|
}
|
|
7841
|
-
async getContactIdentities() {
|
|
7842
|
-
return
|
|
7878
|
+
async getContactIdentities(contactId) {
|
|
7879
|
+
return pick(await this.g("/identity", { contact_id: contactId }), "identities") ?? [];
|
|
7843
7880
|
}
|
|
7844
7881
|
async semanticSearch() {
|
|
7845
7882
|
return unavailable("semanticSearch");
|
|
@@ -7850,44 +7887,45 @@ class ApiStore {
|
|
|
7850
7887
|
async embedAllContacts() {
|
|
7851
7888
|
return unavailable("embedAllContacts");
|
|
7852
7889
|
}
|
|
7853
|
-
async getRelationshipSignals() {
|
|
7854
|
-
return
|
|
7890
|
+
async getRelationshipSignals(contactId) {
|
|
7891
|
+
return pick(await this.g("/signals", { contact_id: contactId }), "signals") ?? [];
|
|
7855
7892
|
}
|
|
7856
7893
|
async getGhostContacts() {
|
|
7857
|
-
return
|
|
7894
|
+
return pick(await this.g("/signals/ghost"), "signals") ?? [];
|
|
7858
7895
|
}
|
|
7859
7896
|
async getWarmingContacts() {
|
|
7860
|
-
return
|
|
7897
|
+
return pick(await this.g("/signals/warming"), "signals") ?? [];
|
|
7861
7898
|
}
|
|
7862
7899
|
async recomputeSignals() {
|
|
7863
|
-
|
|
7900
|
+
const r = await this.post("/signals/recompute");
|
|
7901
|
+
return { updated: Number(r?.updated ?? 0) };
|
|
7864
7902
|
}
|
|
7865
|
-
async getFreshnessScore() {
|
|
7866
|
-
return
|
|
7903
|
+
async getFreshnessScore(contactId) {
|
|
7904
|
+
return pick(await this.g(`/freshness/${this.enc(contactId)}`), "freshness");
|
|
7867
7905
|
}
|
|
7868
|
-
async getStaleContacts() {
|
|
7869
|
-
return
|
|
7906
|
+
async getStaleContacts(threshold) {
|
|
7907
|
+
return pick(await this.g("/freshness/stale", { threshold }), "contacts") ?? [];
|
|
7870
7908
|
}
|
|
7871
|
-
async markFieldVerified() {
|
|
7872
|
-
|
|
7909
|
+
async markFieldVerified(contactId, fieldName, source) {
|
|
7910
|
+
await this.post("/freshness/verify", { contact_id: contactId, field_name: fieldName, source });
|
|
7873
7911
|
}
|
|
7874
|
-
async addOrgChartEdge() {
|
|
7875
|
-
return
|
|
7912
|
+
async addOrgChartEdge(companyId, contactAId, contactBId, edgeType, inferred = false) {
|
|
7913
|
+
return pick(await this.post("/org-chart", { company_id: companyId, contact_a_id: contactAId, contact_b_id: contactBId, edge_type: edgeType, inferred }), "edge");
|
|
7876
7914
|
}
|
|
7877
|
-
async listOrgChart() {
|
|
7878
|
-
return
|
|
7915
|
+
async listOrgChart(companyId) {
|
|
7916
|
+
return pick(await this.g("/org-chart", { company_id: companyId }), "edges") ?? [];
|
|
7879
7917
|
}
|
|
7880
|
-
async setDealContactRole() {
|
|
7881
|
-
return
|
|
7918
|
+
async setDealContactRole(dealId, contactId, accountRole) {
|
|
7919
|
+
return pick(await this.post(`/deals/${this.enc(dealId)}/roles`, { contact_id: contactId, account_role: accountRole }), "role");
|
|
7882
7920
|
}
|
|
7883
|
-
async getDealTeam() {
|
|
7884
|
-
return
|
|
7921
|
+
async getDealTeam(dealId) {
|
|
7922
|
+
return pick(await this.g(`/deals/${this.enc(dealId)}/team`), "team") ?? [];
|
|
7885
7923
|
}
|
|
7886
|
-
async getCoverageGaps() {
|
|
7887
|
-
return
|
|
7924
|
+
async getCoverageGaps(companyId) {
|
|
7925
|
+
return pick(await this.g(`/org-chart/coverage/${this.enc(companyId)}`), "coverage");
|
|
7888
7926
|
}
|
|
7889
|
-
async getRecentContactEvents() {
|
|
7890
|
-
return
|
|
7927
|
+
async getRecentContactEvents(since, eventTypes) {
|
|
7928
|
+
return pick(await this.g("/recent-events", { since, types: eventTypes?.length ? eventTypes.join(",") : undefined }), "events") ?? [];
|
|
7891
7929
|
}
|
|
7892
7930
|
async addDocument() {
|
|
7893
7931
|
return unavailable("addDocument");
|
|
@@ -7913,65 +7951,66 @@ class ApiStore {
|
|
|
7913
7951
|
async deleteHealthData() {
|
|
7914
7952
|
return unavailable("deleteHealthData");
|
|
7915
7953
|
}
|
|
7916
|
-
async createAudience() {
|
|
7917
|
-
return
|
|
7954
|
+
async createAudience(input) {
|
|
7955
|
+
return pick(await this.post("/audiences", input), "audience");
|
|
7918
7956
|
}
|
|
7919
|
-
async getAudience() {
|
|
7920
|
-
return
|
|
7957
|
+
async getAudience(idOrSlug) {
|
|
7958
|
+
return pick(await this.g(`/audiences/${this.enc(idOrSlug)}`), "audience");
|
|
7921
7959
|
}
|
|
7922
7960
|
async listAudiences() {
|
|
7923
|
-
return
|
|
7961
|
+
return pick(await this.g("/audiences"), "audiences") ?? [];
|
|
7924
7962
|
}
|
|
7925
|
-
async updateAudience() {
|
|
7926
|
-
return
|
|
7963
|
+
async updateAudience(idOrSlug, input) {
|
|
7964
|
+
return pick(await this.patch(`/audiences/${this.enc(idOrSlug)}`, input), "audience");
|
|
7927
7965
|
}
|
|
7928
|
-
async deleteAudience() {
|
|
7929
|
-
|
|
7966
|
+
async deleteAudience(idOrSlug) {
|
|
7967
|
+
await this.del(`/audiences/${this.enc(idOrSlug)}`);
|
|
7930
7968
|
}
|
|
7931
|
-
async resolveAudience() {
|
|
7932
|
-
return
|
|
7969
|
+
async resolveAudience(idOrSlug, channel) {
|
|
7970
|
+
return pick(await this.g(`/audiences/${this.enc(idOrSlug)}/resolve`, { channel }), "resolution");
|
|
7933
7971
|
}
|
|
7934
|
-
async setContactConsent() {
|
|
7935
|
-
return
|
|
7972
|
+
async setContactConsent(contactId, channel, status, source) {
|
|
7973
|
+
return pick(await this.post("/consent", { contact_id: contactId, channel, status, source }), "consent");
|
|
7936
7974
|
}
|
|
7937
|
-
async listContactConsent() {
|
|
7938
|
-
return
|
|
7975
|
+
async listContactConsent(contactId) {
|
|
7976
|
+
return pick(await this.g("/consent", { contact_id: contactId }), "consent") ?? [];
|
|
7939
7977
|
}
|
|
7940
|
-
async suppressAddress() {
|
|
7941
|
-
return
|
|
7978
|
+
async suppressAddress(input) {
|
|
7979
|
+
return pick(await this.post("/suppressions", input), "suppression");
|
|
7942
7980
|
}
|
|
7943
|
-
async unsuppressAddress() {
|
|
7944
|
-
|
|
7981
|
+
async unsuppressAddress(channel, address) {
|
|
7982
|
+
await this.del("/suppressions", { channel, address });
|
|
7945
7983
|
}
|
|
7946
|
-
async listSuppressions() {
|
|
7947
|
-
return
|
|
7984
|
+
async listSuppressions(opts = {}) {
|
|
7985
|
+
return pick(await this.g("/suppressions", stripUndefined(opts)), "suppressions") ?? [];
|
|
7948
7986
|
}
|
|
7949
7987
|
async syncSuppressions() {
|
|
7950
7988
|
return unavailable("syncSuppressions");
|
|
7951
7989
|
}
|
|
7952
|
-
async generateBrief() {
|
|
7953
|
-
|
|
7990
|
+
async generateBrief(contactId) {
|
|
7991
|
+
const r = await this.g(`/contacts/${this.enc(contactId)}/brief-text`);
|
|
7992
|
+
return String(r?.text ?? "");
|
|
7954
7993
|
}
|
|
7955
|
-
async getContactCard() {
|
|
7956
|
-
return
|
|
7994
|
+
async getContactCard(contactId) {
|
|
7995
|
+
return pick(await this.g(`/contacts/${this.enc(contactId)}/card`), "card");
|
|
7957
7996
|
}
|
|
7958
|
-
async getContactBrief() {
|
|
7959
|
-
return
|
|
7997
|
+
async getContactBrief(contactId, taskContext) {
|
|
7998
|
+
return pick(await this.g(`/contacts/${this.enc(contactId)}/brief`, taskContext ? { context: taskContext } : undefined), "brief");
|
|
7960
7999
|
}
|
|
7961
|
-
async assembleContext() {
|
|
7962
|
-
return
|
|
8000
|
+
async assembleContext(contactIds, format) {
|
|
8001
|
+
return pick(await this.post("/assemble-context", { contact_ids: contactIds, format }), "context");
|
|
7963
8002
|
}
|
|
7964
|
-
async getUpcomingItems() {
|
|
7965
|
-
return
|
|
8003
|
+
async getUpcomingItems(days) {
|
|
8004
|
+
return pick(await this.g("/upcoming", { days }), "items") ?? [];
|
|
7966
8005
|
}
|
|
7967
8006
|
async getNetworkStats() {
|
|
7968
|
-
return
|
|
8007
|
+
return pick(await this.g("/network-stats"), "stats");
|
|
7969
8008
|
}
|
|
7970
8009
|
async listContactAudit() {
|
|
7971
|
-
return
|
|
8010
|
+
return pick(await this.g("/contact-audit"), "audit") ?? [];
|
|
7972
8011
|
}
|
|
7973
|
-
async getContactTimeline() {
|
|
7974
|
-
return
|
|
8012
|
+
async getContactTimeline(contactId, limit) {
|
|
8013
|
+
return pick(await this.g(`/contacts/${this.enc(contactId)}/timeline`, { limit }), "timeline") ?? [];
|
|
7975
8014
|
}
|
|
7976
8015
|
async ingestMeetingParticipants() {
|
|
7977
8016
|
return unavailable("ingestMeetingParticipants");
|
|
@@ -8001,13 +8040,13 @@ class ApiStore {
|
|
|
8001
8040
|
return unavailable("lockVault");
|
|
8002
8041
|
}
|
|
8003
8042
|
async isVaultInitialized() {
|
|
8004
|
-
return
|
|
8043
|
+
return false;
|
|
8005
8044
|
}
|
|
8006
8045
|
async isVaultUnlocked() {
|
|
8007
|
-
return
|
|
8046
|
+
return false;
|
|
8008
8047
|
}
|
|
8009
8048
|
async vaultStatus() {
|
|
8010
|
-
return
|
|
8049
|
+
return pick(await this.g("/vault-status"), "vault") ?? { initialized: false, unlocked: false, document_count: 0 };
|
|
8011
8050
|
}
|
|
8012
8051
|
async saveFeedback() {
|
|
8013
8052
|
return unavailable("saveFeedback");
|
|
@@ -8016,7 +8055,7 @@ class ApiStore {
|
|
|
8016
8055
|
return null;
|
|
8017
8056
|
}
|
|
8018
8057
|
async listActiveWebhooks() {
|
|
8019
|
-
return
|
|
8058
|
+
return [];
|
|
8020
8059
|
}
|
|
8021
8060
|
}
|
|
8022
8061
|
function getStore(env = process.env) {
|
|
@@ -17305,6 +17344,32 @@ function iso(value) {
|
|
|
17305
17344
|
return value.toISOString();
|
|
17306
17345
|
return typeof value === "string" ? value : String(value ?? "");
|
|
17307
17346
|
}
|
|
17347
|
+
function isoOrNull(value) {
|
|
17348
|
+
if (value === null || value === undefined)
|
|
17349
|
+
return null;
|
|
17350
|
+
return iso(value);
|
|
17351
|
+
}
|
|
17352
|
+
function pj(value, fallback) {
|
|
17353
|
+
if (value === null || value === undefined)
|
|
17354
|
+
return fallback;
|
|
17355
|
+
if (typeof value === "string") {
|
|
17356
|
+
if (value === "")
|
|
17357
|
+
return fallback;
|
|
17358
|
+
try {
|
|
17359
|
+
const parsed = JSON.parse(value);
|
|
17360
|
+
return parsed ?? fallback;
|
|
17361
|
+
} catch {
|
|
17362
|
+
return fallback;
|
|
17363
|
+
}
|
|
17364
|
+
}
|
|
17365
|
+
return value;
|
|
17366
|
+
}
|
|
17367
|
+
function newUuid() {
|
|
17368
|
+
return crypto.randomUUID();
|
|
17369
|
+
}
|
|
17370
|
+
function nowIso() {
|
|
17371
|
+
return new Date().toISOString();
|
|
17372
|
+
}
|
|
17308
17373
|
function parseJson(value) {
|
|
17309
17374
|
if (!value)
|
|
17310
17375
|
return {};
|
|
@@ -17602,115 +17667,1484 @@ class ContactsPgStore {
|
|
|
17602
17667
|
tags: Number(row?.tags ?? 0)
|
|
17603
17668
|
};
|
|
17604
17669
|
}
|
|
17605
|
-
|
|
17606
|
-
|
|
17607
|
-
|
|
17608
|
-
|
|
17609
|
-
|
|
17610
|
-
|
|
17611
|
-
|
|
17612
|
-
|
|
17613
|
-
|
|
17614
|
-
|
|
17615
|
-
|
|
17616
|
-
|
|
17617
|
-
|
|
17618
|
-
|
|
17619
|
-
|
|
17620
|
-
|
|
17621
|
-
try {
|
|
17622
|
-
const text = await req.text();
|
|
17623
|
-
if (!text)
|
|
17624
|
-
return {};
|
|
17625
|
-
return JSON.parse(text);
|
|
17626
|
-
} catch {
|
|
17627
|
-
return null;
|
|
17670
|
+
async loadDetails(contact) {
|
|
17671
|
+
const [emails, phones, tags, company] = await Promise.all([
|
|
17672
|
+
this.client.many(`SELECT * FROM emails WHERE contact_id = $1`, [contact.id]),
|
|
17673
|
+
this.client.many(`SELECT * FROM phones WHERE contact_id = $1`, [contact.id]),
|
|
17674
|
+
this.client.many(`SELECT t.* FROM tags t JOIN contact_tags ct ON ct.tag_id = t.id WHERE ct.contact_id = $1`, [contact.id]),
|
|
17675
|
+
contact.company_id ? this.client.get(`SELECT * FROM companies WHERE id = $1`, [contact.company_id]) : Promise.resolve(null)
|
|
17676
|
+
]);
|
|
17677
|
+
return {
|
|
17678
|
+
...contact,
|
|
17679
|
+
emails: emails.map((e) => ({ ...e, is_primary: Boolean(e.is_primary), created_at: isoOrNull(e.created_at) })),
|
|
17680
|
+
phones: phones.map((p) => ({ ...p, is_primary: Boolean(p.is_primary), created_at: isoOrNull(p.created_at) })),
|
|
17681
|
+
addresses: [],
|
|
17682
|
+
social_profiles: [],
|
|
17683
|
+
tags: tags.map((t) => mapTag(t)),
|
|
17684
|
+
company: company ? mapCompany(company) : null
|
|
17685
|
+
};
|
|
17628
17686
|
}
|
|
17629
|
-
|
|
17630
|
-
|
|
17631
|
-
|
|
17632
|
-
if (path !== "/v1" && !path.startsWith("/v1/"))
|
|
17633
|
-
return null;
|
|
17634
|
-
const method = req.method.toUpperCase();
|
|
17635
|
-
const isWrite = method !== "GET" && method !== "HEAD";
|
|
17636
|
-
const requiredScopes = [isWrite ? `${CONTACTS_APP_SLUG}:write` : `${CONTACTS_APP_SLUG}:read`];
|
|
17637
|
-
let verifier;
|
|
17638
|
-
try {
|
|
17639
|
-
verifier = getCloudVerifier();
|
|
17640
|
-
} catch (e) {
|
|
17641
|
-
return error(503, e.message);
|
|
17687
|
+
async searchContacts(q) {
|
|
17688
|
+
const rows = await this.client.many(`SELECT * FROM contacts WHERE search_vector @@ plainto_tsquery('simple', $1) OR display_name ILIKE $2 ORDER BY display_name ASC LIMIT 50`, [q, `%${q}%`]);
|
|
17689
|
+
return Promise.all(rows.map((r) => this.loadDetails(mapContact(r))));
|
|
17642
17690
|
}
|
|
17643
|
-
|
|
17644
|
-
|
|
17645
|
-
|
|
17691
|
+
async listColdContacts(days) {
|
|
17692
|
+
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
17693
|
+
const rows = await this.client.many(`SELECT * FROM contacts
|
|
17694
|
+
WHERE archived = false AND do_not_contact = false
|
|
17695
|
+
AND (last_contacted_at IS NULL OR last_contacted_at < $1)
|
|
17696
|
+
ORDER BY last_contacted_at ASC NULLS FIRST LIMIT 100`, [cutoff]);
|
|
17697
|
+
return Promise.all(rows.map((r) => this.loadDetails(mapContact(r))));
|
|
17646
17698
|
}
|
|
17647
|
-
|
|
17648
|
-
|
|
17649
|
-
|
|
17650
|
-
|
|
17651
|
-
|
|
17652
|
-
|
|
17653
|
-
|
|
17654
|
-
|
|
17655
|
-
|
|
17656
|
-
|
|
17657
|
-
|
|
17658
|
-
|
|
17659
|
-
|
|
17660
|
-
|
|
17661
|
-
|
|
17662
|
-
|
|
17663
|
-
|
|
17664
|
-
|
|
17665
|
-
|
|
17666
|
-
|
|
17667
|
-
|
|
17668
|
-
|
|
17669
|
-
|
|
17670
|
-
|
|
17671
|
-
|
|
17672
|
-
|
|
17673
|
-
|
|
17674
|
-
|
|
17675
|
-
|
|
17676
|
-
|
|
17677
|
-
|
|
17678
|
-
|
|
17679
|
-
|
|
17680
|
-
|
|
17681
|
-
|
|
17682
|
-
|
|
17683
|
-
|
|
17684
|
-
|
|
17685
|
-
|
|
17686
|
-
|
|
17687
|
-
|
|
17699
|
+
async listContactsNotContactedSince(days, limit) {
|
|
17700
|
+
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
17701
|
+
return this.client.many(`SELECT id, display_name, last_contacted_at FROM contacts
|
|
17702
|
+
WHERE (last_contacted_at IS NULL OR last_contacted_at < $1) AND archived = false LIMIT $2`, [cutoff, Math.max(1, limit)]);
|
|
17703
|
+
}
|
|
17704
|
+
async listFollowupDueContacts(onOrBefore) {
|
|
17705
|
+
return this.client.many(`SELECT id, display_name, follow_up_at FROM contacts
|
|
17706
|
+
WHERE follow_up_at IS NOT NULL AND follow_up_at <= $1 AND archived = false`, [onOrBefore]);
|
|
17707
|
+
}
|
|
17708
|
+
async findContactsForContext(topic, limit) {
|
|
17709
|
+
const like = `%${topic}%`;
|
|
17710
|
+
const [byTitle, byNotes, byCompany, bySpec] = await Promise.all([
|
|
17711
|
+
this.client.many(`SELECT c.id, c.display_name, c.job_title, 'job_title' AS reason FROM contacts c WHERE c.job_title ILIKE $1 AND c.archived = false LIMIT 20`, [like]),
|
|
17712
|
+
this.client.many(`SELECT c.id, c.display_name, c.job_title, 'notes' AS reason FROM contacts c WHERE c.notes ILIKE $1 AND c.archived = false LIMIT 10`, [like]),
|
|
17713
|
+
this.client.many(`SELECT c.id, c.display_name, c.job_title, 'company' AS reason FROM contacts c JOIN companies co ON c.company_id = co.id WHERE (co.name ILIKE $1 OR co.industry ILIKE $1) AND c.archived = false LIMIT 10`, [like]),
|
|
17714
|
+
this.client.many(`SELECT c.id, c.display_name, c.job_title, om.specialization AS reason FROM contacts c JOIN org_members om ON c.id = om.contact_id WHERE om.specialization ILIKE $1 LIMIT 10`, [like])
|
|
17715
|
+
]);
|
|
17716
|
+
const seen = new Set;
|
|
17717
|
+
return [...byTitle, ...bySpec, ...byCompany, ...byNotes].filter((r) => {
|
|
17718
|
+
if (seen.has(r.id))
|
|
17719
|
+
return false;
|
|
17720
|
+
seen.add(r.id);
|
|
17721
|
+
return true;
|
|
17722
|
+
}).slice(0, limit);
|
|
17723
|
+
}
|
|
17724
|
+
async findEmailDuplicates() {
|
|
17725
|
+
const rows = await this.client.many(`SELECT MIN(e.address) AS email, string_agg(e.contact_id, ',') AS ids
|
|
17726
|
+
FROM emails e WHERE e.contact_id IS NOT NULL
|
|
17727
|
+
GROUP BY LOWER(e.address) HAVING COUNT(*) > 1`);
|
|
17728
|
+
return rows.map((r) => ({ email: r.email, contact_ids: (r.ids ?? "").split(",").filter(Boolean) }));
|
|
17729
|
+
}
|
|
17730
|
+
async findNameDuplicates() {
|
|
17731
|
+
const contacts = await this.client.many(`SELECT id, display_name FROM contacts`);
|
|
17732
|
+
const lev = (a, b) => {
|
|
17733
|
+
const m = a.length, n = b.length;
|
|
17734
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0));
|
|
17735
|
+
for (let i = 1;i <= m; i++)
|
|
17736
|
+
for (let j = 1;j <= n; j++)
|
|
17737
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
17738
|
+
return dp[m][n];
|
|
17739
|
+
};
|
|
17740
|
+
const pairs = [];
|
|
17741
|
+
for (let i = 0;i < contacts.length; i++) {
|
|
17742
|
+
for (let j = i + 1;j < contacts.length; j++) {
|
|
17743
|
+
const dist = lev(contacts[i].display_name.toLowerCase(), contacts[j].display_name.toLowerCase());
|
|
17744
|
+
if (dist <= 2 && dist > 0)
|
|
17745
|
+
pairs.push({ contact_ids: [contacts[i].id, contacts[j].id], similarity: dist });
|
|
17688
17746
|
}
|
|
17689
|
-
return error(405, `method ${method} not allowed on /v1/contacts/:id`);
|
|
17690
17747
|
}
|
|
17691
|
-
|
|
17692
|
-
|
|
17693
|
-
|
|
17694
|
-
|
|
17695
|
-
|
|
17696
|
-
|
|
17697
|
-
|
|
17698
|
-
|
|
17699
|
-
|
|
17700
|
-
|
|
17701
|
-
|
|
17702
|
-
|
|
17703
|
-
|
|
17704
|
-
|
|
17705
|
-
|
|
17706
|
-
|
|
17707
|
-
|
|
17708
|
-
|
|
17709
|
-
|
|
17710
|
-
|
|
17711
|
-
|
|
17712
|
-
|
|
17713
|
-
|
|
17748
|
+
return pairs;
|
|
17749
|
+
}
|
|
17750
|
+
async getRecentContactEvents(since, eventTypes) {
|
|
17751
|
+
const params = [];
|
|
17752
|
+
let sql = `SELECT * FROM activity_log WHERE 1=1`;
|
|
17753
|
+
if (since) {
|
|
17754
|
+
params.push(since);
|
|
17755
|
+
sql += ` AND created_at >= $${params.length}`;
|
|
17756
|
+
}
|
|
17757
|
+
if (eventTypes?.length) {
|
|
17758
|
+
const placeholders = eventTypes.map((_, i) => `$${params.length + i + 1}`);
|
|
17759
|
+
params.push(...eventTypes);
|
|
17760
|
+
sql += ` AND action IN (${placeholders.join(",")})`;
|
|
17761
|
+
}
|
|
17762
|
+
sql += ` ORDER BY created_at DESC LIMIT 100`;
|
|
17763
|
+
const rows = await this.client.many(sql, params);
|
|
17764
|
+
return rows.map((r) => ({ ...r, created_at: isoOrNull(r.created_at) }));
|
|
17765
|
+
}
|
|
17766
|
+
mapDeal(r) {
|
|
17767
|
+
return { id: r.id, title: r.title, contact_id: r.contact_id, company_id: r.company_id, stage: r.stage, value_usd: r.value_usd, currency: r.currency, close_date: r.close_date, notes: r.notes, created_at: iso(r.created_at), updated_at: iso(r.updated_at) };
|
|
17768
|
+
}
|
|
17769
|
+
async createDeal(input) {
|
|
17770
|
+
const id = newUuid();
|
|
17771
|
+
const row = await this.client.get(`INSERT INTO deals (id, title, contact_id, company_id, stage, value_usd, currency, close_date, notes)
|
|
17772
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, [id, input.title, input.contact_id ?? null, input.company_id ?? null, input.stage ?? "lead", input.value_usd ?? null, input.currency ?? "USD", input.close_date ?? null, input.notes ?? null]);
|
|
17773
|
+
return this.mapDeal(row);
|
|
17774
|
+
}
|
|
17775
|
+
async getDeal(id) {
|
|
17776
|
+
const row = await this.client.get(`SELECT * FROM deals WHERE id = $1`, [id]);
|
|
17777
|
+
return row ? this.mapDeal(row) : null;
|
|
17778
|
+
}
|
|
17779
|
+
async listDeals(opts = {}) {
|
|
17780
|
+
const where = [];
|
|
17781
|
+
const params = [];
|
|
17782
|
+
if (opts.stage) {
|
|
17783
|
+
params.push(opts.stage);
|
|
17784
|
+
where.push(`stage = $${params.length}`);
|
|
17785
|
+
}
|
|
17786
|
+
if (opts.contact_id) {
|
|
17787
|
+
params.push(opts.contact_id);
|
|
17788
|
+
where.push(`contact_id = $${params.length}`);
|
|
17789
|
+
}
|
|
17790
|
+
if (opts.company_id) {
|
|
17791
|
+
params.push(opts.company_id);
|
|
17792
|
+
where.push(`company_id = $${params.length}`);
|
|
17793
|
+
}
|
|
17794
|
+
const sql = `SELECT * FROM deals ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC`;
|
|
17795
|
+
return (await this.client.many(sql, params)).map((r) => this.mapDeal(r));
|
|
17796
|
+
}
|
|
17797
|
+
async updateDeal(id, input) {
|
|
17798
|
+
const existing = await this.getDeal(id);
|
|
17799
|
+
if (!existing)
|
|
17800
|
+
return null;
|
|
17801
|
+
const cols = ["title", "contact_id", "company_id", "stage", "value_usd", "currency", "close_date", "notes"];
|
|
17802
|
+
const sets = [];
|
|
17803
|
+
const params = [id];
|
|
17804
|
+
for (const c of cols)
|
|
17805
|
+
if (c in input) {
|
|
17806
|
+
params.push(input[c] ?? null);
|
|
17807
|
+
sets.push(`${c} = $${params.length}`);
|
|
17808
|
+
}
|
|
17809
|
+
sets.push(`updated_at = NOW()`);
|
|
17810
|
+
const row = await this.client.get(`UPDATE deals SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, params);
|
|
17811
|
+
return row ? this.mapDeal(row) : null;
|
|
17812
|
+
}
|
|
17813
|
+
async deleteDeal(id) {
|
|
17814
|
+
return (await this.client.query(`DELETE FROM deals WHERE id = $1`, [id])).rowCount > 0;
|
|
17815
|
+
}
|
|
17816
|
+
mapEvent(r) {
|
|
17817
|
+
return { id: r.id, title: r.title, type: r.type, event_date: r.event_date, duration_min: r.duration_min, contact_ids: pj(r.contact_ids, []), company_id: r.company_id, notes: r.notes, outcome: r.outcome, deal_id: r.deal_id, created_at: iso(r.created_at) };
|
|
17818
|
+
}
|
|
17819
|
+
async logEvent(input) {
|
|
17820
|
+
const id = newUuid();
|
|
17821
|
+
const row = await this.client.get(`INSERT INTO events (id, title, type, event_date, duration_min, contact_ids, company_id, notes, outcome, deal_id)
|
|
17822
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`, [id, input.title, input.type ?? "meeting", input.event_date, input.duration_min ?? null, JSON.stringify(input.contact_ids ?? []), input.company_id ?? null, input.notes ?? null, input.outcome ?? null, input.deal_id ?? null]);
|
|
17823
|
+
return this.mapEvent(row);
|
|
17824
|
+
}
|
|
17825
|
+
async listEvents(opts = {}) {
|
|
17826
|
+
const where = [];
|
|
17827
|
+
const params = [];
|
|
17828
|
+
if (opts.contact_id) {
|
|
17829
|
+
params.push(`%${opts.contact_id}%`);
|
|
17830
|
+
where.push(`contact_ids LIKE $${params.length}`);
|
|
17831
|
+
}
|
|
17832
|
+
if (opts.company_id) {
|
|
17833
|
+
params.push(opts.company_id);
|
|
17834
|
+
where.push(`company_id = $${params.length}`);
|
|
17835
|
+
}
|
|
17836
|
+
if (opts.type) {
|
|
17837
|
+
params.push(opts.type);
|
|
17838
|
+
where.push(`type = $${params.length}`);
|
|
17839
|
+
}
|
|
17840
|
+
if (opts.date_from) {
|
|
17841
|
+
params.push(opts.date_from);
|
|
17842
|
+
where.push(`event_date >= $${params.length}`);
|
|
17843
|
+
}
|
|
17844
|
+
if (opts.date_to) {
|
|
17845
|
+
params.push(opts.date_to);
|
|
17846
|
+
where.push(`event_date <= $${params.length}`);
|
|
17847
|
+
}
|
|
17848
|
+
const sql = `SELECT * FROM events ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY event_date DESC`;
|
|
17849
|
+
return (await this.client.many(sql, params)).map((r) => this.mapEvent(r));
|
|
17850
|
+
}
|
|
17851
|
+
async deleteEvent(id) {
|
|
17852
|
+
return (await this.client.query(`DELETE FROM events WHERE id = $1`, [id])).rowCount > 0;
|
|
17853
|
+
}
|
|
17854
|
+
mapTask(r) {
|
|
17855
|
+
return { id: r.id, title: r.title, description: r.description, contact_id: r.contact_id, assigned_by: r.assigned_by, deadline: r.deadline, status: r.status, priority: r.priority, entity_id: r.entity_id, linked_todos_task_id: r.linked_todos_task_id, escalation_rules: pj(r.escalation_rules, []), created_at: iso(r.created_at), updated_at: iso(r.updated_at) };
|
|
17856
|
+
}
|
|
17857
|
+
async createContactTask(input) {
|
|
17858
|
+
const id = newUuid();
|
|
17859
|
+
const row = await this.client.get(`INSERT INTO contact_tasks (id, title, description, contact_id, assigned_by, deadline, status, priority, entity_id, linked_todos_task_id, escalation_rules)
|
|
17860
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, [id, input.title, input.description ?? null, input.contact_id, input.assigned_by ?? null, input.deadline ?? null, input.status ?? "pending", input.priority ?? "medium", input.entity_id ?? null, input.linked_todos_task_id ?? null, JSON.stringify(input.escalation_rules ?? [])]);
|
|
17861
|
+
return this.mapTask(row);
|
|
17862
|
+
}
|
|
17863
|
+
async listContactTasks(opts = {}) {
|
|
17864
|
+
const where = [];
|
|
17865
|
+
const params = [];
|
|
17866
|
+
if (opts.contact_id) {
|
|
17867
|
+
params.push(opts.contact_id);
|
|
17868
|
+
where.push(`contact_id = $${params.length}`);
|
|
17869
|
+
}
|
|
17870
|
+
if (opts.entity_id) {
|
|
17871
|
+
params.push(opts.entity_id);
|
|
17872
|
+
where.push(`entity_id = $${params.length}`);
|
|
17873
|
+
}
|
|
17874
|
+
if (opts.status) {
|
|
17875
|
+
params.push(opts.status);
|
|
17876
|
+
where.push(`status = $${params.length}`);
|
|
17877
|
+
}
|
|
17878
|
+
if (opts.priority) {
|
|
17879
|
+
params.push(opts.priority);
|
|
17880
|
+
where.push(`priority = $${params.length}`);
|
|
17881
|
+
}
|
|
17882
|
+
const sql = `SELECT * FROM contact_tasks ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY deadline ASC NULLS LAST, priority DESC, created_at ASC`;
|
|
17883
|
+
return (await this.client.many(sql, params)).map((r) => this.mapTask(r));
|
|
17884
|
+
}
|
|
17885
|
+
async updateContactTask(id, input) {
|
|
17886
|
+
const cols = ["title", "description", "assigned_by", "deadline", "status", "priority", "entity_id", "linked_todos_task_id"];
|
|
17887
|
+
const sets = [];
|
|
17888
|
+
const params = [id];
|
|
17889
|
+
for (const c of cols)
|
|
17890
|
+
if (c in input) {
|
|
17891
|
+
params.push(input[c] ?? null);
|
|
17892
|
+
sets.push(`${c} = $${params.length}`);
|
|
17893
|
+
}
|
|
17894
|
+
if ("escalation_rules" in input) {
|
|
17895
|
+
params.push(JSON.stringify(input.escalation_rules));
|
|
17896
|
+
sets.push(`escalation_rules = $${params.length}`);
|
|
17897
|
+
}
|
|
17898
|
+
sets.push(`updated_at = NOW()`);
|
|
17899
|
+
const row = await this.client.get(`UPDATE contact_tasks SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, params);
|
|
17900
|
+
return row ? this.mapTask(row) : null;
|
|
17901
|
+
}
|
|
17902
|
+
async deleteContactTask(id) {
|
|
17903
|
+
return (await this.client.query(`DELETE FROM contact_tasks WHERE id = $1`, [id])).rowCount > 0;
|
|
17904
|
+
}
|
|
17905
|
+
async listOverdueTasks() {
|
|
17906
|
+
const rows = await this.client.many(`SELECT * FROM contact_tasks WHERE deadline < $1 AND status NOT IN ('completed','cancelled') ORDER BY deadline ASC`, [nowIso()]);
|
|
17907
|
+
return rows.map((r) => this.mapTask(r));
|
|
17908
|
+
}
|
|
17909
|
+
async checkEscalations() {
|
|
17910
|
+
const overdue = await this.listOverdueTasks();
|
|
17911
|
+
const nowMs = Date.now();
|
|
17912
|
+
const results = [];
|
|
17913
|
+
for (const task of overdue) {
|
|
17914
|
+
const rules = task.escalation_rules ?? [];
|
|
17915
|
+
if (!task.deadline || rules.length === 0)
|
|
17916
|
+
continue;
|
|
17917
|
+
const days = (nowMs - new Date(task.deadline).getTime()) / 86400000;
|
|
17918
|
+
const triggered = rules.filter((r) => days >= r.after_days);
|
|
17919
|
+
if (triggered.length)
|
|
17920
|
+
results.push({ task, rules_triggered: triggered });
|
|
17921
|
+
}
|
|
17922
|
+
return results;
|
|
17923
|
+
}
|
|
17924
|
+
mapApplication(r) {
|
|
17925
|
+
return { id: r.id, program_name: r.program_name, provider_company_id: r.provider_company_id, type: r.type, value_usd: r.value_usd, applicant_contact_id: r.applicant_contact_id, primary_contact_id: r.primary_contact_id, status: r.status, submitted_date: r.submitted_date, decision_date: r.decision_date, follow_up_date: r.follow_up_date, notes: r.notes, method: r.method ?? null, form_url: r.form_url, metadata: pj(r.metadata, {}), created_at: iso(r.created_at), updated_at: iso(r.updated_at) };
|
|
17926
|
+
}
|
|
17927
|
+
async createApplication(input) {
|
|
17928
|
+
const id = newUuid();
|
|
17929
|
+
const row = await this.client.get(`INSERT INTO applications (id, program_name, provider_company_id, type, value_usd, applicant_contact_id, primary_contact_id, status, submitted_date, decision_date, follow_up_date, notes, method, form_url, metadata)
|
|
17930
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`, [id, input.program_name, input.provider_company_id ?? null, input.type ?? "other", input.value_usd ?? null, input.applicant_contact_id ?? null, input.primary_contact_id ?? null, input.status ?? "draft", input.submitted_date ?? null, input.decision_date ?? null, input.follow_up_date ?? null, input.notes ?? null, input.method ?? null, input.form_url ?? null, JSON.stringify(input.metadata ?? {})]);
|
|
17931
|
+
return this.mapApplication(row);
|
|
17932
|
+
}
|
|
17933
|
+
async listApplications(opts = {}) {
|
|
17934
|
+
const where = [];
|
|
17935
|
+
const params = [];
|
|
17936
|
+
if (opts.type) {
|
|
17937
|
+
params.push(opts.type);
|
|
17938
|
+
where.push(`type = $${params.length}`);
|
|
17939
|
+
}
|
|
17940
|
+
if (opts.status) {
|
|
17941
|
+
params.push(opts.status);
|
|
17942
|
+
where.push(`status = $${params.length}`);
|
|
17943
|
+
}
|
|
17944
|
+
if (opts.provider_company_id) {
|
|
17945
|
+
params.push(opts.provider_company_id);
|
|
17946
|
+
where.push(`provider_company_id = $${params.length}`);
|
|
17947
|
+
}
|
|
17948
|
+
if (opts.applicant_contact_id) {
|
|
17949
|
+
params.push(opts.applicant_contact_id);
|
|
17950
|
+
where.push(`applicant_contact_id = $${params.length}`);
|
|
17951
|
+
}
|
|
17952
|
+
const sql = `SELECT * FROM applications ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC`;
|
|
17953
|
+
return (await this.client.many(sql, params)).map((r) => this.mapApplication(r));
|
|
17954
|
+
}
|
|
17955
|
+
async updateApplication(id, input) {
|
|
17956
|
+
const cols = ["program_name", "provider_company_id", "type", "value_usd", "applicant_contact_id", "primary_contact_id", "status", "submitted_date", "decision_date", "follow_up_date", "notes", "method", "form_url"];
|
|
17957
|
+
const sets = [];
|
|
17958
|
+
const params = [id];
|
|
17959
|
+
for (const c of cols)
|
|
17960
|
+
if (c in input) {
|
|
17961
|
+
params.push(input[c] ?? null);
|
|
17962
|
+
sets.push(`${c} = $${params.length}`);
|
|
17963
|
+
}
|
|
17964
|
+
if ("metadata" in input) {
|
|
17965
|
+
params.push(JSON.stringify(input.metadata));
|
|
17966
|
+
sets.push(`metadata = $${params.length}`);
|
|
17967
|
+
}
|
|
17968
|
+
sets.push(`updated_at = NOW()`);
|
|
17969
|
+
const row = await this.client.get(`UPDATE applications SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, params);
|
|
17970
|
+
return row ? this.mapApplication(row) : null;
|
|
17971
|
+
}
|
|
17972
|
+
async listFollowUpDueApplications() {
|
|
17973
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
17974
|
+
const rows = await this.client.many(`SELECT * FROM applications WHERE follow_up_date <= $1 AND status = 'follow_up_needed' ORDER BY follow_up_date ASC`, [today]);
|
|
17975
|
+
return rows.map((r) => this.mapApplication(r));
|
|
17976
|
+
}
|
|
17977
|
+
mapGroup(r) {
|
|
17978
|
+
return { ...r, created_at: isoOrNull(r.created_at), updated_at: isoOrNull(r.updated_at), member_count: r.member_count != null ? Number(r.member_count) : undefined, company_count: r.company_count != null ? Number(r.company_count) : undefined };
|
|
17979
|
+
}
|
|
17980
|
+
async createGroup(input) {
|
|
17981
|
+
const id = newUuid();
|
|
17982
|
+
await this.client.execute(`INSERT INTO groups (id, name, description, project_id) VALUES ($1,$2,$3,$4)`, [id, input.name, input.description ?? null, input.project_id ?? null]);
|
|
17983
|
+
return this.getGroup(id);
|
|
17984
|
+
}
|
|
17985
|
+
async getGroup(id) {
|
|
17986
|
+
const row = await this.client.get(`SELECT * FROM groups WHERE id = $1`, [id]);
|
|
17987
|
+
return row ? this.mapGroup(row) : null;
|
|
17988
|
+
}
|
|
17989
|
+
async listGroups(projectId) {
|
|
17990
|
+
const params = [];
|
|
17991
|
+
let where = "";
|
|
17992
|
+
if (projectId) {
|
|
17993
|
+
params.push(projectId);
|
|
17994
|
+
where = `WHERE g.project_id = $1`;
|
|
17995
|
+
}
|
|
17996
|
+
const rows = await this.client.many(`SELECT g.*, (SELECT COUNT(*) FROM contact_groups cg WHERE cg.group_id = g.id) AS member_count,
|
|
17997
|
+
(SELECT COUNT(*) FROM company_groups cog WHERE cog.group_id = g.id) AS company_count
|
|
17998
|
+
FROM groups g ${where} ORDER BY g.name`, params);
|
|
17999
|
+
return rows.map((r) => this.mapGroup(r));
|
|
18000
|
+
}
|
|
18001
|
+
async updateGroup(id, input) {
|
|
18002
|
+
const sets = [];
|
|
18003
|
+
const params = [id];
|
|
18004
|
+
for (const c of ["name", "description", "project_id"])
|
|
18005
|
+
if (c in input) {
|
|
18006
|
+
params.push(input[c] ?? null);
|
|
18007
|
+
sets.push(`${c} = $${params.length}`);
|
|
18008
|
+
}
|
|
18009
|
+
sets.push(`updated_at = NOW()`);
|
|
18010
|
+
await this.client.execute(`UPDATE groups SET ${sets.join(", ")} WHERE id = $1`, params);
|
|
18011
|
+
return this.getGroup(id);
|
|
18012
|
+
}
|
|
18013
|
+
async deleteGroup(id) {
|
|
18014
|
+
return (await this.client.query(`DELETE FROM groups WHERE id = $1`, [id])).rowCount > 0;
|
|
18015
|
+
}
|
|
18016
|
+
async addContactToGroup(contactId, groupId) {
|
|
18017
|
+
const existing = await this.client.get(`SELECT 1 FROM contact_groups WHERE contact_id = $1 AND group_id = $2`, [contactId, groupId]);
|
|
18018
|
+
if (existing)
|
|
18019
|
+
return { added: false, already_member: true };
|
|
18020
|
+
await this.client.execute(`INSERT INTO contact_groups (contact_id, group_id) VALUES ($1,$2)`, [contactId, groupId]);
|
|
18021
|
+
return { added: true, already_member: false };
|
|
18022
|
+
}
|
|
18023
|
+
async removeContactFromGroup(contactId, groupId) {
|
|
18024
|
+
await this.client.execute(`DELETE FROM contact_groups WHERE contact_id = $1 AND group_id = $2`, [contactId, groupId]);
|
|
18025
|
+
}
|
|
18026
|
+
async listContactsInGroup(groupId) {
|
|
18027
|
+
return (await this.client.many(`SELECT contact_id FROM contact_groups WHERE group_id = $1`, [groupId])).map((r) => r.contact_id);
|
|
18028
|
+
}
|
|
18029
|
+
async listGroupsForContact(contactId) {
|
|
18030
|
+
const rows = await this.client.many(`SELECT g.* FROM groups g JOIN contact_groups cg ON g.id = cg.group_id WHERE cg.contact_id = $1 ORDER BY g.name`, [contactId]);
|
|
18031
|
+
return rows.map((r) => this.mapGroup(r));
|
|
18032
|
+
}
|
|
18033
|
+
async addCompanyToGroup(companyId, groupId) {
|
|
18034
|
+
const existing = await this.client.get(`SELECT 1 FROM company_groups WHERE company_id = $1 AND group_id = $2`, [companyId, groupId]);
|
|
18035
|
+
if (existing)
|
|
18036
|
+
return { added: false, already_member: true };
|
|
18037
|
+
await this.client.execute(`INSERT INTO company_groups (company_id, group_id) VALUES ($1,$2)`, [companyId, groupId]);
|
|
18038
|
+
return { added: true, already_member: false };
|
|
18039
|
+
}
|
|
18040
|
+
async removeCompanyFromGroup(companyId, groupId) {
|
|
18041
|
+
await this.client.execute(`DELETE FROM company_groups WHERE company_id = $1 AND group_id = $2`, [companyId, groupId]);
|
|
18042
|
+
}
|
|
18043
|
+
async listCompaniesInGroup(groupId) {
|
|
18044
|
+
return (await this.client.many(`SELECT company_id FROM company_groups WHERE group_id = $1`, [groupId])).map((r) => r.company_id);
|
|
18045
|
+
}
|
|
18046
|
+
async listGroupsForCompany(companyId) {
|
|
18047
|
+
const rows = await this.client.many(`SELECT g.* FROM groups g JOIN company_groups cog ON g.id = cog.group_id WHERE cog.company_id = $1 ORDER BY g.name`, [companyId]);
|
|
18048
|
+
return rows.map((r) => this.mapGroup(r));
|
|
18049
|
+
}
|
|
18050
|
+
mapVendorComm(r) {
|
|
18051
|
+
return { id: r.id, company_id: r.company_id, contact_id: r.contact_id, comm_date: r.comm_date, type: r.type, direction: r.direction, subject: r.subject, body: r.body, status: r.status, invoice_amount: r.invoice_amount, invoice_currency: r.invoice_currency, invoice_ref: r.invoice_ref, follow_up_date: r.follow_up_date, follow_up_done: Boolean(r.follow_up_done), created_at: iso(r.created_at) };
|
|
18052
|
+
}
|
|
18053
|
+
async logVendorCommunication(input) {
|
|
18054
|
+
const id = newUuid();
|
|
18055
|
+
const row = await this.client.get(`INSERT INTO vendor_communications (id, company_id, contact_id, comm_date, type, direction, subject, body, status, invoice_amount, invoice_currency, invoice_ref, follow_up_date, follow_up_done)
|
|
18056
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, [id, input.company_id, input.contact_id ?? null, input.comm_date, input.type ?? "email", input.direction ?? "outbound", input.subject ?? null, input.body ?? null, input.status ?? "sent", input.invoice_amount ?? null, input.invoice_currency ?? null, input.invoice_ref ?? null, input.follow_up_date ?? null, Boolean(input.follow_up_done)]);
|
|
18057
|
+
return this.mapVendorComm(row);
|
|
18058
|
+
}
|
|
18059
|
+
async listVendorCommunications(companyId, opts = {}) {
|
|
18060
|
+
const where = ["company_id = $1"];
|
|
18061
|
+
const params = [companyId];
|
|
18062
|
+
if (opts.type) {
|
|
18063
|
+
params.push(opts.type);
|
|
18064
|
+
where.push(`type = $${params.length}`);
|
|
18065
|
+
}
|
|
18066
|
+
if (opts.status) {
|
|
18067
|
+
params.push(opts.status);
|
|
18068
|
+
where.push(`status = $${params.length}`);
|
|
18069
|
+
}
|
|
18070
|
+
if (opts.direction) {
|
|
18071
|
+
params.push(opts.direction);
|
|
18072
|
+
where.push(`direction = $${params.length}`);
|
|
18073
|
+
}
|
|
18074
|
+
const rows = await this.client.many(`SELECT * FROM vendor_communications WHERE ${where.join(" AND ")} ORDER BY comm_date DESC`, params);
|
|
18075
|
+
return rows.map((r) => this.mapVendorComm(r));
|
|
18076
|
+
}
|
|
18077
|
+
async listMissingInvoices() {
|
|
18078
|
+
const rows = await this.client.many(`SELECT * FROM vendor_communications WHERE type = 'invoice_request' AND status IN ('awaiting_response','no_response') ORDER BY comm_date ASC`);
|
|
18079
|
+
return rows.map((r) => this.mapVendorComm(r));
|
|
18080
|
+
}
|
|
18081
|
+
async listPendingFollowUps() {
|
|
18082
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
18083
|
+
const rows = await this.client.many(`SELECT * FROM vendor_communications WHERE follow_up_date <= $1 AND follow_up_done = false ORDER BY follow_up_date ASC`, [today]);
|
|
18084
|
+
return rows.map((r) => this.mapVendorComm(r));
|
|
18085
|
+
}
|
|
18086
|
+
async markFollowUpDone(id) {
|
|
18087
|
+
const row = await this.client.get(`UPDATE vendor_communications SET follow_up_done = true WHERE id = $1 RETURNING *`, [id]);
|
|
18088
|
+
return row ? this.mapVendorComm(row) : null;
|
|
18089
|
+
}
|
|
18090
|
+
mapOrgMember(r) {
|
|
18091
|
+
return { id: r.id, company_id: r.company_id, contact_id: r.contact_id, title: r.title, specialization: r.specialization, office_phone: r.office_phone, response_sla_hours: r.response_sla_hours, notes: r.notes, created_at: iso(r.created_at), updated_at: iso(r.updated_at) };
|
|
18092
|
+
}
|
|
18093
|
+
async addOrgMember(input) {
|
|
18094
|
+
const id = newUuid();
|
|
18095
|
+
const row = await this.client.get(`INSERT INTO org_members (id, company_id, contact_id, title, specialization, office_phone, response_sla_hours, notes)
|
|
18096
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, [id, input.company_id, input.contact_id, input.title ?? null, input.specialization ?? null, input.office_phone ?? null, input.response_sla_hours ?? null, input.notes ?? null]);
|
|
18097
|
+
return this.mapOrgMember(row);
|
|
18098
|
+
}
|
|
18099
|
+
async listOrgMembers(companyId) {
|
|
18100
|
+
return (await this.client.many(`SELECT * FROM org_members WHERE company_id = $1 ORDER BY created_at ASC`, [companyId])).map((r) => this.mapOrgMember(r));
|
|
18101
|
+
}
|
|
18102
|
+
async updateOrgMember(id, input) {
|
|
18103
|
+
const sets = [];
|
|
18104
|
+
const params = [id];
|
|
18105
|
+
for (const c of ["title", "specialization", "office_phone", "response_sla_hours", "notes"])
|
|
18106
|
+
if (c in input) {
|
|
18107
|
+
params.push(input[c] ?? null);
|
|
18108
|
+
sets.push(`${c} = $${params.length}`);
|
|
18109
|
+
}
|
|
18110
|
+
sets.push(`updated_at = NOW()`);
|
|
18111
|
+
const row = await this.client.get(`UPDATE org_members SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, params);
|
|
18112
|
+
return row ? this.mapOrgMember(row) : null;
|
|
18113
|
+
}
|
|
18114
|
+
async removeOrgMember(id) {
|
|
18115
|
+
return (await this.client.query(`DELETE FROM org_members WHERE id = $1`, [id])).rowCount > 0;
|
|
18116
|
+
}
|
|
18117
|
+
async listOrgMembersForContact(contactId) {
|
|
18118
|
+
return (await this.client.many(`SELECT * FROM org_members WHERE contact_id = $1 ORDER BY created_at ASC`, [contactId])).map((r) => this.mapOrgMember(r));
|
|
18119
|
+
}
|
|
18120
|
+
mapNote(r) {
|
|
18121
|
+
return { ...r, created_at: isoOrNull(r.created_at) };
|
|
18122
|
+
}
|
|
18123
|
+
async addNote(contactId, body, createdBy, companyId) {
|
|
18124
|
+
const contact = await this.client.get(`SELECT id FROM contacts WHERE id = $1`, [contactId]);
|
|
18125
|
+
if (!contact)
|
|
18126
|
+
throw new Error(`Contact not found: ${contactId}`);
|
|
18127
|
+
const id = newUuid();
|
|
18128
|
+
const row = await this.client.get(`INSERT INTO contact_notes (id, contact_id, body, created_by, company_id) VALUES ($1,$2,$3,$4,$5) RETURNING *`, [id, contactId, body, createdBy ?? null, companyId ?? null]);
|
|
18129
|
+
return this.mapNote(row);
|
|
18130
|
+
}
|
|
18131
|
+
async listNotes(contactId) {
|
|
18132
|
+
return (await this.client.many(`SELECT * FROM contact_notes WHERE contact_id = $1 ORDER BY created_at ASC`, [contactId])).map((r) => this.mapNote(r));
|
|
18133
|
+
}
|
|
18134
|
+
async listNotesForContactAtCompany(contactId, companyId) {
|
|
18135
|
+
return (await this.client.many(`SELECT * FROM contact_notes WHERE contact_id = $1 AND company_id = $2 ORDER BY created_at ASC`, [contactId, companyId])).map((r) => this.mapNote(r));
|
|
18136
|
+
}
|
|
18137
|
+
async deleteNote(noteId) {
|
|
18138
|
+
await this.client.execute(`DELETE FROM contact_notes WHERE id = $1`, [noteId]);
|
|
18139
|
+
}
|
|
18140
|
+
async createRelationship(input) {
|
|
18141
|
+
const id = newUuid();
|
|
18142
|
+
const row = await this.client.get(`INSERT INTO contact_relationships (id, contact_a_id, contact_b_id, relationship_type, notes)
|
|
18143
|
+
VALUES ($1,$2,$3,$4,$5) RETURNING *`, [id, input.contact_a_id, input.contact_b_id, input.relationship_type ?? "knows", input.notes ?? null]);
|
|
18144
|
+
return { ...row, created_at: isoOrNull(row?.created_at) };
|
|
18145
|
+
}
|
|
18146
|
+
async listRelationships(opts = {}) {
|
|
18147
|
+
if (opts.contact_id) {
|
|
18148
|
+
return this.client.many(`SELECT * FROM contact_relationships WHERE contact_a_id = $1 OR contact_b_id = $1`, [opts.contact_id]);
|
|
18149
|
+
}
|
|
18150
|
+
return this.client.many(`SELECT * FROM contact_relationships`);
|
|
18151
|
+
}
|
|
18152
|
+
async deleteRelationship(id) {
|
|
18153
|
+
await this.client.execute(`DELETE FROM contact_relationships WHERE id = $1`, [id]);
|
|
18154
|
+
}
|
|
18155
|
+
async createCompanyRelationship(input) {
|
|
18156
|
+
const id = newUuid();
|
|
18157
|
+
const row = await this.client.get(`INSERT INTO company_relationships (id, contact_id, company_id, relationship_type, notes, start_date, end_date, is_primary, status)
|
|
18158
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, [id, input.contact_id, input.company_id, input.relationship_type, input.notes ?? null, input.start_date ?? null, input.end_date ?? null, Boolean(input.is_primary), input.status ?? "active"]);
|
|
18159
|
+
return { ...row, created_at: isoOrNull(row?.created_at), is_primary: Boolean(row?.is_primary) };
|
|
18160
|
+
}
|
|
18161
|
+
async listCompanyRelationships(opts = {}) {
|
|
18162
|
+
const where = [];
|
|
18163
|
+
const params = [];
|
|
18164
|
+
if (opts.contact_id) {
|
|
18165
|
+
params.push(opts.contact_id);
|
|
18166
|
+
where.push(`contact_id = $${params.length}`);
|
|
18167
|
+
}
|
|
18168
|
+
if (opts.company_id) {
|
|
18169
|
+
params.push(opts.company_id);
|
|
18170
|
+
where.push(`company_id = $${params.length}`);
|
|
18171
|
+
}
|
|
18172
|
+
const sql = `SELECT * FROM company_relationships ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC`;
|
|
18173
|
+
return (await this.client.many(sql, params)).map((r) => ({ ...r, created_at: isoOrNull(r.created_at), is_primary: Boolean(r.is_primary) }));
|
|
18174
|
+
}
|
|
18175
|
+
async deleteCompanyRelationship(id) {
|
|
18176
|
+
await this.client.execute(`DELETE FROM company_relationships WHERE id = $1`, [id]);
|
|
18177
|
+
}
|
|
18178
|
+
async getFieldHistory(contactId, fieldName) {
|
|
18179
|
+
const rows = fieldName ? await this.client.many(`SELECT * FROM contact_field_history WHERE contact_id = $1 AND field_name = $2 ORDER BY valid_from DESC`, [contactId, fieldName]) : await this.client.many(`SELECT * FROM contact_field_history WHERE contact_id = $1 ORDER BY valid_from DESC`, [contactId]);
|
|
18180
|
+
return rows.map((r) => ({ ...r, valid_from: isoOrNull(r.valid_from), created_at: isoOrNull(r.created_at) }));
|
|
18181
|
+
}
|
|
18182
|
+
async getContactAt(contactId, timestamp) {
|
|
18183
|
+
const rows = await this.client.many(`SELECT field_name, new_value FROM contact_field_history WHERE contact_id = $1 AND valid_from <= $2 ORDER BY valid_from ASC`, [contactId, timestamp]);
|
|
18184
|
+
const result = {};
|
|
18185
|
+
for (const r of rows)
|
|
18186
|
+
if (r.new_value != null)
|
|
18187
|
+
result[r.field_name] = r.new_value;
|
|
18188
|
+
return result;
|
|
18189
|
+
}
|
|
18190
|
+
mapJob(r) {
|
|
18191
|
+
return { ...r, is_current: Boolean(r.is_current), inferred: Boolean(r.inferred), created_at: isoOrNull(r.created_at) };
|
|
18192
|
+
}
|
|
18193
|
+
async addJobEntry(contactId, input) {
|
|
18194
|
+
if (input.is_current) {
|
|
18195
|
+
await this.client.execute(`UPDATE job_history SET is_current = false, end_date = COALESCE(end_date, $1) WHERE contact_id = $2 AND is_current = true`, [new Date().toISOString().slice(0, 10), contactId]);
|
|
18196
|
+
}
|
|
18197
|
+
const id = newUuid();
|
|
18198
|
+
const row = await this.client.get(`INSERT INTO job_history (id, contact_id, company_id, company_name, title, start_date, end_date, is_current, inferred, source)
|
|
18199
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`, [id, contactId, input.company_id ?? null, input.company_name, input.title ?? null, input.start_date ?? null, input.end_date ?? null, Boolean(input.is_current), Boolean(input.inferred), input.source ?? null]);
|
|
18200
|
+
return this.mapJob(row);
|
|
18201
|
+
}
|
|
18202
|
+
async getJobHistory(contactId) {
|
|
18203
|
+
return (await this.client.many(`SELECT * FROM job_history WHERE contact_id = $1 ORDER BY is_current DESC, start_date DESC`, [contactId])).map((r) => this.mapJob(r));
|
|
18204
|
+
}
|
|
18205
|
+
mapLearning(r) {
|
|
18206
|
+
return { ...r, tags: pj(r.tags, []), created_at: isoOrNull(r.created_at), updated_at: isoOrNull(r.updated_at) };
|
|
18207
|
+
}
|
|
18208
|
+
async saveLearning(contactId, input) {
|
|
18209
|
+
const id = newUuid();
|
|
18210
|
+
const row = await this.client.get(`INSERT INTO contact_learnings (id, contact_id, content, type, confidence, importance, learned_by, session_id, visibility, tags)
|
|
18211
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`, [id, contactId, input.content, input.type ?? "fact", input.confidence ?? 70, input.importance ?? 5, input.learned_by ?? null, input.session_id ?? null, input.visibility ?? "shared", JSON.stringify(input.tags ?? [])]);
|
|
18212
|
+
return this.mapLearning(row);
|
|
18213
|
+
}
|
|
18214
|
+
async getLearnings(contactId, opts = {}) {
|
|
18215
|
+
let sql = `SELECT * FROM contact_learnings WHERE contact_id = $1`;
|
|
18216
|
+
const params = [contactId];
|
|
18217
|
+
if (opts.type) {
|
|
18218
|
+
params.push(opts.type);
|
|
18219
|
+
sql += ` AND type = $${params.length}`;
|
|
18220
|
+
}
|
|
18221
|
+
if (opts.min_importance) {
|
|
18222
|
+
params.push(opts.min_importance);
|
|
18223
|
+
sql += ` AND importance >= $${params.length}`;
|
|
18224
|
+
}
|
|
18225
|
+
if (opts.visibility) {
|
|
18226
|
+
params.push(opts.visibility);
|
|
18227
|
+
sql += ` AND visibility = $${params.length}`;
|
|
18228
|
+
}
|
|
18229
|
+
sql += ` ORDER BY importance DESC, confidence DESC`;
|
|
18230
|
+
return (await this.client.many(sql, params)).map((r) => this.mapLearning(r));
|
|
18231
|
+
}
|
|
18232
|
+
async searchLearnings(query, opts = {}) {
|
|
18233
|
+
let sql = `SELECT * FROM contact_learnings WHERE content ILIKE $1`;
|
|
18234
|
+
const params = [`%${query}%`];
|
|
18235
|
+
if (opts.type) {
|
|
18236
|
+
params.push(opts.type);
|
|
18237
|
+
sql += ` AND type = $${params.length}`;
|
|
18238
|
+
}
|
|
18239
|
+
if (opts.contact_id) {
|
|
18240
|
+
params.push(opts.contact_id);
|
|
18241
|
+
sql += ` AND contact_id = $${params.length}`;
|
|
18242
|
+
}
|
|
18243
|
+
sql += ` ORDER BY importance DESC, confidence DESC LIMIT 50`;
|
|
18244
|
+
return (await this.client.many(sql, params)).map((r) => this.mapLearning(r));
|
|
18245
|
+
}
|
|
18246
|
+
async confirmLearning(learningId) {
|
|
18247
|
+
await this.client.execute(`UPDATE contact_learnings SET confirmed_count = confirmed_count + 1, confidence = LEAST(100, confidence + 10), updated_at = NOW() WHERE id = $1`, [learningId]);
|
|
18248
|
+
}
|
|
18249
|
+
async getStaleLearnings(daysOld, minConfidence) {
|
|
18250
|
+
const cutoff = new Date(Date.now() - daysOld * 86400000).toISOString();
|
|
18251
|
+
return (await this.client.many(`SELECT * FROM contact_learnings WHERE confirmed_count = 0 AND created_at < $1 AND confidence >= $2 ORDER BY confidence ASC LIMIT 50`, [cutoff, minConfidence])).map((r) => this.mapLearning(r));
|
|
18252
|
+
}
|
|
18253
|
+
async runLearningMaintenance() {
|
|
18254
|
+
const cutoff = new Date(Date.now() - 30 * 86400000).toISOString();
|
|
18255
|
+
const res = await this.client.query(`UPDATE contact_learnings SET confidence = GREATEST(10, confidence - 5), updated_at = NOW() WHERE confirmed_count = 0 AND created_at < $1 AND confidence > 10`, [cutoff]);
|
|
18256
|
+
const dups = await this.client.many(`SELECT contact_id, COUNT(*) AS cnt FROM contact_learnings GROUP BY contact_id, LOWER(SUBSTR(content,1,30)) HAVING COUNT(*) > 1`);
|
|
18257
|
+
return { decayed_count: res.rowCount, potential_contradictions: dups };
|
|
18258
|
+
}
|
|
18259
|
+
mapLock(r) {
|
|
18260
|
+
return { ...r, acquired_at: isoOrNull(r.acquired_at), expires_at: isoOrNull(r.expires_at) };
|
|
18261
|
+
}
|
|
18262
|
+
async acquireContactLock(contactId, agentName, ttlSeconds = 300, reason, sessionId) {
|
|
18263
|
+
await this.client.execute(`DELETE FROM contact_locks WHERE expires_at < NOW()`);
|
|
18264
|
+
const existing = await this.client.get(`SELECT * FROM contact_locks WHERE contact_id = $1`, [contactId]);
|
|
18265
|
+
if (existing)
|
|
18266
|
+
return { acquired: false, held_by: existing.agent_name, lock: this.mapLock(existing) };
|
|
18267
|
+
const id = newUuid();
|
|
18268
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
18269
|
+
const row = await this.client.get(`INSERT INTO contact_locks (id, contact_id, agent_name, reason, expires_at, session_id) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [id, contactId, agentName, reason ?? null, expiresAt, sessionId ?? null]);
|
|
18270
|
+
return { acquired: true, lock: this.mapLock(row) };
|
|
18271
|
+
}
|
|
18272
|
+
async releaseContactLock(contactId, agentName) {
|
|
18273
|
+
return (await this.client.query(`DELETE FROM contact_locks WHERE contact_id = $1 AND agent_name = $2`, [contactId, agentName])).rowCount > 0;
|
|
18274
|
+
}
|
|
18275
|
+
async checkContactLock(contactId) {
|
|
18276
|
+
await this.client.execute(`DELETE FROM contact_locks WHERE expires_at < NOW()`);
|
|
18277
|
+
const row = await this.client.get(`SELECT * FROM contact_locks WHERE contact_id = $1`, [contactId]);
|
|
18278
|
+
return row ? this.mapLock(row) : null;
|
|
18279
|
+
}
|
|
18280
|
+
async logAgentActivity(contactId, agentName, action, details, sessionId) {
|
|
18281
|
+
await this.client.execute(`INSERT INTO contact_agent_activity (id, contact_id, agent_name, action, details, session_id) VALUES ($1,$2,$3,$4,$5,$6)`, [newUuid(), contactId, agentName, action, details ?? null, sessionId ?? null]);
|
|
18282
|
+
}
|
|
18283
|
+
async getAgentActivity(contactId, limit = 20) {
|
|
18284
|
+
return (await this.client.many(`SELECT * FROM contact_agent_activity WHERE contact_id = $1 ORDER BY created_at DESC LIMIT $2`, [contactId, limit])).map((r) => ({ ...r, created_at: isoOrNull(r.created_at) }));
|
|
18285
|
+
}
|
|
18286
|
+
async resolveContactIdentity(partial) {
|
|
18287
|
+
const matches = new Map;
|
|
18288
|
+
const add = (id, name, title, score, reason) => {
|
|
18289
|
+
const ex = matches.get(id);
|
|
18290
|
+
if (ex) {
|
|
18291
|
+
ex.confidence_score = Math.min(100, ex.confidence_score + score);
|
|
18292
|
+
ex.match_reasons.push(reason);
|
|
18293
|
+
} else
|
|
18294
|
+
matches.set(id, { contact: { id, display_name: name, job_title: title }, confidence_score: score, match_reasons: [reason] });
|
|
18295
|
+
};
|
|
18296
|
+
if (partial.email) {
|
|
18297
|
+
const rows = await this.client.many(`SELECT c.id, c.display_name, c.job_title FROM contacts c JOIN emails e ON c.id = e.contact_id WHERE LOWER(e.address) = LOWER($1)`, [partial.email]);
|
|
18298
|
+
rows.forEach((r) => add(r.id, r.display_name, r.job_title, 90, `email match: ${partial.email}`));
|
|
18299
|
+
}
|
|
18300
|
+
if (partial.linkedin_url) {
|
|
18301
|
+
const tail = partial.linkedin_url.split("/").pop();
|
|
18302
|
+
const rows = await this.client.many(`SELECT c.id, c.display_name, c.job_title FROM contacts c JOIN social_profiles sp ON c.id = sp.contact_id WHERE sp.platform = 'linkedin' AND sp.url LIKE $1`, [`%${tail}%`]);
|
|
18303
|
+
rows.forEach((r) => add(r.id, r.display_name, r.job_title, 85, `linkedin match`));
|
|
18304
|
+
}
|
|
18305
|
+
if (partial.name) {
|
|
18306
|
+
const rows = await this.client.many(`SELECT id, display_name, job_title FROM contacts WHERE display_name ILIKE $1 AND archived = false LIMIT 10`, [`%${partial.name}%`]);
|
|
18307
|
+
rows.forEach((r) => add(r.id, r.display_name, r.job_title, 40, `name match: ${partial.name}`));
|
|
18308
|
+
}
|
|
18309
|
+
return Array.from(matches.values()).sort((a, b) => b.confidence_score - a.confidence_score);
|
|
18310
|
+
}
|
|
18311
|
+
async addContactIdentity(contactId, system, externalId, externalUrl, confidence = "inferred") {
|
|
18312
|
+
const id = newUuid();
|
|
18313
|
+
const row = await this.client.get(`INSERT INTO contact_identities (id, contact_id, system, external_id, external_url, confidence) VALUES ($1,$2,$3,$4,$5,$6)
|
|
18314
|
+
ON CONFLICT (system, external_id) DO UPDATE SET contact_id = excluded.contact_id, external_url = excluded.external_url, confidence = excluded.confidence RETURNING *`, [id, contactId, system, externalId, externalUrl ?? null, confidence]);
|
|
18315
|
+
return { ...row, created_at: isoOrNull(row?.created_at) };
|
|
18316
|
+
}
|
|
18317
|
+
async getContactIdentities(contactId) {
|
|
18318
|
+
return (await this.client.many(`SELECT * FROM contact_identities WHERE contact_id = $1 ORDER BY created_at DESC`, [contactId])).map((r) => ({ ...r, created_at: isoOrNull(r.created_at) }));
|
|
18319
|
+
}
|
|
18320
|
+
signalRow(r) {
|
|
18321
|
+
const last = r.last_contacted_at;
|
|
18322
|
+
return {
|
|
18323
|
+
contact_id: r.contact_id,
|
|
18324
|
+
display_name: r.display_name,
|
|
18325
|
+
last_contacted_at: last,
|
|
18326
|
+
interaction_count_30d: Number(r.interaction_count_30d ?? 0),
|
|
18327
|
+
engagement_status: r.engagement_status ?? null,
|
|
18328
|
+
relationship_health: r.relationship_health ?? null,
|
|
18329
|
+
days_since_contact: last ? Math.floor((Date.now() - new Date(last).getTime()) / 86400000) : null
|
|
18330
|
+
};
|
|
18331
|
+
}
|
|
18332
|
+
async getRelationshipSignals(contactId) {
|
|
18333
|
+
const row = await this.client.get(`SELECT id AS contact_id, display_name, last_contacted_at, interaction_count_30d, engagement_status, relationship_health FROM contacts WHERE id = $1`, [contactId]);
|
|
18334
|
+
if (!row)
|
|
18335
|
+
return [];
|
|
18336
|
+
const base = this.signalRow(row);
|
|
18337
|
+
const cnt = base.interaction_count_30d;
|
|
18338
|
+
const health = base.relationship_health ?? 50;
|
|
18339
|
+
const daysSince = base.days_since_contact;
|
|
18340
|
+
let signal_type = "healthy";
|
|
18341
|
+
let reason = `Last contact ${daysSince}d ago, ${cnt} interactions in 30d`;
|
|
18342
|
+
if (daysSince === null || daysSince > 180) {
|
|
18343
|
+
signal_type = "ghost";
|
|
18344
|
+
reason = "No contact in 180+ days or never contacted";
|
|
18345
|
+
} else if (daysSince > 60 && cnt === 0) {
|
|
18346
|
+
signal_type = "cooling";
|
|
18347
|
+
reason = `No contact in ${daysSince} days, no recent interactions`;
|
|
18348
|
+
} else if (cnt > 3 && health > 70) {
|
|
18349
|
+
signal_type = "warming";
|
|
18350
|
+
reason = `${cnt} interactions in last 30 days, health score ${health}`;
|
|
18351
|
+
}
|
|
18352
|
+
return [{ ...base, signal_type, reason }];
|
|
18353
|
+
}
|
|
18354
|
+
async getGhostContacts() {
|
|
18355
|
+
const cutoff = new Date(Date.now() - 180 * 86400000).toISOString();
|
|
18356
|
+
const rows = await this.client.many(`SELECT id AS contact_id, display_name, last_contacted_at, interaction_count_30d, engagement_status, relationship_health FROM contacts WHERE (last_contacted_at IS NULL OR last_contacted_at < $1) AND archived = false ORDER BY last_contacted_at ASC NULLS FIRST LIMIT 50`, [cutoff]);
|
|
18357
|
+
return rows.map((r) => ({ ...this.signalRow(r), signal_type: "ghost", reason: "No contact in 180+ days or never contacted" }));
|
|
18358
|
+
}
|
|
18359
|
+
async getWarmingContacts() {
|
|
18360
|
+
const rows = await this.client.many(`SELECT id AS contact_id, display_name, last_contacted_at, interaction_count_30d, engagement_status, relationship_health FROM contacts WHERE interaction_count_30d > 2 AND relationship_health > 60 AND archived = false ORDER BY relationship_health DESC LIMIT 50`);
|
|
18361
|
+
return rows.map((r) => ({ ...this.signalRow(r), signal_type: "warming", reason: `${Number(r.interaction_count_30d ?? 0)} interactions in last 30 days` }));
|
|
18362
|
+
}
|
|
18363
|
+
async recomputeSignals() {
|
|
18364
|
+
const res = await this.client.query(`UPDATE contacts SET engagement_status = CASE
|
|
18365
|
+
WHEN interaction_count_30d > 3 THEN 'warming'
|
|
18366
|
+
WHEN last_contacted_at IS NULL OR EXTRACT(EPOCH FROM (NOW() - last_contacted_at::timestamptz)) / 86400 > 180 THEN 'ghost'
|
|
18367
|
+
WHEN EXTRACT(EPOCH FROM (NOW() - last_contacted_at::timestamptz)) / 86400 > 60 THEN 'cooling'
|
|
18368
|
+
ELSE 'stable' END,
|
|
18369
|
+
updated_at = NOW() WHERE archived = false`);
|
|
18370
|
+
return { updated: res.rowCount };
|
|
18371
|
+
}
|
|
18372
|
+
async getFreshnessScore(contactId) {
|
|
18373
|
+
const contact = await this.client.get(`SELECT * FROM contacts WHERE id = $1`, [contactId]);
|
|
18374
|
+
if (!contact)
|
|
18375
|
+
throw new Error(`Contact not found: ${contactId}`);
|
|
18376
|
+
const historyRows = await this.client.many(`SELECT field_name, new_value, source, created_at FROM contact_field_history WHERE contact_id = $1 ORDER BY created_at DESC`, [contactId]);
|
|
18377
|
+
const verifiedRows = await this.client.many(`SELECT field_name, last_verified_at, source FROM contact_field_confidence WHERE contact_id = $1 AND confidence = 'verified'`, [contactId]).catch(() => []);
|
|
18378
|
+
const scored = ["display_name", "job_title", "company_id", "emails", "phones", "last_contacted_at"];
|
|
18379
|
+
const verifiedMap = new Map(verifiedRows.map((r) => [r.field_name, r]));
|
|
18380
|
+
const historyMap = new Map;
|
|
18381
|
+
for (const r of historyRows)
|
|
18382
|
+
if (!historyMap.has(r.field_name))
|
|
18383
|
+
historyMap.set(r.field_name, r);
|
|
18384
|
+
const fields = [];
|
|
18385
|
+
for (const field of scored) {
|
|
18386
|
+
let value = null;
|
|
18387
|
+
if (field === "emails") {
|
|
18388
|
+
const e = await this.client.get(`SELECT address FROM emails WHERE contact_id = $1 LIMIT 1`, [contactId]);
|
|
18389
|
+
value = e?.address ?? null;
|
|
18390
|
+
} else if (field === "phones") {
|
|
18391
|
+
const p = await this.client.get(`SELECT number FROM phones WHERE contact_id = $1 LIMIT 1`, [contactId]);
|
|
18392
|
+
value = p?.number ?? null;
|
|
18393
|
+
} else
|
|
18394
|
+
value = contact[field] != null ? String(contact[field]) : null;
|
|
18395
|
+
const verified = verifiedMap.get(field);
|
|
18396
|
+
const history = historyMap.get(field);
|
|
18397
|
+
let confidence = "unknown";
|
|
18398
|
+
let days_old = null;
|
|
18399
|
+
let last_verified_at = null;
|
|
18400
|
+
let source = null;
|
|
18401
|
+
if (verified) {
|
|
18402
|
+
confidence = "verified";
|
|
18403
|
+
last_verified_at = isoOrNull(verified.last_verified_at);
|
|
18404
|
+
source = verified.source;
|
|
18405
|
+
days_old = last_verified_at ? Math.floor((Date.now() - new Date(last_verified_at).getTime()) / 86400000) : null;
|
|
18406
|
+
} else if (history) {
|
|
18407
|
+
confidence = history.source === "import" ? "imported" : "inferred";
|
|
18408
|
+
last_verified_at = isoOrNull(history.created_at);
|
|
18409
|
+
source = history.source;
|
|
18410
|
+
days_old = last_verified_at ? Math.floor((Date.now() - new Date(last_verified_at).getTime()) / 86400000) : null;
|
|
18411
|
+
if (days_old != null && days_old > 365)
|
|
18412
|
+
confidence = "stale";
|
|
18413
|
+
} else if (value)
|
|
18414
|
+
confidence = "inferred";
|
|
18415
|
+
fields.push({ field_name: field, value, last_verified_at, source, confidence, days_old });
|
|
18416
|
+
}
|
|
18417
|
+
const fieldScore = fields.reduce((acc, f) => {
|
|
18418
|
+
if (!f.value)
|
|
18419
|
+
return acc;
|
|
18420
|
+
if (f.confidence === "verified")
|
|
18421
|
+
return acc + 20;
|
|
18422
|
+
if (f.confidence === "imported" || f.confidence === "inferred")
|
|
18423
|
+
return acc + 10;
|
|
18424
|
+
return acc + 5;
|
|
18425
|
+
}, 0);
|
|
18426
|
+
return { contact_id: contactId, overall_score: Math.min(100, fieldScore), fields, stale_fields: fields.filter((f) => f.confidence === "stale" || !f.value && f.field_name !== "phones").map((f) => f.field_name), verified_fields: fields.filter((f) => f.confidence === "verified").map((f) => f.field_name) };
|
|
18427
|
+
}
|
|
18428
|
+
async getStaleContacts(threshold = 40) {
|
|
18429
|
+
return this.client.many(`SELECT * FROM (
|
|
18430
|
+
SELECT c.id AS contact_id, c.display_name,
|
|
18431
|
+
((CASE WHEN c.job_title IS NOT NULL THEN 15 ELSE 0 END) +
|
|
18432
|
+
(CASE WHEN c.company_id IS NOT NULL THEN 15 ELSE 0 END) +
|
|
18433
|
+
(CASE WHEN c.last_contacted_at IS NOT NULL THEN 20 ELSE 0 END) +
|
|
18434
|
+
(CASE WHEN EXISTS(SELECT 1 FROM emails WHERE contact_id = c.id) THEN 20 ELSE 0 END) +
|
|
18435
|
+
(CASE WHEN EXISTS(SELECT 1 FROM phones WHERE contact_id = c.id) THEN 15 ELSE 0 END) +
|
|
18436
|
+
(CASE WHEN c.notes IS NOT NULL THEN 10 ELSE 0 END) +
|
|
18437
|
+
(CASE WHEN EXISTS(SELECT 1 FROM contact_tags WHERE contact_id = c.id) THEN 5 ELSE 0 END)) AS score
|
|
18438
|
+
FROM contacts c WHERE c.archived = false
|
|
18439
|
+
) sub WHERE score < $1 ORDER BY score ASC LIMIT 100`, [threshold]);
|
|
18440
|
+
}
|
|
18441
|
+
async markFieldVerified(contactId, fieldName, source) {
|
|
18442
|
+
await this.client.execute(`INSERT INTO contact_field_confidence (id, contact_id, field_name, confidence, source, last_verified_at) VALUES ($1,$2,$3,'verified',$4,NOW())
|
|
18443
|
+
ON CONFLICT (contact_id, field_name) DO UPDATE SET confidence = 'verified', source = excluded.source, last_verified_at = NOW()`, [newUuid(), contactId, fieldName, source ?? null]);
|
|
18444
|
+
}
|
|
18445
|
+
async computeRelationshipStrength(contactId) {
|
|
18446
|
+
const c = await this.client.get(`SELECT last_contacted_at, interaction_count_30d FROM contacts WHERE id = $1`, [contactId]);
|
|
18447
|
+
if (!c)
|
|
18448
|
+
return 0;
|
|
18449
|
+
let score = 50;
|
|
18450
|
+
if (c.last_contacted_at) {
|
|
18451
|
+
const days = Math.floor((Date.now() - new Date(c.last_contacted_at).getTime()) / 86400000);
|
|
18452
|
+
score += days < 7 ? 30 : days < 30 ? 20 : days < 90 ? 5 : -20;
|
|
18453
|
+
} else
|
|
18454
|
+
score -= 20;
|
|
18455
|
+
score += Math.min(20, (c.interaction_count_30d || 0) * 4);
|
|
18456
|
+
return Math.max(0, Math.min(100, score));
|
|
18457
|
+
}
|
|
18458
|
+
async findWarmPath(fromContactId, toContactId) {
|
|
18459
|
+
const visited = new Set([fromContactId]);
|
|
18460
|
+
const queue = [{ id: fromContactId, path: [] }];
|
|
18461
|
+
while (queue.length) {
|
|
18462
|
+
const { id, path } = queue.shift();
|
|
18463
|
+
if (id === toContactId)
|
|
18464
|
+
return path;
|
|
18465
|
+
if (path.length >= 4)
|
|
18466
|
+
continue;
|
|
18467
|
+
const neighbors = await this.client.many(`SELECT cr.contact_a_id, cr.contact_b_id, cr.strength_score, c.display_name FROM contact_relationships cr JOIN contacts c ON (CASE WHEN cr.contact_a_id = $1 THEN cr.contact_b_id ELSE cr.contact_a_id END) = c.id WHERE cr.contact_a_id = $1 OR cr.contact_b_id = $1 LIMIT 20`, [id]);
|
|
18468
|
+
for (const n of neighbors) {
|
|
18469
|
+
const nextId = n.contact_a_id === id ? n.contact_b_id : n.contact_a_id;
|
|
18470
|
+
if (visited.has(nextId))
|
|
18471
|
+
continue;
|
|
18472
|
+
visited.add(nextId);
|
|
18473
|
+
queue.push({ id: nextId, path: [...path, { contact_id: nextId, display_name: n.display_name, strength: n.strength_score || 50 }] });
|
|
18474
|
+
}
|
|
18475
|
+
}
|
|
18476
|
+
return [];
|
|
18477
|
+
}
|
|
18478
|
+
async findConnectionsAtCompany(companyId) {
|
|
18479
|
+
return this.client.many(`SELECT c.id AS contact_id, c.display_name, c.job_title, c.relationship_health AS strength FROM contacts c WHERE c.company_id = $1 AND c.archived = false ORDER BY c.relationship_health DESC`, [companyId]);
|
|
18480
|
+
}
|
|
18481
|
+
async detectCoolingRelationships() {
|
|
18482
|
+
const cutoff = new Date(Date.now() - 45 * 86400000).toISOString();
|
|
18483
|
+
const rows = await this.client.many(`SELECT id AS contact_id, display_name, last_contacted_at FROM contacts WHERE last_contacted_at IS NOT NULL AND last_contacted_at < $1 AND engagement_status != 'ghost' AND archived = false ORDER BY last_contacted_at ASC LIMIT 50`, [cutoff]);
|
|
18484
|
+
return rows.map((r) => ({ contact_id: r.contact_id, display_name: r.display_name, days_since: Math.floor((Date.now() - new Date(r.last_contacted_at).getTime()) / 86400000) }));
|
|
18485
|
+
}
|
|
18486
|
+
async addOrgChartEdge(companyId, contactAId, contactBId, edgeType, inferred = false) {
|
|
18487
|
+
const id = newUuid();
|
|
18488
|
+
const row = await this.client.get(`INSERT INTO org_chart_edges (id, company_id, contact_a_id, contact_b_id, edge_type, inferred) VALUES ($1,$2,$3,$4,$5,$6)
|
|
18489
|
+
ON CONFLICT (company_id, contact_a_id, contact_b_id, edge_type) DO UPDATE SET inferred = excluded.inferred RETURNING *`, [id, companyId, contactAId, contactBId, edgeType, inferred]);
|
|
18490
|
+
return { ...row, inferred: Boolean(row?.inferred), created_at: isoOrNull(row?.created_at) };
|
|
18491
|
+
}
|
|
18492
|
+
async listOrgChart(companyId) {
|
|
18493
|
+
return this.client.many(`SELECT ca.display_name AS contact_a_name, cb.display_name AS contact_b_name, e.edge_type
|
|
18494
|
+
FROM org_chart_edges e JOIN contacts ca ON e.contact_a_id = ca.id JOIN contacts cb ON e.contact_b_id = cb.id WHERE e.company_id = $1`, [companyId]);
|
|
18495
|
+
}
|
|
18496
|
+
async setDealContactRole(dealId, contactId, accountRole) {
|
|
18497
|
+
const id = newUuid();
|
|
18498
|
+
const row = await this.client.get(`INSERT INTO deal_contact_roles (id, deal_id, contact_id, account_role) VALUES ($1,$2,$3,$4)
|
|
18499
|
+
ON CONFLICT (deal_id, contact_id) DO UPDATE SET account_role = excluded.account_role RETURNING *`, [id, dealId, contactId, accountRole]);
|
|
18500
|
+
return { ...row, created_at: isoOrNull(row?.created_at) };
|
|
18501
|
+
}
|
|
18502
|
+
async getDealTeam(dealId) {
|
|
18503
|
+
return this.client.many(`SELECT c.display_name, r.account_role, c.job_title FROM deal_contact_roles r JOIN contacts c ON r.contact_id = c.id WHERE r.deal_id = $1`, [dealId]);
|
|
18504
|
+
}
|
|
18505
|
+
async getCoverageGaps(companyId) {
|
|
18506
|
+
const team = await this.client.many(`SELECT DISTINCT r.account_role FROM deal_contact_roles r JOIN deals d ON r.deal_id = d.id WHERE d.company_id = $1`, [companyId]);
|
|
18507
|
+
const covered = new Set(team.map((t) => t.account_role));
|
|
18508
|
+
const key = ["economic_buyer", "technical_evaluator", "champion"];
|
|
18509
|
+
return { covered: Array.from(covered), missing_key_roles: key.filter((k) => !covered.has(k)) };
|
|
18510
|
+
}
|
|
18511
|
+
mapAudience(r) {
|
|
18512
|
+
return { id: r.id, audience_id: r.audience_id, name: r.name, match: r.match, predicates: pj(r.predicates, []), consent_policy: r.consent_policy, suppression_synced_at: isoOrNull(r.suppression_synced_at), created_at: isoOrNull(r.created_at), updated_at: isoOrNull(r.updated_at) };
|
|
18513
|
+
}
|
|
18514
|
+
async createAudience(input) {
|
|
18515
|
+
const audienceId = String(input.audience_id ?? "");
|
|
18516
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(audienceId))
|
|
18517
|
+
throw new Error(`audience_id must be a lowercase dashed slug: ${audienceId}`);
|
|
18518
|
+
const predicates = input.predicates ?? [];
|
|
18519
|
+
if (!Array.isArray(predicates) || predicates.length === 0)
|
|
18520
|
+
throw new Error("at least one predicate is required");
|
|
18521
|
+
const dupe = await this.client.get(`SELECT id FROM audiences WHERE audience_id = $1`, [audienceId]);
|
|
18522
|
+
if (dupe)
|
|
18523
|
+
throw new Error(`duplicate audience_id: ${audienceId}`);
|
|
18524
|
+
const id = newUuid();
|
|
18525
|
+
const row = await this.client.get(`INSERT INTO audiences (id, audience_id, name, match, predicates, consent_policy) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [id, audienceId, input.name, input.match ?? "all", JSON.stringify(predicates), input.consent_policy ?? "opt_in"]);
|
|
18526
|
+
const mapped = this.mapAudience(row);
|
|
18527
|
+
return { ...mapped, id: mapped.id, audience_id: mapped.audience_id };
|
|
18528
|
+
}
|
|
18529
|
+
async getAudience(idOrSlug) {
|
|
18530
|
+
const row = await this.client.get(`SELECT * FROM audiences WHERE id = $1 OR audience_id = $1`, [idOrSlug]);
|
|
18531
|
+
if (!row)
|
|
18532
|
+
throw new Error(`audience not found: ${idOrSlug}`);
|
|
18533
|
+
return this.mapAudience(row);
|
|
18534
|
+
}
|
|
18535
|
+
async listAudiences() {
|
|
18536
|
+
return (await this.client.many(`SELECT * FROM audiences ORDER BY audience_id ASC`)).map((r) => this.mapAudience(r));
|
|
18537
|
+
}
|
|
18538
|
+
async updateAudience(idOrSlug, input) {
|
|
18539
|
+
const audience = await this.getAudience(idOrSlug);
|
|
18540
|
+
const sets = [];
|
|
18541
|
+
const params = [audience.id];
|
|
18542
|
+
if ("name" in input) {
|
|
18543
|
+
params.push(input.name);
|
|
18544
|
+
sets.push(`name = $${params.length}`);
|
|
18545
|
+
}
|
|
18546
|
+
if ("match" in input) {
|
|
18547
|
+
params.push(input.match);
|
|
18548
|
+
sets.push(`match = $${params.length}`);
|
|
18549
|
+
}
|
|
18550
|
+
if ("predicates" in input) {
|
|
18551
|
+
params.push(JSON.stringify(input.predicates));
|
|
18552
|
+
sets.push(`predicates = $${params.length}`);
|
|
18553
|
+
}
|
|
18554
|
+
if ("consent_policy" in input) {
|
|
18555
|
+
params.push(input.consent_policy);
|
|
18556
|
+
sets.push(`consent_policy = $${params.length}`);
|
|
18557
|
+
}
|
|
18558
|
+
if (sets.length) {
|
|
18559
|
+
sets.push(`updated_at = NOW()`);
|
|
18560
|
+
await this.client.execute(`UPDATE audiences SET ${sets.join(", ")} WHERE id = $1`, params);
|
|
18561
|
+
}
|
|
18562
|
+
return this.getAudience(String(audience.id));
|
|
18563
|
+
}
|
|
18564
|
+
async deleteAudience(idOrSlug) {
|
|
18565
|
+
const audience = await this.getAudience(idOrSlug);
|
|
18566
|
+
await this.client.execute(`DELETE FROM audiences WHERE id = $1`, [String(audience.id)]);
|
|
18567
|
+
}
|
|
18568
|
+
async setContactConsent(contactId, channel, status, source) {
|
|
18569
|
+
const contact = await this.client.get(`SELECT id FROM contacts WHERE id = $1`, [contactId]);
|
|
18570
|
+
if (!contact)
|
|
18571
|
+
throw new Error(`Contact not found: ${contactId}`);
|
|
18572
|
+
const row = await this.client.get(`INSERT INTO contact_consent (contact_id, channel, status, source, updated_at) VALUES ($1,$2,$3,$4,NOW())
|
|
18573
|
+
ON CONFLICT (contact_id, channel) DO UPDATE SET status = excluded.status, source = excluded.source, updated_at = excluded.updated_at RETURNING *`, [contactId, channel, status, source ?? null]);
|
|
18574
|
+
return { ...row, updated_at: isoOrNull(row?.updated_at) };
|
|
18575
|
+
}
|
|
18576
|
+
async listContactConsent(contactId) {
|
|
18577
|
+
return (await this.client.many(`SELECT * FROM contact_consent WHERE contact_id = $1 ORDER BY channel ASC`, [contactId])).map((r) => ({ ...r, updated_at: isoOrNull(r.updated_at) }));
|
|
18578
|
+
}
|
|
18579
|
+
async suppressAddress(input) {
|
|
18580
|
+
const id = newUuid();
|
|
18581
|
+
const row = await this.client.get(`INSERT INTO contact_suppressions (id, contact_id, channel, address, reason) VALUES ($1,$2,$3,$4,$5)
|
|
18582
|
+
ON CONFLICT (channel, address) DO UPDATE SET reason = excluded.reason, contact_id = COALESCE(excluded.contact_id, contact_suppressions.contact_id), synced_at = NULL RETURNING *`, [id, input.contact_id ?? null, input.channel, input.address, input.reason ?? null]);
|
|
18583
|
+
if (input.contact_id) {
|
|
18584
|
+
const c = await this.client.get(`SELECT id FROM contacts WHERE id = $1`, [input.contact_id]);
|
|
18585
|
+
if (c)
|
|
18586
|
+
await this.setContactConsent(input.contact_id, input.channel, "opt_out", input.reason ?? "suppressed");
|
|
18587
|
+
}
|
|
18588
|
+
return { ...row, created_at: isoOrNull(row?.created_at), synced_at: isoOrNull(row?.synced_at) };
|
|
18589
|
+
}
|
|
18590
|
+
async unsuppressAddress(channel, address) {
|
|
18591
|
+
await this.client.execute(`DELETE FROM contact_suppressions WHERE channel = $1 AND address = $2`, [channel, address]);
|
|
18592
|
+
}
|
|
18593
|
+
async listSuppressions(opts = {}) {
|
|
18594
|
+
const where = [];
|
|
18595
|
+
const params = [];
|
|
18596
|
+
if (opts.channel) {
|
|
18597
|
+
params.push(opts.channel);
|
|
18598
|
+
where.push(`channel = $${params.length}`);
|
|
18599
|
+
}
|
|
18600
|
+
if (opts.unsyncedOnly)
|
|
18601
|
+
where.push(`synced_at IS NULL`);
|
|
18602
|
+
const sql = `SELECT * FROM contact_suppressions ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at ASC`;
|
|
18603
|
+
return (await this.client.many(sql, params)).map((r) => ({ ...r, created_at: isoOrNull(r.created_at), synced_at: isoOrNull(r.synced_at) }));
|
|
18604
|
+
}
|
|
18605
|
+
async resolveAudience(idOrSlug, channel) {
|
|
18606
|
+
const audience = await this.getAudience(idOrSlug);
|
|
18607
|
+
const candidates = await this.client.many(`SELECT * FROM contacts WHERE archived = false`);
|
|
18608
|
+
const predicates = audience.predicates;
|
|
18609
|
+
const norm = (v) => v === null || v === undefined ? null : typeof v === "boolean" ? v ? "true" : "false" : String(v);
|
|
18610
|
+
const compare2 = (actual, p) => {
|
|
18611
|
+
const op = p.op ?? "eq";
|
|
18612
|
+
const a = norm(actual);
|
|
18613
|
+
switch (op) {
|
|
18614
|
+
case "exists":
|
|
18615
|
+
return a !== null && a !== "";
|
|
18616
|
+
case "not_exists":
|
|
18617
|
+
return a === null || a === "";
|
|
18618
|
+
case "eq":
|
|
18619
|
+
return a !== null && a === norm(p.value);
|
|
18620
|
+
case "neq":
|
|
18621
|
+
return a === null || a !== norm(p.value);
|
|
18622
|
+
case "in":
|
|
18623
|
+
return a !== null && (p.values ?? []).some((v) => norm(v) === a);
|
|
18624
|
+
case "not_in":
|
|
18625
|
+
return a === null || !(p.values ?? []).some((v) => norm(v) === a);
|
|
18626
|
+
default:
|
|
18627
|
+
return false;
|
|
18628
|
+
}
|
|
18629
|
+
};
|
|
18630
|
+
const membership = (names, p) => {
|
|
18631
|
+
const op = p.op ?? "eq";
|
|
18632
|
+
const set = new Set(names.map((n) => n.toLowerCase()));
|
|
18633
|
+
const has = (v) => {
|
|
18634
|
+
const n = norm(v);
|
|
18635
|
+
return n !== null && set.has(n.toLowerCase());
|
|
18636
|
+
};
|
|
18637
|
+
switch (op) {
|
|
18638
|
+
case "exists":
|
|
18639
|
+
return set.size > 0;
|
|
18640
|
+
case "not_exists":
|
|
18641
|
+
return set.size === 0;
|
|
18642
|
+
case "eq":
|
|
18643
|
+
return has(p.value);
|
|
18644
|
+
case "neq":
|
|
18645
|
+
return !has(p.value);
|
|
18646
|
+
case "in":
|
|
18647
|
+
return (p.values ?? []).some(has);
|
|
18648
|
+
case "not_in":
|
|
18649
|
+
return !(p.values ?? []).some(has);
|
|
18650
|
+
default:
|
|
18651
|
+
return false;
|
|
18652
|
+
}
|
|
18653
|
+
};
|
|
18654
|
+
const matched = [];
|
|
18655
|
+
for (const row of candidates) {
|
|
18656
|
+
const results = [];
|
|
18657
|
+
for (const p of predicates) {
|
|
18658
|
+
if (p.kind === "tag") {
|
|
18659
|
+
const names = (await this.client.many(`SELECT t.name FROM tags t JOIN contact_tags ct ON ct.tag_id = t.id WHERE ct.contact_id = $1`, [row.id])).map((r) => r.name);
|
|
18660
|
+
results.push(membership(names, p));
|
|
18661
|
+
} else if (p.kind === "group") {
|
|
18662
|
+
const rows = await this.client.many(`SELECT g.name, g.id FROM groups g JOIN contact_groups cg ON cg.group_id = g.id WHERE cg.contact_id = $1`, [row.id]);
|
|
18663
|
+
results.push(membership(rows.flatMap((r) => [r.name, r.id]), p));
|
|
18664
|
+
} else if (p.kind === "attribute") {
|
|
18665
|
+
const key = p.key ?? "";
|
|
18666
|
+
const val = key in row && key !== "custom_fields" ? row[key] : pj(row.custom_fields, {})[key];
|
|
18667
|
+
results.push(compare2(val, p));
|
|
18668
|
+
} else
|
|
18669
|
+
results.push(false);
|
|
18670
|
+
}
|
|
18671
|
+
if (audience.match === "any" ? results.some(Boolean) : results.every(Boolean))
|
|
18672
|
+
matched.push(row);
|
|
18673
|
+
}
|
|
18674
|
+
const suppressed = new Set((await this.client.many(`SELECT address FROM contact_suppressions WHERE channel = $1`, [channel])).map((r) => r.address.toLowerCase()));
|
|
18675
|
+
const recipients = [];
|
|
18676
|
+
const excluded = [];
|
|
18677
|
+
const consentAllows2 = (policy, status) => policy === "opt_in" ? status === "opt_in" : policy === "none" ? true : status !== "opt_out";
|
|
18678
|
+
for (const row of matched) {
|
|
18679
|
+
const cid = row.id;
|
|
18680
|
+
if (row.do_not_contact) {
|
|
18681
|
+
excluded.push({ contact_id: cid, reason: "do_not_contact" });
|
|
18682
|
+
continue;
|
|
18683
|
+
}
|
|
18684
|
+
let address = null;
|
|
18685
|
+
if (channel === "email") {
|
|
18686
|
+
const e = await this.client.get(`SELECT address FROM emails WHERE contact_id = $1 ORDER BY is_primary DESC, created_at ASC LIMIT 1`, [cid]);
|
|
18687
|
+
address = e?.address ?? null;
|
|
18688
|
+
} else if (channel === "sms") {
|
|
18689
|
+
const p = await this.client.get(`SELECT number FROM phones WHERE contact_id = $1 ORDER BY is_primary DESC, created_at ASC LIMIT 1`, [cid]);
|
|
18690
|
+
address = p?.number ?? null;
|
|
18691
|
+
} else {
|
|
18692
|
+
const s = await this.client.get(`SELECT handle, url FROM social_profiles WHERE contact_id = $1 AND platform = 'telegram' ORDER BY is_primary DESC, created_at ASC LIMIT 1`, [cid]);
|
|
18693
|
+
address = s?.handle ?? s?.url ?? null;
|
|
18694
|
+
}
|
|
18695
|
+
if (!address) {
|
|
18696
|
+
excluded.push({ contact_id: cid, reason: "no_address" });
|
|
18697
|
+
continue;
|
|
18698
|
+
}
|
|
18699
|
+
if (suppressed.has(address.toLowerCase())) {
|
|
18700
|
+
excluded.push({ contact_id: cid, reason: "suppressed" });
|
|
18701
|
+
continue;
|
|
18702
|
+
}
|
|
18703
|
+
const consent = await this.client.get(`SELECT status FROM contact_consent WHERE contact_id = $1 AND channel = $2`, [cid, channel]);
|
|
18704
|
+
const status = consent?.status ?? "unknown";
|
|
18705
|
+
if (!consentAllows2(String(audience.consent_policy), status)) {
|
|
18706
|
+
excluded.push({ contact_id: cid, reason: "consent" });
|
|
18707
|
+
continue;
|
|
18708
|
+
}
|
|
18709
|
+
recipients.push({ contact_id: cid, display_name: row.display_name, address, consent_status: status });
|
|
18710
|
+
}
|
|
18711
|
+
return { audience_id: audience.audience_id, channel, consent_policy: audience.consent_policy, matched: matched.length, recipients, excluded };
|
|
18712
|
+
}
|
|
18713
|
+
async getUpcomingItems(days = 7) {
|
|
18714
|
+
const now4 = new Date;
|
|
18715
|
+
const future = new Date(now4.getTime() + days * 86400000);
|
|
18716
|
+
const todayStr = now4.toISOString().slice(0, 10);
|
|
18717
|
+
const futureStr = future.toISOString().slice(0, 10);
|
|
18718
|
+
const urgency = (d) => d < todayStr ? "overdue" : d === todayStr ? "today" : "upcoming";
|
|
18719
|
+
const items = [];
|
|
18720
|
+
for (const r of await this.client.many(`SELECT id, display_name, follow_up_at FROM contacts WHERE follow_up_at IS NOT NULL AND follow_up_at <= $1 AND do_not_contact = false`, [futureStr]))
|
|
18721
|
+
items.push({ date: r.follow_up_at, type: "follow_up", contact_id: r.id, contact_name: r.display_name, title: `Follow up with ${r.display_name}`, urgency: urgency(r.follow_up_at) });
|
|
18722
|
+
for (const t of await this.client.many(`SELECT ct.id, ct.contact_id, ct.title, ct.deadline, c.display_name FROM contact_tasks ct JOIN contacts c ON ct.contact_id = c.id WHERE ct.deadline IS NOT NULL AND ct.deadline <= $1 AND ct.status NOT IN ('completed','cancelled')`, [futureStr]))
|
|
18723
|
+
items.push({ date: t.deadline, type: "task_deadline", contact_id: t.contact_id, contact_name: t.display_name, title: t.title, urgency: urgency(t.deadline) });
|
|
18724
|
+
for (const a of await this.client.many(`SELECT a.follow_up_date, a.program_name, c.display_name AS contact_name FROM applications a LEFT JOIN contacts c ON a.primary_contact_id = c.id WHERE a.follow_up_date IS NOT NULL AND a.follow_up_date <= $1`, [futureStr]))
|
|
18725
|
+
items.push({ date: a.follow_up_date, type: "application_followup", contact_name: a.contact_name ?? undefined, title: `Follow up: ${a.program_name}`, urgency: urgency(a.follow_up_date) });
|
|
18726
|
+
for (const v of await this.client.many(`SELECT vc.follow_up_date, vc.company_id, co.name AS company_name, vc.subject, vc.type FROM vendor_communications vc JOIN companies co ON vc.company_id = co.id WHERE vc.follow_up_date IS NOT NULL AND vc.follow_up_date <= $1 AND vc.follow_up_done = false`, [futureStr]))
|
|
18727
|
+
items.push({ date: v.follow_up_date, type: "vendor_followup", company_id: v.company_id, company_name: v.company_name, title: `Follow up with ${v.company_name}: ${v.subject || v.type}`, urgency: urgency(v.follow_up_date) });
|
|
18728
|
+
for (const c of await this.client.many(`SELECT id, display_name, birthday FROM contacts WHERE birthday IS NOT NULL AND do_not_contact = false`)) {
|
|
18729
|
+
const bday = new Date(c.birthday);
|
|
18730
|
+
const thisYear = new Date(now4.getFullYear(), bday.getMonth(), bday.getDate());
|
|
18731
|
+
const nextBday = thisYear >= now4 ? thisYear : new Date(now4.getFullYear() + 1, bday.getMonth(), bday.getDate());
|
|
18732
|
+
const nextStr = nextBday.toISOString().slice(0, 10);
|
|
18733
|
+
if (nextStr <= futureStr)
|
|
18734
|
+
items.push({ date: nextStr, type: "birthday", contact_id: c.id, contact_name: c.display_name, title: `Birthday: ${c.display_name}`, urgency: nextStr === todayStr ? "today" : "upcoming" });
|
|
18735
|
+
}
|
|
18736
|
+
return items.sort((a, b) => String(a.date).localeCompare(String(b.date)));
|
|
18737
|
+
}
|
|
18738
|
+
async listContactAudit() {
|
|
18739
|
+
const rows = await this.client.many(`SELECT * FROM contacts LIMIT 500`);
|
|
18740
|
+
const results = await Promise.all(rows.map(async (row) => {
|
|
18741
|
+
const details = await this.loadDetails(mapContact(row));
|
|
18742
|
+
const c = details;
|
|
18743
|
+
const missing = [];
|
|
18744
|
+
const suggestions = [];
|
|
18745
|
+
let score = 0;
|
|
18746
|
+
if (c.emails?.length)
|
|
18747
|
+
score += 20;
|
|
18748
|
+
else {
|
|
18749
|
+
missing.push("email");
|
|
18750
|
+
suggestions.push("Add an email address");
|
|
18751
|
+
}
|
|
18752
|
+
if (c.phones?.length)
|
|
18753
|
+
score += 15;
|
|
18754
|
+
else {
|
|
18755
|
+
missing.push("phone");
|
|
18756
|
+
suggestions.push("Add a phone number");
|
|
18757
|
+
}
|
|
18758
|
+
if (c.company_id)
|
|
18759
|
+
score += 15;
|
|
18760
|
+
else {
|
|
18761
|
+
missing.push("company");
|
|
18762
|
+
suggestions.push("Link to a company");
|
|
18763
|
+
}
|
|
18764
|
+
if (c.last_contacted_at)
|
|
18765
|
+
score += 20;
|
|
18766
|
+
else {
|
|
18767
|
+
missing.push("last_contacted_at");
|
|
18768
|
+
suggestions.push("Log a contact interaction");
|
|
18769
|
+
}
|
|
18770
|
+
if (c.tags?.length)
|
|
18771
|
+
score += 10;
|
|
18772
|
+
else {
|
|
18773
|
+
missing.push("tags");
|
|
18774
|
+
suggestions.push("Add at least one tag");
|
|
18775
|
+
}
|
|
18776
|
+
if (c.notes)
|
|
18777
|
+
score += 10;
|
|
18778
|
+
else {
|
|
18779
|
+
missing.push("notes");
|
|
18780
|
+
suggestions.push("Add notes");
|
|
18781
|
+
}
|
|
18782
|
+
if (c.job_title)
|
|
18783
|
+
score += 10;
|
|
18784
|
+
else {
|
|
18785
|
+
missing.push("job_title");
|
|
18786
|
+
suggestions.push("Add a job title");
|
|
18787
|
+
}
|
|
18788
|
+
return { contact_id: c.id, display_name: c.display_name, score, missing, suggestions };
|
|
18789
|
+
}));
|
|
18790
|
+
return results.sort((a, b) => a.score - b.score);
|
|
18791
|
+
}
|
|
18792
|
+
async getContactTimeline(contactId, limit = 50) {
|
|
18793
|
+
const items = [];
|
|
18794
|
+
for (const n of await this.client.many(`SELECT created_at, body FROM contact_notes WHERE contact_id = $1 ORDER BY created_at DESC LIMIT 50`, [contactId]))
|
|
18795
|
+
items.push({ date: iso(n.created_at), type: "note", title: "Note", body: n.body });
|
|
18796
|
+
for (const e of await this.client.many(`SELECT event_date, type, title, notes, outcome, duration_min FROM events WHERE contact_ids LIKE $1 ORDER BY event_date DESC LIMIT 50`, [`%${contactId}%`]))
|
|
18797
|
+
items.push({ date: e.event_date, type: "event", title: `${e.type}: ${e.title}`, body: e.notes ?? undefined, metadata: { outcome: e.outcome, duration_min: e.duration_min } });
|
|
18798
|
+
for (const t of await this.client.many(`SELECT title, created_at, updated_at, status, deadline, priority FROM contact_tasks WHERE contact_id = $1 ORDER BY created_at DESC LIMIT 30`, [contactId])) {
|
|
18799
|
+
items.push({ date: iso(t.created_at), type: "task_created", title: `Task created: ${t.title}`, metadata: { deadline: t.deadline, priority: t.priority } });
|
|
18800
|
+
if (t.status === "completed")
|
|
18801
|
+
items.push({ date: iso(t.updated_at), type: "task_completed", title: `Task completed: ${t.title}` });
|
|
18802
|
+
}
|
|
18803
|
+
for (const c of await this.client.many(`SELECT vc.comm_date, vc.type, co.name AS company_name, vc.subject FROM vendor_communications vc JOIN companies co ON vc.company_id = co.id WHERE vc.contact_id = $1 ORDER BY vc.comm_date DESC LIMIT 20`, [contactId]))
|
|
18804
|
+
items.push({ date: c.comm_date, type: "vendor_comm", title: `${c.type} \u2014 ${c.company_name}`, body: c.subject ?? undefined });
|
|
18805
|
+
for (const a of await this.client.many(`SELECT created_at, action, details FROM activity_log WHERE contact_id = $1 ORDER BY created_at DESC LIMIT 30`, [contactId]))
|
|
18806
|
+
items.push({ date: iso(a.created_at), type: "interaction", title: a.action, body: a.details ?? undefined });
|
|
18807
|
+
return items.sort((a, b) => b.date.localeCompare(a.date)).slice(0, limit);
|
|
18808
|
+
}
|
|
18809
|
+
async getNetworkStats() {
|
|
18810
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
18811
|
+
const d30 = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10);
|
|
18812
|
+
const d60 = new Date(Date.now() - 60 * 86400000).toISOString().slice(0, 10);
|
|
18813
|
+
const d7 = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
|
|
18814
|
+
const n = async (sql, params = []) => Number((await this.client.get(sql, params))?.c ?? 0);
|
|
18815
|
+
return {
|
|
18816
|
+
total_contacts: await n(`SELECT COUNT(*) c FROM contacts WHERE archived = false`),
|
|
18817
|
+
total_companies: await n(`SELECT COUNT(*) c FROM companies WHERE archived = false`),
|
|
18818
|
+
owned_entities: await n(`SELECT COUNT(*) c FROM companies WHERE is_owned_entity = true`),
|
|
18819
|
+
total_tags: await n(`SELECT COUNT(*) c FROM tags`),
|
|
18820
|
+
total_groups: await n(`SELECT COUNT(*) c FROM groups`),
|
|
18821
|
+
total_deals: await n(`SELECT COUNT(*) c FROM deals WHERE stage NOT IN ('won','lost','cancelled')`),
|
|
18822
|
+
total_events: await n(`SELECT COUNT(*) c FROM events`),
|
|
18823
|
+
cold_30d: await n(`SELECT COUNT(*) c FROM contacts WHERE archived = false AND do_not_contact = false AND (last_contacted_at IS NULL OR last_contacted_at < $1)`, [d30]),
|
|
18824
|
+
cold_60d: await n(`SELECT COUNT(*) c FROM contacts WHERE archived = false AND do_not_contact = false AND (last_contacted_at IS NULL OR last_contacted_at < $1)`, [d60]),
|
|
18825
|
+
cold_never: await n(`SELECT COUNT(*) c FROM contacts WHERE archived = false AND do_not_contact = false AND last_contacted_at IS NULL`),
|
|
18826
|
+
contacts_with_email: await n(`SELECT COUNT(DISTINCT contact_id) c FROM emails WHERE contact_id IS NOT NULL`),
|
|
18827
|
+
contacts_with_phone: await n(`SELECT COUNT(DISTINCT contact_id) c FROM phones WHERE contact_id IS NOT NULL`),
|
|
18828
|
+
contacts_no_company: await n(`SELECT COUNT(*) c FROM contacts WHERE archived = false AND company_id IS NULL`),
|
|
18829
|
+
overdue_tasks: await n(`SELECT COUNT(*) c FROM contact_tasks WHERE deadline < $1 AND status NOT IN ('completed','cancelled')`, [today]),
|
|
18830
|
+
pending_applications: await n(`SELECT COUNT(*) c FROM applications WHERE status IN ('submitted','pending','follow_up_needed')`),
|
|
18831
|
+
missing_invoices: await n(`SELECT COUNT(*) c FROM vendor_communications WHERE type = 'invoice_request' AND status IN ('awaiting_response','no_response')`),
|
|
18832
|
+
upcoming_7d: await n(`SELECT COUNT(*) c FROM contacts WHERE follow_up_at BETWEEN $1 AND $2`, [today, d7]),
|
|
18833
|
+
notes_count: await n(`SELECT COUNT(*) c FROM contact_notes`),
|
|
18834
|
+
active_deals_value: await n(`SELECT COALESCE(SUM(value_usd),0) c FROM deals WHERE stage NOT IN ('won','lost','cancelled') AND currency = 'USD'`)
|
|
18835
|
+
};
|
|
18836
|
+
}
|
|
18837
|
+
async getContactCard(contactId) {
|
|
18838
|
+
const contact = await this.getContact(contactId);
|
|
18839
|
+
if (!contact)
|
|
18840
|
+
throw new Error(`Contact not found: ${contactId}`);
|
|
18841
|
+
const details = await this.loadDetails(contact);
|
|
18842
|
+
return {
|
|
18843
|
+
id: details.id,
|
|
18844
|
+
display_name: details.display_name,
|
|
18845
|
+
job_title: details.job_title,
|
|
18846
|
+
company: details.company?.name,
|
|
18847
|
+
primary_email: details.emails?.find((e) => e.is_primary)?.address || details.emails?.[0]?.address,
|
|
18848
|
+
primary_phone: details.phones?.find((p) => p.is_primary)?.number || details.phones?.[0]?.number
|
|
18849
|
+
};
|
|
18850
|
+
}
|
|
18851
|
+
async getContactBrief(contactId, taskContext) {
|
|
18852
|
+
const contact = await this.getContact(contactId);
|
|
18853
|
+
if (!contact)
|
|
18854
|
+
throw new Error(`Contact not found: ${contactId}`);
|
|
18855
|
+
const details = await this.loadDetails(contact);
|
|
18856
|
+
const notes = (await this.listNotes(contactId)).slice(0, 3);
|
|
18857
|
+
const learnings = (await this.getLearnings(contactId, { min_importance: 7 })).slice(0, 5);
|
|
18858
|
+
const ctx = (taskContext ?? "").toLowerCase();
|
|
18859
|
+
const last = contact.last_contacted_at;
|
|
18860
|
+
const daysSince = last ? Math.floor((Date.now() - new Date(last).getTime()) / 86400000) : null;
|
|
18861
|
+
const brief = {
|
|
18862
|
+
id: contact.id,
|
|
18863
|
+
display_name: contact.display_name,
|
|
18864
|
+
job_title: contact.job_title,
|
|
18865
|
+
company: details.company?.name,
|
|
18866
|
+
status: contact.status,
|
|
18867
|
+
last_contacted: daysSince !== null ? `${daysSince}d ago` : "never",
|
|
18868
|
+
relationship_health: contact.relationship_health,
|
|
18869
|
+
engagement_status: contact.engagement_status,
|
|
18870
|
+
preferred_contact: contact.preferred_contact_method
|
|
18871
|
+
};
|
|
18872
|
+
if (ctx.includes("meeting") || ctx.includes("call") || ctx.includes("prep")) {
|
|
18873
|
+
brief.recent_notes = notes.map((nt) => ({ date: String(nt.created_at ?? "").slice(0, 10), content: nt.body }));
|
|
18874
|
+
brief.key_learnings = learnings.map((l) => l.content);
|
|
18875
|
+
}
|
|
18876
|
+
if (ctx.includes("outreach") || ctx.includes("email")) {
|
|
18877
|
+
brief.preferred_channel = contact.preferred_channel;
|
|
18878
|
+
brief.follow_up_at = contact.follow_up_at;
|
|
18879
|
+
}
|
|
18880
|
+
if (ctx.includes("deal"))
|
|
18881
|
+
brief.company_details = details.company ? { name: details.company.name, domain: details.company.domain } : null;
|
|
18882
|
+
if (learnings.length)
|
|
18883
|
+
brief.top_learnings = learnings.map((l) => l.content);
|
|
18884
|
+
return brief;
|
|
18885
|
+
}
|
|
18886
|
+
async assembleContext(contactIds, format) {
|
|
18887
|
+
const briefs = await Promise.all(contactIds.map(async (id) => {
|
|
18888
|
+
try {
|
|
18889
|
+
return await this.getContactBrief(id, format);
|
|
18890
|
+
} catch {
|
|
18891
|
+
return { id, error: "not found" };
|
|
18892
|
+
}
|
|
18893
|
+
}));
|
|
18894
|
+
return { format, contact_count: contactIds.length, assembled_at: new Date().toISOString(), contacts: briefs };
|
|
18895
|
+
}
|
|
18896
|
+
async generateBrief(contactId) {
|
|
18897
|
+
const contact = await this.getContact(contactId);
|
|
18898
|
+
if (!contact)
|
|
18899
|
+
throw new Error(`Contact not found: ${contactId}`);
|
|
18900
|
+
const details = await this.loadDetails(contact);
|
|
18901
|
+
const notes = await this.listNotes(contactId);
|
|
18902
|
+
const allTasks = await this.listContactTasks({ contact_id: contactId });
|
|
18903
|
+
const tasks = allTasks.filter((t) => !["completed", "cancelled"].includes(String(t.status)));
|
|
18904
|
+
const nowIsoStr = new Date().toISOString();
|
|
18905
|
+
const overdueTasks = allTasks.filter((t) => t.deadline && String(t.deadline) < nowIsoStr && !["completed", "cancelled"].includes(String(t.status)));
|
|
18906
|
+
const companyRels = await this.listCompanyRelationships({ contact_id: contactId });
|
|
18907
|
+
const recentTimeline = await this.getContactTimeline(contactId, 5);
|
|
18908
|
+
const last = contact.last_contacted_at;
|
|
18909
|
+
const daysSince = last ? Math.floor((Date.now() - new Date(last).getTime()) / 86400000) : null;
|
|
18910
|
+
const lines = [];
|
|
18911
|
+
lines.push(`# ${contact.display_name}`);
|
|
18912
|
+
if (contact.job_title)
|
|
18913
|
+
lines.push(`**Role:** ${contact.job_title}${contact.company_id ? ` (linked to company)` : ""}`);
|
|
18914
|
+
const emails = details.emails ?? [];
|
|
18915
|
+
const phones = details.phones ?? [];
|
|
18916
|
+
const pe = emails.find((e) => e.is_primary) || emails[0];
|
|
18917
|
+
if (pe)
|
|
18918
|
+
lines.push(`**Email:** ${pe.address}`);
|
|
18919
|
+
const pp = phones.find((p) => p.is_primary) || phones[0];
|
|
18920
|
+
if (pp)
|
|
18921
|
+
lines.push(`**Phone:** ${pp.number}`);
|
|
18922
|
+
if (contact.preferred_contact_method)
|
|
18923
|
+
lines.push(`**Preferred contact:** ${contact.preferred_contact_method}`);
|
|
18924
|
+
lines.push("");
|
|
18925
|
+
lines.push(`## Status`);
|
|
18926
|
+
lines.push(`- Last contacted: ${daysSince !== null ? `${daysSince} days ago` : "never"}`);
|
|
18927
|
+
lines.push(`- Status: ${contact.status || "active"}`);
|
|
18928
|
+
if (contact.follow_up_at)
|
|
18929
|
+
lines.push(`- Follow-up scheduled: ${contact.follow_up_at}`);
|
|
18930
|
+
if (overdueTasks.length)
|
|
18931
|
+
lines.push(`- OVERDUE TASKS: ${overdueTasks.length}`);
|
|
18932
|
+
if (companyRels.length) {
|
|
18933
|
+
lines.push("");
|
|
18934
|
+
lines.push(`## Entity Relationships`);
|
|
18935
|
+
for (const r of companyRels)
|
|
18936
|
+
lines.push(`- ${r.relationship_type} \u2014 ${r.notes || ""}`);
|
|
18937
|
+
}
|
|
18938
|
+
if (tasks.length) {
|
|
18939
|
+
lines.push("");
|
|
18940
|
+
lines.push(`## Open Tasks`);
|
|
18941
|
+
for (const t of tasks)
|
|
18942
|
+
lines.push(`- [${t.priority}] ${t.title}${t.deadline ? ` (due ${t.deadline})` : ""}`);
|
|
18943
|
+
}
|
|
18944
|
+
if (notes.length) {
|
|
18945
|
+
lines.push("");
|
|
18946
|
+
lines.push(`## Recent Notes`);
|
|
18947
|
+
for (const nt of notes.slice(0, 3))
|
|
18948
|
+
lines.push(`**${String(nt.created_at ?? "").slice(0, 10)}:** ${nt.body}`);
|
|
18949
|
+
}
|
|
18950
|
+
if (recentTimeline.length) {
|
|
18951
|
+
lines.push("");
|
|
18952
|
+
lines.push(`## Recent Activity`);
|
|
18953
|
+
for (const item of recentTimeline)
|
|
18954
|
+
lines.push(`- ${item.date.slice(0, 10)} ${item.title}`);
|
|
18955
|
+
}
|
|
18956
|
+
if (contact.notes) {
|
|
18957
|
+
lines.push("");
|
|
18958
|
+
lines.push(`## Background Notes`);
|
|
18959
|
+
lines.push(String(contact.notes));
|
|
18960
|
+
}
|
|
18961
|
+
return lines.join(`
|
|
18962
|
+
`);
|
|
18963
|
+
}
|
|
18964
|
+
async vaultStatus() {
|
|
18965
|
+
let document_count = 0;
|
|
18966
|
+
try {
|
|
18967
|
+
document_count = Number((await this.client.get(`SELECT COUNT(*) n FROM contact_documents`))?.n ?? 0);
|
|
18968
|
+
} catch {}
|
|
18969
|
+
return { initialized: false, unlocked: false, document_count };
|
|
18970
|
+
}
|
|
18971
|
+
}
|
|
18972
|
+
function getContactsPgStore(client) {
|
|
18973
|
+
if (!cachedStore2)
|
|
18974
|
+
cachedStore2 = new ContactsPgStore(client);
|
|
18975
|
+
return cachedStore2;
|
|
18976
|
+
}
|
|
18977
|
+
var cachedStore2 = null;
|
|
18978
|
+
|
|
18979
|
+
// src/server/v1.ts
|
|
18980
|
+
function json5(body, status = 200) {
|
|
18981
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
|
18982
|
+
}
|
|
18983
|
+
function error(status, message, extra) {
|
|
18984
|
+
return json5({ error: message, ...extra ?? {} }, status);
|
|
18985
|
+
}
|
|
18986
|
+
async function readJson(req) {
|
|
18987
|
+
try {
|
|
18988
|
+
const text = await req.text();
|
|
18989
|
+
if (!text)
|
|
18990
|
+
return {};
|
|
18991
|
+
return JSON.parse(text);
|
|
18992
|
+
} catch {
|
|
18993
|
+
return null;
|
|
18994
|
+
}
|
|
18995
|
+
}
|
|
18996
|
+
async function handleV1Request(req, url) {
|
|
18997
|
+
const path = url.pathname;
|
|
18998
|
+
if (path !== "/v1" && !path.startsWith("/v1/"))
|
|
18999
|
+
return null;
|
|
19000
|
+
const method = req.method.toUpperCase();
|
|
19001
|
+
const isWrite = method !== "GET" && method !== "HEAD";
|
|
19002
|
+
const requiredScopes = [isWrite ? `${CONTACTS_APP_SLUG}:write` : `${CONTACTS_APP_SLUG}:read`];
|
|
19003
|
+
let verifier;
|
|
19004
|
+
try {
|
|
19005
|
+
verifier = getCloudVerifier();
|
|
19006
|
+
} catch (e) {
|
|
19007
|
+
return error(503, e.message);
|
|
19008
|
+
}
|
|
19009
|
+
const decision = await verifier.authenticate(req.headers, { method, path, requiredScopes });
|
|
19010
|
+
if (!decision.ok) {
|
|
19011
|
+
return error(decision.status, decision.message, { reason: decision.reason });
|
|
19012
|
+
}
|
|
19013
|
+
await ensureCloudSchemaBestEffort();
|
|
19014
|
+
const store = getContactsPgStore(getCloudClient());
|
|
19015
|
+
const segments = path.split("/").filter(Boolean);
|
|
19016
|
+
const resource = segments[1];
|
|
19017
|
+
const id = segments[2];
|
|
19018
|
+
const sub = segments[3];
|
|
19019
|
+
const qp = (name) => url.searchParams.get(name) ?? undefined;
|
|
19020
|
+
const qn = (name) => {
|
|
19021
|
+
const v = url.searchParams.get(name);
|
|
19022
|
+
return v === null ? undefined : Number(v);
|
|
19023
|
+
};
|
|
19024
|
+
try {
|
|
19025
|
+
if (resource === "contacts" && id && sub) {
|
|
19026
|
+
if (method === "GET" && sub === "timeline")
|
|
19027
|
+
return json5({ timeline: await store.getContactTimeline(id, qn("limit") ?? 50) });
|
|
19028
|
+
if (method === "GET" && sub === "brief")
|
|
19029
|
+
return json5({ brief: await store.getContactBrief(id, qp("context")) });
|
|
19030
|
+
if (method === "GET" && sub === "brief-text")
|
|
19031
|
+
return json5({ text: await store.generateBrief(id) });
|
|
19032
|
+
if (method === "GET" && sub === "card")
|
|
19033
|
+
return json5({ card: await store.getContactCard(id) });
|
|
19034
|
+
if (method === "GET" && sub === "freshness")
|
|
19035
|
+
return json5({ freshness: await store.getFreshnessScore(id) });
|
|
19036
|
+
if (method === "GET" && sub === "signals")
|
|
19037
|
+
return json5({ signals: await store.getRelationshipSignals(id) });
|
|
19038
|
+
if (method === "GET" && sub === "notes")
|
|
19039
|
+
return json5({ notes: qp("company_id") ? await store.listNotesForContactAtCompany(id, qp("company_id")) : await store.listNotes(id) });
|
|
19040
|
+
if (method === "GET" && sub === "consent")
|
|
19041
|
+
return json5({ consent: await store.listContactConsent(id) });
|
|
19042
|
+
if (method === "GET" && sub === "identities")
|
|
19043
|
+
return json5({ identities: await store.getContactIdentities(id) });
|
|
19044
|
+
if (method === "GET" && sub === "groups")
|
|
19045
|
+
return json5({ groups: await store.listGroupsForContact(id) });
|
|
19046
|
+
if (method === "GET" && sub === "org-memberships")
|
|
19047
|
+
return json5({ org_members: await store.listOrgMembersForContact(id) });
|
|
19048
|
+
if (method === "GET" && sub === "relationships")
|
|
19049
|
+
return json5({ relationships: await store.listRelationships({ contact_id: id }) });
|
|
19050
|
+
if (method === "GET" && sub === "company-relationships")
|
|
19051
|
+
return json5({ relationships: await store.listCompanyRelationships({ contact_id: id }) });
|
|
19052
|
+
if (method === "GET" && sub === "field-history")
|
|
19053
|
+
return json5({ history: await store.getFieldHistory(id, qp("field_name")) });
|
|
19054
|
+
if (method === "GET" && sub === "field-at")
|
|
19055
|
+
return json5({ fields: await store.getContactAt(id, qp("timestamp") ?? new Date().toISOString()) });
|
|
19056
|
+
if (sub === "job-history") {
|
|
19057
|
+
if (method === "GET")
|
|
19058
|
+
return json5({ job_history: await store.getJobHistory(id) });
|
|
19059
|
+
if (method === "POST") {
|
|
19060
|
+
const body = await readJson(req);
|
|
19061
|
+
return json5({ job: await store.addJobEntry(id, body ?? {}) }, 201);
|
|
19062
|
+
}
|
|
19063
|
+
}
|
|
19064
|
+
if (sub === "learnings") {
|
|
19065
|
+
if (method === "GET")
|
|
19066
|
+
return json5({ learnings: await store.getLearnings(id, { type: qp("type"), min_importance: qn("min_importance"), visibility: qp("visibility") }) });
|
|
19067
|
+
if (method === "POST") {
|
|
19068
|
+
const body = await readJson(req);
|
|
19069
|
+
return json5({ learning: await store.saveLearning(id, body ?? {}) }, 201);
|
|
19070
|
+
}
|
|
19071
|
+
}
|
|
19072
|
+
if (sub === "consent" && method === "POST") {
|
|
19073
|
+
const body = await readJson(req);
|
|
19074
|
+
if (!body)
|
|
19075
|
+
return error(400, "invalid JSON body");
|
|
19076
|
+
return json5({ consent: await store.setContactConsent(id, body.channel, body.status, body.source) });
|
|
19077
|
+
}
|
|
19078
|
+
if (sub === "field-verify" && method === "POST") {
|
|
19079
|
+
const body = await readJson(req);
|
|
19080
|
+
if (!body)
|
|
19081
|
+
return error(400, "invalid JSON body");
|
|
19082
|
+
await store.markFieldVerified(id, body.field_name, body.source);
|
|
19083
|
+
return json5({ ok: true });
|
|
19084
|
+
}
|
|
19085
|
+
return error(404, `unknown /v1/contacts/:id/${sub}`);
|
|
19086
|
+
}
|
|
19087
|
+
if (resource === "contacts") {
|
|
19088
|
+
if (!id) {
|
|
19089
|
+
if (method === "GET") {
|
|
19090
|
+
const result = await store.listContacts({
|
|
19091
|
+
...url.searchParams.get("company_id") ? { company_id: url.searchParams.get("company_id") } : {},
|
|
19092
|
+
...url.searchParams.get("status") ? { status: url.searchParams.get("status") } : {},
|
|
19093
|
+
...url.searchParams.get("q") ? { q: url.searchParams.get("q") } : {},
|
|
19094
|
+
...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
|
|
19095
|
+
...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
|
|
19096
|
+
});
|
|
19097
|
+
return json5(result);
|
|
19098
|
+
}
|
|
19099
|
+
if (method === "POST") {
|
|
19100
|
+
const body = await readJson(req);
|
|
19101
|
+
if (!body)
|
|
19102
|
+
return error(400, "invalid JSON body");
|
|
19103
|
+
const contact = await store.createContact(body);
|
|
19104
|
+
return json5({ contact }, 201);
|
|
19105
|
+
}
|
|
19106
|
+
return error(405, `method ${method} not allowed on /v1/contacts`);
|
|
19107
|
+
}
|
|
19108
|
+
if (method === "GET") {
|
|
19109
|
+
const contact = await store.getContact(id);
|
|
19110
|
+
return contact ? json5({ contact }) : error(404, "contact not found");
|
|
19111
|
+
}
|
|
19112
|
+
if (method === "PATCH" || method === "PUT") {
|
|
19113
|
+
const body = await readJson(req);
|
|
19114
|
+
if (!body)
|
|
19115
|
+
return error(400, "invalid JSON body");
|
|
19116
|
+
const contact = await store.updateContact(id, body);
|
|
19117
|
+
return contact ? json5({ contact }) : error(404, "contact not found");
|
|
19118
|
+
}
|
|
19119
|
+
if (method === "DELETE") {
|
|
19120
|
+
const deleted = await store.deleteContact(id);
|
|
19121
|
+
return deleted ? json5({ deleted: true, id }) : error(404, "contact not found");
|
|
19122
|
+
}
|
|
19123
|
+
return error(405, `method ${method} not allowed on /v1/contacts/:id`);
|
|
19124
|
+
}
|
|
19125
|
+
if (resource === "companies") {
|
|
19126
|
+
if (!id) {
|
|
19127
|
+
if (method === "GET") {
|
|
19128
|
+
const result = await store.listCompanies({
|
|
19129
|
+
...url.searchParams.get("industry") ? { industry: url.searchParams.get("industry") } : {},
|
|
19130
|
+
...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
|
|
19131
|
+
...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
|
|
19132
|
+
});
|
|
19133
|
+
return json5(result);
|
|
19134
|
+
}
|
|
19135
|
+
if (method === "POST") {
|
|
19136
|
+
const body = await readJson(req);
|
|
19137
|
+
if (!body || typeof body.name !== "string" || !body.name.trim()) {
|
|
19138
|
+
return error(400, "name is required");
|
|
19139
|
+
}
|
|
19140
|
+
const company = await store.createCompany(body);
|
|
19141
|
+
return json5({ company }, 201);
|
|
19142
|
+
}
|
|
19143
|
+
return error(405, `method ${method} not allowed on /v1/companies`);
|
|
19144
|
+
}
|
|
19145
|
+
if (method === "GET") {
|
|
19146
|
+
const company = await store.getCompany(id);
|
|
19147
|
+
return company ? json5({ company }) : error(404, "company not found");
|
|
17714
19148
|
}
|
|
17715
19149
|
if (method === "PATCH" || method === "PUT") {
|
|
17716
19150
|
const body = await readJson(req);
|
|
@@ -17761,6 +19195,521 @@ async function handleV1Request(req, url) {
|
|
|
17761
19195
|
if (resource === "stats" && method === "GET") {
|
|
17762
19196
|
return json5(await store.stats());
|
|
17763
19197
|
}
|
|
19198
|
+
if (resource === "deals") {
|
|
19199
|
+
if (id && sub === "team" && method === "GET")
|
|
19200
|
+
return json5({ team: await store.getDealTeam(id) });
|
|
19201
|
+
if (id && sub === "roles" && method === "POST") {
|
|
19202
|
+
const b = await readJson(req);
|
|
19203
|
+
if (!b)
|
|
19204
|
+
return error(400, "invalid JSON body");
|
|
19205
|
+
return json5({ role: await store.setDealContactRole(id, b.contact_id, b.account_role) }, 201);
|
|
19206
|
+
}
|
|
19207
|
+
if (!id) {
|
|
19208
|
+
if (method === "GET")
|
|
19209
|
+
return json5({ deals: await store.listDeals({ stage: qp("stage"), contact_id: qp("contact_id"), company_id: qp("company_id") }) });
|
|
19210
|
+
if (method === "POST") {
|
|
19211
|
+
const b = await readJson(req);
|
|
19212
|
+
if (!b)
|
|
19213
|
+
return error(400, "invalid JSON body");
|
|
19214
|
+
return json5({ deal: await store.createDeal(b) }, 201);
|
|
19215
|
+
}
|
|
19216
|
+
return error(405, `method ${method} not allowed on /v1/deals`);
|
|
19217
|
+
}
|
|
19218
|
+
if (method === "GET") {
|
|
19219
|
+
const d = await store.getDeal(id);
|
|
19220
|
+
return d ? json5({ deal: d }) : error(404, "deal not found");
|
|
19221
|
+
}
|
|
19222
|
+
if (method === "PATCH" || method === "PUT") {
|
|
19223
|
+
const b = await readJson(req);
|
|
19224
|
+
if (!b)
|
|
19225
|
+
return error(400, "invalid JSON body");
|
|
19226
|
+
const d = await store.updateDeal(id, b);
|
|
19227
|
+
return d ? json5({ deal: d }) : error(404, "deal not found");
|
|
19228
|
+
}
|
|
19229
|
+
if (method === "DELETE")
|
|
19230
|
+
return await store.deleteDeal(id) ? json5({ deleted: true, id }) : error(404, "deal not found");
|
|
19231
|
+
return error(405, "method not allowed");
|
|
19232
|
+
}
|
|
19233
|
+
if (resource === "events") {
|
|
19234
|
+
if (!id) {
|
|
19235
|
+
if (method === "GET")
|
|
19236
|
+
return json5({ events: await store.listEvents({ contact_id: qp("contact_id"), company_id: qp("company_id"), type: qp("type"), date_from: qp("date_from"), date_to: qp("date_to") }) });
|
|
19237
|
+
if (method === "POST") {
|
|
19238
|
+
const b = await readJson(req);
|
|
19239
|
+
if (!b)
|
|
19240
|
+
return error(400, "invalid JSON body");
|
|
19241
|
+
return json5({ event: await store.logEvent(b) }, 201);
|
|
19242
|
+
}
|
|
19243
|
+
return error(405, "method not allowed");
|
|
19244
|
+
}
|
|
19245
|
+
if (method === "DELETE")
|
|
19246
|
+
return await store.deleteEvent(id) ? json5({ deleted: true, id }) : error(404, "event not found");
|
|
19247
|
+
return error(405, "method not allowed");
|
|
19248
|
+
}
|
|
19249
|
+
if (resource === "tasks") {
|
|
19250
|
+
if (id === "overdue" && method === "GET")
|
|
19251
|
+
return json5({ tasks: await store.listOverdueTasks() });
|
|
19252
|
+
if (id === "escalations" && method === "GET")
|
|
19253
|
+
return json5({ escalations: await store.checkEscalations() });
|
|
19254
|
+
if (!id) {
|
|
19255
|
+
if (method === "GET")
|
|
19256
|
+
return json5({ tasks: await store.listContactTasks({ contact_id: qp("contact_id"), entity_id: qp("entity_id"), status: qp("status"), priority: qp("priority") }) });
|
|
19257
|
+
if (method === "POST") {
|
|
19258
|
+
const b = await readJson(req);
|
|
19259
|
+
if (!b)
|
|
19260
|
+
return error(400, "invalid JSON body");
|
|
19261
|
+
return json5({ task: await store.createContactTask(b) }, 201);
|
|
19262
|
+
}
|
|
19263
|
+
return error(405, "method not allowed");
|
|
19264
|
+
}
|
|
19265
|
+
if (method === "PATCH" || method === "PUT") {
|
|
19266
|
+
const b = await readJson(req);
|
|
19267
|
+
if (!b)
|
|
19268
|
+
return error(400, "invalid JSON body");
|
|
19269
|
+
const t = await store.updateContactTask(id, b);
|
|
19270
|
+
return t ? json5({ task: t }) : error(404, "task not found");
|
|
19271
|
+
}
|
|
19272
|
+
if (method === "DELETE")
|
|
19273
|
+
return await store.deleteContactTask(id) ? json5({ deleted: true, id }) : error(404, "task not found");
|
|
19274
|
+
return error(405, "method not allowed");
|
|
19275
|
+
}
|
|
19276
|
+
if (resource === "applications") {
|
|
19277
|
+
if (id === "follow-up-due" && method === "GET")
|
|
19278
|
+
return json5({ applications: await store.listFollowUpDueApplications() });
|
|
19279
|
+
if (!id) {
|
|
19280
|
+
if (method === "GET")
|
|
19281
|
+
return json5({ applications: await store.listApplications({ type: qp("type"), status: qp("status"), provider_company_id: qp("provider_company_id"), applicant_contact_id: qp("applicant_contact_id") }) });
|
|
19282
|
+
if (method === "POST") {
|
|
19283
|
+
const b = await readJson(req);
|
|
19284
|
+
if (!b)
|
|
19285
|
+
return error(400, "invalid JSON body");
|
|
19286
|
+
return json5({ application: await store.createApplication(b) }, 201);
|
|
19287
|
+
}
|
|
19288
|
+
return error(405, "method not allowed");
|
|
19289
|
+
}
|
|
19290
|
+
if (method === "PATCH" || method === "PUT") {
|
|
19291
|
+
const b = await readJson(req);
|
|
19292
|
+
if (!b)
|
|
19293
|
+
return error(400, "invalid JSON body");
|
|
19294
|
+
const a = await store.updateApplication(id, b);
|
|
19295
|
+
return a ? json5({ application: a }) : error(404, "application not found");
|
|
19296
|
+
}
|
|
19297
|
+
return error(405, "method not allowed");
|
|
19298
|
+
}
|
|
19299
|
+
if (resource === "groups") {
|
|
19300
|
+
if (id === "for-contact" && sub && method === "GET")
|
|
19301
|
+
return json5({ groups: await store.listGroupsForContact(sub) });
|
|
19302
|
+
if (id === "for-company" && sub && method === "GET")
|
|
19303
|
+
return json5({ groups: await store.listGroupsForCompany(sub) });
|
|
19304
|
+
if (id && sub === "contacts") {
|
|
19305
|
+
if (method === "GET")
|
|
19306
|
+
return json5({ contact_ids: await store.listContactsInGroup(id) });
|
|
19307
|
+
if (method === "POST") {
|
|
19308
|
+
const b = await readJson(req);
|
|
19309
|
+
if (!b?.contact_id)
|
|
19310
|
+
return error(400, "contact_id required");
|
|
19311
|
+
return json5(await store.addContactToGroup(b.contact_id, id));
|
|
19312
|
+
}
|
|
19313
|
+
if (method === "DELETE") {
|
|
19314
|
+
const cid = segments[4];
|
|
19315
|
+
if (!cid)
|
|
19316
|
+
return error(400, "contact id required");
|
|
19317
|
+
await store.removeContactFromGroup(cid, id);
|
|
19318
|
+
return json5({ ok: true });
|
|
19319
|
+
}
|
|
19320
|
+
}
|
|
19321
|
+
if (id && sub === "companies") {
|
|
19322
|
+
if (method === "GET")
|
|
19323
|
+
return json5({ company_ids: await store.listCompaniesInGroup(id) });
|
|
19324
|
+
if (method === "POST") {
|
|
19325
|
+
const b = await readJson(req);
|
|
19326
|
+
if (!b?.company_id)
|
|
19327
|
+
return error(400, "company_id required");
|
|
19328
|
+
return json5(await store.addCompanyToGroup(b.company_id, id));
|
|
19329
|
+
}
|
|
19330
|
+
if (method === "DELETE") {
|
|
19331
|
+
const coid = segments[4];
|
|
19332
|
+
if (!coid)
|
|
19333
|
+
return error(400, "company id required");
|
|
19334
|
+
await store.removeCompanyFromGroup(coid, id);
|
|
19335
|
+
return json5({ ok: true });
|
|
19336
|
+
}
|
|
19337
|
+
}
|
|
19338
|
+
if (!id) {
|
|
19339
|
+
if (method === "GET")
|
|
19340
|
+
return json5({ groups: await store.listGroups(qp("project_id")) });
|
|
19341
|
+
if (method === "POST") {
|
|
19342
|
+
const b = await readJson(req);
|
|
19343
|
+
if (!b)
|
|
19344
|
+
return error(400, "invalid JSON body");
|
|
19345
|
+
return json5({ group: await store.createGroup(b) }, 201);
|
|
19346
|
+
}
|
|
19347
|
+
return error(405, "method not allowed");
|
|
19348
|
+
}
|
|
19349
|
+
if (method === "GET") {
|
|
19350
|
+
const g = await store.getGroup(id);
|
|
19351
|
+
return g ? json5({ group: g }) : error(404, "group not found");
|
|
19352
|
+
}
|
|
19353
|
+
if (method === "PATCH" || method === "PUT") {
|
|
19354
|
+
const b = await readJson(req);
|
|
19355
|
+
if (!b)
|
|
19356
|
+
return error(400, "invalid JSON body");
|
|
19357
|
+
const g = await store.updateGroup(id, b);
|
|
19358
|
+
return g ? json5({ group: g }) : error(404, "group not found");
|
|
19359
|
+
}
|
|
19360
|
+
if (method === "DELETE")
|
|
19361
|
+
return await store.deleteGroup(id) ? json5({ deleted: true, id }) : error(404, "group not found");
|
|
19362
|
+
return error(405, "method not allowed");
|
|
19363
|
+
}
|
|
19364
|
+
if (resource === "vendor-comms") {
|
|
19365
|
+
if (id === "missing-invoices" && method === "GET")
|
|
19366
|
+
return json5({ communications: await store.listMissingInvoices() });
|
|
19367
|
+
if (id === "pending-follow-ups" && method === "GET")
|
|
19368
|
+
return json5({ communications: await store.listPendingFollowUps() });
|
|
19369
|
+
if (id && sub === "mark-done" && method === "POST") {
|
|
19370
|
+
const c = await store.markFollowUpDone(id);
|
|
19371
|
+
return c ? json5({ communication: c }) : error(404, "not found");
|
|
19372
|
+
}
|
|
19373
|
+
if (!id) {
|
|
19374
|
+
if (method === "GET") {
|
|
19375
|
+
const companyId = qp("company_id");
|
|
19376
|
+
if (!companyId)
|
|
19377
|
+
return error(400, "company_id required");
|
|
19378
|
+
return json5({ communications: await store.listVendorCommunications(companyId, { type: qp("type"), status: qp("status"), direction: qp("direction") }) });
|
|
19379
|
+
}
|
|
19380
|
+
if (method === "POST") {
|
|
19381
|
+
const b = await readJson(req);
|
|
19382
|
+
if (!b)
|
|
19383
|
+
return error(400, "invalid JSON body");
|
|
19384
|
+
return json5({ communication: await store.logVendorCommunication(b) }, 201);
|
|
19385
|
+
}
|
|
19386
|
+
}
|
|
19387
|
+
return error(405, "method not allowed");
|
|
19388
|
+
}
|
|
19389
|
+
if (resource === "org-members") {
|
|
19390
|
+
if (!id) {
|
|
19391
|
+
if (method === "GET") {
|
|
19392
|
+
if (qp("contact_id"))
|
|
19393
|
+
return json5({ org_members: await store.listOrgMembersForContact(qp("contact_id")) });
|
|
19394
|
+
if (qp("company_id"))
|
|
19395
|
+
return json5({ org_members: await store.listOrgMembers(qp("company_id")) });
|
|
19396
|
+
return error(400, "company_id or contact_id required");
|
|
19397
|
+
}
|
|
19398
|
+
if (method === "POST") {
|
|
19399
|
+
const b = await readJson(req);
|
|
19400
|
+
if (!b)
|
|
19401
|
+
return error(400, "invalid JSON body");
|
|
19402
|
+
return json5({ org_member: await store.addOrgMember(b) }, 201);
|
|
19403
|
+
}
|
|
19404
|
+
return error(405, "method not allowed");
|
|
19405
|
+
}
|
|
19406
|
+
if (method === "PATCH" || method === "PUT") {
|
|
19407
|
+
const b = await readJson(req);
|
|
19408
|
+
if (!b)
|
|
19409
|
+
return error(400, "invalid JSON body");
|
|
19410
|
+
const m = await store.updateOrgMember(id, b);
|
|
19411
|
+
return m ? json5({ org_member: m }) : error(404, "not found");
|
|
19412
|
+
}
|
|
19413
|
+
if (method === "DELETE")
|
|
19414
|
+
return await store.removeOrgMember(id) ? json5({ deleted: true, id }) : error(404, "not found");
|
|
19415
|
+
return error(405, "method not allowed");
|
|
19416
|
+
}
|
|
19417
|
+
if (resource === "notes") {
|
|
19418
|
+
if (!id) {
|
|
19419
|
+
if (method === "GET") {
|
|
19420
|
+
const cid = qp("contact_id");
|
|
19421
|
+
if (!cid)
|
|
19422
|
+
return error(400, "contact_id required");
|
|
19423
|
+
return json5({ notes: qp("company_id") ? await store.listNotesForContactAtCompany(cid, qp("company_id")) : await store.listNotes(cid) });
|
|
19424
|
+
}
|
|
19425
|
+
if (method === "POST") {
|
|
19426
|
+
const b = await readJson(req);
|
|
19427
|
+
if (!b?.contact_id || !b?.body)
|
|
19428
|
+
return error(400, "contact_id and body required");
|
|
19429
|
+
return json5({ note: await store.addNote(b.contact_id, b.body, b.created_by, b.company_id) }, 201);
|
|
19430
|
+
}
|
|
19431
|
+
return error(405, "method not allowed");
|
|
19432
|
+
}
|
|
19433
|
+
if (method === "DELETE") {
|
|
19434
|
+
await store.deleteNote(id);
|
|
19435
|
+
return json5({ ok: true });
|
|
19436
|
+
}
|
|
19437
|
+
return error(405, "method not allowed");
|
|
19438
|
+
}
|
|
19439
|
+
if (resource === "relationships") {
|
|
19440
|
+
if (!id) {
|
|
19441
|
+
if (method === "GET")
|
|
19442
|
+
return json5({ relationships: await store.listRelationships({ contact_id: qp("contact_id") }) });
|
|
19443
|
+
if (method === "POST") {
|
|
19444
|
+
const b = await readJson(req);
|
|
19445
|
+
if (!b)
|
|
19446
|
+
return error(400, "invalid JSON body");
|
|
19447
|
+
return json5({ relationship: await store.createRelationship(b) }, 201);
|
|
19448
|
+
}
|
|
19449
|
+
}
|
|
19450
|
+
if (method === "DELETE" && id) {
|
|
19451
|
+
await store.deleteRelationship(id);
|
|
19452
|
+
return json5({ ok: true });
|
|
19453
|
+
}
|
|
19454
|
+
return error(405, "method not allowed");
|
|
19455
|
+
}
|
|
19456
|
+
if (resource === "company-relationships") {
|
|
19457
|
+
if (!id) {
|
|
19458
|
+
if (method === "GET")
|
|
19459
|
+
return json5({ relationships: await store.listCompanyRelationships({ contact_id: qp("contact_id"), company_id: qp("company_id") }) });
|
|
19460
|
+
if (method === "POST") {
|
|
19461
|
+
const b = await readJson(req);
|
|
19462
|
+
if (!b)
|
|
19463
|
+
return error(400, "invalid JSON body");
|
|
19464
|
+
return json5({ relationship: await store.createCompanyRelationship(b) }, 201);
|
|
19465
|
+
}
|
|
19466
|
+
}
|
|
19467
|
+
if (method === "DELETE" && id) {
|
|
19468
|
+
await store.deleteCompanyRelationship(id);
|
|
19469
|
+
return json5({ ok: true });
|
|
19470
|
+
}
|
|
19471
|
+
return error(405, "method not allowed");
|
|
19472
|
+
}
|
|
19473
|
+
if (resource === "learnings") {
|
|
19474
|
+
if (id === "search" && method === "GET")
|
|
19475
|
+
return json5({ learnings: await store.searchLearnings(qp("q") ?? "", { type: qp("type"), contact_id: qp("contact_id") }) });
|
|
19476
|
+
if (id === "stale" && method === "GET")
|
|
19477
|
+
return json5({ learnings: await store.getStaleLearnings(qn("days_old") ?? 30, qn("min_confidence") ?? 0) });
|
|
19478
|
+
if (id === "maintenance" && method === "POST")
|
|
19479
|
+
return json5(await store.runLearningMaintenance());
|
|
19480
|
+
if (id && sub === "confirm" && method === "POST") {
|
|
19481
|
+
await store.confirmLearning(id);
|
|
19482
|
+
return json5({ ok: true });
|
|
19483
|
+
}
|
|
19484
|
+
return error(404, "unknown /v1/learnings route");
|
|
19485
|
+
}
|
|
19486
|
+
if (resource === "locks") {
|
|
19487
|
+
if (id && method === "GET") {
|
|
19488
|
+
const l = await store.checkContactLock(id);
|
|
19489
|
+
return json5({ lock: l });
|
|
19490
|
+
}
|
|
19491
|
+
if (!id && method === "POST") {
|
|
19492
|
+
const b = await readJson(req);
|
|
19493
|
+
if (!b)
|
|
19494
|
+
return error(400, "invalid JSON body");
|
|
19495
|
+
return json5(await store.acquireContactLock(b.contact_id, b.agent_name, b.ttl_seconds, b.reason, b.session_id));
|
|
19496
|
+
}
|
|
19497
|
+
if (id && method === "DELETE") {
|
|
19498
|
+
const released = await store.releaseContactLock(id, qp("agent_name") ?? "");
|
|
19499
|
+
return json5({ released });
|
|
19500
|
+
}
|
|
19501
|
+
return error(405, "method not allowed");
|
|
19502
|
+
}
|
|
19503
|
+
if (resource === "activity") {
|
|
19504
|
+
if (method === "GET") {
|
|
19505
|
+
const cid = qp("contact_id");
|
|
19506
|
+
if (!cid)
|
|
19507
|
+
return error(400, "contact_id required");
|
|
19508
|
+
return json5({ activity: await store.getAgentActivity(cid, qn("limit") ?? 20) });
|
|
19509
|
+
}
|
|
19510
|
+
if (method === "POST") {
|
|
19511
|
+
const b = await readJson(req);
|
|
19512
|
+
if (!b)
|
|
19513
|
+
return error(400, "invalid JSON body");
|
|
19514
|
+
await store.logAgentActivity(b.contact_id, b.agent_name, b.action, b.details, b.session_id);
|
|
19515
|
+
return json5({ ok: true }, 201);
|
|
19516
|
+
}
|
|
19517
|
+
return error(405, "method not allowed");
|
|
19518
|
+
}
|
|
19519
|
+
if (resource === "identity") {
|
|
19520
|
+
if (id === "resolve" && method === "POST") {
|
|
19521
|
+
const b = await readJson(req);
|
|
19522
|
+
if (!b)
|
|
19523
|
+
return error(400, "invalid JSON body");
|
|
19524
|
+
return json5({ matches: await store.resolveContactIdentity(b) });
|
|
19525
|
+
}
|
|
19526
|
+
if (!id) {
|
|
19527
|
+
if (method === "GET") {
|
|
19528
|
+
const cid = qp("contact_id");
|
|
19529
|
+
if (!cid)
|
|
19530
|
+
return error(400, "contact_id required");
|
|
19531
|
+
return json5({ identities: await store.getContactIdentities(cid) });
|
|
19532
|
+
}
|
|
19533
|
+
if (method === "POST") {
|
|
19534
|
+
const b = await readJson(req);
|
|
19535
|
+
if (!b)
|
|
19536
|
+
return error(400, "invalid JSON body");
|
|
19537
|
+
return json5({ identity: await store.addContactIdentity(b.contact_id, b.system, b.external_id, b.external_url, b.confidence) }, 201);
|
|
19538
|
+
}
|
|
19539
|
+
}
|
|
19540
|
+
return error(405, "method not allowed");
|
|
19541
|
+
}
|
|
19542
|
+
if (resource === "signals") {
|
|
19543
|
+
if (id === "ghost" && method === "GET")
|
|
19544
|
+
return json5({ signals: await store.getGhostContacts() });
|
|
19545
|
+
if (id === "warming" && method === "GET")
|
|
19546
|
+
return json5({ signals: await store.getWarmingContacts() });
|
|
19547
|
+
if (id === "recompute" && method === "POST")
|
|
19548
|
+
return json5(await store.recomputeSignals());
|
|
19549
|
+
if (!id && method === "GET") {
|
|
19550
|
+
const cid = qp("contact_id");
|
|
19551
|
+
if (!cid)
|
|
19552
|
+
return error(400, "contact_id required");
|
|
19553
|
+
return json5({ signals: await store.getRelationshipSignals(cid) });
|
|
19554
|
+
}
|
|
19555
|
+
return error(405, "method not allowed");
|
|
19556
|
+
}
|
|
19557
|
+
if (resource === "freshness") {
|
|
19558
|
+
if (id === "stale" && method === "GET")
|
|
19559
|
+
return json5({ contacts: await store.getStaleContacts(qn("threshold") ?? 40) });
|
|
19560
|
+
if (id === "verify" && method === "POST") {
|
|
19561
|
+
const b = await readJson(req);
|
|
19562
|
+
if (!b)
|
|
19563
|
+
return error(400, "invalid JSON body");
|
|
19564
|
+
await store.markFieldVerified(b.contact_id, b.field_name, b.source);
|
|
19565
|
+
return json5({ ok: true });
|
|
19566
|
+
}
|
|
19567
|
+
if (id && method === "GET")
|
|
19568
|
+
return json5({ freshness: await store.getFreshnessScore(id) });
|
|
19569
|
+
return error(405, "method not allowed");
|
|
19570
|
+
}
|
|
19571
|
+
if (resource === "graph") {
|
|
19572
|
+
if (id === "strength" && sub && method === "GET")
|
|
19573
|
+
return json5({ strength: await store.computeRelationshipStrength(sub) });
|
|
19574
|
+
if (id === "warm-path" && method === "GET")
|
|
19575
|
+
return json5({ path: await store.findWarmPath(qp("from") ?? "", qp("to") ?? "") });
|
|
19576
|
+
if (id === "company" && sub && method === "GET")
|
|
19577
|
+
return json5({ connections: await store.findConnectionsAtCompany(sub) });
|
|
19578
|
+
if (id === "cooling" && method === "GET")
|
|
19579
|
+
return json5({ cooling: await store.detectCoolingRelationships() });
|
|
19580
|
+
return error(404, "unknown /v1/graph route");
|
|
19581
|
+
}
|
|
19582
|
+
if (resource === "org-chart") {
|
|
19583
|
+
if (id === "coverage" && sub && method === "GET")
|
|
19584
|
+
return json5({ coverage: await store.getCoverageGaps(sub) });
|
|
19585
|
+
if (!id) {
|
|
19586
|
+
if (method === "GET") {
|
|
19587
|
+
const cid = qp("company_id");
|
|
19588
|
+
if (!cid)
|
|
19589
|
+
return error(400, "company_id required");
|
|
19590
|
+
return json5({ edges: await store.listOrgChart(cid) });
|
|
19591
|
+
}
|
|
19592
|
+
if (method === "POST") {
|
|
19593
|
+
const b = await readJson(req);
|
|
19594
|
+
if (!b)
|
|
19595
|
+
return error(400, "invalid JSON body");
|
|
19596
|
+
return json5({ edge: await store.addOrgChartEdge(b.company_id, b.contact_a_id, b.contact_b_id, b.edge_type, b.inferred) }, 201);
|
|
19597
|
+
}
|
|
19598
|
+
}
|
|
19599
|
+
return error(405, "method not allowed");
|
|
19600
|
+
}
|
|
19601
|
+
if (resource === "audiences") {
|
|
19602
|
+
if (id && sub === "resolve" && method === "GET")
|
|
19603
|
+
return json5({ resolution: await store.resolveAudience(id, qp("channel") ?? "email") });
|
|
19604
|
+
if (!id) {
|
|
19605
|
+
if (method === "GET")
|
|
19606
|
+
return json5({ audiences: await store.listAudiences() });
|
|
19607
|
+
if (method === "POST") {
|
|
19608
|
+
const b = await readJson(req);
|
|
19609
|
+
if (!b)
|
|
19610
|
+
return error(400, "invalid JSON body");
|
|
19611
|
+
return json5({ audience: await store.createAudience(b) }, 201);
|
|
19612
|
+
}
|
|
19613
|
+
return error(405, "method not allowed");
|
|
19614
|
+
}
|
|
19615
|
+
if (method === "GET")
|
|
19616
|
+
return json5({ audience: await store.getAudience(id) });
|
|
19617
|
+
if (method === "PATCH" || method === "PUT") {
|
|
19618
|
+
const b = await readJson(req);
|
|
19619
|
+
if (!b)
|
|
19620
|
+
return error(400, "invalid JSON body");
|
|
19621
|
+
return json5({ audience: await store.updateAudience(id, b) });
|
|
19622
|
+
}
|
|
19623
|
+
if (method === "DELETE") {
|
|
19624
|
+
await store.deleteAudience(id);
|
|
19625
|
+
return json5({ deleted: true, id });
|
|
19626
|
+
}
|
|
19627
|
+
return error(405, "method not allowed");
|
|
19628
|
+
}
|
|
19629
|
+
if (resource === "consent") {
|
|
19630
|
+
if (method === "GET") {
|
|
19631
|
+
const cid = qp("contact_id");
|
|
19632
|
+
if (!cid)
|
|
19633
|
+
return error(400, "contact_id required");
|
|
19634
|
+
return json5({ consent: await store.listContactConsent(cid) });
|
|
19635
|
+
}
|
|
19636
|
+
if (method === "POST") {
|
|
19637
|
+
const b = await readJson(req);
|
|
19638
|
+
if (!b)
|
|
19639
|
+
return error(400, "invalid JSON body");
|
|
19640
|
+
return json5({ consent: await store.setContactConsent(b.contact_id, b.channel, b.status, b.source) });
|
|
19641
|
+
}
|
|
19642
|
+
return error(405, "method not allowed");
|
|
19643
|
+
}
|
|
19644
|
+
if (resource === "suppressions") {
|
|
19645
|
+
if (method === "GET")
|
|
19646
|
+
return json5({ suppressions: await store.listSuppressions({ channel: qp("channel"), unsyncedOnly: qp("unsynced") === "1" || qp("unsynced") === "true" }) });
|
|
19647
|
+
if (method === "POST") {
|
|
19648
|
+
const b = await readJson(req);
|
|
19649
|
+
if (!b)
|
|
19650
|
+
return error(400, "invalid JSON body");
|
|
19651
|
+
return json5({ suppression: await store.suppressAddress(b) }, 201);
|
|
19652
|
+
}
|
|
19653
|
+
if (method === "DELETE") {
|
|
19654
|
+
const channel = qp("channel");
|
|
19655
|
+
const address = qp("address");
|
|
19656
|
+
if (!channel || !address)
|
|
19657
|
+
return error(400, "channel and address required");
|
|
19658
|
+
await store.unsuppressAddress(channel, address);
|
|
19659
|
+
return json5({ ok: true });
|
|
19660
|
+
}
|
|
19661
|
+
return error(405, "method not allowed");
|
|
19662
|
+
}
|
|
19663
|
+
if (resource === "field-history" && method === "GET") {
|
|
19664
|
+
const cid = qp("contact_id");
|
|
19665
|
+
if (!cid)
|
|
19666
|
+
return error(400, "contact_id required");
|
|
19667
|
+
if (id === "at")
|
|
19668
|
+
return json5({ fields: await store.getContactAt(cid, qp("timestamp") ?? new Date().toISOString()) });
|
|
19669
|
+
return json5({ history: await store.getFieldHistory(cid, qp("field_name")) });
|
|
19670
|
+
}
|
|
19671
|
+
if (resource === "job-history") {
|
|
19672
|
+
if (method === "GET") {
|
|
19673
|
+
const cid = qp("contact_id");
|
|
19674
|
+
if (!cid)
|
|
19675
|
+
return error(400, "contact_id required");
|
|
19676
|
+
return json5({ job_history: await store.getJobHistory(cid) });
|
|
19677
|
+
}
|
|
19678
|
+
if (method === "POST") {
|
|
19679
|
+
const b = await readJson(req);
|
|
19680
|
+
if (!b?.contact_id)
|
|
19681
|
+
return error(400, "contact_id required");
|
|
19682
|
+
return json5({ job: await store.addJobEntry(b.contact_id, b) }, 201);
|
|
19683
|
+
}
|
|
19684
|
+
}
|
|
19685
|
+
if (resource === "cold-contacts" && method === "GET")
|
|
19686
|
+
return json5({ contacts: await store.listColdContacts(qn("days") ?? 30) });
|
|
19687
|
+
if (resource === "not-contacted" && method === "GET")
|
|
19688
|
+
return json5({ contacts: await store.listContactsNotContactedSince(qn("days") ?? 90, qn("limit") ?? 50) });
|
|
19689
|
+
if (resource === "followup-due-contacts" && method === "GET")
|
|
19690
|
+
return json5({ contacts: await store.listFollowupDueContacts(qp("on_or_before") ?? new Date().toISOString()) });
|
|
19691
|
+
if (resource === "contacts-for-context" && method === "GET")
|
|
19692
|
+
return json5({ contacts: await store.findContactsForContext(qp("topic") ?? "", qn("limit") ?? 20) });
|
|
19693
|
+
if (resource === "email-duplicates" && method === "GET")
|
|
19694
|
+
return json5({ duplicates: await store.findEmailDuplicates() });
|
|
19695
|
+
if (resource === "name-duplicates" && method === "GET")
|
|
19696
|
+
return json5({ duplicates: await store.findNameDuplicates() });
|
|
19697
|
+
if (resource === "contact-audit" && method === "GET")
|
|
19698
|
+
return json5({ audit: await store.listContactAudit() });
|
|
19699
|
+
if (resource === "upcoming" && method === "GET")
|
|
19700
|
+
return json5({ items: await store.getUpcomingItems(qn("days") ?? 7) });
|
|
19701
|
+
if (resource === "network-stats" && method === "GET")
|
|
19702
|
+
return json5({ stats: await store.getNetworkStats() });
|
|
19703
|
+
if (resource === "recent-events" && method === "GET")
|
|
19704
|
+
return json5({ events: await store.getRecentContactEvents(qp("since"), qp("types") ? qp("types").split(",") : undefined) });
|
|
19705
|
+
if (resource === "vault-status" && method === "GET")
|
|
19706
|
+
return json5({ vault: await store.vaultStatus() });
|
|
19707
|
+
if (resource === "assemble-context" && method === "POST") {
|
|
19708
|
+
const b = await readJson(req);
|
|
19709
|
+
if (!b)
|
|
19710
|
+
return error(400, "invalid JSON body");
|
|
19711
|
+
return json5({ context: await store.assembleContext(b.contact_ids ?? [], b.format ?? "meeting_prep") });
|
|
19712
|
+
}
|
|
17764
19713
|
return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
|
|
17765
19714
|
} catch (e) {
|
|
17766
19715
|
const msg = e.message || "internal error";
|